Skip to main content

sparrow_config/permissions/
mod.rs

1use serde::{Deserialize, Serialize};
2use std::path::{Path, PathBuf};
3
4use sparrow_core::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
256pub fn effective_risk_for_tool(
257    tool_name: &str,
258    base: RiskLevel,
259    args: &serde_json::Value,
260) -> RiskLevel {
261    if tool_name == "exec" {
262        if let Some(command) = args.get("command").and_then(|value| value.as_str()) {
263            if is_forced_destructive_command(command) {
264                return RiskLevel::Destructive;
265            }
266        }
267    }
268    base
269}
270
271pub fn is_forced_destructive_command(command: &str) -> bool {
272    let lower = command.to_ascii_lowercase();
273    let compact = lower.replace(['"', '\''], "");
274    let patterns = [
275        "rm -rf",
276        "rm -fr",
277        "del /s",
278        "rmdir /s",
279        "rd /s",
280        "git clean -fdx",
281        "git clean -xdf",
282        "drop table",
283    ];
284    patterns.iter().any(|pattern| compact.contains(pattern))
285}
286
287fn verdict(decision: Decision, reason: impl Into<String>) -> PermissionVerdict {
288    PermissionVerdict {
289        decision,
290        reason: reason.into(),
291    }
292}
293
294fn matches_pattern(patterns: &[String], value: &str) -> bool {
295    patterns.iter().any(|pattern| {
296        let pattern = pattern.trim();
297        pattern == "*"
298            || pattern.eq_ignore_ascii_case(value)
299            || value
300                .to_lowercase()
301                .contains(pattern.trim_matches('*').to_lowercase().as_str())
302    })
303}
304
305fn paths_from_args(args: &serde_json::Value) -> Vec<PathBuf> {
306    let mut paths = Vec::new();
307    collect_paths(args, &mut paths);
308    paths
309}
310
311fn collect_paths(value: &serde_json::Value, paths: &mut Vec<PathBuf>) {
312    match value {
313        serde_json::Value::Object(map) => {
314            for (key, value) in map {
315                let key = key.to_lowercase();
316                let pathish = matches!(
317                    key.as_str(),
318                    "path" | "file" | "filename" | "target" | "source" | "dest" | "destination"
319                ) || key.ends_with("_path")
320                    || key.ends_with("_file");
321                if pathish {
322                    if let Some(text) = value.as_str() {
323                        paths.push(PathBuf::from(text));
324                    }
325                }
326                collect_paths(value, paths);
327            }
328        }
329        serde_json::Value::Array(items) => {
330            for item in items {
331                collect_paths(item, paths);
332            }
333        }
334        _ => {}
335    }
336}
337
338fn resolve_path(root: &Path, path: &Path) -> PathBuf {
339    if path.is_absolute() {
340        path.to_path_buf()
341    } else {
342        root.join(path)
343    }
344}
345
346fn path_matches(root: &Path, rule: &Path, candidate: &Path) -> bool {
347    let rule = resolve_path(root, rule);
348    let rule_text = normalize_path(&rule);
349    let candidate_text = normalize_path(candidate);
350    candidate_text == rule_text || candidate_text.starts_with(&(rule_text + "/"))
351}
352
353fn normalize_path(path: &Path) -> String {
354    path.components()
355        .map(|c| c.as_os_str().to_string_lossy().replace('\\', "/"))
356        .collect::<Vec<_>>()
357        .join("/")
358        .to_lowercase()
359}
360
361#[cfg(test)]
362mod tests {
363    use super::*;
364
365    #[test]
366    fn read_only_blocks_mutating_tools() {
367        let cfg = PermissionConfig {
368            mode: PermissionMode::ReadOnly,
369            ..PermissionConfig::default()
370        };
371        let verdict = cfg.evaluate(&PermissionContext {
372            tool_name: "edit",
373            risk: RiskLevel::Mutating,
374            args: &serde_json::json!({"path":"src/main.rs"}),
375            workspace_root: Path::new("/home/dev/project"),
376            provider: None,
377            surface: Some("cli"),
378        });
379        assert_eq!(verdict.decision, Decision::Deny);
380    }
381
382    #[test]
383    fn denied_sensitive_paths_win() {
384        let cfg = PermissionConfig::default();
385        let verdict = cfg.evaluate(&PermissionContext {
386            tool_name: "fs_write",
387            risk: RiskLevel::Mutating,
388            args: &serde_json::json!({"path":".git/config"}),
389            workspace_root: Path::new("/home/dev/project"),
390            provider: None,
391            surface: None,
392        });
393        assert_eq!(verdict.decision, Decision::Deny);
394    }
395
396    #[test]
397    fn ask_tool_requires_user() {
398        let mut cfg = PermissionConfig::default();
399        cfg.tools.ask.push("exec".into());
400        let verdict = cfg.evaluate(&PermissionContext {
401            tool_name: "exec",
402            risk: RiskLevel::Exec,
403            args: &serde_json::json!({"cmd":"cargo test"}),
404            workspace_root: Path::new("/home/dev/project"),
405            provider: None,
406            surface: None,
407        });
408        assert_eq!(verdict.decision, Decision::AskUser);
409    }
410
411    #[test]
412    fn exec_dangerous_command_escalates_to_destructive() {
413        let risk = effective_risk_for_tool(
414            "exec",
415            RiskLevel::Exec,
416            &serde_json::json!({"command":"git clean -fdx"}),
417        );
418        assert_eq!(risk, RiskLevel::Destructive);
419        assert!(is_forced_destructive_command("DROP TABLE users"));
420        assert!(is_forced_destructive_command("rmdir /s target"));
421        assert!(!is_forced_destructive_command("cargo test --all-targets"));
422    }
423}
424pub mod store;