Skip to main content

lean_ctx/core/shell_allowlist/
mod.rs

1//! Shell allowlist with AST-based command parsing.
2//!
3//! Security model (Information Bottleneck principle):
4//! - When allowlist is set: ALL segments of a compound command must be allowed (deny-by-default)
5//! - When empty: all commands pass (backwards-compatible blocklist-only mode)
6//! - Dangerous patterns (subshells, eval, backticks) are blocked in restricted mode
7
8mod mode;
9#[cfg(test)]
10mod tests;
11
12pub use mode::ShellSecurity;
13
14/// Checks whether a command may run, honouring the active [`ShellSecurity`] mode
15/// (GL #788). This is the single chokepoint shared by MCP `ctx_shell` and the
16/// CLI shell entrypoints, so the mode applies consistently:
17///
18/// - [`ShellSecurity::Off`] → always `Ok` (gating skipped; compression intact).
19/// - [`ShellSecurity::Warn`] → run the checks, log any violation, return `Ok`.
20/// - [`ShellSecurity::Enforce`] → block on violation (the secure default).
21pub fn check_shell_allowlist(command: &str) -> Result<(), String> {
22    match ShellSecurity::resolve() {
23        ShellSecurity::Off => Ok(()),
24        ShellSecurity::Warn => {
25            if let Err(msg) = enforce_shell_allowlist(command) {
26                tracing::warn!(
27                    target: "shell_security",
28                    "warn-only: would block ({})",
29                    msg.lines().next().unwrap_or("blocked")
30                );
31            }
32            Ok(())
33        }
34        ShellSecurity::Enforce => enforce_shell_allowlist(command),
35    }
36}
37
38/// Allowlist + dangerous-pattern enforcement, evaluated as if in `enforce` mode.
39/// [`check_shell_allowlist`] decides whether a violation blocks, warns, or is
40/// skipped based on the active [`ShellSecurity`] mode.
41///
42/// When the allowlist is empty, all commands pass (blocklist-only mode).
43/// When non-empty, EVERY command segment in the pipeline must match.
44fn enforce_shell_allowlist(command: &str) -> Result<(), String> {
45    let normalized = normalize_line_continuations(command);
46    let cmd = normalized.as_str();
47
48    if has_dangerous_patterns(cmd) {
49        return Err(format!(
50            "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
51             which is blocked regardless of allowlist. \
52             This is a permanent security restriction, not a transient error.\n\
53             Command: {command}"
54        ));
55    }
56
57    let strict = crate::core::config::Config::load().shell_strict_mode;
58    check_substitution_in_args(cmd, strict)?;
59    check_pipe_to_bare_interpreter(cmd, strict)?;
60
61    let allowlist = effective_allowlist();
62    if allowlist.is_empty() {
63        check_unconditional_blocked_only(cmd)?;
64        return Ok(());
65    }
66    check_all_segments(cmd, &allowlist)
67}
68
69/// Normalize the command string: remove backslash-newline continuations and
70/// replace Unicode line separators (U+2028, U+2029) with newlines.
71fn normalize_line_continuations(command: &str) -> String {
72    command
73        .replace("\\\r\n", "")
74        .replace("\\\n", "")
75        .replace(['\u{2028}', '\u{2029}'], "\n")
76}
77
78/// $(), backticks, <() in arguments: warn by default, **block** when
79/// `shell_strict_mode = true` (GH #391 — the strict knob previously only
80/// changed the log line and never actually blocked).
81fn check_substitution_in_args(command: &str, strict: bool) -> Result<(), String> {
82    if has_expanding_substitution_in_args(command) {
83        if strict {
84            tracing::warn!(
85                "[SECURITY] Command substitution in arguments blocked (shell_strict_mode=true): {command}"
86            );
87            return Err(format!(
88                "[BLOCKED — DO NOT RETRY] Command substitution ($(), backticks, <()/>()) in \
89                 arguments is blocked because shell_strict_mode = true. \
90                 This is a permanent security restriction.\n\
91                 Command: {command}"
92            ));
93        }
94        tracing::warn!(
95            "[SECURITY] Command substitution in arguments detected (warn-only, set shell_strict_mode=true to block): {command}"
96        );
97    }
98    Ok(())
99}
100
101/// Check for $(), backticks, <(, >( in arguments wherever the shell would
102/// expand them — i.e. unquoted OR inside double quotes (single quotes inhibit
103/// expansion). `git commit -m "$(cat f)"` expands; `grep '$(x)' f` does not.
104fn has_expanding_substitution_in_args(command: &str) -> bool {
105    let bytes = command.as_bytes();
106    let len = bytes.len();
107    let mut i = 0;
108    let mut in_single_quote = false;
109    let mut seen_space_after_cmd = false;
110
111    while i < len {
112        let ch = bytes[i];
113        if in_single_quote {
114            if ch == b'\'' {
115                in_single_quote = false;
116            }
117            i += 1;
118            continue;
119        }
120        match ch {
121            b'\'' => {
122                in_single_quote = true;
123                i += 1;
124            }
125            b' ' | b'\t' if !seen_space_after_cmd => {
126                seen_space_after_cmd = true;
127                i += 1;
128            }
129            _ if !seen_space_after_cmd => {
130                i += 1;
131            }
132            _ => {
133                if ch == b'$' && i + 1 < len && bytes[i + 1] == b'(' {
134                    return true;
135                }
136                if ch == b'`' {
137                    return true;
138                }
139                if (ch == b'<' || ch == b'>') && i + 1 < len && bytes[i + 1] == b'(' {
140                    return true;
141                }
142                i += 1;
143            }
144        }
145    }
146    false
147}
148
149/// Piping into a bare interpreter (no script file): warn by default, **block**
150/// when `shell_strict_mode = true` (GH #391).
151fn check_pipe_to_bare_interpreter(command: &str, strict: bool) -> Result<(), String> {
152    let segments = split_on_operators(command);
153    let pipe_indices: Vec<usize> = {
154        let mut indices = Vec::new();
155        let bytes = command.as_bytes();
156        let len = bytes.len();
157        let mut j = 0;
158        let mut in_sq = false;
159        let mut in_dq = false;
160        while j < len {
161            if in_sq {
162                if bytes[j] == b'\'' {
163                    in_sq = false;
164                }
165                j += 1;
166                continue;
167            }
168            if in_dq {
169                if bytes[j] == b'"' && (j == 0 || bytes[j - 1] != b'\\') {
170                    in_dq = false;
171                }
172                j += 1;
173                continue;
174            }
175            match bytes[j] {
176                b'\'' => {
177                    in_sq = true;
178                    j += 1;
179                }
180                b'"' => {
181                    in_dq = true;
182                    j += 1;
183                }
184                b'|' if j + 1 < len && bytes[j + 1] != b'|' => {
185                    indices.push(j);
186                    j += 1;
187                }
188                _ => {
189                    j += 1;
190                }
191            }
192        }
193        indices
194    };
195    let _ = pipe_indices;
196
197    for (idx, seg) in segments.iter().enumerate() {
198        if idx == 0 {
199            continue;
200        }
201        if is_bare_interpreter_stdin(seg) {
202            let base = extract_base_from_segment(seg);
203            if strict {
204                tracing::warn!(
205                    "[SECURITY] Pipe to bare interpreter '{base}' blocked (shell_strict_mode=true)"
206                );
207                return Err(format!(
208                    "[BLOCKED — DO NOT RETRY] Piping into bare interpreter '{base}' is blocked \
209                     because shell_strict_mode = true. Run a script file instead.\n\
210                     Command: {command}"
211                ));
212            }
213            tracing::warn!("[SECURITY] Pipe to bare interpreter '{base}' detected (warn-only)");
214        }
215    }
216    Ok(())
217}
218
219/// For empty allowlists: still enforce UNCONDITIONAL_BLOCKED commands.
220fn check_unconditional_blocked_only(command: &str) -> Result<(), String> {
221    let segments = extract_all_commands(command);
222    for seg in &segments {
223        let base = extract_base_from_segment(seg);
224        if !base.is_empty() && UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
225            return Err(format!(
226                "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
227                 regardless of allowlist configuration.\n\
228                 Command: {command}"
229            ));
230        }
231        check_inline_env_block(seg)?;
232        check_interpreter_eval_only(seg)?;
233        check_dangerous_flags(seg)?;
234    }
235    Ok(())
236}
237
238/// Tokenize a shell command segment respecting single/double quotes and backslash escapes.
239/// Returns tokens with outer quotes stripped, matching how the shell would parse them.
240/// E.g. `git -C "Program Files" status` → `["git", "-C", "Program Files", "status"]`
241pub fn shell_tokenize(input: &str) -> Vec<String> {
242    let mut tokens = Vec::new();
243    let mut current = String::new();
244    let mut chars = input.chars().peekable();
245    let mut in_single = false;
246    let mut in_double = false;
247
248    while let Some(c) = chars.next() {
249        match c {
250            '\'' if !in_double => in_single = !in_single,
251            '"' if !in_single => in_double = !in_double,
252            '\\' if !in_single => {
253                if let Some(next) = chars.next() {
254                    current.push(next);
255                }
256            }
257            c if c.is_whitespace() && !in_single && !in_double => {
258                if !current.is_empty() {
259                    tokens.push(std::mem::take(&mut current));
260                }
261            }
262            _ => current.push(c),
263        }
264    }
265    if !current.is_empty() {
266        tokens.push(current);
267    }
268    tokens
269}
270
271/// Returns the byte length of the first shell token in `input`, respecting quotes.
272/// Used by `skip_env_assignments` to advance past env assignments with quoted values
273/// like `FOO="bar baz"`.
274fn quote_aware_token_end(input: &str) -> usize {
275    let bytes = input.as_bytes();
276    let len = bytes.len();
277    let mut i = 0;
278    let mut in_single = false;
279    let mut in_double = false;
280
281    while i < len {
282        let ch = bytes[i];
283        match ch {
284            b'\'' if !in_double => {
285                in_single = !in_single;
286                i += 1;
287            }
288            b'"' if !in_single => {
289                in_double = !in_double;
290                i += 1;
291            }
292            b'\\' if !in_single => {
293                i = (i + 2).min(len);
294            }
295            b if b.is_ascii_whitespace() && !in_single && !in_double => return i,
296            _ => i += 1,
297        }
298    }
299    len
300}
301
302/// Like `check_interpreter_abuse` but only checks for eval flags on interpreters.
303/// Skips allowlist-membership tests (no allowlist exists in blocklist-only mode),
304/// but still follows delegation wrappers so `xargs bash -c …` / `timeout 5 sh -c …`
305/// cannot smuggle inline code past the check (GH #391).
306fn check_interpreter_eval_only(segment: &str) -> Result<(), String> {
307    check_interpreter_eval_only_inner(segment, 0)
308}
309
310fn check_interpreter_eval_only_inner(segment: &str, depth: usize) -> Result<(), String> {
311    if depth > 3 {
312        return Ok(());
313    }
314    let trimmed = skip_env_assignments(segment.trim());
315    let tokens = shell_tokenize(trimmed);
316    if tokens.is_empty() {
317        return Ok(());
318    }
319    let base = tokens[0]
320        .rsplit('/')
321        .next()
322        .unwrap_or(&tokens[0])
323        .to_string();
324
325    if DELEGATION_COMMANDS.contains(&base.as_str()) {
326        let rest_tokens = delegated_command_tokens(&tokens[1..]);
327        if !rest_tokens.is_empty() {
328            return check_interpreter_eval_only_inner(&rest_tokens.join(" "), depth + 1);
329        }
330        return Ok(());
331    }
332
333    if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
334        return Ok(());
335    }
336    for tok in &tokens[1..] {
337        if EVAL_FLAGS.contains(&tok.as_str()) {
338            return Err(format!(
339                "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
340                 flag '{tok}' is blocked. Use a script file instead.\n\
341                 This is a permanent security restriction."
342            ));
343        }
344        if has_eval_flag_prefix(tok) {
345            return Err(format!(
346                "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
347                 containing eval flag is blocked.\n\
348                 This is a permanent security restriction."
349            ));
350        }
351    }
352    if tokens[1..].iter().any(|t| t.contains("<<")) {
353        return Err(format!(
354            "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
355             Use a script file instead.\n\
356             This is a permanent security restriction."
357        ));
358    }
359    Ok(())
360}
361
362/// Commands that are unconditionally blocked regardless of allowlist membership.
363/// These provide direct arbitrary code execution or re-enter the shell.
364const UNCONDITIONAL_BLOCKED: &[&str] = &["eval", "exec", "source", "."];
365
366/// Interpreters that can execute arbitrary code via -c/-e flags.
367const INTERPRETER_COMMANDS: &[&str] = &[
368    "python", "python3", "python2", "node", "ruby", "perl", "lua", "php", "bash", "sh", "zsh",
369    "fish", "dash", "ksh",
370];
371
372/// Flags that indicate inline code execution for interpreters.
373const EVAL_FLAGS: &[&str] = &[
374    "-c", "-e", "-r", "-p", "--eval", "--exec", "-exec", "--print", "--run",
375];
376
377/// Script file extensions that indicate a file argument (not stdin execution).
378const SCRIPT_EXTENSIONS: &[&str] = &[
379    ".py", ".rb", ".js", ".ts", ".pl", ".lua", ".php", ".sh", ".bash", ".zsh", ".mjs", ".cjs",
380    ".tsx", ".jsx",
381];
382
383/// Commands that delegate to another command (the delegated command must also be allowed).
384/// `xargs` is here because `… | xargs bash -c '…'` would otherwise smuggle an
385/// interpreter past both the allowlist and the inline-code check (GH #391).
386const DELEGATION_COMMANDS: &[&str] = &["env", "nice", "timeout", "sudo", "doas", "xargs", "nohup"];
387
388/// Skips a delegation command's own flags/operands to find the delegated
389/// command token: leading `-x` flags, `KEY=VALUE` pairs (env), bare numbers
390/// (timeout/nice durations) and `{}` placeholders (xargs -I).
391fn delegated_command_tokens(tokens: &[String]) -> Vec<&str> {
392    tokens
393        .iter()
394        .map(std::string::String::as_str)
395        .skip_while(|t| {
396            t.starts_with('-')
397                || t.contains('=')
398                || *t == "{}"
399                || (!t.is_empty() && t.chars().all(|c| c.is_ascii_digit()))
400        })
401        .collect()
402}
403
404/// Check if a segment uses an interpreter with an eval flag, or a delegation command
405/// whose target is not in the allowlist.
406fn check_interpreter_abuse(segment: &str, allowlist: &[String]) -> Result<(), String> {
407    check_interpreter_abuse_inner(segment, allowlist, 0)
408}
409
410fn check_interpreter_abuse_inner(
411    segment: &str,
412    allowlist: &[String],
413    depth: usize,
414) -> Result<(), String> {
415    if depth > 3 {
416        return Ok(());
417    }
418    let trimmed = skip_env_assignments(segment.trim());
419    let tokens = shell_tokenize(trimmed);
420    if tokens.is_empty() {
421        return Ok(());
422    }
423
424    let base = tokens[0]
425        .rsplit('/')
426        .next()
427        .unwrap_or(&tokens[0])
428        .to_string();
429
430    if INTERPRETER_COMMANDS.contains(&base.as_str()) {
431        for tok in &tokens[1..] {
432            if EVAL_FLAGS.contains(&tok.as_str()) {
433                return Err(format!(
434                    "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
435                     flag '{tok}' is blocked. Use a script file instead.\n\
436                     This is a permanent security restriction."
437                ));
438            }
439            if has_eval_flag_prefix(tok) {
440                return Err(format!(
441                    "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
442                     containing eval flag is blocked.\n\
443                     This is a permanent security restriction."
444                ));
445            }
446        }
447        if tokens[1..].iter().any(|t| t.contains("<<")) {
448            return Err(format!(
449                "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
450                 Use a script file instead.\n\
451                 This is a permanent security restriction."
452            ));
453        }
454    }
455
456    if DELEGATION_COMMANDS.contains(&base.as_str()) {
457        let rest_tokens = delegated_command_tokens(&tokens[1..]);
458        if let Some(&delegated_tok) = rest_tokens.first() {
459            let delegated = delegated_tok.rsplit('/').next().unwrap_or(delegated_tok);
460            if !delegated.is_empty() && !allowlist.iter().any(|a| a == delegated) {
461                return Err(format!(
462                    "[BLOCKED — DO NOT RETRY] '{base}' delegates to '{delegated}' which is not \
463                     in the shell allowlist. This is a permanent restriction."
464                ));
465            }
466            let rest_str = rest_tokens.join(" ");
467            check_interpreter_abuse_inner(&rest_str, allowlist, depth + 1)?;
468        }
469    }
470
471    Ok(())
472}
473
474/// Check for combined flags like -pe, -ne, -ce that contain eval characters.
475fn has_eval_flag_prefix(token: &str) -> bool {
476    if !token.starts_with('-') || token.starts_with("--") || token.len() < 3 {
477        return false;
478    }
479    let flag_chars = &token[1..];
480    let eval_chars = ['c', 'e', 'r', 'p'];
481    flag_chars.chars().any(|c| eval_chars.contains(&c))
482}
483
484/// Check if a segment is a bare interpreter after a pipe (no script file argument).
485fn is_bare_interpreter_stdin(segment: &str) -> bool {
486    let trimmed = skip_env_assignments(segment.trim());
487    let tokens = shell_tokenize(trimmed);
488    if tokens.is_empty() {
489        return false;
490    }
491    let base = tokens[0]
492        .rsplit('/')
493        .next()
494        .unwrap_or(&tokens[0])
495        .to_string();
496    if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
497        return false;
498    }
499    !tokens[1..]
500        .iter()
501        .any(|t| !t.starts_with('-') && SCRIPT_EXTENSIONS.iter().any(|ext| t.ends_with(ext)))
502}
503
504/// Dangerous flag patterns for specific commands.
505const DANGEROUS_GIT_FLAGS: &[&str] = &[
506    "--upload-pack",
507    "--receive-pack",
508    "--config=core.sshcommand",
509    "--config=core.gitproxy",
510];
511
512const DANGEROUS_TAR_FLAGS: &[&str] = &["--to-command", "--use-compress-program"];
513
514/// Blocked inline environment assignments that can hijack execution.
515const BLOCKED_INLINE_ENV: &[&str] = &[
516    "PATH=",
517    "GIT_ASKPASS=",
518    "GIT_SSH=",
519    "GIT_SSH_COMMAND=",
520    "GIT_EDITOR=",
521    "GIT_EXTERNAL_DIFF=",
522    "SSH_ASKPASS=",
523    "LD_PRELOAD=",
524    "DYLD_INSERT_LIBRARIES=",
525];
526
527fn check_dangerous_flags(segment: &str) -> Result<(), String> {
528    let trimmed = skip_env_assignments(segment.trim());
529    let tokens = shell_tokenize(trimmed);
530    if tokens.is_empty() {
531        return Ok(());
532    }
533    let base = tokens[0]
534        .rsplit('/')
535        .next()
536        .unwrap_or(&tokens[0])
537        .to_string();
538
539    match base.as_str() {
540        "git" => {
541            for tok in &tokens[1..] {
542                for flag in DANGEROUS_GIT_FLAGS {
543                    if tok.starts_with(flag) {
544                        return Err(format!(
545                            "[BLOCKED — DO NOT RETRY] 'git' with dangerous flag '{tok}' is blocked.\n\
546                             This is a permanent security restriction."
547                        ));
548                    }
549                }
550            }
551        }
552        "tar" => {
553            for tok in &tokens[1..] {
554                for flag in DANGEROUS_TAR_FLAGS {
555                    if tok.starts_with(flag) {
556                        return Err(format!(
557                            "[BLOCKED — DO NOT RETRY] 'tar' with dangerous flag '{tok}' is blocked.\n\
558                             This is a permanent security restriction."
559                        ));
560                    }
561                }
562            }
563        }
564        "find" => {
565            for tok in &tokens[1..] {
566                if tok == "-exec" || tok == "-execdir" {
567                    return Err(format!(
568                        "[BLOCKED — DO NOT RETRY] 'find' with '{tok}' is blocked. \
569                         Use 'find ... -print' and pipe to xargs instead.\n\
570                         This is a permanent security restriction."
571                    ));
572                }
573            }
574        }
575        "awk" | "gawk" | "mawk" => {
576            for tok in &tokens[1..] {
577                if tok.contains("system(") {
578                    return Err(format!(
579                        "[BLOCKED — DO NOT RETRY] '{base}' with 'system()' call is blocked.\n\
580                         This is a permanent security restriction."
581                    ));
582                }
583            }
584        }
585        _ => {}
586    }
587    Ok(())
588}
589
590fn check_inline_env_block(segment: &str) -> Result<(), String> {
591    let trimmed = segment.trim();
592    for blocked in BLOCKED_INLINE_ENV {
593        if trimmed.starts_with(blocked) {
594            return Err(format!(
595                "[BLOCKED — DO NOT RETRY] Inline environment override '{blocked}' is blocked.\n\
596                 This is a permanent security restriction."
597            ));
598        }
599    }
600    Ok(())
601}
602
603/// Shell reserved words whose operator-delimited segment carries no validatable
604/// simple command: the `for`/`select` loop *header* (`for x in LIST`) is data,
605/// and `done`/`fi`/`in` close or join a construct. A segment starting with one
606/// of these contributes no leaf command.
607const HEADER_KEYWORDS: &[&str] = &["for", "select", "in", "done", "fi"];
608
609/// Shell reserved words that *introduce* a command which must still be validated:
610/// the condition of `if`/`while`/`until`, the body after `do`/`then`/`else`/
611/// `elif`, and the `time`/`!` modifiers. They are stripped so the real leaf
612/// command behind them is checked against the allowlist.
613const BODY_INTRO_KEYWORDS: &[&str] = &[
614    "do", "then", "else", "elif", "if", "while", "until", "time", "!",
615];
616
617/// Expand a (possibly compound) command into the list of simple-command *leaves*
618/// that must each satisfy the allowlist. This is what makes `for … do CMD; done`,
619/// `if COND; then CMD; fi`, `while …; do CMD; done` and balanced `( CMD )`
620/// subshells usable in restricted mode without weakening deny-by-default: every
621/// leaf is still validated, headers/terminators contribute nothing, and any form
622/// this conservative walker cannot prove safe (`case`/`esac`, `;;`, a subshell
623/// with trailing content, deep nesting) is rejected — it over-blocks, never
624/// under-blocks.
625fn expand_to_leaf_segments(command: &str) -> Result<Vec<String>, String> {
626    if has_case_construct(command) {
627        return Err(format!(
628            "[BLOCKED — DO NOT RETRY] `case`/`esac` constructs are not supported in \
629             restricted (allowlisted) shell mode — their `pattern)` arms cannot be \
630             leaf-validated safely. Run a script file or disable the allowlist instead.\n\
631             Command: {command}"
632        ));
633    }
634    let mut leaves = Vec::new();
635    for seg in extract_all_commands(command) {
636        resolve_segment_leaves(&seg, 0, &mut leaves)?;
637    }
638    Ok(leaves)
639}
640
641/// Resolve one operator-delimited segment into zero or more leaf commands,
642/// stripping reserved words and recursing into balanced `( … )` subshells.
643fn resolve_segment_leaves(
644    segment: &str,
645    depth: usize,
646    out: &mut Vec<String>,
647) -> Result<(), String> {
648    if depth > 4 {
649        return Err(format!(
650            "[BLOCKED — DO NOT RETRY] Shell command nests compound/subshell groups too \
651             deeply to validate safely.\nCommand: {segment}"
652        ));
653    }
654    let mut s = segment.trim();
655    loop {
656        let tokens = shell_tokenize(s);
657        let Some(first) = tokens.first() else {
658            return Ok(()); // empty → no command
659        };
660        let kw = first.as_str();
661        if HEADER_KEYWORDS.contains(&kw) {
662            return Ok(()); // loop header / terminator carries no leaf command
663        }
664        if BODY_INTRO_KEYWORDS.contains(&kw) {
665            s = remainder_after_first_token(s).trim();
666            if s.is_empty() {
667                return Ok(());
668            }
669            continue;
670        }
671        break;
672    }
673    if let Some(inner) = balanced_paren_inner(s) {
674        for inner_seg in extract_all_commands(inner) {
675            resolve_segment_leaves(&inner_seg, depth + 1, out)?;
676        }
677        return Ok(());
678    }
679    // Anything else (incl. `( … ) trailing`, brace groups, leftover delimiters) is
680    // pushed verbatim: base-extraction below sees a first token like `(ls)` or `{`
681    // that cannot match any allowlist entry, so it is blocked. `cmd (sub)` without
682    // a separator is a shell syntax error, so no executable leaf escapes here.
683    out.push(s.to_string());
684    Ok(())
685}
686
687/// Return the substring after the first whitespace-delimited (quote-aware) token.
688fn remainder_after_first_token(s: &str) -> &str {
689    let trimmed = s.trim_start();
690    let end = quote_aware_token_end(trimmed);
691    &trimmed[end..]
692}
693
694/// If `s` is a single balanced `( … )` subshell with nothing trailing the closing
695/// paren, return the inner command (`(a; b)` → `a; b`). `(a) b` returns `None`:
696/// the trailing content falls through to base extraction, which blocks it.
697fn balanced_paren_inner(segment: &str) -> Option<&str> {
698    let trimmed = segment.trim();
699    let bytes = trimmed.as_bytes();
700    if bytes.first() != Some(&b'(') {
701        return None;
702    }
703    let len = bytes.len();
704    let mut depth: i32 = 0;
705    let mut in_single_quote = false;
706    let mut in_double_quote = false;
707    let mut i = 0;
708    while i < len {
709        let ch = bytes[i];
710        if in_single_quote {
711            if ch == b'\'' {
712                in_single_quote = false;
713            }
714            i += 1;
715            continue;
716        }
717        if in_double_quote {
718            if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
719                in_double_quote = false;
720            }
721            i += 1;
722            continue;
723        }
724        match ch {
725            b'\'' => in_single_quote = true,
726            b'"' => in_double_quote = true,
727            b'(' => depth += 1,
728            b')' => {
729                depth -= 1;
730                if depth == 0 {
731                    return if i == len - 1 {
732                        Some(trimmed[1..i].trim())
733                    } else {
734                        None
735                    };
736                }
737            }
738            _ => {}
739        }
740        i += 1;
741    }
742    None
743}
744
745/// True when the command uses a `case`/`esac`/`;;` construct. The leaf walker
746/// deliberately does not parse these (the `pattern)` arms make safe leaf
747/// extraction error-prone), so they are blocked outright in restricted mode.
748fn has_case_construct(command: &str) -> bool {
749    for seg in split_on_operators(command) {
750        if shell_tokenize(seg.trim())
751            .iter()
752            .any(|t| t == "case" || t == "esac")
753        {
754            return true;
755        }
756    }
757    contains_double_semicolon(command)
758}
759
760/// Quote-aware scan for a `;;` terminator (the `case` arm separator).
761fn contains_double_semicolon(command: &str) -> bool {
762    let bytes = command.as_bytes();
763    let len = bytes.len();
764    let mut in_single_quote = false;
765    let mut in_double_quote = false;
766    let mut i = 0;
767    while i < len {
768        let ch = bytes[i];
769        if in_single_quote {
770            if ch == b'\'' {
771                in_single_quote = false;
772            }
773            i += 1;
774            continue;
775        }
776        if in_double_quote {
777            if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
778                in_double_quote = false;
779            }
780            i += 1;
781            continue;
782        }
783        match ch {
784            b'\'' => in_single_quote = true,
785            b'"' => in_double_quote = true,
786            b';' if i + 1 < len && bytes[i + 1] == b';' => return true,
787            _ => {}
788        }
789        i += 1;
790    }
791    false
792}
793
794fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), String> {
795    if allowlist.is_empty() {
796        return Ok(());
797    }
798
799    if has_dangerous_patterns(command) {
800        return Err(format!(
801            "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
802             which is blocked in restricted mode. \
803             This is a permanent security restriction, not a transient error.\n\
804             Command: {command}"
805        ));
806    }
807
808    let segments = expand_to_leaf_segments(command)?;
809    if segments.is_empty() {
810        return Err("[BLOCKED — DO NOT RETRY] Empty command".to_string());
811    }
812
813    for seg in &segments {
814        check_inline_env_block(seg)?;
815        let base = extract_base_from_segment(seg);
816        if base.is_empty() {
817            continue;
818        }
819        if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
820            return Err(format!(
821                "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
822                 regardless of allowlist membership. \
823                 This is a permanent security restriction.\n\
824                 Command: {command}"
825            ));
826        }
827        check_interpreter_abuse(seg, allowlist)?;
828        check_dangerous_flags(seg)?;
829        if !allowlist.iter().any(|a| a == &base) {
830            return Err(allowlist_block_message(&base));
831        }
832    }
833    Ok(())
834}
835
836/// Detect dangerous shell patterns that bypass allowlist intent.
837///
838/// Only blocks patterns that are genuinely dangerous at command position.
839/// `$()` and backticks in *arguments* are allowed — the base command is
840/// already validated by the allowlist, and blocking substitutions in
841/// arguments breaks legitimate workflows (e.g. `git commit -m "$(cat ...)"`,
842/// pre-commit hooks, playwright scripts).
843fn has_dangerous_patterns(command: &str) -> bool {
844    let trimmed = command.trim();
845
846    for blocked in UNCONDITIONAL_BLOCKED {
847        let with_space = format!("{blocked} ");
848        if trimmed.starts_with(&with_space) {
849            return true;
850        }
851        for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
852            if trimmed.contains(&format!("{sep}{blocked} ")) {
853                return true;
854            }
855        }
856    }
857
858    if has_substitution_at_command_pos(trimmed) {
859        return true;
860    }
861
862    false
863}
864
865/// Check if `$()` or backticks appear at command position (first token
866/// of any segment). Substitutions in *arguments* are intentionally
867/// allowed — the security boundary is the base-command allowlist check.
868fn has_substitution_at_command_pos(command: &str) -> bool {
869    let segments = split_on_operators(command);
870    for seg in segments {
871        let trimmed = seg.trim();
872        let cmd_start = skip_env_assignments(trimmed);
873
874        if cmd_start.starts_with("$(") {
875            return true;
876        }
877
878        let tokens = shell_tokenize(cmd_start);
879        let first_token = tokens.first().map_or("", std::string::String::as_str);
880        if first_token.starts_with('`') || first_token == "`" {
881            return true;
882        }
883    }
884    false
885}
886
887/// Extract ALL command segments from a compound shell command.
888/// Splits on: &&, ||, ;, | (pipe), and handles subshell grouping.
889fn extract_all_commands(command: &str) -> Vec<String> {
890    split_on_operators(command)
891        .into_iter()
892        .map(|s| s.trim().to_string())
893        .filter(|s| !s.is_empty())
894        .collect()
895}
896
897/// Split command string on shell operators: ;, &&, ||, |
898/// Respects single/double quotes and parentheses nesting.
899fn split_on_operators(command: &str) -> Vec<&str> {
900    let mut segments = Vec::new();
901    let mut start = 0;
902    let bytes = command.as_bytes();
903    let len = bytes.len();
904    let mut i = 0;
905    let mut in_single_quote = false;
906    let mut in_double_quote = false;
907    let mut paren_depth: u32 = 0;
908
909    while i < len {
910        let ch = bytes[i];
911
912        if in_single_quote {
913            if ch == b'\'' {
914                in_single_quote = false;
915            }
916            i += 1;
917            continue;
918        }
919
920        if in_double_quote {
921            if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
922                in_double_quote = false;
923            }
924            i += 1;
925            continue;
926        }
927
928        match ch {
929            b'\'' => {
930                in_single_quote = true;
931                i += 1;
932            }
933            b'"' => {
934                in_double_quote = true;
935                i += 1;
936            }
937            b'(' => {
938                paren_depth += 1;
939                i += 1;
940            }
941            b')' => {
942                paren_depth = paren_depth.saturating_sub(1);
943                i += 1;
944            }
945            b'\n' | b'\r' | b';' if paren_depth == 0 => {
946                segments.push(&command[start..i]);
947                i += 1;
948                start = i;
949            }
950            b'&' if paren_depth == 0 => {
951                if i + 1 < len && bytes[i + 1] == b'&' {
952                    // &&
953                    segments.push(&command[start..i]);
954                    i += 2;
955                    start = i;
956                } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
957                    // Redirect operator, NOT a separator: `2>&1`, `1>&2`, `>&file` (prev is '>')
958                    // or `&>file`, `&>>file` (next is '>'). The '&' belongs to the current
959                    // command — splitting here would mistake the fd/target (e.g. `1`) for a
960                    // standalone command and falsely block it (#334).
961                    i += 1;
962                } else {
963                    // single & (background operator) — still a command separator
964                    segments.push(&command[start..i]);
965                    i += 1;
966                    start = i;
967                }
968            }
969            b'|' if paren_depth == 0 => {
970                if i + 1 < len && bytes[i + 1] == b'|' {
971                    // ||
972                    segments.push(&command[start..i]);
973                    i += 2;
974                    start = i;
975                } else if i > 0 && bytes[i - 1] == b'>' {
976                    // `>|` (noclobber redirect), NOT a pipe: the '|' belongs to
977                    // the redirect operator and the following token is a file
978                    // path, not a command. Splitting here treated the target
979                    // (e.g. `out` in `date >| out`) as a command and falsely
980                    // blocked it against the allowlist (#387).
981                    i += 1;
982                } else {
983                    // pipe
984                    segments.push(&command[start..i]);
985                    i += 1;
986                    start = i;
987                }
988            }
989            _ => {
990                i += 1;
991            }
992        }
993    }
994
995    if start < len {
996        segments.push(&command[start..]);
997    }
998
999    segments
1000}
1001
1002/// Extract the base command name from a single segment (no operators).
1003fn extract_base_from_segment(segment: &str) -> String {
1004    let trimmed = segment.trim();
1005    if trimmed.is_empty() {
1006        return String::new();
1007    }
1008
1009    let cmd_part = skip_env_assignments(trimmed);
1010    if cmd_part.is_empty() {
1011        return String::new();
1012    }
1013
1014    let tokens = shell_tokenize(cmd_part);
1015    let first_token = tokens.first().map_or("", std::string::String::as_str);
1016
1017    first_token
1018        .rsplit('/')
1019        .next()
1020        .unwrap_or(first_token)
1021        .to_string()
1022}
1023
1024/// Skip leading KEY=VALUE environment variable assignments.
1025/// Uses quote-aware scanning so `FOO="bar baz" git status` correctly
1026/// skips the entire `FOO="bar baz"` token.
1027fn skip_env_assignments(segment: &str) -> &str {
1028    let mut rest = segment;
1029    loop {
1030        let rest_trimmed = rest.trim_start();
1031        if rest_trimmed.is_empty() {
1032            return rest_trimmed;
1033        }
1034        let end = quote_aware_token_end(rest_trimmed);
1035        if end == 0 {
1036            return rest_trimmed;
1037        }
1038        let raw_token = &rest_trimmed[..end];
1039        let unquoted: String = raw_token
1040            .chars()
1041            .filter(|c| *c != '"' && *c != '\'')
1042            .collect();
1043        if unquoted.contains('=')
1044            && !unquoted.starts_with('-')
1045            && !unquoted.starts_with('/')
1046            && !unquoted.starts_with('.')
1047        {
1048            rest = &rest_trimmed[end..];
1049        } else {
1050            return rest_trimmed;
1051        }
1052    }
1053}
1054
1055fn effective_allowlist() -> Vec<String> {
1056    // LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE completely replaces the config (for testing)
1057    if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
1058        return ov
1059            .split(',')
1060            .map(|s| s.trim().to_string())
1061            .filter(|s| !s.is_empty())
1062            .collect();
1063    }
1064    let cfg = crate::core::config::Config::load();
1065    let mut list = cfg.shell_allowlist;
1066    // `shell_allowlist_extra` is purely additive (written by `lean-ctx allow <cmd>`),
1067    // so users can permit a command without nuking the built-in defaults. It only
1068    // matters in restricted mode — when the base list is empty all commands pass anyway.
1069    if !list.is_empty() {
1070        for entry in cfg.shell_allowlist_extra {
1071            if !entry.is_empty() && !list.contains(&entry) {
1072                list.push(entry);
1073            }
1074        }
1075    }
1076    if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
1077        for entry in env_val
1078            .split(',')
1079            .map(|s| s.trim().to_string())
1080            .filter(|s| !s.is_empty())
1081        {
1082            if !list.contains(&entry) {
1083                list.push(entry);
1084            }
1085        }
1086    }
1087    list
1088}
1089
1090/// Builds the actionable, self-diagnosing message shown when a command's base binary
1091/// is not in the allowlist. Unlike a bare "not allowed" string, it tells the user
1092/// (1) the exact additive fix, (2) the real config path the MCP server reads, and
1093/// (3) — crucially — whether their `config.toml` silently failed to parse (in which
1094/// case lean-ctx is on defaults, which is the usual reason an allowlist edit "did
1095/// nothing"). That last signal is otherwise invisible over an MCP/stdio transport.
1096fn allowlist_block_message(base: &str) -> String {
1097    let cfg_path = crate::core::config::Config::path().map_or_else(
1098        || "~/.lean-ctx/config.toml".to_string(),
1099        |p| p.display().to_string(),
1100    );
1101
1102    let mut msg = format!(
1103        "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
1104         This is a permanent restriction, not a transient error.\n\
1105         Fix (additive, keeps the defaults): run  lean-ctx allow {base}\n\
1106         Config in effect: {cfg_path}\n\
1107         Or disable the allowlist entirely: set  shell_allowlist = []\n\
1108         Or turn off all shell gating (you own the risk): set  shell_security = \"off\"  \
1109         (or env LEAN_CTX_SHELL_SECURITY=off) — compression still applies.\n\
1110         Do NOT retry this command — it will fail again with the same error."
1111    );
1112
1113    if crate::core::config::cloud_infra_commands().contains(&base) {
1114        msg.push_str(
1115            "\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
1116             excluded from the defaults — they mutate remote infrastructure with \
1117             ambient credentials. Opting in is a deliberate user decision.",
1118        );
1119    }
1120
1121    if let Some(parse_err) = crate::core::config::last_config_parse_error() {
1122        msg.push_str(&format!(
1123            "\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
1124             built-in defaults — this is almost certainly why editing the allowlist had no \
1125             effect. Fix the TOML error below, then retry:\n  {parse_err}\n  File: {cfg_path}"
1126        ));
1127    } else if let Some(missing) = crate::core::config::Config::missing_config_path() {
1128        // The resolved config doesn't exist → lean-ctx is on defaults. An edit
1129        // made to a config.toml in a different dir (XDG vs legacy ~/.lean-ctx) or
1130        // under a sandboxed/container HOME is never read — say so over MCP (#540).
1131        msg.push_str(&format!(
1132            "\n\n⚠ No config file exists at {} — lean-ctx is running on built-in defaults. \
1133             If you added the command to a config.toml in a DIFFERENT location (XDG \
1134             ~/.config/lean-ctx vs legacy ~/.lean-ctx, or your MCP client launches lean-ctx \
1135             in a sandbox/container with a different HOME), the runtime never reads it. \
1136             `lean-ctx doctor` prints the path actually in effect; pin it with \
1137             LEAN_CTX_CONFIG_DIR.",
1138            missing.display()
1139        ));
1140    }
1141
1142    // A project-local `shell_allowlist`/`shell_allowlist_extra` is silently
1143    // withheld for an untrusted workspace; surface that here so the edit's
1144    // no-op reason isn't buried in an MCP-invisible stderr warning (#540).
1145    if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
1146        msg.push_str("\n\n⚠ ");
1147        msg.push_str(&notice);
1148    }
1149
1150    msg
1151}
1152
1153/// Public accessor for extracting all command segments.
1154pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
1155    extract_all_commands(command)
1156}
1157
1158/// Public accessor: the fully-resolved allowlist actually enforced by the MCP tools
1159/// (base `shell_allowlist` + additive `shell_allowlist_extra` + env), deduplicated.
1160/// Empty means blocklist-only mode (all commands pass). Used by `lean-ctx allow`
1161/// and `lean-ctx doctor` to show users exactly what the runtime sees.
1162#[must_use]
1163pub fn effective_allowlist_pub() -> Vec<String> {
1164    effective_allowlist()
1165}
1166
1167// Legacy compat: single-segment extraction (used by other callers)
1168pub fn extract_base_command(command: &str) -> String {
1169    let first_seg = split_on_operators(command)
1170        .into_iter()
1171        .next()
1172        .unwrap_or(command);
1173    extract_base_from_segment(first_seg)
1174}