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    pub command: Vec<TomlCommand>,
8}
9
10#[derive(Debug, Deserialize)]
11pub(super) struct TomlCommand {
12    pub name: String,
13    #[serde(default)]
14    pub description: Option<String>,
15    #[serde(default)]
16    pub candidate: Option<bool>,
17    #[serde(default)]
18    pub aliases: Vec<String>,
19    #[serde(default)]
20    pub url: String,
21    #[serde(default)]
22    pub level: Option<TomlLevel>,
23    #[serde(default)]
24    pub bare: Option<bool>,
25    #[serde(default)]
26    pub max_positional: Option<usize>,
27    /// Removed in favor of `tolerate_unknown_short` / `tolerate_unknown_long`.
28    /// Build panics if any TOML still sets this — see SAMPLE.toml for the
29    /// migration guidance. Kept on the deserializer struct so the panic
30    /// message can name the offending command instead of a serde error.
31    #[serde(default)]
32    pub positional_style: Option<bool>,
33    #[serde(default)]
34    pub tolerate_unknown_short: Option<bool>,
35    #[serde(default)]
36    pub tolerate_unknown_long: Option<bool>,
37    #[serde(default)]
38    pub numeric_dash: Option<bool>,
39    #[serde(default)]
40    pub standalone: Vec<String>,
41    #[serde(default)]
42    pub valued: Vec<String>,
43    #[serde(default)]
44    pub bare_flags: Vec<String>,
45    #[serde(default)]
46    pub sub: Vec<TomlSub>,
47    #[serde(default)]
48    pub handler: Option<String>,
49    #[serde(default)]
50    pub doc_body: Option<String>,
51    #[serde(default)]
52    pub require_any: Vec<String>,
53    #[serde(default)]
54    pub first_arg: Vec<String>,
55    #[serde(default)]
56    pub wrapper: Option<TomlWrapper>,
57    #[serde(default)]
58    pub write_flags: Vec<String>,
59    #[serde(default)]
60    pub researched_version: Option<String>,
61    /// Sample invocations that double as test fixtures.
62    /// `examples_safe` must validate as Allowed; `examples_denied` must validate as Denied.
63    /// Use these to exercise aliases and canonical forms (e.g. `mise use` and `mise u`)
64    /// so drift between the TOML and runtime dispatch fails the test suite.
65    #[serde(default)]
66    pub examples_safe: Vec<String>,
67    #[serde(default)]
68    pub examples_denied: Vec<String>,
69    /// Marks this command's leaf invocation as safe inside
70    /// `eval "$(CMD ...)"`. Set on flat commands whose stdout is documented
71    /// shell-init code (e.g. `ssh-agent`). The leaf is the deepest matched
72    /// dispatch node — tagging here does NOT propagate to subs; each sub
73    /// must be tagged independently. Unset = not eval-safe (the default).
74    #[serde(default)]
75    pub eval_safe: Option<bool>,
76    /// Flag allowlist that extends `eval_safe = true` — these `-`-prefixed
77    /// tokens are also permitted inside the substitution. Default empty,
78    /// meaning only the bare form plus positionals are eval-safe.
79    /// Build panics if this is set without `eval_safe = true`.
80    #[serde(default)]
81    pub eval_safe_flags: Vec<String>,
82    /// Per-valued-flag value allowlist. Maps each valued flag (which
83    /// MUST also appear in `eval_safe_flags`) to the set of values
84    /// permitted in eval substitutions. Use for tools where the flag's
85    /// value determines stdout shape (`aws --format env` vs
86    /// `--format json`). Default empty = no value restriction beyond
87    /// the bare-literal alphabet.
88    #[serde(default)]
89    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
90    /// Flags where AT LEAST ONE must appear in the eval substitution.
91    /// Use for tools whose bare invocation isn't shell-init code:
92    /// `fzf` is interactive without `--bash|--zsh|--fish|--nushell`.
93    /// Every entry must also appear in `eval_safe_flags`. Default
94    /// empty = no required flags (bare invocation is fine).
95    #[serde(default)]
96    pub eval_safe_required_flags: Vec<String>,
97    /// Shortcut: every invocation of this command is denied. Used in custom
98    /// TOMLs to lock down a built-in (e.g. `name = "gh", deny = true` in
99    /// `.safe-chains.toml` denies every gh form for that project).
100    #[serde(default)]
101    pub deny: Option<bool>,
102    /// Alternate grammar engaged when standard sub-dispatch finds no match.
103    /// Only meaningful for handler-using commands (e.g. tilt's Ruby template
104    /// engine fallback when no Kubernetes tilt sub matches). The handler is
105    /// responsible for invoking it via `registry::try_fallback_grammar()`.
106    #[serde(default)]
107    pub fallback: Option<TomlFallback>,
108    /// Named flag policies the handler references by string key. Used when
109    /// a handler's dispatch logic genuinely can't move to TOML (e.g. gh's
110    /// sub × action matrix) but the per-policy WordSets are still data that
111    /// should live in TOML. The handler reads them via
112    /// `registry::check_handler_policy(cmd, key, tokens)`.
113    #[serde(default)]
114    pub handler_policy: std::collections::HashMap<String, TomlHandlerPolicy>,
115    /// Parent × action → policy matrices. One block declares: "for
116    /// these parent subcommand names, each of these action verbs maps
117    /// to a named `handler_policy` and validates at this safety level."
118    /// Lets handlers express their dispatch tables as data instead of
119    /// `match` arms. Walked by `registry::try_matrix_dispatch()`.
120    #[serde(default)]
121    pub matrix: Vec<TomlMatrix>,
122}
123
124#[derive(Debug, Deserialize)]
125#[serde(deny_unknown_fields)]
126pub(super) struct TomlMatrix {
127    pub parents: Vec<String>,
128    pub level: TomlLevel,
129    pub actions: std::collections::HashMap<String, TomlMatrixAction>,
130}
131
132#[derive(Debug, Deserialize)]
133#[serde(untagged)]
134pub(super) enum TomlMatrixAction {
135    /// Shorthand: `list = "policy_name"` — references handler_policy by
136    /// name; no guard required.
137    Policy(String),
138    /// Detailed form: `download = { policy = "release_download", guard
139    /// = "--output", guard_short = "-O" }`. The guard flag must be
140    /// present in the action's args for the dispatch to succeed.
141    Detailed(TomlMatrixActionDetailed),
142}
143
144#[derive(Debug, Deserialize)]
145#[serde(deny_unknown_fields)]
146pub(super) struct TomlMatrixActionDetailed {
147    pub policy: String,
148    #[serde(default)]
149    pub guard: Option<String>,
150    #[serde(default)]
151    pub guard_short: Option<String>,
152}
153
154#[derive(Debug, Deserialize)]
155pub(super) struct TomlHandlerPolicy {
156    #[serde(default)]
157    pub standalone: Vec<String>,
158    #[serde(default)]
159    pub valued: Vec<String>,
160    #[serde(default)]
161    pub bare: Option<bool>,
162    #[serde(default)]
163    pub max_positional: Option<usize>,
164    #[serde(default)]
165    pub tolerate_unknown_short: Option<bool>,
166    #[serde(default)]
167    pub tolerate_unknown_long: Option<bool>,
168    #[serde(default)]
169    pub numeric_dash: Option<bool>,
170}
171
172#[derive(Debug, Deserialize)]
173pub(super) struct TomlFallback {
174    #[serde(default)]
175    pub level: Option<TomlLevel>,
176    #[serde(default)]
177    pub bare: Option<bool>,
178    #[serde(default)]
179    pub max_positional: Option<usize>,
180    #[serde(default)]
181    pub standalone: Vec<String>,
182    #[serde(default)]
183    pub valued: Vec<String>,
184    #[serde(default)]
185    pub tolerate_unknown_short: Option<bool>,
186    #[serde(default)]
187    pub tolerate_unknown_long: Option<bool>,
188    #[serde(default)]
189    pub numeric_dash: Option<bool>,
190    /// Named predicate the handler applies to the first positional arg.
191    /// Currently the only value is `"path"` — accepts a token shaped like
192    /// a file path (contains `/`, `.`, or is `-` for stdin). Adding new
193    /// shapes is a one-line `PositionalShape` enum addition plus a match
194    /// arm in `policy::positional_matches_shape()`.
195    #[serde(default)]
196    pub positional_shape: Option<String>,
197}
198
199#[derive(Debug, Deserialize)]
200pub(super) struct TomlWrapper {
201    #[serde(default)]
202    pub standalone: Vec<String>,
203    #[serde(default)]
204    pub valued: Vec<String>,
205    #[serde(default)]
206    pub positional_skip: Option<usize>,
207    #[serde(default)]
208    pub separator: Option<String>,
209    #[serde(default)]
210    pub bare_ok: Option<bool>,
211}
212
213#[derive(Debug, Deserialize)]
214pub(super) struct TomlSub {
215    pub name: String,
216    #[serde(default)]
217    pub candidate: Option<bool>,
218    #[serde(default)]
219    pub aliases: Vec<String>,
220    #[serde(default)]
221    pub level: Option<TomlLevel>,
222    #[serde(default)]
223    pub bare: Option<bool>,
224    #[serde(default)]
225    pub max_positional: Option<usize>,
226    /// Removed; see TomlCommand::positional_style.
227    #[serde(default)]
228    pub positional_style: Option<bool>,
229    #[serde(default)]
230    pub tolerate_unknown_short: Option<bool>,
231    #[serde(default)]
232    pub tolerate_unknown_long: Option<bool>,
233    #[serde(default)]
234    pub numeric_dash: Option<bool>,
235    #[serde(default)]
236    pub standalone: Vec<String>,
237    #[serde(default)]
238    pub valued: Vec<String>,
239    #[serde(default)]
240    pub guard: Option<String>,
241    #[serde(default)]
242    pub guard_short: Option<String>,
243    #[serde(default)]
244    pub allow_all: Option<bool>,
245    /// Reference a `[command.handler_policy.KEY]` block by name, copying
246    /// its standalone/valued/bare/etc. into this sub's effective policy.
247    /// Lets a single-sub form (search, browse, gh status) re-use the
248    /// same flag list a matrix entry would, without duplicating the
249    /// WordSets. Mutually exclusive with inline standalone/valued.
250    #[serde(default)]
251    pub policy: Option<String>,
252    #[serde(default)]
253    pub sub: Vec<TomlSub>,
254    #[serde(default)]
255    pub nested_bare: Option<bool>,
256    #[serde(default)]
257    pub require_any: Vec<String>,
258    #[serde(default)]
259    pub first_arg: Vec<String>,
260    #[serde(default)]
261    pub write_flags: Vec<String>,
262    #[serde(default)]
263    pub delegate_after: Option<String>,
264    #[serde(default)]
265    pub delegate_skip: Option<usize>,
266    #[serde(default)]
267    pub handler: Option<String>,
268    #[serde(default)]
269    pub doc_body: Option<String>,
270    /// Marks this sub's leaf invocation as safe inside
271    /// `eval "$(CMD SUB ...)"`. The leaf is the deepest matched dispatch
272    /// node — if this sub has nested sub-subs and the invocation matches
273    /// deeper, the tag does NOT apply; the sub-sub must be tagged itself.
274    /// Unset = not eval-safe (the default).
275    #[serde(default)]
276    pub eval_safe: Option<bool>,
277    /// Flag allowlist that extends `eval_safe = true` — these `-`-prefixed
278    /// tokens are also permitted inside the substitution. Default empty,
279    /// meaning only the bare form plus positionals are eval-safe.
280    /// Build panics if this is set without `eval_safe = true`.
281    #[serde(default)]
282    pub eval_safe_flags: Vec<String>,
283    /// Per-valued-flag value allowlist (same semantics as the
284    /// command-level field). Maps each valued flag (which MUST also
285    /// appear in `eval_safe_flags`) to its permitted values.
286    #[serde(default)]
287    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
288    /// Flags where AT LEAST ONE must appear (same semantics as the
289    /// command-level field).
290    #[serde(default)]
291    pub eval_safe_required_flags: Vec<String>,
292}
293
294#[derive(Debug, Clone, Copy, Deserialize)]
295pub(super) enum TomlLevel {
296    Inert,
297    SafeRead,
298    SafeWrite,
299}
300
301impl From<TomlLevel> for SafetyLevel {
302    fn from(l: TomlLevel) -> Self {
303        match l {
304            TomlLevel::Inert => SafetyLevel::Inert,
305            TomlLevel::SafeRead => SafetyLevel::SafeRead,
306            TomlLevel::SafeWrite => SafetyLevel::SafeWrite,
307        }
308    }
309}
310
311#[derive(Debug)]
312pub struct CommandSpec {
313    pub name: String,
314    pub description: String,
315    pub aliases: Vec<String>,
316    pub url: String,
317    pub category: String,
318    /// Upstream version of the underlying tool that was researched
319    /// when this spec was last updated. Free-form string — e.g.
320    /// `"1.9.0"`, `"v5.10.3"`, `"2026-05-08 master"`,
321    /// `"@northflank/cli 0.10.15"`. Internal-only: not rendered in
322    /// docs or used at runtime. Surfaces in tests and as a tripwire
323    /// when researching newer versions of the same tool.
324    pub researched_version: Option<String>,
325    /// Sample invocations that the registry test runs through `is_safe_command`.
326    /// Each `examples_safe` entry must produce `Verdict::Allowed`.
327    pub examples_safe: Vec<String>,
328    /// Sample invocations that must be denied. Use these to lock in security
329    /// boundaries (e.g. `srb tc --metrics-file=/etc/passwd` should always
330    /// be denied; recording it here catches regressions).
331    pub examples_denied: Vec<String>,
332    /// True when this command's bare invocation (no sub) is tagged as
333    /// safe-to-eval. Walked by `registry::is_eval_safe_invocation()`.
334    pub eval_safe: bool,
335    /// Flag allowlist extending `eval_safe` — flags permitted in the
336    /// substituted invocation when the walker stops at this node.
337    pub eval_safe_flags: Vec<String>,
338    /// Per-valued-flag value allowlist. When the walker hits a flag
339    /// listed here, the value following the flag (separated by `=` or
340    /// space) must be in this list.
341    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
342    /// Flags where at least one must appear in the substituted
343    /// invocation. Empty = no required-flag constraint.
344    pub eval_safe_required_flags: Vec<String>,
345    pub(super) kind: DispatchKind,
346}
347
348#[derive(Debug, Clone)]
349pub(super) struct SubSpec {
350    pub name: String,
351    pub kind: DispatchKind,
352    /// If this sub was declared with `policy = "key"`, the referenced
353    /// handler_policy name is preserved for docs rendering so a sub
354    /// that points at a policy also shown in **Shared flag sets** can
355    /// render as a reference rather than duplicating the flag list.
356    pub policy_ref: Option<String>,
357    /// True when this sub's leaf invocation is tagged as safe-to-eval.
358    /// Walked by `registry::is_eval_safe_invocation()`.
359    pub eval_safe: bool,
360    /// Flag allowlist extending `eval_safe` — flags permitted in the
361    /// substituted invocation when the walker stops at this sub.
362    pub eval_safe_flags: Vec<String>,
363    /// Per-valued-flag value allowlist (same semantics as on
364    /// `CommandSpec`).
365    pub eval_safe_flag_values: std::collections::HashMap<String, Vec<String>>,
366    /// Flags where at least one must appear in the substituted
367    /// invocation (same semantics as on `CommandSpec`).
368    pub eval_safe_required_flags: Vec<String>,
369}
370
371#[derive(Debug, Clone)]
372pub(super) enum DispatchKind {
373    Policy {
374        policy: OwnedPolicy,
375        level: SafetyLevel,
376    },
377    FirstArg {
378        patterns: Vec<String>,
379        level: SafetyLevel,
380    },
381    RequireAny {
382        require_any: Vec<String>,
383        policy: OwnedPolicy,
384        level: SafetyLevel,
385        accept_bare_help: bool,
386    },
387    Branching {
388        subs: Vec<SubSpec>,
389        bare_flags: Vec<String>,
390        bare_ok: bool,
391        pre_standalone: Vec<String>,
392        pre_valued: Vec<String>,
393        first_arg: Vec<String>,
394        first_arg_level: SafetyLevel,
395    },
396    WriteFlagged {
397        policy: OwnedPolicy,
398        base_level: SafetyLevel,
399        write_flags: Vec<String>,
400    },
401    DelegateAfterSeparator {
402        separator: String,
403    },
404    DelegateSkip {
405        skip: usize,
406    },
407    Wrapper {
408        standalone: Vec<String>,
409        valued: Vec<String>,
410        positional_skip: usize,
411        separator: Option<String>,
412        bare_ok: bool,
413    },
414    Custom {
415        #[allow(dead_code)]
416        handler_name: String,
417        doc_body: Option<String>,
418        /// TOML-declared subs the handler may consult via
419        /// `registry::try_sub_dispatch()`. Empty unless the handler
420        /// uses the helper.
421        subs: Vec<SubSpec>,
422        /// TOML-declared alternate grammar the handler may consult
423        /// via `registry::try_fallback_grammar()`. `None` unless the
424        /// handler uses the helper.
425        fallback: Option<FallbackSpec>,
426        /// Named flag policies the handler consults via
427        /// `registry::check_handler_policy()`. Empty unless the handler
428        /// has dispatch logic that picks a policy by name at runtime.
429        handler_policies: std::collections::HashMap<String, OwnedPolicy>,
430        /// Sub × action matrices the handler walks via
431        /// `registry::try_matrix_dispatch()`.
432        matrices: Vec<MatrixSpec>,
433    },
434}
435
436#[derive(Debug, Clone)]
437pub struct OwnedPolicy {
438    pub standalone: Vec<String>,
439    pub valued: Vec<String>,
440    pub bare: bool,
441    pub max_positional: Option<usize>,
442    pub tolerance: crate::policy::FlagTolerance,
443}
444
445#[derive(Debug, Clone)]
446pub(super) struct MatrixSpec {
447    pub parents: Vec<String>,
448    pub level: SafetyLevel,
449    pub actions: std::collections::HashMap<String, MatrixAction>,
450}
451
452#[derive(Debug, Clone)]
453pub(super) struct MatrixAction {
454    pub policy_key: String,
455    pub guard: Option<String>,
456    pub guard_short: Option<String>,
457}
458
459#[derive(Debug, Clone)]
460pub(super) struct FallbackSpec {
461    pub policy: OwnedPolicy,
462    pub level: SafetyLevel,
463    pub positional_shape: Option<crate::policy::PositionalShape>,
464}