use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntentSpec {
#[serde(default)]
pub allowed_tools: Vec<String>,
#[serde(default)]
pub allowed_resources: Vec<String>,
#[serde(default)]
pub forbidden_capabilities: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntentAction {
pub id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool: Option<String>,
#[serde(default)]
pub targets: Vec<String>,
#[serde(default)]
pub capabilities: Vec<String>,
#[serde(default)]
pub depends_on: Vec<String>,
#[serde(default)]
pub untrusted: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IntentViolationKind {
ToolOutOfIntent,
TargetOutOfIntent,
ForbiddenCapability,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct IntentViolation {
pub action: String,
pub kind: IntentViolationKind,
pub detail: String,
pub tool_influenced: bool,
pub explanation: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntentReport {
pub safe: bool,
pub commit_blocked: Vec<String>,
pub violations: Vec<IntentViolation>,
}
fn influenced_set(actions: &[IntentAction]) -> HashSet<String> {
let by_id: HashMap<&str, &IntentAction> =
actions.iter().map(|a| (a.id.as_str(), a)).collect();
let mut influenced: HashSet<String> = HashSet::new();
fn visit(
id: &str,
by_id: &HashMap<&str, &IntentAction>,
influenced: &mut HashSet<String>,
visiting: &mut HashSet<String>,
) -> bool {
if influenced.contains(id) {
return true;
}
if !visiting.insert(id.to_string()) {
return false;
}
let result = match by_id.get(id) {
Some(a) => {
a.untrusted
|| a
.depends_on
.iter()
.any(|d| visit(d, by_id, influenced, visiting))
}
None => false,
};
visiting.remove(id);
if result {
influenced.insert(id.to_string());
}
result
}
for a in actions {
let mut visiting = HashSet::new();
visit(&a.id, &by_id, &mut influenced, &mut visiting);
}
influenced
}
fn covered(target: &str, allowed: &[String]) -> bool {
allowed.is_empty() || allowed.iter().any(|p| target.starts_with(p.as_str()))
}
pub fn check_intent(intent: &IntentSpec, actions: &[IntentAction]) -> IntentReport {
let influenced = influenced_set(actions);
let mut violations = Vec::new();
let mut commit_blocked: Vec<String> = Vec::new();
for a in actions {
let tainted = influenced.contains(&a.id);
let mut block = false;
let mut push = |kind: IntentViolationKind, detail: String, explanation: String| {
violations.push(IntentViolation {
action: a.id.clone(),
kind,
detail,
tool_influenced: tainted,
explanation,
});
};
if let Some(tool) = &a.tool {
if !intent.allowed_tools.is_empty() && !intent.allowed_tools.contains(tool) {
let why = if tainted {
format!(
"tool '{tool}' is outside the user's intent and the call is influenced by \
an untrusted tool result — likely tool-stream injection; reject before commit"
)
} else {
format!("tool '{tool}' is outside the user's declared intent")
};
push(IntentViolationKind::ToolOutOfIntent, tool.clone(), why);
block |= tainted;
}
}
for t in &a.targets {
if !covered(t, &intent.allowed_resources) {
let why = if tainted {
format!(
"target '{t}' is outside the intent's resource scope and is influenced by \
an untrusted tool result — likely injection; reject before commit"
)
} else {
format!("target '{t}' is outside the intent's declared resource scope")
};
push(IntentViolationKind::TargetOutOfIntent, t.clone(), why);
block |= tainted;
}
}
for c in &a.capabilities {
if intent.forbidden_capabilities.contains(c) {
push(
IntentViolationKind::ForbiddenCapability,
c.clone(),
format!("capability '{c}' is forbidden by the user's intent"),
);
block = true;
}
}
if block {
commit_blocked.push(a.id.clone());
}
}
IntentReport {
safe: commit_blocked.is_empty(),
commit_blocked,
violations,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IntentDisposition {
Allow,
RequireApproval,
Block,
}
fn default_on_untainted_drift() -> IntentDisposition {
IntentDisposition::RequireApproval
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntentGatePolicy {
#[serde(default = "default_on_untainted_drift")]
pub on_untainted_drift: IntentDisposition,
}
impl Default for IntentGatePolicy {
fn default() -> Self {
Self {
on_untainted_drift: default_on_untainted_drift(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IntentGateDecision {
pub action: IntentDisposition,
pub blocked: Vec<IntentViolation>,
pub needs_approval: Vec<IntentViolation>,
pub reason: String,
}
pub fn gate_intent(report: &IntentReport, policy: &IntentGatePolicy) -> IntentGateDecision {
let mut blocked = Vec::new();
let mut needs_approval = Vec::new();
for v in &report.violations {
let disposition =
if v.kind == IntentViolationKind::ForbiddenCapability || v.tool_influenced {
IntentDisposition::Block
} else {
policy.on_untainted_drift
};
match disposition {
IntentDisposition::Block => blocked.push(v.clone()),
IntentDisposition::RequireApproval => needs_approval.push(v.clone()),
IntentDisposition::Allow => {}
}
}
let action = if !blocked.is_empty() {
IntentDisposition::Block
} else if !needs_approval.is_empty() {
IntentDisposition::RequireApproval
} else {
IntentDisposition::Allow
};
let reason = match action {
IntentDisposition::Block => format!(
"blocked: {} action(s) must not commit ({} also need approval)",
blocked.len(),
needs_approval.len()
),
IntentDisposition::RequireApproval => format!(
"{} out-of-intent action(s) require human approval before commit",
needs_approval.len()
),
IntentDisposition::Allow => "all actions are within the declared intent".to_string(),
};
IntentGateDecision {
action,
blocked,
needs_approval,
reason,
}
}
pub fn intent_actions_from(
actions: &[car_ir::Action],
untrusted_tools: &HashSet<String>,
untrusted_ids: &HashSet<String>,
) -> Vec<IntentAction> {
let edges = car_ir::dependency_edges(actions);
actions
.iter()
.enumerate()
.map(|(i, a)| {
let depends_on = edges[i].iter().map(|&d| actions[d].id.clone()).collect();
let capabilities = a
.metadata
.get("capabilities")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let untrusted = untrusted_ids.contains(&a.id)
|| a.tool
.as_ref()
.map(|t| untrusted_tools.contains(t))
.unwrap_or(false);
IntentAction {
id: a.id.clone(),
tool: a.tool.clone(),
targets: a.effective_write_set(),
capabilities,
depends_on,
untrusted,
}
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn act(id: &str) -> IntentAction {
IntentAction {
id: id.into(),
..Default::default()
}
}
fn intent() -> IntentSpec {
IntentSpec {
allowed_tools: vec!["search".into(), "read_file".into()],
allowed_resources: vec!["docs/".into()],
forbidden_capabilities: vec!["exfiltrate".into(), "payment".into()],
}
}
#[test]
fn in_intent_plan_is_safe() {
let actions = vec![IntentAction {
tool: Some("read_file".into()),
targets: vec!["docs/readme.md".into()],
..act("a1")
}];
let r = check_intent(&intent(), &actions);
assert!(r.safe, "{r:?}");
assert!(r.violations.is_empty());
}
#[test]
fn injected_tool_call_is_blocked() {
let actions = vec![
IntentAction {
tool: Some("search".into()),
untrusted: true,
..act("a1")
},
IntentAction {
tool: Some("send_email".into()),
depends_on: vec!["a1".into()],
..act("a2")
},
];
let r = check_intent(&intent(), &actions);
assert!(!r.safe);
assert_eq!(r.commit_blocked, vec!["a2".to_string()]);
let v = r.violations.iter().find(|v| v.action == "a2").unwrap();
assert_eq!(v.kind, IntentViolationKind::ToolOutOfIntent);
assert!(v.tool_influenced);
}
#[test]
fn out_of_intent_but_untainted_is_reported_not_blocked() {
let actions = vec![IntentAction {
tool: Some("send_email".into()),
..act("a1")
}];
let r = check_intent(&intent(), &actions);
assert!(r.safe, "untainted drift doesn't block commit: {r:?}");
assert_eq!(r.violations.len(), 1);
assert!(!r.violations[0].tool_influenced);
}
#[test]
fn forbidden_capability_always_blocks() {
let actions = vec![IntentAction {
tool: Some("read_file".into()),
capabilities: vec!["exfiltrate".into()],
..act("a1")
}];
let r = check_intent(&intent(), &actions);
assert!(!r.safe);
assert_eq!(r.commit_blocked, vec!["a1".to_string()]);
assert_eq!(
r.violations[0].kind,
IntentViolationKind::ForbiddenCapability
);
}
#[test]
fn influence_propagates_transitively() {
let actions = vec![
IntentAction {
tool: Some("search".into()),
untrusted: true,
..act("a1")
},
IntentAction {
depends_on: vec!["a1".into()],
..act("a2")
},
IntentAction {
targets: vec!["secrets/key".into()],
depends_on: vec!["a2".into()],
..act("a3")
},
];
let r = check_intent(&intent(), &actions);
assert_eq!(r.commit_blocked, vec!["a3".to_string()]);
let v = &r.violations[0];
assert_eq!(v.kind, IntentViolationKind::TargetOutOfIntent);
assert!(v.tool_influenced);
}
#[test]
fn empty_intent_imposes_no_tool_or_resource_limit() {
let actions = vec![IntentAction {
tool: Some("anything".into()),
targets: vec!["anywhere".into()],
untrusted: true,
..act("a1")
}];
let r = check_intent(&IntentSpec::default(), &actions);
assert!(r.safe, "no restrictions ⇒ nothing out of intent: {r:?}");
}
#[test]
fn gate_blocks_injection_and_escalates_drift() {
let actions = vec![
IntentAction {
tool: Some("search".into()),
untrusted: true,
..act("a1")
},
IntentAction {
tool: Some("send_email".into()),
depends_on: vec!["a1".into()],
..act("a2")
},
IntentAction {
tool: Some("delete_file".into()),
..act("a3")
},
];
let report = check_intent(&intent(), &actions);
let decision = gate_intent(&report, &IntentGatePolicy::default());
assert_eq!(decision.action, IntentDisposition::Block);
assert_eq!(decision.blocked.len(), 1);
assert_eq!(decision.blocked[0].action, "a2");
assert_eq!(decision.needs_approval.len(), 1);
assert_eq!(decision.needs_approval[0].action, "a3");
}
#[test]
fn gate_allow_drift_policy_lets_untainted_through() {
let actions = vec![IntentAction {
tool: Some("delete_file".into()),
..act("a1")
}];
let report = check_intent(&intent(), &actions);
let policy = IntentGatePolicy {
on_untainted_drift: IntentDisposition::Allow,
};
let decision = gate_intent(&report, &policy);
assert_eq!(decision.action, IntentDisposition::Allow);
assert!(decision.blocked.is_empty() && decision.needs_approval.is_empty());
}
#[test]
fn populater_maps_ir_to_intent_actions() {
let actions: Vec<car_ir::Action> = serde_json::from_str(
r#"[
{"id":"fetch","type":"tool_call","tool":"web_fetch","expected_effects":{"page":"x"}},
{"id":"act","type":"tool_call","tool":"send_email","state_dependencies":["page"],
"expected_effects":{"outbox":"y"},"metadata":{"capabilities":["network"]}}
]"#,
)
.unwrap();
let untrusted_tools: HashSet<String> = ["web_fetch".to_string()].into_iter().collect();
let derived = intent_actions_from(&actions, &untrusted_tools, &HashSet::new());
let fetch = derived.iter().find(|a| a.id == "fetch").unwrap();
assert!(fetch.untrusted, "web_fetch reads an open surface");
assert_eq!(fetch.targets, vec!["page".to_string()]);
let act = derived.iter().find(|a| a.id == "act").unwrap();
assert_eq!(act.depends_on, vec!["fetch".to_string()], "reads what fetch wrote");
assert_eq!(act.capabilities, vec!["network".to_string()]);
assert!(!act.untrusted, "its own tool isn't an untrusted source");
}
#[test]
fn populated_plan_blocks_injected_action() {
let actions: Vec<car_ir::Action> = serde_json::from_str(
r#"[
{"id":"fetch","type":"tool_call","tool":"search","expected_effects":{"page":"x"}},
{"id":"send","type":"tool_call","tool":"send_email","state_dependencies":["page"]}
]"#,
)
.unwrap();
let untrusted: HashSet<String> = ["search".to_string()].into_iter().collect();
let ia = intent_actions_from(&actions, &untrusted, &HashSet::new());
let intent = IntentSpec {
allowed_tools: vec!["search".into()],
..Default::default()
};
let r = check_intent(&intent, &ia);
assert_eq!(r.commit_blocked, vec!["send".to_string()]);
}
#[test]
fn dependency_cycle_does_not_hang() {
let actions = vec![
IntentAction {
depends_on: vec!["a2".into()],
..act("a1")
},
IntentAction {
depends_on: vec!["a1".into()],
untrusted: true,
..act("a2")
},
];
let inf = influenced_set(&actions);
assert!(inf.contains("a2") && inf.contains("a1"));
}
}