Skip to main content

agent_code_lib/sandbox/
policy.rs

1//! Resolved sandbox policy used at spawn time.
2//!
3//! A [`SandboxPolicy`] is derived from the user's `[sandbox]` config section
4//! by resolving tildes and making paths absolute. It is what a
5//! [`SandboxStrategy`](super::SandboxStrategy) consumes when wrapping a
6//! subprocess — the raw config is never passed into strategy code.
7
8use std::path::{Path, PathBuf};
9
10use crate::config::SandboxConfig;
11
12/// A concrete sandbox policy with paths resolved to absolute form.
13#[derive(Debug, Clone)]
14pub struct SandboxPolicy {
15    /// Project root — always writable inside the sandbox.
16    pub project_dir: PathBuf,
17    /// Additional writable paths (e.g. `/tmp`, `~/.cache/agent-code`).
18    pub allowed_write_paths: Vec<PathBuf>,
19    /// Paths that must never be readable (e.g. `~/.ssh`, `~/.aws`).
20    pub forbidden_paths: Vec<PathBuf>,
21    /// Whether network access is permitted inside the sandbox.
22    pub allow_network: bool,
23}
24
25impl SandboxPolicy {
26    /// Build a policy from a [`SandboxConfig`] and a project directory.
27    ///
28    /// Path entries have `~` expanded via `$HOME` and are left as-is if
29    /// absolute. Relative paths are joined onto `project_dir`.
30    pub fn from_config(config: &SandboxConfig, project_dir: &Path) -> Self {
31        let home = std::env::var_os("HOME").map(PathBuf::from);
32        let expand = |s: &String| expand_path(s.as_str(), &home, project_dir);
33
34        Self {
35            project_dir: project_dir.to_path_buf(),
36            allowed_write_paths: config.allowed_write_paths.iter().map(expand).collect(),
37            forbidden_paths: config.forbidden_paths.iter().map(expand).collect(),
38            allow_network: config.allow_network,
39        }
40    }
41}
42
43fn expand_path(raw: &str, home: &Option<PathBuf>, project_dir: &Path) -> PathBuf {
44    if let Some(rest) = raw.strip_prefix("~/")
45        && let Some(home) = home
46    {
47        return home.join(rest);
48    }
49    if raw == "~"
50        && let Some(home) = home
51    {
52        return home.clone();
53    }
54
55    let p = PathBuf::from(raw);
56    if p.is_absolute() {
57        p
58    } else {
59        project_dir.join(p)
60    }
61}
62
63#[cfg(test)]
64mod tests {
65    use super::*;
66
67    #[test]
68    fn expands_tilde_to_home() {
69        let home = Some(PathBuf::from("/Users/alice"));
70        let project = PathBuf::from("/work/repo");
71        assert_eq!(
72            expand_path("~/.cache/agent-code", &home, &project),
73            PathBuf::from("/Users/alice/.cache/agent-code")
74        );
75        assert_eq!(
76            expand_path("~", &home, &project),
77            PathBuf::from("/Users/alice")
78        );
79    }
80
81    #[test]
82    fn absolute_paths_unchanged() {
83        let home = Some(PathBuf::from("/Users/alice"));
84        let project = PathBuf::from("/work/repo");
85        assert_eq!(expand_path("/tmp", &home, &project), PathBuf::from("/tmp"));
86    }
87
88    #[test]
89    fn relative_paths_join_project_dir() {
90        let home = Some(PathBuf::from("/Users/alice"));
91        let project = PathBuf::from("/work/repo");
92        assert_eq!(
93            expand_path("target", &home, &project),
94            PathBuf::from("/work/repo/target")
95        );
96    }
97
98    #[test]
99    fn missing_home_leaves_tilde() {
100        // When $HOME is unset, tilde paths fall through to the absolute/relative rules.
101        let home: Option<PathBuf> = None;
102        let project = PathBuf::from("/work/repo");
103        // Stripping ~/ fails without $HOME, so we treat "~/foo" as a relative path.
104        assert_eq!(
105            expand_path("~/foo", &home, &project),
106            PathBuf::from("/work/repo/~/foo")
107        );
108    }
109
110    #[test]
111    fn from_config_resolves_paths() {
112        // Use a guaranteed-present $HOME via an env override for determinism.
113        // SAFETY: single-threaded test process.
114        unsafe { std::env::set_var("HOME", "/Users/test") };
115        let cfg = SandboxConfig {
116            enabled: true,
117            strategy: "seatbelt".to_string(),
118            allowed_write_paths: vec!["/tmp".to_string(), "~/.cache/agent-code".to_string()],
119            forbidden_paths: vec!["~/.ssh".to_string()],
120            allow_network: false,
121        };
122        let policy = SandboxPolicy::from_config(&cfg, Path::new("/work/repo"));
123        assert_eq!(policy.project_dir, PathBuf::from("/work/repo"));
124        assert_eq!(
125            policy.allowed_write_paths,
126            vec![
127                PathBuf::from("/tmp"),
128                PathBuf::from("/Users/test/.cache/agent-code"),
129            ]
130        );
131        assert_eq!(
132            policy.forbidden_paths,
133            vec![PathBuf::from("/Users/test/.ssh")]
134        );
135        assert!(!policy.allow_network);
136    }
137}