car-engine 0.32.1

Core runtime engine for Common Agent Runtime
//! Information-flow admission gate + tool-label loading (EPIC A / A3+A4).
//!
//! `car_verify::check_information_flow` is a verified static check for data
//! exfiltration and forbidden tool orderings, but it was never called by
//! the runtime, and it needs per-tool *labels* (capability, confidentiality,
//! trust, sink) that nothing produced. This module supplies both halves:
//!
//! - **A3 — labels**: a built-in default label table for CAR's commodity
//!   tools, plus a `.car/tool-labels.json` loader so a project can declare
//!   which tools are sinks, which produce confidential data, and what tool
//!   orderings are forbidden.
//! - **A4 — the gate**: [`InformationFlowGate`], an [`crate::admission::AdmissionGate`]
//!   that runs `check_information_flow` → `gate_flow` on every admitted
//!   proposal, blocking exfiltration and escalating forbidden orderings to
//!   approval (which fails closed until A7 wires the approval transport).

use crate::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::ActionProposal;
use car_verify::infoflow::{
    check_information_flow, gate_flow, FlowAction, FlowGatePolicy, FlowPolicy, ToolLabels,
};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt;
use std::path::Path;

/// The deserialized `.car/tool-labels.json` document: per-tool labels plus
/// the flow policy (what counts as a hazard) and the gate policy (what to
/// do about each hazard class). Every field defaults, so a partial file is
/// valid.
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct ToolLabelConfig {
    /// Tool name → information-flow labels.
    #[serde(default)]
    pub labels: HashMap<String, ToolLabels>,
    /// What confidentiality reaching a sink is a violation, and which tool
    /// orderings are forbidden.
    #[serde(default)]
    pub flow_policy: FlowPolicy,
    /// How each hazard class is enforced (block / approve / allow).
    #[serde(default)]
    pub gate_policy: FlowGatePolicy,
}

/// Default information-flow labels for CAR's built-in commodity tools.
///
/// Conservative and capability-only: the network-reaching tools are marked
/// as exfiltration sinks (so any confidential data flowing into them is
/// caught), and the file/process tools get capability tags so forbidden
/// orderings can be expressed against them. Nothing is marked confidential
/// by default — a project declares which of *its* tools/sources produce
/// sensitive data in `.car/tool-labels.json`.
pub fn builtin_tool_labels() -> HashMap<String, ToolLabels> {
    fn sink(capability: &str) -> ToolLabels {
        ToolLabels {
            capability: Some(capability.to_string()),
            sink: true,
            ..Default::default()
        }
    }
    fn cap(capability: &str) -> ToolLabels {
        ToolLabels {
            capability: Some(capability.to_string()),
            ..Default::default()
        }
    }
    let mut m = HashMap::new();
    // Network-reaching tools are exfiltration sinks.
    m.insert("http_request".to_string(), sink("net_send"));
    m.insert("browser".to_string(), sink("net_send"));
    m.insert("search".to_string(), sink("net_send"));
    // Filesystem + process tools: capability-tagged, not sinks.
    m.insert("read_file".to_string(), cap("fs_read"));
    m.insert("write_file".to_string(), cap("fs_write"));
    m.insert("edit_file".to_string(), cap("fs_write"));
    m.insert("list_dir".to_string(), cap("fs_read"));
    m.insert("find_files".to_string(), cap("fs_read"));
    m.insert("grep_files".to_string(), cap("fs_read"));
    m.insert("shell".to_string(), cap("process_exec"));
    m
}

/// Load tool labels for a project, merging `.car/tool-labels.json` over the
/// built-in defaults.
///
/// `car_dir` is the project's `.car` directory; this reads
/// `car_dir/tool-labels.json` if present. Built-in labels are the base;
/// a per-tool entry in the file replaces the built-in for that tool. The
/// flow/gate policies come entirely from the file (or their defaults). A
/// missing file is not an error (returns builtins + default policies); a
/// malformed file *is* an error.
pub fn load_tool_labels(car_dir: impl AsRef<Path>) -> Result<ToolLabelConfig, FlowLoadError> {
    let mut config = ToolLabelConfig {
        labels: builtin_tool_labels(),
        flow_policy: FlowPolicy::default(),
        gate_policy: FlowGatePolicy::default(),
    };
    let path = car_dir.as_ref().join("tool-labels.json");
    if !path.exists() {
        return Ok(config);
    }
    let src = std::fs::read_to_string(&path).map_err(|e| FlowLoadError {
        message: format!("reading {}: {e}", path.display()),
    })?;
    let file: ToolLabelConfig = serde_json::from_str(&src).map_err(|e| FlowLoadError {
        message: format!("parsing {}: {e}", path.display()),
    })?;
    // File labels override builtins per tool; file policies replace defaults.
    config.labels.extend(file.labels);
    config.flow_policy = file.flow_policy;
    config.gate_policy = file.gate_policy;
    Ok(config)
}

/// An [`AdmissionGate`] that enforces information-flow safety: confidential
/// data must not reach an exfiltration sink, and forbidden tool orderings
/// are escalated to human approval.
pub struct InformationFlowGate {
    labels: HashMap<String, ToolLabels>,
    flow_policy: FlowPolicy,
    gate_policy: FlowGatePolicy,
}

impl InformationFlowGate {
    /// Build a gate from a resolved [`ToolLabelConfig`].
    pub fn new(config: ToolLabelConfig) -> Self {
        Self {
            labels: config.labels,
            flow_policy: config.flow_policy,
            gate_policy: config.gate_policy,
        }
    }

    /// Build a gate from the built-in defaults only.
    pub fn with_builtin_labels() -> Self {
        Self::new(ToolLabelConfig {
            labels: builtin_tool_labels(),
            ..Default::default()
        })
    }
}

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

    async fn check(&self, proposal: &ActionProposal, _ctx: &GateContext<'_>) -> GateOutcome {
        let report = check_information_flow(proposal, &self.labels, &self.flow_policy);
        if report.safe {
            return GateOutcome::Allow;
        }
        let decision = gate_flow(&report, &self.gate_policy);
        match decision.action {
            FlowAction::Allow => GateOutcome::Allow,
            FlowAction::Block => {
                let blocked: HashSet<String> = decision
                    .blocked
                    .iter()
                    .flat_map(|v| v.actions.iter().cloned())
                    .collect();
                GateOutcome::Reject {
                    blocked,
                    reason: decision.reason,
                }
            }
            FlowAction::RequireApproval => {
                let actions: HashSet<String> = decision
                    .needs_approval
                    .iter()
                    .flat_map(|v| v.actions.iter().cloned())
                    .collect();
                // Stable fingerprint over EVERY hazard (linus review C-8):
                // fingerprinting only the first hazard let one approval
                // admit hazards 2..n. Sorted for order independence, then
                // HASHED — the components carry model-chosen action ids
                // and state keys, so a delimiter-joined ledger key would
                // be forgeable by embedding the separator (same fix as
                // the intent gate; a ledger key is a security identity).
                let mut fps: Vec<String> = decision
                    .needs_approval
                    .iter()
                    .map(car_policy::flow_fingerprint)
                    .collect();
                fps.sort();
                fps.dedup();
                let fingerprint = if fps.is_empty() {
                    "flow:unknown".to_string()
                } else {
                    use sha2::Digest;
                    let canonical = serde_json::to_string(&fps).unwrap_or_default();
                    format!("flow:sha256:{:x}", sha2::Sha256::digest(canonical.as_bytes()))
                };
                GateOutcome::NeedsApproval {
                    actions,
                    fingerprint,
                    reason: decision.reason,
                }
            }
        }
    }
}

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

impl fmt::Display for FlowLoadError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "tool-labels load error: {}", self.message)
    }
}

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

#[cfg(test)]
mod tests {
    use super::*;
    use car_ir::{Action, ActionType, FailureBehavior};

    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: "p".to_string(),
            source: "test".to_string(),
            actions,
            timestamp: chrono::Utc::now(),
            context: HashMap::new(),
        }
    }

    #[test]
    fn builtins_mark_network_tools_as_sinks() {
        let labels = builtin_tool_labels();
        assert!(labels.get("http_request").unwrap().sink);
        assert!(labels.get("browser").unwrap().sink);
        assert!(!labels.get("read_file").unwrap().sink);
    }

    #[tokio::test]
    async fn clean_proposal_is_allowed() {
        let gate = InformationFlowGate::with_builtin_labels();
        let ctx_state = HashMap::new();
        let ctx_versions = HashMap::new();
        let ctx = GateContext {
            session_id: None,
            scope: None,
            state: &ctx_state,
            versions: &ctx_versions,
        };
        // read_file then http_request with no declared confidential data:
        // nothing sensitive flows, so it's safe.
        let p = proposal(vec![
            tool_action("a1", "read_file"),
            tool_action("a2", "http_request"),
        ]);
        assert!(matches!(gate.check(&p, &ctx).await, GateOutcome::Allow));
    }

    #[tokio::test]
    async fn confidential_to_sink_is_blocked() {
        // Label a custom source as Secret; it flows into http_request (a
        // sink) via a shared state key.
        let mut labels = builtin_tool_labels();
        labels.insert(
            "read_secret".to_string(),
            ToolLabels {
                capability: Some("fs_read".to_string()),
                confidentiality: car_verify::infoflow::Confidentiality::Secret,
                ..Default::default()
            },
        );
        let gate = InformationFlowGate::new(ToolLabelConfig {
            labels,
            ..Default::default()
        });

        // a1 reads a secret into key "data"; a2 (http_request sink) reads it.
        let mut a1 = tool_action("a1", "read_secret");
        a1.expected_effects = [("data".to_string(), serde_json::Value::from(1))].into();
        let mut a2 = tool_action("a2", "http_request");
        a2.state_dependencies = vec!["data".to_string()];

        let ctx_state = HashMap::new();
        let ctx_versions = HashMap::new();
        let ctx = GateContext {
            session_id: None,
            scope: None,
            state: &ctx_state,
            versions: &ctx_versions,
        };
        let outcome = gate.check(&proposal(vec![a1, a2]), &ctx).await;
        assert!(
            matches!(outcome, GateOutcome::Reject { .. }),
            "secret reaching a sink must be blocked, got {outcome:?}"
        );
    }

    #[test]
    fn load_missing_file_returns_builtins() {
        let cfg = load_tool_labels("/nonexistent/.car").unwrap();
        assert!(cfg.labels.contains_key("http_request"));
    }

    #[test]
    fn load_merges_file_over_builtins() {
        let dir = std::env::temp_dir().join(format!("car_flow_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(
            dir.join("tool-labels.json"),
            r#"{"labels":{"my_tool":{"confidentiality":"secret"},"http_request":{"capability":"net_send","sink":false}}}"#,
        )
        .unwrap();
        let cfg = load_tool_labels(&dir).unwrap();
        // New tool added.
        assert_eq!(
            cfg.labels.get("my_tool").unwrap().confidentiality,
            car_verify::infoflow::Confidentiality::Secret
        );
        // Built-in overridden by the file (http_request sink turned off).
        assert!(!cfg.labels.get("http_request").unwrap().sink);
        // Untouched built-in preserved.
        assert!(cfg.labels.contains_key("read_file"));
        std::fs::remove_dir_all(&dir).ok();
    }

    #[test]
    fn load_malformed_file_is_error() {
        let dir = std::env::temp_dir().join(format!("car_flow_bad_{}", std::process::id()));
        std::fs::create_dir_all(&dir).unwrap();
        std::fs::write(dir.join("tool-labels.json"), "{not json").unwrap();
        assert!(load_tool_labels(&dir).is_err());
        std::fs::remove_dir_all(&dir).ok();
    }
}