Skip to main content

lean_ctx/cli/
harden.rs

1use std::path::PathBuf;
2
3pub fn run(args: &[String]) {
4    if args.iter().any(|a| a == "--help" || a == "-h") {
5        print_help();
6        return;
7    }
8
9    let undo = args.iter().any(|a| a == "--undo");
10    let level = if args.iter().any(|a| a == "--hard") {
11        "hard"
12    } else {
13        "soft"
14    };
15
16    if undo {
17        undo_harden();
18    } else {
19        apply_harden(level);
20    }
21}
22
23fn print_help() {
24    println!("lean-ctx harden — tighten IDE security by denying native Read/Grep/Glob");
25    println!();
26    println!("USAGE:");
27    println!("    lean-ctx harden [OPTIONS]");
28    println!();
29    println!("OPTIONS:");
30    println!("    --hard     Replace mode: deny native tools across all IDEs");
31    println!("    --undo     Revert all harden changes");
32    println!("    --help     Show this help message");
33}
34
35fn apply_harden(level: &str) {
36    println!("lean-ctx harden (level: {level})");
37    println!();
38
39    if level == "hard" {
40        println!("  Hard mode = Replace mode: denying native Read/Grep/Glob across all IDEs.");
41        println!();
42        let opts = crate::setup::SetupOptions {
43            non_interactive: true,
44            yes: true,
45            fix: true,
46            ..Default::default()
47        };
48        if let Err(e) = crate::setup::run_setup_with_options(opts) {
49            eprintln!("  Setup error: {e}");
50        }
51        println!();
52        println!("Replace mode active. All native tools denied — use ctx_* MCP tools.");
53        println!("Undo with: lean-ctx harden --undo");
54        return;
55    }
56
57    let mut applied = Vec::new();
58
59    if set_env_in_mcp_configs() {
60        applied.push("Set LEAN_CTX_HARDEN=1 in MCP configs");
61    }
62
63    match cleanup_claude_stale_bash_deny() {
64        Ok(true) => applied.push("Claude Code: removed stale Bash from permissions.deny (GH #799)"),
65        Ok(false) => {}
66        Err(error) => eprintln!("  [ERROR] {error}"),
67    }
68
69    if applied.is_empty() {
70        println!("  Nothing to harden (no supported editors detected).");
71    } else {
72        println!();
73        for item in &applied {
74            println!("  [OK] {item}");
75        }
76        println!();
77        println!("Harden active. Native Read/Grep will be denied (except after Edit).");
78        println!("Undo with: lean-ctx harden --undo");
79    }
80}
81
82fn undo_harden() {
83    println!("lean-ctx harden --undo");
84    println!();
85
86    let mut errors = remove_env_from_mcp_configs();
87    if let Err(error) = remove_claude_permissions_deny() {
88        errors.push(error);
89    }
90
91    if errors.is_empty() {
92        println!("  [OK] Harden deactivated. Native tools allowed again.");
93    } else {
94        for error in &errors {
95            eprintln!("  [ERROR] {error}");
96        }
97        eprintln!("  Harden could not be fully deactivated.");
98    }
99}
100
101fn set_env_in_mcp_configs() -> bool {
102    let targets = discover_mcp_configs();
103    let mut any_set = false;
104
105    for path in targets {
106        if let Ok(content) = std::fs::read_to_string(&path)
107            && let Ok(mut json) = crate::core::jsonc::parse_jsonc(&content)
108            && let Some(servers) = find_lean_ctx_server_mut(&mut json)
109        {
110            let env = servers
111                .as_object_mut()
112                .and_then(|s| s.get_mut("env"))
113                .and_then(|e| e.as_object_mut());
114
115            if let Some(env_map) = env {
116                env_map.insert(
117                    "LEAN_CTX_HARDEN".to_string(),
118                    serde_json::Value::String("1".to_string()),
119                );
120            } else if let Some(server_obj) = servers.as_object_mut() {
121                let mut env_map = serde_json::Map::new();
122                env_map.insert(
123                    "LEAN_CTX_HARDEN".to_string(),
124                    serde_json::Value::String("1".to_string()),
125                );
126                server_obj.insert("env".to_string(), serde_json::Value::Object(env_map));
127            }
128
129            match serde_json::to_string_pretty(&json)
130                .map_err(|error| format!("cannot serialize {}: {error}", path.display()))
131                .and_then(|out| write_json_config(&path, &out))
132            {
133                Ok(()) => {
134                    any_set = true;
135                    println!("  [OK] {}", path.display());
136                }
137                Err(error) => eprintln!("  [ERROR] {error}"),
138            }
139        }
140    }
141    any_set
142}
143
144fn remove_env_from_mcp_configs() -> Vec<String> {
145    let mut errors = Vec::new();
146
147    for path in discover_mcp_configs() {
148        if let Ok(content) = std::fs::read_to_string(&path)
149            && let Ok(mut json) = crate::core::jsonc::parse_jsonc(&content)
150            && let Some(servers) = find_lean_ctx_server_mut(&mut json)
151            && let Some(env) = servers
152                .as_object_mut()
153                .and_then(|s| s.get_mut("env"))
154                .and_then(|e| e.as_object_mut())
155        {
156            env.remove("LEAN_CTX_HARDEN");
157            if let Err(error) = serde_json::to_string_pretty(&json)
158                .map_err(|error| format!("cannot serialize {}: {error}", path.display()))
159                .and_then(|out| write_json_config(&path, &out))
160            {
161                errors.push(error);
162            }
163        }
164    }
165
166    errors
167}
168
169/// Remove stale "Bash" from Claude Code's `permissions.deny` (GH #799).
170///
171/// Older versions added "Bash" here, which blocks ALL bash usage globally —
172/// including plugin commands (e.g. codex-companion). The PreToolUse hook
173/// (`lean-ctx hook deny`) already blocks agent-level native Bash, so the
174/// permissions.deny entry is unnecessary and harmful.
175fn cleanup_claude_stale_bash_deny() -> Result<bool, String> {
176    let Some(home) = dirs::home_dir() else {
177        return Ok(false);
178    };
179    let settings_path = home.join(".claude").join("settings.json");
180    if !settings_path.exists() {
181        return Ok(false);
182    }
183
184    let Ok(content) = std::fs::read_to_string(&settings_path) else {
185        return Ok(false);
186    };
187    let Ok(mut json) = crate::core::jsonc::parse_jsonc(&content) else {
188        return Ok(false);
189    };
190
191    let removed = if let Some(deny) = json
192        .pointer_mut("/permissions/deny")
193        .and_then(|d| d.as_array_mut())
194    {
195        let before = deny.len();
196        deny.retain(|v| v.as_str() != Some("Bash"));
197        deny.len() < before
198    } else {
199        false
200    };
201
202    if removed {
203        serde_json::to_string_pretty(&json)
204            .map_err(|error| format!("cannot serialize {}: {error}", settings_path.display()))
205            .and_then(|out| write_json_config(&settings_path, &out))?;
206    }
207    Ok(removed)
208}
209
210fn remove_claude_permissions_deny() -> Result<(), String> {
211    let Some(home) = dirs::home_dir() else {
212        return Ok(());
213    };
214    let settings_path = home.join(".claude").join("settings.json");
215    if !settings_path.exists() {
216        return Ok(());
217    }
218
219    let Ok(content) = std::fs::read_to_string(&settings_path) else {
220        return Ok(());
221    };
222    let Ok(mut json) = crate::core::jsonc::parse_jsonc(&content) else {
223        return Ok(());
224    };
225
226    if let Some(deny) = json
227        .pointer_mut("/permissions/deny")
228        .and_then(|d| d.as_array_mut())
229    {
230        deny.retain(|v| v.as_str() != Some("Bash"));
231    }
232
233    serde_json::to_string_pretty(&json)
234        .map_err(|error| format!("cannot serialize {}: {error}", settings_path.display()))
235        .and_then(|out| write_json_config(&settings_path, &out))
236}
237
238fn write_json_config(path: &std::path::Path, content: &str) -> Result<(), String> {
239    crate::config_io::write_atomic(path, content)
240        .map_err(|error| format!("cannot write {}: {error}", path.display()))
241}
242
243fn discover_mcp_configs() -> Vec<PathBuf> {
244    let Some(home) = dirs::home_dir() else {
245        return Vec::new();
246    };
247
248    let candidates = [
249        home.join(".cursor").join("mcp.json"),
250        home.join(".claude.json"),
251        home.join(".codebuddy.json"),
252        home.join(".codeium")
253            .join("windsurf")
254            .join("mcp_config.json"),
255    ];
256
257    candidates.into_iter().filter(|p| p.exists()).collect()
258}
259
260fn find_lean_ctx_server_mut(json: &mut serde_json::Value) -> Option<&mut serde_json::Value> {
261    if let Some(servers) = json.get_mut("mcpServers")
262        && let Some(lctx) = servers.get_mut("lean-ctx")
263    {
264        return Some(lctx);
265    }
266    None
267}