1use std::ffi::OsString;
20use std::path::Path;
21
22use tokio::process::Command;
23
24use super::{SandboxPolicy, SandboxStrategy};
25
26pub 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
65pub(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 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 push_flag(&mut out, "--die-with-parent");
92
93 push_flag(&mut out, "--ro-bind");
96 push_path(&mut out, Path::new("/"));
97 push_path(&mut out, Path::new("/"));
98
99 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 bind_rw(&mut out, &policy.project_dir);
109 for p in &policy.allowed_write_paths {
110 bind_rw(&mut out, p);
111 }
112
113 if let Some(dir) = chdir {
115 push_flag(&mut out, "--chdir");
116 push_path(&mut out, dir);
117 }
118
119 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 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 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 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}