car-server-core 0.33.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Durable store + wire surface for the per-agent approval policy
//! ([`car_policy::AgentPermissionPolicy`]).
//!
//! The policy lives at `~/.car/agent-permissions.json` — one file, human-
//! readable — and is loaded on demand (config-frequency, not hot-path). Each
//! mutation is a read-modify-write with an atomic temp+rename, so a reader never
//! sees a torn file. It is **last-writer-wins**, not a merge: two hosts editing
//! permissions at the same instant, the later write wins (human-paced config, so
//! in practice a non-issue). The temp file is per-process so concurrent writers
//! can't stomp each other's staging file.
//!
//! Fail-safety notes (see the per-agent permissions review):
//! - A corrupt/unreadable policy file falls back to the Balanced default rather
//!   than failing closed — a config read never bricks the daemon. The tradeoff:
//!   an agent an operator explicitly set to `Deny` reverts to Balanced if the
//!   file is corrupted (loudly `warn`ed). Back up the file if a standing Deny is
//!   load-bearing.
//! - Enforcement classifies each tool call by risk tier (`classify_tool_call`).
//!   Under the **Trusting** preset (`sandbox_edit → always_allow`) a shell whose
//!   danger the keyword classifier misses (e.g. `cat /etc/shadow`) can run
//!   unprompted. Balanced keeps `sandbox_edit` gated, so this only bites when an
//!   operator has explicitly opted into Trusting.
//!
//! Wire methods (`agent_permissions.*`):
//! - `get` → the raw policy `{ default, agents }`.
//! - `set` `{ agent_id, tier?, mode }` → set one tier (or, with `tier` omitted,
//!   every tier) for an agent.
//! - `set_default` `{ preset }` or `{ tier, mode }` → move the fallback posture.
//! - `reset` `{ agent_id }` → drop an agent's override (revert to default).
//! - `evaluate` `{ agent_id, tier }` → the resolved `ApprovalMode` (the query
//!   the executor/HITL path consults before acting).

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;

/// `~/.car/agent-permissions.json` (HOME, or USERPROFILE on Windows). Creates
/// `~/.car` best-effort; `None` when no home directory is resolvable.
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"))
}

/// Load the policy from disk, falling back to the balanced default when the file
/// is absent or unreadable/corrupt (never fail closed on a config read).
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())?;
    // Atomic write: per-process temp + rename so a crash can't truncate the
    // policy and two concurrent writers can't stomp each other's staging file
    // (a fixed temp name could let A's rename move B's half-written bytes).
    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(())
}

/// The resolved decision for `(agent_id, tier)`, consulted before an agent acts.
pub fn resolve(agent_id: &str, tier: PermissionTier) -> ApprovalMode {
    load_policy().resolve(agent_id, tier)
}

/// Risk tier of one of the assistant's built-in tools. The assistant exposes a
/// fixed, known tool set, so a direct mapping is precise (and avoids
/// reconstructing an `Action` just to classify): reads are `read_only`, file
/// writes/shell are `sandbox_edit`, and network egress is `full_access`.
pub fn assistant_tool_tier(tool: &str) -> PermissionTier {
    match tool {
        "write_file" | "edit_file" | "shell" => PermissionTier::SandboxEdit,
        "http_request" | "web_search" => PermissionTier::FullAccess,
        // read_file / list_dir / find_files / grep_files / calculate / unknown
        _ => PermissionTier::ReadOnly,
    }
}

/// Classify a tool call to a risk tier, **param-aware**: reuse car_policy's
/// `RiskClassifier` (which scans the tool name *and* string parameters for
/// full-access keywords, so a `shell` running `kubectl apply` or reading a secret
/// escalates to full_access), floored by the name-based mapping so a plain
/// `write_file` is still at least `sandbox_edit`. Falls back to the name map if
/// the call can't be shaped into an `Action`.
fn classify_tool_call(
    classifier: &car_policy::permission::RiskClassifier,
    tool: &str,
    params: &Value,
) -> PermissionTier {
    // NOTE: this deserialize omits `id`, relying on `car_ir::Action.id` being
    // `#[serde(default = "short_id")]` (and `tool`/`parameters` defaulting too).
    // The no-downgrade guarantee — classify sees the SAME tool+params that
    // execute — rests on this deserialize succeeding for every well-formed call.
    // If `Action.id` ever becomes non-defaulting, this falls to the weaker
    // name-map tier while execution proceeds: a silent downgrade. Keep it
    // defaulted, or set an `id` here.
    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) {
        // Adopt the classifier's verdict ONLY when it escalates to full_access
        // (its param keyword scan — a `shell` running a deploy or reading a
        // secret). Its conservative sandbox_edit *baseline* for any tool call is
        // deliberately ignored: it would wrongly gate a plain `read_file` behind
        // approval under the Balanced default, defeating "auto-allow safe reads".
        Ok(action) if classifier.classify(&action) == PermissionTier::FullAccess => {
            PermissionTier::FullAccess
        }
        _ => base,
    }
}

/// Public param-aware tier classification for one tool call, for callers outside
/// the assistant loop (e.g. `WorktreeExecutor`). Builds a fresh classifier — fine
/// at tool-call cadence.
pub fn classify_tool_tier(tool: &str, params: &Value) -> PermissionTier {
    let classifier = car_policy::permission::RiskClassifier::new();
    classify_tool_call(&classifier, tool, params)
}

/// Build the live per-agent approval policy the assistant loop enforces: classify
/// each tool call, resolve the agent's posture from the (reloaded) store, and map
/// it to an allow / require-approval / deny decision. Reloading per call keeps
/// the decision live as the operator edits Agent Permissions — tool calls are
/// seconds apart, so the read cost is irrelevant.
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)
}

// MARK: - Handlers

pub fn handle_get(_req: &JsonRpcMessage) -> Result<Value, String> {
    Ok(policy_to_json(&load_policy()))
}

/// `{ agent_id, mode, tier? }` — set one tier (or all tiers if `tier` omitted)
/// for a specific agent.
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))
}

/// `{ preset }` (cautious|balanced|trusting) or `{ tier, mode }` — move the
/// default posture applied to agents without an override.
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))
}

/// `{ agent_id }` — drop an agent's override so it reverts to the default.
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))
}

/// `{ agent_id, tier }` — the resolved mode the HITL/executor path consults.
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() {
        // The security-relevant case: a shell command isn't just "edit" when it
        // deploys or touches secrets — the param-aware classifier must escalate,
        // so an operator who allows sandbox_edit still gets asked for a deploy.
        let classifier = car_policy::permission::RiskClassifier::new();
        let params = json!({ "command": "kubectl apply -f prod.yaml" });
        let tier = classify_tool_call(&classifier, "shell", &params);
        assert_eq!(tier, PermissionTier::FullAccess);

        // A benign shell stays at the sandbox_edit floor.
        let benign = json!({ "command": "ls -la" });
        assert_eq!(
            classify_tool_call(&classifier, "shell", &benign),
            PermissionTier::SandboxEdit
        );
    }

    #[test]
    fn public_classifier_escalates_and_floors() {
        // The helper WorktreeExecutor uses: param-aware escalation + name floor.
        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() {
        // A fully-denied agent resolves to Deny at every tier (independent of the
        // on-disk store, which the CI environment may not have).
        let mut policy = car_policy::AgentPermissionPolicy::default();
        policy.set_agent_uniform("blocked", ApprovalMode::Deny);
        assert_eq!(
            policy.resolve("blocked", PermissionTier::ReadOnly),
            ApprovalMode::Deny
        );
    }
}