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 #[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 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;