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 {
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
.iter()
.map(|r| r.name.as_str())
.collect::<Vec<_>>(),
)
.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) -> std::io::Result<&ApprovalRecord> {
if let Some(path) = &self.journal {
let mut f = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(path)?;
let mut line = serde_json::to_string(&record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
line.push('\n');
f.write_all(line.as_bytes())?;
f.flush()?;
}
use std::collections::hash_map::Entry;
Ok(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()
}
#[allow(clippy::too_many_arguments)]
pub fn record_decision(
&mut self,
fingerprint: &str,
required_tier: PermissionTier,
decision: ApprovalDecision,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> std::io::Result<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.record(record.clone())?;
Ok(record)
}
}
#[derive(Debug, Clone)]
pub struct LedgerPartition<V> {
pub blocked: Vec<V>,
pub pending: Vec<(String, V)>,
}
pub fn partition_by_ledger<V, F>(
hard_blocked: Vec<V>,
needs_approval: &[V],
fingerprint: F,
ledger: &ApprovalLedger,
) -> LedgerPartition<V>
where
V: Clone,
F: Fn(&V) -> String,
{
let mut blocked = hard_blocked;
let mut pending = Vec::new();
for v in needs_approval {
let fp = fingerprint(v);
match ledger.lookup(&fp).map(|r| r.decision) {
Some(ApprovalDecision::Approved) => { }
Some(ApprovalDecision::Rejected) => blocked.push(v.clone()),
None => pending.push((fp, v.clone())),
}
}
LedgerPartition { blocked, pending }
}
#[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 {
self.evaluate_with_granted(action, self.granted, &self.ledger)
}
pub fn evaluate_against(&self, action: &Action, ledger: &ApprovalLedger) -> GateDecision {
self.evaluate_with_granted(action, self.granted, ledger)
}
pub fn evaluate_with_ceiling(
&self,
action: &Action,
ceiling: Option<PermissionTier>,
) -> GateDecision {
self.evaluate_with_ceiling_against(action, ceiling, &self.ledger)
}
pub fn evaluate_with_ceiling_against(
&self,
action: &Action,
ceiling: Option<PermissionTier>,
ledger: &ApprovalLedger,
) -> GateDecision {
let effective = match ceiling {
Some(c) => self.granted.min(c),
None => self.granted,
};
self.evaluate_with_granted(action, effective, ledger)
}
fn evaluate_with_granted(
&self,
action: &Action,
granted: PermissionTier,
ledger: &ApprovalLedger,
) -> GateDecision {
let required = self.classifier.classify(action);
let fingerprint = action_fingerprint(action);
if let Some(rec) = 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 };
}
ApprovalDecision::Approved => {
return GateDecision::NeedsApproval {
required,
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,
fingerprint,
reason: format!(
"{} actions require human approval before execution",
required.as_str()
),
};
}
if granted.covers(required) {
GateDecision::Allow { required, granted }
} else {
GateDecision::NeedsApproval {
required,
granted,
fingerprint,
reason: format!(
"action requires {} but session is granted only {}",
required.as_str(),
granted.as_str()
),
}
}
}
pub fn approve(
&mut self,
action: &Action,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> std::io::Result<ApprovalRecord> {
self.record_decision(
action,
ApprovalDecision::Approved,
reviewer,
reason,
evidence,
)
}
pub fn reject(
&mut self,
action: &Action,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> std::io::Result<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>,
) -> std::io::Result<ApprovalRecord> {
self.ledger.record_decision(
fingerprint,
required_tier,
decision,
reviewer,
reason,
evidence,
)
}
pub fn decision_record(
&self,
action: &Action,
decision: ApprovalDecision,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> ApprovalRecord {
ApprovalRecord {
fingerprint: action_fingerprint(action),
required_tier: self.classifier.classify(action),
decision,
reviewer: reviewer.to_string(),
reason: reason.to_string(),
evidence,
decided_at: chrono::Utc::now().to_rfc3339(),
}
}
fn record_decision(
&mut self,
action: &Action,
decision: ApprovalDecision,
reviewer: &str,
reason: &str,
evidence: Option<String>,
) -> std::io::Result<ApprovalRecord> {
let record = self.decision_record(action, decision, reviewer, reason, evidence);
self.ledger.record(record.clone())?;
Ok(record)
}
}
#[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![],
invocation_mode: Default::default(),
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 skill_ceiling_caps_below_granted_tier() {
let gate = PermissionGate::new(PermissionTier::SandboxEdit);
let act = action(ActionType::StateWrite, None, Map::new()); assert!(gate.evaluate(&act).is_allow());
let capped = gate.evaluate_with_ceiling(&act, Some(PermissionTier::ReadOnly));
assert!(
matches!(capped, GateDecision::NeedsApproval { .. }),
"{capped:?}"
);
assert!(gate.evaluate_with_ceiling(&act, None).is_allow());
}
#[test]
fn skill_ceiling_at_or_above_granted_is_noop() {
let gate = PermissionGate::new(PermissionTier::SandboxEdit);
let act = action(ActionType::StateWrite, None, Map::new());
assert!(gate
.evaluate_with_ceiling(&act, Some(PermissionTier::SandboxEdit))
.is_allow());
assert!(gate
.evaluate_with_ceiling(&act, Some(PermissionTier::FullAccess))
.is_allow());
}
#[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)
.unwrap();
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).unwrap();
assert!(matches!(gate.evaluate(&a), GateDecision::Deny { .. }));
}
#[test]
fn shared_ledger_approval_is_visible_across_gates() {
let gate_a = PermissionGate::new(PermissionTier::ReadOnly);
let gate_b = PermissionGate::new(PermissionTier::ReadOnly);
let mut shared = ApprovalLedger::new();
let a = tool_call("deploy");
assert!(matches!(
gate_b.evaluate_against(&a, &shared),
GateDecision::NeedsApproval { .. }
));
let rec = gate_a.decision_record(&a, ApprovalDecision::Approved, "matt", "ok", None);
shared.record(rec).unwrap();
assert!(gate_b.evaluate_against(&a, &shared).is_allow());
assert!(matches!(
gate_b.evaluate(&a),
GateDecision::NeedsApproval { .. }
));
}
#[test]
fn ledger_record_decision_round_trips_fingerprint() {
let mut ledger = ApprovalLedger::new();
let rec = ledger
.record_decision(
"harness:retry:abcd1234",
PermissionTier::SandboxEdit,
ApprovalDecision::Approved,
"conn:1",
"reviewed",
None,
)
.unwrap();
assert_eq!(rec.fingerprint, "harness:retry:abcd1234");
assert_eq!(
ledger.lookup("harness:retry:abcd1234").map(|r| r.decision),
Some(ApprovalDecision::Approved)
);
}
#[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).unwrap();
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()))
.unwrap();
}
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);
}
#[test]
fn unwritable_journal_errors_and_stores_nothing() {
let dir = std::env::temp_dir().join(format!("car-approvals-dir-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut ledger = ApprovalLedger {
records: HashMap::new(),
journal: Some(dir.clone()),
skipped_on_load: 0,
};
let a = tool_call("deploy");
let fp = action_fingerprint(&a);
let err = ledger.record(ApprovalRecord {
fingerprint: fp.clone(),
required_tier: PermissionTier::FullAccess,
decision: ApprovalDecision::Approved,
reviewer: "matt".into(),
reason: "ok".into(),
evidence: None,
decided_at: chrono::Utc::now().to_rfc3339(),
});
assert!(err.is_err(), "journal write failure must surface");
assert!(
ledger.lookup(&fp).is_none(),
"failed record must not be stored in memory"
);
let _ = std::fs::remove_dir_all(&dir);
}
}