pub(super) fn effective_allowlist() -> Vec<String> {
if let Ok(ov) = std::env::var("LEAN_CTX_SHELL_ALLOWLIST_OVERRIDE") {
return ov
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
}
let cfg = crate::core::config::Config::load();
let mut list = cfg.shell_allowlist;
if !list.is_empty() {
for entry in cfg.shell_allowlist_extra {
if !entry.is_empty() && !list.contains(&entry) {
list.push(entry);
}
}
}
if let Ok(env_val) = std::env::var("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
}
pub(super) fn allowlist_block_message(base: &str) -> String {
let cfg_path = crate::core::config::Config::path().map_or_else(
|| "~/.lean-ctx/config.toml".to_string(),
|p| p.display().to_string(),
);
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\" \
(or env LEAN_CTX_SHELL_SECURITY=off) — 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 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.",
);
}
if 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 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
}
#[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::home::resolve_home_dir() {
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())
}