Skip to main content

lean_ctx_server/server/
permission_inheritance.rs

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