Skip to main content

safe_chains/registry/
mod.rs

1mod build;
2mod custom;
3mod cwd_writes;
4pub mod folder_config;
5pub use custom::fuzz_load_config;
6pub(crate) use cwd_writes::cwd_writes;
7mod dispatch;
8mod docs;
9mod glob_flags;
10use glob_flags::glob_presents_unlisted_flag;
11mod policy;
12pub(crate) mod types;
13
14use std::collections::HashMap;
15use std::sync::LazyLock;
16
17use crate::parse::Token;
18use crate::verdict::Verdict;
19
20pub use build::{build_registry, load_toml};
21pub(crate) use custom::user_config_level;
22pub use dispatch::dispatch_spec;
23pub use types::{CommandSpec, OwnedPolicy};
24
25use types::DispatchKind;
26
27type HandlerFn = fn(&[Token]) -> Verdict;
28
29static CMD_HANDLERS: LazyLock<HashMap<&'static str, HandlerFn>> = LazyLock::new(crate::handlers::custom_cmd_handlers);
30
31static SUB_HANDLERS: LazyLock<HashMap<&'static str, HandlerFn>> = LazyLock::new(crate::handlers::custom_sub_handlers);
32
33static TOML_REGISTRY: LazyLock<HashMap<String, CommandSpec>> = LazyLock::new(|| include!(concat!(env!("OUT_DIR"), "/toml_includes.rs")));
34
35static CUSTOM_REGISTRY: LazyLock<HashMap<String, CommandSpec>> = LazyLock::new(|| {
36    let mut map = HashMap::new();
37    custom::apply_custom(&mut map);
38    map
39});
40
41pub fn toml_dispatch(tokens: &[Token]) -> Option<Verdict> {
42    let cmd = tokens[0].command_name();
43    TOML_REGISTRY.get(cmd).map(|spec| dispatch_spec(tokens, spec))
44}
45
46/// Looks up the command in the runtime custom registry (project-local
47/// `.safe-chains.toml`, then user-level `~/.config/safe-chains.toml`).
48/// A match here wins over the built-in hardcoded handlers, which is how
49/// an override of `gh` takes effect.
50pub fn custom_dispatch(tokens: &[Token]) -> Option<Verdict> {
51    let cmd = tokens[0].command_name();
52    CUSTOM_REGISTRY.get(cmd).map(|spec| dispatch_spec(tokens, spec))
53}
54
55/// The canonical command name `cmd` resolves to via the registry's alias map (`gcat` → `cat`,
56/// `glink` → `ln`). Returns `cmd` unchanged when it is already canonical or unknown, so callers
57/// can canonicalize unconditionally. This is what lets the engine's path-gate dispatch reach an
58/// aliased invocation: without it, `gcat /etc/shadow` misses every resolver and falls through to
59/// the (ungated) legacy classifier. Custom registry first (an override may rename), then TOML.
60pub fn canonical_name(cmd: &str) -> &str {
61    CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd)).map_or(cmd, |spec| spec.name.as_str())
62}
63
64/// The command's own declared path-argument gate (`[command.path_gate]`), if any — consulted by
65/// `pathgate::should_deny` when a command isn't in `pathgates.toml`, so a path-bearing flag gates
66/// from the command's own definition. `cmd` is already canonicalized by the caller.
67pub(crate) fn command_path_gate(cmd: &str) -> Option<&'static crate::pathgate::RoleSpec> {
68    CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd)).and_then(|spec| spec.path_gate.as_ref())
69}
70
71/// The command's declarative facet behavior (`[command.behavior]`), if any — the engine's
72/// non-legacy classification path and the ONLY thing `engine::resolve::resolve` consults (the
73/// hardcoded `RESOLVERS` table is gone; every facet-classified command declares behavior).
74/// `cmd` is already canonicalized by the caller.
75pub(crate) fn command_behavior(cmd: &str) -> Option<&'static crate::registry::types::BehaviorSpec> {
76    CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd)).and_then(|spec| spec.behavior.as_ref())
77}
78
79/// What `cmd`'s stdout can name, when that has been researched and declared (`[command.output]`).
80/// `None` — the default for every command — keeps a `$(cmd …)` unpinnable.
81pub(crate) fn command_output_locus(cmd: &str) -> Option<&'static crate::registry::types::OutputSpec> {
82    CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd)).and_then(|spec| spec.output.as_ref())
83}
84
85/// What the SUBCOMMAND in `words` prints, when that sub has declared it
86/// (`[command.sub.output]`), plus the arguments left after the sub path.
87///
88/// Separate from `command_output_locus` because for a multi-command tool the claim belongs to the
89/// sub, not the program: `git diff --name-only` prints worktree paths while `git log` prints prose.
90/// Expressing that command-level would mean an `invalidated_by` naming every OTHER subcommand — a
91/// denylist, which fails open the next time git grows one.
92///
93/// Descends nested subs to the deepest declaring node, the same walk `is_eval_safe_invocation`
94/// does, so a claim can sit on `<resource> <action>`. `None` when no sub on the path declares one,
95/// which leaves the caller to fall back to the command-level claim (and then to unpinnable).
96/// `canonical` must be the CANONICALIZED command name (`registry::canonical_name` of the token's
97/// command name), the same key `command_output_locus` is given. Keying on the raw first word
98/// instead silently dropped the claim for a path-spelled or aliased command: `/usr/bin/git diff
99/// --name-only` is a trusted spelling of a trusted tool, and it lost the claim while bare `git` kept
100/// it — one operation, two spellings, two answers.
101pub(crate) fn sub_output_locus<'a>(
102    canonical: &str,
103    args: &'a [String],
104) -> Option<(&'static crate::registry::types::OutputSpec, &'a [String])> {
105    let mut rest = args;
106    let spec = CUSTOM_REGISTRY.get(canonical).or_else(|| TOML_REGISTRY.get(canonical))?;
107    let mut kind = &spec.kind;
108    let mut found: Option<(&'static crate::registry::types::OutputSpec, &'a [String])> = None;
109    loop {
110        let subs = match kind {
111            DispatchKind::Branching { subs, .. } | DispatchKind::Custom { subs, .. } => subs,
112            _ => return found,
113        };
114        let Some((arg, tail)) = rest.split_first() else { return found };
115        let Some(sub) = subs.iter().find(|s| s.name == *arg) else {
116            return found;
117        };
118        rest = tail;
119        // Deepest declaration wins. A shallower one is NOT inherited by a child that declares
120        // nothing: the claim is researched per node, and letting `<parent>`'s answer stand in for
121        // an unresearched `<parent> <child>` would be asserting something nobody checked. Reset so
122        // descending past a declaring node into a silent one falls back to unpinnable.
123        found = sub.output.as_ref().map(|o| (o, rest));
124        kind = &sub.kind;
125    }
126}
127
128/// The facet archetypes (`archetypes.toml`) the subcommand `tokens` resolve to — the Phase-1
129/// `profile = …` classification plus a capability for every present escalating `[[command.sub.flag]]`
130/// (`git push` → `[vcs-sync]`; `git push --force` → `[vcs-sync, remote-destroy-irreversible]`). The
131/// engine emits a Capability per name and the level algebra takes the max. Descends `Branching`/
132/// `Custom` subs to the DEEPEST profile-bearing sub (nested `<resource> <action>`). `None` when no
133/// matched sub declares a profile.
134pub(crate) fn sub_archetypes(tokens: &[Token]) -> Option<Vec<&'static str>> {
135    let cmd = canonical_name(tokens.first()?.command_name());
136    let spec = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd))?;
137    let sub = walk_to_profiled_sub(&tokens[1..], &spec.kind)?;
138    let mut out = vec![sub.profile.as_deref()?];
139    for flag in &sub.flags {
140        if flag_escalates(tokens, flag) {
141            out.push(flag.classifies.as_str());
142        }
143    }
144    // The profile fixes the TIER; it must not also decide what is ADMISSIBLE. Without this the
145    // archetype path bypassed the flag allowlist entirely and any flag rode along on the base
146    // profile — `git rebase --exec 'rm -rf /'` classified as an ordinary rebase. `unclassified` is
147    // the sanctioned fail-closed marker: it resolves to no archetype, so the engine emits a worst
148    // capability and the invocation denies.
149    if presents_unlisted_flag(tokens, sub) {
150        out.push("unclassified");
151    }
152    Some(out)
153}
154
155/// Whether `tokens` carry a flag the profiled `sub` does not declare. Mirrors the legacy flag walk:
156/// `--long` matched whole or as `--long=value`, single-dash tokens split into clustered shorts, `--`
157/// ends the flag region. A sub that declares NO flags at all is treated as "not yet enumerated"
158/// rather than "nothing permitted", so this tightens only where a list exists — the global
159/// enforcement of the empty case is staged separately (see the backfill guard).
160fn presents_unlisted_flag(tokens: &[Token], sub: &types::SubSpec) -> bool {
161    // A flag declared as an escalating `[[command.sub.flag]]` IS declared — it just carries a
162    // capability rather than sitting on the plain allowlist. Omitting it here would deny the very
163    // invocations the escalation exists to classify (`npm ci --ignore-scripts`, whose whole point is
164    // that its PRESENCE is the safe form).
165    let known = |f: &str| {
166        sub.allowed_standalone.iter().any(|a| a == f)
167            || sub.allowed_valued.iter().any(|a| a == f)
168            || sub.flags.iter().any(|d| d.name == f)
169            // An output-path flag is declared too — it names the write destination the engine
170            // path-gates (`supabase db dump -f dump.sql`), so it is admissible by construction.
171            || sub.output_path_flags.iter().any(|a| a == f)
172            || sub.destination_flag.as_deref() == Some(f)
173    };
174    for (idx, t) in tokens.iter().enumerate().skip(1) {
175        let s = t.as_str();
176        if s == "--" {
177            break;
178        }
179        if !s.starts_with('-') || s == "-" || crate::cst::opaque::probe_is_value(tokens, idx, &sub.allowed_valued) {
180            continue;
181        }
182        let head = s.split_once('=').map_or(s, |(f, _)| f);
183        // An endpoint flag is admitted only when it names THIS machine — see the identical rule on
184        // the glob path in `glob_presents_unlisted_flag`.
185        if sub.loopback_valued.iter().any(|a| a == head) {
186            let value = match s.split_once('=') {
187                Some((_, v)) => Some(v),
188                None => tokens.get(idx + 1).map(Token::as_str),
189            };
190            if value.is_some_and(crate::netloc::is_loopback) {
191                continue;
192            }
193            return true;
194        }
195        if known(head) {
196            continue;
197        }
198        // An explicitly declared tolerance keeps the sub open for the shape it names — the
199        // established way to say "this surface is genuinely unbounded" (a cloud API's per-service
200        // options). Declared in the TOML, so it is reviewable, unlike the silent default it replaces.
201        use crate::policy::UnknownTolerance as U;
202        let tolerated = if s.starts_with("--") {
203            matches!(sub.allowed_unknown, U::Long | U::Both)
204        } else {
205            matches!(sub.allowed_unknown, U::Short | U::Both)
206        };
207        if tolerated {
208            continue;
209        }
210        // A single-dash multi-char token may be clustered shorts (`-abc` = `-a -b -c`).
211        if !s.starts_with("--") && s.len() > 2 && s[1..].chars().all(|c| known(&format!("-{c}"))) {
212            continue;
213        }
214        return true;
215    }
216    false
217}
218
219/// The facet archetypes a flat command's PRESENT top-level classifying flags (`[[command.flag]]`)
220/// resolve to — the command-level analog of `sub_archetypes` for a flag-triggered mode (`age -d` →
221/// `[decrypt-read]`, `sops --decrypt` → `[decrypt-read]`). `None` when the command declares none or
222/// none are present, so `engine::resolve` falls through to the command's ordinary resolution (the
223/// bare/encrypt form of a bimodal tool). Uses the same `flag_escalates` predicate as the sub flags.
224pub(crate) fn command_flag_archetypes(tokens: &[Token]) -> Option<Vec<&'static str>> {
225    let cmd = canonical_name(tokens.first()?.command_name());
226    let spec = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd))?;
227    let present: Vec<&'static str> = spec
228        .archetype_flags
229        .iter()
230        .filter(|f| flag_escalates(tokens, f))
231        .map(|f| f.classifies.as_str())
232        .collect();
233    (!present.is_empty()).then_some(present)
234}
235
236/// Whether `flag` escalates given `tokens`. A bare flag (no `value_prefix`) escalates on presence; a
237/// value-matched flag escalates only when its VALUE starts with the prefix — the space form
238/// (`-c core.sshCommand=…`) or the glued form (`--flag=core.sshCommand=…`). Scans the whole line;
239/// an escalator counts wherever it sits.
240fn flag_escalates(tokens: &[Token], flag: &types::FlagProvenance) -> bool {
241    if flag.when_absent {
242        // A SAFETY flag whose ABSENCE is the escalation (`npm ci` without `--ignore-scripts`).
243        // It must be AFFIRMATIVELY set — `--ignore-scripts=false` / `--no-ignore-scripts` re-ENABLE
244        // scripts, so they escalate exactly like the flag being missing (a fail-open otherwise).
245        return !flag_is_affirmatively_set(tokens, &flag.name);
246    }
247    let Some(prefix) = flag.value_prefix.as_deref() else {
248        return flag_present(tokens, &flag.name);
249    };
250    // space form: `NAME VALUE`, VALUE starting with the prefix
251    tokens.windows(2).any(|w| w[0].as_str() == flag.name && w[1].as_str().starts_with(prefix))
252        // glued form: `NAME=VALUE`, VALUE starting with the prefix
253        || tokens.iter().any(|t| {
254            t.as_str()
255                .strip_prefix(flag.name.as_str())
256                .and_then(|r| r.strip_prefix('='))
257                .is_some_and(|v| v.starts_with(prefix))
258        })
259}
260
261/// Whether a boolean flag is AFFIRMATIVELY enabled: bare `--flag`, or `--flag=<truthy>`. A
262/// `--flag=false/0/no/off` or a `--no-flag` DISABLES it (returns false), as does absence. Last
263/// occurrence wins, the CLI convention. Used by the `when_absent` escalator so a re-enabling spelling
264/// can't masquerade as the safety flag being set.
265fn flag_is_affirmatively_set(tokens: &[Token], flag: &str) -> bool {
266    let neg = format!("--no-{}", flag.trim_start_matches('-'));
267    let mut set = false;
268    for t in tokens {
269        let s = t.as_str();
270        if s == flag {
271            set = true;
272        } else if let Some(v) = s.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
273            set = !matches!(v.to_ascii_lowercase().as_str(), "false" | "0" | "no" | "off" | "");
274        } else if s == neg {
275            set = false;
276        }
277    }
278    set
279}
280
281fn walk_to_profiled_sub(remaining: &[Token], kind: &'static DispatchKind) -> Option<&'static types::SubSpec> {
282    let subs = match kind {
283        DispatchKind::Branching { subs, .. } | DispatchKind::Custom { subs, .. } => subs,
284        _ => return None,
285    };
286    let arg = remaining.first()?;
287    let sub = subs.iter().find(|s| s.name == arg.as_str())?;
288    // Deepest profiled match wins: a nested action's profile overrides its resource sub's.
289    walk_to_profiled_sub(&remaining[1..], &sub.kind).or_else(|| sub.profile.is_some().then_some(sub))
290}
291
292/// Like `walk_to_profiled_sub`, but also returns the tokens AFTER the matched sub's name — the
293/// operands the engine still needs to inspect (the destination positional, for `network_destination`).
294fn walk_to_profiled_sub_rest<'a>(remaining: &'a [Token], kind: &'static DispatchKind) -> Option<(&'static types::SubSpec, &'a [Token])> {
295    let subs = match kind {
296        DispatchKind::Branching { subs, .. } | DispatchKind::Custom { subs, .. } => subs,
297        _ => return None,
298    };
299    let arg = remaining.first()?;
300    let sub = subs.iter().find(|s| s.name == arg.as_str())?;
301    let rest = &remaining[1..];
302    walk_to_profiled_sub_rest(rest, &sub.kind).or_else(|| sub.profile.is_some().then_some((sub, rest)))
303}
304
305/// For a profiled sub declaring `network_destination`, the first positional after it — the send
306/// TARGET (`git push origin` → `origin`; bare `git push` → `None`, the configured default). `None`
307/// (outer) when the resolved sub does not classify a destination. The engine maps the token's
308/// PROVENANCE onto `locus.provenance` (`resolve::destination_provenance`).
309///
310/// "First non-`-` token" is a heuristic: a VALUED flag's value sitting before the target
311/// (`git push -o $VAR origin`) is read as the destination. That only ever misreads CONSERVATIVELY —
312/// a stray value classifies to `literal`/`opaque` (equal-or-stricter than the real `established`
313/// target), never looser — so it can over-deny a rare form but never under-approve.
314pub(crate) fn sub_destination_token(tokens: &[Token]) -> Option<Option<&str>> {
315    let cmd = canonical_name(tokens.first()?.command_name());
316    let spec = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd))?;
317    let (sub, rest) = walk_to_profiled_sub_rest(&tokens[1..], &spec.kind)?;
318    if !sub.network_destination {
319        return None;
320    }
321    // A destination-carrying flag (`git push --repo=<dest>`) OVERRIDES the positional — else a
322    // `--repo=ext::sh` RCE would slip past a benign positional (`origin`). Scanned across the whole
323    // line since the flag may sit anywhere.
324    if let Some(flag) = sub.destination_flag.as_deref()
325        && let Some(v) = flag_value(tokens, flag)
326    {
327        return Some(Some(v));
328    }
329    Some(rest.iter().map(Token::as_str).find(|t| !t.starts_with('-')))
330}
331
332/// A flag's value, but only from the FLAG REGION — tokens after a `--` terminator are positionals,
333/// not flags.
334///
335/// Used where finding a value makes the classification MORE PERMISSIVE, which is where a
336/// disagreement with the tool costs something. `presents_unlisted_flag` already stops at `--`, so
337/// without this the two layers disagreed: `put-item ... -- --endpoint-url http://localhost:8000`
338/// passed admission (the walk never saw the flag) AND localized (the value lookup did), classifying
339/// a call to the real service as a write to this machine.
340///
341/// The restrictive lookups deliberately do NOT use this. For `output_path_flags`, finding a path
342/// ADDS a path-gated write capability, so scanning past `--` errs toward more gating; switching
343/// them here would relax them. Each direction fails closed, which is why the asymmetry stands.
344fn flag_value_in_flag_region<'a>(tokens: &'a [Token], flag: &str) -> Option<&'a str> {
345    let end = tokens.iter().position(|t| t.as_str() == "--").unwrap_or(tokens.len());
346    flag_value(&tokens[..end], flag)
347}
348
349/// A flag's value, glued (`--repo=VALUE`) or space-separated (`--repo VALUE`); `None` if absent.
350/// Scans the WHOLE token list — see `flag_value_in_flag_region` for the `--`-aware variant and why
351/// only the permissive callers use it.
352fn flag_value<'a>(tokens: &'a [Token], flag: &str) -> Option<&'a str> {
353    if let Some(v) = tokens.iter().find_map(|t| t.as_str().strip_prefix(flag).and_then(|r| r.strip_prefix('='))) {
354        return Some(v);
355    }
356    tokens.windows(2).find(|w| w[0].as_str() == flag).map(|w| w[1].as_str())
357}
358
359/// For a profiled `data-export` sub declaring `output_path_flags`, the output-file PATH one of them
360/// carries — or `None` when the export streams to stdout (no output flag present). The engine adds a
361/// path-gated write capability at this path's locus, so a dump to `/etc/cron.d/job` gates on locus
362/// exactly as a redirect there would. `None` (outer) when the resolved sub declares none.
363///
364/// Values are matched in every spelling the flag admits: `--file=X`, `--file X`, `-f X`, `-f=X`, and
365/// the glued short form `-fX` — the last mustn't be a bypass (`-f/etc/cron.d/job` reaching a system
366/// path would otherwise drop the write cap and auto-approve). A bare `-f` with no value is a
367/// malformed invocation the tool itself rejects, so a `None` there is harmless.
368/// Whether this invocation's declared endpoint flag names THIS machine, so `resolve` should clear
369/// the facets the destination determines. `false` covers "no endpoint flag", "points elsewhere",
370/// and "the sub never opted in".
371///
372/// Returning `false` for a non-loopback value is what keeps this independent of the admission
373/// check: even if `presents_unlisted_flag` were bypassed, the remote facets would still be the ones
374/// that classify.
375pub(crate) fn sub_loopback_localizes(tokens: &[Token]) -> bool {
376    let Some(cmd) = tokens.first().map(|t| canonical_name(t.command_name())) else {
377        return false;
378    };
379    let Some(spec) = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd)) else {
380        return false;
381    };
382    let Some((sub, _rest)) = walk_to_profiled_sub_rest(&tokens[1..], &spec.kind) else {
383        return false;
384    };
385    sub.loopback_effect == types::LoopbackEffect::Localizes
386        && sub
387            .loopback_valued
388            .iter()
389            .filter_map(|f| flag_value_in_flag_region(tokens, f))
390            .any(crate::netloc::is_loopback)
391}
392
393pub(crate) fn sub_output_path_token(tokens: &[Token]) -> Option<&str> {
394    let cmd = canonical_name(tokens.first()?.command_name());
395    let spec = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd))?;
396    let (sub, _rest) = walk_to_profiled_sub_rest(&tokens[1..], &spec.kind)?;
397    sub.output_path_flags.iter().find_map(|f| output_flag_value(tokens, f))
398}
399
400/// `flag_value`, plus the glued short form `-fVALUE` (a two-char `-x` flag with the value fused on).
401/// Kept separate from `flag_value` because a glued short is only unambiguous for the single-letter
402/// output flags this classifies (`-f`, `-r`); the destination-flag path (`--repo`) never needs it.
403fn output_flag_value<'a>(tokens: &'a [Token], flag: &'a str) -> Option<&'a str> {
404    if let Some(v) = flag_value(tokens, flag) {
405        return Some(v);
406    }
407    if flag.len() == 2 && flag.starts_with('-') && !flag.starts_with("--") {
408        return tokens.iter().find_map(|t| t.as_str().strip_prefix(flag).filter(|r| !r.is_empty()));
409    }
410    None
411}
412
413/// Whether `flag` appears anywhere in `tokens` — bare (`--force`), glued (`--flag=v`), or, for a SHORT
414/// single-char flag (`-d`), hidden inside a short CLUSTER (`-da`, `-vd`) for tools that combine short
415/// options. A flag is an escalator wherever it sits, so this scans the whole line. The flag ALLOWLIST
416/// cluster-expands, so this must too — otherwise a cluster admits the command while the classifier
417/// misses the dangerous flag inside it (a classifier/allowlist disagreement; a live bypass for any
418/// clustering tool with a classifying short flag).
419fn flag_present(tokens: &[Token], flag: &str) -> bool {
420    // A `-X` short flag (exactly two chars, leading `-`) can appear in a cluster; `--long` cannot.
421    let short_char = (flag.len() == 2 && flag.as_bytes()[0] == b'-').then(|| flag.as_bytes()[1]);
422    tokens.iter().any(|t| {
423        let s = t.as_str();
424        if s == flag || s.strip_prefix(flag).is_some_and(|rest| rest.starts_with('=')) {
425            return true;
426        }
427        // Short cluster `-<letters>` (single dash, not `--`): scan only the boolean-letter run BEFORE
428        // any `=value`, so a glued value (`-o=decrypted`) can't spuriously match the flag char.
429        matches!(short_char, Some(c)
430            if s.len() > 1 && s.as_bytes()[0] == b'-' && s.as_bytes()[1] != b'-'
431            && s[1..].split('=').next().is_some_and(|run| run.as_bytes().contains(&c)))
432    })
433}
434
435pub fn toml_command_names() -> Vec<&'static str> {
436    TOML_REGISTRY.keys().map(|k| k.as_str()).collect()
437}
438
439/// EVERY declared stdout claim in the registry, command-level and sub-level alike, each paired with
440/// the invocation scope that carries it (`"fd"`, `"git diff"`).
441///
442/// The structural guards over `[command.output]` enumerate this rather than `toml_command_names`,
443/// so a sub-scoped claim is held to the same bar as a command-scoped one. Enumerating only commands
444/// would have let every `[command.sub.output]` ship unprobed — the claim is transitive (it widens
445/// whatever consumes the substitution), so an unguarded one is the worst kind to add.
446#[cfg(test)]
447pub(crate) fn output_claims() -> Vec<(String, &'static crate::registry::types::OutputSpec)> {
448    fn walk(prefix: &str, kind: &'static DispatchKind, out: &mut Vec<(String, &'static crate::registry::types::OutputSpec)>) {
449        let subs = match kind {
450            DispatchKind::Branching { subs, .. } | DispatchKind::Custom { subs, .. } => subs,
451            _ => return,
452        };
453        for sub in subs {
454            let scope = format!("{prefix} {}", sub.name);
455            if let Some(o) = sub.output.as_ref() {
456                out.push((scope.clone(), o));
457            }
458            walk(&scope, &sub.kind, out);
459        }
460    }
461
462    let mut out = Vec::new();
463    for (name, spec) in TOML_REGISTRY.iter() {
464        if let Some(o) = spec.output.as_ref() {
465            out.push((name.clone(), o));
466        }
467        walk(name, &spec.kind, &mut out);
468    }
469    out
470}
471
472/// Every command's canonical name with its declared `examples_safe` / `examples_denied`
473/// — the corpus the engine's never-looser corpus gate runs against.
474#[cfg(test)]
475pub(crate) fn corpus_examples() -> Vec<(&'static str, &'static [String], &'static [String])> {
476    TOML_REGISTRY
477        .iter()
478        .map(|(name, spec)| (name.as_str(), spec.examples_safe.as_slice(), spec.examples_denied.as_slice()))
479        .collect()
480}
481
482/// Look up `cmd_name`'s TOML-declared subs (set via `[[command.sub]]`
483/// blocks alongside `handler = "..."`) and dispatch the one whose name
484/// matches `tokens[1]`. Returns `None` if no sub matched, so the
485/// handler can fall through to its fallback grammar (or deny).
486pub fn try_sub_dispatch(cmd_name: &str, tokens: &[Token]) -> Option<Verdict> {
487    let spec = handler_spec(cmd_name)?;
488    let DispatchKind::Custom { subs, .. } = &spec.kind else {
489        return None;
490    };
491    let arg = tokens.get(1)?.as_str();
492    let sub = subs.iter().find(|s| s.name == arg)?;
493    Some(dispatch::dispatch_sub_kind(&tokens[1..], &sub.kind))
494}
495
496/// Apply `cmd_name`'s TOML-declared `[command.fallback]` grammar.
497/// Returns `None` if no fallback is declared.
498pub fn try_fallback_grammar(cmd_name: &str, tokens: &[Token]) -> Option<Verdict> {
499    let spec = handler_spec(cmd_name)?;
500    let DispatchKind::Custom { fallback, .. } = &spec.kind else {
501        return None;
502    };
503    let f = fallback.as_ref()?;
504    Some(dispatch::dispatch_fallback(tokens, f))
505}
506
507/// The flag vocabulary a handler-dispatched command keeps in `[command.fallback]`, for handlers
508/// that walk their own grammar and only need the SETS.
509///
510/// find is the case this exists for: its expression walk is genuine logic (a valued primary
511/// consumes its value, `-newer*` matches by prefix, `-exec`/`-delete` delegate against the
512/// traversal bases), but the two vocabularies it consults are flag lists, and flag lists belong in
513/// TOML. Returning the slices rather than a verdict keeps the walk in the handler where it belongs
514/// while the DATA lives in one place — which is the whole point: there is no second copy to drift
515/// from, because the `WordSet` constants were deleted rather than kept in sync.
516///
517/// Linear scan, and deliberately so: `impl FlagSet for [String]` is `iter().any(..)`, so unlike
518/// `WordSet` there is no sorted-order precondition to preserve. Ordering travels with the data
519/// structure, not the data.
520pub(crate) fn fallback_flag_sets(cmd_name: &str) -> Option<(&'static [String], &'static [String])> {
521    let spec = handler_spec(cmd_name)?;
522    let DispatchKind::Custom { fallback, .. } = &spec.kind else {
523        return None;
524    };
525    let f = fallback.as_ref()?;
526    Some((f.policy.standalone.as_slice(), f.policy.valued.as_slice()))
527}
528
529/// Dispatch `tokens` against `cmd_name`'s `[[command.matrix]]`
530/// blocks. Looks at `tokens[1]` (parent) and `tokens[2]` (action),
531/// finds the first matrix whose `parents` contains the parent and
532/// whose `actions` map contains the action, then validates
533/// `tokens[2..]` against the named policy (and a guard flag if the
534/// matrix entry declared one). Returns `None` if no matrix matched —
535/// the handler can then fall through to its remaining special cases
536/// or deny.
537pub fn try_matrix_dispatch(cmd_name: &str, tokens: &[Token]) -> Option<Verdict> {
538    let spec = handler_spec(cmd_name)?;
539    let DispatchKind::Custom { matrices, handler_policies, .. } = &spec.kind else {
540        return None;
541    };
542    let parent = tokens.get(1)?.as_str();
543    let action = tokens.get(2)?.as_str();
544    for matrix in matrices {
545        if !matrix.parents.iter().any(|p| p == parent) {
546            continue;
547        }
548        let Some(action_spec) = matrix.actions.get(action) else {
549            continue;
550        };
551        if let Some(long) = action_spec.guard.as_deref()
552            && !crate::parse::has_flag(&tokens[2..], action_spec.guard_short.as_deref(), Some(long))
553        {
554            return Some(Verdict::Denied);
555        }
556        let Some(policy) = handler_policies.get(&action_spec.policy_key) else {
557            return Some(Verdict::Denied);
558        };
559        return Some(dispatch::dispatch_matrix_action(&tokens[2..], policy, matrix.level));
560    }
561    None
562}
563
564/// Validate `tokens` against `cmd_name`'s named flag policy declared
565/// in a `[command.handler_policy.KEY]` block. Returns `false` if no
566/// such policy is declared or the tokens fail it. Used by handlers
567/// whose dispatch logic genuinely can't move to TOML (e.g. gh's
568/// sub × action matrix) but whose per-policy WordSets should live
569/// in TOML rather than as Rust `WordSet` constants.
570pub fn check_handler_policy(cmd_name: &str, key: &str, tokens: &[Token]) -> bool {
571    let Some(spec) = handler_spec(cmd_name) else {
572        return false;
573    };
574    let DispatchKind::Custom { handler_policies, .. } = &spec.kind else {
575        return false;
576    };
577    let Some(policy) = handler_policies.get(key) else {
578        return false;
579    };
580    dispatch::check_handler_policy_owned(tokens, policy)
581}
582
583fn handler_spec(cmd_name: &str) -> Option<&'static CommandSpec> {
584    CUSTOM_REGISTRY.get(cmd_name).or_else(|| TOML_REGISTRY.get(cmd_name))
585}
586
587/// Returns true iff this invocation is tagged eval-safe — meaning its
588/// stdout is documented shell-init code that can safely be substituted
589/// inside `eval "$(...)"`.
590///
591/// The walker descends through `DispatchKind::Branching` AND
592/// `DispatchKind::Custom` matching subs token-by-token (handler-based
593/// commands such as `gh` can have tagged TOML-declared subs even though
594/// the handler does the actual dispatch). The leaf is the deepest matched
595/// node (where no further sub matches). `eval_safe` is checked only at
596/// the leaf — ancestor tags do NOT propagate. After confirming the leaf
597/// is tagged, every `-`-prefixed token in the remaining tail must appear
598/// in `eval_safe_flags`; positionals are unrestricted.
599///
600/// Tagged nodes are vetted manually per-command (see SAMPLE.toml). This
601/// function does not validate that `tokens` is syntactically allowed —
602/// callers must have already passed it through the regular dispatcher.
603pub fn is_eval_safe_invocation(tokens: &[Token]) -> bool {
604    if tokens.is_empty() {
605        return false;
606    }
607    let cmd = tokens[0].command_name();
608    let Some(spec) = CUSTOM_REGISTRY.get(cmd).or_else(|| TOML_REGISTRY.get(cmd)) else {
609        return false;
610    };
611    is_eval_safe_for_spec(spec, tokens)
612}
613
614/// Spec-local variant used by tests so they can build a `CommandSpec`
615/// via `load_toml` and exercise the walker without touching the global
616/// `TOML_REGISTRY`.
617pub(crate) fn is_eval_safe_for_spec(spec: &CommandSpec, tokens: &[Token]) -> bool {
618    if tokens.is_empty() {
619        return false;
620    }
621    walk_to_eval_safe_leaf(
622        &tokens[1..],
623        &spec.kind,
624        spec.eval_safe,
625        &spec.eval_safe_flags,
626        &spec.eval_safe_flag_values,
627        &spec.eval_safe_required_flags,
628    )
629}
630
631fn walk_to_eval_safe_leaf(
632    remaining: &[Token],
633    kind: &DispatchKind,
634    eval_safe: bool,
635    eval_safe_flags: &[String],
636    eval_safe_flag_values: &std::collections::HashMap<String, Vec<String>>,
637    eval_safe_required_flags: &[String],
638) -> bool {
639    let subs_opt = match kind {
640        DispatchKind::Branching { subs, .. } | DispatchKind::Custom { subs, .. } => Some(subs),
641        _ => None,
642    };
643    if let Some(subs) = subs_opt
644        && let Some(arg) = remaining.first()
645        && let Some(sub) = subs.iter().find(|s| s.name == arg.as_str())
646    {
647        return walk_to_eval_safe_leaf(
648            &remaining[1..],
649            &sub.kind,
650            sub.eval_safe,
651            &sub.eval_safe_flags,
652            &sub.eval_safe_flag_values,
653            &sub.eval_safe_required_flags,
654        );
655    }
656    if !eval_safe {
657        return false;
658    }
659    let mut i = 0;
660    let mut seen_required = false;
661    while i < remaining.len() {
662        let s = remaining[i].as_str();
663        if !s.starts_with('-') {
664            i += 1;
665            continue;
666        }
667        let (bare, eq_value) = match s.split_once('=') {
668            Some((k, v)) => (k, Some(v)),
669            None => (s, None),
670        };
671        if !eval_safe_flags.iter().any(|f| f == bare) {
672            return false;
673        }
674        if eval_safe_required_flags.iter().any(|f| f == bare) {
675            seen_required = true;
676        }
677        if let Some(allowed) = eval_safe_flag_values.get(bare) {
678            // Valued flag declared in eval_safe_flag_values. The value
679            // arrives either as `--flag=VALUE` (eq_value is Some) or as
680            // the next token (`--flag VALUE`); either way the walker
681            // consumes it because a flag in eval_safe_flag_values is
682            // structurally valued.
683            //
684            // `allowed` empty = explicit-unrestricted: contributor
685            // vetted that any bare-literal value preserves shell-init
686            // output. Non-empty = value must appear in the allowlist.
687            let value: &str = if let Some(v) = eq_value {
688                v
689            } else if let Some(next) = remaining.get(i + 1) {
690                let v = next.as_str();
691                i += 1;
692                v
693            } else {
694                return false;
695            };
696            // Empty value is denied even under the explicit-
697            // unrestricted (`= []`) posture: `--flag=` and an empty
698            // following token never represent a meaningful tool
699            // argument. The bare-literal alphabet check is per-char
700            // and vacuously passes empty strings, so the walker
701            // has to reject explicitly.
702            if value.is_empty() {
703                return false;
704            }
705            if !allowed.is_empty() && !allowed.iter().any(|av| av == value) {
706                return false;
707            }
708        }
709        i += 1;
710    }
711    if !eval_safe_required_flags.is_empty() && !seen_required {
712        return false;
713    }
714    true
715}
716
717pub fn toml_command_docs() -> Vec<crate::docs::CommandDoc> {
718    TOML_REGISTRY
719        .iter()
720        .filter(|(key, spec)| *key == &spec.name)
721        .map(|(_, spec)| spec.to_command_doc())
722        .collect()
723}
724
725#[cfg(test)]
726mod gate_consistency;
727#[cfg(test)]
728mod tests;