Skip to main content

wraith_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::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}