use std::collections::{HashMap, HashSet};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use car_engine::admission::{AdmissionGate, GateContext, GateOutcome};
use car_ir::{Action, ActionProposal, ActionType};
use car_policy::agent_permissions::{AgentPermissionPolicy, ApprovalMode};
use car_policy::{ActionAxes, ApprovalDecision, GateDecision, PermissionTier};
use tokio::sync::{Mutex, RwLock};
pub const AGENT_TOOL_OVERRIDE_SOURCE: &str = "agent_tool_override";
pub const APPROVAL_LEDGER_SOURCE: &str = "approval_ledger";
pub struct PermissionEvaluation {
pub axes: ActionAxes,
pub authorization_source: Option<&'static str>,
pub schema_digest: Option<String>,
}
fn required_tier(decision: &GateDecision) -> PermissionTier {
match decision {
GateDecision::Allow { required, .. }
| GateDecision::NeedsApproval { required, .. }
| GateDecision::Deny { required, .. } => *required,
}
}
pub fn evaluate_action(
gate: &car_policy::PermissionGate,
policy: &AgentPermissionPolicy,
agent_id: Option<&str>,
action: &Action,
callback_schema_digest: Option<&str>,
ceiling: Option<PermissionTier>,
ledger: &car_policy::ApprovalLedger,
) -> PermissionEvaluation {
let mut axes = gate.evaluate_axes(action, ceiling, Some(ledger));
let Some((agent_id, tool)) = agent_id
.zip(action.tool.as_deref())
.filter(|_| action.action_type == ActionType::ToolCall)
else {
return PermissionEvaluation {
axes,
authorization_source: None,
schema_digest: None,
};
};
let Some(rule) = policy.resolve_tool(agent_id, tool) else {
return PermissionEvaluation {
axes,
authorization_source: None,
schema_digest: None,
};
};
let fingerprint = car_policy::action_fingerprint(action);
if ledger
.lookup(&fingerprint)
.is_some_and(|record| record.decision == ApprovalDecision::Rejected)
{
return PermissionEvaluation {
axes,
authorization_source: Some(APPROVAL_LEDGER_SOURCE),
schema_digest: None,
};
}
let required = required_tier(&axes.decision);
let granted = ceiling
.map(|value| gate.granted_tier().min(value))
.unwrap_or_else(|| gate.granted_tier());
let ceiling_blocks_override = ceiling.is_some_and(|value| !value.covers(required));
let authorization_source = match rule.mode {
ApprovalMode::AlwaysAllow
if rule.schema_digest.as_deref() == callback_schema_digest
&& callback_schema_digest.is_some() =>
{
match axes.decision {
GateDecision::NeedsApproval { .. } if !ceiling_blocks_override => {
axes.decision = GateDecision::Allow { required, granted };
Some(AGENT_TOOL_OVERRIDE_SOURCE)
}
GateDecision::Allow { .. }
if ledger
.lookup(&fingerprint)
.is_some_and(|record| record.decision == ApprovalDecision::Approved) =>
{
Some(APPROVAL_LEDGER_SOURCE)
}
_ => None,
}
}
ApprovalMode::AlwaysAllow => None,
ApprovalMode::Deny => {
axes.decision = GateDecision::Deny {
required,
fingerprint,
reason: format!(
"exact tool '{tool}' is denied for agent '{agent_id}' by host policy"
),
};
Some(AGENT_TOOL_OVERRIDE_SOURCE)
}
ApprovalMode::RequireApproval => match axes.decision {
decision @ GateDecision::Allow { .. }
if ledger
.lookup(&fingerprint)
.is_some_and(|record| record.decision == ApprovalDecision::Approved) =>
{
axes.decision = decision;
Some(APPROVAL_LEDGER_SOURCE)
}
GateDecision::Allow { .. } => {
axes.decision = GateDecision::NeedsApproval {
required,
granted,
fingerprint,
reason: format!(
"exact tool '{tool}' requires approval for agent '{agent_id}' by host policy"
),
};
Some(AGENT_TOOL_OVERRIDE_SOURCE)
}
_ => None,
},
};
PermissionEvaluation {
axes,
authorization_source,
schema_digest: (authorization_source == Some(AGENT_TOOL_OVERRIDE_SOURCE))
.then(|| callback_schema_digest.map(str::to_string))
.flatten(),
}
}
pub struct PermissionAdmissionGate {
gate: Arc<RwLock<car_policy::PermissionGate>>,
ledger: Arc<RwLock<car_policy::ApprovalLedger>>,
authenticated: Arc<AtomicBool>,
agent_id: Arc<Mutex<Option<String>>>,
callback_tool_schema_digests: Arc<RwLock<HashMap<String, String>>>,
registry: Arc<car_engine::ToolRegistry>,
memgine: Option<Arc<Mutex<car_memgine::MemgineEngine>>>,
log: Option<Arc<Mutex<car_eventlog::EventLog>>>,
}
impl PermissionAdmissionGate {
pub fn new(
gate: Arc<RwLock<car_policy::PermissionGate>>,
ledger: Arc<RwLock<car_policy::ApprovalLedger>>,
) -> Self {
Self {
gate,
ledger,
authenticated: Arc::new(AtomicBool::new(false)),
agent_id: Arc::new(Mutex::new(None)),
callback_tool_schema_digests: Arc::new(RwLock::new(HashMap::new())),
registry: Arc::new(car_engine::ToolRegistry::new()),
memgine: None,
log: None,
}
}
pub fn with_authenticated_agent(
mut self,
authenticated: Arc<AtomicBool>,
agent_id: Arc<Mutex<Option<String>>>,
) -> Self {
self.authenticated = authenticated;
self.agent_id = agent_id;
self
}
pub fn with_event_log(mut self, log: Arc<Mutex<car_eventlog::EventLog>>) -> Self {
self.log = Some(log);
self
}
pub fn with_callback_tools(
mut self,
callback_tool_schema_digests: Arc<RwLock<HashMap<String, String>>>,
registry: Arc<car_engine::ToolRegistry>,
) -> Self {
self.callback_tool_schema_digests = callback_tool_schema_digests;
self.registry = registry;
self
}
pub fn with_skill_memgine(mut self, memgine: Arc<Mutex<car_memgine::MemgineEngine>>) -> Self {
self.memgine = Some(memgine);
self
}
}
pub async fn eligible_callback_tool_digests(
callbacks: &RwLock<HashMap<String, String>>,
registry: &car_engine::ToolRegistry,
) -> HashMap<String, String> {
let mut eligible = callbacks.read().await.clone();
for server_owned in registry.names().await {
eligible.remove(&server_owned);
}
eligible
}
fn decision_audit_data(
evaluation: &PermissionEvaluation,
agent_id: &str,
action: &Action,
) -> HashMap<String, serde_json::Value> {
let mut data = HashMap::new();
data.insert("agent_id".into(), agent_id.into());
data.insert(
"tool".into(),
action.tool.as_deref().unwrap_or_default().into(),
);
data.insert(
"authorization_source".into(),
evaluation.authorization_source.unwrap_or_default().into(),
);
if let Some(digest) = evaluation.schema_digest.as_deref() {
data.insert("authorization_schema_digest".into(), digest.into());
}
data.insert(
"reversibility".into(),
evaluation.axes.reversibility.as_str().into(),
);
match &evaluation.axes.decision {
GateDecision::Allow { required, granted } => {
data.insert("gate_decision".into(), "allow".into());
data.insert("required_tier".into(), required.as_str().into());
data.insert("granted_tier".into(), granted.as_str().into());
}
GateDecision::NeedsApproval {
required,
granted,
fingerprint,
reason,
} => {
data.insert("gate_decision".into(), "needs_approval".into());
data.insert("required_tier".into(), required.as_str().into());
data.insert("granted_tier".into(), granted.as_str().into());
data.insert("fingerprint".into(), fingerprint.clone().into());
data.insert("reason".into(), reason.clone().into());
}
GateDecision::Deny {
required,
fingerprint,
reason,
} => {
data.insert("gate_decision".into(), "deny".into());
data.insert("required_tier".into(), required.as_str().into());
data.insert("fingerprint".into(), fingerprint.clone().into());
data.insert("reason".into(), reason.clone().into());
}
}
data
}
#[async_trait::async_trait]
impl AdmissionGate for PermissionAdmissionGate {
fn name(&self) -> &str {
"permission"
}
async fn check(&self, proposal: &ActionProposal, _ctx: &GateContext<'_>) -> GateOutcome {
let agent_id = if self.authenticated.load(Ordering::Acquire) {
self.agent_id.lock().await.clone()
} else {
None
};
let policy = if agent_id.is_some() {
crate::agent_permissions::load_policy()
} else {
AgentPermissionPolicy::default()
};
let ceiling = if agent_id.is_some() {
match (
self.memgine.as_ref(),
proposal
.context
.get(car_engine::SKILL_CONTEXT_KEY)
.and_then(serde_json::Value::as_str),
) {
(Some(memgine), Some(skill)) => memgine
.lock()
.await
.skill_meta(skill)
.and_then(|meta| meta.deployment_tier),
_ => None,
}
} else {
None
};
let callback_tools =
eligible_callback_tool_digests(&self.callback_tool_schema_digests, &self.registry)
.await;
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 {
let evaluation = evaluate_action(
&gate,
&policy,
agent_id.as_deref(),
action,
action
.tool
.as_deref()
.and_then(|tool| callback_tools.get(tool))
.map(String::as_str),
ceiling,
&ledger,
);
if evaluation.authorization_source.is_some() {
if let (Some(log), Some(agent_id)) = (&self.log, agent_id.as_deref()) {
log.lock().await.append(
car_eventlog::EventKind::PermissionDecision,
Some(&action.id),
Some(&proposal.id),
decision_audit_data(&evaluation, agent_id, action),
);
}
}
match evaluation.axes.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, AgentPermissionPolicy, ApprovalDecision, ApprovalLedger, ApprovalMode,
GateDecision, 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 newsroom_publish(id: &str, edition_id: &str) -> Action {
let mut action = Action::new(ActionType::ToolCall);
action.id = id.to_string();
action.tool = Some("newsroom.publish".to_string());
action
.parameters
.insert("edition_id".into(), edition_id.into());
action
.parameters
.insert("delivery".into(), "send email".into());
action
}
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
));
}
#[test]
fn exact_agent_tool_override_changes_authorization_not_risk_axes() {
const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let gate = PermissionGate::new(PermissionTier::SandboxEdit);
let ledger = ApprovalLedger::new();
let mut policy = AgentPermissionPolicy::default();
policy.set_tool(
"daily-continuity-newsroom",
"newsroom.publish",
ApprovalMode::AlwaysAllow,
Some(DIGEST.into()),
);
for (action_id, edition_id) in [("publish-1", "2026-09-04"), ("publish-2", "rerun-2")] {
let action = newsroom_publish(action_id, edition_id);
let evaluation = evaluate_action(
&gate,
&policy,
Some("daily-continuity-newsroom"),
&action,
Some(DIGEST),
None,
&ledger,
);
assert!(matches!(
evaluation.axes.decision,
GateDecision::Allow {
required: PermissionTier::FullAccess,
..
}
));
assert_eq!(
evaluation.axes.reversibility,
car_ir::Reversibility::Irreversible
);
assert_eq!(
evaluation.authorization_source,
Some(AGENT_TOOL_OVERRIDE_SOURCE)
);
}
for (agent, tool) in [
("daily-continuity-newsroom", "mail.send"),
("daily-continuity-newsroom", "deploy_service"),
("another-agent", "newsroom.publish"),
] {
let mut action = newsroom_publish("other", "2026-09-04");
action.tool = Some(tool.to_string());
let evaluation =
evaluate_action(&gate, &policy, Some(agent), &action, None, None, &ledger);
assert!(matches!(
evaluation.axes.decision,
GateDecision::NeedsApproval { .. }
));
assert_eq!(evaluation.authorization_source, None);
}
let unbound = newsroom_publish("unbound", "2026-09-04");
assert!(matches!(
evaluate_action(&gate, &policy, None, &unbound, None, None, &ledger)
.axes
.decision,
GateDecision::NeedsApproval { .. }
));
}
#[test]
fn exact_deny_and_require_approval_override_base_tier_allow() {
let gate = PermissionGate::new(PermissionTier::SandboxEdit);
let mut ledger = ApprovalLedger::new();
let action = state_write("a", "k");
let mut policy = AgentPermissionPolicy::default();
policy.set_tool("agent", "", ApprovalMode::Deny, None);
assert!(matches!(
evaluate_action(&gate, &policy, Some("agent"), &action, None, None, &ledger,)
.axes
.decision,
GateDecision::Allow { .. }
));
let mut tool = Action::new(ActionType::ToolCall);
tool.tool = Some("safe.read".into());
policy.set_tool("agent", "safe.read", ApprovalMode::Deny, None);
assert!(matches!(
evaluate_action(&gate, &policy, Some("agent"), &tool, None, None, &ledger,)
.axes
.decision,
GateDecision::Deny { .. }
));
policy.set_tool("agent", "safe.read", ApprovalMode::RequireApproval, None);
assert!(matches!(
evaluate_action(&gate, &policy, Some("agent"), &tool, None, None, &ledger,)
.axes
.decision,
GateDecision::NeedsApproval { .. }
));
ledger
.record_decision(
&action_fingerprint(&tool),
PermissionTier::SandboxEdit,
ApprovalDecision::Approved,
"operator",
"reviewed",
None,
)
.expect("in-memory ledger cannot fail");
assert!(matches!(
evaluate_action(&gate, &policy, Some("agent"), &tool, None, None, &ledger,)
.axes
.decision,
GateDecision::Allow { .. }
));
}
#[test]
fn durable_rejection_beats_matching_always_allow_override() {
const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let gate = PermissionGate::new(PermissionTier::SandboxEdit);
let action = newsroom_publish("publish-rejected", "2026-09-04");
let mut ledger = ApprovalLedger::new();
ledger
.record_decision(
&action_fingerprint(&action),
PermissionTier::FullAccess,
ApprovalDecision::Rejected,
"operator",
"explicit rejection",
None,
)
.unwrap();
let mut policy = AgentPermissionPolicy::default();
policy.set_tool(
"daily-continuity-newsroom",
"newsroom.publish",
ApprovalMode::AlwaysAllow,
Some(DIGEST.into()),
);
let evaluation = evaluate_action(
&gate,
&policy,
Some("daily-continuity-newsroom"),
&action,
Some(DIGEST),
None,
&ledger,
);
assert!(matches!(
evaluation.axes.decision,
GateDecision::Deny { .. }
));
assert_eq!(
evaluation.authorization_source,
Some(APPROVAL_LEDGER_SOURCE)
);
}
#[test]
fn tool_field_cannot_smuggle_override_onto_non_tool_action() {
const DIGEST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
let gate = PermissionGate::new(PermissionTier::ReadOnly);
let ledger = ApprovalLedger::new();
let mut action = state_write("write", "key");
action.tool = Some("newsroom.publish".into());
let mut policy = AgentPermissionPolicy::default();
policy.set_tool(
"daily-continuity-newsroom",
"newsroom.publish",
ApprovalMode::AlwaysAllow,
Some(DIGEST.into()),
);
let evaluation = evaluate_action(
&gate,
&policy,
Some("daily-continuity-newsroom"),
&action,
Some(DIGEST),
None,
&ledger,
);
assert!(matches!(
evaluation.axes.decision,
GateDecision::NeedsApproval { .. }
));
assert_eq!(evaluation.authorization_source, None);
}
}