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