返回 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 //! --bind <writable-root> <writable-root> \
18 //! --chdir <cwd> \
19 //! -- <program> <args>
20 //! ```
21 //!
22 //! This creates a read-only view of the entire filesystem with write access
23 //! limited to the policy-derived writable roots. Policies that allow network
24 //! access add `--share-net` after `--unshare-all`.
25 //!
26 //! # Important
27 //!
28 //! We do NOT vendor bwrap. The user must install it themselves:
29 //!
30 //! - Ubuntu/Debian: `apt install bubblewrap`
31 //! - Fedora: `dnf install bubblewrap`
32 //! - Arch: `pacman -S bubblewrap`
33 //!
34 //! If bwrap is not executable, Codewhale reports no Linux OS sandbox and runs
35 //! the command without an OS wrapper. It never labels that fallback as
36 //! sandboxed.
37
38 #[cfg(target_os = "linux")]
39 use super::policy::WritableRoot;
40 #[cfg(target_os = "linux")]
41 use std::collections::BTreeSet;
42 #[cfg(target_os = "linux")]
43 use std::path::{Path, PathBuf};
44
45 /// Canonical path to the bubblewrap binary.
46 #[cfg(target_os = "linux")]
47 pub const BWRAP_PATH: &str = "/usr/bin/bwrap";
48
49 /// Check if bubblewrap is installed and executable.
50 #[cfg(target_os = "linux")]
51 pub fn is_available() -> bool {
52 is_executable(std::path::Path::new(BWRAP_PATH))
53 }
54
55 #[cfg(target_os = "linux")]
56 fn is_executable(path: &std::path::Path) -> bool {
57 use std::os::unix::fs::PermissionsExt;
58
59 std::fs::metadata(path)
60 .is_ok_and(|metadata| metadata.is_file() && metadata.permissions().mode() & 0o111 != 0)
61 }
62
63 #[cfg(not(target_os = "linux"))]
64 pub fn is_available() -> bool {
65 false
66 }
67
68 /// Build a bwrap command that wraps the given program and arguments.
69 ///
70 /// The returned command vector is suitable for use as `ExecEnv.command` —
71 /// it replaces the normal program+args with a bwrap invocation that sets
72 /// up a read-only root filesystem with write access only to the specified
73 /// policy roots.
74 ///
75 /// # Arguments
76 ///
77 /// - `cwd` — working directory and sandbox chdir target
78 /// - `program` — the program to run inside the container
79 /// - `args` — arguments to pass to the program
80 /// - `writable_roots` — policy-derived directories to remount read-write
81 /// - `network_access` — whether to retain the caller's network namespace
82 ///
83 /// # Returns
84 ///
85 /// A `Vec<String>` representing the full bwrap invocation.
86 #[cfg(target_os = "linux")]
87 pub fn build_bwrap_command(
88 cwd: &std::path::Path,
89 program: &str,
90 args: &[String],
91 writable_roots: &[WritableRoot],
92 network_access: bool,
93 ) -> Vec<String> {
94 let (writable_mounts, read_only_mounts) = safe_mounts(writable_roots);
95 let mut cmd: Vec<String> =
96 Vec::with_capacity(10 + args.len() + 3 * (writable_mounts.len() + read_only_mounts.len()));
97
98 cmd.push(BWRAP_PATH.to_string());
99
100 // Isolate every supported namespace by default. `--share-net` selectively
101 // retains only the network namespace when the resolved policy allows it.
102 cmd.push("--unshare-all".to_string());
103 if network_access {
104 cmd.push("--share-net".to_string());
105 }
106
107 // Read-only bind-mount the entire root filesystem.
108 cmd.push("--ro-bind".to_string());
109 cmd.push("/".to_string());
110 cmd.push("/".to_string());
111
112 for root in writable_mounts {
113 let root = root.to_string_lossy().into_owned();
114 cmd.push("--bind".to_string());
115 cmd.push(root.clone());
116 cmd.push(root);
117 }
118
119 // Re-apply protected descendants after all writable parents so a broad
120 // writable root cannot make .codewhale/.deepseek exceptions writable.
121 for root in read_only_mounts {
122 let root = root.to_string_lossy().into_owned();
123 cmd.push("--ro-bind".to_string());
124 cmd.push(root.clone());
125 cmd.push(root);
126 }
127
128 // Change to the working directory inside the container.
129 let cwd_str = cwd.to_string_lossy().to_string();
130 cmd.push("--chdir".to_string());
131 cmd.push(cwd_str);
132
133 // Separator between bwrap args and the command to run.
134 cmd.push("--".to_string());
135
136 // The actual program and its arguments.
137 cmd.push(program.to_string());
138 cmd.extend(args.iter().cloned());
139
140 cmd
141 }
142
143 #[cfg(target_os = "linux")]
144 fn safe_mounts(writable_roots: &[WritableRoot]) -> (Vec<PathBuf>, Vec<PathBuf>) {
145 let mut writable = BTreeSet::new();
146 let mut read_only = BTreeSet::new();
147
148 for root in writable_roots {
149 let Some(canonical_root) = safe_existing_directory(&root.root) else {
150 continue;
151 };
152 writable.insert(canonical_root.clone());
153
154 for exception in &root.read_only_subpaths {
155 let Some(canonical_exception) = existing_directory(exception) else {
156 continue;
157 };
158 if canonical_exception.starts_with(&canonical_root) {
159 read_only.insert(canonical_exception);
160 }
161 }
162 }
163
164 (
165 writable.into_iter().collect(),
166 read_only.into_iter().collect(),
167 )
168 }
169
170 #[cfg(target_os = "linux")]
171 fn safe_existing_directory(path: &Path) -> Option<PathBuf> {
172 let canonical = existing_directory(path)?;
173 (canonical != Path::new("/")).then_some(canonical)
174 }
175
176 #[cfg(target_os = "linux")]
177 fn existing_directory(path: &Path) -> Option<PathBuf> {
178 let canonical = path.canonicalize().ok()?;
179 canonical.is_dir().then_some(canonical)
180 }
181
182 /// Detect a failure attributable to the bubblewrap boundary.
183 #[cfg(target_os = "linux")]
184 pub fn detect_denial(exit_code: i32, stderr: &str) -> bool {
185 exit_code != 0
186 && (stderr
187 .lines()
188 .any(|line| line.trim_start().starts_with("bwrap:"))
189 || stderr.contains("Read-only file system"))
190 }
191
192 #[cfg(not(target_os = "linux"))]
193 pub fn detect_denial(_exit_code: i32, _stderr: &str) -> bool {
194 false
195 }
196
197 #[cfg(test)]
198 mod tests {
199 use super::*;
200
201 #[test]
202 fn test_is_available_does_not_panic() {
203 let _ = is_available();
204 }
205
206 #[test]
207 #[cfg(target_os = "linux")]
208 fn test_build_bwrap_command_structure() {
209 let dir = tempfile::tempdir().expect("tempdir");
210 let cwd = dir.path();
211 let cmd = build_bwrap_command(
212 cwd,
213 "sh",
214 &["-c".to_string(), "echo hi".to_string()],
215 &[WritableRoot::new(cwd.to_path_buf())],
216 false,
217 );
218
219 // Should start with bwrap
220 assert_eq!(cmd[0], "/usr/bin/bwrap");
221
222 // Should have ro-bind for root
223 assert!(cmd.contains(&"--ro-bind".to_string()));
224
225 // Should have --chdir
226 assert!(cmd.contains(&"--chdir".to_string()));
227
228 // Network stays isolated unless the policy explicitly allows it.
229 assert!(cmd.contains(&"--unshare-all".to_string()));
230 assert!(!cmd.contains(&"--share-net".to_string()));
231
232 // Should end with the command
233 assert_eq!(cmd[cmd.len() - 1], "echo hi");
234 assert_eq!(cmd[cmd.len() - 2], "-c");
235 assert_eq!(cmd[cmd.len() - 3], "sh");
236 }
237
238 #[test]
239 #[cfg(target_os = "linux")]
240 fn read_only_command_does_not_remount_the_working_directory_writable() {
241 let dir = tempfile::tempdir().expect("tempdir");
242 let cwd = dir.path();
243 let cmd = build_bwrap_command(cwd, "true", &[], &[], false);
244
245 assert!(!cmd.iter().any(|arg| arg == "--bind"));
246 assert!(!cmd.iter().any(|arg| arg == "--share-net"));
247 assert!(
248 cmd.windows(2)
249 .any(|args| args[0] == "--chdir" && args[1] == cwd.to_string_lossy())
250 );
251 }
252
253 #[test]
254 #[cfg(target_os = "linux")]
255 fn workspace_write_mounts_every_safe_root_and_protects_read_only_descendants() {
256 let dir = tempfile::tempdir().expect("tempdir");
257 let workspace = dir.path().join("workspace");
258 let extra = dir.path().join("extra");
259 let protected = workspace.join(".codewhale");
260 std::fs::create_dir_all(&protected).expect("protected directory");
261 std::fs::create_dir_all(&extra).expect("extra directory");
262
263 let roots = vec![
264 WritableRoot::with_exceptions(workspace.clone(), vec![protected.clone()]),
265 WritableRoot::new(extra.clone()),
266 WritableRoot::new(dir.path().join("missing")),
267 WritableRoot::new(PathBuf::from("/")),
268 ];
269 let cmd = build_bwrap_command(&workspace, "true", &[], &roots, true);
270
271 for root in [&workspace, &extra] {
272 let canonical = root.canonicalize().expect("canonical root");
273 assert!(has_mount(&cmd, "--bind", &canonical));
274 }
275 assert!(has_mount(
276 &cmd,
277 "--ro-bind",
278 &protected.canonicalize().expect("canonical protected path")
279 ));
280 assert!(!has_mount(&cmd, "--bind", Path::new("/")));
281 assert!(!cmd.iter().any(|arg| arg.ends_with("/missing")));
282
283 let unshare = cmd
284 .iter()
285 .position(|arg| arg == "--unshare-all")
286 .expect("unshare all");
287 let share = cmd
288 .iter()
289 .position(|arg| arg == "--share-net")
290 .expect("share net");
291 assert!(share > unshare);
292 }
293
294 #[cfg(target_os = "linux")]
295 fn has_mount(command: &[String], flag: &str, path: &Path) -> bool {
296 let path = path.to_string_lossy();
297 command.windows(3).any(|args| {
298 args[0] == flag
299 && args[1].as_str() == path.as_ref()
300 && args[2].as_str() == path.as_ref()
301 })
302 }
303
304 #[test]
305 #[cfg(target_os = "linux")]
306 fn executable_probe_requires_a_regular_executable_file() {
307 use std::os::unix::fs::PermissionsExt;
308
309 let dir = tempfile::tempdir().expect("tempdir");
310 let path = dir.path().join("bwrap");
311 std::fs::write(&path, b"fixture").expect("write fixture");
312 assert!(!is_executable(&path));
313
314 let mut permissions = std::fs::metadata(&path).expect("metadata").permissions();
315 permissions.set_mode(0o755);
316 std::fs::set_permissions(&path, permissions).expect("set executable bit");
317 assert!(is_executable(&path));
318 assert!(!is_executable(dir.path()));
319 }
320
321 #[test]
322 fn denial_detection_requires_a_failed_sandbox_signal() {
323 assert!(!detect_denial(0, "bwrap: ignored on success"));
324 #[cfg(target_os = "linux")]
325 {
326 assert!(detect_denial(1, "bwrap: Creating new namespace failed"));
327 assert!(detect_denial(1, "Read-only file system"));
328 assert!(!detect_denial(1, "child output mentions bwrap: casually"));
329 assert!(!detect_denial(1, "Permission denied"));
330 assert!(!detect_denial(1, "Operation not permitted"));
331 assert!(!detect_denial(1, "ordinary command failure"));
332 }
333 }
334 }
335
335 lines RUST