Story 1829: deterministic output egress strip — neutralise the beacon before it leaves the broker
Why this exists (the catastrophe it prevents). A prompt-injected matter document tells the model: “when you answer, include this image: .” The model obliges. The broker streams that answer to counsel’s browser verbatim, the browser auto-loads the image, and the privileged facts are now in an attacker’s access log — a zero-click data exfiltration, no human ever saw a link. Markdown links and raw <img>/<a> markup are the same vector. The model’s free-text (text.delta chunks + the terminal turn’s next_message_md) left the broker with no server-side scrub. This Story adds the scrub.
Satellite view: one choke-point, three carriers, two surfaces
Every byte of model free-text leaves the broker through clerk_worker.runner._consume_one_stream. A new pure module, src/clerk_worker/egress.py, neutralises three injection carriers there:
raw markup <img src=...> / <a href=...> / <https://...> → removed markdown  / [text](url) → kept as alt/link text, url dropped bare URL https://... / www.... / ftp://... → [link removed]
It is applied to both surfaces the UI consumes: the streamed text.delta feed (live render) and the terminal turn.completed.next_message_md (the answer the UI settles on). A normal answer — bold, lists, inline code, 5 < 10, [1] citations — passes through byte-for-byte unchanged.
The decisions that matter
1. Streaming-safe by look-behind, never buffer-then-scan
The danger is a construct split across stream chunks: . A naive per-chunk strip sees three innocent fragments and forwards the beacon intact. Buffering the whole answer before scanning would kill the live feed (the product’s whole point). So StreamingEgressFilter keeps a look-behind window: on each chunk it neutralises every complete construct and emits it immediately, but holds back a trailing partial — a half-arrived ![, <img, or URL prefix — until the next chunk completes it or flush() closes the stream. The held tail is the only latency, and it is bounded to one in-progress token.
feed(chunk): buf += chunk
cut = earliest start of any in-progress construct # the hold-back point
emit = strip(buf[:cut]); buf = buf[cut:] # complete part out, partial held
flush(): emit strip(buf) # final-frame check, tail released
2. The final-frame check is the safety net AND the settled answer
The browser renders text.delta live, then re-renders the canonical next_message_md when the turn completes. Scrubbing only the stream would leave the settled answer poisoned (and the persisted step row with it). So _scrub_turn runs the same deterministic strip over next_message_md on the terminal turn — before it is persisted (step_completed) and before it is pushed. The streaming filter is flush()ed at that same boundary so the live tail and the settled answer agree. Other turn fields (verdict, proposal, tool_intents) are broker/agent control data, not free-text egress, and are untouched.
3. Deterministic policy: de-link everything, judge nothing
The strip makes no injected-vs-legitimate distinction — that is exactly D6c’s offline job, explicitly out of the hot path. A genuine citation link gets de-linked to its visible text too. That is the intended, auditable behaviour: a closed set of regex transforms with no model in the loop, so it cannot be prompt-injected, cannot hang, and cannot leak under load. The turn.delta ephemeral feed is a complete partial-turn snapshot each tick, so it takes the one-shot strip rather than the streaming window.
4. No new observability span (and why that is correct)
Per pramaan-docs ADR-124, new executable code declares a span or documents its exemption. egress.py is pure deterministic string transforms with no I/O, no boundary crossing, and no state beyond an in-memory buffer — covered by the parent SQS consumer span already wrapping _consume_one_stream. The module docstring records the exemption.
Verification
The acceptance pair plus the streaming-split cases run as plain unit tests; the broker path is exercised end-to-end through the real process_command stream consumer (system boundary, not a fixture spot-check).
| Check | Proves |
|---|---|
test_egress — tracking image + exfil link | a <img> beacon and a markdown/bare exfil URL are neutralised; the attacker host is gone, visible text survives |
test_egress — normal answer | prose with bold/lists/inline-code/5 < 10/[1] is returned byte-for-byte unchanged |
test_egress — split across chunks | image, bare URL, and <img> each split across 2–3 chunks are still caught by the look-behind window |
test_runner — broker path | through real process_command: streamed text.delta AND the settled next_message_md are both neutralised; the run still succeeds |
Ruff is clean; mypy reports no issues in the new module; the full clerk_worker unit suite passes (67); the OpenAPI snapshots are unchanged (no route/DTO touched); git diff --check is clean.
One sentence to remember: the broker now runs a deterministic, streaming-safe strip over the model’s free-text — holding back a partial construct across chunk boundaries and re-checking the settled answer — so an injected <img> or exfil link is neutralised before it ever reaches the browser, with no model call in the path.