Skip to main content

lean_ctx/cli/
allow_cmd.rs

1//! `lean-ctx allow` — manage the shell allowlist additively.
2//!
3//! Setting `shell_allowlist` directly replaces the entire built-in default list
4//! (a footgun reported in #341). This command instead writes to the additive
5//! `shell_allowlist_extra` field, so a user can permit one extra binary (e.g.
6//! `acli`) without losing `git`, `cargo`, … and without restarting anything —
7//! the MCP server re-reads `config.toml` (mtime-invalidated) on the next command.
8
9use crate::core::config;
10use crate::core::shell_allowlist;
11
12pub fn cmd_allow(args: &[String]) {
13    // `--help` anywhere shows usage instead of treating it as a command to
14    // allow (`lean-ctx allow git --help` must not allowlist "--help", GH #393).
15    if args.iter().any(|a| a == "--help" || a == "-h") {
16        print_usage();
17        return;
18    }
19    match args.first().map(std::string::String::as_str) {
20        None => print_usage(),
21        Some("--list" | "list" | "ls") => print_effective(),
22        Some("--remove" | "-r" | "remove" | "rm") => remove(&args[1..]),
23        _ => add(args),
24    }
25}
26
27/// Adds one or more commands to the additive `shell_allowlist_extra`.
28fn add(cmds: &[String]) {
29    let requested: Vec<String> = cmds
30        .iter()
31        .map(|c| c.trim().to_string())
32        .filter(|c| !c.is_empty())
33        .collect();
34
35    if requested.is_empty() {
36        print_usage();
37        return;
38    }
39
40    let mut extra = current_extra_from_global();
41    let mut added = Vec::new();
42    for cmd in requested {
43        if extra.iter().any(|e| e == &cmd) {
44            println!("  already allowed: {cmd}");
45        } else {
46            extra.push(cmd.clone());
47            added.push(cmd);
48        }
49    }
50
51    if added.is_empty() {
52        println!("\nNothing to add — all commands were already in the allowlist.");
53        print_effective();
54        return;
55    }
56
57    if let Err(e) = write_extra(&extra) {
58        eprintln!("Error: {e}");
59        std::process::exit(1);
60    }
61
62    println!("Allowed (additive): {}", added.join(", "));
63    println!("These are merged on top of the defaults — nothing else was removed.");
64    println!("Takes effect immediately; no MCP/daemon restart needed.");
65    print_effective();
66}
67
68/// Removes one or more commands from `shell_allowlist_extra`.
69fn remove(cmds: &[String]) {
70    let to_remove: Vec<String> = cmds
71        .iter()
72        .map(|c| c.trim().to_string())
73        .filter(|c| !c.is_empty())
74        .collect();
75
76    if to_remove.is_empty() {
77        eprintln!("Usage: lean-ctx allow --remove <cmd> [<cmd>...]");
78        std::process::exit(1);
79    }
80
81    let before = current_extra_from_global();
82    let after: Vec<String> = before
83        .iter()
84        .filter(|e| !to_remove.iter().any(|r| r == *e))
85        .cloned()
86        .collect();
87
88    let removed: Vec<&String> = before.iter().filter(|e| !after.contains(e)).collect();
89    if removed.is_empty() {
90        println!("None of those were in shell_allowlist_extra (nothing changed).");
91        println!(
92            "Note: built-in defaults can't be removed here — set `shell_allowlist` explicitly to override the whole list."
93        );
94        return;
95    }
96
97    if let Err(e) = write_extra(&after) {
98        eprintln!("Error: {e}");
99        std::process::exit(1);
100    }
101
102    let names: Vec<&str> = removed.iter().map(|s| s.as_str()).collect();
103    println!("Removed from extra allowlist: {}", names.join(", "));
104    print_effective();
105}
106
107/// Prints the fully-resolved allowlist the MCP server actually enforces, the real
108/// config path, and — critically — whether `config.toml` failed to parse (in which
109/// case lean-ctx is silently on defaults, the usual cause of "my edit did nothing").
110fn print_effective() {
111    let effective = shell_allowlist::effective_allowlist_pub();
112    let parse_err = config::last_config_parse_error();
113    let path = config::Config::path().map_or_else(
114        || "~/.lean-ctx/config.toml".to_string(),
115        |p| p.display().to_string(),
116    );
117
118    println!("\nShell allowlist (enforced by the MCP tools):");
119    println!("  Config: {path}");
120
121    if let Some(err) = parse_err {
122        println!("  \x1b[31m⚠ config.toml FAILED to parse — running on DEFAULTS.\x1b[0m");
123        println!("    {err}");
124        println!("    Fix the TOML above, then re-run `lean-ctx allow --list`.");
125    }
126
127    if effective.is_empty() {
128        println!("  Mode: disabled (every command is allowed)");
129        return;
130    }
131
132    println!(
133        "  Mode: restricted — {} command(s) permitted",
134        effective.len()
135    );
136
137    let extra = current_extra_from_global();
138    if extra.is_empty() {
139        println!("  Extra (additive, via `lean-ctx allow`): none");
140    } else {
141        println!(
142            "  Extra (additive, via `lean-ctx allow`): {}",
143            extra.join(", ")
144        );
145    }
146}
147
148/// Reads `shell_allowlist_extra` from the raw GLOBAL config table (not the merged
149/// runtime view) so we never accidentally persist project-local or default values.
150fn current_extra_from_global() -> Vec<String> {
151    let Some(path) = config::Config::path() else {
152        return Vec::new();
153    };
154    let Ok(raw) = std::fs::read_to_string(&path) else {
155        return Vec::new();
156    };
157    let Ok(table) = raw.parse::<toml::Table>() else {
158        return Vec::new();
159    };
160    table
161        .get("shell_allowlist_extra")
162        .and_then(toml::Value::as_array)
163        .map(|arr| {
164            arr.iter()
165                .filter_map(|v| v.as_str().map(str::to_string))
166                .collect()
167        })
168        .unwrap_or_default()
169}
170
171/// Persists the extra list via the schema-validated setter (minimal-config round-trip).
172fn write_extra(extra: &[String]) -> Result<(), String> {
173    config::setter::set_by_key("shell_allowlist_extra", &extra.join(","))
174        .map(|_| ())
175        .map_err(|e| e.to_string())
176}
177
178fn print_usage() {
179    println!(
180        "Usage: lean-ctx allow <cmd> [<cmd>...]   Add command(s) to the shell allowlist (additive)\n\
181         \x20      lean-ctx allow --list             Show the effective allowlist + config path\n\
182         \x20      lean-ctx allow --remove <cmd>     Remove command(s) you previously added\n\
183         \n\
184         Why this exists: editing `shell_allowlist` replaces the whole built-in list.\n\
185         `lean-ctx allow` appends to `shell_allowlist_extra`, keeping git/cargo/npm/… intact.\n\
186         Example: lean-ctx allow acli"
187    );
188    print_effective();
189}