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
169fn 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}