返回 CodeWhale
mod.rs
根目录 / crates / tui / src / sandbox / mod.rs
1 #![allow(dead_code)]
2
3 //! Sandbox module for secure command execution.
4 //!
5 //! This module provides sandboxing capabilities for shell commands executed by
6 //! CodeWhale. Sandboxing restricts what system resources a command can access,
7 //! preventing accidental or malicious damage to the system.
8 //!
9 //! # Platform Support
10 //!
11 //! - **macOS**: Uses Seatbelt (`sandbox-exec`) when the runtime probe succeeds
12 //! - **Linux**: Uses bubblewrap only when the user opts in and `/usr/bin/bwrap`
13 //! is executable. The seccomp helper is not wired into child execution and
14 //! therefore is not advertised. The extension host is the exception to the
15 //! opt-in: it uses bubblewrap whenever a probe shows it works
16 //! (`extension_host::supervisor::plan_launch`).
17 //! - **OpenHarmony**: No local Linux sandbox is advertised. Bubblewrap,
18 //! seccomp, and Linux `prctl` hardening are gated out under `target_env =
19 //! "ohos"`.
20 //! - **Windows**: No OS sandbox is advertised yet. The planned first helper
21 //! contract is process-tree containment only via a Windows Job Object; it
22 //! must not claim filesystem, network, registry, or AppContainer isolation.
23 //!
24 //! # Usage
25 //!
26 //! ```rust,ignore
27 //! use sandbox::{SandboxManager, CommandSpec, SandboxPolicy};
28 //!
29 //! let manager = SandboxManager::new();
30 //! let spec = CommandSpec::shell("ls -la", PathBuf::from("."), Duration::from_secs(30))
31 //! .with_policy(SandboxPolicy::default());
32 //!
33 //! let exec_env = manager.prepare(&spec);
34 //! // exec_env.command now contains the sandboxed command
35 //! ```
36
37 pub mod backend;
38 pub mod opensandbox;
39 pub mod policy;
40 pub mod process_hardening;
41 pub mod read_guard;
42
43 #[cfg(target_os = "macos")]
44 pub mod seatbelt;
45
46 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
47 pub mod seccomp;
48
49 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
50 pub mod bwrap;
51
52 #[cfg(target_os = "windows")]
53 pub mod windows;
54
55 use std::collections::HashMap;
56 use std::path::PathBuf;
57 use std::time::Duration;
58
59 pub use policy::SandboxPolicy;
60
61 /// Public OS-sandbox capability labels consumed by the website facts
62 /// generator. Keep this list limited to wrappers that the command execution
63 /// path can actually select and apply.
64 // EXTERNAL CONTRACT — zero Rust references by design: the website's docs
65 // drift gate parses this const out of the source text (web/lib/facts-drift.ts
66 // and web/scripts/facts-lib.mjs match the literal declaration). Deleting or
67 // renaming it silently breaks that gate.
68 pub const PUBLIC_SANDBOX_BACKENDS: &[&str] = &[
69 "seatbelt (macOS, when available)",
70 "bubblewrap (Linux, opt-in when installed)",
71 ];
72
73 /// Specification for a command to be executed, potentially within a sandbox.
74 ///
75 /// This struct captures all the information needed to execute a command:
76 /// the program and arguments, working directory, environment variables,
77 /// timeout, and sandbox policy.
78 #[derive(Debug, Clone)]
79 pub struct CommandSpec {
80 /// The program to execute (e.g., "sh", "python", "cargo").
81 pub program: String,
82
83 /// Arguments to pass to the program.
84 pub args: Vec<String>,
85
86 /// Working directory for the command.
87 pub cwd: PathBuf,
88
89 /// Additional environment variables to set.
90 pub env: HashMap<String, String>,
91
92 /// Maximum execution time before the command is killed.
93 pub timeout: Duration,
94
95 /// Sandbox policy controlling resource access.
96 pub sandbox_policy: SandboxPolicy,
97
98 /// Optional justification for why this command needs to run.
99 /// Used for logging and audit purposes.
100 pub justification: Option<String>,
101
102 /// The shell command exactly as requested, before the dispatcher adds
103 /// shell-specific wrapping (encoding prefixes, exit-code capture, temp
104 /// `-File` scripts). Authoritative for display; `None` for specs built
105 /// directly from a program + args.
106 pub requested_command: Option<String>,
107 }
108
109 impl CommandSpec {
110 /// Create a `CommandSpec` for running a shell command via the platform shell.
111 pub fn shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self {
112 let dispatcher = crate::shell_dispatcher::global_dispatcher();
113
114 #[cfg(windows)]
115 let (program, args) = {
116 // Force UTF-8 output. cmd.exe uses chcp; PowerShell sets the
117 // console output encoding directly. See issue #982. Key on the
118 // PowerShell family so a custom PowerShell path keeps the same
119 // output contract as the two detected variants (#6745).
120 let kind = dispatcher.kind();
121 let cmd = if kind.is_powershell() {
122 format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {command}")
123 } else if matches!(kind, crate::shell_dispatcher::ShellKind::Cmd) {
124 format!("chcp 65001 >NUL & {command}")
125 } else {
126 command.to_string()
127 };
128 dispatcher.build_command_parts(&cmd)
129 };
130 #[cfg(not(windows))]
131 let (program, args) = dispatcher.build_command_parts(command);
132
133 let env = {
134 #[cfg(windows)]
135 {
136 windows_shell_default_env()
137 }
138 #[cfg(not(windows))]
139 {
140 HashMap::new()
141 }
142 };
143
144 Self {
145 program,
146 args,
147 cwd,
148 env,
149 timeout,
150 sandbox_policy: SandboxPolicy::default(),
151 justification: None,
152 requested_command: Some(command.to_string()),
153 }
154 }
155
156 /// Create a `CommandSpec` for running a program directly.
157 pub fn program(program: &str, args: Vec<String>, cwd: PathBuf, timeout: Duration) -> Self {
158 Self {
159 program: program.to_string(),
160 args,
161 cwd,
162 env: HashMap::new(),
163 timeout,
164 sandbox_policy: SandboxPolicy::default(),
165 justification: None,
166 requested_command: None,
167 }
168 }
169
170 /// Set the sandbox policy for this command.
171 pub fn with_policy(mut self, policy: SandboxPolicy) -> Self {
172 self.sandbox_policy = policy;
173 self
174 }
175
176 /// Add environment variables for this command.
177 pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
178 self.env = env;
179 self
180 }
181
182 /// Add a single environment variable.
183 pub fn with_env_var(mut self, key: &str, value: &str) -> Self {
184 self.env.insert(key.to_string(), value.to_string());
185 self
186 }
187
188 /// Set a justification for this command (for logging/audit).
189 pub fn with_justification(mut self, justification: &str) -> Self {
190 self.justification = Some(justification.to_string());
191 self
192 }
193
194 /// Get the original command as a single string (for display).
195 pub fn display_command(&self) -> String {
196 if let Some(requested) = &self.requested_command {
197 return requested.clone();
198 }
199 if self.args.len() == 2
200 && self.args[0] == "-c"
201 && matches!(
202 self.program.as_str(),
203 "sh" | "bash" | "/bin/sh" | "/bin/bash" | "/usr/bin/sh" | "/usr/bin/bash"
204 )
205 {
206 // For shell commands, show the actual command
207 self.args[1].clone()
208 } else if self.args.len() == 2
209 && self.args[0] == "-c"
210 && !self.program.eq_ignore_ascii_case("cmd")
211 && !self.program.eq_ignore_ascii_case("pwsh")
212 && !self.program.eq_ignore_ascii_case("pwsh.exe")
213 && !self.program.eq_ignore_ascii_case("powershell")
214 && !self.program.eq_ignore_ascii_case("powershell.exe")
215 {
216 self.args[1].clone()
217 } else if self.program.eq_ignore_ascii_case("cmd")
218 && self.args.len() == 2
219 && self.args[0].eq_ignore_ascii_case("/C")
220 {
221 // Strip the `chcp 65001 >NUL & ` prefix we add on Windows for
222 // UTF-8 output (issue #982).
223 let raw = &self.args[1];
224 raw.strip_prefix("chcp 65001 >NUL & ")
225 .unwrap_or(raw)
226 .to_string()
227 } else if {
228 let program = self.program.to_ascii_lowercase();
229 program == "pwsh"
230 || program == "pwsh.exe"
231 || program == "powershell"
232 || program == "powershell.exe"
233 } && self.args.len() >= 3
234 && self.args[0].eq_ignore_ascii_case("-NoProfile")
235 && self.args[1].eq_ignore_ascii_case("-Command")
236 {
237 // Strip the PowerShell encoding prefix.
238 let raw = &self.args[2];
239 raw.strip_prefix("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ")
240 .unwrap_or(raw)
241 .to_string()
242 } else {
243 // For other commands, join program and args
244 let mut parts = vec![self.program.clone()];
245 parts.extend(self.args.clone());
246 parts.join(" ")
247 }
248 }
249 }
250
251 fn windows_shell_default_env() -> HashMap<String, String> {
252 HashMap::from([("PYTHONIOENCODING".to_string(), "utf-8".to_string())])
253 }
254
255 /// The type of sandbox being used for execution.
256 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
257 pub enum SandboxType {
258 /// No sandboxing - command runs with full permissions.
259 #[default]
260 None,
261
262 /// macOS Seatbelt (sandbox-exec) sandboxing.
263 #[cfg(target_os = "macos")]
264 MacosSeatbelt,
265
266 /// Linux bubblewrap namespace sandboxing.
267 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
268 LinuxBubblewrap,
269
270 /// Windows process-containment helper.
271 ///
272 /// Not advertised until a helper enforces Job Object cleanup. This does
273 /// not imply filesystem, network, registry, or AppContainer isolation.
274 #[cfg(target_os = "windows")]
275 Windows,
276 }
277
278 impl std::fmt::Display for SandboxType {
279 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280 match self {
281 SandboxType::None => write!(f, "none"),
282 #[cfg(target_os = "macos")]
283 SandboxType::MacosSeatbelt => write!(f, "macos-seatbelt"),
284 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
285 SandboxType::LinuxBubblewrap => write!(f, "linux-bwrap"),
286 #[cfg(target_os = "windows")]
287 SandboxType::Windows => write!(f, "windows-sandbox"),
288 }
289 }
290 }
291
292 /// The execution environment after sandbox transformation.
293 ///
294 /// This contains the actual command to run (which may include sandbox wrapper
295 /// commands) and all necessary environment configuration.
296 #[derive(Debug)]
297 pub struct ExecEnv {
298 /// The full command to execute (may include sandbox wrapper).
299 pub command: Vec<String>,
300
301 /// Working directory for execution.
302 pub cwd: PathBuf,
303
304 /// Environment variables to set.
305 pub env: HashMap<String, String>,
306
307 /// Timeout for the command.
308 pub timeout: Duration,
309
310 /// The type of sandbox being used.
311 pub sandbox_type: SandboxType,
312
313 /// The original policy (for reference).
314 pub policy: SandboxPolicy,
315 }
316
317 impl ExecEnv {
318 /// Get the program to execute (first element of command).
319 pub fn program(&self) -> &str {
320 self.command
321 .first()
322 .map_or("sh", std::string::String::as_str)
323 }
324
325 /// Get the arguments (all elements after the first).
326 pub fn args(&self) -> &[String] {
327 if self.command.len() > 1 {
328 &self.command[1..]
329 } else {
330 &[]
331 }
332 }
333
334 /// Check if this execution is sandboxed.
335 pub fn is_sandboxed(&self) -> bool {
336 !matches!(self.sandbox_type, SandboxType::None)
337 }
338 }
339
340 /// Detect what sandbox technology is available on the current platform.
341 pub fn get_platform_sandbox() -> Option<SandboxType> {
342 get_platform_sandbox_with_bwrap_preference(false)
343 }
344
345 /// Detect the sandbox wrapper the configured command path can actually use.
346 ///
347 /// Linux bubblewrap is deliberately opt-in. Source-only sandbox prototypes do
348 /// not make commands sandboxed unless the child launch path applies them.
349 pub fn get_platform_sandbox_with_bwrap_preference(prefer_bwrap: bool) -> Option<SandboxType> {
350 #[cfg(target_os = "macos")]
351 {
352 if seatbelt::is_available() {
353 return Some(SandboxType::MacosSeatbelt);
354 }
355 }
356
357 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
358 {
359 if prefer_bwrap && bwrap::is_available() {
360 return Some(SandboxType::LinuxBubblewrap);
361 }
362 }
363
364 #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
365 let _ = prefer_bwrap;
366
367 #[cfg(target_os = "windows")]
368 {
369 if windows::is_available() {
370 return Some(SandboxType::Windows);
371 }
372 }
373
374 None
375 }
376
377 /// Check if sandboxing is available on this platform.
378 pub fn is_sandbox_available() -> bool {
379 get_platform_sandbox().is_some()
380 }
381
382 /// Manager for sandbox operations.
383 ///
384 /// User-configured bwrap bind-mount extensions (#5410).
385 ///
386 /// The default `--ro-bind / /` already exposes the host filesystem
387 /// read-only, so extra read-only roots are rarely needed; they exist for
388 /// setups where a policy or a future default narrows the root bind. Device
389 /// roots cover host device nodes that must stay writable (e.g. `/dev/null`
390 /// for redirection) — under a read-only root bind `open(O_WRONLY)` on such
391 /// nodes fails with `EROFS`, which is the original #5410 report.
392 #[derive(Clone, Debug, Default, PartialEq, Eq)]
393 pub struct BwrapMountExtensions {
394 /// Extra host paths to bind read-only inside the sandbox. Non-existent
395 /// or non-directory paths are skipped silently (same rule as writable
396 /// roots — a sandbox must never fail to start because config went
397 /// stale).
398 pub read_only_roots: Vec<PathBuf>,
399 /// Host device-node paths to bind read-write (e.g. `/dev/null`).
400 /// Non-existent paths are skipped; paths that exist but are not
401 /// character/block devices are skipped too — this key must never become
402 /// a general writable-root escape hatch.
403 pub device_roots: Vec<PathBuf>,
404 }
405
406 impl BwrapMountExtensions {
407 /// Resolve configured paths against the live filesystem, returning
408 /// `(read_only_mounts, device_mounts)` as canonical paths that exist and
409 /// satisfy each key's constraints. The bwrap module only exists on
410 /// Linux, so the resolution inlines the same two checks its
411 /// `existing_directory` performs (canonicalize + is_dir) — the type is
412 /// carried on every platform because `SandboxManager` is.
413 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
414 fn resolve(&self) -> (Vec<PathBuf>, Vec<PathBuf>) {
415 let read_only = self
416 .read_only_roots
417 .iter()
418 .filter_map(|path| bwrap::existing_directory_shim(path))
419 .filter(|path| path != std::path::Path::new("/"))
420 .collect();
421 let devices = self
422 .device_roots
423 .iter()
424 .filter_map(|path| {
425 let canonical = path.canonicalize().ok()?;
426 let meta = std::fs::metadata(&canonical).ok()?;
427 use std::os::unix::fs::FileTypeExt;
428 let file_type = meta.file_type();
429 (file_type.is_char_device() || file_type.is_block_device()).then_some(canonical)
430 })
431 .collect();
432 (read_only, devices)
433 }
434
435 /// Same resolution on non-Linux platforms, where the sandbox manager
436 /// carries the type but never uses it: there is no bwrap to build a
437 /// command for, so the extension lists resolve empty rather than doing
438 /// filesystem work whose result would be discarded.
439 #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
440 fn resolve(&self) -> (Vec<PathBuf>, Vec<PathBuf>) {
441 (Vec::new(), Vec::new())
442 }
443 }
444
445 /// Expand a leading `~` or `~/` to the user's home directory. Paths without
446 /// the prefix (and any path when no home directory is resolvable) pass
447 /// through unchanged.
448 fn expand_home_prefix(path: PathBuf) -> PathBuf {
449 let Some(text) = path.to_str() else {
450 return path;
451 };
452 if text == "~" {
453 return dirs::home_dir().unwrap_or(path);
454 }
455 if let Some(rest) = text.strip_prefix("~/")
456 && let Some(home) = dirs::home_dir()
457 {
458 return home.join(rest);
459 }
460 path
461 }
462
463 /// The bubblewrap arguments that hide `path` from a sandboxed command: an
464 /// empty tmpfs over an existing directory, `/dev/null` bound over an existing
465 /// file, nothing for a path that does not exist (there is nothing to deny).
466 /// Blocking. Platform-neutral so it is exercised on every host; only the
467 /// Linux bwrap builder emits it.
468 fn bwrap_mask_args(path: &std::path::Path) -> Vec<String> {
469 let Ok(meta) = std::fs::metadata(path) else {
470 return Vec::new();
471 };
472 let path = path.to_string_lossy().into_owned();
473 if meta.is_dir() {
474 vec!["--tmpfs".to_string(), path]
475 } else {
476 vec!["--ro-bind".to_string(), "/dev/null".to_string(), path]
477 }
478 }
479
480 /// Arguments appended after the read deny-list masks so `exceptions` inside
481 /// a masked directory stay visible. Only a denied directory that holds an
482 /// existing exception opens up again: each such exception is bound read-only
483 /// again, then each (canonical) writable root strictly inside such a
484 /// directory is bound writable again, then every denied path strictly inside
485 /// what was bound again is masked again, so an exception never re-exposes a
486 /// denied path. Any other denied directory stays masked over whatever lies
487 /// inside it, writable roots included. Empty, without touching the
488 /// filesystem, when there are no exceptions (every caller but the extension
489 /// host). Blocking (resolves paths).
490 ///
491 /// This is how the extension host denies a Codewhale home whole — including
492 /// entries created after it started, which a per-entry mask cannot cover —
493 /// while its bundle, data dir and plugin code stay reachable.
494 ///
495 /// Known limits: an exception or writable root that does not exist when the
496 /// command starts stays hidden for its lifetime; a protected descendant
497 /// (`.codewhale` inside a writable root) or an extra read-only root inside a
498 /// masked directory is not re-applied. Only the extension host sets
499 /// exceptions, and its data dir has neither.
500 fn bwrap_exception_args(
501 denied: &[PathBuf],
502 exceptions: &[PathBuf],
503 writable_roots: &[PathBuf],
504 ) -> Vec<String> {
505 if exceptions.is_empty() {
506 return Vec::new();
507 }
508 let canonical = |path: &PathBuf| std::fs::canonicalize(path).ok();
509 let strictly_inside = |path: &std::path::Path, roots: &[PathBuf]| {
510 roots
511 .iter()
512 .any(|root| path != root.as_path() && path.starts_with(root))
513 };
514 let exceptions: Vec<PathBuf> = exceptions.iter().filter_map(canonical).collect();
515 let opened: Vec<PathBuf> = denied
516 .iter()
517 .filter(|path| path.is_dir())
518 .filter_map(canonical)
519 .filter(|dir| {
520 exceptions
521 .iter()
522 .any(|exception| strictly_inside(exception, std::slice::from_ref(dir)))
523 })
524 .collect();
525 let mut rebound: Vec<PathBuf> = Vec::new();
526 let mut args = Vec::new();
527 let candidates = exceptions
528 .iter()
529 .map(|path| ("--ro-bind", path))
530 .chain(writable_roots.iter().map(|path| ("--bind", path)));
531 for (flag, path) in candidates {
532 if strictly_inside(path, &opened) && !rebound.contains(path) {
533 let spelled = path.to_string_lossy().into_owned();
534 args.extend([flag.to_string(), spelled.clone(), spelled]);
535 rebound.push(path.clone());
536 }
537 }
538 for path in denied {
539 if canonical(path).is_some_and(|resolved| strictly_inside(&resolved, &rebound)) {
540 args.extend(bwrap_mask_args(path));
541 }
542 }
543 args
544 }
545
546 /// The `SandboxManager` is responsible for:
547 /// - Detecting available sandbox technologies
548 /// - Transforming `CommandSpecs` into sandboxed `ExecEnvs`
549 /// - Detecting sandbox denials from command output
550 #[derive(Debug, Default)]
551 pub struct SandboxManager {
552 /// Cached sandbox availability check.
553 sandbox_available: Option<bool>,
554
555 /// Force a specific sandbox type (for testing).
556 forced_sandbox: Option<SandboxType>,
557
558 /// When true and bwrap is executable on Linux, route commands through
559 /// bubblewrap (#2184).
560 prefer_bwrap: bool,
561
562 /// User-configured bwrap bind-mount extensions (#5410): extra
563 /// read-only roots and writable device nodes.
564 bwrap_extensions: BwrapMountExtensions,
565
566 /// Opt-in read deny-list (S1, #5568): paths sandboxed commands must not
567 /// be able to read even though the sandbox otherwise grants full-disk
568 /// read (Seatbelt appends last-match-wins deny rules; bubblewrap masks
569 /// each path). Empty by default — today's behavior unchanged.
570 denied_read_subpaths: Vec<PathBuf>,
571
572 /// Paths inside a denied directory that stay visible (bubblewrap only,
573 /// [`bwrap_exception_args`]; Seatbelt ignores this list). Empty by
574 /// default.
575 denied_read_exceptions: Vec<PathBuf>,
576 }
577
578 impl SandboxManager {
579 /// Create a new `SandboxManager`.
580 pub fn new() -> Self {
581 Self::default()
582 }
583
584 /// Create a new `SandboxManager` with bwrap preference (#2184).
585 ///
586 /// When `prefer_bwrap` is true and `/usr/bin/bwrap` is executable on Linux,
587 /// exec_shell commands will be routed through bubblewrap.
588 pub fn with_bwrap_preference(prefer_bwrap: bool) -> Self {
589 Self {
590 prefer_bwrap,
591 ..Self::default()
592 }
593 }
594
595 /// Set the bwrap preference (#2184).
596 pub fn set_prefer_bwrap(&mut self, prefer: bool) {
597 self.prefer_bwrap = prefer;
598 self.sandbox_available = None;
599 }
600
601 /// Set user-configured bwrap mount extensions (#5410): extra read-only
602 /// roots and writable device nodes such as `/dev/null`.
603 pub fn set_bwrap_extensions(&mut self, extensions: BwrapMountExtensions) {
604 self.bwrap_extensions = extensions;
605 }
606
607 /// Set the opt-in read deny-list (S1, #5568). A leading `~` in a path
608 /// expands to the user's home directory here, and each existing path is
609 /// ALSO recorded in canonicalized form when that differs: macOS Seatbelt
610 /// matches the kernel-resolved path, so a rule written against
611 /// `/var/...` alone never fires for the real `/private/var/...` file —
612 /// the deny must name both spellings to actually deny.
613 pub fn set_denied_read_subpaths(&mut self, paths: Vec<PathBuf>) {
614 let mut resolved: Vec<PathBuf> = Vec::with_capacity(paths.len());
615 for path in paths.into_iter().map(expand_home_prefix) {
616 if let Ok(canonical) = std::fs::canonicalize(&path)
617 && canonical != path
618 && !resolved.contains(&canonical)
619 {
620 resolved.push(canonical);
621 }
622 if !resolved.contains(&path) {
623 resolved.push(path);
624 }
625 }
626 self.denied_read_subpaths = resolved;
627 }
628
629 /// Set the paths inside a denied directory that stay visible under
630 /// bubblewrap ([`bwrap_exception_args`]). The extension host uses it to
631 /// deny a Codewhale home whole while keeping its own files and plugin
632 /// code readable.
633 pub fn set_denied_read_exceptions(&mut self, paths: Vec<PathBuf>) {
634 self.denied_read_exceptions = paths;
635 }
636
637 /// Test-only view of the resolved deny-list (post home-expansion and
638 /// canonicalization).
639 #[cfg(test)]
640 pub fn denied_read_subpaths_for_test(&self) -> &[PathBuf] {
641 &self.denied_read_subpaths
642 }
643
644 /// Check if sandboxing is available.
645 pub fn is_available(&mut self) -> bool {
646 if let Some(available) = self.sandbox_available {
647 return available;
648 }
649
650 let available = self.configured_sandbox().is_some();
651 self.sandbox_available = Some(available);
652 available
653 }
654
655 /// Return the wrapper this manager is configured and able to apply.
656 pub fn configured_sandbox(&self) -> Option<SandboxType> {
657 get_platform_sandbox_with_bwrap_preference(self.prefer_bwrap)
658 }
659
660 /// Select the appropriate sandbox type for the given policy.
661 pub fn select_sandbox(&self, policy: &SandboxPolicy) -> SandboxType {
662 // If the policy doesn't want sandboxing, return None
663 if !policy.should_sandbox() {
664 return SandboxType::None;
665 }
666
667 // Check for forced sandbox (testing)
668 if let Some(forced) = self.forced_sandbox {
669 return forced;
670 }
671
672 self.configured_sandbox().unwrap_or(SandboxType::None)
673 }
674
675 /// Transform a `CommandSpec` into a sandboxed `ExecEnv`.
676 ///
677 /// This is the main entry point for sandboxing. It takes a command
678 /// specification and returns the actual command to run, which may
679 /// include sandbox wrapper commands.
680 pub fn prepare(&self, spec: &CommandSpec) -> ExecEnv {
681 let sandbox_type = self.select_sandbox(&spec.sandbox_policy);
682
683 match sandbox_type {
684 SandboxType::None => Self::prepare_unsandboxed(spec),
685
686 #[cfg(target_os = "macos")]
687 SandboxType::MacosSeatbelt => self.prepare_seatbelt(spec),
688
689 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
690 SandboxType::LinuxBubblewrap => self.prepare_bwrap(spec),
691
692 #[cfg(target_os = "windows")]
693 SandboxType::Windows => Self::prepare_windows(spec),
694 }
695 }
696
697 /// Prepare an unsandboxed execution environment.
698 fn prepare_unsandboxed(spec: &CommandSpec) -> ExecEnv {
699 let mut command = vec![spec.program.clone()];
700 command.extend(spec.args.clone());
701
702 ExecEnv {
703 command,
704 cwd: spec.cwd.clone(),
705 env: spec.env.clone(),
706 timeout: spec.timeout,
707 sandbox_type: SandboxType::None,
708 policy: spec.sandbox_policy.clone(),
709 }
710 }
711
712 /// Prepare a Seatbelt-sandboxed execution environment (macOS).
713 #[cfg(target_os = "macos")]
714 fn prepare_seatbelt(&self, spec: &CommandSpec) -> ExecEnv {
715 // Build the original command
716 let mut original_command = vec![spec.program.clone()];
717 original_command.extend(spec.args.clone());
718
719 // Generate sandbox-exec arguments
720 let seatbelt_args = seatbelt::create_seatbelt_args(
721 original_command,
722 &spec.sandbox_policy,
723 &spec.cwd,
724 &self.denied_read_subpaths,
725 );
726
727 // Prepend sandbox-exec to the command
728 let mut command = vec![seatbelt::SANDBOX_EXEC_PATH.to_string()];
729 command.extend(seatbelt_args);
730
731 // Add sandbox indicator to environment
732 let mut env = spec.env.clone();
733 env.insert("CODEWHALE_SANDBOX".to_string(), "seatbelt".to_string());
734 env.insert("DEEPSEEK_SANDBOX".to_string(), "seatbelt".to_string());
735
736 ExecEnv {
737 command,
738 cwd: spec.cwd.clone(),
739 env,
740 timeout: spec.timeout,
741 sandbox_type: SandboxType::MacosSeatbelt,
742 policy: spec.sandbox_policy.clone(),
743 }
744 }
745
746 /// Prepare a bubblewrap-sandboxed execution environment (Linux).
747 ///
748 /// Carries the standard container trio `--dev /dev`, `--proc /proc`,
749 /// `--tmpfs /tmp` (#5410): without a private `/dev`, host device nodes
750 /// inherited through the read-only root bind reject `open(O_WRONLY)`
751 /// with `EROFS` — `foo >/dev/null` was the original report — and
752 /// without `/proc`, toolchains that read process tables misbehave.
753 /// `/tmp` is writable-but-isolated (tmpfs) so linkers and test
754 /// harnesses have scratch space without widening the filesystem
755 /// policy. User-configured extensions (extra read-only roots,
756 /// writable device nodes) apply after the defaults.
757 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
758 fn prepare_bwrap(&self, spec: &CommandSpec) -> ExecEnv {
759 let writable_roots = spec.sandbox_policy.get_writable_roots(&spec.cwd);
760 let command = bwrap::build_bwrap_command(
761 &spec.cwd,
762 &spec.program,
763 &spec.args,
764 &writable_roots,
765 spec.sandbox_policy.has_network_access(),
766 &self.bwrap_extensions,
767 &self.denied_read_subpaths,
768 &self.denied_read_exceptions,
769 );
770
771 let mut env = spec.env.clone();
772 env.insert("CODEWHALE_SANDBOX".to_string(), "bwrap".to_string());
773 env.insert("DEEPSEEK_SANDBOX".to_string(), "bwrap".to_string());
774
775 ExecEnv {
776 command,
777 cwd: spec.cwd.clone(),
778 env,
779 timeout: spec.timeout,
780 sandbox_type: SandboxType::LinuxBubblewrap,
781 policy: spec.sandbox_policy.clone(),
782 }
783 }
784
785 /// Prepare a Windows helper execution environment.
786 ///
787 /// Windows support is currently not advertised by `get_platform_sandbox`.
788 /// This branch only exists for forced tests and future helper wiring.
789 /// The first supported helper contract is process-tree containment only;
790 /// it must not be presented as filesystem or network isolation.
791 #[cfg(target_os = "windows")]
792 fn prepare_windows(spec: &CommandSpec) -> ExecEnv {
793 let mut command = vec![spec.program.clone()];
794 command.extend(spec.args.clone());
795
796 let mut env = spec.env.clone();
797 let kind = windows::select_best_kind(&spec.sandbox_policy, &spec.cwd);
798 env.insert("CODEWHALE_SANDBOX".to_string(), format!("windows:{kind}"));
799 env.insert("DEEPSEEK_SANDBOX".to_string(), format!("windows:{kind}"));
800 if !spec.sandbox_policy.has_network_access() {
801 env.insert(
802 "CODEWHALE_SANDBOX_BLOCK_NETWORK".to_string(),
803 "1".to_string(),
804 );
805 env.insert(
806 "DEEPSEEK_SANDBOX_BLOCK_NETWORK".to_string(),
807 "1".to_string(),
808 );
809 }
810
811 ExecEnv {
812 command,
813 cwd: spec.cwd.clone(),
814 env,
815 timeout: spec.timeout,
816 sandbox_type: SandboxType::Windows,
817 policy: spec.sandbox_policy.clone(),
818 }
819 }
820
821 /// Check if a command failure was due to sandbox denial.
822 ///
823 /// This helps distinguish between legitimate command failures and
824 /// sandbox-blocked operations.
825 pub fn was_denied(sandbox_type: SandboxType, exit_code: i32, stderr: &str) -> bool {
826 #[cfg(not(any(
827 target_os = "macos",
828 all(target_os = "linux", not(target_env = "ohos"))
829 )))]
830 let _ = (exit_code, stderr);
831
832 match sandbox_type {
833 SandboxType::None => false,
834
835 #[cfg(target_os = "macos")]
836 SandboxType::MacosSeatbelt => seatbelt::detect_denial(exit_code, stderr),
837
838 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
839 SandboxType::LinuxBubblewrap => bwrap::detect_denial(exit_code, stderr),
840
841 #[cfg(target_os = "windows")]
842 SandboxType::Windows => windows::detect_denial(exit_code, stderr),
843 }
844 }
845
846 /// Get a human-readable description of why a command was blocked.
847 pub fn denial_message(sandbox_type: SandboxType, stderr: &str) -> String {
848 #[cfg(not(any(
849 target_os = "macos",
850 all(target_os = "linux", not(target_env = "ohos"))
851 )))]
852 let _ = stderr;
853
854 match sandbox_type {
855 SandboxType::None => "Command failed (no sandbox)".to_string(),
856
857 #[cfg(target_os = "macos")]
858 SandboxType::MacosSeatbelt => {
859 if stderr.contains("file-write") {
860 "Sandbox blocked write access. The command tried to write to a protected location.".to_string()
861 } else if stderr.contains("network") {
862 "Sandbox blocked network access. Enable network_access in sandbox policy if needed.".to_string()
863 } else {
864 format!(
865 "Sandbox blocked operation: {}",
866 stderr.lines().next().unwrap_or("unknown")
867 )
868 }
869 }
870
871 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
872 SandboxType::LinuxBubblewrap => {
873 if let Some(error) = stderr
874 .lines()
875 .map(str::trim_start)
876 .find(|line| line.starts_with("bwrap:"))
877 {
878 format!("Bubblewrap could not create the sandbox: {}", error)
879 } else if stderr.contains("Read-only file system") {
880 "Bubblewrap blocked access outside the writable workspace view.".to_string()
881 } else {
882 format!(
883 "Bubblewrap blocked operation: {}",
884 stderr.lines().next().unwrap_or("unknown")
885 )
886 }
887 }
888
889 #[cfg(target_os = "windows")]
890 SandboxType::Windows => {
891 if stderr.contains("Access is denied") {
892 "Windows sandbox blocked access. The command lacked required privileges."
893 .to_string()
894 } else if stderr.contains("network") {
895 "Windows sandbox blocked network access. Enable network_access in policy if needed."
896 .to_string()
897 } else {
898 format!(
899 "Windows sandbox blocked operation: {}",
900 stderr.lines().next().unwrap_or("unknown")
901 )
902 }
903 }
904 }
905 }
906 }
907
908 #[cfg(test)]
909 mod tests {
910 use super::*;
911
912 /// The extension host's deny-list form, platform-neutral so it runs on
913 /// every host: an exception and a writable root inside a masked
914 /// directory are bound again, a missing exception and the masked
915 /// directory itself are not, and a denied path inside an exception is
916 /// masked again.
917 #[test]
918 fn bwrap_exceptions_rebind_inside_masks_and_never_reexpose_a_denied_path() {
919 let dir = tempfile::tempdir().unwrap();
920 let root = std::fs::canonicalize(dir.path()).unwrap();
921 let home = root.join("home");
922 std::fs::create_dir_all(home.join("extension-host/data")).unwrap();
923 std::fs::create_dir_all(home.join("extension-host/secret")).unwrap();
924 std::fs::write(home.join("token"), "s3cret").unwrap();
925 std::fs::create_dir_all(root.join("other/writable")).unwrap();
926 let bundle = home.join("extension-host");
927 let data = bundle.join("data");
928 let nested = bundle.join("secret");
929 let token = home.join("token");
930 let spell = |path: &std::path::Path| path.to_string_lossy().into_owned();
931 let (bundle_s, data_s, nested_s, home_s, token_s) = (
932 spell(&bundle),
933 spell(&data),
934 spell(&nested),
935 spell(&home),
936 spell(&token),
937 );
938
939 // `other` is denied without an exception: the writable root inside
940 // it stays masked, as it is for every caller that sets none.
941 let args = bwrap_exception_args(
942 &[
943 home.clone(),
944 token.clone(),
945 nested.clone(),
946 root.join("other"),
947 ],
948 &[bundle.clone(), home.join("plugins"), home.clone()],
949 &[data.clone(), root.clone(), root.join("other/writable")],
950 );
951 assert_eq!(
952 args,
953 [
954 "--ro-bind",
955 bundle_s.as_str(),
956 bundle_s.as_str(),
957 "--bind",
958 data_s.as_str(),
959 data_s.as_str(),
960 "--tmpfs",
961 nested_s.as_str(),
962 ]
963 );
964 // No exceptions, or none inside a masked directory: nothing to add.
965 assert!(
966 bwrap_exception_args(
967 std::slice::from_ref(&home),
968 &[],
969 std::slice::from_ref(&data)
970 )
971 .is_empty()
972 );
973 assert!(bwrap_exception_args(std::slice::from_ref(&token), &[bundle], &[data]).is_empty());
974
975 assert_eq!(bwrap_mask_args(&home), ["--tmpfs", home_s.as_str()]);
976 assert_eq!(
977 bwrap_mask_args(&token),
978 ["--ro-bind", "/dev/null", token_s.as_str()]
979 );
980 assert!(bwrap_mask_args(&home.join("missing")).is_empty());
981 }
982
983 #[test]
984 fn test_command_spec_shell() {
985 let spec = CommandSpec::shell("echo hello", PathBuf::from("/tmp"), Duration::from_secs(30));
986
987 // Program and args depend on the detected shell.
988 assert!(!spec.program.is_empty(), "program must not be empty");
989 assert!(!spec.args.is_empty(), "args must not be empty");
990 assert_eq!(spec.display_command(), "echo hello");
991 }
992
993 #[test]
994 fn test_command_spec_shell_custom_posix_path_display() {
995 let spec = CommandSpec {
996 program: "/bin/zsh".to_string(),
997 args: vec!["-c".to_string(), "echo hello".to_string()],
998 cwd: PathBuf::from("/tmp"),
999 env: HashMap::new(),
1000 timeout: Duration::from_secs(30),
1001 sandbox_policy: SandboxPolicy::default(),
1002 justification: None,
1003 requested_command: None,
1004 };
1005
1006 assert_eq!(spec.display_command(), "echo hello");
1007 }
1008
1009 #[test]
1010 fn test_command_spec_shell_quoted_arg_not_split() {
1011 // Regression for #1691: a `-m` message containing spaces must remain a
1012 // single, unsplit argv entry. The shell command string is passed
1013 // verbatim as ONE argument (`sh -c <cmd>` / `cmd /C <payload>`); we
1014 // must never tokenize it ourselves into `feat:` / `complete` /
1015 // `sub-pages"`.
1016 let cmd = r#"git commit -m "feat: complete sub-pages""#;
1017 let spec = CommandSpec::shell(cmd, PathBuf::from("/tmp"), Duration::from_secs(30));
1018
1019 let dispatcher = crate::shell_dispatcher::global_dispatcher();
1020 assert_eq!(spec.program, dispatcher.kind().binary());
1021 // The quoted message survives in exactly ONE argv slot, regardless of
1022 // which shell-specific wrapping (encoding prefix, exit-code capture)
1023 // the dispatcher added around it. This single-line ASCII command never
1024 // takes the temp `-File` path, so the payload stays on the argv.
1025 let carriers: Vec<&String> = spec
1026 .args
1027 .iter()
1028 .filter(|arg| arg.contains(r#""feat: complete sub-pages""#))
1029 .collect();
1030 assert_eq!(carriers.len(), 1, "args: {:?}", spec.args);
1031 // And no argv entry is a tokenized fragment of the message.
1032 assert!(
1033 !spec
1034 .args
1035 .iter()
1036 .any(|arg| arg == "feat:" || arg == "complete" || arg == "sub-pages\""),
1037 "args: {:?}",
1038 spec.args
1039 );
1040 assert_eq!(spec.display_command(), cmd);
1041 }
1042
1043 #[test]
1044 fn test_command_spec_program() {
1045 let spec = CommandSpec::program(
1046 "cargo",
1047 vec!["build".to_string(), "--release".to_string()],
1048 PathBuf::from("/project"),
1049 Duration::from_secs(300),
1050 );
1051
1052 assert_eq!(spec.program, "cargo");
1053 assert_eq!(spec.display_command(), "cargo build --release");
1054 }
1055
1056 #[test]
1057 fn test_command_spec_builder() {
1058 let spec = CommandSpec::shell("test", PathBuf::from("."), Duration::from_secs(10))
1059 .with_policy(SandboxPolicy::ReadOnly)
1060 .with_env_var("FOO", "bar")
1061 .with_justification("Testing");
1062
1063 assert!(matches!(spec.sandbox_policy, SandboxPolicy::ReadOnly));
1064 assert_eq!(spec.env.get("FOO"), Some(&"bar".to_string()));
1065 assert_eq!(spec.justification, Some("Testing".to_string()));
1066 }
1067
1068 #[test]
1069 fn windows_shell_default_env_forces_python_pipe_stdio_utf8() {
1070 let env = windows_shell_default_env();
1071
1072 assert_eq!(
1073 env.get("PYTHONIOENCODING").map(String::as_str),
1074 Some("utf-8")
1075 );
1076 }
1077
1078 #[test]
1079 fn test_sandbox_manager_new() {
1080 let manager = SandboxManager::new();
1081 assert!(manager.sandbox_available.is_none());
1082 }
1083
1084 #[test]
1085 fn test_sandbox_manager_select_sandbox() {
1086 let manager = SandboxManager::new();
1087
1088 // DangerFullAccess should never sandbox
1089 let no_sandbox = manager.select_sandbox(&SandboxPolicy::DangerFullAccess);
1090 assert_eq!(no_sandbox, SandboxType::None);
1091
1092 // ExternalSandbox should never sandbox
1093 let external = manager.select_sandbox(&SandboxPolicy::ExternalSandbox {
1094 network_access: true,
1095 });
1096 assert_eq!(external, SandboxType::None);
1097 }
1098
1099 #[test]
1100 fn test_prepare_unsandboxed() {
1101 let manager = SandboxManager::new();
1102 let spec = CommandSpec::shell("echo test", PathBuf::from("/tmp"), Duration::from_secs(30))
1103 .with_policy(SandboxPolicy::DangerFullAccess);
1104
1105 let env = manager.prepare(&spec);
1106
1107 assert_eq!(env.sandbox_type, SandboxType::None);
1108 // Unsandboxed preparation passes the spec through untouched: the
1109 // command is exactly the spec's program followed by the dispatcher-
1110 // built args, whatever wrapping the current shell required.
1111 let mut expected = vec![spec.program.clone()];
1112 expected.extend(spec.args.iter().cloned());
1113 assert_eq!(env.command, expected);
1114 assert!(!env.is_sandboxed());
1115 }
1116
1117 #[test]
1118 fn test_exec_env_helpers() {
1119 let env = ExecEnv {
1120 command: vec![
1121 "sandbox-exec".to_string(),
1122 "-p".to_string(),
1123 "policy".to_string(),
1124 "--".to_string(),
1125 "echo".to_string(),
1126 "hello".to_string(),
1127 ],
1128 cwd: PathBuf::from("/tmp"),
1129 env: HashMap::new(),
1130 timeout: Duration::from_secs(30),
1131 sandbox_type: SandboxType::None,
1132 policy: SandboxPolicy::default(),
1133 };
1134
1135 assert_eq!(env.program(), "sandbox-exec");
1136 assert_eq!(env.args().len(), 5);
1137 }
1138
1139 #[test]
1140 fn test_sandbox_type_display() {
1141 assert_eq!(format!("{}", SandboxType::None), "none");
1142
1143 #[cfg(target_os = "macos")]
1144 assert_eq!(format!("{}", SandboxType::MacosSeatbelt), "macos-seatbelt");
1145
1146 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1147 assert_eq!(format!("{}", SandboxType::LinuxBubblewrap), "linux-bwrap");
1148 }
1149
1150 // ── Parity tests (#2187) ──────────────────────────────────────────────
1151
1152 #[test]
1153 fn test_parity_platform_sandbox_detection() {
1154 let sandbox_type = get_platform_sandbox();
1155 let available = is_sandbox_available();
1156 if available {
1157 assert!(sandbox_type.is_some());
1158 }
1159 }
1160
1161 #[test]
1162 #[cfg(target_os = "macos")]
1163 fn test_parity_macos_seatbelt_available() {
1164 // Match real runtime availability (`seatbelt::is_available` via
1165 // `get_platform_sandbox`), not merely the presence of sandbox-exec or a
1166 // diagnostics layer that may report seatbelt at another boundary.
1167 // On hosts where sandbox-exec exists but is denied (e.g. some CI /
1168 // restricted macOS environments), skip rather than asserting a false
1169 // positive.
1170 match get_platform_sandbox() {
1171 Some(SandboxType::MacosSeatbelt) => {}
1172 None => {
1173 eprintln!("skipping: MacosSeatbelt unavailable via get_platform_sandbox()");
1174 }
1175 Some(other) => panic!("unexpected macOS sandbox type: {other:?}"),
1176 }
1177 }
1178
1179 #[test]
1180 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1181 fn linux_default_never_claims_an_unwired_sandbox() {
1182 assert_eq!(get_platform_sandbox(), None);
1183 assert_eq!(get_platform_sandbox_with_bwrap_preference(false), None);
1184 }
1185
1186 #[test]
1187 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1188 fn linux_bwrap_selection_requires_opt_in_and_executable() {
1189 let expected = bwrap::is_available().then_some(SandboxType::LinuxBubblewrap);
1190 assert_eq!(get_platform_sandbox_with_bwrap_preference(true), expected);
1191
1192 let manager = SandboxManager::with_bwrap_preference(true);
1193 let selected = manager.select_sandbox(&SandboxPolicy::default());
1194 assert_eq!(selected, expected.unwrap_or(SandboxType::None));
1195 }
1196
1197 #[test]
1198 fn test_parity_denial_zero_exit_never_denied() {
1199 assert!(!SandboxManager::was_denied(
1200 SandboxType::None,
1201 0,
1202 "anything"
1203 ));
1204 #[cfg(target_os = "macos")]
1205 assert!(!SandboxManager::was_denied(
1206 SandboxType::MacosSeatbelt,
1207 0,
1208 ""
1209 ));
1210 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1211 assert!(!SandboxManager::was_denied(
1212 SandboxType::LinuxBubblewrap,
1213 0,
1214 ""
1215 ));
1216 #[cfg(target_os = "windows")]
1217 assert!(!SandboxManager::was_denied(SandboxType::Windows, 0, ""));
1218 }
1219
1220 #[test]
1221 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1222 fn bwrap_denial_is_not_inferred_from_seccomp_text() {
1223 assert!(!SandboxManager::was_denied(
1224 SandboxType::LinuxBubblewrap,
1225 1,
1226 "Bad system call"
1227 ));
1228 assert!(SandboxManager::was_denied(
1229 SandboxType::LinuxBubblewrap,
1230 1,
1231 "Read-only file system"
1232 ));
1233 }
1234
1235 #[test]
1236 #[cfg(target_os = "macos")]
1237 fn test_parity_seatbelt_file_write_detected() {
1238 // Seatbelt patterns use "Sandbox: <cmd> denied <operation>" format.
1239 assert!(SandboxManager::was_denied(
1240 SandboxType::MacosSeatbelt,
1241 1,
1242 "Sandbox: ls denied file-write*"
1243 ));
1244 assert!(SandboxManager::was_denied(
1245 SandboxType::MacosSeatbelt,
1246 1,
1247 "Operation not permitted"
1248 ));
1249 }
1250
1251 #[test]
1252 #[cfg(target_os = "macos")]
1253 fn sandbox_child_env_exports_codewhale_marker_and_legacy_alias() {
1254 let manager = SandboxManager {
1255 forced_sandbox: Some(SandboxType::MacosSeatbelt),
1256 ..SandboxManager::default()
1257 };
1258 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5));
1259 let env = manager.prepare(&spec);
1260
1261 assert_eq!(
1262 env.env.get("CODEWHALE_SANDBOX").map(String::as_str),
1263 Some("seatbelt")
1264 );
1265 assert_eq!(
1266 env.env.get("DEEPSEEK_SANDBOX").map(String::as_str),
1267 Some("seatbelt")
1268 );
1269 }
1270
1271 #[test]
1272 fn test_parity_manager_default_no_bwrap() {
1273 let manager = SandboxManager::default();
1274 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1275 .with_policy(SandboxPolicy::default());
1276 let env = manager.prepare(&spec);
1277 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1278 {
1279 let primary_marker = env.env.get("CODEWHALE_SANDBOX");
1280 let marker = env.env.get("DEEPSEEK_SANDBOX");
1281 assert!(primary_marker.is_none());
1282 assert!(marker.is_none());
1283 assert_eq!(env.sandbox_type, SandboxType::None);
1284 }
1285 let _ = env;
1286 }
1287
1288 #[test]
1289 fn test_parity_manager_with_bwrap() {
1290 let manager = SandboxManager::with_bwrap_preference(true);
1291 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1292 .with_policy(SandboxPolicy::default());
1293 let env = manager.prepare(&spec);
1294 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1295 {
1296 if crate::sandbox::bwrap::is_available() {
1297 let primary_marker = env.env.get("CODEWHALE_SANDBOX");
1298 let marker = env.env.get("DEEPSEEK_SANDBOX");
1299 assert_eq!(primary_marker.map(String::as_str), Some("bwrap"));
1300 assert_eq!(marker.map(String::as_str), Some("bwrap"));
1301 assert_eq!(env.sandbox_type, SandboxType::LinuxBubblewrap);
1302 assert_eq!(env.program(), bwrap::BWRAP_PATH);
1303 } else {
1304 assert_eq!(env.sandbox_type, SandboxType::None);
1305 assert!(!env.env.contains_key("CODEWHALE_SANDBOX"));
1306 assert!(!env.env.contains_key("DEEPSEEK_SANDBOX"));
1307 }
1308 }
1309 let _ = env;
1310 }
1311
1312 #[test]
1313 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1314 fn bwrap_read_only_policy_keeps_the_working_directory_read_only() {
1315 let manager = SandboxManager {
1316 forced_sandbox: Some(SandboxType::LinuxBubblewrap),
1317 ..SandboxManager::default()
1318 };
1319 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1320 .with_policy(SandboxPolicy::ReadOnly);
1321 let env = manager.prepare(&spec);
1322
1323 assert_eq!(env.sandbox_type, SandboxType::LinuxBubblewrap);
1324 assert!(!env.command.iter().any(|arg| arg == "--bind"));
1325 assert!(!env.command.iter().any(|arg| arg == "--share-net"));
1326 }
1327
1328 #[test]
1329 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1330 fn bwrap_workspace_policy_maps_additional_roots_and_network_access() {
1331 let dir = tempfile::tempdir().expect("tempdir");
1332 let workspace = dir.path().join("workspace");
1333 let extra = dir.path().join("extra");
1334 std::fs::create_dir_all(&workspace).expect("workspace");
1335 std::fs::create_dir_all(&extra).expect("extra");
1336
1337 let manager = SandboxManager {
1338 forced_sandbox: Some(SandboxType::LinuxBubblewrap),
1339 ..SandboxManager::default()
1340 };
1341 let policy = SandboxPolicy::WorkspaceWrite {
1342 writable_roots: vec![extra.clone()],
1343 network_access: true,
1344 exclude_tmpdir: true,
1345 exclude_slash_tmp: true,
1346 };
1347 let spec = CommandSpec::shell("true", workspace.clone(), Duration::from_secs(5))
1348 .with_policy(policy);
1349 let env = manager.prepare(&spec);
1350
1351 for root in [workspace, extra] {
1352 let root = root
1353 .canonicalize()
1354 .expect("writable root")
1355 .to_string_lossy()
1356 .into_owned();
1357 assert!(env.command.windows(3).any(|args| args[0] == "--bind"
1358 && args[1].as_str() == root.as_str()
1359 && args[2].as_str() == root.as_str()));
1360 }
1361 assert!(env.command.iter().any(|arg| arg == "--share-net"));
1362 }
1363
1364 #[test]
1365 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1366 fn full_access_and_external_policies_bypass_forced_bwrap() {
1367 let manager = SandboxManager {
1368 forced_sandbox: Some(SandboxType::LinuxBubblewrap),
1369 ..SandboxManager::default()
1370 };
1371
1372 for policy in [
1373 SandboxPolicy::DangerFullAccess,
1374 SandboxPolicy::ExternalSandbox {
1375 network_access: false,
1376 },
1377 ] {
1378 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1379 .with_policy(policy);
1380 let env = manager.prepare(&spec);
1381 assert_eq!(env.sandbox_type, SandboxType::None);
1382 assert_ne!(env.program(), bwrap::BWRAP_PATH);
1383 }
1384 }
1385
1386 #[test]
1387 fn test_parity_exec_env_for_all_policies() {
1388 let manager = SandboxManager::new();
1389 let policies = [
1390 SandboxPolicy::DangerFullAccess,
1391 SandboxPolicy::ReadOnly,
1392 SandboxPolicy::workspace_with_network(),
1393 SandboxPolicy::default(),
1394 ];
1395 for policy in &policies {
1396 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1397 .with_policy(policy.clone());
1398 let env = manager.prepare(&spec);
1399 assert_eq!(env.policy, *policy);
1400 }
1401 }
1402 }
1403
1403 lines RUST