use car_ir::{Action, ActionType};
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::io::Write as _;
use std::path::PathBuf;
#[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum PermissionTier {
ReadOnly,
SandboxEdit,
FullAccess,
}
impl PermissionTier {
pub fn covers(self, required: PermissionTier) -> bool {
self >= required
}
pub fn as_str(self) -> &'static str {
match self {
PermissionTier::ReadOnly => "read_only",
PermissionTier::SandboxEdit => "sandbox_edit",
PermissionTier::FullAccess => "full_access",
}
}
pub fn from_str_opt(s: &str) -> Option<PermissionTier> {
match s {
"read_only" | "readonly" | "read" => Some(PermissionTier::ReadOnly),
"sandbox_edit" | "sandbox" | "edit" => Some(PermissionTier::SandboxEdit),
"full_access" | "full" => Some(PermissionTier::FullAccess),
_ => None,
}
}
}
const FULL_ACCESS_KEYWORDS: &[&str] = &[
"deploy", "publish", "release", "kubectl", "terraform", "helm",
"docker push", "npm publish", "cargo publish", "aws ", "gcloud",
"az ", "apply", "rollout",
"credential", "secret", "token", "password", "api_key", "apikey",
"ssh", "private key", "private_key",
"delete", "destroy", "drop", "drop table", "delete from", "truncate",
"rm ", "rmdir", "unlink", "mkfs", "dd ", "format", "wipe",
"git push", "push", "force-push", "force_push", "reset --hard",
"git clean", "git reset",
"network", "http", "https", "curl", "wget", "fetch", "request",
"egress", "upload", "download",
"payment", "charge", "refund", "transfer", "wire", "email", "send",
"sms",
"sudo", "chmod", "chown", "setuid",
];
const FULL_ACCESS_NAME_SEGMENTS: &[&str] = &[
"deploy", "publish", "release", "kubectl", "terraform", "helm", "rollout",
"credential", "credentials", "secret", "secrets", "password", "passwd",
"delete", "destroy", "drop", "truncate", "rm", "rmdir", "unlink", "mkfs",
"dd", "wipe", "push", "reset", "clean",
"curl", "wget", "egress", "upload", "download", "send",
"payment", "charge", "refund", "wire",
"sudo", "chmod", "chown", "setuid",
];
fn name_segments(name: &str) -> Vec<String> {
let mut segs = Vec::new();
let mut cur = String::new();
let mut prev_lower_or_digit = false;
for ch in name.chars() {
if ch.is_alphanumeric() {
if prev_lower_or_digit && ch.is_uppercase() && !cur.is_empty() {
segs.push(std::mem::take(&mut cur));
}
cur.extend(ch.to_lowercase());
prev_lower_or_digit = ch.is_lowercase() || ch.is_numeric();
} else {
if !cur.is_empty() {
segs.push(std::mem::take(&mut cur));
}
prev_lower_or_digit = false;
}
}
if !cur.is_empty() {
segs.push(cur);
}
segs
}
pub fn tool_name_is_full_access(name: &str) -> bool {
name_segments(name)
.iter()
.any(|seg| FULL_ACCESS_NAME_SEGMENTS.contains(&seg.as_str()))
}
pub fn any_tool_full_access<I>(names: I) -> bool
where
I: IntoIterator,
I::Item: AsRef<str>,
{
names
.into_iter()
.any(|n| tool_name_is_full_access(n.as_ref()))
}
fn collect_strings(v: &serde_json::Value, out: &mut String) {
use serde_json::Value;
match v {
Value::String(s) => {
out.push_str(s);
out.push(' ');
}
Value::Array(items) => {
for it in items {
collect_strings(it, out);
}
}
Value::Object(map) => {
for val in map.values() {
collect_strings(val, out);
}
}
Value::Number(_) | Value::Bool(_) | Value::Null => {
out.push_str(&v.to_string());
out.push(' ');
}
}
}
pub struct RiskClassifier {
rules: Vec<ClassifierRule>,
}
struct ClassifierRule {
#[allow(dead_code)]
name: String,
tier: PermissionTier,
matcher: Box<dyn Fn(&Action) -> bool + Send + Sync>,
}
impl std::fmt::Debug for RiskClassifier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RiskClassifier")
.field("rules", &self.rules.len())
.finish()
}
}
impl RiskClassifier {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
pub fn add_rule<F>(&mut self, name: &str, tier: PermissionTier, matcher: F)
where
F: Fn(&Action) -> bool + Send + Sync + 'static,
{
self.rules.push(ClassifierRule {
name: name.to_string(),
tier,
matcher: Box::new(matcher),
});
}
fn baseline(action: &Action) -> PermissionTier {
match action.action_type {
ActionType::StateRead | ActionType::Assertion => PermissionTier::ReadOnly,
ActionType::StateWrite => PermissionTier::SandboxEdit,
ActionType::ToolCall => PermissionTier::SandboxEdit,
}
}
fn hits_full_access_keyword(action: &Action) -> bool {
let mut hay = String::new();
if let Some(tool) = &action.tool {
hay.push_str(tool);
hay.push(' ');
}
for v in action.parameters.values() {
collect_strings(v, &mut hay);
}
let hay = hay.to_ascii_lowercase();
FULL_ACCESS_KEYWORDS.iter().any(|k| hay.contains(k))
}
pub fn classify(&self, action: &Action) -> PermissionTier {
let mut tier = Self::baseline(action);
if action.action_type == ActionType::ToolCall
&& Self::hits_full_access_keyword(action)
{
tier = tier.max(PermissionTier::FullAccess);
}
for rule in &self.rules {
if (rule.matcher)(action) {
tier = tier.max(rule.tier);
}
}
tier
}
}
impl Default for RiskClassifier {
fn default() -> Self {
Self::new()
}
}
fn canonical_json(v: &serde_json::Value) -> serde_json::Value {
use serde_json::Value;
match v {
Value::Object(map) => {
let sorted: BTreeMap<&String, Value> =
map.iter().map(|(k, val)| (k, canonical_json(val))).collect();
Value::Object(sorted.into_iter().map(|(k, val)| (k.clone(), val)).collect())
}
Value::Array(items) => Value::Array(items.iter().map(canonical_json).collect()),
other => other.clone(),
}
}
fn action_type_tag(t: &ActionType) -> &'static str {
match t {
ActionType::ToolCall => "tool_call",
ActionType::StateWrite => "state_write",
ActionType::StateRead => "state_read",
ActionType::Assertion => "assertion",
}
}
pub fn action_fingerprint(action: &Action) -> String {
let canonical: BTreeMap<&String, serde_json::Value> = action
.parameters
.iter()
.map(|(k, v)| (k, canonical_json(v)))
.collect();
let params = serde_json::to_string(&canonical).unwrap_or_default();
let tool = action.tool.as_deref().unwrap_or("-");
format!("{}|{}|{}", action_type_tag(&action.action_type), tool, params)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalDecision {
Approved,
Rejected,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ApprovalRecord {
pub fingerprint: String,
pub required_tier: PermissionTier,
pub decision: ApprovalDecision,
pub reviewer: String,
pub reason: String,
#[serde(default)]
pub evidence: Option<String>,
pub decided_at: String,
}
#[derive(Debug, Default)]
pub struct ApprovalLedger {
records: HashMap<String, ApprovalRecord>,
journal: Option<PathBuf>,
skipped_on_load: usize,
}
impl ApprovalLedger {
pub fn new() -> Self {
Self::default()
}
pub fn with_journal(path: impl Into<PathBuf>) -> std::io::Result<Self> {
let path = path.into();
let mut ledger = Self {
records: HashMap::new(),
journal: Some(path.clone()),
skipped_on_load: 0,
};
if path.exists() {
let contents = std::fs::read_to_string(&path)?;
for line in contents.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<ApprovalRecord>(line) {
Ok(rec) => {
ledger.records.insert(rec.fingerprint.clone(), rec);
}
Err(_) => ledger.skipped_on_load += 1,
}
}
}
Ok(ledger)
}
pub fn skipped_on_load(&self) -> usize {
self.skipped_on_load
}
pub fn record(&mut self, record: ApprovalRecord) -> &ApprovalRecord {
if let Some(path) = &self.journal {
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)
{
if let Ok(mut line) = serde_json::to_string(&record) {
line.push('\n');
let _ = f.write_all(line.as_bytes());
let _ = f.flush();
}
}
}
use std::collections::hash_map::Entry;
match self.records.entry(record.fingerprint.clone()) {
Entry::Occupied(mut o) => {
o.insert(record);
o.into_mut()
}
Entry::Vacant(v) => v.insert(record),
}
}
pub fn lookup(&self, fingerprint: &str) -> Option<&ApprovalRecord> {
self.records.get(fingerprint)
}
pub fn all(&self) -> impl Iterator<Item = &ApprovalRecord> {
self.records.values()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "decision", rename_all = "snake_case")]
pub enum GateDecision {
Allow {
required: PermissionTier,
granted: PermissionTier,
},
NeedsApproval {
required: PermissionTier,
granted: PermissionTier,
fingerprint: String,
reason: String,
},
Deny {
required: PermissionTier,
fingerprint: String,
reason: String,
},
}
impl GateDecision {
pub fn is_allow(&self) -> bool {
matches!(self, GateDecision::Allow { .. })
}
}
#[derive(Debug)]
pub struct PermissionGate {
granted: PermissionTier,
require_approval_at: PermissionTier,
classifier: RiskClassifier,
ledger: ApprovalLedger,
}
impl PermissionGate {
pub fn new(granted: PermissionTier) -> Self {
Self {
granted,
require_approval_at: PermissionTier::FullAccess,
classifier: RiskClassifier::new(),
ledger: ApprovalLedger::new(),
}
}
pub fn with_classifier(mut self, classifier: RiskClassifier) -> Self {
self.classifier = classifier;
self
}
pub fn with_ledger(mut self, ledger: ApprovalLedger) -> Self {
self.ledger = ledger;
self
}
pub fn with_mandatory_approval_at(mut self, tier: PermissionTier) -> Self {
self.require_approval_at = tier;
self
}
pub fn granted_tier(&self) -> PermissionTier {
self.granted
}
pub fn set_granted_tier(&mut self, tier: PermissionTier) {
self.granted = tier;
}
pub fn classifier(&self) -> &RiskClassifier {
&self.classifier
}
pub fn ledger(&self) -> &ApprovalLedger {
&self.ledger
}
pub fn evaluate(&self, action: &Action) -> GateDecision {
let required = self.classifier.classify(action);
let fingerprint = action_fingerprint(action);
if let Some(rec) = self.ledger.lookup(&fingerprint) {
match rec.decision {
ApprovalDecision::Rejected => {
return GateDecision::Deny {
required,
fingerprint,
reason: format!(
"previously rejected by {} ({})",
rec.reviewer, rec.reason
),
};
}
ApprovalDecision::Approved if required <= rec.required_tier => {
return GateDecision::Allow {
required,
granted: self.granted,
};
}
ApprovalDecision::Approved => {
return GateDecision::NeedsApproval {
required,
granted: self.granted,
fingerprint,
reason: format!(
"operation reclassified {} → {} since it was approved; re-approval required",
rec.required_tier.as_str(),
required.as_str()
),
};
}
}
}
if required >= self.require_approval_at {
return GateDecision::NeedsApproval {
required,
granted: self.granted,
fingerprint,
reason: format!(
"{} actions require human approval before execution",
required.as_str()
),
};
}
if self.granted.covers(required) {
GateDecision::Allow {
required,
granted: self.granted,
}
} else {
GateDecision::NeedsApproval {
required,
granted: self.granted,
fingerprint,
reason: format!(
"action requires {} but session is granted only {}",
required.as_str(),
self.granted.as_str()
),
}
}
}
pub fn approve(
&mut self,
action: &Action,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> ApprovalRecord {
self.record_decision(action, ApprovalDecision::Approved, reviewer, reason, evidence)
}
pub fn reject(
&mut self,
action: &Action,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> ApprovalRecord {
self.record_decision(action, ApprovalDecision::Rejected, reviewer, reason, evidence)
}
pub fn record_for_fingerprint(
&mut self,
fingerprint: &str,
required_tier: PermissionTier,
decision: ApprovalDecision,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> ApprovalRecord {
let record = ApprovalRecord {
fingerprint: fingerprint.to_string(),
required_tier,
decision,
reviewer: reviewer.to_string(),
reason: reason.to_string(),
evidence,
decided_at: chrono::Utc::now().to_rfc3339(),
};
self.ledger.record(record.clone());
record
}
fn record_decision(
&mut self,
action: &Action,
decision: ApprovalDecision,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> ApprovalRecord {
let required = self.classifier.classify(action);
let fingerprint = action_fingerprint(action);
self.record_for_fingerprint(&fingerprint, required, decision, reviewer, reason, evidence)
}
}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::{ActionType, FailureBehavior};
use std::collections::HashMap as Map;
fn action(action_type: ActionType, tool: Option<&str>, params: Map<String, serde_json::Value>) -> Action {
Action {
id: "a1".to_string(),
action_type,
tool: tool.map(str::to_string),
parameters: params,
preconditions: vec![],
expected_effects: HashMap::new(),
state_dependencies: vec![],
read_set: vec![],
write_set: vec![],
assumptions: vec![],
idempotent: false,
max_retries: 3,
failure_behavior: FailureBehavior::Abort,
timeout_ms: None,
metadata: HashMap::new(),
}
}
fn tool_call(tool: &str) -> Action {
action(ActionType::ToolCall, Some(tool), Map::new())
}
#[test]
fn tool_name_full_access_matches_irreversible_capabilities() {
for name in [
"deploy", "git_push", "gitPush", "delete_file", "kubectl_apply",
"git_reset", "git_clean", "send_email", "sudo_run", "rm_rf_dir",
"drop_table", "upload_artifact",
] {
assert!(
tool_name_is_full_access(name),
"{name} should classify as full-access"
);
}
}
#[test]
fn tool_name_full_access_does_not_false_positive_on_benign_names() {
for name in [
"read_file", "grep", "search", "summarize", "classify",
"count_tokens", "tokenize", "token_usage", "http_get", "https_health",
"apply_template", "request_id", "parse_request", "prefetch_cache",
"transfer_learning", "format_date", "network_topology", "dropdown_open",
] {
assert!(
!tool_name_is_full_access(name),
"{name} should NOT classify as full-access (false positive)"
);
}
}
#[test]
fn name_segments_splits_snake_and_camel() {
assert_eq!(name_segments("git_push"), vec!["git", "push"]);
assert_eq!(name_segments("gitPush"), vec!["git", "push"]);
assert_eq!(name_segments("git-push"), vec!["git", "push"]);
assert_eq!(name_segments("count_tokens"), vec!["count", "tokens"]);
assert!(!name_segments("count_tokens").iter().any(|s| s == "token"));
}
#[test]
fn any_tool_full_access_scans_the_palette() {
assert!(any_tool_full_access(["read_file", "grep", "deploy"]));
assert!(!any_tool_full_access(["read_file", "grep", "summarize"]));
assert!(!any_tool_full_access(std::iter::empty::<&str>()));
let owned: Vec<String> = vec!["read_file".into(), "git_push".into()];
assert!(any_tool_full_access(&owned));
}
#[test]
fn tier_ordering() {
assert!(PermissionTier::FullAccess.covers(PermissionTier::ReadOnly));
assert!(PermissionTier::SandboxEdit.covers(PermissionTier::SandboxEdit));
assert!(!PermissionTier::ReadOnly.covers(PermissionTier::SandboxEdit));
}
#[test]
fn classifier_baseline_by_type() {
let c = RiskClassifier::new();
assert_eq!(
c.classify(&action(ActionType::StateRead, None, Map::new())),
PermissionTier::ReadOnly
);
assert_eq!(
c.classify(&action(ActionType::Assertion, None, Map::new())),
PermissionTier::ReadOnly
);
assert_eq!(
c.classify(&action(ActionType::StateWrite, None, Map::new())),
PermissionTier::SandboxEdit
);
assert_eq!(c.classify(&tool_call("echo")), PermissionTier::SandboxEdit);
}
#[test]
fn classifier_escalates_on_keyword_in_tool_name() {
let c = RiskClassifier::new();
assert_eq!(c.classify(&tool_call("deploy_service")), PermissionTier::FullAccess);
assert_eq!(c.classify(&tool_call("http_get")), PermissionTier::FullAccess);
}
#[test]
fn classifier_escalates_on_keyword_in_params() {
let mut params = Map::new();
params.insert("cmd".to_string(), serde_json::json!("rm -rf /tmp/x"));
let a = action(ActionType::ToolCall, Some("shell"), params);
assert_eq!(RiskClassifier::new().classify(&a), PermissionTier::FullAccess);
}
#[test]
fn custom_rule_only_raises() {
let mut c = RiskClassifier::new();
c.add_rule("flag_search", PermissionTier::FullAccess, |a| {
a.tool.as_deref() == Some("search")
});
assert_eq!(c.classify(&tool_call("search")), PermissionTier::FullAccess);
let mut c2 = RiskClassifier::new();
c2.add_rule("noop", PermissionTier::ReadOnly, |_| true);
assert_eq!(
c2.classify(&action(ActionType::StateWrite, None, Map::new())),
PermissionTier::SandboxEdit
);
}
#[test]
fn gate_allows_within_granted_tier() {
let gate = PermissionGate::new(PermissionTier::SandboxEdit);
let d = gate.evaluate(&action(ActionType::StateWrite, None, Map::new()));
assert!(d.is_allow(), "{d:?}");
}
#[test]
fn gate_escalates_above_granted_tier() {
let gate = PermissionGate::new(PermissionTier::ReadOnly);
let d = gate.evaluate(&action(ActionType::StateWrite, None, Map::new()));
assert!(matches!(d, GateDecision::NeedsApproval { .. }), "{d:?}");
}
#[test]
fn gate_full_access_always_needs_approval_even_when_granted() {
let gate = PermissionGate::new(PermissionTier::FullAccess);
let d = gate.evaluate(&tool_call("deploy"));
assert!(matches!(d, GateDecision::NeedsApproval { .. }), "{d:?}");
}
#[test]
fn approval_makes_future_evaluation_allow() {
let mut gate = PermissionGate::new(PermissionTier::ReadOnly);
let a = tool_call("deploy");
assert!(matches!(gate.evaluate(&a), GateDecision::NeedsApproval { .. }));
gate.approve(&a, "matt", "reviewed the deploy plan", None);
assert!(gate.evaluate(&a).is_allow());
}
#[test]
fn rejection_denies_future_evaluation() {
let mut gate = PermissionGate::new(PermissionTier::FullAccess);
let a = tool_call("transfer_funds");
gate.reject(&a, "matt", "not authorized", None);
assert!(matches!(gate.evaluate(&a), GateDecision::Deny { .. }));
}
#[test]
fn classifier_escalates_on_argv_array_command() {
let mut params = Map::new();
params.insert(
"args".to_string(),
serde_json::json!(["git", "push", "--force", "origin", "main"]),
);
let a = action(ActionType::ToolCall, Some("shell"), params);
assert_eq!(RiskClassifier::new().classify(&a), PermissionTier::FullAccess);
}
#[test]
fn fingerprint_canonicalizes_nested_object_key_order() {
let mk = |json: serde_json::Value| {
let mut p = Map::new();
p.insert("opts".to_string(), json);
action(ActionType::ToolCall, Some("t"), p)
};
let a = mk(serde_json::json!({"a": 1, "b": {"x": 1, "y": 2}}));
let b = mk(serde_json::json!({"b": {"y": 2, "x": 1}, "a": 1}));
assert_eq!(action_fingerprint(&a), action_fingerprint(&b));
}
#[test]
fn fingerprint_uses_stable_type_tag_not_debug() {
let fp = action_fingerprint(&tool_call("x"));
assert!(fp.starts_with("tool_call|"), "got {fp}");
assert!(!fp.contains("ToolCall"));
}
#[test]
fn approval_does_not_survive_upward_reclassification() {
let a = tool_call("safe_tool");
let mut classifier = RiskClassifier::new();
let mut gate = PermissionGate::new(PermissionTier::SandboxEdit)
.with_classifier(classifier);
gate.approve(&a, "matt", "looked fine", None);
assert!(gate.evaluate(&a).is_allow());
classifier = RiskClassifier::new();
classifier.add_rule("now_dangerous", PermissionTier::FullAccess, |act| {
act.tool.as_deref() == Some("safe_tool")
});
let gate = gate.with_classifier(classifier);
assert!(
matches!(gate.evaluate(&a), GateDecision::NeedsApproval { .. }),
"stale low-tier approval must not bypass the FullAccess gate"
);
}
#[test]
fn fingerprint_is_param_sensitive_and_stable() {
let a1 = {
let mut p = Map::new();
p.insert("x".to_string(), serde_json::json!(1));
p.insert("y".to_string(), serde_json::json!(2));
action(ActionType::ToolCall, Some("t"), p)
};
let a2 = {
let mut p = Map::new();
p.insert("y".to_string(), serde_json::json!(2));
p.insert("x".to_string(), serde_json::json!(1));
action(ActionType::ToolCall, Some("t"), p)
};
assert_eq!(action_fingerprint(&a1), action_fingerprint(&a2));
let a3 = {
let mut p = Map::new();
p.insert("x".to_string(), serde_json::json!(99));
action(ActionType::ToolCall, Some("t"), p)
};
assert_ne!(action_fingerprint(&a1), action_fingerprint(&a3));
}
#[test]
fn ledger_journal_round_trips() {
let dir = std::env::temp_dir();
let path = dir.join(format!("car-approvals-test-{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&path);
let a = tool_call("deploy");
{
let ledger = ApprovalLedger::with_journal(&path).unwrap();
let mut gate =
PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger);
gate.approve(&a, "matt", "ok", Some("diff: +1 -0".to_string()));
}
let ledger2 = ApprovalLedger::with_journal(&path).unwrap();
let gate2 = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger2);
assert!(gate2.evaluate(&a).is_allow());
let _ = std::fs::remove_file(&path);
}
}