use std::path::Path;
use serde_json::{Map, Value};
use crate::core::jsonc::parse_jsonc;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PermAction {
Allow,
Ask,
Deny,
}
impl PermAction {
fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"allow" => Some(Self::Allow),
"ask" => Some(Self::Ask),
"deny" => Some(Self::Deny),
_ => None,
}
}
const fn rank(self) -> u8 {
match self {
Self::Allow => 0,
Self::Ask => 1,
Self::Deny => 2,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PermDecision {
pub action: PermAction,
pub rule: String,
}
const OPENCODE_TOOL_KEYS: &[&str] = &[
"read",
"edit",
"write",
"patch",
"glob",
"grep",
"bash",
"task",
"skill",
"lsp",
"question",
"webfetch",
"websearch",
"external_directory",
"doom_loop",
"*",
];
const GLOBAL_SPEC: i64 = -1;
const BLANKET_SPEC: i64 = 0;
#[derive(Debug, Clone, Default)]
pub struct IdePermissionPolicy {
rules: Map<String, Value>,
}
struct Candidate {
spec: i64,
action: PermAction,
rule: String,
}
impl IdePermissionPolicy {
#[must_use]
pub fn is_empty(&self) -> bool {
self.rules.is_empty()
}
#[must_use]
pub fn rule_count(&self) -> usize {
self.rules.len()
}
#[must_use]
pub fn from_rules(rules: Map<String, Value>) -> Self {
Self { rules }
}
#[must_use]
pub fn resolve(&self, tool_key: &str, input: Option<&str>) -> Option<PermDecision> {
let mut best: Option<Candidate> = None;
if let Some(value) = self.rules.get(tool_key) {
collect_from_value(value, input, tool_key, &mut best);
}
if tool_key == "bash" {
if let Some(cmd) = input {
for (key, value) in &self.rules {
if OPENCODE_TOOL_KEYS.contains(&key.as_str()) {
continue;
}
if !key.contains(' ') && !key.contains('*') {
continue;
}
if let Some(action) = value.as_str().and_then(PermAction::parse) {
if wildcard_match(key, cmd) {
consider(&mut best, specificity(key), action, format!("bash:{key}"));
}
}
}
}
}
if let Some(action) = self
.rules
.get("*")
.and_then(Value::as_str)
.and_then(PermAction::parse)
{
consider(&mut best, GLOBAL_SPEC, action, "*".to_string());
}
best.map(|c| PermDecision {
action: c.action,
rule: c.rule,
})
}
}
fn collect_from_value(value: &Value, input: Option<&str>, key: &str, best: &mut Option<Candidate>) {
if let Some(raw) = value.as_str() {
if let Some(action) = PermAction::parse(raw) {
consider(best, BLANKET_SPEC, action, key.to_string());
}
return;
}
let Some(obj) = value.as_object() else {
return;
};
if let Some(inp) = input {
for (pat, av) in obj {
if pat == "*" {
continue;
}
if let Some(action) = av.as_str().and_then(PermAction::parse) {
if wildcard_match(pat, inp) {
consider(best, specificity(pat), action, format!("{key}:{pat}"));
}
}
}
}
if let Some(action) = obj
.get("*")
.and_then(Value::as_str)
.and_then(PermAction::parse)
{
consider(best, BLANKET_SPEC, action, format!("{key}:*"));
}
}
fn consider(best: &mut Option<Candidate>, spec: i64, action: PermAction, rule: String) {
let better = match best {
None => true,
Some(b) => spec > b.spec || (spec == b.spec && action.rank() > b.action.rank()),
};
if better {
*best = Some(Candidate { spec, action, rule });
}
}
fn specificity(pattern: &str) -> i64 {
pattern.chars().filter(|c| *c != '*').count() as i64
}
#[must_use]
pub fn wildcard_match(pattern: &str, text: &str) -> bool {
let pat: Vec<char> = pattern.chars().collect();
let txt: Vec<char> = text.chars().collect();
let (mut p, mut t) = (0usize, 0usize);
let mut star: Option<usize> = None;
let mut star_t = 0usize;
while t < txt.len() {
if p < pat.len() && pat[p] == '*' {
while p + 1 < pat.len() && pat[p + 1] == '*' {
p += 1;
}
star = Some(p);
star_t = t;
p += 1;
} else if p < pat.len() && pat[p] == txt[t] {
p += 1;
t += 1;
} else if let Some(sp) = star {
p = sp + 1;
star_t += 1;
t = star_t;
} else {
return false;
}
}
while p < pat.len() && pat[p] == '*' {
p += 1;
}
p == pat.len()
}
#[must_use]
pub fn load_opencode(home: &Path, project_root: Option<&Path>) -> IdePermissionPolicy {
let mut rules = Map::new();
let opencode = home.join(".config").join("opencode");
merge_permission_file(&opencode.join("opencode.json"), &mut rules);
merge_permission_file(&opencode.join("opencode.jsonc"), &mut rules);
if let Some(root) = project_root {
merge_permission_file(&root.join("opencode.json"), &mut rules);
merge_permission_file(&root.join("opencode.jsonc"), &mut rules);
}
IdePermissionPolicy { rules }
}
fn merge_permission_file(path: &Path, rules: &mut Map<String, Value>) {
let Ok(text) = std::fs::read_to_string(path) else {
return;
};
let Ok(value) = parse_jsonc(&text) else {
return;
};
if let Some(perm) = value.get("permission").and_then(Value::as_object) {
for (key, val) in perm {
rules.insert(key.clone(), val.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn policy(v: Value) -> IdePermissionPolicy {
match v {
Value::Object(map) => IdePermissionPolicy::from_rules(map),
_ => IdePermissionPolicy::default(),
}
}
#[test]
fn wildcard_basic() {
assert!(wildcard_match("rm *", "rm -rf foo"));
assert!(wildcard_match("git *", "git status"));
assert!(!wildcard_match("git *", "gitk"));
assert!(wildcard_match("*", "anything"));
assert!(wildcard_match("src/*", "src/main.rs"));
assert!(!wildcard_match("rm *", "sudo rm -rf /"));
assert!(wildcard_match("**", ""));
assert!(wildcard_match("a*c", "abbbc"));
assert!(!wildcard_match("a*c", "abbb"));
}
#[test]
fn string_rule_resolves() {
let p = policy(json!({ "bash": "deny" }));
let d = p.resolve("bash", Some("ls")).unwrap();
assert_eq!(d.action, PermAction::Deny);
assert_eq!(d.rule, "bash");
}
#[test]
fn nested_bash_pattern_specific_wins() {
let p = policy(json!({
"bash": { "*": "ask", "git *": "allow", "rm *": "deny" }
}));
assert_eq!(
p.resolve("bash", Some("git push")).unwrap().action,
PermAction::Allow
);
assert_eq!(
p.resolve("bash", Some("rm -rf x")).unwrap().action,
PermAction::Deny
);
assert_eq!(
p.resolve("bash", Some("ls")).unwrap().action,
PermAction::Ask
);
}
#[test]
fn top_level_command_pattern_overrides_blanket_bash() {
let p = policy(json!({ "bash": "allow", "rm *": "ask" }));
let d = p.resolve("bash", Some("rm -rf /tmp/x")).unwrap();
assert_eq!(d.action, PermAction::Ask);
assert_eq!(d.rule, "bash:rm *");
assert_eq!(
p.resolve("bash", Some("ls")).unwrap().action,
PermAction::Allow
);
}
#[test]
fn most_specific_wins_regardless_of_map_order() {
let p = policy(json!({ "bash": { "git *": "allow", "git push *": "ask" } }));
assert_eq!(
p.resolve("bash", Some("git push origin")).unwrap().action,
PermAction::Ask
);
}
#[test]
fn read_path_pattern() {
let p = policy(json!({ "read": { "*": "allow", "*.env": "deny" } }));
assert_eq!(
p.resolve("read", Some("src/main.rs")).unwrap().action,
PermAction::Allow
);
assert_eq!(
p.resolve("read", Some("prod.env")).unwrap().action,
PermAction::Deny
);
assert_eq!(
p.resolve("read", Some("config/.env")).unwrap().action,
PermAction::Deny
);
}
#[test]
fn named_tool_beats_global_wildcard() {
let p = policy(json!({ "*": "ask", "bash": "allow" }));
assert_eq!(
p.resolve("bash", Some("ls")).unwrap().action,
PermAction::Allow
);
assert_eq!(
p.resolve("read", Some("x")).unwrap().action,
PermAction::Ask
);
}
#[test]
fn no_rule_returns_none() {
let p = policy(json!({ "bash": "allow" }));
assert!(p.resolve("read", Some("x")).is_none());
}
#[test]
fn empty_policy_is_empty() {
assert!(IdePermissionPolicy::default().is_empty());
}
#[test]
fn load_opencode_merges_global_and_project() {
let dir = tempfile::tempdir().unwrap();
let home = dir.path().join("home");
let proj = dir.path().join("proj");
std::fs::create_dir_all(home.join(".config").join("opencode")).unwrap();
std::fs::create_dir_all(&proj).unwrap();
std::fs::write(
home.join(".config").join("opencode").join("opencode.json"),
r#"{ "permission": { "bash": "ask", "read": "allow" } }"#,
)
.unwrap();
std::fs::write(
proj.join("opencode.jsonc"),
"{ // project\n \"permission\": { \"bash\": \"deny\" } }",
)
.unwrap();
let p = load_opencode(&home, Some(&proj));
assert_eq!(
p.resolve("bash", Some("ls")).unwrap().action,
PermAction::Deny
);
assert_eq!(
p.resolve("read", Some("x")).unwrap().action,
PermAction::Allow
);
}
#[test]
fn load_opencode_missing_files_is_empty() {
let dir = tempfile::tempdir().unwrap();
let p = load_opencode(dir.path(), None);
assert!(p.is_empty());
}
}