Skip to main content

safe_chains/registry/
types.rs

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