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