Skip to main content

safe_chains/registry/
dispatch.rs

1use crate::parse::Token;
2use crate::policy::FlagSet;
3use crate::verdict::{SafetyLevel, Verdict};
4
5use super::policy::check_owned;
6use super::types::*;
7use super::{CMD_HANDLERS, SUB_HANDLERS};
8
9type HandlerMap = std::collections::HashMap<&'static str, super::HandlerFn>;
10
11fn short_flag_char(s: &str) -> Option<char> {
12    let bytes = s.as_bytes();
13    if bytes.len() == 2 && bytes[0] == b'-' && bytes[1] != b'-' {
14        s.chars().nth(1)
15    } else {
16        None
17    }
18}
19
20fn is_combined_short(s: &str) -> bool {
21    let bytes = s.as_bytes();
22    bytes.len() > 2 && bytes[0] == b'-' && bytes[1] != b'-'
23}
24
25fn dispatch_first_arg(tokens: &[Token], patterns: &[String], level: SafetyLevel) -> Verdict {
26    if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
27        return Verdict::Allowed(SafetyLevel::Inert);
28    }
29    let Some(arg) = tokens.get(1) else {
30        return Verdict::Denied;
31    };
32    let arg_str = arg.as_str();
33    let matches = patterns.iter().any(|p| {
34        if let Some(prefix) = p.strip_suffix('*') {
35            arg_str.starts_with(prefix)
36        } else {
37            arg_str == p
38        }
39    });
40    if matches { Verdict::Allowed(level) } else { Verdict::Denied }
41}
42
43fn dispatch_require_any(
44    tokens: &[Token],
45    require_any: &[String],
46    policy: &OwnedPolicy,
47    level: SafetyLevel,
48    accept_bare_help: bool,
49) -> Verdict {
50    if tokens.len() == 2 {
51        let t = tokens[1].as_str();
52        if t == "--help" || t == "-h" || (accept_bare_help && t == "help") {
53            return Verdict::Allowed(SafetyLevel::Inert);
54        }
55    }
56    let has_required = tokens[1..].iter().any(|t| {
57        require_any.iter().any(|r| {
58            let t_str = t.as_str();
59            if t_str == r.as_str() {
60                return true;
61            }
62            if r.starts_with("--") && t_str.starts_with(&format!("{r}=")) {
63                return true;
64            }
65            if let Some(short_char) = short_flag_char(r)
66                && is_combined_short(t_str)
67                && t_str[1..].contains(short_char)
68            {
69                return true;
70            }
71            false
72        })
73    });
74    if has_required && check_owned(tokens, policy) {
75        Verdict::Allowed(level)
76    } else {
77        Verdict::Denied
78    }
79}
80
81fn skip_pre_flags(
82    tokens: &[Token],
83    pre_standalone: &[String],
84    pre_valued: &[String],
85    start: usize,
86) -> usize {
87    let mut i = start;
88    while i < tokens.len() {
89        let t = &tokens[i];
90        let s = t.as_str();
91        if !s.starts_with('-') {
92            break;
93        }
94        if pre_valued.contains_flag(s) {
95            i += 2;
96            continue;
97        }
98        if let Some((flag, _)) = s.split_once('=')
99            && pre_valued.contains_flag(flag)
100        {
101            i += 1;
102            continue;
103        }
104        if pre_standalone.contains_flag(s) {
105            i += 1;
106            continue;
107        }
108        // POSIX-style short-flag cluster (`-vv`, `-vy`): every byte after
109        // the dash must be a known standalone short. Mirrors the same
110        // logic in policy::check_flags for non-wrapper subs.
111        let bytes = s.as_bytes();
112        if bytes.len() > 2
113            && bytes[1] != b'-'
114            && bytes[1..].iter().all(|&b| pre_standalone.contains_short(b))
115        {
116            i += 1;
117            continue;
118        }
119        break;
120    }
121    i
122}
123
124fn dispatch_branching(
125    tokens: &[Token],
126    subs: &[SubSpec],
127    bare_flags: &[String],
128    bare_ok: bool,
129    pre_flags: (&[String], &[String]),
130    first_arg: &[String],
131    first_arg_level: SafetyLevel,
132    credential_first_arg: &[String],
133) -> Verdict {
134    let (pre_standalone, pre_valued) = pre_flags;
135    let start = skip_pre_flags(tokens, pre_standalone, pre_valued, 1);
136    if start >= tokens.len() {
137        return if bare_ok { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
138    }
139    let arg = tokens[start].as_str();
140    let is_bare_flag = bare_flags.iter().any(|f| f == arg)
141        || (bare_flags.is_empty() && matches!(arg, "--help" | "-h"));
142    if is_bare_flag {
143        let after = skip_pre_flags(tokens, pre_standalone, pre_valued, start + 1);
144        if after >= tokens.len() {
145            return Verdict::Allowed(SafetyLevel::Inert);
146        }
147        if bare_flags.is_empty() {
148            return Verdict::Denied;
149        }
150    }
151    if let Some(sub) = subs.iter().find(|s| s.name == arg) {
152        return dispatch_kind(&tokens[start..], &sub.kind, &SUB_HANDLERS);
153    }
154    let glob_match = |p: &str| match p.strip_suffix('*') {
155        Some(prefix) => arg.starts_with(prefix),
156        None => arg == p,
157    };
158    // A first positional naming secret material (`kubectl get secret`/`secret/x`, `aws configure get
159    // aws_secret_access_key`) is a CREDENTIAL-READ — deny before the allow-glob admits it. The
160    // value-dependent complement to `profile=credential-read`; `arg` is already flag-aware. Matched
161    // CASE-INSENSITIVELY: kubectl resource kinds are case-insensitive (`get Secret`/`SECRET` reads
162    // secrets), so a case-variant must not slip past the deny into the `*` allow. The allow-glob
163    // below stays case-sensitive — a case-insensitive ALLOW would be fail-open.
164    let glob_match_ci = |p: &str| match p.strip_suffix('*') {
165        Some(prefix) => {
166            arg.len() >= prefix.len()
167                && arg.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes())
168        }
169        None => arg.eq_ignore_ascii_case(p),
170    };
171    if credential_first_arg.iter().any(|p| glob_match_ci(p)) {
172        return Verdict::Denied;
173    }
174    if !first_arg.is_empty() && first_arg.iter().any(|p| glob_match(p)) {
175        return Verdict::Allowed(first_arg_level);
176    }
177    Verdict::Denied
178}
179
180fn dispatch_wrapper(
181    tokens: &[Token],
182    standalone: &[String],
183    valued: &[String],
184    positional_skip: usize,
185    separator: Option<&str>,
186    bare_ok: bool,
187) -> Verdict {
188    let mut i = 1;
189    while i < tokens.len() {
190        let t = &tokens[i];
191        if let Some(sep) = separator
192            && t == sep
193        {
194            i += 1;
195            break;
196        }
197        if !t.starts_with('-') {
198            break;
199        }
200        if valued.iter().any(|f| t == f.as_str()) {
201            i += 2;
202            continue;
203        }
204        if valued.iter().any(|f| t.as_str().starts_with(&format!("{f}="))) {
205            i += 1;
206            continue;
207        }
208        if standalone.iter().any(|f| t == f.as_str()) {
209            i += 1;
210            continue;
211        }
212        return Verdict::Denied;
213    }
214    for _ in 0..positional_skip {
215        if i >= tokens.len() {
216            return if bare_ok {
217                Verdict::Allowed(SafetyLevel::Inert)
218            } else {
219                Verdict::Denied
220            };
221        }
222        i += 1;
223    }
224    if i >= tokens.len() {
225        return if bare_ok {
226            Verdict::Allowed(SafetyLevel::Inert)
227        } else {
228            Verdict::Denied
229        };
230    }
231    let inner = shell_words::join(tokens[i..].iter().map(|t| t.as_str()));
232    crate::command_verdict(&inner)
233}
234
235fn dispatch_kind(tokens: &[Token], kind: &DispatchKind, handlers: &HandlerMap) -> Verdict {
236    match kind {
237        DispatchKind::Policy { policy, level } => {
238            if check_owned(tokens, policy) {
239                Verdict::Allowed(*level)
240            } else {
241                Verdict::Denied
242            }
243        }
244        DispatchKind::FirstArg { patterns, level } => {
245            dispatch_first_arg(tokens, patterns, *level)
246        }
247        DispatchKind::RequireAny { require_any, policy, level, accept_bare_help } => {
248            dispatch_require_any(tokens, require_any, policy, *level, *accept_bare_help)
249        }
250        DispatchKind::Branching {
251            subs, bare_flags, bare_ok, pre_standalone, pre_valued, first_arg, first_arg_level,
252            credential_first_arg,
253        } => {
254            dispatch_branching(
255                tokens, subs, bare_flags, *bare_ok, (pre_standalone, pre_valued),
256                first_arg, *first_arg_level, credential_first_arg,
257            )
258        }
259        DispatchKind::WriteFlagged { policy, base_level, write_flags } => {
260            if !check_owned(tokens, policy) {
261                return Verdict::Denied;
262            }
263            let has_write = tokens[1..].iter().any(|t| {
264                write_flags.iter().any(|f| t == f.as_str() || t.as_str().starts_with(&format!("{f}=")))
265            });
266            if has_write {
267                Verdict::Allowed(SafetyLevel::SafeWrite)
268            } else {
269                Verdict::Allowed(*base_level)
270            }
271        }
272        DispatchKind::DelegateAfterSeparator { separator } => {
273            let sep_pos = tokens[1..].iter().position(|t| t == separator.as_str());
274            let Some(pos) = sep_pos else {
275                return Verdict::Denied;
276            };
277            let inner_start = pos + 2;
278            if inner_start >= tokens.len() {
279                return Verdict::Denied;
280            }
281            let inner = shell_words::join(tokens[inner_start..].iter().map(|t| t.as_str()));
282            crate::command_verdict(&inner)
283        }
284        DispatchKind::DelegateSkip { skip } => {
285            if tokens.len() <= *skip {
286                return Verdict::Denied;
287            }
288            let inner = shell_words::join(tokens[*skip..].iter().map(|t| t.as_str()));
289            crate::command_verdict(&inner)
290        }
291        DispatchKind::Wrapper {
292            standalone, valued, positional_skip, separator, bare_ok,
293        } => {
294            dispatch_wrapper(tokens, standalone, valued, *positional_skip, separator.as_deref(), *bare_ok)
295        }
296        DispatchKind::VerbChain(spec) => dispatch_verb_chain(tokens, spec),
297        DispatchKind::Executor { policy, level, kind, redirect_flag, shape } => {
298            dispatch_executor(tokens, policy, *kind, *level, redirect_flag.as_deref(), *shape)
299        }
300        DispatchKind::Custom { handler_name, .. } => {
301            handlers
302                .get(handler_name.as_str())
303                .map(|f| f(tokens))
304                .unwrap_or(Verdict::Denied)
305        }
306    }
307}
308
309pub fn dispatch_spec(tokens: &[Token], spec: &CommandSpec) -> Verdict {
310    dispatch_kind(tokens, &spec.kind, &CMD_HANDLERS)
311}
312
313/// Dispatches a sub's kind directly, used by `registry::try_sub_dispatch`
314/// when a handler-using command consults its TOML-declared subs.
315pub(super) fn dispatch_sub_kind(tokens: &[Token], kind: &DispatchKind) -> Verdict {
316    dispatch_kind(tokens, kind, &SUB_HANDLERS)
317}
318
319pub(super) fn check_handler_policy_owned(tokens: &[Token], policy: &OwnedPolicy) -> bool {
320    check_owned(tokens, policy)
321}
322
323pub(super) fn dispatch_matrix_action(
324    tokens: &[Token],
325    policy: &OwnedPolicy,
326    level: SafetyLevel,
327) -> Verdict {
328    if check_owned(tokens, policy) {
329        Verdict::Allowed(level)
330    } else {
331        Verdict::Denied
332    }
333}
334
335/// A `verb-chain` grammar (`mlr`): `CMD [main-flags…] verb [args…] then verb …`. The main-flag
336/// region is a STRICT allowlist (standalone / valued / variadic) — an unlisted flag denies, so a
337/// mutating flag like mlr's `-I`/`--in-place` (omitted) is caught by omission. The region ends at
338/// `--` or the first non-flag token, which opens the verb chain: every `then`-segment's first token
339/// (the verb NAME) must be on the `verbs` allowlist; verb ARGS are open-ended (a pure verb has no
340/// shell/file escape). See `types::VerbChainSpec`.
341pub(super) fn dispatch_verb_chain(tokens: &[Token], spec: &VerbChainSpec) -> Verdict {
342    if tokens.len() == 1 {
343        return Verdict::Denied;
344    }
345    let has = |set: &[String], s: &str| set.iter().any(|f| f == s);
346    let mut i = 1;
347    while i < tokens.len() {
348        let s = tokens[i].as_str();
349        // `--` terminates the main region; the verb chain follows it.
350        if s == "--" {
351            return verb_chain_tail(&tokens[i + 1..], spec);
352        }
353        // The first non-flag token opens the verb chain.
354        if !tokens[i].starts_with('-') {
355            return verb_chain_tail(&tokens[i..], spec);
356        }
357        if has(&spec.main_standalone, s) {
358            i += 1;
359        } else if has(&spec.main_variadic, s) {
360            // Consume input files up to the `--` terminator (or end); the `--` is left for the
361            // loop to treat as the main/verb separator next iteration.
362            i += 1;
363            while i < tokens.len() && tokens[i].as_str() != "--" {
364                i += 1;
365            }
366        } else if has(&spec.main_valued, s) {
367            i += if crate::policy::consumes_next_value(tokens.get(i + 1)) { 2 } else { 1 };
368        } else if s.split_once('=').is_some_and(|(f, _)| has(&spec.main_valued, f)) {
369            i += 1;
370        } else {
371            return Verdict::Denied;
372        }
373    }
374    // Only allowlisted main flags, no verb (`mlr --version`, `mlr --csv`): nothing runs.
375    Verdict::Allowed(spec.level)
376}
377
378/// The verb region: a `then`-chain where every segment's first token must be an allowlisted verb.
379fn verb_chain_tail(region: &[Token], spec: &VerbChainSpec) -> Verdict {
380    let mut expect_verb = true;
381    for t in region {
382        if t.as_str() == spec.separator {
383            expect_verb = true;
384            continue;
385        }
386        if expect_verb {
387            if !spec.verbs.contains(t.as_str()) {
388                return Verdict::Denied;
389            }
390            expect_verb = false;
391        }
392    }
393    // An empty region or a dangling separator leaves `expect_verb` set — no valid verb ran.
394    if expect_verb { Verdict::Denied } else { Verdict::Allowed(spec.level) }
395}
396
397/// Gate a code-execution command through the execution-origin engine (worktree code allows,
398/// foreign denies) instead of a flat level. Shared by executor SUBS (`go run`, `cargo run`)
399/// and executor FALLBACKS (interpreters). See docs/design/behavioral-taxonomy-execution-origin.md.
400///
401/// - `File`: the first positional is the executor path — `bash x.sh`, `go run ./cmd`. Tokens
402///   AFTER it are the script's own arguments (data, not validated). With no positional, it's a
403///   flag-only invocation (`python3 --version`) validated by `policy` (denies a bare REPL).
404/// - `Project`: the current project is the executor — `cargo run`. A `redirect_flag` value
405///   (`--manifest-path DIR/Cargo.toml`) moves the executor OUT of the project and is locus-gated
406///   like a file executor; without it, the project (worktree) is the executor.
407pub(super) fn dispatch_executor(
408    tokens: &[Token],
409    policy: &OwnedPolicy,
410    kind: ExecutorKind,
411    level: SafetyLevel,
412    redirect_flag: Option<&str>,
413    shape: Option<crate::policy::PositionalShape>,
414) -> Verdict {
415    match kind {
416        ExecutorKind::File => match super::policy::first_positional(tokens, policy) {
417            // A declared shape the executor path must satisfy (`go run` → `go-package`):
418            // a remote import path is not a worktree executor, so it denies here.
419            Some(first) if shape.is_some_and(|s| !s.matches(first)) => Verdict::Denied,
420            Some(first) => crate::engine::resolve::execute_file_verdict(first),
421            None if check_owned(tokens, policy) => Verdict::Allowed(level),
422            None => Verdict::Denied,
423        },
424        ExecutorKind::Project => {
425            if !check_owned(tokens, policy) {
426                return Verdict::Denied;
427            }
428            match redirect_flag.and_then(|f| flag_value(tokens, f)) {
429                Some(redirected) => crate::engine::resolve::execute_file_verdict(redirected),
430                None => crate::engine::resolve::execute_project_verdict(),
431            }
432        }
433    }
434}
435
436/// The value of valued flag `flag` in `tokens` — the space form (`--manifest-path P`) or the
437/// glued `=` form (`--manifest-path=P`). Scans only up to a `--` terminator, so a program
438/// argument after `--` can't be mistaken for the flag's value. `None` if the flag is absent.
439fn flag_value<'a>(tokens: &'a [Token], flag: &str) -> Option<&'a str> {
440    let mut i = 1;
441    while i < tokens.len() {
442        let t = tokens[i].as_str();
443        if t == "--" {
444            return None;
445        }
446        if t == flag {
447            return tokens.get(i + 1).map(Token::as_str);
448        }
449        if let Some(rest) = t.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
450            return Some(rest);
451        }
452        i += 1;
453    }
454    None
455}
456
457/// Applies a TOML-declared fallback grammar. Used by
458/// `registry::try_fallback_grammar()`.
459pub(super) fn dispatch_fallback(tokens: &[Token], spec: &FallbackSpec) -> Verdict {
460    if let Some(kind) = spec.executor {
461        return dispatch_executor(
462            tokens, &spec.policy, kind, spec.level,
463            spec.executor_redirect_flag.as_deref(), spec.positional_shape,
464        );
465    }
466    if let Some(shape) = spec.positional_shape
467        && let Some(first) = super::policy::first_positional(tokens, &spec.policy)
468        && !shape.matches(first)
469    {
470        return Verdict::Denied;
471    }
472    if !check_owned(tokens, &spec.policy) {
473        return Verdict::Denied;
474    }
475    Verdict::Allowed(spec.level)
476}