Skip to main content

safe_chains/registry/
types.rs

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