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