1use std::path::Path;
22
23use serde_json::{Map, Value};
24
25use crate::core::jsonc::parse_jsonc;
26
27#[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 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#[derive(Debug, Clone, PartialEq, Eq)]
58pub struct PermDecision {
59 pub action: PermAction,
60 pub rule: String,
62}
63
64const 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
85const GLOBAL_SPEC: i64 = -1;
87const BLANKET_SPEC: i64 = 0;
89
90#[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 #[must_use]
111 pub fn rule_count(&self) -> usize {
112 self.rules.len()
113 }
114
115 #[must_use]
117 pub fn from_rules(rules: Map<String, Value>) -> Self {
118 Self { rules }
119 }
120
121 #[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 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
220fn specificity(pattern: &str) -> i64 {
223 pattern.chars().filter(|c| *c != '*').count() as i64
224}
225
226#[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#[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 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 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 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}