1use std::path::Path;
14use std::sync::{Mutex, OnceLock, PoisonError};
15use std::time::{Duration, Instant};
16
17use rmcp::model::{CallToolResult, ContentBlock};
18use serde_json::{Map, Value};
19
20use crate::core::config::{Config, PermissionInheritance};
21use crate::core::ide_permissions::{self, IdePermissionPolicy, PermAction, PermDecision};
22
23pub struct PermissionCheck {
25 pub blocked: bool,
26 pub message: Option<String>,
27}
28
29impl PermissionCheck {
30 fn allow() -> Self {
31 Self {
32 blocked: false,
33 message: None,
34 }
35 }
36
37 fn blocked(message: String) -> Self {
38 Self {
39 blocked: true,
40 message: Some(message),
41 }
42 }
43}
44
45const CACHE_TTL: Duration = Duration::from_secs(5);
46
47struct CacheEntry {
48 key: String,
49 at: Instant,
50 policy: IdePermissionPolicy,
51}
52
53static POLICY_CACHE: OnceLock<Mutex<Option<CacheEntry>>> = OnceLock::new();
54
55fn client_id(client_name: &str) -> Option<&'static str> {
58 let n = client_name.to_ascii_lowercase();
59 if n.contains("opencode") {
60 Some("opencode")
61 } else {
62 None
63 }
64}
65
66fn map_tool(
69 tool: &str,
70 args: Option<&Map<String, Value>>,
71) -> Option<(&'static str, Option<String>)> {
72 let get = |k: &str| crate::server::helpers::get_str(args, k);
73 match tool {
74 "ctx_shell" | "ctx_execute" => Some(("bash", get("command"))),
75 "ctx_read" | "ctx_multi_read" | "ctx_smart_read" => Some(("read", get("path"))),
76 "ctx_edit" | "ctx_patch" => Some(("edit", get("path"))),
77 "ctx_search" => Some(("grep", get("pattern").or_else(|| get("query")))),
78 _ => None,
79 }
80}
81
82#[must_use]
85pub fn check(
86 client_name: &str,
87 tool: &str,
88 args: Option<&Map<String, Value>>,
89 project_root: Option<&str>,
90 config: &Config,
91) -> PermissionCheck {
92 if config.permission_inheritance_effective() != PermissionInheritance::On {
93 return PermissionCheck::allow();
94 }
95 let Some(cid) = client_id(client_name) else {
96 return PermissionCheck::allow();
97 };
98 let Some((key, input)) = map_tool(tool, args) else {
99 return PermissionCheck::allow();
100 };
101 let policy = policy_for(cid, project_root);
102 if policy.is_empty() {
103 return PermissionCheck::allow();
104 }
105
106 if config.shadow_mode {
113 let decision = policy.resolve(key, input.as_deref());
114 if let Some(ref d) = decision {
115 if is_shadow_written_deny(&d.rule) {
116 return PermissionCheck::allow();
117 }
118 }
119 return decide(display_name(cid), &policy, tool, key, input.as_deref());
120 }
121
122 decide(display_name(cid), &policy, tool, key, input.as_deref())
123}
124
125fn is_shadow_written_deny(rule: &str) -> bool {
131 matches!(rule, "bash" | "read" | "grep" | "glob")
132}
133
134fn decide(
137 ide: &str,
138 policy: &IdePermissionPolicy,
139 tool: &str,
140 key: &str,
141 input: Option<&str>,
142) -> PermissionCheck {
143 let Some(decision) = policy.resolve(key, input) else {
144 return PermissionCheck::allow();
145 };
146 match decision.action {
147 PermAction::Allow => PermissionCheck::allow(),
148 PermAction::Ask => PermissionCheck::blocked(ask_message(ide, &decision, key, input)),
149 PermAction::Deny => {
150 PermissionCheck::blocked(deny_message(ide, tool, &decision, key, input))
151 }
152 }
153}
154
155fn ask_message(ide: &str, decision: &PermDecision, key: &str, input: Option<&str>) -> String {
156 format!(
157 "[IDE PERMISSION] {ide} gates this with `{rule}` = ask. lean-ctx mirrors your IDE \
158 permissions (permission_inheritance=on) and cannot show an interactive prompt for MCP \
159 tools, so the call is held back to honor your rule.{suffix}\n\
160 Approve it via {ide}'s native tool, set the rule to `allow`, or disable inheritance with \
161 `lean-ctx config set permission_inheritance off`.",
162 ide = ide,
163 rule = decision.rule,
164 suffix = input_suffix(key, input),
165 )
166}
167
168fn deny_message(
169 ide: &str,
170 tool: &str,
171 decision: &PermDecision,
172 key: &str,
173 input: Option<&str>,
174) -> String {
175 format!(
176 "[IDE PERMISSION] {ide} blocks this via `{rule}` = deny. lean-ctx mirrors your IDE \
177 permissions (permission_inheritance=on), so `{tool}` is blocked too.{suffix}",
178 ide = ide,
179 rule = decision.rule,
180 tool = tool,
181 suffix = input_suffix(key, input),
182 )
183}
184
185fn input_suffix(key: &str, input: Option<&str>) -> String {
186 let Some(value) = input else {
187 return String::new();
188 };
189 let label = match key {
190 "bash" => "Command",
191 "grep" => "Pattern",
192 _ => "Path",
193 };
194 format!(" {label}: `{}`", truncate(value, 200))
195}
196
197fn truncate(s: &str, max: usize) -> String {
198 if s.chars().count() <= max {
199 return s.to_string();
200 }
201 let mut out: String = s.chars().take(max).collect();
202 out.push('…');
203 out
204}
205
206fn display_name(client_id: &str) -> &'static str {
207 crate::core::client_constraints::by_client_id(client_id).map_or("your IDE", |c| c.display_name)
208}
209
210fn policy_for(client_id: &str, project_root: Option<&str>) -> IdePermissionPolicy {
211 let key = format!("{client_id}|{}", project_root.unwrap_or(""));
212 let cache = POLICY_CACHE.get_or_init(|| Mutex::new(None));
213 let mut guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
214 if let Some(entry) = guard.as_ref()
215 && entry.key == key
216 && entry.at.elapsed() < CACHE_TTL
217 {
218 return entry.policy.clone();
219 }
220 let policy = load_policy(client_id, project_root);
221 *guard = Some(CacheEntry {
222 key,
223 at: Instant::now(),
224 policy: policy.clone(),
225 });
226 policy
227}
228
229fn load_policy(client_id: &str, project_root: Option<&str>) -> IdePermissionPolicy {
230 let Some(home) = dirs::home_dir() else {
231 return IdePermissionPolicy::default();
232 };
233 match client_id {
234 "opencode" => ide_permissions::load_opencode(&home, project_root.map(Path::new)),
235 _ => IdePermissionPolicy::default(),
236 }
237}
238
239#[must_use]
242pub fn into_call_tool_result(check: &PermissionCheck) -> Option<CallToolResult> {
243 if check.blocked {
244 Some(CallToolResult::success(vec![ContentBlock::text(
245 check
246 .message
247 .clone()
248 .unwrap_or_else(|| "Blocked by IDE permission inheritance".to_string()),
249 )]))
250 } else {
251 None
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use serde_json::json;
259
260 fn policy(v: Value) -> IdePermissionPolicy {
261 match v {
262 Value::Object(map) => IdePermissionPolicy::from_rules(map),
263 _ => IdePermissionPolicy::default(),
264 }
265 }
266
267 #[test]
268 fn client_id_detects_opencode() {
269 assert_eq!(client_id("opencode"), Some("opencode"));
270 assert_eq!(client_id("OpenCode 1.2"), Some("opencode"));
271 assert_eq!(client_id("cursor"), None);
272 assert_eq!(client_id(""), None);
273 }
274
275 #[test]
276 fn map_tool_covers_mirrored_tools() {
277 let args = json!({ "command": "rm -rf x", "path": "a.rs", "pattern": "foo" });
278 let map = args.as_object().unwrap();
279 assert_eq!(map_tool("ctx_shell", Some(map)).unwrap().0, "bash");
280 assert_eq!(map_tool("ctx_execute", Some(map)).unwrap().0, "bash");
281 assert_eq!(map_tool("ctx_read", Some(map)).unwrap().0, "read");
282 assert_eq!(map_tool("ctx_edit", Some(map)).unwrap().0, "edit");
283 assert_eq!(map_tool("ctx_patch", Some(map)).unwrap().0, "edit");
285 assert_eq!(map_tool("ctx_search", Some(map)).unwrap().0, "grep");
286 assert!(map_tool("ctx_knowledge", Some(map)).is_none());
287 }
288
289 #[test]
290 fn decide_allow_passes() {
291 let p = policy(json!({ "bash": "allow" }));
292 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("ls"));
293 assert!(!c.blocked);
294 }
295
296 #[test]
297 fn decide_deny_blocks_with_message() {
298 let p = policy(json!({ "bash": "deny" }));
299 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("ls"));
300 assert!(c.blocked);
301 let msg = c.message.unwrap();
302 assert!(msg.contains("deny"));
303 assert!(msg.contains("ctx_shell"));
304 assert!(msg.contains("Command: `ls`"));
305 }
306
307 #[test]
308 fn decide_ask_holds_back_rm() {
309 let p = policy(json!({ "bash": "allow", "rm *": "ask" }));
311 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("rm -rf /tmp/x"));
312 assert!(c.blocked);
313 let msg = c.message.unwrap();
314 assert!(msg.contains("ask"));
315 assert!(msg.contains("bash:rm *"));
316 assert!(msg.contains("permission_inheritance off"));
317 }
318
319 #[test]
320 fn decide_unmatched_tool_input_allows() {
321 let p = policy(json!({ "read": "deny" }));
322 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("ls"));
324 assert!(!c.blocked);
325 }
326
327 #[test]
328 fn into_result_only_when_blocked() {
329 assert!(into_call_tool_result(&PermissionCheck::allow()).is_none());
330 assert!(into_call_tool_result(&PermissionCheck::blocked("x".into())).is_some());
331 }
332
333 #[test]
334 fn check_off_by_default_allows_everything() {
335 if std::env::var("LEAN_CTX_PERMISSION_INHERITANCE").is_ok() {
337 return;
338 }
339 let cfg = Config {
340 permission_inheritance: Some("off".to_string()),
341 ..Default::default()
342 };
343 let args = json!({ "command": "rm -rf /" });
344 let c = check(
345 "opencode",
346 "ctx_shell",
347 Some(args.as_object().unwrap()),
348 None,
349 &cfg,
350 );
351 assert!(!c.blocked);
352 }
353
354 #[test]
355 fn truncate_keeps_short_strings() {
356 assert_eq!(truncate("short", 200), "short");
357 assert_eq!(truncate("abcdef", 3), "abc\u{2026}");
358 }
359
360 #[test]
361 fn is_shadow_written_deny_recognizes_tool_keys() {
362 assert!(is_shadow_written_deny("bash"));
363 assert!(is_shadow_written_deny("read"));
364 assert!(is_shadow_written_deny("grep"));
365 assert!(is_shadow_written_deny("glob"));
366 }
367
368 #[test]
369 fn is_shadow_written_deny_rejects_user_rules() {
370 assert!(!is_shadow_written_deny("bash:rm *"));
371 assert!(!is_shadow_written_deny("bash:*"));
372 assert!(!is_shadow_written_deny("read:*.env"));
373 assert!(!is_shadow_written_deny("*"));
374 assert!(!is_shadow_written_deny("edit"));
375 }
376
377 #[test]
378 fn shadow_mode_honors_user_rm_ask_rule() {
379 let p = policy(json!({ "bash": "deny", "rm *": "ask" }));
382 let d = p.resolve("bash", Some("rm -rf /tmp/important")).unwrap();
384 assert_eq!(d.action, PermAction::Ask);
385 assert_eq!(d.rule, "bash:rm *");
386 assert!(!is_shadow_written_deny(&d.rule));
388 }
389
390 #[test]
391 fn shadow_mode_skips_shadow_written_bash_deny() {
392 let p = policy(json!({ "bash": "deny" }));
395 let d = p.resolve("bash", Some("ls")).unwrap();
396 assert_eq!(d.action, PermAction::Deny);
397 assert_eq!(d.rule, "bash");
398 assert!(is_shadow_written_deny(&d.rule));
399 }
400}