plengauer / plengauer/Thoth

Redirection-only `exec` is not reliably detected, so fd manipulation is treated as process replacement

Open
#4,036 2 comments 1 reaction 2 assignees View on GitHub

@plengauer is already working on this.

Since Aug 20, 2026.

  • #4040 by @openai-code-agent — closed without merging
  • #4085 by @copilot-swe-agent — closed without merging
bug
Dominant language
Shell
Stars
167
Forks
15
Avg merge
2d 23h
Merged PRs (30d)
131

Description

Summary

exec has two distinct meanings in POSIX shells:

  1. Process replacementexec somecommand args...
  2. Redirection onlyexec 3<>/dev/tcp/host/port, exec 7>file, exec 3<&-. No new process; the current shell's file descriptors are modified and execution continues.

Thoth's exec instrumentation is designed for case 1 but does not reliably distinguish case 2. Both instrumented exec aliases mishandle redirection-only forms, each in a different way.

Path A — _otel_inject_and_exec_directly (non-conservative alias)

agent.sh:111:

\alias exec='_otel_inject_and_exec_directly exec'

agent.sh:396:

_otel_inject_and_exec_directly() { # this function assumes there is no fd fuckery
  if \[ "$#" = 1 ]; then
    \export OTEL_SHELL_CONSERVATIVE_EXEC=TRUE
    _otel_end_script
    ...
    \eval '"exec"' "$(\xargs -0 $_otel_shell -c '. otelapi.sh; _otel_escape_args "$@"' sh </proc/$$/cmdline)"
  fi
  ...

When the alias expands exec 3<>"/dev/tcp/127.0.0.1/26831", the 3<>... is parsed by the shell as a redirection applied to the function call, not as an argument to it. Inside the function $# is therefore 1, and the redirection-only case is indistinguishable from a bare exec. The $# = 1 branch is taken, which runs _otel_end_script and then re-execs the shell from /proc/$$/cmdline.

The in-source comment (this function assumes there is no fd fuckery) states the assumption precisely; redirection-only exec is exactly the case that violates it, and there is no guard enforcing it.

Path B — _otel_inject_and_exec_by_location (conservative alias, LINENO available)

agent.sh:106:

\alias exec='eval "$(_otel_inject_and_exec_by_location "..." "...")"; exec'

This path does attempt to detect redirection-only exec, at agent.sh:428:

if \[ -z "$command" ] || \[ "$(\printf '%s' "$command" | \sed 's/ [0-9]*>.*$//')" = "exec" ]; then return 0; fi

The intent is: strip a trailing fd redirection; if what remains is just exec, this is redirection-only, so do nothing. The pattern [0-9]*> handles N> and >, but not:

form meaning matched?
exec 3>file write redirect yes
exec >file stdout redirect yes
exec 3<>file read-write redirect no
exec 3<file read redirect no
exec 3<&- close fd no
exec 3>&1 duplicate fd yes

For exec 3<>"/dev/tcp/..." the regex requires a space, optional digits, then >; the input has < at that position, so nothing is stripped, the string does not equal exec, and the function proceeds to _otel_end_script at agent.sh:436 as if a real process replacement were about to happen.

/dev/tcp read-write probes are the idiomatic way to test port reachability in bash and are common in CI wait-for-service loops, so 3<> is not an exotic case.

Consequences

Treating redirection-only exec as process replacement causes Thoth to tear down telemetry state (and, today, to run the user's deferred EXIT trap — see #4034) at a point where the shell simply continues executing. In path A it additionally attempts to re-exec the shell from /proc/$$/cmdline, which inside a subshell refers to the parent's command line.

Suggested fix

Detect redirection-only exec positively rather than by subtraction, and default to the safe interpretation when unsure.

Path B — replace the ad-hoc strip with a check for whether any non-redirection word follows exec. A stripping approach, if kept, must cover all redirection operators:

s/ *[0-9]*\(>>\|<>\|>&\|<&\|>\|<\)[^ ]*//g

Applying that repeatedly and then comparing to exec handles the table above. Note ordering matters: >>, <>, >&, <& must be tried before the single-character > and <.

Path A$# = 1 cannot distinguish exec from exec 3<>file, because the redirection is consumed by the shell before the function sees its arguments. Options:

  • Prefer the location-based path whenever LINENO is available, so the textual source line can be inspected, and treat the $# = 1 case in _otel_inject_and_exec_directly as "unknown — do nothing but record a span" rather than "bare exec, tear everything down". Fail safe: an uninstrumented redirection-only exec costs a missing span; a mis-detected one corrupts the run.
  • Alternatively, detect the redirection reflectively: on entry, compare the shell's open fds (/proc/self/fd) before and after, though this is racy and probably not worth it.

Adding a regression test for each row of the table above would be worthwhile, since these forms are cheap to exercise and the failure mode is silent.

Related

  • #4034 — _otel_end_script evaluating the user's deferred EXIT trap on exec paths. That issue is what turns this detection gap into user-visible damage; this issue is the reason the gap is reachable at all.
  • #4035 — otel_shutdown deleting the SDK pipe from subshells.

Notes

Analysis against main. OTEL_SHELL_CONSERVATIVE_EXEC can be set at runtime (including by _otel_inject_and_exec_directly itself at agent.sh:398), so both paths A and B are reachable within a single run and both should be fixed.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.