Skip to main content

car_engine/
taint.rs

1//! Runtime taint provenance — which state keys currently hold a value
2//! derived from an untrusted tool result.
3//!
4//! The VIGIL intent gate ([`crate::intent_gate`]) hard-blocks an
5//! out-of-intent action when that action is *tool-stream-influenced* —
6//! reachable from an untrusted tool result. That reachability was
7//! computed from two inputs only: the tool NAMES listed in
8//! `.car/intent.json`'s `untrusted_tools`, and the `depends_on` edges
9//! **inside the one proposal being admitted**. Both inputs are static, and
10//! that leaves two live holes an injection walks straight through:
11//!
12//! 1. **Laundering through a trusted tool.** `fetch_web` (untrusted) writes
13//!    attacker-controlled content into state key `page`. A later
14//!    `read_file`/`summarize` — a tool the operator listed in
15//!    `allowed_tools`, and rightly so — reads `page` and carries the same
16//!    attacker text forward. Its own tool name is trusted, so nothing marks
17//!    it, and an out-of-intent action downstream of it reads as the model's
18//!    own drift (escalate to approval) rather than as the injection
19//!    signature (hard block).
20//! 2. **Laundering through a replan.** The poisoned `fetch_web` action is in
21//!    the PREVIOUS proposal. The replanned proposal has no dependency edge
22//!    reaching back to it — proposals are separate DAGs — so at admission
23//!    time the new plan looks pristine even when its actions read the exact
24//!    state key the poisoned result wrote.
25//!
26//! The executor is the only component that knows, at execution time, which
27//! SPECIFIC results were tainted. This ledger is where it records that, and
28//! it is what turns `intent_actions_from`'s long-empty `untrusted_ids`
29//! argument into a real input.
30//!
31//! ## The model
32//!
33//! Taint is carried by **state keys**, partitioned by tenant (`None` = the
34//! untenanted flat namespace), because state is the only channel by which
35//! one action's result reaches another action across proposal boundaries.
36//! After each successful action the executor calls [`TaintLedger::record_result`]:
37//!
38//! - the action's result is tainted if its tool is in `untrusted_tools` **or**
39//!   any key in its [`car_ir::Action::effective_read_set`] is already tainted
40//!   for that tenant (taint flows through the running session, not just one
41//!   proposal's DAG);
42//! - a tainted result taints every key the action wrote;
43//! - a **trusted** result CLEARS every key it wrote — a fresh trusted write
44//!   is new provenance, so taint does not accumulate monotonically until the
45//!   whole namespace is untouchable.
46//!
47//! At admission, [`TaintLedger::untrusted_action_ids`] names every action in
48//! the incoming proposal whose read set intersects the tainted keys. Those
49//! ids go to `car_verify::intent::intent_actions_from` as `untrusted_ids`,
50//! and `check_intent` propagates from there along `depends_on` exactly as
51//! before. Nothing about the verify core changes; it finally gets the
52//! provenance it always accepted.
53//!
54//! ## Honest limitations
55//!
56//! - **Taint is per state key, not per byte within a result.** If an action
57//!   reads a tainted key and writes a key, the whole written value is
58//!   treated as tainted — even when the attacker-controlled span never
59//!   reached it. That over-approximates (the safe direction for a security
60//!   check) and it means a long-lived key that once held untrusted content
61//!   stays tainted until some trusted action overwrites it.
62//! - **A tool that reads an open environment WITHOUT declaring the read
63//!   can only be caught by naming it in `untrusted_tools`.** The ledger sees
64//!   `effective_read_set()` / `effective_write_set()` — declared state
65//!   dependencies, assumptions, and effects. A tool that reaches the network
66//!   or the filesystem directly, out of band of the state store, declares
67//!   nothing, so nothing propagates. That is precisely what the static
68//!   `untrusted_tools` list is for; this ledger extends it, it does not
69//!   replace it.
70//! - **The clear is attacker-reachable.** A trusted result clearing the keys
71//!   it wrote is correct data-flow semantics — after the overwrite the key
72//!   genuinely holds untainted content — but an injected plan can *arrange*
73//!   that overwrite. One extra in-intent action is enough: a trusted tool
74//!   with the tainted key in its `expected_effects` and no declared read is
75//!   admitted by the gate and wipes the key from the ledger, after which the
76//!   out-of-intent action escalates to approval instead of hard-rejecting.
77//!   The value the follow-on action reads is not attacker-derived, so this
78//!   launders the LEDGER, not the data. It is not a regression: the ceiling
79//!   on the attack is exactly the pre-change verdict (an approval
80//!   escalation), which is what the attacker got before this ledger existed.
81//! - **Taint fires only on DECLARED reads, and the declaration is
82//!   model-authored.** [`car_ir::Action::effective_read_set`] is `read_set` +
83//!   `state_dependencies` + assumption keys — all plan fields an injected
84//!   model writes. An out-of-intent action that simply omits its
85//!   `state_dependencies` reads the poisoned value through dispatch and is
86//!   never marked by the ledger. This is the reader-side twin of the
87//!   writer-side limitation above, and it is the cheaper of the two
88//!   evasions. Same ceiling: omitting the declaration recovers the
89//!   pre-change behavior (approval escalation), nothing beyond it.
90//! - **The executor's idempotency dedup path does not re-taint.** A cached
91//!   hit returns the stored [`car_ir::ActionResult`] before `execute_with_retry`
92//!   runs, so `record_result` is never called for it — a re-proposed
93//!   idempotent untrusted tool does not re-taint a key some trusted action
94//!   cleared in between. The dedup path also commits no state (it only
95//!   *reports* the cached `state_changes`), so the ledger stays in step with
96//!   the store; the caveat is for a consumer that reads those reported
97//!   changes as if a write had just happened.
98//! - **The ledger is process-local and in-memory.** It reflects the taint
99//!   this runtime observed since it started. It is not persisted and does
100//!   not survive a restart, so a restart re-opens the pre-existing static
101//!   behavior until a tainted result is observed again.
102
103use std::collections::{HashMap, HashSet};
104use tokio::sync::RwLock;
105
106/// Runtime-observed taint provenance: the state keys, per tenant, that
107/// currently hold a value derived from an untrusted tool result.
108///
109/// Owned by the [`crate::Runtime`] and installed alongside the VIGIL intent
110/// gate — no intent gate, no ledger, so an unconfigured runtime pays
111/// nothing. See the module docs for the threat model and the limitations.
112pub struct TaintLedger {
113    /// Tools whose RESULTS are attacker-influenceable by construction (web
114    /// fetch, inbox read, …) — the same list the intent gate uses, captured
115    /// so the ledger can decide taint without reaching back into the gate.
116    untrusted_tools: HashSet<String>,
117    /// tenant (`None` = untenanted) → the tainted state keys for it.
118    tainted: RwLock<HashMap<Option<String>, HashSet<String>>>,
119}
120
121impl TaintLedger {
122    /// Build a ledger over the configured untrusted tool names.
123    pub fn new(untrusted_tools: HashSet<String>) -> Self {
124        Self {
125            untrusted_tools,
126            tainted: RwLock::new(HashMap::new()),
127        }
128    }
129
130    /// The configured untrusted tool names.
131    pub fn untrusted_tools(&self) -> &HashSet<String> {
132        &self.untrusted_tools
133    }
134
135    /// Record the provenance of one **successful** action.
136    ///
137    /// `written_keys` is what the action actually changed (the executor's
138    /// `state_changes`: declared `expected_effects` plus the state
139    /// transitions dispatch produced). The result is tainted when the
140    /// action's tool is untrusted, or when it read a key that is already
141    /// tainted for `tenant`; a tainted result taints every written key and a
142    /// trusted one clears them.
143    ///
144    /// Failed actions must NOT be recorded — a failed action commits no
145    /// effects, so it changes no provenance.
146    pub async fn record_result(
147        &self,
148        tenant: Option<&str>,
149        action: &car_ir::Action,
150        written_keys: impl IntoIterator<Item = String>,
151    ) {
152        let written: Vec<String> = written_keys.into_iter().collect();
153        if written.is_empty() {
154            // Nothing reached state, so nothing's provenance changed.
155            return;
156        }
157        let tool_is_untrusted = action
158            .tool
159            .as_ref()
160            .map(|t| self.untrusted_tools.contains(t))
161            .unwrap_or(false);
162        let reads = action.effective_read_set();
163
164        // One write lock for the read-then-mutate: a read lock followed by a
165        // write lock would let a concurrently-executing action interleave
166        // between the taint decision and the commit of it.
167        let mut guard = self.tainted.write().await;
168        let key = tenant.map(str::to_string);
169        let entry = guard.entry(key).or_default();
170        let tainted = tool_is_untrusted || reads.iter().any(|k| entry.contains(k));
171
172        if tainted {
173            for k in written {
174                entry.insert(k);
175            }
176        } else {
177            for k in &written {
178                entry.remove(k);
179            }
180        }
181    }
182
183    /// The ids of `actions` whose read set intersects `tenant`'s tainted
184    /// keys — exactly the `untrusted_ids` input
185    /// `car_verify::intent::intent_actions_from` takes.
186    ///
187    /// Tools named in `untrusted_tools` are deliberately NOT re-derived
188    /// here: `intent_actions_from` already marks those from the tool name.
189    /// This answers the question the tool name cannot — *did this action
190    /// read something a previous untrusted result wrote?*
191    pub async fn untrusted_action_ids(
192        &self,
193        tenant: Option<&str>,
194        actions: &[car_ir::Action],
195    ) -> HashSet<String> {
196        let guard = self.tainted.read().await;
197        let Some(keys) = guard.get(&tenant.map(str::to_string)) else {
198            return HashSet::new();
199        };
200        if keys.is_empty() {
201            return HashSet::new();
202        }
203        actions
204            .iter()
205            .filter(|a| a.effective_read_set().iter().any(|k| keys.contains(k)))
206            .map(|a| a.id.clone())
207            .collect()
208    }
209
210    /// The tainted state keys for `tenant` — for tests and observability.
211    pub async fn tainted_keys(&self, tenant: Option<&str>) -> HashSet<String> {
212        self.tainted
213            .read()
214            .await
215            .get(&tenant.map(str::to_string))
216            .cloned()
217            .unwrap_or_default()
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use car_ir::{Action, ActionType};
225
226    fn tool_action(id: &str, tool: &str) -> Action {
227        let mut a = Action::new(ActionType::ToolCall);
228        a.id = id.to_string();
229        a.tool = Some(tool.to_string());
230        a.max_retries = 0;
231        a
232    }
233
234    fn ledger() -> TaintLedger {
235        TaintLedger::new(["fetch_web".to_string()].into_iter().collect())
236    }
237
238    #[tokio::test]
239    async fn a_trusted_tool_reading_a_tainted_key_is_untrusted() {
240        // The laundering case: fetch_web (untrusted) writes `page`; a later
241        // `summarize` (a tool the intent explicitly allows) reads `page`.
242        // Its tool name says trusted, its provenance says otherwise — and
243        // provenance is what the injection signature is made of.
244        let led = ledger();
245        led.record_result(None, &tool_action("a1", "fetch_web"), ["page".to_string()])
246            .await;
247        assert!(led.tainted_keys(None).await.contains("page"));
248
249        let mut summarize = tool_action("a2", "summarize");
250        summarize.state_dependencies.push("page".to_string());
251        let ids = led.untrusted_action_ids(None, &[summarize]).await;
252        assert!(
253            ids.contains("a2"),
254            "an action reading a tainted key must be reported untrusted: {ids:?}"
255        );
256    }
257
258    #[tokio::test]
259    async fn taint_flows_through_a_trusted_intermediate() {
260        // Two hops, no dependency edge needed: fetch_web → page,
261        // summarize(page) → digest. `digest` is tainted even though
262        // `summarize` is a trusted tool, so an action reading only `digest`
263        // is still untrusted.
264        let led = ledger();
265        led.record_result(None, &tool_action("a1", "fetch_web"), ["page".to_string()])
266            .await;
267        let mut summarize = tool_action("a2", "summarize");
268        summarize.state_dependencies.push("page".to_string());
269        led.record_result(None, &summarize, ["digest".to_string()])
270            .await;
271        assert!(led.tainted_keys(None).await.contains("digest"));
272
273        let mut pay = tool_action("a3", "send_payment");
274        pay.state_dependencies.push("digest".to_string());
275        assert!(led.untrusted_action_ids(None, &[pay]).await.contains("a3"));
276    }
277
278    #[tokio::test]
279    async fn a_trusted_overwrite_clears_the_taint() {
280        // Taint must not be monotone — a fresh trusted write to the key is
281        // new provenance, otherwise a long session degenerates into every
282        // key tainted forever.
283        let led = ledger();
284        led.record_result(None, &tool_action("a1", "fetch_web"), ["page".to_string()])
285            .await;
286        assert!(led.tainted_keys(None).await.contains("page"));
287
288        // A trusted tool that reads nothing tainted, writing the same key.
289        led.record_result(None, &tool_action("a2", "summarize"), ["page".to_string()])
290            .await;
291        assert!(
292            !led.tainted_keys(None).await.contains("page"),
293            "a trusted overwrite must clear the key"
294        );
295
296        let mut reader = tool_action("a3", "send_payment");
297        reader.state_dependencies.push("page".to_string());
298        assert!(led.untrusted_action_ids(None, &[reader]).await.is_empty());
299    }
300
301    #[tokio::test]
302    async fn taint_does_not_cross_tenants() {
303        // State is tenant-partitioned in the store; provenance over it has
304        // to be partitioned the same way, or one tenant's untrusted fetch
305        // starts hard-blocking another tenant's legitimate work.
306        let led = ledger();
307        led.record_result(
308            Some("tenant-a"),
309            &tool_action("a1", "fetch_web"),
310            ["page".to_string()],
311        )
312        .await;
313
314        let mut reader = tool_action("b1", "summarize");
315        reader.state_dependencies.push("page".to_string());
316        assert!(led
317            .untrusted_action_ids(Some("tenant-a"), std::slice::from_ref(&reader))
318            .await
319            .contains("b1"));
320        assert!(led
321            .untrusted_action_ids(Some("tenant-b"), std::slice::from_ref(&reader))
322            .await
323            .is_empty());
324        assert!(led
325            .untrusted_action_ids(None, std::slice::from_ref(&reader))
326            .await
327            .is_empty());
328    }
329
330    #[tokio::test]
331    async fn an_action_writing_nothing_changes_nothing() {
332        let led = ledger();
333        led.record_result(None, &tool_action("a1", "fetch_web"), Vec::new())
334            .await;
335        assert!(led.tainted_keys(None).await.is_empty());
336    }
337
338    #[tokio::test]
339    async fn an_untainted_ledger_reports_no_untrusted_ids() {
340        let led = ledger();
341        let mut reader = tool_action("a1", "summarize");
342        reader.state_dependencies.push("page".to_string());
343        assert!(led.untrusted_action_ids(None, &[reader]).await.is_empty());
344    }
345}