1use std::collections::BTreeMap;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
4pub enum PermissionMode {
5 ReadOnly,
6 WorkspaceWrite,
7 DangerFullAccess,
8 Prompt,
9 Allow,
10}
11
12impl PermissionMode {
13 #[must_use]
14 pub fn as_str(self) -> &'static str {
15 match self {
16 Self::ReadOnly => "read-only",
17 Self::WorkspaceWrite => "workspace-write",
18 Self::DangerFullAccess => "danger-full-access",
19 Self::Prompt => "prompt",
20 Self::Allow => "allow",
21 }
22 }
23}
24
25#[derive(Debug, Clone, PartialEq, Eq)]
26pub struct PermissionRequest {
27 pub tool_name: String,
28 pub input: String,
29 pub current_mode: PermissionMode,
30 pub required_mode: PermissionMode,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub enum PermissionPromptDecision {
35 Allow,
36 Deny { reason: String },
37}
38
39pub trait PermissionPrompter {
40 fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision;
41}
42
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub enum PermissionOutcome {
45 Allow,
46 Deny { reason: String },
47}
48
49#[derive(Debug, Clone, PartialEq, Eq)]
50pub struct PermissionPolicy {
51 active_mode: PermissionMode,
52 tool_requirements: BTreeMap<String, PermissionMode>,
53}
54
55impl PermissionPolicy {
56 #[must_use]
57 pub fn new(active_mode: PermissionMode) -> Self {
58 Self {
59 active_mode,
60 tool_requirements: BTreeMap::new(),
61 }
62 }
63
64 #[must_use]
65 pub fn with_tool_requirement(
66 mut self,
67 tool_name: impl Into<String>,
68 required_mode: PermissionMode,
69 ) -> Self {
70 self.tool_requirements
71 .insert(tool_name.into(), required_mode);
72 self
73 }
74
75 #[must_use]
76 pub fn active_mode(&self) -> PermissionMode {
77 self.active_mode
78 }
79
80 #[must_use]
81 pub fn required_mode_for(&self, tool_name: &str) -> PermissionMode {
82 self.tool_requirements
83 .get(tool_name)
84 .copied()
85 .unwrap_or(PermissionMode::Prompt)
86 }
87
88 #[must_use]
89 pub fn authorize(
90 &self,
91 tool_name: &str,
92 input: &str,
93 mut prompter: Option<&mut dyn PermissionPrompter>,
94 ) -> PermissionOutcome {
95 let current_mode = self.active_mode();
96 let required_mode = self.required_mode_for(tool_name);
97
98 if current_mode == PermissionMode::Allow {
99 return PermissionOutcome::Allow;
100 }
101
102 if current_mode == PermissionMode::Prompt {
103 let request = PermissionRequest {
104 tool_name: tool_name.to_string(),
105 input: input.to_string(),
106 current_mode,
107 required_mode,
108 };
109 return match prompter.as_mut() {
110 Some(prompter) => match prompter.decide(&request) {
111 PermissionPromptDecision::Allow => PermissionOutcome::Allow,
112 PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
113 },
114 None => PermissionOutcome::Deny {
115 reason: format!(
116 "tool '{tool_name}' requires approval to escalate from {} to {}",
117 current_mode.as_str(),
118 required_mode.as_str()
119 ),
120 },
121 };
122 }
123
124 if current_mode >= required_mode {
125 return PermissionOutcome::Allow;
126 }
127
128 let needs_prompt = required_mode == PermissionMode::Prompt
129 || (current_mode == PermissionMode::WorkspaceWrite
130 && required_mode == PermissionMode::DangerFullAccess);
131
132 let request = PermissionRequest {
133 tool_name: tool_name.to_string(),
134 input: input.to_string(),
135 current_mode,
136 required_mode,
137 };
138
139 if needs_prompt {
140 return match prompter.as_mut() {
141 Some(prompter) => match prompter.decide(&request) {
142 PermissionPromptDecision::Allow => PermissionOutcome::Allow,
143 PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
144 },
145 None => PermissionOutcome::Deny {
146 reason: format!(
147 "tool '{tool_name}' requires approval to escalate from {} to {}",
148 current_mode.as_str(),
149 required_mode.as_str()
150 ),
151 },
152 };
153 }
154
155 PermissionOutcome::Deny {
156 reason: format!(
157 "tool '{tool_name}' requires {} permission; current mode is {}",
158 required_mode.as_str(),
159 current_mode.as_str()
160 ),
161 }
162 }
163}
164
165#[cfg(test)]
166mod tests {
167 use super::{
168 PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
169 PermissionPrompter, PermissionRequest,
170 };
171
172 struct RecordingPrompter {
173 seen: Vec<PermissionRequest>,
174 allow: bool,
175 }
176
177 impl PermissionPrompter for RecordingPrompter {
178 fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
179 self.seen.push(request.clone());
180 if self.allow {
181 PermissionPromptDecision::Allow
182 } else {
183 PermissionPromptDecision::Deny {
184 reason: "not now".to_string(),
185 }
186 }
187 }
188 }
189
190 #[test]
191 fn allows_tools_when_active_mode_meets_requirement() {
192 let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
193 .with_tool_requirement("read_file", PermissionMode::ReadOnly)
194 .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
195
196 assert_eq!(
197 policy.authorize("read_file", "{}", None),
198 PermissionOutcome::Allow
199 );
200 assert_eq!(
201 policy.authorize("write_file", "{}", None),
202 PermissionOutcome::Allow
203 );
204 }
205
206 #[test]
207 fn denies_read_only_escalations_without_prompt() {
208 let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
209 .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite)
210 .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
211
212 assert!(matches!(
213 policy.authorize("write_file", "{}", None),
214 PermissionOutcome::Deny { reason } if reason.contains("requires workspace-write permission")
215 ));
216 assert!(matches!(
217 policy.authorize("bash", "{}", None),
218 PermissionOutcome::Deny { reason } if reason.contains("requires danger-full-access permission")
219 ));
220 }
221
222 #[test]
223 fn prompts_for_workspace_write_to_danger_full_access_escalation() {
224 let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
225 .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
226 let mut prompter = RecordingPrompter {
227 seen: Vec::new(),
228 allow: true,
229 };
230
231 let outcome = policy.authorize("bash", "echo hi", Some(&mut prompter));
232
233 assert_eq!(outcome, PermissionOutcome::Allow);
234 assert_eq!(prompter.seen.len(), 1);
235 assert_eq!(prompter.seen[0].tool_name, "bash");
236 assert_eq!(
237 prompter.seen[0].current_mode,
238 PermissionMode::WorkspaceWrite
239 );
240 assert_eq!(
241 prompter.seen[0].required_mode,
242 PermissionMode::DangerFullAccess
243 );
244 }
245
246 #[test]
247 fn honors_prompt_rejection_reason() {
248 let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
249 .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
250 let mut prompter = RecordingPrompter {
251 seen: Vec::new(),
252 allow: false,
253 };
254
255 assert!(matches!(
256 policy.authorize("bash", "echo hi", Some(&mut prompter)),
257 PermissionOutcome::Deny { reason } if reason == "not now"
258 ));
259 }
260
261 #[test]
262 fn prompt_mode_always_prompts_for_dangerous_tools() {
263 let policy = PermissionPolicy::new(PermissionMode::Prompt)
264 .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
265 let mut prompter = RecordingPrompter {
266 seen: Vec::new(),
267 allow: true,
268 };
269
270 let outcome = policy.authorize("bash", "rm -rf /", Some(&mut prompter));
271 assert_eq!(outcome, PermissionOutcome::Allow);
272 assert_eq!(
273 prompter.seen.len(),
274 1,
275 "Prompt mode must invoke the prompter"
276 );
277 }
278
279 #[test]
280 fn prompt_mode_prompts_for_read_only_tools_too() {
281 let policy = PermissionPolicy::new(PermissionMode::Prompt)
282 .with_tool_requirement("read_file", PermissionMode::ReadOnly);
283 let mut prompter = RecordingPrompter {
284 seen: Vec::new(),
285 allow: true,
286 };
287
288 let outcome = policy.authorize("read_file", "{}", Some(&mut prompter));
289 assert_eq!(outcome, PermissionOutcome::Allow);
290 assert_eq!(
291 prompter.seen.len(),
292 1,
293 "Prompt mode should prompt even for read-only tools"
294 );
295 }
296
297 #[test]
298 fn prompt_mode_denies_without_prompter() {
299 let policy = PermissionPolicy::new(PermissionMode::Prompt)
300 .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
301
302 assert!(matches!(
303 policy.authorize("bash", "echo hi", None),
304 PermissionOutcome::Deny { reason } if reason.contains("requires approval")
305 ));
306 }
307
308 #[test]
309 fn read_only_denies_write_tools() {
310 let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
311 .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
312
313 assert!(matches!(
314 policy.authorize("write_file", "{}", None),
315 PermissionOutcome::Deny { .. }
316 ));
317 }
318}