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