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    /// Removed in favor of `tolerate_unknown_short` / `tolerate_unknown_long`.
32    /// Build panics if any TOML still sets this — see SAMPLE.toml for the
33    /// migration guidance. Kept on the deserializer struct so the panic
34    /// message can name the offending command instead of a serde error.
35    #[serde(default)]
36    pub positional_style: Option<bool>,
37    #[serde(default)]
38    pub tolerate_unknown_short: Option<bool>,
39    #[serde(default)]
40    pub tolerate_unknown_long: Option<bool>,
41    #[serde(default)]
42    pub numeric_dash: Option<bool>,
43    #[serde(default)]
44    pub standalone: Vec<String>,
45    #[serde(default)]
46    pub valued: Vec<String>,
47    #[serde(default)]
48    pub bare_flags: Vec<String>,
49    #[serde(default)]
50    pub sub: Vec<TomlSub>,
51    #[serde(default)]
52    pub handler: Option<String>,
53    #[serde(default)]
54    pub doc_body: Option<String>,
55    #[serde(default)]
56    pub require_any: Vec<String>,
57    #[serde(default)]
58    pub first_arg: Vec<String>,
59    #[serde(default)]
60    pub credential_first_arg: Vec<String>,
61    /// Top-level classifying flags (`[[command.flag]]`): a flag whose PRESENCE classifies the WHOLE
62    /// invocation as an archetype — the flat-command analog of `[[command.sub.flag]]`. For a bimodal
63    /// tool where a mode flag flips the operation: `age -d` / `sops --decrypt` reveal plaintext to the
64    /// model (`decrypt-read`), while the bare/encrypt form is an ordinary local write. Resolved by
65    /// `engine::resolve` via `registry::command_flag_archetypes`; each flag's `classifies` must name a
66    /// known archetype and carry `fact`/`source` provenance (the `assert_command_flag_provenance` guard).
67    #[serde(default)]
68    pub flag: Vec<TomlSubFlag>,
69    #[serde(default)]
70    pub wrapper: Option<TomlWrapper>,
71    #[serde(default)]
72    pub write_flags: Vec<String>,
73    /// Path-argument gate co-located with the command (`[command.path_gate]`): the read/write
74    /// role of each path-bearing flag value and of bare positionals. Consulted by
75    /// `pathgate::should_deny` so a `--output`/`-i` path can't ship ungated. Same shape as
76    /// `pathgates.toml`'s `[roles.X]`.
77    #[serde(default)]
78    pub path_gate: Option<crate::pathgate::RoleSpec>,
79    #[serde(default)]
80    pub researched_version: Option<String>,
81    /// Sample invocations that double as test fixtures.
82    /// `examples_safe` must validate as Allowed; `examples_denied` must validate as Denied.
83    /// Use these to exercise aliases and canonical forms (e.g. `mise use` and `mise u`)
84    /// so drift between the TOML and runtime dispatch fails the test suite.
85    #[serde(default)]
86    pub examples_safe: Vec<String>,
87    #[serde(default)]
88    pub examples_denied: Vec<String>,
89    /// Marks this command's leaf invocation as safe inside
90    /// `eval "$(CMD ...)"`. Set on flat commands whose stdout is documented
91    /// shell-init code (e.g. `ssh-agent`). The leaf is the deepest matched
92    /// dispatch node — tagging here does NOT propagate to subs; each sub
93    /// must be tagged independently. Unset = not eval-safe (the default).
94    #[serde(default)]
95    pub eval_safe: Option<bool>,
96    /// Flag allowlist that extends `eval_safe = true` — these `-`-prefixed
97    /// tokens are also permitted inside the substitution. Default empty,
98    /// meaning only the bare form plus positionals are eval-safe.
99    /// Build panics if this is set without `eval_safe = true`.
100    #[serde(default)]
101    pub eval_safe_flags: Vec<String>,
102    /// Per-valued-flag value allowlist. Maps each valued flag (which
103    /// MUST also appear in `eval_safe_flags`) to the set of values
104    /// permitted in eval substitutions. Use for tools where the flag's
105    /// value determines stdout shape (`aws --format env` vs
106    /// `--format json`). Default empty = no value restriction beyond
107    /// the bare-literal alphabet.
108    #[serde(default)]
109    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
110    /// Flags where AT LEAST ONE must appear in the eval substitution.
111    /// Use for tools whose bare invocation isn't shell-init code:
112    /// `fzf` is interactive without `--bash|--zsh|--fish|--nushell`.
113    /// Every entry must also appear in `eval_safe_flags`. Default
114    /// empty = no required flags (bare invocation is fine).
115    #[serde(default)]
116    pub eval_safe_required_flags: Vec<String>,
117    /// Shortcut: every invocation of this command is denied. Used in custom
118    /// TOMLs to lock down a built-in (e.g. `name = "gh", deny = true` in
119    /// `.safe-chains.toml` denies every gh form for that project).
120    #[serde(default)]
121    pub deny: Option<bool>,
122    /// Alternate grammar engaged when standard sub-dispatch finds no match.
123    /// Only meaningful for handler-using commands (e.g. tilt's Ruby template
124    /// engine fallback when no Kubernetes tilt sub matches). The handler is
125    /// responsible for invoking it via `registry::try_fallback_grammar()`.
126    #[serde(default)]
127    pub fallback: Option<TomlFallback>,
128    /// Named flag policies the handler references by string key. Used when
129    /// a handler's dispatch logic genuinely can't move to TOML (e.g. gh's
130    /// sub × action matrix) but the per-policy WordSets are still data that
131    /// should live in TOML. The handler reads them via
132    /// `registry::check_handler_policy(cmd, key, tokens)`.
133    #[serde(default)]
134    pub handler_policy: std::collections::HashMap<String, TomlHandlerPolicy>,
135    /// Parent × action → policy matrices. One block declares: "for
136    /// these parent subcommand names, each of these action verbs maps
137    /// to a named `handler_policy` and validates at this safety level."
138    /// Lets handlers express their dispatch tables as data instead of
139    /// `match` arms. Walked by `registry::try_matrix_dispatch()`.
140    #[serde(default)]
141    pub matrix: Vec<TomlMatrix>,
142    /// A `verb-chain` grammar (`mlr`): a strict main-flag region followed by a
143    /// `then`-chain of allowlisted verbs. Fully declarative — no handler needed.
144    #[serde(default)]
145    pub verb_chain: Option<TomlVerbChain>,
146    /// Declarative facet behavior (`[command.behavior]`) — the non-legacy classification
147    /// path. When present, the engine resolves this command by building a `Profile` from the
148    /// declared operation + operand-role + flags (see `engine::resolve::resolve_behavior`),
149    /// retiring a hardcoded `RESOLVERS` entry. The legacy `level` remains only as the
150    /// fallback the engine already overrides.
151    #[serde(default)]
152    pub behavior: Option<TomlBehavior>,
153}
154
155/// A command's declarative facet behavior (`[command.behavior]`). Field values that name a
156/// facet term are the kebab strings from `engine::facet` (`operation = "observe"`); the build
157/// maps them via `FacetTerm::from_term` and PANICS (naming the command) on an unknown term, so
158/// a typo can't silently mis-classify. The behavior carries its OWN flag grammar
159/// (`standalone`/`valued`), independent of the legacy top-level `standalone` — `rm`'s legacy
160/// flag set is restricted to `--help`/`--version` (so the legacy fallback can't fail-open on
161/// `rm -rf`), while its behavior grammar is the full destructive set.
162#[derive(Debug, Deserialize)]
163#[serde(deny_unknown_fields)]
164pub(super) struct TomlBehavior {
165    /// The act each operand capability performs — an `Operation` term
166    /// (`observe`/`create`/`mutate`/`destroy`/…).
167    pub operation: String,
168    /// How bare positionals are touched: `none` | `read` | `write` | `pattern-then-read` |
169    /// `transfer` (the closed set the `Operands` enum encodes).
170    pub positionals: String,
171    /// Scale model: `single` (every read is one item — cat/head) or `breadth`
172    /// (count/glob/recursion widen it — rm/mkdir). Defaults to `single`.
173    #[serde(default)]
174    pub scale: Option<String>,
175    /// Boolean flags this command accepts (behavior's own grammar). Single-dash single-char
176    /// tokens (`-r`) cluster; `--long` tokens are matched whole.
177    #[serde(default)]
178    pub standalone: Vec<String>,
179    /// Value-taking flags (consume the next token or a glued `=value`).
180    #[serde(default)]
181    pub valued: Vec<String>,
182    /// Accept the obsolete `-NUM` count shorthand (`head -20`).
183    #[serde(default)]
184    pub numeric_shorthand: Option<bool>,
185    /// Per-flag facet deltas — a flag whose presence widens scale (`"-r" = { scale =
186    /// "unbounded" }`), consumes a path value, or supplies a pattern.
187    #[serde(default)]
188    pub flags: std::collections::HashMap<String, TomlBehaviorFlag>,
189    /// Thin custom hook for the irreducible token logic a declaration can't express
190    /// (`grep`'s pattern-vs-file disambiguation). Composes: it returns the classified operand
191    /// set; the facets + level projection stay declarative. Absent = pure declarative.
192    #[serde(default)]
193    pub hook: Option<String>,
194    /// Transfer semantics (`[command.behavior.transfer]`), REQUIRED when `positionals =
195    /// "transfer"` — the source-operand operation and the clobber/recursion flag sets that a
196    /// `cp`/`mv`/`ln`-shaped command differs on.
197    #[serde(default)]
198    pub transfer: Option<TomlTransfer>,
199}
200
201/// The differing knobs of a transfer command (`cp`/`mv`/`ln`): every source operand is read at
202/// its own locus and the destination is a create/overwrite at its locus, but the source
203/// *operation* and the clobber/recursion flags differ per command.
204#[derive(Debug, Deserialize)]
205#[serde(deny_unknown_fields)]
206pub(super) struct TomlTransfer {
207    /// The source-operand operation: `observe` (cp/ln read the source into the dest/link) or
208    /// `relocate` (mv removes the source from its old location).
209    pub source: String,
210    /// Flags whose PRESENCE means the destination will not be overwritten (`cp`/`mv`: `-n`,
211    /// `--no-clobber`). Mutually exclusive with `clobber_flags`.
212    #[serde(default)]
213    pub no_clobber_flags: Vec<String>,
214    /// Flags whose PRESENCE means the destination WILL be overwritten, the default being
215    /// no-clobber (`ln`: `-f`, `--force`). Mutually exclusive with `no_clobber_flags`.
216    #[serde(default)]
217    pub clobber_flags: Vec<String>,
218    /// Flags whose presence widens the scale to unbounded (`cp`: `-r`/`-R`/`-a`).
219    #[serde(default)]
220    pub recursive_flags: Vec<String>,
221}
222
223/// One flag's contribution to a `[command.behavior]` profile: a scale bump when present, and/or
224/// a path role on the flag's VALUE (a valued flag whose value is a path safe-chains must gate,
225/// e.g. `touch -r REF` reads REF's timestamp — folds the `[command.path_gate]` idea into behavior).
226#[derive(Debug, Deserialize)]
227#[serde(deny_unknown_fields)]
228pub(super) struct TomlBehaviorFlag {
229    /// Scale bump when present (`"-r" = { scale = "unbounded" }`).
230    #[serde(default)]
231    pub scale: Option<String>,
232    /// The flag's VALUE is a path with this role: `read` (gated by its read locus) or `write`
233    /// (gated by its write locus). The flag must be a valued flag (in `valued`).
234    #[serde(default)]
235    pub kind: Option<String>,
236}
237
238/// A `verb-chain` command grammar: `CMD [main-flags…] verb [args…] then verb [args…] …`
239/// (`mlr`). The main-flag region is a STRICT allowlist (an unlisted flag denies — so a
240/// mutating flag like mlr's `-I`/`--in-place`, omitted, is caught by omission); the verb
241/// region is a `then`-chain where every verb NAME must be on the `verbs` allowlist (verb
242/// ARGS are open-ended and not inspected — a pure verb has no shell/file escape).
243#[derive(Debug, Deserialize)]
244#[serde(deny_unknown_fields)]
245pub(super) struct TomlVerbChain {
246    #[serde(default)]
247    pub level: Option<TomlLevel>,
248    /// The chain separator keyword (mlr: `then`). Defaults to `then`.
249    #[serde(default)]
250    pub separator: Option<String>,
251    /// Boolean main flags (no value). SAFETY: every value-TAKING main flag must go in
252    /// `main_valued` instead, or the walk mistakes its value for the verb boundary and a
253    /// later mutating flag slips past in verb-land.
254    #[serde(default)]
255    pub main_standalone: Vec<String>,
256    /// Value-taking main flags (`--from FILE`, `--ifs ,`), each consuming the next token.
257    #[serde(default)]
258    pub main_valued: Vec<String>,
259    /// Variadic main flags (mlr `--mfrom A B …`) that consume tokens until a `--` terminator.
260    #[serde(default)]
261    pub main_variadic: Vec<String>,
262    /// The allowlist of verb names permitted in every `then`-segment.
263    #[serde(default)]
264    pub verbs: Vec<String>,
265}
266
267#[derive(Debug, Deserialize)]
268#[serde(deny_unknown_fields)]
269pub(super) struct TomlMatrix {
270    pub parents: Vec<String>,
271    pub level: TomlLevel,
272    pub actions: std::collections::HashMap<String, TomlMatrixAction>,
273}
274
275#[derive(Debug, Deserialize)]
276#[serde(untagged)]
277pub(super) enum TomlMatrixAction {
278    /// Shorthand: `list = "policy_name"` — references handler_policy by
279    /// name; no guard required.
280    Policy(String),
281    /// Detailed form: `download = { policy = "release_download", guard
282    /// = "--output", guard_short = "-O" }`. The guard flag must be
283    /// present in the action's args for the dispatch to succeed.
284    Detailed(TomlMatrixActionDetailed),
285}
286
287#[derive(Debug, Deserialize)]
288#[serde(deny_unknown_fields)]
289pub(super) struct TomlMatrixActionDetailed {
290    pub policy: String,
291    #[serde(default)]
292    pub guard: Option<String>,
293    #[serde(default)]
294    pub guard_short: Option<String>,
295}
296
297#[derive(Debug, Deserialize)]
298pub(super) struct TomlHandlerPolicy {
299    #[serde(default)]
300    pub standalone: Vec<String>,
301    #[serde(default)]
302    pub valued: Vec<String>,
303    #[serde(default)]
304    pub bare: Option<bool>,
305    #[serde(default)]
306    pub max_positional: Option<usize>,
307    #[serde(default)]
308    pub tolerate_unknown_short: Option<bool>,
309    #[serde(default)]
310    pub tolerate_unknown_long: Option<bool>,
311    #[serde(default)]
312    pub numeric_dash: Option<bool>,
313}
314
315#[derive(Debug, Deserialize)]
316pub(super) struct TomlFallback {
317    #[serde(default)]
318    pub level: Option<TomlLevel>,
319    #[serde(default)]
320    pub bare: Option<bool>,
321    #[serde(default)]
322    pub max_positional: Option<usize>,
323    #[serde(default)]
324    pub standalone: Vec<String>,
325    #[serde(default)]
326    pub valued: Vec<String>,
327    #[serde(default)]
328    pub tolerate_unknown_short: Option<bool>,
329    #[serde(default)]
330    pub tolerate_unknown_long: Option<bool>,
331    #[serde(default)]
332    pub numeric_dash: Option<bool>,
333    /// Named predicate the handler applies to the first positional arg.
334    /// Currently the only value is `"path"` — accepts a token shaped like
335    /// a file path (contains `/`, `.`, or is `-` for stdin). Adding new
336    /// shapes is a one-line `PositionalShape` enum addition plus a match
337    /// arm in `policy::positional_matches_shape()`.
338    #[serde(default)]
339    pub positional_shape: Option<String>,
340    /// `"file"` gates the first positional as an EXECUTOR through the execution-origin
341    /// engine (worktree-local code allows, foreign denies) rather than the flat `level`.
342    /// For interpreters run as `python3 ./s.py` / `ruby s.rb`. (`"project"` exists for subs
343    /// but is not used on fallbacks.)
344    #[serde(default)]
345    pub executor: Option<String>,
346    #[serde(default)]
347    pub executor_redirect_flag: Option<String>,
348}
349
350#[derive(Debug, Deserialize)]
351pub(super) struct TomlWrapper {
352    #[serde(default)]
353    pub standalone: Vec<String>,
354    #[serde(default)]
355    pub valued: Vec<String>,
356    #[serde(default)]
357    pub positional_skip: Option<usize>,
358    #[serde(default)]
359    pub separator: Option<String>,
360    #[serde(default)]
361    pub bare_ok: Option<bool>,
362}
363
364/// One `[[command.sub.flag]]`: a flag that escalates its sub's classification when present.
365#[derive(Debug, Deserialize)]
366pub(super) struct TomlSubFlag {
367    pub name: String,
368    /// The archetype (`archetypes.toml`) this flag's presence ADDS to the profile — or
369    /// `"unclassified"` to worst-case (fail-closed) a flag whose effect we can't yet name.
370    pub classifies: String,
371    /// Optional value-match: escalate only when the flag's VALUE starts with this prefix (space
372    /// form `-c core.sshCommand=…` or glued `--flag=core.sshCommand=…`). Absent = escalate on the
373    /// flag's mere PRESENCE (a bare flag like `--force`). This is what lets ONE valued flag be
374    /// benign for most values and dangerous for a specific key (`git -c core.sshCommand=` = exec).
375    #[serde(default)]
376    pub value_prefix: Option<String>,
377    /// `true` INVERTS the trigger: escalate when the flag is ABSENT, not present. For a SAFETY flag
378    /// whose absence is the risk — `npm ci` runs lifecycle scripts UNLESS `--ignore-scripts` is
379    /// given, so its base profile (local-install-pinned) escalates to supply-chain-build when
380    /// `--ignore-scripts` is missing. Mutually exclusive with `value_prefix`.
381    #[serde(default)]
382    pub when_absent: Option<bool>,
383    #[serde(default)]
384    pub fact: Option<String>,
385    #[serde(default)]
386    pub source: Option<String>,
387    #[serde(default)]
388    pub judgment: Option<String>,
389}
390
391#[derive(Debug, Deserialize)]
392pub(super) struct TomlSub {
393    pub name: String,
394    #[serde(default)]
395    pub candidate: Option<bool>,
396    /// A facet archetype name (`archetypes.toml`) — the Phase-1 successor to `candidate = true`:
397    /// instead of hand-marking the sub above the line, it declares which recurring capability
398    /// profile it is, and the engine DERIVES the verdict by projecting that profile through the
399    /// levels. See `docs/design/behavioral-taxonomy-archetypes.md`.
400    #[serde(default)]
401    pub profile: Option<String>,
402    /// Per-item research provenance for the classification (required when `profile` is set — the
403    /// `every_profiled_sub_has_provenance` guard). Three layers so a future researcher can act on
404    /// each precisely: `fact` = what the upstream tool DOCUMENTS (re-check `source` if it moves),
405    /// the `profile` itself = our inference (which archetype it maps to), `judgment` = our stance
406    /// where the source doesn't decide it (a policy call they may revisit). `source` cites the
407    /// upstream doc/section. See `docs/design/behavioral-taxonomy-archetypes.md` §3.
408    #[serde(default)]
409    pub fact: Option<String>,
410    #[serde(default)]
411    pub source: Option<String>,
412    #[serde(default)]
413    pub judgment: Option<String>,
414    #[serde(default)]
415    pub aliases: Vec<String>,
416    #[serde(default)]
417    pub level: Option<TomlLevel>,
418    #[serde(default)]
419    pub bare: Option<bool>,
420    #[serde(default)]
421    pub max_positional: Option<usize>,
422    /// Removed; see TomlCommand::positional_style.
423    #[serde(default)]
424    pub positional_style: Option<bool>,
425    #[serde(default)]
426    pub tolerate_unknown_short: Option<bool>,
427    #[serde(default)]
428    pub tolerate_unknown_long: Option<bool>,
429    #[serde(default)]
430    pub numeric_dash: Option<bool>,
431    #[serde(default)]
432    pub standalone: Vec<String>,
433    #[serde(default)]
434    pub valued: Vec<String>,
435    #[serde(default)]
436    pub guard: Option<String>,
437    #[serde(default)]
438    pub guard_short: Option<String>,
439    #[serde(default)]
440    pub allow_all: Option<bool>,
441    /// Reference a `[command.handler_policy.KEY]` block by name, copying
442    /// its standalone/valued/bare/etc. into this sub's effective policy.
443    /// Lets a single-sub form (search, browse, gh status) re-use the
444    /// same flag list a matrix entry would, without duplicating the
445    /// WordSets. Mutually exclusive with inline standalone/valued.
446    #[serde(default)]
447    pub policy: Option<String>,
448    #[serde(default)]
449    pub sub: Vec<TomlSub>,
450    /// Per-FLAG escalation + provenance (`[[command.sub.flag]]`): a flag that, when present, ADDS a
451    /// capability to this sub's resolved profile — `git push --force` (→ destroy), `-c
452    /// core.sshCommand=` (→ execution). The level algebra takes the max over the added capabilities,
453    /// so a benign base + a dangerous flag lands at the flag's tier. See
454    /// `docs/design/behavioral-taxonomy-archetypes.md` §3 (per-flag layer).
455    #[serde(default)]
456    pub flag: Vec<TomlSubFlag>,
457    /// `true` marks the sub's first positional as a NETWORK DESTINATION whose *provenance* the
458    /// engine classifies onto `locus.provenance` (established remote-name / literal URL / opaque
459    /// `$VAR`), and whose command-transport form (`ext::<cmd>`) worst-cases as RCE. For
460    /// `git push` and its kin (`scp`/`rsync`/`curl -d`). See `behavioral-taxonomy-exposure.md` §4.
461    #[serde(default)]
462    pub network_destination: Option<bool>,
463    /// A flag that ALSO carries the destination and OVERRIDES the positional (`git push
464    /// --repo=<dest>`). Classified with the same provenance rules — so `--repo=ext::sh` is caught as
465    /// RCE. Requires `network_destination`.
466    #[serde(default)]
467    pub destination_flag: Option<String>,
468    /// Flags whose VALUE is a local output-file path, for a `data-export` sub (`supabase db dump
469    /// -f`, `pg_dump --file`). When one is present the engine adds a path-gated write capability at
470    /// that file's locus — a dump to `./out.sql` is a worktree write, one to `/etc/cron.d/job` a
471    /// system write. Absent (the export goes to stdout) → no write, just the bulk remote read.
472    /// Requires `profile` (only a `data-export` sub has an output file). See
473    /// `behavioral-taxonomy-exposure.md`.
474    #[serde(default)]
475    pub output_path_flags: Vec<String>,
476    #[serde(default)]
477    pub nested_bare: Option<bool>,
478    #[serde(default)]
479    pub require_any: Vec<String>,
480    #[serde(default)]
481    pub first_arg: Vec<String>,
482    /// First-positional globs (`secret`, `secret/*`) that make this sub a CREDENTIAL-READ: matching
483    /// denies, before the allow-glob. The value-dependent complement to `profile=credential-read`
484    /// (whole sub) — `kubectl get secret/x`, `aws configure get aws_secret_access_key`.
485    #[serde(default)]
486    pub credential_first_arg: Vec<String>,
487    #[serde(default)]
488    pub write_flags: Vec<String>,
489    #[serde(default)]
490    pub delegate_after: Option<String>,
491    #[serde(default)]
492    pub delegate_skip: Option<usize>,
493    /// `"file"` (first positional is the executor path — `go run ./cmd`) or `"project"`
494    /// (the current project is the executor — `cargo run`). Gates via the execution-origin
495    /// engine instead of a flat level. See `DispatchKind::Executor`.
496    #[serde(default)]
497    pub executor: Option<String>,
498    /// A valued flag whose value redirects the executor out of the project
499    /// (`cargo run --manifest-path DIR/Cargo.toml`); its value is locus-gated. `Project` only.
500    #[serde(default)]
501    pub executor_redirect_flag: Option<String>,
502    /// Predicate the executor path must satisfy (`"go-package"`), else deny. `File` only.
503    #[serde(default)]
504    pub positional_shape: Option<String>,
505    #[serde(default)]
506    pub handler: Option<String>,
507    #[serde(default)]
508    pub doc_body: Option<String>,
509    /// Marks this sub's leaf invocation as safe inside
510    /// `eval "$(CMD SUB ...)"`. The leaf is the deepest matched dispatch
511    /// node — if this sub has nested sub-subs and the invocation matches
512    /// deeper, the tag does NOT apply; the sub-sub must be tagged itself.
513    /// Unset = not eval-safe (the default).
514    #[serde(default)]
515    pub eval_safe: Option<bool>,
516    /// Flag allowlist that extends `eval_safe = true` — these `-`-prefixed
517    /// tokens are also permitted inside the substitution. Default empty,
518    /// meaning only the bare form plus positionals are eval-safe.
519    /// Build panics if this is set without `eval_safe = true`.
520    #[serde(default)]
521    pub eval_safe_flags: Vec<String>,
522    /// Per-valued-flag value allowlist (same semantics as the
523    /// command-level field). Maps each valued flag (which MUST also
524    /// appear in `eval_safe_flags`) to its permitted values.
525    #[serde(default)]
526    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
527    /// Flags where AT LEAST ONE must appear (same semantics as the
528    /// command-level field).
529    #[serde(default)]
530    pub eval_safe_required_flags: Vec<String>,
531}
532
533#[derive(Debug, Clone, Copy, Deserialize)]
534pub(super) enum TomlLevel {
535    Inert,
536    SafeRead,
537    SafeWrite,
538}
539
540impl From<TomlLevel> for SafetyLevel {
541    fn from(l: TomlLevel) -> Self {
542        match l {
543            TomlLevel::Inert => SafetyLevel::Inert,
544            TomlLevel::SafeRead => SafetyLevel::SafeRead,
545            TomlLevel::SafeWrite => SafetyLevel::SafeWrite,
546        }
547    }
548}
549
550#[derive(Debug)]
551pub struct CommandSpec {
552    pub name: String,
553    pub description: String,
554    pub aliases: Vec<String>,
555    pub url: String,
556    pub category: String,
557    /// Upstream version of the underlying tool that was researched
558    /// when this spec was last updated. Free-form string — e.g.
559    /// `"1.9.0"`, `"v5.10.3"`, `"2026-05-08 master"`,
560    /// `"@northflank/cli 0.10.15"`. Internal-only: not rendered in
561    /// docs or used at runtime. Surfaces in tests and as a tripwire
562    /// when researching newer versions of the same tool.
563    pub researched_version: Option<String>,
564    /// Sample invocations that the registry test runs through `is_safe_command`.
565    /// Each `examples_safe` entry must produce `Verdict::Allowed`.
566    pub examples_safe: Vec<String>,
567    /// Sample invocations that must be denied. Use these to lock in security
568    /// boundaries (e.g. `srb tc --metrics-file=/etc/passwd` should always
569    /// be denied; recording it here catches regressions).
570    pub examples_denied: Vec<String>,
571    /// True when this command's bare invocation (no sub) is tagged as
572    /// safe-to-eval. Walked by `registry::is_eval_safe_invocation()`.
573    pub eval_safe: bool,
574    /// Flag allowlist extending `eval_safe` — flags permitted in the
575    /// substituted invocation when the walker stops at this node.
576    pub eval_safe_flags: Vec<String>,
577    /// Per-valued-flag value allowlist. When the walker hits a flag
578    /// listed here, the value following the flag (separated by `=` or
579    /// space) must be in this list.
580    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
581    /// Flags where at least one must appear in the substituted
582    /// invocation. Empty = no required-flag constraint.
583    pub eval_safe_required_flags: Vec<String>,
584    /// The command's own path-argument gate (`[command.path_gate]`), if declared. Read by
585    /// `registry::command_path_gate` → `pathgate::should_deny`.
586    pub(super) path_gate: Option<crate::pathgate::RoleSpec>,
587    /// Top-level classifying flags (`[[command.flag]]`), lowered from `TomlSubFlag`. A present flag
588    /// classifies the whole invocation as its archetype — read by `registry::command_flag_archetypes`
589    /// → `engine::resolve::resolve` (the flat-command analog of a profiled sub's escalating flags).
590    pub(super) archetype_flags: Vec<FlagProvenance>,
591    /// Declarative facet behavior (`[command.behavior]`), lowered to typed facet enums. Read
592    /// by `registry::command_behavior` → `engine::resolve::resolve_behavior`. When present,
593    /// the engine classifies this command from its declared facets instead of a Rust resolver.
594    pub(super) behavior: Option<BehaviorSpec>,
595    pub(super) kind: DispatchKind,
596}
597
598/// A command's declarative facet behavior, lowered from `[command.behavior]` (`TomlBehavior`)
599/// with every facet string resolved to its enum at build time. The generic resolver reads this
600/// plus the tokens and builds a `Profile`. Clone so it can be attached uniformly across the
601/// `build_command` construction sites.
602#[derive(Debug, Clone)]
603pub(crate) struct BehaviorSpec {
604    pub operation: crate::engine::facet::Operation,
605    pub positionals: PositionalRole,
606    pub scale: ScaleModel,
607    /// Behavior's own flag grammar, pre-split for the shared `walk_positionals`.
608    pub short: Vec<u8>,
609    pub valued_short: Vec<u8>,
610    pub long: Vec<String>,
611    pub valued_long: Vec<String>,
612    pub numeric_shorthand: bool,
613    /// Flags whose presence widens the scale to unbounded (`rm -r`, `grep -r`).
614    pub unbounded_flags: Vec<String>,
615    /// Valued flags whose VALUE is a path to gate (`touch -r REF` reads REF), with its role.
616    pub path_flags: Vec<PathFlag>,
617    pub hook: Option<BehaviorHook>,
618    /// Transfer semantics, present iff `positionals == Transfer`.
619    pub transfer: Option<TransferSpec>,
620}
621
622/// A valued flag whose value is a path safe-chains gates by locus. One spelling per entry (a
623/// flag with both short and long forms is two entries); the resolver scans for each.
624#[derive(Debug, Clone)]
625pub(crate) struct PathFlag {
626    pub short: Option<u8>,
627    pub long: Option<String>,
628    pub role: PathRole,
629}
630
631/// The role a path-flag's value plays — read (gated by read locus) or write (by write locus).
632#[derive(Debug, Clone, Copy, PartialEq, Eq)]
633pub(crate) enum PathRole {
634    Read,
635    Write,
636}
637
638/// Lowered `[command.behavior.transfer]` — the per-command transfer knobs, terms resolved.
639#[derive(Debug, Clone)]
640pub(crate) struct TransferSpec {
641    pub source: TransferSource,
642    pub no_clobber_flags: Vec<String>,
643    pub clobber_flags: Vec<String>,
644    pub recursive_flags: Vec<String>,
645}
646
647/// The source-operand operation of a transfer command.
648#[derive(Debug, Clone, Copy, PartialEq, Eq)]
649pub(crate) enum TransferSource {
650    /// cp/ln: read the source into the destination/link (no disclosure to the model).
651    Observe,
652    /// mv: remove the source from its old location (trivially reversible).
653    Relocate,
654}
655
656/// The closed set of operand-role shapes (§ design doc: the `Operands` enum, as data).
657#[derive(Debug, Clone, Copy, PartialEq, Eq)]
658pub(crate) enum PositionalRole {
659    None,
660    Read,
661    Write,
662    PatternThenRead,
663    Transfer,
664}
665
666/// How a command's `Scale` is computed from its operands.
667#[derive(Debug, Clone, Copy, PartialEq, Eq)]
668pub(crate) enum ScaleModel {
669    /// Every operation is a single item regardless of operand count (cat/head).
670    Single,
671    /// Count, glob, or a recursion flag widen it (`breadth_scale`) — rm/mkdir.
672    Breadth,
673}
674
675/// A named thin resolver hook for irreducible token logic a declaration can't express — a
676/// command whose operand syntax is not getopt positional (grep's pattern disambiguation, dd's
677/// `key=value`, tar's dashless mode bundles, sed's mini-language script). The hook parses the
678/// tokens; the facets still come from the declaration + the builders.
679#[derive(Debug, Clone, Copy, PartialEq, Eq)]
680pub(crate) enum BehaviorHook {
681    Grep,
682    Dd,
683    Tar,
684    Sed,
685}
686
687/// Runtime form of a `[[command.sub.flag]]` — the engine-relevant part of an escalating flag: its
688/// `name` (matched against the tokens) and the archetype it `classifies` as when present. Its
689/// research provenance (`fact`/`source`/`judgment`) lives on the TOML side and is validated at build
690/// time, not carried here.
691#[derive(Debug, Clone)]
692pub(super) struct FlagProvenance {
693    pub name: String,
694    pub classifies: String,
695    /// See `TomlSubFlag::value_prefix` — `None` = escalate on presence; `Some` = only when the
696    /// flag's value starts with this.
697    pub value_prefix: Option<String>,
698    /// See `TomlSubFlag::when_absent` — escalate when the flag is ABSENT (a safety flag whose
699    /// absence is the risk).
700    pub when_absent: bool,
701}
702
703#[derive(Debug, Clone)]
704pub(super) struct SubSpec {
705    pub name: String,
706    pub kind: DispatchKind,
707    /// The facet archetype this sub is classified as (`archetypes.toml`), if declared via
708    /// `profile = …`. The engine resolves the sub to this archetype's static capability profile
709    /// (`registry::sub_archetype`), deriving the verdict rather than taking a hand-marked level.
710    /// (Its research provenance — `fact`/`source`/`judgment` — lives on the TOML side only and is
711    /// validated at build time; it is not carried on the runtime spec.)
712    pub profile: Option<String>,
713    /// Escalating flags: each, when present, adds `classifies`'s capability to the resolved profile.
714    pub flags: Vec<FlagProvenance>,
715    /// If this sub was declared with `policy = "key"`, the referenced
716    /// handler_policy name is preserved for docs rendering so a sub
717    /// that points at a policy also shown in **Shared flag sets** can
718    /// render as a reference rather than duplicating the flag list.
719    pub policy_ref: Option<String>,
720    /// True when this sub's leaf invocation is tagged as safe-to-eval.
721    /// Walked by `registry::is_eval_safe_invocation()`.
722    pub eval_safe: bool,
723    /// Flag allowlist extending `eval_safe` — flags permitted in the
724    /// substituted invocation when the walker stops at this sub.
725    pub eval_safe_flags: Vec<String>,
726    /// Per-valued-flag value allowlist (same semantics as on
727    /// `CommandSpec`).
728    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
729    /// Flags where at least one must appear in the substituted
730    /// invocation (same semantics as on `CommandSpec`).
731    pub eval_safe_required_flags: Vec<String>,
732    /// `true` = classify this sub's first positional as a network destination onto
733    /// `locus.provenance` (see `TomlSub::network_destination`).
734    pub network_destination: bool,
735    /// A flag that overrides the positional destination (`git push --repo=…`); see
736    /// `TomlSub::destination_flag`.
737    pub destination_flag: Option<String>,
738    /// Output-file flags for a `data-export` sub; a present one adds a path-gated write capability
739    /// at the file's locus (see `TomlSub::output_path_flags`).
740    pub output_path_flags: Vec<String>,
741}
742
743#[derive(Debug, Clone)]
744pub(super) enum DispatchKind {
745    Policy {
746        policy: OwnedPolicy,
747        level: SafetyLevel,
748    },
749    FirstArg {
750        patterns: Vec<String>,
751        level: SafetyLevel,
752    },
753    RequireAny {
754        require_any: Vec<String>,
755        policy: OwnedPolicy,
756        level: SafetyLevel,
757        accept_bare_help: bool,
758    },
759    Branching {
760        subs: Vec<SubSpec>,
761        bare_flags: Vec<String>,
762        bare_ok: bool,
763        pre_standalone: Vec<String>,
764        pre_valued: Vec<String>,
765        first_arg: Vec<String>,
766        first_arg_level: SafetyLevel,
767        /// First-positional globs that classify the invocation as a credential-read (deny), checked
768        /// after explicit subs and before the allow-glob. Empty for almost every command.
769        credential_first_arg: Vec<String>,
770    },
771    WriteFlagged {
772        policy: OwnedPolicy,
773        base_level: SafetyLevel,
774        write_flags: Vec<String>,
775    },
776    DelegateAfterSeparator {
777        separator: String,
778    },
779    DelegateSkip {
780        skip: usize,
781    },
782    Wrapper {
783        standalone: Vec<String>,
784        valued: Vec<String>,
785        positional_skip: usize,
786        separator: Option<String>,
787        bare_ok: bool,
788    },
789    /// A `verb-chain` grammar (`mlr`): a strict main-flag region + a `then`-chain of
790    /// allowlisted verbs. See `dispatch::dispatch_verb_chain`.
791    VerbChain(VerbChainSpec),
792    /// A code-execution command whose verdict is the execution-origin gate (worktree code
793    /// allows, foreign denies), not a flat level. See `dispatch::dispatch_executor` and
794    /// docs/design/behavioral-taxonomy-execution-origin.md.
795    Executor {
796        policy: OwnedPolicy,
797        /// Verdict for a flag-only invocation with no executor (`python3 --version`).
798        level: SafetyLevel,
799        kind: ExecutorKind,
800        /// A valued flag whose value REDIRECTS the executor out of the project
801        /// (`cargo run --manifest-path DIR/Cargo.toml`) — its value is locus-gated like a
802        /// file executor. Only meaningful for `ExecutorKind::Project`.
803        redirect_flag: Option<String>,
804        /// A predicate the executor path must satisfy, else deny (`ExecutorKind::File`).
805        /// `go run` uses `go-package` so a remote import path (`rsc.io/x@latest`) is not
806        /// treated as a worktree executor.
807        shape: Option<crate::policy::PositionalShape>,
808    },
809    Custom {
810        #[allow(dead_code)]
811        handler_name: String,
812        doc_body: Option<String>,
813        /// TOML-declared subs the handler may consult via
814        /// `registry::try_sub_dispatch()`. Empty unless the handler
815        /// uses the helper.
816        subs: Vec<SubSpec>,
817        /// TOML-declared alternate grammar the handler may consult
818        /// via `registry::try_fallback_grammar()`. `None` unless the
819        /// handler uses the helper.
820        fallback: Option<FallbackSpec>,
821        /// Named flag policies the handler consults via
822        /// `registry::check_handler_policy()`. Empty unless the handler
823        /// has dispatch logic that picks a policy by name at runtime.
824        handler_policies: std::collections::HashMap<String, OwnedPolicy>,
825        /// Sub × action matrices the handler walks via
826        /// `registry::try_matrix_dispatch()`.
827        matrices: Vec<MatrixSpec>,
828    },
829}
830
831/// How a code-execution command locates its executor. `File`: the first positional is the
832/// executor path (`bash x.sh`, `python3 x.py`, `go run ./cmd`). `Project`: the current
833/// project is the executor and there is no path operand (`cargo run`, `dotnet run`).
834#[derive(Debug, Clone, Copy, PartialEq, Eq)]
835pub(super) enum ExecutorKind {
836    File,
837    Project,
838}
839
840impl ExecutorKind {
841    pub(super) fn from_name(name: &str) -> Option<Self> {
842        match name {
843            "file" => Some(Self::File),
844            "project" => Some(Self::Project),
845            _ => None,
846        }
847    }
848}
849
850#[derive(Debug, Clone)]
851pub struct OwnedPolicy {
852    pub standalone: Vec<String>,
853    pub valued: Vec<String>,
854    pub bare: bool,
855    pub max_positional: Option<usize>,
856    pub tolerance: crate::policy::FlagTolerance,
857}
858
859#[derive(Debug, Clone)]
860pub(super) struct MatrixSpec {
861    pub parents: Vec<String>,
862    pub level: SafetyLevel,
863    pub actions: std::collections::HashMap<String, MatrixAction>,
864}
865
866#[derive(Debug, Clone)]
867pub(super) struct MatrixAction {
868    pub policy_key: String,
869    pub guard: Option<String>,
870    pub guard_short: Option<String>,
871}
872
873#[derive(Debug, Clone)]
874pub(super) struct VerbChainSpec {
875    pub level: SafetyLevel,
876    pub separator: String,
877    pub main_standalone: Vec<String>,
878    pub main_valued: Vec<String>,
879    pub main_variadic: Vec<String>,
880    pub verbs: std::collections::HashSet<String>,
881}
882
883#[derive(Debug, Clone)]
884pub(super) struct FallbackSpec {
885    pub policy: OwnedPolicy,
886    pub level: SafetyLevel,
887    pub positional_shape: Option<crate::policy::PositionalShape>,
888    /// When set, the first positional is an EXECUTOR (a script/package the command runs),
889    /// gated by the execution-origin engine instead of the flat `level`. `ExecutorKind::File`
890    /// is the only form used by fallbacks (interpreters). See `dispatch::dispatch_executor`.
891    pub executor: Option<ExecutorKind>,
892    /// See `DispatchKind::Executor::redirect_flag`. Unused for `File` fallbacks.
893    pub executor_redirect_flag: Option<String>,
894}