car-policy 0.33.0

Policy engine for Common Agent Runtime
Documentation
//! HITL bridge for information-flow enforcement (verifiable tool safety, Slice 3).
//!
//! `car-verify::infoflow` produces a [`FlowGateDecision`] — block / require
//! approval / allow — over a plan's data-flow hazards (arXiv 2601.08012). This
//! module wires the `require_approval` outcomes to CAR's durable
//! human-in-the-loop substrate, the [`ApprovalLedger`] (survey §5.2.5): a
//! hazard a human already approved is let through, one they rejected is blocked,
//! and a novel one becomes a pending approval request. So the runtime confirms
//! *only* the flows that are actually hazardous and *only once* — the paper's
//! "reduce dependence on user confirmation" realized on the existing ledger.
//!
//! Pure and synchronous (like [`crate::permission::PermissionGate`]); the engine
//! calls this in its authz path and surfaces the pending requests. The only
//! remaining engine step is that call site — this module decides, it does not
//! execute.

use crate::permission::{ApprovalDecision, ApprovalLedger};
use car_verify::{FlowGateDecision, FlowViolation, FlowViolationKind};
use serde::Serialize;

/// A stable fingerprint for a flow hazard, so a human decision about it is
/// remembered across runs via the [`ApprovalLedger`]. Built from the violation
/// kind, the involved action ids (sorted — order-independent), and the tainted
/// key, so the "same hazard" maps to the same ledger entry.
pub fn flow_fingerprint(v: &FlowViolation) -> String {
    let kind = match v.kind {
        FlowViolationKind::SensitiveToSink => "sensitive_to_sink",
        FlowViolationKind::ForbiddenSequence => "forbidden_sequence",
    };
    let mut actions = v.actions.clone();
    actions.sort();
    format!(
        "flow:{kind}:{}:{}",
        actions.join(","),
        v.key.as_deref().unwrap_or("")
    )
}

/// A flow hazard awaiting a human decision.
#[derive(Debug, Clone, Serialize)]
pub struct PendingFlowApproval {
    pub fingerprint: String,
    pub violation: FlowViolation,
}

/// The result of enforcing a [`FlowGateDecision`] against the approval ledger.
#[derive(Debug, Clone, Serialize)]
pub struct FlowEnforcement {
    /// True only when nothing is blocked and nothing is pending — the plan may
    /// run autonomously.
    pub allow: bool,
    /// Hazards that must not run: hard `block`s plus anything a human rejected.
    pub blocked: Vec<FlowViolation>,
    /// Novel `require_approval` hazards awaiting a human decision.
    pub pending: Vec<PendingFlowApproval>,
    pub reason: String,
}

/// Enforce a [`FlowGateDecision`] against the durable [`ApprovalLedger`].
///
/// - `block` violations are always blocked.
/// - `require_approval` violations are resolved against the ledger by
///   [`flow_fingerprint`]: previously **approved** → allowed; previously
///   **rejected** → blocked; **unseen** → pending (needs HITL).
///
/// `allow` is true only when nothing is blocked and nothing is pending.
pub fn enforce_flow(decision: &FlowGateDecision, ledger: &ApprovalLedger) -> FlowEnforcement {
    let mut blocked = decision.blocked.clone();
    let mut pending = Vec::new();

    for v in &decision.needs_approval {
        let fp = flow_fingerprint(v);
        match ledger.lookup(&fp).map(|r| r.decision) {
            Some(ApprovalDecision::Approved) => { /* a human OK'd this hazard before */ }
            Some(ApprovalDecision::Rejected) => blocked.push(v.clone()),
            None => pending.push(PendingFlowApproval {
                fingerprint: fp,
                violation: v.clone(),
            }),
        }
    }

    let allow = blocked.is_empty() && pending.is_empty();
    let reason = if !blocked.is_empty() {
        format!(
            "blocked: {} flow hazard(s) refused ({} pending approval)",
            blocked.len(),
            pending.len()
        )
    } else if !pending.is_empty() {
        format!("{} flow hazard(s) await human approval", pending.len())
    } else {
        "no flow hazards require action — may proceed".to_string()
    };

    FlowEnforcement {
        allow,
        blocked,
        pending,
        reason,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::permission::{ApprovalRecord, PermissionTier};
    use car_verify::{FlowAction, FlowReport, FlowViolation, FlowViolationKind};

    fn violation(kind: FlowViolationKind, actions: &[&str], key: Option<&str>) -> FlowViolation {
        FlowViolation {
            kind,
            actions: actions.iter().map(|s| s.to_string()).collect(),
            key: key.map(|s| s.to_string()),
            explanation: "x".into(),
            mitigation: "y".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-28T00:00:00Z".into(),
        }
    }

    #[test]
    fn hard_block_is_blocked() {
        let decision = FlowGateDecision {
            action: FlowAction::Block,
            blocked: vec![violation(FlowViolationKind::SensitiveToSink, &["a2"], Some("s"))],
            needs_approval: vec![],
            reason: "".into(),
        };
        let r = enforce_flow(&decision, &ApprovalLedger::new());
        assert!(!r.allow);
        assert_eq!(r.blocked.len(), 1);
    }

    #[test]
    fn novel_approval_is_pending() {
        let decision = FlowGateDecision {
            action: FlowAction::RequireApproval,
            blocked: vec![],
            needs_approval: vec![violation(FlowViolationKind::ForbiddenSequence, &["a1", "a2"], None)],
            reason: "".into(),
        };
        let r = enforce_flow(&decision, &ApprovalLedger::new());
        assert!(!r.allow);
        assert_eq!(r.pending.len(), 1);
    }

    #[test]
    fn previously_approved_is_allowed() {
        let v = violation(FlowViolationKind::ForbiddenSequence, &["a1", "a2"], None);
        let fp = flow_fingerprint(&v);
        let mut ledger = ApprovalLedger::new();
        ledger.record(approval(&fp, ApprovalDecision::Approved)).unwrap();

        let decision = FlowGateDecision {
            action: FlowAction::RequireApproval,
            blocked: vec![],
            needs_approval: vec![v],
            reason: "".into(),
        };
        let r = enforce_flow(&decision, &ledger);
        assert!(r.allow, "a previously-approved hazard runs without re-asking");
        assert!(r.pending.is_empty() && r.blocked.is_empty());
    }

    #[test]
    fn previously_rejected_is_blocked() {
        let v = violation(FlowViolationKind::ForbiddenSequence, &["a1", "a2"], None);
        let fp = flow_fingerprint(&v);
        let mut ledger = ApprovalLedger::new();
        ledger.record(approval(&fp, ApprovalDecision::Rejected)).unwrap();

        let decision = FlowGateDecision {
            action: FlowAction::RequireApproval,
            blocked: vec![],
            needs_approval: vec![v],
            reason: "".into(),
        };
        let r = enforce_flow(&decision, &ledger);
        assert!(!r.allow);
        assert_eq!(r.blocked.len(), 1);
    }

    #[test]
    fn fingerprint_is_action_order_independent() {
        let a = violation(FlowViolationKind::ForbiddenSequence, &["a1", "a2"], None);
        let b = violation(FlowViolationKind::ForbiddenSequence, &["a2", "a1"], None);
        assert_eq!(flow_fingerprint(&a), flow_fingerprint(&b));
    }

    #[test]
    fn clean_report_allows() {
        let report = FlowReport { safe: true, violations: vec![] };
        let decision = car_verify::gate_flow(&report, &Default::default());
        let r = enforce_flow(&decision, &ApprovalLedger::new());
        assert!(r.allow);
    }
}