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}