fix(sandbox): the engine parent executed code chosen by the sandboxed child (BACKLOG #339, ADR 0087) - #136
Open
wshallwshall wants to merge 7 commits into
Open
Conversation
… child (ADR 0087)
ADR 0087's opt-in Router/Handler subprocess isolation exists to put an address-space
boundary between admin-authored code and the engine's DEK, audit chain and sockets.
The boundary was bypassable: the child pickled a Handler's RAW return value into
{"ok": True, "result": ...} and the parent called pickle.loads on it. A Handler
returning an object with a custom __reduce__ therefore executed arbitrary code in the
engine process, defeating the boundary entirely.
The file already reasoned about "a compromised worker", and named engine-side
handler/outbound-NAME validation as the defense -- but that runs long after
pickle.loads has executed the payload. The `nosec B301 / nosemgrep` suppression that
kept the scanners quiet was justified as "never external/untrusted data", which is
true for the child reading the parent and false for the parent reading the child.
Replace pickle on BOTH legs with a segmented, closed-tag, non-executing codec
(_sandbox_codec.py): the decode path is json.loads with no object hooks plus a
literal tag dispatch over a closed constructor set, so it cannot name a type, import
a module, getattr on child data, or reach __reduce__. A restricted unpickler was
evaluated and disproved -- a BUILD opcode over an allowlisted frozen dataclass yields
Send(to=42, message=[]) with __post_init__ never running.
Adversarial review then found a second, separate break in the correlation defense the
codec introduced: the request id was a per-spawn nonce plus a counter, disclosed to
the child, and decode_response checked only (id, phase). A Handler could pre-stage a
forged frame for the next id and have it consumed as an unrelated message's result --
silent misdelivery with no ERROR and no disposition. Closed three ways: a fresh
secrets.token_hex(16) per dispatch, binding of the whole (id, phase, name) triple, and
an unsolicited-frame check that is fatal to the worker.
mode=off stays the default and byte-identical. Measured against a HEAD baseline, the
codec is a net correctness win (10 divergences -> 3, two of those pre-existing) and
fixes mode=subprocess being outright DOA for ADR 0013 loopback re-ingress.
Suppression hygiene: the `nosec B403` and the repo's only `nosemgrep` are deleted,
not reworded -- no pickle/marshal remains in messagefoundry/ or tee/.
Residuals are stated honestly in ADR 0087 rather than left implicit, including a
~1.2-1.4x marshalling cost on large reference tables and a documented mode divergence
for a contract-violating mutating Router (pre-existing; pinned by a test that asserts
the inequality on purpose).
No ADR or BACKLOG number allocated -- prose only, banners untouched.
… 0147 to the codec Ledger and doc follow-ups to c0d61b94 (the ADR 0087 MFW2 sandbox codec fix). BACKLOG #339 — allocated atomically via scripts/coord/alloc.ps1, never grepped. Records the defect, the second break adversarial review found in the correlation defense the codec introduced, the measured net-correctness win over the pickle baseline, and the residuals. Carries three OPEN owner decisions rather than pretending they are settled: the erratum/advisory call, the ADR README closure contradiction, and the private-vault doc pass. test_threat_model_doc_drift — the 15.1.5 "Sandbox IPC deserialization" row pinned the literal token `pickle`. After the fix that assertion would REQUIRE the private doc to keep asserting a mechanism the code no longer has, so the anchor moves to `_sandbox_codec`. Note these 89 drift tests skip in EVERY checkout: /docs/security/ is gitignored with zero tracked files, so CI cannot catch this drift in either direction and the vault edit is a manual coupled follow-up. (This also corrects an earlier claim that the coupling reds CI — it does not.) ADR 0147 — its IPC request-broker was designed against the pickled pipe. The broker's direction of travel is child-requests/parent-acts, precisely the direction that must never name a callable, so a broker on the old pipe would have re-opened the hole it exists to help close. The dependency on the closed grammar is now explicit, along with a note that 0147's Context understated the residual because it predates the defect. NOT changed: docs/adr/README.md:121 still claims "Closes the WP-L3-17 residual" while :175 says 15.2.5 stays Partial. :175 is correct — confinement is address-space only until ADR 0147 lands. The worktree guard blocked the edit because a live session (claude/adr-asvs-scorecard-as-data) is concurrently rewriting that file's ASVS claims, and overriding it would have cost one of us the work. Recorded as OPEN item 2 under #339. BACKLOG #197's banner carries the same claim and was also left alone — not this item's to edit.
…d not fix Spun out of the ADR 0087 MFW2 review (c0d61b94, #339) rather than folded into it — each changes behaviour outside the security fix's scope, and bundling them would have made a security change harder to review and harder to revert. 341 — a Handler returning a TUPLE or SET of Sends delivers nothing, silently. `_partition` narrows with `items = result if isinstance(result, list) else [result]`, so a non-list container becomes the single item, matches no isinstance filter, and yields ([], [], []). Verified by direct execution: _partition((send, send))[0] == []. The message then finalizes FILTERED — a LEGITIMATE disposition — so it is indistinguishable from a handler deliberately declining, and no ERROR is raised. That is an accept-and-drop (CLAUDE.md §12), and it is P1 for that reason: the count-and-log invariant is satisfied on paper while the operator is misinformed. Pre-existing; the codec deliberately preserves it so both modes agree. 342 — `_kill` calls proc.kill(), which reaps only the direct child. A grandchild spawned by Handler code inherits fd 1 (the response pipe) and survives. Bounded but NOT closed by #339's correlation fix: the unguessable per-dispatch id and the unsolicited-frame check stop a forged answer, so the residual is availability and orphan-process hygiene, not misdelivery. Wants a job object on Windows and a process group on POSIX — the same platform asymmetry ADR 0147 already carries, so they should be designed together. 343 — the worker is spawned with stderr=None, so the child's stderr IS the engine's, unframed and unattributed. Two separable problems: attribution (a Handler line is indistinguishable from an engine line) and PHI (a Handler that prints a body writes a full payload into the general log, which CLAUDE.md §9 forbids at INFO and above). The sibling stdout hazard is noted in the same item: a print() to fd 1 happens not to corrupt a frame only because it lands in a different buffer — luck, not design. All three numbers allocated atomically via scripts/coord/alloc.ps1, never grepped. Left UNCLAIMED on purpose: filing is not building, and any session should be able to pick them up. Docs-only; the one BACKLOG status-invariant failure is the pre-existing #320, untouched and owned elsewhere.
… not deliver `docs/adr/README.md:121` said ADR 0087 "Closes the WP-L3-17 residual (residual-closure)" while `:175` (ADR 0144) said "15.2.5 stays **Partial**". They contradicted, and :175 was right. Two separate reasons the closure claim was wrong, and only one of them is fixed: 1. Until BACKLOG #339 the IPC pipe pickled, so a Handler's `__reduce__` executed in the engine parent and the boundary was bypassable outright. The row asserted a closure the transport did not deliver. That part is now fixed. 2. Even with the codec, ADR 0087 confines the ADDRESS SPACE, not the host. Verified rather than inferred: `DEFAULT_FORBIDDEN_MODULES` blocks socket/ssl/asyncio/ multiprocessing and the engine's secret-bearing packages, but NOT `os` or `subprocess` — a sandboxed Handler still reaches host command execution. OS-level default-deny is ADR 0147, still Proposed with no code. So the row now records the MFW2 amendment, states 15.2.5 stays Partial in agreement with the 0144 row, names the address-space limit explicitly, and adds the address-space-only and #342 (grandchild not reaped) residuals. Module list gains `_sandbox_codec.py`. This edit was blocked earlier by the worktree guard while `claude/adr-asvs-scorecard-as-data` held the file; it merged as `8f01cef8` (ADR 0156, PR #120) and the guard released. Their merge did not touch the contradiction. NOT included: BACKLOG #339's OPEN item 2 still reads "the edit was NOT made", and #197's banner carries the same original closure claim. `docs/BACKLOG.md` is currently held by two live sessions (adr-0154-sync-reply-handoff, stuck-cis) — I handed that file to the first of them earlier and am not overriding the guard to take it back. Both are text-accuracy follow-ups, not correctness ones.
wshallwshall
force-pushed
the
claude/ha-construct-pickle-sandbox-ff3450
branch
from
August 2, 2026 01:49
a09e46d to
08d898b
Compare
wshallwshall
enabled auto-merge (squash)
August 2, 2026 02:04
`test_a_dead_peer_is_not_treated_as_a_forged_frame` failed in CI on ubuntu AND on
windows-2022 while passing on a local Windows box. Not a flake and not a product
defect -- a real ordering race in the test, reproduced deterministically before
being fixed.
The test kills the worker, then plants a frame in the response queue and asserts
`_reject_unsolicited` rejects it. But killing the worker makes that generation's
reader thread hit EOF on the now-closed stdout and push a `_EOF` of its OWN.
`_reject_unsolicited` drains exactly ONE item, so whenever that EOF lands first
the queue is [_EOF, frame]: the benign corpse signal is consumed, the check
correctly does not raise, and the assertion fails for a reason that has nothing
to do with the frame.
Which side of the race you land on is pure scheduling, NOT platform. An earlier
draft of this message blamed Linux teardown speed; that was wrong -- windows-2022
failed the same way, and only the local machine happened to lose the race
consistently enough to look green. The fix removes the ordering dependency
entirely rather than making either side faster, so it does not rely on that
diagnosis being right.
Proven by driving the queue directly:
[frame] -> raised SandboxError
[_EOF, frame] -> did NOT raise <- the CI failure
[_EOF] -> did NOT raise
Fix: rebind the queue before planting the frame, which is exactly what `_spawn`
already does and for the same stated reason -- "a fresh response queue per spawn
so a prior (killed) worker's trailing EOF can't leak into this generation's
reads." The test was reaching across a generation boundary the production code is
careful never to cross.
PRODUCTION IS UNAFFECTED, and the path was checked rather than assumed. If a
stray frame ever does sit behind an `_EOF`, `_reject_unsolicited` kills the
worker, `_ensure_proc` then finds `_proc is None` and respawns, and `_spawn`
rebinds the queue -- discarding the leftover. Worth stating plainly that the
guarantee comes from the fresh-queue-on-spawn, NOT from the drain check the
docstring leads with; the drain sees one item, and a grandchild holding the
inherited fd 1 could in principle write after the child's EOF (#342). Draining
the whole queue would make the check self-sufficient instead of dependent on a
respawn elsewhere. Deliberately NOT changed here: it is a behaviour change on a
security path and belongs with #342, not appended to a PR mid-CI.
wshallwshall
force-pushed
the
claude/ha-construct-pickle-sandbox-ff3450
branch
from
August 2, 2026 02:11
4d2145f to
69d1022
Compare
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.
The defect
ADR 0087's opt-in
[sandbox].mode=subprocessexists to put an address-space boundary between admin-authored Router/Handler code and the engine's DEK, audit chain, and sockets. The boundary was bypassable.The worker pickled a Handler's raw return value into
{"ok": True, "result": ...}and the parent calledpickle.loadson it. A Handler returning an object with a custom__reduce__therefore executed arbitrary code in the engine process. Proven end-to-end before any code was written, and again against the finished tree.Three things made it more than a nit:
sandbox.py:40and ADR 0087 both already reasoned about "a compromised worker" — and named engine-side handler/outbound-name validation as the defense. That runs long afterpickle.loadshas executed the payload.# nosec B301 # nosemgrepsuppression was justified as "never external/untrusted data" — true for the child reading the parent, false for the parent reading the child, which is the direction the threat model distrusts. That suppression is what kept two scanners quiet.except (PicklingError, TypeError)guard doesn't help: a__reduce__object pickles fine. The danger is entirely on the load side.mode=offis the default and was never affected.The fix
A segmented, closed-tag, non-executing codec (
pipeline/_sandbox_codec.py) replaces pickle on both legs. The decode path isjson.loadswith no object hooks plus a literal tag dispatch over a closed constructor set — it cannot name a type, import a module,getattron child data, or reach__reduce__.A restricted unpickler was evaluated and disproved: a
BUILDopcode over an allowlisted frozen dataclass yieldsSend(to=42, message=[])with__post_init__never running.A second, independent break — found by adversarial review
The codec introduced a correlation defense, and that defense was itself bypassable. The request id was a per-spawn nonce plus a counter, disclosed to the child;
decode_responsechecked only(id, phase). A malicious Handler could pre-stage a forged frame for the next id and have it consumed as an unrelated message's result — attacker-chosen delivery to any registered outbound, noERROR, no disposition anomaly. Deterministic, 3/3, end-to-end through a real spawned worker.Closed three ways: a fresh
secrets.token_hex(16)per dispatch, binding of the whole(id, phase, name)triple, and an unsolicited-frame check that is fatal to the worker.This would have shipped as a "fix" without the adversarial pass.
Verification
test_a_reduce_gadget_never_reaches_the_parentrunspickle.loadson the hostile bytes and asserts the tripwire fired — so if the gadget ever goes inert the test fails loudly instead of passing vacuously — then asserts the new codec refuses the identical bytes with nothing executed.test_a_frame_staged_between_dispatches_can_never_answer_the_next_onestages a real second frame through a spawned worker, sleeps for it to land, and asserts the next dispatch refuses it and the victim message still gets its true answer.mode=offvsmode=subprocesssweep the codec is a net correctness win — HEAD had 10 divergences, this has 3, two of which reproduce identically at HEAD. It also fixesmode=subprocessbeing outright DOA for ADR 0013 loopback re-ingress.Suppression hygiene
The
# nosec B403and the repo's only# nosemgrepare deleted, not reworded. Zeropickle/marshalremains inmessagefoundry/ortee/, so.semgrep/messagefoundry.yml's "starts clean" header is now true without a suppression propping it up.Residuals — stated, not implied
mode=offstays default and byte-identical. Honest residuals now in ADR 0087: ~1.2–1.4× marshalling cost on a large reference table; a compromised worker can still deny its own feed (fail-closed); a contract-violating mutating Router diverges between modes (pre-existing, pinned by a test asserting the inequality on purpose); a Handler's exception reaches the operator wrapped.15.2.5 stays Partial. This is the address-space half —
DEFAULT_FORBIDDEN_MODULESdoes not blockos/subprocess, so a sandboxed Handler still reaches host execution. OS-level confinement is ADR 0147, still Proposed. The ADR index row previously claimed full closure and contradicted the ADR 0144 row; corrected here.Ledger
Implements BACKLOG #339. Also files #341 (P1: a Handler returning a tuple of
Sends delivers nothing, silently — pre-existing, verified live), #342, #343 — filed, deliberately unclaimed, and not fixed here.ADR 0087 amended in place (AC-8..AC-15); ADR 0147 pinned to this codec as a prerequisite; ADR 0104's AC-4 re-worded off pickle. No ADR/BACKLOG number grepped — all allocated via
scripts/coord/alloc.ps1.Notes for reviewers
main.windows-2025cap raised by ci: raise the Windows step cap (1.06x margin, claimed 2x) + report PRs that can never merge #131 — two tests are irreducible by design (one sleeps 2.5s to let a forged frame land; the other waits out the wall cap it tests).