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