Skip to main content

sparrow/
permissions.rs

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}