返回 DeepSeek-TUI-2026
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 //! DeepSeek TUI. 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) for mandatory access control
12 //! - **Linux**: Uses Landlock (kernel 5.13+) for filesystem access control
13 //! - **Windows**: Windows Sandbox/AppContainer/Restricted token (best-effort)
14 //!
15 //! # Usage
16 //!
17 //! ```rust,ignore
18 //! use sandbox::{SandboxManager, CommandSpec, SandboxPolicy};
19 //!
20 //! let manager = SandboxManager::new();
21 //! let spec = CommandSpec::shell("ls -la", PathBuf::from("."), Duration::from_secs(30))
22 //! .with_policy(SandboxPolicy::default());
23 //!
24 //! let exec_env = manager.prepare(&spec);
25 //! // exec_env.command now contains the sandboxed command
26 //! ```
27
28 pub mod backend;
29 pub mod opensandbox;
30 pub mod policy;
31
32 #[cfg(target_os = "macos")]
33 pub mod seatbelt;
34
35 #[cfg(target_os = "linux")]
36 pub mod landlock;
37
38 #[cfg(target_os = "windows")]
39 pub mod windows;
40
41 use std::collections::HashMap;
42 use std::path::PathBuf;
43 use std::time::Duration;
44
45 pub use policy::SandboxPolicy;
46
47 /// Specification for a command to be executed, potentially within a sandbox.
48 ///
49 /// This struct captures all the information needed to execute a command:
50 /// the program and arguments, working directory, environment variables,
51 /// timeout, and sandbox policy.
52 #[derive(Debug, Clone)]
53 pub struct CommandSpec {
54 /// The program to execute (e.g., "sh", "python", "cargo").
55 pub program: String,
56
57 /// Arguments to pass to the program.
58 pub args: Vec<String>,
59
60 /// Working directory for the command.
61 pub cwd: PathBuf,
62
63 /// Additional environment variables to set.
64 pub env: HashMap<String, String>,
65
66 /// Maximum execution time before the command is killed.
67 pub timeout: Duration,
68
69 /// Sandbox policy controlling resource access.
70 pub sandbox_policy: SandboxPolicy,
71
72 /// Optional justification for why this command needs to run.
73 /// Used for logging and audit purposes.
74 pub justification: Option<String>,
75 }
76
77 impl CommandSpec {
78 /// Create a `CommandSpec` for running a shell command via the platform shell.
79 pub fn shell(command: &str, cwd: PathBuf, timeout: Duration) -> Self {
80 #[cfg(windows)]
81 let (program, args) = (
82 "cmd".to_string(),
83 vec!["/C".to_string(), command.to_string()],
84 );
85 #[cfg(not(windows))]
86 let (program, args) = (
87 "sh".to_string(),
88 vec!["-c".to_string(), command.to_string()],
89 );
90
91 Self {
92 program,
93 args,
94 cwd,
95 env: HashMap::new(),
96 timeout,
97 sandbox_policy: SandboxPolicy::default(),
98 justification: None,
99 }
100 }
101
102 /// Create a `CommandSpec` for running a program directly.
103 pub fn program(program: &str, args: Vec<String>, cwd: PathBuf, timeout: Duration) -> Self {
104 Self {
105 program: program.to_string(),
106 args,
107 cwd,
108 env: HashMap::new(),
109 timeout,
110 sandbox_policy: SandboxPolicy::default(),
111 justification: None,
112 }
113 }
114
115 /// Set the sandbox policy for this command.
116 pub fn with_policy(mut self, policy: SandboxPolicy) -> Self {
117 self.sandbox_policy = policy;
118 self
119 }
120
121 /// Add environment variables for this command.
122 pub fn with_env(mut self, env: HashMap<String, String>) -> Self {
123 self.env = env;
124 self
125 }
126
127 /// Add a single environment variable.
128 pub fn with_env_var(mut self, key: &str, value: &str) -> Self {
129 self.env.insert(key.to_string(), value.to_string());
130 self
131 }
132
133 /// Set a justification for this command (for logging/audit).
134 pub fn with_justification(mut self, justification: &str) -> Self {
135 self.justification = Some(justification.to_string());
136 self
137 }
138
139 /// Get the original command as a single string (for display).
140 pub fn display_command(&self) -> String {
141 if self.program == "sh" && self.args.len() == 2 && self.args[0] == "-c" {
142 // For shell commands, show the actual command
143 self.args[1].clone()
144 } else if self.program.eq_ignore_ascii_case("cmd")
145 && self.args.len() == 2
146 && self.args[0].eq_ignore_ascii_case("/C")
147 {
148 self.args[1].clone()
149 } else {
150 // For other commands, join program and args
151 let mut parts = vec![self.program.clone()];
152 parts.extend(self.args.clone());
153 parts.join(" ")
154 }
155 }
156 }
157
158 /// The type of sandbox being used for execution.
159 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
160 pub enum SandboxType {
161 /// No sandboxing - command runs with full permissions.
162 #[default]
163 None,
164
165 /// macOS Seatbelt (sandbox-exec) sandboxing.
166 #[cfg(target_os = "macos")]
167 MacosSeatbelt,
168
169 /// Linux Landlock sandboxing (kernel 5.13+).
170 #[cfg(target_os = "linux")]
171 LinuxLandlock,
172
173 /// Windows sandboxing (Windows Sandbox/AppContainer/Restricted token).
174 #[cfg(target_os = "windows")]
175 Windows,
176 }
177
178 impl std::fmt::Display for SandboxType {
179 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180 match self {
181 SandboxType::None => write!(f, "none"),
182 #[cfg(target_os = "macos")]
183 SandboxType::MacosSeatbelt => write!(f, "macos-seatbelt"),
184 #[cfg(target_os = "linux")]
185 SandboxType::LinuxLandlock => write!(f, "linux-landlock"),
186 #[cfg(target_os = "windows")]
187 SandboxType::Windows => write!(f, "windows-sandbox"),
188 }
189 }
190 }
191
192 /// The execution environment after sandbox transformation.
193 ///
194 /// This contains the actual command to run (which may include sandbox wrapper
195 /// commands) and all necessary environment configuration.
196 #[derive(Debug)]
197 pub struct ExecEnv {
198 /// The full command to execute (may include sandbox wrapper).
199 pub command: Vec<String>,
200
201 /// Working directory for execution.
202 pub cwd: PathBuf,
203
204 /// Environment variables to set.
205 pub env: HashMap<String, String>,
206
207 /// Timeout for the command.
208 pub timeout: Duration,
209
210 /// The type of sandbox being used.
211 pub sandbox_type: SandboxType,
212
213 /// The original policy (for reference).
214 pub policy: SandboxPolicy,
215 }
216
217 impl ExecEnv {
218 /// Get the program to execute (first element of command).
219 pub fn program(&self) -> &str {
220 self.command
221 .first()
222 .map_or("sh", std::string::String::as_str)
223 }
224
225 /// Get the arguments (all elements after the first).
226 pub fn args(&self) -> &[String] {
227 if self.command.len() > 1 {
228 &self.command[1..]
229 } else {
230 &[]
231 }
232 }
233
234 /// Check if this execution is sandboxed.
235 pub fn is_sandboxed(&self) -> bool {
236 !matches!(self.sandbox_type, SandboxType::None)
237 }
238 }
239
240 /// Detect what sandbox technology is available on the current platform.
241 pub fn get_platform_sandbox() -> Option<SandboxType> {
242 #[cfg(target_os = "macos")]
243 {
244 if seatbelt::is_available() {
245 return Some(SandboxType::MacosSeatbelt);
246 }
247 }
248
249 #[cfg(target_os = "linux")]
250 {
251 if landlock::is_available() {
252 return Some(SandboxType::LinuxLandlock);
253 }
254 }
255
256 #[cfg(target_os = "windows")]
257 {
258 if windows::is_available() {
259 return Some(SandboxType::Windows);
260 }
261 }
262
263 None
264 }
265
266 /// Check if sandboxing is available on this platform.
267 pub fn is_sandbox_available() -> bool {
268 get_platform_sandbox().is_some()
269 }
270
271 /// Manager for sandbox operations.
272 ///
273 /// The `SandboxManager` is responsible for:
274 /// - Detecting available sandbox technologies
275 /// - Transforming `CommandSpecs` into sandboxed `ExecEnvs`
276 /// - Detecting sandbox denials from command output
277 #[derive(Debug, Default)]
278 pub struct SandboxManager {
279 /// Cached sandbox availability check.
280 sandbox_available: Option<bool>,
281
282 /// Force a specific sandbox type (for testing).
283 #[allow(dead_code)]
284 forced_sandbox: Option<SandboxType>,
285 }
286
287 impl SandboxManager {
288 /// Create a new `SandboxManager`.
289 pub fn new() -> Self {
290 Self {
291 sandbox_available: None,
292 forced_sandbox: None,
293 }
294 }
295
296 /// Check if sandboxing is available.
297 pub fn is_available(&mut self) -> bool {
298 if let Some(available) = self.sandbox_available {
299 return available;
300 }
301
302 let available = is_sandbox_available();
303 self.sandbox_available = Some(available);
304 available
305 }
306
307 /// Select the appropriate sandbox type for the given policy.
308 pub fn select_sandbox(&self, policy: &SandboxPolicy) -> SandboxType {
309 // If the policy doesn't want sandboxing, return None
310 if !policy.should_sandbox() {
311 return SandboxType::None;
312 }
313
314 // Check for forced sandbox (testing)
315 if let Some(forced) = self.forced_sandbox {
316 return forced;
317 }
318
319 // Use platform default
320 get_platform_sandbox().unwrap_or(SandboxType::None)
321 }
322
323 /// Transform a `CommandSpec` into a sandboxed `ExecEnv`.
324 ///
325 /// This is the main entry point for sandboxing. It takes a command
326 /// specification and returns the actual command to run, which may
327 /// include sandbox wrapper commands.
328 pub fn prepare(&self, spec: &CommandSpec) -> ExecEnv {
329 let sandbox_type = self.select_sandbox(&spec.sandbox_policy);
330
331 match sandbox_type {
332 SandboxType::None => Self::prepare_unsandboxed(spec),
333
334 #[cfg(target_os = "macos")]
335 SandboxType::MacosSeatbelt => Self::prepare_seatbelt(spec),
336
337 #[cfg(target_os = "linux")]
338 SandboxType::LinuxLandlock => Self::prepare_landlock(spec),
339
340 #[cfg(target_os = "windows")]
341 SandboxType::Windows => Self::prepare_windows(spec),
342 }
343 }
344
345 /// Prepare an unsandboxed execution environment.
346 fn prepare_unsandboxed(spec: &CommandSpec) -> ExecEnv {
347 let mut command = vec![spec.program.clone()];
348 command.extend(spec.args.clone());
349
350 ExecEnv {
351 command,
352 cwd: spec.cwd.clone(),
353 env: spec.env.clone(),
354 timeout: spec.timeout,
355 sandbox_type: SandboxType::None,
356 policy: spec.sandbox_policy.clone(),
357 }
358 }
359
360 /// Prepare a Seatbelt-sandboxed execution environment (macOS).
361 #[cfg(target_os = "macos")]
362 fn prepare_seatbelt(spec: &CommandSpec) -> ExecEnv {
363 // Build the original command
364 let mut original_command = vec![spec.program.clone()];
365 original_command.extend(spec.args.clone());
366
367 // Generate sandbox-exec arguments
368 let seatbelt_args =
369 seatbelt::create_seatbelt_args(original_command, &spec.sandbox_policy, &spec.cwd);
370
371 // Prepend sandbox-exec to the command
372 let mut command = vec![seatbelt::SANDBOX_EXEC_PATH.to_string()];
373 command.extend(seatbelt_args);
374
375 // Add sandbox indicator to environment
376 let mut env = spec.env.clone();
377 env.insert("DEEPSEEK_SANDBOX".to_string(), "seatbelt".to_string());
378
379 ExecEnv {
380 command,
381 cwd: spec.cwd.clone(),
382 env,
383 timeout: spec.timeout,
384 sandbox_type: SandboxType::MacosSeatbelt,
385 policy: spec.sandbox_policy.clone(),
386 }
387 }
388
389 /// Prepare a Landlock-sandboxed execution environment (Linux).
390 ///
391 /// Note: Landlock restricts the current process, so for subprocess sandboxing
392 /// we would need a helper binary. For now, this prepares the environment with
393 /// appropriate markers but doesn't actually apply Landlock (would need helper).
394 #[cfg(target_os = "linux")]
395 fn prepare_landlock(spec: &CommandSpec) -> ExecEnv {
396 // Build the original command
397 let mut command = vec![spec.program.clone()];
398 command.extend(spec.args.clone());
399
400 // Add sandbox indicator to environment
401 let mut env = spec.env.clone();
402 env.insert("DEEPSEEK_SANDBOX".to_string(), "landlock".to_string());
403
404 // Note: Full Landlock implementation would use a helper binary that:
405 // 1. Sets up the Landlock ruleset based on policy
406 // 2. Applies restrictions to itself
407 // 3. Execs the target command
408 //
409 // For now, we just mark that Landlock would be used
410
411 ExecEnv {
412 command,
413 cwd: spec.cwd.clone(),
414 env,
415 timeout: spec.timeout,
416 sandbox_type: SandboxType::LinuxLandlock,
417 policy: spec.sandbox_policy.clone(),
418 }
419 }
420
421 /// Prepare a Windows-sandboxed execution environment.
422 ///
423 /// Note: Windows sandboxing requires a helper process for full isolation.
424 /// This implementation marks intent and defers enforcement to a helper.
425 #[cfg(target_os = "windows")]
426 fn prepare_windows(spec: &CommandSpec) -> ExecEnv {
427 let mut command = vec![spec.program.clone()];
428 command.extend(spec.args.clone());
429
430 let mut env = spec.env.clone();
431 let kind = windows::select_best_kind(&spec.sandbox_policy, &spec.cwd);
432 env.insert("DEEPSEEK_SANDBOX".to_string(), format!("windows:{kind}"));
433 if !spec.sandbox_policy.has_network_access() {
434 env.insert(
435 "DEEPSEEK_SANDBOX_BLOCK_NETWORK".to_string(),
436 "1".to_string(),
437 );
438 }
439
440 ExecEnv {
441 command,
442 cwd: spec.cwd.clone(),
443 env,
444 timeout: spec.timeout,
445 sandbox_type: SandboxType::Windows,
446 policy: spec.sandbox_policy.clone(),
447 }
448 }
449
450 /// Check if a command failure was due to sandbox denial.
451 ///
452 /// This helps distinguish between legitimate command failures and
453 /// sandbox-blocked operations.
454 pub fn was_denied(sandbox_type: SandboxType, exit_code: i32, stderr: &str) -> bool {
455 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
456 let _ = (exit_code, stderr);
457
458 match sandbox_type {
459 SandboxType::None => false,
460
461 #[cfg(target_os = "macos")]
462 SandboxType::MacosSeatbelt => seatbelt::detect_denial(exit_code, stderr),
463
464 #[cfg(target_os = "linux")]
465 SandboxType::LinuxLandlock => landlock::detect_denial(exit_code, stderr),
466
467 #[cfg(target_os = "windows")]
468 SandboxType::Windows => windows::detect_denial(exit_code, stderr),
469 }
470 }
471
472 /// Get a human-readable description of why a command was blocked.
473 pub fn denial_message(sandbox_type: SandboxType, stderr: &str) -> String {
474 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
475 let _ = stderr;
476
477 match sandbox_type {
478 SandboxType::None => "Command failed (no sandbox)".to_string(),
479
480 #[cfg(target_os = "macos")]
481 SandboxType::MacosSeatbelt => {
482 if stderr.contains("file-write") {
483 "Sandbox blocked write access. The command tried to write to a protected location.".to_string()
484 } else if stderr.contains("network") {
485 "Sandbox blocked network access. Enable network_access in sandbox policy if needed.".to_string()
486 } else {
487 format!(
488 "Sandbox blocked operation: {}",
489 stderr.lines().next().unwrap_or("unknown")
490 )
491 }
492 }
493
494 #[cfg(target_os = "linux")]
495 SandboxType::LinuxLandlock => {
496 if stderr.contains("Permission denied") {
497 "Landlock blocked access. The command tried to access a restricted path."
498 .to_string()
499 } else {
500 format!(
501 "Landlock blocked operation: {}",
502 stderr.lines().next().unwrap_or("unknown")
503 )
504 }
505 }
506
507 #[cfg(target_os = "windows")]
508 SandboxType::Windows => {
509 if stderr.contains("Access is denied") {
510 "Windows sandbox blocked access. The command lacked required privileges."
511 .to_string()
512 } else if stderr.contains("network") {
513 "Windows sandbox blocked network access. Enable network_access in policy if needed."
514 .to_string()
515 } else {
516 format!(
517 "Windows sandbox blocked operation: {}",
518 stderr.lines().next().unwrap_or("unknown")
519 )
520 }
521 }
522 }
523 }
524 }
525
526 #[cfg(test)]
527 mod tests {
528 use super::*;
529
530 fn expected_shell_command(command: &str) -> Vec<String> {
531 #[cfg(windows)]
532 {
533 vec!["cmd".to_string(), "/C".to_string(), command.to_string()]
534 }
535 #[cfg(not(windows))]
536 {
537 vec!["sh".to_string(), "-c".to_string(), command.to_string()]
538 }
539 }
540
541 #[test]
542 fn test_command_spec_shell() {
543 let spec = CommandSpec::shell("echo hello", PathBuf::from("/tmp"), Duration::from_secs(30));
544
545 #[cfg(windows)]
546 {
547 assert_eq!(spec.program, "cmd");
548 assert_eq!(spec.args, vec!["/C", "echo hello"]);
549 }
550 #[cfg(not(windows))]
551 {
552 assert_eq!(spec.program, "sh");
553 assert_eq!(spec.args, vec!["-c", "echo hello"]);
554 }
555 assert_eq!(spec.display_command(), "echo hello");
556 }
557
558 #[test]
559 fn test_command_spec_program() {
560 let spec = CommandSpec::program(
561 "cargo",
562 vec!["build".to_string(), "--release".to_string()],
563 PathBuf::from("/project"),
564 Duration::from_secs(300),
565 );
566
567 assert_eq!(spec.program, "cargo");
568 assert_eq!(spec.display_command(), "cargo build --release");
569 }
570
571 #[test]
572 fn test_command_spec_builder() {
573 let spec = CommandSpec::shell("test", PathBuf::from("."), Duration::from_secs(10))
574 .with_policy(SandboxPolicy::ReadOnly)
575 .with_env_var("FOO", "bar")
576 .with_justification("Testing");
577
578 assert!(matches!(spec.sandbox_policy, SandboxPolicy::ReadOnly));
579 assert_eq!(spec.env.get("FOO"), Some(&"bar".to_string()));
580 assert_eq!(spec.justification, Some("Testing".to_string()));
581 }
582
583 #[test]
584 fn test_sandbox_manager_new() {
585 let manager = SandboxManager::new();
586 assert!(manager.sandbox_available.is_none());
587 }
588
589 #[test]
590 fn test_sandbox_manager_select_sandbox() {
591 let manager = SandboxManager::new();
592
593 // DangerFullAccess should never sandbox
594 let no_sandbox = manager.select_sandbox(&SandboxPolicy::DangerFullAccess);
595 assert_eq!(no_sandbox, SandboxType::None);
596
597 // ExternalSandbox should never sandbox
598 let external = manager.select_sandbox(&SandboxPolicy::ExternalSandbox {
599 network_access: true,
600 });
601 assert_eq!(external, SandboxType::None);
602 }
603
604 #[test]
605 fn test_prepare_unsandboxed() {
606 let manager = SandboxManager::new();
607 let spec = CommandSpec::shell("echo test", PathBuf::from("/tmp"), Duration::from_secs(30))
608 .with_policy(SandboxPolicy::DangerFullAccess);
609
610 let env = manager.prepare(&spec);
611
612 assert_eq!(env.sandbox_type, SandboxType::None);
613 assert_eq!(env.command, expected_shell_command("echo test"));
614 assert!(!env.is_sandboxed());
615 }
616
617 #[test]
618 fn test_exec_env_helpers() {
619 let env = ExecEnv {
620 command: vec![
621 "sandbox-exec".to_string(),
622 "-p".to_string(),
623 "policy".to_string(),
624 "--".to_string(),
625 "echo".to_string(),
626 "hello".to_string(),
627 ],
628 cwd: PathBuf::from("/tmp"),
629 env: HashMap::new(),
630 timeout: Duration::from_secs(30),
631 sandbox_type: SandboxType::None,
632 policy: SandboxPolicy::default(),
633 };
634
635 assert_eq!(env.program(), "sandbox-exec");
636 assert_eq!(env.args().len(), 5);
637 }
638
639 #[test]
640 fn test_sandbox_type_display() {
641 assert_eq!(format!("{}", SandboxType::None), "none");
642
643 #[cfg(target_os = "macos")]
644 assert_eq!(format!("{}", SandboxType::MacosSeatbelt), "macos-seatbelt");
645 }
646 }
647
647 lines RUST