Skip to content

fix(execd): keep isolated run code off the shell's stdin - #2517

Open
acodercat wants to merge 3 commits into
opensandbox-group:mainfrom
acodercat:fix/execd-isolated-run-stdin
Open

acodercat wants to merge 3 commits into
opensandbox-group:mainfrom
acodercat:fix/execd-isolated-run-stdin

Conversation

@acodercat

@acodercat acodercat commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor

A foreground isolated run writes its whole script to the persistent shell's stdin: trap : INT, the user code, then echo <marker> $?. The user code runs with that same stdin. So a command in the code that reads stdin (read, cat, python script.py reading input, etc.) consumes the rest of the script, including the end-marker line.

On current main:

  • read x; echo "got:$x"; false returns success. read takes the line echo __ISOLATED_RUN_END___<uuid> $?, the next echo prints it, the scanner finds the marker in that output, and $? doesn't parse, so the exit code defaults to 0. The output is ["got:echo "].
  • cat reports success right away while cat keeps running, and the next run (echo next) returns ["trap : INT", "echo next", "echo "], because cat echoes that run's script back. The session stays like this until a run is cancelled.

This also contradicts the comment that the per-run UUID makes the end marker impossible to forge. Background runs already avoid it by running the script with </dev/null.

Summary

  • The user code is passed to \command eval as one quoted word, with </dev/null as stdin. \command reaches the builtin even if an earlier run defined a function or alias named eval.
    • eval parses and runs the code one command at a time, like top-level input, so shopt -s extglob, aliases and similar settings still apply to the lines after them.
    • Runs without envs still run in the main shell, so exports persist across runs. Runs with envs keep their ( ) subshell.
  • The end-marker line is a separate top-level command, so only the shell reads it.
  • A syntax error in the code now ends the run with status 2 and the session survives, in bash and dash. Before, a syntax error at top level made the non-interactive shell exit.
  • TestRunInIsolatedSession_ReportsFailedScriptWrite used exec sleep 300 to replace the shell partway through a 150 KiB script. The shell now reads the whole eval word before running any of it, so the test gives the run a stdin pipe whose read end is closed, a shell that no longer reads. It still checks that the run reports write stdin promptly instead of waiting for its deadline. The comment on the concurrent write is updated to match.
  • The /dev/null stdin is documented on IsolatedRunRequest.code in the execd spec (with the Python and JavaScript SDK descriptions regenerated) and in the Limitations section of the isolation guide.

Testing

  • Not run (explain why)

  • Unit tests

  • Integration tests

  • e2e / manual verification

  • New TestRunInIsolatedSession_StdinDetached, with and without envs: read x; echo "got:$x"; false must fail with exit code 1 and print got:, and after a cat run the next run must print exactly next. Every assertion fails on current main. Dropping the </dev/null fails it again.

  • New TestRunInIsolatedSession_IgnoresEvalOverrides: after a run that leaves eval() { :; } or alias eval=true, the next run must still execute and report its exit code. It fails with a plain eval.

  • New TestRunInIsolatedSession_ParsesCodeIncrementally: shopt -s extglob followed by a +(a) pattern must match. It fails if the code is wrapped in a { } group.

  • Removing the cancelRun() after a failed write makes the adapted write-failure test wait for its deadline.

  • All execd packages pass (GOOS=linux go test ./...), including the interrupt tests and -race on the isolated tests. golangci-lint is clean, and go vet passes for darwin and windows.

Breaking Changes

  • None
  • Yes (describe impact and migration path)

Code in a foreground isolated run can no longer read the session's stdin. It only ever got the rest of the run's own script that way, so nothing useful depended on it.

Checklist

  • Linked Issue or clearly described motivation
  • Added/updated docs (if needed)
  • Added/updated tests (if needed)
  • Security impact considered
  • Backward compatibility considered

A foreground isolated run sends its script, user code followed by
`echo <marker> $?`, on the persistent shell's stdin. A command in the
user code that reads stdin (read, cat, any program reading stdin)
consumed the rest of the script instead: the marker line became input,
its text could come back as output with `$?` unparsed so the run
reported exit code 0, and `cat` kept running and echoed the next run's
script as that run's output.

Run the user code with </dev/null, in a `{ }` group for no-envs runs so
exports still persist and in the existing subshell for envs runs, the
same way background runs already detach stdin. The shell now reads the
whole group before running it, so the failed-write test simulates a
shell that stops reading with a closed pipe instead of exec'ing away
mid-script.
@github-actions github-actions Bot added component/execd size/S Denotes a PR that changes 10-29 lines, ignoring generated files. labels Oct 31, 2026
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-31T10:27:00.173129Z 4c151e2 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 187278e392

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

// keep cross-run exports working. Either way the user code reads
// /dev/null: the script arrives on the shell's stdin, so a command that
// reads stdin would otherwise consume the end marker below.
openGroup, closeGroup := "{\n", "} </dev/null\n"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve incremental parsing for no-env scripts

With the default Bash shell and envs empty, this brace group is parsed in full before any body command executes. Thus valid input such as shopt -s extglob followed by a case using +(...) now hits a parse error before the option is enabled; Bash exits without the marker, so the run fails and the long-lived session is lost (aliases defined and used later in the same request similarly stop expanding). Preserve top-level incremental parsing while detaching fd 0 rather than wrapping the whole request in one compound command.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, thanks. Fixed in c7610cf: the code is no longer wrapped in a { } group. It is passed to eval as one quoted word with </dev/null, and eval parses and runs it one command at a time like top-level input, so shopt -s extglob and aliases still apply to later lines. The end marker stays a separate top-level command, so user code still cannot read it. Added TestRunInIsolatedSession_ParsesCodeIncrementally, which fails with the group. A side effect with bash: a syntax error now ends the run with status 2 instead of killing the session.

Comment on lines +718 to +719
// /dev/null: the script arrives on the shell's stdin, so a command that
// reads stdin would otherwise consume the end marker below.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Publish the detached-stdin contract

For every foreground run whose program reads standard input, this redirect now produces immediate EOF, but neither specs/execd-api.yaml's IsolatedRunRequest.code contract nor the Limitations section of docs/guides/isolation-sessions.md says that run stdin is unavailable. Since the request has no stdin field, callers otherwise discover this behavior only at execution time; document the /dev/null contract and the supported explicit-redirection/file alternatives together with this user-visible change.

AGENTS.md reference: AGENTS.md:L45-L46

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, done in c7610cf. IsolatedRunRequest.code in the execd spec now says its stdin is /dev/null and to pass input through a file or a here-document, and the Python and JavaScript SDK descriptions are regenerated. The Limitations section of the isolation guide says the same, for foreground and background runs.

Wrapping the run in a { } group made the shell parse the whole request
before running any of it, so `shopt -s extglob` or an alias defined
early in the code no longer applied to the lines after it. Pass the code
to eval instead, with stdin on /dev/null: eval parses and runs it one
command at a time like top-level input, and user commands still cannot
read the end marker. With bash, a syntax error now ends the run with
status 2 instead of killing the session.

Document the /dev/null stdin in the execd spec and the isolation guide,
and regenerate the IsolatedRunRequest descriptions in the Python and
JavaScript SDKs.
@acodercat
acodercat requested a review from jwx0925 as a code owner October 31, 2026 08:30
@github-actions github-actions Bot added documentation Improvements or additions to documentation sdk/js sdk/python labels Oct 31, 2026
@acodercat

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: c7610cf731

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

if !strings.HasSuffix(code, "\n") {
script.WriteString("\n")
}
script.WriteString(fmt.Sprintf("eval %s </dev/null\n", shellescape(code)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep persisted eval overrides from swallowing later runs

When a no-envs run defines a function or expanded alias named eval (or runs enable -n eval), that shell state persists, so this unqualified call in the next request resolves to the user's override instead of the builtin; for example, eval() { :; } makes every later request report success without executing its code. The previous top-level injection did not reserve eval, so invoke the builtin through a path that ordinary user functions and aliases cannot shadow and cover this with a two-run regression test.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch, reproduced with both a function and an alias. Fixed in 4c151e2: the runner now calls \command eval. command skips function lookup and the leading backslash prevents alias expansion, which works the same in bash and dash. State the code sets (functions, cd, variables) still persists across runs. Added TestRunInIsolatedSession_IgnoresEvalOverrides, which leaves eval() { :; } or alias eval=true in one run and checks that the next run still executes and reports its exit code. It fails with plain eval.

The session shell persists between runs, so a function or alias named
eval defined by one run (for example `eval() { :; }`) took over the
runner's eval in every later run, which then reported success without
running its code.

Call `\command eval`: `command` skips function lookup and the backslash
prevents alias expansion, in bash and dash alike. In dash it also keeps
a syntax error in the code from ending the session.
@acodercat

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4c151e28d2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

if !strings.HasSuffix(code, "\n") {
script.WriteString("\n")
}
script.WriteString(fmt.Sprintf("\\command eval %s </dev/null\n", shellescape(code)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Guard the eval dispatch from persisted command functions

Unlike the eval override covered by the new test, a no-env run can define command() { :; }, and that function persists into the next request. The backslash here suppresses alias expansion but not function lookup, so both Bash and dash call the user function, never reach eval, and then emit the marker with status 0 even when all submitted code (for example false) was skipped. Protect this dispatch from a user-defined command and add the corresponding two-run regression test.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I am leaving this one as is, because it is a property of the persistent session rather than of this dispatch. Every word the runner relies on can be redefined as a function by an earlier no-env run: command here, but also builtin, unset, trap, and the echo that prints the end marker. That last one was already true before this PR. On current main, a run that defines echo() { builtin echo "$1" 0; } makes every later run report status 0, false included. Guarding against a deliberate override would take a different protocol, for example having the runner report the status through a descriptor the user code cannot reach. That is a bigger change than this fix. \command eval covers the accidental case, a function or alias that happens to be named eval. The session state belongs to the code that runs in it, so a run that redefines the shell's own commands only affects its own session.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component/execd documentation Improvements or additions to documentation sdk/js sdk/python size/S Denotes a PR that changes 10-29 lines, ignoring generated files.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant