Skip to main content

safe_chains/registry/
types.rs

1use serde::Deserialize;
2
3use crate::verdict::SafetyLevel;
4
5#[path = "types_tables.rs"]
6mod tables;
7pub(super) use tables::*;
8
9#[derive(Debug, Deserialize)]
10pub(super) struct TomlFile {
11    // Defaulted so a user config that contains only `[[trusted]]` (the repo-pin
12    // list, parsed separately) is valid. Unknown tables like `[[trusted]]` are
13    // ignored here.
14    #[serde(default)]
15    pub command: Vec<TomlCommand>,
16}
17
18#[derive(Debug, Deserialize)]
19pub(super) struct TomlCommand {
20    pub name: String,
21    #[serde(default)]
22    pub description: Option<String>,
23    #[serde(default)]
24    pub candidate: Option<bool>,
25    #[serde(default)]
26    pub aliases: Vec<String>,
27    #[serde(default)]
28    pub url: String,
29    #[serde(default)]
30    pub level: Option<TomlLevel>,
31    #[serde(default)]
32    pub bare: Option<bool>,
33    #[serde(default)]
34    pub max_positional: Option<usize>,
35    /// This command PUTS ITS `NAME=VALUE` POSITIONALS INTO THE ENVIRONMENT (`export`, `declare -x`).
36    /// Each one is classified through `envvars.toml`, exactly as the `VAR=value cmd` prefix form is,
37    /// so the two spellings of one capability cannot disagree.
38    #[serde(default)]
39    pub env_assignment_positionals: Option<bool>,
40    /// Removed in favor of `tolerate_unknown_short` / `tolerate_unknown_long`.
41    /// Build panics if any TOML still sets this — see SAMPLE.toml for the
42    /// migration guidance. Kept on the deserializer struct so the panic
43    /// message can name the offending command instead of a serde error.
44    #[serde(default)]
45    pub positional_style: Option<bool>,
46    #[serde(default)]
47    pub tolerate_unknown_short: Option<bool>,
48    #[serde(default)]
49    pub tolerate_unknown_long: Option<bool>,
50    #[serde(default)]
51    pub numeric_dash: Option<bool>,
52    #[serde(default)]
53    pub standalone: Vec<String>,
54    #[serde(default)]
55    pub valued: Vec<String>,
56    /// Flags that take a value OPTIONALLY: `--long` and `--long=27` are both real spellings.
57    /// See `build_policy` for why this compiles down to the other two lists rather than adding
58    /// a state to the walk.
59    #[serde(default)]
60    pub optional_valued: Vec<String>,
61    #[serde(default)]
62    pub bare_flags: Vec<String>,
63    #[serde(default)]
64    pub sub: Vec<TomlSub>,
65    #[serde(default)]
66    pub handler: Option<String>,
67    #[serde(default)]
68    pub doc_body: Option<String>,
69    #[serde(default)]
70    pub require_any: Vec<String>,
71    #[serde(default)]
72    pub first_arg: Vec<String>,
73    /// Flags a `first_arg` GLOB family accepts. The glob admits an invocation on its first
74    /// positional alone, so without these it never examines the flags at all and
75    /// `--endpoint-url http://evil.com` rides along on a read. Empty = family not yet researched
76    /// (permissive, grandfathered — see `no_new_unresearched_first_arg_family`).
77    #[serde(default)]
78    pub first_arg_standalone: Vec<String>,
79    #[serde(default)]
80    pub first_arg_valued: Vec<String>,
81    /// Flags admitted only when their VALUE names this machine (`--endpoint-url
82    /// http://localhost:8000`). Same arity as `first_arg_valued`; the value is classified by
83    /// `netloc::is_loopback`, and anything not positively recognized as loopback denies.
84    #[serde(default)]
85    pub first_arg_loopback_valued: Vec<String>,
86    #[serde(default)]
87    pub credential_first_arg: Vec<String>,
88    /// Top-level classifying flags (`[[command.flag]]`): a flag whose PRESENCE classifies the WHOLE
89    /// invocation as an archetype — the flat-command analog of `[[command.sub.flag]]`. For a bimodal
90    /// tool where a mode flag flips the operation: `age -d` / `sops --decrypt` reveal plaintext to the
91    /// model (`decrypt-read`), while the bare/encrypt form is an ordinary local write. Resolved by
92    /// `engine::resolve` via `registry::command_flag_archetypes`; each flag's `classifies` must name a
93    /// known archetype and carry `fact`/`source` provenance (the `assert_command_flag_provenance` guard).
94    #[serde(default)]
95    pub flag: Vec<TomlSubFlag>,
96    #[serde(default)]
97    pub wrapper: Option<TomlWrapper>,
98    #[serde(default)]
99    pub write_flags: Vec<String>,
100    /// Path-argument gate co-located with the command (`[command.path_gate]`): the read/write
101    /// role of each path-bearing flag value and of bare positionals. Consulted by
102    /// `pathgate::should_deny` so a `--output`/`-i` path can't ship ungated. Same shape as
103    /// `pathgates.toml`'s `[roles.X]`.
104    #[serde(default)]
105    pub path_gate: Option<crate::pathgate::RoleSpec>,
106    #[serde(default)]
107    pub researched_version: Option<String>,
108    /// Sample invocations that double as test fixtures.
109    /// `examples_safe` must validate as Allowed; `examples_denied` must validate as Denied.
110    /// Use these to exercise aliases and canonical forms (e.g. `mise use` and `mise u`)
111    /// so drift between the TOML and runtime dispatch fails the test suite.
112    #[serde(default)]
113    pub examples_safe: Vec<String>,
114    #[serde(default)]
115    pub examples_denied: Vec<String>,
116    /// Marks this command's leaf invocation as safe inside
117    /// `eval "$(CMD ...)"`. Set on flat commands whose stdout is documented
118    /// shell-init code (e.g. `ssh-agent`). The leaf is the deepest matched
119    /// dispatch node — tagging here does NOT propagate to subs; each sub
120    /// must be tagged independently. Unset = not eval-safe (the default).
121    #[serde(default)]
122    pub eval_safe: Option<bool>,
123    /// Flag allowlist that extends `eval_safe = true` — these `-`-prefixed
124    /// tokens are also permitted inside the substitution. Default empty,
125    /// meaning only the bare form plus positionals are eval-safe.
126    /// Build panics if this is set without `eval_safe = true`.
127    #[serde(default)]
128    pub eval_safe_flags: Vec<String>,
129    /// Per-valued-flag value allowlist. Maps each valued flag (which
130    /// MUST also appear in `eval_safe_flags`) to the set of values
131    /// permitted in eval substitutions. Use for tools where the flag's
132    /// value determines stdout shape (`aws --format env` vs
133    /// `--format json`). Default empty = no value restriction beyond
134    /// the bare-literal alphabet.
135    #[serde(default)]
136    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
137    /// Flags where AT LEAST ONE must appear in the eval substitution.
138    /// Use for tools whose bare invocation isn't shell-init code:
139    /// `fzf` is interactive without `--bash|--zsh|--fish|--nushell`.
140    /// Every entry must also appear in `eval_safe_flags`. Default
141    /// empty = no required flags (bare invocation is fine).
142    #[serde(default)]
143    pub eval_safe_required_flags: Vec<String>,
144    /// Shortcut: every invocation of this command is denied. Used in custom
145    /// TOMLs to lock down a built-in (e.g. `name = "gh", deny = true` in
146    /// `.safe-chains.toml` denies every gh form for that project).
147    #[serde(default)]
148    pub deny: Option<bool>,
149    /// Alternate grammar engaged when standard sub-dispatch finds no match.
150    /// Only meaningful for handler-using commands (e.g. tilt's Ruby template
151    /// engine fallback when no Kubernetes tilt sub matches). The handler is
152    /// responsible for invoking it via `registry::try_fallback_grammar()`.
153    #[serde(default)]
154    pub fallback: Option<TomlFallback>,
155    /// Named flag policies the handler references by string key. Used when
156    /// a handler's dispatch logic genuinely can't move to TOML (e.g. gh's
157    /// sub × action matrix) but the per-policy WordSets are still data that
158    /// should live in TOML. The handler reads them via
159    /// `registry::check_handler_policy(cmd, key, tokens)`.
160    #[serde(default)]
161    pub handler_policy: std::collections::HashMap<String, TomlHandlerPolicy>,
162    /// Parent × action → policy matrices. One block declares: "for
163    /// these parent subcommand names, each of these action verbs maps
164    /// to a named `handler_policy` and validates at this safety level."
165    /// Lets handlers express their dispatch tables as data instead of
166    /// `match` arms. Walked by `registry::try_matrix_dispatch()`.
167    #[serde(default)]
168    pub matrix: Vec<TomlMatrix>,
169    /// A `verb-chain` grammar (`mlr`): a strict main-flag region followed by a
170    /// `then`-chain of allowlisted verbs. Fully declarative — no handler needed.
171    #[serde(default)]
172    pub verb_chain: Option<TomlVerbChain>,
173    /// Declarative facet behavior (`[command.behavior]`) — the non-legacy classification
174    /// path. When present, the engine resolves this command by building a `Profile` from the
175    /// declared operation + operand-role + flags (see `engine::resolve::resolve_behavior`),
176    /// retiring a hardcoded `RESOLVERS` entry. The legacy `level` remains only as the
177    /// fallback the engine already overrides.
178    #[serde(default)]
179    pub behavior: Option<TomlBehavior>,
180    /// What this command's STDOUT can name (`[command.output]`) — the axis that decides whether a
181    /// `$(…)` around it yields a bounded path or an unknowable one. Absent (the default) means
182    /// unpinnable: the substitution worst-cases exactly as it always has. See
183    /// docs/design/behavioral-taxonomy-substitution-locus.md.
184    #[serde(default)]
185    pub output: Option<TomlOutput>,
186    /// What this command writes in its folder without naming a path; as on a sub.
187    #[serde(default)]
188    pub writes_cwd: Option<String>,
189    #[serde(default)]
190    pub output_dirs: Vec<String>,
191}
192
193/// `[command.output]` — a researched claim about where this command's stdout can POINT. It is a
194/// separate axis from every facet: how safe a command is to RUN says nothing about what its output
195/// names (`echo` is inert and `$(echo /etc/shadow)` names a credential file), so this is declared
196/// per command or not at all.
197#[derive(Debug, Deserialize)]
198#[serde(deny_unknown_fields)]
199pub(super) struct TomlOutput {
200    /// `operands` — output names paths beneath the command's own path operands (`fd`, `git
201    /// ls-files`); `cwd` — output names the working directory (`pwd`).
202    pub locus_from: String,
203    /// Flags under which the claim does NOT hold, because they change what stdout CONTAINS.
204    /// `fd -x cat {}` prints file contents rather than paths, and `fd -l` prints `ls -l` rows —
205    /// neither is a path any more, so the locus rule cannot describe them. Any of these present
206    /// makes the substitution unpinnable again.
207    #[serde(default)]
208    pub invalidated_by: Vec<String>,
209    /// Value-taking flags, so their VALUE is not mistaken for an operand (`head -n 5` takes no
210    /// file). Carried here rather than read off the legacy top-level `valued` for the same reason
211    /// `[command.behavior]` carries its own grammar: this claim is researched as a unit, and a
212    /// silently-shared list would let an unrelated edit change what counts as a root.
213    #[serde(default)]
214    pub valued: Vec<String>,
215    /// Flags of which at least ONE must be present, or the claim does not hold. The inverse of
216    /// `invalidated_by`, and needed by any command whose default output is not paths at all:
217    /// `git diff` prints a PATCH, and only `--name-only` turns it into a list of paths. Empty
218    /// (the normal case) means the claim holds for the bare invocation.
219    #[serde(default)]
220    pub requires: Vec<String>,
221}
222
223/// A command's declarative facet behavior (`[command.behavior]`). Field values that name a
224/// facet term are the kebab strings from `engine::facet` (`operation = "observe"`); the build
225/// maps them via `FacetTerm::from_term` and PANICS (naming the command) on an unknown term, so
226/// a typo can't silently mis-classify. The behavior carries its OWN flag grammar
227/// (`standalone`/`valued`), independent of the legacy top-level `standalone` — `rm`'s legacy
228/// flag set is restricted to `--help`/`--version` (so the legacy fallback can't fail-open on
229/// `rm -rf`), while its behavior grammar is the full destructive set.
230#[derive(Debug, Deserialize)]
231#[serde(deny_unknown_fields)]
232pub(super) struct TomlBehavior {
233    /// The act each operand capability performs — an `Operation` term
234    /// (`observe`/`create`/`mutate`/`destroy`/…).
235    pub operation: String,
236    /// How bare positionals are touched: `none` | `read` | `write` | `pattern-then-read` |
237    /// `transfer` (the closed set the `Operands` enum encodes).
238    pub positionals: String,
239    /// Scale model: `single` (every read is one item — cat/head) or `breadth`
240    /// (count/glob/recursion widen it — rm/mkdir). Defaults to `single`.
241    #[serde(default)]
242    pub scale: Option<String>,
243    /// Boolean flags this command accepts (behavior's own grammar). Single-dash single-char
244    /// tokens (`-r`) cluster; `--long` tokens are matched whole.
245    #[serde(default)]
246    pub standalone: Vec<String>,
247    /// Value-taking flags (consume the next token or a glued `=value`).
248    #[serde(default)]
249    pub valued: Vec<String>,
250    /// Accept the obsolete `-NUM` count shorthand (`head -20`).
251    #[serde(default)]
252    pub numeric_shorthand: Option<bool>,
253    /// Per-flag facet deltas — a flag whose presence widens scale (`"-r" = { scale =
254    /// "unbounded" }`), consumes a path value, or supplies a pattern.
255    #[serde(default)]
256    pub flags: std::collections::HashMap<String, TomlBehaviorFlag>,
257    /// Thin custom hook for the irreducible token logic a declaration can't express
258    /// (`grep`'s pattern-vs-file disambiguation). Composes: it returns the classified operand
259    /// set; the facets + level projection stay declarative. Absent = pure declarative.
260    #[serde(default)]
261    pub hook: Option<String>,
262    /// Transfer semantics (`[command.behavior.transfer]`), REQUIRED when `positionals =
263    /// "transfer"` — the source-operand operation and the clobber/recursion flag sets that a
264    /// `cp`/`mv`/`ln`-shaped command differs on.
265    #[serde(default)]
266    pub transfer: Option<TomlTransfer>,
267}
268
269/// The differing knobs of a transfer command (`cp`/`mv`/`ln`): every source operand is read at
270/// its own locus and the destination is a create/overwrite at its locus, but the source
271/// *operation* and the clobber/recursion flags differ per command.
272#[derive(Debug, Deserialize)]
273#[serde(deny_unknown_fields)]
274pub(super) struct TomlTransfer {
275    /// The source-operand operation: `observe` (cp/ln read the source into the dest/link) or
276    /// `relocate` (mv removes the source from its old location).
277    pub source: String,
278    /// True when the destination operand is REBOUND rather than written through: `ln` points the
279    /// destination name at something else. Defaults false (cp/mv write bytes at the destination).
280    #[serde(default)]
281    pub rebinds_destination: bool,
282    /// Flags whose PRESENCE means the destination will not be overwritten (`cp`/`mv`: `-n`,
283    /// `--no-clobber`). Mutually exclusive with `clobber_flags`.
284    #[serde(default)]
285    pub no_clobber_flags: Vec<String>,
286    /// Flags whose PRESENCE means the destination WILL be overwritten, the default being
287    /// no-clobber (`ln`: `-f`, `--force`). Mutually exclusive with `no_clobber_flags`.
288    #[serde(default)]
289    pub clobber_flags: Vec<String>,
290    /// Flags whose presence widens the scale to unbounded (`cp`: `-r`/`-R`/`-a`).
291    #[serde(default)]
292    pub recursive_flags: Vec<String>,
293}
294
295/// One flag's contribution to a `[command.behavior]` profile: a scale bump when present, and/or
296/// a path role on the flag's VALUE (a valued flag whose value is a path safe-chains must gate,
297/// e.g. `touch -r REF` reads REF's timestamp — folds the `[command.path_gate]` idea into behavior).
298#[derive(Debug, Deserialize)]
299#[serde(deny_unknown_fields)]
300pub(super) struct TomlBehaviorFlag {
301    /// Scale bump when present (`"-r" = { scale = "unbounded" }`).
302    #[serde(default)]
303    pub scale: Option<String>,
304    /// The flag's VALUE is a path with this role: `read` (gated by its read locus) or `write`
305    /// (gated by its write locus). The flag must be a valued flag (in `valued`).
306    #[serde(default)]
307    pub kind: Option<String>,
308}
309
310/// A `verb-chain` command grammar: `CMD [main-flags…] verb [args…] then verb [args…] …`
311/// (`mlr`). The main-flag region is a STRICT allowlist (an unlisted flag denies — so a
312/// mutating flag like mlr's `-I`/`--in-place`, omitted, is caught by omission); the verb
313/// region is a `then`-chain where every verb NAME must be on the `verbs` allowlist (verb
314/// ARGS are open-ended and not inspected — a pure verb has no shell/file escape).
315#[derive(Debug, Deserialize)]
316#[serde(deny_unknown_fields)]
317pub(super) struct TomlVerbChain {
318    #[serde(default)]
319    pub level: Option<TomlLevel>,
320    /// The chain separator keyword (mlr: `then`). Defaults to `then`.
321    #[serde(default)]
322    pub separator: Option<String>,
323    /// Boolean main flags (no value). SAFETY: every value-TAKING main flag must go in
324    /// `main_valued` instead, or the walk mistakes its value for the verb boundary and a
325    /// later mutating flag slips past in verb-land.
326    #[serde(default)]
327    pub main_standalone: Vec<String>,
328    /// Value-taking main flags (`--from FILE`, `--ifs ,`), each consuming the next token.
329    #[serde(default)]
330    pub main_valued: Vec<String>,
331    /// Variadic main flags (mlr `--mfrom A B …`) that consume tokens until a `--` terminator.
332    #[serde(default)]
333    pub main_variadic: Vec<String>,
334    /// The allowlist of verb names permitted in every `then`-segment.
335    #[serde(default)]
336    pub verbs: Vec<String>,
337}
338
339#[derive(Debug, Deserialize)]
340#[serde(deny_unknown_fields)]
341pub(super) struct TomlMatrix {
342    pub parents: Vec<String>,
343    pub level: TomlLevel,
344    pub actions: std::collections::HashMap<String, TomlMatrixAction>,
345}
346
347#[derive(Debug, Deserialize)]
348#[serde(untagged)]
349pub(super) enum TomlMatrixAction {
350    /// Shorthand: `list = "policy_name"` — references handler_policy by
351    /// name; no guard required.
352    Policy(String),
353    /// Detailed form: `download = { policy = "release_download", guard
354    /// = "--output", guard_short = "-O" }`. The guard flag must be
355    /// present in the action's args for the dispatch to succeed.
356    Detailed(TomlMatrixActionDetailed),
357}
358
359#[derive(Debug, Deserialize)]
360#[serde(deny_unknown_fields)]
361pub(super) struct TomlMatrixActionDetailed {
362    pub policy: String,
363    #[serde(default)]
364    pub guard: Option<String>,
365    #[serde(default)]
366    pub guard_short: Option<String>,
367}
368
369/// One `[[command.sub.flag]]`: a flag that escalates its sub's classification when present.
370#[derive(Debug, Deserialize)]
371pub(super) struct TomlSubFlag {
372    pub name: String,
373    /// The archetype (`archetypes.toml`) this flag's presence ADDS to the profile — or
374    /// `"unclassified"` to worst-case (fail-closed) a flag whose effect we can't yet name.
375    pub classifies: String,
376    /// Optional value-match: escalate only when the flag's VALUE starts with this prefix (space
377    /// form `-c core.sshCommand=…` or glued `--flag=core.sshCommand=…`). Absent = escalate on the
378    /// flag's mere PRESENCE (a bare flag like `--force`). This is what lets ONE valued flag be
379    /// benign for most values and dangerous for a specific key (`git -c core.sshCommand=` = exec).
380    #[serde(default)]
381    pub value_prefix: Option<String>,
382    /// `true` INVERTS the trigger: escalate when the flag is ABSENT, not present. For a SAFETY flag
383    /// whose absence is the risk — `npm ci` runs lifecycle scripts UNLESS `--ignore-scripts` is
384    /// given, so its base profile (local-install-pinned) escalates to supply-chain-build when
385    /// `--ignore-scripts` is missing. Mutually exclusive with `value_prefix`.
386    #[serde(default)]
387    pub when_absent: Option<bool>,
388    #[serde(default)]
389    pub fact: Option<String>,
390    #[serde(default)]
391    pub source: Option<String>,
392    #[serde(default)]
393    pub judgment: Option<String>,
394}
395
396#[derive(Debug, Deserialize)]
397pub(super) struct TomlSub {
398    pub name: String,
399    /// This sub also matches with ONE arbitrary trailing `:segment`, which inherits this sub's
400    /// classification unchanged.
401    ///
402    /// For Rails' per-database rake tasks. A Rails 8 app has four databases out of the box
403    /// (solid_cache, solid_queue, solid_cable), and rake generates a variant of each schema task
404    /// per database: `db:migrate:primary`, `db:create:cache`, `db:drop:queue`. Fourteen base tasks
405    /// times four databases is 56 names, and enumerating them does not even work — the segment is
406    /// a key out of the app's own `config/database.yml`, so another app has `db:migrate:analytics`.
407    ///
408    /// Sound because the variant is strictly NARROWER than the base: `db:migrate:primary` migrates
409    /// one of the databases `db:migrate` migrates all of. Inheriting the base's classification is
410    /// therefore never a widening — `db:drop:cache` lands wherever `db:drop` already sits.
411    ///
412    /// Set it only where that containment argument holds. It is not a general "ignore the tail":
413    /// the suffix must be a single plain identifier, and a sub that means something DIFFERENT with
414    /// a suffix must keep declaring it separately.
415    #[serde(default)]
416    pub per_database: bool,
417    #[serde(default)]
418    pub candidate: Option<bool>,
419    /// A facet archetype name (`archetypes.toml`) — the Phase-1 successor to `candidate = true`:
420    /// instead of hand-marking the sub above the line, it declares which recurring capability
421    /// profile it is, and the engine DERIVES the verdict by projecting that profile through the
422    /// levels. See `docs/design/behavioral-taxonomy-archetypes.md`.
423    #[serde(default)]
424    pub profile: Option<String>,
425    /// Per-item research provenance for the classification (required when `profile` is set — the
426    /// `every_profiled_sub_has_provenance` guard). Three layers so a future researcher can act on
427    /// each precisely: `fact` = what the upstream tool DOCUMENTS (re-check `source` if it moves),
428    /// the `profile` itself = our inference (which archetype it maps to), `judgment` = our stance
429    /// where the source doesn't decide it (a policy call they may revisit). `source` cites the
430    /// upstream doc/section. See `docs/design/behavioral-taxonomy-archetypes.md` §3.
431    #[serde(default)]
432    pub fact: Option<String>,
433    #[serde(default)]
434    pub source: Option<String>,
435    #[serde(default)]
436    pub judgment: Option<String>,
437    #[serde(default)]
438    pub aliases: Vec<String>,
439    #[serde(default)]
440    pub level: Option<TomlLevel>,
441    #[serde(default)]
442    pub bare: Option<bool>,
443    #[serde(default)]
444    pub max_positional: Option<usize>,
445    /// Removed; see TomlCommand::positional_style.
446    #[serde(default)]
447    pub positional_style: Option<bool>,
448    #[serde(default)]
449    pub tolerate_unknown_short: Option<bool>,
450    #[serde(default)]
451    pub tolerate_unknown_long: Option<bool>,
452    #[serde(default)]
453    pub numeric_dash: Option<bool>,
454    #[serde(default)]
455    pub standalone: Vec<String>,
456    #[serde(default)]
457    pub valued: Vec<String>,
458    #[serde(default)]
459    pub optional_valued: Vec<String>,
460    #[serde(default)]
461    pub guard: Option<String>,
462    #[serde(default)]
463    pub guard_short: Option<String>,
464    #[serde(default)]
465    pub allow_all: Option<bool>,
466    /// Reference a `[command.handler_policy.KEY]` block by name, copying
467    /// its standalone/valued/bare/etc. into this sub's effective policy.
468    /// Lets a single-sub form (search, browse, gh status) re-use the
469    /// same flag list a matrix entry would, without duplicating the
470    /// WordSets. Mutually exclusive with inline standalone/valued.
471    #[serde(default)]
472    pub policy: Option<String>,
473    #[serde(default)]
474    pub sub: Vec<TomlSub>,
475    /// Per-FLAG escalation + provenance (`[[command.sub.flag]]`): a flag that, when present, ADDS a
476    /// capability to this sub's resolved profile — `git push --force` (→ destroy), `-c
477    /// core.sshCommand=` (→ execution). The level algebra takes the max over the added capabilities,
478    /// so a benign base + a dangerous flag lands at the flag's tier. See
479    /// `docs/design/behavioral-taxonomy-archetypes.md` §3 (per-flag layer).
480    #[serde(default)]
481    pub flag: Vec<TomlSubFlag>,
482    /// `true` marks the sub's first positional as a NETWORK DESTINATION whose *provenance* the
483    /// engine classifies onto `locus.provenance` (established remote-name / literal URL / opaque
484    /// `$VAR`), and whose command-transport form (`ext::<cmd>`) worst-cases as RCE. For
485    /// `git push` and its kin (`scp`/`rsync`/`curl -d`). See `behavioral-taxonomy-exposure.md` §4.
486    #[serde(default)]
487    pub network_destination: Option<bool>,
488    /// A flag that ALSO carries the destination and OVERRIDES the positional (`git push
489    /// --repo=<dest>`). Classified with the same provenance rules — so `--repo=ext::sh` is caught as
490    /// RCE. Requires `network_destination`.
491    #[serde(default)]
492    pub destination_flag: Option<String>,
493    /// Flags whose VALUE is a local output-file path, for a `data-export` sub (`supabase db dump
494    /// -f`, `pg_dump --file`). When one is present the engine adds a path-gated write capability at
495    /// that file's locus — a dump to `./out.sql` is a worktree write, one to `/etc/cron.d/job` a
496    /// system write. Absent (the export goes to stdout) → no write, just the bulk remote read.
497    /// Requires `profile` (only a `data-export` sub has an output file). See
498    /// `behavioral-taxonomy-exposure.md`.
499    #[serde(default)]
500    pub output_path_flags: Vec<String>,
501    /// What this sub writes in the folder it runs in without naming a path: `"output"` (with
502    /// `output_dirs`), `"source"`, or `"none"`. See `registry::cwd_writes`.
503    #[serde(default)]
504    pub writes_cwd: Option<String>,
505    #[serde(default)]
506    pub output_dirs: Vec<String>,
507    /// Flags naming a network endpoint that must be on THIS machine (`--endpoint-url
508    /// http://localhost:8000`). Two effects, both keyed on `netloc::is_loopback`: the flag is
509    /// admitted only with a loopback value, and a loopback value re-classifies the sub's
510    /// non-destroy capabilities as local (`resolve`'s loopback modifier). Declare only where a
511    /// local emulator is a researched workflow for that service.
512    #[serde(default)]
513    pub loopback_valued: Vec<String>,
514    /// Whether a `loopback_valued` flag naming this machine re-classifies the sub: `resolve` clears
515    /// the destination-determined facets (remote reach, net direction, payload, metered cost) and
516    /// leaves everything describing the operation alone. Absent = the sub keeps its remote
517    /// classification whatever the destination; mandatory for destroy archetypes, build-enforced.
518    #[serde(default)]
519    pub loopback_localizes: Option<bool>,
520    #[serde(default)]
521    pub nested_bare: Option<bool>,
522    #[serde(default)]
523    pub require_any: Vec<String>,
524    #[serde(default)]
525    pub first_arg: Vec<String>,
526    /// Flags a `first_arg` GLOB family accepts. The glob admits an invocation on its first
527    /// positional alone, so without these it never examines the flags at all and
528    /// `--endpoint-url http://evil.com` rides along on a read. Empty = family not yet researched
529    /// (permissive, grandfathered — see `no_new_unresearched_first_arg_family`).
530    #[serde(default)]
531    pub first_arg_standalone: Vec<String>,
532    #[serde(default)]
533    pub first_arg_valued: Vec<String>,
534    /// Flags admitted only when their VALUE names this machine (`--endpoint-url
535    /// http://localhost:8000`). Same arity as `first_arg_valued`; the value is classified by
536    /// `netloc::is_loopback`, and anything not positively recognized as loopback denies.
537    #[serde(default)]
538    pub first_arg_loopback_valued: Vec<String>,
539    /// First-positional globs (`secret`, `secret/*`) that make this sub a CREDENTIAL-READ: matching
540    /// denies, before the allow-glob. The value-dependent complement to `profile=credential-read`
541    /// (whole sub) — `kubectl get secret/x`, `aws configure get aws_secret_access_key`.
542    #[serde(default)]
543    pub credential_first_arg: Vec<String>,
544    #[serde(default)]
545    pub write_flags: Vec<String>,
546    #[serde(default)]
547    pub delegate_after: Option<String>,
548    #[serde(default)]
549    pub delegate_skip: Option<usize>,
550    /// `"file"` (first positional is the executor path — `go run ./cmd`) or `"project"`
551    /// (the current project is the executor — `cargo run`). Gates via the execution-origin
552    /// engine instead of a flat level. See `DispatchKind::Executor`.
553    #[serde(default)]
554    pub executor: Option<String>,
555    /// A valued flag whose value redirects the executor out of the project
556    /// (`cargo run --manifest-path DIR/Cargo.toml`); its value is locus-gated. `Project` only.
557    #[serde(default)]
558    pub executor_redirect_flag: Option<String>,
559    /// Predicate the executor path must satisfy (`"go-package"`), else deny. `File` only.
560    #[serde(default)]
561    pub positional_shape: Option<String>,
562    /// Tokens after the executor path are the SCRIPT's argv, not this command's own arguments.
563    ///
564    /// An interpreter passes them through (`python3 ./task.py --flag arg`) and its flag grammar
565    /// cannot describe them, so only the prefix up to the script is checked. A tool that merely
566    /// TAKES a path does not: a second positional on `karma start` is a second config file it
567    /// loads and executes, and it must be counted by `max_positional`.
568    ///
569    /// Defaults to false, the enforcing answer, so a new File executor is governed by its own
570    /// declared grammar unless someone states otherwise.
571    #[serde(default)]
572    pub passes_argv: Option<bool>,
573    #[serde(default)]
574    pub handler: Option<String>,
575    #[serde(default)]
576    pub doc_body: Option<String>,
577    /// Marks this sub's leaf invocation as safe inside
578    /// `eval "$(CMD SUB ...)"`. The leaf is the deepest matched dispatch
579    /// node — if this sub has nested sub-subs and the invocation matches
580    /// deeper, the tag does NOT apply; the sub-sub must be tagged itself.
581    /// Unset = not eval-safe (the default).
582    #[serde(default)]
583    pub eval_safe: Option<bool>,
584    /// Flag allowlist that extends `eval_safe = true` — these `-`-prefixed
585    /// tokens are also permitted inside the substitution. Default empty,
586    /// meaning only the bare form plus positionals are eval-safe.
587    /// Build panics if this is set without `eval_safe = true`.
588    #[serde(default)]
589    pub eval_safe_flags: Vec<String>,
590    /// Per-valued-flag value allowlist (same semantics as the
591    /// command-level field). Maps each valued flag (which MUST also
592    /// appear in `eval_safe_flags`) to its permitted values.
593    #[serde(default)]
594    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
595    /// Flags where AT LEAST ONE must appear (same semantics as the
596    /// command-level field).
597    #[serde(default)]
598    pub eval_safe_required_flags: Vec<String>,
599    /// `[command.sub.output]` — what THIS SUB's stdout can name (same shape and
600    /// semantics as the command-level `[command.output]`).
601    ///
602    /// Sub-scoped because the claim rarely holds for a whole multi-command tool:
603    /// `git diff --name-only` prints worktree paths, while `git log` prints prose
604    /// and `git config --get` prints whatever was configured. A command-level
605    /// claim would have to be voided by an `invalidated_by` list naming every
606    /// other subcommand, which is a denylist and fails open on the next one git
607    /// adds.
608    #[serde(default)]
609    pub output: Option<TomlOutput>,
610}
611
612#[derive(Debug, Clone, Copy, Deserialize)]
613pub(super) enum TomlLevel {
614    Inert,
615    SafeRead,
616    SafeWrite,
617}
618
619impl From<TomlLevel> for SafetyLevel {
620    fn from(l: TomlLevel) -> Self {
621        match l {
622            TomlLevel::Inert => SafetyLevel::Inert,
623            TomlLevel::SafeRead => SafetyLevel::SafeRead,
624            TomlLevel::SafeWrite => SafetyLevel::SafeWrite,
625        }
626    }
627}
628
629#[derive(Debug)]
630pub struct CommandSpec {
631    pub name: String,
632    pub description: String,
633    pub aliases: Vec<String>,
634    pub url: String,
635    pub category: String,
636    /// Upstream version of the underlying tool that was researched
637    /// when this spec was last updated. Free-form string — e.g.
638    /// `"1.9.0"`, `"v5.10.3"`, `"2026-05-08 master"`,
639    /// `"@northflank/cli 0.10.15"`. Internal-only: not rendered in
640    /// docs or used at runtime. Surfaces in tests and as a tripwire
641    /// when researching newer versions of the same tool.
642    pub researched_version: Option<String>,
643    /// Sample invocations that the registry test runs through `is_safe_command`.
644    /// Each `examples_safe` entry must produce `Verdict::Allowed`.
645    pub examples_safe: Vec<String>,
646    /// Sample invocations that must be denied. Use these to lock in security
647    /// boundaries (e.g. `srb tc --metrics-file=/etc/passwd` should always
648    /// be denied; recording it here catches regressions).
649    pub examples_denied: Vec<String>,
650    /// True when this command's bare invocation (no sub) is tagged as
651    /// safe-to-eval. Walked by `registry::is_eval_safe_invocation()`.
652    pub eval_safe: bool,
653    /// Flag allowlist extending `eval_safe` — flags permitted in the
654    /// substituted invocation when the walker stops at this node.
655    pub eval_safe_flags: Vec<String>,
656    /// Per-valued-flag value allowlist. When the walker hits a flag
657    /// listed here, the value following the flag (separated by `=` or
658    /// space) must be in this list.
659    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
660    /// Flags where at least one must appear in the substituted
661    /// invocation. Empty = no required-flag constraint.
662    pub eval_safe_required_flags: Vec<String>,
663    /// The command's own path-argument gate (`[command.path_gate]`), if declared. Read by
664    /// `registry::command_path_gate` → `pathgate::should_deny`.
665    pub(super) path_gate: Option<crate::pathgate::RoleSpec>,
666    /// Top-level classifying flags (`[[command.flag]]`), lowered from `TomlSubFlag`. A present flag
667    /// classifies the whole invocation as its archetype — read by `registry::command_flag_archetypes`
668    /// → `engine::resolve::resolve` (the flat-command analog of a profiled sub's escalating flags).
669    pub(super) archetype_flags: Vec<FlagProvenance>,
670    /// Declarative facet behavior (`[command.behavior]`), lowered to typed facet enums. Read
671    /// by `registry::command_behavior` → `engine::resolve::resolve_behavior`. When present,
672    /// the engine classifies this command from its declared facets instead of a Rust resolver.
673    pub(super) behavior: Option<BehaviorSpec>,
674    /// Lowered `[command.output]` — read by `registry::command_output_locus` →
675    /// `engine::resolve::substitution_locus`, which decides whether a `$(…)` around this command
676    /// yields a bounded path instead of the unpinnable sentinel.
677    pub(super) output: Option<OutputSpec>,
678    pub(super) writes_cwd: Option<crate::pathctx::anchor::Anchor>,
679    /// True when the command's `NAME=VALUE` positionals become environment variables (`export`,
680    /// `declare -x`). `dispatch_spec` then classifies each through `envvars::assignment_verdict`
681    /// and combines the result, so `export LD_PRELOAD=/tmp/evil.so` denies as
682    /// `LD_PRELOAD=/tmp/evil.so ls` does.
683    pub(super) env_assignment_positionals: bool,
684    pub(super) kind: DispatchKind,
685}
686
687/// A command's declarative facet behavior, lowered from `[command.behavior]` (`TomlBehavior`)
688/// with every facet string resolved to its enum at build time. The generic resolver reads this
689/// plus the tokens and builds a `Profile`. Clone so it can be attached uniformly across the
690/// `build_command` construction sites.
691#[derive(Debug, Clone)]
692pub(crate) struct BehaviorSpec {
693    pub operation: crate::engine::facet::Operation,
694    pub positionals: PositionalRole,
695    pub scale: ScaleModel,
696    /// Behavior's own flag grammar, pre-split for the shared `walk_positionals`.
697    pub short: Vec<u8>,
698    pub valued_short: Vec<u8>,
699    pub long: Vec<String>,
700    pub valued_long: Vec<String>,
701    pub numeric_shorthand: bool,
702    /// Flags whose presence widens the scale to unbounded (`rm -r`, `grep -r`).
703    pub unbounded_flags: Vec<String>,
704    /// Valued flags whose VALUE is a path to gate (`touch -r REF` reads REF), with its role.
705    pub path_flags: Vec<PathFlag>,
706    pub hook: Option<BehaviorHook>,
707    /// Transfer semantics, present iff `positionals == Transfer`.
708    pub transfer: Option<TransferSpec>,
709}
710
711/// A valued flag whose value is a path safe-chains gates by locus. One spelling per entry (a
712/// flag with both short and long forms is two entries); the resolver scans for each.
713#[derive(Debug, Clone)]
714pub(crate) struct PathFlag {
715    pub short: Option<u8>,
716    pub long: Option<String>,
717    pub role: PathRole,
718}
719
720/// The role a path-flag's value plays — read (gated by read locus) or write (by write locus).
721#[derive(Debug, Clone, Copy, PartialEq, Eq)]
722pub(crate) enum PathRole {
723    Read,
724    Write,
725}
726
727/// Lowered `[command.behavior.transfer]` — the per-command transfer knobs, terms resolved.
728#[derive(Debug, Clone)]
729pub(crate) struct TransferSpec {
730    pub source: TransferSource,
731    /// Whether the DESTINATION operand is a rebind rather than an ordinary write: `ln` makes the
732    /// destination name refer to somewhere else, while `cp` and `mv` put bytes at or under it.
733    /// Both are `create`/`transfer` to the engine, so the operation cannot tell them apart.
734    pub rebinds_destination: bool,
735    pub no_clobber_flags: Vec<String>,
736    pub clobber_flags: Vec<String>,
737    pub recursive_flags: Vec<String>,
738}
739
740/// The source-operand operation of a transfer command.
741#[derive(Debug, Clone, Copy, PartialEq, Eq)]
742pub(crate) enum TransferSource {
743    /// cp/ln: read the source into the destination/link (no disclosure to the model).
744    Observe,
745    /// mv: remove the source from its old location (trivially reversible).
746    Relocate,
747}
748
749/// The closed set of operand-role shapes (§ design doc: the `Operands` enum, as data).
750#[derive(Debug, Clone, Copy, PartialEq, Eq)]
751pub(crate) enum PositionalRole {
752    None,
753    Read,
754    Write,
755    PatternThenRead,
756    Transfer,
757}
758
759/// How a command's `Scale` is computed from its operands.
760#[derive(Debug, Clone, Copy, PartialEq, Eq)]
761pub(crate) enum ScaleModel {
762    /// Every operation is a single item regardless of operand count (cat/head).
763    Single,
764    /// Count, glob, or a recursion flag widen it (`breadth_scale`) — rm/mkdir.
765    Breadth,
766}
767
768/// Runtime form of `[command.output]`: how to derive the locus of a command's stdout.
769#[derive(Debug, Clone)]
770pub(crate) struct OutputSpec {
771    pub locus_from: OutputLocus,
772    /// Flags that void the claim (see `TomlOutput::invalidated_by`).
773    pub invalidated_by: Vec<String>,
774    /// Value-taking flags (see `TomlOutput::valued`).
775    pub valued: Vec<String>,
776    /// Flags of which at least one must be present (see `TomlOutput::requires`).
777    pub requires: Vec<String>,
778}
779
780#[derive(Debug, Clone, Copy, PartialEq, Eq)]
781pub(crate) enum OutputLocus {
782    /// Beneath the command's own path operands — the worst `read_locus` over them, or the cwd
783    /// when it has none (`fd pattern` with no root searches `.`).
784    Operands,
785    /// The working directory itself (`pwd`).
786    Cwd,
787    /// A subset of what it was piped (`head -1`, `sort`, `uniq`) — so the locus is the PREVIOUS
788    /// pipeline stage's. Only when the command has no path operand: `head f.txt` prints the
789    /// contents of a file rather than filtering a stream, and contents are not paths.
790    Stdin,
791    /// The output words are ATOMS: they carry no path separator, so splicing one into a path
792    /// cannot move which directory the path names. `seq` prints integers; `basename` prints a
793    /// single component by definition.
794    ///
795    /// This is a different KIND of claim from the others, which all answer "which locus does this
796    /// output name". An atom names no locus at all — the point is that it cannot CHANGE one. That
797    /// is what makes `for i in $(seq 1 4); do … > "$SP/dx_$i.txt"; done` confinable: the prefix is
798    /// literal, and an atom spliced into the leaf cannot escape it.
799    ///
800    /// Separator-freedom alone is not sufficient — an atom that IS a whole component could be
801    /// `..`. Confinement additionally requires the interpolation to be flanked by literal text
802    /// within its component, which is a property of the PATH, not of this declaration. See
803    /// `docs/design/behavioral-taxonomy-*` and the plan recorded in TODO.md.
804    Atom,
805}
806
807/// A named thin resolver hook for irreducible token logic a declaration can't express — a
808/// command whose operand syntax is not getopt positional (grep's pattern disambiguation, dd's
809/// `key=value`, tar's dashless mode bundles, sed's mini-language script). The hook parses the
810/// tokens; the facets still come from the declaration + the builders.
811#[derive(Debug, Clone, Copy, PartialEq, Eq)]
812pub(crate) enum BehaviorHook {
813    Grep,
814    Dd,
815    Tar,
816    Sed,
817    Perl,
818}
819
820/// Runtime form of a `[[command.sub.flag]]` — the engine-relevant part of an escalating flag: its
821/// `name` (matched against the tokens) and the archetype it `classifies` as when present. Its
822/// research provenance (`fact`/`source`/`judgment`) lives on the TOML side and is validated at build
823/// time, not carried here.
824#[derive(Debug, Clone)]
825pub(super) struct FlagProvenance {
826    pub name: String,
827    pub classifies: String,
828    /// See `TomlSubFlag::value_prefix` — `None` = escalate on presence; `Some` = only when the
829    /// flag's value starts with this.
830    pub value_prefix: Option<String>,
831    /// See `TomlSubFlag::when_absent` — escalate when the flag is ABSENT (a safety flag whose
832    /// absence is the risk).
833    pub when_absent: bool,
834}
835
836/// How a sub's declared name is matched. An enum rather than a bool because the two are genuinely
837/// different matching MODES, and because a third boolean on `SubSpec` is the point at which the
838/// struct stops being readable — `name_match: WithDatabaseSuffix` says what `per_database: true`
839/// only implied.
840#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
841pub(super) enum NameMatch {
842    #[default]
843    Exact,
844    /// Also matches `name:<dbname>` for one plain identifier — see `TomlSub::per_database`.
845    WithDatabaseSuffix,
846}
847
848#[derive(Debug, Clone)]
849pub(super) struct SubSpec {
850    pub name: String,
851    pub kind: DispatchKind,
852    /// How this sub's name is matched against an argument.
853    pub name_match: NameMatch,
854    /// The facet archetype this sub is classified as (`archetypes.toml`), if declared via
855    /// `profile = …`. The engine resolves the sub to this archetype's static capability profile
856    /// (`registry::sub_archetype`), deriving the verdict rather than taking a hand-marked level.
857    /// (Its research provenance — `fact`/`source`/`judgment` — lives on the TOML side only and is
858    /// validated at build time; it is not carried on the runtime spec.)
859    pub profile: Option<String>,
860    /// Escalating flags: each, when present, adds `classifies`'s capability to the resolved profile.
861    pub flags: Vec<FlagProvenance>,
862    /// The sub's DECLARED flag allowlist, preserved for a PROFILED sub. Its legacy `kind` is forced
863    /// to deny-all at build time, but the engine classifies a profiled sub straight from its
864    /// archetype and never reaches that kind — so without keeping the lists here the declaration
865    /// would be discarded and ANY flag would ride along on the profile. That was a fail-OPEN:
866    /// `git rebase --exec 'rm -rf /'` and `supabase db dump --frobnicate` both classified as their
867    /// benign base profile. `sub_archetypes` validates presented flags against these.
868    pub allowed_standalone: Vec<String>,
869    pub allowed_valued: Vec<String>,
870    /// The sub's declared tolerance for flags it does not enumerate — the existing, explicit way to
871    /// say "this tool's flag surface is genuinely unbounded" (a cloud API's per-service options).
872    /// Preserved alongside the allowlist so the profiled path honors it: a sub that declares it stays
873    /// open BY DECLARATION (reviewable in the TOML) rather than by a silent engine default, and a sub
874    /// that does not — `git rebase`, where a flag changes the operation — enforces.
875    pub allowed_unknown: crate::policy::UnknownTolerance,
876    /// If this sub was declared with `policy = "key"`, the referenced
877    /// handler_policy name is preserved for docs rendering so a sub
878    /// that points at a policy also shown in **Shared flag sets** can
879    /// render as a reference rather than duplicating the flag list.
880    pub policy_ref: Option<String>,
881    /// True when this sub's leaf invocation is tagged as safe-to-eval.
882    /// Walked by `registry::is_eval_safe_invocation()`.
883    pub eval_safe: bool,
884    /// Flag allowlist extending `eval_safe` — flags permitted in the
885    /// substituted invocation when the walker stops at this sub.
886    pub eval_safe_flags: Vec<String>,
887    /// Per-valued-flag value allowlist (same semantics as on
888    /// `CommandSpec`).
889    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
890    /// Lowered `[command.sub.output]` — this sub's own stdout claim, consulted by
891    /// `registry::sub_output_locus` before the command-level one.
892    pub output: Option<OutputSpec>,
893    /// Flags where at least one must appear in the substituted
894    /// invocation (same semantics as on `CommandSpec`).
895    pub eval_safe_required_flags: Vec<String>,
896    /// `true` = classify this sub's first positional as a network destination onto
897    /// `locus.provenance` (see `TomlSub::network_destination`).
898    pub network_destination: bool,
899    /// A flag that overrides the positional destination (`git push --repo=…`); see
900    /// `TomlSub::destination_flag`.
901    pub destination_flag: Option<String>,
902    /// Output-file flags for a `data-export` sub; a present one adds a path-gated write capability
903    /// at the file's locus (see `TomlSub::output_path_flags`).
904    pub output_path_flags: Vec<String>,
905    pub writes_cwd: Option<crate::pathctx::anchor::Anchor>,
906    /// Endpoint flags gated on naming this machine; see `TomlSub::loopback_valued`.
907    pub loopback_valued: Vec<String>,
908    /// What a loopback endpoint buys this sub; see `TomlSub::loopback_localizes`.
909    pub loopback_effect: LoopbackEffect,
910}
911
912/// What a recognized loopback destination buys a sub. Gating the FLAG and re-classifying the
913/// OPERATION are separate powers: a read only needs the former (it already passes), while a write
914/// needs the latter to stop looking like a call to a cloud service.
915#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
916pub(super) enum LoopbackEffect {
917    /// The endpoint flag is admissible when local; the classification is untouched.
918    #[default]
919    AdmitOnly,
920    /// Also clears the facets the destination determines (remote reach, net direction, payload,
921    /// metered cost), leaving the ones describing the operation alone.
922    Localizes,
923}
924
925#[derive(Debug, Clone)]
926pub(super) enum DispatchKind {
927    Policy {
928        policy: OwnedPolicy,
929        level: SafetyLevel,
930    },
931    FirstArg {
932        patterns: Vec<String>,
933        level: SafetyLevel,
934        /// Flags the glob family accepts. Empty = not yet researched (grandfathered); see
935        /// `glob_presents_unlisted_flag`.
936        standalone: Vec<String>,
937        valued: Vec<String>,
938        /// Flags admitted only when their value is a loopback endpoint.
939        loopback_valued: Vec<String>,
940    },
941    RequireAny {
942        require_any: Vec<String>,
943        policy: OwnedPolicy,
944        level: SafetyLevel,
945        accept_bare_help: bool,
946    },
947    Branching {
948        subs: Vec<SubSpec>,
949        bare_flags: Vec<String>,
950        bare_ok: bool,
951        pre_standalone: Vec<String>,
952        pre_valued: Vec<String>,
953        first_arg: Vec<String>,
954        first_arg_level: SafetyLevel,
955        /// Flags the `first_arg` glob family accepts, as in `DispatchKind::FirstArg`.
956        first_arg_standalone: Vec<String>,
957        first_arg_valued: Vec<String>,
958        first_arg_loopback_valued: Vec<String>,
959        /// First-positional globs that classify the invocation as a credential-read (deny), checked
960        /// after explicit subs and before the allow-glob. Empty for almost every command.
961        credential_first_arg: Vec<String>,
962        /// Accept one leading rustup toolchain selector (`cargo +nightly build`), stripped before
963        /// sub dispatch. From `[command.wrapper] toolchain_selector = true`.
964        toolchain_selector: bool,
965    },
966    WriteFlagged {
967        policy: OwnedPolicy,
968        base_level: SafetyLevel,
969        write_flags: Vec<String>,
970    },
971    DelegateAfterSeparator {
972        separator: String,
973    },
974    DelegateSkip {
975        skip: usize,
976    },
977    Wrapper {
978        standalone: Vec<String>,
979        valued: Vec<String>,
980        positional_skip: usize,
981        separator: Option<String>,
982        bare_ok: bool,
983    },
984    /// A `verb-chain` grammar (`mlr`): a strict main-flag region + a `then`-chain of
985    /// allowlisted verbs. See `dispatch::dispatch_verb_chain`.
986    VerbChain(VerbChainSpec),
987    /// A code-execution command whose verdict is the execution-origin gate (worktree code
988    /// allows, foreign denies), not a flat level. See `dispatch::dispatch_executor` and
989    /// docs/design/behavioral-taxonomy-execution-origin.md.
990    Executor {
991        policy: OwnedPolicy,
992        /// Verdict for a flag-only invocation with no executor (`python3 --version`).
993        level: SafetyLevel,
994        kind: ExecutorKind,
995        /// A valued flag whose value REDIRECTS the executor out of the project
996        /// (`cargo run --manifest-path DIR/Cargo.toml`) — its value is locus-gated like a
997        /// file executor. Only meaningful for `ExecutorKind::Project`.
998        redirect_flag: Option<String>,
999        /// A predicate the executor path must satisfy, else deny (`ExecutorKind::File`).
1000        /// `go run` uses `go-package` so a remote import path (`rsc.io/x@latest`) is not
1001        /// treated as a worktree executor.
1002        shape: Option<crate::policy::PositionalShape>,
1003        /// Whether the tokens AFTER the executor path are the SCRIPT's argv rather than this
1004        /// command's own arguments. `python3 ./task.py --flag arg` passes them; `karma start
1005        /// ./a.conf.js` does not, and a second path there is a second CONFIG it will load.
1006        ///
1007        /// Decides how much of the invocation the flag policy governs, so it defaults to FALSE —
1008        /// the enforcing answer. See `dispatch::dispatch_executor`.
1009        passes_argv: bool,
1010    },
1011    Custom {
1012        #[allow(dead_code)]
1013        handler_name: String,
1014        doc_body: Option<String>,
1015        /// TOML-declared subs the handler may consult via
1016        /// `registry::try_sub_dispatch()`. Empty unless the handler
1017        /// uses the helper.
1018        subs: Vec<SubSpec>,
1019        /// TOML-declared alternate grammar the handler may consult
1020        /// via `registry::try_fallback_grammar()`. `None` unless the
1021        /// handler uses the helper.
1022        fallback: Option<FallbackSpec>,
1023        /// Named flag policies the handler consults via
1024        /// `registry::check_handler_policy()`. Empty unless the handler
1025        /// has dispatch logic that picks a policy by name at runtime.
1026        handler_policies: std::collections::HashMap<String, OwnedPolicy>,
1027        /// Sub × action matrices the handler walks via
1028        /// `registry::try_matrix_dispatch()`.
1029        matrices: Vec<MatrixSpec>,
1030    },
1031}
1032
1033/// How a code-execution command locates its executor. `File`: the first positional is the
1034/// executor path (`bash x.sh`, `python3 x.py`, `go run ./cmd`). `Project`: the current
1035/// project is the executor and there is no path operand (`cargo run`, `dotnet run`).
1036#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1037pub(super) enum ExecutorKind {
1038    File,
1039    Project,
1040}
1041
1042impl ExecutorKind {
1043    pub(super) fn from_name(name: &str) -> Option<Self> {
1044        match name {
1045            "file" => Some(Self::File),
1046            "project" => Some(Self::Project),
1047            _ => None,
1048        }
1049    }
1050}
1051
1052#[derive(Debug, Clone)]
1053pub struct OwnedPolicy {
1054    pub standalone: Vec<String>,
1055    pub valued: Vec<String>,
1056    pub bare: bool,
1057    pub max_positional: Option<usize>,
1058    pub tolerance: crate::policy::FlagTolerance,
1059}
1060
1061#[derive(Debug, Clone)]
1062pub(super) struct MatrixSpec {
1063    pub parents: Vec<String>,
1064    pub level: SafetyLevel,
1065    pub actions: std::collections::HashMap<String, MatrixAction>,
1066}
1067
1068#[derive(Debug, Clone)]
1069pub(super) struct MatrixAction {
1070    pub policy_key: String,
1071    pub guard: Option<String>,
1072    pub guard_short: Option<String>,
1073}
1074
1075#[derive(Debug, Clone)]
1076pub(super) struct VerbChainSpec {
1077    pub level: SafetyLevel,
1078    pub separator: String,
1079    pub main_standalone: Vec<String>,
1080    pub main_valued: Vec<String>,
1081    pub main_variadic: Vec<String>,
1082    pub verbs: std::collections::HashSet<String>,
1083}
1084
1085#[derive(Debug, Clone)]
1086pub(super) struct FallbackSpec {
1087    pub policy: OwnedPolicy,
1088    pub level: SafetyLevel,
1089    pub positional_shape: Option<crate::policy::PositionalShape>,
1090    /// When set, the first positional is an EXECUTOR (a script/package the command runs),
1091    /// gated by the execution-origin engine instead of the flat `level`. `ExecutorKind::File`
1092    /// is the only form used by fallbacks (interpreters). See `dispatch::dispatch_executor`.
1093    pub executor: Option<ExecutorKind>,
1094    /// See `DispatchKind::Executor::redirect_flag`. Unused for `File` fallbacks.
1095    pub executor_redirect_flag: Option<String>,
1096    /// See `DispatchKind::Executor::passes_argv`. This is where the interpreters set it: their
1097    /// trailing tokens are the script's argv, which their own grammar cannot describe.
1098    pub passes_argv: bool,
1099}