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