Skip to main content

lean_ctx/core/
ide_permissions.rs

1//! Read the active IDE's tool-permission configuration and resolve an effective
2//! action for a lean-ctx tool, so lean-ctx can *mirror* ("inherit") the user's
3//! IDE permission rules instead of forming a second, ungoverned execution path.
4//!
5//! Motivation (community request): when lean-ctx is mounted as an MCP server,
6//! its tools (e.g. `ctx_shell`) run inside the lean-ctx process and therefore
7//! bypass the host IDE's own permission engine — a user who set `bash`/`rm *`
8//! to `ask`/`deny` in their IDE would have that guard silently skipped whenever
9//! the agent reaches for `ctx_shell` instead of the native tool. This module
10//! parses the IDE permission config and lets the server gate apply an
11//! equivalent decision.
12//!
13//! v1 supports **OpenCode** (`opencode.json` / `opencode.jsonc`, global +
14//! project). The mapping is intentionally pure and side-effect-free; the server
15//! wiring (client detection, tool→key mapping, messaging, caching) lives in
16//! `server::permission_inheritance`.
17//!
18//! lean-ctx never *writes* the IDE's `permission` block — inheritance is
19//! read-only and runtime-only.
20
21use std::path::Path;
22
23use serde_json::{Map, Value};
24
25use crate::core::jsonc::parse_jsonc;
26
27/// An IDE permission decision for a single action.
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum PermAction {
30    Allow,
31    Ask,
32    Deny,
33}
34
35impl PermAction {
36    fn parse(raw: &str) -> Option<Self> {
37        match raw.trim().to_ascii_lowercase().as_str() {
38            "allow" => Some(Self::Allow),
39            "ask" => Some(Self::Ask),
40            "deny" => Some(Self::Deny),
41            _ => None,
42        }
43    }
44
45    /// Restrictiveness rank used to break ties between equally specific rules
46    /// (the safer, more restrictive action wins).
47    const fn rank(self) -> u8 {
48        match self {
49            Self::Allow => 0,
50            Self::Ask => 1,
51            Self::Deny => 2,
52        }
53    }
54}
55
56/// A resolved decision together with the human-readable rule that produced it.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PermDecision {
59    pub action: PermAction,
60    /// e.g. `bash`, `bash:rm *`, `read:*`, `*`.
61    pub rule: String,
62}
63
64/// OpenCode's documented permission keys. Used to distinguish a real tool key
65/// from a free-form bash command pattern placed at the top level.
66const OPENCODE_TOOL_KEYS: &[&str] = &[
67    "read",
68    "edit",
69    "write",
70    "patch",
71    "glob",
72    "grep",
73    "bash",
74    "task",
75    "skill",
76    "lsp",
77    "question",
78    "webfetch",
79    "websearch",
80    "external_directory",
81    "doom_loop",
82    "*",
83];
84
85/// Specificity score for the global `*` rule (lowest priority).
86const GLOBAL_SPEC: i64 = -1;
87/// Specificity score for a blanket tool rule (`bash: "allow"` or `bash: { "*": … }`).
88const BLANKET_SPEC: i64 = 0;
89
90/// Normalized IDE permission policy: the merged `permission` object from the IDE
91/// config (project entries override global ones per top-level key).
92#[derive(Debug, Clone, Default)]
93pub struct IdePermissionPolicy {
94    rules: Map<String, Value>,
95}
96
97struct Candidate {
98    spec: i64,
99    action: PermAction,
100    rule: String,
101}
102
103impl IdePermissionPolicy {
104    #[must_use]
105    pub fn is_empty(&self) -> bool {
106        self.rules.is_empty()
107    }
108
109    /// Number of top-level permission rules in the merged policy.
110    #[must_use]
111    pub fn rule_count(&self) -> usize {
112        self.rules.len()
113    }
114
115    /// Construct directly from a raw `permission` object (test/utility hook).
116    #[must_use]
117    pub fn from_rules(rules: Map<String, Value>) -> Self {
118        Self { rules }
119    }
120
121    /// Resolve the effective action for an OpenCode tool key (e.g. `bash`,
122    /// `read`) given the relevant tool input (command / path / pattern).
123    ///
124    /// Returns `None` when no rule matches — the caller treats that as the IDE
125    /// default (`allow` for the tools we mirror), so inheritance never *adds*
126    /// friction that the IDE itself would not impose.
127    ///
128    /// Resolution is order-independent (serde_json maps are not insertion-ordered
129    /// without `preserve_order`): the **most specific** rule wins (longest
130    /// pattern by non-wildcard character count; a named tool beats the global
131    /// `*`), ties broken by the **most restrictive** action.
132    #[must_use]
133    pub fn resolve(&self, tool_key: &str, input: Option<&str>) -> Option<PermDecision> {
134        let mut best: Option<Candidate> = None;
135
136        if let Some(value) = self.rules.get(tool_key) {
137            collect_from_value(value, input, tool_key, &mut best);
138        }
139
140        // For shell: also honor top-level command-like patterns. OpenCode
141        // documents these nested under `bash`, but users frequently write them
142        // at the top level (e.g. `"rm *": "ask"`); we accept both so the guard
143        // is never silently ineffective when proxied through `ctx_shell`. A
144        // command pattern is more specific than a blanket `bash: "allow"`.
145        if tool_key == "bash" {
146            if let Some(cmd) = input {
147                for (key, value) in &self.rules {
148                    if OPENCODE_TOOL_KEYS.contains(&key.as_str()) {
149                        continue;
150                    }
151                    if !key.contains(' ') && !key.contains('*') {
152                        continue;
153                    }
154                    if let Some(action) = value.as_str().and_then(PermAction::parse) {
155                        if wildcard_match(key, cmd) {
156                            consider(&mut best, specificity(key), action, format!("bash:{key}"));
157                        }
158                    }
159                }
160            }
161        }
162
163        if let Some(action) = self
164            .rules
165            .get("*")
166            .and_then(Value::as_str)
167            .and_then(PermAction::parse)
168        {
169            consider(&mut best, GLOBAL_SPEC, action, "*".to_string());
170        }
171
172        best.map(|c| PermDecision {
173            action: c.action,
174            rule: c.rule,
175        })
176    }
177}
178
179fn collect_from_value(value: &Value, input: Option<&str>, key: &str, best: &mut Option<Candidate>) {
180    if let Some(raw) = value.as_str() {
181        if let Some(action) = PermAction::parse(raw) {
182            consider(best, BLANKET_SPEC, action, key.to_string());
183        }
184        return;
185    }
186    let Some(obj) = value.as_object() else {
187        return;
188    };
189    if let Some(inp) = input {
190        for (pat, av) in obj {
191            if pat == "*" {
192                continue;
193            }
194            if let Some(action) = av.as_str().and_then(PermAction::parse) {
195                if wildcard_match(pat, inp) {
196                    consider(best, specificity(pat), action, format!("{key}:{pat}"));
197                }
198            }
199        }
200    }
201    if let Some(action) = obj
202        .get("*")
203        .and_then(Value::as_str)
204        .and_then(PermAction::parse)
205    {
206        consider(best, BLANKET_SPEC, action, format!("{key}:*"));
207    }
208}
209
210fn consider(best: &mut Option<Candidate>, spec: i64, action: PermAction, rule: String) {
211    let better = match best {
212        None => true,
213        Some(b) => spec > b.spec || (spec == b.spec && action.rank() > b.action.rank()),
214    };
215    if better {
216        *best = Some(Candidate { spec, action, rule });
217    }
218}
219
220/// Specificity of a glob pattern = count of non-`*` characters (more literal
221/// characters → more specific).
222fn specificity(pattern: &str) -> i64 {
223    pattern.chars().filter(|c| *c != '*').count() as i64
224}
225
226/// Minimal glob matcher supporting `*` (matches any run of characters, including
227/// empty); `**` is treated as `*`. No `?` or character classes — this mirrors
228/// the simple command/path globs OpenCode permission rules use (`git *`,
229/// `rm *`, `src/*`). Matching is case-sensitive.
230#[must_use]
231pub fn wildcard_match(pattern: &str, text: &str) -> bool {
232    let pat: Vec<char> = pattern.chars().collect();
233    let txt: Vec<char> = text.chars().collect();
234    let (mut p, mut t) = (0usize, 0usize);
235    let mut star: Option<usize> = None;
236    let mut star_t = 0usize;
237
238    while t < txt.len() {
239        if p < pat.len() && pat[p] == '*' {
240            while p + 1 < pat.len() && pat[p + 1] == '*' {
241                p += 1;
242            }
243            star = Some(p);
244            star_t = t;
245            p += 1;
246        } else if p < pat.len() && pat[p] == txt[t] {
247            p += 1;
248            t += 1;
249        } else if let Some(sp) = star {
250            p = sp + 1;
251            star_t += 1;
252            t = star_t;
253        } else {
254            return false;
255        }
256    }
257    while p < pat.len() && pat[p] == '*' {
258        p += 1;
259    }
260    p == pat.len()
261}
262
263/// Read and merge the OpenCode `permission` object: global config first, then
264/// the project config (project keys override global). Missing/invalid files are
265/// skipped silently — inheritance must never break a tool call by erroring.
266#[must_use]
267pub fn load_opencode(home: &Path, project_root: Option<&Path>) -> IdePermissionPolicy {
268    let mut rules = Map::new();
269    let opencode = home.join(".config").join("opencode");
270    merge_permission_file(&opencode.join("opencode.json"), &mut rules);
271    merge_permission_file(&opencode.join("opencode.jsonc"), &mut rules);
272    if let Some(root) = project_root {
273        merge_permission_file(&root.join("opencode.json"), &mut rules);
274        merge_permission_file(&root.join("opencode.jsonc"), &mut rules);
275    }
276    IdePermissionPolicy { rules }
277}
278
279fn merge_permission_file(path: &Path, rules: &mut Map<String, Value>) {
280    let Ok(text) = std::fs::read_to_string(path) else {
281        return;
282    };
283    let Ok(value) = parse_jsonc(&text) else {
284        return;
285    };
286    if let Some(perm) = value.get("permission").and_then(Value::as_object) {
287        for (key, val) in perm {
288            rules.insert(key.clone(), val.clone());
289        }
290    }
291}
292
293#[cfg(test)]
294mod tests {
295    use super::*;
296    use serde_json::json;
297
298    fn policy(v: Value) -> IdePermissionPolicy {
299        match v {
300            Value::Object(map) => IdePermissionPolicy::from_rules(map),
301            _ => IdePermissionPolicy::default(),
302        }
303    }
304
305    #[test]
306    fn wildcard_basic() {
307        assert!(wildcard_match("rm *", "rm -rf foo"));
308        assert!(wildcard_match("git *", "git status"));
309        assert!(!wildcard_match("git *", "gitk"));
310        assert!(wildcard_match("*", "anything"));
311        assert!(wildcard_match("src/*", "src/main.rs"));
312        assert!(!wildcard_match("rm *", "sudo rm -rf /"));
313        assert!(wildcard_match("**", ""));
314        assert!(wildcard_match("a*c", "abbbc"));
315        assert!(!wildcard_match("a*c", "abbb"));
316    }
317
318    #[test]
319    fn string_rule_resolves() {
320        let p = policy(json!({ "bash": "deny" }));
321        let d = p.resolve("bash", Some("ls")).unwrap();
322        assert_eq!(d.action, PermAction::Deny);
323        assert_eq!(d.rule, "bash");
324    }
325
326    #[test]
327    fn nested_bash_pattern_specific_wins() {
328        let p = policy(json!({
329            "bash": { "*": "ask", "git *": "allow", "rm *": "deny" }
330        }));
331        assert_eq!(
332            p.resolve("bash", Some("git push")).unwrap().action,
333            PermAction::Allow
334        );
335        assert_eq!(
336            p.resolve("bash", Some("rm -rf x")).unwrap().action,
337            PermAction::Deny
338        );
339        // unmatched falls back to the object's "*"
340        assert_eq!(
341            p.resolve("bash", Some("ls")).unwrap().action,
342            PermAction::Ask
343        );
344    }
345
346    #[test]
347    fn top_level_command_pattern_overrides_blanket_bash() {
348        // The exact shape from the user's screenshot: top-level "rm *": "ask"
349        // alongside a blanket bash=allow. The command pattern is more specific.
350        let p = policy(json!({ "bash": "allow", "rm *": "ask" }));
351        let d = p.resolve("bash", Some("rm -rf /tmp/x")).unwrap();
352        assert_eq!(d.action, PermAction::Ask);
353        assert_eq!(d.rule, "bash:rm *");
354        // a non-rm command falls through to the plain bash=allow
355        assert_eq!(
356            p.resolve("bash", Some("ls")).unwrap().action,
357            PermAction::Allow
358        );
359    }
360
361    #[test]
362    fn most_specific_wins_regardless_of_map_order() {
363        let p = policy(json!({ "bash": { "git *": "allow", "git push *": "ask" } }));
364        assert_eq!(
365            p.resolve("bash", Some("git push origin")).unwrap().action,
366            PermAction::Ask
367        );
368    }
369
370    #[test]
371    fn read_path_pattern() {
372        let p = policy(json!({ "read": { "*": "allow", "*.env": "deny" } }));
373        assert_eq!(
374            p.resolve("read", Some("src/main.rs")).unwrap().action,
375            PermAction::Allow
376        );
377        assert_eq!(
378            p.resolve("read", Some("prod.env")).unwrap().action,
379            PermAction::Deny
380        );
381        assert_eq!(
382            p.resolve("read", Some("config/.env")).unwrap().action,
383            PermAction::Deny
384        );
385    }
386
387    #[test]
388    fn named_tool_beats_global_wildcard() {
389        let p = policy(json!({ "*": "ask", "bash": "allow" }));
390        assert_eq!(
391            p.resolve("bash", Some("ls")).unwrap().action,
392            PermAction::Allow
393        );
394        assert_eq!(
395            p.resolve("read", Some("x")).unwrap().action,
396            PermAction::Ask
397        );
398    }
399
400    #[test]
401    fn no_rule_returns_none() {
402        let p = policy(json!({ "bash": "allow" }));
403        assert!(p.resolve("read", Some("x")).is_none());
404    }
405
406    #[test]
407    fn empty_policy_is_empty() {
408        assert!(IdePermissionPolicy::default().is_empty());
409    }
410
411    #[test]
412    fn load_opencode_merges_global_and_project() {
413        let dir = tempfile::tempdir().unwrap();
414        let home = dir.path().join("home");
415        let proj = dir.path().join("proj");
416        std::fs::create_dir_all(home.join(".config").join("opencode")).unwrap();
417        std::fs::create_dir_all(&proj).unwrap();
418        std::fs::write(
419            home.join(".config").join("opencode").join("opencode.json"),
420            r#"{ "permission": { "bash": "ask", "read": "allow" } }"#,
421        )
422        .unwrap();
423        std::fs::write(
424            proj.join("opencode.jsonc"),
425            "{ // project\n \"permission\": { \"bash\": \"deny\" } }",
426        )
427        .unwrap();
428        let p = load_opencode(&home, Some(&proj));
429        assert_eq!(
430            p.resolve("bash", Some("ls")).unwrap().action,
431            PermAction::Deny
432        );
433        assert_eq!(
434            p.resolve("read", Some("x")).unwrap().action,
435            PermAction::Allow
436        );
437    }
438
439    #[test]
440    fn load_opencode_missing_files_is_empty() {
441        let dir = tempfile::tempdir().unwrap();
442        let p = load_opencode(dir.path(), None);
443        assert!(p.is_empty());
444    }
445}