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_COMMAND_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_TEXT_PHRASES: &[&str] = &[
"drop table",
"drop database",
"drop schema",
"delete from",
"truncate table",
"rm -rf",
"rm -fr",
"rm -r ",
"mkfs",
"shred -",
"dd if=",
"sudo ",
"chmod ",
"chown ",
"setuid",
"git push",
"git reset",
"git clean",
"reset --hard",
"force-push",
"force_push",
"docker push",
"npm publish",
"cargo publish",
"kubectl ",
"terraform ",
"helm ",
"aws ",
"gcloud ",
"curl ",
"wget ",
"private key",
"private_key",
"-----begin",
];
const CREDENTIAL_PARAM_KEYS: &[&str] = &[
"token",
"apikey",
"apitoken",
"accesstoken",
"accesskey",
"authtoken",
"authorization",
"bearer",
"bearertoken",
"clientsecret",
"privatekey",
"refreshtoken",
"secretkey",
"sessiontoken",
"signingkey",
];
const CREDENTIAL_PATH_FRAGMENTS: &[&str] = &[
"ssh",
"id_rsa",
"id_dsa",
"id_ecdsa",
"id_ed25519",
".gnupg",
"secring",
".aws/credentials",
".aws\\credentials",
".kube/config",
".kube\\config",
".docker/config.json",
".netrc",
".pgpass",
".npmrc",
"service-account",
"service_account",
"credential",
"secret",
"password",
"keychain",
".pem",
".p12",
".pfx",
".env",
];
const CREDENTIAL_KEY_SEGMENTS: &[&str] = &[
"secret",
"secrets",
"credential",
"credentials",
"password",
"passwd",
"passphrase",
];
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');
}
if let Some(cmdline) = command_line(action) {
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()
}
fn command_line(action: &Action) -> Option<String> {
let mut cmd = 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 cmd);
}
}
}
if cmd.is_empty() {
None
} else {
Some(cmd.to_ascii_lowercase())
}
}
fn hits_credential_param_key(action: &Action) -> bool {
fn key_is_credential(key: &str) -> bool {
let depunct: String = key
.chars()
.filter(|c| c.is_alphanumeric())
.flat_map(char::to_lowercase)
.collect();
if CREDENTIAL_PARAM_KEYS.contains(&depunct.as_str()) {
return true;
}
name_segments(key)
.iter()
.any(|seg| CREDENTIAL_KEY_SEGMENTS.contains(&seg.as_str()))
}
fn walk(v: &serde_json::Value) -> bool {
use serde_json::Value;
match v {
Value::Object(map) => map
.iter()
.any(|(k, inner)| key_is_credential(k) || walk(inner)),
Value::Array(items) => items.iter().any(walk),
_ => false,
}
}
action
.parameters
.iter()
.any(|(k, v)| key_is_credential(k) || walk(v))
}
#[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(action: &Action, hay: &str) -> bool {
if action.tool.as_deref().is_some_and(tool_name_is_full_access) {
return true;
}
if let Some(cmd) = command_line(action) {
if FULL_ACCESS_COMMAND_KEYWORDS.iter().any(|k| cmd.contains(k)) {
return true;
}
}
if hits_credential_param_key(action) {
return true;
}
let targets = path_parameter_haystack(action);
if CREDENTIAL_PATH_FRAGMENTS
.iter()
.any(|f| targets.contains(f))
{
return true;
}
FULL_ACCESS_TEXT_PHRASES.iter().any(|p| hay.contains(p))
}
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(action, 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()?;
f.sync_all()?;
drop(f);
}
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
);
}
#[test]
fn classifier_tool_name_signal_agrees_with_tool_name_is_full_access() {
let c = RiskClassifier::new();
for name in [
"http_get",
"https_health",
"format_date",
"count_tokens",
"apply_template",
"request_id",
"prefetch_cache",
"transfer_learning",
"network_topology",
"deploy_service",
"send_email",
"git_push",
"read_secret",
] {
let expected = if tool_name_is_full_access(name) {
PermissionTier::FullAccess
} else {
PermissionTier::SandboxEdit
};
assert_eq!(c.classify(&tool_call(name)), expected, "tool name {name}");
}
}
#[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 classifier_does_not_escalate_on_ordinary_text_in_ordinary_parameters() {
let c = RiskClassifier::new();
let cases: Vec<(&str, Action)> = vec![
(
"a URL in a documented url parameter is not `curl` in a shell string",
tool_call_with("http_get", &[("url", json!("https://example.com/a"))]),
),
(
"a search whose query text happens to say `release notes`",
tool_call_with("search", &[("query", json!("release notes for 0.47"))]),
),
(
"a date formatter is not `mkfs --format`",
tool_call_with(
"format_date",
&[("value", json!("2026-08-15")), ("fmt", json!("%Y-%m-%d"))],
),
),
(
"prose containing `send` in a body the tool merely stores",
tool_call_with(
"create_note",
&[(
"body",
json!("Remember to send the report and apply the patch"),
)],
),
),
(
"max_tokens is in nearly every LLM tool call and is not a credential",
tool_call_with(
"complete",
&[("prompt", json!("hello")), ("max_tokens", json!(256))],
),
),
(
"a body that MENTIONS ~/.ssh is not an action against it — only \
declared target paths carry the credential-path signal",
tool_call_with(
"write_file",
&[
("path", json!("/tmp/notes.md")),
("content", json!("Back up ~/.ssh/id_rsa before you start")),
],
),
),
];
for (why, act) in cases {
assert_eq!(
c.classify(&act),
PermissionTier::SandboxEdit,
"must not escalate — {why}"
);
}
}
#[test]
fn classifier_still_escalates_on_the_signals_that_matter() {
let c = RiskClassifier::new();
let cases: Vec<(&str, Action)> =
vec![
(
"tool name — segment match",
tool_call_with("send_email", &[("to", json!("a@example.com"))]),
),
(
"command line — broad keyword over command/args only",
tool_call_with(
"shell",
&[("command", json!("curl")), ("args", json!(["-X", "POST"]))],
),
),
(
"command line — `aws ` needs the trailing space and the argv join",
tool_call_with(
"shell",
&[("command", json!("aws")), ("args", json!(["s3", "rb"]))],
),
),
(
"parameter key — the value is opaque, the key is not",
tool_call_with(
"call_api",
&[("url", json!("https://x/y")), ("api_key", json!("sk-abc"))],
),
),
(
"parameter key — nested under an options object",
tool_call_with("call_api", &[("opts", json!({"auth": {"token": "t"}}))]),
),
(
"parameter key — a segment match on a compound key",
tool_call_with("connect", &[("db_password", json!("hunter2"))]),
),
(
"free text — destructive SQL under a `query` key the command surface cannot see",
tool_call_with("run_sql", &[("query", json!("DROP TABLE users"))]),
),
(
"free text — `delete from` likewise",
tool_call_with("run_sql", &[("query", json!("DELETE FROM orders WHERE 1=1"))]),
),
(
"free text — a destructive script passed as file contents",
tool_call_with(
"write_file",
&[("path", json!("/tmp/x.sh")), ("contents", json!("rm -rf /"))],
),
),
(
"free text — history rewriting in a body parameter",
tool_call_with("run_script", &[("body", json!("git reset --hard HEAD~5"))]),
),
(
"target path — reading someone's private key is a top-grant action",
tool_call_with("read_file", &[("path", json!("~/.ssh/id_rsa"))]),
),
(
"target path — writing over a cloud credential file",
tool_call_with(
"write_file",
&[("path", json!("/home/u/.aws/credentials")), ("content", json!("x"))],
),
),
];
for (why, act) in cases {
assert_eq!(
c.classify(&act),
PermissionTier::FullAccess,
"must escalate — {why}"
);
}
}
#[test]
fn command_shaped_actions_classify_exactly_as_they_did_before_917() {
let c = RiskClassifier::new();
for kw in FULL_ACCESS_COMMAND_KEYWORDS {
let act = tool_call_with("shell", &[("command", json!(format!("x{kw}y")))]);
assert_eq!(
c.classify(&act),
PermissionTier::FullAccess,
"command keyword {kw:?} must still escalate on a command line"
);
}
}
#[test]
fn credential_param_keys_are_boundary_matched_not_substring_matched() {
for key in [
"token",
"api_key",
"apiKey",
"access-token",
"client_secret",
"db_password",
"svc_credentials",
"passphrase",
] {
let act = tool_call_with("t", &[(key, json!("v"))]);
assert!(
hits_credential_param_key(&act),
"{key} should read as credential material"
);
}
for key in [
"max_tokens",
"tokens",
"token_count",
"key",
"keys",
"sort_key",
"keyword",
"secretariat_id",
] {
let act = tool_call_with("t", &[(key, json!("v"))]);
assert!(
!hits_credential_param_key(&act),
"{key} should NOT read as credential material (false positive)"
);
}
}
#[test]
fn command_line_is_none_when_the_action_is_not_command_shaped() {
assert_eq!(command_line(&tool_call("search")), None);
assert_eq!(
command_line(&tool_call_with("search", &[("query", json!("git push"))])),
None,
"a non-command parameter must not become the command line"
);
assert_eq!(
command_line(&tool_call_with(
"shell",
&[("command", json!("git")), ("args", json!(["push"]))]
)),
Some("git push ".to_string()),
);
}
#[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"
);
}
}