use car_ir::{Action, ActionProposal};
use car_policy::{
action_fingerprint, classify_reversibility, ApprovalDecision, ApprovalLedger, PermissionGate,
PermissionTier, RiskClassifier,
};
use serde_json::json;
fn parse_tier(s: &str) -> Result<PermissionTier, String> {
PermissionTier::from_str_opt(s)
.ok_or_else(|| format!("invalid tier '{s}' (expected read_only|sandbox_edit|full_access)"))
}
fn parse_proposal(proposal_json: &str) -> Result<ActionProposal, String> {
serde_json::from_str(proposal_json).map_err(|e| format!("invalid proposal JSON: {e}"))
}
pub fn classify(proposal_json: &str) -> Result<String, String> {
let proposal = parse_proposal(proposal_json)?;
let classifier = RiskClassifier::new();
let rows: Vec<_> = proposal
.actions
.iter()
.map(|a| classification_row(&classifier, a))
.collect();
serde_json::to_string(&rows).map_err(|e| e.to_string())
}
pub fn classification_row(classifier: &RiskClassifier, action: &Action) -> serde_json::Value {
let hay = car_policy::action_text(action);
json!({
"action_id": action.id,
"tool": action.tool,
"required_tier": classifier.classify_with_haystack(action, Some(&hay)).as_str(),
"reversibility": car_policy::classify_reversibility_with_haystack(action, Some(&hay)).as_str(),
"missing_compensation": action.missing_required_compensation(),
})
}
pub fn annotate_reversibility(row: &mut serde_json::Value, action: &Action) {
stamp_reversibility(row, classify_reversibility(action));
}
pub fn stamp_reversibility(row: &mut serde_json::Value, reversibility: car_ir::Reversibility) {
if let Some(map) = row.as_object_mut() {
map.insert("reversibility".into(), json!(reversibility.as_str()));
}
}
pub fn evaluate(
proposal_json: &str,
granted_tier: &str,
ledger_path: Option<&str>,
) -> Result<String, String> {
let proposal = parse_proposal(proposal_json)?;
let granted = parse_tier(granted_tier)?;
let mut gate = PermissionGate::new(granted);
if let Some(path) = ledger_path {
let ledger = ApprovalLedger::with_journal(path)
.map_err(|e| format!("could not open ledger '{path}': {e}"))?;
gate = gate.with_ledger(ledger);
}
let rows: Vec<_> = proposal
.actions
.iter()
.map(|a| {
let axes = gate.evaluate_axes(a, None, None);
let mut obj = serde_json::to_value(&axes.decision).unwrap_or(serde_json::Value::Null);
if let Some(map) = obj.as_object_mut() {
map.insert("action_id".into(), json!(a.id));
map.insert("fingerprint".into(), json!(action_fingerprint(a)));
}
stamp_reversibility(&mut obj, axes.reversibility);
obj
})
.collect();
serde_json::to_string(&rows).map_err(|e| e.to_string())
}
pub fn record_decision(
action_json: &str,
approve: bool,
reviewer: &str,
reason: &str,
evidence: Option<&str>,
ledger_path: &str,
) -> Result<String, String> {
let action: car_ir::Action =
serde_json::from_str(action_json).map_err(|e| format!("invalid action JSON: {e}"))?;
let ledger = ApprovalLedger::with_journal(ledger_path)
.map_err(|e| format!("could not open ledger '{ledger_path}': {e}"))?;
let mut gate = PermissionGate::new(PermissionTier::FullAccess).with_ledger(ledger);
let evidence = evidence.map(str::to_string);
let record = if approve {
gate.approve(&action, reviewer, reason, evidence)
} else {
gate.reject(&action, reviewer, reason, evidence)
}
.map_err(|e| format!("failed to persist approval decision: {e}"))?;
serde_json::to_string(&record).map_err(|e| e.to_string())
}
pub fn record_for_fingerprint(
fingerprint: &str,
required_tier: &str,
approve: bool,
reviewer: &str,
reason: &str,
evidence: Option<&str>,
ledger_path: &str,
) -> Result<String, String> {
let required = parse_tier(required_tier)?;
let ledger = ApprovalLedger::with_journal(ledger_path)
.map_err(|e| format!("could not open ledger '{ledger_path}': {e}"))?;
let mut gate = PermissionGate::new(PermissionTier::FullAccess).with_ledger(ledger);
let decision = if approve {
ApprovalDecision::Approved
} else {
ApprovalDecision::Rejected
};
let record = gate
.record_for_fingerprint(
fingerprint,
required,
decision,
reviewer,
reason,
evidence.map(str::to_string),
)
.map_err(|e| format!("failed to persist approval decision: {e}"))?;
serde_json::to_string(&record).map_err(|e| e.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::Value;
const PROPOSAL: &str = r#"{
"id": "p1", "source": "test",
"actions": [
{"id": "a1", "type": "state_read"},
{"id": "a2", "type": "tool_call", "tool": "deploy_service"}
]
}"#;
#[test]
fn classify_returns_tier_per_action() {
let out = classify(PROPOSAL).unwrap();
let rows: Vec<Value> = serde_json::from_str(&out).unwrap();
assert_eq!(rows[0]["required_tier"], "read_only");
assert_eq!(rows[1]["required_tier"], "full_access");
}
#[test]
fn classify_returns_both_axes_per_action() {
let out = classify(PROPOSAL).unwrap();
let rows: Vec<Value> = serde_json::from_str(&out).unwrap();
assert_eq!(rows[0]["reversibility"], "reversible");
assert_eq!(rows[1]["required_tier"], "full_access");
assert_eq!(rows[1]["reversibility"], "compensable");
assert_eq!(rows[0]["missing_compensation"], false);
assert_eq!(rows[1]["missing_compensation"], false);
}
#[test]
fn the_axes_are_independent_on_the_wire() {
let proposal = r#"{
"id": "p2", "source": "test",
"actions": [
{"id": "a1", "type": "tool_call", "tool": "send_email"},
{"id": "a2", "type": "tool_call", "tool": "read_secret"}
]
}"#;
let rows: Vec<Value> = serde_json::from_str(&classify(proposal).unwrap()).unwrap();
assert_eq!(rows[0]["required_tier"], "full_access");
assert_eq!(rows[0]["reversibility"], "irreversible");
assert_eq!(rows[1]["required_tier"], "full_access");
assert_eq!(rows[1]["reversibility"], "reversible");
}
#[test]
fn missing_compensation_tracks_the_declared_field() {
let proposal = r#"{
"id": "p3", "source": "test",
"actions": [
{"id": "a1", "type": "tool_call", "tool": "db_insert",
"reversibility": "compensable"},
{"id": "a2", "type": "tool_call", "tool": "db_insert",
"reversibility": "compensable",
"compensation": {"type": "tool", "tool": "db_delete"}},
{"id": "a3", "type": "tool_call", "tool": "db_insert"}
]
}"#;
let rows: Vec<Value> = serde_json::from_str(&classify(proposal).unwrap()).unwrap();
assert_eq!(rows[0]["missing_compensation"], true);
assert_eq!(rows[1]["missing_compensation"], false);
assert_eq!(rows[2]["reversibility"], "compensable");
assert_eq!(rows[2]["missing_compensation"], false);
}
#[test]
fn evaluate_rows_carry_reversibility_including_allows() {
let out = evaluate(PROPOSAL, "sandbox_edit", None).unwrap();
let rows: Vec<Value> = serde_json::from_str(&out).unwrap();
assert_eq!(rows[0]["decision"], "allow");
assert_eq!(rows[0]["reversibility"], "reversible");
assert_eq!(rows[1]["decision"], "needs_approval");
assert_eq!(rows[1]["reversibility"], "compensable");
}
#[test]
fn evaluate_escalates_full_access() {
let out = evaluate(PROPOSAL, "sandbox_edit", None).unwrap();
let rows: Vec<Value> = serde_json::from_str(&out).unwrap();
assert_eq!(rows[0]["decision"], "allow");
assert_eq!(rows[1]["decision"], "needs_approval");
assert!(rows[1]["fingerprint"]
.as_str()
.unwrap()
.contains("deploy_service"));
}
#[test]
fn evaluate_rejects_bad_tier() {
assert!(evaluate(PROPOSAL, "nonsense", None).is_err());
}
#[test]
fn durable_approval_changes_later_evaluation() {
let path =
std::env::temp_dir().join(format!("car-permgate-test-{}.jsonl", std::process::id()));
let _ = std::fs::remove_file(&path);
let path_s = path.to_str().unwrap();
let before = evaluate(PROPOSAL, "sandbox_edit", Some(path_s)).unwrap();
let before: Vec<Value> = serde_json::from_str(&before).unwrap();
assert_eq!(before[1]["decision"], "needs_approval");
let action = r#"{"id":"a2","type":"tool_call","tool":"deploy_service"}"#;
let rec = record_decision(action, true, "matt", "ok", None, path_s).unwrap();
assert!(rec.contains("approved"));
let after = evaluate(PROPOSAL, "sandbox_edit", Some(path_s)).unwrap();
let after: Vec<Value> = serde_json::from_str(&after).unwrap();
assert_eq!(after[1]["decision"], "allow");
let _ = std::fs::remove_file(&path);
}
}