use crate::permission::{ApprovalDecision, ApprovalLedger};
use car_verify::{IntentGateDecision, IntentViolation, IntentViolationKind};
use serde::Serialize;
pub fn intent_fingerprint(v: &IntentViolation) -> String {
let kind = match v.kind {
IntentViolationKind::ToolOutOfIntent => "tool",
IntentViolationKind::TargetOutOfIntent => "target",
IntentViolationKind::ForbiddenCapability => "capability",
};
format!("intent:{kind}:{}:{}", v.action, v.detail)
}
#[derive(Debug, Clone, Serialize)]
pub struct PendingIntentApproval {
pub fingerprint: String,
pub violation: IntentViolation,
}
#[derive(Debug, Clone, Serialize)]
pub struct IntentEnforcement {
pub commit: bool,
pub blocked: Vec<IntentViolation>,
pub pending: Vec<PendingIntentApproval>,
pub reason: String,
}
pub fn enforce_intent(
decision: &IntentGateDecision,
ledger: &ApprovalLedger,
) -> IntentEnforcement {
let mut blocked = decision.blocked.clone();
let mut pending = Vec::new();
for v in &decision.needs_approval {
let fp = intent_fingerprint(v);
match ledger.lookup(&fp).map(|r| r.decision) {
Some(ApprovalDecision::Approved) => { }
Some(ApprovalDecision::Rejected) => blocked.push(v.clone()),
None => pending.push(PendingIntentApproval {
fingerprint: fp,
violation: v.clone(),
}),
}
}
let commit = blocked.is_empty() && pending.is_empty();
let reason = if !blocked.is_empty() {
format!(
"blocked: {} out-of-intent action(s) refused ({} pending approval)",
blocked.len(),
pending.len()
)
} else if !pending.is_empty() {
format!("{} out-of-intent action(s) await human approval", pending.len())
} else {
"no out-of-intent actions require action — may commit".to_string()
};
IntentEnforcement {
commit,
blocked,
pending,
reason,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::permission::{ApprovalRecord, PermissionTier};
use car_verify::{
check_intent, gate_intent, IntentAction, IntentGatePolicy, IntentSpec,
};
fn intent() -> IntentSpec {
IntentSpec {
allowed_tools: vec!["search".into()],
allowed_resources: vec!["docs/".into()],
forbidden_capabilities: vec!["exfiltrate".into()],
}
}
fn approval(fp: &str, decision: ApprovalDecision) -> ApprovalRecord {
ApprovalRecord {
fingerprint: fp.to_string(),
required_tier: PermissionTier::FullAccess,
decision,
reviewer: "human".into(),
reason: "test".into(),
evidence: None,
decided_at: "2026-06-30T00:00:00Z".into(),
}
}
fn drift_decision() -> IntentGateDecision {
let actions = vec![IntentAction {
id: "a1".into(),
tool: Some("delete_file".into()),
..Default::default()
}];
let report = check_intent(&intent(), &actions);
gate_intent(&report, &IntentGatePolicy::default())
}
#[test]
fn hard_block_is_never_committable() {
let actions = vec![
IntentAction {
id: "t".into(),
tool: Some("search".into()),
untrusted: true,
..Default::default()
},
IntentAction {
id: "a1".into(),
tool: Some("send_email".into()),
depends_on: vec!["t".into()],
..Default::default()
},
];
let report = check_intent(&intent(), &actions);
let decision = gate_intent(&report, &IntentGatePolicy::default());
let e = enforce_intent(&decision, &ApprovalLedger::new());
assert!(!e.commit);
assert!(!e.blocked.is_empty());
assert!(e.pending.is_empty(), "an injection is blocked, not offered for approval");
}
#[test]
fn novel_drift_is_pending() {
let e = enforce_intent(&drift_decision(), &ApprovalLedger::new());
assert!(!e.commit);
assert_eq!(e.pending.len(), 1);
}
#[test]
fn previously_approved_drift_commits() {
let decision = drift_decision();
let fp = intent_fingerprint(&decision.needs_approval[0]);
let mut ledger = ApprovalLedger::new();
ledger.record(approval(&fp, ApprovalDecision::Approved)).unwrap();
let e = enforce_intent(&decision, &ledger);
assert!(e.commit, "an approved drift commits without re-asking");
assert!(e.pending.is_empty() && e.blocked.is_empty());
}
#[test]
fn previously_rejected_drift_is_blocked() {
let decision = drift_decision();
let fp = intent_fingerprint(&decision.needs_approval[0]);
let mut ledger = ApprovalLedger::new();
ledger.record(approval(&fp, ApprovalDecision::Rejected)).unwrap();
let e = enforce_intent(&decision, &ledger);
assert!(!e.commit && !e.blocked.is_empty() && e.pending.is_empty());
}
#[test]
fn clean_plan_commits() {
let actions = vec![IntentAction {
id: "a1".into(),
tool: Some("search".into()),
..Default::default()
}];
let report = check_intent(&intent(), &actions);
let decision = gate_intent(&report, &IntentGatePolicy::default());
let e = enforce_intent(&decision, &ApprovalLedger::new());
assert!(e.commit);
}
}