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(
173 credentials_dir: impl Into<PathBuf>,
174 workspace: impl Into<PathBuf>,
175 ) -> Self {
176 let cred_dir = credentials_dir.into();
177 Self {
178 deny_read: vec![cred_dir.clone()],
179 deny_write: vec![cred_dir.clone()],
180 deny_exec: vec![cred_dir],
181 allow_paths: Vec::new(),
182 workspace: workspace.into(),
183 }
184 }
185
186 pub fn strict(workspace: impl Into<PathBuf>, allowed: Vec<PathBuf>) -> Self {
188 Self {
189 deny_read: Vec::new(),
190 deny_write: Vec::new(),
191 deny_exec: Vec::new(),
192 allow_paths: allowed,
193 workspace: workspace.into(),
194 }
195 }
196
197 pub fn deny_read(mut self, path: impl Into<PathBuf>) -> Self {
199 self.deny_read.push(path.into());
200 self
201 }
202
203 pub fn deny_write(mut self, path: impl Into<PathBuf>) -> Self {
205 self.deny_write.push(path.into());
206 self
207 }
208}
209
210#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
214pub enum SandboxMode {
215 None,
217 PathValidation,
219 Bubblewrap,
221 Landlock,
223 LandlockBwrap,
225 Docker,
227 MacOSSandbox,
229 #[default]
231 Auto,
232}
233
234impl std::str::FromStr for SandboxMode {
235 type Err = String;
236
237 fn from_str(s: &str) -> Result<Self, Self::Err> {
238 match s.to_lowercase().as_str() {
239 "none" | "off" | "disabled" => Ok(Self::None),
240 "path" | "pathvalidation" | "soft" => Ok(Self::PathValidation),
241 "bwrap" | "bubblewrap" | "namespace" => Ok(Self::Bubblewrap),
242 "landlock" | "kernel" => Ok(Self::Landlock),
243 "landlock+bwrap" | "landlock-bwrap" | "combined" | "lockwrap" => {
244 Ok(Self::LandlockBwrap)
245 }
246 "docker" | "container" => Ok(Self::Docker),
247 "macos" | "seatbelt" | "sandbox-exec" => Ok(Self::MacOSSandbox),
248 "auto" | "" => Ok(Self::Auto),
249 _ => Err(format!("Unknown sandbox mode: {}", s)),
250 }
251 }
252}
253
254impl std::fmt::Display for SandboxMode {
255 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
256 match self {
257 Self::None => write!(f, "none"),
258 Self::PathValidation => write!(f, "path"),
259 Self::Bubblewrap => write!(f, "bwrap"),
260 Self::Landlock => write!(f, "landlock"),
261 Self::LandlockBwrap => write!(f, "landlock+bwrap"),
262 Self::Docker => write!(f, "docker"),
263 Self::MacOSSandbox => write!(f, "macos"),
264 Self::Auto => write!(f, "auto"),
265 }
266 }
267}
268
269pub fn validate_path(path: &Path, policy: &SandboxPolicy) -> Result<(), String> {
273 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
274
275 for denied in &policy.deny_read {
277 if let Ok(denied_canon) = denied.canonicalize() {
278 if canonical.starts_with(&denied_canon) {
279 return Err(format!(
280 "Access denied: path {} is in protected area",
281 path.display()
282 ));
283 }
284 }
285 }
286
287 if !policy.allow_paths.is_empty() {
289 let allowed = policy.allow_paths.iter().any(|allowed| {
290 allowed
291 .canonicalize()
292 .map(|c| canonical.starts_with(&c))
293 .unwrap_or(false)
294 });
295 if !allowed {
296 return Err(format!(
297 "Access denied: path {} is not in allowed areas",
298 path.display()
299 ));
300 }
301 }
302
303 Ok(())
304}
305
306#[cfg(target_os = "linux")]
310pub fn wrap_with_bwrap(command: &str, policy: &SandboxPolicy) -> (String, Vec<String>) {
311 let mut args = Vec::new();
312
313 let is_read_denied = |path: &Path| -> bool {
315 policy
316 .deny_read
317 .iter()
318 .any(|deny| path.starts_with(deny) || deny.starts_with(path))
319 };
320
321 let is_write_denied = |path: &Path| -> bool {
323 policy
324 .deny_write
325 .iter()
326 .any(|deny| path.starts_with(deny) || deny.starts_with(path))
327 };
328
329 let is_exec_denied = |path: &Path| -> bool {
331 policy
332 .deny_exec
333 .iter()
334 .any(|deny| path.starts_with(deny) || deny.starts_with(path))
335 };
336
337 args.push("--unshare-all".to_string());
339 args.push("--share-net".to_string()); for dir in &["/usr", "/lib", "/lib64", "/bin", "/sbin"] {
343 let path = Path::new(dir);
344 if path.exists() && !is_read_denied(path) && !is_exec_denied(path) {
345 args.push("--ro-bind".to_string());
346 args.push(dir.to_string());
347 args.push(dir.to_string());
348 }
349 }
350
351 let etc_path = Path::new("/etc");
353 if etc_path.exists() && !is_read_denied(etc_path) {
354 args.push("--ro-bind".to_string());
355 args.push("/etc".to_string());
356 args.push("/etc".to_string());
357 }
358
359 if !is_read_denied(&policy.workspace) {
361 if is_write_denied(&policy.workspace) {
362 args.push("--ro-bind".to_string());
363 } else {
364 args.push("--bind".to_string());
365 }
366 args.push(policy.workspace.display().to_string());
367 args.push(policy.workspace.display().to_string());
368 }
369
370 args.push("--tmpfs".to_string());
372 args.push("/tmp".to_string());
373
374 args.push("--proc".to_string());
376 args.push("/proc".to_string());
377
378 args.push("--dev".to_string());
380 args.push("/dev".to_string());
381
382 args.push("--chdir".to_string());
384 args.push(policy.workspace.display().to_string());
385
386 args.push("--die-with-parent".to_string());
388
389 args.push("--".to_string());
391 args.push("sh".to_string());
392 args.push("-c".to_string());
393 args.push(command.to_string());
394
395 ("bwrap".to_string(), args)
396}
397
398#[cfg(not(target_os = "linux"))]
399pub fn wrap_with_bwrap(_command: &str, _policy: &SandboxPolicy) -> (String, Vec<String>) {
400 panic!("Bubblewrap is only available on Linux");
401}
402
403#[cfg(target_os = "macos")]
407fn generate_seatbelt_profile(policy: &SandboxPolicy) -> String {
408 let mut profile = String::from("(version 1)\n");
409
410 profile.push_str("(deny default)\n");
412
413 profile.push_str("(allow process-fork)\n");
415 profile.push_str("(allow process-exec)\n");
416 profile.push_str("(allow signal)\n");
417 profile.push_str("(allow sysctl-read)\n");
418
419 profile.push_str("(allow file-read* (subpath \"/usr\"))\n");
421 profile.push_str("(allow file-read* (subpath \"/bin\"))\n");
422 profile.push_str("(allow file-read* (subpath \"/sbin\"))\n");
423 profile.push_str("(allow file-read* (subpath \"/Library\"))\n");
424 profile.push_str("(allow file-read* (subpath \"/System\"))\n");
425 profile.push_str("(allow file-read* (subpath \"/private/etc\"))\n");
426 profile.push_str("(allow file-read* (subpath \"/private/var/db\"))\n");
427
428 profile.push_str(&format!(
430 "(allow file-read* file-write* (subpath \"{}\"))\n",
431 policy.workspace.display()
432 ));
433
434 profile.push_str("(allow file-read* file-write* (subpath \"/private/tmp\"))\n");
436 profile.push_str("(allow file-read* file-write* (subpath \"/tmp\"))\n");
437
438 for denied in &policy.deny_read {
440 profile.push_str(&format!(
441 "(deny file-read* (subpath \"{}\"))\n",
442 denied.display()
443 ));
444 }
445 for denied in &policy.deny_write {
446 profile.push_str(&format!(
447 "(deny file-write* (subpath \"{}\"))\n",
448 denied.display()
449 ));
450 }
451 for denied in &policy.deny_exec {
452 profile.push_str(&format!(
453 "(deny process-exec (subpath \"{}\"))\n",
454 denied.display()
455 ));
456 }
457
458 profile.push_str("(allow network*)\n");
460
461 profile
462}
463
464#[cfg(target_os = "macos")]
466pub fn wrap_with_macos_sandbox(command: &str, policy: &SandboxPolicy) -> (String, Vec<String>) {
467 let profile = generate_seatbelt_profile(policy);
468
469 let args = vec![
470 "-p".to_string(),
471 profile,
472 "sh".to_string(),
473 "-c".to_string(),
474 command.to_string(),
475 ];
476
477 ("sandbox-exec".to_string(), args)
478}
479
480#[cfg(not(target_os = "macos"))]
481pub fn wrap_with_macos_sandbox(_command: &str, _policy: &SandboxPolicy) -> (String, Vec<String>) {
482 panic!("macOS sandbox is only available on macOS");
483}
484
485#[cfg(target_os = "linux")]
494pub fn apply_landlock(policy: &SandboxPolicy) -> Result<(), String> {
495 use landlock::{
496 ABI, Access, AccessFs, PathBeneath, PathFd, Ruleset, RulesetAttr, RulesetCreatedAttr,
497 };
498
499 let abi = ABI::V2;
500
501 let mut ruleset = Ruleset::default()
504 .handle_access(AccessFs::from_all(abi))
505 .map_err(|e| format!("Landlock ruleset creation failed: {}", e))?
506 .create()
507 .map_err(|e| format!("Landlock not supported (kernel < 5.13): {}", e))?;
508
509 let system_read_paths = [
511 "/usr", "/lib", "/lib64", "/bin", "/sbin",
512 "/etc", "/proc", "/sys", "/dev",
514 ];
515
516 let system_rw_paths = ["/tmp", "/var/tmp"];
518
519 for path_str in &system_read_paths {
521 let path = std::path::Path::new(path_str);
522 if path.exists() {
523 match PathFd::new(path) {
524 Ok(fd) => {
525 ruleset = ruleset
526 .add_rule(PathBeneath::new(fd, AccessFs::from_read(abi)))
527 .map_err(|e| format!("Failed to add read rule for {}: {}", path_str, e))?;
528 }
529 Err(e) => {
530 warn!(path = %path_str, error = %e, "Cannot open path for Landlock read rule");
531 }
532 }
533 }
534 }
535
536 for path_str in &system_rw_paths {
538 let path = std::path::Path::new(path_str);
539 if path.exists() {
540 match PathFd::new(path) {
541 Ok(fd) => {
542 ruleset = ruleset
543 .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi)))
544 .map_err(|e| format!("Failed to add rw rule for {}: {}", path_str, e))?;
545 }
546 Err(e) => {
547 warn!(path = %path_str, error = %e, "Cannot open path for Landlock rw rule");
548 }
549 }
550 }
551 }
552
553 if policy.workspace.exists() {
555 match PathFd::new(&policy.workspace) {
556 Ok(fd) => {
557 ruleset = ruleset
558 .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi)))
559 .map_err(|e| format!("Failed to add workspace rule: {}", e))?;
560 }
561 Err(e) => {
562 return Err(format!(
563 "Cannot open workspace {:?} for Landlock: {}",
564 policy.workspace, e
565 ));
566 }
567 }
568 }
569
570 for allowed_path in &policy.allow_paths {
572 if allowed_path.exists() {
573 match PathFd::new(allowed_path) {
574 Ok(fd) => {
575 ruleset = ruleset
576 .add_rule(PathBeneath::new(fd, AccessFs::from_all(abi)))
577 .map_err(|e| {
578 format!("Failed to add allow rule for {:?}: {}", allowed_path, e)
579 })?;
580 }
581 Err(e) => {
582 warn!(
583 path = ?allowed_path,
584 error = %e,
585 "Cannot open path for Landlock allow rule"
586 );
587 }
588 }
589 }
590 }
591
592 if !policy.deny_read.is_empty() {
597 debug!(
598 denied_paths = policy.deny_read.len(),
599 "Landlock: paths denied by omission from allowlist"
600 );
601 }
602
603 ruleset
605 .restrict_self()
606 .map_err(|e| format!("Failed to apply Landlock restrictions: {}", e))?;
607
608 info!(
609 workspace = ?policy.workspace,
610 system_paths = system_read_paths.len() + system_rw_paths.len(),
611 "Landlock sandbox active"
612 );
613
614 Ok(())
615}
616
617#[cfg(not(target_os = "linux"))]
618pub fn apply_landlock(_policy: &SandboxPolicy) -> Result<(), String> {
619 Err("Landlock is only supported on Linux".to_string())
620}
621
622pub fn run_sandboxed(
626 command: &str,
627 policy: &SandboxPolicy,
628 mode: SandboxMode,
629) -> Result<std::process::Output, String> {
630 let caps = SandboxCapabilities::detect();
631
632 let effective_mode = match mode {
634 SandboxMode::Auto => caps.best_mode(),
635 other => other,
636 };
637
638 match effective_mode {
640 SandboxMode::Bubblewrap if !caps.bubblewrap => {
641 warn!("Bubblewrap not available, falling back to path validation");
642 return run_with_path_validation(command, policy);
643 }
644 SandboxMode::Landlock if !caps.landlock => {
645 warn!("Landlock not available, falling back to path validation");
646 return run_with_path_validation(command, policy);
647 }
648 SandboxMode::LandlockBwrap if !caps.landlock || !caps.bubblewrap => {
649 warn!("Landlock+Bubblewrap not fully available");
650 if caps.landlock {
651 debug!("Falling back to Landlock only");
652 return run_with_path_validation(command, policy);
653 } else if caps.bubblewrap {
654 debug!("Falling back to Bubblewrap only");
655 return run_with_bubblewrap(command, policy);
656 } else {
657 debug!("Falling back to path validation");
658 return run_with_path_validation(command, policy);
659 }
660 }
661 SandboxMode::Docker if !caps.docker => {
662 warn!("Docker not available, falling back to path validation");
663 return run_with_path_validation(command, policy);
664 }
665 SandboxMode::MacOSSandbox if !caps.macos_sandbox => {
666 warn!("macOS sandbox not available, falling back to path validation");
667 return run_with_path_validation(command, policy);
668 }
669 _ => {}
670 }
671
672 match effective_mode {
673 SandboxMode::None => run_unsandboxed(command),
674 SandboxMode::PathValidation => run_with_path_validation(command, policy),
675 SandboxMode::Bubblewrap => run_with_bubblewrap(command, policy),
676 SandboxMode::Docker => run_with_docker(command, policy),
677 SandboxMode::MacOSSandbox => run_with_macos_sandbox(command, policy),
678 SandboxMode::Landlock => {
679 run_with_path_validation(command, policy)
681 }
682 SandboxMode::LandlockBwrap => run_with_landlock_bwrap(command, policy),
683 SandboxMode::Auto => unreachable!(), }
685}
686
687fn run_unsandboxed(command: &str) -> Result<std::process::Output, String> {
688 std::process::Command::new("sh")
689 .arg("-c")
690 .arg(command)
691 .output()
692 .map_err(|e| format!("Command failed: {}", e))
693}
694
695pub fn extract_paths_from_command(command: &str) -> Vec<PathBuf> {
704 use std::path::PathBuf;
705 let mut paths = Vec::new();
706
707 let chars = command.chars().peekable();
712 let mut current_token = String::new();
713 let mut in_quotes = false;
714 let mut quote_char = ' ';
715
716 for ch in chars {
717 match ch {
718 '\'' | '"' => {
719 if in_quotes && ch == quote_char {
720 in_quotes = false;
722 if !current_token.is_empty()
723 && (current_token.starts_with('/') || current_token.starts_with("~/"))
724 {
725 paths.push(PathBuf::from(¤t_token));
726 }
727 current_token.clear();
728 } else if !in_quotes {
729 in_quotes = true;
731 quote_char = ch;
732 }
733 }
734 ' ' | '\t' | '\n' | ';' | '&' | '|' | '(' | ')' | '<' | '>' if !in_quotes => {
735 if !current_token.is_empty()
737 && (current_token.starts_with('/') || current_token.starts_with("~/"))
738 {
739 paths.push(PathBuf::from(¤t_token));
740 }
741 current_token.clear();
742 }
743 _ => {
744 current_token.push(ch);
745 }
746 }
747 }
748
749 if !current_token.is_empty()
751 && (current_token.starts_with('/') || current_token.starts_with("~/"))
752 {
753 paths.push(PathBuf::from(¤t_token));
754 }
755
756 paths
757}
758
759fn run_with_path_validation(
760 command: &str,
761 policy: &SandboxPolicy,
762) -> Result<std::process::Output, String> {
763 let paths = extract_paths_from_command(command);
765
766 for path in &paths {
768 validate_path(path, policy)?;
769 }
770
771 let first_token = command.split_whitespace().next().unwrap_or("");
774 if !first_token.is_empty() {
775 let cmd_path = Path::new(first_token);
776
777 if first_token.starts_with('/')
779 || first_token.starts_with("./")
780 || first_token.starts_with("~/")
781 {
782 for denied in &policy.deny_exec {
784 if let (Ok(cmd_canon), Ok(denied_canon)) =
785 (cmd_path.canonicalize(), denied.canonicalize())
786 {
787 if cmd_canon.starts_with(&denied_canon) {
788 return Err(format!(
789 "Execution denied: {} is in protected area (deny_exec)",
790 first_token
791 ));
792 }
793 }
794 }
795 }
796 }
797
798 if paths.is_empty() {
800 warn!(
801 command_preview = &command[..command.len().min(50)],
802 "PathValidation mode cannot detect dynamic paths in command"
803 );
804 }
805
806 run_unsandboxed(command)
807}
808
809#[cfg(target_os = "linux")]
810fn run_with_bubblewrap(
811 command: &str,
812 policy: &SandboxPolicy,
813) -> Result<std::process::Output, String> {
814 let (cmd, args) = wrap_with_bwrap(command, policy);
815
816 let mut proc = std::process::Command::new(&cmd);
817 proc.args(&args);
818
819 proc.env_clear();
821 for (key, value) in std::env::vars() {
822 if key.starts_with("LANG")
823 || key.starts_with("LC_")
824 || key == "PATH"
825 || key == "HOME"
826 || key == "USER"
827 || key == "TERM"
828 {
829 proc.env(&key, &value);
830 }
831 }
832
833 proc.output()
834 .map_err(|e| format!("Sandboxed command failed: {}", e))
835}
836
837#[cfg(not(target_os = "linux"))]
838fn run_with_bubblewrap(
839 _command: &str,
840 _policy: &SandboxPolicy,
841) -> Result<std::process::Output, String> {
842 Err("Bubblewrap is only available on Linux".to_string())
843}
844
845#[cfg(target_os = "macos")]
846fn run_with_macos_sandbox(
847 command: &str,
848 policy: &SandboxPolicy,
849) -> Result<std::process::Output, String> {
850 let (cmd, args) = wrap_with_macos_sandbox(command, policy);
851
852 std::process::Command::new(&cmd)
853 .args(&args)
854 .output()
855 .map_err(|e| format!("Sandboxed command failed: {}", e))
856}
857
858#[cfg(not(target_os = "macos"))]
859fn run_with_macos_sandbox(
860 _command: &str,
861 _policy: &SandboxPolicy,
862) -> Result<std::process::Output, String> {
863 Err("macOS sandbox is only available on macOS".to_string())
864}
865
866#[cfg(target_os = "linux")]
875fn wrap_with_combined_bwrap(command: &str, policy: &SandboxPolicy) -> (String, Vec<String>) {
876 let mut args = Vec::new();
877
878 args.push("--unshare-all".to_string());
880 args.push("--share-net".to_string()); args.push("--die-with-parent".to_string());
882 args.push("--new-session".to_string()); let is_blocked = |path: &Path| -> bool {
886 policy
887 .deny_read
888 .iter()
889 .any(|deny| path.starts_with(deny) || deny.starts_with(path))
890 || policy
891 .deny_exec
892 .iter()
893 .any(|deny| path.starts_with(deny) || deny.starts_with(path))
894 };
895
896 for dir in &["/usr", "/lib", "/lib64", "/bin", "/sbin"] {
898 let path = Path::new(dir);
899 if path.exists() && !is_blocked(path) {
900 args.push("--ro-bind".to_string());
901 args.push(dir.to_string());
902 args.push(dir.to_string());
903 }
904 }
905
906 let etc_path = Path::new("/etc");
908 if etc_path.exists() && !is_blocked(etc_path) {
909 args.push("--ro-bind".to_string());
910 args.push("/etc".to_string());
911 args.push("/etc".to_string());
912 }
913
914 if !policy
916 .deny_read
917 .iter()
918 .any(|deny| policy.workspace.starts_with(deny) || deny.starts_with(&policy.workspace))
919 {
920 if policy
921 .deny_write
922 .iter()
923 .any(|deny| policy.workspace.starts_with(deny) || deny.starts_with(&policy.workspace))
924 {
925 args.push("--ro-bind".to_string());
926 } else {
927 args.push("--bind".to_string());
928 }
929 args.push(policy.workspace.display().to_string());
930 args.push(policy.workspace.display().to_string());
931 }
932
933 args.push("--tmpfs".to_string());
935 args.push("/tmp".to_string());
936
937 args.push("--proc".to_string());
939 args.push("/proc".to_string());
940 args.push("--dev".to_string());
941 args.push("/dev".to_string());
942
943 args.push("--chdir".to_string());
945 args.push(policy.workspace.display().to_string());
946
947 args.push("--".to_string());
949 args.push("sh".to_string());
950 args.push("-c".to_string());
951 args.push(command.to_string());
952
953 ("bwrap".to_string(), args)
954}
955
956#[cfg(target_os = "linux")]
973fn run_with_landlock_bwrap(
974 command: &str,
975 policy: &SandboxPolicy,
976) -> Result<std::process::Output, String> {
977 let (cmd, args) = wrap_with_combined_bwrap(command, policy);
979
980 let mut proc = std::process::Command::new(&cmd);
981 proc.args(&args);
982
983 proc.env_clear();
985 for (key, value) in std::env::vars() {
986 if key.starts_with("LANG")
987 || key.starts_with("LC_")
988 || key == "PATH"
989 || key == "HOME"
990 || key == "USER"
991 || key == "TERM"
992 {
993 proc.env(&key, &value);
994 }
995 }
996
997 info!(
998 mode = "Landlock+Bubblewrap",
999 denied_paths = policy.deny_read.len() + policy.deny_exec.len(),
1000 "Defense-in-depth sandbox active"
1001 );
1002
1003 proc.output()
1004 .map_err(|e| format!("Combined sandboxed command failed: {}", e))
1005}
1006
1007#[cfg(not(target_os = "linux"))]
1008fn run_with_landlock_bwrap(
1009 _command: &str,
1010 _policy: &SandboxPolicy,
1011) -> Result<std::process::Output, String> {
1012 Err("Landlock+Bubblewrap is only available on Linux".to_string())
1013}
1014
1015fn run_with_docker(command: &str, policy: &SandboxPolicy) -> Result<std::process::Output, String> {
1028 use std::time::{SystemTime, UNIX_EPOCH};
1029
1030 let timestamp = SystemTime::now()
1032 .duration_since(UNIX_EPOCH)
1033 .unwrap()
1034 .as_secs();
1035 let container_name = format!("rustyclaw-sandbox-{}", timestamp);
1036
1037 let mut docker_args = vec![
1039 "run".to_string(),
1040 "--rm".to_string(), "--name".to_string(),
1042 container_name,
1043 "--memory".to_string(),
1045 "2g".to_string(),
1046 "--cpus".to_string(),
1047 "1.0".to_string(),
1048 "--user".to_string(),
1050 "1000:1000".to_string(), "--cap-drop".to_string(),
1052 "ALL".to_string(), "--security-opt".to_string(),
1054 "no-new-privileges:true".to_string(),
1055 "--read-only".to_string(), "--network".to_string(),
1058 "bridge".to_string(), "--tmpfs".to_string(),
1061 "/tmp:size=512M".to_string(),
1062 ];
1063
1064 let workspace_str = policy.workspace.display().to_string();
1066
1067 let workspace_denied = policy
1069 .deny_read
1070 .iter()
1071 .any(|deny| policy.workspace.starts_with(deny) || deny.starts_with(&policy.workspace));
1072
1073 if !workspace_denied {
1074 let write_allowed = !policy
1075 .deny_write
1076 .iter()
1077 .any(|deny| policy.workspace.starts_with(deny) || deny.starts_with(&policy.workspace));
1078
1079 let mount_mode = if write_allowed { "rw" } else { "ro" };
1080 docker_args.push("--volume".to_string());
1081 docker_args.push(format!("{}:/workspace:{}", workspace_str, mount_mode));
1082 docker_args.push("--workdir".to_string());
1083 docker_args.push("/workspace".to_string());
1084 } else {
1085 docker_args.push("--workdir".to_string());
1087 docker_args.push("/tmp".to_string());
1088 }
1089
1090 docker_args.push("alpine:latest".to_string());
1092
1093 docker_args.push("sh".to_string());
1095 docker_args.push("-c".to_string());
1096 docker_args.push(command.to_string());
1097
1098 info!(
1099 mode = "Docker",
1100 workspace = %workspace_str,
1101 workspace_blocked = workspace_denied,
1102 "Docker container sandbox active"
1103 );
1104
1105 std::process::Command::new("docker")
1107 .args(&docker_args)
1108 .output()
1109 .map_err(|e| format!("Docker execution failed: {}", e))
1110}
1111
1112pub struct Sandbox {
1116 pub mode: SandboxMode,
1117 pub policy: SandboxPolicy,
1118 pub capabilities: SandboxCapabilities,
1119}
1120
1121impl Sandbox {
1122 pub fn new(policy: SandboxPolicy) -> Self {
1124 let caps = SandboxCapabilities::detect();
1125 Self {
1126 mode: SandboxMode::Auto,
1127 policy,
1128 capabilities: caps,
1129 }
1130 }
1131
1132 pub fn with_mode(mode: SandboxMode, policy: SandboxPolicy) -> Self {
1134 let caps = SandboxCapabilities::detect();
1135 Self {
1136 mode,
1137 policy,
1138 capabilities: caps,
1139 }
1140 }
1141
1142 pub fn effective_mode(&self) -> SandboxMode {
1144 match self.mode {
1145 SandboxMode::Auto => self.capabilities.best_mode(),
1146 other => other,
1147 }
1148 }
1149
1150 pub fn init(&self) -> Result<(), String> {
1152 if self.effective_mode() == SandboxMode::Landlock {
1153 apply_landlock(&self.policy)?;
1154 }
1155 Ok(())
1156 }
1157
1158 pub fn check_path(&self, path: &Path) -> Result<(), String> {
1160 if self.mode == SandboxMode::None {
1161 return Ok(());
1162 }
1163 validate_path(path, &self.policy)
1164 }
1165
1166 pub fn run_command(&self, command: &str) -> Result<std::process::Output, String> {
1168 run_sandboxed(command, &self.policy, self.mode)
1169 }
1170
1171 pub fn status(&self) -> String {
1173 format!(
1174 "Mode: {} (effective: {})\n{}",
1175 self.mode,
1176 self.effective_mode(),
1177 self.capabilities.describe()
1178 )
1179 }
1180}
1181
1182#[cfg(test)]
1185mod tests {
1186 use super::*;
1187
1188 #[test]
1189 fn test_capabilities_detect() {
1190 let caps = SandboxCapabilities::detect();
1191 assert!(
1193 caps.best_mode() != SandboxMode::None
1194 || caps.best_mode() == SandboxMode::PathValidation
1195 );
1196 }
1197
1198 #[test]
1199 fn test_policy_creation() {
1200 let policy = SandboxPolicy::protect_credentials(
1201 "/home/user/.rustyclaw/credentials",
1202 "/home/user/.rustyclaw/workspace",
1203 );
1204
1205 assert_eq!(policy.deny_read.len(), 1);
1206 assert!(policy.deny_read[0].ends_with("credentials"));
1207 }
1208
1209 #[test]
1210 fn test_path_validation_denied() {
1211 let policy = SandboxPolicy::protect_credentials("/tmp/creds", "/tmp/workspace");
1212 std::fs::create_dir_all("/tmp/creds").ok();
1213 let _ = std::fs::write("/tmp/creds/secrets.json", "test");
1215 let result = validate_path(Path::new("/tmp/creds/secrets.json"), &policy);
1216 assert!(result.is_err());
1217 }
1218
1219 #[test]
1220 fn test_path_validation_allowed() {
1221 let policy =
1222 SandboxPolicy::protect_credentials("/tmp/test-creds-isolated", "/tmp/test-workspace");
1223
1224 let result = validate_path(Path::new("/tmp/other/file.txt"), &policy);
1225 assert!(result.is_ok());
1226 }
1227
1228 #[test]
1229 fn test_sandbox_mode_parsing() {
1230 assert_eq!("none".parse::<SandboxMode>().unwrap(), SandboxMode::None);
1231 assert_eq!("auto".parse::<SandboxMode>().unwrap(), SandboxMode::Auto);
1232 assert_eq!(
1233 "bwrap".parse::<SandboxMode>().unwrap(),
1234 SandboxMode::Bubblewrap
1235 );
1236 assert_eq!(
1237 "macos".parse::<SandboxMode>().unwrap(),
1238 SandboxMode::MacOSSandbox
1239 );
1240 }
1241
1242 #[test]
1243 fn test_sandbox_status() {
1244 let policy = SandboxPolicy::default();
1245 let sandbox = Sandbox::new(policy);
1246 let status = sandbox.status();
1247 assert!(status.contains("Mode:"));
1248 assert!(status.contains("Available:"));
1249 }
1250
1251 #[cfg(target_os = "linux")]
1252 #[test]
1253 fn test_bwrap_command_generation() {
1254 let policy = SandboxPolicy {
1255 workspace: PathBuf::from("/home/user/workspace"),
1256 ..Default::default()
1257 };
1258
1259 let (cmd, args) = wrap_with_bwrap("ls -la", &policy);
1260
1261 assert_eq!(cmd, "bwrap");
1262 assert!(args.contains(&"--unshare-all".to_string()));
1263 assert!(args.contains(&"ls -la".to_string()));
1264 }
1265
1266 #[test]
1267 fn test_run_unsandboxed() {
1268 let output = run_unsandboxed("echo hello").unwrap();
1269 assert!(output.status.success());
1270 assert!(String::from_utf8_lossy(&output.stdout).contains("hello"));
1271 }
1272
1273 #[test]
1274 fn test_extract_paths_absolute() {
1275 let paths = extract_paths_from_command("cat /etc/passwd");
1276 assert_eq!(paths.len(), 1);
1277 assert_eq!(paths[0], PathBuf::from("/etc/passwd"));
1278 }
1279
1280 #[test]
1281 fn test_extract_paths_home() {
1282 let paths = extract_paths_from_command("cat ~/file.txt");
1283 assert_eq!(paths.len(), 1);
1284 assert_eq!(paths[0], PathBuf::from("~/file.txt"));
1285 }
1286
1287 #[test]
1288 fn test_extract_paths_multiple() {
1289 let paths = extract_paths_from_command("cp /etc/hosts ~/backup/hosts");
1290 assert_eq!(paths.len(), 2);
1291 assert_eq!(paths[0], PathBuf::from("/etc/hosts"));
1292 assert_eq!(paths[1], PathBuf::from("~/backup/hosts"));
1293 }
1294
1295 #[test]
1296 fn test_extract_paths_quoted() {
1297 let paths = extract_paths_from_command("cat \"/path/with spaces/file\"");
1298 assert_eq!(paths.len(), 1);
1299 assert_eq!(paths[0], PathBuf::from("/path/with spaces/file"));
1300 }
1301
1302 #[test]
1303 fn test_extract_paths_no_paths() {
1304 let paths = extract_paths_from_command("echo hello world");
1305 assert_eq!(paths.len(), 0);
1306 }
1307
1308 #[test]
1309 fn test_extract_paths_complex_command() {
1310 let paths = extract_paths_from_command("tar czf /backup/archive.tar.gz ~/documents");
1311 assert_eq!(paths.len(), 2);
1312 assert_eq!(paths[0], PathBuf::from("/backup/archive.tar.gz"));
1313 assert_eq!(paths[1], PathBuf::from("~/documents"));
1314 }
1315
1316 #[test]
1317 fn test_path_validation_blocks_credentials() {
1318 let policy = SandboxPolicy::protect_credentials("/tmp/test_creds", "/tmp/test_workspace");
1319 std::fs::create_dir_all("/tmp/test_creds").ok();
1320 let _ = std::fs::write("/tmp/test_creds/secret.txt", "test");
1322 let result = run_with_path_validation("cat /tmp/test_creds/secret.txt", &policy);
1324 assert!(result.is_err());
1325 assert!(result.unwrap_err().contains("Access denied"));
1326 }
1327
1328 #[test]
1329 fn test_path_validation_allows_workspace() {
1330 let policy = SandboxPolicy::protect_credentials("/tmp/test_creds2", "/tmp/test_workspace2");
1331 std::fs::create_dir_all("/tmp/test_workspace2").ok();
1332
1333 let result =
1335 run_with_path_validation("echo hello > /tmp/test_workspace2/file.txt", &policy);
1336 if result.is_err() {
1339 assert!(!result.unwrap_err().contains("Access denied"));
1340 }
1341 }
1342}