1use std::path::Path;
14use std::sync::{Mutex, OnceLock, PoisonError};
15use std::time::{Duration, Instant};
16
17use rmcp::model::{CallToolResult, Content};
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" => 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 if config.shadow_mode {
101 return PermissionCheck::allow();
102 }
103 let Some(cid) = client_id(client_name) else {
104 return PermissionCheck::allow();
105 };
106 let Some((key, input)) = map_tool(tool, args) else {
107 return PermissionCheck::allow();
108 };
109 let policy = policy_for(cid, project_root);
110 if policy.is_empty() {
111 return PermissionCheck::allow();
112 }
113 decide(display_name(cid), &policy, tool, key, input.as_deref())
114}
115
116fn decide(
119 ide: &str,
120 policy: &IdePermissionPolicy,
121 tool: &str,
122 key: &str,
123 input: Option<&str>,
124) -> PermissionCheck {
125 let Some(decision) = policy.resolve(key, input) else {
126 return PermissionCheck::allow();
127 };
128 match decision.action {
129 PermAction::Allow => PermissionCheck::allow(),
130 PermAction::Ask => PermissionCheck::blocked(ask_message(ide, &decision, key, input)),
131 PermAction::Deny => {
132 PermissionCheck::blocked(deny_message(ide, tool, &decision, key, input))
133 }
134 }
135}
136
137fn ask_message(ide: &str, decision: &PermDecision, key: &str, input: Option<&str>) -> String {
138 format!(
139 "[IDE PERMISSION] {ide} gates this with `{rule}` = ask. lean-ctx mirrors your IDE \
140 permissions (permission_inheritance=on) and cannot show an interactive prompt for MCP \
141 tools, so the call is held back to honor your rule.{suffix}\n\
142 Approve it via {ide}'s native tool, set the rule to `allow`, or disable inheritance with \
143 `lean-ctx config set permission_inheritance off`.",
144 ide = ide,
145 rule = decision.rule,
146 suffix = input_suffix(key, input),
147 )
148}
149
150fn deny_message(
151 ide: &str,
152 tool: &str,
153 decision: &PermDecision,
154 key: &str,
155 input: Option<&str>,
156) -> String {
157 format!(
158 "[IDE PERMISSION] {ide} blocks this via `{rule}` = deny. lean-ctx mirrors your IDE \
159 permissions (permission_inheritance=on), so `{tool}` is blocked too.{suffix}",
160 ide = ide,
161 rule = decision.rule,
162 tool = tool,
163 suffix = input_suffix(key, input),
164 )
165}
166
167fn input_suffix(key: &str, input: Option<&str>) -> String {
168 let Some(value) = input else {
169 return String::new();
170 };
171 let label = match key {
172 "bash" => "Command",
173 "grep" => "Pattern",
174 _ => "Path",
175 };
176 format!(" {label}: `{}`", truncate(value, 200))
177}
178
179fn truncate(s: &str, max: usize) -> String {
180 if s.chars().count() <= max {
181 return s.to_string();
182 }
183 let mut out: String = s.chars().take(max).collect();
184 out.push('…');
185 out
186}
187
188fn display_name(client_id: &str) -> &'static str {
189 crate::core::client_constraints::by_client_id(client_id).map_or("your IDE", |c| c.display_name)
190}
191
192fn policy_for(client_id: &str, project_root: Option<&str>) -> IdePermissionPolicy {
193 let key = format!("{client_id}|{}", project_root.unwrap_or(""));
194 let cache = POLICY_CACHE.get_or_init(|| Mutex::new(None));
195 let mut guard = cache.lock().unwrap_or_else(PoisonError::into_inner);
196 if let Some(entry) = guard.as_ref()
197 && entry.key == key
198 && entry.at.elapsed() < CACHE_TTL
199 {
200 return entry.policy.clone();
201 }
202 let policy = load_policy(client_id, project_root);
203 *guard = Some(CacheEntry {
204 key,
205 at: Instant::now(),
206 policy: policy.clone(),
207 });
208 policy
209}
210
211fn load_policy(client_id: &str, project_root: Option<&str>) -> IdePermissionPolicy {
212 let Some(home) = dirs::home_dir() else {
213 return IdePermissionPolicy::default();
214 };
215 match client_id {
216 "opencode" => ide_permissions::load_opencode(&home, project_root.map(Path::new)),
217 _ => IdePermissionPolicy::default(),
218 }
219}
220
221#[must_use]
224pub fn into_call_tool_result(check: &PermissionCheck) -> Option<CallToolResult> {
225 if check.blocked {
226 Some(CallToolResult::success(vec![Content::text(
227 check
228 .message
229 .clone()
230 .unwrap_or_else(|| "Blocked by IDE permission inheritance".to_string()),
231 )]))
232 } else {
233 None
234 }
235}
236
237#[cfg(test)]
238mod tests {
239 use super::*;
240 use serde_json::json;
241
242 fn policy(v: Value) -> IdePermissionPolicy {
243 match v {
244 Value::Object(map) => IdePermissionPolicy::from_rules(map),
245 _ => IdePermissionPolicy::default(),
246 }
247 }
248
249 #[test]
250 fn client_id_detects_opencode() {
251 assert_eq!(client_id("opencode"), Some("opencode"));
252 assert_eq!(client_id("OpenCode 1.2"), Some("opencode"));
253 assert_eq!(client_id("cursor"), None);
254 assert_eq!(client_id(""), None);
255 }
256
257 #[test]
258 fn map_tool_covers_mirrored_tools() {
259 let args = json!({ "command": "rm -rf x", "path": "a.rs", "pattern": "foo" });
260 let map = args.as_object().unwrap();
261 assert_eq!(map_tool("ctx_shell", Some(map)).unwrap().0, "bash");
262 assert_eq!(map_tool("ctx_execute", Some(map)).unwrap().0, "bash");
263 assert_eq!(map_tool("ctx_read", Some(map)).unwrap().0, "read");
264 assert_eq!(map_tool("ctx_edit", Some(map)).unwrap().0, "edit");
265 assert_eq!(map_tool("ctx_search", Some(map)).unwrap().0, "grep");
266 assert!(map_tool("ctx_knowledge", Some(map)).is_none());
267 }
268
269 #[test]
270 fn decide_allow_passes() {
271 let p = policy(json!({ "bash": "allow" }));
272 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("ls"));
273 assert!(!c.blocked);
274 }
275
276 #[test]
277 fn decide_deny_blocks_with_message() {
278 let p = policy(json!({ "bash": "deny" }));
279 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("ls"));
280 assert!(c.blocked);
281 let msg = c.message.unwrap();
282 assert!(msg.contains("deny"));
283 assert!(msg.contains("ctx_shell"));
284 assert!(msg.contains("Command: `ls`"));
285 }
286
287 #[test]
288 fn decide_ask_holds_back_rm() {
289 let p = policy(json!({ "bash": "allow", "rm *": "ask" }));
291 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("rm -rf /tmp/x"));
292 assert!(c.blocked);
293 let msg = c.message.unwrap();
294 assert!(msg.contains("ask"));
295 assert!(msg.contains("bash:rm *"));
296 assert!(msg.contains("permission_inheritance off"));
297 }
298
299 #[test]
300 fn decide_unmatched_tool_input_allows() {
301 let p = policy(json!({ "read": "deny" }));
302 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("ls"));
304 assert!(!c.blocked);
305 }
306
307 #[test]
308 fn into_result_only_when_blocked() {
309 assert!(into_call_tool_result(&PermissionCheck::allow()).is_none());
310 assert!(into_call_tool_result(&PermissionCheck::blocked("x".into())).is_some());
311 }
312
313 #[test]
314 fn check_off_by_default_allows_everything() {
315 if std::env::var("LEAN_CTX_PERMISSION_INHERITANCE").is_ok() {
317 return;
318 }
319 let cfg = Config {
320 permission_inheritance: Some("off".to_string()),
321 ..Default::default()
322 };
323 let args = json!({ "command": "rm -rf /" });
324 let c = check(
325 "opencode",
326 "ctx_shell",
327 Some(args.as_object().unwrap()),
328 None,
329 &cfg,
330 );
331 assert!(!c.blocked);
332 }
333
334 #[test]
335 fn truncate_keeps_short_strings() {
336 assert_eq!(truncate("short", 200), "short");
337 assert_eq!(truncate("abcdef", 3), "abc…");
338 }
339}