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 home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)?;
let dir = home.join(".car");
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" => PermissionTier::SandboxEdit,
"http_request" | "web_search" => PermissionTier::FullAccess,
_ => PermissionTier::ReadOnly,
}
}
fn classify_tool_call(
classifier: &car_policy::permission::RiskClassifier,
tool: &str,
params: &Value,
) -> PermissionTier {
let base = assistant_tool_tier(tool);
let action_json = serde_json::json!({
"type": "tool_call",
"tool": tool,
"parameters": 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, tool, params)
}
pub fn build_approval_policy(
agent_id: String,
) -> crate::assistant::agent_loop::ApprovalPolicyFn {
use crate::assistant::agent_loop::ToolApprovalDecision;
let classifier = car_policy::permission::RiskClassifier::new();
std::sync::Arc::new(move |tool: &str, params: &Value| {
let tier = classify_tool_call(&classifier, 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"
)),
}
})
}
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 {
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("http_request"), 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, "shell", ¶ms);
assert_eq!(tier, PermissionTier::FullAccess);
let benign = json!({ "command": "ls -la" });
assert_eq!(
classify_tool_call(&classifier, "shell", &benign),
PermissionTier::SandboxEdit
);
}
#[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("read_file", &json!({ "path": "a.txt" })),
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
);
}
}