car-engine 0.32.1

Core runtime engine for Common Agent Runtime
//! VIGIL intent gate — the live in-loop verify-before-commit call-site
//! (arXiv 2601.05755; `docs/proposals/intent-grounded-verification.md`).
//!
//! The VIGIL slices shipped as stateless verify/policy cores
//! (`car_verify::intent::check_intent` / `gate_intent`,
//! `car_policy::intent_gate::enforce_intent`); nothing on the runtime
//! *called* them on a normal proposal. This module attaches them to the
//! executor's admission seam as an [`AdmissionGate`] — per the epic-merge
//! review's unification finding: ONE enforcement plumbing, the executor's
//! central ledger resolution (A7), instead of a second parallel
//! ledger-resolving authority.
//!
//! Semantics preserved from the cores: a **forbidden capability** or a
//! **tool-stream-influenced out-of-intent action** (reachable from an
//! `untrusted` tool's result through the dependency graph — the injection
//! signature) is a hard `Reject`, never approvable. Untainted drift is
//! policy-driven (default: escalate to approval, resolved against the
//! durable ledger by content-bound fingerprint).
//!
//! Project config: `.car/intent.json` (the `tool-labels.json` idiom) —
//! an [`IntentGateConfig`] naming the `intent` spec, the `untrusted_tools`
//! whose outputs count as tool-stream input, and the untainted-drift
//! policy.

use crate::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::ActionProposal;
use car_verify::intent::{
    check_intent, gate_intent, intent_actions_from, IntentGatePolicy, IntentSpec,
};
use serde::{Deserialize, Serialize};
use sha2::Digest;
use std::collections::HashSet;
use std::path::Path;

/// The deserialized `.car/intent.json` document.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IntentGateConfig {
    /// The declared task intent (VIGIL's root of trust).
    #[serde(default)]
    pub intent: IntentSpec,
    /// Tools whose RESULTS are tool-stream input from an open environment
    /// (web fetch, inbox read, …). An action calling one — and everything
    /// downstream of it in the dependency graph — is treated as
    /// potentially injection-influenced.
    #[serde(default)]
    pub untrusted_tools: Vec<String>,
    /// Untainted-drift policy — deserialized DIRECTLY as
    /// [`car_verify::intent::IntentDisposition`] (`"allow"` /
    /// `"require_approval"` / `"block"`, snake_case). Typed so a typo'd
    /// or wrong-case value ("Block", "deny") is a LOUD parse error,
    /// never a silent downgrade of a hard block to approvable (linus
    /// review). Absent = the core's default (require_approval).
    #[serde(default)]
    pub on_untainted_drift: Option<car_verify::intent::IntentDisposition>,
}

/// Error raised while loading `.car/intent.json`.
#[derive(Debug, Clone)]
pub struct IntentLoadError {
    pub message: String,
}

impl std::fmt::Display for IntentLoadError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}", self.message)
    }
}

impl std::error::Error for IntentLoadError {}

/// Load `.car/intent.json`. Absent file ⇒ `Ok(None)` (the gate is
/// opt-in); a present-but-malformed file is a loud error, never a
/// silently-ungated session (the strict-and-loud A2 loader stance).
pub fn load_intent_config(
    car_dir: impl AsRef<Path>,
) -> Result<Option<IntentGateConfig>, IntentLoadError> {
    let path = car_dir.as_ref().join("intent.json");
    if !path.exists() {
        return Ok(None);
    }
    let raw = std::fs::read_to_string(&path).map_err(|e| IntentLoadError {
        message: format!("read {}: {e}", path.display()),
    })?;
    serde_json::from_str(&raw).map(Some).map_err(|e| IntentLoadError {
        message: format!("parse {}: {e}", path.display()),
    })
}

/// The admission gate. Cheap and side-effect-free per the seam contract:
/// the cores are pure and the config is captured at construction.
pub struct IntentGate {
    intent: IntentSpec,
    untrusted_tools: HashSet<String>,
    policy: IntentGatePolicy,
}

impl IntentGate {
    pub fn new(config: IntentGateConfig) -> Self {
        let policy = match config.on_untainted_drift {
            Some(d) => IntentGatePolicy {
                on_untainted_drift: d,
            },
            None => IntentGatePolicy::default(),
        };
        Self {
            intent: config.intent,
            untrusted_tools: config.untrusted_tools.into_iter().collect(),
            policy,
        }
    }
}

#[async_trait::async_trait]
impl AdmissionGate for IntentGate {
    fn name(&self) -> &str {
        "intent"
    }

    async fn check(&self, proposal: &ActionProposal, _ctx: &GateContext<'_>) -> GateOutcome {
        // Derive IntentActions from the proposal's own IR (the Slice 4
        // populater): tool, effective write set → targets,
        // metadata.capabilities, the same dependency DAG the runtime
        // sequences on. `untrusted_ids` is empty at admission time —
        // taint enters through untrusted TOOLS and propagates through
        // depends_on inside check_intent.
        let intent_actions =
            intent_actions_from(&proposal.actions, &self.untrusted_tools, &HashSet::new());
        let report = check_intent(&self.intent, &intent_actions);
        // NOTE: `report.safe` only means "nothing commit-blocked" —
        // untainted drift is REPORTED in `violations` with safe=true,
        // and the gate policy decides its disposition. So gate on
        // violations, not on `safe`.
        if report.violations.is_empty() {
            return GateOutcome::Allow;
        }
        let decision = gate_intent(&report, &self.policy);
        if !decision.blocked.is_empty() {
            return GateOutcome::Reject {
                blocked: decision.blocked.iter().map(|v| v.action.clone()).collect(),
                reason: decision.reason,
            };
        }
        if !decision.needs_approval.is_empty() {
            // Content-bound fingerprint over EVERY escalated violation
            // (the C-8 lesson: fingerprinting one hazard lets one
            // approval admit the rest), sorted for order independence,
            // then HASHED: the components (action ids, write-set keys,
            // tool names) are model-authored strings under VIGIL's own
            // threat model, so a delimiter-joined key would be forgeable
            // by embedding the separator in a detail (linus review).
            // A ledger key is a security identity, not a log line.
            let mut fps: Vec<String> = decision
                .needs_approval
                .iter()
                .map(car_policy::intent_gate::intent_fingerprint)
                .collect();
            fps.sort();
            fps.dedup();
            let canonical = serde_json::to_string(&fps).unwrap_or_default();
            let digest = sha2::Sha256::digest(canonical.as_bytes());
            return GateOutcome::NeedsApproval {
                actions: decision
                    .needs_approval
                    .iter()
                    .map(|v| v.action.clone())
                    .collect(),
                fingerprint: format!("intent:sha256:{:x}", digest),
                reason: decision.reason,
            };
        }
        GateOutcome::Allow
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::admission::GateContext;
    use car_ir::{Action, ActionProposal, ActionType, FailureBehavior};
    use std::collections::HashMap;

    fn tool_action(id: &str, tool: &str) -> Action {
        Action {
            id: id.to_string(),
            action_type: ActionType::ToolCall,
            tool: Some(tool.to_string()),
            parameters: HashMap::new(),
            preconditions: vec![],
            expected_effects: HashMap::new(),
            state_dependencies: vec![],
            read_set: vec![],
            write_set: vec![],
            assumptions: vec![],
            invocation_mode: Default::default(),
            idempotent: false,
            max_retries: 0,
            failure_behavior: FailureBehavior::Abort,
            timeout_ms: None,
            metadata: HashMap::new(),
        }
    }

    fn proposal(actions: Vec<Action>) -> ActionProposal {
        ActionProposal {
            id: "p1".to_string(),
            source: "test".to_string(),
            actions,
            timestamp: chrono::Utc::now(),
            context: HashMap::new(),
        }
    }

    fn ctx<'a>(
        state: &'a HashMap<String, serde_json::Value>,
        versions: &'a HashMap<String, u64>,
    ) -> GateContext<'a> {
        GateContext {
            session_id: None,
            scope: None,
            state,
            versions,
        }
    }

    #[tokio::test]
    async fn injection_signature_is_hard_rejected() {
        // fetch_web is untrusted; send_payment is out of the declared
        // intent AND downstream of the untrusted result → hard Reject
        // (never approvable) — the VIGIL injection case.
        let gate = IntentGate::new(IntentGateConfig {
            intent: IntentSpec {
                allowed_tools: vec!["fetch_web".to_string(), "summarize".to_string()],
                ..Default::default()
            },
            untrusted_tools: vec!["fetch_web".to_string()],
            on_untainted_drift: None,
        });
        let mut fetch = tool_action("a1", "fetch_web");
        fetch.expected_effects.insert("page".into(), serde_json::json!(""));
        let mut pay = tool_action("a2", "send_payment");
        pay.state_dependencies.push("page".into());
        let p = proposal(vec![fetch, pay]);
        let (state, versions) = (HashMap::new(), HashMap::new());
        match gate.check(&p, &ctx(&state, &versions)).await {
            GateOutcome::Reject { blocked, .. } => assert!(blocked.contains("a2")),
            other => panic!("expected Reject, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn untainted_drift_escalates_with_content_bound_fingerprint() {
        let gate = IntentGate::new(IntentGateConfig {
            intent: IntentSpec {
                allowed_tools: vec!["summarize".to_string()],
                ..Default::default()
            },
            untrusted_tools: vec![],
            on_untainted_drift: None, // default: require approval
        });
        let p = proposal(vec![tool_action("a1", "send_email")]);
        let (state, versions) = (HashMap::new(), HashMap::new());
        match gate.check(&p, &ctx(&state, &versions)).await {
            GateOutcome::NeedsApproval {
                actions,
                fingerprint,
                ..
            } => {
                assert!(actions.contains("a1"));
                assert!(!fingerprint.is_empty());
            }
            other => panic!("expected NeedsApproval, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn in_intent_proposal_is_allowed() {
        let gate = IntentGate::new(IntentGateConfig {
            intent: IntentSpec {
                allowed_tools: vec!["summarize".to_string()],
                ..Default::default()
            },
            untrusted_tools: vec![],
            on_untainted_drift: None,
        });
        let p = proposal(vec![tool_action("a1", "summarize")]);
        let (state, versions) = (HashMap::new(), HashMap::new());
        assert!(matches!(
            gate.check(&p, &ctx(&state, &versions)).await,
            GateOutcome::Allow
        ));
    }

    #[test]
    fn typo_in_drift_policy_is_a_loud_parse_error() {
        // linus review: a stringly policy silently degraded "Block"/"deny"
        // typos to require_approval — a hard block becoming approvable.
        // Typed deserialization must reject loudly instead.
        let tmp = tempfile::tempdir().unwrap();
        std::fs::write(
            tmp.path().join("intent.json"),
            r#"{"on_untainted_drift": "Block"}"#,
        )
        .unwrap();
        assert!(load_intent_config(tmp.path()).is_err(), "wrong case must not parse");
        std::fs::write(
            tmp.path().join("intent.json"),
            r#"{"on_untainted_drift": "block"}"#,
        )
        .unwrap();
        let cfg = load_intent_config(tmp.path()).unwrap().unwrap();
        assert_eq!(
            cfg.on_untainted_drift,
            Some(car_verify::intent::IntentDisposition::Block)
        );
    }

    #[test]
    fn loader_absent_is_none_malformed_is_loud() {
        let tmp = tempfile::tempdir().unwrap();
        assert!(load_intent_config(tmp.path()).unwrap().is_none());
        std::fs::write(tmp.path().join("intent.json"), "{not json").unwrap();
        assert!(load_intent_config(tmp.path()).is_err());
    }
}