use crate::permission::{partition_by_ledger, 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 part = partition_by_ledger(
decision.blocked.clone(),
&decision.needs_approval,
intent_fingerprint,
ledger,
);
let blocked = part.blocked;
let pending: Vec<PendingIntentApproval> = part
.pending
.into_iter()
.map(|(fingerprint, violation)| PendingIntentApproval {
fingerprint,
violation,
})
.collect();
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::{ApprovalDecision, 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);
}
}