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