car-engine 0.32.0

Core runtime engine for Common Agent Runtime
//! Skill deployment-tier ceiling enforcement (EPIC A / A8).
//!
//! A learned skill carries a `deployment_tier` — the maximum permission
//! tier it was governed to run at (arXiv 2602.12430; stamped by
//! `skill.ingest_governed`). But nothing enforced that ceiling on the
//! *actions a skill drives*: a `read_only`-capped skill could still emit a
//! `full_access` action in a `full_access` session.
//!
//! [`SkillCeilingGate`] closes that. It's an [`crate::admission::AdmissionGate`]
//! that, when a proposal names the skill driving it (in
//! `proposal.context["skill"]`, the same "caller names the skill it's
//! running" contract `permission.evaluate`'s `skill` param uses), looks up
//! that skill's live `deployment_tier` from memgine and escalates any
//! action whose required tier exceeds the ceiling to human approval —
//! resolved through the durable ledger wired in A7. The effective authority
//! becomes `min(session grant, skill ceiling)` without the gate having to
//! invent skill→action provenance: the caller declares it.

use crate::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::ActionProposal;
use car_policy::{PermissionTier, RiskClassifier};
use std::collections::HashSet;
use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex;

/// The context key a proposal uses to name the skill driving it.
pub const SKILL_CONTEXT_KEY: &str = "skill";

/// An admission gate that caps a skill-driven proposal's actions at the
/// skill's persisted `deployment_tier`.
///
/// NOTE (neo review): this gate holds its OWN `RiskClassifier`, distinct
/// from the session `PermissionGate`'s. Both are `RiskClassifier::new()`
/// defaults today, so they classify identically — but if either side ever
/// installs custom rules, the two classification points can disagree on
/// an action's tier. If custom rules land, thread ONE shared classifier
/// through both (Arc it) rather than configuring them separately.
pub struct SkillCeilingGate {
    classifier: RiskClassifier,
    memgine: Arc<TokioMutex<car_memgine::MemgineEngine>>,
}

impl SkillCeilingGate {
    /// Build a gate that reads ceilings from `memgine`'s live skill graph.
    pub fn new(memgine: Arc<TokioMutex<car_memgine::MemgineEngine>>) -> Self {
        Self {
            classifier: RiskClassifier::new(),
            memgine,
        }
    }

    /// Override the risk classifier (e.g. with project-specific escalation
    /// rules).
    pub fn with_classifier(mut self, classifier: RiskClassifier) -> Self {
        self.classifier = classifier;
        self
    }

    /// The skill named on a proposal, if any.
    fn driving_skill(proposal: &ActionProposal) -> Option<&str> {
        proposal
            .context
            .get(SKILL_CONTEXT_KEY)
            .and_then(|v| v.as_str())
    }
}

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

    async fn check(&self, proposal: &ActionProposal, _ctx: &GateContext<'_>) -> GateOutcome {
        // Ungoverned proposal (no skill named) → nothing to cap.
        let Some(skill) = Self::driving_skill(proposal) else {
            return GateOutcome::Allow;
        };
        // Look up the skill's live ceiling. An unknown skill, or one with
        // no deployment_tier, is ungoverned — no ceiling to enforce.
        let ceiling = {
            let m = self.memgine.lock().await;
            m.skill_meta(skill).and_then(|meta| meta.deployment_tier)
        };
        let Some(ceiling) = ceiling else {
            return GateOutcome::Allow;
        };
        // Escalate every action whose required tier the ceiling doesn't cover.
        let mut escalate: HashSet<String> = HashSet::new();
        for a in &proposal.actions {
            let required = self.classifier.classify(a);
            if !ceiling.covers(required) {
                escalate.insert(a.id.clone());
            }
        }
        if escalate.is_empty() {
            GateOutcome::Allow
        } else {
            // Content-bound fingerprint (linus review C-8): the key covers
            // the skill, the ceiling, AND the sorted (tool, required-tier)
            // pairs of every over-ceiling action. Approving one benign
            // over-ceiling write must not durably authorize this skill to
            // drive *anything* — a different action set is a different
            // fingerprint and re-asks the operator. Sorted so the same set
            // in a different proposal order matches its prior approval.
            let mut over: Vec<String> = proposal
                .actions
                .iter()
                .filter(|a| escalate.contains(&a.id))
                .map(|a| {
                    format!(
                        "{}={}",
                        a.tool.as_deref().unwrap_or(""),
                        self.classifier.classify(a).as_str()
                    )
                })
                .collect();
            over.sort();
            over.dedup();
            GateOutcome::NeedsApproval {
                actions: escalate,
                fingerprint: format!(
                    "skill_ceiling:{skill}:{}:{}",
                    ceiling.as_str(),
                    over.join(",")
                ),
                reason: format!(
                    "skill '{skill}' is capped at tier '{}' but drives action(s) requiring more",
                    ceiling.as_str()
                ),
            }
        }
    }
}

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

    fn full_access_action(id: &str) -> Action {
        // "deploy" is in the FULL_ACCESS keyword set the classifier escalates.
        Action {
            id: id.to_string(),
            action_type: ActionType::ToolCall,
            tool: Some("deploy".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_with_skill(skill: Option<&str>, actions: Vec<Action>) -> ActionProposal {
        let mut context = HashMap::new();
        if let Some(s) = skill {
            context.insert(SKILL_CONTEXT_KEY.to_string(), serde_json::Value::from(s));
        }
        ActionProposal {
            id: "p".to_string(),
            source: "test".to_string(),
            actions,
            timestamp: chrono::Utc::now(),
            context,
        }
    }

    async fn memgine_with_skill(
        name: &str,
        tier: Option<PermissionTier>,
    ) -> Arc<TokioMutex<car_memgine::MemgineEngine>> {
        let mut eng = car_memgine::MemgineEngine::new(None);
        eng.ingest_skill(
            name,
            "code",
            "general",
            car_memgine::graph::SkillTrigger::default(),
            "test skill",
            None,
            vec![],
            vec![],
        );
        if let Some(t) = tier {
            eng.set_skill_deployment_tier(name, Some(t));
        }
        Arc::new(TokioMutex::new(eng))
    }

    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 no_skill_named_is_allowed() {
        let mem = memgine_with_skill("s", Some(PermissionTier::ReadOnly)).await;
        let gate = SkillCeilingGate::new(mem);
        let (s, v) = (HashMap::new(), HashMap::new());
        let p = proposal_with_skill(None, vec![full_access_action("a1")]);
        assert!(matches!(gate.check(&p, &ctx(&s, &v)).await, GateOutcome::Allow));
    }

    #[tokio::test]
    async fn read_only_skill_escalates_full_access_action() {
        let mem = memgine_with_skill("risky", Some(PermissionTier::ReadOnly)).await;
        let gate = SkillCeilingGate::new(mem);
        let (s, v) = (HashMap::new(), HashMap::new());
        let p = proposal_with_skill(Some("risky"), vec![full_access_action("a1")]);
        match gate.check(&p, &ctx(&s, &v)).await {
            GateOutcome::NeedsApproval { actions, fingerprint, .. } => {
                assert!(actions.contains("a1"));
                assert!(fingerprint.starts_with("skill_ceiling:risky:"));
            }
            other => panic!("expected escalation, got {other:?}"),
        }
    }

    #[tokio::test]
    async fn full_access_skill_allows_full_access_action() {
        let mem = memgine_with_skill("trusted", Some(PermissionTier::FullAccess)).await;
        let gate = SkillCeilingGate::new(mem);
        let (s, v) = (HashMap::new(), HashMap::new());
        let p = proposal_with_skill(Some("trusted"), vec![full_access_action("a1")]);
        assert!(matches!(gate.check(&p, &ctx(&s, &v)).await, GateOutcome::Allow));
    }

    #[tokio::test]
    async fn ungoverned_skill_no_tier_is_allowed() {
        let mem = memgine_with_skill("plain", None).await;
        let gate = SkillCeilingGate::new(mem);
        let (s, v) = (HashMap::new(), HashMap::new());
        let p = proposal_with_skill(Some("plain"), vec![full_access_action("a1")]);
        assert!(matches!(gate.check(&p, &ctx(&s, &v)).await, GateOutcome::Allow));
    }
}