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}