Skip to main content

lean_ctx/core/shell_allowlist/
config.rs

1pub(super) fn effective_allowlist() -> Vec<String> {
2    filter_valid_entries(raw_allowlist_entries())
3}
4
5/// The same union `effective_allowlist()` builds, before `*`-placement
6/// filtering — shared with the invalid-entry and upgrade-advisory doctor
7/// checks, which need to see entries `effective_allowlist()` would drop.
8fn raw_allowlist_entries() -> Vec<String> {
9    use crate::core::config::shell_policy;
10    // LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE completely replaces the config (test
11    // and debug builds only; see `config::shell_policy`).
12    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    // `shell_allowlist_extra` is purely additive (written by `lean-ctx allow <cmd>`),
22    // so users can permit a command without nuking the built-in defaults. It only
23    // matters in restricted mode — when the base list is empty all commands pass anyway.
24    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/// Public accessor: entries the effective allowlist silently dropped for
46/// invalid `*` placement (§4 of the design doc). Used by `lean-ctx doctor`
47/// to surface a count, and by `--fix` to know which raw config lines to
48/// comment out.
49#[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/// Public accessor: multi-word entries with no trailing `*` — their meaning
58/// changed under subcommand scoping (now exact-match only, previously
59/// "whole binary"). Used by the `lean-ctx doctor` upgrade advisory (§2 of
60/// the design doc). Always empty when `shell_allowlist_subcommand_scoping =
61/// false`, since those entries' meaning didn't change under the compat path.
62#[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/// §2 "Shadowing warnings": is `a` made useless by a different entry already
78/// in `entries`? Entry A is shadowed by B when B matches every command A
79/// matches: B is a single word equal to A's first word, or B ends in `*` and
80/// B's words before the `*` are a leading run of A's words (ignoring A's own
81/// trailing `*`). Returns the first covering entry, in list order. Shared by
82/// `lean-ctx allow` (write-time warning) and `lean-ctx doctor` (read-only
83/// advisory) so the rule can't drift between the two.
84#[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/// All shadowed entries in `entries`, paired with the entry that covers each
112/// one — used by the `lean-ctx doctor` advisory (§2).
113#[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
121/// §4 of the design doc: an entry's only valid `*` placement is a single
122/// trailing token. Bare `*`, an embedded `*`, or more than one `*` are
123/// invalid.
124pub 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
133/// Drops invalid entries rather than failing closed on the whole allowlist
134/// (§4): removing one allow-entry can only make matching *more*
135/// restrictive, never less, so this is safe by construction — mirrors the
136/// `ShellSecurity::resolve()` precedent (an unknown value falls back to
137/// enforce, never to open).
138fn 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
154/// Could this token plausibly be an executable name?
155///
156/// Deliberately permissive — the point is to catch obvious scanner debris
157/// (unbalanced parens, quotes, `$`, `&`, embedded spaces), not to validate
158/// against the filesystem. A name that would need quoting to type is not a name
159/// the user should be told to allowlist.
160pub(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
169/// Builds the actionable, self-diagnosing message shown when a command's base binary
170/// is not in the allowlist. Unlike a bare "not allowed" string, it tells the user
171/// (1) the exact additive fix, (2) the real config path the MCP server reads, and
172/// (3) — crucially — whether their `config.toml` silently failed to parse (in which
173/// case lean-ctx is on defaults, which is the usual reason an allowlist edit "did
174/// nothing"). That last signal is otherwise invisible over an MCP/stdio transport.
175pub(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    // #1814: a `$var` command word is a *correct* split, not a mis-split — the
182    // variable genuinely is the command, and the allowlist cannot see what it
183    // expands to at run time. Sending that caller to file a mis-split bug wastes
184    // their time and ours, and "quote the fragment differently" cannot help:
185    // no quoting makes a variable command allowable. Say what actually works.
186    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    // A base that cannot be a command name means the scanner mis-split the line,
200    // not that the user needs to allow something. Printing
201    // `lean-ctx allow print(urllib.parse.quote(sys.argv[1],safe=))` — a real
202    // example from GH #1646 — invites the reader to copy a command that cannot
203    // work and hides the actual fault. Say which it is.
204    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    // A nested shell (`bash -c '…'`, `sh script.sh`) is the most common block
217    // agents retry: the command already runs in a shell, so the wrapper is
218    // redundant — and allowlisting the shell would let any script through.
219    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    // The notes below describe `Config::load`'s file; a release build's gate
258    // reads only the file named above (`config::shell_policy`).
259    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        // The resolved config doesn't exist → lean-ctx is on defaults. An edit
268        // made to a config.toml in a different dir (XDG vs legacy ~/.lean-ctx) or
269        // under a sandboxed/container HOME is never read — say so over MCP (#540).
270        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    // A project-local `shell_allowlist`/`shell_allowlist_extra` is silently
282    // withheld for an untrusted workspace; surface that here so the edit's
283    // no-op reason isn't buried in an MCP-invisible stderr warning (#540).
284    if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
285        msg.push_str("\n\n⚠ ");
286        msg.push_str(&notice);
287    }
288
289    msg
290}
291
292/// §6 of the design doc: names the closest matching allowlist entry and
293/// where the command's tokens diverge from it, so an LLM agent can
294/// self-correct without a human round-trip. Only fires when at least one
295/// entry shares the base binary — otherwise "not in the shell allowlist"
296/// already says enough. Longest common token prefix; ties resolve to the
297/// first entry in list order (same convention as `levenshtein::closest()`).
298fn 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/// Public accessor: the fully-resolved allowlist actually enforced by the MCP tools
334/// (base `shell_allowlist` + additive `shell_allowlist_extra` + env), deduplicated.
335/// Empty means blocklist-only mode (all commands pass). Used by `lean-ctx allow`
336/// and `lean-ctx doctor` to show users exactly what the runtime sees.
337#[must_use]
338pub fn effective_allowlist_pub() -> Vec<String> {
339    effective_allowlist()
340}
341
342/// GH #1466: Check whether the user has explicitly allowed an interpreter for
343/// inline/heredoc use in Claude Code's own permission system.
344///
345/// Reads `permissions.allow` from both global (`~/.claude/settings.json`) and
346/// project-local (`.claude/settings.local.json`) settings. Entries like
347/// `Bash(python3:*)` or `Bash(python3:)` grant the interpreter `python3`
348/// inline-execution rights, bypassing the heredoc/eval-flag block.
349///
350/// Cached per-process to avoid re-reading settings on every shell invocation.
351pub(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
365/// Parse `Bash(<cmd>:...)` entries from Claude's `permissions.allow` arrays.
366fn 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
377/// Returns candidate Claude settings paths (global + project-local).
378fn 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
392/// Extract interpreter names from `Bash(<interpreter>:...)` permission entries.
393pub(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
410/// Parse a Claude permission entry like `Bash(python3:*)` → `Some("python3")`.
411/// Accepted formats: `Bash(cmd:)`, `Bash(cmd:*)`, `Bash(cmd:<anything>)`.
412pub(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    /// §2 of the design doc: pure-helper shadowing rule, all five agreed cases.
429    #[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    /// #1814: a `$var` command word is a *correct* split, not a mis-split. The
461    /// old text asked the caller to file a bug for intended behaviour and
462    /// suggested re-quoting, which cannot help — no quoting makes a variable
463    /// command gateable.
464    #[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    /// The genuine mis-split guidance must survive for tokens that really are
484    /// not command names — that is what #1646 added it for.
485    #[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    /// An ordinary unknown command keeps the actionable `lean-ctx allow` path.
493    #[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    /// A nested shell is redundant, and allowing it would admit every script:
501    /// the message says to drop the wrapper and never to allow the shell.
502    #[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}