use std::collections::HashSet;
use std::sync::Arc;
use car_engine::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::ActionProposal;
use tokio::sync::RwLock;
pub struct PermissionAdmissionGate {
gate: Arc<RwLock<car_policy::PermissionGate>>,
ledger: Arc<RwLock<car_policy::ApprovalLedger>>,
}
impl PermissionAdmissionGate {
pub fn new(
gate: Arc<RwLock<car_policy::PermissionGate>>,
ledger: Arc<RwLock<car_policy::ApprovalLedger>>,
) -> Self {
Self { gate, ledger }
}
}
#[async_trait::async_trait]
impl AdmissionGate for PermissionAdmissionGate {
fn name(&self) -> &str {
"permission"
}
async fn check(&self, proposal: &ActionProposal, _ctx: &GateContext<'_>) -> GateOutcome {
let gate = self.gate.read().await;
let ledger = self.ledger.read().await;
let mut denied: HashSet<String> = HashSet::new();
let mut deny_notes: Vec<String> = Vec::new();
let mut escalate: HashSet<String> = HashSet::new();
let mut escalation_notes: Vec<String> = Vec::new();
let mut fingerprints: Vec<String> = Vec::new();
for action in &proposal.actions {
match gate.evaluate_axes(action, None, Some(&ledger)).decision {
car_policy::GateDecision::Allow { .. } => {}
car_policy::GateDecision::Deny {
required,
fingerprint,
reason,
} => {
denied.insert(action.id.clone());
deny_notes.push(format!(
"action '{}' requires {} and was {} (fingerprint: {fingerprint})",
action.id,
required.as_str(),
reason,
));
}
car_policy::GateDecision::NeedsApproval {
required,
granted,
fingerprint,
reason,
} => {
escalate.insert(action.id.clone());
escalation_notes.push(format!(
"action '{}' requires {} but the session is granted {} — {reason} \
(approve fingerprint: {fingerprint})",
action.id,
required.as_str(),
granted.as_str(),
));
fingerprints.push(fingerprint);
}
}
}
if !denied.is_empty() {
return GateOutcome::Reject {
blocked: denied,
reason: format!(
"operator previously rejected this operation: {}",
deny_notes.join("; ")
),
};
}
if escalate.is_empty() {
return GateOutcome::Allow;
}
fingerprints.sort();
fingerprints.dedup();
GateOutcome::NeedsApproval {
actions: escalate,
fingerprint: format!("permission:{}", fingerprints.join(",")),
reason: format!(
"action(s) exceed the session's granted permission tier and require \
human approval: {}",
escalation_notes.join("; ")
),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::{Action, ActionType};
use car_policy::{
action_fingerprint, ApprovalDecision, ApprovalLedger, PermissionGate, PermissionTier,
};
use std::collections::HashMap;
fn state_write(id: &str, key: &str) -> Action {
let mut a = Action::new(ActionType::StateWrite);
a.id = id.to_string();
a.parameters
.insert("key".to_string(), serde_json::Value::from(key));
a.parameters
.insert("value".to_string(), serde_json::Value::from("v"));
a.max_retries = 0;
a
}
fn full_access_action(id: &str) -> Action {
let mut a = Action::new(ActionType::ToolCall);
a.id = id.to_string();
a.tool = Some("deploy_service".to_string());
a.max_retries = 0;
a
}
fn proposal(actions: Vec<Action>) -> ActionProposal {
ActionProposal {
id: "p".to_string(),
source: "test".to_string(),
actions,
timestamp: chrono::Utc::now(),
context: HashMap::new(),
}
}
fn build(
granted: PermissionTier,
ledger: ApprovalLedger,
) -> (
PermissionAdmissionGate,
Arc<RwLock<car_policy::PermissionGate>>,
) {
let gate = Arc::new(RwLock::new(PermissionGate::new(granted)));
let ledger = Arc::new(RwLock::new(ledger));
(PermissionAdmissionGate::new(gate.clone(), ledger), gate)
}
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 action_within_the_grant_is_allowed() {
let (gate, _) = build(PermissionTier::SandboxEdit, ApprovalLedger::new());
let (s, v) = (HashMap::new(), HashMap::new());
let p = proposal(vec![state_write("a", "k")]);
assert!(matches!(
gate.check(&p, &ctx(&s, &v)).await,
GateOutcome::Allow
));
}
#[tokio::test]
async fn action_above_the_grant_escalates() {
let (gate, _) = build(PermissionTier::ReadOnly, ApprovalLedger::new());
let (s, v) = (HashMap::new(), HashMap::new());
let action = state_write("a", "k");
let expected_fp = action_fingerprint(&action);
let p = proposal(vec![action]);
match gate.check(&p, &ctx(&s, &v)).await {
GateOutcome::NeedsApproval {
actions,
fingerprint,
reason,
} => {
assert!(actions.contains("a"));
assert!(fingerprint.starts_with("permission:"));
assert!(fingerprint.contains(&expected_fp));
assert!(reason.contains("sandbox_edit"), "{reason}");
assert!(reason.contains("read_only"), "{reason}");
assert!(reason.contains(&expected_fp), "{reason}");
}
other => panic!("expected escalation, got {other:?}"),
}
}
#[tokio::test]
async fn a_recorded_rejection_denies() {
let action = state_write("a", "k");
let mut ledger = ApprovalLedger::new();
ledger
.record_decision(
&action_fingerprint(&action),
PermissionTier::SandboxEdit,
ApprovalDecision::Rejected,
"operator",
"not this one",
None,
)
.expect("in-memory ledger cannot fail");
let (gate, _) = build(PermissionTier::FullAccess, ledger);
let (s, v) = (HashMap::new(), HashMap::new());
let p = proposal(vec![action]);
match gate.check(&p, &ctx(&s, &v)).await {
GateOutcome::Reject { blocked, reason } => {
assert!(blocked.contains("a"));
assert!(reason.contains("previously rejected"), "{reason}");
}
other => panic!("expected reject, got {other:?}"),
}
}
#[tokio::test]
async fn a_recorded_approval_allows() {
let action = state_write("a", "k");
let mut ledger = ApprovalLedger::new();
ledger
.record_decision(
&action_fingerprint(&action),
PermissionTier::SandboxEdit,
ApprovalDecision::Approved,
"operator",
"reviewed",
None,
)
.expect("in-memory ledger cannot fail");
let (gate, _) = build(PermissionTier::ReadOnly, ledger);
let (s, v) = (HashMap::new(), HashMap::new());
let p = proposal(vec![action]);
assert!(matches!(
gate.check(&p, &ctx(&s, &v)).await,
GateOutcome::Allow
));
}
#[tokio::test]
async fn multi_action_escalation_names_only_the_offenders() {
let (gate, _) = build(PermissionTier::SandboxEdit, ApprovalLedger::new());
let (s, v) = (HashMap::new(), HashMap::new());
let ok = state_write("a1", "k");
let bad1 = full_access_action("a2");
let bad2 = full_access_action("a3");
let fp2 = action_fingerprint(&bad2);
let p = proposal(vec![ok, bad1, bad2]);
match gate.check(&p, &ctx(&s, &v)).await {
GateOutcome::NeedsApproval {
actions,
fingerprint,
..
} => {
assert!(!actions.contains("a1"), "the in-grant write is not blamed");
assert!(actions.contains("a2"));
assert!(actions.contains("a3"));
assert_eq!(fingerprint, format!("permission:{fp2}"));
}
other => panic!("expected escalation, got {other:?}"),
}
}
#[tokio::test]
async fn tier_changes_take_effect_on_the_shared_gate() {
let (gate, shared) = build(PermissionTier::ReadOnly, ApprovalLedger::new());
let (s, v) = (HashMap::new(), HashMap::new());
let p = proposal(vec![state_write("a", "k")]);
assert!(matches!(
gate.check(&p, &ctx(&s, &v)).await,
GateOutcome::NeedsApproval { .. }
));
shared
.write()
.await
.set_granted_tier(PermissionTier::SandboxEdit);
assert!(matches!(
gate.check(&p, &ctx(&s, &v)).await,
GateOutcome::Allow
));
}
}