1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4use crate::event::{AutonomyLevel, Decision, RiskLevel};
5
6#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
7#[serde(rename_all = "kebab-case")]
8pub enum PermissionMode {
9 ReadOnly,
10 Plan,
11 Supervised,
12 Trusted,
13 Autonomous,
14 EmergencyStop,
15}
16
17impl PermissionMode {
18 pub fn as_str(&self) -> &'static str {
19 match self {
20 PermissionMode::ReadOnly => "read-only",
21 PermissionMode::Plan => "plan",
22 PermissionMode::Supervised => "supervised",
23 PermissionMode::Trusted => "trusted",
24 PermissionMode::Autonomous => "autonomous",
25 PermissionMode::EmergencyStop => "emergency-stop",
26 }
27 }
28
29 pub fn parse(value: &str) -> Option<Self> {
30 match value.trim().to_lowercase().as_str() {
31 "read-only" | "readonly" | "read_only" => Some(Self::ReadOnly),
32 "plan" => Some(Self::Plan),
33 "supervised" => Some(Self::Supervised),
34 "trusted" => Some(Self::Trusted),
35 "autonomous" => Some(Self::Autonomous),
36 "emergency-stop" | "emergency" | "stop" | "kill" => Some(Self::EmergencyStop),
37 _ => None,
38 }
39 }
40
41 pub fn autonomy_level(&self) -> AutonomyLevel {
42 match self {
43 PermissionMode::Autonomous => AutonomyLevel::Autonomous,
44 PermissionMode::Trusted => AutonomyLevel::Trusted,
45 PermissionMode::ReadOnly
46 | PermissionMode::Plan
47 | PermissionMode::Supervised
48 | PermissionMode::EmergencyStop => AutonomyLevel::Supervised,
49 }
50 }
51}
52
53impl Default for PermissionMode {
54 fn default() -> Self {
55 Self::Supervised
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
60pub struct PermissionList {
61 #[serde(default)]
62 pub allow: Vec<String>,
63 #[serde(default)]
64 pub ask: Vec<String>,
65 #[serde(default)]
66 pub deny: Vec<String>,
67}
68
69impl Default for PermissionList {
70 fn default() -> Self {
71 Self {
72 allow: Vec::new(),
73 ask: Vec::new(),
74 deny: Vec::new(),
75 }
76 }
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct PathPermissions {
81 #[serde(default)]
82 pub allow: Vec<PathBuf>,
83 #[serde(default = "default_denied_paths")]
84 pub deny: Vec<PathBuf>,
85}
86
87impl Default for PathPermissions {
88 fn default() -> Self {
89 Self {
90 allow: Vec::new(),
91 deny: default_denied_paths(),
92 }
93 }
94}
95
96fn default_denied_paths() -> Vec<PathBuf> {
97 vec![
98 PathBuf::from(".git"),
99 PathBuf::from(".env"),
100 PathBuf::from(".env.local"),
101 PathBuf::from(".ssh"),
102 PathBuf::from("id_rsa"),
103 PathBuf::from("id_ed25519"),
104 ]
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct PermissionConfig {
109 #[serde(default)]
110 pub mode: PermissionMode,
111 #[serde(default)]
112 pub tools: PermissionList,
113 #[serde(default)]
114 pub paths: PathPermissions,
115 #[serde(default)]
116 pub providers: PermissionList,
117 #[serde(default)]
118 pub surfaces: PermissionList,
119}
120
121impl Default for PermissionConfig {
122 fn default() -> Self {
123 Self {
124 mode: PermissionMode::Supervised,
125 tools: PermissionList::default(),
126 paths: PathPermissions::default(),
127 providers: PermissionList::default(),
128 surfaces: PermissionList::default(),
129 }
130 }
131}
132
133#[derive(Debug, Clone)]
134pub struct PermissionContext<'a> {
135 pub tool_name: &'a str,
136 pub risk: RiskLevel,
137 pub args: &'a serde_json::Value,
138 pub workspace_root: &'a Path,
139 pub provider: Option<&'a str>,
140 pub surface: Option<&'a str>,
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
144pub struct PermissionVerdict {
145 pub decision: Decision,
146 pub reason: String,
147}
148
149impl PermissionConfig {
150 pub fn evaluate(&self, ctx: &PermissionContext<'_>) -> PermissionVerdict {
151 if matches!(self.mode, PermissionMode::EmergencyStop) {
152 return verdict(Decision::Deny, "emergency stop blocks every action");
153 }
154
155 if matches!(self.mode, PermissionMode::Plan) {
156 return verdict(
157 Decision::Deny,
158 "plan mode is read-only and executes no tools",
159 );
160 }
161
162 if matches!(self.mode, PermissionMode::ReadOnly) && ctx.risk != RiskLevel::ReadOnly {
163 return verdict(
164 Decision::Deny,
165 "read-only permission mode blocks mutating, exec, network, and destructive tools",
166 );
167 }
168
169 if matches_pattern(&self.tools.deny, ctx.tool_name) {
170 return verdict(
171 Decision::Deny,
172 format!("tool '{}' is denied by permissions", ctx.tool_name),
173 );
174 }
175 if matches_pattern(&self.tools.ask, ctx.tool_name) {
176 return verdict(
177 Decision::AskUser,
178 format!("tool '{}' requires approval by permissions", ctx.tool_name),
179 );
180 }
181 if matches_pattern(&self.tools.allow, ctx.tool_name) {
182 return verdict(
183 Decision::Allow,
184 format!(
185 "tool '{}' is explicitly allowed by permissions",
186 ctx.tool_name
187 ),
188 );
189 }
190
191 if let Some(provider) = ctx.provider {
192 if matches_pattern(&self.providers.deny, provider) {
193 return verdict(
194 Decision::Deny,
195 format!("provider '{}' is denied by permissions", provider),
196 );
197 }
198 if matches_pattern(&self.providers.ask, provider) {
199 return verdict(
200 Decision::AskUser,
201 format!("provider '{}' requires approval by permissions", provider),
202 );
203 }
204 }
205
206 if let Some(surface) = ctx.surface {
207 if matches_pattern(&self.surfaces.deny, surface) {
208 return verdict(
209 Decision::Deny,
210 format!("surface '{}' is denied by permissions", surface),
211 );
212 }
213 if matches_pattern(&self.surfaces.ask, surface) {
214 return verdict(
215 Decision::AskUser,
216 format!("surface '{}' requires approval by permissions", surface),
217 );
218 }
219 }
220
221 for path in paths_from_args(ctx.args) {
222 let absolute = resolve_path(ctx.workspace_root, &path);
223 if self
224 .paths
225 .deny
226 .iter()
227 .any(|rule| path_matches(ctx.workspace_root, rule, &absolute))
228 {
229 return verdict(
230 Decision::Deny,
231 format!("path '{}' is denied by permissions", path.display()),
232 );
233 }
234 }
235
236 verdict(Decision::Allow, "permissions allow autonomy gate to decide")
237 }
238}
239
240fn verdict(decision: Decision, reason: impl Into<String>) -> PermissionVerdict {
241 PermissionVerdict {
242 decision,
243 reason: reason.into(),
244 }
245}
246
247fn matches_pattern(patterns: &[String], value: &str) -> bool {
248 patterns.iter().any(|pattern| {
249 let pattern = pattern.trim();
250 pattern == "*"
251 || pattern.eq_ignore_ascii_case(value)
252 || value
253 .to_lowercase()
254 .contains(pattern.trim_matches('*').to_lowercase().as_str())
255 })
256}
257
258fn paths_from_args(args: &serde_json::Value) -> Vec<PathBuf> {
259 let mut paths = Vec::new();
260 collect_paths(args, &mut paths);
261 paths
262}
263
264fn collect_paths(value: &serde_json::Value, paths: &mut Vec<PathBuf>) {
265 match value {
266 serde_json::Value::Object(map) => {
267 for (key, value) in map {
268 let key = key.to_lowercase();
269 let pathish = matches!(
270 key.as_str(),
271 "path" | "file" | "filename" | "target" | "source" | "dest" | "destination"
272 ) || key.ends_with("_path")
273 || key.ends_with("_file");
274 if pathish {
275 if let Some(text) = value.as_str() {
276 paths.push(PathBuf::from(text));
277 }
278 }
279 collect_paths(value, paths);
280 }
281 }
282 serde_json::Value::Array(items) => {
283 for item in items {
284 collect_paths(item, paths);
285 }
286 }
287 _ => {}
288 }
289}
290
291fn resolve_path(root: &Path, path: &Path) -> PathBuf {
292 if path.is_absolute() {
293 path.to_path_buf()
294 } else {
295 root.join(path)
296 }
297}
298
299fn path_matches(root: &Path, rule: &Path, candidate: &Path) -> bool {
300 let rule = resolve_path(root, rule);
301 let rule_text = normalize_path(&rule);
302 let candidate_text = normalize_path(candidate);
303 candidate_text == rule_text || candidate_text.starts_with(&(rule_text + "/"))
304}
305
306fn normalize_path(path: &Path) -> String {
307 path.components()
308 .map(|c| c.as_os_str().to_string_lossy().replace('\\', "/"))
309 .collect::<Vec<_>>()
310 .join("/")
311 .to_lowercase()
312}
313
314#[cfg(test)]
315mod tests {
316 use super::*;
317
318 #[test]
319 fn read_only_blocks_mutating_tools() {
320 let cfg = PermissionConfig {
321 mode: PermissionMode::ReadOnly,
322 ..PermissionConfig::default()
323 };
324 let verdict = cfg.evaluate(&PermissionContext {
325 tool_name: "edit",
326 risk: RiskLevel::Mutating,
327 args: &serde_json::json!({"path":"src/main.rs"}),
328 workspace_root: Path::new("C:/Sparrow"),
329 provider: None,
330 surface: Some("cli"),
331 });
332 assert_eq!(verdict.decision, Decision::Deny);
333 }
334
335 #[test]
336 fn denied_sensitive_paths_win() {
337 let cfg = PermissionConfig::default();
338 let verdict = cfg.evaluate(&PermissionContext {
339 tool_name: "fs_write",
340 risk: RiskLevel::Mutating,
341 args: &serde_json::json!({"path":".git/config"}),
342 workspace_root: Path::new("C:/Sparrow"),
343 provider: None,
344 surface: None,
345 });
346 assert_eq!(verdict.decision, Decision::Deny);
347 }
348
349 #[test]
350 fn ask_tool_requires_user() {
351 let mut cfg = PermissionConfig::default();
352 cfg.tools.ask.push("exec".into());
353 let verdict = cfg.evaluate(&PermissionContext {
354 tool_name: "exec",
355 risk: RiskLevel::Exec,
356 args: &serde_json::json!({"cmd":"cargo test"}),
357 workspace_root: Path::new("C:/Sparrow"),
358 provider: None,
359 surface: None,
360 });
361 assert_eq!(verdict.decision, Decision::AskUser);
362 }
363}