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    // #855: a segment that is *entirely* env-var assignments (`VAR=$(cmd …)`,
844    // nothing left over — `out=$(gh pr view …)` is a common, legitimate idiom
845    // for capturing command output) still executes the substituted command.
846    // extract_base_from_segment resolves this segment's own base to empty
847    // (skip_env_assignments consumes the whole thing), so without this the
848    // substituted command would silently escape validation entirely — not
849    // just fail to be *found*, but never be *checked* at all. Recurse into it
850    // as its own leaf so `gh`, not the assignment wrapper, is what actually
851    // gets checked against the allowlist.
852    for inner in assignment_substitution_leaves(s) {
853        for inner_seg in extract_all_commands(inner) {
854            resolve_segment_leaves(&inner_seg, depth + 1, out)?;
855        }
856    }
857    // Anything else (incl. `( … ) trailing`, brace groups, leftover delimiters) is
858    // pushed verbatim: base-extraction below sees a first token like `(ls)` or `{`
859    // that cannot match any allowlist entry, so it is blocked. `cmd (sub)` without
860    // a separator is a shell syntax error, so no executable leaf escapes here.
861    out.push(s.to_string());
862    Ok(())
863}
864
865/// Find the inner text of a `$(...)` command substitution whose `(` sits at
866/// byte offset `open` in `s`. Quote-aware (mirrors `balanced_paren_inner`) so
867/// a nested quoted `)` — e.g. inside a jq filter — doesn't end the walk early.
868/// Returns `(inner, end)` with `end` just past the matching `)`; `None` if
869/// unbalanced.
870fn balanced_paren_at(s: &str, open: usize) -> Option<(&str, usize)> {
871    let bytes = s.as_bytes();
872    let len = bytes.len();
873    let mut depth: i32 = 0;
874    let mut in_single_quote = false;
875    let mut in_double_quote = false;
876    let mut i = open;
877    while i < len {
878        let ch = bytes[i];
879        if in_single_quote {
880            if ch == b'\'' {
881                in_single_quote = false;
882            }
883            i += 1;
884            continue;
885        }
886        if in_double_quote {
887            match ch {
888                b'\\' => i = (i + 2).min(len),
889                b'"' => {
890                    in_double_quote = false;
891                    i += 1;
892                }
893                _ => i += 1,
894            }
895            continue;
896        }
897        match ch {
898            b'\\' => i = (i + 2).min(len),
899            b'\'' => {
900                in_single_quote = true;
901                i += 1;
902            }
903            b'"' => {
904                in_double_quote = true;
905                i += 1;
906            }
907            b'(' => {
908                depth += 1;
909                i += 1;
910            }
911            b')' => {
912                depth -= 1;
913                i += 1;
914                if depth == 0 {
915                    return Some((&s[open + 1..i - 1], i));
916                }
917            }
918            _ => i += 1,
919        }
920    }
921    None
922}
923
924/// #855: the leading run of `VAR=value` assignment tokens in `s` (the same
925/// prefix `skip_env_assignments` walks past) — as a slice of `s`, covering
926/// both `VAR=$(cmd)` alone and `A=1 B=$(cmd) realcmd args` (the assignments
927/// still execute even when a real command follows them).
928fn leading_assignment_prefix(s: &str) -> &str {
929    let rest = skip_env_assignments(s);
930    let offset = (rest.as_ptr() as usize).saturating_sub(s.as_ptr() as usize);
931    &s[..offset.min(s.len())]
932}
933
934/// #855: collect the inner command text of every top-level `$(...)` found in
935/// `s`'s leading env-assignment prefix (`VAR=$(cmd)`, `A=1 B=$(cmd) realcmd`,
936/// …) — those substitutions execute regardless of whether a real command
937/// follows the assignments. `cmd "$(sub)"` in *argument* position (after the
938/// real command) is untouched here and keeps its existing warn-only handling
939/// (`check_substitution_in_args`); this only closes the gap for substitutions
940/// hiding in a leading assignment.
941fn assignment_substitution_leaves(s: &str) -> Vec<&str> {
942    let prefix = leading_assignment_prefix(s);
943    if prefix.is_empty() {
944        return Vec::new();
945    }
946    let mut found = Vec::new();
947    let bytes = prefix.as_bytes();
948    let len = bytes.len();
949    let mut in_single_quote = false;
950    let mut in_double_quote = false;
951    let mut i = 0;
952    while i < len {
953        let ch = bytes[i];
954        if in_single_quote {
955            if ch == b'\'' {
956                in_single_quote = false;
957            }
958            i += 1;
959            continue;
960        }
961        if in_double_quote {
962            match ch {
963                b'\\' => {
964                    i = (i + 2).min(len);
965                    continue;
966                }
967                b'"' => in_double_quote = false,
968                _ => {}
969            }
970            i += 1;
971            continue;
972        }
973        match ch {
974            b'\\' => {
975                i = (i + 2).min(len);
976                continue;
977            }
978            b'\'' => in_single_quote = true,
979            b'"' => in_double_quote = true,
980            b'$' if i + 1 < len && bytes[i + 1] == b'(' => {
981                if let Some((inner, end)) = balanced_paren_at(prefix, i + 1) {
982                    found.push(inner);
983                    i = end;
984                    continue;
985                }
986            }
987            _ => {}
988        }
989        i += 1;
990    }
991    found
992}
993
994/// Return the substring after the first whitespace-delimited (quote-aware) token.
995fn remainder_after_first_token(s: &str) -> &str {
996    let trimmed = s.trim_start();
997    let end = quote_aware_token_end(trimmed);
998    &trimmed[end..]
999}
1000
1001/// If `s` is a single balanced `( … )` subshell with nothing trailing the closing
1002/// paren, return the inner command (`(a; b)` → `a; b`). `(a) b` returns `None`:
1003/// the trailing content falls through to base extraction, which blocks it.
1004fn balanced_paren_inner(segment: &str) -> Option<&str> {
1005    let trimmed = segment.trim();
1006    let bytes = trimmed.as_bytes();
1007    if bytes.first() != Some(&b'(') {
1008        return None;
1009    }
1010    let len = bytes.len();
1011    let mut depth: i32 = 0;
1012    let mut in_single_quote = false;
1013    let mut in_double_quote = false;
1014    let mut i = 0;
1015    while i < len {
1016        let ch = bytes[i];
1017        if in_single_quote {
1018            if ch == b'\'' {
1019                in_single_quote = false;
1020            }
1021            i += 1;
1022            continue;
1023        }
1024        if in_double_quote {
1025            match ch {
1026                b'\\' => i += 1, // \" and \\ stay inside the string
1027                b'"' => in_double_quote = false,
1028                _ => {}
1029            }
1030            i += 1;
1031            continue;
1032        }
1033        match ch {
1034            // Escaped parens are data (GL #1160): `rg foo\(bar\)` must not
1035            // shift the depth this walker uses to find the real closing paren.
1036            b'\\' => i += 1,
1037            b'\'' => in_single_quote = true,
1038            b'"' => in_double_quote = true,
1039            b'(' => depth += 1,
1040            b')' => {
1041                depth -= 1;
1042                if depth == 0 {
1043                    return if i == len - 1 {
1044                        Some(trimmed[1..i].trim())
1045                    } else {
1046                        None
1047                    };
1048                }
1049            }
1050            _ => {}
1051        }
1052        i += 1;
1053    }
1054    None
1055}
1056
1057/// True when the command uses a `case`/`esac`/`;;` construct. The leaf walker
1058/// deliberately does not parse these (the `pattern)` arms make safe leaf
1059/// extraction error-prone), so they are blocked outright in restricted mode.
1060fn has_case_construct(command: &str) -> bool {
1061    for seg in split_on_operators(command) {
1062        if shell_tokenize(seg.trim())
1063            .iter()
1064            .any(|t| t == "case" || t == "esac")
1065        {
1066            return true;
1067        }
1068    }
1069    contains_double_semicolon(command)
1070}
1071
1072/// Quote-aware scan for a `;;` terminator (the `case` arm separator).
1073fn contains_double_semicolon(command: &str) -> bool {
1074    let bytes = command.as_bytes();
1075    let len = bytes.len();
1076    let mut in_single_quote = false;
1077    let mut in_double_quote = false;
1078    let mut i = 0;
1079    while i < len {
1080        let ch = bytes[i];
1081        if in_single_quote {
1082            if ch == b'\'' {
1083                in_single_quote = false;
1084            }
1085            i += 1;
1086            continue;
1087        }
1088        if in_double_quote {
1089            if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
1090                in_double_quote = false;
1091            }
1092            i += 1;
1093            continue;
1094        }
1095        match ch {
1096            b'\'' => in_single_quote = true,
1097            b'"' => in_double_quote = true,
1098            b';' if i + 1 < len && bytes[i + 1] == b';' => return true,
1099            _ => {}
1100        }
1101        i += 1;
1102    }
1103    false
1104}
1105
1106/// #813: check whether a command token resolves to an existing file under the
1107/// project root. Called as a fallback when the base command name isn't in the
1108/// allowlist — agents frequently build project-local binaries (`go build -o
1109/// cbc_old`, `cargo build`, `gcc -o bench`) that shouldn't require a manual
1110/// `lean-ctx allow` round-trip.
1111///
1112/// Only auto-allows when ALL of:
1113/// 1. The token is a path (contains `/` or starts with `./`)
1114/// 2. The resolved path is an existing file
1115/// 3. The resolved path is under the project root
1116fn is_project_root_binary(token: &str) -> bool {
1117    if !token.contains('/') {
1118        return false;
1119    }
1120    let path = std::path::Path::new(token);
1121    let resolved = if path.is_relative() {
1122        match std::env::current_dir() {
1123            Ok(cwd) => cwd.join(path),
1124            Err(_) => return false,
1125        }
1126    } else {
1127        path.to_path_buf()
1128    };
1129    let Ok(canonical) = resolved.canonicalize() else {
1130        return false;
1131    };
1132    if !canonical.is_file() {
1133        return false;
1134    }
1135    let Some(root) = crate::server::derive_project_root_from_cwd() else {
1136        return false;
1137    };
1138    let root_path = std::path::Path::new(&root);
1139    let canonical_root = root_path
1140        .canonicalize()
1141        .unwrap_or_else(|_| root_path.to_path_buf());
1142    canonical.starts_with(&canonical_root)
1143}
1144
1145fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), ShellError> {
1146    if allowlist.is_empty() {
1147        return Ok(());
1148    }
1149
1150    if has_dangerous_patterns(command) {
1151        return Err(format!(
1152            "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
1153             which is blocked in restricted mode. \
1154             This is a permanent security restriction, not a transient error.\n\
1155             Command: {command}"
1156        )
1157        .into());
1158    }
1159
1160    let segments = expand_to_leaf_segments(command)?;
1161    if segments.is_empty() {
1162        return Err("[BLOCKED — DO NOT RETRY] Empty command".into());
1163    }
1164
1165    let total = segments.len();
1166    for (idx, seg) in segments.iter().enumerate() {
1167        check_inline_env_block(seg)?;
1168        let base = extract_base_from_segment(seg);
1169        if base.is_empty() {
1170            continue;
1171        }
1172        if UNCONDITIONAL_BLOCKED.contains(&base.as_str()) {
1173            return Err(format!(
1174                "[BLOCKED — DO NOT RETRY] '{base}' is unconditionally blocked \
1175                 regardless of allowlist membership. \
1176                 This is a permanent security restriction.\n\
1177                 Command: {command}"
1178            )
1179            .into());
1180        }
1181        check_interpreter_abuse(seg, allowlist)?;
1182        check_dangerous_flags(seg)?;
1183        if !allowlist.iter().any(|a| a == &base) {
1184            // #813: auto-allow binaries that resolve to existing files under
1185            // the project root. The first token (before rsplit) carries the
1186            // path context (e.g. "./cbc_old", "../bin/bench").
1187            let first_token = shell_tokenize(skip_env_assignments(seg.trim()))
1188                .into_iter()
1189                .next()
1190                .unwrap_or_default();
1191            if is_project_root_binary(&first_token) {
1192                tracing::info!(
1193                    "[shell_allowlist] auto-allowing project-root binary: {first_token}"
1194                );
1195                continue;
1196            }
1197
1198            // #815: for compound commands, tell the user which segment was
1199            // blocked and that nothing ran (the pipeline is rejected as a
1200            // whole before execution, so no prefix commands executed).
1201            let mut msg = allowlist_block_message(&base);
1202            if total > 1 {
1203                msg.push_str(&format!(
1204                    "\n\n[pipeline: segment {}/{total} blocked — \
1205                     the entire command was rejected before execution, \
1206                     no part of the pipeline ran]",
1207                    idx + 1,
1208                ));
1209            }
1210            return Err(msg.into());
1211        }
1212    }
1213    Ok(())
1214}
1215
1216/// Detect dangerous shell patterns that bypass allowlist intent.
1217///
1218/// Only blocks patterns that are genuinely dangerous at command position.
1219/// `$()` and backticks in *arguments* are allowed — the base command is
1220/// already validated by the allowlist, and blocking substitutions in
1221/// arguments breaks legitimate workflows (e.g. `git commit -m "$(cat ...)"`,
1222/// pre-commit hooks, playwright scripts).
1223fn has_dangerous_patterns(command: &str) -> bool {
1224    let trimmed = command.trim();
1225
1226    for blocked in UNCONDITIONAL_BLOCKED {
1227        let with_space = format!("{blocked} ");
1228        if trimmed.starts_with(&with_space) {
1229            return true;
1230        }
1231        for sep in ["; ", "&& ", "|| ", "| ", "\n"] {
1232            if trimmed.contains(&format!("{sep}{blocked} ")) {
1233                return true;
1234            }
1235        }
1236    }
1237
1238    if has_substitution_at_command_pos(trimmed) {
1239        return true;
1240    }
1241
1242    false
1243}
1244
1245/// Check if `$()` or backticks appear at command position (first token
1246/// of any segment). Substitutions in *arguments* are intentionally
1247/// allowed — the security boundary is the base-command allowlist check.
1248fn has_substitution_at_command_pos(command: &str) -> bool {
1249    let segments = split_on_operators(command);
1250    for seg in segments {
1251        let trimmed = seg.trim();
1252        let cmd_start = skip_env_assignments(trimmed);
1253
1254        if cmd_start.starts_with("$(") {
1255            return true;
1256        }
1257
1258        let tokens = shell_tokenize(cmd_start);
1259        let first_token = tokens.first().map_or("", std::string::String::as_str);
1260        if first_token.starts_with('`') || first_token == "`" {
1261            return true;
1262        }
1263    }
1264    false
1265}
1266
1267/// Extract ALL command segments from a compound shell command.
1268/// Splits on: &&, ||, ;, | (pipe), and handles subshell grouping.
1269fn extract_all_commands(command: &str) -> Vec<String> {
1270    split_on_operators(command)
1271        .into_iter()
1272        .map(|s| s.trim().to_string())
1273        .filter(|s| !s.is_empty())
1274        .collect()
1275}
1276
1277/// Split command string on shell operators: ;, &&, ||, |
1278/// Respects single/double quotes, parentheses nesting, and backslash escapes
1279/// outside single quotes (GL #1160): `rg split\.label\|quantityLabel` is ONE
1280/// command — the escaped pipe is regex data, not an operator. The old scanner
1281/// split there and blocked the pattern fragment as an unknown command; same
1282/// for `find … -exec rm {} \;`.
1283fn split_on_operators(command: &str) -> Vec<&str> {
1284    let mut segments = Vec::new();
1285    let mut start = 0;
1286    let bytes = command.as_bytes();
1287    let len = bytes.len();
1288    let mut i = 0;
1289    let mut in_single_quote = false;
1290    let mut in_double_quote = false;
1291    let mut paren_depth: u32 = 0;
1292
1293    while i < len {
1294        let ch = bytes[i];
1295
1296        if in_single_quote {
1297            if ch == b'\'' {
1298                in_single_quote = false;
1299            }
1300            i += 1;
1301            continue;
1302        }
1303
1304        if in_double_quote {
1305            match ch {
1306                // \" stays inside the string; \\ consumes both so `"x\\"` closes.
1307                b'\\' => i = (i + 2).min(len),
1308                b'"' => {
1309                    in_double_quote = false;
1310                    i += 1;
1311                }
1312                _ => i += 1,
1313            }
1314            continue;
1315        }
1316
1317        match ch {
1318            b'\\' => {
1319                // Escaped char is data (bash semantics outside quotes) — never
1320                // an operator or quote opener.
1321                i = (i + 2).min(len);
1322            }
1323            b'\'' => {
1324                in_single_quote = true;
1325                i += 1;
1326            }
1327            b'"' => {
1328                in_double_quote = true;
1329                i += 1;
1330            }
1331            b'(' => {
1332                paren_depth += 1;
1333                i += 1;
1334            }
1335            b')' => {
1336                paren_depth = paren_depth.saturating_sub(1);
1337                i += 1;
1338            }
1339            b'\n' | b'\r' | b';' if paren_depth == 0 => {
1340                segments.push(&command[start..i]);
1341                i += 1;
1342                start = i;
1343            }
1344            b'&' if paren_depth == 0 => {
1345                if i + 1 < len && bytes[i + 1] == b'&' {
1346                    // &&
1347                    segments.push(&command[start..i]);
1348                    i += 2;
1349                    start = i;
1350                } else if (i > 0 && bytes[i - 1] == b'>') || (i + 1 < len && bytes[i + 1] == b'>') {
1351                    // Redirect operator, NOT a separator: `2>&1`, `1>&2`, `>&file` (prev is '>')
1352                    // or `&>file`, `&>>file` (next is '>'). The '&' belongs to the current
1353                    // command — splitting here would mistake the fd/target (e.g. `1`) for a
1354                    // standalone command and falsely block it (#334).
1355                    i += 1;
1356                } else {
1357                    // single & (background operator) — still a command separator
1358                    segments.push(&command[start..i]);
1359                    i += 1;
1360                    start = i;
1361                }
1362            }
1363            b'|' if paren_depth == 0 => {
1364                if i + 1 < len && bytes[i + 1] == b'|' {
1365                    // ||
1366                    segments.push(&command[start..i]);
1367                    i += 2;
1368                    start = i;
1369                } else if i > 0 && bytes[i - 1] == b'>' {
1370                    // `>|` (noclobber redirect), NOT a pipe: the '|' belongs to
1371                    // the redirect operator and the following token is a file
1372                    // path, not a command. Splitting here treated the target
1373                    // (e.g. `out` in `date >| out`) as a command and falsely
1374                    // blocked it against the allowlist (#387).
1375                    i += 1;
1376                } else {
1377                    // pipe
1378                    segments.push(&command[start..i]);
1379                    i += 1;
1380                    start = i;
1381                }
1382            }
1383            _ => {
1384                i += 1;
1385            }
1386        }
1387    }
1388
1389    if start < len {
1390        segments.push(&command[start..]);
1391    }
1392
1393    segments
1394}
1395
1396/// Extract the base command name from a single segment (no operators).
1397fn extract_base_from_segment(segment: &str) -> String {
1398    let trimmed = segment.trim();
1399    if trimmed.is_empty() {
1400        return String::new();
1401    }
1402
1403    let cmd_part = skip_env_assignments(trimmed);
1404    if cmd_part.is_empty() {
1405        return String::new();
1406    }
1407
1408    let tokens = shell_tokenize(cmd_part);
1409    let first_token = tokens.first().map_or("", std::string::String::as_str);
1410
1411    first_token
1412        .rsplit('/')
1413        .next()
1414        .unwrap_or(first_token)
1415        .to_string()
1416}
1417
1418/// Skip leading KEY=VALUE environment variable assignments.
1419/// Uses quote-aware scanning so `FOO="bar baz" git status` correctly
1420/// skips the entire `FOO="bar baz"` token.
1421fn skip_env_assignments(segment: &str) -> &str {
1422    let mut rest = segment;
1423    loop {
1424        let rest_trimmed = rest.trim_start();
1425        if rest_trimmed.is_empty() {
1426            return rest_trimmed;
1427        }
1428        let end = quote_aware_token_end(rest_trimmed);
1429        if end == 0 {
1430            return rest_trimmed;
1431        }
1432        let raw_token = &rest_trimmed[..end];
1433        let unquoted: String = raw_token
1434            .chars()
1435            .filter(|c| *c != '"' && *c != '\'')
1436            .collect();
1437        if unquoted.contains('=')
1438            && !unquoted.starts_with('-')
1439            && !unquoted.starts_with('/')
1440            && !unquoted.starts_with('.')
1441        {
1442            rest = &rest_trimmed[end..];
1443        } else {
1444            return rest_trimmed;
1445        }
1446    }
1447}
1448
1449fn effective_allowlist() -> Vec<String> {
1450    // LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE completely replaces the config (for testing)
1451    if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
1452        return ov
1453            .split(',')
1454            .map(|s| s.trim().to_string())
1455            .filter(|s| !s.is_empty())
1456            .collect();
1457    }
1458    let cfg = crate::core::config::Config::load();
1459    let mut list = cfg.shell_allowlist;
1460    // `shell_allowlist_extra` is purely additive (written by `lean-ctx allow <cmd>`),
1461    // so users can permit a command without nuking the built-in defaults. It only
1462    // matters in restricted mode — when the base list is empty all commands pass anyway.
1463    if !list.is_empty() {
1464        for entry in cfg.shell_allowlist_extra {
1465            if !entry.is_empty() && !list.contains(&entry) {
1466                list.push(entry);
1467            }
1468        }
1469    }
1470    if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
1471        for entry in env_val
1472            .split(',')
1473            .map(|s| s.trim().to_string())
1474            .filter(|s| !s.is_empty())
1475        {
1476            if !list.contains(&entry) {
1477                list.push(entry);
1478            }
1479        }
1480    }
1481    list
1482}
1483
1484/// Builds the actionable, self-diagnosing message shown when a command's base binary
1485/// is not in the allowlist. Unlike a bare "not allowed" string, it tells the user
1486/// (1) the exact additive fix, (2) the real config path the MCP server reads, and
1487/// (3) — crucially — whether their `config.toml` silently failed to parse (in which
1488/// case lean-ctx is on defaults, which is the usual reason an allowlist edit "did
1489/// nothing"). That last signal is otherwise invisible over an MCP/stdio transport.
1490fn allowlist_block_message(base: &str) -> String {
1491    let cfg_path = crate::core::config::Config::path().map_or_else(
1492        || "~/.lean-ctx/config.toml".to_string(),
1493        |p| p.display().to_string(),
1494    );
1495
1496    let mut msg = format!(
1497        "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
1498         This is a permanent restriction, not a transient error.\n\
1499         Fix (additive, keeps the defaults): run  lean-ctx allow {base}\n\
1500         Config in effect: {cfg_path}\n\
1501         Or disable the allowlist entirely: set  shell_allowlist = []\n\
1502         Or turn off all shell gating (you own the risk): set  shell_security = \"off\"  \
1503         (or env LEAN_CTX_SHELL_SECURITY=off) — compression still applies.\n\
1504         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."
1505    );
1506
1507    if crate::core::config::cloud_infra_commands().contains(&base) {
1508        msg.push_str(
1509            "\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
1510             excluded from the defaults — they mutate remote infrastructure with \
1511             ambient credentials. Opting in is a deliberate user decision.",
1512        );
1513    }
1514
1515    if let Some(parse_err) = crate::core::config::last_config_parse_error() {
1516        msg.push_str(&format!(
1517            "\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
1518             built-in defaults — this is almost certainly why editing the allowlist had no \
1519             effect. Fix the TOML error below, then retry:\n  {parse_err}\n  File: {cfg_path}"
1520        ));
1521    } else if let Some(missing) = crate::core::config::Config::missing_config_path() {
1522        // The resolved config doesn't exist → lean-ctx is on defaults. An edit
1523        // made to a config.toml in a different dir (XDG vs legacy ~/.lean-ctx) or
1524        // under a sandboxed/container HOME is never read — say so over MCP (#540).
1525        msg.push_str(&format!(
1526            "\n\n⚠ No config file exists at {} — lean-ctx is running on built-in defaults. \
1527             If you added the command to a config.toml in a DIFFERENT location (XDG \
1528             ~/.config/lean-ctx vs legacy ~/.lean-ctx, or your MCP client launches lean-ctx \
1529             in a sandbox/container with a different HOME), the runtime never reads it. \
1530             `lean-ctx doctor` prints the path actually in effect; pin it with \
1531             LEAN_CTX_CONFIG_DIR.",
1532            missing.display()
1533        ));
1534    }
1535
1536    // A project-local `shell_allowlist`/`shell_allowlist_extra` is silently
1537    // withheld for an untrusted workspace; surface that here so the edit's
1538    // no-op reason isn't buried in an MCP-invisible stderr warning (#540).
1539    if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
1540        msg.push_str("\n\n⚠ ");
1541        msg.push_str(&notice);
1542    }
1543
1544    msg
1545}
1546
1547/// Public accessor for extracting all command segments.
1548pub fn extract_all_commands_pub(command: &str) -> Vec<String> {
1549    extract_all_commands(command)
1550}
1551
1552/// Public accessor: the fully-resolved allowlist actually enforced by the MCP tools
1553/// (base `shell_allowlist` + additive `shell_allowlist_extra` + env), deduplicated.
1554/// Empty means blocklist-only mode (all commands pass). Used by `lean-ctx allow`
1555/// and `lean-ctx doctor` to show users exactly what the runtime sees.
1556#[must_use]
1557pub fn effective_allowlist_pub() -> Vec<String> {
1558    effective_allowlist()
1559}
1560
1561// Legacy compat: single-segment extraction (used by other callers)
1562pub fn extract_base_command(command: &str) -> String {
1563    let first_seg = split_on_operators(command)
1564        .into_iter()
1565        .next()
1566        .unwrap_or(command);
1567    extract_base_from_segment(first_seg)
1568}