Skip to main content

lean_ctx/server/
permission_inheritance.rs

1//! Pre-dispatch permission-inheritance gate.
2//!
3//! When `permission_inheritance = on`, lean-ctx mirrors the host IDE's
4//! tool-permission rules onto its own MCP tools so that, e.g., `ctx_shell`
5//! honors the user's `bash` / `rm *` rules instead of forming a parallel,
6//! ungoverned execution path. Shaped like [`super::role_guard`]: returns a
7//! blocking [`CallToolResult`] message, or `None` to proceed.
8//!
9//! The decision is split into a pure `decide` (policy in, decision out — fully
10//! unit-tested) and a thin [`check`] that loads/caches the IDE policy from disk.
11//! lean-ctx never *writes* the IDE's `permission` block; this is read-only.
12
13use 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
23/// Result of a permission-inheritance check.
24pub 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
55/// Map an MCP client name (from the `initialize` handshake) to a known IDE id we
56/// can read a permission config for. `None` → no reader → never gated.
57fn 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
66/// Map a lean-ctx tool + its args to the IDE permission key and the relevant
67/// input (command / path / pattern). `None` → tool not mirrored → allowed.
68fn 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/// Public entry point used by the dispatch path. Honors config + env, detects
83/// the IDE, loads (and caches) its permission policy, then defers to `decide`.
84#[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    // Shadow mode writes top-level denies (read/grep/glob/bash) into the IDE's
107    // permission config. A blanket skip would also suppress user sub-command
108    // rules like "rm *": "ask" — a dangerous bypass (GH #1428). Instead: run
109    // the check, then filter out top-level denies for the four shadow-denied
110    // tool keys (those were written by lean-ctx itself). User sub-command rules
111    // (e.g. "bash:rm *") are always honored.
112    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
125/// The four tool keys that shadow mode writes as `"deny"` into the IDE's
126/// permission config. A matched rule whose name is exactly one of these was
127/// shadow-written by lean-ctx — not user-authored — and should be skipped
128/// so the agent retains working tools. Sub-command rules like `"bash:rm *"`
129/// are user-authored and must be honored.
130fn is_shadow_written_deny(rule: &str) -> bool {
131    matches!(rule, "bash" | "read" | "grep" | "glob")
132}
133
134/// Pure decision: given a loaded policy, resolve the action for `tool` (mapped to
135/// IDE `key` + `input`) and turn it into a [`PermissionCheck`].
136fn 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/// Convert a check into a blocking tool result (like `role_guard`): a successful
240/// result carrying the explanation, so the agent reads *why* it was held back.
241#[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        // ctx_patch (anchored editing) inherits the same "edit" permission key.
284        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        // The user's screenshot scenario: bash=allow but rm *=ask.
310        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        // bash has no rule here → allowed.
323        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        // Env var takes precedence over config; skip if a stray one is set.
336        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        // GH #1428: the exact scenario Stephen reported — shadow mode must
380        // NOT bypass the user's "rm *": "ask" rule.
381        let p = policy(json!({ "bash": "deny", "rm *": "ask" }));
382        // Direct policy resolve → the sub-command rule wins:
383        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        // The helper correctly classifies this as user-authored:
387        assert!(!is_shadow_written_deny(&d.rule));
388    }
389
390    #[test]
391    fn shadow_mode_skips_shadow_written_bash_deny() {
392        // When shadow mode wrote "bash": "deny", a plain `ls` command must
393        // not be denied — that deny is shadow-written, not user-authored.
394        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}