Conversation
macOS cannot create a pipe, a socket pair or a pseudo-terminal close-on-exec atomically, and a spawn passes on every descriptor that is not close-on-exec at that instant. #79's baseline run on rust-v0.154.0 found pipe children holding other executions' PTY master and slave pairs (about 20-39% of pipe strays per case) and, twice, another execution's stdin pipe. A holder can delay an execution's completion, keep its terminal allocated, or read and write it. Every spawn the runner host makes without child-side exclusion now marks each descriptor above 2 close-on-exec in the child, before exec: pipe executions, the patch helper, the Linux sandbox probe and prepare helper, and the Gateway's UDS worker. On Linux this is one close_range(2) call; on macOS it lists the child's descriptors with proc_pidinfo into a buffer allocated before the fork; elsewhere, and as the fallback, it walks every descriptor number up to the limit. Standard descriptors are kept, and descriptors that are already close-on-exec (std's exec-error pipe) are left alone, so a failed exec is still reported as a spawn error. The PTY path already sweeps in portable-pty and is unchanged. No pin, dependency or execution-structure change; pipe spawns now take std's fork/exec path instead of posix_spawn. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
novelKR
marked this pull request as ready for review
October 1, 2026 07:03
This was referenced Oct 1, 2026
Replace the macOS descriptor listing and the walk bounded by RLIMIT_NOFILE. A descriptor can stay open above a soft limit lowered after it was opened, so the limit is no coverage bound. The child now walks every descriptor number below its own descriptor-table size: proc_pidinfo(PROC_PIDLISTFDS) without a buffer on macOS, which returns (fd_nfiles + 20) entries, and FDSize from /proc/self/status on Linux when close_range(CLOSE_RANGE_CLOEXEC) is refused. Every open descriptor indexes that table. Fail the spawn instead of continuing when the table size is unavailable or an open descriptor cannot be inspected or marked: only EBADF means a number is not open. Other platforms always fail this way. On Linux the fallback reads /proc/self/status through syscall() (openat, read, close) into a stack buffer, so the step makes no allocating or cancellation-point call after fork. Add tests that fail when each protection is removed: a descriptor above a lowered limit with the table walk forced (run in a fresh copy of the test binary), an unavailable table size failing the spawn before the child runs, the errno classification, and FDSize parsing. Run the descriptor-hygiene tests in the existing macOS CI stage (macos-core): the runner's descriptor and spawn-error tests and the server's worker-command test. The macOS leg's compiles list follows the crates those commands build, so changes to them select the leg. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue
Refs #79. This is the fix for the defect that #79's inventory found and its bounded run measured. It is an independent product defect fix, justified by CodeSpace's own correctness. It is not progress on CS-RG or on any DevGuard integration milestone.
Summary
The defect. macOS cannot create a pipe, a socket pair or a pseudo-terminal close-on-exec atomically, and a spawn passes on every descriptor that is not close-on-exec at that instant. A child spawned while another thread is between creating a descriptor and marking it therefore inherits it. #79's baseline run on
rust-v0.154.0(results) observed:What this guarantees. Every child that the runner host spawns without child-side exclusion keeps only its standard descriptors (0–2): it holds no other descriptor of the Gateway's or the worker's. That covers pipe executions, the patch helper, the Linux sandbox probe and
preparehelper, and the Gateway's UDS worker. Commands already get a cleared environment; now they also get no other descriptor.How. A pre-exec step marks every descriptor above 2 close-on-exec in the child (
crates/runner/src/descriptors.rs):close_range(3, ~0, CLOSE_RANGE_CLOEXEC)proc_pidinfo(PROC_PIDLISTFDS)into a buffer allocated before the fork, sized from the descriptor table plus a marginmin(RLIMIT_NOFILE, kernel limit)ProcessSpawnFailed).What does not change:
6b9826e,rust-v0.154.0), every dependency, andCargo.toml/Cargo.lock;env_clear(), stdio wiring, the working directory, andkill_on_drop.One mechanical difference: because a pre-exec step is installed, std spawns these children through fork/exec instead of
posix_spawn. The verification below reports the timing effect next to the baseline.Alternatives considered:
posix_spawnwithPOSIX_SPAWN_CLOEXEC_DEFAULTDescriptorPolicy::Explicitrust-v0.154.0; it would need a pin changeContract changes
None to tool schemas, error codes, status values or IDs. Behaviour change: a spawned command, the patch helper, the Linux helpers and the UDS worker no longer inherit any descriptor above 2 from the CodeSpace process.
Tests
New regression tests:
descriptors::tests).held held), proving detection.held clear).NotFound).process::tests::pipe_child_keeps_only_its_standard_descriptors). A real pipe execution throughInProcessRunner::execreportsheld clearfor a deliberately inheritable descriptor; fd 1 is the positive control.runtime::tests::worker_keeps_only_its_standard_descriptors). It exercises the exact command the Gateway uses to start the UDS worker.held held, which was checked locally on macOS.Behaviour preservation. These existing tests still pass:
missing_executable_is_process_spawn_failedandtty_missing_executable_is_process_spawn_failed(spawn errors);spawn_hello_then_drop_kills_worker, with the real worker binary;Run locally on macOS (Apple M1, macOS 27, Homebrew Rust 1.98.0):
cargo test -p codespace-runnerfs_watch12;isolation_files2;patch_helper1;uds_runner12cargo test -p codespace-server, withCODESPACE_RUNTIME_BINset to a builtcodespace-codex-runtimeapply,approvals,e2e,http_contract,inbox,operations,policy_contract,preflight,process,protocol_compat,read_find,recovery,rollback,security,stdio_contract,transport_contract)cargo clippy --all-targets -- -D warnings(root)cargo fmt --all --checkCI on this head (
8afb701): all green (9 passed, 3 skipped as planned).close_rangebranch cleanly under-D warnings.Rust / Integrationleg ran every new test, and each passed: the mechanism tests, including the unguarded control; the product-path test through the sandboxed pipe path; and the worker test. It also ranspawn_hello_then_drop_kills_workerand the spawn-error tests.ptyandfile-systemcrates, so the macOS path is verified by the local runs above and by the comparative run below, not by CI. Adding the runner to the macOS leg is a CI change this PR does not make.Comparative verification against the
rust-v0.154.0baseline: every expectation was met.How it ran:
6b9826eand Rust 1.95.0, as the baseline did, under a protocol frozen beforehand (SHA-256d1fe0bda…).ab0341b)8afb701)Notes:
Detection. The frozen analysis marks NC1 detection
inconclusive, which the protocol anticipated: excluding that file is the fix's purpose. Detection is established instead by the probe instrument check (12/12) and by the tests' unguarded controls.Timing has no pass mark. Tail delays at limit 64 differed in both directions:
The host was more loaded this time, with a load average of 6.5 against 2.7.
Evidence (DevGuard, local):
cs79-verify-2026-10-01,MANIFEST.jsonSHA-256ea3e1e43…. The baseline iscs79-2026-09-30,10beb5fd….Security scenarios
Out of scope
🤖 Generated with Claude Code