lean_ctx/core/shell_allowlist/
config.rs1pub(super) fn effective_allowlist() -> Vec<String> {
2 filter_valid_entries(raw_allowlist_entries())
3}
4
5fn raw_allowlist_entries() -> Vec<String> {
9 use crate::core::config::shell_policy;
10 if let Some(ov) = shell_policy::env("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
13 return ov
14 .split(',')
15 .map(|s| s.trim().to_string())
16 .filter(|s| !s.is_empty())
17 .collect();
18 }
19 let cfg = shell_policy::config();
20 let mut list = cfg.shell_allowlist.clone();
21 if !list.is_empty() {
25 for entry in &cfg.shell_allowlist_extra {
26 if !entry.is_empty() && !list.contains(entry) {
27 list.push(entry.clone());
28 }
29 }
30 }
31 if let Some(env_val) = shell_policy::env("LEAN_CTX_SHELL_ALLOWLIST") {
32 for entry in env_val
33 .split(',')
34 .map(|s| s.trim().to_string())
35 .filter(|s| !s.is_empty())
36 {
37 if !list.contains(&entry) {
38 list.push(entry);
39 }
40 }
41 }
42 list
43}
44
45#[must_use]
50pub fn invalid_allowlist_entries_pub() -> Vec<String> {
51 raw_allowlist_entries()
52 .into_iter()
53 .filter(|e| !is_valid_allowlist_entry(e))
54 .collect()
55}
56
57#[must_use]
63pub fn narrowed_allowlist_entries_pub() -> Vec<String> {
64 if !crate::core::config::shell_policy::config().shell_allowlist_subcommand_scoping {
65 return Vec::new();
66 }
67 raw_allowlist_entries()
68 .into_iter()
69 .filter(|e| is_valid_allowlist_entry(e))
70 .filter(|e| {
71 let words: Vec<&str> = e.split_whitespace().collect();
72 words.len() > 1 && words.last() != Some(&"*")
73 })
74 .collect()
75}
76
77#[must_use]
85pub fn shadowing_entry(entries: &[String], a: &str) -> Option<String> {
86 let a_words: Vec<&str> = a.split_whitespace().collect();
87 let a_prefix: &[&str] = if a_words.last() == Some(&"*") {
88 &a_words[..a_words.len() - 1]
89 } else {
90 &a_words
91 };
92 entries
93 .iter()
94 .find(|b| {
95 if b.as_str() == a {
96 return false;
97 }
98 let b_words: Vec<&str> = b.split_whitespace().collect();
99 if b_words.len() == 1 {
100 b_words.first() == a_words.first()
101 } else if b_words.last() == Some(&"*") {
102 let b_prefix = &b_words[..b_words.len() - 1];
103 b_prefix.len() <= a_prefix.len() && a_prefix.starts_with(b_prefix)
104 } else {
105 false
106 }
107 })
108 .cloned()
109}
110
111#[must_use]
114pub fn shadowed_allowlist_entries_pub(entries: &[String]) -> Vec<(String, String)> {
115 entries
116 .iter()
117 .filter_map(|a| shadowing_entry(entries, a).map(|b| (a.clone(), b)))
118 .collect()
119}
120
121pub fn is_valid_allowlist_entry(entry: &str) -> bool {
125 let words: Vec<&str> = entry.split_whitespace().collect();
126 match words.iter().filter(|w| **w == "*").count() {
127 0 => true,
128 1 => words.len() > 1 && words.last() == Some(&"*"),
129 _ => false,
130 }
131}
132
133fn filter_valid_entries(entries: Vec<String>) -> Vec<String> {
139 entries
140 .into_iter()
141 .filter(|entry| {
142 let valid = is_valid_allowlist_entry(entry);
143 if !valid {
144 tracing::warn!(
145 "[shell_allowlist] dropping invalid allowlist entry '{entry}': `*` must be \
146 the single trailing token, not embedded, bare, or repeated"
147 );
148 }
149 valid
150 })
151 .collect()
152}
153
154pub(super) fn is_plausible_command_name(base: &str) -> bool {
161 if base.is_empty() {
162 return false;
163 }
164 !base.chars().any(|c| {
165 c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\'' | '$' | '&' | '|' | ';' | '`')
166 })
167}
168
169pub(super) fn allowlist_block_message(base: &str, tokens: &[String], entries: &[String]) -> String {
176 let cfg_path = crate::core::config::shell_policy::config_path().map_or_else(
177 || "~/.lean-ctx/config.toml".to_string(),
178 |p| p.display().to_string(),
179 );
180
181 if let Some(name) = base.strip_prefix('$') {
187 let hint = name.trim_start_matches('{').trim_end_matches('}');
188 return format!(
189 "[BLOCKED — DO NOT RETRY] '{base}' expands to the command name at run time, so \
190 the allowlist cannot see which command would run.\n\
191 Write the command literally instead (e.g. `echo hi`, `.venv/bin/python -c ...`) — \
192 that is the form the allowlist can gate.\n\
193 Do NOT run `lean-ctx allow {hint}`; it would allowlist the variable's name, not \
194 the command.\n\
195 Config in effect: {cfg_path}"
196 );
197 }
198
199 if !is_plausible_command_name(base) {
205 return format!(
206 "[BLOCKED] '{base}' is not in the shell allowlist — but it does not look like a \
207 command name, which means lean-ctx split your command line wrongly rather than \
208 finding a command to gate.\n\
209 Do NOT run `lean-ctx allow` on it; that would allowlist a fragment.\n\
210 Quote the fragment differently if you can, and please report the original command \
211 at https://github.com/yvgude/lean-ctx/issues — a mis-split is a bug in lean-ctx.\n\
212 Config in effect: {cfg_path}"
213 );
214 }
215
216 if matches!(base, "sh" | "bash" | "zsh" | "dash" | "ksh") {
220 return format!(
221 "[BLOCKED — DO NOT RETRY] A nested '{base}' is not run: the command already \
222 executes in a shell, and a nested shell would hide what it runs from the \
223 allowlist.\n\
224 Do this instead: drop the `{base} -c` wrapper and pass the inner command \
225 directly (pipes, `&&`, loops and functions all work); for a script file, run \
226 the commands it contains, or allow that one script deliberately.\n\
227 Do NOT run `lean-ctx allow {base}`: it would admit every script, not this \
228 command.\n\
229 Config in effect: {cfg_path}"
230 );
231 }
232
233 let mut msg = format!(
234 "[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
235 This is a permanent restriction, not a transient error.\n\
236 Fix (additive, keeps the defaults): run lean-ctx allow {base}\n\
237 Config in effect: {cfg_path}\n\
238 Or disable the allowlist entirely: set shell_allowlist = []\n\
239 Or turn off all shell gating (you own the risk): set shell_security = \"off\" \
240 in that file — compression still applies.\n\
241 Do NOT reroute through ctx_execute(language=\"shell\"): both tools enforce the same \
242 policy. Allow the command explicitly or change shell_security deliberately."
243 );
244
245 if let Some(detail) = closest_entry_detail(tokens, entries) {
246 msg.push_str(&detail);
247 }
248
249 if crate::core::config::cloud_infra_commands().contains(&base) {
250 msg.push_str(
251 "\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
252 excluded from the defaults — they mutate remote infrastructure with \
253 ambient credentials. Opting in is a deliberate user decision.",
254 );
255 }
256
257 let ambient = !crate::core::config::shell_policy::pinned();
260 if ambient && let Some(parse_err) = crate::core::config::last_config_parse_error() {
261 msg.push_str(&format!(
262 "\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
263 built-in defaults — this is almost certainly why editing the allowlist had no \
264 effect. Fix the TOML error below, then retry:\n {parse_err}\n File: {cfg_path}"
265 ));
266 } else if ambient && let Some(missing) = crate::core::config::Config::missing_config_path() {
267 msg.push_str(&format!(
271 "\n\n⚠ No config file exists at {} — lean-ctx is running on built-in defaults. \
272 If you added the command to a config.toml in a DIFFERENT location (XDG \
273 ~/.config/lean-ctx vs legacy ~/.lean-ctx, or your MCP client launches lean-ctx \
274 in a sandbox/container with a different HOME), the runtime never reads it. \
275 `lean-ctx doctor` prints the path actually in effect; pin it with \
276 LEAN_CTX_CONFIG_DIR.",
277 missing.display()
278 ));
279 }
280
281 if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
285 msg.push_str("\n\n⚠ ");
286 msg.push_str(¬ice);
287 }
288
289 msg
290}
291
292fn closest_entry_detail(tokens: &[String], entries: &[String]) -> Option<String> {
299 let base = tokens.first()?.as_str();
300 let mut best: Option<(&str, usize)> = None;
301 for entry in entries {
302 let words: Vec<&str> = entry.split_whitespace().collect();
303 if words.first() != Some(&base) {
304 continue;
305 }
306 let shared = tokens
307 .iter()
308 .zip(words.iter())
309 .take_while(|(t, w)| t.as_str() == **w)
310 .count();
311 if best.is_none_or(|(_, len)| shared > len) {
312 best = Some((entry.as_str(), shared));
313 }
314 }
315 let (entry, shared) = best?;
316 let entry_words: Vec<&str> = entry.split_whitespace().collect();
317 let diverges_at = entry_words.get(shared).copied();
318 let got = tokens.get(shared).map(String::as_str);
319 let mut detail = match (diverges_at, got) {
320 (Some(expected), Some(got)) => {
321 format!("\n(closest entry '{entry}' requires subcommand '{expected}', got '{got}')")
322 }
323 _ => format!("\n(closest entry '{entry}')"),
324 };
325 if got.is_some_and(|g| g.starts_with('-')) {
326 detail.push_str(
327 "\nGlobal options before the subcommand are not matched. Run \
328 `cd <dir> && <cmd>` instead, or add an explicit allowlist entry.",
329 );
330 }
331 Some(detail)
332}
333#[must_use]
338pub fn effective_allowlist_pub() -> Vec<String> {
339 effective_allowlist()
340}
341
342pub(super) fn claude_allows_interpreter_inline(interpreter: &str) -> bool {
352 use std::sync::OnceLock;
353
354 static CACHE: OnceLock<Vec<String>> = OnceLock::new();
355
356 let allowed = CACHE.get_or_init(|| {
357 let mut interpreters = Vec::new();
358 collect_claude_bash_permissions(&mut interpreters);
359 interpreters
360 });
361
362 allowed.iter().any(|a| a == interpreter)
363}
364
365fn collect_claude_bash_permissions(out: &mut Vec<String>) {
367 let paths = claude_settings_paths();
368 for path in &paths {
369 if let Ok(content) = std::fs::read_to_string(path) {
370 if let Ok(json) = crate::core::jsonc::parse_jsonc(&content) {
371 extract_bash_interpreters(&json, out);
372 }
373 }
374 }
375}
376
377fn claude_settings_paths() -> Vec<std::path::PathBuf> {
379 let mut paths = Vec::with_capacity(2);
380
381 if let Some(home) = crate::core::config::shell_policy::settings_home() {
382 paths.push(home.join(".claude").join("settings.json"));
383 }
384
385 if let Ok(cwd) = std::env::current_dir() {
386 paths.push(cwd.join(".claude").join("settings.local.json"));
387 }
388
389 paths
390}
391
392pub(super) fn extract_bash_interpreters(json: &serde_json::Value, out: &mut Vec<String>) {
394 let allow = json
395 .pointer("/permissions/allow")
396 .and_then(|v| v.as_array());
397
398 let Some(arr) = allow else { return };
399
400 for entry in arr {
401 let Some(s) = entry.as_str() else { continue };
402 if let Some(inner) = parse_bash_permission(s) {
403 if !inner.is_empty() && !out.contains(&inner) {
404 out.push(inner);
405 }
406 }
407 }
408}
409
410pub(super) fn parse_bash_permission(entry: &str) -> Option<String> {
413 let rest = entry.strip_prefix("Bash(")?;
414 let rest = rest.strip_suffix(')')?;
415 let colon_pos = rest.find(':')?;
416 let cmd = &rest[..colon_pos];
417 if cmd.is_empty() {
418 return None;
419 }
420 let base = cmd.rsplit('/').next().unwrap_or(cmd);
421 Some(base.to_string())
422}
423
424#[cfg(test)]
425mod tests {
426 use super::{allowlist_block_message, shadowing_entry};
427
428 #[test]
430 fn shadowing_rule_matches_design_doc_cases() {
431 let entries = |a: &str, b: &str| vec![a.to_string(), b.to_string()];
432
433 assert_eq!(
434 shadowing_entry(&entries("git status", "git"), "git status"),
435 Some("git".to_string()),
436 "a single-word entry shadows any multi-word entry sharing its first word"
437 );
438 assert_eq!(
439 shadowing_entry(&entries("git status -s", "git status *"), "git status -s"),
440 Some("git status *".to_string()),
441 "a trailing-* entry shadows a longer exact entry sharing its prefix"
442 );
443 assert_eq!(
444 shadowing_entry(&entries("git status", "git status *"), "git status"),
445 Some("git status *".to_string()),
446 "a trailing-* entry shadows the identical exact entry"
447 );
448 assert_eq!(
449 shadowing_entry(&entries("git log", "git status *"), "git log"),
450 None,
451 "different subcommands must not shadow each other"
452 );
453 assert_eq!(
454 shadowing_entry(&entries("git status *", "git status"), "git status *"),
455 None,
456 "an exact entry must not shadow a broader trailing-* entry"
457 );
458 }
459
460 #[test]
465 fn variable_command_word_explains_expansion_not_a_parser_bug() {
466 for base in ["$X", "$PY", "${CMD}"] {
467 let msg = allowlist_block_message(base, &[], &[]);
468 assert!(
469 msg.contains("expands to the command name at run time"),
470 "{base}: must explain expansion, got: {msg}"
471 );
472 assert!(
473 !msg.contains("mis-split"),
474 "{base}: must not blame the parser, got: {msg}"
475 );
476 assert!(
477 !msg.contains("github.com/yvgude/lean-ctx/issues"),
478 "{base}: must not request a bug report for intended behaviour"
479 );
480 }
481 }
482
483 #[test]
486 fn genuine_mis_splits_still_ask_for_a_report() {
487 let msg = allowlist_block_message("print(urllib.parse.quote(sys.argv[1],safe=))", &[], &[]);
488 assert!(msg.contains("mis-split"), "got: {msg}");
489 assert!(msg.contains("github.com/yvgude/lean-ctx/issues"));
490 }
491
492 #[test]
494 fn ordinary_command_still_offers_allow() {
495 let msg = allowlist_block_message("ncat", &[], &[]);
496 assert!(msg.contains("lean-ctx allow ncat"), "got: {msg}");
497 assert!(!msg.contains("expands to the command name"));
498 }
499
500 #[test]
503 fn nested_shell_says_drop_the_wrapper_not_allow_it() {
504 for shell in ["bash", "sh", "zsh"] {
505 let msg = allowlist_block_message(shell, &[], &[]);
506 assert!(
507 msg.contains(&format!("drop the `{shell} -c` wrapper")),
508 "got: {msg}"
509 );
510 assert!(
511 msg.contains(&format!("Do NOT run `lean-ctx allow {shell}`")),
512 "got: {msg}"
513 );
514 assert!(!msg.contains("Fix (additive"), "got: {msg}");
515 }
516 }
517}