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 && entry.key == key
190 && entry.at.elapsed() < CACHE_TTL
191 {
192 return entry.policy.clone();
193 }
194 let policy = load_policy(client_id, project_root);
195 *guard = Some(CacheEntry {
196 key,
197 at: Instant::now(),
198 policy: policy.clone(),
199 });
200 policy
201}
202
203fn load_policy(client_id: &str, project_root: Option<&str>) -> IdePermissionPolicy {
204 let Some(home) = dirs::home_dir() else {
205 return IdePermissionPolicy::default();
206 };
207 match client_id {
208 "opencode" => ide_permissions::load_opencode(&home, project_root.map(Path::new)),
209 _ => IdePermissionPolicy::default(),
210 }
211}
212
213#[must_use]
216pub fn into_call_tool_result(check: &PermissionCheck) -> Option<CallToolResult> {
217 if check.blocked {
218 Some(CallToolResult::success(vec![Content::text(
219 check
220 .message
221 .clone()
222 .unwrap_or_else(|| "Blocked by IDE permission inheritance".to_string()),
223 )]))
224 } else {
225 None
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232 use serde_json::json;
233
234 fn policy(v: Value) -> IdePermissionPolicy {
235 match v {
236 Value::Object(map) => IdePermissionPolicy::from_rules(map),
237 _ => IdePermissionPolicy::default(),
238 }
239 }
240
241 #[test]
242 fn client_id_detects_opencode() {
243 assert_eq!(client_id("opencode"), Some("opencode"));
244 assert_eq!(client_id("OpenCode 1.2"), Some("opencode"));
245 assert_eq!(client_id("cursor"), None);
246 assert_eq!(client_id(""), None);
247 }
248
249 #[test]
250 fn map_tool_covers_mirrored_tools() {
251 let args = json!({ "command": "rm -rf x", "path": "a.rs", "pattern": "foo" });
252 let map = args.as_object().unwrap();
253 assert_eq!(map_tool("ctx_shell", Some(map)).unwrap().0, "bash");
254 assert_eq!(map_tool("ctx_execute", Some(map)).unwrap().0, "bash");
255 assert_eq!(map_tool("ctx_read", Some(map)).unwrap().0, "read");
256 assert_eq!(map_tool("ctx_edit", Some(map)).unwrap().0, "edit");
257 assert_eq!(map_tool("ctx_search", Some(map)).unwrap().0, "grep");
258 assert!(map_tool("ctx_knowledge", Some(map)).is_none());
259 }
260
261 #[test]
262 fn decide_allow_passes() {
263 let p = policy(json!({ "bash": "allow" }));
264 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("ls"));
265 assert!(!c.blocked);
266 }
267
268 #[test]
269 fn decide_deny_blocks_with_message() {
270 let p = policy(json!({ "bash": "deny" }));
271 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("ls"));
272 assert!(c.blocked);
273 let msg = c.message.unwrap();
274 assert!(msg.contains("deny"));
275 assert!(msg.contains("ctx_shell"));
276 assert!(msg.contains("Command: `ls`"));
277 }
278
279 #[test]
280 fn decide_ask_holds_back_rm() {
281 let p = policy(json!({ "bash": "allow", "rm *": "ask" }));
283 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("rm -rf /tmp/x"));
284 assert!(c.blocked);
285 let msg = c.message.unwrap();
286 assert!(msg.contains("ask"));
287 assert!(msg.contains("bash:rm *"));
288 assert!(msg.contains("permission_inheritance off"));
289 }
290
291 #[test]
292 fn decide_unmatched_tool_input_allows() {
293 let p = policy(json!({ "read": "deny" }));
294 let c = decide("OpenCode", &p, "ctx_shell", "bash", Some("ls"));
296 assert!(!c.blocked);
297 }
298
299 #[test]
300 fn into_result_only_when_blocked() {
301 assert!(into_call_tool_result(&PermissionCheck::allow()).is_none());
302 assert!(into_call_tool_result(&PermissionCheck::blocked("x".into())).is_some());
303 }
304
305 #[test]
306 fn check_off_by_default_allows_everything() {
307 if std::env::var("LEAN_CTX_PERMISSION_INHERITANCE").is_ok() {
309 return;
310 }
311 let cfg = Config {
312 permission_inheritance: Some("off".to_string()),
313 ..Default::default()
314 };
315 let args = json!({ "command": "rm -rf /" });
316 let c = check(
317 "opencode",
318 "ctx_shell",
319 Some(args.as_object().unwrap()),
320 None,
321 &cfg,
322 );
323 assert!(!c.blocked);
324 }
325
326 #[test]
327 fn truncate_keeps_short_strings() {
328 assert_eq!(truncate("short", 200), "short");
329 assert_eq!(truncate("abcdef", 3), "abc…");
330 }
331}