Skip to main content

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}