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;
pub const SKILL_CONTEXT_KEY: &str = "skill";
pub struct SkillCeilingGate {
classifier: RiskClassifier,
memgine: Arc<TokioMutex<car_memgine::MemgineEngine>>,
}
impl SkillCeilingGate {
pub fn new(memgine: Arc<TokioMutex<car_memgine::MemgineEngine>>) -> Self {
Self {
classifier: RiskClassifier::new(),
memgine,
}
}
pub fn with_classifier(mut self, classifier: RiskClassifier) -> Self {
self.classifier = classifier;
self
}
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 {
let Some(skill) = Self::driving_skill(proposal) else {
return GateOutcome::Allow;
};
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;
};
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 {
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 {
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));
}
}