Skip to main content

sparrow/permissions/
mod.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    /// Per-tool persisted decisions (loaded from permissions.json).
120    /// Not serialized in config.toml — stored separately.
121    #[serde(skip)]
122    pub store: crate::permissions::store::PermissionStore,
123}
124
125impl Default for PermissionConfig {
126    fn default() -> Self {
127        Self {
128            mode: PermissionMode::Supervised,
129            tools: PermissionList::default(),
130            paths: PathPermissions::default(),
131            providers: PermissionList::default(),
132            surfaces: PermissionList::default(),
133            store: store::PermissionStore::default(),
134        }
135    }
136}
137
138#[derive(Debug, Clone)]
139pub struct PermissionContext<'a> {
140    pub tool_name: &'a str,
141    pub risk: RiskLevel,
142    pub args: &'a serde_json::Value,
143    pub workspace_root: &'a Path,
144    pub provider: Option<&'a str>,
145    pub surface: Option<&'a str>,
146}
147
148#[derive(Debug, Clone, PartialEq, Eq)]
149pub struct PermissionVerdict {
150    pub decision: Decision,
151    pub reason: String,
152}
153
154impl PermissionConfig {
155    pub fn evaluate(&self, ctx: &PermissionContext<'_>) -> PermissionVerdict {
156        // ── Check persisted per-tool decisions first ──────────────────────
157        // Durable decisions (AllowAlways, Deny) survive across sessions.
158        if let Some(pd) = self.store.get(ctx.tool_name) {
159            if pd.is_durable() {
160                return verdict(
161                    pd.to_decision(),
162                    format!("tool '{}' has persisted decision: {:?}", ctx.tool_name, pd),
163                );
164            }
165        }
166
167        if matches!(self.mode, PermissionMode::EmergencyStop) {
168            return verdict(Decision::Deny, "emergency stop blocks every action");
169        }
170
171        if matches!(self.mode, PermissionMode::Plan) {
172            return verdict(
173                Decision::Deny,
174                "plan mode is read-only and executes no tools",
175            );
176        }
177
178        if matches!(self.mode, PermissionMode::ReadOnly) && ctx.risk != RiskLevel::ReadOnly {
179            return verdict(
180                Decision::Deny,
181                "read-only permission mode blocks mutating, exec, network, and destructive tools",
182            );
183        }
184
185        if matches_pattern(&self.tools.deny, ctx.tool_name) {
186            return verdict(
187                Decision::Deny,
188                format!("tool '{}' is denied by permissions", ctx.tool_name),
189            );
190        }
191        if matches_pattern(&self.tools.ask, ctx.tool_name) {
192            return verdict(
193                Decision::AskUser,
194                format!("tool '{}' requires approval by permissions", ctx.tool_name),
195            );
196        }
197        if matches_pattern(&self.tools.allow, ctx.tool_name) {
198            return verdict(
199                Decision::Allow,
200                format!(
201                    "tool '{}' is explicitly allowed by permissions",
202                    ctx.tool_name
203                ),
204            );
205        }
206
207        if let Some(provider) = ctx.provider {
208            if matches_pattern(&self.providers.deny, provider) {
209                return verdict(
210                    Decision::Deny,
211                    format!("provider '{}' is denied by permissions", provider),
212                );
213            }
214            if matches_pattern(&self.providers.ask, provider) {
215                return verdict(
216                    Decision::AskUser,
217                    format!("provider '{}' requires approval by permissions", provider),
218                );
219            }
220        }
221
222        if let Some(surface) = ctx.surface {
223            if matches_pattern(&self.surfaces.deny, surface) {
224                return verdict(
225                    Decision::Deny,
226                    format!("surface '{}' is denied by permissions", surface),
227                );
228            }
229            if matches_pattern(&self.surfaces.ask, surface) {
230                return verdict(
231                    Decision::AskUser,
232                    format!("surface '{}' requires approval by permissions", surface),
233                );
234            }
235        }
236
237        for path in paths_from_args(ctx.args) {
238            let absolute = resolve_path(ctx.workspace_root, &path);
239            if self
240                .paths
241                .deny
242                .iter()
243                .any(|rule| path_matches(ctx.workspace_root, rule, &absolute))
244            {
245                return verdict(
246                    Decision::Deny,
247                    format!("path '{}' is denied by permissions", path.display()),
248                );
249            }
250        }
251
252        verdict(Decision::Allow, "permissions allow autonomy gate to decide")
253    }
254}
255
256fn verdict(decision: Decision, reason: impl Into<String>) -> PermissionVerdict {
257    PermissionVerdict {
258        decision,
259        reason: reason.into(),
260    }
261}
262
263fn matches_pattern(patterns: &[String], value: &str) -> bool {
264    patterns.iter().any(|pattern| {
265        let pattern = pattern.trim();
266        pattern == "*"
267            || pattern.eq_ignore_ascii_case(value)
268            || value
269                .to_lowercase()
270                .contains(pattern.trim_matches('*').to_lowercase().as_str())
271    })
272}
273
274fn paths_from_args(args: &serde_json::Value) -> Vec<PathBuf> {
275    let mut paths = Vec::new();
276    collect_paths(args, &mut paths);
277    paths
278}
279
280fn collect_paths(value: &serde_json::Value, paths: &mut Vec<PathBuf>) {
281    match value {
282        serde_json::Value::Object(map) => {
283            for (key, value) in map {
284                let key = key.to_lowercase();
285                let pathish = matches!(
286                    key.as_str(),
287                    "path" | "file" | "filename" | "target" | "source" | "dest" | "destination"
288                ) || key.ends_with("_path")
289                    || key.ends_with("_file");
290                if pathish {
291                    if let Some(text) = value.as_str() {
292                        paths.push(PathBuf::from(text));
293                    }
294                }
295                collect_paths(value, paths);
296            }
297        }
298        serde_json::Value::Array(items) => {
299            for item in items {
300                collect_paths(item, paths);
301            }
302        }
303        _ => {}
304    }
305}
306
307fn resolve_path(root: &Path, path: &Path) -> PathBuf {
308    if path.is_absolute() {
309        path.to_path_buf()
310    } else {
311        root.join(path)
312    }
313}
314
315fn path_matches(root: &Path, rule: &Path, candidate: &Path) -> bool {
316    let rule = resolve_path(root, rule);
317    let rule_text = normalize_path(&rule);
318    let candidate_text = normalize_path(candidate);
319    candidate_text == rule_text || candidate_text.starts_with(&(rule_text + "/"))
320}
321
322fn normalize_path(path: &Path) -> String {
323    path.components()
324        .map(|c| c.as_os_str().to_string_lossy().replace('\\', "/"))
325        .collect::<Vec<_>>()
326        .join("/")
327        .to_lowercase()
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn read_only_blocks_mutating_tools() {
336        let cfg = PermissionConfig {
337            mode: PermissionMode::ReadOnly,
338            ..PermissionConfig::default()
339        };
340        let verdict = cfg.evaluate(&PermissionContext {
341            tool_name: "edit",
342            risk: RiskLevel::Mutating,
343            args: &serde_json::json!({"path":"src/main.rs"}),
344            workspace_root: Path::new("/home/dev/project"),
345            provider: None,
346            surface: Some("cli"),
347        });
348        assert_eq!(verdict.decision, Decision::Deny);
349    }
350
351    #[test]
352    fn denied_sensitive_paths_win() {
353        let cfg = PermissionConfig::default();
354        let verdict = cfg.evaluate(&PermissionContext {
355            tool_name: "fs_write",
356            risk: RiskLevel::Mutating,
357            args: &serde_json::json!({"path":".git/config"}),
358            workspace_root: Path::new("/home/dev/project"),
359            provider: None,
360            surface: None,
361        });
362        assert_eq!(verdict.decision, Decision::Deny);
363    }
364
365    #[test]
366    fn ask_tool_requires_user() {
367        let mut cfg = PermissionConfig::default();
368        cfg.tools.ask.push("exec".into());
369        let verdict = cfg.evaluate(&PermissionContext {
370            tool_name: "exec",
371            risk: RiskLevel::Exec,
372            args: &serde_json::json!({"cmd":"cargo test"}),
373            workspace_root: Path::new("/home/dev/project"),
374            provider: None,
375            surface: None,
376        });
377        assert_eq!(verdict.decision, Decision::AskUser);
378    }
379}
380pub mod store;