返回 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.
15 //! - **OpenHarmony**: No local Linux sandbox is advertised. Bubblewrap,
16 //! seccomp, and Linux `prctl` hardening are gated out under `target_env =
17 //! "ohos"`.
18 //! - **Windows**: No OS sandbox is advertised yet. The planned first helper
19 //! contract is process-tree containment only via a Windows Job Object; it
20 //! must not claim filesystem, network, registry, or AppContainer isolation.
21 //!
22 //! # Usage
23 //!
24 //! ```rust,ignore
25 //! use sandbox::{SandboxManager, CommandSpec, SandboxPolicy};
26 //!
27 //! let manager = SandboxManager::new();
28 //! let spec = CommandSpec::shell("ls -la", PathBuf::from("."), Duration::from_secs(30))
29 //! .with_policy(SandboxPolicy::default());
30 //!
31 //! let exec_env = manager.prepare(&spec);
32 //! // exec_env.command now contains the sandboxed command
33 //! ```
34
35 pub mod backend;
36 pub mod opensandbox;
37 pub mod policy;
38 pub mod process_hardening;
39 pub mod read_guard;
40
41 #[cfg(target_os = "macos")]
42 pub mod seatbelt;
43
44 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
45 pub mod seccomp;
46
47 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
48 pub mod bwrap;
49
50 #[cfg(target_os = "windows")]
51 pub mod windows;
52
53 use std::collections::HashMap;
54 use std::path::PathBuf;
55 use std::time::Duration;
56
57 pub use policy::SandboxPolicy;
58
59 /// Public OS-sandbox capability labels consumed by the website facts
60 /// generator. Keep this list limited to wrappers that the command execution
61 /// path can actually select and apply.
62 // EXTERNAL CONTRACT — zero Rust references by design: the website's docs
63 // drift gate parses this const out of the source text (web/lib/facts-drift.ts
64 // and web/scripts/facts-lib.mjs match the literal declaration). Deleting or
65 // renaming it silently breaks that gate.
66 pub const PUBLIC_SANDBOX_BACKENDS: &[&str] = &[
67 "seatbelt (macOS, when available)",
68 "bubblewrap (Linux, opt-in when installed)",
69 ];
70
71 /// Specification for a command to be executed, potentially within a sandbox.
72 ///
73 /// This struct captures all the information needed to execute a command:
74 /// the program and arguments, working directory, environment variables,
75 /// timeout, and sandbox policy.
76 #[derive(Debug, Clone)]
77 pub struct CommandSpec {
78 /// The program to execute (e.g., "sh", "python", "cargo").
79 pub program: String,
80
81 /// Arguments to pass to the program.
82 pub args: Vec<String>,
83
84 /// Working directory for the command.
85 pub cwd: PathBuf,
86
87 /// Additional environment variables to set.
88 pub env: HashMap<String, String>,
89
90 /// Maximum execution time before the command is killed.
91 pub timeout: Duration,
92
93 /// Sandbox policy controlling resource access.
94 pub sandbox_policy: SandboxPolicy,
95
96 /// Optional justification for why this command needs to run.
97 /// Used for logging and audit purposes.
98 pub justification: Option<String>,
99
100 /// The shell command exactly as requested, before the dispatcher adds
101 /// shell-specific wrapping (encoding prefixes, exit-code capture, temp
102 /// `-File` scripts). Authoritative for display; `None` for specs built
103 /// directly from a program + args.
104 pub requested_command: Option<String>,
105 }
106
107 impl CommandSpec {
108 /// Create a `CommandSpec` for running a shell command via the platform shell.
109 pub fn shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self {
110 let dispatcher = crate::shell_dispatcher::global_dispatcher();
111
112 #[cfg(windows)]
113 let (program, args) = {
114 // Force UTF-8 output. cmd.exe uses chcp; PowerShell sets the
115 // console output encoding directly. See issue #982.
116 let kind = dispatcher.kind();
117 let cmd = if matches!(
118 kind,
119 crate::shell_dispatcher::ShellKind::Pwsh
120 | crate::shell_dispatcher::ShellKind::WindowsPowerShell
121 ) {
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 `SandboxManager` is responsible for:
464 /// - Detecting available sandbox technologies
465 /// - Transforming `CommandSpecs` into sandboxed `ExecEnvs`
466 /// - Detecting sandbox denials from command output
467 #[derive(Debug, Default)]
468 pub struct SandboxManager {
469 /// Cached sandbox availability check.
470 sandbox_available: Option<bool>,
471
472 /// Force a specific sandbox type (for testing).
473 forced_sandbox: Option<SandboxType>,
474
475 /// When true and bwrap is executable on Linux, route commands through
476 /// bubblewrap (#2184).
477 prefer_bwrap: bool,
478
479 /// User-configured bwrap bind-mount extensions (#5410): extra
480 /// read-only roots and writable device nodes.
481 bwrap_extensions: BwrapMountExtensions,
482
483 /// Opt-in read deny-list (S1, #5568): paths sandboxed commands must not
484 /// be able to read even though the sandbox otherwise grants full-disk
485 /// read (Seatbelt appends last-match-wins deny rules; bubblewrap masks
486 /// each path). Empty by default — today's behavior unchanged.
487 denied_read_subpaths: Vec<PathBuf>,
488 }
489
490 impl SandboxManager {
491 /// Create a new `SandboxManager`.
492 pub fn new() -> Self {
493 Self::default()
494 }
495
496 /// Create a new `SandboxManager` with bwrap preference (#2184).
497 ///
498 /// When `prefer_bwrap` is true and `/usr/bin/bwrap` is executable on Linux,
499 /// exec_shell commands will be routed through bubblewrap.
500 pub fn with_bwrap_preference(prefer_bwrap: bool) -> Self {
501 Self {
502 prefer_bwrap,
503 ..Self::default()
504 }
505 }
506
507 /// Set the bwrap preference (#2184).
508 pub fn set_prefer_bwrap(&mut self, prefer: bool) {
509 self.prefer_bwrap = prefer;
510 self.sandbox_available = None;
511 }
512
513 /// Set user-configured bwrap mount extensions (#5410): extra read-only
514 /// roots and writable device nodes such as `/dev/null`.
515 pub fn set_bwrap_extensions(&mut self, extensions: BwrapMountExtensions) {
516 self.bwrap_extensions = extensions;
517 }
518
519 /// Set the opt-in read deny-list (S1, #5568). A leading `~` in a path
520 /// expands to the user's home directory here, and each existing path is
521 /// ALSO recorded in canonicalized form when that differs: macOS Seatbelt
522 /// matches the kernel-resolved path, so a rule written against
523 /// `/var/...` alone never fires for the real `/private/var/...` file —
524 /// the deny must name both spellings to actually deny.
525 pub fn set_denied_read_subpaths(&mut self, paths: Vec<PathBuf>) {
526 let mut resolved: Vec<PathBuf> = Vec::with_capacity(paths.len());
527 for path in paths.into_iter().map(expand_home_prefix) {
528 if let Ok(canonical) = std::fs::canonicalize(&path)
529 && canonical != path
530 && !resolved.contains(&canonical)
531 {
532 resolved.push(canonical);
533 }
534 if !resolved.contains(&path) {
535 resolved.push(path);
536 }
537 }
538 self.denied_read_subpaths = resolved;
539 }
540
541 /// Test-only view of the resolved deny-list (post home-expansion and
542 /// canonicalization).
543 #[cfg(test)]
544 pub fn denied_read_subpaths_for_test(&self) -> &[PathBuf] {
545 &self.denied_read_subpaths
546 }
547
548 /// Check if sandboxing is available.
549 pub fn is_available(&mut self) -> bool {
550 if let Some(available) = self.sandbox_available {
551 return available;
552 }
553
554 let available = self.configured_sandbox().is_some();
555 self.sandbox_available = Some(available);
556 available
557 }
558
559 /// Return the wrapper this manager is configured and able to apply.
560 pub fn configured_sandbox(&self) -> Option<SandboxType> {
561 get_platform_sandbox_with_bwrap_preference(self.prefer_bwrap)
562 }
563
564 /// Select the appropriate sandbox type for the given policy.
565 pub fn select_sandbox(&self, policy: &SandboxPolicy) -> SandboxType {
566 // If the policy doesn't want sandboxing, return None
567 if !policy.should_sandbox() {
568 return SandboxType::None;
569 }
570
571 // Check for forced sandbox (testing)
572 if let Some(forced) = self.forced_sandbox {
573 return forced;
574 }
575
576 self.configured_sandbox().unwrap_or(SandboxType::None)
577 }
578
579 /// Transform a `CommandSpec` into a sandboxed `ExecEnv`.
580 ///
581 /// This is the main entry point for sandboxing. It takes a command
582 /// specification and returns the actual command to run, which may
583 /// include sandbox wrapper commands.
584 pub fn prepare(&self, spec: &CommandSpec) -> ExecEnv {
585 let sandbox_type = self.select_sandbox(&spec.sandbox_policy);
586
587 match sandbox_type {
588 SandboxType::None => Self::prepare_unsandboxed(spec),
589
590 #[cfg(target_os = "macos")]
591 SandboxType::MacosSeatbelt => self.prepare_seatbelt(spec),
592
593 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
594 SandboxType::LinuxBubblewrap => self.prepare_bwrap(spec),
595
596 #[cfg(target_os = "windows")]
597 SandboxType::Windows => Self::prepare_windows(spec),
598 }
599 }
600
601 /// Prepare an unsandboxed execution environment.
602 fn prepare_unsandboxed(spec: &CommandSpec) -> ExecEnv {
603 let mut command = vec![spec.program.clone()];
604 command.extend(spec.args.clone());
605
606 ExecEnv {
607 command,
608 cwd: spec.cwd.clone(),
609 env: spec.env.clone(),
610 timeout: spec.timeout,
611 sandbox_type: SandboxType::None,
612 policy: spec.sandbox_policy.clone(),
613 }
614 }
615
616 /// Prepare a Seatbelt-sandboxed execution environment (macOS).
617 #[cfg(target_os = "macos")]
618 fn prepare_seatbelt(&self, spec: &CommandSpec) -> ExecEnv {
619 // Build the original command
620 let mut original_command = vec![spec.program.clone()];
621 original_command.extend(spec.args.clone());
622
623 // Generate sandbox-exec arguments
624 let seatbelt_args = seatbelt::create_seatbelt_args(
625 original_command,
626 &spec.sandbox_policy,
627 &spec.cwd,
628 &self.denied_read_subpaths,
629 );
630
631 // Prepend sandbox-exec to the command
632 let mut command = vec![seatbelt::SANDBOX_EXEC_PATH.to_string()];
633 command.extend(seatbelt_args);
634
635 // Add sandbox indicator to environment
636 let mut env = spec.env.clone();
637 env.insert("CODEWHALE_SANDBOX".to_string(), "seatbelt".to_string());
638 env.insert("DEEPSEEK_SANDBOX".to_string(), "seatbelt".to_string());
639
640 ExecEnv {
641 command,
642 cwd: spec.cwd.clone(),
643 env,
644 timeout: spec.timeout,
645 sandbox_type: SandboxType::MacosSeatbelt,
646 policy: spec.sandbox_policy.clone(),
647 }
648 }
649
650 /// Prepare a bubblewrap-sandboxed execution environment (Linux).
651 ///
652 /// Carries the standard container trio `--dev /dev`, `--proc /proc`,
653 /// `--tmpfs /tmp` (#5410): without a private `/dev`, host device nodes
654 /// inherited through the read-only root bind reject `open(O_WRONLY)`
655 /// with `EROFS` — `foo >/dev/null` was the original report — and
656 /// without `/proc`, toolchains that read process tables misbehave.
657 /// `/tmp` is writable-but-isolated (tmpfs) so linkers and test
658 /// harnesses have scratch space without widening the filesystem
659 /// policy. User-configured extensions (extra read-only roots,
660 /// writable device nodes) apply after the defaults.
661 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
662 fn prepare_bwrap(&self, spec: &CommandSpec) -> ExecEnv {
663 let writable_roots = spec.sandbox_policy.get_writable_roots(&spec.cwd);
664 let command = bwrap::build_bwrap_command(
665 &spec.cwd,
666 &spec.program,
667 &spec.args,
668 &writable_roots,
669 spec.sandbox_policy.has_network_access(),
670 &self.bwrap_extensions,
671 &self.denied_read_subpaths,
672 );
673
674 let mut env = spec.env.clone();
675 env.insert("CODEWHALE_SANDBOX".to_string(), "bwrap".to_string());
676 env.insert("DEEPSEEK_SANDBOX".to_string(), "bwrap".to_string());
677
678 ExecEnv {
679 command,
680 cwd: spec.cwd.clone(),
681 env,
682 timeout: spec.timeout,
683 sandbox_type: SandboxType::LinuxBubblewrap,
684 policy: spec.sandbox_policy.clone(),
685 }
686 }
687
688 /// Prepare a Windows helper execution environment.
689 ///
690 /// Windows support is currently not advertised by `get_platform_sandbox`.
691 /// This branch only exists for forced tests and future helper wiring.
692 /// The first supported helper contract is process-tree containment only;
693 /// it must not be presented as filesystem or network isolation.
694 #[cfg(target_os = "windows")]
695 fn prepare_windows(spec: &CommandSpec) -> ExecEnv {
696 let mut command = vec![spec.program.clone()];
697 command.extend(spec.args.clone());
698
699 let mut env = spec.env.clone();
700 let kind = windows::select_best_kind(&spec.sandbox_policy, &spec.cwd);
701 env.insert("CODEWHALE_SANDBOX".to_string(), format!("windows:{kind}"));
702 env.insert("DEEPSEEK_SANDBOX".to_string(), format!("windows:{kind}"));
703 if !spec.sandbox_policy.has_network_access() {
704 env.insert(
705 "CODEWHALE_SANDBOX_BLOCK_NETWORK".to_string(),
706 "1".to_string(),
707 );
708 env.insert(
709 "DEEPSEEK_SANDBOX_BLOCK_NETWORK".to_string(),
710 "1".to_string(),
711 );
712 }
713
714 ExecEnv {
715 command,
716 cwd: spec.cwd.clone(),
717 env,
718 timeout: spec.timeout,
719 sandbox_type: SandboxType::Windows,
720 policy: spec.sandbox_policy.clone(),
721 }
722 }
723
724 /// Check if a command failure was due to sandbox denial.
725 ///
726 /// This helps distinguish between legitimate command failures and
727 /// sandbox-blocked operations.
728 pub fn was_denied(sandbox_type: SandboxType, exit_code: i32, stderr: &str) -> bool {
729 #[cfg(not(any(
730 target_os = "macos",
731 all(target_os = "linux", not(target_env = "ohos"))
732 )))]
733 let _ = (exit_code, stderr);
734
735 match sandbox_type {
736 SandboxType::None => false,
737
738 #[cfg(target_os = "macos")]
739 SandboxType::MacosSeatbelt => seatbelt::detect_denial(exit_code, stderr),
740
741 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
742 SandboxType::LinuxBubblewrap => bwrap::detect_denial(exit_code, stderr),
743
744 #[cfg(target_os = "windows")]
745 SandboxType::Windows => windows::detect_denial(exit_code, stderr),
746 }
747 }
748
749 /// Get a human-readable description of why a command was blocked.
750 pub fn denial_message(sandbox_type: SandboxType, stderr: &str) -> String {
751 #[cfg(not(any(
752 target_os = "macos",
753 all(target_os = "linux", not(target_env = "ohos"))
754 )))]
755 let _ = stderr;
756
757 match sandbox_type {
758 SandboxType::None => "Command failed (no sandbox)".to_string(),
759
760 #[cfg(target_os = "macos")]
761 SandboxType::MacosSeatbelt => {
762 if stderr.contains("file-write") {
763 "Sandbox blocked write access. The command tried to write to a protected location.".to_string()
764 } else if stderr.contains("network") {
765 "Sandbox blocked network access. Enable network_access in sandbox policy if needed.".to_string()
766 } else {
767 format!(
768 "Sandbox blocked operation: {}",
769 stderr.lines().next().unwrap_or("unknown")
770 )
771 }
772 }
773
774 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
775 SandboxType::LinuxBubblewrap => {
776 if let Some(error) = stderr
777 .lines()
778 .map(str::trim_start)
779 .find(|line| line.starts_with("bwrap:"))
780 {
781 format!("Bubblewrap could not create the sandbox: {}", error)
782 } else if stderr.contains("Read-only file system") {
783 "Bubblewrap blocked access outside the writable workspace view.".to_string()
784 } else {
785 format!(
786 "Bubblewrap blocked operation: {}",
787 stderr.lines().next().unwrap_or("unknown")
788 )
789 }
790 }
791
792 #[cfg(target_os = "windows")]
793 SandboxType::Windows => {
794 if stderr.contains("Access is denied") {
795 "Windows sandbox blocked access. The command lacked required privileges."
796 .to_string()
797 } else if stderr.contains("network") {
798 "Windows sandbox blocked network access. Enable network_access in policy if needed."
799 .to_string()
800 } else {
801 format!(
802 "Windows sandbox blocked operation: {}",
803 stderr.lines().next().unwrap_or("unknown")
804 )
805 }
806 }
807 }
808 }
809 }
810
811 #[cfg(test)]
812 mod tests {
813 use super::*;
814
815 #[test]
816 fn test_command_spec_shell() {
817 let spec = CommandSpec::shell("echo hello", PathBuf::from("/tmp"), Duration::from_secs(30));
818
819 // Program and args depend on the detected shell.
820 assert!(!spec.program.is_empty(), "program must not be empty");
821 assert!(!spec.args.is_empty(), "args must not be empty");
822 assert_eq!(spec.display_command(), "echo hello");
823 }
824
825 #[test]
826 fn test_command_spec_shell_custom_posix_path_display() {
827 let spec = CommandSpec {
828 program: "/bin/zsh".to_string(),
829 args: vec!["-c".to_string(), "echo hello".to_string()],
830 cwd: PathBuf::from("/tmp"),
831 env: HashMap::new(),
832 timeout: Duration::from_secs(30),
833 sandbox_policy: SandboxPolicy::default(),
834 justification: None,
835 requested_command: None,
836 };
837
838 assert_eq!(spec.display_command(), "echo hello");
839 }
840
841 #[test]
842 fn test_command_spec_shell_quoted_arg_not_split() {
843 // Regression for #1691: a `-m` message containing spaces must remain a
844 // single, unsplit argv entry. The shell command string is passed
845 // verbatim as ONE argument (`sh -c <cmd>` / `cmd /C <payload>`); we
846 // must never tokenize it ourselves into `feat:` / `complete` /
847 // `sub-pages"`.
848 let cmd = r#"git commit -m "feat: complete sub-pages""#;
849 let spec = CommandSpec::shell(cmd, PathBuf::from("/tmp"), Duration::from_secs(30));
850
851 let dispatcher = crate::shell_dispatcher::global_dispatcher();
852 assert_eq!(spec.program, dispatcher.kind().binary());
853 // The quoted message survives in exactly ONE argv slot, regardless of
854 // which shell-specific wrapping (encoding prefix, exit-code capture)
855 // the dispatcher added around it. This single-line ASCII command never
856 // takes the temp `-File` path, so the payload stays on the argv.
857 let carriers: Vec<&String> = spec
858 .args
859 .iter()
860 .filter(|arg| arg.contains(r#""feat: complete sub-pages""#))
861 .collect();
862 assert_eq!(carriers.len(), 1, "args: {:?}", spec.args);
863 // And no argv entry is a tokenized fragment of the message.
864 assert!(
865 !spec
866 .args
867 .iter()
868 .any(|arg| arg == "feat:" || arg == "complete" || arg == "sub-pages\""),
869 "args: {:?}",
870 spec.args
871 );
872 assert_eq!(spec.display_command(), cmd);
873 }
874
875 #[test]
876 fn test_command_spec_program() {
877 let spec = CommandSpec::program(
878 "cargo",
879 vec!["build".to_string(), "--release".to_string()],
880 PathBuf::from("/project"),
881 Duration::from_secs(300),
882 );
883
884 assert_eq!(spec.program, "cargo");
885 assert_eq!(spec.display_command(), "cargo build --release");
886 }
887
888 #[test]
889 fn test_command_spec_builder() {
890 let spec = CommandSpec::shell("test", PathBuf::from("."), Duration::from_secs(10))
891 .with_policy(SandboxPolicy::ReadOnly)
892 .with_env_var("FOO", "bar")
893 .with_justification("Testing");
894
895 assert!(matches!(spec.sandbox_policy, SandboxPolicy::ReadOnly));
896 assert_eq!(spec.env.get("FOO"), Some(&"bar".to_string()));
897 assert_eq!(spec.justification, Some("Testing".to_string()));
898 }
899
900 #[test]
901 fn windows_shell_default_env_forces_python_pipe_stdio_utf8() {
902 let env = windows_shell_default_env();
903
904 assert_eq!(
905 env.get("PYTHONIOENCODING").map(String::as_str),
906 Some("utf-8")
907 );
908 }
909
910 #[test]
911 fn test_sandbox_manager_new() {
912 let manager = SandboxManager::new();
913 assert!(manager.sandbox_available.is_none());
914 }
915
916 #[test]
917 fn test_sandbox_manager_select_sandbox() {
918 let manager = SandboxManager::new();
919
920 // DangerFullAccess should never sandbox
921 let no_sandbox = manager.select_sandbox(&SandboxPolicy::DangerFullAccess);
922 assert_eq!(no_sandbox, SandboxType::None);
923
924 // ExternalSandbox should never sandbox
925 let external = manager.select_sandbox(&SandboxPolicy::ExternalSandbox {
926 network_access: true,
927 });
928 assert_eq!(external, SandboxType::None);
929 }
930
931 #[test]
932 fn test_prepare_unsandboxed() {
933 let manager = SandboxManager::new();
934 let spec = CommandSpec::shell("echo test", PathBuf::from("/tmp"), Duration::from_secs(30))
935 .with_policy(SandboxPolicy::DangerFullAccess);
936
937 let env = manager.prepare(&spec);
938
939 assert_eq!(env.sandbox_type, SandboxType::None);
940 // Unsandboxed preparation passes the spec through untouched: the
941 // command is exactly the spec's program followed by the dispatcher-
942 // built args, whatever wrapping the current shell required.
943 let mut expected = vec![spec.program.clone()];
944 expected.extend(spec.args.iter().cloned());
945 assert_eq!(env.command, expected);
946 assert!(!env.is_sandboxed());
947 }
948
949 #[test]
950 fn test_exec_env_helpers() {
951 let env = ExecEnv {
952 command: vec![
953 "sandbox-exec".to_string(),
954 "-p".to_string(),
955 "policy".to_string(),
956 "--".to_string(),
957 "echo".to_string(),
958 "hello".to_string(),
959 ],
960 cwd: PathBuf::from("/tmp"),
961 env: HashMap::new(),
962 timeout: Duration::from_secs(30),
963 sandbox_type: SandboxType::None,
964 policy: SandboxPolicy::default(),
965 };
966
967 assert_eq!(env.program(), "sandbox-exec");
968 assert_eq!(env.args().len(), 5);
969 }
970
971 #[test]
972 fn test_sandbox_type_display() {
973 assert_eq!(format!("{}", SandboxType::None), "none");
974
975 #[cfg(target_os = "macos")]
976 assert_eq!(format!("{}", SandboxType::MacosSeatbelt), "macos-seatbelt");
977
978 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
979 assert_eq!(format!("{}", SandboxType::LinuxBubblewrap), "linux-bwrap");
980 }
981
982 // ── Parity tests (#2187) ──────────────────────────────────────────────
983
984 #[test]
985 fn test_parity_platform_sandbox_detection() {
986 let sandbox_type = get_platform_sandbox();
987 let available = is_sandbox_available();
988 if available {
989 assert!(sandbox_type.is_some());
990 }
991 }
992
993 #[test]
994 #[cfg(target_os = "macos")]
995 fn test_parity_macos_seatbelt_available() {
996 // Match real runtime availability (`seatbelt::is_available` via
997 // `get_platform_sandbox`), not merely the presence of sandbox-exec or a
998 // diagnostics layer that may report seatbelt at another boundary.
999 // On hosts where sandbox-exec exists but is denied (e.g. some CI /
1000 // restricted macOS environments), skip rather than asserting a false
1001 // positive.
1002 match get_platform_sandbox() {
1003 Some(SandboxType::MacosSeatbelt) => {}
1004 None => {
1005 eprintln!("skipping: MacosSeatbelt unavailable via get_platform_sandbox()");
1006 }
1007 Some(other) => panic!("unexpected macOS sandbox type: {other:?}"),
1008 }
1009 }
1010
1011 #[test]
1012 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1013 fn linux_default_never_claims_an_unwired_sandbox() {
1014 assert_eq!(get_platform_sandbox(), None);
1015 assert_eq!(get_platform_sandbox_with_bwrap_preference(false), None);
1016 }
1017
1018 #[test]
1019 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1020 fn linux_bwrap_selection_requires_opt_in_and_executable() {
1021 let expected = bwrap::is_available().then_some(SandboxType::LinuxBubblewrap);
1022 assert_eq!(get_platform_sandbox_with_bwrap_preference(true), expected);
1023
1024 let manager = SandboxManager::with_bwrap_preference(true);
1025 let selected = manager.select_sandbox(&SandboxPolicy::default());
1026 assert_eq!(selected, expected.unwrap_or(SandboxType::None));
1027 }
1028
1029 #[test]
1030 fn test_parity_denial_zero_exit_never_denied() {
1031 assert!(!SandboxManager::was_denied(
1032 SandboxType::None,
1033 0,
1034 "anything"
1035 ));
1036 #[cfg(target_os = "macos")]
1037 assert!(!SandboxManager::was_denied(
1038 SandboxType::MacosSeatbelt,
1039 0,
1040 ""
1041 ));
1042 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1043 assert!(!SandboxManager::was_denied(
1044 SandboxType::LinuxBubblewrap,
1045 0,
1046 ""
1047 ));
1048 #[cfg(target_os = "windows")]
1049 assert!(!SandboxManager::was_denied(SandboxType::Windows, 0, ""));
1050 }
1051
1052 #[test]
1053 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1054 fn bwrap_denial_is_not_inferred_from_seccomp_text() {
1055 assert!(!SandboxManager::was_denied(
1056 SandboxType::LinuxBubblewrap,
1057 1,
1058 "Bad system call"
1059 ));
1060 assert!(SandboxManager::was_denied(
1061 SandboxType::LinuxBubblewrap,
1062 1,
1063 "Read-only file system"
1064 ));
1065 }
1066
1067 #[test]
1068 #[cfg(target_os = "macos")]
1069 fn test_parity_seatbelt_file_write_detected() {
1070 // Seatbelt patterns use "Sandbox: <cmd> denied <operation>" format.
1071 assert!(SandboxManager::was_denied(
1072 SandboxType::MacosSeatbelt,
1073 1,
1074 "Sandbox: ls denied file-write*"
1075 ));
1076 assert!(SandboxManager::was_denied(
1077 SandboxType::MacosSeatbelt,
1078 1,
1079 "Operation not permitted"
1080 ));
1081 }
1082
1083 #[test]
1084 #[cfg(target_os = "macos")]
1085 fn sandbox_child_env_exports_codewhale_marker_and_legacy_alias() {
1086 let manager = SandboxManager {
1087 forced_sandbox: Some(SandboxType::MacosSeatbelt),
1088 ..SandboxManager::default()
1089 };
1090 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5));
1091 let env = manager.prepare(&spec);
1092
1093 assert_eq!(
1094 env.env.get("CODEWHALE_SANDBOX").map(String::as_str),
1095 Some("seatbelt")
1096 );
1097 assert_eq!(
1098 env.env.get("DEEPSEEK_SANDBOX").map(String::as_str),
1099 Some("seatbelt")
1100 );
1101 }
1102
1103 #[test]
1104 fn test_parity_manager_default_no_bwrap() {
1105 let manager = SandboxManager::default();
1106 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1107 .with_policy(SandboxPolicy::default());
1108 let env = manager.prepare(&spec);
1109 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1110 {
1111 let primary_marker = env.env.get("CODEWHALE_SANDBOX");
1112 let marker = env.env.get("DEEPSEEK_SANDBOX");
1113 assert!(primary_marker.is_none());
1114 assert!(marker.is_none());
1115 assert_eq!(env.sandbox_type, SandboxType::None);
1116 }
1117 let _ = env;
1118 }
1119
1120 #[test]
1121 fn test_parity_manager_with_bwrap() {
1122 let manager = SandboxManager::with_bwrap_preference(true);
1123 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1124 .with_policy(SandboxPolicy::default());
1125 let env = manager.prepare(&spec);
1126 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1127 {
1128 if crate::sandbox::bwrap::is_available() {
1129 let primary_marker = env.env.get("CODEWHALE_SANDBOX");
1130 let marker = env.env.get("DEEPSEEK_SANDBOX");
1131 assert_eq!(primary_marker.map(String::as_str), Some("bwrap"));
1132 assert_eq!(marker.map(String::as_str), Some("bwrap"));
1133 assert_eq!(env.sandbox_type, SandboxType::LinuxBubblewrap);
1134 assert_eq!(env.program(), bwrap::BWRAP_PATH);
1135 } else {
1136 assert_eq!(env.sandbox_type, SandboxType::None);
1137 assert!(!env.env.contains_key("CODEWHALE_SANDBOX"));
1138 assert!(!env.env.contains_key("DEEPSEEK_SANDBOX"));
1139 }
1140 }
1141 let _ = env;
1142 }
1143
1144 #[test]
1145 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1146 fn bwrap_read_only_policy_keeps_the_working_directory_read_only() {
1147 let manager = SandboxManager {
1148 forced_sandbox: Some(SandboxType::LinuxBubblewrap),
1149 ..SandboxManager::default()
1150 };
1151 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1152 .with_policy(SandboxPolicy::ReadOnly);
1153 let env = manager.prepare(&spec);
1154
1155 assert_eq!(env.sandbox_type, SandboxType::LinuxBubblewrap);
1156 assert!(!env.command.iter().any(|arg| arg == "--bind"));
1157 assert!(!env.command.iter().any(|arg| arg == "--share-net"));
1158 }
1159
1160 #[test]
1161 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1162 fn bwrap_workspace_policy_maps_additional_roots_and_network_access() {
1163 let dir = tempfile::tempdir().expect("tempdir");
1164 let workspace = dir.path().join("workspace");
1165 let extra = dir.path().join("extra");
1166 std::fs::create_dir_all(&workspace).expect("workspace");
1167 std::fs::create_dir_all(&extra).expect("extra");
1168
1169 let manager = SandboxManager {
1170 forced_sandbox: Some(SandboxType::LinuxBubblewrap),
1171 ..SandboxManager::default()
1172 };
1173 let policy = SandboxPolicy::WorkspaceWrite {
1174 writable_roots: vec![extra.clone()],
1175 network_access: true,
1176 exclude_tmpdir: true,
1177 exclude_slash_tmp: true,
1178 };
1179 let spec = CommandSpec::shell("true", workspace.clone(), Duration::from_secs(5))
1180 .with_policy(policy);
1181 let env = manager.prepare(&spec);
1182
1183 for root in [workspace, extra] {
1184 let root = root
1185 .canonicalize()
1186 .expect("writable root")
1187 .to_string_lossy()
1188 .into_owned();
1189 assert!(env.command.windows(3).any(|args| args[0] == "--bind"
1190 && args[1].as_str() == root.as_str()
1191 && args[2].as_str() == root.as_str()));
1192 }
1193 assert!(env.command.iter().any(|arg| arg == "--share-net"));
1194 }
1195
1196 #[test]
1197 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1198 fn full_access_and_external_policies_bypass_forced_bwrap() {
1199 let manager = SandboxManager {
1200 forced_sandbox: Some(SandboxType::LinuxBubblewrap),
1201 ..SandboxManager::default()
1202 };
1203
1204 for policy in [
1205 SandboxPolicy::DangerFullAccess,
1206 SandboxPolicy::ExternalSandbox {
1207 network_access: false,
1208 },
1209 ] {
1210 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1211 .with_policy(policy);
1212 let env = manager.prepare(&spec);
1213 assert_eq!(env.sandbox_type, SandboxType::None);
1214 assert_ne!(env.program(), bwrap::BWRAP_PATH);
1215 }
1216 }
1217
1218 #[test]
1219 fn test_parity_exec_env_for_all_policies() {
1220 let manager = SandboxManager::new();
1221 let policies = [
1222 SandboxPolicy::DangerFullAccess,
1223 SandboxPolicy::ReadOnly,
1224 SandboxPolicy::workspace_with_network(),
1225 SandboxPolicy::default(),
1226 ];
1227 for policy in &policies {
1228 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1229 .with_policy(policy.clone());
1230 let env = manager.prepare(&spec);
1231 assert_eq!(env.policy, *policy);
1232 }
1233 }
1234 }
1235
1235 lines RUST