返回 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
40 #[cfg(target_os = "macos")]
41 pub mod seatbelt;
42
43 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
44 pub mod seccomp;
45
46 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
47 pub mod bwrap;
48
49 #[cfg(target_os = "windows")]
50 pub mod windows;
51
52 use std::collections::HashMap;
53 use std::path::PathBuf;
54 use std::time::Duration;
55
56 pub use policy::SandboxPolicy;
57
58 /// Public OS-sandbox capability labels consumed by the website facts
59 /// generator. Keep this list limited to wrappers that the command execution
60 /// path can actually select and apply.
61 #[allow(dead_code)] // Parsed from source by web/scripts/facts-lib.mjs.
62 pub const PUBLIC_SANDBOX_BACKENDS: &[&str] = &[
63 "seatbelt (macOS, when available)",
64 "bubblewrap (Linux, opt-in when installed)",
65 ];
66
67 /// Specification for a command to be executed, potentially within a sandbox.
68 ///
69 /// This struct captures all the information needed to execute a command:
70 /// the program and arguments, working directory, environment variables,
71 /// timeout, and sandbox policy.
72 #[derive(Debug, Clone)]
73 pub struct CommandSpec {
74 /// The program to execute (e.g., "sh", "python", "cargo").
75 pub program: String,
76
77 /// Arguments to pass to the program.
78 pub args: Vec<String>,
79
80 /// Working directory for the command.
81 pub cwd: PathBuf,
82
83 /// Additional environment variables to set.
84 pub env: HashMap<String, String>,
85
86 /// Maximum execution time before the command is killed.
87 pub timeout: Duration,
88
89 /// Sandbox policy controlling resource access.
90 pub sandbox_policy: SandboxPolicy,
91
92 /// Optional justification for why this command needs to run.
93 /// Used for logging and audit purposes.
94 pub justification: Option<String>,
95
96 /// The shell command exactly as requested, before the dispatcher adds
97 /// shell-specific wrapping (encoding prefixes, exit-code capture, temp
98 /// `-File` scripts). Authoritative for display; `None` for specs built
99 /// directly from a program + args.
100 pub requested_command: Option<String>,
101 }
102
103 impl CommandSpec {
104 /// Create a `CommandSpec` for running a shell command via the platform shell.
105 pub fn shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self {
106 let dispatcher = crate::shell_dispatcher::global_dispatcher();
107
108 #[cfg(windows)]
109 let (program, args) = {
110 // Force UTF-8 output. cmd.exe uses chcp; PowerShell sets the
111 // console output encoding directly. See issue #982.
112 let kind = dispatcher.kind();
113 let cmd = if matches!(
114 kind,
115 crate::shell_dispatcher::ShellKind::Pwsh
116 | crate::shell_dispatcher::ShellKind::WindowsPowerShell
117 ) {
118 format!("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; {command}")
119 } else if matches!(kind, crate::shell_dispatcher::ShellKind::Cmd) {
120 format!("chcp 65001 >NUL & {command}")
121 } else {
122 command.to_string()
123 };
124 dispatcher.build_command_parts(&cmd)
125 };
126 #[cfg(not(windows))]
127 let (program, args) = dispatcher.build_command_parts(command);
128
129 let env = {
130 #[cfg(windows)]
131 {
132 windows_shell_default_env()
133 }
134 #[cfg(not(windows))]
135 {
136 HashMap::new()
137 }
138 };
139
140 Self {
141 program,
142 args,
143 cwd,
144 env,
145 timeout,
146 sandbox_policy: SandboxPolicy::default(),
147 justification: None,
148 requested_command: Some(command.to_string()),
149 }
150 }
151
152 /// Create a `CommandSpec` for running a program directly.
153 pub fn program(program: &str, args: Vec<String>, cwd: PathBuf, timeout: Duration) -> Self {
154 Self {
155 program: program.to_string(),
156 args,
157 cwd,
158 env: HashMap::new(),
159 timeout,
160 sandbox_policy: SandboxPolicy::default(),
161 justification: None,
162 requested_command: None,
163 }
164 }
165
166 /// Set the sandbox policy for this command.
167 pub fn with_policy(mut self, policy: SandboxPolicy) -> Self {
168 self.sandbox_policy = policy;
169 self
170 }
171
172 /// Add environment variables for this command.
173 pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
174 self.env = env;
175 self
176 }
177
178 /// Add a single environment variable.
179 pub fn with_env_var(mut self, key: &str, value: &str) -> Self {
180 self.env.insert(key.to_string(), value.to_string());
181 self
182 }
183
184 /// Set a justification for this command (for logging/audit).
185 pub fn with_justification(mut self, justification: &str) -> Self {
186 self.justification = Some(justification.to_string());
187 self
188 }
189
190 /// Get the original command as a single string (for display).
191 pub fn display_command(&self) -> String {
192 if let Some(requested) = &self.requested_command {
193 return requested.clone();
194 }
195 if self.args.len() == 2
196 && self.args[0] == "-c"
197 && matches!(
198 self.program.as_str(),
199 "sh" | "bash" | "/bin/sh" | "/bin/bash" | "/usr/bin/sh" | "/usr/bin/bash"
200 )
201 {
202 // For shell commands, show the actual command
203 self.args[1].clone()
204 } else if self.args.len() == 2
205 && self.args[0] == "-c"
206 && !self.program.eq_ignore_ascii_case("cmd")
207 && !self.program.eq_ignore_ascii_case("pwsh")
208 && !self.program.eq_ignore_ascii_case("pwsh.exe")
209 && !self.program.eq_ignore_ascii_case("powershell")
210 && !self.program.eq_ignore_ascii_case("powershell.exe")
211 {
212 self.args[1].clone()
213 } else if self.program.eq_ignore_ascii_case("cmd")
214 && self.args.len() == 2
215 && self.args[0].eq_ignore_ascii_case("/C")
216 {
217 // Strip the `chcp 65001 >NUL & ` prefix we add on Windows for
218 // UTF-8 output (issue #982).
219 let raw = &self.args[1];
220 raw.strip_prefix("chcp 65001 >NUL & ")
221 .unwrap_or(raw)
222 .to_string()
223 } else if {
224 let program = self.program.to_ascii_lowercase();
225 program == "pwsh"
226 || program == "pwsh.exe"
227 || program == "powershell"
228 || program == "powershell.exe"
229 } && self.args.len() >= 3
230 && self.args[0].eq_ignore_ascii_case("-NoProfile")
231 && self.args[1].eq_ignore_ascii_case("-Command")
232 {
233 // Strip the PowerShell encoding prefix.
234 let raw = &self.args[2];
235 raw.strip_prefix("[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; ")
236 .unwrap_or(raw)
237 .to_string()
238 } else {
239 // For other commands, join program and args
240 let mut parts = vec![self.program.clone()];
241 parts.extend(self.args.clone());
242 parts.join(" ")
243 }
244 }
245 }
246
247 fn windows_shell_default_env() -> HashMap<String, String> {
248 HashMap::from([("PYTHONIOENCODING".to_string(), "utf-8".to_string())])
249 }
250
251 /// The type of sandbox being used for execution.
252 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
253 pub enum SandboxType {
254 /// No sandboxing - command runs with full permissions.
255 #[default]
256 None,
257
258 /// macOS Seatbelt (sandbox-exec) sandboxing.
259 #[cfg(target_os = "macos")]
260 MacosSeatbelt,
261
262 /// Linux bubblewrap namespace sandboxing.
263 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
264 LinuxBubblewrap,
265
266 /// Windows process-containment helper.
267 ///
268 /// Not advertised until a helper enforces Job Object cleanup. This does
269 /// not imply filesystem, network, registry, or AppContainer isolation.
270 #[cfg(target_os = "windows")]
271 Windows,
272 }
273
274 impl std::fmt::Display for SandboxType {
275 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
276 match self {
277 SandboxType::None => write!(f, "none"),
278 #[cfg(target_os = "macos")]
279 SandboxType::MacosSeatbelt => write!(f, "macos-seatbelt"),
280 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
281 SandboxType::LinuxBubblewrap => write!(f, "linux-bwrap"),
282 #[cfg(target_os = "windows")]
283 SandboxType::Windows => write!(f, "windows-sandbox"),
284 }
285 }
286 }
287
288 /// The execution environment after sandbox transformation.
289 ///
290 /// This contains the actual command to run (which may include sandbox wrapper
291 /// commands) and all necessary environment configuration.
292 #[derive(Debug)]
293 pub struct ExecEnv {
294 /// The full command to execute (may include sandbox wrapper).
295 pub command: Vec<String>,
296
297 /// Working directory for execution.
298 pub cwd: PathBuf,
299
300 /// Environment variables to set.
301 pub env: HashMap<String, String>,
302
303 /// Timeout for the command.
304 pub timeout: Duration,
305
306 /// The type of sandbox being used.
307 pub sandbox_type: SandboxType,
308
309 /// The original policy (for reference).
310 pub policy: SandboxPolicy,
311 }
312
313 impl ExecEnv {
314 /// Get the program to execute (first element of command).
315 pub fn program(&self) -> &str {
316 self.command
317 .first()
318 .map_or("sh", std::string::String::as_str)
319 }
320
321 /// Get the arguments (all elements after the first).
322 pub fn args(&self) -> &[String] {
323 if self.command.len() > 1 {
324 &self.command[1..]
325 } else {
326 &[]
327 }
328 }
329
330 /// Check if this execution is sandboxed.
331 pub fn is_sandboxed(&self) -> bool {
332 !matches!(self.sandbox_type, SandboxType::None)
333 }
334 }
335
336 /// Detect what sandbox technology is available on the current platform.
337 pub fn get_platform_sandbox() -> Option<SandboxType> {
338 get_platform_sandbox_with_bwrap_preference(false)
339 }
340
341 /// Detect the sandbox wrapper the configured command path can actually use.
342 ///
343 /// Linux bubblewrap is deliberately opt-in. Source-only sandbox prototypes do
344 /// not make commands sandboxed unless the child launch path applies them.
345 pub fn get_platform_sandbox_with_bwrap_preference(prefer_bwrap: bool) -> Option<SandboxType> {
346 #[cfg(target_os = "macos")]
347 {
348 if seatbelt::is_available() {
349 return Some(SandboxType::MacosSeatbelt);
350 }
351 }
352
353 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
354 {
355 if prefer_bwrap && bwrap::is_available() {
356 return Some(SandboxType::LinuxBubblewrap);
357 }
358 }
359
360 #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))]
361 let _ = prefer_bwrap;
362
363 #[cfg(target_os = "windows")]
364 {
365 if windows::is_available() {
366 return Some(SandboxType::Windows);
367 }
368 }
369
370 None
371 }
372
373 /// Check if sandboxing is available on this platform.
374 pub fn is_sandbox_available() -> bool {
375 get_platform_sandbox().is_some()
376 }
377
378 /// Manager for sandbox operations.
379 ///
380 /// The `SandboxManager` is responsible for:
381 /// - Detecting available sandbox technologies
382 /// - Transforming `CommandSpecs` into sandboxed `ExecEnvs`
383 /// - Detecting sandbox denials from command output
384 #[derive(Debug, Default)]
385 pub struct SandboxManager {
386 /// Cached sandbox availability check.
387 sandbox_available: Option<bool>,
388
389 /// Force a specific sandbox type (for testing).
390 #[allow(dead_code)]
391 forced_sandbox: Option<SandboxType>,
392
393 /// When true and bwrap is executable on Linux, route commands through
394 /// bubblewrap (#2184).
395 prefer_bwrap: bool,
396 }
397
398 impl SandboxManager {
399 /// Create a new `SandboxManager`.
400 pub fn new() -> Self {
401 Self::default()
402 }
403
404 /// Create a new `SandboxManager` with bwrap preference (#2184).
405 ///
406 /// When `prefer_bwrap` is true and `/usr/bin/bwrap` is executable on Linux,
407 /// exec_shell commands will be routed through bubblewrap.
408 pub fn with_bwrap_preference(prefer_bwrap: bool) -> Self {
409 Self {
410 prefer_bwrap,
411 ..Self::default()
412 }
413 }
414
415 /// Set the bwrap preference (#2184).
416 pub fn set_prefer_bwrap(&mut self, prefer: bool) {
417 self.prefer_bwrap = prefer;
418 self.sandbox_available = None;
419 }
420
421 /// Check if sandboxing is available.
422 pub fn is_available(&mut self) -> bool {
423 if let Some(available) = self.sandbox_available {
424 return available;
425 }
426
427 let available = self.configured_sandbox().is_some();
428 self.sandbox_available = Some(available);
429 available
430 }
431
432 /// Return the wrapper this manager is configured and able to apply.
433 pub fn configured_sandbox(&self) -> Option<SandboxType> {
434 get_platform_sandbox_with_bwrap_preference(self.prefer_bwrap)
435 }
436
437 /// Select the appropriate sandbox type for the given policy.
438 pub fn select_sandbox(&self, policy: &SandboxPolicy) -> SandboxType {
439 // If the policy doesn't want sandboxing, return None
440 if !policy.should_sandbox() {
441 return SandboxType::None;
442 }
443
444 // Check for forced sandbox (testing)
445 if let Some(forced) = self.forced_sandbox {
446 return forced;
447 }
448
449 self.configured_sandbox().unwrap_or(SandboxType::None)
450 }
451
452 /// Transform a `CommandSpec` into a sandboxed `ExecEnv`.
453 ///
454 /// This is the main entry point for sandboxing. It takes a command
455 /// specification and returns the actual command to run, which may
456 /// include sandbox wrapper commands.
457 pub fn prepare(&self, spec: &CommandSpec) -> ExecEnv {
458 let sandbox_type = self.select_sandbox(&spec.sandbox_policy);
459
460 match sandbox_type {
461 SandboxType::None => Self::prepare_unsandboxed(spec),
462
463 #[cfg(target_os = "macos")]
464 SandboxType::MacosSeatbelt => Self::prepare_seatbelt(spec),
465
466 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
467 SandboxType::LinuxBubblewrap => Self::prepare_bwrap(spec),
468
469 #[cfg(target_os = "windows")]
470 SandboxType::Windows => Self::prepare_windows(spec),
471 }
472 }
473
474 /// Prepare an unsandboxed execution environment.
475 fn prepare_unsandboxed(spec: &CommandSpec) -> ExecEnv {
476 let mut command = vec![spec.program.clone()];
477 command.extend(spec.args.clone());
478
479 ExecEnv {
480 command,
481 cwd: spec.cwd.clone(),
482 env: spec.env.clone(),
483 timeout: spec.timeout,
484 sandbox_type: SandboxType::None,
485 policy: spec.sandbox_policy.clone(),
486 }
487 }
488
489 /// Prepare a Seatbelt-sandboxed execution environment (macOS).
490 #[cfg(target_os = "macos")]
491 fn prepare_seatbelt(spec: &CommandSpec) -> ExecEnv {
492 // Build the original command
493 let mut original_command = vec![spec.program.clone()];
494 original_command.extend(spec.args.clone());
495
496 // Generate sandbox-exec arguments
497 let seatbelt_args =
498 seatbelt::create_seatbelt_args(original_command, &spec.sandbox_policy, &spec.cwd);
499
500 // Prepend sandbox-exec to the command
501 let mut command = vec![seatbelt::SANDBOX_EXEC_PATH.to_string()];
502 command.extend(seatbelt_args);
503
504 // Add sandbox indicator to environment
505 let mut env = spec.env.clone();
506 env.insert("DEEPSEEK_SANDBOX".to_string(), "seatbelt".to_string());
507
508 ExecEnv {
509 command,
510 cwd: spec.cwd.clone(),
511 env,
512 timeout: spec.timeout,
513 sandbox_type: SandboxType::MacosSeatbelt,
514 policy: spec.sandbox_policy.clone(),
515 }
516 }
517
518 /// Prepare a bubblewrap-sandboxed execution environment (Linux).
519 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
520 fn prepare_bwrap(spec: &CommandSpec) -> ExecEnv {
521 let writable_roots = spec.sandbox_policy.get_writable_roots(&spec.cwd);
522 let command = bwrap::build_bwrap_command(
523 &spec.cwd,
524 &spec.program,
525 &spec.args,
526 &writable_roots,
527 spec.sandbox_policy.has_network_access(),
528 );
529
530 let mut env = spec.env.clone();
531 env.insert("DEEPSEEK_SANDBOX".to_string(), "bwrap".to_string());
532
533 ExecEnv {
534 command,
535 cwd: spec.cwd.clone(),
536 env,
537 timeout: spec.timeout,
538 sandbox_type: SandboxType::LinuxBubblewrap,
539 policy: spec.sandbox_policy.clone(),
540 }
541 }
542
543 /// Prepare a Windows helper execution environment.
544 ///
545 /// Windows support is currently not advertised by `get_platform_sandbox`.
546 /// This branch only exists for forced tests and future helper wiring.
547 /// The first supported helper contract is process-tree containment only;
548 /// it must not be presented as filesystem or network isolation.
549 #[cfg(target_os = "windows")]
550 fn prepare_windows(spec: &CommandSpec) -> ExecEnv {
551 let mut command = vec![spec.program.clone()];
552 command.extend(spec.args.clone());
553
554 let mut env = spec.env.clone();
555 let kind = windows::select_best_kind(&spec.sandbox_policy, &spec.cwd);
556 env.insert("DEEPSEEK_SANDBOX".to_string(), format!("windows:{kind}"));
557 if !spec.sandbox_policy.has_network_access() {
558 env.insert(
559 "DEEPSEEK_SANDBOX_BLOCK_NETWORK".to_string(),
560 "1".to_string(),
561 );
562 }
563
564 ExecEnv {
565 command,
566 cwd: spec.cwd.clone(),
567 env,
568 timeout: spec.timeout,
569 sandbox_type: SandboxType::Windows,
570 policy: spec.sandbox_policy.clone(),
571 }
572 }
573
574 /// Check if a command failure was due to sandbox denial.
575 ///
576 /// This helps distinguish between legitimate command failures and
577 /// sandbox-blocked operations.
578 pub fn was_denied(sandbox_type: SandboxType, exit_code: i32, stderr: &str) -> bool {
579 #[cfg(not(any(
580 target_os = "macos",
581 all(target_os = "linux", not(target_env = "ohos"))
582 )))]
583 let _ = (exit_code, stderr);
584
585 match sandbox_type {
586 SandboxType::None => false,
587
588 #[cfg(target_os = "macos")]
589 SandboxType::MacosSeatbelt => seatbelt::detect_denial(exit_code, stderr),
590
591 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
592 SandboxType::LinuxBubblewrap => bwrap::detect_denial(exit_code, stderr),
593
594 #[cfg(target_os = "windows")]
595 SandboxType::Windows => windows::detect_denial(exit_code, stderr),
596 }
597 }
598
599 /// Get a human-readable description of why a command was blocked.
600 pub fn denial_message(sandbox_type: SandboxType, stderr: &str) -> String {
601 #[cfg(not(any(
602 target_os = "macos",
603 all(target_os = "linux", not(target_env = "ohos"))
604 )))]
605 let _ = stderr;
606
607 match sandbox_type {
608 SandboxType::None => "Command failed (no sandbox)".to_string(),
609
610 #[cfg(target_os = "macos")]
611 SandboxType::MacosSeatbelt => {
612 if stderr.contains("file-write") {
613 "Sandbox blocked write access. The command tried to write to a protected location.".to_string()
614 } else if stderr.contains("network") {
615 "Sandbox blocked network access. Enable network_access in sandbox policy if needed.".to_string()
616 } else {
617 format!(
618 "Sandbox blocked operation: {}",
619 stderr.lines().next().unwrap_or("unknown")
620 )
621 }
622 }
623
624 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
625 SandboxType::LinuxBubblewrap => {
626 if let Some(error) = stderr
627 .lines()
628 .map(str::trim_start)
629 .find(|line| line.starts_with("bwrap:"))
630 {
631 format!("Bubblewrap could not create the sandbox: {}", error)
632 } else if stderr.contains("Read-only file system") {
633 "Bubblewrap blocked access outside the writable workspace view.".to_string()
634 } else {
635 format!(
636 "Bubblewrap blocked operation: {}",
637 stderr.lines().next().unwrap_or("unknown")
638 )
639 }
640 }
641
642 #[cfg(target_os = "windows")]
643 SandboxType::Windows => {
644 if stderr.contains("Access is denied") {
645 "Windows sandbox blocked access. The command lacked required privileges."
646 .to_string()
647 } else if stderr.contains("network") {
648 "Windows sandbox blocked network access. Enable network_access in policy if needed."
649 .to_string()
650 } else {
651 format!(
652 "Windows sandbox blocked operation: {}",
653 stderr.lines().next().unwrap_or("unknown")
654 )
655 }
656 }
657 }
658 }
659 }
660
661 #[cfg(test)]
662 mod tests {
663 use super::*;
664
665 #[test]
666 fn test_command_spec_shell() {
667 let spec = CommandSpec::shell("echo hello", PathBuf::from("/tmp"), Duration::from_secs(30));
668
669 // Program and args depend on the detected shell.
670 assert!(!spec.program.is_empty(), "program must not be empty");
671 assert!(!spec.args.is_empty(), "args must not be empty");
672 assert_eq!(spec.display_command(), "echo hello");
673 }
674
675 #[test]
676 fn test_command_spec_shell_custom_posix_path_display() {
677 let spec = CommandSpec {
678 program: "/bin/zsh".to_string(),
679 args: vec!["-c".to_string(), "echo hello".to_string()],
680 cwd: PathBuf::from("/tmp"),
681 env: HashMap::new(),
682 timeout: Duration::from_secs(30),
683 sandbox_policy: SandboxPolicy::default(),
684 justification: None,
685 requested_command: None,
686 };
687
688 assert_eq!(spec.display_command(), "echo hello");
689 }
690
691 #[test]
692 fn test_command_spec_shell_quoted_arg_not_split() {
693 // Regression for #1691: a `-m` message containing spaces must remain a
694 // single, unsplit argv entry. The shell command string is passed
695 // verbatim as ONE argument (`sh -c <cmd>` / `cmd /C <payload>`); we
696 // must never tokenize it ourselves into `feat:` / `complete` /
697 // `sub-pages"`.
698 let cmd = r#"git commit -m "feat: complete sub-pages""#;
699 let spec = CommandSpec::shell(cmd, PathBuf::from("/tmp"), Duration::from_secs(30));
700
701 let dispatcher = crate::shell_dispatcher::global_dispatcher();
702 assert_eq!(spec.program, dispatcher.kind().binary());
703 // The quoted message survives in exactly ONE argv slot, regardless of
704 // which shell-specific wrapping (encoding prefix, exit-code capture)
705 // the dispatcher added around it. This single-line ASCII command never
706 // takes the temp `-File` path, so the payload stays on the argv.
707 let carriers: Vec<&String> = spec
708 .args
709 .iter()
710 .filter(|arg| arg.contains(r#""feat: complete sub-pages""#))
711 .collect();
712 assert_eq!(carriers.len(), 1, "args: {:?}", spec.args);
713 // And no argv entry is a tokenized fragment of the message.
714 assert!(
715 !spec
716 .args
717 .iter()
718 .any(|arg| arg == "feat:" || arg == "complete" || arg == "sub-pages\""),
719 "args: {:?}",
720 spec.args
721 );
722 assert_eq!(spec.display_command(), cmd);
723 }
724
725 #[test]
726 fn test_command_spec_program() {
727 let spec = CommandSpec::program(
728 "cargo",
729 vec!["build".to_string(), "--release".to_string()],
730 PathBuf::from("/project"),
731 Duration::from_secs(300),
732 );
733
734 assert_eq!(spec.program, "cargo");
735 assert_eq!(spec.display_command(), "cargo build --release");
736 }
737
738 #[test]
739 fn test_command_spec_builder() {
740 let spec = CommandSpec::shell("test", PathBuf::from("."), Duration::from_secs(10))
741 .with_policy(SandboxPolicy::ReadOnly)
742 .with_env_var("FOO", "bar")
743 .with_justification("Testing");
744
745 assert!(matches!(spec.sandbox_policy, SandboxPolicy::ReadOnly));
746 assert_eq!(spec.env.get("FOO"), Some(&"bar".to_string()));
747 assert_eq!(spec.justification, Some("Testing".to_string()));
748 }
749
750 #[test]
751 fn windows_shell_default_env_forces_python_pipe_stdio_utf8() {
752 let env = windows_shell_default_env();
753
754 assert_eq!(
755 env.get("PYTHONIOENCODING").map(String::as_str),
756 Some("utf-8")
757 );
758 }
759
760 #[test]
761 fn test_sandbox_manager_new() {
762 let manager = SandboxManager::new();
763 assert!(manager.sandbox_available.is_none());
764 }
765
766 #[test]
767 fn test_sandbox_manager_select_sandbox() {
768 let manager = SandboxManager::new();
769
770 // DangerFullAccess should never sandbox
771 let no_sandbox = manager.select_sandbox(&SandboxPolicy::DangerFullAccess);
772 assert_eq!(no_sandbox, SandboxType::None);
773
774 // ExternalSandbox should never sandbox
775 let external = manager.select_sandbox(&SandboxPolicy::ExternalSandbox {
776 network_access: true,
777 });
778 assert_eq!(external, SandboxType::None);
779 }
780
781 #[test]
782 fn test_prepare_unsandboxed() {
783 let manager = SandboxManager::new();
784 let spec = CommandSpec::shell("echo test", PathBuf::from("/tmp"), Duration::from_secs(30))
785 .with_policy(SandboxPolicy::DangerFullAccess);
786
787 let env = manager.prepare(&spec);
788
789 assert_eq!(env.sandbox_type, SandboxType::None);
790 // Unsandboxed preparation passes the spec through untouched: the
791 // command is exactly the spec's program followed by the dispatcher-
792 // built args, whatever wrapping the current shell required.
793 let mut expected = vec![spec.program.clone()];
794 expected.extend(spec.args.iter().cloned());
795 assert_eq!(env.command, expected);
796 assert!(!env.is_sandboxed());
797 }
798
799 #[test]
800 fn test_exec_env_helpers() {
801 let env = ExecEnv {
802 command: vec![
803 "sandbox-exec".to_string(),
804 "-p".to_string(),
805 "policy".to_string(),
806 "--".to_string(),
807 "echo".to_string(),
808 "hello".to_string(),
809 ],
810 cwd: PathBuf::from("/tmp"),
811 env: HashMap::new(),
812 timeout: Duration::from_secs(30),
813 sandbox_type: SandboxType::None,
814 policy: SandboxPolicy::default(),
815 };
816
817 assert_eq!(env.program(), "sandbox-exec");
818 assert_eq!(env.args().len(), 5);
819 }
820
821 #[test]
822 fn test_sandbox_type_display() {
823 assert_eq!(format!("{}", SandboxType::None), "none");
824
825 #[cfg(target_os = "macos")]
826 assert_eq!(format!("{}", SandboxType::MacosSeatbelt), "macos-seatbelt");
827
828 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
829 assert_eq!(format!("{}", SandboxType::LinuxBubblewrap), "linux-bwrap");
830 }
831
832 // ── Parity tests (#2187) ──────────────────────────────────────────────
833
834 #[test]
835 fn test_parity_platform_sandbox_detection() {
836 let sandbox_type = get_platform_sandbox();
837 let available = is_sandbox_available();
838 if available {
839 assert!(sandbox_type.is_some());
840 }
841 }
842
843 #[test]
844 #[cfg(target_os = "macos")]
845 fn test_parity_macos_seatbelt_available() {
846 // Match real runtime availability (`seatbelt::is_available` via
847 // `get_platform_sandbox`), not merely the presence of sandbox-exec or a
848 // diagnostics layer that may report seatbelt at another boundary.
849 // On hosts where sandbox-exec exists but is denied (e.g. some CI /
850 // restricted macOS environments), skip rather than asserting a false
851 // positive.
852 match get_platform_sandbox() {
853 Some(SandboxType::MacosSeatbelt) => {}
854 None => {
855 eprintln!("skipping: MacosSeatbelt unavailable via get_platform_sandbox()");
856 }
857 Some(other) => panic!("unexpected macOS sandbox type: {other:?}"),
858 }
859 }
860
861 #[test]
862 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
863 fn linux_default_never_claims_an_unwired_sandbox() {
864 assert_eq!(get_platform_sandbox(), None);
865 assert_eq!(get_platform_sandbox_with_bwrap_preference(false), None);
866 }
867
868 #[test]
869 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
870 fn linux_bwrap_selection_requires_opt_in_and_executable() {
871 let expected = bwrap::is_available().then_some(SandboxType::LinuxBubblewrap);
872 assert_eq!(get_platform_sandbox_with_bwrap_preference(true), expected);
873
874 let manager = SandboxManager::with_bwrap_preference(true);
875 let selected = manager.select_sandbox(&SandboxPolicy::default());
876 assert_eq!(selected, expected.unwrap_or(SandboxType::None));
877 }
878
879 #[test]
880 fn test_parity_denial_zero_exit_never_denied() {
881 assert!(!SandboxManager::was_denied(
882 SandboxType::None,
883 0,
884 "anything"
885 ));
886 #[cfg(target_os = "macos")]
887 assert!(!SandboxManager::was_denied(
888 SandboxType::MacosSeatbelt,
889 0,
890 ""
891 ));
892 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
893 assert!(!SandboxManager::was_denied(
894 SandboxType::LinuxBubblewrap,
895 0,
896 ""
897 ));
898 #[cfg(target_os = "windows")]
899 assert!(!SandboxManager::was_denied(SandboxType::Windows, 0, ""));
900 }
901
902 #[test]
903 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
904 fn bwrap_denial_is_not_inferred_from_seccomp_text() {
905 assert!(!SandboxManager::was_denied(
906 SandboxType::LinuxBubblewrap,
907 1,
908 "Bad system call"
909 ));
910 assert!(SandboxManager::was_denied(
911 SandboxType::LinuxBubblewrap,
912 1,
913 "Read-only file system"
914 ));
915 }
916
917 #[test]
918 #[cfg(target_os = "macos")]
919 fn test_parity_seatbelt_file_write_detected() {
920 // Seatbelt patterns use "Sandbox: <cmd> denied <operation>" format.
921 assert!(SandboxManager::was_denied(
922 SandboxType::MacosSeatbelt,
923 1,
924 "Sandbox: ls denied file-write*"
925 ));
926 assert!(SandboxManager::was_denied(
927 SandboxType::MacosSeatbelt,
928 1,
929 "Operation not permitted"
930 ));
931 }
932
933 #[test]
934 fn test_parity_manager_default_no_bwrap() {
935 let manager = SandboxManager::default();
936 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
937 .with_policy(SandboxPolicy::default());
938 let env = manager.prepare(&spec);
939 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
940 {
941 let marker = env.env.get("DEEPSEEK_SANDBOX");
942 assert!(marker.is_none());
943 assert_eq!(env.sandbox_type, SandboxType::None);
944 }
945 let _ = env;
946 }
947
948 #[test]
949 fn test_parity_manager_with_bwrap() {
950 let manager = SandboxManager::with_bwrap_preference(true);
951 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
952 .with_policy(SandboxPolicy::default());
953 let env = manager.prepare(&spec);
954 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
955 {
956 if crate::sandbox::bwrap::is_available() {
957 let marker = env.env.get("DEEPSEEK_SANDBOX");
958 assert_eq!(marker.map(String::as_str), Some("bwrap"));
959 assert_eq!(env.sandbox_type, SandboxType::LinuxBubblewrap);
960 assert_eq!(env.program(), bwrap::BWRAP_PATH);
961 } else {
962 assert_eq!(env.sandbox_type, SandboxType::None);
963 assert!(!env.env.contains_key("DEEPSEEK_SANDBOX"));
964 }
965 }
966 let _ = env;
967 }
968
969 #[test]
970 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
971 fn bwrap_read_only_policy_keeps_the_working_directory_read_only() {
972 let manager = SandboxManager {
973 forced_sandbox: Some(SandboxType::LinuxBubblewrap),
974 ..SandboxManager::default()
975 };
976 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
977 .with_policy(SandboxPolicy::ReadOnly);
978 let env = manager.prepare(&spec);
979
980 assert_eq!(env.sandbox_type, SandboxType::LinuxBubblewrap);
981 assert!(!env.command.iter().any(|arg| arg == "--bind"));
982 assert!(!env.command.iter().any(|arg| arg == "--share-net"));
983 }
984
985 #[test]
986 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
987 fn bwrap_workspace_policy_maps_additional_roots_and_network_access() {
988 let dir = tempfile::tempdir().expect("tempdir");
989 let workspace = dir.path().join("workspace");
990 let extra = dir.path().join("extra");
991 std::fs::create_dir_all(&workspace).expect("workspace");
992 std::fs::create_dir_all(&extra).expect("extra");
993
994 let manager = SandboxManager {
995 forced_sandbox: Some(SandboxType::LinuxBubblewrap),
996 ..SandboxManager::default()
997 };
998 let policy = SandboxPolicy::WorkspaceWrite {
999 writable_roots: vec![extra.clone()],
1000 network_access: true,
1001 exclude_tmpdir: true,
1002 exclude_slash_tmp: true,
1003 };
1004 let spec = CommandSpec::shell("true", workspace.clone(), Duration::from_secs(5))
1005 .with_policy(policy);
1006 let env = manager.prepare(&spec);
1007
1008 for root in [workspace, extra] {
1009 let root = root
1010 .canonicalize()
1011 .expect("writable root")
1012 .to_string_lossy()
1013 .into_owned();
1014 assert!(env.command.windows(3).any(|args| args[0] == "--bind"
1015 && args[1].as_str() == root.as_str()
1016 && args[2].as_str() == root.as_str()));
1017 }
1018 assert!(env.command.iter().any(|arg| arg == "--share-net"));
1019 }
1020
1021 #[test]
1022 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
1023 fn full_access_and_external_policies_bypass_forced_bwrap() {
1024 let manager = SandboxManager {
1025 forced_sandbox: Some(SandboxType::LinuxBubblewrap),
1026 ..SandboxManager::default()
1027 };
1028
1029 for policy in [
1030 SandboxPolicy::DangerFullAccess,
1031 SandboxPolicy::ExternalSandbox {
1032 network_access: false,
1033 },
1034 ] {
1035 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1036 .with_policy(policy);
1037 let env = manager.prepare(&spec);
1038 assert_eq!(env.sandbox_type, SandboxType::None);
1039 assert_ne!(env.program(), bwrap::BWRAP_PATH);
1040 }
1041 }
1042
1043 #[test]
1044 fn test_parity_exec_env_for_all_policies() {
1045 let manager = SandboxManager::new();
1046 let policies = [
1047 SandboxPolicy::DangerFullAccess,
1048 SandboxPolicy::ReadOnly,
1049 SandboxPolicy::workspace_with_network(),
1050 SandboxPolicy::default(),
1051 ];
1052 for policy in &policies {
1053 let spec = CommandSpec::shell("true", PathBuf::from("/tmp"), Duration::from_secs(5))
1054 .with_policy(policy.clone());
1055 let env = manager.prepare(&spec);
1056 assert_eq!(env.policy, *policy);
1057 }
1058 }
1059 }
1060
1060 lines RUST