Skip to main content

lean_ctx/cli/
harden.rs

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