Skip to content

runner: end a sandboxed pipe process with its whole sandbox, also during setup (#109) - #118

Merged
novelKR merged 1 commit into
mainfrom
claude/project-thread-71t5wo
Oct 21, 2026
Merged

novelKR merged 1 commit into
mainfrom
claude/project-thread-71t5wo

Conversation

@novelKR

@novelKR novelKR commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Issue

Closes #109

Item Value
Base main at 0ac3191
Head 5c6f777
Pins Codex gitlink with its recorded patch, and DevGuard 6e7e065; both unchanged

Summary

Before: on Linux, terminating a sandboxed pipe process, or its timeout, killed the sandbox helper alone. bubblewrap's sandbox process sets its parent-death signal only just before it runs the command, so a kill that landed while the sandbox was being set up left that process running under PID 1. It then ran the command, which kept the Runner's output pipes open: the Runner reported the end only when the command ended on its own, and a second terminate did nothing, as the Runner had already reaped the helper.

After: the Runner ends a sandboxed pipe process with every process it started, also during the sandbox's setup. A terminate or a timeout first stops the helper, then each process below a stopped one, from the top down, then kills them all, children before their parents, and the helper last, as before. Killing the sandbox's first process ends its PID namespace, so everything in the sandbox ends with it.

How.

  • crates/runner/src/sandbox_tree.rs (new, Linux only): kill_descendants stops the root with SIGSTOP, waits until each of its threads has stopped, then reads /proc and stops the processes whose parent it has already stopped, a level at a time, until a read finds none. Each read comes after every process found so far has stopped, so none can start a process or reap one: each process ID it signals stays that process's own. It then sends SIGKILL to each process it found, in reverse order. SIGSTOP and SIGKILL reach a PID namespace's first process from the Runner's namespace whatever signal handlers it has set. The waits share a 500 ms budget, after which the walk goes on without waiting. It never signals PID 0, a negative PID or the Runner itself, and does nothing when /proc does not show the root as the Runner's child.
  • crates/runner/src/process.rs: a pipe slot records whether the Linux sandbox launched it, and request_kill (terminate, workspace termination and the independent Runner's stop) and the timeout kill a pipe root through kill_pipe_root, which walks a sandboxed root's processes first. Any other root is killed alone, as before.
  • docs/runner-isolation.md and its Korean pair: the paragraph on ending a process says how a sandboxed pipe process ends. The pair's hashes are recorded with scripts/check_docs.py record.

Contract changes

None to tool schemas, error codes, status values or IDs. Ending a sandboxed pipe process now ends its sandbox also while the sandbox is being set up, as it already did once the command ran, through bubblewrap's parent-death signal. Processes that the Linux sandbox does not run, and tty: true processes, end as before.

Tests

Verified here on the same change at 5b4824a, based on d0f2e49, before it was rebased onto 0ac3191, which changes only scripts and documentation; in a Linux cloud container running as root, with bubblewrap 0.9.0, the Codex checkout with its recorded patch applied, and the Linux sandbox helper built:

  • The issue reproduced outside the Runner first: with the helper killed as soon as the sandbox's first process existed, 19 of 30 runs left /bin/sleep running.
  • process::tests::terminate_during_sandbox_setup_leaves_no_process_behind (new) starts a sandboxed /bin/sleep, waits until the sandbox's first process exists and still runs bubblewrap, under the bubblewrap that runs the command, then terminates it, and requires the output to end within 2 s with no sleep left. It repeats until 5 terminates landed during setup. It failed 3 of 3 runs with kill_pipe_root's walk disabled, at the first or second terminate, and passed 25 of 25 runs with it.
  • sandbox_tree::tests (new): a root whose shell starts sleep in a session of its own, which outlives a kill of the root alone, ends with every process below it while the root stays stopped; a process that is not the caller's child is left alone; PID 0, an out-of-range PID and the caller are never signalled; and stat lines with spaces and parentheses in the command name parse.
  • cargo test --locked -p codespace-runner --lib, with the sandbox helper available: 158 passed.
  • cargo fmt --check; cargo clippy --locked --all-targets -- -D warnings, and -p codespace-runner --features devguard.
  • python3 scripts/check_docs.py check (22 paired documents) and scripts/check-no-model-deps.sh.

Not verified here: the workspace's other test suites and the macOS checks, which CI runs.

Security scenarios

  • Signals reach only the launch's processes. The walk starts at the Runner's own unreaped child and goes down only through parents it has stopped, so it reaches only processes that the launch started, never the Runner or its other children. It refuses PID 0 and negative PIDs, which kill would take as a process group or every process, and the Runner's own PID.
  • Process ID reuse. A process the walk found is either stopped or a child of a stopped process, which cannot reap it, so its PID cannot pass to another process before it is killed. The one exception is a parent that ignores SIGCHLD, whose exiting children the kernel reaps itself; the kernel then hands out that PID again only once its counter has gone round every other one.
  • Workspace isolation, path escape, token logging, rollback honesty. Unchanged: nothing here reads or writes workspace files, and nothing new is logged.

Out of scope

  • tty: true sandboxed processes. Their termination signals the root's process group, which bubblewrap's sandbox process leaves only just before it sets its parent-death signal, so the window is much narrower but not closed. The pinned Codex PTY does not name its root's PID, so the Runner cannot walk its processes; covering it means a change to the recorded Codex patch.
  • A Runner that is itself killed, or a worker that exits, while a sandbox is being set up: nothing is left in the Runner to end the sandbox.
  • How a pipe process that the Linux sandbox does not run ends: its root alone, as docs/runner-isolation.md says.

On Linux, terminating a sandboxed pipe process, or its timeout, killed
the sandbox helper alone. bubblewrap's sandbox process sets its
parent-death signal only just before it runs the command, so a kill
during the sandbox's setup left that process running under PID 1 with
the command, which kept the Runner's output pipes open.

A sandboxed pipe root is now stopped first, then each process below a
stopped one, read from /proc a level at a time, and every process found
is killed, children before their parents, before the root. Killing the
sandbox's first process ends its PID namespace. Other roots are killed
alone, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@novelKR novelKR self-assigned this Oct 19, 2026
@novelKR
novelKR merged commit 0aaad91 into main Oct 21, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runner: a sandboxed command terminated during its sandbox setup keeps running (Linux)

2 participants