fix(sandbox): screen pinned-spawn PATH by directory identity, not lexically - #7114
Conversation
…ically The pinned-spawn PATH screen kept any absolute entry, so an absolute entry that resolves inside the descriptor-bound workspace survived the screen the pin exists to enforce. Screen surviving absolute entries by (st_dev, st_ino) ancestry against the bound descriptor's identity: an entry that is the pinned directory or lives beneath it -- by any spelling, symlink aliases included -- is dropped from the resolve search and from the child's environment alike. The identity walk opens PATH entries, so it shares one worker-thread hop with the resolve instead of running on the event loop; an unreadable bound descriptor degrades deliberately to the lexical absolute-only screen, and a PATH entry that cannot be opened is dropped, fail-closed per entry. Fixes #7095
Design Review (Fable 5) — ✅ PASSDesign-level review of Design-Verdict: PASS A named boundary (agent-writable workspace vs. the sandbox spawn path) enforced at the one lever that controls the child's later lookup — proportionate, fail-closed, degrade pinned. The screen is aimed at the right mechanism: the wrapper's own in-child PATH lookup after [DESIGN-REVIEWED] c8f8fdf |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsNo findings. The single candidate (
No new grounded findings emerged; the identity-screen logic closes fds via [OPUS-REVIEWED] c8f8fdf Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of All counts I need are confirmed. Writing the review now. First-Principles-Verdict: CONCERNS The sandbox fix earns every part of itself; roughly two-thirds of this diff is other jobs the description never mentions, and it matches commits already sitting on the base branch. What this change shipsIntent: stop a binary planted inside the pinned agent workspace from winning the child's PATH lookup during a sandboxed spawn (#7095) — a FIX.
Watch
Subtractions
[FIRST-PRINCIPLES-REVIEWED] c8f8fdf |
…ed descriptor Review round 1 (GPT lane, verified real): the identity screen checked each entry's resolved target but kept the caller's SPELLING, so a spelling that traverses an agent-retargetable symlink could pass the screen pointing outside and be retargeted before the child's own PATH lookup re-resolves it. Kept entries are now emitted as the OPENED descriptor's canonical path (hooks._fd_real_path, the same verify-then-substitute idiom as bound_agent_workspace_target); an entry whose canonical path cannot be read is dropped, fail-closed.
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Backend sandbox fix plus an image-latch timing repair that only improves the experience: failed hero/screenshot art now retries on theme flip-back instead of staying hidden. [UX-REVIEWED] c8f8fdf |
Disposition of First Principles concerns (head c8f8fdf)Riders (inventory items 5-10) - declined as not-on-branch, with evidence. The lane's own Watch paragraph self-diagnosed this correctly: its patch was computed on a merge checkout against a stale base, sweeping in work already merged to main (the cited subjects match base commits cda8750, 9a444de, 4bd1629). The authoritative GitHub compare for this PR lists exactly two files: The branch carries two commits on top of main touching only those files - the identity screen and its review-round hardening. None of items 5-10 (skill-path CI gate, SEL root fixture, MCP marker move, app-art rebind, embed-pool/gateway-lock/config-memo/marker-cap changes, session-test provider mocks) exist on this branch, so there is nothing to split out. The Subtraction (make
If a maintainer prefers the required-keyword shape, it is a two-line follow-up with no behaviour change; it is not taken here to keep this security fix at its reviewed surface. |
Fixes #7095.
What
create_subprocess_limited(..., chdir_fd=fd)sanitizesPATHwhile the child's working directory is pinned by descriptor — for its ownargv[0]resolution AND for the child's environment. The screen was lexical:os.path.isabs(entry)only. That closes every relative spelling ('',.,..,tools), but an absolute entry that resolves inside the bound workspace (e.g.PATH=/Users/me/.kiro/crew/workspace/bin:/usr/bin) passed unchanged — and resolvingargv[0]is not the last lookup that happens: the wrapper this spawns looks its own target up onPATHfrom inside the child, after the shim has already entered the bound directory. A binary planted behind such an entry would be exec'd ahead of the sandbox meant to contain it.This PR makes the screen an identity check: after the cheap lexical filter, each surviving absolute entry is opened and its
(st_dev, st_ino)ancestry (via the existing_directory_ancestor_identities, which walks by descriptor) is tested against the bound descriptor's identity. An entry that IS the pinned directory or lives anywhere beneath it — by any spelling, symlink aliases included — is dropped from the resolve search and from the child's environment alike._absolutely_rooted_pathis renamed_pinned_spawn_path, since the old name described only the lexical half.Severity, stated honestly
This is not agent-reachable on a default install: it requires the gateway's own
PATHto already contain a directory inside the agent workspace, and an agent cannot edit the gateway's environment. It is a screen that did not enforce its stated invariant, in security-critical code, on a configuration an operator could plausibly create. Not an exploitable escape; not cosmetic either — thechdir_fddocstring and the(a)/(b)/(c)invariant comment both already claimed the pinned directory is untrusted for name resolution, and they over-claimed. Closing the gap and correcting the prose are the same change.Design decisions
asyncio.to_threadhop for the resolve. The screen therefore shares one worker-thread hop with the resolve (_screened_spawn_plan), which also means a pinned explicit-path spawn now takes the hop — its child env still needs screening (clause (c)). Returning the screened env alongside the resolved target keeps clauses (b) and (c) fed from the same value by construction.os.fstat(chdir_fd)raises), there is nothing to compare against, so the lexical absolute-only screen stands alone for that spawn. In productionchdir_fdalways originates frombind_voice_safe_agent_workspace's real opened descriptor; one that cannot befstated is one the shim's ownfchdirrejects before any command runs (pinned by an existing test). This is why every pre-existingTestCreateSubprocessLimitedtest passing a placeholderchdir_fd=9under a mocked spawn keeps passing untouched — a zero-regression property asserted deliberately bytest_an_unreadable_bound_descriptor_degrades_to_the_lexical_screen, not discovered. The degrade is pinned so it cannot silently become fail-open in a later refactor.PATHentry that cannot be opened or walked is dropped. An unopenable entry cannot contribute a resolvable binary today, and dropping is the direction that cannot be gamed by making a directory un-stat-able.PATHstill raisesFileNotFoundErrorfrom the resolve, exactly as before. No fallback added.execvpeparity, relative entries included — pinned behavior only.Tests (all in
test/test_spawn_exec_shim.py::TestCreateSubprocessLimited, real directory descriptors)pinned-outsidestartswithpinned), so the same test guards against a string-prefix reimplementationE is boundcase)os.path.realpathdeliberately broken in the test — proving the screen is descriptor identity, not pathname/realpath string comparisonPATHraisesFileNotFoundErrorRed-before-fix: 6 of the new tests fail on unfixed source; the two behavior-preserving pins pass. Mutation checks, each killed: (1) revert to lexical
isabs, (2) equality-only (drop the descendant half), (3) realpath string comparison instead of descriptor identity, (4) screen the search but notkwargs["env"], (5) invert per-entryOSErrorto keep.Full local verification:
test_spawn_exec_shim.py+test_sandbox_argv.py+test_spawn_auditall green (255 passed); full backend suite vs anorigin/mainworktree with failure sets compared both directions — branch failures (107) are a strict subset of main's (108, the one main-only failure is an unrelated flake that passed on branch), identical 2 pre-existing setup errors.black/isort/flake8/mypyclean.Out of scope (audited, not fixed here)
_resolve_spawn_target'sif cwd:relative-entry join — correct for the unpinned path, unreachable on the pinned one (the pinned branch passescwd=None); claim re-verified on this tip.execvpe-parity break, a product decision.No screenshot evidence: backend-only change with no visual surface; the evidence is the focus of the test assertions above.