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