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}