pub(super) fn effective_allowlist() -> Vec<String> {
filter_valid_entries(raw_allowlist_entries())
}
fn raw_allowlist_entries() -> Vec<String> {
use crate::core::config::shell_policy;
if let Some(ov) = shell_policy::env("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
return ov
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
let cfg = shell_policy::config();
let mut list = cfg.shell_allowlist.clone();
if !list.is_empty() {
for entry in &cfg.shell_allowlist_extra {
if !entry.is_empty() && !list.contains(entry) {
list.push(entry.clone());
}
}
}
if let Some(env_val) = shell_policy::env("LEAN_CTX_SHELL_ALLOWLIST") {
for entry in env_val
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
if !list.contains(&entry) {
list.push(entry);
}
}
}
list
}
#[must_use]
pub fn invalid_allowlist_entries_pub() -> Vec<String> {
raw_allowlist_entries()
.into_iter()
.filter(|e| !is_valid_allowlist_entry(e))
.collect()
}
#[must_use]
pub fn narrowed_allowlist_entries_pub() -> Vec<String> {
if !crate::core::config::shell_policy::config().shell_allowlist_subcommand_scoping {
return Vec::new();
}
raw_allowlist_entries()
.into_iter()
.filter(|e| is_valid_allowlist_entry(e))
.filter(|e| {
let words: Vec<&str> = e.split_whitespace().collect();
words.len() > 1 && words.last() != Some(&"*")
})
.collect()
}
#[must_use]
pub fn shadowing_entry(entries: &[String], a: &str) -> Option<String> {
let a_words: Vec<&str> = a.split_whitespace().collect();
let a_prefix: &[&str] = if a_words.last() == Some(&"*") {
&a_words[..a_words.len() - 1]
} else {
&a_words
};
entries
.iter()
.find(|b| {
if b.as_str() == a {
return false;
}
let b_words: Vec<&str> = b.split_whitespace().collect();
if b_words.len() == 1 {
b_words.first() == a_words.first()
} else if b_words.last() == Some(&"*") {
let b_prefix = &b_words[..b_words.len() - 1];
b_prefix.len() <= a_prefix.len() && a_prefix.starts_with(b_prefix)
} else {
false
}
})
.cloned()
}
#[must_use]
pub fn shadowed_allowlist_entries_pub(entries: &[String]) -> Vec<(String, String)> {
entries
.iter()
.filter_map(|a| shadowing_entry(entries, a).map(|b| (a.clone(), b)))
.collect()
}
pub fn is_valid_allowlist_entry(entry: &str) -> bool {
let words: Vec<&str> = entry.split_whitespace().collect();
match words.iter().filter(|w| **w == "*").count() {
0 => true,
1 => words.len() > 1 && words.last() == Some(&"*"),
_ => false,
}
}
fn filter_valid_entries(entries: Vec<String>) -> Vec<String> {
entries
.into_iter()
.filter(|entry| {
let valid = is_valid_allowlist_entry(entry);
if !valid {
tracing::warn!(
"[shell_allowlist] dropping invalid allowlist entry '{entry}': `*` must be \
the single trailing token, not embedded, bare, or repeated"
);
}
valid
})
.collect()
}
pub(super) fn is_plausible_command_name(base: &str) -> bool {
if base.is_empty() {
return false;
}
!base.chars().any(|c| {
c.is_whitespace() || matches!(c, '(' | ')' | '"' | '\'' | '$' | '&' | '|' | ';' | '`')
})
}
pub(super) fn allowlist_block_message(base: &str, tokens: &[String], entries: &[String]) -> String {
let cfg_path = crate::core::config::shell_policy::config_path().map_or_else(
|| "~/.lean-ctx/config.toml".to_string(),
|p| p.display().to_string(),
);
if let Some(name) = base.strip_prefix('$') {
let hint = name.trim_start_matches('{').trim_end_matches('}');
return format!(
"[BLOCKED — DO NOT RETRY] '{base}' expands to the command name at run time, so \
the allowlist cannot see which command would run.\n\
Write the command literally instead (e.g. `echo hi`, `.venv/bin/python -c ...`) — \
that is the form the allowlist can gate.\n\
Do NOT run `lean-ctx allow {hint}`; it would allowlist the variable's name, not \
the command.\n\
Config in effect: {cfg_path}"
);
}
if !is_plausible_command_name(base) {
return format!(
"[BLOCKED] '{base}' is not in the shell allowlist — but it does not look like a \
command name, which means lean-ctx split your command line wrongly rather than \
finding a command to gate.\n\
Do NOT run `lean-ctx allow` on it; that would allowlist a fragment.\n\
Quote the fragment differently if you can, and please report the original command \
at https://github.com/yvgude/lean-ctx/issues — a mis-split is a bug in lean-ctx.\n\
Config in effect: {cfg_path}"
);
}
if matches!(base, "sh" | "bash" | "zsh" | "dash" | "ksh") {
return format!(
"[BLOCKED — DO NOT RETRY] A nested '{base}' is not run: the command already \
executes in a shell, and a nested shell would hide what it runs from the \
allowlist.\n\
Do this instead: drop the `{base} -c` wrapper and pass the inner command \
directly (pipes, `&&`, loops and functions all work); for a script file, run \
the commands it contains, or allow that one script deliberately.\n\
Do NOT run `lean-ctx allow {base}`: it would admit every script, not this \
command.\n\
Config in effect: {cfg_path}"
);
}
let mut msg = format!(
"[BLOCKED — DO NOT RETRY] '{base}' is not in the shell allowlist. \
This is a permanent restriction, not a transient error.\n\
Fix (additive, keeps the defaults): run lean-ctx allow {base}\n\
Config in effect: {cfg_path}\n\
Or disable the allowlist entirely: set shell_allowlist = []\n\
Or turn off all shell gating (you own the risk): set shell_security = \"off\" \
in that file — compression still applies.\n\
Do NOT reroute through ctx_execute(language=\"shell\"): both tools enforce the same \
policy. Allow the command explicitly or change shell_security deliberately."
);
if let Some(detail) = closest_entry_detail(tokens, entries) {
msg.push_str(&detail);
}
if crate::core::config::cloud_infra_commands().contains(&base) {
msg.push_str(
"\nNote: cloud/infra CLIs (terraform, kubectl, aws, …) are deliberately \
excluded from the defaults — they mutate remote infrastructure with \
ambient credentials. Opting in is a deliberate user decision.",
);
}
let ambient = !crate::core::config::shell_policy::pinned();
if ambient && let Some(parse_err) = crate::core::config::last_config_parse_error() {
msg.push_str(&format!(
"\n\n⚠ Your config.toml currently FAILS to parse, so lean-ctx is running on the \
built-in defaults — this is almost certainly why editing the allowlist had no \
effect. Fix the TOML error below, then retry:\n {parse_err}\n File: {cfg_path}"
));
} else if ambient && let Some(missing) = crate::core::config::Config::missing_config_path() {
msg.push_str(&format!(
"\n\n⚠ No config file exists at {} — lean-ctx is running on built-in defaults. \
If you added the command to a config.toml in a DIFFERENT location (XDG \
~/.config/lean-ctx vs legacy ~/.lean-ctx, or your MCP client launches lean-ctx \
in a sandbox/container with a different HOME), the runtime never reads it. \
`lean-ctx doctor` prints the path actually in effect; pin it with \
LEAN_CTX_CONFIG_DIR.",
missing.display()
));
}
if let Some(notice) = crate::core::workspace_trust::untrusted_override_notice() {
msg.push_str("\n\n⚠ ");
msg.push_str(¬ice);
}
msg
}
fn closest_entry_detail(tokens: &[String], entries: &[String]) -> Option<String> {
let base = tokens.first()?.as_str();
let mut best: Option<(&str, usize)> = None;
for entry in entries {
let words: Vec<&str> = entry.split_whitespace().collect();
if words.first() != Some(&base) {
continue;
}
let shared = tokens
.iter()
.zip(words.iter())
.take_while(|(t, w)| t.as_str() == **w)
.count();
if best.is_none_or(|(_, len)| shared > len) {
best = Some((entry.as_str(), shared));
}
}
let (entry, shared) = best?;
let entry_words: Vec<&str> = entry.split_whitespace().collect();
let diverges_at = entry_words.get(shared).copied();
let got = tokens.get(shared).map(String::as_str);
let mut detail = match (diverges_at, got) {
(Some(expected), Some(got)) => {
format!("\n(closest entry '{entry}' requires subcommand '{expected}', got '{got}')")
}
_ => format!("\n(closest entry '{entry}')"),
};
if got.is_some_and(|g| g.starts_with('-')) {
detail.push_str(
"\nGlobal options before the subcommand are not matched. Run \
`cd <dir> && <cmd>` instead, or add an explicit allowlist entry.",
);
}
Some(detail)
}
#[must_use]
pub fn effective_allowlist_pub() -> Vec<String> {
effective_allowlist()
}
pub(super) fn claude_allows_interpreter_inline(interpreter: &str) -> bool {
use std::sync::OnceLock;
static CACHE: OnceLock<Vec<String>> = OnceLock::new();
let allowed = CACHE.get_or_init(|| {
let mut interpreters = Vec::new();
collect_claude_bash_permissions(&mut interpreters);
interpreters
});
allowed.iter().any(|a| a == interpreter)
}
fn collect_claude_bash_permissions(out: &mut Vec<String>) {
let paths = claude_settings_paths();
for path in &paths {
if let Ok(content) = std::fs::read_to_string(path) {
if let Ok(json) = crate::core::jsonc::parse_jsonc(&content) {
extract_bash_interpreters(&json, out);
}
}
}
}
fn claude_settings_paths() -> Vec<std::path::PathBuf> {
let mut paths = Vec::with_capacity(2);
if let Some(home) = crate::core::config::shell_policy::settings_home() {
paths.push(home.join(".claude").join("settings.json"));
}
if let Ok(cwd) = std::env::current_dir() {
paths.push(cwd.join(".claude").join("settings.local.json"));
}
paths
}
pub(super) fn extract_bash_interpreters(json: &serde_json::Value, out: &mut Vec<String>) {
let allow = json
.pointer("/permissions/allow")
.and_then(|v| v.as_array());
let Some(arr) = allow else { return };
for entry in arr {
let Some(s) = entry.as_str() else { continue };
if let Some(inner) = parse_bash_permission(s) {
if !inner.is_empty() && !out.contains(&inner) {
out.push(inner);
}
}
}
}
pub(super) fn parse_bash_permission(entry: &str) -> Option<String> {
let rest = entry.strip_prefix("Bash(")?;
let rest = rest.strip_suffix(')')?;
let colon_pos = rest.find(':')?;
let cmd = &rest[..colon_pos];
if cmd.is_empty() {
return None;
}
let base = cmd.rsplit('/').next().unwrap_or(cmd);
Some(base.to_string())
}
#[cfg(test)]
mod tests {
use super::{allowlist_block_message, shadowing_entry};
#[test]
fn shadowing_rule_matches_design_doc_cases() {
let entries = |a: &str, b: &str| vec![a.to_string(), b.to_string()];
assert_eq!(
shadowing_entry(&entries("git status", "git"), "git status"),
Some("git".to_string()),
"a single-word entry shadows any multi-word entry sharing its first word"
);
assert_eq!(
shadowing_entry(&entries("git status -s", "git status *"), "git status -s"),
Some("git status *".to_string()),
"a trailing-* entry shadows a longer exact entry sharing its prefix"
);
assert_eq!(
shadowing_entry(&entries("git status", "git status *"), "git status"),
Some("git status *".to_string()),
"a trailing-* entry shadows the identical exact entry"
);
assert_eq!(
shadowing_entry(&entries("git log", "git status *"), "git log"),
None,
"different subcommands must not shadow each other"
);
assert_eq!(
shadowing_entry(&entries("git status *", "git status"), "git status *"),
None,
"an exact entry must not shadow a broader trailing-* entry"
);
}
#[test]
fn variable_command_word_explains_expansion_not_a_parser_bug() {
for base in ["$X", "$PY", "${CMD}"] {
let msg = allowlist_block_message(base, &[], &[]);
assert!(
msg.contains("expands to the command name at run time"),
"{base}: must explain expansion, got: {msg}"
);
assert!(
!msg.contains("mis-split"),
"{base}: must not blame the parser, got: {msg}"
);
assert!(
!msg.contains("github.com/yvgude/lean-ctx/issues"),
"{base}: must not request a bug report for intended behaviour"
);
}
}
#[test]
fn genuine_mis_splits_still_ask_for_a_report() {
let msg = allowlist_block_message("print(urllib.parse.quote(sys.argv[1],safe=))", &[], &[]);
assert!(msg.contains("mis-split"), "got: {msg}");
assert!(msg.contains("github.com/yvgude/lean-ctx/issues"));
}
#[test]
fn ordinary_command_still_offers_allow() {
let msg = allowlist_block_message("ncat", &[], &[]);
assert!(msg.contains("lean-ctx allow ncat"), "got: {msg}");
assert!(!msg.contains("expands to the command name"));
}
#[test]
fn nested_shell_says_drop_the_wrapper_not_allow_it() {
for shell in ["bash", "sh", "zsh"] {
let msg = allowlist_block_message(shell, &[], &[]);
assert!(
msg.contains(&format!("drop the `{shell} -c` wrapper")),
"got: {msg}"
);
assert!(
msg.contains(&format!("Do NOT run `lean-ctx allow {shell}`")),
"got: {msg}"
);
assert!(!msg.contains("Fix (additive"), "got: {msg}");
}
}
}