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
11/// The `per_database` sub that `arg` is a variant of: `arg` is `<sub.name>:<dbname>`.
12///
13/// The suffix must be ONE plain identifier — the shape of a key in `config/database.yml`. That is
14/// what keeps this from degenerating into "ignore anything after a colon": a further `:` would
15/// name a different task, and a suffix carrying a `/`, a space or a substitution is not a database
16/// name and must not inherit a schema task's classification.
17fn per_database_variant<'a>(subs: &'a [SubSpec], arg: &str) -> Option<&'a SubSpec> {
18    let (base, dbname) = arg.rsplit_once(':')?;
19    let plain = !dbname.is_empty() && dbname.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
20    // The character test alone is not enough, and failing to say so was a fail-open in the first
21    // cut of this: a substitution reaches dispatch already rewritten to `__SAFE_CHAINS_CMDSUB__`,
22    // which is alphanumeric-and-underscore and sailed straight through as a "plain identifier".
23    // `db:migrate:$(anything)` was admitted as a per-database variant.
24    if !plain || crate::cst::check::is_opaque_value(dbname) {
25        return None;
26    }
27    subs.iter().find(|s| s.name_match == NameMatch::WithDatabaseSuffix && s.name == base)
28}
29
30fn short_flag_char(s: &str) -> Option<char> {
31    let bytes = s.as_bytes();
32    if bytes.len() == 2 && bytes[0] == b'-' && bytes[1] != b'-' { s.chars().nth(1) } else { None }
33}
34
35fn is_combined_short(s: &str) -> bool {
36    let bytes = s.as_bytes();
37    bytes.len() > 2 && bytes[0] == b'-' && bytes[1] != b'-'
38}
39
40fn dispatch_first_arg(
41    tokens: &[Token],
42    patterns: &[String],
43    level: SafetyLevel,
44    standalone: &[String],
45    valued: &[String],
46    loopback_valued: &[String],
47) -> Verdict {
48    if tokens.len() == 2 && (tokens[1] == "--help" || tokens[1] == "-h") {
49        return Verdict::Allowed(SafetyLevel::Inert);
50    }
51    let Some(arg) = tokens.get(1) else {
52        return Verdict::Denied;
53    };
54    let arg_str = arg.as_str();
55    let matches = patterns
56        .iter()
57        .any(|p| if let Some(prefix) = p.strip_suffix('*') { arg_str.starts_with(prefix) } else { arg_str == p });
58    if !matches {
59        return Verdict::Denied;
60    }
61    if super::glob_presents_unlisted_flag(tokens, 2, standalone, valued, loopback_valued) {
62        return Verdict::Denied;
63    }
64    Verdict::Allowed(level)
65}
66
67fn dispatch_require_any(
68    tokens: &[Token],
69    require_any: &[String],
70    policy: &OwnedPolicy,
71    level: SafetyLevel,
72    accept_bare_help: bool,
73) -> Verdict {
74    if tokens.len() == 2 {
75        let t = tokens[1].as_str();
76        if t == "--help" || t == "-h" || (accept_bare_help && t == "help") {
77            return Verdict::Allowed(SafetyLevel::Inert);
78        }
79    }
80    let has_required = tokens[1..].iter().any(|t| {
81        require_any.iter().any(|r| {
82            let t_str = t.as_str();
83            if t_str == r.as_str() {
84                return true;
85            }
86            if r.starts_with("--") && t_str.starts_with(&format!("{r}=")) {
87                return true;
88            }
89            if let Some(short_char) = short_flag_char(r)
90                && is_combined_short(t_str)
91                && t_str[1..].contains(short_char)
92            {
93                return true;
94            }
95            false
96        })
97    });
98    if has_required && check_owned(tokens, policy) { Verdict::Allowed(level) } else { Verdict::Denied }
99}
100
101/// Whether `s` is a rustup toolchain selector (`+nightly`, `+1.90.0`, `+nightly-2026-01-01`).
102///
103/// A POSITIVE shape test, so an unrecognized spelling is simply not a selector and dispatch
104/// proceeds without stripping it (which then denies on the unknown token). The selector names an
105/// ALREADY-INSTALLED toolchain — rustup resolves `+name` through its own toolchain list and cannot
106/// be pointed at an arbitrary directory — so the shape is restricted to what a toolchain name can
107/// be. `/`, `~`, `$`, `.` and `..` are excluded by construction rather than by exclusion: only
108/// alphanumerics and `._-` are admitted, and a name must start alphanumeric so `+..` cannot form.
109fn is_toolchain_selector(s: &str) -> bool {
110    let Some(name) = s.strip_prefix('+') else { return false };
111    !name.is_empty()
112        && name.starts_with(|c: char| c.is_ascii_alphanumeric())
113        && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))
114}
115
116fn skip_pre_flags(tokens: &[Token], pre_standalone: &[String], pre_valued: &[String], toolchain_selector: bool, start: usize) -> usize {
117    let mut i = start;
118    // At most ONE, and only in front. `cargo +nightly build` selects which toolchain runs `build`;
119    // it does not change what `build` does, which is why the sub dispatch below is reached
120    // unchanged. Bounded to a single leading token so a `+`-prefixed OPERAND deeper in the line is
121    // never silently eaten.
122    if toolchain_selector
123        && let Some(t) = tokens.get(i)
124        && is_toolchain_selector(t.as_str())
125    {
126        i += 1;
127    }
128    while i < tokens.len() {
129        let t = &tokens[i];
130        let s = t.as_str();
131        if !s.starts_with('-') {
132            break;
133        }
134        if pre_valued.contains_flag(s) {
135            i += 2;
136            continue;
137        }
138        if let Some((flag, _)) = s.split_once('=')
139            && pre_valued.contains_flag(flag)
140        {
141            i += 1;
142            continue;
143        }
144        if pre_standalone.contains_flag(s) {
145            i += 1;
146            continue;
147        }
148        // POSIX-style short-flag cluster (`-vv`, `-vy`): every byte after
149        // the dash must be a known standalone short. Mirrors the same
150        // logic in policy::check_flags for non-wrapper subs.
151        let bytes = s.as_bytes();
152        if bytes.len() > 2 && bytes[1] != b'-' && bytes[1..].iter().all(|&b| pre_standalone.contains_short(b)) {
153            i += 1;
154            continue;
155        }
156        break;
157    }
158    i
159}
160
161/// The first-positional GLOB arm of a `Branching`: which verbs it admits, at what level, the flags
162/// it accepts once admitted, and the verbs that instead mark the invocation a credential-read.
163struct GlobArm<'a> {
164    patterns: &'a [String],
165    level: SafetyLevel,
166    standalone: &'a [String],
167    valued: &'a [String],
168    loopback_valued: &'a [String],
169    credential: &'a [String],
170}
171
172fn dispatch_branching(
173    tokens: &[Token],
174    subs: &[SubSpec],
175    bare_flags: &[String],
176    bare_ok: bool,
177    pre_flags: (&[String], &[String]),
178    toolchain_selector: bool,
179    glob: &GlobArm<'_>,
180) -> Verdict {
181    let (pre_standalone, pre_valued) = pre_flags;
182    let start = skip_pre_flags(tokens, pre_standalone, pre_valued, toolchain_selector, 1);
183    if start >= tokens.len() {
184        return if bare_ok { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
185    }
186    let arg = tokens[start].as_str();
187    let is_bare_flag = bare_flags.iter().any(|f| f == arg) || (bare_flags.is_empty() && matches!(arg, "--help" | "-h"));
188    if is_bare_flag {
189        let after = skip_pre_flags(tokens, pre_standalone, pre_valued, toolchain_selector, start + 1);
190        if after >= tokens.len() {
191            return Verdict::Allowed(SafetyLevel::Inert);
192        }
193        if bare_flags.is_empty() {
194            return Verdict::Denied;
195        }
196    }
197    if let Some(sub) = subs.iter().find(|s| s.name == arg) {
198        return dispatch_kind(&tokens[start..], &sub.kind, &SUB_HANDLERS);
199    }
200    // A per-database variant: `db:migrate:primary` is `db:migrate` aimed at one of the databases
201    // it would otherwise migrate all of. Only subs that OPT IN (`per_database`) match this way,
202    // and only for a single trailing plain identifier — see `TomlSub::per_database` for why the
203    // set cannot be enumerated and why inheriting the base's classification cannot widen it.
204    if let Some(sub) = per_database_variant(subs, arg) {
205        return dispatch_kind(&tokens[start..], &sub.kind, &SUB_HANDLERS);
206    }
207    let glob_match = |p: &str| match p.strip_suffix('*') {
208        Some(prefix) => arg.starts_with(prefix),
209        None => arg == p,
210    };
211    // A first positional naming secret material (`kubectl get secret`/`secret/x`, `aws configure get
212    // aws_secret_access_key`) is a CREDENTIAL-READ — deny before the allow-glob admits it. The
213    // value-dependent complement to `profile=credential-read`; `arg` is already flag-aware. Matched
214    // CASE-INSENSITIVELY: kubectl resource kinds are case-insensitive (`get Secret`/`SECRET` reads
215    // secrets), so a case-variant must not slip past the deny into the `*` allow. The allow-glob
216    // below stays case-sensitive — a case-insensitive ALLOW would be fail-open.
217    let glob_match_ci = |p: &str| match p.strip_suffix('*') {
218        Some(prefix) => arg.len() >= prefix.len() && arg.as_bytes()[..prefix.len()].eq_ignore_ascii_case(prefix.as_bytes()),
219        None => arg.eq_ignore_ascii_case(p),
220    };
221    if glob.credential.iter().any(|p| glob_match_ci(p)) {
222        return Verdict::Denied;
223    }
224    if !glob.patterns.is_empty() && glob.patterns.iter().any(|p| glob_match(p)) {
225        if super::glob_presents_unlisted_flag(tokens, start + 1, glob.standalone, glob.valued, glob.loopback_valued) {
226            return Verdict::Denied;
227        }
228        return Verdict::Allowed(glob.level);
229    }
230    Verdict::Denied
231}
232
233fn dispatch_wrapper(
234    tokens: &[Token],
235    standalone: &[String],
236    valued: &[String],
237    positional_skip: usize,
238    separator: Option<&str>,
239    bare_ok: bool,
240) -> Verdict {
241    let mut i = 1;
242    while i < tokens.len() {
243        let t = &tokens[i];
244        if let Some(sep) = separator
245            && t == sep
246        {
247            i += 1;
248            break;
249        }
250        if !t.starts_with('-') {
251            break;
252        }
253        if valued.iter().any(|f| t == f.as_str()) {
254            i += 2;
255            continue;
256        }
257        if valued.iter().any(|f| t.as_str().starts_with(&format!("{f}="))) {
258            i += 1;
259            continue;
260        }
261        if standalone.iter().any(|f| t == f.as_str()) {
262            i += 1;
263            continue;
264        }
265        return Verdict::Denied;
266    }
267    for _ in 0..positional_skip {
268        if i >= tokens.len() {
269            return if bare_ok { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
270        }
271        i += 1;
272    }
273    if i >= tokens.len() {
274        return if bare_ok { Verdict::Allowed(SafetyLevel::Inert) } else { Verdict::Denied };
275    }
276    let inner = shell_words::join(tokens[i..].iter().map(|t| t.as_str()));
277    crate::command_verdict(&inner)
278}
279
280fn dispatch_kind(tokens: &[Token], kind: &DispatchKind, handlers: &HandlerMap) -> Verdict {
281    match kind {
282        DispatchKind::Policy { policy, level } => {
283            if check_owned(tokens, policy) {
284                Verdict::Allowed(*level)
285            } else {
286                Verdict::Denied
287            }
288        }
289        DispatchKind::FirstArg { patterns, level, standalone, valued, loopback_valued } => {
290            dispatch_first_arg(tokens, patterns, *level, standalone, valued, loopback_valued)
291        }
292        DispatchKind::RequireAny { require_any, policy, level, accept_bare_help } => {
293            dispatch_require_any(tokens, require_any, policy, *level, *accept_bare_help)
294        }
295        DispatchKind::Branching {
296            subs,
297            bare_flags,
298            bare_ok,
299            pre_standalone,
300            pre_valued,
301            first_arg,
302            first_arg_level,
303            first_arg_standalone,
304            first_arg_valued,
305            first_arg_loopback_valued,
306            credential_first_arg,
307            toolchain_selector,
308        } => dispatch_branching(
309            tokens,
310            subs,
311            bare_flags,
312            *bare_ok,
313            (pre_standalone, pre_valued),
314            *toolchain_selector,
315            &GlobArm {
316                patterns: first_arg,
317                level: *first_arg_level,
318                standalone: first_arg_standalone,
319                valued: first_arg_valued,
320                loopback_valued: first_arg_loopback_valued,
321                credential: credential_first_arg,
322            },
323        ),
324        DispatchKind::WriteFlagged { policy, base_level, write_flags } => {
325            if !check_owned(tokens, policy) {
326                return Verdict::Denied;
327            }
328            let has_write = tokens[1..]
329                .iter()
330                .any(|t| write_flags.iter().any(|f| t == f.as_str() || t.as_str().starts_with(&format!("{f}="))));
331            if has_write { Verdict::Allowed(SafetyLevel::SafeWrite) } else { Verdict::Allowed(*base_level) }
332        }
333        DispatchKind::DelegateAfterSeparator { separator } => {
334            let sep_pos = tokens[1..].iter().position(|t| t == separator.as_str());
335            let Some(pos) = sep_pos else {
336                return Verdict::Denied;
337            };
338            let inner_start = pos + 2;
339            if inner_start >= tokens.len() {
340                return Verdict::Denied;
341            }
342            let inner = shell_words::join(tokens[inner_start..].iter().map(|t| t.as_str()));
343            crate::command_verdict(&inner)
344        }
345        DispatchKind::DelegateSkip { skip } => {
346            if tokens.len() <= *skip {
347                return Verdict::Denied;
348            }
349            let inner = shell_words::join(tokens[*skip..].iter().map(|t| t.as_str()));
350            crate::command_verdict(&inner)
351        }
352        DispatchKind::Wrapper { standalone, valued, positional_skip, separator, bare_ok } => {
353            dispatch_wrapper(tokens, standalone, valued, *positional_skip, separator.as_deref(), *bare_ok)
354        }
355        DispatchKind::VerbChain(spec) => dispatch_verb_chain(tokens, spec),
356        DispatchKind::Executor { policy, level, kind, redirect_flag, shape, passes_argv } => {
357            dispatch_executor(tokens, policy, *kind, *level, redirect_flag.as_deref(), *shape, *passes_argv)
358        }
359        DispatchKind::Custom { handler_name, .. } => handlers.get(handler_name.as_str()).map(|f| f(tokens)).unwrap_or(Verdict::Denied),
360    }
361}
362
363pub fn dispatch_spec(tokens: &[Token], spec: &CommandSpec) -> Verdict {
364    let verdict = dispatch_kind(tokens, &spec.kind, &CMD_HANDLERS);
365    if !spec.env_assignment_positionals {
366        return verdict;
367    }
368    // `export LD_PRELOAD=/tmp/evil.so` puts the variable in the environment of every command the
369    // shell spawns afterwards, so it is the `LD_PRELOAD=/tmp/evil.so cmd` prefix wearing different
370    // syntax. Classify each assignment the same way rather than letting the second spelling through.
371    tokens[1..]
372        .iter()
373        .filter(|t| !t.starts_with('-'))
374        .filter_map(|t| t.as_str().split_once('='))
375        .map(|(name, value)| crate::envvars::assignment_verdict(name, value))
376        .fold(verdict, Verdict::combine)
377}
378
379/// Dispatches a sub's kind directly, used by `registry::try_sub_dispatch`
380/// when a handler-using command consults its TOML-declared subs.
381pub(super) fn dispatch_sub_kind(tokens: &[Token], kind: &DispatchKind) -> Verdict {
382    dispatch_kind(tokens, kind, &SUB_HANDLERS)
383}
384
385pub(super) fn check_handler_policy_owned(tokens: &[Token], policy: &OwnedPolicy) -> bool {
386    check_owned(tokens, policy)
387}
388
389pub(super) fn dispatch_matrix_action(tokens: &[Token], policy: &OwnedPolicy, level: SafetyLevel) -> Verdict {
390    if check_owned(tokens, policy) { Verdict::Allowed(level) } else { Verdict::Denied }
391}
392
393/// A `verb-chain` grammar (`mlr`): `CMD [main-flags…] verb [args…] then verb …`. The main-flag
394/// region is a STRICT allowlist (standalone / valued / variadic) — an unlisted flag denies, so a
395/// mutating flag like mlr's `-I`/`--in-place` (omitted) is caught by omission. The region ends at
396/// `--` or the first non-flag token, which opens the verb chain: every `then`-segment's first token
397/// (the verb NAME) must be on the `verbs` allowlist; verb ARGS are open-ended (a pure verb has no
398/// shell/file escape). See `types::VerbChainSpec`.
399pub(super) fn dispatch_verb_chain(tokens: &[Token], spec: &VerbChainSpec) -> Verdict {
400    if tokens.len() == 1 {
401        return Verdict::Denied;
402    }
403    let has = |set: &[String], s: &str| set.iter().any(|f| f == s);
404    let mut i = 1;
405    while i < tokens.len() {
406        let s = tokens[i].as_str();
407        // `--` terminates the main region; the verb chain follows it.
408        if s == "--" {
409            return verb_chain_tail(&tokens[i + 1..], spec);
410        }
411        // The first non-flag token opens the verb chain.
412        if !tokens[i].starts_with('-') {
413            return verb_chain_tail(&tokens[i..], spec);
414        }
415        if has(&spec.main_standalone, s) {
416            i += 1;
417        } else if has(&spec.main_variadic, s) {
418            // Consume input files up to the `--` terminator (or end); the `--` is left for the
419            // loop to treat as the main/verb separator next iteration.
420            i += 1;
421            while i < tokens.len() && tokens[i].as_str() != "--" {
422                i += 1;
423            }
424        } else if has(&spec.main_valued, s) {
425            i += if crate::policy::consumes_next_value(tokens.get(i + 1)) { 2 } else { 1 };
426        } else if s.split_once('=').is_some_and(|(f, _)| has(&spec.main_valued, f)) {
427            i += 1;
428        } else {
429            return Verdict::Denied;
430        }
431    }
432    // Only allowlisted main flags, no verb (`mlr --version`, `mlr --csv`): nothing runs.
433    Verdict::Allowed(spec.level)
434}
435
436/// The verb region: a `then`-chain where every segment's first token must be an allowlisted verb.
437fn verb_chain_tail(region: &[Token], spec: &VerbChainSpec) -> Verdict {
438    let mut expect_verb = true;
439    for t in region {
440        if t.as_str() == spec.separator {
441            expect_verb = true;
442            continue;
443        }
444        if expect_verb {
445            if !spec.verbs.contains(t.as_str()) {
446                return Verdict::Denied;
447            }
448            expect_verb = false;
449        }
450    }
451    // An empty region or a dangling separator leaves `expect_verb` set — no valid verb ran.
452    if expect_verb { Verdict::Denied } else { Verdict::Allowed(spec.level) }
453}
454
455/// Gate a code-execution command through the execution-origin engine (worktree code allows,
456/// foreign denies) instead of a flat level. Shared by executor SUBS (`go run`, `cargo run`)
457/// and executor FALLBACKS (interpreters). See docs/design/behavioral-taxonomy-execution-origin.md.
458///
459/// - `File`: the first positional is the executor path — `bash x.sh`, `go run ./cmd`. Tokens
460///   AFTER it are the script's own arguments (data, not validated). With no positional, it's a
461///   flag-only invocation (`python3 --version`) validated by `policy` (denies a bare REPL).
462/// - `Project`: the current project is the executor — `cargo run`. A `redirect_flag` value
463///   (`--manifest-path DIR/Cargo.toml`) moves the executor OUT of the project and is locus-gated
464///   like a file executor; without it, the project (worktree) is the executor.
465pub(super) fn dispatch_executor(
466    tokens: &[Token],
467    policy: &OwnedPolicy,
468    kind: ExecutorKind,
469    level: SafetyLevel,
470    redirect_flag: Option<&str>,
471    shape: Option<crate::policy::PositionalShape>,
472    passes_argv: bool,
473) -> Verdict {
474    match kind {
475        ExecutorKind::File => match super::policy::first_positional_at(tokens, policy).and_then(|(at, first)| {
476            // How much of the invocation the command's own grammar governs.
477            //
478            // This used to check NOTHING once a first positional resolved, so `max_positional`
479            // went unenforced and a command that OPENS its extra positionals was handed them:
480            // `karma start ./ok.conf.js /etc/evil.conf.js` was admitted, the second path being
481            // a second config karma loads and runs. `karma` and `tilt` each grew a `path_gate`
482            // to compensate, which worked but left the next such command to inherit the hole.
483            //
484            // Checking only the PREFIX up to the executor — TODO's first suggestion — does not
485            // fix it, which was measured rather than reasoned: the prefix contains exactly one
486            // positional by construction, so `max_positional = 1` always passes and the second
487            // config is still never counted. That approach is a no-op for the case it targets.
488            //
489            // So the command DECLARES whether trailing tokens are its own. An interpreter
490            // passes them to the script (`python3 ./task.py --flag arg`) and its grammar
491            // genuinely cannot describe them, so only the prefix is checked. Everything else is
492            // governed in full, which is the enforcing default a new entry gets for free.
493            let governed = if passes_argv { &tokens[..=at] } else { tokens };
494            check_owned(governed, policy).then_some(first)
495        }) {
496            // `-` is STDIN, not a file. Every interpreter reads its program from stdin when given
497            // it, so the code being run is not in the workspace and is not in the command string
498            // either — the classifier cannot see it at all. It was resolving as a bare relative
499            // path, which classifies worktree-local, so `curl … | python3 -` auto-approved: remote
500            // code execution straight through the allowlist. `/dev/stdin` and `/dev/fd/0` already
501            // denied as absolute foreign paths; only the short spelling slipped.
502            Some("-") => Verdict::Denied,
503            // A declared shape the executor path must satisfy (`go run` → `go-package`):
504            // a remote import path is not a worktree executor, so it denies here.
505            Some(first) if shape.is_some_and(|s| !s.matches(first)) => Verdict::Denied,
506            // Only the locus, NOT `check_owned`. For an interpreter every token after the script
507            // is the SCRIPT's argv (`python3 ./task.py --flag arg`), so the command's own flag
508            // grammar cannot describe it. The cost is that `max_positional` goes unenforced here
509            // — see TODO.md; a command that needs its positionals gated declares a `path_gate`
510            // with the `exec` role instead of relying on this.
511            Some(first) => crate::engine::resolve::execute_file_verdict(first),
512            None if check_owned(tokens, policy) => Verdict::Allowed(level),
513            None => Verdict::Denied,
514        },
515        ExecutorKind::Project => {
516            if !check_owned(tokens, policy) {
517                return Verdict::Denied;
518            }
519            match redirect_flag.and_then(|f| flag_value(tokens, f)) {
520                Some(redirected) => crate::engine::resolve::execute_file_verdict(redirected),
521                None => crate::engine::resolve::execute_project_verdict(),
522            }
523        }
524    }
525}
526
527/// The value of valued flag `flag` in `tokens` — the space form (`--manifest-path P`) or the
528/// glued `=` form (`--manifest-path=P`). Scans only up to a `--` terminator, so a program
529/// argument after `--` can't be mistaken for the flag's value. `None` if the flag is absent.
530fn flag_value<'a>(tokens: &'a [Token], flag: &str) -> Option<&'a str> {
531    let mut i = 1;
532    while i < tokens.len() {
533        let t = tokens[i].as_str();
534        if t == "--" {
535            return None;
536        }
537        if t == flag {
538            return tokens.get(i + 1).map(Token::as_str);
539        }
540        if let Some(rest) = t.strip_prefix(flag).and_then(|r| r.strip_prefix('=')) {
541            return Some(rest);
542        }
543        i += 1;
544    }
545    None
546}
547
548/// Applies a TOML-declared fallback grammar. Used by
549/// `registry::try_fallback_grammar()`.
550pub(super) fn dispatch_fallback(tokens: &[Token], spec: &FallbackSpec) -> Verdict {
551    if let Some(kind) = spec.executor {
552        return dispatch_executor(
553            tokens,
554            &spec.policy,
555            kind,
556            spec.level,
557            spec.executor_redirect_flag.as_deref(),
558            spec.positional_shape,
559            spec.passes_argv,
560        );
561    }
562    if let Some(shape) = spec.positional_shape
563        && let Some(first) = super::policy::first_positional(tokens, &spec.policy)
564        && !shape.matches(first)
565    {
566        return Verdict::Denied;
567    }
568    if !check_owned(tokens, &spec.policy) {
569        return Verdict::Denied;
570    }
571    Verdict::Allowed(spec.level)
572}
573
574#[cfg(test)]
575mod toolchain_selector_tests {
576    use crate::is_safe_command;
577
578    /// The selector is OPT-IN, and must stay that way. Stripping a leading `+token` for every
579    /// structured command would silently swallow a first positional on tools that have no such
580    /// convention — `+nightly` is a rustup spelling, not a shell one.
581    ///
582    /// Enumerated over commands that dispatch subs but do NOT declare it: the bare sub allows, and
583    /// the same sub behind a `+toolchain` must NOT, because for these tools that token is an
584    /// unknown operand rather than a selector.
585    #[test]
586    fn a_toolchain_selector_is_not_stripped_unless_declared() {
587        for (bare, with_selector) in [
588            ("git log", "git +nightly log"),
589            ("jj log", "jj +nightly log"),
590            ("docker compose logs", "docker +nightly compose logs"),
591            ("go version", "go +nightly version"),
592        ] {
593            assert!(is_safe_command(bare), "precondition: `{bare}` should allow");
594            assert!(
595                !is_safe_command(with_selector),
596                "`{with_selector}`: a toolchain selector was stripped for a command that does not \
597                 declare `toolchain_selector`, so a leading operand is being silently discarded"
598            );
599        }
600    }
601
602    /// ...and IS stripped where declared, for every spelling a toolchain name takes.
603    #[test]
604    fn a_declared_toolchain_selector_is_accepted() {
605        for line in [
606            "cargo +nightly build", "cargo +stable test", "cargo +1.90.0 check", "cargo +nightly-2026-01-01 build",
607            "cargo +nightly-x86_64-apple-darwin build",
608        ] {
609            assert!(is_safe_command(line), "`{line}` should classify as its bare subcommand does");
610        }
611    }
612
613    /// The selector does not launder the SUBCOMMAND: what `cargo publish` is stays what
614    /// `cargo +nightly publish` is. This is the fail-open the strip would cause if it were applied
615    /// before, rather than instead of, the sub's own classification.
616    #[test]
617    fn a_toolchain_selector_does_not_change_its_subcommand() {
618        for sub in ["publish", "install ripgrep", "login"] {
619            let bare = format!("cargo {sub}");
620            let selected = format!("cargo +nightly {sub}");
621            assert_eq!(
622                is_safe_command(&bare),
623                is_safe_command(&selected),
624                "`{selected}` disagrees with `{bare}` — the selector changed the verdict",
625            );
626        }
627    }
628}