car_engine/
skill_ceiling.rs1use 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
26pub const SKILL_CONTEXT_KEY: &str = "skill";
28
29pub struct SkillCeilingGate {
39 classifier: RiskClassifier,
40 memgine: Arc<TokioMutex<car_memgine::MemgineEngine>>,
41}
42
43impl SkillCeilingGate {
44 pub fn new(memgine: Arc<TokioMutex<car_memgine::MemgineEngine>>) -> Self {
46 Self {
47 classifier: RiskClassifier::new(),
48 memgine,
49 }
50 }
51
52 pub fn with_classifier(mut self, classifier: RiskClassifier) -> Self {
55 self.classifier = classifier;
56 self
57 }
58
59 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 let Some(skill) = Self::driving_skill(proposal) else {
77 return GateOutcome::Allow;
78 };
79 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 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 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 {
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}