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