Skip to main content

car_engine/
skill_ceiling.rs

1//! Skill deployment-tier ceiling enforcement (EPIC A / A8).
2//!
3//! A learned skill carries a `deployment_tier` — the maximum permission
4//! tier it was governed to run at (arXiv 2602.12430; stamped by
5//! `skill.ingest_governed`). But nothing enforced that ceiling on the
6//! *actions a skill drives*: a `read_only`-capped skill could still emit a
7//! `full_access` action in a `full_access` session.
8//!
9//! [`SkillCeilingGate`] closes that. It's an [`crate::admission::AdmissionGate`]
10//! that, when a proposal names the skill driving it (in
11//! `proposal.context["skill"]`, the same "caller names the skill it's
12//! running" contract `permission.evaluate`'s `skill` param uses), looks up
13//! that skill's live `deployment_tier` from memgine and escalates any
14//! action whose required tier exceeds the ceiling to human approval —
15//! resolved through the durable ledger wired in A7. The effective authority
16//! becomes `min(session grant, skill ceiling)` without the gate having to
17//! invent skill→action provenance: the caller declares it.
18
19use crate::admission::{AdmissionGate, GateContext, GateOutcome};
20use car_ir::ActionProposal;
21use car_policy::RiskClassifier;
22use std::collections::HashSet;
23use std::sync::Arc;
24use tokio::sync::Mutex as TokioMutex;
25
26/// The context key a proposal uses to name the skill driving it.
27pub const SKILL_CONTEXT_KEY: &str = "skill";
28
29/// An admission gate that caps a skill-driven proposal's actions at the
30/// skill's persisted `deployment_tier`.
31///
32/// NOTE (neo review): this gate holds its OWN `RiskClassifier`, distinct
33/// from the session `PermissionGate`'s. Both are `RiskClassifier::new()`
34/// defaults today, so they classify identically — but if either side ever
35/// installs custom rules, the two classification points can disagree on
36/// an action's tier. If custom rules land, thread ONE shared classifier
37/// through both (Arc it) rather than configuring them separately.
38pub struct SkillCeilingGate {
39    classifier: RiskClassifier,
40    memgine: Arc<TokioMutex<car_memgine::MemgineEngine>>,
41}
42
43impl SkillCeilingGate {
44    /// Build a gate that reads ceilings from `memgine`'s live skill graph.
45    pub fn new(memgine: Arc<TokioMutex<car_memgine::MemgineEngine>>) -> Self {
46        Self {
47            classifier: RiskClassifier::new(),
48            memgine,
49        }
50    }
51
52    /// Override the risk classifier (e.g. with project-specific escalation
53    /// rules).
54    pub fn with_classifier(mut self, classifier: RiskClassifier) -> Self {
55        self.classifier = classifier;
56        self
57    }
58
59    /// The skill named on a proposal, if any.
60    fn driving_skill(proposal: &ActionProposal) -> Option<&str> {
61        proposal
62            .context
63            .get(SKILL_CONTEXT_KEY)
64            .and_then(|v| v.as_str())
65    }
66}
67
68#[async_trait::async_trait]
69impl AdmissionGate for SkillCeilingGate {
70    fn name(&self) -> &str {
71        "skill_ceiling"
72    }
73
74    async fn check(&self, proposal: &ActionProposal, _ctx: &GateContext<'_>) -> GateOutcome {
75        // Ungoverned proposal (no skill named) → nothing to cap.
76        let Some(skill) = Self::driving_skill(proposal) else {
77            return GateOutcome::Allow;
78        };
79        // Look up the skill's live ceiling. An unknown skill, or one with
80        // no deployment_tier, is ungoverned — no ceiling to enforce.
81        let ceiling = {
82            let m = self.memgine.lock().await;
83            m.skill_meta(skill).and_then(|meta| meta.deployment_tier)
84        };
85        let Some(ceiling) = ceiling else {
86            return GateOutcome::Allow;
87        };
88        // Escalate every action whose required tier the ceiling doesn't cover.
89        let mut escalate: HashSet<String> = HashSet::new();
90        for a in &proposal.actions {
91            let required = self.classifier.classify(a);
92            if !ceiling.covers(required) {
93                escalate.insert(a.id.clone());
94            }
95        }
96        if escalate.is_empty() {
97            GateOutcome::Allow
98        } else {
99            // Content-bound fingerprint (linus review C-8): the key covers
100            // the skill, the ceiling, AND the sorted (tool, required-tier)
101            // pairs of every over-ceiling action. Approving one benign
102            // over-ceiling write must not durably authorize this skill to
103            // drive *anything* — a different action set is a different
104            // fingerprint and re-asks the operator. Sorted so the same set
105            // in a different proposal order matches its prior approval.
106            let mut over: Vec<String> = proposal
107                .actions
108                .iter()
109                .filter(|a| escalate.contains(&a.id))
110                .map(|a| {
111                    format!(
112                        "{}={}",
113                        a.tool.as_deref().unwrap_or(""),
114                        self.classifier.classify(a).as_str()
115                    )
116                })
117                .collect();
118            over.sort();
119            over.dedup();
120            GateOutcome::NeedsApproval {
121                actions: escalate,
122                fingerprint: format!(
123                    "skill_ceiling:{skill}:{}:{}",
124                    ceiling.as_str(),
125                    over.join(",")
126                ),
127                reason: format!(
128                    "skill '{skill}' is capped at tier '{}' but drives action(s) requiring more",
129                    ceiling.as_str()
130                ),
131            }
132        }
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use car_ir::{Action, ActionProposal, ActionType};
140    use car_policy::PermissionTier;
141    use std::collections::HashMap;
142
143    fn full_access_action(id: &str) -> Action {
144        // "deploy" is in the FULL_ACCESS keyword set the classifier escalates.
145        {
146            let mut a = Action::new(ActionType::ToolCall);
147            a.id = id.to_string();
148            a.tool = Some("deploy".to_string());
149            a.max_retries = 0;
150            a
151        }
152    }
153
154    fn proposal_with_skill(skill: Option<&str>, actions: Vec<Action>) -> ActionProposal {
155        let mut context = HashMap::new();
156        if let Some(s) = skill {
157            context.insert(SKILL_CONTEXT_KEY.to_string(), serde_json::Value::from(s));
158        }
159        ActionProposal {
160            id: "p".to_string(),
161            source: "test".to_string(),
162            actions,
163            timestamp: chrono::Utc::now(),
164            context,
165        }
166    }
167
168    async fn memgine_with_skill(
169        name: &str,
170        tier: Option<PermissionTier>,
171    ) -> Arc<TokioMutex<car_memgine::MemgineEngine>> {
172        let mut eng = car_memgine::MemgineEngine::new(None);
173        eng.ingest_skill(
174            name,
175            "code",
176            "general",
177            car_memgine::graph::SkillTrigger::default(),
178            "test skill",
179            None,
180            vec![],
181            vec![],
182        );
183        if let Some(t) = tier {
184            eng.set_skill_deployment_tier(name, Some(t));
185        }
186        Arc::new(TokioMutex::new(eng))
187    }
188
189    fn ctx<'a>(
190        state: &'a HashMap<String, serde_json::Value>,
191        versions: &'a HashMap<String, u64>,
192    ) -> GateContext<'a> {
193        GateContext {
194            session_id: None,
195            scope: None,
196            state,
197            versions,
198        }
199    }
200
201    #[tokio::test]
202    async fn no_skill_named_is_allowed() {
203        let mem = memgine_with_skill("s", Some(PermissionTier::ReadOnly)).await;
204        let gate = SkillCeilingGate::new(mem);
205        let (s, v) = (HashMap::new(), HashMap::new());
206        let p = proposal_with_skill(None, vec![full_access_action("a1")]);
207        assert!(matches!(
208            gate.check(&p, &ctx(&s, &v)).await,
209            GateOutcome::Allow
210        ));
211    }
212
213    #[tokio::test]
214    async fn read_only_skill_escalates_full_access_action() {
215        let mem = memgine_with_skill("risky", Some(PermissionTier::ReadOnly)).await;
216        let gate = SkillCeilingGate::new(mem);
217        let (s, v) = (HashMap::new(), HashMap::new());
218        let p = proposal_with_skill(Some("risky"), vec![full_access_action("a1")]);
219        match gate.check(&p, &ctx(&s, &v)).await {
220            GateOutcome::NeedsApproval {
221                actions,
222                fingerprint,
223                ..
224            } => {
225                assert!(actions.contains("a1"));
226                assert!(fingerprint.starts_with("skill_ceiling:risky:"));
227            }
228            other => panic!("expected escalation, got {other:?}"),
229        }
230    }
231
232    #[tokio::test]
233    async fn full_access_skill_allows_full_access_action() {
234        let mem = memgine_with_skill("trusted", Some(PermissionTier::FullAccess)).await;
235        let gate = SkillCeilingGate::new(mem);
236        let (s, v) = (HashMap::new(), HashMap::new());
237        let p = proposal_with_skill(Some("trusted"), vec![full_access_action("a1")]);
238        assert!(matches!(
239            gate.check(&p, &ctx(&s, &v)).await,
240            GateOutcome::Allow
241        ));
242    }
243
244    #[tokio::test]
245    async fn ungoverned_skill_no_tier_is_allowed() {
246        let mem = memgine_with_skill("plain", None).await;
247        let gate = SkillCeilingGate::new(mem);
248        let (s, v) = (HashMap::new(), HashMap::new());
249        let p = proposal_with_skill(Some("plain"), vec![full_access_action("a1")]);
250        assert!(matches!(
251            gate.check(&p, &ctx(&s, &v)).await,
252            GateOutcome::Allow
253        ));
254    }
255}