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    /// Append through [`car_secrets::open_private_append`] rather than a plain
1662    /// open. Set only by [`ApprovalLedger::with_private_journal`].
1663    private_journal: bool,
1664    /// Count of unparseable lines skipped during the last load — nonzero
1665    /// signals journal corruption or a concurrent torn write, so callers
1666    /// can surface it rather than silently trusting a partial ledger.
1667    skipped_on_load: usize,
1668}
1669
1670impl ApprovalLedger {
1671    pub fn new() -> Self {
1672        Self::default()
1673    }
1674
1675    /// Create a ledger backed by a JSONL journal at `path`, loading any
1676    /// existing decisions. Each line is one [`ApprovalRecord`]; the last
1677    /// line for a fingerprint wins, so a later rejection overrides an
1678    /// earlier approval.
1679    pub fn with_journal(path: impl Into<PathBuf>) -> std::io::Result<Self> {
1680        let path = path.into();
1681        let contents = if path.exists() {
1682            Some(std::fs::read_to_string(&path)?)
1683        } else {
1684            None
1685        };
1686        Ok(Self::loaded(path, contents, false))
1687    }
1688
1689    /// A journal-backed ledger holding the decisions in `contents`.
1690    fn loaded(path: PathBuf, contents: Option<String>, private_journal: bool) -> Self {
1691        let mut ledger = Self {
1692            records: HashMap::new(),
1693            journal: Some(path),
1694            private_journal,
1695            skipped_on_load: 0,
1696        };
1697        if let Some(contents) = contents {
1698            // Lines are read in file (append) order, so a later decision
1699            // for a fingerprint overwrites an earlier one — last wins.
1700            for line in contents.lines() {
1701                let line = line.trim();
1702                if line.is_empty() {
1703                    continue;
1704                }
1705                match serde_json::from_str::<ApprovalRecord>(line) {
1706                    Ok(rec) => {
1707                        ledger.records.insert(rec.fingerprint.clone(), rec);
1708                    }
1709                    Err(_) => ledger.skipped_on_load += 1,
1710                }
1711            }
1712        }
1713        ledger
1714    }
1715
1716    /// [`with_journal`](Self::with_journal) for a journal CAR keeps in its own
1717    /// state root, such as the daemon's `~/.car/approvals.jsonl`. The load and
1718    /// every [`record`](Self::record) go through the owner-private opens: the
1719    /// file is created `0600`, an existing looser file is tightened in place,
1720    /// and a symlink at the path or in a parent directory, or a parent the
1721    /// user does not own, is an error rather than followed (#3403). A missing
1722    /// journal loads empty. Refusing at load matters as much as at write: a
1723    /// symlink to a file holding an `Approved` row would otherwise authorize
1724    /// an operation nobody approved. Records are written exactly as [`with_journal`](Self::with_journal)
1725    /// writes them, so fingerprints keep matching.
1726    ///
1727    /// Caller-chosen journal paths (the FFI `ledger_path`) stay on
1728    /// [`with_journal`](Self::with_journal): the private open also makes the
1729    /// parent directory `0700` and refuses one the user does not own, which a
1730    /// journal placed directly in `/tmp` would fail.
1731    pub fn with_private_journal(path: impl Into<PathBuf>) -> std::io::Result<Self> {
1732        use std::io::Read;
1733        let path = path.into();
1734        let contents = match car_secrets::open_private_read(&path) {
1735            Ok(mut file) => {
1736                let mut contents = String::new();
1737                file.read_to_string(&mut contents)?;
1738                Some(contents)
1739            }
1740            Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
1741            Err(error) => return Err(error),
1742        };
1743        Ok(Self::loaded(path, contents, true))
1744    }
1745
1746    /// Number of unparseable lines skipped during the load. Nonzero means
1747    /// the journal is corrupt or was torn by a concurrent writer.
1748    pub fn skipped_on_load(&self) -> usize {
1749        self.skipped_on_load
1750    }
1751
1752    /// Record a decision, persisting it to the journal when configured.
1753    /// Returns the stored record.
1754    ///
1755    /// A journal write failure is an **error, not best-effort** (review A7):
1756    /// callers emit `ApprovalRecorded` audit events on the strength of this
1757    /// call, so a decision that only landed in memory must not be reported
1758    /// as durable. On `Err` the decision is NOT stored (memory and journal
1759    /// stay consistent — both lack it) and the caller must surface the
1760    /// failure. An in-memory ledger (no journal) cannot fail.
1761    pub fn record(&mut self, record: ApprovalRecord) -> std::io::Result<&ApprovalRecord> {
1762        if let Some(path) = &self.journal {
1763            // Durable append: write the whole line, flush Rust's writer, ask
1764            // the OS to commit file data + metadata, then close the handle
1765            // before publishing the decision in memory. `flush` alone is not
1766            // a durability boundary and left a fresh Windows process able to
1767            // observe the pre-approval journal (Parslee-ai/car#1227).
1768            let mut f = if self.private_journal {
1769                car_secrets::open_private_append(path)?
1770            } else {
1771                std::fs::OpenOptions::new()
1772                    .create(true)
1773                    .append(true)
1774                    .open(path)?
1775            };
1776            let mut line = serde_json::to_string(&record)
1777                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
1778            line.push('\n');
1779            f.write_all(line.as_bytes())?;
1780            f.flush()?;
1781            f.sync_all()?;
1782            drop(f);
1783        }
1784        // Last-wins: overwrite any prior decision for this fingerprint.
1785        use std::collections::hash_map::Entry;
1786        Ok(match self.records.entry(record.fingerprint.clone()) {
1787            Entry::Occupied(mut o) => {
1788                o.insert(record);
1789                o.into_mut()
1790            }
1791            Entry::Vacant(v) => v.insert(record),
1792        })
1793    }
1794
1795    /// The current decision for a fingerprint, if any.
1796    pub fn lookup(&self, fingerprint: &str) -> Option<&ApprovalRecord> {
1797        self.records.get(fingerprint)
1798    }
1799
1800    pub fn all(&self) -> impl Iterator<Item = &ApprovalRecord> {
1801        self.records.values()
1802    }
1803
1804    /// Build and [`record`](Self::record) a decision against an explicit
1805    /// fingerprint — the ledger-level twin of
1806    /// [`PermissionGate::record_for_fingerprint`], for callers that share
1807    /// ONE ledger across many gates/sessions (e.g. the daemon's shared
1808    /// approval substrate) and so must not route the write through any
1809    /// single session's gate. Errs when the journal write fails (the
1810    /// decision is then NOT recorded — see [`Self::record`]).
1811    #[allow(clippy::too_many_arguments)]
1812    pub fn record_decision(
1813        &mut self,
1814        fingerprint: &str,
1815        required_tier: PermissionTier,
1816        decision: ApprovalDecision,
1817        reviewer: &str,
1818        reason: &str,
1819        evidence: Option<String>,
1820    ) -> std::io::Result<ApprovalRecord> {
1821        let record = ApprovalRecord {
1822            fingerprint: fingerprint.to_string(),
1823            required_tier,
1824            decision,
1825            reviewer: reviewer.to_string(),
1826            reason: reason.to_string(),
1827            evidence,
1828            decided_at: chrono::Utc::now().to_rfc3339(),
1829        };
1830        self.record(record.clone())?;
1831        Ok(record)
1832    }
1833}
1834
1835/// A set of hazards partitioned against the durable [`ApprovalLedger`]: those
1836/// that must not run, and those still awaiting a human decision (paired with the
1837/// fingerprint the decision is keyed by).
1838///
1839/// The shared shape behind the per-domain HITL bridges
1840/// ([`crate::flow_gate::enforce_flow`], [`crate::intent_gate::enforce_intent`]):
1841/// each maps this into its own domain-specific enforcement struct + reason.
1842#[derive(Debug, Clone)]
1843pub struct LedgerPartition<V> {
1844    /// Hazards that must not run: the hard blocks passed in, plus anything a
1845    /// human previously **rejected**.
1846    pub blocked: Vec<V>,
1847    /// Novel hazards awaiting a human decision, each paired with its ledger
1848    /// fingerprint.
1849    pub pending: Vec<(String, V)>,
1850}
1851
1852/// Resolve a `require_approval` hazard set against the durable ledger, the one
1853/// place that fixes the HITL semantics: a hazard a human previously **approved**
1854/// is dropped (let through), one they **rejected** joins `hard_blocked`, and an
1855/// **unseen** one becomes pending. `fingerprint` maps a hazard to its stable
1856/// ledger key (the per-domain part callers keep specialized).
1857pub fn partition_by_ledger<V, F>(
1858    hard_blocked: Vec<V>,
1859    needs_approval: &[V],
1860    fingerprint: F,
1861    ledger: &ApprovalLedger,
1862) -> LedgerPartition<V>
1863where
1864    V: Clone,
1865    F: Fn(&V) -> String,
1866{
1867    let mut blocked = hard_blocked;
1868    let mut pending = Vec::new();
1869    for v in needs_approval {
1870        let fp = fingerprint(v);
1871        match ledger.lookup(&fp).map(|r| r.decision) {
1872            Some(ApprovalDecision::Approved) => { /* a human OK'd this hazard before */ }
1873            Some(ApprovalDecision::Rejected) => blocked.push(v.clone()),
1874            None => pending.push((fp, v.clone())),
1875        }
1876    }
1877    LedgerPartition { blocked, pending }
1878}
1879
1880/// The outcome of evaluating an action against the gate.
1881#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1882#[serde(tag = "decision", rename_all = "snake_case")]
1883pub enum GateDecision {
1884    /// May proceed autonomously. `required` and `granted` explain why.
1885    Allow {
1886        required: PermissionTier,
1887        granted: PermissionTier,
1888    },
1889    /// Autonomy is suspended pending a human decision (survey §5.2.5).
1890    /// The caller surfaces the request; a later [`PermissionGate::approve`]
1891    /// or [`PermissionGate::reject`] resolves it.
1892    NeedsApproval {
1893        required: PermissionTier,
1894        granted: PermissionTier,
1895        fingerprint: String,
1896        reason: String,
1897    },
1898    /// Refused — a human already rejected this operation.
1899    Deny {
1900        required: PermissionTier,
1901        fingerprint: String,
1902        reason: String,
1903    },
1904}
1905
1906impl GateDecision {
1907    pub fn is_allow(&self) -> bool {
1908        matches!(self, GateDecision::Allow { .. })
1909    }
1910}
1911
1912/// The permission gate: a session's standing authority plus the classifier
1913/// and the durable approval ledger. Pure and synchronous so it can be
1914/// embedded anywhere; the engine wraps it for its async pipeline.
1915#[derive(Debug)]
1916pub struct PermissionGate {
1917    /// Standing authority granted to this session.
1918    granted: PermissionTier,
1919    /// Actions at or above this tier *always* require a human decision,
1920    /// even when the granted tier would cover them — the "mandatory HITL
1921    /// gate" for consequential actions (§5.2.5). Default: `FullAccess`.
1922    require_approval_at: PermissionTier,
1923    classifier: RiskClassifier,
1924    ledger: ApprovalLedger,
1925}
1926
1927impl PermissionGate {
1928    /// A gate with the given standing tier, default classifier, mandatory
1929    /// approval at `FullAccess`, and an in-memory ledger.
1930    pub fn new(granted: PermissionTier) -> Self {
1931        Self {
1932            granted,
1933            require_approval_at: PermissionTier::FullAccess,
1934            classifier: RiskClassifier::new(),
1935            ledger: ApprovalLedger::new(),
1936        }
1937    }
1938
1939    pub fn with_classifier(mut self, classifier: RiskClassifier) -> Self {
1940        self.classifier = classifier;
1941        self
1942    }
1943
1944    pub fn with_ledger(mut self, ledger: ApprovalLedger) -> Self {
1945        self.ledger = ledger;
1946        self
1947    }
1948
1949    /// Override the tier at and above which approval is mandatory.
1950    pub fn with_mandatory_approval_at(mut self, tier: PermissionTier) -> Self {
1951        self.require_approval_at = tier;
1952        self
1953    }
1954
1955    pub fn granted_tier(&self) -> PermissionTier {
1956        self.granted
1957    }
1958
1959    pub fn set_granted_tier(&mut self, tier: PermissionTier) {
1960        self.granted = tier;
1961    }
1962
1963    pub fn classifier(&self) -> &RiskClassifier {
1964        &self.classifier
1965    }
1966
1967    pub fn ledger(&self) -> &ApprovalLedger {
1968        &self.ledger
1969    }
1970
1971    /// Evaluate an action. Precedence:
1972    /// 1. A prior **rejection** denies (a human said no).
1973    /// 2. A prior **approval** allows (a human elevated this operation).
1974    /// 3. Actions at/above the mandatory-approval tier need approval.
1975    /// 4. Otherwise the granted tier must cover the required tier.
1976    /// 5. Exceeding standing authority escalates to a human, not a hard
1977    ///    deny — autonomy is suspended, not the task abandoned.
1978    pub fn evaluate(&self, action: &Action) -> GateDecision {
1979        self.evaluate_with_granted(action, self.granted, &self.ledger)
1980    }
1981
1982    /// [`Self::evaluate`], but consulting an **external** ledger instead of the
1983    /// gate's own. For deployments that share ONE approval ledger across many
1984    /// per-session gates (per-session *tier*, shared *approvals*): the daemon
1985    /// keeps a single journal-backed ledger on its server state so an approval
1986    /// recorded on one connection is visible to every other and survives
1987    /// restart — a per-connection in-memory ledger would strand the approver's
1988    /// decision where the runner never reads it.
1989    pub fn evaluate_against(&self, action: &Action, ledger: &ApprovalLedger) -> GateDecision {
1990        self.evaluate_with_granted(action, self.granted, ledger)
1991    }
1992
1993    /// Evaluate an action as if the session's standing authority were capped at
1994    /// `ceiling` — the join between skill-trust governance (arXiv 2602.12430) and
1995    /// the action-level gate. The effective authority is `min(granted, ceiling)`,
1996    /// so an action driven by a skill whose deployment ceiling is `read_only`
1997    /// cannot perform a `sandbox_edit` operation even in a `full_access` session;
1998    /// it escalates to a human instead. A `None` ceiling is identical to
1999    /// [`Self::evaluate`].
2000    ///
2001    /// The caller supplies the ceiling — it is the persisted
2002    /// `SkillMeta::deployment_tier` of the skill that drove the action, which the
2003    /// caller already knows (it retrieved the skill). The gate does not invent
2004    /// action→skill provenance; it honours the ceiling it is handed.
2005    pub fn evaluate_with_ceiling(
2006        &self,
2007        action: &Action,
2008        ceiling: Option<PermissionTier>,
2009    ) -> GateDecision {
2010        self.evaluate_with_ceiling_against(action, ceiling, &self.ledger)
2011    }
2012
2013    /// [`Self::evaluate_with_ceiling`] against an external ledger — see
2014    /// [`Self::evaluate_against`] for when a shared ledger is the substrate.
2015    pub fn evaluate_with_ceiling_against(
2016        &self,
2017        action: &Action,
2018        ceiling: Option<PermissionTier>,
2019        ledger: &ApprovalLedger,
2020    ) -> GateDecision {
2021        let effective = match ceiling {
2022            Some(c) => self.granted.min(c),
2023            None => self.granted,
2024        };
2025        self.evaluate_with_granted(action, effective, ledger)
2026    }
2027
2028    /// Both authorization axes for one action, flattening its parameters
2029    /// **once**.
2030    ///
2031    /// Every caller that records a `PermissionDecision` needs both: the gate's
2032    /// verdict (*may this run?*) and the rollback contract (*could it be taken
2033    /// back?*). Computed separately they each call [`action_text`], which walks
2034    /// every nested string in `Action::parameters` into a `String` and then
2035    /// allocates a lowercase copy — twice, per action, on the execution path,
2036    /// for payloads that can be a whole document or diff. Parslee-ai/car#856.
2037    ///
2038    /// `ceiling` caps standing authority the way
2039    /// [`Self::evaluate_with_ceiling`] does; `ledger` selects an external
2040    /// approval ledger the way [`Self::evaluate_against`] does, or `None` to
2041    /// use the gate's own.
2042    ///
2043    /// The two fields answer independent questions and neither is derived from
2044    /// the other — see [`classify_reversibility`]. They travel together here
2045    /// only because they read the same text.
2046    pub fn evaluate_axes(
2047        &self,
2048        action: &Action,
2049        ceiling: Option<PermissionTier>,
2050        ledger: Option<&ApprovalLedger>,
2051    ) -> ActionAxes {
2052        let hay = action_text(action);
2053        let granted = match ceiling {
2054            Some(c) => self.granted.min(c),
2055            None => self.granted,
2056        };
2057        ActionAxes {
2058            decision: self.evaluate_with_granted_haystack(
2059                action,
2060                granted,
2061                ledger.unwrap_or(&self.ledger),
2062                Some(&hay),
2063            ),
2064            reversibility: classify_reversibility_with_haystack(action, Some(&hay)),
2065        }
2066    }
2067
2068    /// Core evaluation against an explicit effective `granted` tier (so a skill
2069    /// ceiling can cap standing authority without mutating the gate). Precedence:
2070    /// 1. A prior **rejection** denies (a human said no).
2071    /// 2. A prior **approval** allows (a human elevated this operation).
2072    /// 3. Actions at/above the mandatory-approval tier need approval.
2073    /// 4. Otherwise the effective granted tier must cover the required tier.
2074    /// 5. Exceeding standing authority escalates to a human, not a hard
2075    ///    deny — autonomy is suspended, not the task abandoned.
2076    fn evaluate_with_granted(
2077        &self,
2078        action: &Action,
2079        granted: PermissionTier,
2080        ledger: &ApprovalLedger,
2081    ) -> GateDecision {
2082        self.evaluate_with_granted_haystack(action, granted, ledger, None)
2083    }
2084
2085    /// [`Self::evaluate_with_granted`] reusing a haystack the caller already
2086    /// built. Private: the public door is [`Self::evaluate_axes`], which owns
2087    /// the "build it once" decision rather than leaving it to every caller.
2088    fn evaluate_with_granted_haystack(
2089        &self,
2090        action: &Action,
2091        granted: PermissionTier,
2092        ledger: &ApprovalLedger,
2093        haystack: Option<&str>,
2094    ) -> GateDecision {
2095        let required = self.classifier.classify_with_haystack(action, haystack);
2096        let fingerprint = action_fingerprint(action);
2097
2098        if let Some(rec) = ledger.lookup(&fingerprint) {
2099            match rec.decision {
2100                ApprovalDecision::Rejected => {
2101                    return GateDecision::Deny {
2102                        required,
2103                        fingerprint,
2104                        reason: format!("previously rejected by {} ({})", rec.reviewer, rec.reason),
2105                    };
2106                }
2107                // An approval is scoped to the risk that was actually
2108                // reviewed. If the operation has since been reclassified
2109                // *upward* (a new keyword, a new custom rule), the stale
2110                // approval must not bypass the mandatory gate — re-prompt
2111                // instead (neo review: tier-blind stale approvals).
2112                ApprovalDecision::Approved if required <= rec.required_tier => {
2113                    return GateDecision::Allow { required, granted };
2114                }
2115                ApprovalDecision::Approved => {
2116                    return GateDecision::NeedsApproval {
2117                        required,
2118                        granted,
2119                        fingerprint,
2120                        reason: format!(
2121                            "operation reclassified {} → {} since it was approved; re-approval required",
2122                            rec.required_tier.as_str(),
2123                            required.as_str()
2124                        ),
2125                    };
2126                }
2127            }
2128        }
2129
2130        if required >= self.require_approval_at {
2131            return GateDecision::NeedsApproval {
2132                required,
2133                granted,
2134                fingerprint,
2135                reason: format!(
2136                    "{} actions require human approval before execution",
2137                    required.as_str()
2138                ),
2139            };
2140        }
2141
2142        if granted.covers(required) {
2143            GateDecision::Allow { required, granted }
2144        } else {
2145            GateDecision::NeedsApproval {
2146                required,
2147                granted,
2148                fingerprint,
2149                reason: format!(
2150                    "action requires {} but session is granted only {}",
2151                    required.as_str(),
2152                    granted.as_str()
2153                ),
2154            }
2155        }
2156    }
2157
2158    /// Record a human approval for the operation `action` represents.
2159    /// Errs when the ledger journal write fails (the decision is then NOT
2160    /// recorded — see [`ApprovalLedger::record`]).
2161    pub fn approve(
2162        &mut self,
2163        action: &Action,
2164        reviewer: &str,
2165        reason: &str,
2166        evidence: Option<String>,
2167    ) -> std::io::Result<ApprovalRecord> {
2168        self.record_decision(
2169            action,
2170            ApprovalDecision::Approved,
2171            reviewer,
2172            reason,
2173            evidence,
2174        )
2175    }
2176
2177    /// Record a human rejection for the operation `action` represents.
2178    /// Errs when the ledger journal write fails (the decision is then NOT
2179    /// recorded — see [`ApprovalLedger::record`]).
2180    pub fn reject(
2181        &mut self,
2182        action: &Action,
2183        reviewer: &str,
2184        reason: &str,
2185        evidence: Option<String>,
2186    ) -> std::io::Result<ApprovalRecord> {
2187        self.record_decision(
2188            action,
2189            ApprovalDecision::Rejected,
2190            reviewer,
2191            reason,
2192            evidence,
2193        )
2194    }
2195
2196    /// Record a decision against an explicit fingerprint (when the caller
2197    /// holds the fingerprint from a prior `NeedsApproval`, not the action).
2198    /// Errs when the ledger journal write fails (the decision is then NOT
2199    /// recorded — see [`ApprovalLedger::record`]).
2200    pub fn record_for_fingerprint(
2201        &mut self,
2202        fingerprint: &str,
2203        required_tier: PermissionTier,
2204        decision: ApprovalDecision,
2205        reviewer: &str,
2206        reason: &str,
2207        evidence: Option<String>,
2208    ) -> std::io::Result<ApprovalRecord> {
2209        self.ledger.record_decision(
2210            fingerprint,
2211            required_tier,
2212            decision,
2213            reviewer,
2214            reason,
2215            evidence,
2216        )
2217    }
2218
2219    /// Build (but do NOT store) the [`ApprovalRecord`] for a decision on
2220    /// `action` — the gate classifies the action and derives its fingerprint;
2221    /// the caller records the result on whichever ledger is the substrate
2222    /// (its own via [`ApprovalLedger::record`], or a shared daemon ledger).
2223    /// This is what lets a per-session gate keep its tier/classifier while the
2224    /// approval store is shared.
2225    pub fn decision_record(
2226        &self,
2227        action: &Action,
2228        decision: ApprovalDecision,
2229        reviewer: &str,
2230        reason: &str,
2231        evidence: Option<String>,
2232    ) -> ApprovalRecord {
2233        ApprovalRecord {
2234            fingerprint: action_fingerprint(action),
2235            required_tier: self.classifier.classify(action),
2236            decision,
2237            reviewer: reviewer.to_string(),
2238            reason: reason.to_string(),
2239            evidence,
2240            decided_at: chrono::Utc::now().to_rfc3339(),
2241        }
2242    }
2243
2244    fn record_decision(
2245        &mut self,
2246        action: &Action,
2247        decision: ApprovalDecision,
2248        reviewer: &str,
2249        reason: &str,
2250        evidence: Option<String>,
2251    ) -> std::io::Result<ApprovalRecord> {
2252        let record = self.decision_record(action, decision, reviewer, reason, evidence);
2253        self.ledger.record(record.clone())?;
2254        Ok(record)
2255    }
2256}
2257
2258#[cfg(test)]
2259mod tests {
2260    use super::*;
2261    use car_ir::ActionType;
2262    use serde_json::json;
2263    use std::collections::HashMap as Map;
2264
2265    fn action(
2266        action_type: ActionType,
2267        tool: Option<&str>,
2268        params: Map<String, serde_json::Value>,
2269    ) -> Action {
2270        {
2271            let mut a = Action::new(action_type);
2272            a.id = "a1".to_string();
2273            a.tool = tool.map(str::to_string);
2274            a.parameters = params;
2275            a
2276        }
2277    }
2278
2279    fn tool_call(tool: &str) -> Action {
2280        action(ActionType::ToolCall, Some(tool), Map::new())
2281    }
2282
2283    fn tool_call_with(tool: &str, params: &[(&str, serde_json::Value)]) -> Action {
2284        let mut p = Map::new();
2285        for (k, v) in params {
2286            p.insert((*k).to_string(), v.clone());
2287        }
2288        action(ActionType::ToolCall, Some(tool), p)
2289    }
2290
2291    #[test]
2292    fn tool_name_full_access_matches_irreversible_capabilities() {
2293        // Irreversible / externally-consequential tool names are full-access —
2294        // including the space-bearing-keyword cases a substring scan would MISS:
2295        // `git_reset`/`git_clean` have no bare `reset`/`clean` in the free-text
2296        // list, only `"git reset"`/`"git clean"` (with spaces), which can't occur
2297        // in an identifier. Segment matching catches them.
2298        for name in [
2299            "deploy",
2300            "git_push",
2301            "gitPush",
2302            "delete_file",
2303            "kubectl_apply",
2304            "git_reset",
2305            "git_clean",
2306            "send_email",
2307            "sudo_run",
2308            "rm_rf_dir",
2309            "drop_table",
2310            "upload_artifact",
2311        ] {
2312            assert!(
2313                tool_name_is_full_access(name),
2314                "{name} should classify as full-access"
2315            );
2316        }
2317    }
2318
2319    #[test]
2320    fn tool_name_full_access_does_not_false_positive_on_benign_names() {
2321        // The collision cases that a raw substring scan of the free-text keyword
2322        // list gets WRONG. `count_tokens`/`tokenize` (vs `token`), `http_get`
2323        // (vs `http`), `apply_template` (vs `apply`), `request_id` (vs
2324        // `request`), `prefetch_cache` (vs `fetch`) must all be benign — segment
2325        // matching + a curated name set is what buys this.
2326        for name in [
2327            "read_file",
2328            "grep",
2329            "search",
2330            "summarize",
2331            "classify",
2332            "count_tokens",
2333            "tokenize",
2334            "token_usage",
2335            "http_get",
2336            "https_health",
2337            "apply_template",
2338            "request_id",
2339            "parse_request",
2340            "prefetch_cache",
2341            "transfer_learning",
2342            "format_date",
2343            "network_topology",
2344            "dropdown_open",
2345        ] {
2346            assert!(
2347                !tool_name_is_full_access(name),
2348                "{name} should NOT classify as full-access (false positive)"
2349            );
2350        }
2351    }
2352
2353    #[test]
2354    fn name_segments_splits_snake_and_camel() {
2355        assert_eq!(name_segments("git_push"), vec!["git", "push"]);
2356        assert_eq!(name_segments("gitPush"), vec!["git", "push"]);
2357        assert_eq!(name_segments("git-push"), vec!["git", "push"]);
2358        assert_eq!(name_segments("count_tokens"), vec!["count", "tokens"]);
2359        // `tokens` is its own segment and never equals the `token` danger word.
2360        assert!(!name_segments("count_tokens").iter().any(|s| s == "token"));
2361    }
2362
2363    #[test]
2364    fn any_tool_full_access_scans_the_palette() {
2365        // Works over &[&str], owned String collections, and is false on empty.
2366        assert!(any_tool_full_access(["read_file", "grep", "deploy"]));
2367        assert!(!any_tool_full_access(["read_file", "grep", "summarize"]));
2368        assert!(!any_tool_full_access(std::iter::empty::<&str>()));
2369        let owned: Vec<String> = vec!["read_file".into(), "git_push".into()];
2370        assert!(any_tool_full_access(&owned));
2371    }
2372
2373    #[test]
2374    fn tier_ordering() {
2375        assert!(PermissionTier::FullAccess.covers(PermissionTier::ReadOnly));
2376        assert!(PermissionTier::SandboxEdit.covers(PermissionTier::SandboxEdit));
2377        assert!(!PermissionTier::ReadOnly.covers(PermissionTier::SandboxEdit));
2378    }
2379
2380    #[test]
2381    fn classifier_baseline_by_type() {
2382        let c = RiskClassifier::new();
2383        assert_eq!(
2384            c.classify(&action(ActionType::StateRead, None, Map::new())),
2385            PermissionTier::ReadOnly
2386        );
2387        assert_eq!(
2388            c.classify(&action(ActionType::Assertion, None, Map::new())),
2389            PermissionTier::ReadOnly
2390        );
2391        assert_eq!(
2392            c.classify(&action(ActionType::StateWrite, None, Map::new())),
2393            PermissionTier::SandboxEdit
2394        );
2395        assert_eq!(c.classify(&tool_call("echo")), PermissionTier::SandboxEdit);
2396    }
2397
2398    #[test]
2399    fn classifier_escalates_on_keyword_in_tool_name() {
2400        let c = RiskClassifier::new();
2401        assert_eq!(
2402            c.classify(&tool_call("deploy_service")),
2403            PermissionTier::FullAccess
2404        );
2405    }
2406
2407    #[test]
2408    fn classifier_tool_name_signal_agrees_with_tool_name_is_full_access() {
2409        // Parslee-ai/car#917. These two answered the same question — "does this
2410        // tool name name a consequential capability?" — and disagreed on every
2411        // name below, because `classify` substring-scanned the command keyword
2412        // list over the name while `tool_name_is_full_access` segment-matched a
2413        // curated set. `http_get` was full-access to one and benign to the
2414        // other. Now `classify` calls it, so drift is not expressible.
2415        let c = RiskClassifier::new();
2416        for name in [
2417            "http_get",
2418            "https_health",
2419            "format_date",
2420            "count_tokens",
2421            "apply_template",
2422            "request_id",
2423            "prefetch_cache",
2424            "transfer_learning",
2425            "network_topology",
2426            "deploy_service",
2427            "send_email",
2428            "git_push",
2429            "read_secret",
2430        ] {
2431            let expected = if tool_name_is_full_access(name) {
2432                PermissionTier::FullAccess
2433            } else {
2434                PermissionTier::SandboxEdit
2435            };
2436            assert_eq!(c.classify(&tool_call(name)), expected, "tool name {name}");
2437        }
2438    }
2439
2440    #[test]
2441    fn classifier_escalates_on_keyword_in_params() {
2442        let mut params = Map::new();
2443        params.insert("cmd".to_string(), serde_json::json!("rm -rf /tmp/x"));
2444        let a = action(ActionType::ToolCall, Some("shell"), params);
2445        assert_eq!(
2446            RiskClassifier::new().classify(&a),
2447            PermissionTier::FullAccess
2448        );
2449    }
2450
2451    #[test]
2452    fn classifier_does_not_escalate_on_ordinary_text_in_ordinary_parameters() {
2453        // Parslee-ai/car#917: the four false positives the issue names, each of
2454        // which became a HARD REJECTION at Parslee-ai/car#915 — a `full_access`
2455        // classification is mandatory HITL regardless of granted tier, so on an
2456        // automated connection with no approver there is nobody to say yes.
2457        let c = RiskClassifier::new();
2458        let cases: Vec<(&str, Action)> = vec![
2459            (
2460                "a URL in a documented url parameter is not `curl` in a shell string",
2461                tool_call_with("http_get", &[("url", json!("https://example.com/a"))]),
2462            ),
2463            (
2464                "a search whose query text happens to say `release notes`",
2465                tool_call_with("search", &[("query", json!("release notes for 0.47"))]),
2466            ),
2467            (
2468                "a date formatter is not `mkfs --format`",
2469                tool_call_with(
2470                    "format_date",
2471                    &[("value", json!("2026-08-15")), ("fmt", json!("%Y-%m-%d"))],
2472                ),
2473            ),
2474            (
2475                "prose containing `send` in a body the tool merely stores",
2476                tool_call_with(
2477                    "create_note",
2478                    &[(
2479                        "body",
2480                        json!("Remember to send the report and apply the patch"),
2481                    )],
2482                ),
2483            ),
2484            (
2485                "max_tokens is in nearly every LLM tool call and is not a credential",
2486                tool_call_with(
2487                    "complete",
2488                    &[("prompt", json!("hello")), ("max_tokens", json!(256))],
2489                ),
2490            ),
2491            (
2492                "a body that MENTIONS ~/.ssh is not an action against it — only \
2493                 declared target paths carry the credential-path signal",
2494                tool_call_with(
2495                    "write_file",
2496                    &[
2497                        ("path", json!("/tmp/notes.md")),
2498                        ("content", json!("Back up ~/.ssh/id_rsa before you start")),
2499                    ],
2500                ),
2501            ),
2502        ];
2503        for (why, act) in cases {
2504            assert_eq!(
2505                c.classify(&act),
2506                PermissionTier::SandboxEdit,
2507                "must not escalate — {why}"
2508            );
2509        }
2510    }
2511
2512    #[test]
2513    fn classifier_still_escalates_on_the_signals_that_matter() {
2514        // The other half of #917: narrowing must not let any of these through.
2515        // Each one reaches a DIFFERENT signal, so this pins all four.
2516        let c = RiskClassifier::new();
2517        let cases: Vec<(&str, Action)> =
2518            vec![
2519            (
2520                "tool name — segment match",
2521                tool_call_with("send_email", &[("to", json!("a@example.com"))]),
2522            ),
2523            (
2524                "command line — broad keyword over command/args only",
2525                tool_call_with(
2526                    "shell",
2527                    &[("command", json!("curl")), ("args", json!(["-X", "POST"]))],
2528                ),
2529            ),
2530            (
2531                "command line — `aws ` needs the trailing space and the argv join",
2532                tool_call_with(
2533                    "shell",
2534                    &[("command", json!("aws")), ("args", json!(["s3", "rb"]))],
2535                ),
2536            ),
2537            (
2538                "parameter key — the value is opaque, the key is not",
2539                tool_call_with(
2540                    "call_api",
2541                    &[("url", json!("https://x/y")), ("api_key", json!("sk-abc"))],
2542                ),
2543            ),
2544            (
2545                "parameter key — nested under an options object",
2546                tool_call_with("call_api", &[("opts", json!({"auth": {"token": "t"}}))]),
2547            ),
2548            (
2549                "parameter key — a segment match on a compound key",
2550                tool_call_with("connect", &[("db_password", json!("hunter2"))]),
2551            ),
2552            (
2553                "free text — destructive SQL under a `query` key the command surface cannot see",
2554                tool_call_with("run_sql", &[("query", json!("DROP TABLE users"))]),
2555            ),
2556            (
2557                "free text — `delete from` likewise",
2558                tool_call_with("run_sql", &[("query", json!("DELETE FROM orders WHERE 1=1"))]),
2559            ),
2560            (
2561                "free text — a destructive script passed as file contents",
2562                tool_call_with(
2563                    "write_file",
2564                    &[("path", json!("/tmp/x.sh")), ("contents", json!("rm -rf /"))],
2565                ),
2566            ),
2567            (
2568                "free text — history rewriting in a body parameter",
2569                tool_call_with("run_script", &[("body", json!("git reset --hard HEAD~5"))]),
2570            ),
2571            (
2572                "target path — reading someone's private key is a top-grant action",
2573                tool_call_with("read_file", &[("path", json!("~/.ssh/id_rsa"))]),
2574            ),
2575            (
2576                "target path — writing over a cloud credential file",
2577                tool_call_with(
2578                    "write_file",
2579                    &[("path", json!("/home/u/.aws/credentials")), ("content", json!("x"))],
2580                ),
2581            ),
2582        ];
2583        for (why, act) in cases {
2584            assert_eq!(
2585                c.classify(&act),
2586                PermissionTier::FullAccess,
2587                "must escalate — {why}"
2588            );
2589        }
2590    }
2591
2592    #[test]
2593    fn command_shaped_actions_classify_exactly_as_they_did_before_917() {
2594        // The safety argument for #917 is that the broad keyword list was
2595        // re-aimed, not pruned: on a command line every entry still fires. If
2596        // this ever fails, the narrowing has reached the command surface and
2597        // the change is no longer behaviour-preserving where it claimed to be.
2598        let c = RiskClassifier::new();
2599        for kw in FULL_ACCESS_COMMAND_KEYWORDS {
2600            let act = tool_call_with("shell", &[("command", json!(format!("x{kw}y")))]);
2601            assert_eq!(
2602                c.classify(&act),
2603                PermissionTier::FullAccess,
2604                "command keyword {kw:?} must still escalate on a command line"
2605            );
2606        }
2607    }
2608
2609    #[test]
2610    fn credential_param_keys_are_boundary_matched_not_substring_matched() {
2611        // `max_tokens` is the case that makes this worth testing: a raw
2612        // substring scan for `token` flags every LLM tool call in existence.
2613        for key in [
2614            "token",
2615            "api_key",
2616            "apiKey",
2617            "access-token",
2618            "client_secret",
2619            "db_password",
2620            "svc_credentials",
2621            "passphrase",
2622        ] {
2623            let act = tool_call_with("t", &[(key, json!("v"))]);
2624            assert!(
2625                hits_credential_param_key(&act),
2626                "{key} should read as credential material"
2627            );
2628        }
2629        for key in [
2630            "max_tokens",
2631            "tokens",
2632            "token_count",
2633            "key",
2634            "keys",
2635            "sort_key",
2636            "keyword",
2637            "secretariat_id",
2638        ] {
2639            let act = tool_call_with("t", &[(key, json!("v"))]);
2640            assert!(
2641                !hits_credential_param_key(&act),
2642                "{key} should NOT read as credential material (false positive)"
2643            );
2644        }
2645    }
2646
2647    #[test]
2648    fn command_line_is_none_when_the_action_is_not_command_shaped() {
2649        assert_eq!(command_line(&tool_call("search")), None);
2650        assert_eq!(
2651            command_line(&tool_call_with("search", &[("query", json!("git push"))])),
2652            None,
2653            "a non-command parameter must not become the command line"
2654        );
2655        assert_eq!(
2656            command_line(&tool_call_with(
2657                "shell",
2658                &[("command", json!("git")), ("args", json!(["push"]))]
2659            )),
2660            Some("git push ".to_string()),
2661        );
2662    }
2663
2664    #[test]
2665    fn custom_rule_only_raises() {
2666        let mut c = RiskClassifier::new();
2667        c.add_rule("flag_search", PermissionTier::FullAccess, |a| {
2668            a.tool.as_deref() == Some("search")
2669        });
2670        assert_eq!(c.classify(&tool_call("search")), PermissionTier::FullAccess);
2671        // A read action a rule matches at a lower tier stays at the
2672        // higher baseline — rules never lower.
2673        let mut c2 = RiskClassifier::new();
2674        c2.add_rule("noop", PermissionTier::ReadOnly, |_| true);
2675        assert_eq!(
2676            c2.classify(&action(ActionType::StateWrite, None, Map::new())),
2677            PermissionTier::SandboxEdit
2678        );
2679    }
2680
2681    #[test]
2682    fn gate_allows_within_granted_tier() {
2683        let gate = PermissionGate::new(PermissionTier::SandboxEdit);
2684        let d = gate.evaluate(&action(ActionType::StateWrite, None, Map::new()));
2685        assert!(d.is_allow(), "{d:?}");
2686    }
2687
2688    #[test]
2689    fn gate_escalates_above_granted_tier() {
2690        let gate = PermissionGate::new(PermissionTier::ReadOnly);
2691        let d = gate.evaluate(&action(ActionType::StateWrite, None, Map::new()));
2692        assert!(matches!(d, GateDecision::NeedsApproval { .. }), "{d:?}");
2693    }
2694
2695    #[test]
2696    fn skill_ceiling_caps_below_granted_tier() {
2697        // The session can perform sandbox edits...
2698        let gate = PermissionGate::new(PermissionTier::SandboxEdit);
2699        let act = action(ActionType::StateWrite, None, Map::new()); // classifies SandboxEdit
2700        assert!(gate.evaluate(&act).is_allow());
2701        // ...but an action driven by a skill capped at read_only cannot: the
2702        // same action escalates to a human instead of running.
2703        let capped = gate.evaluate_with_ceiling(&act, Some(PermissionTier::ReadOnly));
2704        assert!(
2705            matches!(capped, GateDecision::NeedsApproval { .. }),
2706            "{capped:?}"
2707        );
2708        // No ceiling is identical to evaluate.
2709        assert!(gate.evaluate_with_ceiling(&act, None).is_allow());
2710    }
2711
2712    #[test]
2713    fn skill_ceiling_at_or_above_granted_is_noop() {
2714        let gate = PermissionGate::new(PermissionTier::SandboxEdit);
2715        let act = action(ActionType::StateWrite, None, Map::new());
2716        // A ceiling at or above the granted tier leaves the outcome unchanged
2717        // (effective authority is min(granted, ceiling)).
2718        assert!(gate
2719            .evaluate_with_ceiling(&act, Some(PermissionTier::SandboxEdit))
2720            .is_allow());
2721        assert!(gate
2722            .evaluate_with_ceiling(&act, Some(PermissionTier::FullAccess))
2723            .is_allow());
2724    }
2725
2726    #[test]
2727    fn gate_full_access_always_needs_approval_even_when_granted() {
2728        // Mandatory HITL: a FullAccess action is gated even for a
2729        // FullAccess session.
2730        let gate = PermissionGate::new(PermissionTier::FullAccess);
2731        let d = gate.evaluate(&tool_call("deploy"));
2732        assert!(matches!(d, GateDecision::NeedsApproval { .. }), "{d:?}");
2733    }
2734
2735    #[test]
2736    fn approval_makes_future_evaluation_allow() {
2737        let mut gate = PermissionGate::new(PermissionTier::ReadOnly);
2738        let a = tool_call("deploy");
2739        assert!(matches!(
2740            gate.evaluate(&a),
2741            GateDecision::NeedsApproval { .. }
2742        ));
2743        gate.approve(&a, "matt", "reviewed the deploy plan", None)
2744            .unwrap();
2745        assert!(gate.evaluate(&a).is_allow());
2746    }
2747
2748    #[test]
2749    fn rejection_denies_future_evaluation() {
2750        let mut gate = PermissionGate::new(PermissionTier::FullAccess);
2751        let a = tool_call("transfer_funds");
2752        gate.reject(&a, "matt", "not authorized", None).unwrap();
2753        assert!(matches!(gate.evaluate(&a), GateDecision::Deny { .. }));
2754    }
2755
2756    #[test]
2757    fn shared_ledger_approval_is_visible_across_gates() {
2758        // The daemon substrate: per-session gates (tier state), ONE shared
2759        // ledger. An approval recorded through gate A's classification must
2760        // flip gate B's evaluation — with per-gate in-memory ledgers the
2761        // approver's decision would be stranded where the runner never reads
2762        // it (kernel review C1).
2763        let gate_a = PermissionGate::new(PermissionTier::ReadOnly);
2764        let gate_b = PermissionGate::new(PermissionTier::ReadOnly);
2765        let mut shared = ApprovalLedger::new();
2766        let a = tool_call("deploy");
2767
2768        assert!(matches!(
2769            gate_b.evaluate_against(&a, &shared),
2770            GateDecision::NeedsApproval { .. }
2771        ));
2772        // Approver (gate A's session) builds the record; the SHARED ledger
2773        // stores it.
2774        let rec = gate_a.decision_record(&a, ApprovalDecision::Approved, "matt", "ok", None);
2775        shared.record(rec).unwrap();
2776        // Runner (gate B's session) sees it.
2777        assert!(gate_b.evaluate_against(&a, &shared).is_allow());
2778        // The gates' own (empty) ledgers still gate — nothing leaked into them.
2779        assert!(matches!(
2780            gate_b.evaluate(&a),
2781            GateDecision::NeedsApproval { .. }
2782        ));
2783    }
2784
2785    #[test]
2786    fn ledger_record_decision_round_trips_fingerprint() {
2787        let mut ledger = ApprovalLedger::new();
2788        let rec = ledger
2789            .record_decision(
2790                "harness:retry:abcd1234",
2791                PermissionTier::SandboxEdit,
2792                ApprovalDecision::Approved,
2793                "conn:1",
2794                "reviewed",
2795                None,
2796            )
2797            .unwrap();
2798        assert_eq!(rec.fingerprint, "harness:retry:abcd1234");
2799        assert_eq!(
2800            ledger.lookup("harness:retry:abcd1234").map(|r| r.decision),
2801            Some(ApprovalDecision::Approved)
2802        );
2803    }
2804
2805    #[test]
2806    fn classifier_escalates_on_argv_array_command() {
2807        // The dangerous command is split across an argv array, so the raw
2808        // JSON never contains the literal "git push" substring — the
2809        // recursive haystack must still catch it.
2810        let mut params = Map::new();
2811        params.insert(
2812            "args".to_string(),
2813            serde_json::json!(["git", "push", "--force", "origin", "main"]),
2814        );
2815        let a = action(ActionType::ToolCall, Some("shell"), params);
2816        assert_eq!(
2817            RiskClassifier::new().classify(&a),
2818            PermissionTier::FullAccess
2819        );
2820    }
2821
2822    #[test]
2823    fn fingerprint_canonicalizes_nested_object_key_order() {
2824        // Two semantically identical actions whose params differ only in
2825        // NESTED object key order must share a fingerprint — otherwise a
2826        // standing rejection is evadable by permuting nested keys.
2827        let mk = |json: serde_json::Value| {
2828            let mut p = Map::new();
2829            p.insert("opts".to_string(), json);
2830            action(ActionType::ToolCall, Some("t"), p)
2831        };
2832        let a = mk(serde_json::json!({"a": 1, "b": {"x": 1, "y": 2}}));
2833        let b = mk(serde_json::json!({"b": {"y": 2, "x": 1}, "a": 1}));
2834        assert_eq!(action_fingerprint(&a), action_fingerprint(&b));
2835    }
2836
2837    #[test]
2838    fn fingerprint_uses_stable_type_tag_not_debug() {
2839        // The fingerprint must carry the stable serde tag, never the
2840        // Debug spelling.
2841        let fp = action_fingerprint(&tool_call("x"));
2842        assert!(fp.starts_with("tool_call|"), "got {fp}");
2843        assert!(!fp.contains("ToolCall"));
2844    }
2845
2846    #[test]
2847    fn approval_does_not_survive_upward_reclassification() {
2848        // An operation approved at SandboxEdit must NOT remain allowed
2849        // after a classifier change pushes it to FullAccess.
2850        let a = tool_call("safe_tool");
2851        // Record an approval at SandboxEdit (the tier when reviewed).
2852        let mut classifier = RiskClassifier::new();
2853        let mut gate = PermissionGate::new(PermissionTier::SandboxEdit).with_classifier(classifier);
2854        gate.approve(&a, "matt", "looked fine", None).unwrap();
2855        assert!(gate.evaluate(&a).is_allow());
2856
2857        // Now a stricter classifier reclassifies the same op as FullAccess.
2858        classifier = RiskClassifier::new();
2859        classifier.add_rule("now_dangerous", PermissionTier::FullAccess, |act| {
2860            act.tool.as_deref() == Some("safe_tool")
2861        });
2862        let gate = gate.with_classifier(classifier);
2863        assert!(
2864            matches!(gate.evaluate(&a), GateDecision::NeedsApproval { .. }),
2865            "stale low-tier approval must not bypass the FullAccess gate"
2866        );
2867    }
2868
2869    #[test]
2870    fn fingerprint_is_param_sensitive_and_stable() {
2871        let a1 = {
2872            let mut p = Map::new();
2873            p.insert("x".to_string(), serde_json::json!(1));
2874            p.insert("y".to_string(), serde_json::json!(2));
2875            action(ActionType::ToolCall, Some("t"), p)
2876        };
2877        let a2 = {
2878            // same params, inserted in a different order → same fingerprint
2879            let mut p = Map::new();
2880            p.insert("y".to_string(), serde_json::json!(2));
2881            p.insert("x".to_string(), serde_json::json!(1));
2882            action(ActionType::ToolCall, Some("t"), p)
2883        };
2884        assert_eq!(action_fingerprint(&a1), action_fingerprint(&a2));
2885        // different params → different fingerprint
2886        let a3 = {
2887            let mut p = Map::new();
2888            p.insert("x".to_string(), serde_json::json!(99));
2889            action(ActionType::ToolCall, Some("t"), p)
2890        };
2891        assert_ne!(action_fingerprint(&a1), action_fingerprint(&a3));
2892    }
2893
2894    #[test]
2895    fn ledger_journal_round_trips() {
2896        let dir = std::env::temp_dir();
2897        let path = dir.join(format!("car-approvals-test-{}.jsonl", std::process::id()));
2898        let _ = std::fs::remove_file(&path);
2899
2900        let a = tool_call("deploy");
2901        {
2902            let ledger = ApprovalLedger::with_journal(&path).unwrap();
2903            let mut gate = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger);
2904            gate.approve(&a, "matt", "ok", Some("diff: +1 -0".to_string()))
2905                .unwrap();
2906        }
2907        // A fresh ledger loading the same journal sees the decision.
2908        let ledger2 = ApprovalLedger::with_journal(&path).unwrap();
2909        let gate2 = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger2);
2910        assert!(gate2.evaluate(&a).is_allow());
2911
2912        let _ = std::fs::remove_file(&path);
2913    }
2914
2915    #[cfg(unix)]
2916    fn approval(fingerprint: &str) -> ApprovalRecord {
2917        ApprovalRecord {
2918            fingerprint: fingerprint.to_string(),
2919            required_tier: PermissionTier::FullAccess,
2920            decision: ApprovalDecision::Approved,
2921            reviewer: "matt".into(),
2922            reason: "ok".into(),
2923            evidence: None,
2924            decided_at: chrono::Utc::now().to_rfc3339(),
2925        }
2926    }
2927
2928    #[cfg(unix)]
2929    fn mode(path: &std::path::Path) -> u32 {
2930        use std::os::unix::fs::PermissionsExt;
2931        std::fs::metadata(path).unwrap().permissions().mode() & 0o777
2932    }
2933
2934    /// #3403: a private journal is created owner-only, and a reload finds the
2935    /// approval under the action's own fingerprint, so private storage does
2936    /// not change what an approval matches.
2937    #[cfg(unix)]
2938    #[test]
2939    fn private_journal_is_owner_only_and_keeps_the_fingerprint() {
2940        let dir = tempfile::tempdir().unwrap();
2941        let path = dir.path().join("approvals.jsonl");
2942        let a = tool_call_with("deploy", &[("env", json!("prod"))]);
2943        let fp = action_fingerprint(&a);
2944
2945        let ledger = ApprovalLedger::with_private_journal(&path).unwrap();
2946        let mut gate = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(ledger);
2947        gate.approve(&a, "matt", "ok", None).unwrap();
2948
2949        assert_eq!(mode(&path), 0o600, "approval journal must be owner-only");
2950        let reloaded = ApprovalLedger::with_private_journal(&path).unwrap();
2951        assert_eq!(
2952            reloaded.lookup(&fp).map(|r| r.decision),
2953            Some(ApprovalDecision::Approved)
2954        );
2955        let gate2 = PermissionGate::new(PermissionTier::ReadOnly).with_ledger(reloaded);
2956        assert!(gate2.evaluate(&a).is_allow());
2957    }
2958
2959    /// #3403: a symlink placed at a private journal's path after load is
2960    /// refused on write, and the refused decision is not stored in memory.
2961    #[cfg(unix)]
2962    #[test]
2963    fn private_journal_symlink_is_refused() {
2964        let dir = tempfile::tempdir().unwrap();
2965        let target = dir.path().join("elsewhere.jsonl");
2966        std::fs::write(&target, "").unwrap();
2967        let path = dir.path().join("approvals.jsonl");
2968        // Loaded before the symlink appears, so this is the write-side check.
2969        let mut ledger = ApprovalLedger::with_private_journal(&path).unwrap();
2970        std::os::unix::fs::symlink(&target, &path).unwrap();
2971        assert!(ledger.record(approval("fp-symlink")).is_err());
2972        assert!(ledger.lookup("fp-symlink").is_none());
2973        assert_eq!(std::fs::read_to_string(&target).unwrap(), "");
2974    }
2975
2976    /// #3403: a symlink at a private journal's path is refused at load, so an
2977    /// `Approved` row in the file it points at is never honoured.
2978    #[cfg(unix)]
2979    #[test]
2980    fn private_journal_symlink_to_a_forged_approval_is_refused_at_load() {
2981        let dir = tempfile::tempdir().unwrap();
2982        let a = tool_call("deploy");
2983        let forged = dir.path().join("forged.jsonl");
2984        let row = serde_json::to_string(&approval(&action_fingerprint(&a))).unwrap();
2985        std::fs::write(&forged, format!("{row}\n")).unwrap();
2986        let path = dir.path().join("approvals.jsonl");
2987        std::os::unix::fs::symlink(&forged, &path).unwrap();
2988
2989        assert!(ApprovalLedger::with_private_journal(&path).is_err());
2990        // The caller-path constructor still reads through it, as before.
2991        let caller = ApprovalLedger::with_journal(&path).unwrap();
2992        assert!(caller.lookup(&action_fingerprint(&a)).is_some());
2993    }
2994
2995    /// #3403: loading a private journal tightens a looser file in place.
2996    #[cfg(unix)]
2997    #[test]
2998    fn private_journal_load_tightens_a_loose_file() {
2999        use std::os::unix::fs::PermissionsExt;
3000        let dir = tempfile::tempdir().unwrap();
3001        let path = dir.path().join("approvals.jsonl");
3002        let row = serde_json::to_string(&approval("fp-kept")).unwrap();
3003        std::fs::write(&path, format!("{row}\n")).unwrap();
3004        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3005
3006        let ledger = ApprovalLedger::with_private_journal(&path).unwrap();
3007        assert_eq!(mode(&path), 0o600);
3008        assert!(ledger.lookup("fp-kept").is_some());
3009    }
3010
3011    /// #3403: a journal an older version left with a looser mode is tightened
3012    /// in place on the next record; its decisions are kept.
3013    #[cfg(unix)]
3014    #[test]
3015    fn loose_private_journal_is_tightened_in_place() {
3016        use std::os::unix::fs::PermissionsExt;
3017        let dir = tempfile::tempdir().unwrap();
3018        let path = dir.path().join("approvals.jsonl");
3019        let old = serde_json::to_string(&approval("fp-old")).unwrap();
3020        std::fs::write(&path, format!("{old}\n")).unwrap();
3021        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
3022
3023        let mut ledger = ApprovalLedger::with_private_journal(&path).unwrap();
3024        ledger.record(approval("fp-new")).unwrap();
3025
3026        assert_eq!(mode(&path), 0o600);
3027        let contents = std::fs::read_to_string(&path).unwrap();
3028        assert!(contents.starts_with(&old), "existing decisions are kept");
3029        let reloaded = ApprovalLedger::with_private_journal(&path).unwrap();
3030        assert!(reloaded.lookup("fp-old").is_some());
3031        assert!(reloaded.lookup("fp-new").is_some());
3032    }
3033
3034    /// A caller-chosen journal (the FFI `ledger_path`) keeps working through a
3035    /// symlinked directory, which the private open would refuse.
3036    #[cfg(unix)]
3037    #[test]
3038    fn caller_journal_still_records_through_a_symlinked_directory() {
3039        let dir = tempfile::tempdir().unwrap();
3040        let real = dir.path().join("real");
3041        std::fs::create_dir(&real).unwrap();
3042        let link = dir.path().join("link");
3043        std::os::unix::fs::symlink(&real, &link).unwrap();
3044
3045        let mut ledger = ApprovalLedger::with_journal(link.join("approvals.jsonl")).unwrap();
3046        ledger.record(approval("fp-caller")).unwrap();
3047        assert!(std::fs::read_to_string(real.join("approvals.jsonl"))
3048            .unwrap()
3049            .contains("fp-caller"));
3050    }
3051
3052    #[test]
3053    fn unwritable_journal_errors_and_stores_nothing() {
3054        // Review A7: a journal write failure must surface as an error, and
3055        // the decision must NOT land in memory either — otherwise callers
3056        // emit ApprovalRecorded audit events for a decision that isn't
3057        // durable. A directory as the journal path makes the append fail.
3058        let dir = std::env::temp_dir().join(format!("car-approvals-dir-{}", std::process::id()));
3059        let _ = std::fs::remove_dir_all(&dir);
3060        std::fs::create_dir_all(&dir).unwrap();
3061
3062        let mut ledger = ApprovalLedger {
3063            records: HashMap::new(),
3064            journal: Some(dir.clone()),
3065            private_journal: false,
3066            skipped_on_load: 0,
3067        };
3068        let a = tool_call("deploy");
3069        let fp = action_fingerprint(&a);
3070        let err = ledger.record(ApprovalRecord {
3071            fingerprint: fp.clone(),
3072            required_tier: PermissionTier::FullAccess,
3073            decision: ApprovalDecision::Approved,
3074            reviewer: "matt".into(),
3075            reason: "ok".into(),
3076            evidence: None,
3077            decided_at: chrono::Utc::now().to_rfc3339(),
3078        });
3079        assert!(err.is_err(), "journal write failure must surface");
3080        assert!(
3081            ledger.lookup(&fp).is_none(),
3082            "failed record must not be stored in memory"
3083        );
3084
3085        let _ = std::fs::remove_dir_all(&dir);
3086    }
3087
3088    // --- The second axis: reversibility -----------------------------------
3089
3090    /// The reason the axis exists. Every row asserts **both** classifiers,
3091    /// because a test that only checked reversibility would pass just as well
3092    /// if `classify_reversibility` were secretly derived from the tier — and
3093    /// that derivation is the bug this work removes.
3094    #[test]
3095    fn the_two_axes_disagree_in_both_directions() {
3096        let cases: Vec<(&str, Action, PermissionTier, Reversibility)> = vec![
3097            (
3098                "a state read observes and mutates nothing",
3099                action(ActionType::StateRead, None, Map::new()),
3100                PermissionTier::ReadOnly,
3101                Reversibility::Reversible,
3102            ),
3103            (
3104                "a scratch-file write is undone by discarding the sandbox",
3105                tool_call_with(
3106                    "write_file",
3107                    &[
3108                        ("path", serde_json::json!("/tmp/car-sandbox/notes.txt")),
3109                        ("contents", serde_json::json!("hello")),
3110                    ],
3111                ),
3112                PermissionTier::SandboxEdit,
3113                Reversibility::Reversible,
3114            ),
3115            (
3116                "reading a credential takes the top grant and leaves nothing to undo",
3117                tool_call("read_secret"),
3118                PermissionTier::FullAccess,
3119                Reversibility::Reversible,
3120            ),
3121            (
3122                "a row insert reads as low-authority but needs a compensating delete",
3123                tool_call_with(
3124                    "db_insert",
3125                    &[
3126                        ("table", serde_json::json!("orders")),
3127                        ("row", serde_json::json!({"id": 7})),
3128                    ],
3129                ),
3130                PermissionTier::SandboxEdit,
3131                Reversibility::Compensable,
3132            ),
3133            (
3134                "a push takes the top grant AND is recoverable — force-push the prior ref",
3135                tool_call("git_push"),
3136                PermissionTier::FullAccess,
3137                Reversibility::Compensable,
3138            ),
3139            (
3140                "a sent email takes the top grant and is permanent",
3141                tool_call_with(
3142                    "send_email",
3143                    &[
3144                        ("to", serde_json::json!("ops@example.com")),
3145                        ("subject", serde_json::json!("nightly status")),
3146                    ],
3147                ),
3148                PermissionTier::FullAccess,
3149                Reversibility::Irreversible,
3150            ),
3151            (
3152                "a charged card takes the top grant and is permanent",
3153                tool_call_with("charge_card", &[("amount_cents", serde_json::json!(4200))]),
3154                PermissionTier::FullAccess,
3155                Reversibility::Irreversible,
3156            ),
3157            (
3158                "a write outside the sandbox reads as low-authority and is unrecoverable",
3159                tool_call_with(
3160                    "write_file",
3161                    &[
3162                        ("path", serde_json::json!("/etc/hosts")),
3163                        ("contents", serde_json::json!("127.0.0.1 x")),
3164                    ],
3165                ),
3166                PermissionTier::SandboxEdit,
3167                Reversibility::Irreversible,
3168            ),
3169        ];
3170
3171        let classifier = RiskClassifier::new();
3172        for (why, act, tier, rev) in cases {
3173            assert_eq!(classifier.classify(&act), tier, "required tier — {why}");
3174            assert_eq!(classify_reversibility(&act), rev, "reversibility — {why}");
3175        }
3176    }
3177
3178    #[test]
3179    fn evaluate_axes_agrees_with_computing_the_axes_separately() {
3180        // #856 shares one flattened haystack between the two classifiers. That
3181        // is only safe if it changes no verdict, so pin both axes against the
3182        // independent paths across shapes that exercise the tier keywords, the
3183        // reversibility phrases, and the sandbox rule.
3184        let cases = vec![
3185            tool_call("send_email"),
3186            tool_call("read_secret"),
3187            tool_call_with(
3188                "shell",
3189                &[
3190                    ("command", json!("git")),
3191                    ("args", json!(["push", "--force", "origin", "main"])),
3192                ],
3193            ),
3194            tool_call_with("write_file", &[("path", json!("/tmp/scratch/a.txt"))]),
3195            tool_call_with("write_file", &[("path", json!("/etc/hosts"))]),
3196            tool_call_with("execute_query", &[("sql", json!("UPDATE t SET x = 1"))]),
3197            action(ActionType::StateRead, None, Map::new()),
3198            action(ActionType::StateWrite, None, Map::new()),
3199        ];
3200
3201        for granted in [
3202            PermissionTier::ReadOnly,
3203            PermissionTier::SandboxEdit,
3204            PermissionTier::FullAccess,
3205        ] {
3206            let gate = PermissionGate::new(granted);
3207            for a in &cases {
3208                let axes = gate.evaluate_axes(a, None, None);
3209                assert_eq!(
3210                    axes.decision,
3211                    gate.evaluate(a),
3212                    "decision drifted for {:?} at {granted:?}",
3213                    a.tool
3214                );
3215                assert_eq!(
3216                    axes.reversibility,
3217                    classify_reversibility(a),
3218                    "reversibility drifted for {:?}",
3219                    a.tool
3220                );
3221            }
3222        }
3223
3224        // The ceiling argument still caps standing authority.
3225        let gate = PermissionGate::new(PermissionTier::FullAccess);
3226        let a = tool_call("deploy_service");
3227        assert_eq!(
3228            gate.evaluate_axes(&a, Some(PermissionTier::ReadOnly), None)
3229                .decision,
3230            gate.evaluate_with_ceiling(&a, Some(PermissionTier::ReadOnly)),
3231        );
3232    }
3233
3234    #[test]
3235    fn neither_axis_is_a_function_of_the_other() {
3236        // The structural claim, stated twice: one tier spans all three rollback
3237        // contracts, and one rollback contract spans all three tiers. No
3238        // mapping in either direction could reproduce both.
3239        let c = RiskClassifier::new();
3240
3241        for (act, rev) in [
3242            (tool_call("read_secret"), Reversibility::Reversible),
3243            (tool_call("git_push"), Reversibility::Compensable),
3244            (tool_call("send_email"), Reversibility::Irreversible),
3245        ] {
3246            let name = act.tool.clone().unwrap_or_default();
3247            assert_eq!(c.classify(&act), PermissionTier::FullAccess, "tier {name}");
3248            assert_eq!(classify_reversibility(&act), rev, "reversibility {name}");
3249        }
3250
3251        for (act, tier) in [
3252            (
3253                action(ActionType::StateRead, None, Map::new()),
3254                PermissionTier::ReadOnly,
3255            ),
3256            (
3257                action(ActionType::StateWrite, None, Map::new()),
3258                PermissionTier::SandboxEdit,
3259            ),
3260            (tool_call("read_secret"), PermissionTier::FullAccess),
3261        ] {
3262            assert_eq!(
3263                classify_reversibility(&act),
3264                Reversibility::Reversible,
3265                "reversibility {:?}",
3266                act.tool
3267            );
3268            assert_eq!(c.classify(&act), tier, "tier {:?}", act.tool);
3269        }
3270    }
3271
3272    #[test]
3273    fn state_actions_are_settled_by_their_type() {
3274        for kind in [ActionType::StateRead, ActionType::Assertion] {
3275            assert_eq!(
3276                classify_reversibility(&action(kind, None, Map::new())),
3277                Reversibility::Reversible
3278            );
3279        }
3280        // A state write's whole footprint is the KV store, which snapshot /
3281        // rollback restores — so it stays `Reversible` even when its *value*
3282        // happens to read like something destructive. The keyword ladder only
3283        // runs for tool calls, whose effects are the opaque ones.
3284        let mut p = Map::new();
3285        p.insert("value".to_string(), serde_json::json!("rm -rf /"));
3286        assert_eq!(
3287            classify_reversibility(&action(ActionType::StateWrite, None, p)),
3288            Reversibility::Reversible
3289        );
3290    }
3291
3292    #[test]
3293    fn a_generic_shell_is_classified_from_its_argv() {
3294        // The signal that fires when the tool name says nothing at all. Neither
3295        // phrase exists in the raw JSON — the argv array only becomes matchable
3296        // text once it is flattened.
3297        let destructive = tool_call_with(
3298            "shell",
3299            &[("args", serde_json::json!(["rm", "-rf", "/var/data"]))],
3300        );
3301        let recoverable = tool_call_with(
3302            "shell",
3303            &[("args", serde_json::json!(["git", "push", "origin", "main"]))],
3304        );
3305        assert_eq!(
3306            classify_reversibility(&destructive),
3307            Reversibility::Irreversible
3308        );
3309        assert_eq!(
3310            classify_reversibility(&recoverable),
3311            Reversibility::Compensable
3312        );
3313
3314        // Both are FullAccess: the authority ladder cannot tell them apart,
3315        // which is exactly the gap the second axis fills.
3316        let c = RiskClassifier::new();
3317        assert_eq!(c.classify(&destructive), PermissionTier::FullAccess);
3318        assert_eq!(c.classify(&recoverable), PermissionTier::FullAccess);
3319    }
3320
3321    #[test]
3322    fn severity_wins_when_both_families_match() {
3323        // `create` is compensable and `payment` is irreversible; the ladder is
3324        // most-severe-first, so the permanent reading wins.
3325        assert_eq!(
3326            classify_reversibility(&tool_call("create_payment")),
3327            Reversibility::Irreversible
3328        );
3329        // A retrieval verb does not rescue a destructive one — step 2 requires
3330        // the name to carry NO mutating segment.
3331        assert_eq!(
3332            classify_reversibility(&tool_call("get_and_delete")),
3333            Reversibility::Irreversible
3334        );
3335        assert_eq!(
3336            classify_reversibility(&tool_call("list_and_push")),
3337            Reversibility::Compensable
3338        );
3339    }
3340
3341    #[test]
3342    fn sandbox_confinement_is_all_or_nothing() {
3343        // One absolute path outside scratch space disqualifies the action, even
3344        // though another is inside it.
3345        let mixed = tool_call_with(
3346            "write_file",
3347            &[
3348                ("src", serde_json::json!("/tmp/car-sandbox/in.txt")),
3349                ("dst", serde_json::json!("/etc/hosts")),
3350            ],
3351        );
3352        assert_eq!(classify_reversibility(&mixed), Reversibility::Irreversible);
3353
3354        // A relative path is invisible to the rule (no cwd to resolve it
3355        // against), so it neither qualifies nor disqualifies — and the action
3356        // falls through to the conservative default.
3357        let relative = tool_call_with("write_file", &[("path", serde_json::json!("notes.txt"))]);
3358        assert_eq!(
3359            classify_reversibility(&relative),
3360            Reversibility::Irreversible
3361        );
3362    }
3363
3364    #[test]
3365    fn the_sandbox_rule_does_not_leak_to_non_filesystem_tools() {
3366        // An incidental scratch path in a network tool's parameters must not
3367        // talk that tool down to `Reversible`.
3368        let a = tool_call_with(
3369            "http_post",
3370            &[("body_file", serde_json::json!("/tmp/payload.json"))],
3371        );
3372        assert_eq!(classify_reversibility(&a), Reversibility::Irreversible);
3373    }
3374
3375    // ---- Regressions for the four classifier defects found in review. ----
3376    // Each of these fails on the pre-fix classifier; the failure direction is
3377    // the one the module docs commit to never taking (a destructive action
3378    // reported as recoverable), except the first, which failed in BOTH.
3379
3380    #[test]
3381    fn classification_is_deterministic_across_parameter_orderings() {
3382        // `Action::parameters` is a std HashMap with per-process randomized
3383        // iteration. Flattening it directly made a phrase form between two
3384        // unrelated parameters on some draws and not others: a force-push came
3385        // back `compensable` ~285/500 and `irreversible` ~215/500 in one
3386        // process. Same action, same call, different audit row.
3387        //
3388        // Rebuilding the map many times exercises fresh orderings; the answer
3389        // must not move.
3390        let mk = || {
3391            tool_call_with(
3392                "shell",
3393                &[
3394                    ("command", json!("git")),
3395                    ("args", json!(["push", "--force", "origin", "main"])),
3396                    ("cwd", json!("/srv/app")),
3397                    ("timeout", json!(30)),
3398                ],
3399            )
3400        };
3401        let first = classify_reversibility(&mk());
3402        for i in 0..256 {
3403            assert_eq!(
3404                classify_reversibility(&mk()),
3405                first,
3406                "reversibility moved on iteration {i}"
3407            );
3408        }
3409        // And the command line is genuinely reconstructed: `git push` is only
3410        // spellable across the `command`/`args` boundary.
3411        assert_eq!(first, Reversibility::Compensable);
3412    }
3413
3414    #[test]
3415    fn command_and_args_stay_adjacent_so_rm_rf_is_still_seen() {
3416        // The counterpart risk to the fix above: separating parameters to stop
3417        // phantom phrases must not lose the ONE cross-parameter adjacency that
3418        // is real. Sorting keys alphabetically puts `args` before `command` and
3419        // silently drops `rm -rf` — a deterministic miss, worse than a random
3420        // one, and in the unsafe direction.
3421        let a = tool_call_with(
3422            "shell",
3423            &[
3424                ("command", json!("rm")),
3425                ("args", json!(["-rf", "/var/data"])),
3426            ],
3427        );
3428        assert_eq!(classify_reversibility(&a), Reversibility::Irreversible);
3429    }
3430
3431    #[test]
3432    fn a_read_verb_in_the_name_cannot_override_a_mutation_in_the_parameters() {
3433        // Step 2 is the only path that reaches `Reversible` without positive
3434        // evidence of confinement, and it used to fire on the tool NAME alone
3435        // without ever inspecting the parameters. `query` is the literal tool
3436        // name the MCP Postgres connector exposes.
3437        for (tool, params) in [
3438            ("execute_query", json!("UPDATE accounts SET balance = 0")),
3439            ("db_query", json!("ALTER TABLE users DROP COLUMN email")),
3440            ("query", json!("INSERT INTO audit VALUES (1)")),
3441            ("search_index", json!("DROP INDEX idx_users")),
3442        ] {
3443            let a = tool_call_with(tool, &[("sql", params)]);
3444            assert_ne!(
3445                classify_reversibility(&a),
3446                Reversibility::Reversible,
3447                "{tool} carries a mutation and must not classify as reversible"
3448            );
3449        }
3450        // A find that deletes what it finds.
3451        let a = tool_call_with(
3452            "find",
3453            &[
3454                ("command", json!("find")),
3455                ("args", json!(["/data", "-name", "*.log", "-delete"])),
3456            ],
3457        );
3458        assert_ne!(classify_reversibility(&a), Reversibility::Reversible);
3459        // The control: a genuine read still short-circuits.
3460        let a = tool_call_with("execute_query", &[("sql", json!("SELECT id FROM users"))]);
3461        assert_eq!(classify_reversibility(&a), Reversibility::Reversible);
3462    }
3463
3464    #[test]
3465    fn only_os_scratch_roots_count_as_discardable() {
3466        // `SANDBOX_PATH_MARKERS` matched the bare substrings "sandbox" and
3467        // "scratch" anywhere in a path token, so ordinary production
3468        // directories that happen to contain those words read as throwaway.
3469        for path in [
3470            "/srv/sandbox-prod/index.html",
3471            "/System/Library/Sandbox/Profiles/x.sb",
3472            "/opt/scratch-data/customers.db",
3473            "/var/lib/sandbox/state.json",
3474            // `contains` rather than `starts_with` also accepted this one.
3475            "/etc/tmp/hosts",
3476        ] {
3477            let a = tool_call_with("write_file", &[("path", json!(path))]);
3478            assert_ne!(
3479                classify_reversibility(&a),
3480                Reversibility::Reversible,
3481                "{path} is not OS-designated scratch space"
3482            );
3483        }
3484        // Real scratch roots still qualify.
3485        for path in ["/tmp/build/out.txt", "/private/var/folders/xy/z/T/a.txt"] {
3486            let a = tool_call_with("write_file", &[("path", json!(path))]);
3487            assert_eq!(
3488                classify_reversibility(&a),
3489                Reversibility::Reversible,
3490                "{path} is scratch space"
3491            );
3492        }
3493    }
3494
3495    #[test]
3496    fn an_incidental_scratch_path_cannot_vouch_for_a_write_elsewhere() {
3497        // Step 5 read every string in the parameters, so a `/tmp` path in a
3498        // file's CONTENTS licensed a write whose actual target was a relative
3499        // path the classifier has already said it cannot resolve.
3500        let a = tool_call_with(
3501            "write_file",
3502            &[
3503                ("path", json!("config.yaml")),
3504                ("contents", json!("cache_dir: /tmp/app\n")),
3505            ],
3506        );
3507        assert_eq!(
3508            classify_reversibility(&a),
3509            Reversibility::Irreversible,
3510            "the target is a relative path; the /tmp string is incidental"
3511        );
3512
3513        // Same shape, but the target really is scratch space.
3514        let a = tool_call_with(
3515            "write_file",
3516            &[
3517                ("path", json!("/tmp/app/config.yaml")),
3518                ("contents", json!("cache_dir: ./app\n")),
3519            ],
3520        );
3521        assert_eq!(classify_reversibility(&a), Reversibility::Reversible);
3522
3523        // A scratch target plus a non-scratch target is still not discardable.
3524        let a = tool_call_with(
3525            "write_file",
3526            &[("paths", json!(["/tmp/a.txt", "/etc/hosts"]))],
3527        );
3528        assert_eq!(classify_reversibility(&a), Reversibility::Irreversible);
3529
3530        // ...and the veto does not depend on the dangerous path arriving under
3531        // a key `PATH_PARAM_KEYS` happens to know. `dst` is not in that set; if
3532        // only recognized keys were scanned, `/etc/hosts` would be invisible
3533        // and the scratch `src` would wrongly vouch for the whole action.
3534        let a = tool_call_with(
3535            "write_file",
3536            &[
3537                ("src", json!("/tmp/in.txt")),
3538                ("dst", json!("/etc/hosts")),
3539                ("mode", json!("0644")),
3540            ],
3541        );
3542        assert_eq!(
3543            classify_reversibility(&a),
3544            Reversibility::Irreversible,
3545            "an unrecognized key must still be able to veto"
3546        );
3547    }
3548
3549    #[test]
3550    fn unrecognized_tools_default_to_irreversible() {
3551        for name in ["frobnicate", "acme_widget", "run"] {
3552            assert_eq!(
3553                classify_reversibility(&tool_call(name)),
3554                Reversibility::Irreversible,
3555                "{name} is unrecognized and must assume the worst"
3556            );
3557        }
3558    }
3559
3560    #[test]
3561    fn documented_over_classifications_stay_documented() {
3562        // The misses named in `classify_reversibility`'s "What this is not"
3563        // section, pinned so the docs cannot drift away from the behavior.
3564        // Both err toward the conservative answer, which is the point.
3565        assert_eq!(
3566            classify_reversibility(&tool_call("list_deployments")),
3567            Reversibility::Compensable,
3568            "a read over a mutating noun over-classifies"
3569        );
3570        assert_eq!(
3571            classify_reversibility(&tool_call("get_payment")),
3572            Reversibility::Irreversible,
3573            "money is matched by its object, not its verb"
3574        );
3575    }
3576}