car-engine 0.32.1

Core runtime engine for Common Agent Runtime
//! Proposal-admission gates — the executor's pre-execution safety seam
//! (EPIC A, task A1).
//!
//! CAR ships a large suite of *verified* safety checks as pure library
//! functions — information-flow analysis (`car_verify::check_information_flow`),
//! concurrency-anomaly detection (`car_verify::analyze_concurrency`),
//! tool-receipt grounding (`car_eventlog::verify_tool_receipts`), policy
//! enforcement (`car_policy`). Historically none of them were *called* by
//! the runtime on a normal proposal: a check was only as good as the
//! consumer who remembered to invoke it.
//!
//! This module is the single place those checks attach to the live
//! [`crate::Runtime`]. An [`AdmissionGate`] inspects a proposal *before any
//! action runs* and returns a [`GateOutcome`]. The executor runs every
//! registered gate during proposal admission (right after the existing
//! transactional pre-check), aggregates the verdicts, and refuses to
//! execute a proposal that any gate blocks or parks for approval.
//!
//! A1 establishes the seam and the aggregation contract; the individual
//! gates (information-flow → A4, concurrency → A5, blocking-policy → A9)
//! are thin [`AdmissionGate`] implementations layered on top, and the
//! approval routing for [`GateOutcome::NeedsApproval`] is wired in A7.
//!
//! The default gate list is empty, so a `Runtime` that registers no gates
//! behaves exactly as before — this is purely additive.

use crate::scope::RuntimeScope;
use car_ir::ActionProposal;
use serde_json::Value;
use std::collections::{HashMap, HashSet};

/// Read-only context handed to each gate at admission time.
///
/// It carries the cheap, always-available facts a gate needs to reason
/// about a proposal without reaching back into the `Runtime` (which would
/// create a borrow cycle, since gates are *stored on* the runtime). Gates
/// that need richer inputs — per-tool information-flow labels (A3/A4), the
/// timestamped multi-agent schedule (A5) — hold those as their own state,
/// captured when the gate is constructed.
pub struct GateContext<'a> {
    /// The session the proposal executes under, if any. Lets a gate apply
    /// session-scoped rules on top of global ones, mirroring the
    /// per-session policy registries.
    pub session_id: Option<&'a str>,
    /// The caller/tenant identity attached to this execution (car#187).
    pub scope: Option<&'a RuntimeScope>,
    /// A snapshot of shared state at admission time.
    pub state: &'a HashMap<String, Value>,
    /// Per-key version counters for the same snapshot — the input the
    /// transactional / information-flow checks reason over.
    pub versions: &'a HashMap<String, u64>,
}

/// The verdict a single [`AdmissionGate`] returns for a proposal.
#[derive(Debug, Clone)]
pub enum GateOutcome {
    /// The gate raises no objection. Execution may proceed (subject to the
    /// other gates and the normal per-action validation/policy pipeline).
    Allow,
    /// The gate forbids execution. `blocked` names the offending action
    /// ids (empty means "the proposal as a whole"); `reason` is the
    /// human-readable explanation surfaced on the rejected results and the
    /// audit log. A rejected proposal does not run *any* action — a safety
    /// hazard is a property of the action set, not an isolated action, the
    /// same stance the transactional pre-check takes.
    Reject {
        blocked: HashSet<String>,
        reason: String,
    },
    /// The gate would allow the proposal only with human approval.
    /// `fingerprint` is the stable identity an operator approves/rejects
    /// against (so a prior decision sticks); `actions` names the actions
    /// that triggered the escalation.
    ///
    /// Until the durable approval transport is wired (A7), the executor
    /// treats this as a block with an explanatory reason — fail-closed,
    /// never fail-open. A7 replaces that with a real pending-approval that
    /// resolves through the `permission.*` surface.
    NeedsApproval {
        actions: HashSet<String>,
        fingerprint: String,
        reason: String,
    },
}

impl GateOutcome {
    /// Convenience constructor for a whole-proposal rejection.
    pub fn reject_all(reason: impl Into<String>) -> Self {
        GateOutcome::Reject {
            blocked: HashSet::new(),
            reason: reason.into(),
        }
    }

    /// Convenience constructor for rejecting specific actions.
    pub fn reject_actions<I, S>(blocked: I, reason: impl Into<String>) -> Self
    where
        I: IntoIterator<Item = S>,
        S: Into<String>,
    {
        GateOutcome::Reject {
            blocked: blocked.into_iter().map(Into::into).collect(),
            reason: reason.into(),
        }
    }

    /// True when this outcome permits execution.
    pub fn is_allow(&self) -> bool {
        matches!(self, GateOutcome::Allow)
    }

    /// The serde-stable label for the audit event (`allow` / `reject` /
    /// `needs_approval`). Kept as an explicit method rather than `Debug`
    /// so the wire/doc contract is stable.
    pub fn label(&self) -> &'static str {
        match self {
            GateOutcome::Allow => "allow",
            GateOutcome::Reject { .. } => "reject",
            GateOutcome::NeedsApproval { .. } => "needs_approval",
        }
    }
}

/// A pre-execution safety check that can veto a proposal.
///
/// Implementations must be cheap and side-effect-free: they run on the hot
/// path of every admitted proposal. A gate that needs to *do* something on
/// rejection (record an approval request, emit specialized telemetry)
/// returns the verdict and lets the executor's admission loop handle the
/// uniform consequences (rejection results, the `AdmissionGateDecision`
/// event); gate-specific events are emitted by the gate itself before it
/// returns.
#[async_trait::async_trait]
pub trait AdmissionGate: Send + Sync {
    /// Short stable identifier (e.g. `"information_flow"`, `"concurrency"`)
    /// recorded on the audit event so a denial is attributable to a gate.
    fn name(&self) -> &str;

    /// Inspect a proposal and return a verdict. Must not mutate shared
    /// state — admission runs before the execution snapshot is taken.
    async fn check(&self, proposal: &ActionProposal, ctx: &GateContext<'_>) -> GateOutcome;
}

/// One gate's approval escalation, carrying its own fingerprint. Every
/// escalation must be individually approved for the proposal to run —
/// a single approved fingerprint must never clear another gate's
/// objection.
#[derive(Debug, Clone)]
pub struct AdmissionEscalation {
    /// The escalating gate's name.
    pub gate: String,
    /// The stable identity an operator approves/rejects against.
    pub fingerprint: String,
    /// The gate's human-readable reason.
    pub reason: String,
    /// The action ids that triggered this escalation.
    pub actions: HashSet<String>,
}

/// The aggregate decision after running every registered gate.
///
/// Aggregation is conjunctive and fail-closed: the proposal is admitted
/// only if *every* gate allowed it. The first blocking gate's reason and
/// name are surfaced as the primary cause; all blocked action ids across
/// gates are unioned so the rejected results name every offending action.
///
/// Two orthogonal severities are tracked:
/// - `hard_rejected`: at least one gate returned a hard `Reject`. Never
///   overridable — no ledger approval (however old or broad) may clear it.
/// - `escalations`: every `NeedsApproval` outcome, **each with its own
///   fingerprint**. The executor admits only when *all* of them resolve
///   to an operator approval; one pending or rejected fingerprint keeps
///   the proposal blocked (fail-closed).
#[derive(Debug, Clone, Default)]
pub struct AdmissionDecision {
    /// True when no gate objected and execution may proceed.
    pub admitted: bool,
    /// Union of action ids any gate blocked. Empty with `admitted == false`
    /// means a whole-proposal rejection.
    pub blocked: HashSet<String>,
    /// The name of the first gate that objected, for attribution.
    pub deciding_gate: Option<String>,
    /// The first objecting gate's human-readable reason.
    pub reason: Option<String>,
    /// True when at least one gate hard-rejected. A hard reject is never
    /// resolvable via the approval ledger.
    pub hard_rejected: bool,
    /// Every approval escalation from every gate, in gate order. All of
    /// them must be approved for the proposal to run.
    pub escalations: Vec<AdmissionEscalation>,
}

impl AdmissionDecision {
    /// The all-clear decision.
    pub fn admit() -> Self {
        AdmissionDecision {
            admitted: true,
            ..Default::default()
        }
    }

    /// True when the block consists solely of approval escalations — i.e.
    /// resolvable by an operator, not a hard deny.
    pub fn needs_approval(&self) -> bool {
        !self.hard_rejected && !self.escalations.is_empty()
    }

    /// Fold a single gate's outcome into the running decision. Once a
    /// blocking outcome is recorded, later allowing gates can't un-block
    /// it; later blocking gates still contribute their action ids to the
    /// union and their escalations to the list (so the rejection report
    /// is complete and every escalation keeps its own fingerprint).
    pub fn absorb(&mut self, gate_name: &str, outcome: GateOutcome) {
        match outcome {
            GateOutcome::Allow => {}
            GateOutcome::Reject { blocked, reason } => {
                self.blocked.extend(blocked);
                self.hard_rejected = true;
                if self.admitted {
                    self.admitted = false;
                    self.deciding_gate = Some(gate_name.to_string());
                    self.reason = Some(reason);
                } else if self.deciding_gate.is_none() {
                    self.deciding_gate = Some(gate_name.to_string());
                    self.reason = Some(reason);
                }
            }
            GateOutcome::NeedsApproval {
                actions,
                fingerprint,
                reason,
            } => {
                self.blocked.extend(actions.clone());
                self.escalations.push(AdmissionEscalation {
                    gate: gate_name.to_string(),
                    fingerprint,
                    reason: reason.clone(),
                    actions,
                });
                if self.admitted {
                    self.admitted = false;
                    self.deciding_gate = Some(gate_name.to_string());
                    self.reason = Some(reason);
                }
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn empty_decision_admits() {
        let d = AdmissionDecision::admit();
        assert!(d.admitted);
        assert!(d.blocked.is_empty());
    }

    #[test]
    fn reject_marks_first_gate_and_unions_actions() {
        let mut d = AdmissionDecision::admit();
        d.absorb("flow", GateOutcome::reject_actions(["a1"], "exfil"));
        d.absorb("concurrency", GateOutcome::reject_actions(["a2"], "stale"));
        assert!(!d.admitted);
        // First objecting gate is the attributed cause.
        assert_eq!(d.deciding_gate.as_deref(), Some("flow"));
        assert_eq!(d.reason.as_deref(), Some("exfil"));
        // Both gates' actions are reported.
        assert!(d.blocked.contains("a1"));
        assert!(d.blocked.contains("a2"));
        assert!(!d.needs_approval());
        assert!(d.hard_rejected);
    }

    #[test]
    fn allow_after_reject_stays_rejected() {
        let mut d = AdmissionDecision::admit();
        d.absorb("flow", GateOutcome::reject_all("nope"));
        d.absorb("other", GateOutcome::Allow);
        assert!(!d.admitted);
        assert_eq!(d.deciding_gate.as_deref(), Some("flow"));
    }

    #[test]
    fn needs_approval_is_tracked() {
        let mut d = AdmissionDecision::admit();
        d.absorb(
            "permission",
            GateOutcome::NeedsApproval {
                actions: ["a1".to_string()].into_iter().collect(),
                fingerprint: "fp123".to_string(),
                reason: "tier escalation".to_string(),
            },
        );
        assert!(!d.admitted);
        assert!(d.needs_approval());
        assert_eq!(d.escalations.len(), 1);
        assert_eq!(d.escalations[0].fingerprint, "fp123");
        assert_eq!(d.escalations[0].gate, "permission");
    }

    #[test]
    fn every_escalation_keeps_its_own_fingerprint() {
        // C-1 regression: a second gate's escalation must not be
        // swallowed by the first — each carries its own fingerprint and
        // the executor requires ALL of them approved.
        let mut d = AdmissionDecision::admit();
        d.absorb(
            "flow",
            GateOutcome::NeedsApproval {
                actions: ["a1".to_string()].into_iter().collect(),
                fingerprint: "fp-flow".to_string(),
                reason: "flow hazard".to_string(),
            },
        );
        d.absorb(
            "skill_ceiling",
            GateOutcome::NeedsApproval {
                actions: ["a2".to_string()].into_iter().collect(),
                fingerprint: "fp-ceiling".to_string(),
                reason: "over ceiling".to_string(),
            },
        );
        assert!(d.needs_approval());
        let fps: Vec<&str> = d.escalations.iter().map(|e| e.fingerprint.as_str()).collect();
        assert_eq!(fps, vec!["fp-flow", "fp-ceiling"]);
    }

    #[test]
    fn hard_reject_dominates_escalations() {
        // C-1 regression: once any gate hard-rejects, the decision is not
        // approval-resolvable, regardless of gate order.
        let mut d = AdmissionDecision::admit();
        d.absorb(
            "flow",
            GateOutcome::NeedsApproval {
                actions: ["a1".to_string()].into_iter().collect(),
                fingerprint: "fp-flow".to_string(),
                reason: "flow hazard".to_string(),
            },
        );
        d.absorb("rules", GateOutcome::reject_actions(["a2"], "deny rule"));
        assert!(d.hard_rejected);
        assert!(!d.needs_approval(), "a hard reject is never approval-resolvable");
    }

    #[test]
    fn outcome_labels_are_stable() {
        assert_eq!(GateOutcome::Allow.label(), "allow");
        assert_eq!(GateOutcome::reject_all("x").label(), "reject");
    }
}