1use std::cell::RefCell;
38use std::collections::BTreeSet;
39use std::path::{Component, Path, PathBuf};
40use std::process::{Command, Output, Stdio};
41use std::rc::Rc;
42
43use crate::orchestration::{CapabilityPolicy, SandboxProfile};
44use crate::value::{ErrorCategory, VmError, VmValue};
45use crate::vm::Vm;
46
47#[cfg(target_os = "linux")]
48mod linux;
49#[cfg(target_os = "macos")]
50mod macos;
51#[cfg(target_os = "openbsd")]
52mod openbsd;
53#[cfg(target_os = "windows")]
54mod windows;
55
56const HANDLER_SANDBOX_ENV: &str = "HARN_HANDLER_SANDBOX";
57
58thread_local! {
59 static WARNED_KEYS: RefCell<BTreeSet<String>> = const { RefCell::new(BTreeSet::new()) };
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum FsAccess {
68 Read,
69 Write,
70 Delete,
71}
72
73#[derive(Clone, Debug, Default)]
74pub struct ProcessCommandConfig {
75 pub cwd: Option<PathBuf>,
76 pub env: Vec<(String, String)>,
77 pub stdin_null: bool,
78}
79
80#[derive(Clone, Copy, Debug, PartialEq, Eq)]
81pub(crate) enum SandboxFallback {
82 Off,
83 Warn,
84 Enforce,
85}
86
87pub(crate) trait SandboxBackend {
98 fn name() -> &'static str;
100
101 fn available() -> bool;
105
106 fn prepare_std_command(
112 program: &str,
113 args: &[String],
114 command: &mut Command,
115 policy: &CapabilityPolicy,
116 profile: SandboxProfile,
117 ) -> Result<PrepareOutcome, VmError>;
118
119 fn prepare_tokio_command(
121 program: &str,
122 args: &[String],
123 command: &mut tokio::process::Command,
124 policy: &CapabilityPolicy,
125 profile: SandboxProfile,
126 ) -> Result<PrepareOutcome, VmError>;
127
128 fn run_to_output(
133 program: &str,
134 args: &[String],
135 config: &ProcessCommandConfig,
136 policy: &CapabilityPolicy,
137 profile: SandboxProfile,
138 ) -> Result<Output, VmError> {
139 let mut command = build_std_command::<Self>(program, args, policy, profile)?;
140 apply_process_config(&mut command, config);
141 command
142 .output()
143 .map_err(|error| process_spawn_error(&error).unwrap_or_else(|| spawn_error(error)))
144 }
145}
146
147pub(crate) enum PrepareOutcome {
151 Direct,
153 #[cfg_attr(not(target_os = "macos"), allow(dead_code))]
159 WrappedExec { wrapper: String, args: Vec<String> },
160}
161
162#[cfg(target_os = "linux")]
163type ActiveBackend = linux::Backend;
164#[cfg(target_os = "macos")]
165type ActiveBackend = macos::Backend;
166#[cfg(target_os = "openbsd")]
167type ActiveBackend = openbsd::Backend;
168#[cfg(target_os = "windows")]
169type ActiveBackend = windows::Backend;
170#[cfg(not(any(
171 target_os = "linux",
172 target_os = "macos",
173 target_os = "openbsd",
174 target_os = "windows"
175)))]
176type ActiveBackend = NoopBackend;
177
178#[cfg(not(any(
179 target_os = "linux",
180 target_os = "macos",
181 target_os = "openbsd",
182 target_os = "windows"
183)))]
184pub(crate) struct NoopBackend;
185
186#[cfg(not(any(
187 target_os = "linux",
188 target_os = "macos",
189 target_os = "openbsd",
190 target_os = "windows"
191)))]
192impl SandboxBackend for NoopBackend {
193 fn name() -> &'static str {
194 "noop"
195 }
196 fn available() -> bool {
197 false
198 }
199 fn prepare_std_command(
200 _program: &str,
201 _args: &[String],
202 _command: &mut Command,
203 _policy: &CapabilityPolicy,
204 _profile: SandboxProfile,
205 ) -> Result<PrepareOutcome, VmError> {
206 Ok(PrepareOutcome::Direct)
207 }
208 fn prepare_tokio_command(
209 _program: &str,
210 _args: &[String],
211 _command: &mut tokio::process::Command,
212 _policy: &CapabilityPolicy,
213 _profile: SandboxProfile,
214 ) -> Result<PrepareOutcome, VmError> {
215 Ok(PrepareOutcome::Direct)
216 }
217}
218
219pub(crate) fn reset_sandbox_state() {
220 WARNED_KEYS.with(|keys| keys.borrow_mut().clear());
221}
222
223pub fn active_backend_name() -> &'static str {
227 ActiveBackend::name()
228}
229
230pub fn active_backend_available() -> bool {
235 ActiveBackend::available()
236}
237
238pub fn register_sandbox_builtins(vm: &mut Vm) {
242 for def in MODULE_BUILTINS {
243 vm.register_builtin_def(def);
244 }
245}
246
247pub(crate) const MODULE_BUILTINS: &[&crate::stdlib::macros::VmBuiltinDef] = &[
248 &SANDBOX_ACTIVE_BACKEND_IMPL_DEF,
249 &SANDBOX_BACKEND_AVAILABLE_IMPL_DEF,
250 &SANDBOX_ACTIVE_PROFILE_IMPL_DEF,
251];
252
253#[crate::stdlib::macros::harn_builtin(
254 sig = "sandbox_active_backend() -> string",
255 category = "sandbox"
256)]
257fn sandbox_active_backend_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
258 Ok(VmValue::String(Rc::from(active_backend_name())))
259}
260
261#[crate::stdlib::macros::harn_builtin(
262 sig = "sandbox_backend_available() -> bool",
263 category = "sandbox"
264)]
265fn sandbox_backend_available_impl(
266 _args: &[VmValue],
267 _out: &mut String,
268) -> Result<VmValue, VmError> {
269 Ok(VmValue::Bool(active_backend_available()))
270}
271
272#[crate::stdlib::macros::harn_builtin(
273 sig = "sandbox_active_profile() -> string",
274 category = "sandbox"
275)]
276fn sandbox_active_profile_impl(_args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
277 let profile = crate::orchestration::current_execution_policy()
278 .map(|policy| policy.sandbox_profile)
279 .unwrap_or(SandboxProfile::Unrestricted);
280 Ok(VmValue::String(Rc::from(profile.as_str())))
281}
282
283#[derive(Clone, Debug)]
290pub struct SandboxViolation {
291 pub attempted: PathBuf,
295 pub roots: Vec<PathBuf>,
298 pub access: FsAccess,
300}
301
302impl SandboxViolation {
303 pub fn message(&self, builtin: &str) -> String {
307 format!(
308 "sandbox violation: builtin '{builtin}' attempted to {} '{}' outside workspace_roots [{}]",
309 self.access.verb(),
310 self.attempted.display(),
311 self.roots
312 .iter()
313 .map(|root| root.display().to_string())
314 .collect::<Vec<_>>()
315 .join(", ")
316 )
317 }
318}
319
320pub fn check_fs_path_scope(path: &Path, access: FsAccess) -> Result<(), SandboxViolation> {
334 let Some(policy) = crate::orchestration::current_execution_policy() else {
335 return Ok(());
336 };
337 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
338 return Ok(());
339 }
340 let candidate = normalize_for_policy(path);
341 let roots = normalized_workspace_roots(&policy);
342 if roots.iter().any(|root| path_is_within(&candidate, root)) {
343 return Ok(());
344 }
345 Err(SandboxViolation {
346 attempted: candidate,
347 roots,
348 access,
349 })
350}
351
352pub(crate) fn enforce_fs_path(builtin: &str, path: &Path, access: FsAccess) -> Result<(), VmError> {
353 check_fs_path_scope(path, access)
354 .map_err(|violation| sandbox_rejection(violation.message(builtin)))
355}
356
357pub fn enforce_process_cwd(path: &Path) -> Result<(), VmError> {
358 let Some(policy) = crate::orchestration::current_execution_policy() else {
359 return Ok(());
360 };
361 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
362 return Ok(());
363 }
364 let candidate = normalize_for_policy(path);
365 let roots = normalized_workspace_roots(&policy);
366 if roots.iter().any(|root| path_is_within(&candidate, root)) {
367 return Ok(());
368 }
369 Err(sandbox_rejection(format!(
370 "sandbox violation: process cwd '{}' is outside workspace_roots [{}]",
371 candidate.display(),
372 roots
373 .iter()
374 .map(|root| root.display().to_string())
375 .collect::<Vec<_>>()
376 .join(", ")
377 )))
378}
379
380pub fn std_command_for(program: &str, args: &[String]) -> Result<Command, VmError> {
381 let (policy, profile) = match active_sandbox_policy() {
382 Some(value) => value,
383 None => {
384 let mut command = Command::new(program);
385 command.args(args);
386 return Ok(command);
387 }
388 };
389 build_std_command::<ActiveBackend>(program, args, &policy, profile)
390}
391
392pub fn tokio_command_for(
393 program: &str,
394 args: &[String],
395) -> Result<tokio::process::Command, VmError> {
396 let (policy, profile) = match active_sandbox_policy() {
397 Some(value) => value,
398 None => {
399 let mut command = tokio::process::Command::new(program);
400 command.args(args);
401 return Ok(command);
402 }
403 };
404 build_tokio_command::<ActiveBackend>(program, args, &policy, profile)
405}
406
407pub fn command_output(
408 program: &str,
409 args: &[String],
410 config: &ProcessCommandConfig,
411) -> Result<Output, VmError> {
412 if let Some(intercepted) =
417 crate::testbench::process_tape::intercept_spawn(program, args, config.cwd.as_deref())
418 {
419 return intercepted.map_err(|message| {
420 VmError::Thrown(crate::value::VmValue::String(std::rc::Rc::from(message)))
421 });
422 }
423
424 let recording =
425 crate::testbench::process_tape::start_recording(program, args, config.cwd.as_deref());
426
427 let output = match active_sandbox_policy() {
428 Some((policy, profile)) => {
429 ActiveBackend::run_to_output(program, args, config, &policy, profile)?
430 }
431 None => {
432 let mut command = Command::new(program);
433 command.args(args);
434 apply_process_config(&mut command, config);
435 command.output().map_err(|error| {
436 process_spawn_error(&error).unwrap_or_else(|| spawn_error(error))
437 })?
438 }
439 };
440 if let Some(error) = process_violation_error(&output) {
441 return Err(error);
442 }
443 if let Some(span) = recording {
444 span.finish(&output);
445 }
446 Ok(output)
447}
448
449fn build_std_command<B: SandboxBackend + ?Sized>(
450 program: &str,
451 args: &[String],
452 policy: &CapabilityPolicy,
453 profile: SandboxProfile,
454) -> Result<Command, VmError> {
455 let mut command = Command::new(program);
456 command.args(args);
457 match B::prepare_std_command(program, args, &mut command, policy, profile)? {
458 PrepareOutcome::Direct => Ok(command),
459 PrepareOutcome::WrappedExec { wrapper, args } => {
460 let mut wrapped = Command::new(wrapper);
461 wrapped.args(args);
462 Ok(wrapped)
463 }
464 }
465}
466
467fn build_tokio_command<B: SandboxBackend + ?Sized>(
468 program: &str,
469 args: &[String],
470 policy: &CapabilityPolicy,
471 profile: SandboxProfile,
472) -> Result<tokio::process::Command, VmError> {
473 let mut command = tokio::process::Command::new(program);
474 command.args(args);
475 match B::prepare_tokio_command(program, args, &mut command, policy, profile)? {
476 PrepareOutcome::Direct => Ok(command),
477 PrepareOutcome::WrappedExec { wrapper, args } => {
478 let mut wrapped = tokio::process::Command::new(wrapper);
479 wrapped.args(args);
480 Ok(wrapped)
481 }
482 }
483}
484
485pub fn process_violation_error(output: &std::process::Output) -> Option<VmError> {
486 let policy = crate::orchestration::current_execution_policy()?;
487 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
488 return None;
489 }
490 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
491 || !ActiveBackend::available()
492 {
493 return None;
494 }
495 let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
496 let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
497 if !output.status.success()
498 && (stderr.contains("operation not permitted")
499 || stderr.contains("permission denied")
500 || stderr.contains("access is denied")
501 || stdout.contains("operation not permitted"))
502 {
503 return Some(sandbox_rejection(format!(
504 "sandbox violation: process was denied by the OS sandbox (status {})",
505 output.status.code().unwrap_or(-1)
506 )));
507 }
508 if sandbox_signal_status(output) {
509 return Some(sandbox_rejection(format!(
510 "sandbox violation: process was terminated by the OS sandbox (status {})",
511 output.status
512 )));
513 }
514 None
515}
516
517pub fn process_spawn_error(error: &std::io::Error) -> Option<VmError> {
518 let policy = crate::orchestration::current_execution_policy()?;
519 if matches!(policy.sandbox_profile, SandboxProfile::Unrestricted) {
520 return None;
521 }
522 if effective_fallback(policy.sandbox_profile) == SandboxFallback::Off
523 || !ActiveBackend::available()
524 {
525 return None;
526 }
527 let message = error.to_string().to_ascii_lowercase();
528 if error.kind() == std::io::ErrorKind::PermissionDenied
529 || message.contains("operation not permitted")
530 || message.contains("permission denied")
531 || message.contains("access is denied")
532 {
533 return Some(sandbox_rejection(format!(
534 "sandbox violation: process was denied by the OS sandbox before exec: {error}"
535 )));
536 }
537 None
538}
539
540#[cfg(unix)]
541fn sandbox_signal_status(output: &std::process::Output) -> bool {
542 use std::os::unix::process::ExitStatusExt;
543
544 matches!(
545 output.status.signal(),
546 Some(libc::SIGSYS) | Some(libc::SIGABRT) | Some(libc::SIGKILL)
547 )
548}
549
550#[cfg(not(unix))]
551fn sandbox_signal_status(_output: &std::process::Output) -> bool {
552 false
553}
554
555pub(crate) fn active_sandbox_policy() -> Option<(CapabilityPolicy, SandboxProfile)> {
563 let policy = crate::orchestration::current_execution_policy()?;
564 let profile = policy.sandbox_profile;
565 match profile {
566 SandboxProfile::Unrestricted | SandboxProfile::Wasi => None,
567 SandboxProfile::Worktree | SandboxProfile::OsHardened => {
568 if effective_fallback(profile) == SandboxFallback::Off {
569 None
570 } else {
571 Some((policy, profile))
572 }
573 }
574 }
575}
576
577fn apply_process_config(command: &mut Command, config: &ProcessCommandConfig) {
578 if let Some(cwd) = config.cwd.as_ref() {
579 command.current_dir(cwd);
580 }
581 command.envs(config.env.iter().map(|(key, value)| (key, value)));
582 if config.stdin_null {
583 command.stdin(Stdio::null());
584 }
585}
586
587fn spawn_error(error: std::io::Error) -> VmError {
588 VmError::Thrown(crate::value::VmValue::String(std::rc::Rc::from(format!(
589 "process spawn failed: {error}"
590 ))))
591}
592
593pub(crate) fn effective_fallback(profile: SandboxProfile) -> SandboxFallback {
598 if matches!(profile, SandboxProfile::OsHardened) {
599 return SandboxFallback::Enforce;
600 }
601 match std::env::var(HANDLER_SANDBOX_ENV)
602 .unwrap_or_else(|_| "warn".to_string())
603 .trim()
604 .to_ascii_lowercase()
605 .as_str()
606 {
607 "0" | "false" | "off" | "none" => SandboxFallback::Off,
608 "1" | "true" | "enforce" | "required" => SandboxFallback::Enforce,
609 _ => SandboxFallback::Warn,
610 }
611}
612
613pub(crate) fn warn_once(key: &str, message: &str) {
614 let inserted = WARNED_KEYS.with(|keys| keys.borrow_mut().insert(key.to_string()));
615 if inserted {
616 crate::events::log_warn("handler_sandbox", message);
617 }
618}
619
620pub(crate) fn sandbox_rejection(message: String) -> VmError {
621 VmError::CategorizedError {
622 message,
623 category: ErrorCategory::ToolRejected,
624 }
625}
626
627#[cfg_attr(not(any(target_os = "macos", target_os = "windows")), allow(dead_code))]
637pub(crate) fn unavailable(
638 message: &str,
639 profile: SandboxProfile,
640) -> Result<PrepareOutcome, VmError> {
641 match effective_fallback(profile) {
642 SandboxFallback::Off | SandboxFallback::Warn => {
643 warn_once("handler_sandbox_unavailable", message);
644 Ok(PrepareOutcome::Direct)
645 }
646 SandboxFallback::Enforce => Err(sandbox_rejection(format!(
647 "{message}; set {HANDLER_SANDBOX_ENV}=warn or off to run unsandboxed"
648 ))),
649 }
650}
651
652fn normalized_workspace_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
653 if policy.workspace_roots.is_empty() {
654 return vec![normalize_for_policy(
655 &crate::stdlib::process::execution_root_path(),
656 )];
657 }
658 policy
659 .workspace_roots
660 .iter()
661 .map(|root| normalize_for_policy(&resolve_policy_path(root)))
662 .collect()
663}
664
665pub(crate) fn process_sandbox_roots(policy: &CapabilityPolicy) -> Vec<PathBuf> {
666 normalized_workspace_roots(policy)
667}
668
669fn resolve_policy_path(path: &str) -> PathBuf {
670 let candidate = PathBuf::from(path);
671 if candidate.is_absolute() {
672 candidate
673 } else {
674 crate::stdlib::process::execution_root_path().join(candidate)
675 }
676}
677
678fn normalize_for_policy(path: &Path) -> PathBuf {
679 let absolute = if path.is_absolute() {
680 path.to_path_buf()
681 } else {
682 crate::stdlib::process::execution_root_path().join(path)
683 };
684 let absolute = normalize_lexically(&absolute);
685 if let Ok(canonical) = absolute.canonicalize() {
686 return canonical;
687 }
688
689 let mut existing = absolute.as_path();
690 let mut suffix = Vec::new();
691 while !existing.exists() {
692 let Some(parent) = existing.parent() else {
693 return normalize_lexically(&absolute);
694 };
695 if let Some(name) = existing.file_name() {
696 suffix.push(name.to_os_string());
697 }
698 existing = parent;
699 }
700
701 let mut normalized = existing
702 .canonicalize()
703 .unwrap_or_else(|_| normalize_lexically(existing));
704 for component in suffix.iter().rev() {
705 normalized.push(component);
706 }
707 normalize_lexically(&normalized)
708}
709
710fn normalize_lexically(path: &Path) -> PathBuf {
711 let mut normalized = PathBuf::new();
712 for component in path.components() {
713 match component {
714 Component::CurDir => {}
715 Component::ParentDir => {
716 normalized.pop();
717 }
718 other => normalized.push(other.as_os_str()),
719 }
720 }
721 normalized
722}
723
724fn path_is_within(path: &Path, root: &Path) -> bool {
725 path == root || path.starts_with(root)
726}
727
728#[cfg(any(target_os = "linux", target_os = "macos", target_os = "openbsd"))]
729pub(crate) fn policy_allows_network(policy: &CapabilityPolicy) -> bool {
730 fn rank(value: &str) -> usize {
731 match value {
732 "none" => 0,
733 "read_only" => 1,
734 "workspace_write" => 2,
735 "process_exec" => 3,
736 "network" => 4,
737 _ => 5,
738 }
739 }
740 policy
741 .side_effect_level
742 .as_ref()
743 .map(|level| rank(level) >= rank("network"))
744 .unwrap_or(true)
745}
746
747#[cfg(any(target_os = "macos", target_os = "openbsd", target_os = "windows"))]
748pub(crate) fn policy_allows_workspace_write(policy: &CapabilityPolicy) -> bool {
749 policy.capabilities.is_empty()
750 || policy_allows_capability(policy, "workspace", &["write_text", "delete"])
751}
752
753#[cfg(any(
754 target_os = "linux",
755 target_os = "macos",
756 target_os = "openbsd",
757 target_os = "windows"
758))]
759pub(crate) fn policy_allows_capability(
760 policy: &CapabilityPolicy,
761 capability: &str,
762 ops: &[&str],
763) -> bool {
764 policy
765 .capabilities
766 .get(capability)
767 .map(|allowed| {
768 ops.iter()
769 .any(|op| allowed.iter().any(|candidate| candidate == op))
770 })
771 .unwrap_or(false)
772}
773
774impl FsAccess {
775 fn verb(self) -> &'static str {
776 match self {
777 FsAccess::Read => "read",
778 FsAccess::Write => "write",
779 FsAccess::Delete => "delete",
780 }
781 }
782}
783
784#[cfg(test)]
785mod tests {
786 use super::*;
787 use crate::orchestration::{pop_execution_policy, push_execution_policy};
788
789 #[test]
790 fn missing_create_path_normalizes_against_existing_parent() {
791 let dir = tempfile::tempdir().unwrap();
792 let nested = dir.path().join("a/../new.txt");
793 let normalized = normalize_for_policy(&nested);
794 assert_eq!(
795 normalized,
796 normalize_for_policy(&dir.path().join("new.txt"))
797 );
798 }
799
800 #[test]
801 fn empty_workspace_roots_default_to_execution_root_for_fs_paths() {
802 let dir = tempfile::tempdir().unwrap();
803 crate::stdlib::process::set_thread_execution_context(Some(
804 crate::orchestration::RunExecutionRecord {
805 cwd: Some(dir.path().to_string_lossy().into_owned()),
806 source_dir: None,
807 env: Default::default(),
808 adapter: None,
809 repo_path: None,
810 worktree_path: None,
811 branch: None,
812 base_ref: None,
813 cleanup: None,
814 },
815 ));
816 push_execution_policy(CapabilityPolicy {
817 sandbox_profile: SandboxProfile::Worktree,
818 ..CapabilityPolicy::default()
819 });
820
821 assert!(
822 enforce_fs_path("read_file", &dir.path().join("inside.txt"), FsAccess::Read).is_ok()
823 );
824 let outside = tempfile::tempdir().unwrap();
825 assert!(enforce_fs_path(
826 "read_file",
827 &outside.path().join("outside.txt"),
828 FsAccess::Read
829 )
830 .is_err());
831
832 pop_execution_policy();
833 crate::stdlib::process::set_thread_execution_context(None);
834 }
835
836 #[test]
837 fn empty_workspace_roots_default_to_execution_root_for_process_cwd() {
838 let dir = tempfile::tempdir().unwrap();
839 crate::stdlib::process::set_thread_execution_context(Some(
840 crate::orchestration::RunExecutionRecord {
841 cwd: Some(dir.path().to_string_lossy().into_owned()),
842 source_dir: None,
843 env: Default::default(),
844 adapter: None,
845 repo_path: None,
846 worktree_path: None,
847 branch: None,
848 base_ref: None,
849 cleanup: None,
850 },
851 ));
852 push_execution_policy(CapabilityPolicy {
853 sandbox_profile: SandboxProfile::Worktree,
854 ..CapabilityPolicy::default()
855 });
856
857 assert!(enforce_process_cwd(dir.path()).is_ok());
858 let outside = tempfile::tempdir().unwrap();
859 assert!(enforce_process_cwd(outside.path()).is_err());
860
861 pop_execution_policy();
862 crate::stdlib::process::set_thread_execution_context(None);
863 }
864
865 #[test]
866 fn path_within_root_accepts_root_and_children() {
867 let root = Path::new("/tmp/harn-root");
868 assert!(path_is_within(root, root));
869 assert!(path_is_within(Path::new("/tmp/harn-root/file"), root));
870 assert!(!path_is_within(
871 Path::new("/tmp/harn-root-other/file"),
872 root
873 ));
874 }
875
876 #[test]
877 fn os_hardened_profile_overrides_fallback_env() {
878 assert_eq!(
883 effective_fallback(SandboxProfile::OsHardened),
884 SandboxFallback::Enforce
885 );
886 }
887
888 #[test]
889 fn unrestricted_profile_skips_active_sandbox() {
890 let policy = CapabilityPolicy {
891 sandbox_profile: SandboxProfile::Unrestricted,
892 workspace_roots: vec!["/tmp".to_string()],
893 ..Default::default()
894 };
895 crate::orchestration::push_execution_policy(policy);
896 let result = active_sandbox_policy();
897 crate::orchestration::pop_execution_policy();
898 assert!(
899 result.is_none(),
900 "Unrestricted profile must short-circuit sandbox dispatch"
901 );
902 }
903
904 #[test]
905 fn worktree_profile_engages_active_sandbox() {
906 let policy = CapabilityPolicy {
907 sandbox_profile: SandboxProfile::Worktree,
908 workspace_roots: vec!["/tmp".to_string()],
909 ..Default::default()
910 };
911 crate::orchestration::push_execution_policy(policy);
912 let result = active_sandbox_policy();
913 crate::orchestration::pop_execution_policy();
914 assert!(
915 result.is_some(),
916 "Worktree profile must keep sandbox dispatch active"
917 );
918 }
919}