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}