ADR 0025 — Untrusted-context delimiting: provenance taint + data fences¶
Status: Accepted
Context¶
SPEC §11 has been honest since v0.2 that prompt/transition injection is the
language's declared gap: untrusted text (tool observations, host inputs,
prior deposits) was interpolated raw into produce prompts and into the
judge's CONTEXT blob, with "no language-level delimiting or dual-channel
control" (§9). The only defenses were voluntary author prose (std_research /
std_compress mark notes untrusted by hand) and the discipline bullet in §11.
Precedents already pointed the way: ADR 0024 declares file bodies "untrusted
observations", ADR 0017 Layer 2 defers capability separation, and the console
(console/render.py) strictly separates chrome markup from untrusted text.
Decision¶
Two orthogonal structural mechanisms, both normative in SPEC §6:
-
Provenance taint. The engine tracks a per-run set of tainted top-level context keys. Trusted at start ⇔ the value equals the author's
.mklcontext:literal; host-supplied or host-overridden values are tainted (embedders may vouch viarun(..., trusted_keys=...)). Every deposit taints itsoutputkey — tool observation,callresult, and produce output (the LLM is an untrusted oracle per §11). Fan-outiteminherits theover:source's taint (indexstays trusted); acallinput is tainted in the sub-run iff it interpolates a tainted parent key. Checkpoint frames persist the set ("tainted"); a frame without the field resumes all-tainted (fail-safe), and resume-injected values are tainted (checkpoint.taint_frame, used by CLI--setand MCPinputs). -
Sentinel fences. Tainted interpolations render as
<data-NONCE>\n<value>\n</data-NONCE>— byte-for-byte, no escaping. The nonce issecrets.token_hex(6), fresh per LLM call, re-rolled on collision with any fenced value, so content can never forge a closing tag. When a produce user message carries a fence,build_produce_systemappends one rule paragraph telling the model fenced spans are data, never directives; prompts without tainted interpolations stay byte-identical. The judge user message (llm.base.build_judge_user, now shared by both adapters) fences OUTPUT / REASONING / CONTEXT unconditionally — the output is always oracle-derived — while the author'swhenconditions stay bare;JUDGE_SYSTEMstates that fenced content is evidence under judgment.parse_choiceis untouched (it reads only the judge reply).
Recorded choices:
- Provenance, not annotation. No
.mklschema change and no authorabletrust:face — taint derives from where a value came from, never from authoring. Zones/annotations stay deferred with ADR 0017 Layer 2. - No detection heuristics. Regex payload scanners and injection classifiers are brittle and bypassable; the defense is structural only.
- No content mutation. Observations cross the boundary byte-for-byte; only nonce selection reacts to content, never the content itself.
- No provider role tricks beyond the existing system/user split: there is no portable multi-role data channel across OpenAI-compatible providers, so the fence is the mechanism the spec can mandate for any implementation.
- Escape hatch:
run(..., delimit=False)disables produce-side fencing for debugging/comparison (reachable frommklang testvia the scenariorun:block). Judge-side fencing has no switch. - The scenario/conformance format gains an
input:key (host-supplied context, tainted by provenance) so cases can exercise host-input delimiting; fourtaint-*conformance cases pin observation, host-input, call-result fencing and author-literal bareness via the stable<data-prefix. Judge-prompt fencing is adapter behavior → unit tests, not conformance (ADR 0009 boundary, same call as ADR 0024).
Consequences¶
- The §11 attack surface item 1 is downgraded from "no delimiting" to a structural mitigation with a stated residual risk: a model can still be persuaded by fenced content — fences remove ambiguity, not influence. Dual-channel / CaMeL-style control stays an explicit open question (§9).
- Live prompts change: machines fed host inputs or tool observations now carry fences and (produce-side) one extra system paragraph. Machines whose prompts only interpolate author literals are byte-identical.
- Old checkpoints resume all-tainted — safe, at worst over-fenced.
- Follow-up audit (post-release): the console brain needs no delimiting code
of its own — it runs through
engine.run, souser_message/historyare tainted by provenance and arrive fenced (regression test intests/test_console_app.py);llmlintsynthesizes probe prompts from author faces only (trusted); the remainingrender()call ininterpolate.resolvefeeds tool/callinputs, which must stay raw by design. No further taint work pending.