agent_code_lib/permissions/
mod.rs1pub mod tracking;
14
15use crate::config::{PermissionMode, PermissionRule, PermissionsConfig};
16
17#[derive(Debug, Clone)]
19pub enum PermissionDecision {
20 Allow,
22 Deny(String),
24 Ask(String),
26}
27
28pub struct PermissionChecker {
30 default_mode: PermissionMode,
31 rules: Vec<PermissionRule>,
32}
33
34impl PermissionChecker {
35 pub fn from_config(config: &PermissionsConfig) -> Self {
37 Self {
38 default_mode: config.default_mode,
39 rules: config.rules.clone(),
40 }
41 }
42
43 pub fn allow_all() -> Self {
45 Self {
46 default_mode: PermissionMode::Allow,
47 rules: Vec::new(),
48 }
49 }
50
51 pub fn check(&self, tool_name: &str, input: &serde_json::Value) -> PermissionDecision {
56 for rule in &self.rules {
58 if !matches_tool(&rule.tool, tool_name) {
59 continue;
60 }
61
62 if let Some(ref pattern) = rule.pattern
63 && !matches_input_pattern(pattern, input)
64 {
65 continue;
66 }
67
68 return mode_to_decision(rule.action, tool_name);
69 }
70
71 mode_to_decision(self.default_mode, tool_name)
73 }
74
75 pub fn check_read(&self, tool_name: &str, input: &serde_json::Value) -> PermissionDecision {
77 for rule in &self.rules {
79 if !matches_tool(&rule.tool, tool_name) {
80 continue;
81 }
82 if let Some(ref pattern) = rule.pattern
83 && !matches_input_pattern(pattern, input)
84 {
85 continue;
86 }
87 if matches!(rule.action, PermissionMode::Deny) {
88 return PermissionDecision::Deny(format!("Denied by rule for {tool_name}"));
89 }
90 }
91 PermissionDecision::Allow
92 }
93}
94
95fn matches_tool(rule_tool: &str, tool_name: &str) -> bool {
96 rule_tool == "*" || rule_tool.eq_ignore_ascii_case(tool_name)
97}
98
99fn matches_input_pattern(pattern: &str, input: &serde_json::Value) -> bool {
100 let input_str = input
102 .get("command")
103 .or_else(|| input.get("file_path"))
104 .or_else(|| input.get("pattern"))
105 .and_then(|v| v.as_str())
106 .unwrap_or("");
107
108 glob_match(pattern, input_str)
109}
110
111fn glob_match(pattern: &str, text: &str) -> bool {
113 let pattern_chars: Vec<char> = pattern.chars().collect();
114 let text_chars: Vec<char> = text.chars().collect();
115 glob_match_inner(&pattern_chars, &text_chars)
116}
117
118fn glob_match_inner(pattern: &[char], text: &[char]) -> bool {
119 match (pattern.first(), text.first()) {
120 (None, None) => true,
121 (Some('*'), _) => {
122 glob_match_inner(&pattern[1..], text)
124 || (!text.is_empty() && glob_match_inner(pattern, &text[1..]))
125 }
126 (Some('?'), Some(_)) => glob_match_inner(&pattern[1..], &text[1..]),
127 (Some(p), Some(t)) if p == t => glob_match_inner(&pattern[1..], &text[1..]),
128 _ => false,
129 }
130}
131
132fn mode_to_decision(mode: PermissionMode, tool_name: &str) -> PermissionDecision {
133 match mode {
134 PermissionMode::Allow | PermissionMode::AcceptEdits => PermissionDecision::Allow,
135 PermissionMode::Deny => {
136 PermissionDecision::Deny(format!("Default mode denies {tool_name}"))
137 }
138 PermissionMode::Ask => PermissionDecision::Ask(format!("Allow {tool_name} to execute?")),
139 PermissionMode::Plan => {
140 PermissionDecision::Deny("Plan mode: only read-only operations allowed".into())
141 }
142 }
143}
144
145#[cfg(test)]
146mod tests {
147 use super::*;
148
149 #[test]
150 fn test_glob_match() {
151 assert!(glob_match("git *", "git status"));
152 assert!(glob_match("git *", "git push --force"));
153 assert!(!glob_match("git *", "rm -rf /"));
154 assert!(glob_match("*.rs", "main.rs"));
155 assert!(glob_match("*", "anything"));
156 assert!(glob_match("??", "ab"));
157 assert!(!glob_match("??", "abc"));
158 }
159
160 #[test]
161 fn test_allow_all() {
162 let checker = PermissionChecker::allow_all();
163 assert!(matches!(
164 checker.check("Bash", &serde_json::json!({"command": "ls"})),
165 PermissionDecision::Allow
166 ));
167 }
168
169 #[test]
170 fn test_rule_matching() {
171 let checker = PermissionChecker::from_config(&PermissionsConfig {
172 default_mode: PermissionMode::Ask,
173 rules: vec![
174 PermissionRule {
175 tool: "Bash".into(),
176 pattern: Some("git *".into()),
177 action: PermissionMode::Allow,
178 },
179 PermissionRule {
180 tool: "Bash".into(),
181 pattern: Some("rm *".into()),
182 action: PermissionMode::Deny,
183 },
184 ],
185 });
186
187 assert!(matches!(
188 checker.check("Bash", &serde_json::json!({"command": "git status"})),
189 PermissionDecision::Allow
190 ));
191 assert!(matches!(
192 checker.check("Bash", &serde_json::json!({"command": "rm -rf /"})),
193 PermissionDecision::Deny(_)
194 ));
195 assert!(matches!(
196 checker.check("Bash", &serde_json::json!({"command": "ls"})),
197 PermissionDecision::Ask(_)
198 ));
199 }
200}