car-policy 0.52.1

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

    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::{ApprovalDecision, 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);
    }
}