Expand description
Runtime taint provenance — which state keys currently hold a value derived from an untrusted tool result.
The VIGIL intent gate (crate::intent_gate) hard-blocks an
out-of-intent action when that action is tool-stream-influenced —
reachable from an untrusted tool result. That reachability was
computed from two inputs only: the tool NAMES listed in
.car/intent.json’s untrusted_tools, and the depends_on edges
inside the one proposal being admitted. Both inputs are static, and
that leaves two live holes an injection walks straight through:
- Laundering through a trusted tool.
fetch_web(untrusted) writes attacker-controlled content into state keypage. A laterread_file/summarize— a tool the operator listed inallowed_tools, and rightly so — readspageand carries the same attacker text forward. Its own tool name is trusted, so nothing marks it, and an out-of-intent action downstream of it reads as the model’s own drift (escalate to approval) rather than as the injection signature (hard block). - Laundering through a replan. The poisoned
fetch_webaction is in the PREVIOUS proposal. The replanned proposal has no dependency edge reaching back to it — proposals are separate DAGs — so at admission time the new plan looks pristine even when its actions read the exact state key the poisoned result wrote.
The executor is the only component that knows, at execution time, which
SPECIFIC results were tainted. This ledger is where it records that, and
it is what turns intent_actions_from’s long-empty untrusted_ids
argument into a real input.
§The model
Taint is carried by state keys, partitioned by tenant (None = the
untenanted flat namespace), because state is the only channel by which
one action’s result reaches another action across proposal boundaries.
After each successful action the executor calls TaintLedger::record_result:
- the action’s result is tainted if its tool is in
untrusted_toolsor any key in itscar_ir::Action::effective_read_setis already tainted for that tenant (taint flows through the running session, not just one proposal’s DAG); - a tainted result taints every key the action wrote;
- a trusted result CLEARS every key it wrote — a fresh trusted write is new provenance, so taint does not accumulate monotonically until the whole namespace is untouchable.
At admission, TaintLedger::untrusted_action_ids names every action in
the incoming proposal whose read set intersects the tainted keys. Those
ids go to car_verify::intent::intent_actions_from as untrusted_ids,
and check_intent propagates from there along depends_on exactly as
before. Nothing about the verify core changes; it finally gets the
provenance it always accepted.
§Honest limitations
- Taint is per state key, not per byte within a result. If an action reads a tainted key and writes a key, the whole written value is treated as tainted — even when the attacker-controlled span never reached it. That over-approximates (the safe direction for a security check) and it means a long-lived key that once held untrusted content stays tainted until some trusted action overwrites it.
- A tool that reads an open environment WITHOUT declaring the read
can only be caught by naming it in
untrusted_tools. The ledger seeseffective_read_set()/effective_write_set()— declared state dependencies, assumptions, and effects. A tool that reaches the network or the filesystem directly, out of band of the state store, declares nothing, so nothing propagates. That is precisely what the staticuntrusted_toolslist is for; this ledger extends it, it does not replace it. - The clear is attacker-reachable. A trusted result clearing the keys
it wrote is correct data-flow semantics — after the overwrite the key
genuinely holds untainted content — but an injected plan can arrange
that overwrite. One extra in-intent action is enough: a trusted tool
with the tainted key in its
expected_effectsand no declared read is admitted by the gate and wipes the key from the ledger, after which the out-of-intent action escalates to approval instead of hard-rejecting. The value the follow-on action reads is not attacker-derived, so this launders the LEDGER, not the data. It is not a regression: the ceiling on the attack is exactly the pre-change verdict (an approval escalation), which is what the attacker got before this ledger existed. - Taint fires only on DECLARED reads, and the declaration is
model-authored.
car_ir::Action::effective_read_setisread_set+state_dependencies+ assumption keys — all plan fields an injected model writes. An out-of-intent action that simply omits itsstate_dependenciesreads the poisoned value through dispatch and is never marked by the ledger. This is the reader-side twin of the writer-side limitation above, and it is the cheaper of the two evasions. Same ceiling: omitting the declaration recovers the pre-change behavior (approval escalation), nothing beyond it. - The executor’s idempotency dedup path does not re-taint. A cached
hit returns the stored
car_ir::ActionResultbeforeexecute_with_retryruns, sorecord_resultis never called for it — a re-proposed idempotent untrusted tool does not re-taint a key some trusted action cleared in between. The dedup path also commits no state (it only reports the cachedstate_changes), so the ledger stays in step with the store; the caveat is for a consumer that reads those reported changes as if a write had just happened. - The ledger is process-local and in-memory. It reflects the taint this runtime observed since it started. It is not persisted and does not survive a restart, so a restart re-opens the pre-existing static behavior until a tainted result is observed again.
Structs§
- Taint
Ledger - Runtime-observed taint provenance: the state keys, per tenant, that currently hold a value derived from an untrusted tool result.