Skip to content

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
mainfrom
claude/ha-construct-pickle-sandbox-ff3450
Open

fix(sandbox): the engine parent executed code chosen by the sandboxed child (BACKLOG #339, ADR 0087)#136
wshallwshall wants to merge 7 commits into
mainfrom
claude/ha-construct-pickle-sandbox-ff3450

Conversation

@wshallwshall

Copy link
Copy Markdown
Collaborator

The defect

ADR 0087's opt-in [sandbox].mode=subprocess exists 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 called pickle.loads on 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:40 and ADR 0087 both already reasoned about "a compromised worker" — and named engine-side handler/outbound-name validation as the defense. That runs long after pickle.loads has executed the payload.
  • The # nosec B301 # nosemgrep suppression 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.
  • The child's own except (PicklingError, TypeError) guard doesn't help: a __reduce__ object pickles fine. The danger is entirely on the load side.

mode=off is 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 is json.loads with no object hooks plus a literal tag dispatch over a closed constructor set — 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.

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_response checked 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, no ERROR, 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

  • Self-proving regression test. test_a_reduce_gadget_never_reaches_the_parent runs pickle.loads on 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.
  • End-to-end forgery test. test_a_frame_staged_between_dispatches_can_never_answer_the_next_one stages 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.
  • Parity measured against a HEAD baseline. On a 41-check mode=off vs mode=subprocess sweep the codec is a net correctness win — HEAD had 10 divergences, this has 3, two of which reproduce identically at HEAD. It also 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. Zero pickle/marshal remains in messagefoundry/ or tee/, so .semgrep/messagefoundry.yml's "starts clean" header is now true without a suppression propping it up.

Residuals — stated, not implied

mode=off stays 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_MODULES does not block os/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

  • Rebased three times today; currently 0 behind main.
  • Local suite: 9,900 passed. The only failures were environment artifacts (stale venv metadata, a Windows path-mangling issue in a test's own temp-file creation), both proven unrelated.
  • Adds ~1,393 test lines that spawn real worker subprocesses. Sized against the windows-2025 cap 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).

… 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
wshallwshall force-pushed the claude/ha-construct-pickle-sandbox-ff3450 branch from a09e46d to 08d898b Compare August 2, 2026 01:49
@wshallwshall
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
wshallwshall force-pushed the claude/ha-construct-pickle-sandbox-ff3450 branch from 4d2145f to 69d1022 Compare August 2, 2026 02:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant