Skip to main content

agentzero_tools/
autonomy.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashSet;
3use std::path::Path;
4use tracing::warn;
5
6/// Autonomy levels ordered by increasing permissiveness.
7#[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/// Runtime autonomy policy built from config values.
34#[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/// Outcome of a tool approval check.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub enum ApprovalDecision {
72    /// Tool is auto-approved.
73    Approved,
74    /// Tool requires interactive approval.
75    NeedsApproval { reason: String },
76    /// Tool is blocked unconditionally.
77    Blocked { reason: String },
78}
79
80/// Sensitive file patterns for detection.
81const 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    /// Evaluate whether a tool invocation should proceed.
97    pub fn check_tool(&self, tool_name: &str) -> ApprovalDecision {
98        // Read-only mode blocks all write tools.
99        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        // Always-ask list overrides auto-approve.
115        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        // Auto-approve list.
122        if self.auto_approve.contains(tool_name) {
123            return ApprovalDecision::Approved;
124        }
125
126        // Full autonomy approves everything not explicitly blocked.
127        if matches!(self.level, AutonomyLevel::Full) {
128            return ApprovalDecision::Approved;
129        }
130
131        // Supervised mode: requires approval for non-read tools.
132        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    /// Check whether a file path is allowed for reading.
146    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    /// Check whether a file path is allowed for writing.
168    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    /// Check whether a file has multiple hard links (potential symlink attack).
195    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
240/// Detect sensitive files by path suffix/pattern.
241pub 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}