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    // #876: a quoted-delimiter heredoc body (`<<'EOF' … EOF`) is literal stdin
64    // data, not commands. Strip it before analysis so the operator-splitter can't
65    // dice a commit message (`feat(...)`) into bogus "segments" and block them.
66    // #876: quoted-delimiter heredoc body = literal stdin, not commands.
67    // Substitution checks ($(), backticks) need the quoted-only strip so they
68    // can still flag expanding substitutions in unquoted bodies.
69    let quoted_stripped = strip_quoted_heredoc_bodies(&normalized);
70    // #931: for command-segment and redirect checks, strip ALL heredoc bodies
71    // (quoted + unquoted) — a `>` or command word in any body is opaque data.
72    let all_stripped = strip_all_heredoc_bodies(&normalized);
73    let cmd = quoted_stripped.as_str();
74    let cmd_all = all_stripped.as_str();
75
76    if has_dangerous_patterns(cmd) {
77        return Err(format!(
78            "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
79             which is blocked regardless of allowlist. \
80             This is a permanent security restriction, not a transient error.\n\
81             Command: {command}"
82        )
83        .into());
84    }
85
86    let strict = crate::core::config::Config::load().shell_strict_mode;
87    check_substitution_in_args(cmd, strict)?;
88    check_pipe_to_bare_interpreter(cmd, strict)?;
89
90    let allowlist = effective_allowlist();
91    if allowlist.is_empty() {
92        check_unconditional_blocked_only(cmd_all)?;
93        return Ok(());
94    }
95    check_all_segments(cmd_all, &allowlist)
96}
97
98/// Normalize the command string: remove backslash-newline continuations and
99/// replace Unicode line separators (U+2028, U+2029) with newlines.
100fn normalize_line_continuations(command: &str) -> String {
101    command
102        .replace("\\\r\n", "")
103        .replace("\\\n", "")
104        .replace(['\u{2028}', '\u{2029}'], "\n")
105}
106
107/// Strip the *bodies* of quoted-delimiter heredocs (`<<'EOF' … EOF`,
108/// `<<-"E" … E`) prior to allowlist analysis (#876).
109///
110/// A quoted heredoc delimiter disables all shell expansion, so every body line
111/// is pure literal stdin data — never an executable command. Left in place, the
112/// operator-splitter dices those lines into "segments" and blocks the first word
113/// that isn't allowlisted (e.g. a commit message piped via `git commit -F -`,
114/// whose first token is `feat(...)`).
115///
116/// Only quoted delimiters are stripped. An *unquoted* `<<EOF` heredoc DOES expand
117/// `$()`/backticks/`$VAR` in its body, so those bodies are deliberately left
118/// intact for the command-substitution checks to see.
119fn strip_quoted_heredoc_bodies(command: &str) -> String {
120    if !command.contains("<<") {
121        return command.to_string();
122    }
123    let mut out: Vec<&str> = Vec::new();
124    // Delimiters awaiting their terminator line, in body order (stacked heredocs
125    // `cmd <<'A' <<'B'` drain A's body first, then B's).
126    let mut pending: Vec<String> = Vec::new();
127    for line in command.lines() {
128        if pending.is_empty() {
129            out.push(line);
130            pending = heredoc_delims(line, true);
131        } else if line.trim_start_matches('\t').trim() == pending[0] {
132            // Terminator line: drop it and resume. `<<-` allows leading tabs; be
133            // lenient (over-stripping body data is harmless — a heredoc body is
134            // never a command anyway).
135            pending.remove(0);
136        }
137        // else: a heredoc body line — dropped (not pushed to `out`).
138    }
139    out.join("\n")
140}
141
142/// Like `strip_quoted_heredoc_bodies` but strips bodies for **all** heredocs
143/// (quoted *and* unquoted delimiters). Use for checks that must never interpret
144/// heredoc body content as commands or redirects (#931).
145pub fn strip_all_heredoc_bodies(command: &str) -> String {
146    if !command.contains("<<") {
147        return command.to_string();
148    }
149    let mut out: Vec<&str> = Vec::new();
150    let mut pending: Vec<String> = Vec::new();
151    for line in command.lines() {
152        if pending.is_empty() {
153            out.push(line);
154            pending = heredoc_delims(line, false);
155        } else if line.trim_start_matches('\t').trim() == pending[0] {
156            pending.remove(0);
157        }
158    }
159    out.join("\n")
160}
161
162/// Scan one line for heredoc operators with a **quoted** delimiter and return
163/// their bare delimiter names in source order. Quote-aware, so a `<<` inside a
164/// quoted string is ignored; a `<<<` here-string (no body) is skipped.
165fn heredoc_delims(line: &str, quoted_only: bool) -> Vec<String> {
166    let bytes = line.as_bytes();
167    let len = bytes.len();
168    let mut i = 0;
169    let mut in_single = false;
170    let mut in_double = false;
171    let mut delims = Vec::new();
172    while i < len {
173        let ch = bytes[i];
174        if in_single {
175            if ch == b'\'' {
176                in_single = false;
177            }
178            i += 1;
179            continue;
180        }
181        if in_double {
182            match ch {
183                b'\\' => i = (i + 2).min(len),
184                b'"' => {
185                    in_double = false;
186                    i += 1;
187                }
188                _ => i += 1,
189            }
190            continue;
191        }
192        match ch {
193            b'\\' => i = (i + 2).min(len),
194            b'\'' => {
195                in_single = true;
196                i += 1;
197            }
198            b'"' => {
199                in_double = true;
200                i += 1;
201            }
202            b'<' if i + 1 < len && bytes[i + 1] == b'<' => {
203                // `<<<` is a here-string (no body), not a heredoc.
204                if i + 2 < len && bytes[i + 2] == b'<' {
205                    i += 3;
206                    continue;
207                }
208                let mut j = i + 2;
209                if j < len && bytes[j] == b'-' {
210                    j += 1; // `<<-` (tab-stripped terminator)
211                }
212                while j < len && (bytes[j] == b' ' || bytes[j] == b'\t') {
213                    j += 1;
214                }
215                if let Some((delim, quoted, next)) = read_heredoc_delim(bytes, j) {
216                    if !quoted_only || quoted {
217                        delims.push(delim);
218                    }
219                    i = next;
220                    continue;
221                }
222                i = j;
223            }
224            _ => i += 1,
225        }
226    }
227    delims
228}
229
230/// Parse a heredoc delimiter token starting at `start`, returning its bare name
231/// (quotes/escapes removed), whether any part was quoted, and the index just
232/// past the token. `None` when no delimiter is present.
233fn read_heredoc_delim(bytes: &[u8], start: usize) -> Option<(String, bool, usize)> {
234    let len = bytes.len();
235    let mut i = start;
236    let mut name: Vec<u8> = Vec::new();
237    let mut quoted = false;
238    while i < len {
239        match bytes[i] {
240            b'\'' => {
241                quoted = true;
242                i += 1;
243                while i < len && bytes[i] != b'\'' {
244                    name.push(bytes[i]);
245                    i += 1;
246                }
247                i += usize::from(i < len); // skip closing quote if present
248            }
249            b'"' => {
250                quoted = true;
251                i += 1;
252                while i < len && bytes[i] != b'"' {
253                    name.push(bytes[i]);
254                    i += 1;
255                }
256                i += usize::from(i < len);
257            }
258            b'\\' => {
259                quoted = true;
260                i += 1;
261                if i < len {
262                    name.push(bytes[i]);
263                    i += 1;
264                }
265            }
266            b' ' | b'\t' | b'<' | b'>' | b'|' | b'&' | b';' => break,
267            c => {
268                name.push(c);
269                i += 1;
270            }
271        }
272    }
273    if name.is_empty() {
274        None
275    } else {
276        Some((String::from_utf8_lossy(&name).into_owned(), quoted, i))
277    }
278}
279
280/// $(), backticks, <() in arguments: warn by default, **block** when
281/// `shell_strict_mode = true` (GH #391 — the strict knob previously only
282/// changed the log line and never actually blocked).
283fn check_substitution_in_args(command: &str, strict: bool) -> Result<(), ShellError> {
284    if has_expanding_substitution_in_args(command) {
285        if strict {
286            tracing::warn!(
287                "[SECURITY] Command substitution in arguments blocked (shell_strict_mode=true): {command}"
288            );
289            return Err(format!(
290                "[BLOCKED — DO NOT RETRY] Command substitution ($(), backticks, <()/>()) in \
291                 arguments is blocked because shell_strict_mode = true. \
292                 This is a permanent security restriction.\n\
293                 Command: {command}"
294            )
295            .into());
296        }
297        tracing::warn!(
298            "[SECURITY] Command substitution in arguments detected (warn-only, set shell_strict_mode=true to block): {command}"
299        );
300    }
301    Ok(())
302}
303
304/// Check for $(), backticks, <(, >( in arguments wherever the shell would
305/// expand them — i.e. unquoted OR inside double quotes (single quotes inhibit
306/// expansion). `git commit -m "$(cat f)"` expands; `grep '$(x)' f` does not.
307fn has_expanding_substitution_in_args(command: &str) -> bool {
308    let bytes = command.as_bytes();
309    let len = bytes.len();
310    let mut i = 0;
311    let mut in_single_quote = false;
312    let mut seen_space_after_cmd = false;
313
314    while i < len {
315        let ch = bytes[i];
316        if in_single_quote {
317            if ch == b'\'' {
318                in_single_quote = false;
319            }
320            i += 1;
321            continue;
322        }
323        // Backslash inhibits expansion outside single quotes (GL #1160):
324        // `\$(`, `\`` and `\<(` are literal data in bash — both unquoted and
325        // inside double quotes.
326        if ch == b'\\' {
327            i = (i + 2).min(len);
328            continue;
329        }
330        match ch {
331            b'\'' => {
332                in_single_quote = true;
333                i += 1;
334            }
335            b' ' | b'\t' if !seen_space_after_cmd => {
336                seen_space_after_cmd = true;
337                i += 1;
338            }
339            _ if !seen_space_after_cmd => {
340                i += 1;
341            }
342            _ => {
343                if ch == b'$' && i + 1 < len && bytes[i + 1] == b'(' {
344                    return true;
345                }
346                if ch == b'`' {
347                    return true;
348                }
349                if (ch == b'<' || ch == b'>') && i + 1 < len && bytes[i + 1] == b'(' {
350                    return true;
351                }
352                i += 1;
353            }
354        }
355    }
356    false
357}
358
359/// Piping into a bare interpreter (no script file): warn by default, **block**
360/// when `shell_strict_mode = true` (GH #391).
361fn check_pipe_to_bare_interpreter(command: &str, strict: bool) -> Result<(), ShellError> {
362    let segments = split_on_operators(command);
363
364    for (idx, seg) in segments.iter().enumerate() {
365        if idx == 0 {
366            continue;
367        }
368        if is_bare_interpreter_stdin(seg) {
369            let base = extract_base_from_segment(seg);
370            if strict {
371                tracing::warn!(
372                    "[SECURITY] Pipe to bare interpreter '{base}' blocked (shell_strict_mode=true)"
373                );
374                return Err(format!(
375                    "[BLOCKED — DO NOT RETRY] Piping into bare interpreter '{base}' is blocked \
376                     because shell_strict_mode = true. Run a script file instead.\n\
377                     Command: {command}"
378                )
379                .into());
380            }
381            tracing::warn!("[SECURITY] Pipe to bare interpreter '{base}' detected (warn-only)");
382        }
383    }
384    Ok(())
385}
386
387/// For empty allowlists: still enforce UNCONDITIONAL_BLOCKED commands.
388fn check_unconditional_blocked_only(command: &str) -> Result<(), ShellError> {
389    let segments = extract_all_commands(command);
390    for seg in &segments {
391        let base = extract_base_from_segment(seg);
392        if !base.is_empty() && UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
393            return Err(format!(
394                "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
395                 regardless of allowlist configuration.\n\
396                 Command: {command}"
397            )
398            .into());
399        }
400        check_inline_env_block(seg)?;
401        check_interpreter_eval_only(seg)?;
402        check_dangerous_flags(seg)?;
403    }
404    Ok(())
405}
406
407/// Tokenize a shell command segment respecting single/double quotes and backslash escapes.
408/// Returns tokens with outer quotes stripped, matching how the shell would parse them.
409/// E.g. `git -C "Program Files" status` → `["git", "-C", "Program Files", "status"]`
410pub fn shell_tokenize(input: &str) -> Vec<String> {
411    let mut tokens = Vec::new();
412    let mut current = String::new();
413    let mut chars = input.chars().peekable();
414    let mut in_single = false;
415    let mut in_double = false;
416
417    while let Some(c) = chars.next() {
418        match c {
419            '\'' if !in_double => in_single = !in_single,
420            '"' if !in_single => in_double = !in_double,
421            '\\' if !in_single => {
422                if let Some(next) = chars.next() {
423                    current.push(next);
424                }
425            }
426            c if c.is_whitespace() && !in_single && !in_double => {
427                if !current.is_empty() {
428                    tokens.push(std::mem::take(&mut current));
429                }
430            }
431            _ => current.push(c),
432        }
433    }
434    if !current.is_empty() {
435        tokens.push(current);
436    }
437    tokens
438}
439
440/// Returns the byte length of the first shell token in `input`, respecting quotes
441/// and `(...)` nesting. Used by `skip_env_assignments` to advance past env
442/// assignments with quoted values like `FOO="bar baz"` — and, critically, past
443/// assignments whose value is a command substitution like `FOO=$(cmd a b)`
444/// (#855): without paren-depth tracking, whitespace *inside* the unclosed
445/// `$(...)` looked like the end of the token, splitting `s=$(gh pr view …)`
446/// into a bogus token `s=$(gh` plus a leftover `pr` that got misread as the
447/// base command.
448fn quote_aware_token_end(input: &str) -> usize {
449    let bytes = input.as_bytes();
450    let len = bytes.len();
451    let mut i = 0;
452    let mut in_single = false;
453    let mut in_double = false;
454    let mut paren_depth: u32 = 0;
455
456    while i < len {
457        let ch = bytes[i];
458        match ch {
459            b'\'' if !in_double => {
460                in_single = !in_single;
461                i += 1;
462            }
463            b'"' if !in_single => {
464                in_double = !in_double;
465                i += 1;
466            }
467            b'\\' if !in_single => {
468                i = (i + 2).min(len);
469            }
470            b'(' if !in_single && !in_double => {
471                paren_depth += 1;
472                i += 1;
473            }
474            b')' if !in_single && !in_double && paren_depth > 0 => {
475                paren_depth -= 1;
476                i += 1;
477            }
478            b if b.is_ascii_whitespace() && !in_single && !in_double && paren_depth == 0 => {
479                return i;
480            }
481            _ => i += 1,
482        }
483    }
484    len
485}
486
487/// Like `check_interpreter_abuse` but only checks for eval flags on interpreters.
488/// Skips allowlist-membership tests (no allowlist exists in blocklist-only mode),
489/// but still follows delegation wrappers so `xargs bash -c …` / `timeout 5 sh -c …`
490/// cannot smuggle inline code past the check (GH #391).
491fn check_interpreter_eval_only(segment: &str) -> Result<(), ShellError> {
492    let inline_ok = crate::core::config::Config::load().shell_allow_inline_scripts_effective();
493    check_interpreter_inner(segment, None, 0, inline_ok)
494}
495
496/// #823: unified interpreter-abuse walk. Both eval-only (empty allowlist) and
497/// restricted (non-empty allowlist) modes share this single recursive check.
498/// `allowlist`: None = blocklist-only mode, Some = restricted mode with delegation gating.
499/// `inline_ok`: if true, skip eval-flag/heredoc checks (#814 opt-in).
500fn check_interpreter_inner(
501    segment: &str,
502    allowlist: Option<&[String]>,
503    depth: usize,
504    inline_ok: bool,
505) -> Result<(), ShellError> {
506    if depth > 3 {
507        return Ok(());
508    }
509    let trimmed = skip_env_assignments(segment.trim());
510    let tokens = shell_tokenize(trimmed);
511    if tokens.is_empty() {
512        return Ok(());
513    }
514    let base = tokens[0]
515        .rsplit('/')
516        .next()
517        .unwrap_or(&tokens[0])
518        .to_string();
519
520    // Eval-flag / heredoc checks on interpreters (unless opted out via #814).
521    if INTERPRETER_COMMANDS.contains(&base.as_str()) && !inline_ok {
522        for tok in &tokens[1..] {
523            if EVAL_FLAGS.contains(&tok.as_str()) {
524                return Err(format!(
525                    "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with inline code execution \
526                     flag '{tok}' is blocked. Use a script file instead.\n\
527                     This is a permanent security restriction."
528                )
529                .into());
530            }
531            if has_eval_flag_prefix(tok) {
532                return Err(format!(
533                    "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with combined flag '{tok}' \
534                     containing eval flag is blocked.\n\
535                     This is a permanent security restriction."
536                )
537                .into());
538            }
539        }
540        if tokens[1..].iter().any(|t| t.contains("<<")) {
541            return Err(heredoc_blocked_message(&base).into());
542        }
543    }
544
545    // Delegation-command walk (recursive).
546    if DELEGATION_COMMANDS.contains(&base.as_str()) {
547        let rest_tokens = delegated_command_tokens(&tokens[1..]);
548        if let Some(&delegated_tok) = rest_tokens.first() {
549            // In restricted mode, the delegated command must be in the allowlist.
550            if let Some(al) = allowlist {
551                let delegated = delegated_tok.rsplit('/').next().unwrap_or(delegated_tok);
552                if !delegated.is_empty() && !al.iter().any(|a| a == delegated) {
553                    return Err(format!(
554                        "[BLOCKED — DO NOT RETRY] '{base}' delegates to '{delegated}' which is not \
555                         in the shell allowlist. This is a permanent restriction."
556                    )
557                    .into());
558                }
559            }
560            let rest_str = rest_tokens.join(" ");
561            return check_interpreter_inner(&rest_str, allowlist, depth + 1, inline_ok);
562        }
563    }
564
565    Ok(())
566}
567
568/// Actionable message for the heredoc-stdin block (GL #1161): the restriction
569/// is deliberate — inline code embedded in the command string never exists as
570/// an inspectable artifact, unlike a script file, which leaves an auditable
571/// trail and passes the write path's own guards. Name the exact workaround
572/// instead of leaving the agent to rediscover it by trial and error.
573fn heredoc_blocked_message(base: &str) -> String {
574    format!(
575        "[BLOCKED — DO NOT RETRY] Interpreter '{base}' with heredoc stdin is blocked. \
576         Inline code in the command string leaves no auditable artifact.\n\
577         Do this instead: write the code to a file, then run it —\n\
578           1. create /tmp/snippet with your code (Write/ctx_edit tool)\n\
579           2. {base} /tmp/snippet\n\
580         This is a permanent security restriction."
581    )
582}
583
584/// Commands that are unconditionally blocked regardless of allowlist membership.
585/// These provide direct arbitrary code execution or re-enter the shell.
586const UNCONDITIONAL_BLOCKED: &[&str] = &["eval", "exec", "source", "."];
587
588/// Interpreters that can execute arbitrary code via -c/-e flags.
589const INTERPRETER_COMMANDS: &[&str] = &[
590    "python", "python3", "python2", "node", "ruby", "perl", "lua", "php", "bash", "sh", "zsh",
591    "fish", "dash", "ksh",
592];
593
594/// Flags that indicate inline code execution for interpreters.
595const EVAL_FLAGS: &[&str] = &[
596    "-c", "-e", "-r", "-p", "--eval", "--exec", "-exec", "--print", "--run",
597];
598
599/// Script file extensions that indicate a file argument (not stdin execution).
600const SCRIPT_EXTENSIONS: &[&str] = &[
601    ".py", ".rb", ".js", ".ts", ".pl", ".lua", ".php", ".sh", ".bash", ".zsh", ".mjs", ".cjs",
602    ".tsx", ".jsx",
603];
604
605/// Commands that delegate to another command (the delegated command must also be allowed).
606/// `xargs` is here because `… | xargs bash -c '…'` would otherwise smuggle an
607/// interpreter past both the allowlist and the inline-code check (GH #391).
608const DELEGATION_COMMANDS: &[&str] = &["env", "nice", "timeout", "sudo", "doas", "xargs", "nohup"];
609
610/// Skips a delegation command's own flags/operands to find the delegated
611/// command token: leading `-x` flags, `KEY=VALUE` pairs (env), bare numbers
612/// (timeout/nice durations) and `{}` placeholders (xargs -I).
613fn delegated_command_tokens(tokens: &[String]) -> Vec<&str> {
614    tokens
615        .iter()
616        .map(std::string::String::as_str)
617        .skip_while(|t| {
618            t.starts_with('-')
619                || t.contains('=')
620                || *t == "{}"
621                || (!t.is_empty() && t.chars().all(|c| c.is_ascii_digit()))
622        })
623        .collect()
624}
625
626/// Check if a segment uses an interpreter with an eval flag, or a delegation command
627/// whose target is not in the allowlist.
628fn check_interpreter_abuse(segment: &str, allowlist: &[String]) -> Result<(), ShellError> {
629    let inline_ok = crate::core::config::Config::load().shell_allow_inline_scripts_effective();
630    check_interpreter_inner(segment, Some(allowlist), 0, inline_ok)
631}
632
633/// Check for combined flags like -pe, -ne, -ce that contain eval characters.
634fn has_eval_flag_prefix(token: &str) -> bool {
635    if !token.starts_with('-') || token.starts_with("--") || token.len() < 3 {
636        return false;
637    }
638    let flag_chars = &token[1..];
639    let eval_chars = ['c', 'e', 'r', 'p'];
640    flag_chars.chars().any(|c| eval_chars.contains(&c))
641}
642
643/// Check if a segment is a bare interpreter after a pipe (no script file argument).
644fn is_bare_interpreter_stdin(segment: &str) -> bool {
645    let trimmed = skip_env_assignments(segment.trim());
646    let tokens = shell_tokenize(trimmed);
647    if tokens.is_empty() {
648        return false;
649    }
650    let base = tokens[0]
651        .rsplit('/')
652        .next()
653        .unwrap_or(&tokens[0])
654        .to_string();
655    if !INTERPRETER_COMMANDS.contains(&base.as_str()) {
656        return false;
657    }
658    !tokens[1..]
659        .iter()
660        .any(|t| !t.starts_with('-') && SCRIPT_EXTENSIONS.iter().any(|ext| t.ends_with(ext)))
661}
662
663/// Dangerous flag patterns for specific commands.
664const DANGEROUS_GIT_FLAGS: &[&str] = &[
665    "--upload-pack",
666    "--receive-pack",
667    "--config=core.sshcommand",
668    "--config=core.gitproxy",
669];
670
671const DANGEROUS_TAR_FLAGS: &[&str] = &["--to-command", "--use-compress-program"];
672
673/// Blocked inline environment assignments that can hijack execution.
674const BLOCKED_INLINE_ENV: &[&str] = &[
675    "PATH=",
676    "GIT_ASKPASS=",
677    "GIT_SSH=",
678    "GIT_SSH_COMMAND=",
679    "GIT_EDITOR=",
680    "GIT_EXTERNAL_DIFF=",
681    "SSH_ASKPASS=",
682    "LD_PRELOAD=",
683    "DYLD_INSERT_LIBRARIES=",
684];
685
686fn check_dangerous_flags(segment: &str) -> Result<(), ShellError> {
687    let trimmed = skip_env_assignments(segment.trim());
688    let tokens = shell_tokenize(trimmed);
689    if tokens.is_empty() {
690        return Ok(());
691    }
692    let base = tokens[0]
693        .rsplit('/')
694        .next()
695        .unwrap_or(&tokens[0])
696        .to_string();
697
698    match base.as_str() {
699        "git" => {
700            for tok in &tokens[1..] {
701                for flag in DANGEROUS_GIT_FLAGS {
702                    if tok.starts_with(flag) {
703                        return Err(format!(
704                            "[BLOCKED — DO NOT RETRY] 'git' with dangerous flag '{tok}' is blocked.\n\
705                             This is a permanent security restriction."
706                        ).into());
707                    }
708                }
709            }
710        }
711        "tar" => {
712            for tok in &tokens[1..] {
713                for flag in DANGEROUS_TAR_FLAGS {
714                    if tok.starts_with(flag) {
715                        return Err(format!(
716                            "[BLOCKED — DO NOT RETRY] 'tar' with dangerous flag '{tok}' is blocked.\n\
717                             This is a permanent security restriction."
718                        ).into());
719                    }
720                }
721            }
722        }
723        "find" => {
724            for tok in &tokens[1..] {
725                if tok == "-exec" || tok == "-execdir" {
726                    return Err(format!(
727                        "[BLOCKED — DO NOT RETRY] 'find' with '{tok}' is blocked. \
728                         Use 'find ... -print' and pipe to xargs instead.\n\
729                         This is a permanent security restriction."
730                    )
731                    .into());
732                }
733            }
734        }
735        "awk" | "gawk" | "mawk" => {
736            for tok in &tokens[1..] {
737                if tok.contains("system(") {
738                    return Err(format!(
739                        "[BLOCKED — DO NOT RETRY] '{base}' with 'system()' call is blocked.\n\
740                         This is a permanent security restriction."
741                    )
742                    .into());
743                }
744            }
745        }
746        _ => {}
747    }
748    Ok(())
749}
750
751fn check_inline_env_block(segment: &str) -> Result<(), ShellError> {
752    let trimmed = segment.trim();
753    for blocked in BLOCKED_INLINE_ENV {
754        if trimmed.starts_with(blocked) {
755            return Err(format!(
756                "[BLOCKED — DO NOT RETRY] Inline environment override '{blocked}' is blocked.\n\
757                 This is a permanent security restriction."
758            )
759            .into());
760        }
761    }
762    Ok(())
763}
764
765/// Shell reserved words whose operator-delimited segment carries no validatable
766/// simple command: the `for`/`select` loop *header* (`for x in LIST`) is data,
767/// and `done`/`fi`/`in` close or join a construct. A segment starting with one
768/// of these contributes no leaf command.
769const HEADER_KEYWORDS: &[&str] = &["for", "select", "in", "done", "fi"];
770
771/// Shell reserved words that *introduce* a command which must still be validated:
772/// the condition of `if`/`while`/`until`, the body after `do`/`then`/`else`/
773/// `elif`, and the `time`/`!` modifiers. They are stripped so the real leaf
774/// command behind them is checked against the allowlist.
775const BODY_INTRO_KEYWORDS: &[&str] = &[
776    "do", "then", "else", "elif", "if", "while", "until", "time", "!",
777];
778
779/// Expand a (possibly compound) command into the list of simple-command *leaves*
780/// that must each satisfy the allowlist. This is what makes `for … do CMD; done`,
781/// `if COND; then CMD; fi`, `while …; do CMD; done` and balanced `( CMD )`
782/// subshells usable in restricted mode without weakening deny-by-default: every
783/// leaf is still validated, headers/terminators contribute nothing, and any form
784/// this conservative walker cannot prove safe (`case`/`esac`, `;;`, a subshell
785/// with trailing content, deep nesting) is rejected — it over-blocks, never
786/// under-blocks.
787fn expand_to_leaf_segments(command: &str) -> Result<Vec<String>, ShellError> {
788    if has_case_construct(command) {
789        return Err(format!(
790            "[BLOCKED — DO NOT RETRY] `case`/`esac` constructs are not supported in \
791             restricted (allowlisted) shell mode — their `pattern)` arms cannot be \
792             leaf-validated safely. Run a script file or disable the allowlist instead.\n\
793             Command: {command}"
794        )
795        .into());
796    }
797    let mut leaves = Vec::new();
798    for seg in extract_all_commands(command) {
799        resolve_segment_leaves(&seg, 0, &mut leaves)?;
800    }
801    Ok(leaves)
802}
803
804/// Resolve one operator-delimited segment into zero or more leaf commands,
805/// stripping reserved words and recursing into balanced `( … )` subshells.
806fn resolve_segment_leaves(
807    segment: &str,
808    depth: usize,
809    out: &mut Vec<String>,
810) -> Result<(), ShellError> {
811    if depth > 4 {
812        return Err(format!(
813            "[BLOCKED — DO NOT RETRY] Shell command nests compound/subshell groups too \
814             deeply to validate safely.\nCommand: {segment}"
815        )
816        .into());
817    }
818    let mut s = segment.trim();
819    loop {
820        let tokens = shell_tokenize(s);
821        let Some(first) = tokens.first() else {
822            return Ok(()); // empty → no command
823        };
824        let kw = first.as_str();
825        if HEADER_KEYWORDS.contains(&kw) {
826            return Ok(()); // loop header / terminator carries no leaf command
827        }
828        if BODY_INTRO_KEYWORDS.contains(&kw) {
829            s = remainder_after_first_token(s).trim();
830            if s.is_empty() {
831                return Ok(());
832            }
833            continue;
834        }
835        break;
836    }
837    if let Some(inner) = balanced_paren_inner(s) {
838        for inner_seg in extract_all_commands(inner) {
839            resolve_segment_leaves(&inner_seg, depth + 1, out)?;
840        }
841        return Ok(());
842    }
843    // #968: a `{ cmd1; cmd2; }` brace group must be recursed into exactly like
844    // a `( … )` subshell above — otherwise every command after the first
845    // escapes validation entirely. #939 shielded `{ }` in split_on_operators
846    // (so the group survives as one segment) and taught
847    // extract_base_from_segment to skip the leading `{`, but only the FIRST
848    // inner command becomes that base; a non-allowlisted `cmd2` (e.g.
849    // `{ echo hi; ncat evil 4444; }`) then bypassed the allowlist, the `$()`
850    // hard-block, and the dangerous-flags checks alike. Recursing re-validates
851    // each inner command as its own leaf. This is a validation-only walk — the
852    // command string is never rewritten — so the cd/env-persistence property
853    // that #939 relied on (why it declined to recurse) is unaffected.
854    if let Some(inner) = balanced_brace_inner(s) {
855        for inner_seg in extract_all_commands(inner) {
856            resolve_segment_leaves(&inner_seg, depth + 1, out)?;
857        }
858        return Ok(());
859    }
860    // #855: a segment that is *entirely* env-var assignments (`VAR=$(cmd …)`,
861    // nothing left over — `out=$(gh pr view …)` is a common, legitimate idiom
862    // for capturing command output) still executes the substituted command.
863    // extract_base_from_segment resolves this segment's own base to empty
864    // (skip_env_assignments consumes the whole thing), so without this the
865    // substituted command would silently escape validation entirely — not
866    // just fail to be *found*, but never be *checked* at all. Recurse into it
867    // as its own leaf so `gh`, not the assignment wrapper, is what actually
868    // gets checked against the allowlist.
869    for inner in assignment_substitution_leaves(s) {
870        for inner_seg in extract_all_commands(inner) {
871            resolve_segment_leaves(&inner_seg, depth + 1, out)?;
872        }
873    }
874    // Anything else (incl. `( … ) trailing`, leftover delimiters) is pushed
875    // verbatim: base-extraction below sees a first token like `(ls)` that
876    // cannot match any allowlist entry, so it is blocked. `cmd (sub)` without
877    // a separator is a shell syntax error, so no executable leaf escapes
878    // here. A `{ cmd; }` brace group is the one exception: split_on_operators
879    // already shields it with `brace_depth` the same way `( … )` is shielded
880    // with `paren_depth`, so it survives as one leaf here, and
881    // extract_base_from_segment (below) skips the leading `{` token to find
882    // the real base command inside — no recursion needed like subshells get,
883    // since `cd`/env changes inside `{ }` must persist to the caller (#939,
884    // agent_wrapper::rebuild's cwd-tracking wrapper).
885    out.push(s.to_string());
886    Ok(())
887}
888
889/// Find the inner text of a `$(...)` command substitution whose `(` sits at
890/// byte offset `open` in `s`. Quote-aware (mirrors `balanced_paren_inner`) so
891/// a nested quoted `)` — e.g. inside a jq filter — doesn't end the walk early.
892/// Returns `(inner, end)` with `end` just past the matching `)`; `None` if
893/// unbalanced.
894fn balanced_paren_at(s: &str, open: usize) -> Option<(&str, usize)> {
895    let bytes = s.as_bytes();
896    let len = bytes.len();
897    let mut depth: i32 = 0;
898    let mut in_single_quote = false;
899    let mut in_double_quote = false;
900    let mut i = open;
901    while i < len {
902        let ch = bytes[i];
903        if in_single_quote {
904            if ch == b'\'' {
905                in_single_quote = false;
906            }
907            i += 1;
908            continue;
909        }
910        if in_double_quote {
911            match ch {
912                b'\\' => i = (i + 2).min(len),
913                b'"' => {
914                    in_double_quote = false;
915                    i += 1;
916                }
917                _ => i += 1,
918            }
919            continue;
920        }
921        match ch {
922            b'\\' => i = (i + 2).min(len),
923            b'\'' => {
924                in_single_quote = true;
925                i += 1;
926            }
927            b'"' => {
928                in_double_quote = true;
929                i += 1;
930            }
931            b'(' => {
932                depth += 1;
933                i += 1;
934            }
935            b')' => {
936                depth -= 1;
937                i += 1;
938                if depth == 0 {
939                    return Some((&s[open + 1..i - 1], i));
940                }
941            }
942            _ => i += 1,
943        }
944    }
945    None
946}
947
948/// #855: the leading run of `VAR=value` assignment tokens in `s` (the same
949/// prefix `skip_env_assignments` walks past) — as a slice of `s`, covering
950/// both `VAR=$(cmd)` alone and `A=1 B=$(cmd) realcmd args` (the assignments
951/// still execute even when a real command follows them).
952fn leading_assignment_prefix(s: &str) -> &str {
953    let rest = skip_env_assignments(s);
954    let offset = (rest.as_ptr() as usize).saturating_sub(s.as_ptr() as usize);
955    &s[..offset.min(s.len())]
956}
957
958/// #855: collect the inner command text of every top-level `$(...)` found in
959/// `s`'s leading env-assignment prefix (`VAR=$(cmd)`, `A=1 B=$(cmd) realcmd`,
960/// …) — those substitutions execute regardless of whether a real command
961/// follows the assignments. `cmd "$(sub)"` in *argument* position (after the
962/// real command) is untouched here and keeps its existing warn-only handling
963/// (`check_substitution_in_args`); this only closes the gap for substitutions
964/// hiding in a leading assignment.
965fn assignment_substitution_leaves(s: &str) -> Vec<&str> {
966    let prefix = leading_assignment_prefix(s);
967    if prefix.is_empty() {
968        return Vec::new();
969    }
970    let mut found = Vec::new();
971    let bytes = prefix.as_bytes();
972    let len = bytes.len();
973    let mut in_single_quote = false;
974    let mut in_double_quote = false;
975    let mut i = 0;
976    while i < len {
977        let ch = bytes[i];
978        if in_single_quote {
979            if ch == b'\'' {
980                in_single_quote = false;
981            }
982            i += 1;
983            continue;
984        }
985        if in_double_quote {
986            match ch {
987                b'\\' => {
988                    i = (i + 2).min(len);
989                    continue;
990                }
991                b'"' => in_double_quote = false,
992                _ => {}
993            }
994            i += 1;
995            continue;
996        }
997        match ch {
998            b'\\' => {
999                i = (i + 2).min(len);
1000                continue;
1001            }
1002            b'\'' => in_single_quote = true,
1003            b'"' => in_double_quote = true,
1004            b'$' if i + 1 < len && bytes[i + 1] == b'(' => {
1005                if let Some((inner, end)) = balanced_paren_at(prefix, i + 1) {
1006                    found.push(inner);
1007                    i = end;
1008                    continue;
1009                }
1010            }
1011            _ => {}
1012        }
1013        i += 1;
1014    }
1015    found
1016}
1017
1018/// Return the substring after the first whitespace-delimited (quote-aware) token.
1019fn remainder_after_first_token(s: &str) -> &str {
1020    let trimmed = s.trim_start();
1021    let end = quote_aware_token_end(trimmed);
1022    &trimmed[end..]
1023}
1024
1025/// If `s` is a single balanced `( … )` subshell with nothing trailing the closing
1026/// paren, return the inner command (`(a; b)` → `a; b`). `(a) b` returns `None`:
1027/// the trailing content falls through to base extraction, which blocks it.
1028fn balanced_paren_inner(segment: &str) -> Option<&str> {
1029    let trimmed = segment.trim();
1030    let bytes = trimmed.as_bytes();
1031    if bytes.first() != Some(&b'(') {
1032        return None;
1033    }
1034    let len = bytes.len();
1035    let mut depth: i32 = 0;
1036    let mut in_single_quote = false;
1037    let mut in_double_quote = false;
1038    let mut i = 0;
1039    while i < len {
1040        let ch = bytes[i];
1041        if in_single_quote {
1042            if ch == b'\'' {
1043                in_single_quote = false;
1044            }
1045            i += 1;
1046            continue;
1047        }
1048        if in_double_quote {
1049            match ch {
1050                b'\\' => i += 1, // \" and \\ stay inside the string
1051                b'"' => in_double_quote = false,
1052                _ => {}
1053            }
1054            i += 1;
1055            continue;
1056        }
1057        match ch {
1058            // Escaped parens are data (GL #1160): `rg foo\(bar\)` must not
1059            // shift the depth this walker uses to find the real closing paren.
1060            b'\\' => i += 1,
1061            b'\'' => in_single_quote = true,
1062            b'"' => in_double_quote = true,
1063            b'(' => depth += 1,
1064            b')' => {
1065                depth -= 1;
1066                if depth == 0 {
1067                    return if i == len - 1 {
1068                        Some(trimmed[1..i].trim())
1069                    } else {
1070                        None
1071                    };
1072                }
1073            }
1074            _ => {}
1075        }
1076        i += 1;
1077    }
1078    None
1079}
1080
1081/// If `s` is a single balanced `{ … }` brace group with nothing trailing the
1082/// closing `}`, return the inner command list (`{ a; b; }` → `a; b`). Mirrors
1083/// [`balanced_paren_inner`] so [`resolve_segment_leaves`] can recurse into the
1084/// group and validate every inner command, not just the first (#968).
1085///
1086/// Only a real brace *group* qualifies: the `{` must be followed by whitespace
1087/// (`{ cmd; }`), never `{a,b}` brace *expansion* — that is an argument, and its
1088/// enclosing command's base is validated normally. `{ a; } b` returns `None`
1089/// (trailing content → falls through to base extraction), matching the paren
1090/// walker; such a form is a shell syntax error anyway.
1091fn balanced_brace_inner(segment: &str) -> Option<&str> {
1092    let trimmed = segment.trim();
1093    let bytes = trimmed.as_bytes();
1094    if bytes.first() != Some(&b'{') {
1095        return None;
1096    }
1097    // A brace *group* requires whitespace after `{`; `{a,b}` (expansion) or a
1098    // bare `{` at EOF is not a group we should peel open.
1099    match bytes.get(1) {
1100        Some(&(b' ' | b'\t' | b'\n' | b'\r')) => {}
1101        _ => return None,
1102    }
1103    let len = bytes.len();
1104    let mut depth: i32 = 0;
1105    let mut in_single_quote = false;
1106    let mut in_double_quote = false;
1107    let mut i = 0;
1108    while i < len {
1109        let ch = bytes[i];
1110        if in_single_quote {
1111            if ch == b'\'' {
1112                in_single_quote = false;
1113            }
1114            i += 1;
1115            continue;
1116        }
1117        if in_double_quote {
1118            match ch {
1119                b'\\' => i += 1, // \" and \\ stay inside the string
1120                b'"' => in_double_quote = false,
1121                _ => {}
1122            }
1123            i += 1;
1124            continue;
1125        }
1126        match ch {
1127            b'\\' => i += 1, // escaped brace is data, not a group delimiter
1128            b'\'' => in_single_quote = true,
1129            b'"' => in_double_quote = true,
1130            b'{' => depth += 1,
1131            b'}' => {
1132                depth -= 1;
1133                if depth == 0 {
1134                    return if i == len - 1 {
1135                        Some(trimmed[1..i].trim())
1136                    } else {
1137                        None
1138                    };
1139                }
1140            }
1141            _ => {}
1142        }
1143        i += 1;
1144    }
1145    None
1146}
1147
1148/// True when the command uses a `case`/`esac`/`;;` construct. The leaf walker
1149/// deliberately does not parse these (the `pattern)` arms make safe leaf
1150/// extraction error-prone), so they are blocked outright in restricted mode.
1151fn has_case_construct(command: &str) -> bool {
1152    for seg in split_on_operators(command) {
1153        if shell_tokenize(seg.trim())
1154            .iter()
1155            .any(|t| t == "case" || t == "esac")
1156        {
1157            return true;
1158        }
1159    }
1160    contains_double_semicolon(command)
1161}
1162
1163/// Quote-aware scan for a `;;` terminator (the `case` arm separator).
1164fn contains_double_semicolon(command: &str) -> bool {
1165    let bytes = command.as_bytes();
1166    let len = bytes.len();
1167    let mut in_single_quote = false;
1168    let mut in_double_quote = false;
1169    let mut i = 0;
1170    while i < len {
1171        let ch = bytes[i];
1172        if in_single_quote {
1173            if ch == b'\'' {
1174                in_single_quote = false;
1175            }
1176            i += 1;
1177            continue;
1178        }
1179        if in_double_quote {
1180            if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
1181                in_double_quote = false;
1182            }
1183            i += 1;
1184            continue;
1185        }
1186        match ch {
1187            b'\'' => in_single_quote = true,
1188            b'"' => in_double_quote = true,
1189            b';' if i + 1 < len && bytes[i + 1] == b';' => return true,
1190            _ => {}
1191        }
1192        i += 1;
1193    }
1194    false
1195}
1196
1197/// #813: check whether a command token resolves to an existing file under the
1198/// project root. Called as a fallback when the base command name isn't in the
1199/// allowlist — agents frequently build project-local binaries (`go build -o
1200/// cbc_old`, `cargo build`, `gcc -o bench`) that shouldn't require a manual
1201/// `lean-ctx allow` round-trip.
1202///
1203/// Only auto-allows when ALL of:
1204/// 1. The token is a path (contains `/` or starts with `./`)
1205/// 2. The resolved path is an existing file
1206/// 3. The resolved path is under the project root
1207fn is_project_root_binary(token: &str) -> bool {
1208    if !token.contains('/') {
1209        return false;
1210    }
1211    let path = std::path::Path::new(token);
1212    let resolved = if path.is_relative() {
1213        match std::env::current_dir() {
1214            Ok(cwd) => cwd.join(path),
1215            Err(_) => return false,
1216        }
1217    } else {
1218        path.to_path_buf()
1219    };
1220    let Ok(canonical) = resolved.canonicalize() else {
1221        return false;
1222    };
1223    if !canonical.is_file() {
1224        return false;
1225    }
1226    let Some(root) = crate::server::derive_project_root_from_cwd() else {
1227        return false;
1228    };
1229    let root_path = std::path::Path::new(&root);
1230    let canonical_root = root_path
1231        .canonicalize()
1232        .unwrap_or_else(|_| root_path.to_path_buf());
1233    canonical.starts_with(&canonical_root)
1234}
1235
1236fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), ShellError> {
1237    if allowlist.is_empty() {
1238        return Ok(());
1239    }
1240
1241    if has_dangerous_patterns(command) {
1242        return Err(format!(
1243            "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
1244             which is blocked in restricted mode. \
1245             This is a permanent security restriction, not a transient error.\n\
1246             Command: {command}"
1247        )
1248        .into());
1249    }
1250
1251    let segments = expand_to_leaf_segments(command)?;
1252    if segments.is_empty() {
1253        return Err("[BLOCKED — DO NOT RETRY] Empty command".into());
1254    }
1255
1256    let total = segments.len();
1257    for (idx, seg) in segments.iter().enumerate() {
1258        check_inline_env_block(seg)?;
1259        let base = extract_base_from_segment(seg);
1260        if base.is_empty() {
1261            continue;
1262        }
1263        if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
1264            return Err(format!(
1265                "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
1266                 regardless of allowlist membership. \
1267                 This is a permanent security restriction.\n\
1268                 Command: {command}"
1269            )
1270            .into());
1271        }
1272        check_interpreter_abuse(seg, allowlist)?;
1273        check_dangerous_flags(seg)?;
1274        if !allowlist.iter().any(|a| a == &base) {
1275            // #813: auto-allow binaries that resolve to existing files under
1276            // the project root. The first token (before rsplit) carries the
1277            // path context (e.g. "./cbc_old", "../bin/bench").
1278            let first_token = shell_tokenize(skip_env_assignments(seg.trim()))
1279                .into_iter()
1280                .next()
1281                .unwrap_or_default();
1282            if is_project_root_binary(&first_token) {
1283                tracing::info!(
1284                    "[shell_allowlist] auto-allowing project-root binary: {first_token}"
1285                );
1286                continue;
1287            }
1288
1289            // #815: for compound commands, tell the user which segment was
1290            // blocked and that nothing ran (the pipeline is rejected as a
1291            // whole before execution, so no prefix commands executed).
1292            let mut msg = allowlist_block_message(&base);
1293            if total > 1 {
1294                msg.push_str(&format!(
1295                    "\n\n[pipeline: segment {}/{total} blocked — \
1296                     the entire command was rejected before execution, \
1297                     no part of the pipeline ran]",
1298                    idx + 1,
1299                ));
1300            }
1301            return Err(msg.into());
1302        }
1303    }
1304    Ok(())
1305}
1306
1307/// Detect dangerous shell patterns that bypass allowlist intent.
1308///
1309/// Only blocks patterns that are genuinely dangerous at command position.
1310/// `$()` and backticks in *arguments* are allowed — the base command is
1311/// already validated by the allowlist, and blocking substitutions in
1312/// arguments breaks legitimate workflows (e.g. `git commit -m "$(cat ...)"`,
1313/// pre-commit hooks, playwright scripts).
1314fn has_dangerous_patterns(command: &str) -> bool {
1315    let trimmed = command.trim();
1316
1317    for blocked in UNCONDITIONAL_BLOCKED {
1318        let with_space = format!("{blocked} ");
1319        if trimmed.starts_with(&with_space) {
1320            return true;
1321        }
1322        for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
1323            if trimmed.contains(&format!("{sep}{blocked} ")) {
1324                return true;
1325            }
1326        }
1327    }
1328
1329    if has_substitution_at_command_pos(trimmed) {
1330        return true;
1331    }
1332
1333    false
1334}
1335
1336/// Check if `$()` or backticks appear at command position (first token
1337/// of any segment). Substitutions in *arguments* are intentionally
1338/// allowed — the security boundary is the base-command allowlist check.
1339fn has_substitution_at_command_pos(command: &str) -> bool {
1340    let segments = split_on_operators(command);
1341    for seg in segments {
1342        let trimmed = seg.trim();
1343        let cmd_start = skip_env_assignments(trimmed);
1344
1345        if cmd_start.starts_with("$(") {
1346            return true;
1347        }
1348
1349        let tokens = shell_tokenize(cmd_start);
1350        let first_token = tokens.first().map_or("", std::string::String::as_str);
1351        if first_token.starts_with('`') || first_token == "`" {
1352            return true;
1353        }
1354    }
1355    false
1356}
1357
1358/// Extract ALL command segments from a compound shell command.
1359/// Splits on: &&, ||, ;, | (pipe), and handles subshell grouping.
1360fn extract_all_commands(command: &str) -> Vec<String> {
1361    split_on_operators(command)
1362        .into_iter()
1363        .map(|s| s.trim().to_string())
1364        .filter(|s| !s.is_empty())
1365        .collect()
1366}
1367
1368/// Split command string on shell operators: ;, &&, ||, |
1369/// Respects single/double quotes, parentheses nesting, and backslash escapes
1370/// outside single quotes (GL #1160): `rg split\.label\|quantityLabel` is ONE
1371/// command — the escaped pipe is regex data, not an operator. The old scanner
1372/// split there and blocked the pattern fragment as an unknown command; same
1373/// for `find … -exec rm {} \;`.
1374fn split_on_operators(command: &str) -> Vec<&str> {
1375    let mut segments = Vec::new();
1376    let mut start = 0;
1377    let bytes = command.as_bytes();
1378    let len = bytes.len();
1379    let mut i = 0;
1380    let mut in_single_quote = false;
1381    let mut in_double_quote = false;
1382    let mut paren_depth: u32 = 0;
1383    // #939: brace groups (`{ cmd; }`) need the same operator-shielding as
1384    // `( cmd )` subshells — otherwise a `}` that closes a `{` opened on an
1385    // earlier physical line (e.g. after heredoc-body stripping collapses the
1386    // body between them) is misread as its own bare command segment.
1387    let mut brace_depth: u32 = 0;
1388
1389    while i < len {
1390        let ch = bytes[i];
1391
1392        if in_single_quote {
1393            if ch == b'\'' {
1394                in_single_quote = false;
1395            }
1396            i += 1;
1397            continue;
1398        }
1399
1400        if in_double_quote {
1401            match ch {
1402                // \" stays inside the string; \\ consumes both so `"x\\"` closes.
1403                b'\\' => i = (i + 2).min(len),
1404                b'"' => {
1405                    in_double_quote = false;
1406                    i += 1;
1407                }
1408                _ => i += 1,
1409            }
1410            continue;
1411        }
1412
1413        match ch {
1414            b'\\' => {
1415                // Escaped char is data (bash semantics outside quotes) — never
1416                // an operator or quote opener.
1417                i = (i + 2).min(len);
1418            }
1419            b'\'' => {
1420                in_single_quote = true;
1421                i += 1;
1422            }
1423            b'"' => {
1424                in_double_quote = true;
1425                i += 1;
1426            }
1427            b'(' => {
1428                paren_depth += 1;
1429                i += 1;
1430            }
1431            b')' => {
1432                paren_depth = paren_depth.saturating_sub(1);
1433                i += 1;
1434            }
1435            b'{' => {
1436                brace_depth += 1;
1437                i += 1;
1438            }
1439            b'}' => {
1440                brace_depth = brace_depth.saturating_sub(1);
1441                i += 1;
1442            }
1443            b'\n' | b'\r' | b';' if paren_depth == 0 && brace_depth == 0 => {
1444                segments.push(&command[start..i]);
1445                i += 1;
1446                start = i;
1447            }
1448            b'&' if paren_depth == 0 && brace_depth == 0 => {
1449                if i + 1 < len && bytes[i + 1] == b'&' {
1450                    // &&
1451                    segments.push(&command[start..i]);
1452                    i += 2;
1453                    start = i;
1454                } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
1455                    // Redirect operator, NOT a separator: `2>&1`, `1>&2`, `>&file` (prev is '>')
1456                    // or `&>file`, `&>>file` (next is '>'). The '&' belongs to the current
1457                    // command — splitting here would mistake the fd/target (e.g. `1`) for a
1458                    // standalone command and falsely block it (#334).
1459                    i += 1;
1460                } else {
1461                    // single & (background operator) — still a command separator
1462                    segments.push(&command[start..i]);
1463                    i += 1;
1464                    start = i;
1465                }
1466            }
1467            b'|' if paren_depth == 0 && brace_depth == 0 => {
1468                if i + 1 < len && bytes[i + 1] == b'|' {
1469                    // ||
1470                    segments.push(&command[start..i]);
1471                    i += 2;
1472                    start = i;
1473                } else if i > 0 && bytes[i - 1] == b'>' {
1474                    // `>|` (noclobber redirect), NOT a pipe: the '|' belongs to
1475                    // the redirect operator and the following token is a file
1476                    // path, not a command. Splitting here treated the target
1477                    // (e.g. `out` in `date >| out`) as a command and falsely
1478                    // blocked it against the allowlist (#387).
1479                    i += 1;
1480                } else {
1481                    // pipe
1482                    segments.push(&command[start..i]);
1483                    i += 1;
1484                    start = i;
1485                }
1486            }
1487            _ => {
1488                i += 1;
1489            }
1490        }
1491    }
1492
1493    if start < len {
1494        segments.push(&command[start..]);
1495    }
1496
1497    segments
1498}
1499
1500/// Extract the base command name from a single segment (no operators).
1501fn extract_base_from_segment(segment: &str) -> String {
1502    let trimmed = segment.trim();
1503    if trimmed.is_empty() {
1504        return String::new();
1505    }
1506
1507    let cmd_part = skip_env_assignments(trimmed);
1508    if cmd_part.is_empty() {
1509        return String::new();
1510    }
1511
1512    let tokens = shell_tokenize(cmd_part);
1513    // #939: a leading `{` brace-group token (e.g. from
1514    // `agent_wrapper::rebuild`'s `{ <real command>\n} && pwd ...` wrapping)
1515    // is not itself a command — skip it so the base extracted is the real
1516    // command inside the group, not the brace.
1517    let mut token_iter = tokens.iter();
1518    let first_token = match token_iter.next().map(String::as_str) {
1519        Some("{") => token_iter.next().map_or("", String::as_str),
1520        other => other.unwrap_or(""),
1521    };
1522
1523    first_token
1524        .rsplit('/')
1525        .next()
1526        .unwrap_or(first_token)
1527        .to_string()
1528}
1529
1530/// Skip leading KEY=VALUE environment variable assignments.
1531/// Uses quote-aware scanning so `FOO="bar baz" git status` correctly
1532/// skips the entire `FOO="bar baz"` token.
1533fn skip_env_assignments(segment: &str) -> &str {
1534    let mut rest = segment;
1535    loop {
1536        let rest_trimmed = rest.trim_start();
1537        if rest_trimmed.is_empty() {
1538            return rest_trimmed;
1539        }
1540        let end = quote_aware_token_end(rest_trimmed);
1541        if end == 0 {
1542            return rest_trimmed;
1543        }
1544        let raw_token = &rest_trimmed[..end];
1545        let unquoted: String = raw_token
1546            .chars()
1547            .filter(|c| *c != '"' && *c != '\'')
1548            .collect();
1549        if unquoted.contains('=')
1550            && !unquoted.starts_with('-')
1551            && !unquoted.starts_with('/')
1552            && !unquoted.starts_with('.')
1553        {
1554            rest = &rest_trimmed[end..];
1555        } else {
1556            return rest_trimmed;
1557        }
1558    }
1559}
1560
1561fn effective_allowlist() -> Vec<String> {
1562    // LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE completely replaces the config (for testing)
1563    if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
1564        return ov
1565            .split(',')
1566            .map(|s| s.trim().to_string())
1567            .filter(|s| !s.is_empty())
1568            .collect();
1569    }
1570    let cfg = crate::core::config::Config::load();
1571    let mut list = cfg.shell_allowlist;
1572    // `shell_allowlist_extra` is purely additive (written by `lean-ctx allow <cmd>`),
1573    // so users can permit a command without nuking the built-in defaults. It only
1574    // matters in restricted mode — when the base list is empty all commands pass anyway.
1575    if !list.is_empty() {
1576        for entry in cfg.shell_allowlist_extra {
1577            if !entry.is_empty() && !list.contains(&entry) {
1578                list.push(entry);
1579            }
1580        }
1581    }
1582    if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
1583        for entry in env_val
1584            .split(',')
1585            .map(|s| s.trim().to_string())
1586            .filter(|s| !s.is_empty())
1587        {
1588            if !list.contains(&entry) {
1589                list.push(entry);
1590            }
1591        }
1592    }
1593    list
1594}
1595
1596/// Builds the actionable, self-diagnosing message shown when a command's base binary
1597/// is not in the allowlist. Unlike a bare "not allowed" string, it tells the user
1598/// (1) the exact additive fix, (2) the real config path the MCP server reads, and
1599/// (3) — crucially — whether their `config.toml` silently failed to parse (in which
1600/// case lean-ctx is on defaults, which is the usual reason an allowlist edit "did
1601/// nothing"). That last signal is otherwise invisible over an MCP/stdio transport.
1602fn allowlist_block_message(base: &str) -> String {
1603    let cfg_path = crate::core::config::Config::path().map_or_else(
1604        || "~/.lean-ctx/config.toml".to_string(),
1605        |p| p.display().to_string(),
1606    );
1607
1608    let mut msg = format!(
1609        "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
1610         This is a permanent restriction, not a transient error.\n\
1611         Fix (additive, keeps the defaults): run  lean-ctx allow {base}\n\
1612         Config in effect: {cfg_path}\n\
1613         Or disable the allowlist entirely: set  shell_allowlist = []\n\
1614         Or turn off all shell gating (you own the risk): set  shell_security = \"off\"  \
1615         (or env LEAN_CTX_SHELL_SECURITY=off) — compression still applies.\n\
1616         Do NOT retry this command — it will fail again with the same error.\n         For multi-line scripts or complex pipelines: use ctx_execute(language=\"shell\") instead — \n         it is the sanctioned path for script execution without allowlist restrictions."
1617    );
1618
1619    if crate::core::config::cloud_infra_commands().contains(&base) {
1620        msg.push_str(
1621            "\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
1622             excluded from the defaults — they mutate remote infrastructure with \
1623             ambient credentials. Opting in is a deliberate user decision.",
1624        );
1625    }
1626
1627    if let Some(parse_err) = crate::core::config::last_config_parse_error() {
1628        msg.push_str(&format!(
1629            "\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
1630             built-in defaults — this is almost certainly why editing the allowlist had no \
1631             effect. Fix the TOML error below, then retry:\n  {parse_err}\n  File: {cfg_path}"
1632        ));
1633    } else if let Some(missing) = crate::core::config::Config::missing_config_path() {
1634        // The resolved config doesn't exist → lean-ctx is on defaults. An edit
1635        // made to a config.toml in a different dir (XDG vs legacy ~/.lean-ctx) or
1636        // under a sandboxed/container HOME is never read — say so over MCP (#540).
1637        msg.push_str(&format!(
1638            "\n\n⚠ No config file exists at {} — lean-ctx is running on built-in defaults. \
1639             If you added the command to a config.toml in a DIFFERENT location (XDG \
1640             ~/.config/lean-ctx vs legacy ~/.lean-ctx, or your MCP client launches lean-ctx \
1641             in a sandbox/container with a different HOME), the runtime never reads it. \
1642             `lean-ctx doctor` prints the path actually in effect; pin it with \
1643             LEAN_CTX_CONFIG_DIR.",
1644            missing.display()
1645        ));
1646    }
1647
1648    // A project-local `shell_allowlist`/`shell_allowlist_extra` is silently
1649    // withheld for an untrusted workspace; surface that here so the edit's
1650    // no-op reason isn't buried in an MCP-invisible stderr warning (#540).
1651    if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
1652        msg.push_str("\n\n⚠ ");
1653        msg.push_str(&notice);
1654    }
1655
1656    msg
1657}
1658
1659/// Public accessor for extracting all command segments.
1660pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
1661    extract_all_commands(command)
1662}
1663
1664/// Public accessor: the fully-resolved allowlist actually enforced by the MCP tools
1665/// (base `shell_allowlist` + additive `shell_allowlist_extra` + env), deduplicated.
1666/// Empty means blocklist-only mode (all commands pass). Used by `lean-ctx allow`
1667/// and `lean-ctx doctor` to show users exactly what the runtime sees.
1668#[must_use]
1669pub fn effective_allowlist_pub() -> Vec<String> {
1670    effective_allowlist()
1671}
1672
1673// Legacy compat: single-segment extraction (used by other callers)
1674pub fn extract_base_command(command: &str) -> String {
1675    let first_seg = split_on_operators(command)
1676        .into_iter()
1677        .next()
1678        .unwrap_or(command);
1679    extract_base_from_segment(first_seg)
1680}