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