1use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SandboxConfig {
19 pub allowed_paths: Vec<PathBuf>,
21 pub denied_paths: Vec<PathBuf>,
23 pub env_allowlist: Vec<String>,
25 pub env_denylist: Vec<String>,
27 pub max_output_bytes: usize,
29 pub max_execution_secs: u64,
31 pub allow_network: bool,
33 pub working_dir: Option<PathBuf>,
35 pub denied_commands: Vec<String>,
37}
38
39impl Default for SandboxConfig {
40 fn default() -> Self {
41 let home = std::env::var("HOME")
42 .map(PathBuf::from)
43 .unwrap_or_else(|_| PathBuf::from("/root"));
44
45 Self {
46 allowed_paths: vec![
47 std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
48 PathBuf::from("/tmp"),
49 ],
50 denied_paths: vec![
51 PathBuf::from("/etc/shadow"),
52 PathBuf::from("/etc/passwd"),
53 home.join(".ssh"),
54 home.join(".gnupg"),
55 home.join(".aws"),
56 ],
57 env_allowlist: vec![
58 "PATH".into(),
59 "HOME".into(),
60 "USER".into(),
61 "LANG".into(),
62 "LC_ALL".into(),
63 "TERM".into(),
64 "SHELL".into(),
65 "TMPDIR".into(),
66 ],
67 env_denylist: vec![
68 "*_SECRET*".into(),
69 "*_TOKEN".into(),
70 "*_PASSWORD".into(),
71 "*_KEY".into(),
72 "AWS_*".into(),
73 "GITHUB_TOKEN".into(),
74 ],
75 max_output_bytes: 1_048_576, max_execution_secs: 120,
77 allow_network: true,
78 working_dir: None,
79 denied_commands: vec![
80 "rm -rf /".into(),
81 "rm -rf /*".into(),
82 "dd if=/dev".into(),
83 "mkfs".into(),
84 ":(){ :|:& };:".into(),
85 "chmod -R 777 /".into(),
86 "> /dev/sda".into(),
87 ],
88 }
89 }
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
97pub enum SandboxViolation {
98 DeniedCommand { command: String, reason: String },
100 PathTraversal {
102 path: String,
103 attempted_escape: String,
104 },
105 DeniedPath { path: String },
107 PathNotAllowed { path: String },
109 DeniedEnvironment { var_name: String },
111}
112
113impl std::fmt::Display for SandboxViolation {
114 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
115 match self {
116 SandboxViolation::DeniedCommand { command, reason } => {
117 write!(
118 f,
119 "sandbox violation: denied command '{}' — {}",
120 command, reason
121 )
122 }
123 SandboxViolation::PathTraversal {
124 path,
125 attempted_escape,
126 } => {
127 write!(
128 f,
129 "sandbox violation: path traversal in '{}' — attempted escape via '{}'",
130 path, attempted_escape
131 )
132 }
133 SandboxViolation::DeniedPath { path } => {
134 write!(f, "sandbox violation: access to denied path '{}'", path)
135 }
136 SandboxViolation::PathNotAllowed { path } => {
137 write!(
138 f,
139 "sandbox violation: path '{}' is outside allowed directories",
140 path
141 )
142 }
143 SandboxViolation::DeniedEnvironment { var_name } => {
144 write!(
145 f,
146 "sandbox violation: environment variable '{}' is denied",
147 var_name
148 )
149 }
150 }
151 }
152}
153
154impl std::error::Error for SandboxViolation {}
155
156#[derive(Debug, Clone)]
161pub struct SandboxEnforcer {
162 pub config: SandboxConfig,
164}
165
166impl SandboxEnforcer {
167 pub fn new(config: SandboxConfig) -> Self {
169 Self { config }
170 }
171
172 pub fn with_defaults() -> Self {
174 Self::new(SandboxConfig::default())
175 }
176
177 pub fn validate_command(&self, command: &str) -> Result<(), SandboxViolation> {
182 let trimmed = command.trim();
183
184 for denied in &self.config.denied_commands {
186 if trimmed.starts_with(denied.as_str()) || trimmed.contains(denied.as_str()) {
187 return Err(SandboxViolation::DeniedCommand {
188 command: trimmed.to_string(),
189 reason: format!("matches denied pattern '{}'", denied),
190 });
191 }
192 }
193
194 if trimmed.contains('`') {
196 return Err(SandboxViolation::DeniedCommand {
199 command: trimmed.to_string(),
200 reason: "backtick shell injection detected".into(),
201 });
202 }
203
204 if trimmed.contains("$(") {
206 return Err(SandboxViolation::DeniedCommand {
207 command: trimmed.to_string(),
208 reason: "$() command substitution detected".into(),
209 });
210 }
211
212 let sensitive_pipe_targets = ["sh", "bash", "eval", "exec", "sudo"];
214 if trimmed.contains('|') {
215 for segment in trimmed.split('|').skip(1) {
216 let target = segment.split_whitespace().next().unwrap_or("");
217 for sensitive in &sensitive_pipe_targets {
218 if target == *sensitive {
219 return Err(SandboxViolation::DeniedCommand {
220 command: trimmed.to_string(),
221 reason: format!("pipe to sensitive command '{}' detected", sensitive),
222 });
223 }
224 }
225 }
226 }
227
228 Ok(())
229 }
230
231 pub fn validate_path(&self, path: &Path) -> Result<(), SandboxViolation> {
236 let canonical = match path.canonicalize() {
239 Ok(p) => p,
240 Err(_) => {
241 self.normalize_path(path)
243 }
244 };
245
246 let canonical_str = canonical.display().to_string();
247
248 for denied in &self.config.denied_paths {
250 let denied_canonical = match denied.canonicalize() {
251 Ok(p) => p,
252 Err(_) => self.normalize_path(denied),
253 };
254 if canonical.starts_with(&denied_canonical) {
255 return Err(SandboxViolation::DeniedPath {
256 path: canonical_str,
257 });
258 }
259 }
260
261 if self.config.allowed_paths.is_empty() {
263 return Err(SandboxViolation::PathNotAllowed {
264 path: canonical_str,
265 });
266 }
267
268 let mut inside_allowed = false;
269 for allowed in &self.config.allowed_paths {
270 let allowed_canonical = match allowed.canonicalize() {
271 Ok(p) => p,
272 Err(_) => self.normalize_path(allowed),
273 };
274 if canonical.starts_with(&allowed_canonical) {
275 inside_allowed = true;
276 break;
277 }
278 }
279
280 if !inside_allowed {
281 let path_str = path.display().to_string();
283 if path_str.contains("..") {
284 return Err(SandboxViolation::PathTraversal {
285 path: path_str,
286 attempted_escape: canonical_str,
287 });
288 }
289 return Err(SandboxViolation::PathNotAllowed {
290 path: canonical_str,
291 });
292 }
293
294 Ok(())
295 }
296
297 pub fn sanitize_environment(&self) -> Vec<(String, String)> {
304 let current_env: Vec<(String, String)> = std::env::vars().collect();
305 let mut sanitized = Vec::new();
306
307 for (key, value) in ¤t_env {
308 if !self.config.env_allowlist.contains(key) {
310 continue;
311 }
312
313 if self.matches_env_denylist(key) {
315 continue;
316 }
317
318 sanitized.push((key.clone(), value.clone()));
319 }
320
321 sanitized
322 }
323
324 pub fn build_command(
329 &self,
330 command: &str,
331 ) -> Result<tokio::process::Command, SandboxViolation> {
332 self.validate_command(command)?;
334
335 let mut cmd = tokio::process::Command::new("sh");
336 cmd.arg("-c").arg(command);
337
338 cmd.env_clear();
340 for (key, value) in self.sanitize_environment() {
341 cmd.env(&key, &value);
342 }
343
344 if let Some(ref wd) = self.config.working_dir {
346 cmd.current_dir(wd);
347 }
348
349 Ok(cmd)
350 }
351
352 fn matches_env_denylist(&self, var_name: &str) -> bool {
354 for pattern in &self.config.env_denylist {
355 if glob_match(pattern, var_name) {
356 if var_name == "PATH" && pattern.contains("_KEY") {
358 continue;
359 }
360 return true;
361 }
362 }
363 false
364 }
365
366 fn normalize_path(&self, path: &Path) -> PathBuf {
371 let effective = if path.is_relative() {
373 if let Some(ref wd) = self.config.working_dir {
374 wd.join(path)
375 } else {
376 std::env::current_dir()
377 .unwrap_or_else(|_| PathBuf::from("/"))
378 .join(path)
379 }
380 } else {
381 path.to_path_buf()
382 };
383
384 let mut logical_components = Vec::new();
386 for component in effective.components() {
387 match component {
388 std::path::Component::ParentDir => {
389 logical_components.pop();
390 }
391 std::path::Component::CurDir => {}
392 other => {
393 logical_components.push(other.as_os_str().to_os_string());
394 }
395 }
396 }
397
398 let mut logical = PathBuf::new();
399 for c in &logical_components {
400 logical.push(c);
401 }
402 if logical.as_os_str().is_empty() {
403 logical = PathBuf::from("/");
404 }
405
406 let mut ancestor = logical.clone();
410 let mut suffix_parts = Vec::new();
411 loop {
412 if ancestor.exists() {
413 if let Ok(real) = ancestor.canonicalize() {
414 let mut result = real;
415 for part in suffix_parts.into_iter().rev() {
416 result.push(part);
417 }
418 return result;
419 }
420 break;
421 }
422 if let Some(file_name) = ancestor.file_name() {
423 suffix_parts.push(file_name.to_os_string());
424 if !ancestor.pop() {
425 break;
426 }
427 } else {
428 break;
429 }
430 }
431
432 logical
433 }
434}
435
436fn glob_match(pattern: &str, text: &str) -> bool {
441 let parts: Vec<&str> = pattern.split('*').collect();
442
443 if parts.len() == 1 {
444 return pattern == text;
446 }
447
448 let mut pos = 0;
449 for (i, part) in parts.iter().enumerate() {
450 if part.is_empty() {
451 continue;
452 }
453 match text[pos..].find(part) {
454 Some(found) => {
455 if i == 0 && found != 0 {
457 return false;
458 }
459 pos += found + part.len();
460 }
461 None => return false,
462 }
463 }
464
465 if !pattern.ends_with('*') {
467 return pos == text.len();
468 }
469
470 true
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
481 fn test_default_config_sensible_values() {
482 let config = SandboxConfig::default();
483
484 assert!(!config.allowed_paths.is_empty());
485 assert!(config.allowed_paths.contains(&PathBuf::from("/tmp")));
486 assert!(!config.denied_paths.is_empty());
487 assert!(!config.env_allowlist.is_empty());
488 assert!(config.env_allowlist.contains(&"PATH".to_string()));
489 assert!(config.env_allowlist.contains(&"HOME".to_string()));
490 assert!(!config.env_denylist.is_empty());
491 assert_eq!(config.max_output_bytes, 1_048_576);
492 assert_eq!(config.max_execution_secs, 120);
493 assert!(config.allow_network);
494 assert!(config.working_dir.is_none());
495 assert!(!config.denied_commands.is_empty());
496 }
497
498 #[test]
502 fn test_validate_command_allows_normal_commands() {
503 let enforcer = SandboxEnforcer::with_defaults();
504
505 assert!(enforcer.validate_command("ls -la").is_ok());
506 assert!(enforcer.validate_command("cat README.md").is_ok());
507 assert!(enforcer.validate_command("grep -r 'pattern' src/").is_ok());
508 assert!(enforcer.validate_command("cargo build").is_ok());
509 assert!(enforcer.validate_command("echo hello").is_ok());
510 }
511
512 #[test]
516 fn test_validate_command_blocks_denied_commands() {
517 let enforcer = SandboxEnforcer::with_defaults();
518
519 let result = enforcer.validate_command("rm -rf /");
520 assert!(result.is_err());
521 match result.unwrap_err() {
522 SandboxViolation::DeniedCommand { command, reason } => {
523 assert!(command.contains("rm -rf /"));
524 assert!(reason.contains("denied pattern"));
525 }
526 other => panic!("expected DeniedCommand, got {:?}", other),
527 }
528
529 assert!(enforcer.validate_command("rm -rf /*").is_err());
530 assert!(
531 enforcer
532 .validate_command("dd if=/dev/zero of=disk.img")
533 .is_err()
534 );
535 assert!(enforcer.validate_command("mkfs.ext4 /dev/sda1").is_err());
536 }
537
538 #[test]
542 fn test_validate_command_detects_fork_bomb() {
543 let enforcer = SandboxEnforcer::with_defaults();
544
545 let result = enforcer.validate_command(":(){ :|:& };:");
546 assert!(result.is_err());
547 match result.unwrap_err() {
548 SandboxViolation::DeniedCommand { reason, .. } => {
549 assert!(reason.contains("denied pattern"));
550 }
551 other => panic!("expected DeniedCommand, got {:?}", other),
552 }
553 }
554
555 #[test]
559 fn test_validate_path_allows_files_in_allowed_dirs() {
560 let mut config = SandboxConfig::default();
561 config.allowed_paths = vec![PathBuf::from("/tmp")];
562 config.denied_paths = vec![];
563 let enforcer = SandboxEnforcer::new(config);
564
565 assert!(enforcer.validate_path(Path::new("/tmp/test.txt")).is_ok());
566 assert!(
567 enforcer
568 .validate_path(Path::new("/tmp/subdir/file.rs"))
569 .is_ok()
570 );
571 }
572
573 #[test]
577 fn test_validate_path_blocks_denied_dirs() {
578 let home = std::env::var("HOME")
579 .map(PathBuf::from)
580 .unwrap_or_else(|_| PathBuf::from("/root"));
581
582 let mut config = SandboxConfig::default();
583 config.allowed_paths = vec![home.clone()];
584 let enforcer = SandboxEnforcer::new(config);
585
586 let ssh_key = home.join(".ssh/id_rsa");
587 let result = enforcer.validate_path(&ssh_key);
588 assert!(result.is_err());
589 match result.unwrap_err() {
590 SandboxViolation::DeniedPath { path } => {
591 assert!(path.contains(".ssh"));
592 }
593 other => panic!("expected DeniedPath, got {:?}", other),
594 }
595 }
596
597 #[test]
601 fn test_validate_path_detects_traversal() {
602 let mut config = SandboxConfig::default();
603 config.allowed_paths = vec![PathBuf::from("/tmp/sandbox")];
604 config.denied_paths = vec![];
605 let enforcer = SandboxEnforcer::new(config);
606
607 let result = enforcer.validate_path(Path::new("/tmp/sandbox/../../etc/passwd"));
609 assert!(result.is_err());
610 match result.unwrap_err() {
611 SandboxViolation::PathTraversal {
612 path,
613 attempted_escape,
614 } => {
615 assert!(path.contains(".."));
616 assert!(attempted_escape.contains("etc"));
617 }
618 SandboxViolation::PathNotAllowed { .. } => {
619 }
621 other => panic!("expected PathTraversal or PathNotAllowed, got {:?}", other),
622 }
623 }
624
625 #[test]
629 fn test_validate_path_handles_symlink_traversal() {
630 let mut config = SandboxConfig::default();
631 config.allowed_paths = vec![PathBuf::from("/tmp/arena")];
632 config.denied_paths = vec![];
633 let enforcer = SandboxEnforcer::new(config);
634
635 let result = enforcer.validate_path(Path::new("/tmp/arena/../../../etc/shadow"));
637 assert!(result.is_err());
638 }
639
640 #[test]
644 fn test_sanitize_environment_only_allowed_vars() {
645 let enforcer = SandboxEnforcer::with_defaults();
646 let env = enforcer.sanitize_environment();
647
648 for (key, _) in &env {
650 assert!(
651 enforcer.config.env_allowlist.contains(key),
652 "unexpected env var '{}' passed through sanitization",
653 key
654 );
655 }
656
657 if std::env::var("PATH").is_ok() {
659 assert!(
660 env.iter().any(|(k, _)| k == "PATH"),
661 "PATH should be in sanitized environment"
662 );
663 }
664 }
665
666 #[test]
670 fn test_sanitize_environment_filters_denied_patterns() {
671 let mut config = SandboxConfig::default();
672 config.env_allowlist.push("MY_SECRET_KEY".to_string());
674 config.env_allowlist.push("AWS_ACCESS_KEY_ID".to_string());
675 let enforcer = SandboxEnforcer::new(config);
676
677 unsafe {
680 std::env::set_var("MY_SECRET_KEY", "should-be-denied");
681 std::env::set_var("AWS_ACCESS_KEY_ID", "should-be-denied");
682 }
683
684 let env = enforcer.sanitize_environment();
685
686 assert!(
688 !env.iter().any(|(k, _)| k == "MY_SECRET_KEY"),
689 "MY_SECRET_KEY should be filtered by *_SECRET* pattern"
690 );
691 assert!(
692 !env.iter().any(|(k, _)| k == "AWS_ACCESS_KEY_ID"),
693 "AWS_ACCESS_KEY_ID should be filtered by AWS_* pattern"
694 );
695
696 unsafe {
699 std::env::remove_var("MY_SECRET_KEY");
700 std::env::remove_var("AWS_ACCESS_KEY_ID");
701 }
702 }
703
704 #[test]
708 fn test_build_command_creates_sanitized_command() {
709 let enforcer = SandboxEnforcer::with_defaults();
710 let result = enforcer.build_command("ls -la");
711 assert!(result.is_ok());
712 }
713
714 #[test]
718 fn test_build_command_fails_for_denied_commands() {
719 let enforcer = SandboxEnforcer::with_defaults();
720 let result = enforcer.build_command("rm -rf /");
721 assert!(result.is_err());
722 match result.unwrap_err() {
723 SandboxViolation::DeniedCommand { .. } => {}
724 other => panic!("expected DeniedCommand, got {:?}", other),
725 }
726 }
727
728 #[test]
732 fn test_custom_config_overrides_defaults() {
733 let config = SandboxConfig {
734 allowed_paths: vec![PathBuf::from("/opt/arena")],
735 denied_paths: vec![PathBuf::from("/opt/arena/secrets")],
736 env_allowlist: vec!["CUSTOM_VAR".into()],
737 env_denylist: vec![],
738 max_output_bytes: 512,
739 max_execution_secs: 30,
740 allow_network: false,
741 working_dir: Some(PathBuf::from("/opt/arena")),
742 denied_commands: vec!["danger".into()],
743 };
744
745 let enforcer = SandboxEnforcer::new(config.clone());
746 assert_eq!(enforcer.config.max_output_bytes, 512);
747 assert_eq!(enforcer.config.max_execution_secs, 30);
748 assert!(!enforcer.config.allow_network);
749 assert_eq!(enforcer.config.allowed_paths.len(), 1);
750 assert_eq!(enforcer.config.denied_commands, vec!["danger".to_string()]);
751
752 assert!(enforcer.validate_command("danger zone").is_err());
754 assert!(enforcer.validate_command("rm -rf /").is_ok());
756 }
757
758 #[test]
762 fn test_empty_allowed_paths_denies_all() {
763 let config = SandboxConfig {
764 allowed_paths: vec![],
765 denied_paths: vec![],
766 ..SandboxConfig::default()
767 };
768 let enforcer = SandboxEnforcer::new(config);
769
770 let result = enforcer.validate_path(Path::new("/tmp/anything"));
771 assert!(result.is_err());
772 match result.unwrap_err() {
773 SandboxViolation::PathNotAllowed { .. } => {}
774 other => panic!("expected PathNotAllowed, got {:?}", other),
775 }
776 }
777
778 #[test]
782 fn test_denied_command_display_formatting() {
783 let violation = SandboxViolation::DeniedCommand {
784 command: "rm -rf /".into(),
785 reason: "matches denied pattern".into(),
786 };
787 let display = format!("{}", violation);
788 assert!(display.contains("sandbox violation"));
789 assert!(display.contains("rm -rf /"));
790 assert!(display.contains("matches denied pattern"));
791
792 let traversal = SandboxViolation::PathTraversal {
793 path: "../../etc/passwd".into(),
794 attempted_escape: "/etc/passwd".into(),
795 };
796 let display = format!("{}", traversal);
797 assert!(display.contains("path traversal"));
798 assert!(display.contains("../../etc/passwd"));
799
800 let denied_path = SandboxViolation::DeniedPath {
801 path: "/etc/shadow".into(),
802 };
803 let display = format!("{}", denied_path);
804 assert!(display.contains("denied path"));
805
806 let not_allowed = SandboxViolation::PathNotAllowed {
807 path: "/root/secret".into(),
808 };
809 let display = format!("{}", not_allowed);
810 assert!(display.contains("outside allowed"));
811
812 let denied_env = SandboxViolation::DeniedEnvironment {
813 var_name: "AWS_SECRET_KEY".into(),
814 };
815 let display = format!("{}", denied_env);
816 assert!(display.contains("denied"));
817 assert!(display.contains("AWS_SECRET_KEY"));
818 }
819
820 #[test]
824 fn test_path_canonicalization_relative() {
825 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
826 let mut config = SandboxConfig::default();
827 config.allowed_paths = vec![cwd.clone()];
828 config.denied_paths = vec![];
829 config.working_dir = Some(cwd.clone());
830 let enforcer = SandboxEnforcer::new(config);
831
832 let normalized = enforcer.normalize_path(Path::new("src/main.rs"));
834 assert!(normalized.is_absolute());
835 assert!(normalized.starts_with(&cwd));
836 }
837
838 #[test]
842 fn test_glob_match_patterns() {
843 assert!(glob_match("*_TOKEN", "GITHUB_TOKEN"));
844 assert!(glob_match("*_TOKEN", "SLACK_TOKEN"));
845 assert!(!glob_match("*_TOKEN", "GITHUB_TOKEN_EXTRA"));
846 assert!(glob_match("AWS_*", "AWS_SECRET_ACCESS_KEY"));
847 assert!(glob_match("AWS_*", "AWS_REGION"));
848 assert!(!glob_match("AWS_*", "NOT_AWS"));
849 assert!(glob_match("*_SECRET*", "MY_SECRET_KEY"));
850 assert!(glob_match("*_SECRET*", "DB_SECRET"));
851 assert!(glob_match("EXACT", "EXACT"));
852 assert!(!glob_match("EXACT", "NOT_EXACT"));
853 }
854
855 #[test]
859 fn test_validate_command_detects_substitution() {
860 let enforcer = SandboxEnforcer::with_defaults();
861
862 assert!(enforcer.validate_command("echo $(whoami)").is_err());
863 assert!(enforcer.validate_command("echo `whoami`").is_err());
864 }
865
866 #[test]
870 fn test_validate_command_detects_pipe_to_sensitive() {
871 let enforcer = SandboxEnforcer::with_defaults();
872
873 assert!(enforcer.validate_command("cat file | sh").is_err());
874 assert!(enforcer.validate_command("echo cmd | bash").is_err());
875 assert!(enforcer.validate_command("echo cmd | sudo rm").is_err());
876
877 assert!(enforcer.validate_command("ls | grep pattern").is_ok());
879 assert!(enforcer.validate_command("cat file | wc -l").is_ok());
880 }
881}