use car_ir::{Action, ActionType, Reversibility};
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(' ');
}
}
}
fn action_haystack(action: &Action) -> String {
let mut hay = String::new();
if let Some(tool) = &action.tool {
hay.push_str(tool);
hay.push('\n');
}
let mut cmdline = String::new();
for group in [COMMAND_KEYS, ARG_KEYS] {
for key in group {
if let Some(v) = action.parameters.get(*key) {
collect_strings(v, &mut cmdline);
}
}
}
if !cmdline.is_empty() {
hay.push_str(&cmdline);
hay.push('\n');
}
let mut keys: Vec<&String> = action
.parameters
.keys()
.filter(|k| {
let k = k.as_str();
!COMMAND_KEYS.contains(&k) && !ARG_KEYS.contains(&k)
})
.collect();
keys.sort();
for k in keys {
if let Some(v) = action.parameters.get(k) {
collect_strings(v, &mut hay);
hay.push('\n');
}
}
hay.to_ascii_lowercase()
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ActionAxes {
pub decision: GateDecision,
pub reversibility: Reversibility,
}
pub fn action_text(action: &Action) -> String {
action_haystack(action)
}
const COMMAND_KEYS: &[&str] = &["command", "cmd", "executable", "program", "bin", "binary"];
const ARG_KEYS: &[&str] = &["args", "argv", "arguments", "flags", "options"];
const PATH_PARAM_KEYS: &[&str] = &[
"path",
"paths",
"file",
"files",
"file_path",
"filepath",
"filename",
"dir",
"directory",
"folder",
"dest",
"destination",
"target",
"target_path",
"output",
"output_path",
"out",
"src",
"source",
"source_path",
"cwd",
"workdir",
"working_dir",
];
fn path_parameter_haystack(action: &Action) -> String {
fn walk(v: &serde_json::Value, key_matched: bool, out: &mut String) {
use serde_json::Value;
match v {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for k in keys {
let hit =
key_matched || PATH_PARAM_KEYS.contains(&k.to_ascii_lowercase().as_str());
if let Some(inner) = map.get(k) {
walk(inner, hit, out);
}
}
}
Value::Array(items) => {
for it in items {
walk(it, key_matched, out);
}
}
Value::String(s) if key_matched => {
out.push_str(s);
out.push(' ');
}
_ => {}
}
}
let mut out = String::new();
let mut keys: Vec<&String> = action.parameters.keys().collect();
keys.sort();
for k in keys {
let hit = PATH_PARAM_KEYS.contains(&k.to_ascii_lowercase().as_str());
if let Some(v) = action.parameters.get(k) {
walk(v, hit, &mut out);
}
}
out.to_ascii_lowercase()
}
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(hay: &str) -> bool {
FULL_ACCESS_KEYWORDS.iter().any(|k| hay.contains(k))
}
pub fn classify(&self, action: &Action) -> PermissionTier {
self.classify_with_haystack(action, None)
}
pub fn classify_with_haystack(
&self,
action: &Action,
haystack: Option<&str>,
) -> PermissionTier {
let mut tier = Self::baseline(action);
if action.action_type == ActionType::ToolCall {
let owned;
let hay: &str = match haystack {
Some(h) => h,
None => {
owned = action_haystack(action);
&owned
}
};
if Self::hits_full_access_keyword(hay) {
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()
}
}
const IRREVERSIBLE_NAME_SEGMENTS: &[&str] = &[
"send",
"sendmail",
"notify",
"dispatch",
"broadcast",
"announce",
"publish",
"enqueue",
"pay",
"payment",
"payments",
"charge",
"refund",
"payout",
"invoice",
"checkout",
"wire",
"remit",
"delete",
"destroy",
"purge",
"wipe",
"shred",
"erase",
"truncate",
"mkfs",
"rm",
"rmdir",
"unlink",
"drop",
"revoke",
"actuate",
"unlock",
];
const COMPENSABLE_NAME_SEGMENTS: &[&str] = &[
"insert",
"upsert",
"create",
"put",
"update",
"patch",
"clone",
"register",
"provision",
"allocate",
"attach",
"mount",
"subscribe",
"enable",
"disable",
"start",
"stop",
"restart",
"grant",
"push",
"tag",
"commit",
"deploy",
"deployment",
"deployments",
"rollout",
"install",
"upgrade",
"upload",
];
const RETRIEVAL_NAME_SEGMENTS: &[&str] = &[
"read",
"get",
"list",
"search",
"query",
"select",
"find",
"grep",
"stat",
"describe",
"inspect",
"show",
"view",
"count",
"head",
"tail",
"cat",
"diff",
"status",
"lookup",
"fetch",
"load",
"scan",
"peek",
"exists",
"resolve",
"summarize",
"analyze",
"classify",
"parse",
"validate",
"check",
];
const FILESYSTEM_NAME_SEGMENTS: &[&str] = &[
"file",
"files",
"fs",
"filesystem",
"dir",
"directory",
"folder",
"path",
"write",
"edit",
"append",
"mkdir",
"touch",
"save",
];
const IRREVERSIBLE_PARAM_PHRASES: &[&str] = &[
"rm -rf",
"rm -r ",
"rm -f ",
"mkfs",
"shred ",
"dd if=",
"drop table",
"drop database",
"delete from",
"truncate table",
"terraform destroy",
"kubectl delete",
];
const COMPENSABLE_PARAM_PHRASES: &[&str] = &[
"git push",
"git commit",
"git tag",
"docker push",
"kubectl apply",
"kubectl rollout",
"helm install",
"helm upgrade",
"terraform apply",
"insert into",
];
const SANDBOX_PATH_PREFIXES: &[&str] = &[
"/tmp/",
"/private/tmp/",
"/var/tmp/",
"/private/var/tmp/",
"/var/folders/",
"/private/var/folders/",
"/dev/shm/",
"c:\\temp\\",
"c:\\windows\\temp\\",
"\\\\?\\c:\\temp\\",
];
fn absolute_path_tokens(hay: &str) -> impl Iterator<Item = &str> {
hay.split_whitespace()
.map(|raw| {
raw.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | ',' | ';' | ')' | '('))
})
.filter(|token| {
let bytes = token.as_bytes();
token.starts_with('/')
|| token.starts_with("\\\\")
|| (bytes.len() > 2 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/'))
})
}
fn is_scratch_path(token: &str) -> bool {
SANDBOX_PATH_PREFIXES.iter().any(|p| token.starts_with(p))
}
fn sandbox_confined_paths(target_paths: &str, whole_action: &str) -> bool {
if absolute_path_tokens(whole_action).any(|t| !is_scratch_path(t)) {
return false;
}
absolute_path_tokens(target_paths).next().is_some()
}
const MUTATING_PARAM_PHRASES: &[&str] = &[
"insert into",
"delete from",
"drop table",
"drop column",
"drop database",
"drop index",
"drop view",
"drop constraint",
"alter table",
"alter column",
"create table",
"create index",
"create database",
"truncate table",
"replace into",
"merge into",
"grant ",
"revoke ",
"-delete",
"-exec rm",
"--force",
"--overwrite",
"--prune",
"rm -",
"mv ",
"chmod ",
"chown ",
];
fn mutating_parameter_evidence(hay: &str) -> bool {
if MUTATING_PARAM_PHRASES.iter().any(|p| hay.contains(p)) {
return true;
}
hay.contains("update ") && hay.contains(" set ")
}
pub fn classify_reversibility(action: &Action) -> Reversibility {
classify_reversibility_with_haystack(action, None)
}
pub fn classify_reversibility_with_haystack(
action: &Action,
haystack: Option<&str>,
) -> Reversibility {
match action.action_type {
ActionType::StateRead | ActionType::Assertion => Reversibility::Reversible,
ActionType::StateWrite => Reversibility::Reversible,
ActionType::ToolCall => classify_tool_call_reversibility(action, haystack),
}
}
fn classify_tool_call_reversibility(action: &Action, haystack: Option<&str>) -> Reversibility {
let owned;
let hay: &str = match haystack {
Some(h) => h,
None => {
owned = action_haystack(action);
&owned
}
};
let segments = action
.tool
.as_deref()
.map(name_segments)
.unwrap_or_default();
let has = |set: &[&str]| segments.iter().any(|s| set.contains(&s.as_str()));
let compensable_phrase = COMPENSABLE_PARAM_PHRASES.iter().any(|p| hay.contains(p));
if IRREVERSIBLE_PARAM_PHRASES.iter().any(|p| hay.contains(p)) {
return Reversibility::Irreversible;
}
if has(RETRIEVAL_NAME_SEGMENTS)
&& !has(IRREVERSIBLE_NAME_SEGMENTS)
&& !has(COMPENSABLE_NAME_SEGMENTS)
&& !compensable_phrase
&& !mutating_parameter_evidence(hay)
{
return Reversibility::Reversible;
}
if has(IRREVERSIBLE_NAME_SEGMENTS) {
return Reversibility::Irreversible;
}
if has(COMPENSABLE_NAME_SEGMENTS) || compensable_phrase {
return Reversibility::Compensable;
}
if has(FILESYSTEM_NAME_SEGMENTS)
&& sandbox_confined_paths(&path_parameter_haystack(action), hay)
{
return Reversibility::Reversible;
}
Reversibility::Irreversible
}
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)
}
pub fn evaluate_axes(
&self,
action: &Action,
ceiling: Option<PermissionTier>,
ledger: Option<&ApprovalLedger>,
) -> ActionAxes {
let hay = action_text(action);
let granted = match ceiling {
Some(c) => self.granted.min(c),
None => self.granted,
};
ActionAxes {
decision: self.evaluate_with_granted_haystack(
action,
granted,
ledger.unwrap_or(&self.ledger),
Some(&hay),
),
reversibility: classify_reversibility_with_haystack(action, Some(&hay)),
}
}
fn evaluate_with_granted(
&self,
action: &Action,
granted: PermissionTier,
ledger: &ApprovalLedger,
) -> GateDecision {
self.evaluate_with_granted_haystack(action, granted, ledger, None)
}
fn evaluate_with_granted_haystack(
&self,
action: &Action,
granted: PermissionTier,
ledger: &ApprovalLedger,
haystack: Option<&str>,
) -> GateDecision {
let required = self.classifier.classify_with_haystack(action, haystack);
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;
use serde_json::json;
use std::collections::HashMap as Map;
fn action(
action_type: ActionType,
tool: Option<&str>,
params: Map<String, serde_json::Value>,
) -> Action {
{
let mut a = Action::new(action_type);
a.id = "a1".to_string();
a.tool = tool.map(str::to_string);
a.parameters = params;
a
}
}
fn tool_call(tool: &str) -> Action {
action(ActionType::ToolCall, Some(tool), Map::new())
}
fn tool_call_with(tool: &str, params: &[(&str, serde_json::Value)]) -> Action {
let mut p = Map::new();
for (k, v) in params {
p.insert((*k).to_string(), v.clone());
}
action(ActionType::ToolCall, Some(tool), p)
}
#[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);
}
#[test]
fn the_two_axes_disagree_in_both_directions() {
let cases: Vec<(&str, Action, PermissionTier, Reversibility)> = vec![
(
"a state read observes and mutates nothing",
action(ActionType::StateRead, None, Map::new()),
PermissionTier::ReadOnly,
Reversibility::Reversible,
),
(
"a scratch-file write is undone by discarding the sandbox",
tool_call_with(
"write_file",
&[
("path", serde_json::json!("/tmp/car-sandbox/notes.txt")),
("contents", serde_json::json!("hello")),
],
),
PermissionTier::SandboxEdit,
Reversibility::Reversible,
),
(
"reading a credential takes the top grant and leaves nothing to undo",
tool_call("read_secret"),
PermissionTier::FullAccess,
Reversibility::Reversible,
),
(
"a row insert reads as low-authority but needs a compensating delete",
tool_call_with(
"db_insert",
&[
("table", serde_json::json!("orders")),
("row", serde_json::json!({"id": 7})),
],
),
PermissionTier::SandboxEdit,
Reversibility::Compensable,
),
(
"a push takes the top grant AND is recoverable — force-push the prior ref",
tool_call("git_push"),
PermissionTier::FullAccess,
Reversibility::Compensable,
),
(
"a sent email takes the top grant and is permanent",
tool_call_with(
"send_email",
&[
("to", serde_json::json!("ops@example.com")),
("subject", serde_json::json!("nightly status")),
],
),
PermissionTier::FullAccess,
Reversibility::Irreversible,
),
(
"a charged card takes the top grant and is permanent",
tool_call_with("charge_card", &[("amount_cents", serde_json::json!(4200))]),
PermissionTier::FullAccess,
Reversibility::Irreversible,
),
(
"a write outside the sandbox reads as low-authority and is unrecoverable",
tool_call_with(
"write_file",
&[
("path", serde_json::json!("/etc/hosts")),
("contents", serde_json::json!("127.0.0.1 x")),
],
),
PermissionTier::SandboxEdit,
Reversibility::Irreversible,
),
];
let classifier = RiskClassifier::new();
for (why, act, tier, rev) in cases {
assert_eq!(classifier.classify(&act), tier, "required tier — {why}");
assert_eq!(classify_reversibility(&act), rev, "reversibility — {why}");
}
}
#[test]
fn evaluate_axes_agrees_with_computing_the_axes_separately() {
let cases = vec![
tool_call("send_email"),
tool_call("read_secret"),
tool_call_with(
"shell",
&[
("command", json!("git")),
("args", json!(["push", "--force", "origin", "main"])),
],
),
tool_call_with("write_file", &[("path", json!("/tmp/scratch/a.txt"))]),
tool_call_with("write_file", &[("path", json!("/etc/hosts"))]),
tool_call_with("execute_query", &[("sql", json!("UPDATE t SET x = 1"))]),
action(ActionType::StateRead, None, Map::new()),
action(ActionType::StateWrite, None, Map::new()),
];
for granted in [
PermissionTier::ReadOnly,
PermissionTier::SandboxEdit,
PermissionTier::FullAccess,
] {
let gate = PermissionGate::new(granted);
for a in &cases {
let axes = gate.evaluate_axes(a, None, None);
assert_eq!(
axes.decision,
gate.evaluate(a),
"decision drifted for {:?} at {granted:?}",
a.tool
);
assert_eq!(
axes.reversibility,
classify_reversibility(a),
"reversibility drifted for {:?}",
a.tool
);
}
}
let gate = PermissionGate::new(PermissionTier::FullAccess);
let a = tool_call("deploy_service");
assert_eq!(
gate.evaluate_axes(&a, Some(PermissionTier::ReadOnly), None)
.decision,
gate.evaluate_with_ceiling(&a, Some(PermissionTier::ReadOnly)),
);
}
#[test]
fn neither_axis_is_a_function_of_the_other() {
let c = RiskClassifier::new();
for (act, rev) in [
(tool_call("read_secret"), Reversibility::Reversible),
(tool_call("git_push"), Reversibility::Compensable),
(tool_call("send_email"), Reversibility::Irreversible),
] {
let name = act.tool.clone().unwrap_or_default();
assert_eq!(c.classify(&act), PermissionTier::FullAccess, "tier {name}");
assert_eq!(classify_reversibility(&act), rev, "reversibility {name}");
}
for (act, tier) in [
(
action(ActionType::StateRead, None, Map::new()),
PermissionTier::ReadOnly,
),
(
action(ActionType::StateWrite, None, Map::new()),
PermissionTier::SandboxEdit,
),
(tool_call("read_secret"), PermissionTier::FullAccess),
] {
assert_eq!(
classify_reversibility(&act),
Reversibility::Reversible,
"reversibility {:?}",
act.tool
);
assert_eq!(c.classify(&act), tier, "tier {:?}", act.tool);
}
}
#[test]
fn state_actions_are_settled_by_their_type() {
for kind in [ActionType::StateRead, ActionType::Assertion] {
assert_eq!(
classify_reversibility(&action(kind, None, Map::new())),
Reversibility::Reversible
);
}
let mut p = Map::new();
p.insert("value".to_string(), serde_json::json!("rm -rf /"));
assert_eq!(
classify_reversibility(&action(ActionType::StateWrite, None, p)),
Reversibility::Reversible
);
}
#[test]
fn a_generic_shell_is_classified_from_its_argv() {
let destructive = tool_call_with(
"shell",
&[("args", serde_json::json!(["rm", "-rf", "/var/data"]))],
);
let recoverable = tool_call_with(
"shell",
&[("args", serde_json::json!(["git", "push", "origin", "main"]))],
);
assert_eq!(
classify_reversibility(&destructive),
Reversibility::Irreversible
);
assert_eq!(
classify_reversibility(&recoverable),
Reversibility::Compensable
);
let c = RiskClassifier::new();
assert_eq!(c.classify(&destructive), PermissionTier::FullAccess);
assert_eq!(c.classify(&recoverable), PermissionTier::FullAccess);
}
#[test]
fn severity_wins_when_both_families_match() {
assert_eq!(
classify_reversibility(&tool_call("create_payment")),
Reversibility::Irreversible
);
assert_eq!(
classify_reversibility(&tool_call("get_and_delete")),
Reversibility::Irreversible
);
assert_eq!(
classify_reversibility(&tool_call("list_and_push")),
Reversibility::Compensable
);
}
#[test]
fn sandbox_confinement_is_all_or_nothing() {
let mixed = tool_call_with(
"write_file",
&[
("src", serde_json::json!("/tmp/car-sandbox/in.txt")),
("dst", serde_json::json!("/etc/hosts")),
],
);
assert_eq!(classify_reversibility(&mixed), Reversibility::Irreversible);
let relative = tool_call_with("write_file", &[("path", serde_json::json!("notes.txt"))]);
assert_eq!(
classify_reversibility(&relative),
Reversibility::Irreversible
);
}
#[test]
fn the_sandbox_rule_does_not_leak_to_non_filesystem_tools() {
let a = tool_call_with(
"http_post",
&[("body_file", serde_json::json!("/tmp/payload.json"))],
);
assert_eq!(classify_reversibility(&a), Reversibility::Irreversible);
}
#[test]
fn classification_is_deterministic_across_parameter_orderings() {
let mk = || {
tool_call_with(
"shell",
&[
("command", json!("git")),
("args", json!(["push", "--force", "origin", "main"])),
("cwd", json!("/srv/app")),
("timeout", json!(30)),
],
)
};
let first = classify_reversibility(&mk());
for i in 0..256 {
assert_eq!(
classify_reversibility(&mk()),
first,
"reversibility moved on iteration {i}"
);
}
assert_eq!(first, Reversibility::Compensable);
}
#[test]
fn command_and_args_stay_adjacent_so_rm_rf_is_still_seen() {
let a = tool_call_with(
"shell",
&[
("command", json!("rm")),
("args", json!(["-rf", "/var/data"])),
],
);
assert_eq!(classify_reversibility(&a), Reversibility::Irreversible);
}
#[test]
fn a_read_verb_in_the_name_cannot_override_a_mutation_in_the_parameters() {
for (tool, params) in [
("execute_query", json!("UPDATE accounts SET balance = 0")),
("db_query", json!("ALTER TABLE users DROP COLUMN email")),
("query", json!("INSERT INTO audit VALUES (1)")),
("search_index", json!("DROP INDEX idx_users")),
] {
let a = tool_call_with(tool, &[("sql", params)]);
assert_ne!(
classify_reversibility(&a),
Reversibility::Reversible,
"{tool} carries a mutation and must not classify as reversible"
);
}
let a = tool_call_with(
"find",
&[
("command", json!("find")),
("args", json!(["/data", "-name", "*.log", "-delete"])),
],
);
assert_ne!(classify_reversibility(&a), Reversibility::Reversible);
let a = tool_call_with("execute_query", &[("sql", json!("SELECT id FROM users"))]);
assert_eq!(classify_reversibility(&a), Reversibility::Reversible);
}
#[test]
fn only_os_scratch_roots_count_as_discardable() {
for path in [
"/srv/sandbox-prod/index.html",
"/System/Library/Sandbox/Profiles/x.sb",
"/opt/scratch-data/customers.db",
"/var/lib/sandbox/state.json",
"/etc/tmp/hosts",
] {
let a = tool_call_with("write_file", &[("path", json!(path))]);
assert_ne!(
classify_reversibility(&a),
Reversibility::Reversible,
"{path} is not OS-designated scratch space"
);
}
for path in ["/tmp/build/out.txt", "/private/var/folders/xy/z/T/a.txt"] {
let a = tool_call_with("write_file", &[("path", json!(path))]);
assert_eq!(
classify_reversibility(&a),
Reversibility::Reversible,
"{path} is scratch space"
);
}
}
#[test]
fn an_incidental_scratch_path_cannot_vouch_for_a_write_elsewhere() {
let a = tool_call_with(
"write_file",
&[
("path", json!("config.yaml")),
("contents", json!("cache_dir: /tmp/app\n")),
],
);
assert_eq!(
classify_reversibility(&a),
Reversibility::Irreversible,
"the target is a relative path; the /tmp string is incidental"
);
let a = tool_call_with(
"write_file",
&[
("path", json!("/tmp/app/config.yaml")),
("contents", json!("cache_dir: ./app\n")),
],
);
assert_eq!(classify_reversibility(&a), Reversibility::Reversible);
let a = tool_call_with(
"write_file",
&[("paths", json!(["/tmp/a.txt", "/etc/hosts"]))],
);
assert_eq!(classify_reversibility(&a), Reversibility::Irreversible);
let a = tool_call_with(
"write_file",
&[
("src", json!("/tmp/in.txt")),
("dst", json!("/etc/hosts")),
("mode", json!("0644")),
],
);
assert_eq!(
classify_reversibility(&a),
Reversibility::Irreversible,
"an unrecognized key must still be able to veto"
);
}
#[test]
fn unrecognized_tools_default_to_irreversible() {
for name in ["frobnicate", "acme_widget", "run"] {
assert_eq!(
classify_reversibility(&tool_call(name)),
Reversibility::Irreversible,
"{name} is unrecognized and must assume the worst"
);
}
}
#[test]
fn documented_over_classifications_stay_documented() {
assert_eq!(
classify_reversibility(&tool_call("list_deployments")),
Reversibility::Compensable,
"a read over a mutating noun over-classifies"
);
assert_eq!(
classify_reversibility(&tool_call("get_payment")),
Reversibility::Irreversible,
"money is matched by its object, not its verb"
);
}
}