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