1use 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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
39pub struct IntentGateConfig {
40 #[serde(default)]
42 pub intent: IntentSpec,
43 #[serde(default)]
48 pub untrusted_tools: Vec<String>,
49 #[serde(default)]
56 pub on_untainted_drift: Option<car_verify::intent::IntentDisposition>,
57}
58
59#[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
73pub 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
93pub struct IntentGate {
96 intent: IntentSpec,
97 untrusted_tools: HashSet<String>,
98 policy: IntentGatePolicy,
99 taint: Option<Arc<TaintLedger>>,
103}
104
105impl IntentGate {
106 pub fn new(config: IntentGateConfig) -> Self {
109 Self::build(config, None)
110 }
111
112 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 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 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 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 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, });
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 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 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 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 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 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 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}