use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Mutex as StdMutex, OnceLock};
use car_policy::agent_permissions::{ApprovalMode, ApprovalPreset};
use car_policy::permission::PermissionTier;
use car_policy::AgentPermissionPolicy;
use serde_json::{json, Value};
use crate::handler::JsonRpcMessage;
fn policy_path() -> Option<PathBuf> {
let dir = car_home::root()?;
let _ = std::fs::create_dir_all(&dir);
Some(dir.join("agent-permissions.json"))
}
#[derive(Default)]
struct PolicyStoreState {
last_valid: HashMap<PathBuf, AgentPermissionPolicy>,
next_temp: u64,
}
fn policy_store() -> &'static StdMutex<PolicyStoreState> {
static STORE: OnceLock<StdMutex<PolicyStoreState>> = OnceLock::new();
STORE.get_or_init(|| StdMutex::new(PolicyStoreState::default()))
}
fn cautious_policy() -> AgentPermissionPolicy {
let mut policy = AgentPermissionPolicy::default();
policy.set_default_preset(ApprovalPreset::Cautious);
policy
}
fn load_policy_locked(path: &PathBuf, store: &mut PolicyStoreState) -> AgentPermissionPolicy {
match std::fs::read_to_string(path) {
Ok(contents) => match serde_json::from_str::<AgentPermissionPolicy>(&contents) {
Ok(policy) => {
store.last_valid.insert(path.clone(), policy.clone());
policy
}
Err(error) => {
tracing::error!(
"[agent_permissions] corrupt {path:?} ({error}); retaining the last valid policy or failing closed"
);
store
.last_valid
.get(path)
.cloned()
.unwrap_or_else(cautious_policy)
}
},
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
store.last_valid.get(path).cloned().unwrap_or_default()
}
Err(error) => {
tracing::error!(
"[agent_permissions] unreadable {path:?} ({error}); retaining the last valid policy or failing closed"
);
store
.last_valid
.get(path)
.cloned()
.unwrap_or_else(cautious_policy)
}
}
}
pub fn load_policy() -> AgentPermissionPolicy {
let Some(path) = policy_path() else {
return cautious_policy();
};
let mut store = policy_store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
load_policy_locked(&path, &mut store)
}
fn save_policy_locked(
path: &PathBuf,
policy: &AgentPermissionPolicy,
store: &mut PolicyStoreState,
) -> Result<(), String> {
let json = serde_json::to_string_pretty(policy).map_err(|e| e.to_string())?;
store.next_temp = store.next_temp.wrapping_add(1);
let filename = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("agent-permissions.json");
let tmp = path.with_file_name(format!(
".{filename}.{}.{}.tmp",
std::process::id(),
store.next_temp
));
let write_result = (|| -> Result<(), String> {
let mut file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&tmp)
.map_err(|error| format!("create unique policy temp {tmp:?}: {error}"))?;
file.write_all(json.as_bytes())
.map_err(|error| format!("write policy temp {tmp:?}: {error}"))?;
file.sync_all()
.map_err(|error| format!("sync policy temp {tmp:?}: {error}"))?;
std::fs::rename(&tmp, path).map_err(|error| format!("replace policy {path:?}: {error}"))?;
Ok(())
})();
if write_result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
write_result?;
store.last_valid.insert(path.clone(), policy.clone());
Ok(())
}
fn update_policy<R>(
mutation: impl FnOnce(&mut AgentPermissionPolicy) -> Result<R, String>,
) -> Result<(AgentPermissionPolicy, R), String> {
let path = policy_path().ok_or("no home directory for agent-permissions.json")?;
update_policy_at(path, mutation)
}
fn update_policy_at<R>(
path: PathBuf,
mutation: impl FnOnce(&mut AgentPermissionPolicy) -> Result<R, String>,
) -> Result<(AgentPermissionPolicy, R), String> {
let mut store = policy_store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let mut policy = load_policy_locked(&path, &mut store);
let result = mutation(&mut policy)?;
save_policy_locked(&path, &policy, &mut store)?;
Ok((policy, result))
}
pub fn resolve(agent_id: &str, tier: PermissionTier) -> ApprovalMode {
load_policy().resolve(agent_id, tier)
}
pub fn assistant_tool_tier(tool: &str) -> PermissionTier {
match tool {
"write_file" | "edit_file" | "shell" | "generate_image" | "generate_speech" => {
PermissionTier::SandboxEdit
}
"http_request"
| "web_search"
| "remember"
| "generate_music"
| "generate_jingle"
| "generate_studio_image"
| "generate_song"
| "run_applescript"
| "run_powershell" => PermissionTier::FullAccess,
_ => PermissionTier::ReadOnly,
}
}
fn declared_tool_tiers(tool_defs: &[Value]) -> HashMap<String, PermissionTier> {
tool_defs
.iter()
.filter_map(|def| {
let name = def.get("name").and_then(Value::as_str)?;
let tier = def
.get("tier")
.and_then(Value::as_str)
.and_then(PermissionTier::from_str_opt)?;
Some((name.to_string(), tier))
})
.collect()
}
const WRITE_PAYLOAD_KEYS: &[&str] = &["new_text", "old_text", "content"];
const SEARCH_NEEDLE_KEYS: &[&str] = &["pattern", "query"];
fn strip_write_payload_for_scan(tool: &str, params: &Value) -> Value {
let payload_keys: &[&str] = match tool {
"edit_file" | "write_file" => WRITE_PAYLOAD_KEYS,
"grep_files" | "search_files" => SEARCH_NEEDLE_KEYS,
_ => return params.clone(),
};
match params {
Value::Object(map) => Value::Object(
map.iter()
.filter(|(k, _)| !payload_keys.contains(&k.as_str()))
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
),
other => other.clone(),
}
}
fn classify_tool_call(
classifier: &car_policy::permission::RiskClassifier,
declared_tiers: Option<&HashMap<String, PermissionTier>>,
tool: &str,
params: &Value,
) -> PermissionTier {
let base = declared_tiers
.and_then(|tiers| tiers.get(tool).copied())
.unwrap_or_else(|| assistant_tool_tier(tool));
let scan_params = strip_write_payload_for_scan(tool, params);
let action_json = serde_json::json!({
"type": "tool_call",
"tool": tool,
"parameters": scan_params,
});
match serde_json::from_value::<car_ir::Action>(action_json) {
Ok(action) if classifier.classify(&action) == PermissionTier::FullAccess => {
PermissionTier::FullAccess
}
_ => base,
}
}
pub fn classify_tool_tier(tool: &str, params: &Value) -> PermissionTier {
let classifier = car_policy::permission::RiskClassifier::new();
classify_tool_call(&classifier, None, tool, params)
}
pub fn classify_tool_tier_with_defs(
tool: &str,
params: &Value,
tool_defs: &[Value],
) -> PermissionTier {
let classifier = car_policy::permission::RiskClassifier::new();
let declared_tiers = declared_tool_tiers(tool_defs);
classify_tool_call(&classifier, Some(&declared_tiers), tool, params)
}
pub fn build_approval_policy(
agent_id: String,
tool_defs: Vec<Value>,
) -> crate::assistant::agent_loop::ApprovalPolicyFn {
use crate::assistant::agent_loop::ToolApprovalDecision;
let classifier = car_policy::permission::RiskClassifier::new();
let declared_tiers = declared_tool_tiers(&tool_defs);
std::sync::Arc::new(move |tool: &str, params: &Value| {
let tier = classify_tool_call(&classifier, Some(&declared_tiers), tool, params);
match load_policy().resolve(&agent_id, tier) {
ApprovalMode::AlwaysAllow => ToolApprovalDecision::Allow,
ApprovalMode::RequireApproval => ToolApprovalDecision::RequireApproval,
ApprovalMode::Deny => ToolApprovalDecision::Deny(format!(
"'{tool}' is denied for this agent by your Agent Permissions settings"
)),
}
})
}
pub fn build_governed_approval_policy(
agent_id: String,
tool_defs: Vec<Value>,
) -> crate::assistant::agent_loop::ApprovalPolicyFn {
use crate::assistant::agent_loop::ToolApprovalDecision;
let classifier = car_policy::permission::RiskClassifier::new();
let declared_tiers = declared_tool_tiers(&tool_defs);
std::sync::Arc::new(move |tool: &str, params: &Value| {
let policy = load_policy();
if tool == "shell" && governed_read_only_shell(params) {
return match policy.resolve(&agent_id, PermissionTier::ReadOnly) {
ApprovalMode::AlwaysAllow => ToolApprovalDecision::Allow,
ApprovalMode::RequireApproval => ToolApprovalDecision::RequireApproval,
ApprovalMode::Deny => ToolApprovalDecision::Deny(
"read-only shell diagnostics are denied for this agent by your Agent Permissions settings".into(),
),
};
}
let tier = classify_tool_call(&classifier, Some(&declared_tiers), tool, params);
if tier == PermissionTier::FullAccess {
return ToolApprovalDecision::RequireApproval;
}
match policy.resolve(&agent_id, tier) {
ApprovalMode::AlwaysAllow => ToolApprovalDecision::Allow,
ApprovalMode::RequireApproval => ToolApprovalDecision::RequireApproval,
ApprovalMode::Deny => ToolApprovalDecision::Deny(format!(
"'{tool}' is denied for this agent by your Agent Permissions settings"
)),
}
})
}
fn governed_read_only_shell(params: &Value) -> bool {
let Some(command) = params.get("command").and_then(Value::as_str) else {
return false;
};
let Some(segments) = observational_segments(command) else {
return false;
};
!segments.is_empty()
&& segments
.iter()
.all(|segment| observational_segment(segment))
}
fn observational_segments(command: &str) -> Option<Vec<String>> {
if command.contains('`') || command.contains("$(") || command.contains(['\r', '\n']) {
return None;
}
let mut segments = Vec::new();
let mut current = String::new();
let mut single = false;
let mut double = false;
let mut escaped = false;
let chars: Vec<char> = command.chars().collect();
let mut i = 0;
while i < chars.len() {
let ch = chars[i];
if escaped {
current.push(ch);
escaped = false;
i += 1;
continue;
}
if ch == '\\' && !single {
current.push(ch);
escaped = true;
i += 1;
continue;
}
if ch == '\'' && !double {
single = !single;
current.push(ch);
i += 1;
continue;
}
if ch == '"' && !single {
double = !double;
current.push(ch);
i += 1;
continue;
}
if !single && !double {
if matches!(ch, ';' | '>' | '<') {
return None;
}
if ch == '|' {
if chars.get(i + 1) == Some(&'|') {
return None;
}
let segment = current.trim();
if segment.is_empty() {
return None;
}
segments.push(segment.to_string());
current.clear();
i += 1;
continue;
}
if ch == '&' {
if chars.get(i + 1) != Some(&'&') {
return None;
}
let segment = current.trim();
if segment.is_empty() {
return None;
}
segments.push(segment.to_string());
current.clear();
i += 2;
continue;
}
}
current.push(ch);
i += 1;
}
if single || double || escaped {
return None;
}
let tail = current.trim();
if tail.is_empty() {
return None;
}
segments.push(tail.to_string());
Some(segments)
}
fn observational_segment(segment: &str) -> bool {
let tokens: Vec<&str> = segment
.split_whitespace()
.map(|token| token.trim_matches(['\'', '"']))
.filter(|token| !token.is_empty())
.collect();
let Some(verb) = tokens.first().copied() else {
return false;
};
if verb.contains('=') {
return false;
}
match verb {
"cd" => tokens.len() == 2,
"pwd" => tokens.len() == 1,
"ls" | "cat" | "head" | "tail" => true,
"sed" => !tokens.iter().skip(1).any(|token| {
*token == "-i" || token.starts_with("-i.") || token.starts_with("--in-place")
}),
"rg" => !tokens
.iter()
.skip(1)
.any(|token| token.starts_with("--pre") || *token == "--files-with-matches"),
"git" => {
let Some(subcommand) = tokens.get(1).copied() else {
return false;
};
matches!(
subcommand,
"status" | "log" | "show" | "diff" | "rev-parse" | "remote" | "branch"
) && !tokens.iter().skip(2).any(|token| {
*token == "-o"
|| token.starts_with("--output")
|| *token == "--edit"
|| *token == "-e"
|| *token == "add"
|| *token == "set-url"
|| *token == "remove"
|| *token == "rename"
})
}
"az" => match (tokens.get(1).copied(), tokens.get(2).copied()) {
(Some("account" | "resource" | "webapp"), Some("show" | "list")) => true,
(Some("monitor"), Some("app-insights")) => tokens.get(3) == Some(&"query"),
(Some("pipelines"), Some("show" | "list")) => true,
(Some("pipelines"), Some("runs")) => tokens.get(3) == Some(&"show"),
_ => false,
},
_ => false,
}
}
fn policy_to_json(policy: &AgentPermissionPolicy) -> Value {
serde_json::to_value(policy).unwrap_or_else(|_| json!({}))
}
fn tier_param(v: &Value) -> Option<PermissionTier> {
v.get("tier")
.and_then(|t| t.as_str())
.and_then(PermissionTier::from_str_opt)
}
pub fn handle_get(_req: &JsonRpcMessage) -> Result<Value, String> {
Ok(policy_to_json(&load_policy()))
}
pub fn handle_set(req: &JsonRpcMessage) -> Result<Value, String> {
let p = &req.params;
let agent_id = p
.get("agent_id")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.ok_or("missing or empty agent_id")?;
let mode = p
.get("mode")
.and_then(|v| v.as_str())
.and_then(ApprovalMode::from_str_opt)
.ok_or("missing or invalid mode (always_allow|require_approval|deny)")?;
let (policy, ()) = update_policy(|policy| {
match tier_param(p) {
Some(tier) => policy.set_agent(agent_id, tier, mode),
None => policy.set_agent_uniform(agent_id, mode),
}
Ok(())
})?;
Ok(policy_to_json(&policy))
}
pub fn handle_set_default(req: &JsonRpcMessage) -> Result<Value, String> {
let p = &req.params;
let preset = p
.get("preset")
.and_then(|v| v.as_str())
.and_then(ApprovalPreset::from_str_opt);
let tier_mode = if preset.is_none() {
let tier = tier_param(p).ok_or("missing preset, or tier+mode")?;
let mode = p
.get("mode")
.and_then(|v| v.as_str())
.and_then(ApprovalMode::from_str_opt)
.ok_or("missing or invalid mode")?;
Some((tier, mode))
} else {
None
};
let (policy, ()) = update_policy(|policy| {
if let Some(preset) = preset {
policy.set_default_preset(preset);
} else if let Some((tier, mode)) = tier_mode {
policy.set_default(tier, mode);
}
Ok(())
})?;
Ok(policy_to_json(&policy))
}
pub fn handle_reset(req: &JsonRpcMessage) -> Result<Value, String> {
let agent_id = req
.params
.get("agent_id")
.and_then(|v| v.as_str())
.ok_or("missing agent_id")?;
let (policy, ()) = update_policy(|policy| {
policy.reset_agent(agent_id);
Ok(())
})?;
Ok(policy_to_json(&policy))
}
pub fn handle_evaluate(req: &JsonRpcMessage) -> Result<Value, String> {
let p = &req.params;
let agent_id = p
.get("agent_id")
.and_then(|v| v.as_str())
.ok_or("missing agent_id")?;
let tier = tier_param(p).ok_or("missing or invalid tier")?;
let policy = load_policy();
let mode = policy.resolve(agent_id, tier);
Ok(json!({
"agent_id": agent_id,
"tier": tier.as_str(),
"mode": mode.as_str(),
"has_override": policy.has_override(agent_id),
}))
}
pub(crate) fn exact_tool_params(params: &Value) -> Result<(&str, &str), String> {
let agent_id = params
.get("agent_id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or("missing or empty agent_id")?;
let tool = params
.get("tool")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
.ok_or("missing or empty tool")?;
Ok((agent_id, tool))
}
pub(crate) fn requested_tool_mode(params: &Value) -> Result<ApprovalMode, String> {
params
.get("mode")
.and_then(Value::as_str)
.and_then(ApprovalMode::from_str_opt)
.ok_or_else(|| "missing or invalid mode (always_allow|require_approval|deny)".to_string())
}
pub fn handle_set_tool(
req: &JsonRpcMessage,
observed_schema_digest: Option<String>,
) -> Result<Value, String> {
let (agent_id, tool) = exact_tool_params(&req.params)?;
let mode = requested_tool_mode(&req.params)?;
if req.params.get("schema_digest").is_some() {
return Err(
"schema_digest is server-recorded and must not be supplied by the caller".into(),
);
}
let stored_digest = (mode == ApprovalMode::AlwaysAllow)
.then_some(observed_schema_digest)
.flatten();
if mode == ApprovalMode::AlwaysAllow
&& !stored_digest.as_deref().is_some_and(valid_schema_digest)
{
return Err(
"always_allow requires a live authenticated exact callback registration".into(),
);
}
let (_policy, ()) = update_policy(|policy| {
policy.set_tool(agent_id, tool, mode, stored_digest.clone());
Ok(())
})?;
Ok(json!({
"agent_id": agent_id,
"tool": tool,
"mode": mode.as_str(),
"schema_digest": stored_digest,
"authorization_source": "agent_tool_override",
}))
}
pub fn handle_reset_tool(req: &JsonRpcMessage) -> Result<Value, String> {
let (agent_id, tool) = exact_tool_params(&req.params)?;
let (_policy, removed) = update_policy(|policy| Ok(policy.reset_tool(agent_id, tool)))?;
Ok(json!({
"agent_id": agent_id,
"tool": tool,
"removed": removed,
}))
}
pub fn handle_evaluate_tool(req: &JsonRpcMessage) -> Result<Value, String> {
let (agent_id, tool) = exact_tool_params(&req.params)?;
let tier = tier_param(&req.params).ok_or("missing or invalid tier")?;
let policy = load_policy();
let tool_override = policy.resolve_tool(agent_id, tool);
let mode = tool_override
.map(|rule| rule.mode)
.unwrap_or_else(|| policy.resolve(agent_id, tier));
Ok(json!({
"agent_id": agent_id,
"tool": tool,
"tier": tier.as_str(),
"mode": mode.as_str(),
"has_tool_override": tool_override.is_some(),
"schema_digest": tool_override.and_then(|rule| rule.schema_digest.as_deref()),
"authorization_source": tool_override.map(|_| "agent_tool_override").unwrap_or("tier_policy"),
}))
}
fn valid_schema_digest(value: &str) -> bool {
value.len() == 64
&& value
.bytes()
.all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
}
#[cfg(test)]
mod tests {
#[test]
fn grep_pattern_is_a_needle_not_a_command_and_never_escalates() {
let blocked = serde_json::json!({"path": ".", "pattern": "preserv|context|push"});
assert_eq!(
classify_tool_tier("grep_files", &blocked),
PermissionTier::ReadOnly,
"searching for 'push' must not become a full-access action"
);
for needle in ["delete", "secret", "token", "http", "request", "rm "] {
let p = serde_json::json!({"path": "src", "pattern": needle});
assert_ne!(
classify_tool_tier("grep_files", &p),
PermissionTier::FullAccess,
"grep for {needle:?} must not escalate"
);
}
let sensitive = serde_json::json!({"path": "~/.ssh/id_rsa", "pattern": "x"});
assert_eq!(
classify_tool_tier("grep_files", &sensitive),
PermissionTier::FullAccess
);
let sh = serde_json::json!({"command": "git push origin main"});
assert_eq!(classify_tool_tier("shell", &sh), PermissionTier::FullAccess);
}
use super::*;
#[test]
fn name_map_floors_are_correct() {
assert_eq!(assistant_tool_tier("read_file"), PermissionTier::ReadOnly);
assert_eq!(
assistant_tool_tier("write_file"),
PermissionTier::SandboxEdit
);
assert_eq!(assistant_tool_tier("shell"), PermissionTier::SandboxEdit);
assert_eq!(
assistant_tool_tier("generate_image"),
PermissionTier::SandboxEdit
);
assert_eq!(
assistant_tool_tier("generate_speech"),
PermissionTier::SandboxEdit
);
assert_eq!(
assistant_tool_tier("http_request"),
PermissionTier::FullAccess
);
assert_eq!(
assistant_tool_tier("web_search"),
PermissionTier::FullAccess
);
assert_eq!(assistant_tool_tier("remember"), PermissionTier::FullAccess);
assert_eq!(
assistant_tool_tier("generate_music"),
PermissionTier::FullAccess
);
assert_eq!(
assistant_tool_tier("generate_jingle"),
PermissionTier::FullAccess
);
assert_eq!(
assistant_tool_tier("generate_studio_image"),
PermissionTier::FullAccess
);
assert_eq!(
assistant_tool_tier("generate_song"),
PermissionTier::FullAccess
);
assert_eq!(
assistant_tool_tier("run_applescript"),
PermissionTier::FullAccess
);
}
#[test]
fn shell_running_a_deploy_escalates_to_full_access() {
let classifier = car_policy::permission::RiskClassifier::new();
let params = json!({ "command": "kubectl apply -f prod.yaml" });
let tier = classify_tool_call(&classifier, None, "shell", ¶ms);
assert_eq!(tier, PermissionTier::FullAccess);
let benign = json!({ "command": "ls -la" });
assert_eq!(
classify_tool_call(&classifier, None, "shell", &benign),
PermissionTier::SandboxEdit
);
}
#[test]
fn governed_read_only_shell_accepts_diagnostics_and_quoted_kusto() {
for command in [
"git status",
"cd /repo && git status && git rev-parse HEAD && git diff --stat",
"rg -n Veryon api/FMS.Api",
"sed -n '150,220p' api/FMS.Api/Services/VeryonClient.cs",
"az account show --output json",
"az resource list --resource-type microsoft.insights/components --output json",
"az pipelines runs show --id 23708 --output json",
"az monitor app-insights query --analytics-query \"exceptions | where timestamp > ago(1d) | summarize count()\"",
"git log --oneline --all | head -20",
"rg -n Veryon api/FMS.Api | head -50",
] {
assert!(
governed_read_only_shell(&json!({"command": command})),
"expected observational command: {command}"
);
}
}
#[test]
fn governed_read_only_shell_rejects_mutation_and_shell_composition() {
for command in [
"git push origin main",
"git remote set-url origin https://example.invalid/repo",
"git diff --output=leak.patch",
"sed -i '' s/a/b/ file",
"rg x --pre 'sh -c evil'",
"az webapp restart --name fms",
"git status > status.txt",
"git status | tee status.txt",
"git status || touch changed",
"git status; touch changed",
"git status && $(touch changed)",
"FOO=1 git status",
] {
assert!(
!governed_read_only_shell(&json!({"command": command})),
"expected gated command: {command}"
);
}
}
#[test]
fn public_classifier_escalates_and_floors() {
assert_eq!(
classify_tool_tier("shell", &json!({ "command": "terraform apply" })),
PermissionTier::FullAccess
);
assert_eq!(
classify_tool_tier("write_file", &json!({ "path": "a.txt", "content": "hi" })),
PermissionTier::SandboxEdit
);
assert_eq!(
classify_tool_tier("remember", &json!({ "subject": "token", "body": "secret" })),
PermissionTier::FullAccess
);
assert_eq!(
classify_tool_tier(
"run_applescript",
&json!({ "script": "display dialog \"hi\"" })
),
PermissionTier::FullAccess
);
assert_eq!(
classify_tool_tier("read_file", &json!({ "path": "a.txt" })),
PermissionTier::ReadOnly
);
}
#[test]
fn file_write_body_is_not_keyword_scanned() {
assert_eq!(
classify_tool_tier(
"edit_file",
&json!({
"path": "boltons/strutils.py",
"old_text": "def split_punct_ws(text):",
"new_text": "def split(text, sep=None, maxsplit=-1):\n \"\"\"Split text; a callable form is handy when you need to format or delete tokens.\"\"\"\n return text.split(sep, maxsplit)\n\n\ndef split_punct_ws(text):",
})
),
PermissionTier::SandboxEdit,
"an edit whose body mentions format/delete/token must not escalate"
);
assert_eq!(
classify_tool_tier(
"write_file",
&json!({ "path": "src/net.rs", "content": "// http request push delete secret token" })
),
PermissionTier::SandboxEdit,
"write_file body keywords must not escalate"
);
assert_eq!(
classify_tool_tier(
"write_file",
&json!({ "path": "/home/u/.ssh/id_rsa", "content": "ok" })
),
PermissionTier::FullAccess,
"a sensitive target PATH must still escalate"
);
assert_eq!(
classify_tool_tier("shell", &json!({ "command": "git push --force" })),
PermissionTier::FullAccess,
"shell command scanning must be unchanged"
);
}
#[test]
fn schema_declared_tier_is_the_supervised_policy_floor() {
let defs = vec![
json!({"name": "new_external_sink", "tier": "full_access"}),
json!({"name": "new_writer", "tier": "sandbox_edit"}),
json!({"name": "plain_reader"}),
];
assert_eq!(
classify_tool_tier_with_defs("new_external_sink", &json!({}), &defs),
PermissionTier::FullAccess
);
assert_eq!(
classify_tool_tier_with_defs("new_writer", &json!({}), &defs),
PermissionTier::SandboxEdit
);
assert_eq!(
classify_tool_tier_with_defs("plain_reader", &json!({}), &defs),
PermissionTier::ReadOnly
);
}
#[test]
fn deny_posture_blocks_via_resolved_policy() {
let mut policy = car_policy::AgentPermissionPolicy::default();
policy.set_agent_uniform("blocked", ApprovalMode::Deny);
assert_eq!(
policy.resolve("blocked", PermissionTier::ReadOnly),
ApprovalMode::Deny
);
}
#[test]
fn concurrent_policy_mutations_are_serialized_without_lost_updates() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("agent-permissions.json");
let mut workers = Vec::new();
for index in 0..16 {
let path = path.clone();
workers.push(std::thread::spawn(move || {
update_policy_at(path, |policy| {
policy.set_agent_uniform(&format!("agent-{index}"), ApprovalMode::Deny);
Ok(())
})
.unwrap();
}));
}
for worker in workers {
worker.join().unwrap();
}
let persisted: AgentPermissionPolicy =
serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
for index in 0..16 {
assert_eq!(
persisted.resolve(&format!("agent-{index}"), PermissionTier::FullAccess),
ApprovalMode::Deny
);
}
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().ends_with(".tmp"))
.collect();
assert!(
leftovers.is_empty(),
"temporary files leaked: {leftovers:?}"
);
}
#[test]
fn corrupt_policy_retains_last_valid_or_uses_cautious_floor() {
let dir = tempfile::tempdir().unwrap();
let remembered = dir.path().join("remembered.json");
update_policy_at(remembered.clone(), |policy| {
policy.set_agent_uniform("blocked", ApprovalMode::Deny);
Ok(())
})
.unwrap();
std::fs::write(&remembered, "{broken").unwrap();
let mut store = policy_store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let retained = load_policy_locked(&remembered, &mut store);
assert_eq!(
retained.resolve("blocked", PermissionTier::FullAccess),
ApprovalMode::Deny
);
let never_valid = dir.path().join("never-valid.json");
std::fs::write(&never_valid, "{also-broken").unwrap();
let cautious = load_policy_locked(&never_valid, &mut store);
assert_eq!(
cautious.resolve("unknown", PermissionTier::ReadOnly),
ApprovalMode::RequireApproval
);
}
}