Redirection-only `exec` is not reliably detected, so fd manipulation is treated as process replacement
@plengauer is already working on this.
Since Aug 20, 2026.
- 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:
- Process replacement —
exec somecommand args... - Redirection only —
exec 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
LINENOis available, so the textual source line can be inspected, and treat the$# = 1case in_otel_inject_and_exec_directlyas "unknown — do nothing but record a span" rather than "bare exec, tear everything down". Fail safe: an uninstrumented redirection-onlyexeccosts 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_scriptevaluating the user's deferred EXIT trap onexecpaths. 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_shutdowndeleting 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
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.