Skip to main content

Module taint

Module taint 

Source
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:

  1. Laundering through a trusted tool. fetch_web (untrusted) writes attacker-controlled content into state key page. A later read_file/summarize — a tool the operator listed in allowed_tools, and rightly so — reads page and 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).
  2. Laundering through a replan. The poisoned fetch_web action 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_tools or any key in its car_ir::Action::effective_read_set is 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 sees effective_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 static untrusted_tools list 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_effects and 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_set is read_set + state_dependencies + assumption keys — all plan fields an injected model writes. An out-of-intent action that simply omits its state_dependencies reads 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::ActionResult before execute_with_retry runs, so record_result is 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 cached state_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§

TaintLedger
Runtime-observed taint provenance: the state keys, per tenant, that currently hold a value derived from an untrusted tool result.