Skip to main content

car_engine/
intent_gate.rs

1//! VIGIL intent gate — the live in-loop verify-before-commit call-site
2//! (arXiv 2601.05755; `docs/proposals/intent-grounded-verification.md`).
3//!
4//! The VIGIL slices shipped as stateless verify/policy cores
5//! (`car_verify::intent::check_intent` / `gate_intent`,
6//! `car_policy::intent_gate::enforce_intent`); nothing on the runtime
7//! *called* them on a normal proposal. This module attaches them to the
8//! executor's admission seam as an [`AdmissionGate`] — per the epic-merge
9//! review's unification finding: ONE enforcement plumbing, the executor's
10//! central ledger resolution (A7), instead of a second parallel
11//! ledger-resolving authority.
12//!
13//! Semantics preserved from the cores: a **forbidden capability** or a
14//! **tool-stream-influenced out-of-intent action** (reachable from an
15//! `untrusted` tool's result through the dependency graph — the injection
16//! signature) is a hard `Reject`, never approvable. Untainted drift is
17//! policy-driven (default: escalate to approval, resolved against the
18//! durable ledger by content-bound fingerprint).
19//!
20//! Project config: `.car/intent.json` (the `tool-labels.json` idiom) —
21//! an [`IntentGateConfig`] naming the `intent` spec, the `untrusted_tools`
22//! whose outputs count as tool-stream input, and the untainted-drift
23//! policy.
24
25use crate::admission::{AdmissionGate, GateContext, GateOutcome};
26use crate::taint::TaintLedger;
27use car_ir::ActionProposal;
28use car_verify::intent::{
29    check_intent, gate_intent, intent_actions_from, IntentGatePolicy, IntentSpec,
30};
31use serde::{Deserialize, Serialize};
32use sha2::Digest;
33use std::collections::HashSet;
34use std::path::Path;
35use std::sync::Arc;
36
37/// The deserialized `.car/intent.json` document.
38#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct IntentGateConfig {
40    /// The declared task intent (VIGIL's root of trust).
41    #[serde(default)]
42    pub intent: IntentSpec,
43    /// Tools whose RESULTS are tool-stream input from an open environment
44    /// (web fetch, inbox read, …). An action calling one — and everything
45    /// downstream of it in the dependency graph — is treated as
46    /// potentially injection-influenced.
47    #[serde(default)]
48    pub untrusted_tools: Vec<String>,
49    /// Untainted-drift policy — deserialized DIRECTLY as
50    /// [`car_verify::intent::IntentDisposition`] (`"allow"` /
51    /// `"require_approval"` / `"block"`, snake_case). Typed so a typo'd
52    /// or wrong-case value ("Block", "deny") is a LOUD parse error,
53    /// never a silent downgrade of a hard block to approvable (linus
54    /// review). Absent = the core's default (require_approval).
55    #[serde(default)]
56    pub on_untainted_drift: Option<car_verify::intent::IntentDisposition>,
57}
58
59/// Error raised while loading `.car/intent.json`.
60#[derive(Debug, Clone)]
61pub struct IntentLoadError {
62    pub message: String,
63}
64
65impl std::fmt::Display for IntentLoadError {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        write!(f, "{}", self.message)
68    }
69}
70
71impl std::error::Error for IntentLoadError {}
72
73/// Load `.car/intent.json`. Absent file ⇒ `Ok(None)` (the gate is
74/// opt-in); a present-but-malformed file is a loud error, never a
75/// silently-ungated session (the strict-and-loud A2 loader stance).
76pub fn load_intent_config(
77    car_dir: impl AsRef<Path>,
78) -> Result<Option<IntentGateConfig>, IntentLoadError> {
79    let path = car_dir.as_ref().join("intent.json");
80    if !path.exists() {
81        return Ok(None);
82    }
83    let raw = std::fs::read_to_string(&path).map_err(|e| IntentLoadError {
84        message: format!("read {}: {e}", path.display()),
85    })?;
86    serde_json::from_str(&raw)
87        .map(Some)
88        .map_err(|e| IntentLoadError {
89            message: format!("parse {}: {e}", path.display()),
90        })
91}
92
93/// The admission gate. Cheap and side-effect-free per the seam contract:
94/// the cores are pure and the config is captured at construction.
95pub struct IntentGate {
96    intent: IntentSpec,
97    untrusted_tools: HashSet<String>,
98    policy: IntentGatePolicy,
99    /// Runtime taint provenance, when the runtime installed one. `None`
100    /// ⇒ taint enters only through `untrusted_tools` and one proposal's
101    /// own dependency DAG — the original, statically-scoped behavior.
102    taint: Option<Arc<TaintLedger>>,
103}
104
105impl IntentGate {
106    /// A gate with no runtime provenance: taint is derived from the tool
107    /// names and the proposal's own DAG only.
108    pub fn new(config: IntentGateConfig) -> Self {
109        Self::build(config, None)
110    }
111
112    /// A gate backed by the runtime's [`TaintLedger`], so a result the
113    /// executor observed as tainted marks the actions that READ it — even
114    /// when their own tool is trusted, and even when the tainting action
115    /// belongs to an earlier proposal (the replan case). See
116    /// [`crate::taint`].
117    pub fn with_taint(config: IntentGateConfig, ledger: Arc<TaintLedger>) -> Self {
118        Self::build(config, Some(ledger))
119    }
120
121    fn build(config: IntentGateConfig, taint: Option<Arc<TaintLedger>>) -> Self {
122        let policy = match config.on_untainted_drift {
123            Some(d) => IntentGatePolicy {
124                on_untainted_drift: d,
125            },
126            None => IntentGatePolicy::default(),
127        };
128        Self {
129            intent: config.intent,
130            untrusted_tools: config.untrusted_tools.into_iter().collect(),
131            policy,
132            taint,
133        }
134    }
135}
136
137#[async_trait::async_trait]
138impl AdmissionGate for IntentGate {
139    fn name(&self) -> &str {
140        "intent"
141    }
142
143    async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome {
144        // Derive IntentActions from the proposal's own IR (the Slice 4
145        // populater): tool, effective write set → targets,
146        // metadata.capabilities, the same dependency DAG the runtime
147        // sequences on. Taint enters two ways: statically through
148        // untrusted TOOLS, and — when the runtime installed a
149        // [`TaintLedger`] — through `untrusted_ids`, the actions whose
150        // read set touches a state key an untrusted result actually
151        // wrote at execution time. The second is what covers a trusted
152        // tool laundering attacker content and a replan that carries no
153        // dependency edge back to the poisoned action. From there
154        // `check_intent` propagates along depends_on exactly as before.
155        let untrusted_ids = match &self.taint {
156            Some(ledger) => {
157                let tenant = ctx.scope.and_then(|s| s.tenant_id.as_deref());
158                ledger.untrusted_action_ids(tenant, &proposal.actions).await
159            }
160            None => HashSet::new(),
161        };
162        let intent_actions =
163            intent_actions_from(&proposal.actions, &self.untrusted_tools, &untrusted_ids);
164        let report = check_intent(&self.intent, &intent_actions);
165        // NOTE: `report.safe` only means "nothing commit-blocked" —
166        // untainted drift is REPORTED in `violations` with safe=true,
167        // and the gate policy decides its disposition. So gate on
168        // violations, not on `safe`.
169        if report.violations.is_empty() {
170            return GateOutcome::Allow;
171        }
172        let decision = gate_intent(&report, &self.policy);
173        if !decision.blocked.is_empty() {
174            return GateOutcome::Reject {
175                blocked: decision.blocked.iter().map(|v| v.action.clone()).collect(),
176                reason: decision.reason,
177            };
178        }
179        if !decision.needs_approval.is_empty() {
180            // Content-bound fingerprint over EVERY escalated violation
181            // (the C-8 lesson: fingerprinting one hazard lets one
182            // approval admit the rest), sorted for order independence,
183            // then HASHED: the components (action ids, write-set keys,
184            // tool names) are model-authored strings under VIGIL's own
185            // threat model, so a delimiter-joined key would be forgeable
186            // by embedding the separator in a detail (linus review).
187            // A ledger key is a security identity, not a log line.
188            let mut fps: Vec<String> = decision
189                .needs_approval
190                .iter()
191                .map(car_policy::intent_gate::intent_fingerprint)
192                .collect();
193            fps.sort();
194            fps.dedup();
195            let canonical = serde_json::to_string(&fps).unwrap_or_default();
196            let digest = sha2::Sha256::digest(canonical.as_bytes());
197            return GateOutcome::NeedsApproval {
198                actions: decision
199                    .needs_approval
200                    .iter()
201                    .map(|v| v.action.clone())
202                    .collect(),
203                fingerprint: format!("intent:sha256:{:x}", digest),
204                reason: decision.reason,
205            };
206        }
207        GateOutcome::Allow
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::admission::GateContext;
215    use car_ir::{Action, ActionProposal, ActionType};
216    use std::collections::HashMap;
217
218    fn tool_action(id: &str, tool: &str) -> Action {
219        {
220            let mut a = Action::new(ActionType::ToolCall);
221            a.id = id.to_string();
222            a.tool = Some(tool.to_string());
223            a.max_retries = 0;
224            a
225        }
226    }
227
228    fn proposal(actions: Vec<Action>) -> ActionProposal {
229        ActionProposal {
230            id: "p1".to_string(),
231            source: "test".to_string(),
232            actions,
233            timestamp: chrono::Utc::now(),
234            context: HashMap::new(),
235        }
236    }
237
238    fn ctx<'a>(
239        state: &'a HashMap<String, serde_json::Value>,
240        versions: &'a HashMap<String, u64>,
241    ) -> GateContext<'a> {
242        GateContext {
243            session_id: None,
244            scope: None,
245            state,
246            versions,
247        }
248    }
249
250    #[tokio::test]
251    async fn injection_signature_is_hard_rejected() {
252        // fetch_web is untrusted; send_payment is out of the declared
253        // intent AND downstream of the untrusted result → hard Reject
254        // (never approvable) — the VIGIL injection case.
255        let gate = IntentGate::new(IntentGateConfig {
256            intent: IntentSpec {
257                allowed_tools: vec!["fetch_web".to_string(), "summarize".to_string()],
258                ..Default::default()
259            },
260            untrusted_tools: vec!["fetch_web".to_string()],
261            on_untainted_drift: None,
262        });
263        let mut fetch = tool_action("a1", "fetch_web");
264        fetch
265            .expected_effects
266            .insert("page".into(), serde_json::json!("…"));
267        let mut pay = tool_action("a2", "send_payment");
268        pay.state_dependencies.push("page".into());
269        let p = proposal(vec![fetch, pay]);
270        let (state, versions) = (HashMap::new(), HashMap::new());
271        match gate.check(&p, &ctx(&state, &versions)).await {
272            GateOutcome::Reject { blocked, .. } => assert!(blocked.contains("a2")),
273            other => panic!("expected Reject, got {other:?}"),
274        }
275    }
276
277    #[tokio::test]
278    async fn untainted_drift_escalates_with_content_bound_fingerprint() {
279        let gate = IntentGate::new(IntentGateConfig {
280            intent: IntentSpec {
281                allowed_tools: vec!["summarize".to_string()],
282                ..Default::default()
283            },
284            untrusted_tools: vec![],
285            on_untainted_drift: None, // default: require approval
286        });
287        let p = proposal(vec![tool_action("a1", "send_email")]);
288        let (state, versions) = (HashMap::new(), HashMap::new());
289        match gate.check(&p, &ctx(&state, &versions)).await {
290            GateOutcome::NeedsApproval {
291                actions,
292                fingerprint,
293                ..
294            } => {
295                assert!(actions.contains("a1"));
296                assert!(!fingerprint.is_empty());
297            }
298            other => panic!("expected NeedsApproval, got {other:?}"),
299        }
300    }
301
302    #[tokio::test]
303    async fn in_intent_proposal_is_allowed() {
304        let gate = IntentGate::new(IntentGateConfig {
305            intent: IntentSpec {
306                allowed_tools: vec!["summarize".to_string()],
307                ..Default::default()
308            },
309            untrusted_tools: vec![],
310            on_untainted_drift: None,
311        });
312        let p = proposal(vec![tool_action("a1", "summarize")]);
313        let (state, versions) = (HashMap::new(), HashMap::new());
314        assert!(matches!(
315            gate.check(&p, &ctx(&state, &versions)).await,
316            GateOutcome::Allow
317        ));
318    }
319
320    fn taint_config() -> IntentGateConfig {
321        IntentGateConfig {
322            intent: IntentSpec {
323                allowed_tools: vec!["fetch_web".to_string(), "summarize".to_string()],
324                ..Default::default()
325            },
326            untrusted_tools: vec!["fetch_web".to_string()],
327            on_untainted_drift: None,
328        }
329    }
330
331    /// An out-of-intent `send_payment` that reads state key `page`, with no
332    /// dependency edge to whatever wrote `page` — the replan shape.
333    fn out_of_intent_reader() -> ActionProposal {
334        let mut pay = tool_action("a9", "send_payment");
335        pay.state_dependencies.push("page".into());
336        proposal(vec![pay])
337    }
338
339    #[tokio::test]
340    async fn without_the_ledger_a_replanned_reader_only_escalates() {
341        // The control: this is exactly today's behavior, and exactly the
342        // hole. The poisoned `fetch_web` is in a PREVIOUS proposal, so the
343        // gate sees a lone out-of-intent action with no untrusted
344        // dependency — the model's own drift, merely approvable.
345        let gate = IntentGate::new(taint_config());
346        let (state, versions) = (HashMap::new(), HashMap::new());
347        match gate
348            .check(&out_of_intent_reader(), &ctx(&state, &versions))
349            .await
350        {
351            GateOutcome::NeedsApproval { actions, .. } => assert!(actions.contains("a9")),
352            other => panic!("expected NeedsApproval without a ledger, got {other:?}"),
353        }
354    }
355
356    #[tokio::test]
357    async fn runtime_taint_hard_rejects_a_replanned_out_of_intent_reader() {
358        // Same proposal, same intent — but the runtime observed `fetch_web`
359        // writing `page`. That provenance makes the reader tool-stream-
360        // influenced, which is the injection signature: a hard block, never
361        // approvable.
362        let ledger = Arc::new(crate::taint::TaintLedger::new(
363            ["fetch_web".to_string()].into_iter().collect(),
364        ));
365        let mut fetch = tool_action("a1", "fetch_web");
366        fetch
367            .expected_effects
368            .insert("page".into(), serde_json::json!("…"));
369        ledger
370            .record_result(None, &fetch, ["page".to_string()])
371            .await;
372
373        let gate = IntentGate::with_taint(taint_config(), ledger);
374        let (state, versions) = (HashMap::new(), HashMap::new());
375        match gate
376            .check(&out_of_intent_reader(), &ctx(&state, &versions))
377            .await
378        {
379            GateOutcome::Reject { blocked, .. } => assert!(blocked.contains("a9")),
380            other => panic!("expected Reject with runtime taint, got {other:?}"),
381        }
382    }
383
384    #[tokio::test]
385    async fn runtime_taint_under_another_tenant_does_not_reject() {
386        // Provenance is tenant-partitioned; tenant A's poisoned fetch must
387        // not hard-block tenant B. Without a scope the gate looks at the
388        // untenanted partition, which is empty here.
389        let ledger = Arc::new(crate::taint::TaintLedger::new(
390            ["fetch_web".to_string()].into_iter().collect(),
391        ));
392        ledger
393            .record_result(
394                Some("tenant-a"),
395                &tool_action("a1", "fetch_web"),
396                ["page".to_string()],
397            )
398            .await;
399
400        let gate = IntentGate::with_taint(taint_config(), ledger);
401        let (state, versions) = (HashMap::new(), HashMap::new());
402        match gate
403            .check(&out_of_intent_reader(), &ctx(&state, &versions))
404            .await
405        {
406            GateOutcome::NeedsApproval { .. } => {}
407            other => panic!("expected NeedsApproval for a different tenant, got {other:?}"),
408        }
409    }
410
411    #[tokio::test]
412    async fn an_empty_ledger_preserves_the_no_ledger_verdict() {
413        // Installing the ledger must not change anything on its own — only
414        // an observed tainted result may.
415        let ledger = Arc::new(crate::taint::TaintLedger::new(
416            ["fetch_web".to_string()].into_iter().collect(),
417        ));
418        let gate = IntentGate::with_taint(taint_config(), ledger);
419        let (state, versions) = (HashMap::new(), HashMap::new());
420        match gate
421            .check(&out_of_intent_reader(), &ctx(&state, &versions))
422            .await
423        {
424            GateOutcome::NeedsApproval { .. } => {}
425            other => panic!("expected NeedsApproval with an empty ledger, got {other:?}"),
426        }
427    }
428
429    #[test]
430    fn typo_in_drift_policy_is_a_loud_parse_error() {
431        // linus review: a stringly policy silently degraded "Block"/"deny"
432        // typos to require_approval — a hard block becoming approvable.
433        // Typed deserialization must reject loudly instead.
434        let tmp = tempfile::tempdir().unwrap();
435        std::fs::write(
436            tmp.path().join("intent.json"),
437            r#"{"on_untainted_drift": "Block"}"#,
438        )
439        .unwrap();
440        assert!(
441            load_intent_config(tmp.path()).is_err(),
442            "wrong case must not parse"
443        );
444        std::fs::write(
445            tmp.path().join("intent.json"),
446            r#"{"on_untainted_drift": "block"}"#,
447        )
448        .unwrap();
449        let cfg = load_intent_config(tmp.path()).unwrap().unwrap();
450        assert_eq!(
451            cfg.on_untainted_drift,
452            Some(car_verify::intent::IntentDisposition::Block)
453        );
454    }
455
456    #[test]
457    fn loader_absent_is_none_malformed_is_loud() {
458        let tmp = tempfile::tempdir().unwrap();
459        assert!(load_intent_config(tmp.path()).unwrap().is_none());
460        std::fs::write(tmp.path().join("intent.json"), "{not json").unwrap();
461        assert!(load_intent_config(tmp.path()).is_err());
462    }
463}