Skip to main content

opendev_runtime/permissions/
mod.rs

1//! Fine-grained permission rule set with glob-based matching and directory scoping.
2//!
3//! Provides [`PermissionRuleSet`] for ordered, priority-based permission evaluation,
4//! with optional per-directory scoping via glob patterns.
5
6mod glob;
7
8pub use glob::{glob_matches, glob_matches_path};
9
10use serde::{Deserialize, Serialize};
11use std::path::Path;
12
13/// Action to take when a permission rule matches.
14#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum PermissionAction {
17    /// Silently allow the operation.
18    Allow,
19    /// Silently deny the operation.
20    Deny,
21    /// Prompt the user for confirmation.
22    Prompt,
23}
24
25/// A single permission rule with glob pattern matching and optional directory scope.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct PermissionRule {
28    /// Glob pattern matched against `"tool_name:args"` (e.g. `"bash:rm *"`, `"edit:*"`).
29    pub pattern: String,
30    /// What to do when the pattern matches.
31    pub action: PermissionAction,
32    /// Higher-priority rules are evaluated first.
33    pub priority: i32,
34    /// Optional glob restricting the rule to operations within matching directories.
35    /// Example: `Some("src/**")` only applies when the working directory is under `src/`.
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub directory_scope: Option<String>,
38}
39
40/// An ordered collection of permission rules evaluated highest-priority-first.
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
42pub struct PermissionRuleSet {
43    rules: Vec<PermissionRule>,
44}
45
46/// Check whether a file path points to a sensitive file that should be denied by default.
47///
48/// Returns `true` for `.env`, `.env.*` (but NOT `.env.example`), and other credential files.
49pub fn is_sensitive_file(path: &str) -> bool {
50    let filename = path.rsplit('/').next().unwrap_or(path);
51    let lower = filename.to_lowercase();
52
53    // .env and .env.* (but allow .env.example, .env.sample, .env.template)
54    if lower == ".env" {
55        return true;
56    }
57    if let Some(suffix) = lower.strip_prefix(".env.") {
58        return !matches!(suffix, "example" | "sample" | "template");
59    }
60
61    // Other common credential files
62    matches!(
63        lower.as_str(),
64        "credentials.json"
65            | "service-account.json"
66            | "id_rsa"
67            | "id_ed25519"
68            | ".npmrc"
69            | ".pypirc"
70    )
71}
72
73impl PermissionRuleSet {
74    /// Create an empty rule set.
75    pub fn new() -> Self {
76        Self { rules: Vec::new() }
77    }
78
79    /// Create a rule set with built-in security defaults.
80    ///
81    /// Includes auto-deny for reading/writing `.env` files and other credential files,
82    /// while allowing `.env.example`.
83    pub fn with_defaults() -> Self {
84        let mut rs = Self::new();
85
86        // Deny reading sensitive env files (high priority)
87        rs.add_rule(PermissionRule {
88            pattern: "read_file:*.env".into(),
89            action: PermissionAction::Deny,
90            priority: 1000,
91            directory_scope: None,
92        });
93        rs.add_rule(PermissionRule {
94            pattern: "read_file:*.env.*".into(),
95            action: PermissionAction::Deny,
96            priority: 1000,
97            directory_scope: None,
98        });
99        // Allow .env.example specifically (higher priority overrides deny)
100        rs.add_rule(PermissionRule {
101            pattern: "read_file:*.env.example".into(),
102            action: PermissionAction::Allow,
103            priority: 1001,
104            directory_scope: None,
105        });
106        rs.add_rule(PermissionRule {
107            pattern: "read_file:*.env.sample".into(),
108            action: PermissionAction::Allow,
109            priority: 1001,
110            directory_scope: None,
111        });
112        rs.add_rule(PermissionRule {
113            pattern: "read_file:*.env.template".into(),
114            action: PermissionAction::Allow,
115            priority: 1001,
116            directory_scope: None,
117        });
118
119        // Deny editing/writing sensitive env files
120        rs.add_rule(PermissionRule {
121            pattern: "edit_file:*.env".into(),
122            action: PermissionAction::Deny,
123            priority: 1000,
124            directory_scope: None,
125        });
126        rs.add_rule(PermissionRule {
127            pattern: "edit_file:*.env.*".into(),
128            action: PermissionAction::Deny,
129            priority: 1000,
130            directory_scope: None,
131        });
132        rs.add_rule(PermissionRule {
133            pattern: "write_file:*.env".into(),
134            action: PermissionAction::Deny,
135            priority: 1000,
136            directory_scope: None,
137        });
138        rs.add_rule(PermissionRule {
139            pattern: "write_file:*.env.*".into(),
140            action: PermissionAction::Deny,
141            priority: 1000,
142            directory_scope: None,
143        });
144
145        rs
146    }
147
148    /// Add a rule to the set.
149    pub fn add_rule(&mut self, rule: PermissionRule) {
150        self.rules.push(rule);
151    }
152
153    /// Remove all rules matching a predicate.
154    pub fn remove_rules<F: Fn(&PermissionRule) -> bool>(&mut self, predicate: F) {
155        self.rules.retain(|r| !predicate(r));
156    }
157
158    /// Read-only access to the rules.
159    pub fn rules(&self) -> &[PermissionRule] {
160        &self.rules
161    }
162
163    /// Evaluate a tool invocation against the rule set.
164    ///
165    /// `tool_name` is the tool being invoked (e.g. `"bash"`, `"edit"`).
166    /// `args` is the argument string (e.g. the command or file path).
167    /// `working_dir` is the optional directory context for directory-scoped rules.
168    ///
169    /// Returns the action from the highest-priority matching rule, or `None` if
170    /// no rule matches.
171    pub fn evaluate(
172        &self,
173        tool_name: &str,
174        args: &str,
175        working_dir: Option<&Path>,
176    ) -> Option<PermissionAction> {
177        let input = format!("{tool_name}:{args}");
178
179        let mut sorted: Vec<&PermissionRule> = self.rules.iter().collect();
180        sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
181
182        for rule in sorted {
183            // Check directory scope first
184            if let Some(ref scope) = rule.directory_scope {
185                match working_dir {
186                    Some(dir) => {
187                        if !glob_matches_path(scope, &dir.to_string_lossy()) {
188                            continue;
189                        }
190                    }
191                    None => continue, // scoped rule requires a directory
192                }
193            }
194
195            if glob_matches(&rule.pattern, &input) {
196                return Some(rule.action.clone());
197            }
198        }
199
200        None
201    }
202
203    /// Convenience wrapper without directory context.
204    pub fn evaluate_simple(&self, tool_name: &str, args: &str) -> Option<PermissionAction> {
205        self.evaluate(tool_name, args, None)
206    }
207}
208
209#[cfg(test)]
210mod tests;