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