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