1use std::path::{Path, PathBuf};
14use tracing::{debug, info, warn};
15
16#[derive(Debug, Clone)]
20pub struct SandboxCapabilities {
21 pub landlock: bool,
22 pub bubblewrap: bool,
23 pub macos_sandbox: bool,
24 pub unprivileged_userns: bool,
25 pub docker: bool,
26}
27
28impl SandboxCapabilities {
29 pub fn detect() -> Self {
31 Self {
32 landlock: Self::check_landlock(),
33 bubblewrap: Self::check_bubblewrap(),
34 macos_sandbox: Self::check_macos_sandbox(),
35 unprivileged_userns: Self::check_userns(),
36 docker: Self::check_docker(),
37 }
38 }
39
40 fn check_docker() -> bool {
41 std::process::Command::new("docker")
42 .arg("info")
43 .output()
44 .map(|o| o.status.success())
45 .unwrap_or(false)
46 }
47
48 #[cfg(target_os = "linux")]
49 fn check_landlock() -> bool {
50 std::fs::read_to_string("/sys/kernel/security/lsm")
52 .map(|s| s.contains("landlock"))
53 .unwrap_or(false)
54 }
55
56 #[cfg(not(target_os = "linux"))]
57 fn check_landlock() -> bool {
58 false
59 }
60
61 fn check_bubblewrap() -> bool {
62 std::process::Command::new("bwrap")
63 .arg("--version")
64 .output()
65 .map(|o| o.status.success())
66 .unwrap_or(false)
67 }
68
69 #[cfg(target_os = "macos")]
70 fn check_macos_sandbox() -> bool {
71 std::process::Command::new("sandbox-exec")
73 .arg("-n")
74 .arg("no-network")
75 .arg("true")
76 .output()
77 .map(|o| o.status.success())
78 .unwrap_or(false)
79 }
80
81 #[cfg(not(target_os = "macos"))]
82 fn check_macos_sandbox() -> bool {
83 false
84 }
85
86 #[cfg(target_os = "linux")]
87 fn check_userns() -> bool {
88 std::fs::read_to_string("/proc/sys/kernel/unprivileged_userns_clone")
89 .map(|s| s.trim() == "1")
90 .unwrap_or(false)
91 }
92
93 #[cfg(not(target_os = "linux"))]
94 fn check_userns() -> bool {
95 false
96 }
97
98 pub fn best_mode(&self) -> SandboxMode {
100 if self.landlock && self.bubblewrap {
102 SandboxMode::LandlockBwrap
103 } else if self.landlock {
104 SandboxMode::Landlock
105 } else if self.bubblewrap {
106 SandboxMode::Bubblewrap
107 } else if self.docker {
108 SandboxMode::Docker
110 } else if self.macos_sandbox {
111 SandboxMode::MacOSSandbox
112 } else {
113 SandboxMode::PathValidation
114 }
115 }
116
117 pub fn describe(&self) -> String {
119 let mut opts = Vec::new();
120 if self.landlock && self.bubblewrap {
121 opts.push("Landlock+Bubblewrap");
122 }
123 if self.landlock {
124 opts.push("Landlock");
125 }
126 if self.bubblewrap {
127 opts.push("Bubblewrap");
128 }
129 if self.docker {
130 opts.push("Docker");
131 }
132 if self.macos_sandbox {
133 opts.push("macOS Sandbox");
134 }
135 opts.push("Path Validation"); format!("Available: {}", opts.join(", "))
138 }
139}
140
141#[derive(Debug, Clone)]
145pub struct SandboxPolicy {
146 pub deny_read: Vec<PathBuf>,
148 pub deny_write: Vec<PathBuf>,
150 pub deny_exec: Vec<PathBuf>,
152 pub allow_paths: Vec<PathBuf>,
154 pub workspace: PathBuf,
156}
157
158impl Default for SandboxPolicy {
159 fn default() -> Self {
160 Self {
161 deny_read: Vec::new(),
162 deny_write: Vec::new(),
163 deny_exec: Vec::new(),
164 allow_paths: Vec::new(),
165 workspace: PathBuf::from("."),
166 }
167 }
168}
169
170impl SandboxPolicy {
171 pub fn protect_credentials(credentials_dir: impl Into<PathBuf>, workspace: impl Into<PathBuf>) -> Self {
173 let cred_dir = credentials_dir.into();
174 Self {
175 deny_read: vec![cred_dir.clone()],
176 deny_write: vec![cred_dir.clone()],
177 deny_exec: vec![cred_dir],
178 allow_paths: Vec::new(),
179 workspace: workspace.into(),
180 }
181 }
182
183 pub fn strict(workspace: impl Into<PathBuf>, allowed: Vec<PathBuf>) -> Self {
185 Self {
186 deny_read: Vec::new(),
187 deny_write: Vec::new(),
188 deny_exec: Vec::new(),
189 allow_paths: allowed,
190 workspace: workspace.into(),
191 }
192 }
193
194 pub fn deny_read(mut self, path: impl Into<PathBuf>) -> Self {
196 self.deny_read.push(path.into());
197 self
198 }
199
200 pub fn deny_write(mut self, path: impl Into<PathBuf>) -> Self {
202 self.deny_write.push(path.into());
203 self
204 }
205}
206
207#[derive(Debug, Clone, Copy, PartialEq, Eq)]
211#[derive(Default)]
212pub enum SandboxMode {
213 None,
215 PathValidation,
217 Bubblewrap,
219 Landlock,
221 LandlockBwrap,
223 Docker,
225 MacOSSandbox,
227 #[default]
229 Auto,
230}
231
232
233impl std::str::FromStr for SandboxMode {
234 type Err = String;
235
236 fn from_str(s: &str) -> Result<Self, Self::Err> {
237 match s.to_lowercase().as_str() {
238 "none" | "off" | "disabled" => Ok(Self::None),
239 "path" | "pathvalidation" | "soft" => Ok(Self::PathValidation),
240 "bwrap" | "bubblewrap" | "namespace" => Ok(Self::Bubblewrap),
241 "landlock" | "kernel" => Ok(Self::Landlock),
242 "landlock+bwrap" | "landlock-bwrap" | "combined" | "lockwrap" => Ok(Self::LandlockBwrap),
243 "docker" | "container" => Ok(Self::Docker),
244 "macos" | "seatbelt" | "sandbox-exec" => Ok(Self::MacOSSandbox),
245 "auto" | "" => Ok(Self::Auto),
246 _ => Err(format!("Unknown sandbox mode: {}", s)),
247 }
248 }
249}
250
251impl std::fmt::Display for SandboxMode {
252 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
253 match self {
254 Self::None => write!(f, "none"),
255 Self::PathValidation => write!(f, "path"),
256 Self::Bubblewrap => write!(f, "bwrap"),
257 Self::Landlock => write!(f, "landlock"),
258 Self::LandlockBwrap => write!(f, "landlock+bwrap"),
259 Self::Docker => write!(f, "docker"),
260 Self::MacOSSandbox => write!(f, "macos"),
261 Self::Auto => write!(f, "auto"),
262 }
263 }
264}
265
266pub fn validate_path(path: &Path, policy: &SandboxPolicy) -> Result<(), String> {
270 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
271
272 for denied in &policy.deny_read {
274 if let Ok(denied_canon) = denied.canonicalize() {
275 if canonical.starts_with(&denied_canon) {
276 return Err(format!(
277 "Access denied: path {} is in protected area",
278 path.display()
279 ));
280 }
281 }
282 }
283
284 if !policy.allow_paths.is_empty() {
286 let allowed = policy.allow_paths.iter().any(|allowed| {
287 allowed
288 .canonicalize()
289 .map(|c| canonical.starts_with(&c))
290 .unwrap_or(false)
291 });
292 if !allowed {
293 return Err(format!(
294 "Access denied: path {} is not in allowed areas",
295 path.display()
296 ));
297 }
298 }
299
300 Ok(())
301}
302
303#[cfg(target_os = "linux")]
307pub fn wrap_with_bwrap(command: &str, policy: &SandboxPolicy) -> (String, Vec<String>) {
308 let mut args = Vec::new();
309
310 let is_read_denied = |path: &Path| -> bool {
312 policy.deny_read.iter().any(|deny| {
313 path.starts_with(deny) || deny.starts_with(path)
314 })
315 };
316
317 let is_write_denied = |path: &Path| -> bool {
319 policy.deny_write.iter().any(|deny| {
320 path.starts_with(deny) || deny.starts_with(path)
321 })
322 };
323
324 let is_exec_denied = |path: &Path| -> bool {
326 policy.deny_exec.iter().any(|deny| {
327 path.starts_with(deny) || deny.starts_with(path)
328 })
329 };
330
331 args.push("--unshare-all".to_string());
333 args.push("--share-net".to_string()); for dir in &["/usr", "/lib", "/lib64", "/bin", "/sbin"] {
337 let path = Path::new(dir);
338 if path.exists() && !is_read_denied(path) && !is_exec_denied(path) {
339 args.push("--ro-bind".to_string());
340 args.push(dir.to_string());
341 args.push(dir.to_string());
342 }
343 }
344
345 let etc_path = Path::new("/etc");
347 if etc_path.exists() && !is_read_denied(etc_path) {
348 args.push("--ro-bind".to_string());
349 args.push("/etc".to_string());
350 args.push("/etc".to_string());
351 }
352
353 if !is_read_denied(&policy.workspace) {
355 if is_write_denied(&policy.workspace) {
356 args.push("--ro-bind".to_string());
357 } else {
358 args.push("--bind".to_string());
359 }
360 args.push(policy.workspace.display().to_string());
361 args.push(policy.workspace.display().to_string());
362 }
363
364 args.push("--tmpfs".to_string());
366 args.push("/tmp".to_string());
367
368 args.push("--proc".to_string());
370 args.push("/proc".to_string());
371
372 args.push("--dev".to_string());
374 args.push("/dev".to_string());
375
376 args.push("--chdir".to_string());
378 args.push(policy.workspace.display().to_string());
379
380 args.push("--die-with-parent".to_string());
382
383 args.push("--".to_string());
385 args.push("sh".to_string());
386 args.push("-c".to_string());
387 args.push(command.to_string());
388
389 ("bwrap".to_string(), args)
390}
391
392#[cfg(not(target_os = "linux"))]
393pub fn wrap_with_bwrap(_command: &str, _policy: &SandboxPolicy) -> (String, Vec<String>) {
394 panic!("Bubblewrap is only available on Linux");
395}
396
397#[cfg(target_os = "macos")]
401fn generate_seatbelt_profile(policy: &SandboxPolicy) -> String {
402 let mut profile = String::from("(version 1)\n");
403
404 profile.push_str("(deny default)\n");
406
407 profile.push_str("(allow process-fork)\n");
409 profile.push_str("(allow process-exec)\n");
410 profile.push_str("(allow signal)\n");
411 profile.push_str("(allow sysctl-read)\n");
412
413 profile.push_str("(allow file-read* (subpath \"/usr\"))\n");
415 profile.push_str("(allow file-read* (subpath \"/bin\"))\n");
416 profile.push_str("(allow file-read* (subpath \"/sbin\"))\n");
417 profile.push_str("(allow file-read* (subpath \"/Library\"))\n");
418 profile.push_str("(allow file-read* (subpath \"/System\"))\n");
419 profile.push_str("(allow file-read* (subpath \"/private/etc\"))\n");
420 profile.push_str("(allow file-read* (subpath \"/private/var/db\"))\n");
421
422 profile.push_str(&format!(
424 "(allow file-read* file-write* (subpath \"{}\"))\n",
425 policy.workspace.display()
426 ));
427
428 profile.push_str("(allow file-read* file-write* (subpath \"/private/tmp\"))\n");
430 profile.push_str("(allow file-read* file-write* (subpath \"/tmp\"))\n");
431
432 for denied in &policy.deny_read {
434 profile.push_str(&format!(
435 "(deny file-read* (subpath \"{}\"))\n",
436 denied.display()
437 ));
438 }
439 for denied in &policy.deny_write {
440 profile.push_str(&format!(
441 "(deny file-write* (subpath \"{}\"))\n",
442 denied.display()
443 ));
444 }
445 for denied in &policy.deny_exec {
446 profile.push_str(&format!(
447 "(deny process-exec (subpath \"{}\"))\n",
448 denied.display()
449 ));
450 }
451
452 profile.push_str("(allow network*)\n");
454
455 profile
456}
457
458#[cfg(target_os = "macos")]
460pub fn wrap_with_macos_sandbox(command: &str, policy: &SandboxPolicy) -> (String, Vec<String>) {
461 let profile = generate_seatbelt_profile(policy);
462
463 let args = vec![
464 "-p".to_string(),
465 profile,
466 "sh".to_string(),
467 "-c".to_string(),
468 command.to_string(),
469 ];
470
471 ("sandbox-exec".to_string(), args)
472}
473
474#[cfg(not(target_os = "macos"))]
475pub fn wrap_with_macos_sandbox(_command: &str, _policy: &SandboxPolicy) -> (String, Vec<String>) {
476 panic!("macOS sandbox is only available on macOS");
477}
478
479#[cfg(target_os = "linux")]
488pub fn apply_landlock(policy: &SandboxPolicy) -> Result<(), String> {
489 use landlock::{
490 Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr, ABI,
491 };
492
493 let abi = ABI::V2;
494
495 let mut ruleset = Ruleset::default()
498 .handle_access(AccessFs::from_all(abi))
499 .map_err(|e| format!("Landlock ruleset creation failed: {}", e))?
500 .create()
501 .map_err(|e| format!("Landlock not supported (kernel < 5.13): {}", e))?;
502
503 let system_read_paths = [
505 "/usr",
506 "/lib",
507 "/lib64",
508 "/bin",
509 "/sbin",
510 "/etc", "/proc",
512 "/sys",
513 "/dev",
514 ];
515
516 let system_rw_paths = [
518 "/tmp",
519 "/var/tmp",
520 ];
521
522 for path_str in &system_read_paths {
524 let path = std::path::Path::new(path_str);
525 if path.exists() {
526 match PathFd::new(path) {
527 Ok(fd) => {
528 ruleset = ruleset
529 .add_rule(PathBeneath::new(fd, AccessFs::from_read(abi)))
530 .map_err(|e| format!("Failed to add read rule for {}: {}", path_str, e))?;
531 }
532 Err(e) => {
533 warn!(path = %path_str, error = %e, "Cannot open path for Landlock read rule");
534 }
535 }
536 }
537 }
538
539 for path_str in &system_rw_paths {
541 let path = std::path::Path::new(path_str);
542 if path.exists() {
543 match PathFd::new(path) {
544 Ok(fd) => {
545 ruleset = ruleset
546 .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi)))
547 .map_err(|e| format!("Failed to add rw rule for {}: {}", path_str, e))?;
548 }
549 Err(e) => {
550 warn!(path = %path_str, error = %e, "Cannot open path for Landlock rw rule");
551 }
552 }
553 }
554 }
555
556 if policy.workspace.exists() {
558 match PathFd::new(&policy.workspace) {
559 Ok(fd) => {
560 ruleset = ruleset
561 .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi)))
562 .map_err(|e| format!("Failed to add workspace rule: {}", e))?;
563 }
564 Err(e) => {
565 return Err(format!(
566 "Cannot open workspace {:?} for Landlock: {}",
567 policy.workspace, e
568 ));
569 }
570 }
571 }
572
573 for allowed_path in &policy.allow_paths {
575 if allowed_path.exists() {
576 match PathFd::new(allowed_path) {
577 Ok(fd) => {
578 ruleset = ruleset
579 .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi)))
580 .map_err(|e| {
581 format!("Failed to add allow rule for {:?}: {}", allowed_path, e)
582 })?;
583 }
584 Err(e) => {
585 warn!(
586 path = ?allowed_path,
587 error = %e,
588 "Cannot open path for Landlock allow rule"
589 );
590 }
591 }
592 }
593 }
594
595 if !policy.deny_read.is_empty() {
600 debug!(
601 denied_paths = policy.deny_read.len(),
602 "Landlock: paths denied by omission from allowlist"
603 );
604 }
605
606 ruleset
608 .restrict_self()
609 .map_err(|e| format!("Failed to apply Landlock restrictions: {}", e))?;
610
611 info!(
612 workspace = ?policy.workspace,
613 system_paths = system_read_paths.len() + system_rw_paths.len(),
614 "Landlock sandbox active"
615 );
616
617 Ok(())
618}
619
620#[cfg(not(target_os = "linux"))]
621pub fn apply_landlock(_policy: &SandboxPolicy) -> Result<(), String> {
622 Err("Landlock is only supported on Linux".to_string())
623}
624
625pub fn run_sandboxed(
629 command: &str,
630 policy: &SandboxPolicy,
631 mode: SandboxMode,
632) -> Result<std::process::Output, String> {
633 let caps = SandboxCapabilities::detect();
634
635 let effective_mode = match mode {
637 SandboxMode::Auto => caps.best_mode(),
638 other => other,
639 };
640
641 match effective_mode {
643 SandboxMode::Bubblewrap if !caps.bubblewrap => {
644 warn!("Bubblewrap not available, falling back to path validation");
645 return run_with_path_validation(command, policy);
646 }
647 SandboxMode::Landlock if !caps.landlock => {
648 warn!("Landlock not available, falling back to path validation");
649 return run_with_path_validation(command, policy);
650 }
651 SandboxMode::LandlockBwrap if !caps.landlock || !caps.bubblewrap => {
652 warn!("Landlock+Bubblewrap not fully available");
653 if caps.landlock {
654 debug!("Falling back to Landlock only");
655 return run_with_path_validation(command, policy);
656 } else if caps.bubblewrap {
657 debug!("Falling back to Bubblewrap only");
658 return run_with_bubblewrap(command, policy);
659 } else {
660 debug!("Falling back to path validation");
661 return run_with_path_validation(command, policy);
662 }
663 }
664 SandboxMode::Docker if !caps.docker => {
665 warn!("Docker not available, falling back to path validation");
666 return run_with_path_validation(command, policy);
667 }
668 SandboxMode::MacOSSandbox if !caps.macos_sandbox => {
669 warn!("macOS sandbox not available, falling back to path validation");
670 return run_with_path_validation(command, policy);
671 }
672 _ => {}
673 }
674
675 match effective_mode {
676 SandboxMode::None => run_unsandboxed(command),
677 SandboxMode::PathValidation => run_with_path_validation(command, policy),
678 SandboxMode::Bubblewrap => run_with_bubblewrap(command, policy),
679 SandboxMode::Docker => run_with_docker(command, policy),
680 SandboxMode::MacOSSandbox => run_with_macos_sandbox(command, policy),
681 SandboxMode::Landlock => {
682 run_with_path_validation(command, policy)
684 }
685 SandboxMode::LandlockBwrap => run_with_landlock_bwrap(command, policy),
686 SandboxMode::Auto => unreachable!(), }
688}
689
690fn run_unsandboxed(command: &str) -> Result<std::process::Output, String> {
691 std::process::Command::new("sh")
692 .arg("-c")
693 .arg(command)
694 .output()
695 .map_err(|e| format!("Command failed: {}", e))
696}
697
698pub fn extract_paths_from_command(command: &str) -> Vec<PathBuf> {
707 use std::path::PathBuf;
708 let mut paths = Vec::new();
709
710 let chars = command.chars().peekable();
715 let mut current_token = String::new();
716 let mut in_quotes = false;
717 let mut quote_char = ' ';
718
719 for ch in chars {
720 match ch {
721 '\'' | '"' => {
722 if in_quotes && ch == quote_char {
723 in_quotes = false;
725 if !current_token.is_empty() && (current_token.starts_with('/') || current_token.starts_with("~/")) {
726 paths.push(PathBuf::from(¤t_token));
727 }
728 current_token.clear();
729 } else if !in_quotes {
730 in_quotes = true;
732 quote_char = ch;
733 }
734 }
735 ' ' | '\t' | '\n' | ';' | '&' | '|' | '(' | ')' | '<' | '>' if !in_quotes => {
736 if !current_token.is_empty() && (current_token.starts_with('/') || current_token.starts_with("~/")) {
738 paths.push(PathBuf::from(¤t_token));
739 }
740 current_token.clear();
741 }
742 _ => {
743 current_token.push(ch);
744 }
745 }
746 }
747
748 if !current_token.is_empty() && (current_token.starts_with('/') || current_token.starts_with("~/")) {
750 paths.push(PathBuf::from(¤t_token));
751 }
752
753 paths
754}
755
756fn run_with_path_validation(command: &str, policy: &SandboxPolicy) -> Result<std::process::Output, String> {
757 let paths = extract_paths_from_command(command);
759
760 for path in &paths {
762 validate_path(path, policy)?;
763 }
764
765 let first_token = command.split_whitespace().next().unwrap_or("");
768 if !first_token.is_empty() {
769 let cmd_path = Path::new(first_token);
770
771 if first_token.starts_with('/') || first_token.starts_with("./") || first_token.starts_with("~/") {
773 for denied in &policy.deny_exec {
775 if let (Ok(cmd_canon), Ok(denied_canon)) = (cmd_path.canonicalize(), denied.canonicalize()) {
776 if cmd_canon.starts_with(&denied_canon) {
777 return Err(format!(
778 "Execution denied: {} is in protected area (deny_exec)",
779 first_token
780 ));
781 }
782 }
783 }
784 }
785 }
786
787 if paths.is_empty() {
789 warn!(
790 command_preview = &command[..command.len().min(50)],
791 "PathValidation mode cannot detect dynamic paths in command"
792 );
793 }
794
795 run_unsandboxed(command)
796}
797
798#[cfg(target_os = "linux")]
799fn run_with_bubblewrap(command: &str, policy: &SandboxPolicy) -> Result<std::process::Output, String> {
800 let (cmd, args) = wrap_with_bwrap(command, policy);
801
802 let mut proc = std::process::Command::new(&cmd);
803 proc.args(&args);
804
805 proc.env_clear();
807 for (key, value) in std::env::vars() {
808 if key.starts_with("LANG")
809 || key.starts_with("LC_")
810 || key == "PATH"
811 || key == "HOME"
812 || key == "USER"
813 || key == "TERM"
814 {
815 proc.env(&key, &value);
816 }
817 }
818
819 proc.output()
820 .map_err(|e| format!("Sandboxed command failed: {}", e))
821}
822
823#[cfg(not(target_os = "linux"))]
824fn run_with_bubblewrap(_command: &str, _policy: &SandboxPolicy) -> Result<std::process::Output, String> {
825 Err("Bubblewrap is only available on Linux".to_string())
826}
827
828#[cfg(target_os = "macos")]
829fn run_with_macos_sandbox(command: &str, policy: &SandboxPolicy) -> Result<std::process::Output, String> {
830 let (cmd, args) = wrap_with_macos_sandbox(command, policy);
831
832 std::process::Command::new(&cmd)
833 .args(&args)
834 .output()
835 .map_err(|e| format!("Sandboxed command failed: {}", e))
836}
837
838#[cfg(not(target_os = "macos"))]
839fn run_with_macos_sandbox(_command: &str, _policy: &SandboxPolicy) -> Result<std::process::Output, String> {
840 Err("macOS sandbox is only available on macOS".to_string())
841}
842
843#[cfg(target_os = "linux")]
852fn wrap_with_combined_bwrap(command: &str, policy: &SandboxPolicy) -> (String, Vec<String>) {
853 let mut args = Vec::new();
854
855 args.push("--unshare-all".to_string());
857 args.push("--share-net".to_string()); args.push("--die-with-parent".to_string());
859 args.push("--new-session".to_string()); let is_blocked = |path: &Path| -> bool {
863 policy.deny_read.iter().any(|deny| {
864 path.starts_with(deny) || deny.starts_with(path)
865 }) || policy.deny_exec.iter().any(|deny| {
866 path.starts_with(deny) || deny.starts_with(path)
867 })
868 };
869
870 for dir in &["/usr", "/lib", "/lib64", "/bin", "/sbin"] {
872 let path = Path::new(dir);
873 if path.exists() && !is_blocked(path) {
874 args.push("--ro-bind".to_string());
875 args.push(dir.to_string());
876 args.push(dir.to_string());
877 }
878 }
879
880 let etc_path = Path::new("/etc");
882 if etc_path.exists() && !is_blocked(etc_path) {
883 args.push("--ro-bind".to_string());
884 args.push("/etc".to_string());
885 args.push("/etc".to_string());
886 }
887
888 if !policy.deny_read.iter().any(|deny| {
890 policy.workspace.starts_with(deny) || deny.starts_with(&policy.workspace)
891 }) {
892 if policy.deny_write.iter().any(|deny| {
893 policy.workspace.starts_with(deny) || deny.starts_with(&policy.workspace)
894 }) {
895 args.push("--ro-bind".to_string());
896 } else {
897 args.push("--bind".to_string());
898 }
899 args.push(policy.workspace.display().to_string());
900 args.push(policy.workspace.display().to_string());
901 }
902
903 args.push("--tmpfs".to_string());
905 args.push("/tmp".to_string());
906
907 args.push("--proc".to_string());
909 args.push("/proc".to_string());
910 args.push("--dev".to_string());
911 args.push("/dev".to_string());
912
913 args.push("--chdir".to_string());
915 args.push(policy.workspace.display().to_string());
916
917 args.push("--".to_string());
919 args.push("sh".to_string());
920 args.push("-c".to_string());
921 args.push(command.to_string());
922
923 ("bwrap".to_string(), args)
924}
925
926#[cfg(target_os = "linux")]
943fn run_with_landlock_bwrap(command: &str, policy: &SandboxPolicy) -> Result<std::process::Output, String> {
944 let (cmd, args) = wrap_with_combined_bwrap(command, policy);
946
947 let mut proc = std::process::Command::new(&cmd);
948 proc.args(&args);
949
950 proc.env_clear();
952 for (key, value) in std::env::vars() {
953 if key.starts_with("LANG")
954 || key.starts_with("LC_")
955 || key == "PATH"
956 || key == "HOME"
957 || key == "USER"
958 || key == "TERM"
959 {
960 proc.env(&key, &value);
961 }
962 }
963
964 info!(
965 mode = "Landlock+Bubblewrap",
966 denied_paths = policy.deny_read.len() + policy.deny_exec.len(),
967 "Defense-in-depth sandbox active"
968 );
969
970 proc.output()
971 .map_err(|e| format!("Combined sandboxed command failed: {}", e))
972}
973
974#[cfg(not(target_os = "linux"))]
975fn run_with_landlock_bwrap(_command: &str, _policy: &SandboxPolicy) -> Result<std::process::Output, String> {
976 Err("Landlock+Bubblewrap is only available on Linux".to_string())
977}
978
979fn run_with_docker(command: &str, policy: &SandboxPolicy) -> Result<std::process::Output, String> {
992 use std::time::{SystemTime, UNIX_EPOCH};
993
994 let timestamp = SystemTime::now()
996 .duration_since(UNIX_EPOCH)
997 .unwrap()
998 .as_secs();
999 let container_name = format!("rustyclaw-sandbox-{}", timestamp);
1000
1001 let mut docker_args = vec![
1003 "run".to_string(),
1004 "--rm".to_string(), "--name".to_string(),
1006 container_name,
1007 "--memory".to_string(),
1009 "2g".to_string(),
1010 "--cpus".to_string(),
1011 "1.0".to_string(),
1012 "--user".to_string(),
1014 "1000:1000".to_string(), "--cap-drop".to_string(),
1016 "ALL".to_string(), "--security-opt".to_string(),
1018 "no-new-privileges:true".to_string(),
1019 "--read-only".to_string(), "--network".to_string(),
1022 "bridge".to_string(), "--tmpfs".to_string(),
1025 "/tmp:size=512M".to_string(),
1026 ];
1027
1028 let workspace_str = policy.workspace.display().to_string();
1030
1031 let workspace_denied = policy.deny_read.iter().any(|deny| {
1033 policy.workspace.starts_with(deny) || deny.starts_with(&policy.workspace)
1034 });
1035
1036 if !workspace_denied {
1037 let write_allowed = !policy.deny_write.iter().any(|deny| {
1038 policy.workspace.starts_with(deny) || deny.starts_with(&policy.workspace)
1039 });
1040
1041 let mount_mode = if write_allowed { "rw" } else { "ro" };
1042 docker_args.push("--volume".to_string());
1043 docker_args.push(format!("{}:/workspace:{}", workspace_str, mount_mode));
1044 docker_args.push("--workdir".to_string());
1045 docker_args.push("/workspace".to_string());
1046 } else {
1047 docker_args.push("--workdir".to_string());
1049 docker_args.push("/tmp".to_string());
1050 }
1051
1052 docker_args.push("alpine:latest".to_string());
1054
1055 docker_args.push("sh".to_string());
1057 docker_args.push("-c".to_string());
1058 docker_args.push(command.to_string());
1059
1060 info!(
1061 mode = "Docker",
1062 workspace = %workspace_str,
1063 workspace_blocked = workspace_denied,
1064 "Docker container sandbox active"
1065 );
1066
1067 std::process::Command::new("docker")
1069 .args(&docker_args)
1070 .output()
1071 .map_err(|e| format!("Docker execution failed: {}", e))
1072}
1073
1074pub struct Sandbox {
1078 pub mode: SandboxMode,
1079 pub policy: SandboxPolicy,
1080 pub capabilities: SandboxCapabilities,
1081}
1082
1083impl Sandbox {
1084 pub fn new(policy: SandboxPolicy) -> Self {
1086 let caps = SandboxCapabilities::detect();
1087 Self {
1088 mode: SandboxMode::Auto,
1089 policy,
1090 capabilities: caps,
1091 }
1092 }
1093
1094 pub fn with_mode(mode: SandboxMode, policy: SandboxPolicy) -> Self {
1096 let caps = SandboxCapabilities::detect();
1097 Self {
1098 mode,
1099 policy,
1100 capabilities: caps,
1101 }
1102 }
1103
1104 pub fn effective_mode(&self) -> SandboxMode {
1106 match self.mode {
1107 SandboxMode::Auto => self.capabilities.best_mode(),
1108 other => other,
1109 }
1110 }
1111
1112 pub fn init(&self) -> Result<(), String> {
1114 if self.effective_mode() == SandboxMode::Landlock {
1115 apply_landlock(&self.policy)?;
1116 }
1117 Ok(())
1118 }
1119
1120 pub fn check_path(&self, path: &Path) -> Result<(), String> {
1122 if self.mode == SandboxMode::None {
1123 return Ok(());
1124 }
1125 validate_path(path, &self.policy)
1126 }
1127
1128 pub fn run_command(&self, command: &str) -> Result<std::process::Output, String> {
1130 run_sandboxed(command, &self.policy, self.mode)
1131 }
1132
1133 pub fn status(&self) -> String {
1135 format!(
1136 "Mode: {} (effective: {})\n{}",
1137 self.mode,
1138 self.effective_mode(),
1139 self.capabilities.describe()
1140 )
1141 }
1142}
1143
1144#[cfg(test)]
1147mod tests {
1148 use super::*;
1149
1150 #[test]
1151 fn test_capabilities_detect() {
1152 let caps = SandboxCapabilities::detect();
1153 assert!(caps.best_mode() != SandboxMode::None || caps.best_mode() == SandboxMode::PathValidation);
1155 }
1156
1157 #[test]
1158 fn test_policy_creation() {
1159 let policy = SandboxPolicy::protect_credentials(
1160 "/home/user/.rustyclaw/credentials",
1161 "/home/user/.rustyclaw/workspace",
1162 );
1163
1164 assert_eq!(policy.deny_read.len(), 1);
1165 assert!(policy.deny_read[0].ends_with("credentials"));
1166 }
1167
1168 #[test]
1169 fn test_path_validation_denied() {
1170 let policy = SandboxPolicy::protect_credentials("/tmp/creds", "/tmp/workspace");
1171 std::fs::create_dir_all("/tmp/creds").ok();
1172 let _ = std::fs::write("/tmp/creds/secrets.json", "test");
1174 let result = validate_path(Path::new("/tmp/creds/secrets.json"), &policy);
1175 assert!(result.is_err());
1176 }
1177
1178 #[test]
1179 fn test_path_validation_allowed() {
1180 let policy =
1181 SandboxPolicy::protect_credentials("/tmp/test-creds-isolated", "/tmp/test-workspace");
1182
1183 let result = validate_path(Path::new("/tmp/other/file.txt"), &policy);
1184 assert!(result.is_ok());
1185 }
1186
1187 #[test]
1188 fn test_sandbox_mode_parsing() {
1189 assert_eq!("none".parse::<SandboxMode>().unwrap(), SandboxMode::None);
1190 assert_eq!("auto".parse::<SandboxMode>().unwrap(), SandboxMode::Auto);
1191 assert_eq!("bwrap".parse::<SandboxMode>().unwrap(), SandboxMode::Bubblewrap);
1192 assert_eq!("macos".parse::<SandboxMode>().unwrap(), SandboxMode::MacOSSandbox);
1193 }
1194
1195 #[test]
1196 fn test_sandbox_status() {
1197 let policy = SandboxPolicy::default();
1198 let sandbox = Sandbox::new(policy);
1199 let status = sandbox.status();
1200 assert!(status.contains("Mode:"));
1201 assert!(status.contains("Available:"));
1202 }
1203
1204 #[cfg(target_os = "linux")]
1205 #[test]
1206 fn test_bwrap_command_generation() {
1207 let policy = SandboxPolicy {
1208 workspace: PathBuf::from("/home/user/workspace"),
1209 ..Default::default()
1210 };
1211
1212 let (cmd, args) = wrap_with_bwrap("ls -la", &policy);
1213
1214 assert_eq!(cmd, "bwrap");
1215 assert!(args.contains(&"--unshare-all".to_string()));
1216 assert!(args.contains(&"ls -la".to_string()));
1217 }
1218
1219 #[test]
1220 fn test_run_unsandboxed() {
1221 let output = run_unsandboxed("echo hello").unwrap();
1222 assert!(output.status.success());
1223 assert!(String::from_utf8_lossy(&output.stdout).contains("hello"));
1224 }
1225
1226 #[test]
1227 fn test_extract_paths_absolute() {
1228 let paths = extract_paths_from_command("cat /etc/passwd");
1229 assert_eq!(paths.len(), 1);
1230 assert_eq!(paths[0], PathBuf::from("/etc/passwd"));
1231 }
1232
1233 #[test]
1234 fn test_extract_paths_home() {
1235 let paths = extract_paths_from_command("cat ~/file.txt");
1236 assert_eq!(paths.len(), 1);
1237 assert_eq!(paths[0], PathBuf::from("~/file.txt"));
1238 }
1239
1240 #[test]
1241 fn test_extract_paths_multiple() {
1242 let paths = extract_paths_from_command("cp /etc/hosts ~/backup/hosts");
1243 assert_eq!(paths.len(), 2);
1244 assert_eq!(paths[0], PathBuf::from("/etc/hosts"));
1245 assert_eq!(paths[1], PathBuf::from("~/backup/hosts"));
1246 }
1247
1248 #[test]
1249 fn test_extract_paths_quoted() {
1250 let paths = extract_paths_from_command("cat \"/path/with spaces/file\"");
1251 assert_eq!(paths.len(), 1);
1252 assert_eq!(paths[0], PathBuf::from("/path/with spaces/file"));
1253 }
1254
1255 #[test]
1256 fn test_extract_paths_no_paths() {
1257 let paths = extract_paths_from_command("echo hello world");
1258 assert_eq!(paths.len(), 0);
1259 }
1260
1261 #[test]
1262 fn test_extract_paths_complex_command() {
1263 let paths = extract_paths_from_command("tar czf /backup/archive.tar.gz ~/documents");
1264 assert_eq!(paths.len(), 2);
1265 assert_eq!(paths[0], PathBuf::from("/backup/archive.tar.gz"));
1266 assert_eq!(paths[1], PathBuf::from("~/documents"));
1267 }
1268
1269 #[test]
1270 fn test_path_validation_blocks_credentials() {
1271 let policy = SandboxPolicy::protect_credentials("/tmp/test_creds", "/tmp/test_workspace");
1272 std::fs::create_dir_all("/tmp/test_creds").ok();
1273 let _ = std::fs::write("/tmp/test_creds/secret.txt", "test");
1275 let result = run_with_path_validation("cat /tmp/test_creds/secret.txt", &policy);
1277 assert!(result.is_err());
1278 assert!(result.unwrap_err().contains("Access denied"));
1279 }
1280
1281 #[test]
1282 fn test_path_validation_allows_workspace() {
1283 let policy = SandboxPolicy::protect_credentials("/tmp/test_creds2", "/tmp/test_workspace2");
1284 std::fs::create_dir_all("/tmp/test_workspace2").ok();
1285
1286 let result = run_with_path_validation("echo hello > /tmp/test_workspace2/file.txt", &policy);
1288 if result.is_err() {
1291 assert!(!result.unwrap_err().contains("Access denied"));
1292 }
1293 }
1294}