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