1use std::path::{Path, PathBuf};
2
3use serde::{Deserialize, Serialize};
4
5use crate::ToolEffect;
6
7#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
8#[serde(rename_all = "lowercase")]
9pub enum PermissionMode {
10 Ask,
11 #[default]
12 Auto,
13 Yolo,
14}
15
16#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
17#[serde(rename_all = "lowercase")]
18pub enum PermissionAction {
19 Allow,
20 Ask,
21 Deny,
22}
23
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25pub struct PermissionRule {
26 pub permission: String,
27 pub pattern: String,
28 pub action: PermissionAction,
29}
30
31#[derive(Clone, Debug, Eq, PartialEq)]
32pub struct PermissionRequest {
33 pub permission: String,
34 pub target: String,
36 pub grant_target: String,
39 pub effect: ToolEffect,
40}
41
42impl PermissionRequest {
43 pub fn new(
44 permission: impl Into<String>,
45 target: impl Into<String>,
46 effect: ToolEffect,
47 ) -> Self {
48 let target = target.into();
49 Self {
50 permission: permission.into(),
51 grant_target: target.clone(),
52 target,
53 effect,
54 }
55 }
56
57 pub fn with_grant_target(mut self, target: impl Into<String>) -> Self {
58 self.grant_target = target.into();
59 self
60 }
61}
62
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum PermissionDecision {
65 Allow,
66 Ask,
67 AutoReview,
68 Deny,
69}
70
71#[derive(Clone, Debug, Default)]
73pub struct PermissionEngine {
74 mode: PermissionMode,
75 configured_rules: Vec<PermissionRule>,
76 session_grants: Vec<(String, String)>,
77}
78
79impl PermissionEngine {
80 pub fn new(mode: PermissionMode, configured_rules: Vec<PermissionRule>) -> Self {
81 Self {
82 mode,
83 configured_rules,
84 session_grants: Vec::new(),
85 }
86 }
87
88 pub fn mode(&self) -> PermissionMode {
89 self.mode
90 }
91
92 pub fn set_mode(&mut self, mode: PermissionMode) {
95 self.mode = mode;
96 }
97
98 pub fn rules(&self) -> &[PermissionRule] {
99 &self.configured_rules
100 }
101
102 pub fn grants(&self) -> &[(String, String)] {
103 &self.session_grants
104 }
105
106 pub fn grant_for_session(&mut self, permission: &str, target: &str) {
107 let entry = (permission.to_owned(), target.to_owned());
108 if !self.session_grants.contains(&entry) {
109 self.session_grants.push(entry);
110 }
111 }
112
113 pub fn grant_request_for_session(&mut self, request: &PermissionRequest) {
114 self.grant_for_session(&request.permission, &request.grant_target);
115 }
116
117 pub fn decide(&self, request: &PermissionRequest) -> PermissionDecision {
120 if self.mode == PermissionMode::Yolo {
121 return PermissionDecision::Allow;
122 }
123
124 let mut configured = None;
125 for rule in self
126 .configured_rules
127 .iter()
128 .filter(|rule| rule.permission == request.permission || rule.permission == "*")
129 .filter(|rule| pattern_matches(&rule.pattern, &request.target))
130 {
131 configured = Some(match rule.action {
132 PermissionAction::Allow => PermissionDecision::Allow,
133 PermissionAction::Ask => PermissionDecision::Ask,
134 PermissionAction::Deny => PermissionDecision::Deny,
135 });
136 }
137
138 if configured == Some(PermissionDecision::Deny) {
139 return PermissionDecision::Deny;
140 }
141
142 if self.session_grants.iter().any(|(permission, target)| {
143 permission == &request.permission && target == &request.grant_target
144 }) {
145 return PermissionDecision::Allow;
146 }
147
148 configured.unwrap_or(match self.mode {
149 PermissionMode::Ask => PermissionDecision::Ask,
150 PermissionMode::Auto => automatic_baseline(request.effect),
151 PermissionMode::Yolo => PermissionDecision::Allow,
152 })
153 }
154}
155
156fn automatic_baseline(effect: ToolEffect) -> PermissionDecision {
157 match effect {
158 ToolEffect::Read => PermissionDecision::Allow,
159 ToolEffect::Write
160 | ToolEffect::Process
161 | ToolEffect::Network
162 | ToolEffect::UserInteraction
163 | ToolEffect::Delegation => PermissionDecision::AutoReview,
164 }
165}
166
167fn pattern_matches(pattern: &str, target: &str) -> bool {
168 if pattern == "*" || pattern == target {
169 return true;
170 }
171 let Some(prefix) = pattern.strip_suffix("/**") else {
172 return false;
173 };
174 let prefix = Path::new(prefix);
175 PathBuf::from(target).starts_with(prefix)
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 fn request(effect: ToolEffect) -> PermissionRequest {
183 PermissionRequest::new("write_file", "/repo/src/lib.rs", effect)
184 }
185
186 #[test]
187 fn configured_deny_wins_over_session_grant() {
188 let mut engine = PermissionEngine::new(
189 PermissionMode::Auto,
190 vec![PermissionRule {
191 permission: "write_file".into(),
192 pattern: "/repo/**".into(),
193 action: PermissionAction::Deny,
194 }],
195 );
196 engine.grant_for_session("write_file", "/repo/src/lib.rs");
197
198 assert_eq!(
199 engine.decide(&request(ToolEffect::Write)),
200 PermissionDecision::Deny
201 );
202 }
203
204 #[test]
205 fn auto_allows_reads_and_reviews_mutations() {
206 let engine = PermissionEngine::new(PermissionMode::Auto, Vec::new());
207 assert_eq!(
208 engine.decide(&request(ToolEffect::Read)),
209 PermissionDecision::Allow
210 );
211 assert_eq!(
212 engine.decide(&request(ToolEffect::Write)),
213 PermissionDecision::AutoReview
214 );
215 }
216
217 #[test]
218 fn later_matching_configured_rule_wins_within_one_layer() {
219 let engine = PermissionEngine::new(
220 PermissionMode::Auto,
221 vec![
222 PermissionRule {
223 permission: "write_file".into(),
224 pattern: "/repo/**".into(),
225 action: PermissionAction::Deny,
226 },
227 PermissionRule {
228 permission: "write_file".into(),
229 pattern: "/repo/src/lib.rs".into(),
230 action: PermissionAction::Allow,
231 },
232 ],
233 );
234
235 assert_eq!(
236 engine.decide(&request(ToolEffect::Write)),
237 PermissionDecision::Allow
238 );
239 }
240
241 #[test]
242 fn configured_ask_stays_interactive_in_auto_mode() {
243 let engine = PermissionEngine::new(
244 PermissionMode::Auto,
245 vec![PermissionRule {
246 permission: "write_file".into(),
247 pattern: "/repo/**".into(),
248 action: PermissionAction::Ask,
249 }],
250 );
251 assert_eq!(
252 engine.decide(&request(ToolEffect::Write)),
253 PermissionDecision::Ask
254 );
255 }
256}