car-policy 0.55.0

Policy engine for Common Agent Runtime
Documentation
//! HITL bridge for intent-grounded verification (VIGIL, arXiv 2601.05755 —
//! Slice 3).
//!
//! `car-verify::intent` produces an [`IntentGateDecision`] — block / require
//! approval / allow — over a plan's out-of-intent actions. This module wires the
//! `require_approval` outcomes to CAR's durable human-in-the-loop substrate, the
//! [`ApprovalLedger`] (survey §5.2.5), exactly as [`crate::flow_gate`] does for
//! information-flow hazards: an out-of-intent action a human already approved is
//! let through, one they rejected is blocked, and a novel one becomes a pending
//! request. So the runtime confirms *only* the actions that actually drift from
//! the declared task, and *only once*.
//!
//! Pure and synchronous. Hard blocks (tool-stream injections and forbidden
//! capabilities) are never offered for approval here — the gate already refused
//! them; this module only resolves the borderline `require_approval` set.

use crate::permission::{partition_by_ledger, ApprovalLedger};
use car_verify::{IntentGateDecision, IntentViolation, IntentViolationKind};
use serde::Serialize;

/// A stable fingerprint for an out-of-intent action, so a human decision about it
/// is remembered across runs via the [`ApprovalLedger`]. Built from the violation
/// kind, the action id, and the offending detail (tool/resource/capability).
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)
}

/// An out-of-intent action awaiting a human decision.
#[derive(Debug, Clone, Serialize)]
pub struct PendingIntentApproval {
    pub fingerprint: String,
    pub violation: IntentViolation,
}

/// The result of enforcing an [`IntentGateDecision`] against the approval ledger.
#[derive(Debug, Clone, Serialize)]
pub struct IntentEnforcement {
    /// True only when nothing is blocked and nothing is pending — every action
    /// may commit.
    pub commit: bool,
    /// Actions that must not commit: hard blocks (injections / forbidden
    /// capabilities) plus anything a human rejected.
    pub blocked: Vec<IntentViolation>,
    /// Novel out-of-intent actions awaiting a human decision.
    pub pending: Vec<PendingIntentApproval>,
    pub reason: String,
}

/// Enforce an [`IntentGateDecision`] against the durable [`ApprovalLedger`].
///
/// - `blocked` violations stay blocked (the gate refused them).
/// - `needs_approval` violations are resolved against the ledger by
///   [`intent_fingerprint`]: previously **approved** → committed; previously
///   **rejected** → blocked; **unseen** → pending (needs HITL).
///
/// `commit` is true only when nothing is blocked and nothing is pending.
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(),
        }
    }

    /// The model's own untainted drift (an unlisted tool, not downstream of a
    /// tool result) → escalated by the gate → pending on a fresh ledger.
    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() {
        // An injected (tool-influenced) out-of-intent call → hard block.
        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);
    }
}