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}