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::DangerFullAccess)
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 if current_mode == PermissionMode::Allow || current_mode >= required_mode {
98 return PermissionOutcome::Allow;
99 }
100
101 let request = PermissionRequest {
102 tool_name: tool_name.to_string(),
103 input: input.to_string(),
104 current_mode,
105 required_mode,
106 };
107
108 if current_mode == PermissionMode::Prompt
109 || (current_mode == PermissionMode::WorkspaceWrite
110 && required_mode == PermissionMode::DangerFullAccess)
111 {
112 return match prompter.as_mut() {
113 Some(prompter) => match prompter.decide(&request) {
114 PermissionPromptDecision::Allow => PermissionOutcome::Allow,
115 PermissionPromptDecision::Deny { reason } => PermissionOutcome::Deny { reason },
116 },
117 None => PermissionOutcome::Deny {
118 reason: format!(
119 "tool '{tool_name}' requires approval to escalate from {} to {}",
120 current_mode.as_str(),
121 required_mode.as_str()
122 ),
123 },
124 };
125 }
126
127 PermissionOutcome::Deny {
128 reason: format!(
129 "tool '{tool_name}' requires {} permission; current mode is {}",
130 required_mode.as_str(),
131 current_mode.as_str()
132 ),
133 }
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::{
140 PermissionMode, PermissionOutcome, PermissionPolicy, PermissionPromptDecision,
141 PermissionPrompter, PermissionRequest,
142 };
143
144 struct RecordingPrompter {
145 seen: Vec<PermissionRequest>,
146 allow: bool,
147 }
148
149 impl PermissionPrompter for RecordingPrompter {
150 fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
151 self.seen.push(request.clone());
152 if self.allow {
153 PermissionPromptDecision::Allow
154 } else {
155 PermissionPromptDecision::Deny {
156 reason: "not now".to_string(),
157 }
158 }
159 }
160 }
161
162 #[test]
163 fn allows_tools_when_active_mode_meets_requirement() {
164 let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
165 .with_tool_requirement("read_file", PermissionMode::ReadOnly)
166 .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite);
167
168 assert_eq!(
169 policy.authorize("read_file", "{}", None),
170 PermissionOutcome::Allow
171 );
172 assert_eq!(
173 policy.authorize("write_file", "{}", None),
174 PermissionOutcome::Allow
175 );
176 }
177
178 #[test]
179 fn denies_read_only_escalations_without_prompt() {
180 let policy = PermissionPolicy::new(PermissionMode::ReadOnly)
181 .with_tool_requirement("write_file", PermissionMode::WorkspaceWrite)
182 .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
183
184 assert!(matches!(
185 policy.authorize("write_file", "{}", None),
186 PermissionOutcome::Deny { reason } if reason.contains("requires workspace-write permission")
187 ));
188 assert!(matches!(
189 policy.authorize("bash", "{}", None),
190 PermissionOutcome::Deny { reason } if reason.contains("requires danger-full-access permission")
191 ));
192 }
193
194 #[test]
195 fn prompts_for_workspace_write_to_danger_full_access_escalation() {
196 let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
197 .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
198 let mut prompter = RecordingPrompter {
199 seen: Vec::new(),
200 allow: true,
201 };
202
203 let outcome = policy.authorize("bash", "echo hi", Some(&mut prompter));
204
205 assert_eq!(outcome, PermissionOutcome::Allow);
206 assert_eq!(prompter.seen.len(), 1);
207 assert_eq!(prompter.seen[0].tool_name, "bash");
208 assert_eq!(
209 prompter.seen[0].current_mode,
210 PermissionMode::WorkspaceWrite
211 );
212 assert_eq!(
213 prompter.seen[0].required_mode,
214 PermissionMode::DangerFullAccess
215 );
216 }
217
218 #[test]
219 fn honors_prompt_rejection_reason() {
220 let policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite)
221 .with_tool_requirement("bash", PermissionMode::DangerFullAccess);
222 let mut prompter = RecordingPrompter {
223 seen: Vec::new(),
224 allow: false,
225 };
226
227 assert!(matches!(
228 policy.authorize("bash", "echo hi", Some(&mut prompter)),
229 PermissionOutcome::Deny { reason } if reason == "not now"
230 ));
231 }
232}