Skip to main content

codineer_runtime/
permissions.rs

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}