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    /// Shortcut: every invocation of this command is denied. Used in custom
83    /// TOMLs to lock down a built-in (e.g. `name = "gh", deny = true` in
84    /// `.safe-chains.toml` denies every gh form for that project).
85    #[serde(default)]
86    pub deny: Option<bool>,
87    /// Alternate grammar engaged when standard sub-dispatch finds no match.
88    /// Only meaningful for handler-using commands (e.g. tilt's Ruby template
89    /// engine fallback when no Kubernetes tilt sub matches). The handler is
90    /// responsible for invoking it via `registry::try_fallback_grammar()`.
91    #[serde(default)]
92    pub fallback: Option<TomlFallback>,
93    /// Named flag policies the handler references by string key. Used when
94    /// a handler's dispatch logic genuinely can't move to TOML (e.g. gh's
95    /// sub × action matrix) but the per-policy WordSets are still data that
96    /// should live in TOML. The handler reads them via
97    /// `registry::check_handler_policy(cmd, key, tokens)`.
98    #[serde(default)]
99    pub handler_policy: std::collections::HashMap<String, TomlHandlerPolicy>,
100    /// Parent × action → policy matrices. One block declares: "for
101    /// these parent subcommand names, each of these action verbs maps
102    /// to a named `handler_policy` and validates at this safety level."
103    /// Lets handlers express their dispatch tables as data instead of
104    /// `match` arms. Walked by `registry::try_matrix_dispatch()`.
105    #[serde(default)]
106    pub matrix: Vec<TomlMatrix>,
107}
108
109#[derive(Debug, Deserialize)]
110pub(super) struct TomlMatrix {
111    pub parents: Vec<String>,
112    pub level: TomlLevel,
113    pub actions: std::collections::HashMap<String, TomlMatrixAction>,
114}
115
116#[derive(Debug, Deserialize)]
117#[serde(untagged)]
118pub(super) enum TomlMatrixAction {
119    /// Shorthand: `list = "policy_name"` — references handler_policy by
120    /// name; no guard required.
121    Policy(String),
122    /// Detailed form: `download = { policy = "release_download", guard
123    /// = "--output", guard_short = "-O" }`. The guard flag must be
124    /// present in the action's args for the dispatch to succeed.
125    Detailed {
126        policy: String,
127        #[serde(default)]
128        guard: Option<String>,
129        #[serde(default)]
130        guard_short: Option<String>,
131    },
132}
133
134#[derive(Debug, Deserialize)]
135pub(super) struct TomlHandlerPolicy {
136    #[serde(default)]
137    pub standalone: Vec<String>,
138    #[serde(default)]
139    pub valued: Vec<String>,
140    #[serde(default)]
141    pub bare: Option<bool>,
142    #[serde(default)]
143    pub max_positional: Option<usize>,
144    #[serde(default)]
145    pub tolerate_unknown_short: Option<bool>,
146    #[serde(default)]
147    pub tolerate_unknown_long: Option<bool>,
148    #[serde(default)]
149    pub numeric_dash: Option<bool>,
150}
151
152#[derive(Debug, Deserialize)]
153pub(super) struct TomlFallback {
154    #[serde(default)]
155    pub level: Option<TomlLevel>,
156    #[serde(default)]
157    pub bare: Option<bool>,
158    #[serde(default)]
159    pub max_positional: Option<usize>,
160    #[serde(default)]
161    pub standalone: Vec<String>,
162    #[serde(default)]
163    pub valued: Vec<String>,
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    /// Named predicate the handler applies to the first positional arg.
171    /// Currently the only value is `"path"` — accepts a token shaped like
172    /// a file path (contains `/`, `.`, or is `-` for stdin). Adding new
173    /// shapes is a one-line `PositionalShape` enum addition plus a match
174    /// arm in `policy::positional_matches_shape()`.
175    #[serde(default)]
176    pub positional_shape: Option<String>,
177}
178
179#[derive(Debug, Deserialize)]
180pub(super) struct TomlWrapper {
181    #[serde(default)]
182    pub standalone: Vec<String>,
183    #[serde(default)]
184    pub valued: Vec<String>,
185    #[serde(default)]
186    pub positional_skip: Option<usize>,
187    #[serde(default)]
188    pub separator: Option<String>,
189    #[serde(default)]
190    pub bare_ok: Option<bool>,
191}
192
193#[derive(Debug, Deserialize)]
194pub(super) struct TomlSub {
195    pub name: String,
196    #[serde(default)]
197    pub candidate: Option<bool>,
198    #[serde(default)]
199    pub aliases: Vec<String>,
200    #[serde(default)]
201    pub level: Option<TomlLevel>,
202    #[serde(default)]
203    pub bare: Option<bool>,
204    #[serde(default)]
205    pub max_positional: Option<usize>,
206    /// Removed; see TomlCommand::positional_style.
207    #[serde(default)]
208    pub positional_style: Option<bool>,
209    #[serde(default)]
210    pub tolerate_unknown_short: Option<bool>,
211    #[serde(default)]
212    pub tolerate_unknown_long: Option<bool>,
213    #[serde(default)]
214    pub numeric_dash: Option<bool>,
215    #[serde(default)]
216    pub standalone: Vec<String>,
217    #[serde(default)]
218    pub valued: Vec<String>,
219    #[serde(default)]
220    pub guard: Option<String>,
221    #[serde(default)]
222    pub guard_short: Option<String>,
223    #[serde(default)]
224    pub allow_all: Option<bool>,
225    /// Reference a `[command.handler_policy.KEY]` block by name, copying
226    /// its standalone/valued/bare/etc. into this sub's effective policy.
227    /// Lets a single-sub form (search, browse, gh status) re-use the
228    /// same flag list a matrix entry would, without duplicating the
229    /// WordSets. Mutually exclusive with inline standalone/valued.
230    #[serde(default)]
231    pub policy: Option<String>,
232    #[serde(default)]
233    pub sub: Vec<TomlSub>,
234    #[serde(default)]
235    pub nested_bare: Option<bool>,
236    #[serde(default)]
237    pub require_any: Vec<String>,
238    #[serde(default)]
239    pub first_arg: Vec<String>,
240    #[serde(default)]
241    pub write_flags: Vec<String>,
242    #[serde(default)]
243    pub delegate_after: Option<String>,
244    #[serde(default)]
245    pub delegate_skip: Option<usize>,
246    #[serde(default)]
247    pub handler: Option<String>,
248    #[serde(default)]
249    pub doc_body: Option<String>,
250    /// Marks this sub's leaf invocation as safe inside
251    /// `eval "$(CMD SUB ...)"`. The leaf is the deepest matched dispatch
252    /// node — if this sub has nested sub-subs and the invocation matches
253    /// deeper, the tag does NOT apply; the sub-sub must be tagged itself.
254    /// Unset = not eval-safe (the default).
255    #[serde(default)]
256    pub eval_safe: Option<bool>,
257    /// Flag allowlist that extends `eval_safe = true` — these `-`-prefixed
258    /// tokens are also permitted inside the substitution. Default empty,
259    /// meaning only the bare form plus positionals are eval-safe.
260    /// Build panics if this is set without `eval_safe = true`.
261    #[serde(default)]
262    pub eval_safe_flags: Vec<String>,
263}
264
265#[derive(Debug, Clone, Copy, Deserialize)]
266pub(super) enum TomlLevel {
267    Inert,
268    SafeRead,
269    SafeWrite,
270}
271
272impl From<TomlLevel> for SafetyLevel {
273    fn from(l: TomlLevel) -> Self {
274        match l {
275            TomlLevel::Inert => SafetyLevel::Inert,
276            TomlLevel::SafeRead => SafetyLevel::SafeRead,
277            TomlLevel::SafeWrite => SafetyLevel::SafeWrite,
278        }
279    }
280}
281
282#[derive(Debug)]
283pub struct CommandSpec {
284    pub name: String,
285    pub description: String,
286    pub aliases: Vec<String>,
287    pub url: String,
288    pub category: String,
289    /// Upstream version of the underlying tool that was researched
290    /// when this spec was last updated. Free-form string — e.g.
291    /// `"1.9.0"`, `"v5.10.3"`, `"2026-05-08 master"`,
292    /// `"@northflank/cli 0.10.15"`. Internal-only: not rendered in
293    /// docs or used at runtime. Surfaces in tests and as a tripwire
294    /// when researching newer versions of the same tool.
295    pub researched_version: Option<String>,
296    /// Sample invocations that the registry test runs through `is_safe_command`.
297    /// Each `examples_safe` entry must produce `Verdict::Allowed`.
298    pub examples_safe: Vec<String>,
299    /// Sample invocations that must be denied. Use these to lock in security
300    /// boundaries (e.g. `srb tc --metrics-file=/etc/passwd` should always
301    /// be denied; recording it here catches regressions).
302    pub examples_denied: Vec<String>,
303    /// True when this command's bare invocation (no sub) is tagged as
304    /// safe-to-eval. Walked by `registry::is_eval_safe_invocation()`.
305    pub eval_safe: bool,
306    /// Flag allowlist extending `eval_safe` — flags permitted in the
307    /// substituted invocation when the walker stops at this node.
308    pub eval_safe_flags: Vec<String>,
309    pub(super) kind: DispatchKind,
310}
311
312#[derive(Debug, Clone)]
313pub(super) struct SubSpec {
314    pub name: String,
315    pub kind: DispatchKind,
316    /// If this sub was declared with `policy = "key"`, the referenced
317    /// handler_policy name is preserved for docs rendering so a sub
318    /// that points at a policy also shown in **Shared flag sets** can
319    /// render as a reference rather than duplicating the flag list.
320    pub policy_ref: Option<String>,
321    /// True when this sub's leaf invocation is tagged as safe-to-eval.
322    /// Walked by `registry::is_eval_safe_invocation()`.
323    pub eval_safe: bool,
324    /// Flag allowlist extending `eval_safe` — flags permitted in the
325    /// substituted invocation when the walker stops at this sub.
326    pub eval_safe_flags: Vec<String>,
327}
328
329#[derive(Debug, Clone)]
330pub(super) enum DispatchKind {
331    Policy {
332        policy: OwnedPolicy,
333        level: SafetyLevel,
334    },
335    FirstArg {
336        patterns: Vec<String>,
337        level: SafetyLevel,
338    },
339    RequireAny {
340        require_any: Vec<String>,
341        policy: OwnedPolicy,
342        level: SafetyLevel,
343        accept_bare_help: bool,
344    },
345    Branching {
346        subs: Vec<SubSpec>,
347        bare_flags: Vec<String>,
348        bare_ok: bool,
349        pre_standalone: Vec<String>,
350        pre_valued: Vec<String>,
351        first_arg: Vec<String>,
352        first_arg_level: SafetyLevel,
353    },
354    WriteFlagged {
355        policy: OwnedPolicy,
356        base_level: SafetyLevel,
357        write_flags: Vec<String>,
358    },
359    DelegateAfterSeparator {
360        separator: String,
361    },
362    DelegateSkip {
363        skip: usize,
364    },
365    Wrapper {
366        standalone: Vec<String>,
367        valued: Vec<String>,
368        positional_skip: usize,
369        separator: Option<String>,
370        bare_ok: bool,
371    },
372    Custom {
373        #[allow(dead_code)]
374        handler_name: String,
375        doc_body: Option<String>,
376        /// TOML-declared subs the handler may consult via
377        /// `registry::try_sub_dispatch()`. Empty unless the handler
378        /// uses the helper.
379        subs: Vec<SubSpec>,
380        /// TOML-declared alternate grammar the handler may consult
381        /// via `registry::try_fallback_grammar()`. `None` unless the
382        /// handler uses the helper.
383        fallback: Option<FallbackSpec>,
384        /// Named flag policies the handler consults via
385        /// `registry::check_handler_policy()`. Empty unless the handler
386        /// has dispatch logic that picks a policy by name at runtime.
387        handler_policies: std::collections::HashMap<String, OwnedPolicy>,
388        /// Sub × action matrices the handler walks via
389        /// `registry::try_matrix_dispatch()`.
390        matrices: Vec<MatrixSpec>,
391    },
392}
393
394#[derive(Debug, Clone)]
395pub struct OwnedPolicy {
396    pub standalone: Vec<String>,
397    pub valued: Vec<String>,
398    pub bare: bool,
399    pub max_positional: Option<usize>,
400    pub tolerance: crate::policy::FlagTolerance,
401}
402
403#[derive(Debug, Clone)]
404pub(super) struct MatrixSpec {
405    pub parents: Vec<String>,
406    pub level: SafetyLevel,
407    pub actions: std::collections::HashMap<String, MatrixAction>,
408}
409
410#[derive(Debug, Clone)]
411pub(super) struct MatrixAction {
412    pub policy_key: String,
413    pub guard: Option<String>,
414    pub guard_short: Option<String>,
415}
416
417#[derive(Debug, Clone)]
418pub(super) struct FallbackSpec {
419    pub policy: OwnedPolicy,
420    pub level: SafetyLevel,
421    pub positional_shape: Option<crate::policy::PositionalShape>,
422}