car-server-core 0.35.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
//! 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::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;

/// `~/.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)
}

/// Fallback risk tier of one of the assistant's built-in tools. Supervised
/// `car do --serve` should prefer schema-declared `"tier"` metadata from the
/// actual advertised tool list; this map exists only for builtins whose defs do
/// not carry metadata and for non-assistant callers that lack a schema list.
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" => PermissionTier::FullAccess,
        // read_file / list_dir / find_files / grep_files / calculate / unknown
        _ => 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()
}

/// 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,
    declared_tiers: Option<&HashMap<String, PermissionTier>>,
    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 = declared_tiers
        .and_then(|tiers| tiers.get(tool).copied())
        .unwrap_or_else(|| 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, None, tool, params)
}

/// Public classifier variant for the flagship assistant, where the exact
/// advertised tool definitions are available. This is the path supervised
/// `car do --serve` uses so newly-added tools with `"tier"` metadata don't need
/// a second hand-maintained permission map.
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)
}

/// 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,
    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"
            )),
        }
    })
}

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("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() {
        // 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, None, "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, None, "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("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 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() {
        // 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
        );
    }
}