Skip to main content

agent_code_lib/sandbox/
bwrap.rs

1//! Linux sandbox strategy using `bwrap` (bubblewrap).
2//!
3//! Builds an argv that namespace-isolates the child process:
4//! - user, IPC, UTS, PID, cgroup namespaces are always unshared
5//! - network namespace is unshared only when `allow_network` is false
6//! - the host filesystem is bind-mounted read-only at `/`
7//! - `/dev` and `/proc` are overlaid with clean views
8//! - the project directory and every `allowed_write_paths` entry are
9//!   rw-bind-mounted on top of the read-only base
10//! - `--die-with-parent` ensures the sandbox dies with the agent
11//!
12//! Forbidden-path masking (`~/.ssh` etc.) is deferred to a follow-up
13//! PR — bwrap does not support the seatbelt `subpath` deny model
14//! directly and needs per-file handling. For now, forbidden paths are
15//! logged but not enforced by this strategy; callers that need secret
16//! masking should rely on the in-process permission system until the
17//! follow-up lands.
18
19use std::ffi::OsString;
20use std::path::Path;
21
22use tokio::process::Command;
23
24use super::{SandboxPolicy, SandboxStrategy};
25
26/// Linux bubblewrap strategy. See module docs.
27pub struct BwrapStrategy;
28
29impl SandboxStrategy for BwrapStrategy {
30    fn name(&self) -> &'static str {
31        "bwrap"
32    }
33
34    fn wrap_command(&self, cmd: Command, policy: &SandboxPolicy) -> Command {
35        let std_cmd = cmd.as_std();
36        let program = std_cmd.get_program().to_os_string();
37        let args: Vec<OsString> = std_cmd.get_args().map(|a| a.to_os_string()).collect();
38        let current_dir = std_cmd.get_current_dir().map(Path::to_path_buf);
39        let envs: Vec<(OsString, Option<OsString>)> = std_cmd
40            .get_envs()
41            .map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
42            .collect();
43
44        let bwrap_args = build_args(policy, current_dir.as_deref(), &program, &args);
45
46        let mut wrapped = Command::new("bwrap");
47        wrapped.args(bwrap_args);
48        if let Some(dir) = current_dir {
49            wrapped.current_dir(dir);
50        }
51        for (k, v) in envs {
52            match v {
53                Some(val) => {
54                    wrapped.env(k, val);
55                }
56                None => {
57                    wrapped.env_remove(k);
58                }
59            }
60        }
61        wrapped
62    }
63}
64
65/// Build the argv passed to `bwrap` (everything after the `bwrap` program).
66///
67/// Extracted so unit tests can assert the exact shape without spawning.
68pub(super) fn build_args(
69    policy: &SandboxPolicy,
70    chdir: Option<&Path>,
71    program: &OsString,
72    program_args: &[OsString],
73) -> Vec<OsString> {
74    let mut out: Vec<OsString> = Vec::new();
75
76    // Namespace isolation. We deliberately do NOT unshare the network by
77    // default — most coding-agent bash calls need outbound HTTPS for git,
78    // package managers, and network-aware tools. Users who need strict
79    // isolation set `allow_network = false` in config.
80    push_flag(&mut out, "--unshare-user");
81    push_flag(&mut out, "--unshare-ipc");
82    push_flag(&mut out, "--unshare-uts");
83    push_flag(&mut out, "--unshare-pid");
84    push_flag(&mut out, "--unshare-cgroup");
85    if !policy.allow_network {
86        push_flag(&mut out, "--unshare-net");
87    }
88
89    // Die with parent — without this, a bwrap-wrapped child can outlive
90    // the agent if the agent crashes mid-turn.
91    push_flag(&mut out, "--die-with-parent");
92
93    // Read-only view of the entire host filesystem. Subsequent --bind and
94    // overlay operations shadow specific paths on top of this.
95    push_flag(&mut out, "--ro-bind");
96    push_path(&mut out, Path::new("/"));
97    push_path(&mut out, Path::new("/"));
98
99    // Clean /dev and /proc overlays — the ro-bind above would otherwise
100    // expose the host's kernel-managed filesystems as they were at snapshot.
101    push_flag(&mut out, "--dev");
102    push_path(&mut out, Path::new("/dev"));
103    push_flag(&mut out, "--proc");
104    push_path(&mut out, Path::new("/proc"));
105
106    // Read-write mount for the project directory. This is the only
107    // writable location by default.
108    bind_rw(&mut out, &policy.project_dir);
109    for p in &policy.allowed_write_paths {
110        bind_rw(&mut out, p);
111    }
112
113    // Working directory inside the namespace.
114    if let Some(dir) = chdir {
115        push_flag(&mut out, "--chdir");
116        push_path(&mut out, dir);
117    }
118
119    // `--` separates bwrap's flags from the program to exec. Without it,
120    // bwrap would try to interpret the program as one of its own flags.
121    push_flag(&mut out, "--");
122    out.push(program.clone());
123    out.extend(program_args.iter().cloned());
124
125    out
126}
127
128fn bind_rw(out: &mut Vec<OsString>, path: &Path) {
129    // `--bind` is rw by default in bwrap. We emit both the raw path and
130    // its canonicalized form when they differ, mirroring the seatbelt
131    // strategy's symlink handling — on Linux, /var/run -> /run and
132    // similar resolutions can otherwise leave the child unable to write
133    // to its own working directory.
134    push_flag(out, "--bind");
135    push_path(out, path);
136    push_path(out, path);
137    if let Ok(canonical) = std::fs::canonicalize(path)
138        && canonical != path
139    {
140        push_flag(out, "--bind");
141        push_path(out, &canonical);
142        push_path(out, &canonical);
143    }
144}
145
146fn push_flag(out: &mut Vec<OsString>, flag: &str) {
147    out.push(OsString::from(flag));
148}
149
150fn push_path(out: &mut Vec<OsString>, path: &Path) {
151    out.push(OsString::from(path));
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use std::path::PathBuf;
158
159    fn test_policy() -> SandboxPolicy {
160        SandboxPolicy {
161            project_dir: PathBuf::from("/work/repo"),
162            allowed_write_paths: vec![
163                PathBuf::from("/tmp/agent-cache"),
164                PathBuf::from("/var/build-output"),
165            ],
166            forbidden_paths: vec![],
167            allow_network: false,
168        }
169    }
170
171    fn args_with(policy: &SandboxPolicy, chdir: Option<&Path>) -> Vec<String> {
172        build_args(
173            policy,
174            chdir,
175            &OsString::from("bash"),
176            &[OsString::from("-c"), OsString::from("echo hi")],
177        )
178        .into_iter()
179        .map(|s| s.to_string_lossy().into_owned())
180        .collect()
181    }
182
183    fn contains_sequence(haystack: &[String], needle: &[&str]) -> bool {
184        haystack
185            .windows(needle.len())
186            .any(|w| w.iter().map(String::as_str).eq(needle.iter().copied()))
187    }
188
189    #[test]
190    fn argv_unshares_standard_namespaces() {
191        let policy = test_policy();
192        let args = args_with(&policy, None);
193        for ns in [
194            "--unshare-user",
195            "--unshare-ipc",
196            "--unshare-uts",
197            "--unshare-pid",
198            "--unshare-cgroup",
199        ] {
200            assert!(
201                args.iter().any(|a| a == ns),
202                "expected {ns} in argv: {args:?}"
203            );
204        }
205    }
206
207    #[test]
208    fn argv_unshares_network_only_when_denied() {
209        let mut policy = test_policy();
210        policy.allow_network = false;
211        let denied = args_with(&policy, None);
212        assert!(denied.iter().any(|a| a == "--unshare-net"));
213
214        policy.allow_network = true;
215        let allowed = args_with(&policy, None);
216        assert!(!allowed.iter().any(|a| a == "--unshare-net"));
217    }
218
219    #[test]
220    fn argv_mounts_root_read_only() {
221        let args = args_with(&test_policy(), None);
222        assert!(contains_sequence(&args, &["--ro-bind", "/", "/"]));
223    }
224
225    #[test]
226    fn argv_overlays_dev_and_proc() {
227        let args = args_with(&test_policy(), None);
228        assert!(contains_sequence(&args, &["--dev", "/dev"]));
229        assert!(contains_sequence(&args, &["--proc", "/proc"]));
230    }
231
232    #[test]
233    fn argv_rw_binds_project_dir() {
234        let args = args_with(&test_policy(), None);
235        assert!(contains_sequence(
236            &args,
237            &["--bind", "/work/repo", "/work/repo"]
238        ));
239    }
240
241    #[test]
242    fn argv_rw_binds_allowed_paths() {
243        let args = args_with(&test_policy(), None);
244        assert!(contains_sequence(
245            &args,
246            &["--bind", "/tmp/agent-cache", "/tmp/agent-cache"]
247        ));
248        assert!(contains_sequence(
249            &args,
250            &["--bind", "/var/build-output", "/var/build-output"]
251        ));
252    }
253
254    #[test]
255    fn argv_sets_die_with_parent() {
256        let args = args_with(&test_policy(), None);
257        assert!(args.iter().any(|a| a == "--die-with-parent"));
258    }
259
260    #[test]
261    fn argv_passes_chdir_when_provided() {
262        let args = args_with(&test_policy(), Some(Path::new("/work/repo")));
263        assert!(contains_sequence(&args, &["--chdir", "/work/repo"]));
264    }
265
266    #[test]
267    fn argv_terminates_with_double_dash_and_program() {
268        let args = args_with(&test_policy(), None);
269        // Everything after `--` is the program and its args.
270        let dash_idx = args
271            .iter()
272            .position(|a| a == "--")
273            .expect("argv must contain `--`");
274        assert_eq!(args[dash_idx + 1], "bash");
275        assert_eq!(args[dash_idx + 2], "-c");
276        assert_eq!(args[dash_idx + 3], "echo hi");
277    }
278
279    #[test]
280    fn argv_handles_empty_allow_and_forbid_lists() {
281        let policy = SandboxPolicy {
282            project_dir: PathBuf::from("/work/repo"),
283            allowed_write_paths: vec![],
284            forbidden_paths: vec![],
285            allow_network: false,
286        };
287        let args = args_with(&policy, None);
288        // Project-dir bind must still be present.
289        assert!(contains_sequence(
290            &args,
291            &["--bind", "/work/repo", "/work/repo"]
292        ));
293    }
294
295    #[test]
296    fn strategy_name_is_bwrap() {
297        assert_eq!(BwrapStrategy.name(), "bwrap");
298    }
299
300    #[test]
301    fn wrap_command_sets_bwrap_as_program() {
302        let policy = test_policy();
303        let mut cmd = Command::new("bash");
304        cmd.arg("-c").arg("echo hi");
305        let wrapped = BwrapStrategy.wrap_command(cmd, &policy);
306        assert_eq!(wrapped.as_std().get_program(), "bwrap");
307    }
308
309    #[test]
310    fn wrap_command_preserves_current_dir() {
311        let policy = test_policy();
312        let mut cmd = Command::new("bash");
313        cmd.current_dir("/work/repo");
314        let wrapped = BwrapStrategy.wrap_command(cmd, &policy);
315        assert_eq!(
316            wrapped.as_std().get_current_dir(),
317            Some(Path::new("/work/repo"))
318        );
319    }
320
321    #[test]
322    fn wrap_command_preserves_env_vars() {
323        let policy = test_policy();
324        let mut cmd = Command::new("bash");
325        cmd.env("MY_VAR", "hello").env_remove("SECRET");
326        let wrapped = BwrapStrategy.wrap_command(cmd, &policy);
327        let envs: std::collections::HashMap<_, _> = wrapped
328            .as_std()
329            .get_envs()
330            .map(|(k, v)| (k.to_os_string(), v.map(|v| v.to_os_string())))
331            .collect();
332        assert_eq!(
333            envs.get(&OsString::from("MY_VAR")).and_then(|v| v.clone()),
334            Some(OsString::from("hello"))
335        );
336        assert_eq!(envs.get(&OsString::from("SECRET")), Some(&None));
337    }
338}