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