use std::collections::HashMap;
use std::path::PathBuf;
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"))
}
pub fn load_policy() -> AgentPermissionPolicy {
let Some(path) = policy_path() else {
return AgentPermissionPolicy::default();
};
match std::fs::read_to_string(&path) {
Ok(s) => serde_json::from_str(&s).unwrap_or_else(|e| {
tracing::warn!("[agent_permissions] corrupt {path:?} ({e}); using defaults");
AgentPermissionPolicy::default()
}),
Err(_) => AgentPermissionPolicy::default(),
}
}
fn save_policy(policy: &AgentPermissionPolicy) -> Result<(), String> {
let path = policy_path().ok_or("no home directory for agent-permissions.json")?;
let json = serde_json::to_string_pretty(policy).map_err(|e| e.to_string())?;
let tmp = path.with_extension(format!("json.{}.tmp", std::process::id()));
std::fs::write(&tmp, json).map_err(|e| e.to_string())?;
std::fs::rename(&tmp, &path).map_err(|e| e.to_string())?;
Ok(())
}
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| {
if tool == "shell" && governed_read_only_shell(params) {
return match load_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 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"
)),
}
})
}
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 mut policy = load_policy();
match tier_param(p) {
Some(tier) => policy.set_agent(agent_id, tier, mode),
None => policy.set_agent_uniform(agent_id, mode),
}
save_policy(&policy)?;
Ok(policy_to_json(&policy))
}
pub fn handle_set_default(req: &JsonRpcMessage) -> Result<Value, String> {
let p = &req.params;
let mut policy = load_policy();
if let Some(preset) = p
.get("preset")
.and_then(|v| v.as_str())
.and_then(ApprovalPreset::from_str_opt)
{
policy.set_default_preset(preset);
} else {
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")?;
policy.set_default(tier, mode);
}
save_policy(&policy)?;
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 mut policy = load_policy();
policy.reset_agent(agent_id);
save_policy(&policy)?;
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),
}))
}
#[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
);
}
}