Skip to main content

safe_chains/engine/
resolve.rs

1//! The profile resolver — turning a parsed command into its behavior profile
2//! (annex `behavioral-taxonomy-engine`). Runs via `engine::bridge`, which is
3//! AUTHORITATIVE for every command it can resolve (`engine_verdict(tokens).unwrap_or(legacy)`
4//! in `cst::check::leaf_verdict`) — there is no opt-out.
5//!
6//! This file holds the dispatch (`resolve`) and the per-command `resolve_*` functions;
7//! the shared toolkit they build on lives in submodules: [`flags`] (the getopt-style
8//! flag walker), [`locus`] (`classify_locus` — the [`LocalLocus`] ladder that refines the
9//! old `is_safe_write_target` boolean, v1.4 §2.2), and [`capability`] (the builders that
10//! stamp out each `Capability` with the facet pairing its operation warrants).
11
12use super::facet::*;
13use crate::parse::{Token, has_flag};
14
15mod capability;
16mod flags;
17pub(crate) mod locus;
18pub(crate) mod regions;
19#[cfg(test)]
20mod scenarios;
21
22use capability::{
23    breadth_scale, creates, destroys, executes, mutates, observes, overwrites, reads_content,
24    reads_to_model, relocates, transfer_profile, worst, writes_export_file,
25};
26use flags::{walk_positionals, walk_value};
27use locus::{classify_locus, read_locus, write_locus};
28pub(crate) use locus::{anchoring_of, names_credential_store};
29pub(crate) use locus::{is_substitution_value, is_unpinnable};
30
31/// For `for VAR in ITEMS; do …$VAR…`, the representatives to bind `$VAR` to in the body: the
32/// worst-READ item and the worst-WRITE item of the list (they can differ, so a read and a
33/// write of `$VAR` each get their list's worst case). `$VAR` then inherits the list's locus
34/// per operation — the `find … {}`→path binding, one layer up. `None` for an empty list, which
35/// leaves `$VAR` fail-closed (machine). An item the classifier cannot bound — an UNDECLARED command
36/// substitution, a process substitution, arithmetic — worst-cases to machine via a `$`-carrying
37/// sentinel representative. An item from a substitution whose inner command DECLARED its output
38/// locus is bounded, so it is classified like any other path: `for f in $(fd a app/)` reads the
39/// worktree, and `for f in $(fd a /etc)` still lands at machine because that is what the tag says.
40/// (The test here used to be a `__SAFE_CHAINS_` PREFIX match, which caught the bounded sentinel too
41/// and made the loop form deny while the bare `cat $(fd a app/)` was allowed.)
42pub(crate) fn loop_reprs(items: &[String]) -> Option<(String, String)> {
43    if items.is_empty() {
44        return None;
45    }
46    let faced: Vec<(String, LocalLocus, LocalLocus)> = items
47        .iter()
48        .map(|s| {
49            if crate::cst::check::is_opaque_value(s) {
50                ("$loop_sub".to_string(), LocalLocus::Machine, LocalLocus::Machine)
51            } else {
52                (s.clone(), read_locus(s), write_locus(s))
53            }
54        })
55        .collect();
56    let read_item = faced.iter().max_by_key(|(_, r, _)| *r).map(|(s, _, _)| s.clone())?;
57    let write_item = faced.iter().max_by_key(|(_, _, w)| *w).map(|(s, _, _)| s.clone())?;
58    // Freeze against the CURRENT (outer) loop bindings, so an inner representative like `$d/x`
59    // doesn't carry a stale outer variable into the body — nested loops compose.
60    let read_repr = crate::pathctx::expand_vars(&read_item, false).into_owned();
61    let write_repr = crate::pathctx::expand_vars(&write_item, true).into_owned();
62    Some((read_repr, write_repr))
63}
64
65/// The verdict for READING the content of `path` — used to gate an input-redirect source
66/// (`cmd < path`) by its read locus, exactly as an operand read is gated, so `cat < /etc/shadow`
67/// denies like `cat /etc/shadow`. `-` / stdin never reaches here (redirects always name a file).
68pub(crate) fn read_content_verdict(path: &str) -> crate::verdict::Verdict {
69    let cap = reads_content(read_locus(path), Scale::Single, "reads a redirect source");
70    crate::engine::bridge::project(&Profile::of(vec![cap]))
71}
72
73/// The verdict for WRITING/overwriting `path` — used to gate a legacy writer command's file
74/// operand (`tee`/`shred`/`bzip2`) by its write locus, so `shred /etc/hosts` denies.
75pub(crate) fn write_target_verdict(path: &str) -> crate::verdict::Verdict {
76    let cap = overwrites(write_locus(path), Scale::Single, false);
77    crate::engine::bridge::project(&Profile::of(vec![cap]))
78}
79
80/// Judge a path value, taking the WORST element when it is a colon-separated LIST.
81///
82/// The one place the list rule lives, so the environment gate and the flag gate cannot drift apart.
83/// They HAD drifted: the env gate always split on `:` while the flag gate never did, so
84/// `BORG_RSH=x:/tmp/evil` denied and `borg --rsh x:/tmp/evil` — the same operation — was approved.
85///
86/// Splitting is opt-OUT rather than opt-in, because the two mistakes are not symmetric. Treating a
87/// real list as one string is a FAIL-OPEN: `PYTHONPATH=/tmp/evil:/ok` read whole matches no locus
88/// rule and sails through. Treating a single value as a list is merely stricter. So a value splits
89/// unless its entry says it is a single value, and the entries that say so are commands
90/// (`BORG_RSH`, `RSYNC_RSH`) rather than search paths.
91///
92/// A URL is never split: `https://example.com` is not `https` plus `//example.com`, and splitting
93/// it denied every `curl` invocation in the suite.
94pub(crate) fn worst_path_element(
95    value: &str,
96    judge: fn(&str) -> crate::verdict::Verdict,
97    split_list: bool,
98) -> crate::verdict::Verdict {
99    let mut worst = judge(value);
100    if split_list && value.contains(':') && !value.contains("://") {
101        for element in value.split(':').filter(|s| !s.is_empty()) {
102            worst = worst.combine(judge(element));
103        }
104    }
105    worst
106}
107
108
109/// The verdict for EXECUTING the code in file `path` — used to gate an interpreter/runner's
110/// script operand (`bash x.sh`, `python x.py`, `node x.js`, `go run pkg/`) by its EXECUTOR
111/// locus. A worktree-local script is the dev loop → admitted at `developer`; a foreign one
112/// (`/tmp/x.sh`, `~/x.py`, `/usr/local/bin/x`) or an unpinnable path (`$VAR`, glob, `..`
113/// beyond cwd → `machine`) denies. `CallerFile` trust (code from a named file). See
114/// docs/design/behavioral-taxonomy-execution-origin.md.
115pub(crate) fn execute_file_verdict(path: &str) -> crate::verdict::Verdict {
116    // A GLOB executor (`bash *.sh`) names no specific file — the matched code is unknown, so
117    // it cannot be pinned to a worktree executor; deny (design §6). ($VAR/../cmdsub are already
118    // worst-cased by classify_locus.) A glob stays fine as a read/write OPERAND, where every
119    // match is locus-gated; only as an EXECUTOR is the code it would run unknowable.
120    if path.contains(['*', '?', '[']) {
121        return crate::engine::bridge::project(&worst("glob executor — the code that would run is unknown (§6)"));
122    }
123    let cap = executes(classify_locus(path), ExecutionTrust::CallerFile, "runs code from a named file");
124    crate::engine::bridge::project(&Profile::of(vec![cap]))
125}
126
127/// The verdict for running the CURRENT PROJECT's own code — an implicit-project runner
128/// (`cargo run`, `dotnet run`, `swift run`) with no path operand and no redirect out of the
129/// worktree. `SelfCode` @ `Worktree` → admitted at `developer`. A runner redirected out of the
130/// project (`cargo run --manifest-path ~/o/Cargo.toml`) resolves that path through
131/// [`execute_file_verdict`] instead. See docs/design/behavioral-taxonomy-execution-origin.md.
132pub(crate) fn execute_project_verdict() -> crate::verdict::Verdict {
133    let cap = executes(LocalLocus::Worktree, ExecutionTrust::SelfCode, "runs the current project's own code");
134    crate::engine::bridge::project(&Profile::of(vec![cap]))
135}
136
137/// Resolve a command's leaf tokens to its behavior profile, or `None` if the command
138/// has no resolver yet (the caller then worst-cases / falls back to the legacy
139/// classifier — §0 fail-closed). Redirects, substitutions, and chain semantics are the
140/// surrounding CST's job, not this leaf's (annex `…-engine` §1).
141pub fn resolve(tokens: &[Token]) -> Option<Profile> {
142    let arg0 = tokens.first()?;
143    // Canonicalize the invoked token through the registry's alias map (`gcat` → `cat`) BEFORE the
144    // resolver lookup: Homebrew installs GNU coreutils as g-prefixed aliases, and without this
145    // they'd miss every resolver and fall through to the ungated legacy classifier (a fail-open —
146    // `gtee /etc/cron.d/job`, `gcat /etc/shadow`). The `tokens` are passed through unchanged; the
147    // resolver gates operands by position, not by re-reading the command name.
148    let canonical = crate::registry::canonical_name(arg0.command_name());
149    // `sudo`/`doas` ELEVATE the wrapped command's authority — they are a delegating wrapper, not a
150    // command of their own. Resolve the inner command and lift its authority to root (or `other-user`
151    // for `-u`), so the safety of `sudo X` is the safety of `X` run privileged: `sudo cat ./notes`
152    // → a root READ (local-admin), `sudo rm -rf /` → the catastrophe corner (denied everywhere).
153    if canonical == "openssl" {
154        return resolve_openssl(arg0, tokens);
155    }
156    if matches!(canonical, "sudo" | "doas") {
157        return resolve_privilege_wrapper(arg0, tokens);
158    }
159    // Phase 1: a subcommand tagged with a facet archetype (`profile = …`) classifies as that
160    // archetype's static capability — the derived, self-documenting successor to `candidate = true`.
161    // Checked BEFORE command-level behavior, since a subcommand tool carries no `[command.behavior]`.
162    if let Some(names) = crate::registry::sub_archetypes(tokens) {
163        if !trusted_command_path(arg0.as_str()) {
164            return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
165        }
166        // An endpoint flag pointed at THIS machine makes the sub a DIFFERENT operation, not the same
167        // one with softened edges: `put-item --endpoint-url http://localhost:8000` writes to a
168        // process here, so `remote-mutate`'s `sends-host-data`, `effortful` reversibility and (for
169        // create) `metered` cost are all describing a cloud service that isn't in the picture. The
170        // sub names the archetype it becomes, so the substitution stays reviewable data rather than
171        // ad-hoc facet arithmetic at resolve time.
172        //
173        // Destroy archetypes may not declare a substitute at all — `assert_no_loopback_profile_on_
174        // destroy` refuses it at build time. We cannot verify the emulator claim (`ssh -L
175        // 8000:dynamodb.us-east-1.amazonaws.com:443` makes `localhost:8000` production, and no
176        // static classifier sees the tunnel), and that lie is only unrecoverable in the destroy
177        // direction.
178        // One capability per archetype (the sub's profile + each present escalating flag); the level
179        // algebra takes the max. Fail-closed: an unknown archetype name → a worst capability, so a
180        // typo or `unclassified` can never silently pass (a proptest catches typos at test time).
181        let mut caps: Vec<Capability> = names
182            .iter()
183            .map(|n| {
184                crate::engine::archetype::archetype(n).cloned().unwrap_or_else(|| {
185                    Capability::worst("subcommand/flag declares an unknown archetype (§0)")
186                })
187            })
188            .collect();
189        // Destination-trust (exposure §4): a sub tagged `network_destination` gets its send TARGET
190        // classified onto the base archetype's `locus.provenance` — established remote / literal URL
191        // / opaque `$VAR` — or, for a command-transport form (`ext::…`), worst-cased as RCE.
192        if let Some(dest) = crate::registry::sub_destination_token(tokens) {
193            match destination_provenance(dest) {
194                Some(prov) => {
195                    if let Some(base) = caps.first_mut() {
196                        base.locus.provenance = prov;
197                    }
198                }
199                None => {
200                    return Some(worst(
201                        "send target is a command transport (ext::…) — runs a local command, RCE (§4)",
202                    ));
203                }
204            }
205        }
206        // A `data-export` sub with an OUTPUT-FILE flag (`db dump -f out.sql`) writes its bulk result
207        // to a local file — a SECOND capability beyond the remote read, gated at the file's locus
208        // (worktree write vs a system-path clobber). Absent → the export streams to stdout, so the
209        // profile is the remote read alone.
210        if let Some(path) = crate::registry::sub_output_path_token(tokens) {
211            caps.push(writes_export_file(classify_locus(path)));
212        }
213        // A declared endpoint flag naming THIS machine changes WHERE the call goes, so it changes
214        // exactly the facets the destination determines and nothing else. That boundary is the whole
215        // design: `remote-mutate` describes a cloud service in four places — it reaches a fixed
216        // remote, talks outbound, sends host data off the machine, and bills — and all four are
217        // false for `http://localhost:8000`. Its other facets (what the operation DOES: the
218        // operation itself, scale, retrieval, reversibility, persistence, disclosure) are properties
219        // of the call, not of its destination, and stay untouched.
220        //
221        // Composing rather than substituting a whole "local" archetype matters twice over: the
222        // remote archetypes do not each need a local twin, and nothing here asserts a fact the
223        // destination cannot establish. (An earlier cut swapped in `local-mutate-recoverable`, which
224        // claims `locus.local = worktree` and `persistence = data` — both untrue of a container.)
225        //
226        // DESTROY is skipped here and refused outright at build time by
227        // `assert_loopback_localizes_is_coherent`. The emulator claim is unverifiable: `ssh -L
228        // 8000:dynamodb.<region>.amazonaws.com:443` makes `localhost:8000` production and no static
229        // classifier sees the tunnel. Being wrong costs a stray write; being wrong about a delete
230        // costs the data.
231        if crate::registry::sub_loopback_localizes(tokens) {
232            for c in &mut caps {
233                if c.operation == Operation::Destroy {
234                    continue;
235                }
236                c.locus.remote = RemoteReach::None;
237                c.network.direction = NetDirection::Loopback;
238                c.network.payload = NetPayload::None;
239                c.cost = Cost::None;
240                // The archetype's prose describes the cloud call and is now half wrong; say so,
241                // or `--explain` prints "changes remote state" over a profile that reaches no
242                // remote. The facets carry the classification, but the prose is what a human reads.
243                c.because = format!("{} — but the endpoint names this machine, so no remote is reached", c.because);
244            }
245        }
246        return Some(Profile::of(caps));
247    }
248    // A flat command whose top-level classifying flag (`[[command.flag]]`) is present resolves to
249    // that flag's archetype — the flag-triggered mode of a bimodal tool: `age -d` / `sops --decrypt`
250    // reveal plaintext to the model (`decrypt-read`), while the bare/encrypt form falls through to
251    // ordinary resolution below. Checked after the profiled-sub walk (a sub match wins) so a
252    // subcommand form (`sops decrypt`) and the flag form (`sops -d`) both classify.
253    if let Some(names) = crate::registry::command_flag_archetypes(tokens) {
254        if !trusted_command_path(arg0.as_str()) {
255            return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
256        }
257        let caps: Vec<Capability> = names
258            .iter()
259            .map(|n| {
260                crate::engine::archetype::archetype(n).cloned().unwrap_or_else(|| {
261                    Capability::worst("command flag declares an unknown archetype (§0)")
262                })
263            })
264            .collect();
265        return Some(Profile::of(caps));
266    }
267    // Every facet-classified command declares `[command.behavior]` (the coreutils are all ported;
268    // dd/tar/sed/grep declare a `hook`). No declaration → the command is unresearched for the
269    // engine, so return `None` (the caller falls back to the legacy classifier).
270    let spec = crate::registry::command_behavior(canonical)?;
271    // A resolvable basename reached via a NON-STANDARD path (`./cat`, `/tmp/cat`, `~/bin/grep`)
272    // is not necessarily the real tool — a planted binary named `cat` would be certified as safe
273    // coreutils. Don't certify it; worst-case (§0). Bare names and standard bin paths are
274    // trusted. (Legacy classifies purely by basename and inherits the spoof; the engine is
275    // stricter here, which keeps it never-looser.)
276    if !trusted_command_path(arg0.as_str()) {
277        return Some(worst("resolvable name invoked from a non-standard path — possible spoof (§0)"));
278    }
279    Some(resolve_behavior(spec, tokens))
280}
281
282/// `sudo`/`doas`: resolve the wrapped command and ELEVATE its authority. Authority is the axis every
283/// level below `local-admin` pins to `user`, so a root capability lands at `local-admin` (or `yolo`)
284/// — the projection does the rest. Fail-closed: an unknown sudo option, a root shell/editor
285/// (`-i`/`-s`/`-e`), or an inner command from a non-standard path worst-cases; an unresolved inner
286/// returns `None` so the caller's legacy fallback denies it (never *looser* than the bare command).
287fn resolve_privilege_wrapper(arg0: &Token, tokens: &[Token]) -> Option<Profile> {
288    if !trusted_command_path(arg0.as_str()) {
289        return Some(worst("sudo/doas invoked from a non-standard path — possible spoof (§0)"));
290    }
291    let mut i = 1;
292    let mut run_as_other = false;
293    'scan: while let Some(tok) = tokens.get(i) {
294        let t = tok.as_str();
295        if t == "--" {
296            i += 1;
297            break;
298        }
299        if !t.starts_with('-') || t == "-" {
300            break; // the inner command starts here
301        }
302        if let Some(long) = t.strip_prefix("--") {
303            let (name, glued_val) = match long.split_once('=') {
304                Some((n, _)) => (n, true),
305                None => (long, false),
306            };
307            match name {
308                "login" | "shell" | "edit" => {
309                    return Some(worst("sudo -i/-s/-e runs a root shell or editor — arbitrary code as root (§0)"));
310                }
311                "user" | "other-user" => {
312                    run_as_other = true;
313                    if !glued_val { i += 1; }
314                }
315                "group" | "prompt" | "close-from" | "host" | "role" | "type"
316                | "command-timeout" | "chroot" | "chdir" | "preserve-env" => {
317                    // `--preserve-env` is boolean OR `--preserve-env=list`; only the space form of the
318                    // others consumes a value. A bare `--preserve-env` just falls through (no skip).
319                    if !glued_val && name != "preserve-env" { i += 1; }
320                }
321                "background" | "stdin" | "non-interactive" | "reset-timestamp"
322                | "remove-timestamp" | "set-home" | "askpass" | "help" | "version"
323                | "validate" | "list" | "bell" => {}
324                _ => return Some(worst("sudo: unrecognized option — fail-closed (§0)")),
325            }
326        } else {
327            // A short cluster (`-EH`, `-u root`, `-uroot`). Consume char by char; a valued flag eats
328            // the rest of the token as its value, or the next token if the rest is empty.
329            let rest = &t[1..];
330            for (idx, c) in rest.char_indices() {
331                match c {
332                    'i' | 's' | 'e' => {
333                        return Some(worst("sudo -i/-s/-e runs a root shell or editor — arbitrary code as root (§0)"));
334                    }
335                    'u' | 'U' | 'g' | 'p' | 'C' | 'h' | 'r' | 't' | 'T' | 'R' | 'D' => {
336                        if c == 'u' || c == 'U' { run_as_other = true; }
337                        if idx + c.len_utf8() == rest.len() { i += 1; } // value is the next token
338                        i += 1;
339                        continue 'scan; // rest of the token was this flag's value
340                    }
341                    'E' | 'H' | 'k' | 'K' | 'n' | 'b' | 'A' | 'S' | 'P' | 'B' | 'v' | 'l' => {}
342                    _ => return Some(worst("sudo: unrecognized option — fail-closed (§0)")),
343                }
344            }
345        }
346        i += 1;
347    }
348    // A valued short flag at end-of-input (`sudo -u`, `doas -r`) consumes a "next token" that isn't
349    // there, pushing `i` one past the end — clamp so the slice can't panic (fail-OPEN crash of the
350    // hook). An overshoot means no command was left to elevate, same as the empty case below.
351    let inner = &tokens[i.min(tokens.len())..];
352    if inner.is_empty() {
353        return None; // `sudo` / `sudo -v` / `sudo -l` — no command to elevate; legacy decides
354    }
355    let elevated = if run_as_other { Authority::OtherUser } else { Authority::Root };
356    let caps = resolve(inner)?
357        .capabilities
358        .into_iter()
359        .map(|mut c| {
360            c.authority = c.authority.max(elevated);
361            c
362        })
363        .collect();
364    Some(Profile::of(caps))
365}
366
367/// openssl decrypt / private-key disclosure resolver. openssl's flag grammar defeats declarative
368/// flag-gating — it accepts `--opt` as an alias for `-opt` on every subcommand, `-text` dumps the
369/// PRIVATE key components to stdout past `-pubout`/`-noout`, and `-out`'s VALUE can itself be stdout
370/// (`-out -`, `-out /dev/stdout`) — so the disclosure-prone subs are classified here in Rust. Returns
371/// `decrypt-read` (→ yolo, denied below) only when private/decrypted material reaches the MODEL
372/// (stdout); returns `None` for public-key ops, to-FILE extraction, encrypt/sign, and the ~30 benign
373/// subs, which fall through to openssl's declarative (allow_all) classification. Fail-closed: a spoofed
374/// path worst-cases; a disclosure sub always yields a verdict rather than abstaining to the permissive
375/// legacy default.
376fn resolve_openssl(arg0: &Token, tokens: &[Token]) -> Option<Profile> {
377    if !trusted_command_path(arg0.as_str()) {
378        return Some(worst("openssl invoked from a non-standard path — possible spoof (§0)"));
379    }
380    let sub = tokens.get(1)?.as_str();
381    let args = &tokens[2..];
382    let discloses = match sub {
383        // Private-key subs: private material reaches the model UNLESS the input is public (`-pubin`),
384        // or it's public-key output (`-pubout`) with no `-text` side channel — and then only if the
385        // (private-key) output actually goes to stdout, not a file.
386        "rsa" | "pkey" | "ec" | "dsa" => {
387            if openssl_flag(args, "-pubin") {
388                false
389            } else if openssl_flag(args, "-text") {
390                true // dumps the private exponent/primes to stdout regardless of -out/-noout/-pubout
391            } else if openssl_flag(args, "-pubout") {
392                false // public-key PEM out, no -text
393            } else {
394                openssl_output_reaches_model(args)
395            }
396        }
397        // PKCS#8 is a private-key format with no public mode; disclosed if it reaches stdout.
398        "pkcs8" => openssl_flag(args, "-text") || openssl_output_reaches_model(args),
399        // Unencrypted key export (`-nodes`/`-noenc`, OpenSSL 3.0 spelling); disclosed if it hits stdout.
400        "pkcs12" => {
401            (openssl_flag(args, "-nodes") || openssl_flag(args, "-noenc"))
402                && openssl_output_reaches_model(args)
403        }
404        // Symmetric decrypt: plaintext to the model only when it goes to stdout.
405        "enc" => openssl_flag(args, "-d") && openssl_output_reaches_model(args),
406        "smime" => openssl_flag(args, "-decrypt") && openssl_output_reaches_model(args),
407        "cms" => {
408            (openssl_flag(args, "-decrypt") || openssl_flag(args, "-EncryptedData_decrypt"))
409                && openssl_output_reaches_model(args)
410        }
411        _ => return None, // benign subs — openssl's declarative (allow_all) classification
412    };
413    if discloses {
414        let cap = crate::engine::archetype::archetype("decrypt-read")
415            .cloned()
416            .unwrap_or_else(|| Capability::worst("decrypt-read archetype missing (§0)"));
417        Some(Profile::of(vec![cap]))
418    } else {
419        None // public / to-file / encrypt / benign → legacy allow_all classification
420    }
421}
422
423/// Whether an openssl BOOLEAN flag (`-d`, `-text`, `-pubout`) is present, accepting the `--` twin
424/// openssl honors on every subcommand (`--d`, `--text`). Value flags use [`openssl_flag_value`].
425fn openssl_flag(args: &[Token], flag: &str) -> bool {
426    args.iter().any(|t| {
427        let s = t.as_str();
428        s == flag || (s.starts_with("--") && s.len() > 2 && &s[1..] == flag)
429    })
430}
431
432/// Whether the sub's OUTPUT reaches the model. FAIL-CLOSED (a path string cannot be soundly matched
433/// against a denylist of device spellings — the OS collapses `//dev/stdout`, `/dev/./stdout`,
434/// `/dev/fd//1` to the same device, and openssl honors the LAST of duplicate `-out`s): the output
435/// reaches the model UNLESS it is provably diverted to a single plain FILE. So it's model-reaching
436/// when `-noout` is absent AND NOT (exactly one `-out` whose value is a plain file). `-noout`
437/// suppresses the PEM output (a validate); `-text` is checked by the caller BEFORE this, since it
438/// dumps to stdout past both `-noout` and `-out`.
439fn openssl_output_reaches_model(args: &[Token]) -> bool {
440    if openssl_flag(args, "-noout") {
441        return false;
442    }
443    let outs = openssl_flag_values(args, "-out");
444    // Diverted to disk ONLY when there is exactly one `-out` naming a plain file. No `-out` (default
445    // stdout), a duplicate `-out` (last-wins — the first is untrustworthy), or a device/`-` value all
446    // reach the model.
447    !matches!(outs.as_slice(), [only] if out_value_is_plain_file(only))
448}
449
450/// Whether an `-out` value names a plain FILE (a safe diversion), as opposed to stdout/`-`, or a
451/// device / fd / console path (`/dev/stdout`, `/dev/stderr`, `/dev/fd/1`, `/proc/self/fd/1`). Collapses
452/// redundant `/`, `.`, and `..` segments first so alternate spellings can't evade. Fail-closed: `-`,
453/// empty, or any `/dev/…` or `/proc/…/fd/…` path is NOT a plain file. (Symlinks are classified by their
454/// literal spelling — out of scope for a static classifier, per AGENTS.md.)
455///
456/// A value that is itself a FLAG token (starts with `-`) is NOT proof of diversion: openssl's own
457/// parser lets a preceding valued flag SWALLOW the `-out` token as its value (`-provider-path -out
458/// -provider-path f.pem` leaves openssl with no `-out` → stdout), and our scan then misreads the next
459/// flag as the filename. The tell in every such bypass is a dash-leading `-out` value — reject it.
460fn out_value_is_plain_file(value: &str) -> bool {
461    if value.is_empty() || value.starts_with('-') {
462        return false;
463    }
464    let norm = collapse_path(value).to_ascii_lowercase();
465    let device_or_fd =
466        norm == "/dev" || norm.starts_with("/dev/") || (norm.starts_with("/proc/") && norm.contains("/fd/"));
467    !device_or_fd
468}
469
470/// Collapse a path's redundant `/` / `.` / `..` segments (what the kernel does before opening it), so
471/// `//dev/stdout`, `/dev/./stdout`, `/dev/fd//1`, `/foo/../dev/stdout` all normalize to the device
472/// path. A leading `..` on a relative path is kept (can't resolve above an unknown cwd).
473fn collapse_path(p: &str) -> String {
474    let absolute = p.starts_with('/');
475    let mut stack: Vec<&str> = Vec::new();
476    for seg in p.split('/') {
477        match seg {
478            "" | "." => {}
479            ".." => {
480                if matches!(stack.last(), Some(&s) if s != "..") {
481                    stack.pop();
482                } else if !absolute {
483                    stack.push("..");
484                }
485            }
486            s => stack.push(s),
487        }
488    }
489    let joined = stack.join("/");
490    if absolute { format!("/{joined}") } else { joined }
491}
492
493/// Every value of a valued openssl flag (`-out file` / `--out file` / `-out=file` / `--out=file`),
494/// accepting the `--` twin — ALL occurrences, in order (openssl honors the last; the caller fails
495/// closed on duplicates).
496fn openssl_flag_values<'a>(args: &'a [Token], flag: &str) -> Vec<&'a str> {
497    let twin = format!("-{flag}"); // `-out` → `--out`
498    let mut out = Vec::new();
499    let mut i = 0;
500    while i < args.len() {
501        let s = args[i].as_str();
502        if let Some(v) = s
503            .strip_prefix(flag)
504            .or_else(|| s.strip_prefix(twin.as_str()))
505            .and_then(|r| r.strip_prefix('='))
506        {
507            out.push(v);
508        } else if (s == flag || s == twin)
509            && let Some(next) = args.get(i + 1)
510        {
511            out.push(next.as_str());
512            i += 1;
513        }
514        i += 1;
515    }
516    out
517}
518
519/// Classify a network-destination token's PROVENANCE (exposure §4). `None` (a bare invocation) is
520/// the configured default → `Established`. A command-transport form (`ext::<cmd>`) is not a
521/// destination but LOCAL CODE, signalled by a `None` return so the caller worst-cases it as RCE.
522fn destination_provenance(dest: Option<&str>) -> Option<Provenance> {
523    let Some(tok) = dest else {
524        return Some(Provenance::Established);
525    };
526    if tok.starts_with("ext::") {
527        return None; // `git push ext::sh -c …` runs a local command — RCE, not egress
528    }
529    // A variable / substitution: the actual target is not in the command string, so it cannot be
530    // reviewed — the fail-closed case.
531    if tok.contains('$') || tok.contains('`') {
532        return Some(Provenance::Opaque);
533    }
534    // Spelled inline: a URL scheme, an scp-style `user@host:path`, or a filesystem path. Otherwise a
535    // bare word is a reference to a configured remote (established by a prior `clone`/`remote add`).
536    let literal = tok.contains("://")
537        || (tok.contains('@') && tok.contains(':'))
538        || tok.starts_with('/')
539        || tok.starts_with("./")
540        || tok.starts_with("../");
541    Some(if literal { Provenance::Literal } else { Provenance::Established })
542}
543
544/// The generic, declaration-driven resolver: build a `Profile` from a command's
545/// `[command.behavior]` (`BehaviorSpec`) and its tokens. This is the non-legacy classification
546/// path expressed in TOML — the operation + operand-role + flag grammar are data, and this one
547/// function replaces a hardcoded `resolve_*`. Irreducible token logic a declaration can't
548/// express is delegated to a named `hook`.
549fn resolve_behavior(spec: &crate::registry::types::BehaviorSpec, tokens: &[Token]) -> Profile {
550    use crate::registry::types::{BehaviorHook, PositionalRole};
551    if let Some(hook) = spec.hook {
552        return match hook {
553            // grep's hook supplies the operand set (the irreducible token logic); the declared
554            // operation + the builders supply the facets — the composition seam (§8). grep is
555            // observe-only, so its operands become content reads.
556            BehaviorHook::Grep => {
557                let Some(g) = grep_operands(tokens) else {
558                    return worst("grep: unrecognized flag or missing pattern — worst-cased (§0)");
559                };
560                let mut caps: Vec<Capability> = g
561                    .pattern_files
562                    .iter()
563                    .map(|f| reads_content(read_locus(f), Scale::Single, "reads a grep -f pattern file"))
564                    .collect();
565                caps.extend(reads_to_model(&g.files, g.scale));
566                Profile::of(caps)
567            }
568            // dd/tar/sed parse their own irregular operand syntax (`key=value`, dashless mode
569            // bundles, a mini-language script) AND build their own multi-role profiles, so their
570            // hook returns the full `Profile` — the parser and the facets are entangled with the
571            // parse and stay in Rust (their DATA — flag/param sets — is small and audited).
572            BehaviorHook::Dd => resolve_dd(tokens),
573            BehaviorHook::Tar => resolve_tar(tokens),
574            BehaviorHook::Sed => resolve_sed(tokens),
575            BehaviorHook::Perl => resolve_perl(tokens),
576        };
577    }
578    // No path operands (echo): a pure stdout emitter, handled BEFORE the flag walk — echo has no
579    // flag grammar (it prints any `-x` verbatim), so walking would wrongly reject it. `observe`
580    // with model disclosure and no fs/net/exec; its args touch nothing.
581    if matches!(spec.positionals, PositionalRole::None) {
582        return match spec.operation {
583            Operation::Observe => {
584                let mut c = Capability::new(Operation::Observe);
585                c.disclosure.audience = DisclosureAudience::LocalProcess;
586                c.because = "behavior: prints its arguments to stdout; no fs/net/exec/secret".to_string();
587                Profile::of(vec![c])
588            }
589            _ => worst("behavior: none-operand role supports only observe (§0)"),
590        };
591    }
592    let long: Vec<&str> = spec.long.iter().map(String::as_str).collect();
593    let valued_long: Vec<&str> = spec.valued_long.iter().map(String::as_str).collect();
594    let Some(operands) = walk_positionals(&spec.short, &spec.valued_short, &long, &valued_long, spec.numeric_shorthand, tokens) else {
595        return worst("behavior: unrecognized flag — worst-cased (§0)");
596    };
597    let scale = behavior_scale(spec, &operands, tokens);
598    // Path-flag values (e.g. `touch -r REF`) are gated alongside the positional operands.
599    let flag_caps = path_flag_caps(spec, tokens);
600    match spec.positionals {
601        PositionalRole::Read => {
602            let mut caps = reads_to_model(&operands, scale);
603            caps.extend(flag_caps);
604            Profile::of(caps)
605        }
606        PositionalRole::Write => {
607            if operands.is_empty() {
608                return worst("behavior: write operation with no operand — worst-cased (§0)");
609            }
610            let mut caps: Vec<Capability> = operands
611                .iter()
612                .map(|p| match spec.operation {
613                    Operation::Destroy => destroys(classify_locus(p), scale),
614                    Operation::Create => creates(classify_locus(p), scale),
615                    Operation::Mutate => mutates(classify_locus(p), scale, "behavior: in-place mutate"),
616                    _ => Capability::worst("behavior: unsupported write operation — worst-cased (§0)"),
617                })
618                .collect();
619            caps.extend(flag_caps);
620            Profile::of(caps)
621        }
622        PositionalRole::Transfer => resolve_transfer(spec, operands, flag_caps, tokens),
623        // None is handled above (before the flag walk); pattern-then-read routes through a hook
624        // (grep). Neither reaches here, so both fail closed.
625        PositionalRole::None | PositionalRole::PatternThenRead => {
626            worst("behavior: operand role not resolvable without a hook (§0)")
627        }
628    }
629}
630
631/// The transfer arm of `resolve_behavior` (cp/mv/ln): split the operands into sources and a
632/// destination (`-t`/`--target-directory` value, else the last operand), gate each at its locus
633/// — a relocate source at its WRITE face — and fold in any path-flag capabilities. Fails closed
634/// on a missing spec, a missing dest, or a `-t` dest with no sources.
635fn resolve_transfer(
636    spec: &crate::registry::types::BehaviorSpec,
637    operands: Vec<&str>,
638    flag_caps: Vec<Capability>,
639    tokens: &[Token],
640) -> Profile {
641    use crate::registry::types::TransferSource;
642    let Some(t) = &spec.transfer else {
643        return worst("behavior: transfer role without transfer spec — worst-cased (§0)");
644    };
645    let (sources, dest) = if let Some(d) = walk_value(&spec.valued_short, tokens, b't', "--target-directory") {
646        if operands.is_empty() {
647            return worst("behavior: transfer -t with no source operand — worst-cased (§0)");
648        }
649        (operands, d)
650    } else {
651        match operands.split_last() {
652            Some((last, rest)) if !rest.is_empty() => (rest.to_vec(), *last),
653            _ => return worst("behavior: transfer needs a source and a destination — worst-cased (§0)"),
654        }
655    };
656    let no_clobber = if t.clobber_flags.is_empty() {
657        t.no_clobber_flags.iter().any(|f| behavior_flag_present(tokens, f))
658    } else {
659        // A clobber flag PRESENT means overwrite; its absence is the no-clobber default.
660        !t.clobber_flags.iter().any(|f| behavior_flag_present(tokens, f))
661    };
662    let recursive = t.recursive_flags.iter().any(|f| behavior_flag_present(tokens, f));
663    let transfer_scale = breadth_scale(&sources, recursive);
664    // A relocate REMOVES its source (a write), so gate the source at its write face.
665    let source_writes = matches!(t.source, TransferSource::Relocate);
666    let mut prof = transfer_profile(
667        &sources,
668        dest,
669        transfer_scale,
670        source_writes,
671        |loc, sc| match t.source {
672            TransferSource::Observe => observes(loc, sc, "transfer reads the source at its locus"),
673            TransferSource::Relocate => relocates(loc, sc),
674        },
675        |loc, sc| overwrites(loc, sc, no_clobber),
676    );
677    prof.capabilities.extend(flag_caps);
678    prof
679}
680
681/// Capabilities for a command's declared PATH-FLAGS: a valued flag whose value is a path
682/// (`touch -r REF` reads REF's timestamp) is gated by its role's locus, exactly like an operand
683/// — so an out-of-workspace value denies. Folds the `[command.path_gate]` idea into behavior.
684fn path_flag_caps(spec: &crate::registry::types::BehaviorSpec, tokens: &[Token]) -> Vec<Capability> {
685    use crate::registry::types::PathRole;
686    let mut caps = Vec::new();
687    for pf in &spec.path_flags {
688        let short = pf.short.unwrap_or(0);
689        let long = pf.long.as_deref().unwrap_or("");
690        if let Some(v) = walk_value(&spec.valued_short, tokens, short, long) {
691            caps.push(match pf.role {
692                PathRole::Read => observes(read_locus(v), Scale::Single, "behavior: a flag value is a read path"),
693                PathRole::Write => mutates(write_locus(v), Scale::Single, "behavior: a flag value is a write path"),
694            });
695        }
696    }
697    caps
698}
699
700/// The `Scale` for a behavior resolution: `single` always yields one item; `breadth` widens on
701/// operand count, a glob, or a declared unbounded flag (`rm -r`) via `breadth_scale`.
702fn behavior_scale(
703    spec: &crate::registry::types::BehaviorSpec,
704    operands: &[&str],
705    tokens: &[Token],
706) -> Scale {
707    use crate::registry::types::ScaleModel;
708    match spec.scale {
709        ScaleModel::Single => Scale::Single,
710        ScaleModel::Breadth => {
711            let recursive = spec.unbounded_flags.iter().any(|f| behavior_flag_present(tokens, f));
712            breadth_scale(operands, recursive)
713        }
714    }
715}
716
717/// Whether a declared behavior flag (a bare token like `-r` or `--recursive`) is present,
718/// via the shared `has_flag` (which handles short clustering and `--flag=value`).
719fn behavior_flag_present(tokens: &[Token], flag: &str) -> bool {
720    if flag.starts_with("--") {
721        has_flag(tokens, None, Some(flag))
722    } else {
723        has_flag(tokens, Some(flag), None)
724    }
725}
726
727/// A command name with no resolver and no plausible future one — the stable stand-in for
728/// "unresearched" across engine tests. Using a real tool here is a trap: when `rm` gained
729/// a resolver, three tests that used `rm` as their unresearched example silently broke.
730/// A name that will never be a real tool can never be silently repurposed.
731#[cfg(test)]
732pub(crate) const UNRESOLVED_CMD: &[&str] = &["safe-chains-unresolved-sentinel"];
733
734/// Whether `arg0` is a trusted way to invoke a standard tool: a bare name (found via
735/// `$PATH`) or an absolute path under a standard system bin directory. A path elsewhere
736/// (`./x`, `/tmp/x`, `~/bin/x`) may be an impostor.
737fn trusted_command_path(arg0: &str) -> bool {
738    const STD_BINS: &[&str] =
739        &["/usr/bin/", "/bin/", "/usr/local/bin/", "/opt/homebrew/bin/", "/sbin/", "/usr/sbin/"];
740    !arg0.contains('/') || STD_BINS.iter().any(|p| arg0.starts_with(p))
741}
742
743/// The classified operand set of a `grep` invocation: the positional file operands (read at
744/// `scale`, empty = stdin) and the `-f`/`--file` pattern files (each read once). This is the
745/// irreducible token logic a `[command.behavior]` declaration can't express — grep's
746/// pattern-vs-file disambiguation, `-e`/`-f` pattern flags, and the unknown-`--token`-is-a-
747/// pattern heuristic. The declared `operation` (observe) and the builders turn these operands
748/// into capabilities in `resolve_behavior`'s hook arm; this function assigns no facets.
749struct GrepOperands<'a> {
750    files: Vec<&'a str>,
751    pattern_files: Vec<&'a str>,
752    scale: Scale,
753}
754
755/// Walk a `grep` command into its `GrepOperands`, or `None` to fail closed (unrecognized flag,
756/// or no pattern operand). The behavior hook (`BehaviorHook::Grep`) for `commands/text/grep.toml`.
757fn grep_operands(tokens: &[Token]) -> Option<GrepOperands<'_>> {
758    // `-r` (or --recursive); `-R`/--dereference-recursive is not benign and worst-cases
759    // in the walk below, so it needn't be detected here.
760    let recursive = has_flag(tokens, Some("-r"), Some("--recursive"));
761    let scale = if recursive { Scale::Unbounded } else { Scale::Single };
762
763    let mut files = Vec::new(); // positional file operands
764    let mut pattern_files = Vec::new(); // -f/--file pattern files grep reads
765    let mut pattern_from_flag = false;
766    let mut unknown_flag = false;
767    let mut flags_done = false;
768    let mut i = 1;
769    while i < tokens.len() {
770        let t = tokens[i].as_str();
771        let next = tokens.get(i + 1).map(Token::as_str);
772        if !flags_done && t == "--" {
773            flags_done = true;
774            i += 1;
775        } else if flags_done || !t.starts_with('-') || t == "-" {
776            files.push(t);
777            i += 1;
778        } else if t.starts_with("--") {
779            if let Some(v) = t.strip_prefix("--file=") {
780                pattern_from_flag = true;
781                pattern_files.push(v);
782                i += 1;
783            } else if t == "--file" {
784                pattern_from_flag = true;
785                pattern_files.extend(next);
786                i += 2;
787            } else if t == "--regexp" {
788                pattern_from_flag = true;
789                i += 2;
790            } else if t.starts_with("--regexp=") {
791                pattern_from_flag = true;
792                i += 1;
793            } else if grep_long_known(t) {
794                i += 1;
795            } else if grep_long_dangerous(t) {
796                unknown_flag = true;
797                i += 1;
798            } else {
799                // An unrecognized `--token` is not a grep flag: it is the search PATTERN
800                // (grep patterns commonly look like `-->`, `---`, `--foo`). Treat it as a
801                // positional so the file operands classify the read, matching legacy.
802                files.push(t);
803                i += 1;
804            }
805        } else {
806            match grep_short_cluster(t, next) {
807                GrepShort::Unrecognized => {
808                    unknown_flag = true;
809                    i += 1;
810                }
811                GrepShort::Standalone => i += 1,
812                GrepShort::Pattern { file, consumes_next } => {
813                    pattern_files.extend(file);
814                    pattern_from_flag = true;
815                    i += if consumes_next { 2 } else { 1 };
816                }
817                GrepShort::SkipValue { consumes_next } => i += if consumes_next { 2 } else { 1 },
818            }
819        }
820    }
821
822    if unknown_flag {
823        return None; // unrecognized flag → fail closed (§0)
824    }
825    if files.is_empty() {
826        // No positional operand → grep has no pattern (a `-e`/`-f` pattern still needs a
827        // search target). This is a usage error; the legacy classifier denies it, so the
828        // engine must not be looser — fail closed (§0).
829        return None;
830    }
831
832    if !pattern_from_flag {
833        files.remove(0); // the first positional is the PATTERN, not a file
834    }
835    if recursive && files.is_empty() {
836        files.push("."); // grep -r with no path searches the cwd
837    }
838
839    Some(GrepOperands { files, pattern_files, scale })
840}
841
842/// The outcome of parsing one grep short-option cluster.
843enum GrepShort<'a> {
844    /// An unrecognized short (e.g. `-R`, symlink-dereferencing recursive) → the caller worst-cases.
845    Unrecognized,
846    /// All chars benign; no value taken.
847    Standalone,
848    /// `-e`/`-f` supplied the pattern (so positionals are files); `-f`'s value, if any,
849    /// is a pattern file grep reads.
850    Pattern { file: Option<&'a str>, consumes_next: bool },
851    /// `-m`/`-A`/`-B`/`-C`/`-d` — a count/action value to skip.
852    SkipValue { consumes_next: bool },
853}
854
855/// Parse a grep short-option cluster (e.g. `-ifpatterns`), honoring GNU semantics that a
856/// value-taking short consumes the rest of its cluster (glued) or the next token.
857fn grep_short_cluster<'a>(cluster: &'a str, next: Option<&'a str>) -> GrepShort<'a> {
858    // NB: `r` (recursive) is benign, but `R` (--dereference-recursive) follows symlinks
859    // and can escape the classified locus, so it is NOT benign — it worst-cases. `P`
860    // (PCRE, `--perl-regexp`) IS benign: GNU grep's PCRE2 does not implement Perl's
861    // `(?{code})` execution, so it runs no code — it's just another regex engine like `-E`/`-F`.
862    const BENIGN: &[u8] = b"ivnclLoqswxHhaIrzZEFGbUP";
863    let bytes = cluster.as_bytes();
864    let mut k = 1;
865    while k < bytes.len() {
866        // Non-ASCII bytes aren't flags and would make `cluster[k + 1..]` slice mid-char.
867        if !bytes[k].is_ascii() {
868            return GrepShort::Unrecognized;
869        }
870        let glued = &cluster[k + 1..]; // safe: bytes[k] is ASCII → k+1 is a char boundary
871        let has = !glued.is_empty();
872        match bytes[k] {
873            b'f' => {
874                let file = if has { Some(glued) } else { next };
875                return GrepShort::Pattern { file, consumes_next: !has };
876            }
877            b'e' => return GrepShort::Pattern { file: None, consumes_next: !has },
878            b'm' | b'A' | b'B' | b'C' | b'd' => return GrepShort::SkipValue { consumes_next: !has },
879            b if BENIGN.contains(&b) => k += 1,
880            _ => return GrepShort::Unrecognized,
881        }
882    }
883    GrepShort::Standalone
884}
885
886/// Whether a grep long flag (its `--name`, ignoring any `=value`) is recognized-benign.
887/// `--dereference-recursive` and anything unlisted are not → worst-case (§0).
888fn grep_long_known(flag: &str) -> bool {
889    const KNOWN: &[&str] = &[
890        "--recursive", "--ignore-case", "--invert-match", // NB: --dereference-recursive
891        // (symlink-following) is intentionally absent → worst-case (M2)
892        "--line-number", "--count", "--files-with-matches", "--files-without-match",
893        "--only-matching", "--perl-regexp", "--word-regexp", "--line-regexp", "--fixed-strings",
894        "--extended-regexp", "--basic-regexp", "--with-filename", "--no-filename",
895        "--quiet", "--silent", "--no-messages", "--null", "--byte-offset", "--text",
896        "--color", "--colour", "--help", "--version", "--after-context", "--before-context",
897        "--context", "--max-count", "--include", "--exclude", "--exclude-dir",
898        "--include-dir", "--binary-files", "--devices", "--directories",
899    ];
900    let name = flag.split('=').next().unwrap_or(flag);
901    KNOWN.contains(&name)
902}
903
904/// The long spelling of the dangerous grep short `-R`: `--dereference-recursive` (follows
905/// symlinks out of the classified locus, M2). Recognized so both spellings worst-case; every
906/// OTHER unrecognized `--token` is a search pattern, not a flag. (`--perl-regexp`/`-P` is NOT
907/// here — PCRE2 executes no code, so it is benign, like `-E`/`-F`.)
908fn grep_long_dangerous(flag: &str) -> bool {
909    let name = flag.split('=').next().unwrap_or(flag);
910    matches!(name, "--dereference-recursive")
911}
912
913/// `dd if=IN of=OUT bs=… …` — the operand-model breaker: `dd` takes NO getopt flags or
914/// positionals, only `key=value` operands, so the shared `Flags`/`positionals` toolkit does
915/// not apply and it parses its own. `if=` reads (default stdin), `of=` writes (default
916/// stdout). It is still a transfer at the facet level — `dd if=~/.ssh/id_rsa of=./x` denies
917/// on the input locus, `dd if=./x of=/dev/rdisk0` denies on the output locus (a raw device
918/// is beneath the fs) — but the roles arrive inside `key=value`, not positional slots, which
919/// is why its conservation probe is `Operands::Custom`. `bs`/`count`/`conv`/… are benign
920/// transfer parameters; any other key, or a non-`key=value` operand, worst-cases (§0).
921fn resolve_dd(tokens: &[Token]) -> Profile {
922    const PARAMS: &[&str] = &[
923        "bs", "ibs", "obs", "cbs", "count", "skip", "seek", "conv", "iflag", "oflag", "status",
924    ];
925    let (mut input, mut output) = (None, None);
926    for t in &tokens[1..] {
927        let t = t.as_str();
928        if t == "--help" || t == "--version" {
929            continue;
930        }
931        let Some((key, val)) = t.split_once('=') else {
932            return worst("dd: non key=value operand — worst-cased (§0)");
933        };
934        match key {
935            "if" => input = Some(val),
936            "of" => output = Some(val),
937            k if PARAMS.contains(&k) => {}
938            _ => return worst("dd: unrecognized operand — worst-cased (§0)"),
939        }
940    }
941    // dd touches exactly one input and one output — a `single` blast radius, whatever the
942    // data VOLUME. The disk-wipe danger of `of=/dev/rdisk0` is carried by its device locus,
943    // not by scale.
944    let input_locus = input.map_or(LocalLocus::Process, read_locus);
945    match output {
946        // of= names a sink: read the input into it (no model disclosure) + write the sink.
947        Some(of) => Profile::of(vec![
948            observes(input_locus, Scale::Single, "dd reads its input (if=) into the output"),
949            overwrites(classify_locus(of), Scale::Single, false),
950        ]),
951        // no of= → output is stdout, so the input content reaches the model (like `cat`).
952        None => Profile::of(vec![reads_content(
953            input_locus,
954            Scale::Single,
955            "dd copies its input to stdout (→ the model)",
956        )]),
957    }
958}
959
960/// `tar` — the flag-SYNTAX breaker: its options may be written WITHOUT a leading dash
961/// (`tar czf` == `tar -czf`), so the getopt walker misreads the cluster as a positional; tar
962/// parses its own. The mode letter splits the profile sharply:
963///   - create/append (`c`/`r`/`u`): reads each member (source) + writes the archive (dest) —
964///     a bundler, so `tar czf - ~/.ssh` denies on the member locus (golden-set).
965///   - list (`t`): reads the archive, prints member names to the model.
966///   - extract (`x`) and the rarer modes: extraction writes an ARCHIVE-CONTROLLED set of
967///     paths that `..`-traversal can send anywhere — unknowable without opening the archive,
968///     so worst-case (§0). Any value-taking option we don't model (`-C`, `-T`, …) or an
969///     unknown letter also worst-cases.
970fn resolve_tar(tokens: &[Token]) -> Profile {
971    let mut p = TarParse::default();
972    // `-C DIR` changes the directory for the members that FOLLOW it, so a member's real locus
973    // is `DIR/member` — the same `find … {}`→path binding. tar applies `-C` CUMULATIVELY: each
974    // `-C` chdir's relative to the already-changed directory, so consecutive `-C /  -C etc`
975    // resolves to `/etc`, not `etc`. Compose relative values onto the active dir (via the same
976    // `tar_bound` join, which also lets an absolute value replace and routes any `..` through
977    // the unpinnable guard); stamp each positional with the accumulated dir.
978    let mut dir: Option<String> = None;
979    let mut i = 1;
980    while i < tokens.len() {
981        let t = tokens[i].as_str();
982        if t == "-C" || t == "--directory" {
983            dir = tokens.get(i + 1).map(|d| tar_bound(dir.as_deref(), d.as_str()));
984            i += 2;
985            continue;
986        }
987        if let Some(d) = t.strip_prefix("--directory=").or_else(|| t.strip_prefix("-C").filter(|d| !d.is_empty())) {
988            dir = Some(tar_bound(dir.as_deref(), d));
989            i += 1;
990            continue;
991        }
992        if let Some(long) = t.strip_prefix("--") {
993            p.long_option(long);
994        } else if let Some(cluster) = t.strip_prefix('-').filter(|c| !c.is_empty()) {
995            p.cluster(cluster);
996        } else if i == 1 {
997            p.cluster(t); // dashless old-style option bundle (only the first argument)
998        } else {
999            p.positionals.push((dir.clone(), t));
1000        }
1001        i += 1;
1002    }
1003    p.into_profile()
1004}
1005
1006/// A tar positional: a member/archive path with the accumulated `-C` directory active when it
1007/// appeared (already composed across consecutive `-C` options).
1008type TarPositional<'a> = (Option<String>, &'a str);
1009
1010/// A tar positional borrowed for classification: (`-C` dir, path).
1011type TarRef<'a> = (Option<&'a str>, &'a str);
1012
1013/// A tar member/archive path resolved against an active `-C` directory: `DIR/path` for a
1014/// relative path, or `path` unchanged when there is no `-C` or the path is absolute (an
1015/// absolute member ignores `-C`).
1016fn tar_bound(dir: Option<&str>, path: &str) -> String {
1017    match dir {
1018        Some(d) if !path.starts_with('/') && !path.starts_with('~') && !path.starts_with('-') => {
1019            format!("{}/{}", d.trim_end_matches('/'), path)
1020        }
1021        _ => path.to_string(),
1022    }
1023}
1024
1025/// Accumulated `tar` parse: the mode, whether `-f` wants an archive, and `reject` — set by
1026/// any option we can't model safely (an unknown letter, or a value-taking option like `-T`
1027/// / `-X` whose ordered operand consumption we don't track). `-C` IS modeled (see
1028/// `resolve_tar`); it only reaches `cluster` inside a mixed bundle, which still worst-cases.
1029#[derive(Default)]
1030struct TarParse<'a> {
1031    mode: Option<u8>,
1032    want_archive: bool,
1033    reject: bool,
1034    long_archive: Option<&'a str>,
1035    /// Each positional with the `-C` directory active when it appeared (`None` = cwd).
1036    positionals: Vec<TarPositional<'a>>,
1037}
1038
1039impl<'a> TarParse<'a> {
1040    fn cluster(&mut self, cluster: &str) {
1041        const NOVAL: &[u8] = b"vzjJZpkmOwhSlPa"; // benign no-value option letters
1042        for b in cluster.bytes() {
1043            match b {
1044                b'c' | b'x' | b't' | b'r' | b'u' | b'A' | b'd' => self.mode = Some(b),
1045                b'f' => self.want_archive = true,
1046                b'C' | b'T' | b'X' | b'b' | b'H' | b'g' | b'K' | b'N' => self.reject = true,
1047                x if NOVAL.contains(&x) => {}
1048                _ => self.reject = true,
1049            }
1050        }
1051    }
1052
1053    fn long_option(&mut self, long: &'a str) {
1054        let name = long.split('=').next().unwrap_or(long);
1055        match name {
1056            "create" => self.mode = Some(b'c'),
1057            "extract" | "get" => self.mode = Some(b'x'),
1058            "list" => self.mode = Some(b't'),
1059            "append" => self.mode = Some(b'r'),
1060            "update" => self.mode = Some(b'u'),
1061            "file" => match long.split_once('=') {
1062                Some((_, v)) => self.long_archive = Some(v),
1063                None => self.want_archive = true,
1064            },
1065            "gzip" | "bzip2" | "xz" | "zstd" | "compress" | "verbose" | "preserve-permissions"
1066            | "same-permissions" | "to-stdout" | "help" | "version" | "dereference" | "totals" => {}
1067            _ => self.reject = true,
1068        }
1069    }
1070
1071    fn into_profile(self) -> Profile {
1072        let Some(mode) = self.mode.filter(|_| !self.reject) else {
1073            return worst("tar: unrecognized/unmodeled option — worst-cased (§0)");
1074        };
1075        // Separate the archive from the members. `--file=X` names it directly; a bare `f`
1076        // (dashless `czf` or dashed `-czf`) takes the FIRST positional as the archive.
1077        let (archive, members): (Option<TarRef>, &[TarPositional]) =
1078            if let Some(a) = self.long_archive {
1079                (Some((None, a)), &self.positionals)
1080            } else if self.want_archive {
1081                match self.positionals.split_first() {
1082                    Some((first, rest)) => (Some((first.0.as_deref(), first.1)), rest),
1083                    None => return worst("tar: -f without an archive — worst-cased (§0)"),
1084                }
1085            } else {
1086                (None, &self.positionals) // archive is stdin/stdout
1087            };
1088        // A `-` archive (or none) is a stdout/stdin stream, not a file to gate.
1089        let archive_file = archive.filter(|(_, a)| *a != "-");
1090
1091        match mode {
1092            b'c' | b'r' | b'u' => {
1093                let mut caps: Vec<Capability> = members
1094                    .iter()
1095                    .map(|(dir, m)| observes(read_locus(&tar_bound(dir.as_deref(), m)), Scale::Bounded, "tar reads a member into the archive"))
1096                    .collect();
1097                if let Some((dir, a)) = archive_file {
1098                    caps.push(overwrites(classify_locus(&tar_bound(dir, a)), Scale::Single, false));
1099                }
1100                if caps.is_empty() {
1101                    return worst("tar create with no members — worst-cased (§0)");
1102                }
1103                Profile::of(caps)
1104            }
1105            b't' => {
1106                let loc = archive_file.map_or(LocalLocus::Process, |(dir, a)| classify_locus(&tar_bound(dir, a)));
1107                Profile::of(vec![reads_content(loc, Scale::Single, "tar lists the archive's members (names → the model)")])
1108            }
1109            // x (extract) and A/d: archive-controlled, ..-escapable writes → worst-case.
1110            _ => worst("tar extract writes an archive-controlled, ..-escapable path set — worst-cased (§0)"),
1111        }
1112    }
1113}
1114
1115/// `sed` — the read-becomes-WRITE breaker: `sed 's/…/…/' FILE` reads FILE and prints to the
1116/// model, but `sed -i` edits the SAME file operands **in place** (a mutate), so a single
1117/// flag flips the operation on the same slots. Two more wrinkles: `-i` takes an OPTIONAL
1118/// glued suffix (`-i.bak`) the getopt walker can't express, and — like `grep` — the first
1119/// positional is the SCRIPT unless `-e`/`-f` supplied it (`-f` also reads a script file).
1120/// So `sed` parses its own flags.
1121fn resolve_sed(tokens: &[Token]) -> Profile {
1122    // HP-7: sed is a mini-language. Its `e` command/modifier executes text as a shell command
1123    // (RCE), and its `w`/`W`/`r`/`R` commands write/read arbitrary files EMBEDDED in the script —
1124    // both invisible to flag parsing. Scan the script(s): an `e`/unknown command worst-cases; the
1125    // file commands' filenames get gated by locus below (a local write is fine, `/etc/cron.d/x` is
1126    // not), exactly like the operand files.
1127    let script = crate::handlers::coreutils::sed::scan_sed(tokens);
1128    if script.exec || script.unknown {
1129        return worst("sed: script has an `e` exec or unmodeled command — worst-cased (§0, HP-7)");
1130    }
1131    // A `-f`/`--file` script comes from a file we can't read — its `e`/`w`/`r` commands are invisible,
1132    // so we can't verify it (like `awk -f`, `bash script.sh`, mlr `--load`). Worst-case it.
1133    if script.script_file {
1134        return worst("sed: -f runs a script file we can't inspect — worst-cased (§0)");
1135    }
1136    const BOOL: &[u8] = b"nrEsuz"; // no-value short flags
1137    let mut in_place = false;
1138    let mut script_from_flag = false;
1139    let mut script_files: Vec<&str> = Vec::new(); // -f FILE — sed reads these
1140    let mut files: Vec<&str> = Vec::new();
1141    let mut flags_done = false;
1142    let mut i = 1;
1143    while i < tokens.len() {
1144        let t = tokens[i].as_str();
1145        let next = tokens.get(i + 1).map(Token::as_str);
1146        if !flags_done && t == "--" {
1147            flags_done = true;
1148            i += 1;
1149        } else if flags_done || t == "-" || !t.starts_with('-') {
1150            files.push(t);
1151            i += 1;
1152        } else if let Some(long) = t.strip_prefix("--") {
1153            match sed_long(long, next, &mut in_place, &mut script_from_flag, &mut script_files) {
1154                Some(consumed) => i += consumed,
1155                None => return worst("sed: unrecognized flag — worst-cased (§0)"),
1156            }
1157        } else {
1158            match sed_cluster(&t[1..], next, BOOL) {
1159                SedShort::Bad => return worst("sed: unrecognized flag — worst-cased (§0)"),
1160                SedShort::InPlace => {
1161                    in_place = true;
1162                    i += 1;
1163                }
1164                SedShort::Standalone => i += 1,
1165                SedShort::Script { consumes_next } => {
1166                    script_from_flag = true;
1167                    i += usize::from(consumes_next) + 1;
1168                }
1169                SedShort::ScriptFile { file, consumes_next } => {
1170                    script_from_flag = true;
1171                    script_files.extend(file);
1172                    i += usize::from(consumes_next) + 1;
1173                }
1174                SedShort::SkipValue { consumes_next } => i += usize::from(consumes_next) + 1,
1175            }
1176        }
1177    }
1178    // Without -e/-f, the first positional is the SCRIPT, not a file.
1179    if !script_from_flag && !files.is_empty() {
1180        files.remove(0);
1181    }
1182    // Blast radius: a glob (`sed -i … *`) or several operands is bounded, not single — so a
1183    // sweeping in-place edit is scored honestly (still worktree-bound by locus; a system or
1184    // home path denies whatever the scale).
1185    let scale = breadth_scale(&files, false);
1186    let mut caps: Vec<Capability> =
1187        script_files.iter().map(|f| observes(read_locus(f), Scale::Single, "sed reads an -f script file")).collect();
1188    // Script-embedded file commands (`w`/`W` write, `r`/`R` read, `s///w` write) — gate each target
1189    // by its locus, just like an operand file.
1190    caps.extend(script.writes.iter().map(|f| mutates(classify_locus(f), Scale::Single, "sed w/W writes a file")));
1191    caps.extend(script.reads.iter().map(|f| observes(read_locus(f), Scale::Single, "sed r/R reads a file")));
1192    if in_place {
1193        caps.extend(files.iter().map(|f| mutates(classify_locus(f), scale, "sed -i edits the file in place")));
1194    } else {
1195        caps.extend(reads_to_model(&files, scale));
1196    }
1197    Profile::of(caps)
1198}
1199
1200/// The locus of the paths a `$( … )` can PRODUCE, or `None` when nothing bounds them.
1201///
1202/// This is a different question from "is the inner command safe to run", and conflating the two is
1203/// a fail-open: `echo` is inert and `$(echo /etc/shadow)` still names a credential file. So a
1204/// command only gets an answer here if it has declared one (`[command.output]`); everything else
1205/// stays unpinnable, exactly as before. See docs/design/behavioral-taxonomy-substitution-locus.md.
1206pub(crate) fn substitution_claim(script: &crate::cst::Script) -> Option<SubClaim> {
1207    // A pipeline's VALUE is its last stage's stdout; the earlier stages feed it and are verdicted
1208    // separately as usual. Pass-through filters (`… | head -1`) emit a SUBSET of what they were
1209    // given, so walking back over them reaches the stage that actually produced the paths.
1210    let [stmt] = script.0.as_slice() else { return None };
1211    let cmds = &stmt.pipeline.commands;
1212    let mut idx = cmds.len().checked_sub(1)?;
1213    loop {
1214        match stage_output_locus(cmds.get(idx)?)? {
1215            StageOutput::Locus(l) => return Some(SubClaim::Locus(l)),
1216            // A pass-through filter emits a SUBSET of its input words, so it cannot turn an atom
1217            // into something with a separator — the claim survives the filter unchanged.
1218            StageOutput::Atom => return Some(SubClaim::Atom),
1219            StageOutput::PassThrough => idx = idx.checked_sub(1)?,
1220        }
1221    }
1222}
1223
1224enum StageOutput {
1225    Locus(LocalLocus),
1226    /// Every word of this stage's stdout is separator-free, so no word can BE a path.
1227    Atom,
1228    /// This stage only filters; ask the stage before it.
1229    PassThrough,
1230}
1231
1232/// What a `$(…)` is known to yield. Two different kinds of claim, which is why this is not an
1233/// `Option<LocalLocus>`: a locus says the value NAMES something at a rung, an atom says the value
1234/// names nothing at all and cannot traverse. The second is the weaker claim and the more useful
1235/// one — it is what lets a literal prefix survive around an interpolated leaf.
1236pub(crate) enum SubClaim {
1237    Locus(LocalLocus),
1238    Atom,
1239}
1240
1241fn stage_output_locus(cmd: &crate::cst::Cmd) -> Option<StageOutput> {
1242    let crate::cst::Cmd::Simple(simple) = cmd else { return None };
1243    let words: Vec<String> = simple.words.iter().map(crate::cst::Word::eval).collect();
1244    use crate::registry::types::OutputLocus;
1245    let (name, args) = words.split_first()?;
1246    // A resolvable name reached from a non-standard path (`./fd`) may not be the real tool, so it
1247    // gets no output-locus claim — the same spoof rule `resolve` applies to the command itself.
1248    if !trusted_command_path(name) {
1249        return None;
1250    }
1251    let token = Token::from_raw(name.clone());
1252    let canonical = crate::registry::canonical_name(token.command_name());
1253    let rule = crate::registry::command_output_locus(canonical)?;
1254    // `--help` and `--version` replace the command's DATA output with prose, and EVERY output
1255    // claim is a statement about the data. GNU `seq --help` prints
1256    // `<https://www.gnu.org/software/coreutils/>` — slash-bearing words under an `atom` claim that
1257    // says no word can contain a separator. Handled here rather than in each command's
1258    // `invalidated_by` so it holds for claims that do not exist yet: the danger is not seq (whose
1259    // help leaks only URLs, which as paths are relative) but the next atom source whose help
1260    // prints `/etc/foo.conf`, which would hand an ABSOLUTE path to a caller told it was confined.
1261    //
1262    // Long forms only. `-h` and `-V` are not reliably help/version — `sort -h` is human-numeric
1263    // sort — so treating them as informational would void real claims. A command whose OWN grammar
1264    // maps a short flag to help lists it in `invalidated_by` (see seq).
1265    //
1266    // Not caught by the local install: macOS ships BSD seq, whose help is terse and slash-free.
1267    if args.iter().any(|a| a == "--help" || a == "--version") {
1268        return None;
1269    }
1270    // A flag that changes what stdout CONTAINS (`fd -x cat {}` prints file bodies, `fd -l` prints
1271    // `ls -l` rows) voids the claim — the output is no longer a path at all.
1272    if args.iter().any(|a| flag_present(a, &rule.invalidated_by)) {
1273        return None;
1274    }
1275
1276    match rule.locus_from {
1277        // An ATOM names no locus — a separator-free word is not a path and cannot stand in for
1278        // one. It pays off in the PATH layer instead: a literal prefix around a FLANKED atom leaf
1279        // is confinable, because the atom cannot introduce a `/` and the flanking rules out the
1280        // leaf being `.` or `..`. Both halves of that are enforced in `locus::neutralize_atoms`;
1281        // on its own this claim widens nothing, since an atom sentinel is `is_unpinnable`.
1282        OutputLocus::Atom => Some(StageOutput::Atom),
1283        // The cwd is the workspace root by construction (the harness passes it), so `$(pwd)` is a
1284        // worktree path. `pathctx` is what decides whether the cwd itself escaped the root.
1285        OutputLocus::Cwd => Some(StageOutput::Locus(read_locus("."))),
1286        // Output descends the command's own path operands, so it is bounded by their worst read
1287        // locus. `fd x app/ lib/` → worktree; `fd x /` → machine.
1288        OutputLocus::Operands => {
1289            // ANY unpinnable argument voids the claim, checked before the path-shape filter and
1290            // over every argument rather than the ones that look like roots. A `$VAR` root carries
1291            // no `/`, so shape-filtering first read `fd pat $SECRET` as having no root at all and
1292            // reported worktree — while the command searches wherever `$SECRET` points.
1293            if args.iter().any(|a| is_unpinnable(a)) {
1294                return None;
1295            }
1296            let roots = candidate_roots(args, &rule.valued);
1297            // No path operand means the command searches `.` (`fd pattern`), which is the cwd.
1298            let worst = roots.iter().map(|r| read_locus(r)).max().unwrap_or_else(|| read_locus("."));
1299            Some(StageOutput::Locus(worst))
1300        }
1301        // Only a filter when it is filtering: given a file operand it prints that file's CONTENTS,
1302        // which are caller-controlled text and no kind of path.
1303        OutputLocus::Stdin => {
1304            if candidate_roots(args, &rule.valued).is_empty() {
1305                Some(StageOutput::PassThrough)
1306            } else {
1307                None
1308            }
1309        }
1310    }
1311}
1312
1313/// Whether `arg` is one of `flags`, in any spelling that carries a value (`-x`, `--exec`,
1314/// `--exec=…`). A short flag may also be CLUSTERED (`-lx`), so single-char forms are matched
1315/// against the cluster's letters.
1316fn flag_present(arg: &str, flags: &[String]) -> bool {
1317    let head = arg.split('=').next().unwrap_or(arg);
1318    flags.iter().any(|f| {
1319        if head == f {
1320            return true;
1321        }
1322        match (f.strip_prefix('-'), arg.strip_prefix('-')) {
1323            (Some(letter), Some(cluster)) if f.len() == 2 && !arg.starts_with("--") => {
1324                cluster.contains(letter)
1325            }
1326            _ => false,
1327        }
1328    })
1329}
1330
1331/// Every argument that could name a search ROOT, over-approximated on purpose.
1332///
1333/// Under-counting here is a fail-OPEN — a missed root means a lower locus than the command actually
1334/// reaches — so EVERY non-flag argument counts, plus any path glued to a flag
1335/// (`--search-path=/etc`, `-E/etc/x`). Over-counting only ever raises the locus, which denies.
1336///
1337/// It deliberately does NOT ask whether an argument looks like a path. That test (`looks_like_path`)
1338/// keys on a `/` or a `.`, so a bare `~` failed it and `cat $(fd pat ~)` auto-approved a sweep of
1339/// the home directory as though it were worktree-local. A shape heuristic cannot be the last word
1340/// on a question whose wrong answer opens a hole.
1341fn candidate_roots<'a>(args: &'a [String], valued: &[String]) -> Vec<&'a str> {
1342    let mut roots = Vec::new();
1343    let mut skip_value = false;
1344    for a in args {
1345        if std::mem::take(&mut skip_value) {
1346            continue;
1347        }
1348        if a.starts_with('-') {
1349            // `valued` declares "this flag's value is NOT a path" (a count, a separator), so its
1350            // value is skipped in BOTH spellings. Handling only the separated form denied
1351            // `head --lines=5` while `head -n 5` passed — the same operation, two spellings.
1352            let (head, glued_value) = match a.split_once('=') {
1353                Some((h, v)) => (h, Some(v)),
1354                None => (a.as_str(), None),
1355            };
1356            if valued.iter().any(|v| v == head) {
1357                skip_value = glued_value.is_none();
1358                continue;
1359            }
1360            // Otherwise a glued value can still name a root. After `=` the whole value counts —
1361            // keying on `/` alone missed `--search-path=~`, the same blind spot as the shape test.
1362            // Without an `=`, a glued short value starts at the first path-ish character.
1363            let glued = glued_value.or_else(|| a.find(['/', '~']).map(|i| &a[i..]));
1364            if let Some(v) = glued.filter(|v| !v.is_empty()) {
1365                roots.push(v);
1366            }
1367            continue;
1368        }
1369        roots.push(a.as_str());
1370    }
1371    roots
1372}
1373
1374fn resolve_perl(tokens: &[Token]) -> Profile {
1375    // perl's `-e` one-liner is arbitrary code, so the identifier gate in `handlers::perl` decides
1376    // whether the CODE is inert. What that gate cannot do is judge the OPERANDS: it never looked at
1377    // them, which is why `perl -pe s/a/b/ /etc/shadow` used to read a credential file and print it
1378    // to the model. Both halves are needed — an inert one-liner over a system file is still an
1379    // exfiltration, and a worktree file rewritten by unmodeled code is still RCE.
1380    use crate::handlers::perl::PerlCode;
1381    let Some(scan) = crate::handlers::perl::scan_perl(tokens) else {
1382        return worst("perl: unmodeled flag cluster — worst-cased (§0)");
1383    };
1384    match scan.code {
1385        PerlCode::None => {
1386            let mut c = Capability::new(Operation::Observe);
1387            c.disclosure.audience = DisclosureAudience::LocalProcess;
1388            c.because = "perl: reports its own version/usage".to_string();
1389            return Profile::of(vec![c]);
1390        }
1391        // No `-e`/`-E` means the first operand is a SCRIPT FILE whose contents we cannot inspect
1392        // (like `sed -f`, `awk -f`, `bash x.sh`), and a failed identifier gate means the one-liner
1393        // reached outside the modeled vocabulary. Neither is separable from arbitrary execution.
1394        PerlCode::Opaque => return worst("perl: no inspectable -e/-E one-liner — worst-cased (§0)"),
1395        PerlCode::Inspectable => {}
1396    }
1397    // A sweeping in-place edit (`perl -pi -e … *`) is bounded but not single; locus still binds
1398    // each operand, so breadth widens the blast radius without ever admitting a system path.
1399    let files: Vec<&str> = scan.files.iter().map(String::as_str).collect();
1400    let scale = breadth_scale(&files, false);
1401    // No `execute` capability, deliberately. perl does run code, so recording one looks more
1402    // honest — but it is the wrong model here and the experiment says so: an
1403    // `executes(caller-inline)` capability denies at every band, which would take out every perl
1404    // one-liner including the in-place edits this hook exists to admit. The reason it denies is
1405    // that the `execute` rung describes running code of UNKNOWN content, and by this point the
1406    // identifier gate has already established the opposite — the one-liner reaches nothing but
1407    // pure built-ins, no I/O, no exec, no network. What remains observable is the operand reads
1408    // and writes below, and those ARE the profile. If the gate's vocabulary ever admits an
1409    // identifier with side effects, the fix belongs in the gate, not in a capability here.
1410    let caps: Vec<Capability> = if scan.in_place {
1411        files.iter().map(|f| mutates(classify_locus(f), scale, "perl -i edits the file in place")).collect()
1412    } else {
1413        reads_to_model(&files, scale)
1414    };
1415    Profile::of(caps)
1416}
1417
1418/// The outcome of parsing one `sed` short-option cluster.
1419enum SedShort<'a> {
1420    Bad,
1421    Standalone,
1422    InPlace,                                                    // -i (rest is the optional suffix)
1423    Script { consumes_next: bool },                            // -e SCRIPT
1424    ScriptFile { file: Option<&'a str>, consumes_next: bool }, // -f FILE
1425    SkipValue { consumes_next: bool },                         // -l N
1426}
1427
1428fn sed_cluster<'a>(cluster: &'a str, next: Option<&'a str>, boolset: &[u8]) -> SedShort<'a> {
1429    let bytes = cluster.as_bytes();
1430    let mut k = 0;
1431    while k < bytes.len() {
1432        // A flag byte is ASCII; a non-ASCII lead/continuation byte is not a flag, and slicing
1433        // `cluster[k + 1..]` at it would land mid-char and panic. Bail as unrecognized.
1434        if !bytes[k].is_ascii() {
1435            return SedShort::Bad;
1436        }
1437        let glued = &cluster[k + 1..]; // safe: bytes[k] is ASCII → k+1 is a char boundary
1438        let has = !glued.is_empty();
1439        match bytes[k] {
1440            b'i' => return SedShort::InPlace, // -i[SUFFIX]: the rest of the cluster is the suffix
1441            b'e' => return SedShort::Script { consumes_next: !has },
1442            b'f' => {
1443                let file = if has { Some(glued) } else { next };
1444                return SedShort::ScriptFile { file, consumes_next: !has };
1445            }
1446            b'l' if has || next.is_some() => return SedShort::SkipValue { consumes_next: !has }, // -l N
1447            b if boolset.contains(&b) => k += 1,
1448            _ => return SedShort::Bad,
1449        }
1450    }
1451    SedShort::Standalone
1452}
1453
1454/// Parse a `sed` long option, returning how many tokens it consumed, or `None` if unknown.
1455fn sed_long<'a>(
1456    long: &'a str,
1457    next: Option<&'a str>,
1458    in_place: &mut bool,
1459    script_from_flag: &mut bool,
1460    script_files: &mut Vec<&'a str>,
1461) -> Option<usize> {
1462    let name = long.split('=').next().unwrap_or(long);
1463    match name {
1464        "in-place" => *in_place = true, // --in-place[=SUFFIX] (glued only)
1465        "expression" => {
1466            *script_from_flag = true;
1467            return Some(if long.contains('=') { 1 } else { 2 });
1468        }
1469        "file" => {
1470            *script_from_flag = true;
1471            match long.split_once('=') {
1472                Some((_, v)) => script_files.push(v),
1473                None => {
1474                    script_files.extend(next);
1475                    return Some(2);
1476                }
1477            }
1478        }
1479        "quiet" | "silent" | "regexp-extended" | "null-data" | "separate" | "unbuffered"
1480        | "posix" | "help" | "version" | "debug" | "follow-symlinks" | "sandbox"
1481        | "zero-terminated" | "line-length" => {}
1482        _ => return None,
1483    }
1484    Some(1)
1485}
1486
1487#[cfg(test)]
1488mod tests {
1489    use super::*;
1490
1491    fn toks(parts: &[&str]) -> Vec<Token> {
1492        parts.iter().map(|p| Token::from_test(p)).collect()
1493    }
1494
1495    fn level(name: &str) -> &'static crate::engine::level::Level {
1496        crate::engine::authoring::default_levels()
1497            .iter()
1498            .find(|l| l.name == name)
1499            .expect("level exists")
1500    }
1501
1502    fn inert() -> &'static crate::engine::level::Level {
1503        level("paranoid")
1504    }
1505
1506    fn read_local() -> &'static crate::engine::level::Level {
1507        level("reader")
1508    }
1509
1510    /// `resolve_openssl` contract: a private-key/decrypt form reaching the MODEL classifies as
1511    /// decrypt-read (secret=reads → refused by developer, admitted only by yolo); a public/to-file/
1512    /// validate form ABSTAINS (None → openssl's legacy allow_all); a spoofed path worst-cases.
1513    #[test]
1514    fn openssl_resolver_gates_model_disclosure_only() {
1515        let (dev, yolo) = (level("developer"), level("yolo"));
1516        for parts in [
1517            &["openssl", "rsa", "-in", "priv.pem"][..],
1518            &["openssl", "rsa", "-in", "priv.pem", "-pubout", "-text"], // -text past -pubout
1519            &["openssl", "rsa", "-in", "priv.pem", "-out", "/dev/stdout"], // -out value is stdout
1520            &["openssl", "rsa", "-in", "priv.pem", "-noout", "-text"],
1521            &["openssl", "pkcs8", "-in", "priv.pem"],
1522            &["openssl", "enc", "--d", "-k", "p", "-in", "c"], // --opt alias
1523            &["openssl", "cms", "-EncryptedData_decrypt", "-in", "m"],
1524            &["openssl", "pkcs12", "-in", "f.p12", "-noenc"],
1525        ] {
1526            let p = resolve(&toks(parts)).unwrap_or_else(|| panic!("resolves: {parts:?}"));
1527            assert!(
1528                p.capabilities.iter().any(|c| c.secret.level == SecretLevel::Reads),
1529                "secret=reads: {parts:?}",
1530            );
1531            assert!(!dev.admits(&p), "developer refuses: {parts:?}");
1532            assert!(yolo.admits(&p), "yolo admits: {parts:?}");
1533        }
1534        for parts in [
1535            &["openssl", "rsa", "-in", "priv.pem", "-pubout"][..],
1536            &["openssl", "rsa", "-in", "priv.pem", "-noout"],       // validate, no output
1537            &["openssl", "rsa", "-in", "enc.pem", "-out", "clean.pem"], // to a FILE, off the model
1538            &["openssl", "pkey", "-in", "pub.pem", "-pubin", "-text"], // public input → public text
1539            &["openssl", "pkcs12", "-in", "f.p12", "-nodes", "-out", "k.pem"],
1540            &["openssl", "enc", "-e", "-in", "x", "-out", "x.enc", "-k", "p"],
1541            &["openssl", "x509", "-in", "c", "-noout", "-text"],
1542        ] {
1543            assert!(resolve(&toks(parts)).is_none(), "resolver abstains (→ legacy): {parts:?}");
1544        }
1545    }
1546
1547    /// The output-destination check is FAIL-CLOSED: only a single plain-file `-out` diverts the key
1548    /// off the model. Path-normalization spellings, `/dev/stderr`, and duplicate `-out` (openssl honors
1549    /// the last) must all read as model-reaching — the sign-off review found the old device-spelling
1550    /// denylist let these through.
1551    #[test]
1552    fn openssl_output_destination_is_fail_closed() {
1553        let dev = level("developer");
1554        let reaches_model = |args: &[&str]| {
1555            let mut parts = vec!["openssl", "rsa", "-in", "priv.pem"];
1556            parts.extend_from_slice(args);
1557            // decrypt-read (secret=reads, refused by developer) ⇔ the output reached the model; a
1558            // diverted output makes the resolver ABSTAIN (None → openssl's benign legacy).
1559            match resolve(&toks(&parts)) {
1560                None => false,
1561                Some(p) => {
1562                    p.capabilities.iter().any(|c| c.secret.level == SecretLevel::Reads) && !dev.admits(&p)
1563                }
1564            }
1565        };
1566        for evasion in [
1567            &["-out", "//dev/stdout"][..],
1568            &["-out", "/dev/./stdout"],
1569            &["-out", "//dev/fd/1"],
1570            &["-out", "/dev/fd//1"],
1571            &["-out=//dev/stdout"],
1572            &["-out", "/dev/stderr"],
1573            &["-out", "/foo/../dev/stdout"],
1574            &["-out", "dup.pem", "-out", "/dev/stdout"], // last-wins
1575            &["-out", "-"],
1576            // openssl's parser lets `-provider-path` swallow the `-out` token → openssl writes to
1577            // stdout; our scan then misreads the trailing flag as the filename. A dash-leading `-out`
1578            // value is the tell → fail closed.
1579            &["-provider-path", "-out", "-provider-path", "safe.pem"],
1580            &["-out", "-anything"],
1581        ] {
1582            assert!(reaches_model(evasion), "must read as model-reaching: {evasion:?}");
1583        }
1584        for diverted in [
1585            &["-out", "clean.pem"][..],
1586            &["-out", "./sub/key.pem"],
1587            &["-out", "devnotes.pem"], // "dev" prefix on a filename is not the /dev device
1588            &["-out", "/home/u/key.pem"],
1589            &["-noout"],
1590        ] {
1591            assert!(!reaches_model(diverted), "must divert off the model: {diverted:?}");
1592        }
1593    }
1594
1595    #[test]
1596    fn echo_resolves_to_a_benign_inert_profile() {
1597        let p = resolve(&toks(&["echo", "hi"])).expect("echo has a resolver");
1598        assert_eq!(p.capabilities.len(), 1);
1599        let c = &p.capabilities[0];
1600        assert_eq!(c.operation, Operation::Observe);
1601        assert_eq!(c.locus.local, LocalLocus::Process);
1602        assert_eq!(c.disclosure.audience, DisclosureAudience::LocalProcess);
1603        assert!(!c.because.is_empty(), "a structural certification cites its reason");
1604        // admitted at the *strictest* level — every facet (network/exec/secret/…) is zero
1605        assert!(inert().admits(&p), "echo is fully certified and inert-safe");
1606    }
1607
1608    #[test]
1609    fn echo_flags_do_not_change_its_profile() {
1610        let bare = resolve(&toks(&["echo", "hi"])).expect("echo");
1611        let flagged = resolve(&toks(&["echo", "-n", "-e", "hi"])).expect("echo -n -e");
1612        assert_eq!(bare, flagged);
1613        assert!(inert().admits(&flagged));
1614    }
1615
1616    #[test]
1617    fn an_unresearched_command_has_no_resolver() {
1618        assert!(resolve(&toks(UNRESOLVED_CMD)).is_none(), "unresearched → caller worst-cases");
1619        assert!(resolve(&[]).is_none(), "empty tokens");
1620    }
1621
1622    #[test]
1623    fn cat_of_a_worktree_file_is_read_local() {
1624        let p = resolve(&toks(&["cat", "./notes.md"])).expect("cat");
1625        assert!(read_local().admits(&p), "cat ./notes.md");
1626        assert!(!inert().admits(&p), "reading a real file is above inert");
1627    }
1628
1629    #[test]
1630    fn cat_beyond_the_worktree_is_denied_by_locus() {
1631        // Secrets, private home, unpinnable, and unrecognized system paths stay denied…
1632        for path in ["~/.ssh/id_rsa", "~/notes", "/etc/shadow", "$SECRET", "../outside", "/var/lib/mysql/data"] {
1633            let p = resolve(&toks(&["cat", path])).expect("cat");
1634            assert!(!read_local().admits(&p), "cat {path} is above read-local by locus");
1635        }
1636    }
1637
1638    #[test]
1639    fn cat_of_machine_config_is_not_admitted() {
1640        // The retreat still holds for machine CONFIG and STATE: these prompt, or the user grants.
1641        // `/usr/share/doc/x` moved out of this list deliberately — see the test below.
1642        for path in ["/etc/hosts", "/etc/os-release", "/usr/local/etc/nginx/nginx.conf", "/var/log/auth.log"] {
1643            let p = resolve(&toks(&["cat", path])).expect("cat");
1644            assert!(!read_local().admits(&p), "cat {path} is no longer auto-approved");
1645        }
1646    }
1647
1648    /// Distributed package CONTENT is read-admitted; its WRITE face is not.
1649    ///
1650    /// The retreat refused whole roots because an audit found them leaking — the macOS keychain,
1651    /// Homebrew service configs under `etc`, auth tokens under `/var/log`. Those all live in a
1652    /// root's machine-local half. Cutting at the layer the FHS already separates keeps the leaks
1653    /// out (`etc`/`var` are never admitted) while ending the friction of refusing a man page or a
1654    /// vendored crate README, whose bytes are public by construction.
1655    #[test]
1656    fn package_content_is_readable_but_never_writable() {
1657        for path in [
1658            "/usr/share/doc/x",
1659            "/usr/share/man/man1/git.1",
1660            "/usr/local/share/doc/x/README",
1661            "/opt/homebrew/lib/node_modules/npm/package.json",
1662            "/Library/Developer/CommandLineTools/usr/include/stdio.h",
1663            "/nix/store/abc/share/doc/README",
1664            "~/.cargo/registry/src/idx/serde-1.0/README.md",
1665            "~/.rustup/toolchains/stable/lib/rustlib/src/core/src/lib.rs",
1666            "~/go/pkg/mod/github.com/x/y@v1/README.md",
1667            "~/.local/share/mise/installs/node/22/README.md",
1668        ] {
1669            let read = resolve(&toks(&["cat", path])).expect("cat");
1670            assert!(read_local().admits(&read), "reading package content {path} should be admitted");
1671            let write = resolve(&toks(&["rm", "-rf", path])).expect("rm");
1672            assert!(
1673                !read_local().admits(&write),
1674                "package content {path} must NOT be writable — this widens disclosure only"
1675            );
1676        }
1677    }
1678
1679    /// The credential shield outranks an admit prefix, whatever the specificity ordering says.
1680    ///
1681    /// Specificity ranks exact ≫ prefix ≫ segment, so every subtree admit outranked the shield's
1682    /// segment match: `/usr/share/.ssh/id_rsa` was APPROVED the moment package content became
1683    /// readable. A shield that a new admit node can widen is not a shield.
1684    #[test]
1685    fn an_admit_prefix_can_never_widen_the_credential_shield() {
1686        for path in [
1687            "/usr/share/.ssh/id_rsa",
1688            "/usr/local/lib/.aws/credentials",
1689            "/opt/homebrew/share/.gnupg/secring.gpg",
1690            "~/.cargo/registry/.ssh/id_ed25519",
1691            "/nix/store/x/.aws/credentials",
1692        ] {
1693            let p = resolve(&toks(&["cat", path])).expect("cat");
1694            assert!(!read_local().admits(&p), "an admit prefix widened the shield at {path}");
1695        }
1696    }
1697
1698    #[test]
1699    fn cat_stdin_is_process_scoped() {
1700        assert!(inert().admits(&resolve(&toks(&["cat"])).expect("cat")), "no operand → stdin");
1701        assert!(inert().admits(&resolve(&toks(&["cat", "-"])).expect("cat -")), "- → stdin");
1702    }
1703
1704    #[test]
1705    fn cat_reads_every_file_operand_and_one_home_read_sinks_it() {
1706        let p = resolve(&toks(&["cat", "-n", "a.txt", "src/b.rs"])).expect("cat");
1707        assert_eq!(p.capabilities.len(), 2, "-n is a flag; two files");
1708        assert!(read_local().admits(&p), "both worktree");
1709
1710        let mixed = resolve(&toks(&["cat", "a.txt", "~/.ssh/id_rsa"])).expect("cat");
1711        assert!(!read_local().admits(&mixed), "one home read sinks the whole profile");
1712    }
1713
1714    #[test]
1715    fn cat_double_dash_treats_the_rest_as_files() {
1716        let p = resolve(&toks(&["cat", "--", "-n"])).expect("cat");
1717        assert_eq!(p.capabilities.len(), 1, "-n after -- is a filename");
1718        assert!(read_local().admits(&p));
1719    }
1720
1721    #[test]
1722    fn head_tail_wc_read_like_cat_and_honor_numeric_shorthand() {
1723        use crate::engine::bridge::project;
1724        use crate::verdict::{SafetyLevel, Verdict};
1725        // worktree reads → read-local (SafeRead); home reads → denied by locus, same as cat.
1726        for cmd in [
1727            vec!["head", "README.md"],
1728            vec!["head", "-n", "5", "src/main.rs"],
1729            vec!["head", "-20", "src/main.rs"],   // obsolete -NUM form must parse
1730            vec!["tail", "-f", "./log.txt"],       // follow is still a bounded read
1731            vec!["tail", "-n", "100", "./log.txt"],
1732            vec!["wc", "-l", "./notes.md"],
1733        ] {
1734            assert_eq!(project(&resolve(&toks(&cmd)).expect("read")), Verdict::Allowed(SafetyLevel::SafeRead), "{cmd:?}");
1735        }
1736        // reading stdin (`-`) is process-scoped → inert, like `cat -`.
1737        assert_eq!(project(&resolve(&toks(&["wc", "-c", "-"])).expect("wc")), Verdict::Allowed(SafetyLevel::Inert), "wc stdin");
1738        for cmd in [vec!["head", "~/.ssh/id_rsa"], vec!["tail", "/etc/shadow"], vec!["wc", "-l", "$SECRET"]] {
1739            assert_eq!(project(&resolve(&toks(&cmd)).expect("read")), Verdict::Denied, "{cmd:?} beyond worktree");
1740        }
1741        // -NUM consumes no operand: `head -20 file` reads exactly `file`, not a phantom "20".
1742        let p = resolve(&toks(&["head", "-20", "src/main.rs"])).expect("head");
1743        assert_eq!(p.capabilities.len(), 1, "-20 is the count, not a file");
1744        // wc --files0-from reads an unpinnable set → worst-case → denied.
1745        assert_eq!(project(&resolve(&toks(&["wc", "--files0-from=list"])).expect("wc")), Verdict::Denied, "--files0-from");
1746        assert_eq!(project(&resolve(&toks(&["wc", "--files0-from", "-"])).expect("wc")), Verdict::Denied, "--files0-from -");
1747        // unknown flags fail closed.
1748        assert_eq!(project(&resolve(&toks(&["head", "-Z", "x"])).expect("head")), Verdict::Denied, "unknown flag");
1749    }
1750
1751    #[test]
1752    fn grep_reads_its_files_not_the_pattern() {
1753        let p = resolve(&toks(&["grep", "foo", "file.txt"])).expect("grep");
1754        assert_eq!(p.capabilities.len(), 1, "the pattern is not a file");
1755        assert!(read_local().admits(&p));
1756    }
1757
1758    #[test]
1759    fn grep_beyond_the_worktree_is_denied() {
1760        for args in [
1761            vec!["grep", "foo", "~/.ssh/config"],
1762            vec!["grep", "-r", "foo", "~"],
1763            vec!["grep", "foo", "$DIR"],
1764        ] {
1765            let p = resolve(&toks(&args)).expect("grep");
1766            assert!(!read_local().admits(&p), "{args:?}");
1767        }
1768    }
1769
1770    #[test]
1771    fn grep_recursive_is_unbounded_and_defaults_to_cwd() {
1772        let p = resolve(&toks(&["grep", "-r", "foo", "src/"])).expect("grep");
1773        assert!(p.capabilities.iter().all(|c| c.scale == Scale::Unbounded), "-r → unbounded");
1774        assert!(read_local().admits(&p), "recursive worktree search");
1775
1776        let cwd = resolve(&toks(&["grep", "-r", "foo"])).expect("grep");
1777        assert!(cwd.capabilities.iter().all(|c| c.locus.local == LocalLocus::Worktree), "cwd, not stdin");
1778        assert!(read_local().admits(&cwd));
1779    }
1780
1781    #[test]
1782    fn grep_e_and_f_supply_the_pattern_so_positionals_are_files() {
1783        // -e: pattern is the flag's value; file.txt is the only file
1784        let e = resolve(&toks(&["grep", "-e", "foo", "file.txt"])).expect("grep -e");
1785        assert_eq!(e.capabilities.len(), 1);
1786        assert!(read_local().admits(&e));
1787
1788        // -f: the pattern FILE is itself a read
1789        let f = resolve(&toks(&["grep", "-f", "patterns.txt", "file.txt"])).expect("grep -f");
1790        assert_eq!(f.capabilities.len(), 2, "patterns.txt + file.txt");
1791        assert!(read_local().admits(&f));
1792
1793        let home = resolve(&toks(&["grep", "-f", "~/.secret-patterns", "file.txt"])).expect("grep -f");
1794        assert!(!read_local().admits(&home), "a home pattern file is denied by locus");
1795
1796        // glued short value: -fpatterns.txt and -ifpatterns.txt both name a pattern file
1797        let glued = resolve(&toks(&["grep", "-fpatterns.txt", "file.txt"])).expect("grep -f glued");
1798        assert_eq!(glued.capabilities.len(), 2, "glued -f value is still a read");
1799        let glued_home = resolve(&toks(&["grep", "-if~/.secrets", "x"])).expect("grep -if glued");
1800        assert!(!read_local().admits(&glued_home), "glued home pattern file denied by locus");
1801    }
1802
1803    #[test]
1804    fn grep_long_flags() {
1805        // --file / --file= name a pattern file grep also reads (2 caps)
1806        assert_eq!(resolve(&toks(&["grep", "--file", "p.txt", "f.txt"])).expect("grep").capabilities.len(), 2);
1807        assert_eq!(resolve(&toks(&["grep", "--file=p.txt", "f.txt"])).expect("grep").capabilities.len(), 2);
1808
1809        // --regexp supplies the pattern; the positional is the file
1810        let r = resolve(&toks(&["grep", "--regexp", "foo", "f.txt"])).expect("grep");
1811        assert_eq!(r.capabilities.len(), 1);
1812        assert!(read_local().admits(&r));
1813
1814        // a space-separated long value (`--max-count 5`) is imprecise — `5` is read as a
1815        // phantom positional — but FAIL-SAFE: still worktree-bounded, admitted at
1816        // read-local, never looser. (Precise handling needs the TOML flag schema.)
1817        let m = resolve(&toks(&["grep", "--max-count", "5", "foo", "f.txt"])).expect("grep");
1818        assert!(read_local().admits(&m), "--max-count 5 is fail-safe (imprecise)");
1819
1820        // --perl-regexp (PCRE2) runs no code — benign like any regex-engine flag; reads read-local.
1821        let pcre = resolve(&toks(&["grep", "--perl-regexp", "foo", "f"])).expect("grep");
1822        assert!(read_local().admits(&pcre), "grep --perl-regexp reads a file, it does not exec");
1823    }
1824
1825    #[test]
1826    fn grep_dash_patterns_are_search_patterns_not_flags() {
1827        // A `--`-prefixed token that is not a recognized grep flag is a SEARCH PATTERN, not
1828        // an unknown flag — grep patterns commonly look like `-->`, `---`, `--foo`. The
1829        // engine must read the file operand at read-local, matching the legacy handler, not
1830        // worst-case it.
1831        for args in [
1832            vec!["grep", "-->", "file.txt"],
1833            vec!["grep", "---", "file.txt"],
1834            vec!["grep", "--some-pattern", "file.txt"],
1835            vec!["grep", "-rn", "-->", "src/"],
1836            vec!["grep", "-i", "-r", "-n", "-->", "src/"],
1837        ] {
1838            let p = resolve(&toks(&args)).expect("grep");
1839            assert!(read_local().admits(&p), "dash-pattern should read-local: {args:?}");
1840            assert!(!inert().admits(&p), "it still reads a file: {args:?}");
1841        }
1842        // but the genuinely-dangerous long (--dereference-recursive, symlink escape) worst-cases
1843        let args = vec!["grep", "--dereference-recursive", "foo", "dir"];
1844        let p = resolve(&toks(&args)).expect("grep");
1845        assert!(!read_local().admits(&p), "dangerous long must worst-case: {args:?}");
1846        // PCRE flags now read-local (PCRE2 execs no code): -P short, --perl-regexp long, -oP combined.
1847        for args in [
1848            vec!["grep", "-P", "foo", "f"],
1849            vec!["grep", "--perl-regexp", "foo", "f"],
1850            vec!["grep", "-oP", "foo", "f"],
1851        ] {
1852            let p = resolve(&toks(&args)).expect("grep");
1853            assert!(read_local().admits(&p), "grep PCRE flag should read-local: {args:?}");
1854        }
1855    }
1856
1857    #[test]
1858    fn grep_stdin_and_standalone_flags() {
1859        assert!(inert().admits(&resolve(&toks(&["grep", "foo"])).expect("grep")), "no file → stdin");
1860        let p = resolve(&toks(&["grep", "-i", "-n", "foo", "file.txt"])).expect("grep");
1861        assert_eq!(p.capabilities.len(), 1, "-i -n standalone; foo pattern; file.txt file");
1862        assert!(read_local().admits(&p));
1863    }
1864
1865    /// The complete resolved capability for a single-capability invocation, with
1866    /// `because` cleared so the assertion is over the **facets** (not the prose).
1867    fn one_cap(cmd: &[&str]) -> Capability {
1868        let p = resolve(&toks(cmd)).expect("resolves");
1869        assert_eq!(p.capabilities.len(), 1, "{cmd:?} is a single-capability invocation");
1870        let mut c = p.capabilities[0].clone();
1871        c.because = String::new();
1872        c
1873    }
1874
1875    /// Golden profiles: assert **every** facet of the resolved capability for
1876    /// representative invocations. This is the "all facets covered" check (§0) — struct
1877    /// equality means a facet the resolver forgot (left at a wrong default) or set wrong
1878    /// fails the test, per command. When commands carry TOML profiles, the expected
1879    /// profile is derived from the TOML instead of hand-built here.
1880    #[test]
1881    fn golden_profiles_cover_every_facet() {
1882        // echo — the reference `structural` profile: observe, process-scoped, output to
1883        // the model, and every other axis provably zero.
1884        let mut echo = Capability::new(Operation::Observe);
1885        echo.disclosure.audience = DisclosureAudience::LocalProcess;
1886        assert_eq!(one_cap(&["echo", "hi"]), echo, "echo");
1887
1888        // cat of a worktree file — observe · worktree · content-to-model.
1889        let mut cat = Capability::new(Operation::Observe);
1890        cat.locus.local = LocalLocus::Worktree;
1891        cat.disclosure.audience = DisclosureAudience::LocalProcess;
1892        assert_eq!(one_cap(&["cat", "./notes.md"]), cat, "cat ./notes.md");
1893
1894        // cat of a plain home file — home is no longer admitted, so locus rises to machine (deny).
1895        let mut cat_home = cat.clone();
1896        cat_home.locus.local = LocalLocus::Machine;
1897        assert_eq!(one_cap(&["cat", "~/notes.txt"]), cat_home, "cat ~/notes.txt");
1898
1899        // cat of a home CREDENTIAL store rises further, to machine (HP-20 credential role).
1900        let mut cat_cred = cat.clone();
1901        cat_cred.locus.local = LocalLocus::Machine;
1902        assert_eq!(one_cap(&["cat", "~/.ssh/id_rsa"]), cat_cred, "cat ~/.ssh/id_rsa");
1903
1904        // grep of a worktree file — like cat, bounded to the single searched file.
1905        assert_eq!(one_cap(&["grep", "foo", "file.txt"]), cat, "grep foo file.txt");
1906
1907        // grep -r — the recursive search raises scale to unbounded and nothing else.
1908        let mut grep_r = cat.clone();
1909        grep_r.scale = Scale::Unbounded;
1910        assert_eq!(one_cap(&["grep", "-r", "foo", "src/"]), grep_r, "grep -r foo src/");
1911
1912        // rm — destroy · worktree · effortful; no net/exec/secret.
1913        let mut rm = Capability::new(Operation::Destroy);
1914        rm.locus.local = LocalLocus::Worktree;
1915        rm.reversibility = Reversibility::Effortful;
1916        assert_eq!(one_cap(&["rm", "./x"]), rm, "rm ./x");
1917
1918        // mkdir — create · worktree · trivial · leaves data. A fresh dir is rmdir-removable.
1919        let mut mkdir = Capability::new(Operation::Create);
1920        mkdir.locus.local = LocalLocus::Worktree;
1921        mkdir.reversibility = Reversibility::Trivial;
1922        mkdir.persistence.level = PersistenceLevel::Data;
1923        assert_eq!(one_cap(&["mkdir", "./build"]), mkdir, "mkdir ./build");
1924
1925        // touch — the same create · worktree · trivial · data shape as mkdir.
1926        assert_eq!(one_cap(&["touch", "./new.txt"]), mkdir, "touch ./new.txt");
1927
1928        // cp -n ./a ./b — a guaranteed-non-clobbering copy is TWO capabilities:
1929        // a source read (observe, worktree, NO model disclosure) and a trivial dest create.
1930        let cp = resolve(&toks(&["cp", "-n", "./a", "./b"])).expect("cp");
1931        assert_eq!(cp.capabilities.len(), 2, "cp = source read + dest write");
1932        let mut src = Capability::new(Operation::Observe);
1933        src.locus.local = LocalLocus::Worktree; // disclosure.audience stays `none`: file→file
1934        assert_eq!(clear_because(&cp.capabilities[0]), src, "cp source read");
1935        let mut dst = Capability::new(Operation::Create);
1936        dst.locus.local = LocalLocus::Worktree;
1937        dst.reversibility = Reversibility::Trivial; // -n → cannot overwrite
1938        dst.persistence.level = PersistenceLevel::Data;
1939        assert_eq!(clear_because(&cp.capabilities[1]), dst, "cp -n dest write");
1940
1941        // mv ./a ./b — a relocation: source MUTATE (trivial, transient — the entry leaves)
1942        // + dest CREATE (recoverable overwrite). Contrast cp's source, which is an observe.
1943        let mv = resolve(&toks(&["mv", "./a", "./b"])).expect("mv");
1944        let mut mv_src = Capability::new(Operation::Mutate);
1945        mv_src.locus.local = LocalLocus::Worktree;
1946        mv_src.reversibility = Reversibility::Trivial;
1947        assert_eq!(clear_because(&mv.capabilities[0]), mv_src, "mv source relocation");
1948        let mut mv_dst = Capability::new(Operation::Create);
1949        mv_dst.locus.local = LocalLocus::Worktree;
1950        mv_dst.reversibility = Reversibility::Recoverable;
1951        mv_dst.persistence.level = PersistenceLevel::Data;
1952        assert_eq!(clear_because(&mv.capabilities[1]), mv_dst, "mv dest write");
1953
1954        // ln ./a ./b — target bridged (observe, no model disclosure) + link create (trivial,
1955        // no -f). Same facet shapes as cp -n, the point being ln reuses `observes`.
1956        let ln = resolve(&toks(&["ln", "./a", "./b"])).expect("ln");
1957        let mut ln_tgt = Capability::new(Operation::Observe);
1958        ln_tgt.locus.local = LocalLocus::Worktree;
1959        assert_eq!(clear_because(&ln.capabilities[0]), ln_tgt, "ln target bridge");
1960        let mut ln_link = Capability::new(Operation::Create);
1961        ln_link.locus.local = LocalLocus::Worktree;
1962        ln_link.reversibility = Reversibility::Trivial;
1963        ln_link.persistence.level = PersistenceLevel::Data;
1964        assert_eq!(clear_because(&ln.capabilities[1]), ln_link, "ln link create");
1965    }
1966
1967    fn clear_because(c: &Capability) -> Capability {
1968        let mut c = c.clone();
1969        c.because = String::new();
1970        c
1971    }
1972
1973    #[test]
1974    fn mkdir_creates_in_the_worktree_but_not_beyond_it() {
1975        use crate::engine::bridge::project;
1976        use crate::verdict::{SafetyLevel, Verdict};
1977        // a fresh dir is a trivial-reversibility create → write-local (SafeWrite)
1978        for cmd in [vec!["mkdir", "./build"], vec!["mkdir", "-p", "a/b/c"], vec!["mkdir", "-m", "755", "./x"]] {
1979            assert_eq!(project(&resolve(&toks(&cmd)).expect("mkdir")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
1980        }
1981        // outside the worktree → denied by locus
1982        for cmd in [vec!["mkdir", "/etc/evil"], vec!["mkdir", "~/newdir"], vec!["mkdir", "$HOME/x"]] {
1983            assert_eq!(project(&resolve(&toks(&cmd)).expect("mkdir")), Verdict::Denied, "{cmd:?}");
1984        }
1985        // a glued valued short (-m755) and its value must not be read as operands
1986        let g = resolve(&toks(&["mkdir", "-m755", "./x"])).expect("mkdir");
1987        assert_eq!(g.capabilities.len(), 1, "-m755 glued: only ./x is an operand");
1988        assert_eq!(g.capabilities[0].locus.local, LocalLocus::Worktree);
1989        // fail-closed on an unknown flag / no operand
1990        assert_eq!(project(&resolve(&toks(&["mkdir", "-Q", "x"])).expect("mkdir")), Verdict::Denied, "unknown flag");
1991        assert_eq!(project(&resolve(&toks(&["mkdir"])).expect("mkdir")), Verdict::Denied, "no operand");
1992    }
1993
1994    #[test]
1995    fn cp_splits_source_and_dest_loci_and_overwrite_gates_the_level() {
1996        use crate::engine::bridge::project;
1997        use crate::verdict::{SafetyLevel, Verdict};
1998
1999        // a copy is a create/overwrite, not a destroy → write-local (SafeWrite), matching
2000        // echo > config.json. Overwriting is recoverable; -n can't clobber (trivial). Both
2001        // write-local — the destroy-vs-create boundary keeps cp below rm.
2002        let plain = resolve(&toks(&["cp", "./a", "./b"])).expect("cp");
2003        assert_eq!(plain.capabilities.last().unwrap().reversibility, Reversibility::Recoverable, "dest overwrite");
2004        assert_eq!(project(&plain), Verdict::Allowed(SafetyLevel::SafeWrite), "cp ./a ./b");
2005        let nc = resolve(&toks(&["cp", "-n", "./a", "./b"])).expect("cp");
2006        assert_eq!(nc.capabilities.last().unwrap().reversibility, Reversibility::Trivial, "-n cannot clobber");
2007        assert_eq!(project(&nc), Verdict::Allowed(SafetyLevel::SafeWrite), "cp -n ./a ./b");
2008
2009        // reading a home/system SOURCE is denied by the source locus — no secret detector,
2010        // just the read locus (cp can't smuggle ~/.ssh/id_rsa into the worktree).
2011        assert_eq!(project(&resolve(&toks(&["cp", "~/.ssh/id_rsa", "./x"])).expect("cp")), Verdict::Denied, "home source");
2012        assert_eq!(project(&resolve(&toks(&["cp", "/etc/shadow", "./x"])).expect("cp")), Verdict::Denied, "system source");
2013        // writing a home/system DEST is denied by the dest locus.
2014        assert_eq!(project(&resolve(&toks(&["cp", "./x", "~/backdoor"])).expect("cp")), Verdict::Denied, "home dest");
2015        assert_eq!(project(&resolve(&toks(&["cp", "./x", "/etc/cron.d/x"])).expect("cp")), Verdict::Denied, "system dest");
2016
2017        // -t DIR makes every positional a source; the dir is the dest. All three spellings
2018        // (separate, --long=, and glued short) must parse the same way.
2019        for form in [
2020            vec!["cp", "-t", "./dest", "./a", "./b"],
2021            vec!["cp", "--target-directory=./dest", "./a", "./b"],
2022            vec!["cp", "-t./dest", "./a", "./b"], // glued short — previously worst-cased
2023        ] {
2024            let t = resolve(&toks(&form)).expect("cp -t");
2025            assert_eq!(t.capabilities.len(), 3, "{form:?}: 2 sources + 1 dest");
2026            assert_eq!(project(&t), Verdict::Allowed(SafetyLevel::SafeWrite), "{form:?}");
2027        }
2028        // a glued -t pointing outside the worktree is still denied by the dest locus.
2029        assert_eq!(project(&resolve(&toks(&["cp", "-t/etc", "./a"])).expect("cp")), Verdict::Denied, "cp -t/etc");
2030
2031        // optional-argument longs (--backup[=X], --preserve[=X]) must NOT swallow the
2032        // source operand: bare and glued forms both leave ./a a source and ./b the dest.
2033        for form in [
2034            vec!["cp", "--backup", "./a", "./b"],
2035            vec!["cp", "--preserve", "./a", "./b"],
2036            vec!["cp", "--preserve=mode", "./a", "./b"],
2037        ] {
2038            let c = resolve(&toks(&form)).expect("cp");
2039            assert_eq!(c.capabilities.len(), 2, "{form:?}: source read + dest write");
2040            assert_eq!(project(&c), Verdict::Allowed(SafetyLevel::SafeWrite), "{form:?}");
2041        }
2042
2043        // recursion raises scale to unbounded; a lone operand / unknown flag worst-cases.
2044        assert_eq!(resolve(&toks(&["cp", "-r", "./a", "./b"])).expect("cp").capabilities[0].scale, Scale::Unbounded);
2045        assert_eq!(project(&resolve(&toks(&["cp", "./only"])).expect("cp")), Verdict::Denied, "no dest");
2046        assert_eq!(project(&resolve(&toks(&["cp", "-Q", "./a", "./b"])).expect("cp")), Verdict::Denied, "unknown flag");
2047        // -t naming a dest with NO source operands is a usage error → fail closed (not a lone,
2048        // benign dest write).
2049        assert_eq!(project(&resolve(&toks(&["cp", "-t", "./dest"])).expect("cp")), Verdict::Denied, "-t no source");
2050    }
2051
2052    #[test]
2053    fn mv_relocates_within_the_worktree_and_gates_both_loci() {
2054        use crate::engine::bridge::project;
2055        use crate::verdict::{SafetyLevel, Verdict};
2056
2057        // a move within the worktree is a mutate (source) + create (dest), both trivial/
2058        // recoverable → write-local, NOT developer. Unlike rm, a move relocates, not destroys.
2059        let m = resolve(&toks(&["mv", "./a", "./b"])).expect("mv");
2060        assert_eq!(m.capabilities[0].operation, Operation::Mutate, "source is a relocation, not a destroy");
2061        assert_eq!(m.capabilities[0].reversibility, Reversibility::Trivial, "mv back");
2062        assert_eq!(project(&m), Verdict::Allowed(SafetyLevel::SafeWrite), "mv ./a ./b");
2063
2064        // both loci are gated as writes: source-out and dest-out both deny.
2065        assert_eq!(project(&resolve(&toks(&["mv", "~/.ssh/id_rsa", "./x"])).expect("mv")), Verdict::Denied, "source in home");
2066        assert_eq!(project(&resolve(&toks(&["mv", "./x", "~/exfil"])).expect("mv")), Verdict::Denied, "dest in home");
2067        // moving a worktree-TRUSTED file mutates .git → denied, even though cp of it is
2068        // allowed (cp only READS .git/config; the dest write puts cp at SafeWrite).
2069        assert_eq!(project(&resolve(&toks(&["mv", ".git/config", "./x"])).expect("mv")), Verdict::Denied, "mv .git/config");
2070        assert_eq!(project(&resolve(&toks(&["cp", ".git/config", "./x"])).expect("cp")), Verdict::Allowed(SafetyLevel::SafeWrite), "cp .git/config reads");
2071
2072        // The relocate source gates at its WRITE face, not its read face. safe-chains' own config
2073        // READS at worktree-trusted but WRITES at machine (un-grantable): `mv`ing it REMOVES it,
2074        // so the removal must gate at the write face (machine); a `cp` of it only READS
2075        // (worktree-trusted). Both deny by verdict, so assert the source LOCUS to pin the face —
2076        // this is the case a read-face relocate would fail open on.
2077        let cfg = "~/.config/safe-chains.toml";
2078        assert_eq!(
2079            resolve(&toks(&["mv", cfg, "./x"])).expect("mv").capabilities[0].locus.local,
2080            LocalLocus::Machine,
2081            "mv source removal gates at the WRITE face",
2082        );
2083        assert_eq!(
2084            resolve(&toks(&["cp", cfg, "./x"])).expect("cp").capabilities[0].locus.local,
2085            LocalLocus::WorktreeTrusted,
2086            "cp source read gates at the READ face",
2087        );
2088
2089        // -t DIR and glued forms; fail-closed on unknown flag / lone operand.
2090        let t = resolve(&toks(&["mv", "-t", "./dest", "./a", "./b"])).expect("mv -t");
2091        assert_eq!(t.capabilities.len(), 3, "2 sources + 1 dest");
2092        assert_eq!(project(&resolve(&toks(&["mv", "./only"])).expect("mv")), Verdict::Denied, "no dest");
2093        assert_eq!(project(&resolve(&toks(&["mv", "-Q", "./a", "./b"])).expect("mv")), Verdict::Denied, "unknown flag");
2094    }
2095
2096    #[test]
2097    fn ln_is_cp_by_reference_and_gates_the_target_locus() {
2098        use crate::engine::bridge::project;
2099        use crate::verdict::{SafetyLevel, Verdict};
2100
2101        // a worktree link (hard or symbolic) is target-read + link-create → write-local.
2102        for cmd in [vec!["ln", "./a", "./b"], vec!["ln", "-s", "./target", "./link"]] {
2103            let p = resolve(&toks(&cmd)).expect("ln");
2104            assert_eq!(p.capabilities[0].operation, Operation::Observe, "target is a bridged read");
2105            assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2106        }
2107        // the cp-bypass is closed: linking a SECRET/unreadable TARGET denies on the target
2108        // locus, exactly as `cp` of it would (a link would otherwise alias the secret in).
2109        assert_eq!(project(&resolve(&toks(&["ln", "~/.ssh/id_rsa", "./x"])).expect("ln")), Verdict::Denied, "hard link to home credential");
2110        assert_eq!(project(&resolve(&toks(&["ln", "-s", "/etc/shadow", "./x"])).expect("ln")), Verdict::Denied, "symlink to secret");
2111        // the retreat: linking to a NON-workspace target denies on the target locus (as cp would).
2112        assert_eq!(project(&resolve(&toks(&["ln", "-s", "/etc/hosts", "./x"])).expect("ln")), Verdict::Denied, "symlink to system path");
2113        // writing the LINK outside the worktree denies on the link locus.
2114        assert_eq!(project(&resolve(&toks(&["ln", "-s", "./a", "~/evil"])).expect("ln")), Verdict::Denied, "link into home");
2115        // -t DIR, lone operand, unknown flag.
2116        assert_eq!(resolve(&toks(&["ln", "-t", "./dir", "./a", "./b"])).expect("ln -t").capabilities.len(), 3);
2117        assert_eq!(project(&resolve(&toks(&["ln", "./only"])).expect("ln")), Verdict::Denied, "no link name");
2118        assert_eq!(project(&resolve(&toks(&["ln", "-Q", "./a", "./b"])).expect("ln")), Verdict::Denied, "unknown flag");
2119        // -f (a clobber flag PRESENT) flips the link-create from the no-clobber default
2120        // (`trivial`) to `recoverable` — still write-local. Exercises the `clobber_flags`-present
2121        // branch of the transfer arm, the inverse of cp/mv's `no_clobber_flags`.
2122        let forced = resolve(&toks(&["ln", "-f", "./a", "./b"])).expect("ln -f");
2123        assert_eq!(project(&forced), Verdict::Allowed(SafetyLevel::SafeWrite), "ln -f worktree link");
2124        assert_eq!(forced.capabilities.last().unwrap().reversibility, Reversibility::Recoverable, "ln -f overwrites → recoverable");
2125        assert_eq!(
2126            resolve(&toks(&["ln", "./a", "./b"])).expect("ln").capabilities.last().unwrap().reversibility,
2127            Reversibility::Trivial,
2128            "ln default no-clobber → trivial",
2129        );
2130    }
2131
2132    #[test]
2133    fn dd_parses_key_value_operands_and_gates_both_sides() {
2134        use crate::engine::bridge::project;
2135        use crate::verdict::{SafetyLevel, Verdict};
2136
2137        // a worktree-to-worktree copy → write-local; params (bs/count/conv) are ignored.
2138        assert_eq!(
2139            project(&resolve(&toks(&["dd", "if=./a", "of=./b", "bs=1M", "count=10"])).expect("dd")),
2140            Verdict::Allowed(SafetyLevel::SafeWrite),
2141            "dd worktree copy",
2142        );
2143        // input from stdout (no of=) discloses the input content to the model, like cat.
2144        assert_eq!(project(&resolve(&toks(&["dd", "if=./notes"])).expect("dd")), Verdict::Allowed(SafetyLevel::SafeRead), "dd to stdout");
2145        assert_eq!(project(&resolve(&toks(&["dd"])).expect("dd")), Verdict::Allowed(SafetyLevel::Inert), "bare dd is stdin→stdout");
2146
2147        // both sides gated by locus: a home INPUT or a device/home OUTPUT denies.
2148        for cmd in [
2149            vec!["dd", "if=~/.ssh/id_rsa", "of=./x"], // read a home secret
2150            vec!["dd", "if=./x", "of=/dev/rdisk0"],   // write a raw device (disk wipe)
2151            vec!["dd", "if=./x", "of=/dev/sda"],
2152            vec!["dd", "if=./x", "of=~/backup"],      // write into home
2153            vec!["dd", "if=~/.ssh/id_rsa"],           // home secret to stdout (→ model)
2154        ] {
2155            assert_eq!(project(&resolve(&toks(&cmd)).expect("dd")), Verdict::Denied, "{cmd:?}");
2156        }
2157        // fail-closed: a non key=value operand, or an unknown key, worst-cases.
2158        assert_eq!(project(&resolve(&toks(&["dd", "./file"])).expect("dd")), Verdict::Denied, "positional operand");
2159        assert_eq!(project(&resolve(&toks(&["dd", "exec=evil", "of=./x"])).expect("dd")), Verdict::Denied, "unknown key");
2160    }
2161
2162    #[test]
2163    fn tar_parses_dashless_bundles_and_splits_by_mode() {
2164        use crate::engine::bridge::project;
2165        use crate::verdict::{SafetyLevel, Verdict};
2166
2167        // dashless `czf` and dashed `-czf` and the long form all parse the same: a create is
2168        // members-read + archive-write → write-local for a worktree backup.
2169        for cmd in [
2170            vec!["tar", "czf", "backup.tar", "./src"],
2171            vec!["tar", "-czf", "backup.tar", "./src"],
2172            vec!["tar", "--create", "--file=backup.tar", "./src"],
2173        ] {
2174            assert_eq!(project(&resolve(&toks(&cmd)).expect("tar")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2175        }
2176        // list reads the archive → read-local.
2177        assert_eq!(project(&resolve(&toks(&["tar", "tzf", "backup.tar"])).expect("tar")), Verdict::Allowed(SafetyLevel::SafeRead), "list");
2178
2179        // the bundler-exfil case (golden-set): a home member denies on the member locus,
2180        // whether the archive goes to stdout or a file.
2181        assert_eq!(project(&resolve(&toks(&["tar", "czf", "-", "~/.ssh"])).expect("tar")), Verdict::Denied, "bundle secret to stdout");
2182        assert_eq!(project(&resolve(&toks(&["tar", "czf", "out.tar", "~/.aws"])).expect("tar")), Verdict::Denied, "bundle home member");
2183        // a home/system ARCHIVE denies on the archive write locus.
2184        assert_eq!(project(&resolve(&toks(&["tar", "cf", "~/backup.tar", "./src"])).expect("tar")), Verdict::Denied, "archive into home");
2185
2186        // extract is archive-controlled (..-escapable) → worst-case, even for a benign archive.
2187        assert_eq!(project(&resolve(&toks(&["tar", "xzf", "release.tar"])).expect("tar")), Verdict::Denied, "extract");
2188        // `tar cf backup.tar` with no members creates an empty archive — a benign worktree
2189        // write, so SafeWrite (not a fail-closed case).
2190        assert_eq!(project(&resolve(&toks(&["tar", "cf", "backup.tar"])).expect("tar")), Verdict::Allowed(SafetyLevel::SafeWrite), "empty archive");
2191        // fail-closed: an unmodeled value option (-C), no mode, an empty profile, a bad letter.
2192        assert_eq!(project(&resolve(&toks(&["tar", "-C", "/etc", "xf", "a.tar"])).expect("tar")), Verdict::Denied, "-C unmodeled");
2193        assert_eq!(project(&resolve(&toks(&["tar", "c"])).expect("tar")), Verdict::Denied, "create to stdout, no members");
2194        assert_eq!(project(&resolve(&toks(&["tar", "zf", "backup.tar"])).expect("tar")), Verdict::Denied, "no mode letter");
2195    }
2196
2197    /// perl's two gates are independent and BOTH are required: the identifier allowlist decides
2198    /// whether the one-liner is inert, locus decides whether the operands may be touched. The
2199    /// second was missing — `perl -pe 's/a/b/' /etc/shadow` auto-approved, because the handler
2200    /// judged only the code — so the read cases below are the regression, and the `-i` cases are
2201    /// the capability the missing gate had been standing in for.
2202    /// Parse `line` and ask what a `$( … )` around it would evaluate to.
2203    #[cfg(test)]
2204    fn sub_locus(line: &str) -> Option<LocalLocus> {
2205        let script = crate::cst::parse(line).expect("parses");
2206        match substitution_claim(&script)? {
2207            SubClaim::Locus(l) => Some(l),
2208            // An atom names no locus, so these callers — which ask "which rung does this value
2209            // point at" — correctly see nothing.
2210            SubClaim::Atom => None,
2211        }
2212    }
2213
2214    /// No output claim survives `--help` / `--version`, for EVERY command that declares one.
2215    ///
2216    /// Enumerated over the registry rather than spot-checked on seq, because the failure is a
2217    /// property of what those flags DO — replace the command's data output with prose — and so it
2218    /// applies to every claim, including ones added later. The prose routinely carries paths and
2219    /// URLs: GNU `seq --help` prints `<https://www.gnu.org/software/coreutils/>` under an `atom`
2220    /// claim asserting no word holds a separator.
2221    ///
2222    /// Missed by hand-probing because macOS ships BSD seq, whose help is terse and slash-free —
2223    /// the local install disagreed with the upstream the claim is written against.
2224    #[test]
2225    fn no_output_claim_survives_a_help_or_version_flag() {
2226        let mut probed = 0usize;
2227        for name in crate::registry::toml_command_names() {
2228            if crate::registry::command_output_locus(name).is_none() {
2229                continue;
2230            }
2231            for flag in ["--help", "--version"] {
2232                let line = format!("{name} {flag}");
2233                let Some(script) = crate::cst::parse(&line) else { continue };
2234                probed += 1;
2235                assert!(
2236                    substitution_claim(&script).is_none(),
2237                    "`{line}` still carries an output claim, but --help/--version print prose \
2238                     rather than the command's data, so the claim does not describe it"
2239                );
2240            }
2241        }
2242        assert!(probed > 0, "no command declares [command.output]; this guard would be vacuous");
2243    }
2244
2245    /// Fail-closed, enumerated over the REGISTRY: every `[command.output]` claim is probed on a HOT
2246    /// root, and must never report a locus below what reading that root reports. This is the
2247    /// fail-open the whole feature risks — a missed search root means the substitution is admitted
2248    /// at worktree while the command actually reaches `/etc`. The `match` is EXHAUSTIVE, so a new
2249    /// `OutputLocus` variant must state how it is probed or the build breaks.
2250    ///
2251    /// Red→green: drop the glued-value branch from `candidate_roots` and
2252    /// `fd --search-path=/etc x` stops reporting machine.
2253    #[test]
2254    fn every_output_claim_is_bounded_by_its_roots() {
2255        use crate::registry::types::OutputLocus;
2256
2257        let mut probed = 0usize;
2258        for name in crate::registry::toml_command_names() {
2259            let Some(spec) = crate::registry::command_output_locus(name) else { continue };
2260            probed += 1;
2261            match spec.locus_from {
2262                // An `atom` claim is that no output word can contain a separator, so the check is
2263                // the claim: run the command's OWN examples and read what they would print. A
2264                // command whose examples emit a `/` is mis-declared, and the consequence is not
2265                // subtle — the confinement layer treats the value as unable to leave its
2266                // component, so a separator would let it walk anywhere the prefix can reach.
2267                //
2268                // Enumerated over the registry rather than spot-checked, because the next command
2269                // to declare `atom` gets this for free, which is the only way a data-driven claim
2270                // stays honest as the data grows.
2271                // An `atom` claim cannot be checked the way the others can. The rest are probed by
2272                // asking the resolver where a HOT root lands, but "no output word contains a
2273                // separator" is a fact about the TOOL, and the only mechanical way to confirm it
2274                // would be to run the command — which a unit test must not do for arbitrary
2275                // registry entries.
2276                //
2277                // So this is a REVIEW gate, not a proof: the claim has to be argued per command,
2278                // and a new declaration fails here until someone does that and adds it. What makes
2279                // it worth having is the failure mode it guards — an atom is treated as unable to
2280                // leave its path component, so a tool that CAN emit a `/` would let the value walk
2281                // anywhere its prefix reaches. `seq`'s argument is in its TOML: numbers only, with
2282                // the three flags that inject caller text (`-s`, `-t`, `-f`) in `invalidated_by`.
2283                //
2284                // The soundness of the confinement ITSELF — that a separator-free value beside
2285                // literal text cannot escape — is proved separately, by
2286                // `a_flanked_atom_never_moves_where_the_write_lands`.
2287                OutputLocus::Atom => {
2288                    const ARGUED: &[&str] = &["seq"];
2289                    assert!(
2290                        ARGUED.contains(&name),
2291                        "command '{name}' declares `locus_from = \"atom\"`, which asserts that no \
2292                         word it prints can contain a separator. That cannot be checked here \
2293                         without running the command, so it must be argued in the command's TOML \
2294                         (what it prints, and which flags reshape it into `invalidated_by`) and \
2295                         then listed in ARGUED."
2296                    );
2297                }
2298                OutputLocus::Operands => {
2299                    // `~` is here as a named case, not just inside HOT_PATHS, because it is the
2300                    // spelling that actually got through: it carries neither `/` nor `.`, so the
2301                    // path-SHAPE test skipped it and `cat $(fd pat ~)` swept the home directory
2302                    // while reporting worktree.
2303                    let hot_roots: Vec<&str> =
2304                        HOT_PATHS.iter().copied().chain(["~", "~/.ssh"]).collect();
2305                    for hot in hot_roots {
2306                        // Every spelling a root can arrive in: bare operand, separated flag value,
2307                        // glued long value, glued short value. Missing any is the fail-open.
2308                        for line in [
2309                            format!("{name} pat {hot}"),
2310                            format!("{name} --base-directory {hot} pat"),
2311                            format!("{name} --search-path={hot} pat"),
2312                            format!("{name} -E{hot} pat"),
2313                        ] {
2314                            let got = sub_locus(&line);
2315                            let want = read_locus(hot);
2316                            assert!(
2317                                got.is_none_or(|l| l >= want),
2318                                "`{line}`: reported {got:?}, but reading {hot} is {want:?}",
2319                            );
2320                        }
2321                    }
2322                    // A substitution in a root slot is unknowable — no claim.
2323                    assert_eq!(sub_locus(&format!("{name} pat $(hostname)")), None, "{name}: nested sub");
2324                }
2325                // Its output is the cwd, which takes no root operand; the guard that matters is
2326                // that it does not somehow report BELOW the cwd's own locus.
2327                OutputLocus::Cwd => {
2328                    assert_eq!(sub_locus(name), Some(read_locus(".")), "{name}: bare");
2329                }
2330                // A filter only filters while it has no file operand — given one it prints that
2331                // file's CONTENTS, which are not paths and must void the claim.
2332                OutputLocus::Stdin => {
2333                    for hot in HOT_PATHS {
2334                        assert_eq!(
2335                            sub_locus(&format!("{name} {hot}")),
2336                            None,
2337                            "{name}: a file operand makes it print contents, not paths",
2338                        );
2339                    }
2340                }
2341            }
2342            // Every flag the command declares as invalidating must actually void the claim.
2343            for flag in &spec.invalidated_by {
2344                let line = format!("{name} {flag} pat");
2345                assert_eq!(sub_locus(&line), None, "`{line}`: {flag} is declared invalidating");
2346            }
2347        }
2348        assert!(probed > 0, "no command declares [command.output] — the guard is vacuous");
2349    }
2350
2351    /// Enumerated over the REGISTRY: a flag declared `valued` on `[command.output]` means "this
2352    /// value is not a path", and BOTH spellings must agree. Handling only the separated form denied
2353    /// `head --lines=5` while `head -n 5` passed — one operation, two spellings, two answers, which
2354    /// is the false-deny class the flag-form equivalence guards exist to kill.
2355    #[test]
2356    fn output_valued_flags_agree_across_spellings() {
2357        use crate::registry::types::OutputLocus;
2358        let mut checked = 0usize;
2359        for name in crate::registry::toml_command_names() {
2360            let Some(spec) = crate::registry::command_output_locus(name) else { continue };
2361            for flag in &spec.valued {
2362                // An invalidating flag voids the claim by design, so it is not a spelling case.
2363                if spec.invalidated_by.contains(flag) {
2364                    continue;
2365                }
2366                // Two things are load-bearing about the probe shape, and without EITHER the
2367                // guard silently passes a broken skip:
2368                //  - a PRODUCER stage, because a lone `stdin` command walks back off the end of
2369                //    the pipeline and reports `None` whether or not it saw a file operand, hiding
2370                //    the difference entirely;
2371                //  - a TRAILING OPERAND, because a glued form that over-skips (swallowing the
2372                //    next argument as if it were a separated value) is indistinguishable from a
2373                //    correct one until there is a next argument to lose.
2374                // Together they expose the over-skip as a file operand going missing — which for
2375                // a `stdin` claim is a fail-open: contents get classified as if they were paths.
2376                let producer = match spec.locus_from {
2377                    OutputLocus::Stdin => "fd a app/ | ",
2378                    _ => "",
2379                };
2380                for tail in ["", " /etc/hosts"] {
2381                    let separated = sub_locus(&format!("{producer}{name} {flag} 5{tail}"));
2382                    let glued = sub_locus(&format!("{producer}{name} {flag}=5{tail}"));
2383                    assert_eq!(
2384                        separated, glued,
2385                        "{name} {flag} (tail {tail:?}): separated {separated:?}, glued {glued:?}",
2386                    );
2387                    checked += 1;
2388                }
2389            }
2390        }
2391        assert!(checked > 0, "no output claim declares a valued flag — the guard is vacuous");
2392    }
2393
2394    /// The default is unpinnable. A command that has NOT been researched for its output locus must
2395    /// keep the opaque sentinel, so the feature can only ever widen through a deliberate
2396    /// declaration — never by a command happening to look read-only.
2397    #[test]
2398    fn undeclared_commands_get_no_output_claim() {
2399        // `echo` is the load-bearing case: as safe as a command gets, and its output is whatever
2400        // the caller typed. If it ever acquires a claim, `cat $(echo /etc/shadow)` opens up.
2401        for line in ["echo /etc/shadow", "hostname", "cat ./f", "ls", "git rev-parse --show-toplevel"] {
2402            assert_eq!(sub_locus(line), None, "`{line}` must have no output claim");
2403        }
2404        assert!(!crate::is_safe_command("cat $(echo /etc/shadow)"), "echo must not bound its output");
2405    }
2406
2407    #[test]
2408    fn perl_i_worktree_vs_system() {
2409        use crate::engine::bridge::project;
2410        use crate::verdict::{SafetyLevel, Verdict};
2411
2412        // No -i: the operands are content reads, gated by READ locus.
2413        let read = resolve(&toks(&["perl", "-pe", "s/x/y/", "./foo"])).expect("perl");
2414        assert_eq!(read.capabilities[0].operation, Operation::Observe, "no -i → read");
2415        assert_eq!(project(&read), Verdict::Allowed(SafetyLevel::SafeRead), "perl read");
2416
2417        // -i flips them to in-place MUTATES, admitted only in the worktree.
2418        let edit = resolve(&toks(&["perl", "-pi", "-e", "s/x/y/", "./foo"])).expect("perl");
2419        assert_eq!(edit.capabilities[0].operation, Operation::Mutate, "-i → in-place write");
2420        assert_eq!(project(&edit), Verdict::Allowed(SafetyLevel::SafeWrite), "perl -i worktree");
2421        let glued = resolve(&toks(&["perl", "-i.bak", "-pe", "s/x/y/", "./foo"])).expect("perl");
2422        assert_eq!(glued.capabilities[0].operation, Operation::Mutate, "-i.bak is still in-place");
2423
2424        // THE REGRESSION: an inert one-liner does not license the operand. Reads above the
2425        // worktree deny, exactly as `cat` and `sed` already did.
2426        for cmd in [
2427            vec!["perl", "-pe", "s/a/b/", "/etc/shadow"],
2428            vec!["perl", "-ne", "print", "~/.ssh/id_rsa"],
2429            vec!["perl", "-pe", "s/a/b/", "/etc/passwd"],
2430            vec!["perl", "-pe", "s/a/b/", "$CONFIG"], // unpinnable
2431            vec!["perl", "-pi", "-e", "s/a/b/", "/etc/hosts"],
2432            vec!["perl", "-pi", "-e", "s/a/b/", "~/.bashrc"],
2433            vec!["perl", "-pi", "-e", "s/a/b/", "../outside"],
2434        ] {
2435            assert_eq!(project(&resolve(&toks(&cmd)).expect("perl")), Verdict::Denied, "{cmd:?} must deny");
2436        }
2437
2438        // Opaque code is refused whatever the operand: no `-e` means the first operand is a script
2439        // file we cannot read, and a failed identifier gate means the one-liner left the vocabulary.
2440        for cmd in [
2441            vec!["perl", "./script.pl"],
2442            vec!["perl", "-n", "./file.txt"],
2443            vec!["perl", "-e", "system(\"rm -rf /\")", "./foo"],
2444            vec!["perl", "-pie", "s/a/b/", "./foo"], // ambiguous suffix spelling — unmodeled
2445        ] {
2446            assert_eq!(project(&resolve(&toks(&cmd)).expect("perl")), Verdict::Denied, "{cmd:?} must deny");
2447        }
2448
2449        // A worktree-scoped sweep is bounded, not single — scored honestly, still admitted.
2450        let glob = resolve(&toks(&["perl", "-pi", "-e", "s/a/b/", "*"])).expect("perl");
2451        assert_eq!(glob.capabilities[0].scale, Scale::Bounded, "a glob is a bounded blast radius");
2452        assert_eq!(project(&glob), Verdict::Allowed(SafetyLevel::SafeWrite), "perl -i * (worktree)");
2453    }
2454
2455    #[test]
2456    fn sed_i_flips_read_to_write_and_locus_stops_system_wide_damage() {
2457        use crate::engine::bridge::project;
2458        use crate::verdict::{SafetyLevel, Verdict};
2459
2460        // -i turns the file operands from reads into in-place MUTATES.
2461        let read = resolve(&toks(&["sed", "s/x/y/", "./foo"])).expect("sed");
2462        assert_eq!(read.capabilities[0].operation, Operation::Observe, "no -i → read");
2463        assert_eq!(project(&read), Verdict::Allowed(SafetyLevel::SafeRead), "sed read");
2464        let edit = resolve(&toks(&["sed", "-i", "s/x/y/", "./foo"])).expect("sed");
2465        assert_eq!(edit.capabilities[0].operation, Operation::Mutate, "-i → in-place write");
2466        assert_eq!(project(&edit), Verdict::Allowed(SafetyLevel::SafeWrite), "sed -i worktree");
2467
2468        // THE CONCERN: a stray system-wide `sed -i` is stopped by LOCUS — a system, home, or
2469        // unpinnable target denies whatever the scale. Damage needs a target above the
2470        // worktree, and every such target is denied.
2471        for cmd in [
2472            vec!["sed", "-i", "s/a/b/", "/etc/passwd"],
2473            vec!["sed", "-i", "s/a/b/", "/etc/hosts"],
2474            vec!["sed", "-i", "s/a/b/", "~/.bashrc"],
2475            vec!["sed", "-i", "s/a/b/", "$CONFIG"],      // unpinnable
2476            vec!["sed", "-i", "s/a/b/", "../outside"],   // escapes the worktree
2477            vec!["sed", "-i", "-e", "s/a/b/", "/etc/x"], // -e script, system file
2478        ] {
2479            assert_eq!(project(&resolve(&toks(&cmd)).expect("sed")), Verdict::Denied, "{cmd:?} must deny");
2480        }
2481
2482        // A worktree-scoped sweep IS allowed — bounded, recoverable, your own project files.
2483        // The glob/multi-operand blast radius is scored as `bounded`, still write-local.
2484        let glob = resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed");
2485        assert_eq!(glob.capabilities[0].scale, Scale::Bounded, "a glob is a bounded blast radius");
2486        assert_eq!(project(&glob), Verdict::Allowed(SafetyLevel::SafeWrite), "sed -i * (worktree)");
2487        assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "a", "b", "c"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "multi-file");
2488
2489        // -i.bak (optional glued suffix) still parses as in-place.
2490        assert_eq!(project(&resolve(&toks(&["sed", "-i.bak", "s/a/b/", "./foo"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "-i.bak");
2491        // -f runs a script file we can't inspect (its e/w/r commands are invisible) → denied, like
2492        // `awk -f`, `bash script.sh`, mlr `--load`.
2493        assert_eq!(project(&resolve(&toks(&["sed", "-f", "script.sed", "./foo"])).expect("sed")), Verdict::Denied, "-f script file unanalyzable");
2494        // a home file read (no -i) still denies by locus, like cat.
2495        assert_eq!(project(&resolve(&toks(&["sed", "s/a/b/", "~/.ssh/id_rsa"])).expect("sed")), Verdict::Denied, "read home secret");
2496        assert_eq!(project(&resolve(&toks(&["sed", "-Q", "./foo"])).expect("sed")), Verdict::Denied, "unknown flag");
2497    }
2498
2499    #[test]
2500    fn sed_exec_command_is_worst_cased_at_parity_with_legacy() {
2501        use crate::engine::bridge::project;
2502        use crate::verdict::Verdict;
2503        // The `e` command/modifier executes text as a shell command (RCE). The resolver must
2504        // worst-case it — flag parsing alone treated the script as opaque and let it through.
2505        for cmd in [
2506            vec!["sed", "s/test/touch tmp/e", "file"],   // s///e modifier
2507            vec!["sed", "-e", "s/x/cmd/e", "file"],       // via -e
2508            vec!["sed", "s/x/cmd/ew", "file"],            // e flag BEFORE the greedy w flag
2509            vec!["sed", "1e", "file"],                    // address + e
2510            vec!["sed", "e"],                             // bare e
2511            vec!["sed", "-e", "e"],
2512            vec!["sed", "1e reboot", "file"],             // address + e WITH a command argument
2513            vec!["sed", "p;e id", "file"],                // e after a `;` separator
2514        ] {
2515            assert_eq!(project(&resolve(&toks(&cmd)).expect("sed")), Verdict::Denied, "{cmd:?}: exec must deny");
2516        }
2517        // `s/x/cmd/we` is NOT here: `w` is greedy-to-EOL, so `we` writes to a file named `e` (a
2518        // local SafeWrite), not w-then-e exec. `sed '1e reboot'` — the former residual gap — is now
2519        // caught by the sed sub-parser (`scan_sed`).
2520    }
2521
2522    /// HP-19 #1 (engine): `classify_locus` now resolves relative paths against the ambient
2523    /// cwd/root. With no context it falls back to relative-is-worktree (status quo); under a
2524    /// `cd /etc` context the same operands resolve to `/etc/*` and deny.
2525    #[test]
2526    fn classify_locus_resolves_relative_operands_against_the_cwd_context() {
2527        use crate::engine::bridge::project;
2528        use crate::pathctx::PathCtx;
2529        use crate::verdict::{SafetyLevel, Verdict};
2530
2531        // No context → relative is worktree (fallback), and a sweeping edit is write-local.
2532        for p in ["*", "passwd", "config"] {
2533            assert_eq!(classify_locus(p), LocalLocus::Worktree, "{p}: no ctx → worktree");
2534        }
2535        assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed")), Verdict::Allowed(SafetyLevel::SafeWrite), "no ctx: sed -i *");
2536
2537        // Context says the shell is in /etc → relative operands are /etc/* → machine → deny.
2538        let _g = crate::pathctx::enter(PathCtx { cwd: Some("/etc".into()), root: Some("/home/u/proj".into()), ..Default::default() });
2539        for p in ["*", "hosts", "config", "cron.d"] {
2540            assert_eq!(classify_locus(p), LocalLocus::Machine, "{p}: cwd=/etc → machine");
2541        }
2542        // /etc/passwd is the identity substrate: its WRITE face worst-cases to system-integrity
2543        // (above machine → above local-admin), even reached as a relative operand from cwd=/etc.
2544        assert_eq!(classify_locus("passwd"), LocalLocus::SystemIntegrity, "passwd: cwd=/etc → system-integrity");
2545        assert_eq!(project(&resolve(&toks(&["sed", "-i", "s/a/b/", "*"])).expect("sed")), Verdict::Denied, "cwd=/etc: sed -i * denied");
2546        assert_eq!(project(&resolve(&toks(&["dd", "if=./x", "of=passwd"])).expect("dd")), Verdict::Denied, "cwd=/etc: dd of=passwd denied");
2547        assert_eq!(project(&resolve(&toks(&["cp", "./payload", "config"])).expect("cp")), Verdict::Denied, "cwd=/etc: cp denied");
2548    }
2549
2550    #[test]
2551    fn touch_creates_in_the_worktree_and_gates_the_reference_path() {
2552        use crate::engine::bridge::project;
2553        use crate::verdict::{SafetyLevel, Verdict};
2554        for cmd in [
2555            vec!["touch", "./new.txt"],
2556            vec!["touch", "-c", "existing"],
2557            vec!["touch", "-r", "ref.txt", "./out"], // worktree reference: a read + a create, both worktree
2558            vec!["touch", "-d", "-1 day", "./out"],  // -d takes a DATE literal (not a path), dash-leading value
2559        ] {
2560            assert_eq!(project(&resolve(&toks(&cmd)).expect("touch")), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2561        }
2562        // `-r REF` reads REF's timestamp — a path-flag gated by REF's locus. A worktree ref is a
2563        // worktree read (allowed, 2 caps), but an out-of-workspace reference DENIES (it would
2564        // otherwise be an mtime/existence oracle for arbitrary paths).
2565        let p = resolve(&toks(&["touch", "-r", "ref.txt", "./out"])).expect("touch");
2566        assert_eq!(p.capabilities.len(), 2, "./out create + ref.txt read");
2567        assert!(p.capabilities.iter().any(|c| c.operation == Operation::Observe), "the -r reference is a read");
2568        assert_eq!(project(&resolve(&toks(&["touch", "-r", "~/.bashrc", "./out"])).expect("touch")), Verdict::Denied, "home reference");
2569        assert_eq!(project(&resolve(&toks(&["touch", "-r", "/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "system reference");
2570        assert_eq!(project(&resolve(&toks(&["touch", "--reference=/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "long glued reference");
2571        assert_eq!(project(&resolve(&toks(&["touch", "--reference", "/etc/shadow", "./out"])).expect("touch")), Verdict::Denied, "long spaced reference");
2572        // -d's dash-leading date literal is NOT a path and is NOT gated.
2573        assert_eq!(project(&resolve(&toks(&["touch", "-d", "-1 day", "/tmp/../etc/x"])).expect("touch")), Verdict::Denied, "operand still gated");
2574        // beyond the worktree, and fail-closed cases
2575        assert_eq!(project(&resolve(&toks(&["touch", "/etc/x"])).expect("touch")), Verdict::Denied, "system path");
2576        assert_eq!(project(&resolve(&toks(&["touch", "-Z", "x"])).expect("touch")), Verdict::Denied, "unknown flag");
2577        assert_eq!(project(&resolve(&toks(&["touch"])).expect("touch")), Verdict::Denied, "no operand");
2578    }
2579
2580    #[test]
2581    fn worst_case_is_denied_even_by_a_permissive_yolo_shaped_level() {
2582        use crate::engine::level::{Clause, Level, OrdBound};
2583        // a yolo-shaped level: allow anything local up to `machine`, minus a destroy corner
2584        let yolo = Level::new("yolo-ish")
2585            .allowing(Clause {
2586                local_locus: Some(OrdBound::at_most(LocalLocus::Machine)),
2587                ..Default::default()
2588            })
2589            .denying(Clause {
2590                operation: Some(vec![Operation::Destroy]),
2591                reversibility: Some(OrdBound::at_least(Reversibility::Irreversible)),
2592                ..Default::default()
2593            });
2594        let wc = Profile::of(vec![Capability::worst("test")]);
2595        assert!(!yolo.admits(&wc), "worst_case (locus=kernel) exceeds even a machine-capped allow");
2596    }
2597
2598    #[test]
2599    fn rm_within_the_worktree_projects_to_developer_but_beyond_it_denies() {
2600        use crate::engine::bridge::project;
2601        use crate::verdict::{SafetyLevel, Verdict};
2602        // `developer` admits destroy WITHIN the worktree (golden-set decision 2), even
2603        // recursive/effortful; it maps to the legacy SafeWrite ceiling.
2604        for cmd in [
2605            vec!["rm", "./stale.log"],
2606            vec!["rm", "-rf", "./node_modules"],
2607            vec!["rm", "a", "b", "c"],
2608            vec!["rm", "--interactive=always", "./x"], // optional-arg long: must not worst-case
2609        ] {
2610            let p = resolve(&toks(&cmd)).expect("rm resolves");
2611            assert!(p.capabilities.iter().all(|c| c.operation == Operation::Destroy), "{cmd:?} destroys");
2612            assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?} → developer");
2613        }
2614        // Deletion that reaches beyond the worktree (home/system) is above `developer`,
2615        // denied by locus — no clause admits a machine/user-scoped destroy.
2616        for cmd in [vec!["rm", "-rf", "/"], vec!["rm", "-rf", "~/notes"], vec!["rm", "/etc/hosts"]] {
2617            assert_eq!(project(&resolve(&toks(&cmd)).expect("rm")), Verdict::Denied, "{cmd:?} beyond worktree");
2618        }
2619    }
2620
2621    /// End-to-end: `rm -rf /` resolves to the `destroy · irreversible · unbounded` corner and
2622    /// is the one thing even a maximally-permissive yolo refuses — by facet, not by name.
2623    /// Everything one facet away stays yolo-admitted.
2624    #[test]
2625    fn rm_rf_root_is_the_one_thing_even_yolo_denies() {
2626        let yolo = level("yolo");
2627        let root = resolve(&toks(&["rm", "-rf", "/"])).expect("rm");
2628        assert_eq!(root.capabilities[0].reversibility, Reversibility::Irreversible, "rm -rf / is irreversible");
2629        assert_eq!(root.capabilities[0].scale, Scale::Unbounded);
2630        assert!(!yolo.admits(&root), "rm -rf / denied even at yolo");
2631        assert!(!yolo.admits(&resolve(&toks(&["rm", "-rf", "~/notes"])).expect("rm")), "rm -rf ~ likewise");
2632        // adjacent-by-one-facet stays yolo-allowed:
2633        assert!(yolo.admits(&resolve(&toks(&["rm", "-rf", "./node_modules"])).expect("rm")), "recoverable worktree");
2634        assert!(yolo.admits(&resolve(&toks(&["rm", "/etc/hosts"])).expect("rm")), "single (bounded) system delete");
2635    }
2636
2637    /// Phase 1 end-to-end: a subcommand tagged `profile = "<archetype>"` resolves (through the
2638    /// nested `<resource> <action>` grammar) to that archetype's exact static capability, so its
2639    /// verdict is DERIVED from facets, not hand-marked. Untagged sibling subs leave the engine
2640    /// abstaining (→ legacy).
2641    #[test]
2642    fn a_subcommand_profile_resolves_to_its_archetype() {
2643        let p = resolve(&toks(&["koyeb", "apps", "delete", "myapp"])).expect("koyeb apps delete resolves");
2644        assert_eq!(p.capabilities.len(), 1);
2645        assert_eq!(
2646            &p.capabilities[0],
2647            crate::engine::archetype::archetype("remote-destroy-recoverable").unwrap(),
2648            "the sub resolves to its declared archetype's capability",
2649        );
2650        // a differently-tagged action gets a different archetype
2651        let create = resolve(&toks(&["koyeb", "apps", "create", "myapp"])).expect("resolves");
2652        assert_eq!(create.capabilities[0].operation, Operation::Create);
2653        // an untagged read sub: no profile, no command behavior → the engine abstains (legacy decides)
2654        assert!(resolve(&toks(&["koyeb", "apps", "list"])).is_none(), "untagged sub → engine abstains");
2655    }
2656
2657    /// Per-flag escalation (Phase 1 layer): a dangerous flag ADDS a capability to the sub's profile,
2658    /// and the level algebra takes the max — so a benign base + a destructive flag lands at the
2659    /// flag's tier. `git push` is vcs-sync (network-admin); `git push --force` adds
2660    /// remote-destroy-irreversible and escalates past it, to yolo.
2661    #[test]
2662    fn an_escalating_flag_adds_a_capability_and_raises_the_tier() {
2663        let destroy = crate::engine::archetype::archetype("remote-destroy-irreversible").unwrap();
2664        // The vcs-sync base now carries the destination's provenance (exposure §4): `origin` and the
2665        // bare `--force` form (default remote) are both `established`.
2666        let vcs_sync = {
2667            let mut c = crate::engine::archetype::archetype("vcs-sync").unwrap().clone();
2668            c.locus.provenance = Provenance::Established;
2669            c
2670        };
2671
2672        let base = resolve(&toks(&["git", "push", "origin", "main"])).expect("git push resolves");
2673        assert_eq!(base.capabilities, vec![vcs_sync.clone()], "base is vcs-sync, established destination");
2674
2675        let forced = resolve(&toks(&["git", "push", "--force"])).expect("resolves");
2676        assert_eq!(forced.capabilities.len(), 2);
2677        assert!(forced.capabilities.contains(&vcs_sync) && forced.capabilities.contains(destroy),
2678            "--force ADDS remote-destroy-irreversible to the vcs-sync base");
2679
2680        // the escalation MATTERS at the level layer: network-admin admits the base but not the
2681        // forced push; the flag pushed it up to yolo.
2682        let network_admin = level("network-admin");
2683        assert!(network_admin.admits(&base), "git push is network-admin");
2684        assert!(!network_admin.admits(&forced), "git push --force escalated past network-admin");
2685        assert!(level("yolo").admits(&forced), "and lands at yolo");
2686
2687        // the -f short form escalates identically
2688        assert_eq!(resolve(&toks(&["git", "push", "-f"])).unwrap().capabilities.len(), 2);
2689    }
2690
2691    /// Destination-trust (exposure §4): `git push`'s send TARGET is classified onto
2692    /// `locus.provenance`, and an `ext::` command-transport worst-cases as RCE. The one resolver
2693    /// that makes the `locus.provenance` facet actually bind to a command.
2694    #[test]
2695    fn git_push_destination_provenance_is_classified() {
2696        use crate::engine::bridge::project;
2697        use crate::verdict::Verdict;
2698
2699        let prov = |cmd: &[&str]| resolve(&toks(cmd)).expect("push resolves").capabilities[0].locus.provenance;
2700
2701        // bare (configured default) and a bare remote NAME → established (a prior deliberate act).
2702        assert_eq!(prov(&["git", "push"]), Provenance::Established, "bare push = default remote");
2703        assert_eq!(prov(&["git", "push", "origin", "main"]), Provenance::Established, "remote name");
2704        // a flag before the target doesn't hide it.
2705        assert_eq!(prov(&["git", "push", "--force", "origin"]), Provenance::Established, "flag then name");
2706        // spelled inline → literal (visible but injectable): URL, scp-path, filesystem path.
2707        assert_eq!(prov(&["git", "push", "https://h/x.git", "main"]), Provenance::Literal, "url");
2708        assert_eq!(prov(&["git", "push", "git@h:x.git"]), Provenance::Literal, "scp-style");
2709        assert_eq!(prov(&["git", "push", "/srv/mirror.git"]), Provenance::Literal, "path");
2710        // a variable / substitution → opaque (unreviewable).
2711        assert_eq!(prov(&["git", "push", "$REMOTE"]), Provenance::Opaque, "variable");
2712
2713        // network-admin admits established + literal, refuses opaque; the ext:: transport is RCE.
2714        let net = level("network-admin");
2715        assert!(net.admits(&resolve(&toks(&["git", "push", "origin"])).unwrap()), "established at network-admin");
2716        assert!(net.admits(&resolve(&toks(&["git", "push", "https://h/x.git"])).unwrap()), "literal URL at network-admin");
2717        assert!(!net.admits(&resolve(&toks(&["git", "push", "$REMOTE"])).unwrap()), "opaque above network-admin");
2718        // ext::<cmd> runs a local command — worst-cased, denied below yolo.
2719        assert_eq!(project(&resolve(&toks(&["git", "push", "ext::sh"])).unwrap()), Verdict::Denied, "ext:: is RCE");
2720        assert!(!net.admits(&resolve(&toks(&["git", "push", "ext::sh"])).unwrap()), "ext:: not at network-admin");
2721
2722        // `--repo=<dest>` OVERRIDES the positional (the fail-open the review found: `--repo=ext::sh`
2723        // slipping past a benign `origin`). Glued and space forms; a bare remote name still allows.
2724        assert_eq!(project(&resolve(&toks(&["git", "push", "--repo=ext::sh", "origin"])).unwrap()), Verdict::Denied, "--repo=ext:: is RCE");
2725        assert!(!net.admits(&resolve(&toks(&["git", "push", "--repo", "$VAR", "origin"])).unwrap()), "--repo $VAR is opaque");
2726        assert_eq!(prov(&["git", "push", "--repo=https://h/x.git", "main"]), Provenance::Literal, "--repo URL is literal");
2727        assert!(net.admits(&resolve(&toks(&["git", "push", "--repo=upstream", "main"])).unwrap()), "--repo=<remote name> is established");
2728    }
2729
2730    /// The `data-export` resolver: a bulk remote export (`supabase db dump`) is a read that
2731    /// auto-approves to stdout, but its OUTPUT-FILE form (`-f path`) adds a SECOND, path-gated local
2732    /// write — a dump to the worktree stays local (SafeWrite) while one to a system path gates on
2733    /// locus (denied), and the glued short `-f/path` spelling can't slip that gate. The unbounded
2734    /// `scale` records the volume without itself gating the read. See `behavioral-taxonomy-exposure.md`.
2735    #[test]
2736    fn data_export_gates_its_output_file() {
2737        use crate::engine::bridge::project;
2738        use crate::verdict::{SafetyLevel, Verdict};
2739
2740        // to stdout: the bulk remote read alone — one capability, auto-approves as a read.
2741        let stdout = resolve(&toks(&["supabase", "db", "dump", "--data-only"])).expect("dump resolves");
2742        assert_eq!(stdout.capabilities.len(), 1, "stdout dump = the remote read only");
2743        assert_eq!(stdout.capabilities[0].scale, Scale::Unbounded, "a dump records its volume");
2744        assert_eq!(project(&stdout), Verdict::Allowed(SafetyLevel::SafeRead), "bulk read auto-approves");
2745
2746        // -f into the worktree: read + a worktree write → still auto-approves (SafeWrite).
2747        for cmd in [
2748            vec!["supabase", "db", "dump", "-f", "dump.sql"],
2749            vec!["supabase", "db", "dump", "--file=dump.sql", "--data-only"],
2750        ] {
2751            let p = resolve(&toks(&cmd)).expect("dump resolves");
2752            assert_eq!(p.capabilities.len(), 2, "{cmd:?}: the remote read + a local write");
2753            assert_eq!(project(&p), Verdict::Allowed(SafetyLevel::SafeWrite), "{cmd:?}");
2754        }
2755
2756        // -f onto a system path: the write gates on locus → denied, in every spelling — space,
2757        // glued `=`, AND the glued short `-f/path` that mustn't be a bypass.
2758        for cmd in [
2759            vec!["supabase", "db", "dump", "-f", "/etc/passwd"],
2760            vec!["supabase", "db", "dump", "--file=/etc/passwd"],
2761            vec!["supabase", "db", "dump", "-f/etc/passwd"],
2762        ] {
2763            assert_eq!(project(&resolve(&toks(&cmd)).expect("resolves")), Verdict::Denied, "{cmd:?} writes a system path");
2764        }
2765    }
2766
2767    /// `sudo`/`doas` elevate the wrapped command's AUTHORITY — the resolver that finally gives
2768    /// `local-admin` something to admit. `sudo <safe cmd>` = a root op (above every user-authority
2769    /// band); `sudo rm -rf /` stays the catastrophe corner; `-u`/`-i` and unknown options fail up.
2770    #[test]
2771    fn sudo_elevates_the_wrapped_commands_authority() {
2772        use crate::engine::bridge::project;
2773        use crate::verdict::Verdict;
2774        let (dev, local, net, yolo) = (level("developer"), level("local-admin"), level("network-admin"), level("yolo"));
2775
2776        // sudo cat ./notes — a ROOT read. Authority lifts to root; every band below local-admin pins
2777        // authority=user, so it lands at local-admin (and yolo), NOT developer/network-admin.
2778        let read = resolve(&toks(&["sudo", "cat", "./notes.md"])).expect("sudo cat resolves");
2779        assert_eq!(read.capabilities[0].authority, Authority::Root, "authority lifted to root");
2780        assert!(!dev.admits(&read) && !net.admits(&read), "a root op is above the user-authority bands");
2781        assert!(local.admits(&read) && yolo.admits(&read), "a root read is local-admin");
2782
2783        // benign flag clusters are skipped without losing the inner command (space + glued values too).
2784        assert_eq!(resolve(&toks(&["sudo", "-EH", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2785        assert_eq!(resolve(&toks(&["sudo", "-n", "-p", "pw", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2786
2787        // bumping authority does NOT rescue the catastrophe corner.
2788        assert_eq!(project(&resolve(&toks(&["sudo", "rm", "-rf", "/"])).unwrap()), Verdict::Denied, "sudo rm -rf / denied everywhere");
2789
2790        // -u (run as another user) → other-user authority → yolo-only (identity confusion tops the ladder).
2791        let other = resolve(&toks(&["sudo", "-u", "bob", "cat", "./x"])).expect("sudo -u resolves");
2792        assert_eq!(other.capabilities[0].authority, Authority::OtherUser, "-u = run as other user");
2793        assert!(!local.admits(&other) && yolo.admits(&other), "other-user is yolo-only");
2794        assert_eq!(resolve(&toks(&["sudo", "-ubob", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::OtherUser, "glued -ubob");
2795
2796        // -i / -s / -e launch a root shell or editor → arbitrary code, worst-cased.
2797        assert_eq!(project(&resolve(&toks(&["sudo", "-i"])).unwrap()), Verdict::Denied, "sudo -i is a root shell");
2798        assert!(!local.admits(&resolve(&toks(&["sudo", "-s", "bash"])).unwrap()), "root shell not local-admin");
2799
2800        // an UNRECOGNIZED sudo option fails closed.
2801        assert_eq!(project(&resolve(&toks(&["sudo", "--nonsense", "cat", "./x"])).unwrap()), Verdict::Denied, "unknown option worst-cases");
2802
2803        // an UNRESOLVED inner → None, so the caller's legacy fallback denies (never looser than bare).
2804        assert!(resolve(&toks(&["sudo", "totallyunknowncmd", "x"])).is_none(), "unresolved inner → legacy denies");
2805        // `sudo` with no command → None (legacy decides).
2806        assert!(resolve(&toks(&["sudo", "-v"])).is_none(), "no inner command");
2807
2808        // doas is the same wrapper.
2809        assert_eq!(resolve(&toks(&["doas", "cat", "./x"])).unwrap().capabilities[0].authority, Authority::Root);
2810
2811        // A valued short flag at end-of-input has no next-token value: `i` overshot the slice and
2812        // PANICKED (fail-open hook crash, found by the parse fuzzer). Now clamps → no inner → None.
2813        assert!(resolve(&toks(&["doas", "-r"])).is_none(), "doas -r must not panic");
2814        assert!(resolve(&toks(&["sudo", "-u"])).is_none(), "sudo -u must not panic");
2815        assert_eq!(crate::command_verdict("doas -r"), Verdict::Denied, "doas -r denied, not crashed");
2816
2817        // NEVER LOOSER: at the default band every `sudo …` is denied (root authority is auto-approved
2818        // by NO level below local-admin), exactly like the legacy classifier, which denies sudo whole.
2819        for cmd in ["sudo cat ./notes.md", "sudo rm -rf ./build", "sudo -EH cat ./x", "sudo -u bob ls"] {
2820            assert_eq!(crate::command_verdict(cmd), Verdict::Denied, "`{cmd}` must not auto-approve at the default band");
2821        }
2822    }
2823
2824    /// systemctl — the first REAL command user of the `local-privileged` archetype. Read subs stay
2825    /// SafeRead (any band); service-management subs land at local-admin; and `sudo systemctl restart`
2826    /// (previously fail-closed, since systemctl's inner sub was unmodeled) now resolves to local-admin.
2827    #[test]
2828    fn systemctl_service_management_is_local_admin() {
2829        use crate::verdict::Verdict;
2830        let (dev, local, net, yolo) = (level("developer"), level("local-admin"), level("network-admin"), level("yolo"));
2831
2832        // service management → local-privileged: local-admin and yolo admit; developer/network-admin don't.
2833        for sub in ["restart", "start", "stop", "enable", "disable", "mask", "daemon-reload", "kill"] {
2834            let p = resolve(&toks(&["systemctl", sub, "nginx"])).unwrap_or_else(|| panic!("systemctl {sub} resolves"));
2835            assert!(!dev.admits(&p) && !net.admits(&p), "systemctl {sub} is above developer/network-admin");
2836            assert!(local.admits(&p) && yolo.admits(&p), "systemctl {sub} is local-admin");
2837        }
2838        // reads stay auto-approvable (SafeRead), a power-state sub denies by omission (not modeled).
2839        assert!(crate::command_verdict("systemctl status nginx").is_allowed(), "status reads");
2840        assert_eq!(crate::command_verdict("systemctl reboot"), Verdict::Denied, "reboot omitted → denied");
2841
2842        // the fail-closed case is fixed: `sudo systemctl restart` resolves the inner sub (already root).
2843        let sudo_restart = resolve(&toks(&["sudo", "systemctl", "restart", "nginx"])).expect("resolves");
2844        assert!(local.admits(&sudo_restart), "sudo systemctl restart is local-admin");
2845        assert_eq!(crate::command_verdict("sudo systemctl restart nginx"), Verdict::Denied, "still not auto-approved at default");
2846    }
2847
2848    /// The flag-conditional-archetype resolver (the `when_absent` mechanism, npm exemplar):
2849    /// `npm ci --ignore-scripts` is a PINNED, scripts-off install → `local-install-pinned`
2850    /// (developer). Dropping `--ignore-scripts` escalates it to `supply-chain-build` (yolo — runs
2851    /// fetched code at install). `npm install`/`i` are FLOATING → always `supply-chain-build`. This
2852    /// is the pattern the package-manager fan-out replicates.
2853    #[test]
2854    fn npm_install_is_classified_by_pinning_and_scripts_off() {
2855        let (dev, yolo) = (level("developer"), level("yolo"));
2856
2857        // pinned (ci) + scripts-off → developer.
2858        let safe = resolve(&toks(&["npm", "ci", "--ignore-scripts"])).expect("npm ci --ignore-scripts");
2859        assert!(dev.admits(&safe), "pinned, scripts-off ci is developer");
2860
2861        // pinned but scripts-ON → the --ignore-scripts ABSENCE escalates to supply-chain-build → yolo.
2862        let scripts_on = resolve(&toks(&["npm", "ci"])).expect("npm ci");
2863        assert!(!dev.admits(&scripts_on), "ci without --ignore-scripts runs fetched code → above developer");
2864        assert!(yolo.admits(&scripts_on), "and lands at yolo");
2865
2866        // floating installs → supply-chain-build regardless of flags.
2867        for c in [&["npm", "install"][..], &["npm", "install", "left-pad"], &["npm", "i", "react"], &["npm", "install", "--ignore-scripts"]] {
2868            let p = resolve(&toks(c)).unwrap_or_else(|| panic!("{c:?} resolves"));
2869            assert!(!dev.admits(&p) && yolo.admits(&p), "{c:?}: floating install → supply-chain (yolo)");
2870        }
2871    }
2872
2873    #[test]
2874    fn rm_flag_and_operand_fail_closed() {
2875        use crate::engine::bridge::project;
2876        use crate::verdict::Verdict;
2877        for cmd in [
2878            vec!["rm", "--no-preserve-root", "-rf", "/"], // enables rm -rf / → must worst-case
2879            vec!["rm", "-Z", "x"],                        // unknown flag
2880            vec!["rm"],                                   // no operand (usage error)
2881            vec!["./rm", "x"],                            // basename spoof
2882        ] {
2883            assert_eq!(project(&resolve(&toks(&cmd)).expect("resolves")), Verdict::Denied, "{cmd:?}");
2884        }
2885    }
2886
2887    #[test]
2888    fn rm_scale_and_force_semantics() {
2889        let cap = |cmd: &[&str]| resolve(&toks(cmd)).expect("rm").capabilities[0].clone();
2890        assert_eq!(cap(&["rm", "./x"]).scale, Scale::Single);
2891        assert_eq!(cap(&["rm", "a", "b"]).scale, Scale::Bounded, "multiple operands");
2892        assert_eq!(cap(&["rm", "*.log"]).scale, Scale::Bounded, "a glob");
2893        assert_eq!(cap(&["rm", "-r", "./dir"]).scale, Scale::Unbounded, "recursive");
2894        // -f only suppresses prompts — it does NOT raise reversibility for rm
2895        assert_eq!(cap(&["rm", "./x"]).reversibility, Reversibility::Effortful);
2896        assert_eq!(cap(&["rm", "-f", "./x"]).reversibility, Reversibility::Effortful, "-f is not a raiser");
2897    }
2898
2899    #[test]
2900    fn a_resolvable_name_from_a_non_standard_path_worst_cases() {
2901        // ./cat, /tmp/cat, ~/bin/grep may be impostors → worst-case, not certified safe
2902        for cmd in [vec!["./cat", "x"], vec!["/tmp/cat", "x"], vec!["~/bin/grep", "foo", "f"]] {
2903            let p = resolve(&toks(&cmd)).expect("resolvable name");
2904            assert!(!read_local().admits(&p), "{cmd:?} from a non-standard path must worst-case");
2905        }
2906        // bare names and standard bin paths resolve normally
2907        assert!(read_local().admits(&resolve(&toks(&["cat", "./notes.md"])).expect("cat")));
2908        assert!(read_local().admits(&resolve(&toks(&["/usr/bin/cat", "./notes.md"])).expect("cat")));
2909        // a non-resolvable command from any path → None (the engine doesn't claim it)
2910        assert!(resolve(&toks(&["/tmp/mytool", "x"])).is_none());
2911    }
2912
2913    #[test]
2914    fn unrecognized_flags_worst_case_fail_closed() {
2915        for cmd in [
2916            vec!["cat", "-Z", "./x"],
2917            vec!["cat", "--wat", "./x"],
2918            vec!["grep", "-Q", "foo", "f"], // unknown grep short char (-Z is benign: --null)
2919            vec!["grep", "-R", "foo", "dir"], // -R follows symlinks → escapes locus (M2)
2920        ] {
2921            let p = resolve(&toks(&cmd)).expect("resolver");
2922            assert!(!inert().admits(&p) && !read_local().admits(&p), "{cmd:?} must worst-case");
2923        }
2924        // recognized-benign flags still resolve normally
2925        assert!(read_local().admits(&resolve(&toks(&["cat", "-nA", "./x"])).expect("cat")));
2926        assert!(read_local().admits(&resolve(&toks(&["grep", "-rin", "foo", "src/"])).expect("grep")));
2927    }
2928
2929    use proptest::prelude::*;
2930
2931    /// The content-transfer commands: every one moves/bridges content between a source and
2932    /// a destination operand, so BOTH roles must be locus-gated. Extend this list as
2933    /// `install`/`dd`/`rsync`/`tar` land — a resolver that forgets to gate a role then fails
2934    /// the property below (the `ln` cp-bypass class, §HP re: capability laundering).
2935    const TRANSFER_CMDS: &[&str] = &["cp", "mv", "ln"];
2936
2937    /// A sensitive path that must never be laundered through a transfer command, in any
2938    /// role. Covers each locus rung above the worktree AND the two unpinnable markers.
2939    const HOT_PATHS: &[&str] = &["/etc/shadow", "~/.ssh/id_rsa", "$SECRET", "../out", "~/.aws"];
2940
2941    proptest! {
2942        /// No capability laundering: a hot path in EITHER operand role of a transfer command
2943        /// denies — you can neither pull a secret in (`cp ~/.ssh/id_rsa ./x`) nor push one
2944        /// out (`cp ./x /etc/cron.d/y`). This is the STRICT property that catches an ignored
2945        /// operand; plain locus-monotonicity does not, because ignoring a role leaves the
2946        /// verdict unchanged, and unchanged is "not looser".
2947        #[test]
2948        fn transfer_commands_gate_both_operand_roles(
2949            cmd in prop::sample::select(TRANSFER_CMDS),
2950            hot in prop::sample::select(HOT_PATHS),
2951        ) {
2952            use crate::engine::bridge::project;
2953            use crate::verdict::Verdict;
2954            let hot_source = resolve(&toks(&[cmd, hot, "./safe"])).expect("resolves");
2955            prop_assert_eq!(project(&hot_source), Verdict::Denied, "{} hot SOURCE ({})", cmd, hot);
2956            let hot_dest = resolve(&toks(&[cmd, "./safe", hot])).expect("resolves");
2957            prop_assert_eq!(project(&hot_dest), Verdict::Denied, "{} hot DEST ({})", cmd, hot);
2958        }
2959
2960        /// The sudo/doas flag walk must never panic (a panic in the resolver is a fail-OPEN hook
2961        /// crash) nor depend on evaluation order, for ANY flag salad — crucially a valued short flag
2962        /// at end-of-input (`doas -r`, `sudo -u`), which consumes a "next token" that isn't there and
2963        /// pushed `i` one past the end. That `&tokens[i..]` out-of-range is what the parse fuzzer hit
2964        /// on `doas -r`; uniform command sampling never lands on this resolver often enough to find it.
2965        #[test]
2966        fn sudo_family_flag_walk_never_panics(
2967            head in prop::sample::select(vec!["sudo", "doas"]),
2968            args in prop::collection::vec(
2969                prop_oneof![
2970                    Just("-u".to_string()), Just("-r".to_string()), Just("-g".to_string()),
2971                    Just("-i".to_string()), Just("-EH".to_string()), Just("-uEH".to_string()),
2972                    Just("-uroot".to_string()), Just("--".to_string()), Just("-".to_string()),
2973                    Just("root".to_string()), Just("cat".to_string()), Just("./x".to_string()),
2974                    "-[a-zA-Z]{1,4}",
2975                ],
2976                0..6,
2977            ),
2978        ) {
2979            let parts: Vec<&str> =
2980                std::iter::once(head).chain(args.iter().map(String::as_str)).collect();
2981            let a = resolve(&toks(&parts)).is_some();
2982            let b = resolve(&toks(&parts)).is_some();
2983            prop_assert_eq!(a, b, "nondeterministic verdict for {:?}", parts);
2984        }
2985    }
2986
2987    /// The exact roster of commands classified by `[command.behavior]`. Pinning it turns a
2988    /// DROPPED or typo'd behavior block into a test failure: `TomlCommand` deliberately lacks
2989    /// `deny_unknown_fields` (it must tolerate `[[trusted]]`), so a mistyped top-level key
2990    /// (`behaviour = …`) is silently dropped and the command reverts to its PERMISSIVE legacy
2991    /// fallback — a fail-open the enumeration guards can't see (they `continue` on `None`). This
2992    /// roster is that missing tripwire, and the guards below derive their non-vacuity floors from
2993    /// it so the floors track reality. Update deliberately when porting a command. `echo` is a
2994    /// none-role printer; `dd`/`tar`/`sed` are hook commands; `grep` is a hook + pattern-then-read;
2995    /// the other 10 are the plain positional coreutils.
2996    const EXPECTED_BEHAVIOR_COMMANDS: &[&str] = &[
2997        "cat", "cp", "dd", "echo", "grep", "head", "ln", "mkdir", "mv", "perl", "rm", "sed",
2998        "tail", "tar", "touch", "wc",
2999    ];
3000
3001    /// The behavior roster is exactly `EXPECTED_BEHAVIOR_COMMANDS` — no command silently lost its
3002    /// `[command.behavior]` (fail-open) and none was added without being pinned. Red→green: delete
3003    /// one command's behavior block and this fails.
3004    #[test]
3005    fn behavior_command_roster_is_pinned() {
3006        use std::collections::BTreeSet;
3007        let actual: BTreeSet<&str> = crate::registry::toml_command_names()
3008            .into_iter()
3009            .filter(|n| crate::registry::command_behavior(n).is_some())
3010            .collect();
3011        let expected: BTreeSet<&str> = EXPECTED_BEHAVIOR_COMMANDS.iter().copied().collect();
3012        assert_eq!(
3013            actual, expected,
3014            "behavior-command roster drifted — a [command.behavior] block was added, dropped, or \
3015             typo'd. A dropped block silently reverts the command to its fail-open legacy path."
3016        );
3017    }
3018
3019    /// Hot-path probes for a `[command.behavior]` command, keyed on its declared operand role
3020    /// (the parallel of `probes` for the `Operands` enum). A `@` in a slot is the hot path.
3021    fn behavior_probes(cmd: &str, role: crate::registry::types::PositionalRole, hot: &str) -> Vec<Vec<String>> {
3022        use crate::registry::types::PositionalRole;
3023        let inv = |slots: &[&str]| -> Vec<String> {
3024            std::iter::once(cmd.to_string()).chain(slots.iter().map(|s| s.replace('@', hot))).collect()
3025        };
3026        match role {
3027            PositionalRole::None => vec![],
3028            PositionalRole::Read | PositionalRole::Write => vec![inv(&["@"])],
3029            PositionalRole::PatternThenRead => vec![inv(&["PATTERN", "@"])],
3030            PositionalRole::Transfer => vec![inv(&["@", "./safe"]), inv(&["./safe", "@"])],
3031        }
3032    }
3033
3034    /// Hot-path probes for a HOOK command, whose irregular operand syntax `behavior_probes`
3035    /// (positional roles) can't express — dd's `key=value`, tar's dashless mode bundles, sed's
3036    /// script. The `match` is EXHAUSTIVE, so a new `BehaviorHook` variant must declare its probe
3037    /// rows here or the build breaks — restoring the "new entry covered automatically" property the
3038    /// deleted `every_touched_path_operand_is_gated` had via `Operands::Custom`. `@` = the hot slot.
3039    fn hook_probes(hook: crate::registry::types::BehaviorHook, cmd: &str, hot: &str) -> Vec<Vec<String>> {
3040        use crate::registry::types::BehaviorHook;
3041        let inv = |slots: &[&str]| -> Vec<String> {
3042            std::iter::once(cmd.to_string()).chain(slots.iter().map(|s| s.replace('@', hot))).collect()
3043        };
3044        match hook {
3045            // grep is pattern-then-read → already probed by `behavior_probes`; no extra rows.
3046            BehaviorHook::Grep => vec![],
3047            BehaviorHook::Dd => vec![inv(&["if=@", "of=./safe"]), inv(&["if=./safe", "of=@"])],
3048            BehaviorHook::Tar => vec![inv(&["cf", "./s.tar", "@"]), inv(&["cf", "@", "./s"]), inv(&["tf", "@"])],
3049            BehaviorHook::Sed => vec![inv(&["s/x/y/", "@"]), inv(&["-i", "s/x/y/", "@"])],
3050            BehaviorHook::Perl => {
3051                vec![inv(&["-pe", "s/x/y/", "@"]), inv(&["-pi", "-e", "s/x/y/", "@"])]
3052            }
3053        }
3054    }
3055
3056    /// Fail-closed, enumerated over the REGISTRY: every `[command.behavior]` command denies an
3057    /// operand on a hot path (a secret, home, system, or unpinnable locus), AND a write-role
3058    /// command denies a write into the worktree-trusted rung (`.git/config`). Restores and
3059    /// generalizes `every_touched_path_operand_is_gated` for the declarative path — a command
3060    /// ported off Rust is covered automatically. Red→green: make `resolve_behavior` skip
3061    /// `classify_locus` and this fails on the first probe.
3062    #[test]
3063    fn every_behavior_command_gates_hot_operands() {
3064        use crate::engine::bridge::project;
3065        use crate::registry::types::PositionalRole;
3066        use crate::verdict::Verdict;
3067
3068        let deny = |cmd: &[String], why: &str| {
3069            let refs: Vec<&str> = cmd.iter().map(String::as_str).collect();
3070            let profile = resolve(&toks(&refs)).expect("behavior command resolves");
3071            assert_eq!(project(&profile), Verdict::Denied, "{cmd:?}: {why}");
3072        };
3073
3074        let mut path_bearing = 0usize;
3075        let mut hook_bearing = 0usize;
3076        for name in crate::registry::toml_command_names() {
3077            let Some(b) = crate::registry::command_behavior(name) else { continue };
3078            if !matches!(b.positionals, PositionalRole::None) {
3079                path_bearing += 1;
3080            }
3081            if b.hook.is_some() {
3082                hook_bearing += 1;
3083            }
3084            for hot in HOT_PATHS {
3085                for cmd in behavior_probes(name, b.positionals, hot) {
3086                    deny(&cmd, "touched hot path not gated");
3087                }
3088                // Hook commands (dd/tar/sed) have irregular operand syntax, so they are swept by
3089                // their own probe table — restoring the enumerated coverage the deleted RESOLVERS
3090                // sweep gave them.
3091                if let Some(hook) = b.hook {
3092                    for cmd in hook_probes(hook, name, hot) {
3093                        deny(&cmd, "hook: touched hot path not gated");
3094                    }
3095                }
3096            }
3097            // Worktree-trusted is a WRITE boundary only: reading `.git/config` (cat/grep) is
3098            // legitimately allowed, but a write/destroy/relocate into it must deny. Probe the
3099            // write face — the destination slot for a transfer, the operand for a plain write.
3100            let inv = |slots: &[&str]| -> Vec<String> {
3101                std::iter::once(name.to_string()).chain(slots.iter().map(|s| s.to_string())).collect()
3102            };
3103            match b.positionals {
3104                PositionalRole::Write => deny(&inv(&[".git/config"]), "write into worktree-trusted not gated"),
3105                PositionalRole::Transfer => deny(&inv(&["./safe", ".git/config"]), "transfer dest into worktree-trusted not gated"),
3106                _ => {}
3107            }
3108        }
3109        // Non-vacuity: every path-bearing AND every hook command on the roster was reached and
3110        // probed. Derived from the roster (not a magic number) — none-role printers (echo) don't
3111        // positionally gate; hook commands (dd/tar/sed) gate via `hook_probes`.
3112        let count = |pred: fn(&crate::registry::types::BehaviorSpec) -> bool| {
3113            EXPECTED_BEHAVIOR_COMMANDS
3114                .iter()
3115                .filter(|n| crate::registry::command_behavior(n).is_some_and(pred))
3116                .count()
3117        };
3118        assert_eq!(
3119            path_bearing,
3120            count(|b| !matches!(b.positionals, PositionalRole::None)),
3121            "path-bearing behavior commands: saw {path_bearing}"
3122        );
3123        assert_eq!(hook_bearing, count(|b| b.hook.is_some()), "hook behavior commands: saw {hook_bearing}");
3124    }
3125
3126    /// Fail-closed on unknown flags, enumerated over the REGISTRY: every DECLARATIVE flag-walking
3127    /// behavior command (a Read/Write/Transfer role, hookless) worst-cases an unrecognized flag —
3128    /// the `walk_positionals` → `worst` path. Exempt: `grep` (its hook treats an unknown `--token`
3129    /// as a search pattern — keyed on `BehaviorHook::Grep` SPECIFICALLY, not `hook.is_some()`, so a
3130    /// future hook variant is not auto-exempted), and none-role commands (echo prints its args;
3131    /// dd/tar/sed parse their own irregular syntax — all covered by their own resolver tests, and
3132    /// none-role commands take no positional path operands, so an unknown flag can't unlock danger).
3133    #[test]
3134    fn every_hookless_behavior_command_worst_cases_unknown_flags() {
3135        use crate::engine::bridge::project;
3136        use crate::registry::types::{BehaviorHook, PositionalRole};
3137        use crate::verdict::Verdict;
3138
3139        let exempt = |b: &crate::registry::types::BehaviorSpec| {
3140            matches!(b.hook, Some(BehaviorHook::Grep)) || matches!(b.positionals, PositionalRole::None)
3141        };
3142        let mut checked = 0usize;
3143        for name in crate::registry::toml_command_names() {
3144            let Some(b) = crate::registry::command_behavior(name) else { continue };
3145            if exempt(b) {
3146                continue;
3147            }
3148            let profile = resolve(&toks(&[name, "--xyzzy-unknown-42", "./safe"])).expect("resolves");
3149            assert_eq!(project(&profile), Verdict::Denied, "{name}: unknown flag not worst-cased");
3150            checked += 1;
3151        }
3152        let expected = EXPECTED_BEHAVIOR_COMMANDS
3153            .iter()
3154            .filter(|n| crate::registry::command_behavior(n).is_some_and(|b| !exempt(b)))
3155            .count();
3156        assert_eq!(checked, expected, "declarative flag-walking behavior commands: saw {checked}");
3157    }
3158
3159    /// Fail-closed authoring guard: `path_flag_caps` (which gates a valued flag's path VALUE, e.g.
3160    /// `touch -r REF`) runs ONLY on the declarative Read/Write/Transfer path — the None arm (echo)
3161    /// and the hook arm (grep/dd/tar/sed) both return before it. So a `[command.behavior.flags]`
3162    /// path-role declared on a none-role or hook command would be SILENTLY UNGATED — a fail-open.
3163    /// Assert no command does that. Red→green: add `kind = "read"` to a hook command's flags.
3164    #[test]
3165    fn no_none_or_hook_command_declares_ungated_path_flags() {
3166        use crate::registry::types::PositionalRole;
3167        for name in crate::registry::toml_command_names() {
3168            let Some(b) = crate::registry::command_behavior(name) else { continue };
3169            if b.path_flags.is_empty() {
3170                continue;
3171            }
3172            assert!(
3173                b.hook.is_none() && !matches!(b.positionals, PositionalRole::None),
3174                "{name}: behavior path-flags are gated only on the Read/Write/Transfer path; on a \
3175                 none-role or hook command they would be silently ungated (fail-open)"
3176            );
3177        }
3178    }
3179}