use crate::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::ActionProposal;
use car_verify::intent::{
check_intent, gate_intent, intent_actions_from, IntentGatePolicy, IntentSpec,
};
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::collections::HashSet;
use std::path::Path;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntentGateConfig {
#[serde(default)]
pub intent: IntentSpec,
#[serde(default)]
pub untrusted_tools: Vec<String>,
#[serde(default)]
pub on_untainted_drift: Option<car_verify::intent::IntentDisposition>,
}
#[derive(Debug, Clone)]
pub struct IntentLoadError {
pub message: String,
}
impl std::fmt::Display for IntentLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for IntentLoadError {}
pub fn load_intent_config(
car_dir: impl AsRef<Path>,
) -> Result<Option<IntentGateConfig>, IntentLoadError> {
let path = car_dir.as_ref().join("intent.json");
if !path.exists() {
return Ok(None);
}
let raw = std::fs::read_to_string(&path).map_err(|e| IntentLoadError {
message: format!("read {}: {e}", path.display()),
})?;
serde_json::from_str(&raw).map(Some).map_err(|e| IntentLoadError {
message: format!("parse {}: {e}", path.display()),
})
}
pub struct IntentGate {
intent: IntentSpec,
untrusted_tools: HashSet<String>,
policy: IntentGatePolicy,
}
impl IntentGate {
pub fn new(config: IntentGateConfig) -> Self {
let policy = match config.on_untainted_drift {
Some(d) => IntentGatePolicy {
on_untainted_drift: d,
},
None => IntentGatePolicy::default(),
};
Self {
intent: config.intent,
untrusted_tools: config.untrusted_tools.into_iter().collect(),
policy,
}
}
}
#[async_trait::async_trait]
impl AdmissionGate for IntentGate {
fn name(&self) -> &str {
"intent"
}
async fn check(&self, proposal: &ActionProposal, _ctx: &GateContext<'_>) -> GateOutcome {
let intent_actions =
intent_actions_from(&proposal.actions, &self.untrusted_tools, &HashSet::new());
let report = check_intent(&self.intent, &intent_actions);
if report.violations.is_empty() {
return GateOutcome::Allow;
}
let decision = gate_intent(&report, &self.policy);
if !decision.blocked.is_empty() {
return GateOutcome::Reject {
blocked: decision.blocked.iter().map(|v| v.action.clone()).collect(),
reason: decision.reason,
};
}
if !decision.needs_approval.is_empty() {
let mut fps: Vec<String> = decision
.needs_approval
.iter()
.map(car_policy::intent_gate::intent_fingerprint)
.collect();
fps.sort();
fps.dedup();
let canonical = serde_json::to_string(&fps).unwrap_or_default();
let digest = sha2::Sha256::digest(canonical.as_bytes());
return GateOutcome::NeedsApproval {
actions: decision
.needs_approval
.iter()
.map(|v| v.action.clone())
.collect(),
fingerprint: format!("intent:sha256:{:x}", digest),
reason: decision.reason,
};
}
GateOutcome::Allow
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::admission::GateContext;
use car_ir::{Action, ActionProposal, ActionType, FailureBehavior};
use std::collections::HashMap;
fn tool_action(id: &str, tool: &str) -> Action {
Action {
id: id.to_string(),
action_type: ActionType::ToolCall,
tool: Some(tool.to_string()),
parameters: HashMap::new(),
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
read_set: vec![],
write_set: vec![],
assumptions: vec![],
invocation_mode: Default::default(),
idempotent: false,
max_retries: 0,
failure_behavior: FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
}
}
fn proposal(actions: Vec<Action>) -> ActionProposal {
ActionProposal {
id: "p1".to_string(),
source: "test".to_string(),
actions,
timestamp: chrono::Utc::now(),
context: HashMap::new(),
}
}
fn ctx<'a>(
state: &'a HashMap<String, serde_json::Value>,
versions: &'a HashMap<String, u64>,
) -> GateContext<'a> {
GateContext {
session_id: None,
scope: None,
state,
versions,
}
}
#[tokio::test]
async fn injection_signature_is_hard_rejected() {
let gate = IntentGate::new(IntentGateConfig {
intent: IntentSpec {
allowed_tools: vec!["fetch_web".to_string(), "summarize".to_string()],
..Default::default()
},
untrusted_tools: vec!["fetch_web".to_string()],
on_untainted_drift: None,
});
let mut fetch = tool_action("a1", "fetch_web");
fetch.expected_effects.insert("page".into(), serde_json::json!("…"));
let mut pay = tool_action("a2", "send_payment");
pay.state_dependencies.push("page".into());
let p = proposal(vec![fetch, pay]);
let (state, versions) = (HashMap::new(), HashMap::new());
match gate.check(&p, &ctx(&state, &versions)).await {
GateOutcome::Reject { blocked, .. } => assert!(blocked.contains("a2")),
other => panic!("expected Reject, got {other:?}"),
}
}
#[tokio::test]
async fn untainted_drift_escalates_with_content_bound_fingerprint() {
let gate = IntentGate::new(IntentGateConfig {
intent: IntentSpec {
allowed_tools: vec!["summarize".to_string()],
..Default::default()
},
untrusted_tools: vec![],
on_untainted_drift: None, });
let p = proposal(vec![tool_action("a1", "send_email")]);
let (state, versions) = (HashMap::new(), HashMap::new());
match gate.check(&p, &ctx(&state, &versions)).await {
GateOutcome::NeedsApproval {
actions,
fingerprint,
..
} => {
assert!(actions.contains("a1"));
assert!(!fingerprint.is_empty());
}
other => panic!("expected NeedsApproval, got {other:?}"),
}
}
#[tokio::test]
async fn in_intent_proposal_is_allowed() {
let gate = IntentGate::new(IntentGateConfig {
intent: IntentSpec {
allowed_tools: vec!["summarize".to_string()],
..Default::default()
},
untrusted_tools: vec![],
on_untainted_drift: None,
});
let p = proposal(vec![tool_action("a1", "summarize")]);
let (state, versions) = (HashMap::new(), HashMap::new());
assert!(matches!(
gate.check(&p, &ctx(&state, &versions)).await,
GateOutcome::Allow
));
}
#[test]
fn typo_in_drift_policy_is_a_loud_parse_error() {
let tmp = tempfile::tempdir().unwrap();
std::fs::write(
tmp.path().join("intent.json"),
r#"{"on_untainted_drift": "Block"}"#,
)
.unwrap();
assert!(load_intent_config(tmp.path()).is_err(), "wrong case must not parse");
std::fs::write(
tmp.path().join("intent.json"),
r#"{"on_untainted_drift": "block"}"#,
)
.unwrap();
let cfg = load_intent_config(tmp.path()).unwrap().unwrap();
assert_eq!(
cfg.on_untainted_drift,
Some(car_verify::intent::IntentDisposition::Block)
);
}
#[test]
fn loader_absent_is_none_malformed_is_loud() {
let tmp = tempfile::tempdir().unwrap();
assert!(load_intent_config(tmp.path()).unwrap().is_none());
std::fs::write(tmp.path().join("intent.json"), "{not json").unwrap();
assert!(load_intent_config(tmp.path()).is_err());
}
}