You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
backlog: file three findings the sandbox codec review surfaced but did 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.
Copy file name to clipboardExpand all lines: docs/BACKLOG.md
+52Lines changed: 52 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -8331,3 +8331,55 @@ Two worked instances the same day. **#74** went green on 2026-07-30 and sat unme
8331
8331
**Related:** #197 (the boundary), ADR 0087 (amended in place — AC-8..AC-15, no new number), ADR 0147 (its broker now explicitly rides this codec's closed grammar), ADR 0104 (AC-4 re-worded off pickle; the copy-on-Send guarantee is strengthened — `Send` carries encoded text, so the parent's rebuild is a provable no-op), ADR 0144 (the static half of the same 15.2.5 defense).
8332
8332
8333
8333
**Source:** evaluation of an unscoped "recheck the pickle sandbox" suggestion, 2026-08-01; defect confirmed by proof-of-concept before any code was written.
8334
+
8335
+
---
8336
+
8337
+
## 341. Handler returning a tuple or set of Sends delivers nothing, silently
8338
+
8339
+
> 🚧 **Status OPEN (filed 2026-08-01).** `_partition` ([pipeline/dryrun.py:112](../messagefoundry/pipeline/dryrun.py)) narrows with `items = result if isinstance(result, list) else [result]`. A **`list`** of `Send`s works; a **tuple** or **set** does not — the container itself becomes the single item, matches none of the three `isinstance` filters, and yields `([], [], [])`. **Verified live:** `_partition((send, send))[0] == []`. The Handler ran, returned deliveries, and **nothing is delivered and nothing errors** — the message finalizes `FILTERED` (every handler ran but delivered nothing), which is indistinguishable from a handler deliberately declining it. That is an **accept-and-drop**, the one thing CLAUDE.md §12 forbids outright.
8340
+
8341
+
**Cluster:** Correctness / data loss. **Priority:** P1. **Verdict:** build (small). **Severity:** high (silent PHI non-delivery, no operator signal), medium (likelihood: `return (Send(...), Send(...))` is a natural idiom and a one-character difference from the working form).
8342
+
8343
+
**Why it is not merely cosmetic:** the disposition is not `ERROR` and not `UNROUTED` — it is `FILTERED`, a *legitimate* outcome. So the count-and-log invariant is satisfied on paper (the message is counted and logged) while the operator is told the handler chose to drop it. Nothing in the store, the console, or an alert distinguishes this from intent.
8344
+
8345
+
**Fix direction (not yet decided):** accept any non-`str` iterable in `_partition`, **or** fail loud on a non-`list` container. Failing loud is probably right — silently accepting a tuple widens the contract, whereas a `ValueError` routes to `ERROR`/dead-letter and tells the author exactly what happened. Whichever is chosen, `SetState`/`SetMeta` must behave identically, and `HandlerFn`'s type hint should be widened or tightened to match so mypy catches it at authoring time.
8346
+
8347
+
**Pre-existing, not introduced by #339.** ADR 0087's MFW2 codec **deliberately preserves** this behaviour rather than changing routing semantics inside a security fix: it describes such an item as `{"o": "other"}` and rebuilds an inert `Ignored()`, so `_partition` stays the sole filter and `mode=off` / `mode=subprocess` agree. Fixing `_partition` fixes both modes at once.
8348
+
8349
+
**Related:** #339 (found during its adversarial review), ADR 0005 / ADR 0081 (`SetState` / `SetMeta`), CLAUDE.md §12.
8350
+
8351
+
**Source:** adversarial review of the ADR 0087 sandbox codec, 2026-08-01; confirmed by direct execution of `_partition`.
8352
+
8353
+
---
8354
+
8355
+
## 342. Sandbox worker kill does not reap a grandchild holding the response pipe
8356
+
8357
+
> 🚧 **Status OPEN (filed 2026-08-01).** `SandboxSession._kill` ([pipeline/sandbox.py:323](../messagefoundry/pipeline/sandbox.py)) calls `proc.kill()`, which terminates **only the direct worker child**. Admin-authored Handler code running in that child can spawn a grandchild, which **inherits fd 1 — the response pipe** — and survives the kill. It can then write frames onto a pipe the parent believes belongs to a freshly-spawned worker, and it leaks as an orphan process for the engine's lifetime.
8358
+
8359
+
**Cluster:** Security & Compliance. **Priority:** P2. **Verdict:** build (small). **Severity:** medium, low (likelihood: requires Handler-authoring rights, i.e. the same admin threat model as #339).
8360
+
8361
+
**Bounded by the #339 correlation fix, not closed by it:** a grandchild cannot make the parent accept a *forged answer* — the per-dispatch `secrets.token_hex(16)` id is unguessable and the unsolicited-frame check is fatal to the worker. So the residual is **availability and process hygiene**, not misdelivery: the orphan can force repeated kill+respawn cycles on its own feed (each dead-lettering the message in hand, fail-closed) and accumulate leaked processes.
8362
+
8363
+
**Fix direction:** spawn into a job object on Windows (`CREATE_NEW_PROCESS_GROUP` + a kill-on-close job) and a process group on POSIX (`start_new_session=True`, then `killpg`), so the whole tree dies with the worker. Note the platform asymmetry is the same one ADR 0147 already documents for confinement, so the two should be designed together rather than twice.
8364
+
8365
+
**Related:** #339, ADR 0087 (residual now stated there), ADR 0147 (OS-level confinement — the natural home for the job-object work), #343 (the sibling fd-2 issue).
8366
+
8367
+
**Source:** adversarial review of the ADR 0087 sandbox codec, 2026-08-01.
8368
+
8369
+
---
8370
+
8371
+
## 343. Sandbox child stderr is inherited unframed into the engine log stream
8372
+
8373
+
> 🚧 **Status OPEN (filed 2026-08-01).** The worker is spawned with `stderr=None` ([pipeline/sandbox.py:266](../messagefoundry/pipeline/sandbox.py)), so the child's stderr is the **engine's own stderr**, unframed and unattributed. fd 1 is the IPC channel and is strictly framed; fd 2 has no such discipline. Admin-authored Handler code can therefore write arbitrary bytes straight into the engine's log stream — including forged log lines, ANSI control sequences, or content that breaks whatever consumes those logs (NSSM captures stdout/stderr to files; see [docs/SERVICE.md](SERVICE.md)).
**Two distinct problems, worth separating when fixed:** (a) **attribution** — a line from a sandboxed Handler is indistinguishable from an engine line, so an operator cannot tell which inbound produced it; (b) **PHI** — a Handler that `print()`s a message body to stderr writes a full payload into the general log at whatever level the operator is running, which CLAUDE.md §9 forbids for INFO and above. (b) is the one that matters for a PHI deployment.
8378
+
8379
+
**Fix direction:** capture the child's stderr (`stderr=subprocess.PIPE`) and relay it through the engine's stdlib logger on a reader thread, prefixed with the inbound + worker identity and rate-limited. That also removes the interleaving hazard of two processes writing one fd concurrently.
8380
+
8381
+
**Adjacent, same fd-discipline root:** a Handler that `print()`s to **stdout** is a latent landmine in both the pre- and post-#339 code — it lands in the `TextIOWrapper` buffer rather than the `BufferedWriter` the frames use, so it happens not to corrupt a frame today. That is luck, not design, and should be closed with this item (redirect the child's `sys.stdout` to stderr at bootstrap, leaving the raw fd 1 exclusively for frames).
0 commit comments