返回 CodeWhale
bwrap.rs
根目录 / crates / tui / src / sandbox / bwrap.rs
1 //! Bubblewrap (bwrap) passthrough for Linux sandbox (#2184).
2 //!
3 //! Bubblewrap is a setuid-less container runtime used by Flatpak and other
4 //! projects. It creates a new mount namespace with configurable bind mounts,
5 //! providing filesystem isolation without requiring root privileges.
6 //!
7 //! # How it works
8 //!
9 //! When `/usr/bin/bwrap` is executable AND the top-level config key
10 //! `prefer_bwrap` is set to `true`, exec_shell commands are routed through
11 //! bwrap. The bwrap invocation looks like:
12 //!
13 //! ```text
14 //! bwrap \
15 //! --unshare-all \
16 //! --ro-bind / / \
17 //! --dev /dev \
18 //! --proc /proc \
19 //! --tmpfs /tmp \
20 //! --bind <writable-root> <writable-root> \
21 //! --chdir <cwd> \
22 //! -- <program> <args>
23 //! ```
24 //!
25 //! This creates a read-only view of the entire filesystem with write access
26 //! limited to the policy-derived writable roots. Policies that allow network
27 //! access add `--share-net` after `--unshare-all`. A private `/dev` and
28 //! `/proc` plus a tmpfs `/tmp` keep standard toolchain expectations working
29 //! (#5410); user-configured extra roots and device nodes append after them.
30 //!
31 //! # Important
32 //!
33 //! We do NOT vendor bwrap. The user must install it themselves:
34 //!
35 //! - Ubuntu/Debian: `apt install bubblewrap`
36 //! - Fedora: `dnf install bubblewrap`
37 //! - Arch: `pacman -S bubblewrap`
38 //!
39 //! If bwrap is not executable, Codewhale reports no Linux OS sandbox and runs
40 //! the command without an OS wrapper. It never labels that fallback as
41 //! sandboxed.
42
43 #[cfg(target_os = "linux")]
44 use super::policy::WritableRoot;
45 #[cfg(target_os = "linux")]
46 use std::collections::BTreeSet;
47 #[cfg(target_os = "linux")]
48 use std::path::{Path, PathBuf};
49
50 /// Crate-visible wrapper over [`existing_directory`] so the sandbox module's
51 /// `BwrapMountExtensions::resolve` applies the same canonicalize + is_dir
52 /// rule to user-configured read-only roots (#5410).
53 #[cfg(target_os = "linux")]
54 pub(crate) fn existing_directory_shim(path: &Path) -> Option<PathBuf> {
55 existing_directory(path)
56 }
57
58 /// Canonical path to the bubblewrap binary.
59 #[cfg(target_os = "linux")]
60 pub const BWRAP_PATH: &str = "/usr/bin/bwrap";
61
62 /// Check if bubblewrap is installed and executable.
63 #[cfg(target_os = "linux")]
64 pub fn is_available() -> bool {
65 is_executable(std::path::Path::new(BWRAP_PATH))
66 }
67
68 #[cfg(target_os = "linux")]
69 fn is_executable(path: &std::path::Path) -> bool {
70 use std::os::unix::fs::PermissionsExt;
71
72 std::fs::metadata(path)
73 .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
74 }
75
76 #[cfg(not(target_os = "linux"))]
77 pub fn is_available() -> bool {
78 false
79 }
80
81 /// Build a bwrap command that wraps the given program and arguments.
82 ///
83 /// The returned command vector is suitable for use as `ExecEnv.command` —
84 /// it replaces the normal program+args with a bwrap invocation that sets
85 /// up a read-only root filesystem with write access only to the specified
86 /// policy roots.
87 ///
88 /// # Arguments
89 ///
90 /// - `cwd` — working directory and sandbox chdir target
91 /// - `program` — the program to run inside the container
92 /// - `args` — arguments to pass to the program
93 /// - `writable_roots` — policy-derived directories to remount read-write
94 /// - `network_access` — whether to retain the caller's network namespace
95 /// - `extensions` — user-configured extra read-only roots and writable
96 /// device nodes (#5410); skipped when they do not exist on the host
97 ///
98 /// # Returns
99 ///
100 /// A `Vec<String>` representing the full bwrap invocation.
101 #[cfg(target_os = "linux")]
102 pub fn build_bwrap_command(
103 cwd: &std::path::Path,
104 program: &str,
105 args: &[String],
106 writable_roots: &[WritableRoot],
107 network_access: bool,
108 extensions: &crate::sandbox::BwrapMountExtensions,
109 denied_read_subpaths: &[std::path::PathBuf],
110 ) -> Vec<String> {
111 let (writable_mounts, read_only_mounts) = safe_mounts(writable_roots);
112 let (extra_read_only, device_mounts) = extensions.resolve();
113 let mut cmd: Vec<String> =
114 Vec::with_capacity(10 + args.len() + 3 * (writable_mounts.len() + read_only_mounts.len()));
115
116 cmd.push(BWRAP_PATH.to_string());
117
118 // Isolate every supported namespace by default. `--share-net` selectively
119 // retains only the network namespace when the resolved policy allows it.
120 cmd.push("--unshare-all".to_string());
121 if network_access {
122 cmd.push("--share-net".to_string());
123 }
124
125 // Read-only bind-mount the entire root filesystem.
126 cmd.push("--ro-bind".to_string());
127 cmd.push("/".to_string());
128 cmd.push("/".to_string());
129
130 // Standard container essentials (#5410): a private `/dev` (so device
131 // nodes exist fresh and writable — `>/dev/null` under the read-only
132 // root bind is EROFS without this), `/proc`, and a writable isolated
133 // `/tmp` for toolchain scratch space.
134 cmd.push("--dev".to_string());
135 cmd.push("/dev".to_string());
136 cmd.push("--proc".to_string());
137 cmd.push("/proc".to_string());
138 cmd.push("--tmpfs".to_string());
139 cmd.push("/tmp".to_string());
140
141 // User-configured writable device nodes (#5410), e.g. a host `/dev/null`
142 // when the caller needs the host's, not the fresh private one.
143 for device in device_mounts {
144 let device = device.to_string_lossy().into_owned();
145 cmd.push("--dev-bind".to_string());
146 cmd.push(device.clone());
147 cmd.push(device);
148 }
149
150 for root in writable_mounts {
151 let root = root.to_string_lossy().into_owned();
152 cmd.push("--bind".to_string());
153 cmd.push(root.clone());
154 cmd.push(root);
155 }
156
157 // Re-apply protected descendants after all writable parents so a broad
158 // writable root cannot make .codewhale/.deepseek exceptions writable.
159 for root in read_only_mounts {
160 let root = root.to_string_lossy().into_owned();
161 cmd.push("--ro-bind".to_string());
162 cmd.push(root.clone());
163 cmd.push(root);
164 }
165
166 // User-configured extra read-only roots (#5410) apply last so they can
167 // narrow (re-mount read-only over) any earlier writable bind if the
168 // user explicitly lists a path the policy made writable.
169 for root in extra_read_only {
170 let root = root.to_string_lossy().into_owned();
171 cmd.push("--ro-bind".to_string());
172 cmd.push(root.clone());
173 cmd.push(root);
174 }
175
176 // Opt-in read deny-list (S1, #5568), applied after every bind so nothing
177 // re-exposes a denied path: an existing directory is masked with an empty
178 // tmpfs, an existing file with a bind of /dev/null. Non-existent paths
179 // are skipped — there is nothing to deny.
180 for denied in denied_read_subpaths {
181 let Ok(meta) = std::fs::metadata(denied) else {
182 continue;
183 };
184 let denied_str = denied.to_string_lossy().into_owned();
185 if meta.is_dir() {
186 cmd.push("--tmpfs".to_string());
187 cmd.push(denied_str);
188 } else {
189 cmd.push("--ro-bind".to_string());
190 cmd.push("/dev/null".to_string());
191 cmd.push(denied_str);
192 }
193 }
194
195 // Change to the working directory inside the container.
196 let cwd_str = cwd.to_string_lossy().to_string();
197 cmd.push("--chdir".to_string());
198 cmd.push(cwd_str);
199
200 // Separator between bwrap args and the command to run.
201 cmd.push("--".to_string());
202
203 // The actual program and its arguments.
204 cmd.push(program.to_string());
205 cmd.extend(args.iter().cloned());
206
207 cmd
208 }
209
210 #[cfg(target_os = "linux")]
211 fn safe_mounts(writable_roots: &[WritableRoot]) -> (Vec<PathBuf>, Vec<PathBuf>) {
212 let mut writable = BTreeSet::new();
213 let mut read_only = BTreeSet::new();
214
215 for root in writable_roots {
216 let Some(canonical_root) = safe_existing_directory(&root.root) else {
217 continue;
218 };
219 writable.insert(canonical_root.clone());
220
221 for exception in &root.read_only_subpaths {
222 let Some(canonical_exception) = existing_directory(exception) else {
223 continue;
224 };
225 if canonical_exception.starts_with(&canonical_root) {
226 read_only.insert(canonical_exception);
227 }
228 }
229 }
230
231 (
232 writable.into_iter().collect(),
233 read_only.into_iter().collect(),
234 )
235 }
236
237 #[cfg(target_os = "linux")]
238 fn safe_existing_directory(path: &Path) -> Option<PathBuf> {
239 let canonical = existing_directory(path)?;
240 (canonical != Path::new("/")).then_some(canonical)
241 }
242
243 #[cfg(target_os = "linux")]
244 fn existing_directory(path: &Path) -> Option<PathBuf> {
245 let canonical = path.canonicalize().ok()?;
246 canonical.is_dir().then_some(canonical)
247 }
248
249 /// Detect a failure attributable to the bubblewrap boundary.
250 #[cfg(target_os = "linux")]
251 pub fn detect_denial(exit_code: i32, stderr: &str) -> bool {
252 exit_code != 0
253 && (stderr
254 .lines()
255 .any(|line| line.trim_start().starts_with("bwrap:"))
256 || stderr.contains("Read-only file system"))
257 }
258
259 #[cfg(not(target_os = "linux"))]
260 pub fn detect_denial(_exit_code: i32, _stderr: &str) -> bool {
261 false
262 }
263
264 #[cfg(test)]
265 mod tests {
266 use super::*;
267
268 #[test]
269 fn test_is_available_does_not_panic() {
270 let _ = is_available();
271 }
272
273 #[test]
274 #[cfg(target_os = "linux")]
275 fn test_build_bwrap_command_structure() {
276 let dir = tempfile::tempdir().expect("tempdir");
277 let cwd = dir.path();
278 let cmd = build_bwrap_command(
279 cwd,
280 "sh",
281 &["-c".to_string(), "echo hi".to_string()],
282 &[WritableRoot::new(cwd.to_path_buf())],
283 false,
284 &crate::sandbox::BwrapMountExtensions::default(),
285 &[],
286 );
287
288 // Should start with bwrap
289 assert_eq!(cmd[0], "/usr/bin/bwrap");
290
291 // Should have ro-bind for root
292 assert!(cmd.contains(&"--ro-bind".to_string()));
293
294 // Standard container essentials (#5410): private /dev, /proc, tmpfs /tmp.
295 assert!(cmd.contains(&"--dev".to_string()));
296 assert!(cmd.contains(&"--proc".to_string()));
297 assert!(cmd.contains(&"--tmpfs".to_string()));
298
299 // Should have --chdir
300 assert!(cmd.contains(&"--chdir".to_string()));
301
302 // Network stays isolated unless the policy explicitly allows it.
303 assert!(cmd.contains(&"--unshare-all".to_string()));
304 assert!(!cmd.contains(&"--share-net".to_string()));
305
306 // Should end with the command
307 assert_eq!(cmd[cmd.len() - 1], "echo hi");
308 assert_eq!(cmd[cmd.len() - 2], "-c");
309 assert_eq!(cmd[cmd.len() - 3], "sh");
310 }
311
312 #[test]
313 #[cfg(target_os = "linux")]
314 fn read_only_command_does_not_remount_the_working_directory_writable() {
315 let dir = tempfile::tempdir().expect("tempdir");
316 let cwd = dir.path();
317 let cmd = build_bwrap_command(
318 cwd,
319 "true",
320 &[],
321 &[],
322 false,
323 &crate::sandbox::BwrapMountExtensions::default(),
324 &[],
325 );
326
327 assert!(!cmd.iter().any(|arg| arg == "--bind"));
328 assert!(!cmd.iter().any(|arg| arg == "--share-net"));
329 assert!(
330 cmd.windows(2)
331 .any(|args| args[0] == "--chdir" && args[1] == cwd.to_string_lossy())
332 );
333 }
334
335 #[test]
336 #[cfg(target_os = "linux")]
337 fn workspace_write_mounts_every_safe_root_and_protects_read_only_descendants() {
338 let dir = tempfile::tempdir().expect("tempdir");
339 let workspace = dir.path().join("workspace");
340 let extra = dir.path().join("extra");
341 let protected = workspace.join(".codewhale");
342 std::fs::create_dir_all(&protected).expect("protected directory");
343 std::fs::create_dir_all(&extra).expect("extra directory");
344
345 let roots = vec![
346 WritableRoot::with_exceptions(workspace.clone(), vec![protected.clone()]),
347 WritableRoot::new(extra.clone()),
348 WritableRoot::new(dir.path().join("missing")),
349 WritableRoot::new(PathBuf::from("/")),
350 ];
351 let cmd = build_bwrap_command(
352 &workspace,
353 "true",
354 &[],
355 &roots,
356 true,
357 &crate::sandbox::BwrapMountExtensions::default(),
358 &[],
359 );
360
361 for root in [&workspace, &extra] {
362 let canonical = root.canonicalize().expect("canonical root");
363 assert!(has_mount(&cmd, "--bind", &canonical));
364 }
365 assert!(has_mount(
366 &cmd,
367 "--ro-bind",
368 &protected.canonicalize().expect("canonical protected path")
369 ));
370 assert!(!has_mount(&cmd, "--bind", Path::new("/")));
371 assert!(!cmd.iter().any(|arg| arg.ends_with("/missing")));
372
373 let unshare = cmd
374 .iter()
375 .position(|arg| arg == "--unshare-all")
376 .expect("unshare all");
377 let share = cmd
378 .iter()
379 .position(|arg| arg == "--share-net")
380 .expect("share net");
381 assert!(share > unshare);
382 }
383
384 #[test]
385 #[cfg(target_os = "linux")]
386 fn extensions_add_ro_roots_and_device_nodes_and_skip_invalid_entries() {
387 let dir = tempfile::tempdir().expect("tempdir");
388 let cwd = dir.path();
389 let extra_ro = dir.path().join("vendor-libs");
390 std::fs::create_dir_all(&extra_ro).expect("extra ro dir");
391
392 let extensions = crate::sandbox::BwrapMountExtensions {
393 read_only_roots: vec![
394 extra_ro.clone(),
395 dir.path().join("missing-ro"), // silently skipped
396 ],
397 device_roots: vec![
398 // A real char device on every Linux host: /dev/null.
399 PathBuf::from("/dev/null"),
400 // Not a device: skipped even though it exists.
401 extra_ro.clone(),
402 // Missing: skipped.
403 PathBuf::from("/dev/does-not-exist"),
404 ],
405 };
406 let cmd = build_bwrap_command(cwd, "true", &[], &[], false, &extensions, &[]);
407
408 assert!(has_mount(
409 &cmd,
410 "--ro-bind",
411 &extra_ro.canonicalize().expect("canonical extra"),
412 ));
413 assert!(has_mount(
414 &cmd,
415 "--dev-bind",
416 &PathBuf::from("/dev/null")
417 .canonicalize()
418 .expect("canonical null")
419 ));
420 // The non-device directory must never appear as a dev-bind — the key
421 // is not a writable-root escape hatch.
422 assert!(!cmd.iter().any(|arg| arg.ends_with("/missing-ro")));
423 let dev_binds_of_extra = cmd
424 .windows(3)
425 .any(|args| args[0] == "--dev-bind" && args[1].as_str() == extra_ro.to_string_lossy());
426 assert!(!dev_binds_of_extra, "directories must not be dev-bound");
427 }
428
429 #[test]
430 #[cfg(target_os = "linux")]
431 fn extension_ro_roots_apply_after_writable_binds_so_they_can_narrow() {
432 let dir = tempfile::tempdir().expect("tempdir");
433 let workspace = dir.path().join("workspace");
434 let narrowed = workspace.join("vendor-libs");
435 std::fs::create_dir_all(&narrowed).expect("dirs");
436
437 let roots = vec![WritableRoot::new(workspace.clone())];
438 let extensions = crate::sandbox::BwrapMountExtensions {
439 read_only_roots: vec![narrowed.clone()],
440 device_roots: vec![],
441 };
442 let cmd = build_bwrap_command(&workspace, "true", &[], &roots, false, &extensions, &[]);
443
444 let writable_pos = cmd
445 .windows(3)
446 .position(|args| {
447 args[0] == "--bind"
448 && args[1].as_str() == workspace.canonicalize().unwrap().to_string_lossy()
449 })
450 .expect("workspace writable bind");
451 let narrow_pos = cmd
452 .windows(3)
453 .position(|args| {
454 args[0] == "--ro-bind"
455 && args[1].as_str() == narrowed.canonicalize().unwrap().to_string_lossy()
456 })
457 .expect("narrowed ro bind");
458 assert!(
459 narrow_pos > writable_pos,
460 "extra ro roots must apply after writable binds so they can narrow them"
461 );
462 }
463
464 #[cfg(target_os = "linux")]
465 fn has_mount(command: &[String], flag: &str, path: &Path) -> bool {
466 let path = path.to_string_lossy();
467 command.windows(3).any(|args| {
468 args[0] == flag
469 && args[1].as_str() == path.as_ref()
470 && args[2].as_str() == path.as_ref()
471 })
472 }
473
474 #[test]
475 #[cfg(target_os = "linux")]
476 fn executable_probe_requires_a_regular_executable_file() {
477 use std::os::unix::fs::PermissionsExt;
478
479 let dir = tempfile::tempdir().expect("tempdir");
480 let path = dir.path().join("bwrap");
481 std::fs::write(&path, b"fixture").expect("write fixture");
482 assert!(!is_executable(&path));
483
484 let mut permissions = std::fs::metadata(&path).expect("metadata").permissions();
485 permissions.set_mode(0o755);
486 std::fs::set_permissions(&path, permissions).expect("set executable bit");
487 assert!(is_executable(&path));
488 assert!(!is_executable(dir.path()));
489 }
490
491 #[test]
492 fn denial_detection_requires_a_failed_sandbox_signal() {
493 assert!(!detect_denial(0, "bwrap: ignored on success"));
494 #[cfg(target_os = "linux")]
495 {
496 assert!(detect_denial(1, "bwrap: Creating new namespace failed"));
497 assert!(detect_denial(1, "Read-only file system"));
498 assert!(!detect_denial(1, "child output mentions bwrap: casually"));
499 assert!(!detect_denial(1, "Permission denied"));
500 assert!(!detect_denial(1, "Operation not permitted"));
501 assert!(!detect_denial(1, "ordinary command failure"));
502 }
503 }
504 }
505
505 lines RUST