car_policy/permission.rs
1//! Permission tiers, risk classification, and human-in-the-loop approval
2//! as **durable harness state**.
3//!
4//! Motivated by the "Code as Agent Harness" survey (arXiv 2605.18747)
5//! §3.4.3 and §5.2.5: a harness must act as a *safety governor* between
6//! model intent and real-world consequence, not merely a tool executor.
7//! Two ideas from that section are made concrete here:
8//!
9//! 1. **A multi-tier permission model.** Every action is classified by
10//! risk into [`PermissionTier::ReadOnly`], [`PermissionTier::SandboxEdit`],
11//! or [`PermissionTier::FullAccess`]. The session holds a *granted*
12//! standing tier; an action whose required tier exceeds it cannot run
13//! autonomously.
14//! 2. **Human-in-the-loop as durable, auditable state.** Top-tier
15//! (externally-consequential / irreversible) actions are gated behind a
16//! mandatory human decision. That decision is not an ephemeral prompt:
17//! it is recorded in an [`ApprovalLedger`] — who approved or rejected
18//! what, when, on what evidence — that persists and feeds back into
19//! every later evaluation. "Each approval, rejection, policy exception,
20//! or reviewer correction should become durable harness state."
21//!
22//! A **third** idea arrived later, from a different paper, and it is the one
23//! that corrects a mistake in the first two:
24//!
25//! 3. **"Who may authorize this?" and "can this be undone?" are two axes,
26//! not one.** [`PermissionTier`] answers only the first. It used to be
27//! documented as though it answered both — `SandboxEdit` as "reversible
28//! local mutation", `FullAccess` as "externally-consequential **or**
29//! irreversible" — and that `or` fused a `git push` (undo by force-pushing
30//! the prior ref), a production `INSERT` (undo by deleting the row), and a
31//! charged card (no undo at all) onto one rung. The rollback contract now
32//! has its own type, [`car_ir::Reversibility`], classified here by
33//! [`classify_reversibility`] from its own independently curated keyword
34//! sets. The two are reported side by side rather than collapsed. See
35//! `docs/proposals/shepherd-substrate-adoption.md`, "The finding worth
36//! acting on first: two axes, one enum".
37//!
38//! The classifier and gate are pure and synchronous; the engine bridges
39//! [`PermissionGate`] into its async authorization pipeline (see
40//! `car-engine`'s `TierPermissionHandler`).
41
42use car_ir::{Action, ActionType, Reversibility};
43use serde::{Deserialize, Serialize};
44use std::collections::BTreeMap;
45use std::collections::HashMap;
46use std::io::Write as _;
47use std::path::PathBuf;
48
49/// Permission tiers — **who may authorize this action?** — ordered by the
50/// authority an action demands (survey §3.4.3). The `Ord` derive makes
51/// `ReadOnly < SandboxEdit < FullAccess`, so "does the granted tier cover the
52/// required tier?" is a single `>=`.
53///
54/// # This ladder does not answer "can it be undone?"
55///
56/// It used to read as though it did. `SandboxEdit` was documented as
57/// "reversible local mutation" and `FullAccess` as "externally-consequential
58/// **or** irreversible", and that `or` quietly fused two independent
59/// questions onto one rung: a `git push`, a production `INSERT`, and a charged
60/// card are all `FullAccess` and have three different rollback contracts.
61/// Collapsed, the runtime had two options and no third — gate every
62/// `FullAccess` action identically (approval fatigue, and the predictable
63/// response is that someone turns the gate off), or relax the tier and lose
64/// the permanent cases along with the recoverable ones.
65///
66/// The rollback contract is now [`car_ir::Reversibility`], classified by
67/// [`classify_reversibility`] from its own keyword sets, and the two axes are
68/// reported side by side. **Nothing about this enum changed when that axis
69/// landed** — not its ordering, not its variants, not what
70/// [`RiskClassifier::classify`] returns for any action. Only this
71/// documentation changed, because it had been describing the other axis.
72#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
73#[serde(rename_all = "snake_case")]
74pub enum PermissionTier {
75 /// Observation only — state reads, retrieval, static inspection, log
76 /// analysis. Nothing is mutated, so the only authority at stake is the
77 /// authority to look.
78 ReadOnly,
79 /// Mutation whose blast radius stops at the session's own workspace —
80 /// state writes, local patches, sandboxed tool calls, temporary dependency
81 /// installs inside an isolated workspace. Authorizable by a standing grant
82 /// precisely because nothing crosses the sandbox boundary.
83 SandboxEdit,
84 /// Externally consequential — effects that cross the sandbox boundary:
85 /// network egress, credentials/secrets, deployment, destructive filesystem
86 /// or VCS operations, financial/medical actions, physical control. Needs
87 /// the top standing grant and, by default (`require_approval_at`), a human
88 /// decision as well.
89 ///
90 /// Whether such an action can afterwards be *undone* is a separate
91 /// question this rung does not answer: it holds `read_secret` (nothing to
92 /// undo), `git push` (force-push the prior ref), and a charged card (no
93 /// undo at all) alike. Ask [`classify_reversibility`] for that.
94 FullAccess,
95}
96
97impl PermissionTier {
98 /// Does a session granted `self` cover an action requiring `required`?
99 pub fn covers(self, required: PermissionTier) -> bool {
100 self >= required
101 }
102
103 pub fn as_str(self) -> &'static str {
104 match self {
105 PermissionTier::ReadOnly => "read_only",
106 PermissionTier::SandboxEdit => "sandbox_edit",
107 PermissionTier::FullAccess => "full_access",
108 }
109 }
110
111 pub fn from_str_opt(s: &str) -> Option<PermissionTier> {
112 match s {
113 "read_only" | "readonly" | "read" => Some(PermissionTier::ReadOnly),
114 "sandbox_edit" | "sandbox" | "edit" => Some(PermissionTier::SandboxEdit),
115 "full_access" | "full" => Some(PermissionTier::FullAccess),
116 _ => None,
117 }
118 }
119}
120
121/// Substrings that mark an action as [`PermissionTier::FullAccess`] when they
122/// appear **on the command surface** — the reconstructed `command`/`args`
123/// command line (see [`command_line`]). Conservative and additive there: the
124/// cost of over-classifying a command line is an approval prompt; the cost of
125/// under-classifying one is an ungated consequential action.
126///
127/// # Why this list is scoped to the command line and not the whole action
128///
129/// It used to be substring-scanned over [`action_haystack`] — the entire
130/// flattened parameter blob — and it contains tokens that are ordinary English
131/// and ordinary JSON: `http`, `send`, `apply`, `format`, `token`, `release`,
132/// `request`, `network`, `fetch`, `transfer`. Against free text every one of
133/// those fires constantly: a URL in a `url` parameter matched `http`, a search
134/// for "release notes" matched `release`, a `format_date` call matched
135/// `format`. That was survivable while the classification was advisory, and
136/// stopped being survivable at Parslee-ai/car#915, which made a `full_access`
137/// classification mandatory HITL at `proposal.submit` — on an automated
138/// connection with no approver, a false positive became a hard rejection
139/// (Parslee-ai/car#917).
140///
141/// The sibling list [`FULL_ACCESS_NAME_SEGMENTS`] had already reached this
142/// conclusion for tool *names* and deliberately excludes the same tokens. The
143/// reasoning simply had not been carried across. On a command line, though,
144/// they mean what the list says they mean — `curl https://…`, `terraform
145/// apply`, `git push` — so the list is kept verbatim and re-aimed rather than
146/// pruned. **For a command-shaped action the classification is byte-for-byte
147/// what it was before #917**; only non-command parameters changed.
148///
149/// The other three signals that reach the rest of the action are
150/// [`tool_name_is_full_access`] (whole name segments),
151/// [`CREDENTIAL_PARAM_KEYS`] (parameter keys, not values), and
152/// [`FULL_ACCESS_TEXT_PHRASES`] (the narrow always-dangerous phrases).
153const FULL_ACCESS_COMMAND_KEYWORDS: &[&str] = &[
154 // Deploy / publish / release
155 "deploy",
156 "publish",
157 "release",
158 "kubectl",
159 "terraform",
160 "helm",
161 "docker push",
162 "npm publish",
163 "cargo publish",
164 "aws ",
165 "gcloud",
166 "az ",
167 "apply",
168 "rollout",
169 // Credentials / secrets
170 "credential",
171 "secret",
172 "token",
173 "password",
174 "api_key",
175 "apikey",
176 "ssh",
177 "private key",
178 "private_key",
179 // Destructive filesystem / VCS
180 "delete",
181 "destroy",
182 "drop",
183 "drop table",
184 "delete from",
185 "truncate",
186 "rm ",
187 "rmdir",
188 "unlink",
189 "mkfs",
190 "dd ",
191 "format",
192 "wipe",
193 "git push",
194 "push",
195 "force-push",
196 "force_push",
197 "reset --hard",
198 "git clean",
199 "git reset",
200 // Network egress
201 "network",
202 "http",
203 "https",
204 "curl",
205 "wget",
206 "fetch",
207 "request",
208 "egress",
209 "upload",
210 "download",
211 // Money / messaging
212 "payment",
213 "charge",
214 "refund",
215 "transfer",
216 "wire",
217 "email",
218 "send",
219 "sms",
220 // Privilege
221 "sudo",
222 "chmod",
223 "chown",
224 "setuid",
225];
226
227/// Phrases dangerous enough to escalate **wherever** they appear in an action —
228/// including inside a message body, a document, a SQL string, or a diff.
229///
230/// This is the free-text half of the split described on
231/// [`FULL_ACCESS_COMMAND_KEYWORDS`], and every entry earns its place by being a
232/// token *sequence* that is essentially never ordinary prose. A single common
233/// word (`send`, `release`, `format`) is exactly what this list must not
234/// contain; `drop table` and `rm -rf` are unambiguous no matter what parameter
235/// carries them.
236///
237/// The direction of error is still conservative — a `curl` example inside a
238/// README body escalates, which costs an approval — but the *rate* is what
239/// #917 is about, and prose does not accidentally spell `reset --hard`.
240///
241/// Two families are load-bearing rather than belt-and-braces, because the
242/// command surface cannot see them:
243/// - **SQL under a `sql`/`query` parameter.** `query` is not a
244/// [`COMMAND_KEYS`] entry, so `{"tool": "run_sql", "query": "DROP TABLE
245/// users"}` has an empty command line and a benign tool name. `drop table`
246/// here is the only signal there is.
247/// - **A script passed as content.** `{"tool": "write_file", "contents": "rm
248/// -rf /"}` likewise reaches nothing else.
249const FULL_ACCESS_TEXT_PHRASES: &[&str] = &[
250 // Destructive SQL — a bare `drop`/`delete` is far too common, the
251 // statement head is not.
252 "drop table",
253 "drop database",
254 "drop schema",
255 "delete from",
256 "truncate table",
257 // Destructive shell.
258 "rm -rf",
259 "rm -fr",
260 "rm -r ",
261 "mkfs",
262 "shred -",
263 "dd if=",
264 // Privilege escalation — always a command, never prose.
265 "sudo ",
266 "chmod ",
267 "chown ",
268 "setuid",
269 // VCS history rewriting / publishing.
270 "git push",
271 "git reset",
272 "git clean",
273 "reset --hard",
274 "force-push",
275 "force_push",
276 // Publish / deploy toolchains.
277 "docker push",
278 "npm publish",
279 "cargo publish",
280 "kubectl ",
281 "terraform ",
282 "helm ",
283 "aws ",
284 "gcloud ",
285 // Network egress invoked as a command.
286 "curl ",
287 "wget ",
288 // Credential material appearing literally.
289 "private key",
290 "private_key",
291 "-----begin",
292];
293
294/// Parameter **keys** whose presence means the action handles credential
295/// material, whatever the value happens to be.
296///
297/// This is the "parameter-key awareness" half of the #917 fix, and it is what
298/// lets the broad value-scan be narrowed without losing the credential signal:
299/// `{"api_key": "…"}` is a full-access action because of the key, so `token`
300/// and `secret` no longer have to be substring-hunted through every value in
301/// the payload to catch it.
302///
303/// Matched two ways against each key (and recursively against nested object
304/// keys), both boundary-aware, never as a raw substring:
305/// 1. the key with `_`/`-` removed, whole (`api_key` → `apikey`);
306/// 2. any single [`name_segments`] segment against [`CREDENTIAL_KEY_SEGMENTS`].
307///
308/// The pair is what keeps `max_tokens` — in virtually every LLM tool call in
309/// existence — from reading as a credential: `maxtokens` is not in this set,
310/// and `tokens` is deliberately not in [`CREDENTIAL_KEY_SEGMENTS`]. A bare
311/// `token` key is, since that one really is the credential itself.
312const CREDENTIAL_PARAM_KEYS: &[&str] = &[
313 "token",
314 "apikey",
315 "apitoken",
316 "accesstoken",
317 "accesskey",
318 "authtoken",
319 "authorization",
320 "bearer",
321 "bearertoken",
322 "clientsecret",
323 "privatekey",
324 "refreshtoken",
325 "secretkey",
326 "sessiontoken",
327 "signingkey",
328];
329
330/// Path fragments naming credential material *on disk*, matched against
331/// [`path_parameter_haystack`] — the paths an action declares as its targets.
332///
333/// The third place a credential signal can live, after the tool name and the
334/// parameter keys. Before Parslee-ai/car#917 this was covered incidentally: the
335/// broad keyword list contained bare `ssh`, `secret`, and `credential`, so
336/// `{"path": "~/.ssh/id_rsa"}` escalated because the whole payload was
337/// substring-scanned. Narrowing the value scan removed that by accident, and
338/// `car-server-core`'s `agent_permissions` tests caught it — reading or writing
339/// someone's private key is exactly the action that should need the top grant.
340///
341/// Scoped to declared target paths rather than the whole action on purpose: a
342/// diff or a README that *mentions* `~/.ssh` is not an action against it, and
343/// re-scanning free text is the mistake this issue exists to undo.
344/// Whether `path` names credential material on disk, by the same fragments
345/// [`RiskClassifier`] reads in an action's declared target paths. For callers
346/// that track what was read across calls, such as the Claude Code policy hook
347/// (car#3367).
348pub fn names_credential_material(path: &str) -> bool {
349 let path = path.to_ascii_lowercase();
350 CREDENTIAL_PATH_FRAGMENTS.iter().any(|f| path.contains(f))
351}
352
353const CREDENTIAL_PATH_FRAGMENTS: &[&str] = &[
354 // SSH / GPG key material
355 "ssh",
356 "id_rsa",
357 "id_dsa",
358 "id_ecdsa",
359 "id_ed25519",
360 ".gnupg",
361 "secring",
362 // Cloud + registry credential files
363 ".aws/credentials",
364 ".aws\\credentials",
365 ".kube/config",
366 ".kube\\config",
367 ".docker/config.json",
368 ".netrc",
369 ".pgpass",
370 ".npmrc",
371 "service-account",
372 "service_account",
373 // Generic credential-bearing names and key/cert extensions
374 "credential",
375 "secret",
376 "password",
377 "keychain",
378 ".pem",
379 ".p12",
380 ".pfx",
381 ".env",
382];
383
384/// Key segments that name credential material on their own, at any position in
385/// a parameter key (`secret_name`, `db_password`, `svc_credentials`).
386///
387/// Strictly the words that carry the meaning alone. `token`/`tokens` are
388/// excluded on purpose — see [`CREDENTIAL_PARAM_KEYS`] for `max_tokens` — as is
389/// `key`, which is the single most common object key there is.
390const CREDENTIAL_KEY_SEGMENTS: &[&str] = &[
391 "secret",
392 "secrets",
393 "credential",
394 "credentials",
395 "password",
396 "passwd",
397 "passphrase",
398];
399
400/// Tool-name segments that signal an irreversible / externally-consequential
401/// capability.
402///
403/// Curated for IDENTIFIER matching (whole snake/camel segments), NOT command
404/// text like [`FULL_ACCESS_COMMAND_KEYWORDS`]. Two deliberate differences from
405/// that list:
406/// 1. Short verbs that collide with benign tool names as substrings are
407/// EXCLUDED — `http` (`http_get` is a read), `token` (`count_tokens`,
408/// `tokenize`), `request` (`request_id`), `fetch` (`prefetch`), `apply`
409/// (`apply_template`), `network`, `format`, `transfer`. A substring matcher
410/// would mis-route every one of those to quality-first.
411/// 2. The space-bearing command keywords (`git push`, `rm -rf`, `git reset`)
412/// are represented by their bare COMMAND segments (`push`, `rm`, `reset`, …)
413/// so they actually fire on an identifier — a substring scan of the
414/// command list never could (no tool name contains the literal `"rm "`).
415///
416/// Since Parslee-ai/car#917 this list is not merely the analogue used by the
417/// in-process loops: it *is* the tool-name signal inside
418/// [`RiskClassifier::classify`], which is what makes the two agree. Before that
419/// they disagreed on every name in this doc comment — `classify` substring-
420/// scanned the command list over the name and called `http_get` full-access
421/// while `tool_name_is_full_access` called it benign.
422const FULL_ACCESS_NAME_SEGMENTS: &[&str] = &[
423 // deploy / publish / release
424 "deploy",
425 "publish",
426 "release",
427 "kubectl",
428 "terraform",
429 "helm",
430 "rollout",
431 // credentials / secrets
432 "credential",
433 "credentials",
434 "secret",
435 "secrets",
436 "password",
437 "passwd",
438 // destructive filesystem / VCS
439 "delete",
440 "destroy",
441 "drop",
442 "truncate",
443 "rm",
444 "rmdir",
445 "unlink",
446 "mkfs",
447 "dd",
448 "wipe",
449 "push",
450 "reset",
451 "clean",
452 // network egress / external I/O
453 "curl",
454 "wget",
455 "egress",
456 "upload",
457 "download",
458 "send",
459 // money
460 "payment",
461 "charge",
462 "refund",
463 "wire",
464 // privilege
465 "sudo",
466 "chmod",
467 "chown",
468 "setuid",
469];
470
471/// Split a tool name into lowercase segments on non-alphanumeric boundaries AND
472/// camelCase transitions, so `gitPush`, `git_push`, and `git-push` all yield
473/// `["git", "push"]`. Matching whole segments (not substrings) is what makes the
474/// name check honest: `count_tokens` → `["count", "tokens"]` does NOT match the
475/// `token`-class danger, and `git_reset` → `["git", "reset"]` DOES match `reset`.
476fn name_segments(name: &str) -> Vec<String> {
477 let mut segs = Vec::new();
478 let mut cur = String::new();
479 let mut prev_lower_or_digit = false;
480 for ch in name.chars() {
481 if ch.is_alphanumeric() {
482 // camelCase boundary: a lower/digit followed by an uppercase letter
483 // starts a new segment (`gitPush` → `git` | `push`).
484 if prev_lower_or_digit && ch.is_uppercase() && !cur.is_empty() {
485 segs.push(std::mem::take(&mut cur));
486 }
487 cur.extend(ch.to_lowercase());
488 prev_lower_or_digit = ch.is_lowercase() || ch.is_numeric();
489 } else {
490 if !cur.is_empty() {
491 segs.push(std::mem::take(&mut cur));
492 }
493 prev_lower_or_digit = false;
494 }
495 }
496 if !cur.is_empty() {
497 segs.push(cur);
498 }
499 segs
500}
501
502/// True if a tool *name* names an irreversible / externally-consequential
503/// capability — the stakes signal at the granularity available *before* an
504/// action is built: a planning/agent loop knows its tool palette, not yet the
505/// concrete action. Matches whole identifier segments against the curated
506/// [`FULL_ACCESS_NAME_SEGMENTS`] set.
507///
508/// This is a SEGMENT-LEVEL APPROXIMATION of the irreversibility signal, NOT the
509/// whole of the per-[`Action`] [`RiskClassifier::classify`] — it sees only the
510/// tool name, so param-derived escalation (e.g. `rm -rf /` in a `cmd` arg under
511/// a generic `shell` tool) is invisible here by construction. It is
512/// deliberately cleaner than substring-scanning
513/// [`FULL_ACCESS_COMMAND_KEYWORDS`] over a name (no `count_tokens`/`http_get`
514/// false positives, no space-keyword false negatives). Used by the in-process
515/// autonomous loops (active-planner, agents) to route generation quality-first
516/// — the analogue of the daemon's session-tier `high_stakes` gate for the paths
517/// that bypass it.
518///
519/// **Since Parslee-ai/car#917 a mis-classification here is not free.**
520/// `classify` calls this as its tool-name signal, so a name wrongly in
521/// [`FULL_ACCESS_NAME_SEGMENTS`] costs an approval prompt and a name wrongly
522/// absent removes one — an authz decision, not just which model generates.
523/// Weigh additions to that list accordingly.
524pub fn tool_name_is_full_access(name: &str) -> bool {
525 name_segments(name)
526 .iter()
527 .any(|seg| FULL_ACCESS_NAME_SEGMENTS.contains(&seg.as_str()))
528}
529
530/// True if *any* of the supplied tool names is full-access — convenience over
531/// [`tool_name_is_full_access`] for a loop assessing its whole tool palette.
532/// The `AsRef<str>` item bound lets callers pass `&[String]`, `&HashSet<String>`,
533/// or `&[&str]` without an explicit `.map(String::as_str)`.
534pub fn any_tool_full_access<I>(names: I) -> bool
535where
536 I: IntoIterator,
537 I::Item: AsRef<str>,
538{
539 names
540 .into_iter()
541 .any(|n| tool_name_is_full_access(n.as_ref()))
542}
543
544/// Append every string scalar reachable under `v` to `out`, space-
545/// separated. Arrays are flattened in order, so an argv array like
546/// `["git","push","--force"]` becomes `"git push --force"` and matches a
547/// space-containing keyword that the raw JSON (`["git","push"]`) would
548/// hide (neo review). Non-string scalars are stringified too.
549fn collect_strings(v: &serde_json::Value, out: &mut String) {
550 use serde_json::Value;
551 match v {
552 Value::String(s) => {
553 out.push_str(s);
554 out.push(' ');
555 }
556 Value::Array(items) => {
557 for it in items {
558 collect_strings(it, out);
559 }
560 }
561 Value::Object(map) => {
562 for val in map.values() {
563 collect_strings(val, out);
564 }
565 }
566 Value::Number(_) | Value::Bool(_) | Value::Null => {
567 out.push_str(&v.to_string());
568 out.push(' ');
569 }
570 }
571}
572
573/// One lowercase haystack per action: the tool name followed by every
574/// (possibly nested, possibly argv-array) string reachable in its parameters.
575/// Shared by both classifiers so the authority axis and the reversibility axis
576/// read the *same* text and differ only in what they look for in it.
577///
578/// Flattening is what makes space-bearing phrases matchable at all: an argv
579/// array `["git","push","--force"]` becomes `"git push --force "`, which the
580/// raw JSON never contained.
581///
582/// # Two properties this function must hold, and why
583///
584/// **Deterministic.** `Action::parameters` is a `std::collections::HashMap`,
585/// whose iteration order is randomized per process. Walking it directly makes
586/// the haystack — and therefore both classifications — differ run to run for
587/// the same action. Parameters are visited in sorted key order instead.
588///
589/// **Parameter-separated.** Values are joined with `\n` rather than a space, so
590/// a phrase can only match *within* one top-level parameter. Concatenating two
591/// unrelated parameters can spell a phrase neither of them contains: with
592/// `{"command": "git", "args": ["push", ...]}` the flattened text reads
593/// `"git push"` and matches `COMPENSABLE_PARAM_PHRASES` — half the time, on
594/// whichever iteration order the process happened to draw.
595///
596/// **Command-line adjacency is reconstructed, not left to chance.** The one
597/// cross-parameter concatenation that carries real meaning is
598/// `{"command": "rm", "args": ["-rf", …]}`, where `"rm -rf"` exists only once
599/// the two are joined. That is why the fix is not "sort the keys": sorting
600/// alone puts `args` before `command` and loses `"rm -rf"` — deterministically,
601/// which is worse than losing it at random. [`COMMAND_KEYS`] are emitted first,
602/// then [`ARG_KEYS`], space-joined into one line; every other parameter follows
603/// on its own line in sorted order.
604///
605/// Together these fix a defect that mattered in both directions and was
606/// invisible in one. For the tier classifier a phantom match only ever
607/// escalates. For [`classify_reversibility`] a phantom
608/// `COMPENSABLE_PARAM_PHRASES` hit *lowers* the contract from the
609/// `Irreversible` default — a force-push came back `compensable` or
610/// `irreversible` depending on the draw — while a *missed* `rm -rf` left a
611/// destructive shell call unrecognized on exactly the same coin flip.
612///
613/// `\n` is whitespace, so [`sandbox_confined_paths`]' tokenization is
614/// unaffected, and no phrase in any set here spans a newline.
615fn action_haystack(action: &Action) -> String {
616 let mut hay = String::new();
617 if let Some(tool) = &action.tool {
618 hay.push_str(tool);
619 hay.push('\n');
620 }
621
622 // The command line, rebuilt in argv order so `command` + `args` form the
623 // one adjacency the phrase sets are written against. Shared with
624 // `command_line` so the text scanned by `FULL_ACCESS_COMMAND_KEYWORDS` and
625 // the text embedded here can never drift apart.
626 if let Some(cmdline) = command_line(action) {
627 hay.push_str(&cmdline);
628 hay.push('\n');
629 }
630
631 // Everything else, one parameter per line, in a deterministic order.
632 let mut keys: Vec<&String> = action
633 .parameters
634 .keys()
635 .filter(|k| {
636 let k = k.as_str();
637 !COMMAND_KEYS.contains(&k) && !ARG_KEYS.contains(&k)
638 })
639 .collect();
640 keys.sort();
641 for k in keys {
642 if let Some(v) = action.parameters.get(k) {
643 collect_strings(v, &mut hay);
644 hay.push('\n');
645 }
646 }
647 hay.to_ascii_lowercase()
648}
649
650/// The action's command line — the [`COMMAND_KEYS`] program followed by its
651/// [`ARG_KEYS`] argument vector, flattened in argv order and lowercased —
652/// or `None` when the action is not command-shaped.
653///
654/// This is the surface [`FULL_ACCESS_COMMAND_KEYWORDS`] is scanned over, and
655/// isolating it is what lets that list stay broad: `http`, `apply`, and
656/// `format` are honest danger signals in `curl https://…`, `terraform apply`,
657/// and `mkfs.ext4 --format`, and were only ever wrong when the same scan ran
658/// across message bodies and URLs (Parslee-ai/car#917).
659///
660/// It reads the same keys in the same order as [`action_haystack`]'s command
661/// line, so the two never disagree about what the command was. It is computed
662/// from the action rather than sliced out of the shared haystack because the
663/// haystack's line structure is positional — with no tool name, line 1 *is* the
664/// command line — and a classifier must not depend on that. The extra
665/// allocation is bounded by the argv, not by the payload: the parameters that
666/// make flattening expensive (a file body, a diff) are exactly the ones
667/// excluded here.
668fn command_line(action: &Action) -> Option<String> {
669 let mut cmd = String::new();
670 for group in [COMMAND_KEYS, ARG_KEYS] {
671 for key in group {
672 if let Some(v) = action.parameters.get(*key) {
673 collect_strings(v, &mut cmd);
674 }
675 }
676 }
677 if cmd.is_empty() {
678 None
679 } else {
680 Some(cmd.to_ascii_lowercase())
681 }
682}
683
684/// True if any parameter key — at any nesting depth — names credential
685/// material, per [`CREDENTIAL_PARAM_KEYS`] and [`CREDENTIAL_KEY_SEGMENTS`].
686///
687/// Reads **keys only, never values**. That is the point: the value of an
688/// `api_key` parameter is opaque high-entropy text that no keyword list can
689/// recognize, while the key states outright what the action is handling. It is
690/// also why narrowing the value scan in #917 did not lose the credential
691/// signal — it moved to where the evidence actually is.
692///
693/// Array elements inherit nothing: unlike [`path_parameter_haystack`] there is
694/// no "descend under a matched key" mode, because the question is whether a
695/// credential-named key *exists*, not what is stored beneath it.
696fn hits_credential_param_key(action: &Action) -> bool {
697 fn key_is_credential(key: &str) -> bool {
698 let depunct: String = key
699 .chars()
700 .filter(|c| c.is_alphanumeric())
701 .flat_map(char::to_lowercase)
702 .collect();
703 if CREDENTIAL_PARAM_KEYS.contains(&depunct.as_str()) {
704 return true;
705 }
706 name_segments(key)
707 .iter()
708 .any(|seg| CREDENTIAL_KEY_SEGMENTS.contains(&seg.as_str()))
709 }
710
711 fn walk(v: &serde_json::Value) -> bool {
712 use serde_json::Value;
713 match v {
714 Value::Object(map) => map
715 .iter()
716 .any(|(k, inner)| key_is_credential(k) || walk(inner)),
717 Value::Array(items) => items.iter().any(walk),
718 _ => false,
719 }
720 }
721
722 action
723 .parameters
724 .iter()
725 .any(|(k, v)| key_is_credential(k) || walk(v))
726}
727
728/// Both authorization axes for one action, from [`PermissionGate::evaluate_axes`].
729///
730/// `#[non_exhaustive]` for the reason `car_ir::Action` is (Parslee-ai/car#855):
731/// `car-policy` is published, and a third axis should not break every consumer
732/// that destructures this.
733#[derive(Debug, Clone)]
734#[non_exhaustive]
735pub struct ActionAxes {
736 /// May this run — the gate's verdict, carrying the required tier.
737 pub decision: GateDecision,
738 /// Could it be taken back. Independent of `decision`: the gate's verdict
739 /// says nothing about whether the effect survives a rollback.
740 pub reversibility: Reversibility,
741}
742
743/// The flattened, lowercased text both classifiers read for one action.
744///
745/// Public so a caller computing both axes can build it once and hand it to
746/// [`RiskClassifier::classify_with_haystack`] and
747/// [`classify_reversibility_with_haystack`]. The flattening walks every nested
748/// string in `Action::parameters` and then allocates a lowercase copy, which is
749/// not free when a parameter carries a large file body or diff.
750pub fn action_text(action: &Action) -> String {
751 action_haystack(action)
752}
753
754/// Parameter keys naming the program in a command-shaped invocation.
755/// Emitted before [`ARG_KEYS`] by [`action_haystack`] so the flattened text
756/// reads as a command line rather than in whatever order a `HashMap` yields.
757const COMMAND_KEYS: &[&str] = &["command", "cmd", "executable", "program", "bin", "binary"];
758
759/// Parameter keys naming the argument vector in a command-shaped invocation.
760const ARG_KEYS: &[&str] = &["args", "argv", "arguments", "flags", "options"];
761
762/// Keys whose value names a filesystem location the tool will act *on*.
763///
764/// [`sandbox_confined_paths`] reads only these, so that a scratch path
765/// appearing somewhere incidental — inside a file's `contents`, a diff body, a
766/// rendered template — cannot vouch for a write whose actual target is
767/// somewhere else entirely.
768///
769/// A tool that names its target with a key not listed here contributes no
770/// paths, so the sandbox rule finds nothing to qualify on and the ladder falls
771/// through to the conservative default. That is the intended direction: this
772/// set failing to recognize a target costs an over-ask, while reading the wrong
773/// parameter would cost a silent under-classification.
774///
775/// Crucially, this set gates only the *qualifying* half of
776/// [`sandbox_confined_paths`]. The disqualifying half reads the whole action,
777/// so a dangerous path under a key missing from this list still vetoes — an
778/// omission here can never make a write look discardable when it is not.
779const PATH_PARAM_KEYS: &[&str] = &[
780 "path",
781 "paths",
782 "file",
783 "files",
784 "file_path",
785 "filepath",
786 "filename",
787 "dir",
788 "directory",
789 "folder",
790 "dest",
791 "destination",
792 "target",
793 "target_path",
794 "output",
795 "output_path",
796 "out",
797 "src",
798 "source",
799 "source_path",
800 "cwd",
801 "workdir",
802 "working_dir",
803];
804
805/// The paths an action declares as its *targets*, lowercased and
806/// whitespace-joined — the input to [`sandbox_confined_paths`].
807///
808/// Walks `parameters` in sorted key order (same determinism requirement as
809/// [`action_haystack`]) and descends into nested objects, collecting only
810/// values reached through a [`PATH_PARAM_KEYS`] key.
811fn path_parameter_haystack(action: &Action) -> String {
812 fn walk(v: &serde_json::Value, key_matched: bool, out: &mut String) {
813 use serde_json::Value;
814 match v {
815 Value::Object(map) => {
816 let mut keys: Vec<&String> = map.keys().collect();
817 keys.sort();
818 for k in keys {
819 let hit =
820 key_matched || PATH_PARAM_KEYS.contains(&k.to_ascii_lowercase().as_str());
821 if let Some(inner) = map.get(k) {
822 walk(inner, hit, out);
823 }
824 }
825 }
826 Value::Array(items) => {
827 for it in items {
828 walk(it, key_matched, out);
829 }
830 }
831 Value::String(s) if key_matched => {
832 out.push_str(s);
833 out.push(' ');
834 }
835 _ => {}
836 }
837 }
838
839 let mut out = String::new();
840 let mut keys: Vec<&String> = action.parameters.keys().collect();
841 keys.sort();
842 for k in keys {
843 let hit = PATH_PARAM_KEYS.contains(&k.to_ascii_lowercase().as_str());
844 if let Some(v) = action.parameters.get(k) {
845 walk(v, hit, &mut out);
846 }
847 }
848 out.to_ascii_lowercase()
849}
850
851/// Classifies an [`Action`] into the minimum [`PermissionTier`] required
852/// to perform it. Combines a built-in heuristic with optional custom
853/// rules; the result is the **highest** tier any signal implies, since
854/// risk is monotonic (one high-risk signal escalates the whole action).
855pub struct RiskClassifier {
856 rules: Vec<ClassifierRule>,
857}
858
859struct ClassifierRule {
860 name: String,
861 tier: PermissionTier,
862 matcher: Box<dyn Fn(&Action) -> bool + Send + Sync>,
863}
864
865impl std::fmt::Debug for RiskClassifier {
866 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
867 f.debug_struct("RiskClassifier")
868 .field(
869 "rules",
870 &self
871 .rules
872 .iter()
873 .map(|r| r.name.as_str())
874 .collect::<Vec<_>>(),
875 )
876 .finish()
877 }
878}
879
880impl RiskClassifier {
881 /// A classifier with only the built-in heuristic.
882 pub fn new() -> Self {
883 Self { rules: Vec::new() }
884 }
885
886 /// Add a custom rule. A matching rule can only *raise* the required
887 /// tier (via `max`), never lower it — safety is the default.
888 pub fn add_rule<F>(&mut self, name: &str, tier: PermissionTier, matcher: F)
889 where
890 F: Fn(&Action) -> bool + Send + Sync + 'static,
891 {
892 self.rules.push(ClassifierRule {
893 name: name.to_string(),
894 tier,
895 matcher: Box::new(matcher),
896 });
897 }
898
899 /// The built-in, keyword-free baseline from the action's *type*.
900 fn baseline(action: &Action) -> PermissionTier {
901 match action.action_type {
902 // Reads and assertions never mutate or reach outside.
903 ActionType::StateRead | ActionType::Assertion => PermissionTier::ReadOnly,
904 // Local state mutation is reversible (snapshot/rollback).
905 ActionType::StateWrite => PermissionTier::SandboxEdit,
906 // A tool call's effects are opaque to static analysis; assume
907 // it can mutate, but escalate to FullAccess only on a signal.
908 ActionType::ToolCall => PermissionTier::SandboxEdit,
909 }
910 }
911
912 /// Does anything about this action require the top grant?
913 ///
914 /// Four independent signals, each matched at the granularity its evidence
915 /// actually supports — which is the whole of the Parslee-ai/car#917 fix.
916 /// The predecessor was one substring scan of the broad keyword list over
917 /// the entire flattened payload, so `http` in a URL, `send` in a message
918 /// body, and `format` in a tool name all read as full-access.
919 ///
920 /// 1. **Tool name**, whole snake/camel segments against the curated
921 /// [`FULL_ACCESS_NAME_SEGMENTS`] — [`tool_name_is_full_access`].
922 /// `deploy_service` and `send_email` hit; `http_get`, `format_date`, and
923 /// `count_tokens` no longer do.
924 /// 2. **Command line**, substring against [`FULL_ACCESS_COMMAND_KEYWORDS`]
925 /// — the unchanged broad list, over `command`/`args` only, where those
926 /// tokens genuinely name commands.
927 /// 3. **Parameter keys**, boundary-matched against
928 /// [`CREDENTIAL_PARAM_KEYS`] — `{"api_key": …}` is full-access because
929 /// of the key, so credential values need no substring hunt.
930 /// 4. **Declared target paths**, substring against
931 /// [`CREDENTIAL_PATH_FRAGMENTS`] — `{"path": "~/.ssh/id_rsa"}` acts
932 /// *on* key material, which a mention of `~/.ssh` in a file body does
933 /// not.
934 /// 5. **Everything else**, substring against the narrow
935 /// [`FULL_ACCESS_TEXT_PHRASES`] — `drop table`, `rm -rf`, phrases that
936 /// are never accidental prose.
937 ///
938 /// Signal 5 reads the full shared haystack rather than only the leftover
939 /// parameters. Scanning the command line twice is harmless (both lists
940 /// escalate) and it keeps the one-allocation contract of
941 /// [`classify_with_haystack`] intact.
942 fn hits_full_access(action: &Action, hay: &str) -> bool {
943 if action.tool.as_deref().is_some_and(tool_name_is_full_access) {
944 return true;
945 }
946 if let Some(cmd) = command_line(action) {
947 if FULL_ACCESS_COMMAND_KEYWORDS.iter().any(|k| cmd.contains(k)) {
948 return true;
949 }
950 }
951 if hits_credential_param_key(action) {
952 return true;
953 }
954 let targets = path_parameter_haystack(action);
955 if CREDENTIAL_PATH_FRAGMENTS
956 .iter()
957 .any(|f| targets.contains(f))
958 {
959 return true;
960 }
961 FULL_ACCESS_TEXT_PHRASES.iter().any(|p| hay.contains(p))
962 }
963
964 /// Classify an action into its minimum required tier.
965 pub fn classify(&self, action: &Action) -> PermissionTier {
966 self.classify_with_haystack(action, None)
967 }
968
969 /// [`RiskClassifier::classify`] reusing a haystack the caller already
970 /// built. A caller computing *both* axes for one action would otherwise
971 /// flatten and lowercase the whole parameter payload twice — see
972 /// [`classify_reversibility_with_haystack`], and [`action_text`] for the
973 /// haystack itself.
974 pub fn classify_with_haystack(
975 &self,
976 action: &Action,
977 haystack: Option<&str>,
978 ) -> PermissionTier {
979 let mut tier = Self::baseline(action);
980 if action.action_type == ActionType::ToolCall {
981 let owned;
982 let hay: &str = match haystack {
983 Some(h) => h,
984 None => {
985 owned = action_haystack(action);
986 &owned
987 }
988 };
989 if Self::hits_full_access(action, hay) {
990 tier = tier.max(PermissionTier::FullAccess);
991 }
992 }
993 for rule in &self.rules {
994 if (rule.matcher)(action) {
995 tier = tier.max(rule.tier);
996 }
997 }
998 tier
999 }
1000}
1001
1002impl Default for RiskClassifier {
1003 fn default() -> Self {
1004 Self::new()
1005 }
1006}
1007
1008// ---------------------------------------------------------------------------
1009// The second axis: can this be undone?
1010//
1011// Everything below classifies `car_ir::Reversibility` and reads NOTHING from
1012// the tier machinery above — not `RiskClassifier`, not `PermissionTier`, not
1013// `FULL_ACCESS_COMMAND_KEYWORDS`. That separation is the entire point: deriving one
1014// axis from the other would reproduce the conflation this exists to undo.
1015// The two sets overlap in places (both care about `delete`) and diverge in
1016// more (`secret` is top-authority and perfectly reversible; `insert` is
1017// low-authority and needs a compensating delete), and each is curated against
1018// its own question.
1019// ---------------------------------------------------------------------------
1020
1021/// Tool-name segments naming an effect that cannot be undone once it runs.
1022///
1023/// Curated for IDENTIFIER matching against whole snake/camel segments (see
1024/// [`name_segments`]), like [`FULL_ACCESS_NAME_SEGMENTS`] and unlike the
1025/// free-text [`FULL_ACCESS_TEXT_PHRASES`].
1026///
1027/// **Mostly verbs, deliberately.** The object a tool touches tells you what is
1028/// at stake; only the verb tells you what happens to it. `email` as a segment
1029/// would make `read_email` and `list_email` irreversible, which is nonsense —
1030/// so the egress family is matched by `send` / `notify` / `dispatch` instead.
1031/// The exception is money (see the fn docs for [`classify_reversibility`]):
1032/// every mutation on a payment rail is permanent, so those are matched as
1033/// nouns and the cost is that `get_payment` over-classifies.
1034///
1035/// Segments that would collide with common benign names are excluded on the
1036/// same grounds the tier segment list excludes them: `page` (`get_page`),
1037/// `format` (`format_date`), `capture` (`capture_screenshot`), `launch`
1038/// (`launch_browser`), `call` (`call_tool`), `message` (`get_messages`).
1039const IRREVERSIBLE_NAME_SEGMENTS: &[&str] = &[
1040 // Egress to a person or a third party. By the time anyone objects the
1041 // recipient has already read it. A retraction is a *mitigation*, not a
1042 // compensation — "send a correction" does not unsend the first message.
1043 "send",
1044 "sendmail",
1045 "notify",
1046 "dispatch",
1047 "broadcast",
1048 "announce",
1049 // Publication. An artifact on a public registry can be yanked but never
1050 // un-published, and a message a consumer has already drained off a queue
1051 // cannot be recalled.
1052 "publish",
1053 "enqueue",
1054 // Money — matched by its object, not its verb. See the fn docs.
1055 "pay",
1056 "payment",
1057 "payments",
1058 "charge",
1059 "refund",
1060 "payout",
1061 "invoice",
1062 "checkout",
1063 "wire",
1064 "remit",
1065 // Destruction with no retained copy. `car_engine::Checkpoint` restores the
1066 // state map and nothing else, so whatever a tool erased from disk stays
1067 // erased — the rollback hole the Shepherd proposal calls gap #2.
1068 "delete",
1069 "destroy",
1070 "purge",
1071 "wipe",
1072 "shred",
1073 "erase",
1074 "truncate",
1075 "mkfs",
1076 "rm",
1077 "rmdir",
1078 "unlink",
1079 "drop",
1080 // Privilege withdrawal and physical actuation, on the world side of the
1081 // gate. A reissued credential is a new credential, not the old one back.
1082 "revoke",
1083 "actuate",
1084 "unlock",
1085];
1086
1087/// Tool-name segments naming an effect that left the scope but has a known,
1088/// mechanical inverse — the `Compensable` contract.
1089///
1090/// **Deliberately stingier than [`IRREVERSIBLE_NAME_SEGMENTS`]**, because the
1091/// two sets fail in opposite directions. Omitting something here drops it to
1092/// the conservative `Irreversible` default (over-asks, visible, locally
1093/// fixable); adding something wrongly *lowers* the assessed contract, which is
1094/// the failure mode that stays quiet. Generic verbs that would lower a pure
1095/// function are therefore left out: `add` (`add_numbers`), `set`, `merge`
1096/// (`merge_dicts`), `scale` (`scale_image`), `branch` (`branch_decision`),
1097/// `apply` (`apply_template` — the tier segment list excludes it for the same
1098/// reason; the genuinely consequential `kubectl apply` / `terraform apply`
1099/// cases are caught as parameter phrases instead).
1100const COMPENSABLE_NAME_SEGMENTS: &[&str] = &[
1101 // Row/record creation and mutation: the inverse is a delete or a rewrite.
1102 "insert",
1103 "upsert",
1104 "create",
1105 "put",
1106 "update",
1107 "patch",
1108 "clone",
1109 // Resource lifecycle: the inverse is the paired verb (deregister,
1110 // unsubscribe, detach, unmount, stop, revoke-the-grant).
1111 "register",
1112 "provision",
1113 "allocate",
1114 "attach",
1115 "mount",
1116 "subscribe",
1117 "enable",
1118 "disable",
1119 "start",
1120 "stop",
1121 "restart",
1122 "grant",
1123 // VCS: force-push the prior ref, delete the tag, `git reset` the commit.
1124 "push",
1125 "tag",
1126 "commit",
1127 // Deployment: the inverse is a rollback deploy. `deployment` is listed
1128 // separately because segment matching is exact — `scale_deployment` splits
1129 // to `["scale", "deployment"]` and never equals `deploy`.
1130 "deploy",
1131 "deployment",
1132 "deployments",
1133 "rollout",
1134 "install",
1135 "upgrade",
1136 // Object storage: the inverse is deleting the object written.
1137 "upload",
1138];
1139
1140/// Tool-name segments naming pure retrieval. A read leaves nothing behind, so
1141/// there is nothing to undo — but this only decides when the name carries no
1142/// mutating segment of either kind, so `get_and_delete` still falls through to
1143/// `Irreversible`.
1144const RETRIEVAL_NAME_SEGMENTS: &[&str] = &[
1145 "read",
1146 "get",
1147 "list",
1148 "search",
1149 "query",
1150 "select",
1151 "find",
1152 "grep",
1153 "stat",
1154 "describe",
1155 "inspect",
1156 "show",
1157 "view",
1158 "count",
1159 "head",
1160 "tail",
1161 "cat",
1162 "diff",
1163 "status",
1164 "lookup",
1165 "fetch",
1166 "load",
1167 "scan",
1168 "peek",
1169 "exists",
1170 "resolve",
1171 "summarize",
1172 "analyze",
1173 "classify",
1174 "parse",
1175 "validate",
1176 "check",
1177];
1178
1179/// Tool-name segments that mark a tool as filesystem-shaped. Gates the
1180/// sandbox-path rule so that a *non*-filesystem tool carrying an incidental
1181/// `/tmp` path in its parameters — `http_post` with a `body_file`, say — is
1182/// never talked down to `Reversible` by it.
1183const FILESYSTEM_NAME_SEGMENTS: &[&str] = &[
1184 "file",
1185 "files",
1186 "fs",
1187 "filesystem",
1188 "dir",
1189 "directory",
1190 "folder",
1191 "path",
1192 "write",
1193 "edit",
1194 "append",
1195 "mkdir",
1196 "touch",
1197 "save",
1198];
1199
1200/// Command-shaped destruction, matched as free text against the whole
1201/// [`action_haystack`]. This is the only signal that fires on a *generic*
1202/// tool: `shell` with `["rm","-rf","/var/data"]` names nothing dangerous in
1203/// its tool name, and the argv array never contained the literal phrase until
1204/// [`collect_strings`] flattened it.
1205const IRREVERSIBLE_PARAM_PHRASES: &[&str] = &[
1206 "rm -rf",
1207 "rm -r ",
1208 "rm -f ",
1209 "mkfs",
1210 "shred ",
1211 "dd if=",
1212 "drop table",
1213 "drop database",
1214 "delete from",
1215 "truncate table",
1216 "terraform destroy",
1217 "kubectl delete",
1218];
1219
1220/// Free-text phrases that mark a `Compensable` effect.
1221///
1222/// Every entry is **multi-word and command-shaped**, and that is a rule rather
1223/// than a coincidence: a hit here *lowers* the assessed contract from the
1224/// `Irreversible` default, so a phrase loose enough to appear in an English
1225/// parameter ("update the docs", "insert a paragraph") would quietly downgrade
1226/// unrelated actions. A phrase that only occurs as a shell or SQL fragment
1227/// cannot.
1228const COMPENSABLE_PARAM_PHRASES: &[&str] = &[
1229 "git push",
1230 "git commit",
1231 "git tag",
1232 "docker push",
1233 "kubectl apply",
1234 "kubectl rollout",
1235 "helm install",
1236 "helm upgrade",
1237 "terraform apply",
1238 "insert into",
1239];
1240
1241/// Absolute path **prefixes** the OS designates as scratch space — somewhere a
1242/// write can be discarded wholesale.
1243///
1244/// Every entry is anchored and matched with `starts_with`, not `contains`, and
1245/// that is load-bearing twice over. This set once carried the bare fragments
1246/// `"sandbox"` and `"scratch"`, which matched anywhere in a path token and so
1247/// accepted `/srv/sandbox-prod/index.html` and `/System/Library/Sandbox/…` as
1248/// discardable; and `contains` alone would still accept `/etc/tmp/hosts` on the
1249/// strength of `/tmp/`.
1250///
1251/// A directory merely *named* "sandbox" is a naming convention, not a
1252/// guarantee that anything will restore it, so no such entry belongs here. Only
1253/// locations whose disposability the operating system itself defines qualify.
1254const SANDBOX_PATH_PREFIXES: &[&str] = &[
1255 "/tmp/",
1256 "/private/tmp/",
1257 "/var/tmp/",
1258 "/private/var/tmp/",
1259 "/var/folders/",
1260 "/private/var/folders/",
1261 "/dev/shm/",
1262 "c:\\temp\\",
1263 "c:\\windows\\temp\\",
1264 "\\\\?\\c:\\temp\\",
1265];
1266
1267/// The absolute-path tokens in a flattened parameter blob, de-quoted.
1268fn absolute_path_tokens(hay: &str) -> impl Iterator<Item = &str> {
1269 hay.split_whitespace()
1270 .map(|raw| {
1271 raw.trim_matches(|c: char| matches!(c, '"' | '\'' | '`' | ',' | ';' | ')' | '('))
1272 })
1273 .filter(|token| {
1274 let bytes = token.as_bytes();
1275 // POSIX absolute, a UNC share, or a Windows drive-qualified path.
1276 token.starts_with('/')
1277 || token.starts_with("\\\\")
1278 || (bytes.len() > 2 && bytes[1] == b':' && (bytes[2] == b'\\' || bytes[2] == b'/'))
1279 })
1280}
1281
1282fn is_scratch_path(token: &str) -> bool {
1283 SANDBOX_PATH_PREFIXES.iter().any(|p| token.starts_with(p))
1284}
1285
1286/// True when the action's writes are confined to scratch space. Two halves,
1287/// reading two different blobs, because qualifying and disqualifying have
1288/// opposite safe directions.
1289///
1290/// **Disqualify — read everything.** Any absolute path *anywhere* in the action
1291/// that is not scratch space vetoes the rule, whether or not it arrived under a
1292/// key [`PATH_PARAM_KEYS`] recognizes. This is what keeps a write touching both
1293/// `/tmp/in` and `/etc/hosts` out of the `Reversible` bucket, and it is
1294/// deliberately not restricted to recognized keys: an unrecognized key holding
1295/// the dangerous path (`dst`, `where`, `loc`) would otherwise be *invisible*,
1296/// turning a missed key from an over-ask into a silent under-classification.
1297///
1298/// **Qualify — read only the declared targets.** At least one absolute path
1299/// must appear among [`path_parameter_haystack`]'s output. A `/tmp` path in a
1300/// file's `contents` says nothing about where that file lands, so it may veto
1301/// but never vouch.
1302///
1303/// Relative paths are invisible to both halves by construction — the classifier
1304/// cannot see the working directory they would resolve against — so they
1305/// neither qualify an action nor disqualify it, and a tool called with only
1306/// relative paths falls through to the conservative default.
1307fn sandbox_confined_paths(target_paths: &str, whole_action: &str) -> bool {
1308 if absolute_path_tokens(whole_action).any(|t| !is_scratch_path(t)) {
1309 return false;
1310 }
1311 absolute_path_tokens(target_paths).next().is_some()
1312}
1313
1314/// Statement shapes that prove a *mutation* whatever the tool is called.
1315///
1316/// Used only as a **veto** on the retrieval short-circuit (step 2), never to
1317/// assign a contract. That asymmetry is what lets this set be generous where
1318/// [`COMPENSABLE_PARAM_PHRASES`] must be stingy: an over-match here costs a
1319/// fall-through to the conservative ladder (an over-ask, visible and locally
1320/// fixable), while an over-match there silently downgrades a permanent effect.
1321///
1322/// The bare SQL verbs are deliberately absent — "update the docs" and "create a
1323/// summary" are ordinary English. `UPDATE … SET` is matched as a pair by
1324/// [`mutating_parameter_evidence`] for the same reason.
1325const MUTATING_PARAM_PHRASES: &[&str] = &[
1326 // SQL DML / DDL.
1327 "insert into",
1328 "delete from",
1329 "drop table",
1330 "drop column",
1331 "drop database",
1332 "drop index",
1333 "drop view",
1334 "drop constraint",
1335 "alter table",
1336 "alter column",
1337 "create table",
1338 "create index",
1339 "create database",
1340 "truncate table",
1341 "replace into",
1342 "merge into",
1343 "grant ",
1344 "revoke ",
1345 // Shell-shaped mutation a retrieval-sounding wrapper might carry.
1346 "-delete",
1347 "-exec rm",
1348 "--force",
1349 "--overwrite",
1350 "--prune",
1351 "rm -",
1352 "mv ",
1353 "chmod ",
1354 "chown ",
1355];
1356
1357/// Does the flattened parameter text prove the action mutates something?
1358///
1359/// The veto behind step 2 of [`classify_reversibility`]. A retrieval verb in a
1360/// *tool name* is weak evidence — `execute_query`, `db_query`, and `find` all
1361/// read as retrieval and all routinely carry a mutation in their arguments —
1362/// and it must not be allowed to override evidence in the parameters
1363/// themselves.
1364fn mutating_parameter_evidence(hay: &str) -> bool {
1365 if MUTATING_PARAM_PHRASES.iter().any(|p| hay.contains(p)) {
1366 return true;
1367 }
1368 // `UPDATE <table> SET <col> = …`: neither half is command-shaped alone.
1369 hay.contains("update ") && hay.contains(" set ")
1370}
1371
1372/// Classify an [`Action`] into its rollback contract — **can this be undone?**
1373///
1374/// This is the second of CAR's two authorization-adjacent axes, and it is
1375/// computed without consulting [`RiskClassifier`], [`PermissionTier`], or
1376/// [`FULL_ACCESS_COMMAND_KEYWORDS`] at any point. Deriving it from the tier would
1377/// reproduce exactly the conflation it exists to undo: a database `INSERT` and
1378/// a sent email are indistinguishable on the authority ladder and have nothing
1379/// in common on this one. The two are meant to be read side by side —
1380/// `car-engine`'s `TierPermissionHandler` puts `required_tier` and
1381/// `reversibility` on the same `PermissionDecision` event — and they disagree
1382/// in **both** directions:
1383///
1384/// | Action | Authority | Rollback contract |
1385/// |---|---|---|
1386/// | `state_read` | `ReadOnly` | `Reversible` |
1387/// | write a scratch file under `/tmp` | `SandboxEdit` | `Reversible` |
1388/// | `read_secret` | **`FullAccess`** | **`Reversible`** — a read leaves nothing to undo |
1389/// | `db_insert` | **`SandboxEdit`** | **`Compensable`** — delete the row |
1390/// | `git_push` | `FullAccess` | `Compensable` — force-push the prior ref |
1391/// | `send_email`, `charge_card` | `FullAccess` | `Irreversible` |
1392///
1393/// # It says nothing about disclosure
1394///
1395/// `read_secret` is `Reversible`, and that is correct rather than a bug: this
1396/// axis is about *effects*, and reading has none to reverse. An exfiltrating
1397/// read is maximally dangerous and maximally reversible at the same time,
1398/// which is precisely why this must never be used as a stand-in for
1399/// [`PermissionTier`]. Two axes, two questions, two answers.
1400///
1401/// # How it decides
1402///
1403/// Non-tool actions are settled by their type: a [`ActionType::StateRead`] or
1404/// [`ActionType::Assertion`] leaves nothing behind, and a
1405/// [`ActionType::StateWrite`]'s whole footprint is the KV store, which
1406/// `car_state`'s snapshot/rollback restores — that *is* the definition of
1407/// `Reversible`. A [`ActionType::ToolCall`] runs a ladder, most severe first:
1408///
1409/// 1. A command-shaped destructive phrase anywhere in the flattened
1410/// parameters → `Irreversible`. First, because it is the only signal that
1411/// fires on a generic tool (`shell` with `rm -rf` in an argv array).
1412/// 2. A retrieval verb with **no** mutating segment of either kind, no
1413/// compensable phrase, and no mutation spelled out in the parameters
1414/// ([`mutating_parameter_evidence`]) → `Reversible`.
1415/// 3. An [`IRREVERSIBLE_NAME_SEGMENTS`] hit → `Irreversible`.
1416/// 4. A [`COMPENSABLE_NAME_SEGMENTS`] or [`COMPENSABLE_PARAM_PHRASES`] hit →
1417/// `Compensable`.
1418/// 5. A filesystem-shaped tool whose every absolute **target** path
1419/// ([`path_parameter_haystack`]) is scratch space → `Reversible`.
1420/// 6. Otherwise `Irreversible` — the same conservative default, for the same
1421/// reason, as `Action::reversibility`'s `#[serde(default)]`.
1422///
1423/// The direction that is safe differs per set, and the sets are curated
1424/// accordingly: over-matching `Irreversible` over-asks (visible, locally
1425/// fixable), while over-matching `Compensable` or `Reversible` quietly
1426/// understates a permanent effect. So the irreversible set is generous and the
1427/// other two are stingy.
1428///
1429/// # What this is not
1430///
1431/// A heuristic over identifiers and strings — **not** a decision procedure,
1432/// and no more authoritative than the tier classifier above. It reads a tool
1433/// name and a flattened parameter blob. It does not know what a tool actually
1434/// does, cannot consult a tool's schema or documentation, and cannot resolve a
1435/// relative path. Known misses, recorded so nobody has to rediscover them:
1436///
1437/// - **An unrecognized tool comes back `Irreversible`.** Against a corpus of
1438/// tools nobody has taught it about, that is most of them. The fix is to
1439/// annotate `Action::reversibility` explicitly or extend a set here — not to
1440/// loosen the default.
1441/// - **A retrieval verb over a mutating noun over-classifies.**
1442/// `list_deployments` is a read, but `deployments` is in the compensable
1443/// set, so step 2 declines and step 4 answers `Compensable`.
1444/// - **Money is matched by its object.** `get_payment` therefore comes back
1445/// `Irreversible`. Every *mutation* on a payment rail is permanent and the
1446/// classifier does not try to be clever about which calls are reads.
1447/// - **`git push` is `Compensable` unconditionally**, where the honest hedge
1448/// is "if nobody has pulled yet". A force-push that destroys commits no one
1449/// else holds a copy of is irreversible in fact, and nothing here can tell.
1450/// - **A relative path defeats the sandbox rule** (step 5), so
1451/// `write_file("notes.txt")` falls through to `Irreversible` even when the
1452/// cwd is a sandbox.
1453/// - **A target named by an unrecognized key defeats it too.** Step 5 reads
1454/// only [`PATH_PARAM_KEYS`]; a tool that calls its destination `where` or
1455/// `loc` contributes no paths and falls through. Over-asking, deliberately.
1456/// - **Only OS scratch roots count as discardable.** A directory named
1457/// `sandbox` or `scratch` does not qualify — see [`SANDBOX_PATH_PREFIXES`].
1458/// - **Prose can trip a phrase.** A parameter containing the literal text
1459/// `insert into` reads as a SQL insert.
1460/// - **The retrieval veto is generous.** [`MUTATING_PARAM_PHRASES`] fires on
1461/// `--force` and `mv `, so `search_files` over a corpus that quotes either
1462/// loses the step-2 short-circuit and falls to the conservative default.
1463///
1464/// And it is not enforced. Nothing in the runtime consults this value to
1465/// decide whether an action runs; it is classified and audited. Deferring the
1466/// materialization of an irreversible effect needs a checkpoint coupled to the
1467/// filesystem, which CAR does not have. See the
1468/// [`car_ir::reversibility`] module docs.
1469pub fn classify_reversibility(action: &Action) -> Reversibility {
1470 classify_reversibility_with_haystack(action, None)
1471}
1472
1473/// [`classify_reversibility`] reusing a haystack the caller already built.
1474///
1475/// [`action_haystack`] flattens every nested string in `Action::parameters`
1476/// into a `String` and then allocates a lowercase copy of it. A caller that
1477/// classifies *both* axes for the same action — `TierPermissionHandler` on the
1478/// execution path, `permission.evaluate` over a whole batch — otherwise pays
1479/// that twice for payloads that can be a multi-megabyte `contents` or diff.
1480///
1481/// The haystack must be [`action_haystack`]'s output for this same action;
1482/// passing anything else silently changes the classification.
1483pub fn classify_reversibility_with_haystack(
1484 action: &Action,
1485 haystack: Option<&str>,
1486) -> Reversibility {
1487 match action.action_type {
1488 // A read and an assertion observe; there is no effect to reverse.
1489 ActionType::StateRead | ActionType::Assertion => Reversibility::Reversible,
1490 // The whole footprint is the KV store, and `car_state` can restore it.
1491 ActionType::StateWrite => Reversibility::Reversible,
1492 ActionType::ToolCall => classify_tool_call_reversibility(action, haystack),
1493 }
1494}
1495
1496/// The keyword ladder for [`ActionType::ToolCall`] — the only action type
1497/// whose effects are opaque to the IR. Documented step by step on
1498/// [`classify_reversibility`].
1499fn classify_tool_call_reversibility(action: &Action, haystack: Option<&str>) -> Reversibility {
1500 let owned;
1501 let hay: &str = match haystack {
1502 Some(h) => h,
1503 None => {
1504 owned = action_haystack(action);
1505 &owned
1506 }
1507 };
1508 let segments = action
1509 .tool
1510 .as_deref()
1511 .map(name_segments)
1512 .unwrap_or_default();
1513 let has = |set: &[&str]| segments.iter().any(|s| set.contains(&s.as_str()));
1514 let compensable_phrase = COMPENSABLE_PARAM_PHRASES.iter().any(|p| hay.contains(p));
1515
1516 // 1. Destruction spelled out in the parameters, whatever the tool is called.
1517 if IRREVERSIBLE_PARAM_PHRASES.iter().any(|p| hay.contains(p)) {
1518 return Reversibility::Irreversible;
1519 }
1520
1521 // 2. Unambiguous retrieval: a read verb and no mutation signal anywhere.
1522 // Guarded on BOTH mutating name sets so `get_and_delete` and
1523 // `list_and_push` fall through to the ladder below rather than being
1524 // waved past it — and on the PARAMETERS too, because a retrieval verb in
1525 // the tool name is weak evidence that the arguments can contradict.
1526 // `execute_query` carrying `UPDATE accounts SET balance = 0` is a read
1527 // by name and a permanent overwrite in fact; this is the only step that
1528 // reaches `Reversible` without positive evidence of confinement, so it
1529 // is the one that must not be reachable on the strength of a name alone.
1530 if has(RETRIEVAL_NAME_SEGMENTS)
1531 && !has(IRREVERSIBLE_NAME_SEGMENTS)
1532 && !has(COMPENSABLE_NAME_SEGMENTS)
1533 && !compensable_phrase
1534 && !mutating_parameter_evidence(hay)
1535 {
1536 return Reversibility::Reversible;
1537 }
1538
1539 // 3. Permanent effects, before compensable ones: severity wins ties, so a
1540 // `create_payment` is answered by `payment`, not by `create`.
1541 if has(IRREVERSIBLE_NAME_SEGMENTS) {
1542 return Reversibility::Irreversible;
1543 }
1544
1545 // 4. Effects that left the scope but have a mechanical inverse.
1546 if has(COMPENSABLE_NAME_SEGMENTS) || compensable_phrase {
1547 return Reversibility::Compensable;
1548 }
1549
1550 // 5. A filesystem write confined to scratch space: discard the tree and
1551 // the effect is gone. Two independent gates, because either alone lets
1552 // an unrelated scratch path vouch for a real write. The tool must be
1553 // filesystem-shaped (so `http_post` with a `body_file` is not talked
1554 // down), AND the paths considered are only those the action declares as
1555 // its *target* (so `/tmp` inside a file's `contents` cannot vouch for a
1556 // write whose `path` is `config.yaml`).
1557 if has(FILESYSTEM_NAME_SEGMENTS)
1558 && sandbox_confined_paths(&path_parameter_haystack(action), hay)
1559 {
1560 return Reversibility::Reversible;
1561 }
1562
1563 // 6. Nothing recognized. Assume the worst — see the fn docs.
1564 Reversibility::Irreversible
1565}
1566
1567/// Recursively canonicalize a JSON value so logically-identical values
1568/// produce byte-identical serializations: every object's keys are sorted
1569/// at *every* depth. Without this, two semantically identical params that
1570/// differ only in nested-object key order would fingerprint differently —
1571/// which would let a model evade a standing **rejection** simply by
1572/// permuting nested keys (neo review).
1573fn canonical_json(v: &serde_json::Value) -> serde_json::Value {
1574 use serde_json::Value;
1575 match v {
1576 Value::Object(map) => {
1577 let sorted: BTreeMap<&String, Value> = map
1578 .iter()
1579 .map(|(k, val)| (k, canonical_json(val)))
1580 .collect();
1581 Value::Object(
1582 sorted
1583 .into_iter()
1584 .map(|(k, val)| (k.clone(), val))
1585 .collect(),
1586 )
1587 }
1588 Value::Array(items) => Value::Array(items.iter().map(canonical_json).collect()),
1589 other => other.clone(),
1590 }
1591}
1592
1593/// The serde (snake_case) name of an action type — a **stable** wire
1594/// representation, unlike `Debug`, which carries no stability contract and
1595/// must never anchor a persisted key.
1596fn action_type_tag(t: &ActionType) -> &'static str {
1597 match t {
1598 ActionType::ToolCall => "tool_call",
1599 ActionType::StateWrite => "state_write",
1600 ActionType::StateRead => "state_read",
1601 ActionType::Assertion => "assertion",
1602 }
1603}
1604
1605/// A stable fingerprint identifying "this kind of operation" so a human
1606/// approval/rejection can be matched against future occurrences — across
1607/// processes and builds. Built from the action type (stable serde tag),
1608/// tool, and **recursively** canonicalized parameters — *not* the action
1609/// id, which is not stable across proposals.
1610pub fn action_fingerprint(action: &Action) -> String {
1611 let canonical: BTreeMap<&String, serde_json::Value> = action
1612 .parameters
1613 .iter()
1614 .map(|(k, v)| (k, canonical_json(v)))
1615 .collect();
1616 let params = serde_json::to_string(&canonical).unwrap_or_default();
1617 let tool = action.tool.as_deref().unwrap_or("-");
1618 format!(
1619 "{}|{}|{}",
1620 action_type_tag(&action.action_type),
1621 tool,
1622 params
1623 )
1624}
1625
1626/// Whether a recorded human decision approved or rejected an operation.
1627#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1628#[serde(rename_all = "snake_case")]
1629pub enum ApprovalDecision {
1630 Approved,
1631 Rejected,
1632}
1633
1634/// A durable record of a human-in-the-loop decision — the auditable state
1635/// transition §5.2.5 calls for: what was proposed, who decided, why, and
1636/// against what evidence.
1637#[derive(Debug, Clone, Serialize, Deserialize)]
1638pub struct ApprovalRecord {
1639 /// Stable [`action_fingerprint`] this decision applies to.
1640 pub fingerprint: String,
1641 /// Tier the action required when the decision was made.
1642 pub required_tier: PermissionTier,
1643 pub decision: ApprovalDecision,
1644 /// Identity of the human (or delegated authority) who decided.
1645 pub reviewer: String,
1646 /// Why the decision was made — the recorded rationale.
1647 pub reason: String,
1648 /// Evidence shown at decision time (diff summary, risk surface, etc.).
1649 #[serde(default)]
1650 pub evidence: Option<String>,
1651 /// RFC3339 timestamp of the decision.
1652 pub decided_at: String,
1653}
1654
1655/// An append-only ledger of human-in-the-loop decisions, keyed by
1656/// fingerprint (last decision wins). Optionally persisted as JSONL so the
1657/// approval state survives restarts — HITL decisions are *durable* harness
1658/// state, not transient prompts.
1659///
1660/// Concurrency: a single writer per journal file is assumed. The
1661/// stateless FFI opens a fresh ledger per call, so a product that drives
1662/// approvals from multiple processes against one journal must serialize
1663/// those writes itself (e.g. route them through the daemon). Reads
1664/// tolerate the writer appending concurrently; a torn final line is
1665/// skipped on load and counted in [`ApprovalLedger::skipped_on_load`].
1666#[derive(Debug, Default)]
1667pub struct ApprovalLedger {
1668 records: HashMap<String, ApprovalRecord>,
1669 journal: Option<PathBuf>,
1670 /// Append through [`car_secrets::open_private_append`] rather than a plain
1671 /// open. Set only by [`ApprovalLedger::with_private_journal`].
1672 private_journal: bool,
1673 /// Count of unparseable lines skipped during the last load — nonzero
1674 /// signals journal corruption or a concurrent torn write, so callers
1675 /// can surface it rather than silently trusting a partial ledger.
1676 skipped_on_load: usize,
1677}
1678
1679impl ApprovalLedger {
1680 pub fn new() -> Self {
1681 Self::default()
1682 }
1683
1684 /// Create a ledger backed by a JSONL journal at `path`, loading any
1685 /// existing decisions. Each line is one [`ApprovalRecord`]; the last
1686 /// line for a fingerprint wins, so a later rejection overrides an
1687 /// earlier approval.
1688 pub fn with_journal(path: impl Into<PathBuf>) -> std::io::Result<Self> {
1689 let path = path.into();
1690 let contents = if path.exists() {
1691 Some(std::fs::read_to_string(&path)?)
1692 } else {
1693 None
1694 };
1695 Ok(Self::loaded(path, contents, false))
1696 }
1697
1698 /// A journal-backed ledger holding the decisions in `contents`.
1699 fn loaded(path: PathBuf, contents: Option<String>, private_journal: bool) -> Self {
1700 let mut ledger = Self {
1701 records: HashMap::new(),
1702 journal: Some(path),
1703 private_journal,
1704 skipped_on_load: 0,
1705 };
1706 if let Some(contents) = contents {
1707 // Lines are read in file (append) order, so a later decision
1708 // for a fingerprint overwrites an earlier one — last wins.
1709 for line in contents.lines() {
1710 let line = line.trim();
1711 if line.is_empty() {
1712 continue;
1713 }
1714 match serde_json::from_str::<ApprovalRecord>(line) {
1715 Ok(rec) => {
1716 ledger.records.insert(rec.fingerprint.clone(), rec);
1717 }
1718 Err(_) => ledger.skipped_on_load += 1,
1719 }
1720 }
1721 }
1722 ledger
1723 }
1724
1725 /// [`with_journal`](Self::with_journal) for a journal CAR keeps in its own
1726 /// state root, such as the daemon's `~/.car/approvals.jsonl`. The load and
1727 /// every [`record`](Self::record) go through the owner-private opens: the
1728 /// file is created `0600`, an existing looser file is tightened in place,
1729 /// and a symlink at the path or in a parent directory, or a parent the
1730 /// user does not own, is an error rather than followed (#3403). A missing
1731 /// journal loads empty. Refusing at load matters as much as at write: a
1732 /// symlink to a file holding an `Approved` row would otherwise authorize
1733 /// an operation nobody approved. Records are written exactly as [`with_journal`](Self::with_journal)
1734 /// writes them, so fingerprints keep matching.
1735 ///
1736 /// Caller-chosen journal paths (the FFI `ledger_path`) stay on
1737 /// [`with_journal`](Self::with_journal): the private open also makes the
1738 /// parent directory `0700` and refuses one the user does not own, which a
1739 /// journal placed directly in `/tmp` would fail.
1740 pub fn with_private_journal(path: impl Into<PathBuf>) -> std::io::Result<Self> {
1741 use std::io::Read;
1742 let path = path.into();
1743 let contents = match car_secrets::open_private_read(&path) {
1744 Ok(mut file) => {
1745 let mut contents = String::new();
1746 file.read_to_string(&mut contents)?;
1747 Some(contents)
1748 }
1749 Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1750 Err(error) => return Err(error),
1751 };
1752 Ok(Self::loaded(path, contents, true))
1753 }
1754
1755 /// Number of unparseable lines skipped during the load. Nonzero means
1756 /// the journal is corrupt or was torn by a concurrent writer.
1757 pub fn skipped_on_load(&self) -> usize {
1758 self.skipped_on_load
1759 }
1760
1761 /// Record a decision, persisting it to the journal when configured.
1762 /// Returns the stored record.
1763 ///
1764 /// A journal write failure is an **error, not best-effort** (review A7):
1765 /// callers emit `ApprovalRecorded` audit events on the strength of this
1766 /// call, so a decision that only landed in memory must not be reported
1767 /// as durable. On `Err` the decision is NOT stored (memory and journal
1768 /// stay consistent — both lack it) and the caller must surface the
1769 /// failure. An in-memory ledger (no journal) cannot fail.
1770 pub fn record(&mut self, record: ApprovalRecord) -> std::io::Result<&ApprovalRecord> {
1771 if let Some(path) = &self.journal {
1772 // Durable append: write the whole line, flush Rust's writer, ask
1773 // the OS to commit file data + metadata, then close the handle
1774 // before publishing the decision in memory. `flush` alone is not
1775 // a durability boundary and left a fresh Windows process able to
1776 // observe the pre-approval journal (Parslee-ai/car#1227).
1777 let mut f = if self.private_journal {
1778 car_secrets::open_private_append(path)?
1779 } else {
1780 std::fs::OpenOptions::new()
1781 .create(true)
1782 .append(true)
1783 .open(path)?
1784 };
1785 let mut line = serde_json::to_string(&record)
1786 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1787 line.push('\n');
1788 f.write_all(line.as_bytes())?;
1789 f.flush()?;
1790 f.sync_all()?;
1791 drop(f);
1792 }
1793 // Last-wins: overwrite any prior decision for this fingerprint.
1794 use std::collections::hash_map::Entry;
1795 Ok(match self.records.entry(record.fingerprint.clone()) {
1796 Entry::Occupied(mut o) => {
1797 o.insert(record);
1798 o.into_mut()
1799 }
1800 Entry::Vacant(v) => v.insert(record),
1801 })
1802 }
1803
1804 /// The current decision for a fingerprint, if any.
1805 pub fn lookup(&self, fingerprint: &str) -> Option<&ApprovalRecord> {
1806 self.records.get(fingerprint)
1807 }
1808
1809 pub fn all(&self) -> impl Iterator<Item = &ApprovalRecord> {
1810 self.records.values()
1811 }
1812
1813 /// Build and [`record`](Self::record) a decision against an explicit
1814 /// fingerprint — the ledger-level twin of
1815 /// [`PermissionGate::record_for_fingerprint`], for callers that share
1816 /// ONE ledger across many gates/sessions (e.g. the daemon's shared
1817 /// approval substrate) and so must not route the write through any
1818 /// single session's gate. Errs when the journal write fails (the
1819 /// decision is then NOT recorded — see [`Self::record`]).
1820 #[allow(clippy::too_many_arguments)]
1821 pub fn record_decision(
1822 &mut self,
1823 fingerprint: &str,
1824 required_tier: PermissionTier,
1825 decision: ApprovalDecision,
1826 reviewer: &str,
1827 reason: &str,
1828 evidence: Option<String>,
1829 ) -> std::io::Result<ApprovalRecord> {
1830 let record = ApprovalRecord {
1831 fingerprint: fingerprint.to_string(),
1832 required_tier,
1833 decision,
1834 reviewer: reviewer.to_string(),
1835 reason: reason.to_string(),
1836 evidence,
1837 decided_at: chrono::Utc::now().to_rfc3339(),
1838 };
1839 self.record(record.clone())?;
1840 Ok(record)
1841 }
1842}
1843
1844/// A set of hazards partitioned against the durable [`ApprovalLedger`]: those
1845/// that must not run, and those still awaiting a human decision (paired with the
1846/// fingerprint the decision is keyed by).
1847///
1848/// The shared shape behind the per-domain HITL bridges
1849/// ([`crate::flow_gate::enforce_flow`], [`crate::intent_gate::enforce_intent`]):
1850/// each maps this into its own domain-specific enforcement struct + reason.
1851#[derive(Debug, Clone)]
1852pub struct LedgerPartition<V> {
1853 /// Hazards that must not run: the hard blocks passed in, plus anything a
1854 /// human previously **rejected**.
1855 pub blocked: Vec<V>,
1856 /// Novel hazards awaiting a human decision, each paired with its ledger
1857 /// fingerprint.
1858 pub pending: Vec<(String, V)>,
1859}
1860
1861/// Resolve a `require_approval` hazard set against the durable ledger, the one
1862/// place that fixes the HITL semantics: a hazard a human previously **approved**
1863/// is dropped (let through), one they **rejected** joins `hard_blocked`, and an
1864/// **unseen** one becomes pending. `fingerprint` maps a hazard to its stable
1865/// ledger key (the per-domain part callers keep specialized).
1866pub fn partition_by_ledger<V, F>(
1867 hard_blocked: Vec<V>,
1868 needs_approval: &[V],
1869 fingerprint: F,
1870 ledger: &ApprovalLedger,
1871) -> LedgerPartition<V>
1872where
1873 V: Clone,
1874 F: Fn(&V) -> String,
1875{
1876 let mut blocked = hard_blocked;
1877 let mut pending = Vec::new();
1878 for v in needs_approval {
1879 let fp = fingerprint(v);
1880 match ledger.lookup(&fp).map(|r| r.decision) {
1881 Some(ApprovalDecision::Approved) => { /* a human OK'd this hazard before */ }
1882 Some(ApprovalDecision::Rejected) => blocked.push(v.clone()),
1883 None => pending.push((fp, v.clone())),
1884 }
1885 }
1886 LedgerPartition { blocked, pending }
1887}
1888
1889/// The outcome of evaluating an action against the gate.
1890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1891#[serde(tag = "decision", rename_all = "snake_case")]
1892pub enum GateDecision {
1893 /// May proceed autonomously. `required` and `granted` explain why.
1894 Allow {
1895 required: PermissionTier,
1896 granted: PermissionTier,
1897 },
1898 /// Autonomy is suspended pending a human decision (survey §5.2.5).
1899 /// The caller surfaces the request; a later [`PermissionGate::approve`]
1900 /// or [`PermissionGate::reject`] resolves it.
1901 NeedsApproval {
1902 required: PermissionTier,
1903 granted: PermissionTier,
1904 fingerprint: String,
1905 reason: String,
1906 },
1907 /// Refused — a human already rejected this operation.
1908 Deny {
1909 required: PermissionTier,
1910 fingerprint: String,
1911 reason: String,
1912 },
1913}
1914
1915impl GateDecision {
1916 pub fn is_allow(&self) -> bool {
1917 matches!(self, GateDecision::Allow { .. })
1918 }
1919}
1920
1921/// The permission gate: a session's standing authority plus the classifier
1922/// and the durable approval ledger. Pure and synchronous so it can be
1923/// embedded anywhere; the engine wraps it for its async pipeline.
1924#[derive(Debug)]
1925pub struct PermissionGate {
1926 /// Standing authority granted to this session.
1927 granted: PermissionTier,
1928 /// Actions at or above this tier *always* require a human decision,
1929 /// even when the granted tier would cover them — the "mandatory HITL
1930 /// gate" for consequential actions (§5.2.5). Default: `FullAccess`.
1931 require_approval_at: PermissionTier,
1932 classifier: RiskClassifier,
1933 ledger: ApprovalLedger,
1934}
1935
1936impl PermissionGate {
1937 /// A gate with the given standing tier, default classifier, mandatory
1938 /// approval at `FullAccess`, and an in-memory ledger.
1939 pub fn new(granted: PermissionTier) -> Self {
1940 Self {
1941 granted,
1942 require_approval_at: PermissionTier::FullAccess,
1943 classifier: RiskClassifier::new(),
1944 ledger: ApprovalLedger::new(),
1945 }
1946 }
1947
1948 pub fn with_classifier(mut self, classifier: RiskClassifier) -> Self {
1949 self.classifier = classifier;
1950 self
1951 }
1952
1953 pub fn with_ledger(mut self, ledger: ApprovalLedger) -> Self {
1954 self.ledger = ledger;
1955 self
1956 }
1957
1958 /// Override the tier at and above which approval is mandatory.
1959 pub fn with_mandatory_approval_at(mut self, tier: PermissionTier) -> Self {
1960 self.require_approval_at = tier;
1961 self
1962 }
1963
1964 pub fn granted_tier(&self) -> PermissionTier {
1965 self.granted
1966 }
1967
1968 pub fn set_granted_tier(&mut self, tier: PermissionTier) {
1969 self.granted = tier;
1970 }
1971
1972 pub fn classifier(&self) -> &RiskClassifier {
1973 &self.classifier
1974 }
1975
1976 pub fn ledger(&self) -> &ApprovalLedger {
1977 &self.ledger
1978 }
1979
1980 /// Evaluate an action. Precedence:
1981 /// 1. A prior **rejection** denies (a human said no).
1982 /// 2. A prior **approval** allows (a human elevated this operation).
1983 /// 3. Actions at/above the mandatory-approval tier need approval.
1984 /// 4. Otherwise the granted tier must cover the required tier.
1985 /// 5. Exceeding standing authority escalates to a human, not a hard
1986 /// deny — autonomy is suspended, not the task abandoned.
1987 pub fn evaluate(&self, action: &Action) -> GateDecision {
1988 self.evaluate_with_granted(action, self.granted, &self.ledger)
1989 }
1990
1991 /// [`Self::evaluate`], but consulting an **external** ledger instead of the
1992 /// gate's own. For deployments that share ONE approval ledger across many
1993 /// per-session gates (per-session *tier*, shared *approvals*): the daemon
1994 /// keeps a single journal-backed ledger on its server state so an approval
1995 /// recorded on one connection is visible to every other and survives
1996 /// restart — a per-connection in-memory ledger would strand the approver's
1997 /// decision where the runner never reads it.
1998 pub fn evaluate_against(&self, action: &Action, ledger: &ApprovalLedger) -> GateDecision {
1999 self.evaluate_with_granted(action, self.granted, ledger)
2000 }
2001
2002 /// Evaluate an action as if the session's standing authority were capped at
2003 /// `ceiling` — the join between skill-trust governance (arXiv 2602.12430) and
2004 /// the action-level gate. The effective authority is `min(granted, ceiling)`,
2005 /// so an action driven by a skill whose deployment ceiling is `read_only`
2006 /// cannot perform a `sandbox_edit` operation even in a `full_access` session;
2007 /// it escalates to a human instead. A `None` ceiling is identical to
2008 /// [`Self::evaluate`].
2009 ///
2010 /// The caller supplies the ceiling — it is the persisted
2011 /// `SkillMeta::deployment_tier` of the skill that drove the action, which the
2012 /// caller already knows (it retrieved the skill). The gate does not invent
2013 /// action→skill provenance; it honours the ceiling it is handed.
2014 pub fn evaluate_with_ceiling(
2015 &self,
2016 action: &Action,
2017 ceiling: Option<PermissionTier>,
2018 ) -> GateDecision {
2019 self.evaluate_with_ceiling_against(action, ceiling, &self.ledger)
2020 }
2021
2022 /// [`Self::evaluate_with_ceiling`] against an external ledger — see
2023 /// [`Self::evaluate_against`] for when a shared ledger is the substrate.
2024 pub fn evaluate_with_ceiling_against(
2025 &self,
2026 action: &Action,
2027 ceiling: Option<PermissionTier>,
2028 ledger: &ApprovalLedger,
2029 ) -> GateDecision {
2030 let effective = match ceiling {
2031 Some(c) => self.granted.min(c),
2032 None => self.granted,
2033 };
2034 self.evaluate_with_granted(action, effective, ledger)
2035 }
2036
2037 /// Both authorization axes for one action, flattening its parameters
2038 /// **once**.
2039 ///
2040 /// Every caller that records a `PermissionDecision` needs both: the gate's
2041 /// verdict (*may this run?*) and the rollback contract (*could it be taken
2042 /// back?*). Computed separately they each call [`action_text`], which walks
2043 /// every nested string in `Action::parameters` into a `String` and then
2044 /// allocates a lowercase copy — twice, per action, on the execution path,
2045 /// for payloads that can be a whole document or diff. Parslee-ai/car#856.
2046 ///
2047 /// `ceiling` caps standing authority the way
2048 /// [`Self::evaluate_with_ceiling`] does; `ledger` selects an external
2049 /// approval ledger the way [`Self::evaluate_against`] does, or `None` to
2050 /// use the gate's own.
2051 ///
2052 /// The two fields answer independent questions and neither is derived from
2053 /// the other — see [`classify_reversibility`]. They travel together here
2054 /// only because they read the same text.
2055 pub fn evaluate_axes(
2056 &self,
2057 action: &Action,
2058 ceiling: Option<PermissionTier>,
2059 ledger: Option<&ApprovalLedger>,
2060 ) -> ActionAxes {
2061 let hay = action_text(action);
2062 let granted = match ceiling {
2063 Some(c) => self.granted.min(c),
2064 None => self.granted,
2065 };
2066 ActionAxes {
2067 decision: self.evaluate_with_granted_haystack(
2068 action,
2069 granted,
2070 ledger.unwrap_or(&self.ledger),
2071 Some(&hay),
2072 ),
2073 reversibility: classify_reversibility_with_haystack(action, Some(&hay)),
2074 }
2075 }
2076
2077 /// Core evaluation against an explicit effective `granted` tier (so a skill
2078 /// ceiling can cap standing authority without mutating the gate). Precedence:
2079 /// 1. A prior **rejection** denies (a human said no).
2080 /// 2. A prior **approval** allows (a human elevated this operation).
2081 /// 3. Actions at/above the mandatory-approval tier need approval.
2082 /// 4. Otherwise the effective granted tier must cover the required tier.
2083 /// 5. Exceeding standing authority escalates to a human, not a hard
2084 /// deny — autonomy is suspended, not the task abandoned.
2085 fn evaluate_with_granted(
2086 &self,
2087 action: &Action,
2088 granted: PermissionTier,
2089 ledger: &ApprovalLedger,
2090 ) -> GateDecision {
2091 self.evaluate_with_granted_haystack(action, granted, ledger, None)
2092 }
2093
2094 /// [`Self::evaluate_with_granted`] reusing a haystack the caller already
2095 /// built. Private: the public door is [`Self::evaluate_axes`], which owns
2096 /// the "build it once" decision rather than leaving it to every caller.
2097 fn evaluate_with_granted_haystack(
2098 &self,
2099 action: &Action,
2100 granted: PermissionTier,
2101 ledger: &ApprovalLedger,
2102 haystack: Option<&str>,
2103 ) -> GateDecision {
2104 let required = self.classifier.classify_with_haystack(action, haystack);
2105 let fingerprint = action_fingerprint(action);
2106
2107 if let Some(rec) = ledger.lookup(&fingerprint) {
2108 match rec.decision {
2109 ApprovalDecision::Rejected => {
2110 return GateDecision::Deny {
2111 required,
2112 fingerprint,
2113 reason: format!("previously rejected by {} ({})", rec.reviewer, rec.reason),
2114 };
2115 }
2116 // An approval is scoped to the risk that was actually
2117 // reviewed. If the operation has since been reclassified
2118 // *upward* (a new keyword, a new custom rule), the stale
2119 // approval must not bypass the mandatory gate — re-prompt
2120 // instead (neo review: tier-blind stale approvals).
2121 ApprovalDecision::Approved if required <= rec.required_tier => {
2122 return GateDecision::Allow { required, granted };
2123 }
2124 ApprovalDecision::Approved => {
2125 return GateDecision::NeedsApproval {
2126 required,
2127 granted,
2128 fingerprint,
2129 reason: format!(
2130 "operation reclassified {} → {} since it was approved; re-approval required",
2131 rec.required_tier.as_str(),
2132 required.as_str()
2133 ),
2134 };
2135 }
2136 }
2137 }
2138
2139 if required >= self.require_approval_at {
2140 return GateDecision::NeedsApproval {
2141 required,
2142 granted,
2143 fingerprint,
2144 reason: format!(
2145 "{} actions require human approval before execution",
2146 required.as_str()
2147 ),
2148 };
2149 }
2150
2151 if granted.covers(required) {
2152 GateDecision::Allow { required, granted }
2153 } else {
2154 GateDecision::NeedsApproval {
2155 required,
2156 granted,
2157 fingerprint,
2158 reason: format!(
2159 "action requires {} but session is granted only {}",
2160 required.as_str(),
2161 granted.as_str()
2162 ),
2163 }
2164 }
2165 }
2166
2167 /// Record a human approval for the operation `action` represents.
2168 /// Errs when the ledger journal write fails (the decision is then NOT
2169 /// recorded — see [`ApprovalLedger::record`]).
2170 pub fn approve(
2171 &mut self,
2172 action: &Action,
2173 reviewer: &str,
2174 reason: &str,
2175 evidence: Option<String>,
2176 ) -> std::io::Result<ApprovalRecord> {
2177 self.record_decision(
2178 action,
2179 ApprovalDecision::Approved,
2180 reviewer,
2181 reason,
2182 evidence,
2183 )
2184 }
2185
2186 /// Record a human rejection for the operation `action` represents.
2187 /// Errs when the ledger journal write fails (the decision is then NOT
2188 /// recorded — see [`ApprovalLedger::record`]).
2189 pub fn reject(
2190 &mut self,
2191 action: &Action,
2192 reviewer: &str,
2193 reason: &str,
2194 evidence: Option<String>,
2195 ) -> std::io::Result<ApprovalRecord> {
2196 self.record_decision(
2197 action,
2198 ApprovalDecision::Rejected,
2199 reviewer,
2200 reason,
2201 evidence,
2202 )
2203 }
2204
2205 /// Record a decision against an explicit fingerprint (when the caller
2206 /// holds the fingerprint from a prior `NeedsApproval`, not the action).
2207 /// Errs when the ledger journal write fails (the decision is then NOT
2208 /// recorded — see [`ApprovalLedger::record`]).
2209 pub fn record_for_fingerprint(
2210 &mut self,
2211 fingerprint: &str,
2212 required_tier: PermissionTier,
2213 decision: ApprovalDecision,
2214 reviewer: &str,
2215 reason: &str,
2216 evidence: Option<String>,
2217 ) -> std::io::Result<ApprovalRecord> {
2218 self.ledger.record_decision(
2219 fingerprint,
2220 required_tier,
2221 decision,
2222 reviewer,
2223 reason,
2224 evidence,
2225 )
2226 }
2227
2228 /// Build (but do NOT store) the [`ApprovalRecord`] for a decision on
2229 /// `action` — the gate classifies the action and derives its fingerprint;
2230 /// the caller records the result on whichever ledger is the substrate
2231 /// (its own via [`ApprovalLedger::record`], or a shared daemon ledger).
2232 /// This is what lets a per-session gate keep its tier/classifier while the
2233 /// approval store is shared.
2234 pub fn decision_record(
2235 &self,
2236 action: &Action,
2237 decision: ApprovalDecision,
2238 reviewer: &str,
2239 reason: &str,
2240 evidence: Option<String>,
2241 ) -> ApprovalRecord {
2242 ApprovalRecord {
2243 fingerprint: action_fingerprint(action),
2244 required_tier: self.classifier.classify(action),
2245 decision,
2246 reviewer: reviewer.to_string(),
2247 reason: reason.to_string(),
2248 evidence,
2249 decided_at: chrono::Utc::now().to_rfc3339(),
2250 }
2251 }
2252
2253 fn record_decision(
2254 &mut self,
2255 action: &Action,
2256 decision: ApprovalDecision,
2257 reviewer: &str,
2258 reason: &str,
2259 evidence: Option<String>,
2260 ) -> std::io::Result<ApprovalRecord> {
2261 let record = self.decision_record(action, decision, reviewer, reason, evidence);
2262 self.ledger.record(record.clone())?;
2263 Ok(record)
2264 }
2265}
2266
2267#[cfg(test)]
2268mod tests {
2269 use super::*;
2270 use car_ir::ActionType;
2271 use serde_json::json;
2272 use std::collections::HashMap as Map;
2273
2274 fn action(
2275 action_type: ActionType,
2276 tool: Option<&str>,
2277 params: Map<String, serde_json::Value>,
2278 ) -> Action {
2279 {
2280 let mut a = Action::new(action_type);
2281 a.id = "a1".to_string();
2282 a.tool = tool.map(str::to_string);
2283 a.parameters = params;
2284 a
2285 }
2286 }
2287
2288 fn tool_call(tool: &str) -> Action {
2289 action(ActionType::ToolCall, Some(tool), Map::new())
2290 }
2291
2292 fn tool_call_with(tool: &str, params: &[(&str, serde_json::Value)]) -> Action {
2293 let mut p = Map::new();
2294 for (k, v) in params {
2295 p.insert((*k).to_string(), v.clone());
2296 }
2297 action(ActionType::ToolCall, Some(tool), p)
2298 }
2299
2300 #[test]
2301 fn tool_name_full_access_matches_irreversible_capabilities() {
2302 // Irreversible / externally-consequential tool names are full-access —
2303 // including the space-bearing-keyword cases a substring scan would MISS:
2304 // `git_reset`/`git_clean` have no bare `reset`/`clean` in the free-text
2305 // list, only `"git reset"`/`"git clean"` (with spaces), which can't occur
2306 // in an identifier. Segment matching catches them.
2307 for name in [
2308 "deploy",
2309 "git_push",
2310 "gitPush",
2311 "delete_file",
2312 "kubectl_apply",
2313 "git_reset",
2314 "git_clean",
2315 "send_email",
2316 "sudo_run",
2317 "rm_rf_dir",
2318 "drop_table",
2319 "upload_artifact",
2320 ] {
2321 assert!(
2322 tool_name_is_full_access(name),
2323 "{name} should classify as full-access"
2324 );
2325 }
2326 }
2327
2328 #[test]
2329 fn tool_name_full_access_does_not_false_positive_on_benign_names() {
2330 // The collision cases that a raw substring scan of the free-text keyword
2331 // list gets WRONG. `count_tokens`/`tokenize` (vs `token`), `http_get`
2332 // (vs `http`), `apply_template` (vs `apply`), `request_id` (vs
2333 // `request`), `prefetch_cache` (vs `fetch`) must all be benign — segment
2334 // matching + a curated name set is what buys this.
2335 for name in [
2336 "read_file",
2337 "grep",
2338 "search",
2339 "summarize",
2340 "classify",
2341 "count_tokens",
2342 "tokenize",
2343 "token_usage",
2344 "http_get",
2345 "https_health",
2346 "apply_template",
2347 "request_id",
2348 "parse_request",
2349 "prefetch_cache",
2350 "transfer_learning",
2351 "format_date",
2352 "network_topology",
2353 "dropdown_open",
2354 ] {
2355 assert!(
2356 !tool_name_is_full_access(name),
2357 "{name} should NOT classify as full-access (false positive)"
2358 );
2359 }
2360 }
2361
2362 #[test]
2363 fn name_segments_splits_snake_and_camel() {
2364 assert_eq!(name_segments("git_push"), vec!["git", "push"]);
2365 assert_eq!(name_segments("gitPush"), vec!["git", "push"]);
2366 assert_eq!(name_segments("git-push"), vec!["git", "push"]);
2367 assert_eq!(name_segments("count_tokens"), vec!["count", "tokens"]);
2368 // `tokens` is its own segment and never equals the `token` danger word.
2369 assert!(!name_segments("count_tokens").iter().any(|s| s == "token"));
2370 }
2371
2372 #[test]
2373 fn any_tool_full_access_scans_the_palette() {
2374 // Works over &[&str], owned String collections, and is false on empty.
2375 assert!(any_tool_full_access(["read_file", "grep", "deploy"]));
2376 assert!(!any_tool_full_access(["read_file", "grep", "summarize"]));
2377 assert!(!any_tool_full_access(std::iter::empty::<&str>()));
2378 let owned: Vec<String> = vec!["read_file".into(), "git_push".into()];
2379 assert!(any_tool_full_access(&owned));
2380 }
2381
2382 #[test]
2383 fn tier_ordering() {
2384 assert!(PermissionTier::FullAccess.covers(PermissionTier::ReadOnly));
2385 assert!(PermissionTier::SandboxEdit.covers(PermissionTier::SandboxEdit));
2386 assert!(!PermissionTier::ReadOnly.covers(PermissionTier::SandboxEdit));
2387 }
2388
2389 #[test]
2390 fn classifier_baseline_by_type() {
2391 let c = RiskClassifier::new();
2392 assert_eq!(
2393 c.classify(&action(ActionType::StateRead, None, Map::new())),
2394 PermissionTier::ReadOnly
2395 );
2396 assert_eq!(
2397 c.classify(&action(ActionType::Assertion, None, Map::new())),
2398 PermissionTier::ReadOnly
2399 );
2400 assert_eq!(
2401 c.classify(&action(ActionType::StateWrite, None, Map::new())),
2402 PermissionTier::SandboxEdit
2403 );
2404 assert_eq!(c.classify(&tool_call("echo")), PermissionTier::SandboxEdit);
2405 }
2406
2407 #[test]
2408 fn classifier_escalates_on_keyword_in_tool_name() {
2409 let c = RiskClassifier::new();
2410 assert_eq!(
2411 c.classify(&tool_call("deploy_service")),
2412 PermissionTier::FullAccess
2413 );
2414 }
2415
2416 #[test]
2417 fn classifier_tool_name_signal_agrees_with_tool_name_is_full_access() {
2418 // Parslee-ai/car#917. These two answered the same question — "does this
2419 // tool name name a consequential capability?" — and disagreed on every
2420 // name below, because `classify` substring-scanned the command keyword
2421 // list over the name while `tool_name_is_full_access` segment-matched a
2422 // curated set. `http_get` was full-access to one and benign to the
2423 // other. Now `classify` calls it, so drift is not expressible.
2424 let c = RiskClassifier::new();
2425 for name in [
2426 "http_get",
2427 "https_health",
2428 "format_date",
2429 "count_tokens",
2430 "apply_template",
2431 "request_id",
2432 "prefetch_cache",
2433 "transfer_learning",
2434 "network_topology",
2435 "deploy_service",
2436 "send_email",
2437 "git_push",
2438 "read_secret",
2439 ] {
2440 let expected = if tool_name_is_full_access(name) {
2441 PermissionTier::FullAccess
2442 } else {
2443 PermissionTier::SandboxEdit
2444 };
2445 assert_eq!(c.classify(&tool_call(name)), expected, "tool name {name}");
2446 }
2447 }
2448
2449 #[test]
2450 fn classifier_escalates_on_keyword_in_params() {
2451 let mut params = Map::new();
2452 params.insert("cmd".to_string(), serde_json::json!("rm -rf /tmp/x"));
2453 let a = action(ActionType::ToolCall, Some("shell"), params);
2454 assert_eq!(
2455 RiskClassifier::new().classify(&a),
2456 PermissionTier::FullAccess
2457 );
2458 }
2459
2460 #[test]
2461 fn classifier_does_not_escalate_on_ordinary_text_in_ordinary_parameters() {
2462 // Parslee-ai/car#917: the four false positives the issue names, each of
2463 // which became a HARD REJECTION at Parslee-ai/car#915 — a `full_access`
2464 // classification is mandatory HITL regardless of granted tier, so on an
2465 // automated connection with no approver there is nobody to say yes.
2466 let c = RiskClassifier::new();
2467 let cases: Vec<(&str, Action)> = vec![
2468 (
2469 "a URL in a documented url parameter is not `curl` in a shell string",
2470 tool_call_with("http_get", &[("url", json!("https://example.com/a"))]),
2471 ),
2472 (
2473 "a search whose query text happens to say `release notes`",
2474 tool_call_with("search", &[("query", json!("release notes for 0.47"))]),
2475 ),
2476 (
2477 "a date formatter is not `mkfs --format`",
2478 tool_call_with(
2479 "format_date",
2480 &[("value", json!("2026-08-15")), ("fmt", json!("%Y-%m-%d"))],
2481 ),
2482 ),
2483 (
2484 "prose containing `send` in a body the tool merely stores",
2485 tool_call_with(
2486 "create_note",
2487 &[(
2488 "body",
2489 json!("Remember to send the report and apply the patch"),
2490 )],
2491 ),
2492 ),
2493 (
2494 "max_tokens is in nearly every LLM tool call and is not a credential",
2495 tool_call_with(
2496 "complete",
2497 &[("prompt", json!("hello")), ("max_tokens", json!(256))],
2498 ),
2499 ),
2500 (
2501 "a body that MENTIONS ~/.ssh is not an action against it — only \
2502 declared target paths carry the credential-path signal",
2503 tool_call_with(
2504 "write_file",
2505 &[
2506 ("path", json!("/tmp/notes.md")),
2507 ("content", json!("Back up ~/.ssh/id_rsa before you start")),
2508 ],
2509 ),
2510 ),
2511 ];
2512 for (why, act) in cases {
2513 assert_eq!(
2514 c.classify(&act),
2515 PermissionTier::SandboxEdit,
2516 "must not escalate — {why}"
2517 );
2518 }
2519 }
2520
2521 #[test]
2522 fn classifier_still_escalates_on_the_signals_that_matter() {
2523 // The other half of #917: narrowing must not let any of these through.
2524 // Each one reaches a DIFFERENT signal, so this pins all four.
2525 let c = RiskClassifier::new();
2526 let cases: Vec<(&str, Action)> =
2527 vec![
2528 (
2529 "tool name — segment match",
2530 tool_call_with("send_email", &[("to", json!("a@example.com"))]),
2531 ),
2532 (
2533 "command line — broad keyword over command/args only",
2534 tool_call_with(
2535 "shell",
2536 &[("command", json!("curl")), ("args", json!(["-X", "POST"]))],
2537 ),
2538 ),
2539 (
2540 "command line — `aws ` needs the trailing space and the argv join",
2541 tool_call_with(
2542 "shell",
2543 &[("command", json!("aws")), ("args", json!(["s3", "rb"]))],
2544 ),
2545 ),
2546 (
2547 "parameter key — the value is opaque, the key is not",
2548 tool_call_with(
2549 "call_api",
2550 &[("url", json!("https://x/y")), ("api_key", json!("sk-abc"))],
2551 ),
2552 ),
2553 (
2554 "parameter key — nested under an options object",
2555 tool_call_with("call_api", &[("opts", json!({"auth": {"token": "t"}}))]),
2556 ),
2557 (
2558 "parameter key — a segment match on a compound key",
2559 tool_call_with("connect", &[("db_password", json!("hunter2"))]),
2560 ),
2561 (
2562 "free text — destructive SQL under a `query` key the command surface cannot see",
2563 tool_call_with("run_sql", &[("query", json!("DROP TABLE users"))]),
2564 ),
2565 (
2566 "free text — `delete from` likewise",
2567 tool_call_with("run_sql", &[("query", json!("DELETE FROM orders WHERE 1=1"))]),
2568 ),
2569 (
2570 "free text — a destructive script passed as file contents",
2571 tool_call_with(
2572 "write_file",
2573 &[("path", json!("/tmp/x.sh")), ("contents", json!("rm -rf /"))],
2574 ),
2575 ),
2576 (
2577 "free text — history rewriting in a body parameter",
2578 tool_call_with("run_script", &[("body", json!("git reset --hard HEAD~5"))]),
2579 ),
2580 (
2581 "target path — reading someone's private key is a top-grant action",
2582 tool_call_with("read_file", &[("path", json!("~/.ssh/id_rsa"))]),
2583 ),
2584 (
2585 "target path — writing over a cloud credential file",
2586 tool_call_with(
2587 "write_file",
2588 &[("path", json!("/home/u/.aws/credentials")), ("content", json!("x"))],
2589 ),
2590 ),
2591 ];
2592 for (why, act) in cases {
2593 assert_eq!(
2594 c.classify(&act),
2595 PermissionTier::FullAccess,
2596 "must escalate — {why}"
2597 );
2598 }
2599 }
2600
2601 #[test]
2602 fn command_shaped_actions_classify_exactly_as_they_did_before_917() {
2603 // The safety argument for #917 is that the broad keyword list was
2604 // re-aimed, not pruned: on a command line every entry still fires. If
2605 // this ever fails, the narrowing has reached the command surface and
2606 // the change is no longer behaviour-preserving where it claimed to be.
2607 let c = RiskClassifier::new();
2608 for kw in FULL_ACCESS_COMMAND_KEYWORDS {
2609 let act = tool_call_with("shell", &[("command", json!(format!("x{kw}y")))]);
2610 assert_eq!(
2611 c.classify(&act),
2612 PermissionTier::FullAccess,
2613 "command keyword {kw:?} must still escalate on a command line"
2614 );
2615 }
2616 }
2617
2618 #[test]
2619 fn credential_param_keys_are_boundary_matched_not_substring_matched() {
2620 // `max_tokens` is the case that makes this worth testing: a raw
2621 // substring scan for `token` flags every LLM tool call in existence.
2622 for key in [
2623 "token",
2624 "api_key",
2625 "apiKey",
2626 "access-token",
2627 "client_secret",
2628 "db_password",
2629 "svc_credentials",
2630 "passphrase",
2631 ] {
2632 let act = tool_call_with("t", &[(key, json!("v"))]);
2633 assert!(
2634 hits_credential_param_key(&act),
2635 "{key} should read as credential material"
2636 );
2637 }
2638 for key in [
2639 "max_tokens",
2640 "tokens",
2641 "token_count",
2642 "key",
2643 "keys",
2644 "sort_key",
2645 "keyword",
2646 "secretariat_id",
2647 ] {
2648 let act = tool_call_with("t", &[(key, json!("v"))]);
2649 assert!(
2650 !hits_credential_param_key(&act),
2651 "{key} should NOT read as credential material (false positive)"
2652 );
2653 }
2654 }
2655
2656 #[test]
2657 fn command_line_is_none_when_the_action_is_not_command_shaped() {
2658 assert_eq!(command_line(&tool_call("search")), None);
2659 assert_eq!(
2660 command_line(&tool_call_with("search", &[("query", json!("git push"))])),
2661 None,
2662 "a non-command parameter must not become the command line"
2663 );
2664 assert_eq!(
2665 command_line(&tool_call_with(
2666 "shell",
2667 &[("command", json!("git")), ("args", json!(["push"]))]
2668 )),
2669 Some("git push ".to_string()),
2670 );
2671 }
2672
2673 #[test]
2674 fn custom_rule_only_raises() {
2675 let mut c = RiskClassifier::new();
2676 c.add_rule("flag_search", PermissionTier::FullAccess, |a| {
2677 a.tool.as_deref() == Some("search")
2678 });
2679 assert_eq!(c.classify(&tool_call("search")), PermissionTier::FullAccess);
2680 // A read action a rule matches at a lower tier stays at the
2681 // higher baseline — rules never lower.
2682 let mut c2 = RiskClassifier::new();
2683 c2.add_rule("noop", PermissionTier::ReadOnly, |_| true);
2684 assert_eq!(
2685 c2.classify(&action(ActionType::StateWrite, None, Map::new())),
2686 PermissionTier::SandboxEdit
2687 );
2688 }
2689
2690 #[test]
2691 fn gate_allows_within_granted_tier() {
2692 let gate = PermissionGate::new(PermissionTier::SandboxEdit);
2693 let d = gate.evaluate(&action(ActionType::StateWrite, None, Map::new()));
2694 assert!(d.is_allow(), "{d:?}");
2695 }
2696
2697 #[test]
2698 fn gate_escalates_above_granted_tier() {
2699 let gate = PermissionGate::new(PermissionTier::ReadOnly);
2700 let d = gate.evaluate(&action(ActionType::StateWrite, None, Map::new()));
2701 assert!(matches!(d, GateDecision::NeedsApproval { .. }), "{d:?}");
2702 }
2703
2704 #[test]
2705 fn skill_ceiling_caps_below_granted_tier() {
2706 // The session can perform sandbox edits...
2707 let gate = PermissionGate::new(PermissionTier::SandboxEdit);
2708 let act = action(ActionType::StateWrite, None, Map::new()); // classifies SandboxEdit
2709 assert!(gate.evaluate(&act).is_allow());
2710 // ...but an action driven by a skill capped at read_only cannot: the
2711 // same action escalates to a human instead of running.
2712 let capped = gate.evaluate_with_ceiling(&act, Some(PermissionTier::ReadOnly));
2713 assert!(
2714 matches!(capped, GateDecision::NeedsApproval { .. }),
2715 "{capped:?}"
2716 );
2717 // No ceiling is identical to evaluate.
2718 assert!(gate.evaluate_with_ceiling(&act, None).is_allow());
2719 }
2720
2721 #[test]
2722 fn skill_ceiling_at_or_above_granted_is_noop() {
2723 let gate = PermissionGate::new(PermissionTier::SandboxEdit);
2724 let act = action(ActionType::StateWrite, None, Map::new());
2725 // A ceiling at or above the granted tier leaves the outcome unchanged
2726 // (effective authority is min(granted, ceiling)).
2727 assert!(gate
2728 .evaluate_with_ceiling(&act, Some(PermissionTier::SandboxEdit))
2729 .is_allow());
2730 assert!(gate
2731 .evaluate_with_ceiling(&act, Some(PermissionTier::FullAccess))
2732 .is_allow());
2733 }
2734
2735 #[test]
2736 fn gate_full_access_always_needs_approval_even_when_granted() {
2737 // Mandatory HITL: a FullAccess action is gated even for a
2738 // FullAccess session.
2739 let gate = PermissionGate::new(PermissionTier::FullAccess);
2740 let d = gate.evaluate(&tool_call("deploy"));
2741 assert!(matches!(d, GateDecision::NeedsApproval { .. }), "{d:?}");
2742 }
2743
2744 #[test]
2745 fn approval_makes_future_evaluation_allow() {
2746 let mut gate = PermissionGate::new(PermissionTier::ReadOnly);
2747 let a = tool_call("deploy");
2748 assert!(matches!(
2749 gate.evaluate(&a),
2750 GateDecision::NeedsApproval { .. }
2751 ));
2752 gate.approve(&a, "matt", "reviewed the deploy plan", None)
2753 .unwrap();
2754 assert!(gate.evaluate(&a).is_allow());
2755 }
2756
2757 #[test]
2758 fn rejection_denies_future_evaluation() {
2759 let mut gate = PermissionGate::new(PermissionTier::FullAccess);
2760 let a = tool_call("transfer_funds");
2761 gate.reject(&a, "matt", "not authorized", None).unwrap();
2762 assert!(matches!(gate.evaluate(&a), GateDecision::Deny { .. }));
2763 }
2764
2765 #[test]
2766 fn shared_ledger_approval_is_visible_across_gates() {
2767 // The daemon substrate: per-session gates (tier state), ONE shared
2768 // ledger. An approval recorded through gate A's classification must
2769 // flip gate B's evaluation — with per-gate in-memory ledgers the
2770 // approver's decision would be stranded where the runner never reads
2771 // it (kernel review C1).
2772 let gate_a = PermissionGate::new(PermissionTier::ReadOnly);
2773 let gate_b = PermissionGate::new(PermissionTier::ReadOnly);
2774 let mut shared = ApprovalLedger::new();
2775 let a = tool_call("deploy");
2776
2777 assert!(matches!(
2778 gate_b.evaluate_against(&a, &shared),
2779 GateDecision::NeedsApproval { .. }
2780 ));
2781 // Approver (gate A's session) builds the record; the SHARED ledger
2782 // stores it.
2783 let rec = gate_a.decision_record(&a, ApprovalDecision::Approved, "matt", "ok", None);
2784 shared.record(rec).unwrap();
2785 // Runner (gate B's session) sees it.
2786 assert!(gate_b.evaluate_against(&a, &shared).is_allow());
2787 // The gates' own (empty) ledgers still gate — nothing leaked into them.
2788 assert!(matches!(
2789 gate_b.evaluate(&a),
2790 GateDecision::NeedsApproval { .. }
2791 ));
2792 }
2793
2794 #[test]
2795 fn ledger_record_decision_round_trips_fingerprint() {
2796 let mut ledger = ApprovalLedger::new();
2797 let rec = ledger
2798 .record_decision(
2799 "harness:retry:abcd1234",
2800 PermissionTier::SandboxEdit,
2801 ApprovalDecision::Approved,
2802 "conn:1",
2803 "reviewed",
2804 None,
2805 )
2806 .unwrap();
2807 assert_eq!(rec.fingerprint, "harness:retry:abcd1234");
2808 assert_eq!(
2809 ledger.lookup("harness:retry:abcd1234").map(|r| r.decision),
2810 Some(ApprovalDecision::Approved)
2811 );
2812 }
2813
2814 #[test]
2815 fn classifier_escalates_on_argv_array_command() {
2816 // The dangerous command is split across an argv array, so the raw
2817 // JSON never contains the literal "git push" substring — the
2818 // recursive haystack must still catch it.
2819 let mut params = Map::new();
2820 params.insert(
2821 "args".to_string(),
2822 serde_json::json!(["git", "push", "--force", "origin", "main"]),
2823 );
2824 let a = action(ActionType::ToolCall, Some("shell"), params);
2825 assert_eq!(
2826 RiskClassifier::new().classify(&a),
2827 PermissionTier::FullAccess
2828 );
2829 }
2830
2831 #[test]
2832 fn fingerprint_canonicalizes_nested_object_key_order() {
2833 // Two semantically identical actions whose params differ only in
2834 // NESTED object key order must share a fingerprint — otherwise a
2835 // standing rejection is evadable by permuting nested keys.
2836 let mk = |json: serde_json::Value| {
2837 let mut p = Map::new();
2838 p.insert("opts".to_string(), json);
2839 action(ActionType::ToolCall, Some("t"), p)
2840 };
2841 let a = mk(serde_json::json!({"a": 1, "b": {"x": 1, "y": 2}}));
2842 let b = mk(serde_json::json!({"b": {"y": 2, "x": 1}, "a": 1}));
2843 assert_eq!(action_fingerprint(&a), action_fingerprint(&b));
2844 }
2845
2846 #[test]
2847 fn fingerprint_uses_stable_type_tag_not_debug() {
2848 // The fingerprint must carry the stable serde tag, never the
2849 // Debug spelling.
2850 let fp = action_fingerprint(&tool_call("x"));
2851 assert!(fp.starts_with("tool_call|"), "got {fp}");
2852 assert!(!fp.contains("ToolCall"));
2853 }
2854
2855 #[test]
2856 fn approval_does_not_survive_upward_reclassification() {
2857 // An operation approved at SandboxEdit must NOT remain allowed
2858 // after a classifier change pushes it to FullAccess.
2859 let a = tool_call("safe_tool");
2860 // Record an approval at SandboxEdit (the tier when reviewed).
2861 let mut classifier = RiskClassifier::new();
2862 let mut gate = PermissionGate::new(PermissionTier::SandboxEdit).with_classifier(classifier);
2863 gate.approve(&a, "matt", "looked fine", None).unwrap();
2864 assert!(gate.evaluate(&a).is_allow());
2865
2866 // Now a stricter classifier reclassifies the same op as FullAccess.
2867 classifier = RiskClassifier::new();
2868 classifier.add_rule("now_dangerous", PermissionTier::FullAccess, |act| {
2869 act.tool.as_deref() == Some("safe_tool")
2870 });
2871 let gate = gate.with_classifier(classifier);
2872 assert!(
2873 matches!(gate.evaluate(&a), GateDecision::NeedsApproval { .. }),
2874 "stale low-tier approval must not bypass the FullAccess gate"
2875 );
2876 }
2877
2878 #[test]
2879 fn fingerprint_is_param_sensitive_and_stable() {
2880 let a1 = {
2881 let mut p = Map::new();
2882 p.insert("x".to_string(), serde_json::json!(1));
2883 p.insert("y".to_string(), serde_json::json!(2));
2884 action(ActionType::ToolCall, Some("t"), p)
2885 };
2886 let a2 = {
2887 // same params, inserted in a different order → same fingerprint
2888 let mut p = Map::new();
2889 p.insert("y".to_string(), serde_json::json!(2));
2890 p.insert("x".to_string(), serde_json::json!(1));
2891 action(ActionType::ToolCall, Some("t"), p)
2892 };
2893 assert_eq!(action_fingerprint(&a1), action_fingerprint(&a2));
2894 // different params → different fingerprint
2895 let a3 = {
2896 let mut p = Map::new();
2897 p.insert("x".to_string(), serde_json::json!(99));
2898 action(ActionType::ToolCall, Some("t"), p)
2899 };
2900 assert_ne!(action_fingerprint(&a1), action_fingerprint(&a3));
2901 }
2902
2903 #[test]
2904 fn ledger_journal_round_trips() {
2905 let dir = std::env::temp_dir();
2906 let path = dir.join(format!("car-approvals-test-{}.jsonl", std::process::id()));
2907 let _ = std::fs::remove_file(&path);
2908
2909 let a = tool_call("deploy");
2910 {
2911 let ledger = ApprovalLedger::with_journal(&path).unwrap();
2912 let mut gate = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger);
2913 gate.approve(&a, "matt", "ok", Some("diff: +1 -0".to_string()))
2914 .unwrap();
2915 }
2916 // A fresh ledger loading the same journal sees the decision.
2917 let ledger2 = ApprovalLedger::with_journal(&path).unwrap();
2918 let gate2 = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger2);
2919 assert!(gate2.evaluate(&a).is_allow());
2920
2921 let _ = std::fs::remove_file(&path);
2922 }
2923
2924 #[cfg(unix)]
2925 fn approval(fingerprint: &str) -> ApprovalRecord {
2926 ApprovalRecord {
2927 fingerprint: fingerprint.to_string(),
2928 required_tier: PermissionTier::FullAccess,
2929 decision: ApprovalDecision::Approved,
2930 reviewer: "matt".into(),
2931 reason: "ok".into(),
2932 evidence: None,
2933 decided_at: chrono::Utc::now().to_rfc3339(),
2934 }
2935 }
2936
2937 #[cfg(unix)]
2938 fn mode(path: &std::path::Path) -> u32 {
2939 use std::os::unix::fs::PermissionsExt;
2940 std::fs::metadata(path).unwrap().permissions().mode() & 0o777
2941 }
2942
2943 /// #3403: a private journal is created owner-only, and a reload finds the
2944 /// approval under the action's own fingerprint, so private storage does
2945 /// not change what an approval matches.
2946 #[cfg(unix)]
2947 #[test]
2948 fn private_journal_is_owner_only_and_keeps_the_fingerprint() {
2949 let dir = tempfile::tempdir().unwrap();
2950 let path = dir.path().join("approvals.jsonl");
2951 let a = tool_call_with("deploy", &[("env", json!("prod"))]);
2952 let fp = action_fingerprint(&a);
2953
2954 let ledger = ApprovalLedger::with_private_journal(&path).unwrap();
2955 let mut gate = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger);
2956 gate.approve(&a, "matt", "ok", None).unwrap();
2957
2958 assert_eq!(mode(&path), 0o600, "approval journal must be owner-only");
2959 let reloaded = ApprovalLedger::with_private_journal(&path).unwrap();
2960 assert_eq!(
2961 reloaded.lookup(&fp).map(|r| r.decision),
2962 Some(ApprovalDecision::Approved)
2963 );
2964 let gate2 = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(reloaded);
2965 assert!(gate2.evaluate(&a).is_allow());
2966 }
2967
2968 /// #3403: a symlink placed at a private journal's path after load is
2969 /// refused on write, and the refused decision is not stored in memory.
2970 #[cfg(unix)]
2971 #[test]
2972 fn private_journal_symlink_is_refused() {
2973 let dir = tempfile::tempdir().unwrap();
2974 let target = dir.path().join("elsewhere.jsonl");
2975 std::fs::write(&target, "").unwrap();
2976 let path = dir.path().join("approvals.jsonl");
2977 // Loaded before the symlink appears, so this is the write-side check.
2978 let mut ledger = ApprovalLedger::with_private_journal(&path).unwrap();
2979 std::os::unix::fs::symlink(&target, &path).unwrap();
2980 assert!(ledger.record(approval("fp-symlink")).is_err());
2981 assert!(ledger.lookup("fp-symlink").is_none());
2982 assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
2983 }
2984
2985 /// #3403: a symlink at a private journal's path is refused at load, so an
2986 /// `Approved` row in the file it points at is never honoured.
2987 #[cfg(unix)]
2988 #[test]
2989 fn private_journal_symlink_to_a_forged_approval_is_refused_at_load() {
2990 let dir = tempfile::tempdir().unwrap();
2991 let a = tool_call("deploy");
2992 let forged = dir.path().join("forged.jsonl");
2993 let row = serde_json::to_string(&approval(&action_fingerprint(&a))).unwrap();
2994 std::fs::write(&forged, format!("{row}\n")).unwrap();
2995 let path = dir.path().join("approvals.jsonl");
2996 std::os::unix::fs::symlink(&forged, &path).unwrap();
2997
2998 assert!(ApprovalLedger::with_private_journal(&path).is_err());
2999 // The caller-path constructor still reads through it, as before.
3000 let caller = ApprovalLedger::with_journal(&path).unwrap();
3001 assert!(caller.lookup(&action_fingerprint(&a)).is_some());
3002 }
3003
3004 /// #3403: loading a private journal tightens a looser file in place.
3005 #[cfg(unix)]
3006 #[test]
3007 fn private_journal_load_tightens_a_loose_file() {
3008 use std::os::unix::fs::PermissionsExt;
3009 let dir = tempfile::tempdir().unwrap();
3010 let path = dir.path().join("approvals.jsonl");
3011 let row = serde_json::to_string(&approval("fp-kept")).unwrap();
3012 std::fs::write(&path, format!("{row}\n")).unwrap();
3013 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3014
3015 let ledger = ApprovalLedger::with_private_journal(&path).unwrap();
3016 assert_eq!(mode(&path), 0o600);
3017 assert!(ledger.lookup("fp-kept").is_some());
3018 }
3019
3020 /// #3403: a journal an older version left with a looser mode is tightened
3021 /// in place on the next record; its decisions are kept.
3022 #[cfg(unix)]
3023 #[test]
3024 fn loose_private_journal_is_tightened_in_place() {
3025 use std::os::unix::fs::PermissionsExt;
3026 let dir = tempfile::tempdir().unwrap();
3027 let path = dir.path().join("approvals.jsonl");
3028 let old = serde_json::to_string(&approval("fp-old")).unwrap();
3029 std::fs::write(&path, format!("{old}\n")).unwrap();
3030 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3031
3032 let mut ledger = ApprovalLedger::with_private_journal(&path).unwrap();
3033 ledger.record(approval("fp-new")).unwrap();
3034
3035 assert_eq!(mode(&path), 0o600);
3036 let contents = std::fs::read_to_string(&path).unwrap();
3037 assert!(contents.starts_with(&old), "existing decisions are kept");
3038 let reloaded = ApprovalLedger::with_private_journal(&path).unwrap();
3039 assert!(reloaded.lookup("fp-old").is_some());
3040 assert!(reloaded.lookup("fp-new").is_some());
3041 }
3042
3043 /// A caller-chosen journal (the FFI `ledger_path`) keeps working through a
3044 /// symlinked directory, which the private open would refuse.
3045 #[cfg(unix)]
3046 #[test]
3047 fn caller_journal_still_records_through_a_symlinked_directory() {
3048 let dir = tempfile::tempdir().unwrap();
3049 let real = dir.path().join("real");
3050 std::fs::create_dir(&real).unwrap();
3051 let link = dir.path().join("link");
3052 std::os::unix::fs::symlink(&real, &link).unwrap();
3053
3054 let mut ledger = ApprovalLedger::with_journal(link.join("approvals.jsonl")).unwrap();
3055 ledger.record(approval("fp-caller")).unwrap();
3056 assert!(std::fs::read_to_string(real.join("approvals.jsonl"))
3057 .unwrap()
3058 .contains("fp-caller"));
3059 }
3060
3061 #[test]
3062 fn unwritable_journal_errors_and_stores_nothing() {
3063 // Review A7: a journal write failure must surface as an error, and
3064 // the decision must NOT land in memory either — otherwise callers
3065 // emit ApprovalRecorded audit events for a decision that isn't
3066 // durable. A directory as the journal path makes the append fail.
3067 let dir = std::env::temp_dir().join(format!("car-approvals-dir-{}", std::process::id()));
3068 let _ = std::fs::remove_dir_all(&dir);
3069 std::fs::create_dir_all(&dir).unwrap();
3070
3071 let mut ledger = ApprovalLedger {
3072 records: HashMap::new(),
3073 journal: Some(dir.clone()),
3074 private_journal: false,
3075 skipped_on_load: 0,
3076 };
3077 let a = tool_call("deploy");
3078 let fp = action_fingerprint(&a);
3079 let err = ledger.record(ApprovalRecord {
3080 fingerprint: fp.clone(),
3081 required_tier: PermissionTier::FullAccess,
3082 decision: ApprovalDecision::Approved,
3083 reviewer: "matt".into(),
3084 reason: "ok".into(),
3085 evidence: None,
3086 decided_at: chrono::Utc::now().to_rfc3339(),
3087 });
3088 assert!(err.is_err(), "journal write failure must surface");
3089 assert!(
3090 ledger.lookup(&fp).is_none(),
3091 "failed record must not be stored in memory"
3092 );
3093
3094 let _ = std::fs::remove_dir_all(&dir);
3095 }
3096
3097 // --- The second axis: reversibility -----------------------------------
3098
3099 /// The reason the axis exists. Every row asserts **both** classifiers,
3100 /// because a test that only checked reversibility would pass just as well
3101 /// if `classify_reversibility` were secretly derived from the tier — and
3102 /// that derivation is the bug this work removes.
3103 #[test]
3104 fn the_two_axes_disagree_in_both_directions() {
3105 let cases: Vec<(&str, Action, PermissionTier, Reversibility)> = vec![
3106 (
3107 "a state read observes and mutates nothing",
3108 action(ActionType::StateRead, None, Map::new()),
3109 PermissionTier::ReadOnly,
3110 Reversibility::Reversible,
3111 ),
3112 (
3113 "a scratch-file write is undone by discarding the sandbox",
3114 tool_call_with(
3115 "write_file",
3116 &[
3117 ("path", serde_json::json!("/tmp/car-sandbox/notes.txt")),
3118 ("contents", serde_json::json!("hello")),
3119 ],
3120 ),
3121 PermissionTier::SandboxEdit,
3122 Reversibility::Reversible,
3123 ),
3124 (
3125 "reading a credential takes the top grant and leaves nothing to undo",
3126 tool_call("read_secret"),
3127 PermissionTier::FullAccess,
3128 Reversibility::Reversible,
3129 ),
3130 (
3131 "a row insert reads as low-authority but needs a compensating delete",
3132 tool_call_with(
3133 "db_insert",
3134 &[
3135 ("table", serde_json::json!("orders")),
3136 ("row", serde_json::json!({"id": 7})),
3137 ],
3138 ),
3139 PermissionTier::SandboxEdit,
3140 Reversibility::Compensable,
3141 ),
3142 (
3143 "a push takes the top grant AND is recoverable — force-push the prior ref",
3144 tool_call("git_push"),
3145 PermissionTier::FullAccess,
3146 Reversibility::Compensable,
3147 ),
3148 (
3149 "a sent email takes the top grant and is permanent",
3150 tool_call_with(
3151 "send_email",
3152 &[
3153 ("to", serde_json::json!("ops@example.com")),
3154 ("subject", serde_json::json!("nightly status")),
3155 ],
3156 ),
3157 PermissionTier::FullAccess,
3158 Reversibility::Irreversible,
3159 ),
3160 (
3161 "a charged card takes the top grant and is permanent",
3162 tool_call_with("charge_card", &[("amount_cents", serde_json::json!(4200))]),
3163 PermissionTier::FullAccess,
3164 Reversibility::Irreversible,
3165 ),
3166 (
3167 "a write outside the sandbox reads as low-authority and is unrecoverable",
3168 tool_call_with(
3169 "write_file",
3170 &[
3171 ("path", serde_json::json!("/etc/hosts")),
3172 ("contents", serde_json::json!("127.0.0.1 x")),
3173 ],
3174 ),
3175 PermissionTier::SandboxEdit,
3176 Reversibility::Irreversible,
3177 ),
3178 ];
3179
3180 let classifier = RiskClassifier::new();
3181 for (why, act, tier, rev) in cases {
3182 assert_eq!(classifier.classify(&act), tier, "required tier — {why}");
3183 assert_eq!(classify_reversibility(&act), rev, "reversibility — {why}");
3184 }
3185 }
3186
3187 #[test]
3188 fn evaluate_axes_agrees_with_computing_the_axes_separately() {
3189 // #856 shares one flattened haystack between the two classifiers. That
3190 // is only safe if it changes no verdict, so pin both axes against the
3191 // independent paths across shapes that exercise the tier keywords, the
3192 // reversibility phrases, and the sandbox rule.
3193 let cases = vec![
3194 tool_call("send_email"),
3195 tool_call("read_secret"),
3196 tool_call_with(
3197 "shell",
3198 &[
3199 ("command", json!("git")),
3200 ("args", json!(["push", "--force", "origin", "main"])),
3201 ],
3202 ),
3203 tool_call_with("write_file", &[("path", json!("/tmp/scratch/a.txt"))]),
3204 tool_call_with("write_file", &[("path", json!("/etc/hosts"))]),
3205 tool_call_with("execute_query", &[("sql", json!("UPDATE t SET x = 1"))]),
3206 action(ActionType::StateRead, None, Map::new()),
3207 action(ActionType::StateWrite, None, Map::new()),
3208 ];
3209
3210 for granted in [
3211 PermissionTier::ReadOnly,
3212 PermissionTier::SandboxEdit,
3213 PermissionTier::FullAccess,
3214 ] {
3215 let gate = PermissionGate::new(granted);
3216 for a in &cases {
3217 let axes = gate.evaluate_axes(a, None, None);
3218 assert_eq!(
3219 axes.decision,
3220 gate.evaluate(a),
3221 "decision drifted for {:?} at {granted:?}",
3222 a.tool
3223 );
3224 assert_eq!(
3225 axes.reversibility,
3226 classify_reversibility(a),
3227 "reversibility drifted for {:?}",
3228 a.tool
3229 );
3230 }
3231 }
3232
3233 // The ceiling argument still caps standing authority.
3234 let gate = PermissionGate::new(PermissionTier::FullAccess);
3235 let a = tool_call("deploy_service");
3236 assert_eq!(
3237 gate.evaluate_axes(&a, Some(PermissionTier::ReadOnly), None)
3238 .decision,
3239 gate.evaluate_with_ceiling(&a, Some(PermissionTier::ReadOnly)),
3240 );
3241 }
3242
3243 #[test]
3244 fn neither_axis_is_a_function_of_the_other() {
3245 // The structural claim, stated twice: one tier spans all three rollback
3246 // contracts, and one rollback contract spans all three tiers. No
3247 // mapping in either direction could reproduce both.
3248 let c = RiskClassifier::new();
3249
3250 for (act, rev) in [
3251 (tool_call("read_secret"), Reversibility::Reversible),
3252 (tool_call("git_push"), Reversibility::Compensable),
3253 (tool_call("send_email"), Reversibility::Irreversible),
3254 ] {
3255 let name = act.tool.clone().unwrap_or_default();
3256 assert_eq!(c.classify(&act), PermissionTier::FullAccess, "tier {name}");
3257 assert_eq!(classify_reversibility(&act), rev, "reversibility {name}");
3258 }
3259
3260 for (act, tier) in [
3261 (
3262 action(ActionType::StateRead, None, Map::new()),
3263 PermissionTier::ReadOnly,
3264 ),
3265 (
3266 action(ActionType::StateWrite, None, Map::new()),
3267 PermissionTier::SandboxEdit,
3268 ),
3269 (tool_call("read_secret"), PermissionTier::FullAccess),
3270 ] {
3271 assert_eq!(
3272 classify_reversibility(&act),
3273 Reversibility::Reversible,
3274 "reversibility {:?}",
3275 act.tool
3276 );
3277 assert_eq!(c.classify(&act), tier, "tier {:?}", act.tool);
3278 }
3279 }
3280
3281 #[test]
3282 fn state_actions_are_settled_by_their_type() {
3283 for kind in [ActionType::StateRead, ActionType::Assertion] {
3284 assert_eq!(
3285 classify_reversibility(&action(kind, None, Map::new())),
3286 Reversibility::Reversible
3287 );
3288 }
3289 // A state write's whole footprint is the KV store, which snapshot /
3290 // rollback restores — so it stays `Reversible` even when its *value*
3291 // happens to read like something destructive. The keyword ladder only
3292 // runs for tool calls, whose effects are the opaque ones.
3293 let mut p = Map::new();
3294 p.insert("value".to_string(), serde_json::json!("rm -rf /"));
3295 assert_eq!(
3296 classify_reversibility(&action(ActionType::StateWrite, None, p)),
3297 Reversibility::Reversible
3298 );
3299 }
3300
3301 #[test]
3302 fn a_generic_shell_is_classified_from_its_argv() {
3303 // The signal that fires when the tool name says nothing at all. Neither
3304 // phrase exists in the raw JSON — the argv array only becomes matchable
3305 // text once it is flattened.
3306 let destructive = tool_call_with(
3307 "shell",
3308 &[("args", serde_json::json!(["rm", "-rf", "/var/data"]))],
3309 );
3310 let recoverable = tool_call_with(
3311 "shell",
3312 &[("args", serde_json::json!(["git", "push", "origin", "main"]))],
3313 );
3314 assert_eq!(
3315 classify_reversibility(&destructive),
3316 Reversibility::Irreversible
3317 );
3318 assert_eq!(
3319 classify_reversibility(&recoverable),
3320 Reversibility::Compensable
3321 );
3322
3323 // Both are FullAccess: the authority ladder cannot tell them apart,
3324 // which is exactly the gap the second axis fills.
3325 let c = RiskClassifier::new();
3326 assert_eq!(c.classify(&destructive), PermissionTier::FullAccess);
3327 assert_eq!(c.classify(&recoverable), PermissionTier::FullAccess);
3328 }
3329
3330 #[test]
3331 fn severity_wins_when_both_families_match() {
3332 // `create` is compensable and `payment` is irreversible; the ladder is
3333 // most-severe-first, so the permanent reading wins.
3334 assert_eq!(
3335 classify_reversibility(&tool_call("create_payment")),
3336 Reversibility::Irreversible
3337 );
3338 // A retrieval verb does not rescue a destructive one — step 2 requires
3339 // the name to carry NO mutating segment.
3340 assert_eq!(
3341 classify_reversibility(&tool_call("get_and_delete")),
3342 Reversibility::Irreversible
3343 );
3344 assert_eq!(
3345 classify_reversibility(&tool_call("list_and_push")),
3346 Reversibility::Compensable
3347 );
3348 }
3349
3350 #[test]
3351 fn sandbox_confinement_is_all_or_nothing() {
3352 // One absolute path outside scratch space disqualifies the action, even
3353 // though another is inside it.
3354 let mixed = tool_call_with(
3355 "write_file",
3356 &[
3357 ("src", serde_json::json!("/tmp/car-sandbox/in.txt")),
3358 ("dst", serde_json::json!("/etc/hosts")),
3359 ],
3360 );
3361 assert_eq!(classify_reversibility(&mixed), Reversibility::Irreversible);
3362
3363 // A relative path is invisible to the rule (no cwd to resolve it
3364 // against), so it neither qualifies nor disqualifies — and the action
3365 // falls through to the conservative default.
3366 let relative = tool_call_with("write_file", &[("path", serde_json::json!("notes.txt"))]);
3367 assert_eq!(
3368 classify_reversibility(&relative),
3369 Reversibility::Irreversible
3370 );
3371 }
3372
3373 #[test]
3374 fn the_sandbox_rule_does_not_leak_to_non_filesystem_tools() {
3375 // An incidental scratch path in a network tool's parameters must not
3376 // talk that tool down to `Reversible`.
3377 let a = tool_call_with(
3378 "http_post",
3379 &[("body_file", serde_json::json!("/tmp/payload.json"))],
3380 );
3381 assert_eq!(classify_reversibility(&a), Reversibility::Irreversible);
3382 }
3383
3384 // ---- Regressions for the four classifier defects found in review. ----
3385 // Each of these fails on the pre-fix classifier; the failure direction is
3386 // the one the module docs commit to never taking (a destructive action
3387 // reported as recoverable), except the first, which failed in BOTH.
3388
3389 #[test]
3390 fn classification_is_deterministic_across_parameter_orderings() {
3391 // `Action::parameters` is a std HashMap with per-process randomized
3392 // iteration. Flattening it directly made a phrase form between two
3393 // unrelated parameters on some draws and not others: a force-push came
3394 // back `compensable` ~285/500 and `irreversible` ~215/500 in one
3395 // process. Same action, same call, different audit row.
3396 //
3397 // Rebuilding the map many times exercises fresh orderings; the answer
3398 // must not move.
3399 let mk = || {
3400 tool_call_with(
3401 "shell",
3402 &[
3403 ("command", json!("git")),
3404 ("args", json!(["push", "--force", "origin", "main"])),
3405 ("cwd", json!("/srv/app")),
3406 ("timeout", json!(30)),
3407 ],
3408 )
3409 };
3410 let first = classify_reversibility(&mk());
3411 for i in 0..256 {
3412 assert_eq!(
3413 classify_reversibility(&mk()),
3414 first,
3415 "reversibility moved on iteration {i}"
3416 );
3417 }
3418 // And the command line is genuinely reconstructed: `git push` is only
3419 // spellable across the `command`/`args` boundary.
3420 assert_eq!(first, Reversibility::Compensable);
3421 }
3422
3423 #[test]
3424 fn command_and_args_stay_adjacent_so_rm_rf_is_still_seen() {
3425 // The counterpart risk to the fix above: separating parameters to stop
3426 // phantom phrases must not lose the ONE cross-parameter adjacency that
3427 // is real. Sorting keys alphabetically puts `args` before `command` and
3428 // silently drops `rm -rf` — a deterministic miss, worse than a random
3429 // one, and in the unsafe direction.
3430 let a = tool_call_with(
3431 "shell",
3432 &[
3433 ("command", json!("rm")),
3434 ("args", json!(["-rf", "/var/data"])),
3435 ],
3436 );
3437 assert_eq!(classify_reversibility(&a), Reversibility::Irreversible);
3438 }
3439
3440 #[test]
3441 fn a_read_verb_in_the_name_cannot_override_a_mutation_in_the_parameters() {
3442 // Step 2 is the only path that reaches `Reversible` without positive
3443 // evidence of confinement, and it used to fire on the tool NAME alone
3444 // without ever inspecting the parameters. `query` is the literal tool
3445 // name the MCP Postgres connector exposes.
3446 for (tool, params) in [
3447 ("execute_query", json!("UPDATE accounts SET balance = 0")),
3448 ("db_query", json!("ALTER TABLE users DROP COLUMN email")),
3449 ("query", json!("INSERT INTO audit VALUES (1)")),
3450 ("search_index", json!("DROP INDEX idx_users")),
3451 ] {
3452 let a = tool_call_with(tool, &[("sql", params)]);
3453 assert_ne!(
3454 classify_reversibility(&a),
3455 Reversibility::Reversible,
3456 "{tool} carries a mutation and must not classify as reversible"
3457 );
3458 }
3459 // A find that deletes what it finds.
3460 let a = tool_call_with(
3461 "find",
3462 &[
3463 ("command", json!("find")),
3464 ("args", json!(["/data", "-name", "*.log", "-delete"])),
3465 ],
3466 );
3467 assert_ne!(classify_reversibility(&a), Reversibility::Reversible);
3468 // The control: a genuine read still short-circuits.
3469 let a = tool_call_with("execute_query", &[("sql", json!("SELECT id FROM users"))]);
3470 assert_eq!(classify_reversibility(&a), Reversibility::Reversible);
3471 }
3472
3473 #[test]
3474 fn only_os_scratch_roots_count_as_discardable() {
3475 // `SANDBOX_PATH_MARKERS` matched the bare substrings "sandbox" and
3476 // "scratch" anywhere in a path token, so ordinary production
3477 // directories that happen to contain those words read as throwaway.
3478 for path in [
3479 "/srv/sandbox-prod/index.html",
3480 "/System/Library/Sandbox/Profiles/x.sb",
3481 "/opt/scratch-data/customers.db",
3482 "/var/lib/sandbox/state.json",
3483 // `contains` rather than `starts_with` also accepted this one.
3484 "/etc/tmp/hosts",
3485 ] {
3486 let a = tool_call_with("write_file", &[("path", json!(path))]);
3487 assert_ne!(
3488 classify_reversibility(&a),
3489 Reversibility::Reversible,
3490 "{path} is not OS-designated scratch space"
3491 );
3492 }
3493 // Real scratch roots still qualify.
3494 for path in ["/tmp/build/out.txt", "/private/var/folders/xy/z/T/a.txt"] {
3495 let a = tool_call_with("write_file", &[("path", json!(path))]);
3496 assert_eq!(
3497 classify_reversibility(&a),
3498 Reversibility::Reversible,
3499 "{path} is scratch space"
3500 );
3501 }
3502 }
3503
3504 #[test]
3505 fn an_incidental_scratch_path_cannot_vouch_for_a_write_elsewhere() {
3506 // Step 5 read every string in the parameters, so a `/tmp` path in a
3507 // file's CONTENTS licensed a write whose actual target was a relative
3508 // path the classifier has already said it cannot resolve.
3509 let a = tool_call_with(
3510 "write_file",
3511 &[
3512 ("path", json!("config.yaml")),
3513 ("contents", json!("cache_dir: /tmp/app\n")),
3514 ],
3515 );
3516 assert_eq!(
3517 classify_reversibility(&a),
3518 Reversibility::Irreversible,
3519 "the target is a relative path; the /tmp string is incidental"
3520 );
3521
3522 // Same shape, but the target really is scratch space.
3523 let a = tool_call_with(
3524 "write_file",
3525 &[
3526 ("path", json!("/tmp/app/config.yaml")),
3527 ("contents", json!("cache_dir: ./app\n")),
3528 ],
3529 );
3530 assert_eq!(classify_reversibility(&a), Reversibility::Reversible);
3531
3532 // A scratch target plus a non-scratch target is still not discardable.
3533 let a = tool_call_with(
3534 "write_file",
3535 &[("paths", json!(["/tmp/a.txt", "/etc/hosts"]))],
3536 );
3537 assert_eq!(classify_reversibility(&a), Reversibility::Irreversible);
3538
3539 // ...and the veto does not depend on the dangerous path arriving under
3540 // a key `PATH_PARAM_KEYS` happens to know. `dst` is not in that set; if
3541 // only recognized keys were scanned, `/etc/hosts` would be invisible
3542 // and the scratch `src` would wrongly vouch for the whole action.
3543 let a = tool_call_with(
3544 "write_file",
3545 &[
3546 ("src", json!("/tmp/in.txt")),
3547 ("dst", json!("/etc/hosts")),
3548 ("mode", json!("0644")),
3549 ],
3550 );
3551 assert_eq!(
3552 classify_reversibility(&a),
3553 Reversibility::Irreversible,
3554 "an unrecognized key must still be able to veto"
3555 );
3556 }
3557
3558 #[test]
3559 fn unrecognized_tools_default_to_irreversible() {
3560 for name in ["frobnicate", "acme_widget", "run"] {
3561 assert_eq!(
3562 classify_reversibility(&tool_call(name)),
3563 Reversibility::Irreversible,
3564 "{name} is unrecognized and must assume the worst"
3565 );
3566 }
3567 }
3568
3569 #[test]
3570 fn documented_over_classifications_stay_documented() {
3571 // The misses named in `classify_reversibility`'s "What this is not"
3572 // section, pinned so the docs cannot drift away from the behavior.
3573 // Both err toward the conservative answer, which is the point.
3574 assert_eq!(
3575 classify_reversibility(&tool_call("list_deployments")),
3576 Reversibility::Compensable,
3577 "a read over a mutating noun over-classifies"
3578 );
3579 assert_eq!(
3580 classify_reversibility(&tool_call("get_payment")),
3581 Reversibility::Irreversible,
3582 "money is matched by its object, not its verb"
3583 );
3584 }
3585}