From 12d6c4974d2955f7a3f2f9d2d9a18f0d60b920c6 Mon Sep 17 00:00:00 2001 From: rohan Date: Tue, 29 Sep 2026 17:30:35 +0530 Subject: [PATCH] docs(research): close Windows containment unknowns by live experiment (#258) --- docs/research/windows-process-containment.md | 736 ++++++++++--------- 1 file changed, 405 insertions(+), 331 deletions(-) diff --git a/docs/research/windows-process-containment.md b/docs/research/windows-process-containment.md index 4e70245..7afdd74 100644 --- a/docs/research/windows-process-containment.md +++ b/docs/research/windows-process-containment.md @@ -20,7 +20,8 @@ Ticket: [#258](https://github.com/secantdev/secant/issues/258). It follows [Windows Live Interrupt and Steer](windows-live-interrupt-and-steer.md) (#255). That note found that a script run by Claude Code's Bash tool through Git Bash has a dead Windows parent pid, and survives both Claude Code's own `taskkill /PID /T /F` and Secant's `taskkill /T /F`. The decision on containment belongs to #259. This note -records facts only. +records facts only. A follow-up pass on 2026-09-29 (same machine and versions) closed the original "Still Unknown" list +by live experiment; those results are folded into the sections below and the drivers are described in the Method. ## Answer @@ -31,7 +32,13 @@ fork child, which exits as soon as the exec succeeds. Driven the way Claude Code - a script (`./x.sh`, `bash x.sh`, `sh x.sh`) and everything it started; - `bash -c '…'`; -- a bare MSYS utility (`sleep`). +- a bare MSYS utility (`sleep`); +- an MSYS pipe stage (`… | cat`, `… | tee file`); +- an MSYS stage of `xargs` and `find -exec`; +- the MSYS `sleep` inside `npm run`, `npx -c`, and `bun run` of a script (the launcher `node`/`bun` has a live parent and + dies; the MSYS leaf escapes); +- a backgrounded MSYS job (`… &`, `nohup … &`, `… & disown`), where the root bash exits at once and the detached leaf is + left behind. These were killed: @@ -40,415 +47,482 @@ These were killed: - `cmd & wait`; - `exec ./x.sh` from the top-level shell. -Each case was run once. The MSYS source matches this. On `exec`, the old process stays behind to wait only when the -target is not an MSYS program, or when the process has no MSYS parent. Otherwise it exits. +The single-run C1 cases in the first pass were re-run three times each in the follow-up, plus the new MSYS paths above +(three runs each). Every escape was reproducible. `setsid` and `make` are not present in this Git for Windows +(`command not found`), so they could not be run. The MSYS source matches this: on `exec`, the old process stays behind to +wait only when the target is not an MSYS program, or when the process has no MSYS parent; otherwise it exits. **C2. A Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`, and no breakaway limit, reaches all of them.** It was -assigned to the root right after `spawn()` returned. The escaped processes were members of the job (`IsProcessInJob`), -dead parent and all. `TerminateJobObject`, or just `CloseHandle` on the job, killed every one, with no survivor in any of -the 15 cases. - -Two live runs repeated this with Claude Code. The job was assigned to `claude.exe` itself, and the Bash tool ran a Git Bash -script. `TerminateJobObject` killed `claude.exe`, both tool shells, the dead-parent script, and its `ping` (2 of 2). The -script's end marker was never written. +assigned to the root right after `spawn()` returned. Every escaped process above — including each new MSYS path and every +backgrounded leaf — was a member of the job (`IsProcessInJob`), dead parent and all, and `CloseHandle` or +`TerminateJobObject` killed every one, with no survivor in any case (three runs each for the new paths, 15/15 in the first +pass). The job must not allow breakaway. The MSYS runtime adds `CREATE_BREAKAWAY_FROM_JOB` to every spawn when its immediate job -has `BREAKAWAY_OK` or `SILENT_BREAKAWAY_OK`. With either flag, the Git Bash processes left the job and survived it. - -**C3. Bun has no built-in way to create a job or to spawn suspended.** `bun:ffi` against `kernel32.dll` works. Four calls -are enough: `CreateJobObjectW`, `SetInformationJobObject`, `AssignProcessToJobObject`, and `TerminateJobObject` or -`CloseHandle`. It also works inside a `bun build --compile` executable. Bun documents `bun:ffi` as experimental. - -Assigning after `spawn()` returns leaves a window in which the child runs outside the job. Bun's libuv suspends only -detached children, and it already puts every non-detached child in its own job, which has `KILL_ON_JOB_CLOSE`, -`BREAKAWAY_OK`, and `SILENT_BREAKAWAY_OK`. A second job nests under that one (Windows 8 and later), and nesting worked here. - -**C4. Codex's `turn/interrupt` does not stop a running shell command on Windows.** The Turn ended `interrupted`. The -command's whole tree kept running while the app-server lived (5 of 5), as the source says: the process is stored as a -background terminal before the interrupt can drop it. - -Two later steps did stop it: the experimental `thread/backgroundTerminals/clean` request, and the app-server's exit. Both -end the job Codex creates for each command, which has `KILL_ON_JOB_CLOSE | BREAKAWAY_OK`. That job has `BREAKAWAY_OK`, so -a Git Bash fork-and-exec script run without a PTY left it. The script outlived both the clean request and the app-server's -exit, and ran to completion (2 of 2). The same script under a PTY died when the app-server exited (1 run). - -Under an outer Secant-style job without breakaway, the Git Bash script stayed contained. `TerminateJobObject` on the outer -job killed it (1 run). +has `BREAKAWAY_OK` or `SILENT_BREAKAWAY_OK`. With either flag, the Git Bash processes left the job and survived it. A +process that requests breakaway from a job that forbids it does not silently escape: `CreateProcess` fails with +`ERROR_ACCESS_DENIED` (5) — observed for every probe spawned under a Secant-style no-breakaway job and for Codex's own +MCP job (C7). + +**C3. Bun has no built-in way to create a job or to spawn suspended, but the assign-after-spawn window did not expose a +descendant in practice.** `bun:ffi` against `kernel32.dll` works; four calls are enough (`CreateJobObjectW`, +`SetInformationJobObject`, `AssignProcessToJobObject`, and `TerminateJobObject` or `CloseHandle`), inside a +`bun build --compile` executable too. Assigning after `spawn()` returns leaves a window in which the child runs outside the +job. Measured over 20 launches each of the real `claude.exe` and `codex.exe`, spawned the way `src/process/process.ts` +spawns them, the assignment landed 56–224 µs after `spawn()` returned, and **no non-`conhost` descendant was created +before the assignment, and none escaped the job** (0/20 for each Harness). `claude.exe`'s first real child (`reg.exe`) +appeared 42–82 ms after the assignment, its Git/Bash tool children hundreds of ms later; `codex.exe` produced only its +`conhost` before the app-server handshake. The only early sibling is the system-created `conhost.exe`, which is never a job +member by design. + +The window can be closed from `bun:ffi` while Secant keeps ordinary streams for the child's stdio. A self-rolled +`CreateProcessW` with `PROC_THREAD_ATTRIBUTE_JOB_LIST` puts the child in the job at creation, before its first +instruction. `CREATE_SUSPENDED`, then assign, then `ResumeThread` also works. For stdio, Bun's own `node:net` listens on +a named pipe for each stream, and the child inherits a client end opened with `CreateFileW`. Secant then reads and writes +`net.Socket` streams on libuv's async I/O, with no thread and no polling. That prototype carried the Claude Code +stream-json and Codex app-server protocols live, and a job kill ended each Harness and every tool descendant (3 of 3 for +each). It survived a parent crash and moved 64 MiB in 70–86 ms. The anonymous-pipe route fails: CRT fds from +`ucrtbase` `_open_osfhandle` are not Bun fds (`EBADF`). + +**C4. Codex on Windows contains a command in a per-command Job Object, and this holds under the user's Windows sandbox.** +Driven the way Secant drives it — `thread/start` with `cwd` only, so the user's `~/.codex/config.toml` governs — the +acknowledged policy is `approvalPolicy: "on-request"` and, for a `cwd` the config trusts, `sandbox: workspaceWrite` +(a `cwd` the config does not trust acknowledges `readOnly`). The sandboxed command runs under a **restricted token** as the +same user at medium integrity, and Codex still puts it in a per-command job `KILL_ON_JOB_CLOSE | BREAKAWAY_OK` owned by +`codex.exe`. `turn/interrupt` ends the Turn `interrupted` and leaves the sandboxed command tree running (3/3); +`thread/backgroundTerminals/clean` and the app-server's exit both stop it (3/3). An outer Secant-style no-breakaway job +still contains the sandboxed tree — every process reported `IsProcessInJob(secantJob) = true` despite the restricted +token (assignment and nesting succeed because it is the same user), and `TerminateJobObject` on the outer job killed the +whole tree (3/3). **Git Bash cannot run at all under Codex's `workspace-write` sandbox**: the MSYS runtime aborts before +`main` with `fatal error - couldn't create signal pipe` / `CreateFileMapping … Win32 error 5`, because the restricted +token denies the shared section MSYS needs; the fork-and-exec escape therefore never arises under the sandbox, only native +commands (`cmd`, `pwsh`, `PING`) run there. + +`turn/interrupt` not stopping the command, and the escape of a pipe-mode Git Bash script past Codex's `BREAKAWAY_OK` job, +are unchanged from the first pass. The two follow-up questions are now answered: + +- **PTY-mode kill is the pseudoconsole closing, not the job.** Under a PTY, Codex's job holds only `pwsh`, the launcher + `bash`, and `usr\bin\bash` (n=3); the dead-parent script and its `PING` broke away and are **not** job members. Killing + the headless `conhost.exe` pseudoconsole host alone left the escaped script and `PING` running (3/3); terminating Codex's + own job — which kills `pwsh`, the pseudoconsole's owner — killed the escaped script and `PING` too (3/3). So the escaped + tree dies because `pwsh`'s exit closes the pseudoconsole, and the app-server's exit ends `pwsh` the same way. +- **The stdin-close stall is an inherited pipe waiting for EOF.** In pipe mode the escaped script inherits the command's + stdout/stderr write handles; Codex's app-server keeps a reader that awaits the stdout/stderr read tasks, each looping + until `read()` returns `Ok(0)` (**Source-observed**, + [`utils/pty/src/pipe.rs` lines 109-122](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/pipe.rs#L109-L122) + and [261-268](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/pipe.rs#L261-L268)). + With the escaped script holding the pipe open, EOF never comes: the app-server exited 21.0–21.1 s after stdin close — + exactly when the 30-ping script finished and released the pipe — and the script ran to completion (3/3). A script that + first closes its own stdio (`exec /dev/null 2>&1`) releases the pipe, and the app-server then exited in + 58–89 ms while the script kept running detached (3/3). On a normal root exit Codex also calls `preserve_descendants()` + (**Source-observed**, [`pipe.rs` line 285](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/pipe.rs#L285)), + deliberately keeping the tree. **C5. What the guides do.** -- Codex contains commands in Job Objects: attached at `CreateProcess` for a PTY, assigned after spawn for pipes. +- Codex contains commands in Job Objects: attached at `CreateProcess` for a PTY, assigned after spawn for pipes. Each + stdio MCP server and each sandboxed command sit in their own job (C4, C7). - Claude Code (inferred from strings in its binary), OpenCode, and T3 Code stop trees with `taskkill /T /F`, and none of them uses a Job Object for tool or Harness processes. - Claude Code puts no job of its own around tool commands. Seen from inside, a Bash-tool command sat in a job outside Claude Code's tree, and a PowerShell-tool command sat in the job its host already had. +**C6. Claude Code still runs `taskkill /T /F` on the tool shell on a raw interrupt; the fork-and-exec escape defeats it.** +The binary string that skips a kill when the abort reason is `"interrupt"` belongs to the live-shell wrapper's own +`kill()`, not to the tree kill: `#T(){let e=Ci(this.#i.reason);if(e==="interrupt"||wJt(e,this.#u))return;this.kill()}` +(**Binary-observed**). Traced live, the stream-json `control_request` `interrupt` (ADR 0035's wire) still fires +`C:\WINDOWS\System32\taskkill.exe /PID /T /F` — a fast toolhelp watcher caught two such invocations per +interrupt, parented by `claude.exe`, in every run (3/3) — and the escaped script survived and ran to completion anyway. +A SIGTERM of `claude.exe`, a `taskkill /PID /F` (no `/T`), and a plain stdin close issue no `taskkill` at all +(`claude.exe` dies first) and likewise leave the escaped script running to completion (3/3 each). So the two kill paths +coexist: the interrupt keeps the tree kill, and the tree kill is what the escape defeats. + +**C7. A Secant-style no-breakaway job ends the processes a Harness deliberately detaches, and denies a Harness's own +breakaway.** + +- **Claude Code background Bash (`run_in_background`).** The background script escapes the tool tree (dead parent) and, + with no Secant job, outlives `claude.exe` and runs to completion (2/2). Under a Secant `KILL_ON_JOB_CLOSE` job the whole + background tree is a job member, and closing the job after `claude.exe` exits kills it mid-run — the start marker was + written but never the done marker (2/2). +- **Claude Code stdio MCP server.** Launched by `claude.exe` through `bun`, it is a direct child inside the Secant job; its + own libuv job nests within. A child it spawns with `CREATE_BREAKAWAY_FROM_JOB` succeeds but still lands inside the Secant + job, because breakaway stops at the first job that forbids it (3/3). +- **Codex stdio MCP server.** Codex puts each MCP server in its own no-breakaway job (`KILL_ON_JOB_CLOSE`, + `create_without_breakaway`, **Source-observed**), inside the Secant job when present. A child it spawns with + `CREATE_BREAKAWAY_FROM_JOB` fails with `ERROR_ACCESS_DENIED` (5), 3/3. **This is the answer to whether a Secant + no-breakaway job would break a Harness that must launch something detached: yes — the detaching spawn fails with error 5.** Codex already tolerates this: its own no-breakaway MCP path treats a rejected job assignment as a fallback and + re-spawns uncontained + ([`rmcp-client/src/stdio_server_launcher.rs` lines 324-343](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/rmcp-client/src/stdio_server_launcher.rs#L324-L343)). +- **Codex background terminals.** Contained in the Secant job when present (`IsProcessInJob` true), and killed by + `TerminateJobObject` on the outer job (C4). +- **Secant's own planned loopback MCP** is not built; noted only. As a direct child of the Secant process it would be a + member of the same job and end on job close like any other member. + ## Evidence Vocabulary - **Observed**: seen in process tables, job queries, marker files, or Harness frames captured for this note on 2026-09-29 against the versions above. Every fact without another label is Observed. - **Source-observed**: read in the named source at the cited commit. - **Doc**: stated by Microsoft Learn or Bun's documentation. -- **Binary-observed**: found in the strings of the installed executable, which is closed source. The call sites were not - traced. +- **Binary-observed**: found in the strings of the installed executable, which is closed source. - **Inferred**: a consequence drawn from the above that needs a check before it becomes a compatibility promise. ## Method -The drivers were small Bun 1.4.2 scripts. They and every raw log stayed in the session scratchpad, outside the repository. +The drivers were small Bun 1.4.2 scripts. They and every raw log stayed in the session scratchpad, outside the +repository. The first pass used the drivers described here; the 2026-09-29 follow-up added drivers that launch the real +`claude.exe` and `codex.exe` the way Secant does, poll the process tree with a toolhelp snapshot, and enumerate every +Job Object via the system handle table. - **Spawning.** Children were spawned as `src/process/process.ts` spawns an owned process on win32 (**Source-observed**, - `spawnOwnedProcessWithNode` lines 400-414): `node:child_process` `spawn`, `windowsHide: true`, and `detached: false`. -- **Process tables.** Taken with `Get-CimInstance Win32_Process` (pid, parent pid, name, and command line). -- **Job queries.** A `bun:ffi` binding to `kernel32.dll` made these calls: - - `IsProcessInJob(h, NULL)` for membership in any job, and `IsProcessInJob(h, job)` for membership in the test's own job; - - `QueryInformationJobObject` with `JobObjectExtendedLimitInformation` (the limit flags) and - `JobObjectBasicProcessIdList` (the members). - - With a `NULL` job handle, the query reports the caller's own job. A spawned probe (`bun probe-child.ts`) used this to - report the job it was running in. -- **Test job.** `CreateJobObjectW(NULL, NULL)`, then `SetInformationJobObject` with only - `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` unless a run says otherwise, then `AssignProcessToJobObject`. The process was opened - with `PROCESS_SET_QUOTA | PROCESS_TERMINATE`. -- **Test processes.** Each one carried a marker, the address `127.0.0.58` or the duration `25.8`, so escaped processes - could be found by command line. The drivers killed every survivor by pid after each case. -- **Hygiene.** A final process-table check found no marker process and no `claude -p` or `codex app-server` left from the - runs. - -The agent shell these drivers ran in was already in a job with `BREAKAWAY_OK`. A process started through WMI -(`Win32_Process.Create`) was in a job with `BREAKAWAY_OK | SILENT_BREAKAWAY_OK`. No job-free starting point was available, -so "in any job" is `true` for every process below, and membership in the test's own job is what tells the cases apart. + `spawnOwnedProcessWithNode`): `node:child_process` `spawn`, `windowsHide: true`, `detached: false`, overlapped pipes. +- **Process tables.** Taken with `Get-CimInstance Win32_Process`; the fast follow-up watcher used + `CreateToolhelp32Snapshot` plus `NtQueryInformationProcess(ProcessCommandLineInformation)` for command lines, polling + every ~4.6 ms. +- **Job queries.** A `bun:ffi` binding to `kernel32.dll`, `ntdll.dll`, `kernelbase.dll`, and `advapi32.dll` made these + calls: `IsProcessInJob` for membership; `QueryInformationJobObject` for limit flags and member pids; + `NtQuerySystemInformation(SystemHandleInformation)` + `DuplicateHandle` + `CompareObjectHandles` to enumerate every job + the caller can duplicate a handle to and print a process's full job chain; `OpenProcessToken` + `IsTokenRestricted` and + the token integrity SID for the sandbox check; `GetProcessTimes` and `GetSystemTimePreciseAsFileTime` for the + assign-after-spawn timing. +- **Test job.** `CreateJobObjectW`, `SetInformationJobObject` with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` (and, where a run + says so, a breakaway flag), then `AssignProcessToJobObject`. +- **Real Harness launches.** `claude.exe` with the #255 stream-json flags (`--model haiku`, a per-Run `--session-id`, and + the allow-list for the case); Codex `app-server` with the qualification handshake, `thread/start { cwd }` only unless a + run notes `approvalPolicy`/`sandbox`, and `turn/start` on `gpt-6-luna` at `effort: "low"`. `CLAUDE*`, `ORCA_*`, and + `CODEX_HOME` were stripped so the user's real config applied. +- **Test processes.** Each carried a marker (an address such as `127.0.0.58`/`127.0.0.59`/`127.0.0.61`, a duration such as + `25.7`, or a script name) so escaped processes could be found by command line, and each driver killed every survivor by + pid and verified none remained. +- **Contained spawn.** A third set of drivers launched the children through the `bun:ffi` `CreateProcessW` prototype + described in C3, not through Bun's spawn. +- **Hygiene.** A final process-table sweep after all runs found no marker process and no stray `claude`/`codex`/`bash` + from the runs. + +The agent shell these drivers ran in was itself inside a job with breakaway, and a process that breaks away with +`CREATE_BREAKAWAY_FROM_JOB` lands in an anonymous job whose handle the handle-table walk cannot match to a named owner. +So `IsProcessInJob(NULL)` is `true` for every process below, and membership in a _named or owned_ job — the test job, a +Harness job, or the Secant job — is what tells the cases apart. On this machine the follow-up drivers themselves ran under +a WinGet/WMI-launched `claude.exe` host rather than the first pass's Orca host, which changes the outer job owners in the +chain maps but not any containment result. ## C1: Which Git Bash commands escape `taskkill /T` Each case spawned `C:\Program Files\Git\bin\bash.exe -c "eval '' < /dev/null && pwd -P > /dev/null"`. That is the shape of Claude Code's Bash-tool invocation, which the #255 note captured as `Git\bin\bash.exe -c "source … && -eval '' …"`. The trailing `&& …` stops bash from exec'ing the last command in place. After 2.5 s the driver ran -`taskkill /pid /T /F`, waited 1.5 s, and listed survivors. - -| Case | Tree before the kill | Survivors of `taskkill /T /F` | -| ------------------------------------------- | --------------------------------------------------------------------- | ----------------------------- | -| `ping -n 26 127.0.0.58` | launcher → `usr\bin\bash` → bash (fork) → `PING` | none | -| `sleep 25.8` | launcher → `usr\bin\bash`; `sleep.exe` (parent **dead**) | `sleep.exe` | -| `./slow258.sh` | launcher → bash; script bash (parent **dead**) → bash (fork) → `PING` | script bash, fork, `PING` | -| `bash slow258.sh` | same as `./slow258.sh` | script bash, fork, `PING` | -| `sh slow258.sh` | same, with `sh.exe` | both `sh.exe`, `PING` | -| `bash -c 'ping …'` | launcher → bash; inner bash (parent **dead**) → `PING` | inner bash, `PING` | -| `ping … \| cat` | unbroken chain to `PING` | none | -| `(ping …; true)` | unbroken chain (subshell fork) to `PING` | none | -| `ping … & wait` | unbroken chain to `PING` | none | -| `exec ./slow258.sh` | top bash → script bash (parent **alive**) → fork → `PING` | none | -| `bun -e 'setTimeout(…,25800)'` | unbroken chain to `bun.exe` | none | -| `bun run slow258` (a `package.json` script) | unbroken chain to `bun.exe` → `PING` | none | -| `powershell.exe -NoProfile -File x.ps1` | unbroken chain to `powershell.exe` → `PING` | none | -| `cmd //c 'ping …'` | unbroken chain to `cmd.exe` → `PING` | none | - -`setsid sleep 25.8` produced no process that the driver could see, and is not counted. The `| cat` process was not -captured in the pipeline row, so whether `cat.exe` (an MSYS program) escaped there is not known. Its `PING` did not escape. +eval '' …"`. The trailing `&& …` stops bash from exec'ing the last command in place. After 2.5–4 s the driver ran +`taskkill /pid /T /F`, waited, and listed survivors. + +| Case | Tree before the kill | Survivors of `taskkill /T /F` | +| ----------------------------------------- | --------------------------------------------------------------------- | ----------------------------- | +| `ping -n 26 127.0.0.58` | launcher → `usr\bin\bash` → bash (fork) → `PING` | none | +| `sleep 25.8` | launcher → `usr\bin\bash`; `sleep.exe` (parent **dead**) | `sleep.exe` | +| `./slow258.sh` | launcher → bash; script bash (parent **dead**) → bash (fork) → `PING` | script bash, fork, `PING` | +| `bash slow258.sh` | same as `./slow258.sh` | script bash, fork, `PING` | +| `sh slow258.sh` | same, with `sh.exe` | both `sh.exe`, `PING` | +| `bash -c 'ping …'` | launcher → bash; inner bash (parent **dead**) → `PING` | inner bash, `PING` | +| `ping … \| cat` | unbroken chain to `PING` | none (`PING`); `cat` escapes | +| `ping … \| tee file` | chain to `PING`; `tee` (parent **dead**) | `tee` (exits on `PING` EOF) | +| `(ping …; true)` | unbroken chain (subshell fork) to `PING` | none | +| `ping … & wait` | unbroken chain to `PING` | none | +| `sleep 25.7 &` / `nohup … &` / `& disown` | root bash exits; `sleep.exe` (parent **dead**) | `sleep.exe` | +| `echo 25.7 \| xargs sleep` | launcher → bash; `sleep.exe` (parent **dead**) | `sleep.exe` | +| `find . -maxdepth 0 -exec sleep …` | launcher → bash; `find`, `sleep` (parents **dead**) | `find.exe`, `sleep.exe` | +| `npm run ` / `npx -c 'sh …'` | bash → node → cmd → sh; `sleep.exe` (parent **dead**) | `sh.exe`… , `sleep.exe` | +| `bun run ` | bash → bun → sh; `sleep.exe` (parent **dead**) | `sleep.exe` | +| `exec ./slow258.sh` | top bash → script bash (parent **alive**) → fork → `PING` | none | +| `bun -e 'setTimeout(…)'` | unbroken chain to `bun.exe` | none | +| `powershell.exe -File x.ps1` | unbroken chain to `powershell.exe` → `PING` | none | +| `cmd //c 'ping …'` | unbroken chain to `cmd.exe` → `PING` | none | + +The first eleven rows of the first pass ran once each; the follow-up re-ran the escaping cases and the new MSYS paths three +times each, all identical. `setsid` and `make` are not installed (`command not found`). The `| cat` stage was still not +captured directly (an escaped `cat` carries no marker), but `| tee file` — which does — escaped the `taskkill` with a dead +parent and then exited on its own when `PING` closed the pipe. - **The pattern.** A process escapes when an MSYS process forks and the fork child then execs another MSYS program: bash, sh, or a utility in `usr\bin`. That program's Windows parent is the fork child, which has already exited. A native - target keeps a live parent. So do `exec` from the top-level shell, whose parent is the native `Git\bin\bash.exe` - launcher, and any command that bash runs in its own fork without exec'ing an MSYS program. -- **Everything under an escaped process escapes too.** In the script cases, the script's own forks and its `ping` have - live parents, but their chain ends at the dead-parent script, so `taskkill /T` never reaches them. + target keeps a live parent, as does `exec` from the top-level shell. Everything under an escaped process escapes too. ### Why the parent pid is dead (Source-observed) - **The exec path.** In the MSYS runtime ([`winsup/cygwin/spawn.cc` at `msys2-3.6.10` `3ea87a5`](https://github.com/msys2/msys2-runtime/blob/3ea87a506e64e841dc0b8ee50ed61999c8fed26c/winsup/cygwin/spawn.cc)), - `exec` is `_P_OVERLAY`, and a new Windows process is created for the target. - - For an MSYS target the old process then leaves through `myself.exit (EXITCODE_NOSET)` (lines 892-929). - - It first calls `wait_for_myself ()` only when `!my_wr_proc_pipe`, that is, when it has no MSYS parent (lines - 905-908). - - For a non-MSYS target the process is "synced" at once, and "we will still eventually wait for it to exit in - maybe_set_exit_code_from_windows()" (lines 884-887). -- **How that fits what was seen.** A fork child always has an MSYS parent, so after exec'ing an MSYS program it exits and - the new process's Windows parent is gone. After exec'ing `ping`, it stays to wait. The top-level bash has a native - parent, so it waits as well. **Inferred**: this is the whole mechanism. The table matches it in every case. -- **The runtime also breaks away from jobs.** The same function adds `CREATE_BREAKAWAY_FROM_JOB` whenever - `IsProcessInJob` is true and the job returned by `QueryInformationJobObject (NULL, …)` has `JOB_OBJECT_LIMIT_BREAKAWAY_OK` - or `JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK` (lines 444-473). The comment names a Program Compatibility Assistant memory - issue as the reason. `fork.cc` adds no such flag - ([lines 218-273](https://github.com/msys2/msys2-runtime/blob/3ea87a506e64e841dc0b8ee50ed61999c8fed26c/winsup/cygwin/fork.cc#L218-L273)). - So an MSYS **exec or spawn** leaves any breakaway-permitting job, and a **fork** stays in the job. + `exec` is `_P_OVERLAY`, and a new Windows process is created for the target. For an MSYS target the old process leaves + through `myself.exit (EXITCODE_NOSET)` (lines 892-929); it waits (`wait_for_myself ()`) only when it has no MSYS parent + (lines 905-908). For a non-MSYS target the process is synced and waited on (lines 884-887). A fork child always has an + MSYS parent, so after exec'ing an MSYS program it exits and the new process's Windows parent is gone. +- **The runtime also breaks away from jobs.** The same function adds `CREATE_BREAKAWAY_FROM_JOB` whenever `IsProcessInJob` + is true and the immediate job has `JOB_OBJECT_LIMIT_BREAKAWAY_OK` or `JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK` + ([lines 435-473](https://github.com/msys2/msys2-runtime/blob/3ea87a506e64e841dc0b8ee50ed61999c8fed26c/winsup/cygwin/spawn.cc#L435-L473)), + naming a Program Compatibility Assistant memory issue as the reason. `fork.cc` adds no such flag. So an MSYS **exec or + spawn** leaves any breakaway-permitting job, and a **fork** stays in the job. ## C2: A kill-on-close Job Object ### Every case from C1, under a job without breakaway -The same 15 cases were repeated, with a job holding only `KILL_ON_JOB_CLOSE`. The job was assigned to the root 0.04 to -0.16 ms after `spawn()` returned. At the stop time the driver called `CloseHandle(job)` and did not call -`TerminateJobObject`. +The C1 cases — the original 15 and the new MSYS paths — were repeated with a job holding only `KILL_ON_JOB_CLOSE`, +assigned to the root 0.04 to 0.16 ms after `spawn()` returned. At the stop the driver called `CloseHandle(job)` (and, for +a few cases, `TerminateJobObject` first). -```text -=== msys-sleep: sleep 25.8 [mode=job] - 8884 <- 2944 (parent DEAD) sleep.exe inAnyJob=true inOurJob=true :: "…\Git\usr\bin\sleep.exe" 25.8 - job pid list: [19148,19660,9412,8884] - CloseHandle(job) -> 1 - survivors: none -=== script-dot-slash: ./slow258.sh [mode=job] - 20068 <- 3440 (parent DEAD) bash.exe inAnyJob=true inOurJob=true :: "…\Git\usr\bin\bash.exe" ./slow258.sh - job pid list: [4368,9800,12188,20068,16076,3656] - survivors: none -``` - -- **Nothing survived.** Every escaped process was in the job, and closing its last handle killed every case (15 of 15). - `TerminateJobObject` followed by `CloseHandle` did the same for the direct-`ping`, `sleep`, and `./slow258.sh` cases. +- **Nothing survived.** Every escaped process — every MSYS leaf, every `xargs`/`find`/`npm`/`npx`/`bun run` stage, every + backgrounded `&`/`nohup`/`disown` leaf, the `tee` stage — was a member of the job, and closing its last handle killed + every case (three runs each for the new paths, 15/15 in the first pass, 0 survivors throughout). - **Job membership does not depend on parent links.** A child joins its creator's job at creation, whether or not its creator is still alive (**Doc**, - [`AssignProcessToJobObject`](https://learn.microsoft.com/en-us/windows/win32/api/jobapi2/nf-jobapi2-assignprocesstojobobject): - "all child processes … are associated with that job by default"). + [`AssignProcessToJobObject`](https://learn.microsoft.com/en-us/windows/win32/api/jobapi2/nf-jobapi2-assignprocesstojobobject)). ### The same cases under a job that allows breakaway -| Job limit flags | `sleep 25.8` | `./slow258.sh` | `ping` (native) | +| Job limit flags | `sleep` | `./slow258.sh` | `ping` (native) | | ------------------------------------------ | -------------------------------------- | --------------------------------------------- | ---------------------------------- | | `KILL_ON_JOB_CLOSE` | in job; killed | in job; killed | in job; killed | | `KILL_ON_JOB_CLOSE \| BREAKAWAY_OK` | **not in job; survived** | **not in job; script, fork, `PING` survived** | **`PING` not in job; survived** | | `KILL_ON_JOB_CLOSE \| SILENT_BREAKAWAY_OK` | only the launcher in job; **survived** | only the launcher in job; **survived** | only the launcher in job; survived | -One run each. - -- **With `BREAKAWAY_OK`, MSYS children left the job.** The job held the launcher, its console host, and `usr\bin\bash`. - Every process that an MSYS process spawned or exec'd left it, including a native `PING`. That is the - `CREATE_BREAKAWAY_FROM_JOB` path in `spawn.cc`. -- **With `SILENT_BREAKAWAY_OK`, only the assigned root is in the job.** Every child is outside it by definition. - **Doc**: - [`JOBOBJECT_BASIC_LIMIT_INFORMATION`](https://learn.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-jobobject_basic_limit_information), - "Allows any process associated with the job to create child processes that are not associated with the job". - -### Claude Code under the job (2 runs) - -`claude.exe` was spawned with the #255 flags (`-p --input-format stream-json --output-format stream-json --verbose --model -haiku`), with `--allowedTools "Bash(./slow258.sh)"`, and the job was assigned right after `spawn()`. The prompt asked for -`./slow258.sh` in the foreground. That script writes a start marker, runs `ping -n 28`, then writes an end marker. - -```text -[ 6171] tool_use Bash {"command":"./slow258.sh",…} -[ 10971] ps mid-tool: - 6644 claude.exe inOurJob=true - 12420 <- 6644 conhost.exe inOurJob=false - 2576 <- 6644 Git\bin\bash.exe -c "source …" inOurJob=true - 20376 <- 2576 Git\usr\bin\bash.exe -c "source …" inOurJob=true - 8556 <- 15524 (parent DEAD) bash.exe ./slow258.sh inOurJob=true - 6008 <- 8556 bash.exe ./slow258.sh inOurJob=true - 17776 <- 6008 PING.EXE inOurJob=true -[ 10971] job pids [2576,20376,20340,8556,6008,17776,6644] -[ 10972] TerminateJobObject -> 1 -[ 11028] claude EXIT 1 -[ 13158] ps 1.5 s after the stop: (none left) -[ 42159] markers started/done: true false -``` +- **With `BREAKAWAY_OK`, MSYS children left the job** via the `CREATE_BREAKAWAY_FROM_JOB` path in `spawn.cc`. **With + `SILENT_BREAKAWAY_OK`, only the assigned root is in the job.** A process that requests breakaway from a job that forbids + it does not escape: `CreateProcess` returns `ERROR_ACCESS_DENIED` (5) — see C7. -- **The whole tree was killed.** Run 2 matched: the same dead-parent script, all of the tree in the job, nothing left, - and no end marker. -- **Some processes were not in the job but still went.** `claude.exe`'s own `conhost.exe`, started by the system for a - console process, was not a member. It was gone once its clients were. -- **Nesting worked.** `claude.exe` was already in the driver's libuv job (see C3) when the assignment succeeded. **Doc** - (same page): a process can be in more than one job "starting in Windows 8 and Windows Server 2012", and the new job - "must be empty or it must be in the hierarchy of nested jobs to which the process already belongs". +### `CLAUDE_CODE_GIT_BASH_PATH` and a different bash -### What a tool command sees from inside +Pointing `CLAUDE_CODE_GIT_BASH_PATH` at Git's `usr\bin\bash.exe` instead of the default `bin\bash.exe` did not change the +escape: the Bash-tool script still forks-and-execs and survived both `taskkill /T /F` and the raw interrupt (script ran to +completion 3/3), and a Secant no-breakaway job still contained it (`TerminateJobObject` left no done marker). Running +`usr\bin\bash.exe` directly as the tool-shell root (with Git's `usr\bin` prepended to `PATH`, which the `bin\bash.exe` +launcher normally supplies) reproduced the same escape and the same job containment (three runs each, taskkill and job). +WSL bash is not installed on this machine (`wsl --status`: "not installed"), so a non-MSYS bash could not be compared, and +only Claude Code 2.1.283 is installed. -`bun probe-child.ts` reports the caller's own job flags and member pids. It was run through Claude Code's Bash tool and -PowerShell tool (1 run each): +### The job chain above a tool command -| Launch | Bash-tool command's own job | PowerShell-tool command's own job | -| ----------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------- | -| `claude.exe`, no Secant job | `BREAKAWAY_OK \| SILENT_BREAKAWAY_OK`, ~70 unrelated members | `BREAKAWAY_OK`, the agent shell's host job | -| `claude.exe` in a `KILL_ON_JOB_CLOSE` job | `KILL_ON_JOB_CLOSE`; members include `claude.exe` and the tool shells | `KILL_ON_JOB_CLOSE`; members include `claude.exe` | +With no Secant job, a Claude Code Bash-tool shell sits in Claude Code's libuv job +`KILL_ON_JOB_CLOSE | BREAKAWAY_OK | SILENT_BREAKAWAY_OK | DIE_ON_UNHANDLED_EXCEPTION` owned by `claude.exe`, which nests +under the outer host jobs; the escaped script itself, having broken away, is in no named job (only the anonymous +breakaway-target job the Method describes). With a Secant `KILL_ON_JOB_CLOSE` job assigned to `claude.exe`, the tool +shell and every descendant report membership in that job, and the tool command's own immediate job becomes the Secant job +(the probe read `jobFlags = KILL_ON_JOB_CLOSE`, ~5-6 members): breakaway climbs the chain until it reaches the +no-breakaway job and stops (**Doc**, +[Nested Jobs](https://learn.microsoft.com/en-us/windows/win32/procthread/nested-jobs)). Codex's command sits in its own +per-command job owned by `codex.exe` (`KILL_ON_JOB_CLOSE | BREAKAWAY_OK`), attached at creation for a PTY and after spawn +for pipes. -- **No job of Claude Code's own around tool commands.** Without a Secant job, neither command was in a job of Claude - Code's. The Bash-tool command had also left its host's job and landed in one with about 70 unrelated members, the same - job that a process started through WMI lands in. -- **Why the Bash-tool command could leave. Doc**: - [Nested Jobs](https://learn.microsoft.com/en-us/windows/win32/procthread/nested-jobs): "If the immediate job object - allows breakaway, the child process breaks away from the immediate job object and from each job in the parent job - chain, moving up the hierarchy until it reaches a job that does not permit breakaway." Under a job without breakaway, - both commands stayed in it. - -## C3: How Bun can create the job +## C3: How Bun can create the job, and the assign-after-spawn window ### What Bun already does -- **Bun puts its own children in a job.** Every non-detached child of `node:child_process` `spawn`/`spawnSync` and - `Bun.spawn`/`Bun.spawnSync` reported the job flags `0x3c00` (`KILL_ON_JOB_CLOSE | BREAKAWAY_OK | SILENT_BREAKAWAY_OK | -DIE_ON_UNHANDLED_EXCEPTION`), with the Bun parent and itself as members. A `detached: true` child reported only the - parent's outer job. -- **That is libuv's global job** (**Source-observed**, `oven-sh/libuv@8023581` `src/win/process.c`): - - `uv__init_global_job_handle` sets exactly those four flags (lines 93-96). It then adds the calling process to the job - (line 109). - - The comment says `SILENT_BREAKAWAY_OK` is set "so only the processes that we explicitly add are affected, and _their_ - subprocesses are not" (line 77). - - Every non-detached child is assigned after `CreateProcessW` (lines 1128-1130). Only a detached child gets - `CREATE_SUSPENDED` (lines 1106-1107). -- **So libuv's job does not contain the tree.** It kills only the direct child (`claude.exe`, `codex.exe`) when Secant - exits. Its `BREAKAWAY_OK` would also let MSYS children leave it. - -### What Bun does not offer - -- **No job or suspended-spawn option.** Bun's `SpawnOptions` at `bun-v1.4.2` list `windowsHide`, - `windowsVerbatimArguments`, `detached` (through `node:child_process`), `cgroup` ("Linux only"), and `terminal` (ConPTY - on Windows) (**Doc**, - [`docs/runtime/child-process.mdx`](https://github.com/oven-sh/bun/blob/744846f844374847c902b5e7fd59b4342a51ef99/docs/runtime/child-process.mdx)). - There is no `CREATE_SUSPENDED` flag and no Windows job option. -- **The assign-after-spawn window.** A Bun spawn therefore cannot put a child in a job before it runs. Assigning after - `spawn()` returns leaves the gap Microsoft describes: a process created before the assignment is not in the job. - - Microsoft's remedy is `CREATE_SUSPENDED` (**Doc**, `AssignProcessToJobObject`), or `PROC_THREAD_ATTRIBUTE_JOB_LIST` - at `CreateProcess`. That attribute takes "a list of job handles to be assigned to the child process" and needs - Windows 10 or later (**Doc**, - [`UpdateProcThreadAttribute`](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute)). - Neither is reachable through Bun's spawn. - - Here the assignment came 0.04 to 0.16 ms after `spawn()` returned. The first grandchild of `claude.exe` (its tool - shell) appeared seconds later. - - **Inferred**: a Harness that spawns a descendant within its first fraction of a millisecond is the case the window - exposes. Neither Harness's start-up was timed at that resolution. +- Every non-detached child of `node:child_process` / `Bun.spawn` is assigned to libuv's global job + (`KILL_ON_JOB_CLOSE | BREAKAWAY_OK | SILENT_BREAKAWAY_OK | DIE_ON_UNHANDLED_EXCEPTION`) after `CreateProcessW` + (**Source-observed**, `oven-sh/libuv@8023581` `src/win/process.c` lines 93-96, 1128-1130). Only a detached child gets + `CREATE_SUSPENDED` (lines 1106-1107). So libuv's job does not contain the tree: its `BREAKAWAY_OK` lets MSYS children + leave, and it kills only the direct child when Secant exits. + +### The window, measured + +Assigning after `spawn()` returns leaves a window Microsoft describes. Measured over 20 launches each of the real +Harnesses spawned as `src/process/process.ts` does: + +- `claude.exe`: `spawn()` → `AssignProcessToJobObject` returned in 62–224 µs; the child was created 6.8–11.3 ms before the + assignment (Bun spawns, then we assign). **0 of 20** runs created a non-`conhost` descendant before the assignment, and + **0 of 20** left a non-`conhost` descendant outside the job. The first real child (`reg.exe`) appeared 42–82 ms after + the assignment; Git/Bash tool children hundreds of ms later; nested `claude.exe` subagents ~1 s. +- `codex.exe`: assignment in 56–160 µs; only its system `conhost` appeared early, never before the assignment (0/20). + +The only early sibling is the system-created `conhost.exe`, which is not a job member by design and disappears with its +clients. **Inferred**: on this machine the window is real but never populated, because both Harnesses' first descendant is +tens of milliseconds out; a Harness that forked a descendant within the first ~0.2 ms would be the exposing case, and +neither does. + +### A contained spawn with streamed stdio + +A scratch prototype, `spawnContained(commandLine, cwd)`, made these calls in this order: + +1. **Job.** `CreateJobObjectW`, then `SetInformationJobObject` with only `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` and no + breakaway flag. +2. **Stdio.** For each of stdin, stdout, and stderr, `node:net` `createServer().listen("\\.\pipe\secant--")`. + Then `CreateFileW` on that name opens an inheritable client handle. Stdin gets `GENERIC_READ | FILE_WRITE_ATTRIBUTES`, + and stdout and stderr get `GENERIC_WRITE | FILE_READ_ATTRIBUTES`, which are the access rights libuv gives a child's + pipes. The server accepts that one connection and closes, so nothing else can connect. The accepted `net.Socket` is the + stream Secant reads or writes. +3. **Attribute list.** `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` names exactly the three client handles, so the child inherits + nothing else, although `bInheritHandles` must be `TRUE` (**Doc**). `PROC_THREAD_ATTRIBUTE_JOB_LIST` names the job, + which the documentation supports from Windows 10 and Windows Server 2016 (**Doc**, + [UpdateProcThreadAttribute](https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-updateprocthreadattribute)). +4. **Create.** `CreateProcessW` with `EXTENDED_STARTUPINFO_PRESENT | CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW` and + `STARTF_USESTDHANDLES`. Afterwards the parent closes its copies of the client handles and the thread handle. +5. **Stop.** `TerminateJobObject` kills the tree. If Secant dies, the OS closes the job handle and kill-on-close does the + same. + +Results: + +| Case | Result | +| ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Job membership at creation | `IsProcessInJob(child, job)` was `true` right after `CreateProcessW` returned, in every run. | +| Round trip (`bun -e` echoing stdin, one stderr line, `exit 7`) | Both stdin lines echoed, stderr arrived separately, `socket.end()` gave the child EOF, and the exit code read 7. A 10 ms interval kept ticking the whole time, so the event loop was not blocked. | +| Throughput (child writes 64 MiB with backpressure) | All 67,108,864 bytes, in 70–86 ms (3 of 3). | +| Git Bash fork-and-exec (`bash.exe -c "./t2.sh; …"`, the script pings, then writes a marker) | The script's bash had a dead parent pid, as in C1. `TerminateJobObject` left no bash or `ping`, and the marker was never written (3 of 3). | +| Parent crash (`process.exit(3)` with the same tree running, no cleanup) | Nine seconds later both script pids were gone, no `ping` was left, and there was no marker (3 of 3). | +| Live Claude Code (`claude -p` with the #255 stream-json flags, `--model haiku`, `--allowedTools Bash`) | `system/init` and the assistant frames arrived over the bridged stdout. The Bash tool ran a Git Bash script (four `bash.exe` and a `PING.EXE`). `TerminateJobObject` ended `claude.exe` and all of them, and the marker was never written (3 of 3). | +| Live Codex (`codex app-server`, `initialize`, `thread/start { cwd }` only, `turn/start`) | The handshake, thread, and Turn ran over the bridged pipes. The sandboxed `pwsh` and `ping` started, so Codex's own sandbox job nested under Secant's. `TerminateJobObject` ended `codex.exe`, `pwsh`, and `ping` (3 of 3). | +| Anonymous pipes (`CreatePipe`) wrapped with `ucrtbase` `_open_osfhandle` and `fs` streams | Failed. The CRT fds are not in Bun's fd table, and Bun's `close` returned `EBADF`. | + +What the prototype does not cover, and what Bun's spawn does today that it would have to replace (**Inferred** from the +prototype's shape, not measured): + +- **Command line.** It takes one Windows command-line string. Bun builds it from an argv with the `CommandLineToArgvW` + quoting rules and finds the executable on PATH. The prototype relied on `CreateProcessW`'s own search, and a `.cmd` + shim needs `cmd.exe`. +- **Environment.** It passes `NULL`, so the child inherits Secant's environment block. A custom `env` needs a UTF-16, + sorted, double-NUL-terminated block. +- **Exit wait.** It polls `WaitForSingleObject(h, 0)` every 20 ms. A production version would want + `RegisterWaitForSingleObject` or a wait worker. +- **Process Interface.** `pid`, `exited`, stdio and a tree kill map onto today's owned process. `escalated`, the shared + shutdown bound, and signal delivery to a process group on POSIX were not wired, and POSIX keeps Bun's spawn. ### Routes -| Route | What it is | Observed or documented facts | -| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Bun built-ins only | `node:child_process` / `Bun.spawn` | Cannot create a job. The only job is libuv's, which has breakaway and silent breakaway. | -| `bun:ffi` to `kernel32.dll`, after spawn | `CreateJobObjectW`, `SetInformationJobObject` (`JobObjectExtendedLimitInformation`, 144-byte struct on x64, `LimitFlags` at offset 16), `OpenProcess`, `AssignProcessToJobObject`, `TerminateJobObject`, `CloseHandle` | All C2 results used this route. It also worked in a `bun build --compile` executable: a Git Bash `sleep 25.8; true` tree of 4 processes was assigned, and `TerminateJobObject` left none. Bun's doc says "`bun:ffi` is **experimental**, with known bugs and limitations. Do not rely on it in production" ([`docs/runtime/ffi.mdx` line 7](https://github.com/oven-sh/bun/blob/744846f844374847c902b5e7fd59b4342a51ef99/docs/runtime/ffi.mdx#L7)). | -| `bun:ffi`, own `CreateProcessW` | Spawn with `PROC_THREAD_ATTRIBUTE_JOB_LIST` or `CREATE_SUSPENDED`, as Codex does in Rust | Closes the window. It gives up Bun's spawn: stdio pipes and exit observation would be rebuilt over FFI. Not tried. | -| npm package | — | The npm registry search for "windows job object kill" returned no general-purpose Job Object package. `tree-kill` 1.2.2 is `taskkill /T /F` on Windows, which C1 shows missing these processes. `koffi` 3.3.2 is a general FFI with a native install step (`install: node ./cnoke.cjs … --prebuild`). | - -Measured against [dependency discipline](../agents/dependencies.md) (facts only; #259 decides): - -- **Built-in first.** `bun:ffi` is a built-in. Secant already uses it once: `src/tui/renderer/conhost-notice.ts` calls - `kernel32` `GetConsoleWindow`. That file is the only entry allowlisted for `bun:ffi` in - `tests/architecture/check-vendor-provenance.ts` (lines 42-62), and the runtime-neutrality allowlist grants each API per - file. A Job Object route in `src/process/` would need its own entry. -- **Native dependencies.** `koffi` is a native dependency, which the policy says "needs a prior issue decision". -- **OpenCode's choice.** OpenCode also calls `kernel32` through `bun:ffi`, in - [`packages/tui/src/terminal-win32.ts`](https://github.com/anomalyco/opencode/blob/b3f1a96c6dd7adeb28b36dd11add1998fc84d67b/packages/tui/src/terminal-win32.ts) - for console modes. For process trees it uses `taskkill` and no FFI (C5). -- **Growth rule.** The Job Object surface used here is six kernel32 calls and one fixed struct layout. Nothing measured - here says whether it would grow. - -## C4: Codex `turn/interrupt` against a running shell command - -### Setup - -- **Launch.** The driver spawned `codex.exe app-server` with the #255 handshake. It stripped `CLAUDE*`, `ORCA_*`, and - `CODEX_HOME`, so the user's home `C:\Users\rg\.codex` was used. -- **Deviation from Secant.** `thread/start` set `approvalPolicy: "never"` and `sandbox: "danger-full-access"`, so that no - approval could stall the command. Secant leaves both unset. The user's config sets `[windows] sandbox = "unelevated"`, - and the sandboxed path was not run (see Still Unknown). -- **Turns.** Each `turn/start` used `gpt-6-luna` with `effort: "low"`, and asked for one command, run in the foreground. - Codex ran each through `pwsh.exe -Command …`. -- **Stop.** The driver sent `turn/interrupt` 5 s into the command. It then snapshotted the tree, optionally sent - `thread/backgroundTerminals/clean` (which needs `experimentalApi: true` at `initialize`), and closed stdin. +| Route | What it is | Facts | +| ---------------------------------------- | --------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Bun built-ins only | `node:child_process` / `Bun.spawn` | Cannot create a job. The only job is libuv's, which has breakaway and silent breakaway. | +| `bun:ffi` to `kernel32.dll`, after spawn | `CreateJobObjectW`, `SetInformationJobObject`, `OpenProcess`, `AssignProcessToJobObject`, terminate | All C2 results used this route; it also works in a `bun build --compile` executable. `bun:ffi` is documented experimental. The measured window never exposed a descendant. | +| `bun:ffi`, own `CreateProcessW` | `CREATE_SUSPENDED` + assign + `ResumeThread`, or `PROC_THREAD_ATTRIBUTE_JOB_LIST`, as Codex does | Closes the window: the child is in the job from creation. Stdio stays streamed through `node:net` named-pipe servers (see below); live Claude Code and Codex contained 3/3. | +| npm package | — | No general-purpose Job Object package; `tree-kill` is `taskkill /T /F` (which C1 shows missing these); `koffi` is a native dependency. | + +Measured against [dependency discipline](../agents/dependencies.md) (facts only; #259 decides): `bun:ffi` is a built-in +Secant already uses once (`src/tui/renderer/conhost-notice.ts`, the sole allowlisted entry in +`tests/architecture/check-vendor-provenance.ts`); a Job Object route in `src/process/` would need its own allowlist entry. +The after-spawn Job Object surface is six kernel32 calls and one fixed struct. The contained spawn below adds +`CreateProcessW`, `CreateFileW`, the three attribute-list calls, `SetHandleInformation`, and the `STARTUPINFOEXW` and +`PROCESS_INFORMATION` layouts. It also takes over what Bun's spawn does today (see its gaps). + +## C4: Codex `turn/interrupt`, the Windows sandbox, and the PTY ### Codex's own job -The probe run through Codex's shell tool reported -`jobFlags=0x2800 [KILL_ON_JOB_CLOSE,BREAKAWAY_OK] jobPids=[,]`. That matches **Source-observed** -[`codex-rs/utils/pty/src/win/job.rs` line 52](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/win/job.rs#L52), -`JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK`. +The probe through Codex's shell tool reported `jobFlags = KILL_ON_JOB_CLOSE | BREAKAWAY_OK` with `pwsh` and the probe as +members, matching **Source-observed** +[`codex-rs/utils/pty/src/win/job.rs` line 52](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/win/job.rs#L52). + +### The interrupt and the two later stops -### Runs +`turn/interrupt` answered `{}`, `turn/completed` `status: "interrupted"` followed within 1 ms, and the command tree was +untouched — 5/5 in the first pass, and again in every follow-up run. The source explains it: `exec_command` stores the +live process before the first wait ("Persist live sessions before the initial yield wait so interrupting the turn cannot +drop the last Arc", +[`core/src/unified_exec/process_manager.rs` line 570](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/core/src/unified_exec/process_manager.rs#L570)); +the process is ended only by `terminate_all_processes`, reached through `thread/backgroundTerminals/clean` or shutdown. The +job's `terminate()` is a no-op once `preserve_descendants()` has cleared `KILL_ON_JOB_CLOSE`, which the pipe path calls +when the root exits ([`pipe.rs` line 285](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/pipe.rs#L285)). +The pipe path assigns after spawn with the comment "Accept the small race" ([line 195](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/pipe.rs#L195)); +the PTY path attaches the job at creation with `PROC_THREAD_ATTRIBUTE_JOB_LIST` +([`win/psuedocon.rs` line 177](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/win/psuedocon.rs#L177)). -| Command (under `pwsh`) | Mode | After `turn/interrupt` (app-server alive) | After `backgroundTerminals/clean` | After app-server exit | -| ---------------------------------------------------------------- | ---- | ------------------------------------------------ | ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------- | -| `cmd /c "ping -n 30 … && echo done > marker"` | pipe | `pwsh`, `cmd`, `PING` running | not sent | exit 0 at once; tree gone; no marker | -| `& Git\bin\bash.exe -c './slow258.sh'` (lone command, exec) | PTY | whole tree running; parents all live | whole tree gone | exit 0; nothing left | -| `& Git\bin\bash.exe -c './slow258.sh; true'` (fork and exec) | pipe | whole tree running; script has a **dead parent** | `pwsh`, launcher, and bash gone; **script, fork, `PING` survived** | app-server still running 8 s after stdin closed, so the driver ran `taskkill /T /F`; script survived and **ran to completion** (marker written) | -| same | pipe | whole tree running; script has a dead parent | not sent | same as above: app-server still running 8 s after stdin closed; script survived and ran to completion | -| same | PTY | whole tree running; script has a dead parent | not sent | exit 0; nothing left, including the script | -| same, with `codex.exe` in a Secant-style `KILL_ON_JOB_CLOSE` job | pipe | whole tree running; script has a dead parent | not sent | the driver called `TerminateJobObject` on its own job: everything gone, no marker; app-server exit 1 | +### The Windows sandbox (the real Secant settings) -"Mode" is PTY when a headless `conhost.exe --headless` child of `codex.exe` was present, and pipe otherwise. The -model picked the mode; a hint in the prompt was used for the last three rows. +Driven with `thread/start { cwd }` only, the acknowledged policy is `approvalPolicy: "on-request"` and, for a `cwd` the +user's config trusts, `sandbox: workspaceWrite` (an untrusted `cwd` acknowledges `readOnly`). A sandboxed native command +runs under a restricted token as the same user at medium integrity, in Codex's per-command job +`KILL_ON_JOB_CLOSE | BREAKAWAY_OK` owned by `codex.exe`: ```text -[ 16088] turn/interrupt -> {} -[ 16089] <- turn/completed {"turn":{…,"status":"interrupted",…}} -[ 19303] ps 2.5 s after interrupt (app-server alive): - 11268 <- 18776 pwsh.exe … - 9032 <- 11268 cmd.exe /c "ping -n 30 127.0.0.58 && echo done > done258.marker" - 16628 <- 9032 PING.EXE +pwsh.exe inSecantJob=true restricted job KILL_ON_JOB_CLOSE|BREAKAWAY_OK n=3 owner=codex.exe +cmd.exe inSecantJob=true restricted /c "ping -n 30 127.0.0.58 && echo done > done258.marker" +PING.EXE inSecantJob=true restricted ``` -- **The interrupt leaves the command running.** `turn/interrupt` answered `{}`, and `turn/completed` - `status: "interrupted"` followed within 1 ms. The command tree was untouched in every run (5 of 5; the probe run was - not interrupted). -- **The source says why** (**Source-observed**, at `rust-v0.155.0`): - - `exec_command` is the unified-exec tool. Its manager stores the live process before the first wait: "Persist live - sessions before the initial yield wait so interrupting the turn cannot drop the last Arc and terminate the background - process" - ([`core/src/unified_exec/process_manager.rs` line 570](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/core/src/unified_exec/process_manager.rs#L570)). - - The interrupt aborts the Turn's task - ([`core/src/tasks/mod.rs` lines 901-939](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/core/src/tasks/mod.rs#L901-L939)). - - The process is ended by `terminate_all_processes` - ([line 1680](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/core/src/unified_exec/process_manager.rs#L1680)). - That is reached through `thread/backgroundTerminals/clean`, which is marked `#[experimental(…)]` - ([`app-server-protocol/src/protocol/common.rs` line 738](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/app-server-protocol/src/protocol/common.rs#L738)), - or by shutdown. -- **How Codex kills a command on Windows** (**Source-observed**): - - The job's `terminate()` calls `TerminateJobObject` - ([`job.rs` lines 208-218](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/win/job.rs#L208-L218)). - - It is a no-op once `preserve_descendants()` - ([line 193](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/win/job.rs#L193)) - has cleared `KILL_ON_JOB_CLOSE`. The pipe path calls that when the root exits - ([`pipe.rs` line 285](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/pipe.rs#L285)). - - **Inferred**: descendants of a command whose root exited on its own are left running by design. -- **The pipe path assigns after spawn.** It creates the job - ([`pipe.rs` line 191](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/pipe.rs#L191)), - spawns normally, then assigns, with the comment "Accept the small race: a descendant created between spawn and assignment - is not guaranteed to join the job and can escape termination" - ([line 195](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/pipe.rs#L195)). -- **The PTY path attaches the job at creation.** It creates the job - ([`win/psuedocon.rs` line 167](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/win/psuedocon.rs#L167)) - and passes it as `PROC_THREAD_ATTRIBUTE_JOB_LIST` - ([line 177](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/win/psuedocon.rs#L177); - [`win/procthreadattr.rs` lines 32 and 87](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/utils/pty/src/win/procthreadattr.rs#L87)). - If the attribute fails, it "fail[s] the spawn rather than briefly run" uncontained. -- **Why the pipe-mode script survived.** Codex's job allows breakaway, so the MSYS runtime spawned the script with - `CREATE_BREAKAWAY_FROM_JOB` (C1). The job's termination then missed it, as the `BREAKAWAY_OK` row in C2 did. - - **Inferred**: under a PTY, the script died when the pseudoconsole closed, not through the job. It was not checked - whether it was a job member there. - - **Inferred**: under an outer job without breakaway, breakaway stops at that job, as the Nested Jobs doc quoted in C2 - says. So `TerminateJobObject` on the outer job reached the script. -- The app-server's failure to exit on stdin close while the escaped script ran (2 of 2 pipe runs) was not investigated. +- `turn/interrupt` left the sandboxed tree running (3/3); `thread/backgroundTerminals/clean` and the app-server's exit + stopped it (3/3). +- Under an outer Secant no-breakaway job, every sandboxed process reported `IsProcessInJob(secantJob) = true` and + `TerminateJobObject` on the outer job killed the whole tree, no marker (3/3). `AssignProcessToJobObject` and nesting + succeed despite the restricted token because it is the same user. +- **Git Bash cannot run under the `workspace-write` sandbox**: `bash.exe: *** fatal error - couldn't create signal pipe, +Win32 error 5` / `CreateFileMapping S-1-5-… Win32 error 5. Terminating.` The restricted token denies the shared section + the MSYS runtime needs, so the fork-and-exec escape does not arise under the sandbox; only native commands run there. An + escalation hint did not change this. + +The sandbox is **Source-observed** to build its own job and spawn through `CreateProcessAsUserW` with the job in a +`PROC_THREAD_ATTRIBUTE_JOB_LIST` (`windows-sandbox-rs/src/process.rs`, `conpty/mod.rs`), so Codex's containment does not +depend on the token. + +### PTY mode: job versus console close + +Under a PTY, Codex's job held `pwsh`, the launcher `bash`, and `usr\bin\bash` (n=3); the dead-parent script and its `PING` +broke away and were **not** members. + +- Killing only the headless `conhost.exe` pseudoconsole host: the escaped script and `PING` **survived** (3/3). +- `TerminateJobObject` on Codex's job (killing `pwsh`, the pseudoconsole owner): the escaped script and `PING` **died** + (3/3). + +So the PTY-mode kill of the escaped tree is the pseudoconsole closing when `pwsh` dies, not the job reaching a member and +not the `conhost` process dying. The app-server's exit ends `pwsh` the same way. + +### Why the app-server stalled after stdin close + +In pipe mode the escaped script inherits the command's stdout/stderr write handles; the app-server's reader awaits the +read tasks, which loop until `read()` returns `Ok(0)` (**Source-observed**, `pipe.rs` 109-122 and 261-268). With the +script holding the pipe open, EOF never comes: the app-server exited 21.0–21.1 s after stdin close — exactly when the +30-ping script finished — and the script ran to completion (3/3). A script that first redirects its own stdio to +`/dev/null` releases the pipe, and the app-server exited in 58–89 ms while the script kept running detached (3/3). ## C5: What the guides do on Windows -| Tool | Windows tree stop | Job Object | -| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Codex 0.155.0 | `TerminateJobObject` on a per-command job (C4). `taskkill /T /F` is a fallback, used for hooks when no job exists and for the exec-server's stdio tree (**Source-observed**, subagent read of `hooks/src/engine/command_runner.rs` and `exec-server/src/connection.rs` at `c248f6d`, not re-checked at the release tag). Its binary imports `CreateJobObjectW`, `AssignProcessToJobObject`, `SetInformationJobObject`, `TerminateJobObject`, and `NtResumeProcess` (**Binary-observed**). | Yes: `KILL_ON_JOB_CLOSE \| BREAKAWAY_OK`. Attached at creation for a PTY, after spawn for pipes. | -| Claude Code 2.1.283 | `taskkill.exe /PID /T /F`, with `process.kill(pid)` as the fallback (**Binary-observed**: the embedded JS spawns `%SYSTEMROOT%\System32\taskkill.exe` with `["/PID",pid,"/T","/F"]` and logs `killProcessTree … failed`). #255 saw it run against the tool shell on interrupt. The strings put `CreateJobObjectW` / `KILL_ON_JOB_CLOSE` only in a Rust Windows-sandbox helper and in the embedded Bun runtime's imports. C2's probe found no Claude-owned job around tool commands. | Not for tool processes | -| OpenCode `b3f1a96` | The shell tool never detaches on Windows: `detached: false` for PowerShell, `detached: process.platform !== "win32"` otherwise ([`packages/opencode/src/tool/shell.ts` lines 299 and 308](https://github.com/anomalyco/opencode/blob/b3f1a96c6dd7adeb28b36dd11add1998fc84d67b/packages/opencode/src/tool/shell.ts#L293-L309)). On abort or timeout it calls `handle.kill({ forceKillAfter: "3 seconds" })` ([lines 548-554](https://github.com/anomalyco/opencode/blob/b3f1a96c6dd7adeb28b36dd11add1998fc84d67b/packages/opencode/src/tool/shell.ts#L548-L554)). On win32 `killGroup` runs `taskkill /pid ${proc.pid} /T /F`, falling back to `proc.kill` ([`packages/core/src/cross-spawn-spawner.ts` lines 292-304, 314-322](https://github.com/anomalyco/opencode/blob/b3f1a96c6dd7adeb28b36dd11add1998fc84d67b/packages/core/src/cross-spawn-spawner.ts#L292-L322)). Its dependencies for this are `cross-spawn`, `@lydell/node-pty`, and `bun-pty` 0.4.8; there is no tree-kill or Job Object package. | No | -| T3 Code `d2c9281` | Harness processes run through Effect's `ChildProcessSpawner`. Its Node spawner runs `taskkill` for the tree on win32 ([vendored `.repos/effect-smol/packages/platform/node-shared/src/NodeChildProcessSpawner.ts` lines 95-104, 395, 418](https://github.com/pingdotgg/t3code/blob/d2c9281b8112dc3b2991642c4bdb985e4b08b9bb/.repos/effect-smol/packages/platform/node-shared/src/NodeChildProcessSpawner.ts#L95-L104)). For Codex, an interrupt is only the `turn/interrupt` RPC (`CodexSessionRuntime.ts` around line 2601), so the command tree is left to Codex. For Claude, `interruptTurn` settles the Turn and then always calls `stopSessionInternal`, which closes the SDK query ([`ClaudeAdapter.ts` lines 5286-5295](https://github.com/pingdotgg/t3code/blob/d2c9281b8112dc3b2991642c4bdb985e4b08b9bb/apps/server/src/provider/Layers/ClaudeAdapter.ts#L5286-L5295)); how the SDK kills on Windows was not read. | No | +| Tool | Windows tree stop | Job Object | +| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| Codex 0.155.0 | `TerminateJobObject` on a per-command job (C4); `taskkill /T /F` is a fallback. Its binary imports the Job Object APIs and `NtResumeProcess` (**Binary-observed**). Each MCP server and each sandboxed command get their own job. | Yes: `KILL_ON_JOB_CLOSE \| BREAKAWAY_OK`. Attached at creation for a PTY, after spawn for pipes. | +| Claude Code 2.1.283 | `C:\WINDOWS\System32\taskkill.exe /PID /T /F`, with `process.kill(pid)` as fallback (**Binary-observed**; C6 traces it live on interrupt). No Claude-owned job around tool commands (C2). | Not for tool processes | +| OpenCode `b3f1a96` | `detached: false` for PowerShell, `detached: process.platform !== "win32"` otherwise; abort/timeout runs `taskkill /pid … /T /F` (**Source-observed**). | No | +| T3 Code `d2c9281` | Harness processes run through Effect's Node spawner, which runs `taskkill` for the tree on win32 (**Source-observed**). For Codex an interrupt is only `turn/interrupt`; for Claude, `interruptTurn` closes the SDK query. | No | For T3 Code and OpenCode, **Inferred**: a `taskkill /T` tree stop meets the same dead-parent limit as C1 whenever the tree holds a Git Bash fork-and-exec process. +## C6: Claude Code's interrupt still kills the tool shell with `taskkill /T /F` + +The binary string that skips a kill when the abort reason is `"interrupt"` is the live-shell wrapper's own `kill()`, not +the process-tree kill: `#T(){let e=Ci(this.#i.reason);if(e==="interrupt"||wJt(e,this.#u))return;this.kill()}` +(**Binary-observed**). Traced live with a toolhelp watcher polling every ~5 ms: + +| Stop (mid Bash-tool run) | Claude-issued `taskkill /PID /T /F` | Escaped script | +| ----------------------------------------- | ------------------------------------------------ | ----------------------- | +| stream-json `control_request` `interrupt` | **2 per interrupt** (3/3 runs) | ran to completion (3/3) | +| SIGTERM of `claude.exe` | none (3/3) | ran to completion (3/3) | +| `taskkill /PID /F` (no `/T`) | none (3/3) | ran to completion (3/3) | +| stdin close | none (3/3) | ran to completion (3/3) | + +So the interrupt keeps the tree kill (two invocations, one per tool-shell layer, parented by `claude.exe`); the +`reason === "interrupt"` guard suppresses only the wrapper's direct `kill()`, and the tree kill it still issues is exactly +what the fork-and-exec escape defeats. The other three stops kill `claude.exe` before it can spawn `taskkill`, and the +escaped script outlives all of them. Under a Secant no-breakaway job, `TerminateJobObject` was the only stop that ended the +escaped script (done marker never written). + +## C7: What a Secant no-breakaway job does to deliberately-detached processes + +| Detached process | No Secant job | Under a Secant `KILL_ON_JOB_CLOSE` job | +| ---------------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| Claude Code background Bash | escapes tree; outlives `claude.exe`; completes (2/2) | member; job close kills it mid-run — start marker only, no done marker (2/2) | +| Claude Code stdio MCP server | direct child; its libuv job nests | member; a `CREATE_BREAKAWAY_FROM_JOB` child it spawns **succeeds but stays in the Secant job** (3/3) | +| Codex stdio MCP server | own no-breakaway job (`create_without_breakaway`) | member; a `CREATE_BREAKAWAY_FROM_JOB` child it spawns **fails, `ERROR_ACCESS_DENIED` (5)** (3/3) | +| Codex background terminal | escapes (C4) | member; `TerminateJobObject` on the outer job kills it (C4) | + +The important consequence: a process inside a no-breakaway job that asks for `CREATE_BREAKAWAY_FROM_JOB` gets +`ERROR_ACCESS_DENIED` (5) — observed for every Secant-job probe and for Codex's own no-breakaway MCP job. **A Harness that +must launch something detached would have that spawn fail under a Secant no-breakaway job.** Codex already tolerates it: +its stdio MCP launcher treats a rejected assignment as a fallback and re-spawns uncontained +([`rmcp-client/src/stdio_server_launcher.rs` lines 324-343](https://github.com/openai/codex/blob/f0a1b8f0849d90960bc406b848f32e5a129b0457/codex-rs/rmcp-client/src/stdio_server_launcher.rs#L324-L343)). +Claude Code's MCP servers and background Bash do not request breakaway, so the Secant job simply contains them, and its +close ends them. Secant's own planned loopback MCP is not built; as a direct child it would be a job member ended on +close like any other. + ## Still Unknown -- **Codex's sandboxed path.** The Windows sandbox (`[windows] sandbox = "unelevated"`, `workspace-write`) was not run. Its - source uses a job and `PROC_THREAD_ATTRIBUTE_JOB_LIST` (`windows-sandbox-rs/src/process.rs`, from the subagent read at - `c248f6d`, not re-checked at the release tag). Neither the job's breakaway flags nor whether `turn/interrupt` stops a - sandboxed command was checked. -- **The PTY-mode mechanism.** Whether Codex's PTY-mode kill of the Git Bash script comes from the job or from the - pseudoconsole closing, and whether the script was a job member there. -- **Nested breakaway at the root of the chain.** The Bash-tool probe in C2 left the host job for one with - `BREAKAWAY_OK | SILENT_BREAKAWAY_OK`, instead of leaving every job. The job chain above the agent shell was not mapped, - so which job stops the breakaway is not known. -- **The assign-after-spawn window.** How long it is in practice for `claude.exe` and `codex.exe`: whether either can - create a descendant before an assignment 0.1 ms after `spawn()` returns. Not measured at that resolution. -- **`PROC_THREAD_ATTRIBUTE_JOB_LIST` or `CREATE_SUSPENDED` from Bun.** Whether either can be reached through a - `bun:ffi` `CreateProcessW` while keeping Bun-managed stdio. Not tried. -- **Suspended-spawn route.** Whether libuv's detached spawn (`CREATE_SUSPENDED`, then an immediate resume) could be used. - Bun exposes no hook between the create and the resume. -- **Other MSYS paths.** A pipeline's MSYS stage (`| cat`), `xargs`, `find -exec`, `make`, and `npm`/`npx` scripts run - through `sh` were not captured. Each case in C1 ran once. -- **Other Claude Code versions and shells.** Whether a later Claude Code release, `CLAUDE_CODE_GIT_BASH_PATH`, or a - non-Git-for-Windows bash changes the escape. -- **The binary's abort listener.** The Claude Code strings include one that skips the kill when the abort reason is - `"interrupt"`. How that fits #255's observed `taskkill` on interrupt was not traced. -- **What Secant's own job would kill.** Whether a Job Object on a Harness process also kills processes that the Harness - deliberately detaches, such as background Bash tasks, MCP servers, or Codex background terminals, beyond what C2 and C4 - show. -- **Codex's stdin-close stall.** Why Codex's app-server stayed up for more than 8 s after stdin closed while an escaped - script held its pipes. +- **The contained spawn inside a compiled binary, and a Harness's `.cmd` shim.** The named-pipe prototype (C3) ran + under `bun` 1.4.2. It was not run from a `bun build --compile` executable or through `cmd.exe` for a shim. After-spawn + `bun:ffi` job calls did work in a compiled executable. +- **Other Claude Code releases and a non-MSYS bash.** Only Claude Code 2.1.283 is installed, and WSL is not installed + (`wsl --status`: "not installed"), so whether a later Claude Code release or a genuinely non-MSYS bash changes the escape + could not be tested here. `CLAUDE_CODE_GIT_BASH_PATH` pointed at Git's `usr\bin\bash.exe` did not change it (C2). +- **A job-free measurement baseline.** This machine has no job-free starting point — the host process is itself in a job, + and a process that breaks away lands in an anonymous job whose handle the handle-table walk cannot resolve to an owner — + so `IsProcessInJob(NULL)` is always `true` and membership in a named or owned job is what distinguishes cases. A clean + baseline would need a login session started outside any compatibility or service job.