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