1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3use std::path::Path;
4use tracing::warn;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
8#[serde(rename_all = "snake_case")]
9pub enum AutonomyLevel {
10 ReadOnly,
11 Supervised,
12 Full,
13}
14
15impl AutonomyLevel {
16 pub fn from_str_loose(s: &str) -> Self {
17 match s.trim().to_ascii_lowercase().as_str() {
18 "read_only" | "readonly" | "read-only" => Self::ReadOnly,
19 "full" | "autonomous" => Self::Full,
20 _ => Self::Supervised,
21 }
22 }
23
24 pub fn allows_writes(&self) -> bool {
25 !matches!(self, Self::ReadOnly)
26 }
27
28 pub fn requires_approval(&self) -> bool {
29 matches!(self, Self::Supervised)
30 }
31}
32
33#[derive(Debug, Clone)]
35pub struct AutonomyPolicy {
36 pub level: AutonomyLevel,
37 pub workspace_only: bool,
38 pub forbidden_paths: Vec<String>,
39 pub allowed_roots: Vec<String>,
40 pub auto_approve: HashSet<String>,
41 pub always_ask: HashSet<String>,
42 pub allow_sensitive_file_reads: bool,
43 pub allow_sensitive_file_writes: bool,
44}
45
46impl Default for AutonomyPolicy {
47 fn default() -> Self {
48 Self {
49 level: AutonomyLevel::Supervised,
50 workspace_only: true,
51 forbidden_paths: vec![
52 "/etc".into(),
53 "/root".into(),
54 "/proc".into(),
55 "/sys".into(),
56 "~/.ssh".into(),
57 "~/.gnupg".into(),
58 "~/.aws".into(),
59 ],
60 allowed_roots: Vec::new(),
61 auto_approve: HashSet::new(),
62 always_ask: HashSet::new(),
63 allow_sensitive_file_reads: false,
64 allow_sensitive_file_writes: false,
65 }
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ApprovalDecision {
72 Approved,
74 NeedsApproval { reason: String },
76 Blocked { reason: String },
78}
79
80const SENSITIVE_FILE_PATTERNS: &[&str] = &[
82 ".env",
83 ".env.local",
84 ".env.production",
85 ".aws/credentials",
86 ".ssh/id_rsa",
87 ".ssh/id_ed25519",
88 ".gnupg/",
89 "credentials.json",
90 "service-account.json",
91 ".npmrc",
92 ".pypirc",
93];
94
95impl AutonomyPolicy {
96 pub fn check_tool(&self, tool_name: &str) -> ApprovalDecision {
98 if !self.level.allows_writes() {
100 let write_tools = [
101 "file_write",
102 "shell",
103 "apply_patch",
104 "browser",
105 "http_request",
106 ];
107 if write_tools.contains(&tool_name) {
108 return ApprovalDecision::Blocked {
109 reason: format!("tool `{tool_name}` blocked: autonomy level is read_only"),
110 };
111 }
112 }
113
114 if self.always_ask.contains(tool_name) {
116 return ApprovalDecision::NeedsApproval {
117 reason: format!("tool `{tool_name}` is in always_ask list"),
118 };
119 }
120
121 if self.auto_approve.contains(tool_name) {
123 return ApprovalDecision::Approved;
124 }
125
126 if matches!(self.level, AutonomyLevel::Full) {
128 return ApprovalDecision::Approved;
129 }
130
131 if self.level.requires_approval() {
133 let read_tools = ["file_read", "glob_search", "content_search", "memory_read"];
134 if read_tools.contains(&tool_name) {
135 return ApprovalDecision::Approved;
136 }
137 return ApprovalDecision::NeedsApproval {
138 reason: format!("tool `{tool_name}` requires approval in supervised mode"),
139 };
140 }
141
142 ApprovalDecision::Approved
143 }
144
145 pub fn check_file_read(&self, path: &str) -> ApprovalDecision {
147 if self.is_forbidden_path(path) {
148 return ApprovalDecision::Blocked {
149 reason: format!("path `{path}` is in forbidden_paths"),
150 };
151 }
152 if !self.allow_sensitive_file_reads && is_sensitive_path(path) {
153 return ApprovalDecision::Blocked {
154 reason: format!(
155 "path `{path}` is a sensitive file (allow_sensitive_file_reads = false)"
156 ),
157 };
158 }
159 if self.workspace_only && !self.is_within_allowed_roots(path) {
160 return ApprovalDecision::Blocked {
161 reason: format!("path `{path}` is outside allowed workspace roots"),
162 };
163 }
164 ApprovalDecision::Approved
165 }
166
167 pub fn check_file_write(&self, path: &str) -> ApprovalDecision {
169 if !self.level.allows_writes() {
170 return ApprovalDecision::Blocked {
171 reason: "writes blocked: autonomy level is read_only".into(),
172 };
173 }
174 if self.is_forbidden_path(path) {
175 return ApprovalDecision::Blocked {
176 reason: format!("path `{path}` is in forbidden_paths"),
177 };
178 }
179 if !self.allow_sensitive_file_writes && is_sensitive_path(path) {
180 return ApprovalDecision::Blocked {
181 reason: format!(
182 "path `{path}` is a sensitive file (allow_sensitive_file_writes = false)"
183 ),
184 };
185 }
186 if self.workspace_only && !self.is_within_allowed_roots(path) {
187 return ApprovalDecision::Blocked {
188 reason: format!("path `{path}` is outside allowed workspace roots"),
189 };
190 }
191 ApprovalDecision::Approved
192 }
193
194 pub fn check_hard_links(path: &str) -> anyhow::Result<()> {
196 let metadata = std::fs::metadata(path);
197 match metadata {
198 Ok(meta) => {
199 #[cfg(unix)]
200 {
201 use std::os::unix::fs::MetadataExt;
202 if meta.nlink() > 1 {
203 anyhow::bail!(
204 "refusing to operate on `{path}`: file has {} hard links",
205 meta.nlink()
206 );
207 }
208 }
209 let _ = meta;
210 Ok(())
211 }
212 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
213 Err(e) => {
214 warn!("hard-link check failed for {path}: {e}");
215 Ok(())
216 }
217 }
218 }
219
220 fn is_forbidden_path(&self, path: &str) -> bool {
221 let expanded = expand_tilde(path);
222 self.forbidden_paths.iter().any(|forbidden| {
223 let forbidden_expanded = expand_tilde(forbidden);
224 expanded.starts_with(&forbidden_expanded)
225 })
226 }
227
228 fn is_within_allowed_roots(&self, path: &str) -> bool {
229 if self.allowed_roots.is_empty() {
230 return true;
231 }
232 let expanded = expand_tilde(path);
233 self.allowed_roots.iter().any(|root| {
234 let root_expanded = expand_tilde(root);
235 expanded.starts_with(&root_expanded)
236 })
237 }
238}
239
240pub fn is_sensitive_path(path: &str) -> bool {
242 let normalized = path.replace('\\', "/");
243 SENSITIVE_FILE_PATTERNS.iter().any(|pattern| {
244 normalized.ends_with(pattern)
245 || normalized.contains(&format!("/{pattern}"))
246 || Path::new(&normalized)
247 .file_name()
248 .is_some_and(|f| f.to_string_lossy() == *pattern)
249 })
250}
251
252fn expand_tilde(path: &str) -> String {
253 if path.starts_with("~/") {
254 if let Ok(home) = std::env::var("HOME") {
255 return format!("{home}{}", &path[1..]);
256 }
257 }
258 path.to_string()
259}
260
261#[cfg(test)]
262mod tests {
263 use super::*;
264
265 fn policy() -> AutonomyPolicy {
266 AutonomyPolicy::default()
267 }
268
269 #[test]
270 fn autonomy_level_from_str_loose() {
271 assert_eq!(
272 AutonomyLevel::from_str_loose("read_only"),
273 AutonomyLevel::ReadOnly
274 );
275 assert_eq!(
276 AutonomyLevel::from_str_loose("readonly"),
277 AutonomyLevel::ReadOnly
278 );
279 assert_eq!(AutonomyLevel::from_str_loose("full"), AutonomyLevel::Full);
280 assert_eq!(
281 AutonomyLevel::from_str_loose("supervised"),
282 AutonomyLevel::Supervised
283 );
284 assert_eq!(
285 AutonomyLevel::from_str_loose("anything"),
286 AutonomyLevel::Supervised
287 );
288 }
289
290 #[test]
291 fn read_only_blocks_write_tools() {
292 let mut p = policy();
293 p.level = AutonomyLevel::ReadOnly;
294 assert_eq!(
295 p.check_tool("shell"),
296 ApprovalDecision::Blocked {
297 reason: "tool `shell` blocked: autonomy level is read_only".into()
298 }
299 );
300 assert_eq!(p.check_tool("file_read"), ApprovalDecision::Approved);
301 }
302
303 #[test]
304 fn supervised_requires_approval_for_non_read_tools() {
305 let p = policy();
306 assert_eq!(p.check_tool("file_read"), ApprovalDecision::Approved);
307 assert!(matches!(
308 p.check_tool("shell"),
309 ApprovalDecision::NeedsApproval { .. }
310 ));
311 }
312
313 #[test]
314 fn full_autonomy_auto_approves_everything() {
315 let mut p = policy();
316 p.level = AutonomyLevel::Full;
317 assert_eq!(p.check_tool("shell"), ApprovalDecision::Approved);
318 assert_eq!(p.check_tool("file_write"), ApprovalDecision::Approved);
319 }
320
321 #[test]
322 fn always_ask_overrides_auto_approve() {
323 let mut p = policy();
324 p.level = AutonomyLevel::Full;
325 p.auto_approve.insert("shell".into());
326 p.always_ask.insert("shell".into());
327 assert!(matches!(
328 p.check_tool("shell"),
329 ApprovalDecision::NeedsApproval { .. }
330 ));
331 }
332
333 #[test]
334 fn forbidden_paths_blocks_access() {
335 let p = policy();
336 assert!(matches!(
337 p.check_file_read("/etc/passwd"),
338 ApprovalDecision::Blocked { .. }
339 ));
340 }
341
342 #[test]
343 fn sensitive_file_detection() {
344 assert!(is_sensitive_path("/home/user/.env"));
345 assert!(is_sensitive_path("/home/user/.aws/credentials"));
346 assert!(is_sensitive_path("/project/.ssh/id_rsa"));
347 assert!(!is_sensitive_path("/project/src/main.rs"));
348 }
349
350 #[test]
351 fn sensitive_file_read_blocked_by_default() {
352 let p = policy();
353 assert!(matches!(
354 p.check_file_read("/project/.env"),
355 ApprovalDecision::Blocked { .. }
356 ));
357 }
358
359 #[test]
360 fn sensitive_file_read_allowed_when_configured() {
361 let mut p = policy();
362 p.allow_sensitive_file_reads = true;
363 assert_eq!(
364 p.check_file_read("/project/.env"),
365 ApprovalDecision::Approved
366 );
367 }
368
369 #[test]
370 fn write_blocked_in_read_only() {
371 let mut p = policy();
372 p.level = AutonomyLevel::ReadOnly;
373 assert!(matches!(
374 p.check_file_write("/project/file.txt"),
375 ApprovalDecision::Blocked { .. }
376 ));
377 }
378}