1use std::{
2 collections::BTreeMap,
3 ffi::OsString,
4 process::{Command, Stdio},
5 time::{Duration, Instant},
6};
7
8use crate::{
9 artifact_config::ARTIFACT_CONFIG_ENV,
10 detached::{DetachPolicy, DetachedDispatchInfo},
11 types::CapabilityPolicy,
12 workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE},
13};
14
15pub(crate) const MAX_SHELL_OUTPUT_BYTES: usize = 16 * 1024;
16
17const SHELL_INTERPRETERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
18const DEFAULT_ENV_BASELINE: &[&str] = &[
19 "PATH",
20 "HOME",
21 "USER",
22 "LOGNAME",
23 "LANG",
24 "LC_ALL",
25 "CARGO_HOME",
26 "RUSTUP_HOME",
27 "TERM",
28];
29
30pub(crate) const ACCOUNT_ENV: [&str; 2] = ["LOGNAME", "USER"];
34
35#[cfg(test)]
38pub(crate) static ACCOUNT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
39
40#[derive(Debug, Clone, PartialEq, Eq)]
47pub(crate) struct HostAccountEnv {
48 set: Vec<(String, String)>,
49 unset: Vec<String>,
50}
51
52impl HostAccountEnv {
53 pub(crate) fn from_host() -> Self {
55 Self::from_lookup(|name| std::env::var(name).ok())
56 }
57
58 pub(crate) fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
60 let mut set = Vec::new();
61 let mut unset = Vec::new();
62 for name in ACCOUNT_ENV {
63 match lookup(name) {
64 Some(value) => set.push((name.to_string(), value)),
65 None => unset.push(name.to_string()),
66 }
67 }
68 Self { set, unset }
69 }
70
71 #[cfg(test)]
73 pub(crate) fn from_pairs(pairs: &[(&str, &str)]) -> Self {
74 Self::from_lookup(|name| {
75 pairs
76 .iter()
77 .find(|(key, _)| *key == name)
78 .map(|(_, value)| value.to_string())
79 })
80 }
81
82 pub(crate) fn unset(&self) -> &[String] {
84 &self.unset
85 }
86
87 pub(crate) fn set(&self) -> &[(String, String)] {
89 &self.set
90 }
91
92 pub(crate) fn apply(&self, env: &mut BTreeMap<String, String>) {
95 for name in ACCOUNT_ENV {
96 env.remove(name);
97 }
98 env.extend(self.set.iter().cloned());
99 }
100}
101
102pub(crate) const NO_HOME_FOLDER_REFUSAL: &str =
105 "no home folder: this command's enforcement holds no session home folder (home/ in the \
106 session's records directory) to be its $HOME, so it is not run";
107
108pub(crate) const CREDENTIAL_ENV_PATTERNS: &[&str] = &[
113 "ANTHROPIC_API_KEY",
114 "OPENAI_API_KEY",
115 "GOOGLE_API_KEY",
116 "HUGGING_FACE_HUB_TOKEN",
117 "AWS_*",
118 "GITHUB_TOKEN",
119 "GH_TOKEN",
120 "DOCKER_*",
121 "KUBECONFIG",
122 "NPM_TOKEN",
123 "PYPI_TOKEN",
124 "CARGO_REGISTRY_TOKEN",
125 "*_API_KEY",
126];
127
128#[derive(Debug)]
129pub(crate) struct ShellResult {
130 pub(crate) binary: String,
136 pub(crate) exit_code: i32,
137 pub(crate) stdout: String,
138 pub(crate) stderr: String,
139 pub(crate) duration_ms: u64,
140 pub(crate) truncated: bool,
141 pub(crate) full_output_path: Option<String>,
146 pub(crate) resource_limit_hit: Option<String>,
151 pub(crate) output_holders: OutputHolders,
154}
155
156#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub(crate) enum OutputHolders {
164 Closed,
166 Killed,
169 Escaped,
173}
174
175impl OutputHolders {
176 pub(crate) fn note(self) -> Option<String> {
179 const REDIRECT: &str = "A process meant to keep running in the background needs its \
180 output redirected away from the call, e.g. `server >server.log 2>&1 &`.";
181 match self {
182 Self::Closed => None,
183 Self::Killed => Some(format!(
184 "The command exited, but processes it started were still holding its output \
185 after the wait and were killed. {REDIRECT}"
186 )),
187 Self::Escaped => Some(format!(
188 "The command exited, but processes it started outside its process group still \
189 hold its output; the result has what was read before the call returned, and \
190 those processes keep running until the session ends. {REDIRECT}"
191 )),
192 }
193 }
194}
195
196const PIPE_DRAIN_AFTER_EXIT: Duration = Duration::from_secs(1);
202
203pub(crate) const LEFTOVER_KILL_WAIT: Duration = Duration::from_secs(2);
206
207const PIPE_POLL_INTERVAL: Duration = Duration::from_millis(10);
209
210#[derive(Debug)]
220pub(crate) enum ShellExecError {
221 Failed(String),
225 SealedRootConstructionFailed { detail: String },
229}
230
231impl ShellExecError {
232 pub(crate) fn session_fatal(&self) -> Option<crate::errors::RuntimeError> {
238 match self {
239 Self::Failed(_) => None,
240 Self::SealedRootConstructionFailed { detail } => {
241 Some(crate::errors::RuntimeError::SealedRootConstructionFailed {
242 detail: detail.clone(),
243 })
244 }
245 }
246 }
247}
248
249pub(crate) fn enforced_spawn_failure(
260 command: Command,
261 supervisor: &crate::sandbox::SupervisorHandle,
262 subject: &str,
263 spawn_error: String,
264) -> ShellExecError {
265 drop(command);
266 match supervisor.read_diagnostic() {
267 Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
273 ShellExecError::SealedRootConstructionFailed { detail }
274 }
275 Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
276 "sandbox: {subject} enforcement setup failed before exec: {detail}"
277 )),
278 _ => ShellExecError::Failed(spawn_error),
279 }
280}
281
282impl std::fmt::Display for ShellExecError {
283 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
284 match self {
285 Self::Failed(message) => f.write_str(message),
286 Self::SealedRootConstructionFailed { detail } => write!(
290 f,
291 "{}",
292 crate::errors::RuntimeError::SealedRootConstructionFailed {
293 detail: detail.clone(),
294 }
295 ),
296 }
297 }
298}
299
300impl From<String> for ShellExecError {
302 fn from(message: String) -> Self {
303 Self::Failed(message)
304 }
305}
306
307pub(crate) fn is_shell_interpreter(binary: &str) -> bool {
308 SHELL_INTERPRETERS.contains(&binary)
309}
310
311pub(crate) use murmur_artifact::split_shell_words;
312
313pub(crate) fn shell_tool_manifest_yaml(binary: &str) -> String {
314 let description = if is_shell_interpreter(binary) {
315 format!("Run a shell command via {binary} in the capsule workdir. The `command` field is passed to {binary} via -c.")
316 } else {
317 format!("Run {binary} in the capsule workdir. The `command` field holds {binary}'s arguments and not the binary name. It is split into arguments at whitespace; single or double quotes group words, and a backslash escapes the next character. No shell runs it, so nothing in it is expanded.")
318 };
319
320 format!(
321 "name: {binary}\nversion: 0.0.0\nruntime: tool\ndescription: \"{description}\"\ninput_schema: '{{\"type\":\"object\",\"properties\":{{\"command\":{{\"type\":\"string\"}}}},\"required\":[\"command\"]}}'\n"
322 )
323}
324
325thread_local! {
326 static SPAWNED_GROUP: std::cell::RefCell<Option<std::sync::Arc<std::sync::atomic::AtomicI32>>> =
329 const { std::cell::RefCell::new(None) };
330}
331
332pub(crate) fn report_spawned_group_to(sink: std::sync::Arc<std::sync::atomic::AtomicI32>) {
335 SPAWNED_GROUP.with(|slot| *slot.borrow_mut() = Some(sink));
336}
337
338#[derive(Debug)]
340pub(crate) enum ShellOutcome {
341 Finished(ShellResult),
342 Detached(DetachedDispatchInfo),
343}
344
345const DETACH_POLL_INTERVAL: Duration = Duration::from_millis(20);
352
353pub(crate) const CANCELED_BEFORE_SPAWN: &str =
355 "the task was canceled before this command started, so it was not run";
356
357#[allow(clippy::too_many_arguments)]
386pub(crate) fn run_shell(
387 binary: &str,
388 args: &[&str],
389 env_overrides: &[(String, String)],
390 accessible_workdir: &WorkdirRoot,
391 policy: &CapabilityPolicy,
392 enforcement: &crate::sandbox::ShellEnforcement,
393 detach: Option<DetachPolicy>,
394) -> Result<ShellOutcome, ShellExecError> {
395 let wait = detach.map_or(Wait::Blocking, Wait::Detach);
396 spawn_and_wait(
397 binary,
398 args,
399 env_overrides,
400 accessible_workdir,
401 policy,
402 enforcement,
403 wait,
404 )
405 .map(|(outcome, _)| outcome)
406}
407
408pub(crate) struct ForegroundStop {
410 pub canceled: crate::cancel::CancelSignal,
412 pub timeout: Duration,
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
418pub(crate) enum ForegroundStopReason {
419 Canceled,
421 TimedOut,
423}
424
425#[derive(Debug)]
427pub(crate) struct ForegroundRun {
428 pub result: ShellResult,
429 pub stopped: Option<ForegroundStopReason>,
430}
431
432pub(crate) fn run_shell_until_stopped(
440 binary: &str,
441 args: &[&str],
442 env_overrides: &[(String, String)],
443 accessible_workdir: &WorkdirRoot,
444 policy: &CapabilityPolicy,
445 enforcement: &crate::sandbox::ShellEnforcement,
446 stop: ForegroundStop,
447) -> Result<ForegroundRun, ShellExecError> {
448 match spawn_and_wait(
449 binary,
450 args,
451 env_overrides,
452 accessible_workdir,
453 policy,
454 enforcement,
455 Wait::Stop(stop),
456 )? {
457 (ShellOutcome::Finished(result), stopped) => Ok(ForegroundRun { result, stopped }),
458 (ShellOutcome::Detached(info), _) => Err(ShellExecError::Failed(format!(
460 "a foreground command was demoted to the background as {}",
461 info.work_id
462 ))),
463 }
464}
465
466enum Wait {
468 Blocking,
470 Detach(DetachPolicy),
472 Stop(ForegroundStop),
475}
476
477pub(crate) fn check_shell_allow(policy: &CapabilityPolicy, binary: &str) -> Result<(), String> {
480 if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
481 return Err(format!(
482 "binary '{binary}' is not in capabilities.shell.allow"
483 ));
484 }
485 Ok(())
486}
487
488pub(crate) enum ContainedSpawnError {
490 WorkdirBudget(String),
492 Exec(ShellExecError),
494}
495
496impl From<ContainedSpawnError> for ShellExecError {
497 fn from(error: ContainedSpawnError) -> Self {
498 match error {
499 ContainedSpawnError::WorkdirBudget(message) => Self::Failed(message),
500 ContainedSpawnError::Exec(error) => error,
501 }
502 }
503}
504
505pub(crate) struct ContainedChild {
508 pub(crate) child: std::process::Child,
509 pub(crate) pgid: i32,
511 pub(crate) resolved_binary: String,
513 pub(crate) started: Instant,
514 pub(crate) cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
515 pub(crate) cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
516}
517
518#[allow(clippy::too_many_arguments)]
527pub(crate) fn spawn_contained(
528 binary: &str,
529 args: &[&str],
530 env_overrides: &[(String, String)],
531 accessible_workdir: &WorkdirRoot,
532 policy: &CapabilityPolicy,
533 enforcement: &crate::sandbox::ShellEnforcement,
534 stdin: Stdio,
535 subject: &str,
536) -> Result<ContainedChild, ContainedSpawnError> {
537 use std::os::unix::process::CommandExt as _;
538
539 enforcement
542 .check_workdir_budget()
543 .map_err(ContainedSpawnError::WorkdirBudget)?;
544
545 let env = subprocess_env(policy, env_overrides, enforcement)
546 .map_err(|error| ContainedSpawnError::Exec(ShellExecError::Failed(error)))?;
547
548 let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
551
552 let started = Instant::now();
553 let mut command = Command::new(binary);
554 command
555 .args(args)
556 .current_dir(accessible_workdir.path())
557 .env_clear()
558 .envs(env)
559 .stdin(stdin)
563 .stdout(Stdio::piped())
564 .stderr(Stdio::piped())
565 .process_group(0);
568
569 let supervisor =
574 crate::sandbox::prepare_enforcement(&mut command, enforcement, accessible_workdir.path())
575 .map_err(|error| ContainedSpawnError::Exec(ShellExecError::Failed(error)))?;
576
577 let cgroup_occupancy = enforcement.cgroup_scope.as_ref().map(|scope| scope.enter());
581 let cgroup_counters_before = enforcement
584 .cgroup_scope
585 .as_ref()
586 .map(|scope| scope.event_counters());
587
588 let child = match command.spawn() {
589 Ok(child) => child,
590 Err(error) => {
591 return Err(ContainedSpawnError::Exec(enforced_spawn_failure(
592 command,
593 &supervisor,
594 subject,
595 error.to_string(),
596 )))
597 }
598 };
599 let pgid = child.id() as i32;
601 SPAWNED_GROUP.with(|slot| {
602 if let Some(sink) = slot.borrow().as_ref() {
603 sink.store(pgid, std::sync::atomic::Ordering::SeqCst);
604 }
605 });
606
607 Ok(ContainedChild {
608 child,
609 pgid,
610 resolved_binary,
611 started,
612 cgroup_occupancy,
613 cgroup_counters_before,
614 })
615}
616
617fn spawn_and_wait(
621 binary: &str,
622 args: &[&str],
623 env_overrides: &[(String, String)],
624 accessible_workdir: &WorkdirRoot,
625 policy: &CapabilityPolicy,
626 enforcement: &crate::sandbox::ShellEnforcement,
627 wait: Wait,
628) -> Result<(ShellOutcome, Option<ForegroundStopReason>), ShellExecError> {
629 check_shell_allow(policy, binary).map_err(ShellExecError::Failed)?;
630
631 if let Wait::Detach(DetachPolicy {
634 canceled: Some(canceled),
635 ..
636 }) = &wait
637 {
638 if canceled.is_canceled() {
639 return Err(ShellExecError::Failed(CANCELED_BEFORE_SPAWN.to_string()));
640 }
641 }
642
643 let ContainedChild {
644 mut child,
645 pgid,
646 resolved_binary,
647 started,
648 cgroup_occupancy,
649 cgroup_counters_before,
650 } = spawn_contained(
651 binary,
652 args,
653 env_overrides,
654 accessible_workdir,
655 policy,
656 enforcement,
657 Stdio::null(),
658 "shell",
659 )?;
660
661 let readers = OutputReaders::spawn(&mut child);
665
666 let records_root = enforcement.records_root();
669 let wait = match wait {
670 Wait::Detach(_) if records_root.is_none() => Wait::Blocking,
671 wait => wait,
672 };
673
674 let mut stopped = None;
675 let (status, drain_deadline) = match wait {
676 Wait::Blocking => {
677 let status = child.wait().map_err(|error| error.to_string())?;
678 (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT)
679 }
680 Wait::Stop(stop) => {
681 let deadline = started + stop.timeout;
682 loop {
683 if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
684 break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
685 }
686 let remaining = deadline.saturating_duration_since(Instant::now());
687 let reason = if stop.canceled.is_canceled() {
688 Some(ForegroundStopReason::Canceled)
689 } else if remaining.is_zero() {
690 Some(ForegroundStopReason::TimedOut)
691 } else {
692 None
693 };
694 if let Some(reason) = reason {
695 crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL);
697 let status = child.wait().map_err(|error| error.to_string())?;
698 stopped = Some(reason);
699 break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
700 }
701 std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
702 }
703 }
704 Wait::Detach(policy) => {
705 let deadline = policy.grace.map(|grace| started + grace);
707 loop {
708 match child.try_wait().map_err(|error| error.to_string())? {
709 Some(status) => {
710 let drain = Instant::now() + PIPE_DRAIN_AFTER_EXIT;
711 break (
712 status,
713 deadline.map_or(drain, |deadline| drain.min(deadline)),
714 );
715 }
716 None => {
717 let remaining = deadline
718 .map(|deadline| deadline.saturating_duration_since(Instant::now()));
719 let abandoned = policy
722 .abandoned
723 .as_ref()
724 .is_some_and(crate::detached::AbandonSignal::is_raised);
725 let canceled = policy
728 .canceled
729 .as_ref()
730 .filter(|signal| signal.is_canceled());
731 if let Some(signal) = canceled {
732 signal.note_phase(crate::cancel::PHASE_SHELL);
733 }
734 if remaining.is_some_and(|remaining| remaining.is_zero())
735 || abandoned
736 || canceled.is_some()
737 {
738 let records_root = records_root
740 .expect("a detach policy is kept only with a records root")
741 .clone();
742 let detached = demote(
743 child,
744 readers,
745 DemotionContext {
746 policy,
747 records_root,
748 invoked_binary: binary.to_string(),
749 resolved_binary,
750 args: args.iter().map(|arg| (*arg).to_string()).collect(),
751 started,
752 pgid,
753 cgroup_scope: enforcement.cgroup_scope.clone(),
754 cgroup_counters_before,
755 cgroup_occupancy,
756 },
757 );
758 return Ok((ShellOutcome::Detached(detached), None));
759 }
760 std::thread::sleep(remaining.map_or(DETACH_POLL_INTERVAL, |remaining| {
761 remaining.min(DETACH_POLL_INTERVAL)
762 }));
763 }
764 }
765 }
766 }
767 };
768 let (stdout_bytes, stderr_bytes, output_holders) = finish_output(readers, pgid, drain_deadline);
769 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
770
771 let resource_limit_hit = classify_resource_limit(
774 &status,
775 enforcement.cgroup_scope.as_deref(),
776 cgroup_counters_before,
777 cgroup_occupancy.as_ref(),
778 )
779 .filter(|_| stopped.is_none());
780 drop(cgroup_occupancy);
781 let exit_code = exit_code_of(&status);
782
783 let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
784 let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
785 let mut truncated = false;
786 let mut full_output_path = None;
787
788 if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
789 truncated = true;
790 let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
791 let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
792 let full_stdout = std::mem::replace(
793 &mut stdout,
794 String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
795 );
796 let full_stderr = std::mem::replace(
797 &mut stderr,
798 String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
799 );
800 if let Some(records_root) = records_root {
801 full_output_path = Some(write_shell_output_log(
802 records_root,
803 &format!("shell-{}", crate::trace::timestamp_ms()),
804 binary,
805 args,
806 exit_code,
807 &full_stdout,
808 &full_stderr,
809 )?);
810 }
811 }
812
813 Ok((
814 ShellOutcome::Finished(ShellResult {
815 binary: resolved_binary,
816 exit_code,
817 stdout,
818 stderr,
819 duration_ms,
820 truncated,
821 full_output_path,
822 resource_limit_hit,
823 output_holders,
824 }),
825 stopped,
826 ))
827}
828
829pub(crate) fn execute_shell(
834 binary: &str,
835 args: &[&str],
836 env_overrides: &[(String, String)],
837 accessible_workdir: &WorkdirRoot,
838 policy: &CapabilityPolicy,
839 enforcement: &crate::sandbox::ShellEnforcement,
840) -> Result<ShellResult, ShellExecError> {
841 match run_shell(
842 binary,
843 args,
844 env_overrides,
845 accessible_workdir,
846 policy,
847 enforcement,
848 None,
849 )? {
850 ShellOutcome::Finished(result) => Ok(result),
851 ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
855 "shell command was demoted to the background as {} on a call site that cannot receive a completion",
856 info.work_id
857 ))),
858 }
859}
860
861struct OutputReaders {
872 stdout: Option<PipeReader>,
873 stderr: Option<PipeReader>,
874}
875
876enum PipeSink {
878 Buffer(Vec<u8>),
880 Live(std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>),
882 Discard,
885}
886
887struct PipeReader {
889 thread: std::thread::JoinHandle<()>,
890 sink: std::sync::Arc<std::sync::Mutex<PipeSink>>,
891}
892
893fn lock_sink(sink: &std::sync::Mutex<PipeSink>) -> std::sync::MutexGuard<'_, PipeSink> {
894 sink.lock()
895 .unwrap_or_else(std::sync::PoisonError::into_inner)
896}
897
898impl PipeReader {
899 fn spawn(mut pipe: impl std::io::Read + Send + 'static) -> Self {
900 let sink = std::sync::Arc::new(std::sync::Mutex::new(PipeSink::Buffer(Vec::new())));
901 let thread = {
902 let sink = std::sync::Arc::clone(&sink);
903 std::thread::spawn(move || {
904 let mut chunk = [0u8; 8192];
905 loop {
906 match pipe.read(&mut chunk) {
907 Ok(0) => break,
908 Ok(read) => match &mut *lock_sink(&sink) {
909 PipeSink::Buffer(buffer) => buffer.extend_from_slice(&chunk[..read]),
910 PipeSink::Live(output) => output
911 .lock()
912 .unwrap_or_else(std::sync::PoisonError::into_inner)
913 .append(&chunk[..read]),
914 PipeSink::Discard => {}
915 },
916 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
917 Err(_) => break,
921 }
922 }
923 })
924 };
925 Self { thread, sink }
926 }
927
928 fn take(self) -> Vec<u8> {
931 let taken = match std::mem::replace(&mut *lock_sink(&self.sink), PipeSink::Discard) {
932 PipeSink::Buffer(buffer) => buffer,
933 PipeSink::Live(_) | PipeSink::Discard => Vec::new(),
934 };
935 if self.thread.is_finished() && self.thread.join().is_err() {
936 return Vec::new();
937 }
938 taken
939 }
940}
941
942impl OutputReaders {
943 fn spawn(child: &mut std::process::Child) -> Self {
944 Self {
945 stdout: child.stdout.take().map(PipeReader::spawn),
946 stderr: child.stderr.take().map(PipeReader::spawn),
947 }
948 }
949
950 fn go_live(&self, output: &std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>) {
956 let mut sinks: Vec<_> = [&self.stdout, &self.stderr]
957 .into_iter()
958 .flatten()
959 .map(|reader| lock_sink(&reader.sink))
960 .collect();
961 let mut live = output
962 .lock()
963 .unwrap_or_else(std::sync::PoisonError::into_inner);
964 for sink in &mut sinks {
965 if let PipeSink::Buffer(buffer) =
966 std::mem::replace(&mut **sink, PipeSink::Live(std::sync::Arc::clone(output)))
967 {
968 live.append(&buffer);
969 }
970 }
971 }
972
973 fn at_eof(&self) -> bool {
975 [&self.stdout, &self.stderr]
976 .into_iter()
977 .flatten()
978 .all(|reader| reader.thread.is_finished())
979 }
980
981 fn wait_for_eof(&self, deadline: Instant) -> bool {
984 loop {
985 if self.at_eof() {
986 return true;
987 }
988 let remaining = deadline.saturating_duration_since(Instant::now());
989 if remaining.is_zero() {
990 return false;
991 }
992 std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
993 }
994 }
995
996 fn take(self) -> (Vec<u8>, Vec<u8>) {
999 let take = |reader: Option<PipeReader>| reader.map(PipeReader::take).unwrap_or_default();
1000 (take(self.stdout), take(self.stderr))
1001 }
1002}
1003
1004fn finish_output(
1013 readers: OutputReaders,
1014 pgid: i32,
1015 drain_deadline: Instant,
1016) -> (Vec<u8>, Vec<u8>, OutputHolders) {
1017 if readers.wait_for_eof(drain_deadline) {
1018 let (stdout, stderr) = readers.take();
1019 return (stdout, stderr, OutputHolders::Closed);
1020 }
1021
1022 let give_up = Instant::now() + LEFTOVER_KILL_WAIT;
1023 let mut group_alive = true;
1024 let mut killed = false;
1025 let closed = loop {
1026 if group_alive {
1027 if crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL) {
1029 killed = true;
1030 } else {
1031 group_alive = false;
1032 }
1033 }
1034 if readers.at_eof() {
1035 break true;
1036 }
1037 let remaining = give_up.saturating_duration_since(Instant::now());
1038 if remaining.is_zero() {
1039 break false;
1040 }
1041 std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
1042 };
1043
1044 let (stdout, stderr) = readers.take();
1045 let holders = match (closed, killed) {
1046 (true, true) => OutputHolders::Killed,
1047 (true, false) => OutputHolders::Closed,
1049 (false, _) => OutputHolders::Escaped,
1050 };
1051 (stdout, stderr, holders)
1052}
1053
1054struct DemotionContext {
1056 policy: DetachPolicy,
1057 records_root: WorkdirRoot,
1060 invoked_binary: String,
1062 resolved_binary: String,
1065 args: Vec<String>,
1066 started: Instant,
1067 pgid: i32,
1070 cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
1071 cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
1072 cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
1074}
1075
1076fn demote(
1089 mut child: std::process::Child,
1090 readers: OutputReaders,
1091 context: DemotionContext,
1092) -> DetachedDispatchInfo {
1093 let work_id = crate::detached::new_work_id();
1094 let info = DetachedDispatchInfo {
1095 work_id: work_id.clone(),
1096 binary: context.resolved_binary.clone(),
1097 command: context.policy.command.clone(),
1098 grace_ms: context
1099 .policy
1100 .grace
1101 .map(|grace| grace.as_millis().try_into().unwrap_or(u64::MAX)),
1102 };
1103
1104 let arg_refs: Vec<&str> = context.args.iter().map(String::as_str).collect();
1105 let log = crate::detached::output_path_for(&context.records_root, &work_id);
1106 let output = std::sync::Arc::new(std::sync::Mutex::new(crate::detached::LiveOutput::open(
1107 &context.records_root,
1108 &log.relative,
1109 &log_command_line(&context.invoked_binary, &arg_refs),
1110 )));
1111 readers.go_live(&output);
1112
1113 context.policy.registry.register(
1114 crate::detached::DetachedWork {
1115 work_id: work_id.clone(),
1116 binary: context.resolved_binary.clone(),
1117 command: context.policy.command.clone(),
1118 started_at_ms: crate::trace::timestamp_ms().saturating_sub(
1124 context
1125 .started
1126 .elapsed()
1127 .as_millis()
1128 .try_into()
1129 .unwrap_or(u64::MAX),
1130 ),
1131 },
1132 context.started,
1133 context.pgid,
1134 log.path.clone(),
1135 std::sync::Arc::clone(&output),
1136 );
1137
1138 let provenance = context.policy.completion_provenance();
1139 let DemotionContext {
1140 policy,
1141 records_root,
1142 resolved_binary,
1143 started,
1144 pgid,
1145 cgroup_scope,
1146 cgroup_counters_before,
1147 cgroup_occupancy,
1148 ..
1149 } = context;
1150
1151 std::thread::spawn(move || {
1152 let observed = wait_for_exit_unreaped(pgid);
1153 policy.registry.leader_exited(&work_id);
1154 let waited = child.wait();
1155 let exited_at_ms = crate::trace::timestamp_ms();
1156 let (_, _, output_holders) =
1157 finish_output(readers, pgid, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
1158 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1159
1160 let (exit_code, resource_limit, mut error) = match waited {
1161 Ok(status) => (
1162 exit_code_of(&status),
1163 classify_resource_limit(
1164 &status,
1165 cgroup_scope.as_deref(),
1166 cgroup_counters_before,
1167 cgroup_occupancy.as_ref(),
1168 ),
1169 None,
1170 ),
1171 Err(failure) => (
1172 -1,
1173 None,
1174 Some(format!("waiting for the command failed: {failure}")),
1175 ),
1176 };
1177 drop(cgroup_occupancy);
1178 if let (Err(failure), None) = (observed, &error) {
1179 error = Some(format!("observing the command's exit failed: {failure}"));
1180 }
1181
1182 let write_error = output
1183 .lock()
1184 .unwrap_or_else(std::sync::PoisonError::into_inner)
1185 .finish(exit_code);
1186 if let Some(failure) = write_error {
1187 error = Some(match error {
1188 Some(existing) => format!("{existing}; {failure}"),
1189 None => failure,
1190 });
1191 }
1192 let output_bytes = records_root
1193 .file_len(&log.relative)
1194 .ok()
1195 .flatten()
1196 .unwrap_or(0);
1197
1198 policy
1199 .registry
1200 .complete(crate::detached::DetachedCompletion {
1201 work_id,
1202 binary: resolved_binary,
1203 command: policy.command.clone(),
1204 exit_code,
1205 duration_ms,
1206 exited_at_ms,
1207 output_path: log.path,
1208 output_bytes,
1209 resource_limit,
1210 output_holders,
1211 context_id: policy.context_id.clone(),
1212 provenance,
1213 error,
1214 });
1215 });
1216
1217 info
1218}
1219
1220pub(crate) fn wait_for_exit_unreaped(pid: i32) -> std::io::Result<()> {
1226 use rustix::process::{waitid, Pid, WaitId, WaitIdOptions};
1227
1228 let pid = (pid > 0)
1229 .then(|| Pid::from_raw(pid))
1230 .flatten()
1231 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
1232 loop {
1233 match waitid(
1234 WaitId::Pid(pid),
1235 WaitIdOptions::EXITED | WaitIdOptions::NOWAIT,
1236 ) {
1237 Ok(_) => return Ok(()),
1238 Err(rustix::io::Errno::INTR) => {}
1239 Err(errno) => return Err(errno.into()),
1240 }
1241 }
1242}
1243
1244pub(crate) fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
1252 use std::os::unix::process::ExitStatusExt;
1253
1254 status
1255 .code()
1256 .or_else(|| status.signal().map(|signal| 128 + signal))
1257 .unwrap_or(-1)
1258}
1259
1260fn classify_resource_limit(
1282 status: &std::process::ExitStatus,
1283 cgroup_scope: Option<&crate::cgroup::CgroupScope>,
1284 counters_before: Option<crate::cgroup::CgroupEventCounters>,
1285 occupancy: Option<&crate::cgroup::ScopeOccupancy>,
1286) -> Option<String> {
1287 use std::os::unix::process::ExitStatusExt;
1288
1289 let signal_limit = status
1290 .signal()
1291 .and_then(crate::resources::limit_from_signal);
1292 let counter_delta = match (cgroup_scope, counters_before) {
1293 (Some(scope), Some(before)) => scope.event_counters().attribution_since(before),
1294 _ => None,
1295 };
1296 let exclusive = occupancy.is_some_and(crate::cgroup::ScopeOccupancy::exclusive);
1297 attribute_resource_limit(signal_limit, counter_delta, exclusive)
1298}
1299
1300pub(crate) fn attribute_resource_limit(
1307 signal_limit: Option<&'static str>,
1308 counter_delta: Option<&'static str>,
1309 exclusive: bool,
1310) -> Option<String> {
1311 signal_limit
1312 .or(counter_delta.filter(|_| exclusive))
1313 .map(str::to_string)
1314}
1315
1316pub(crate) fn build_shell_env(
1327 policy: &CapabilityPolicy,
1328 env_overrides: &[(String, String)],
1329 home_dir: Option<&std::path::Path>,
1330 runtime_dir: Option<&std::path::Path>,
1331 records_dir: Option<&std::path::Path>,
1332 tmpdir: Option<&str>,
1333) -> Result<BTreeMap<String, String>, String> {
1334 let mut env = BTreeMap::new();
1335
1336 for key in DEFAULT_ENV_BASELINE {
1337 if let Ok(value) = std::env::var(key) {
1338 env.insert((*key).to_string(), value);
1339 }
1340 }
1341
1342 for (key, value) in env_overrides {
1343 env.insert(key.clone(), value.clone());
1344 }
1345
1346 for key in &policy.shell_baseline_env {
1347 if crate::formation::is_member_grant_env(key)
1350 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1351 {
1352 continue;
1353 }
1354 if let Ok(value) = std::env::var(key) {
1355 env.insert(key.clone(), value);
1356 }
1357 }
1358
1359 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1360
1361 env.remove(ARTIFACT_CONFIG_ENV);
1364
1365 let home = home_dir.ok_or_else(|| NO_HOME_FOLDER_REFUSAL.to_string())?;
1369 let home = home.to_string_lossy().into_owned();
1370 env.insert("HOME".to_string(), home.clone());
1371 env.insert("USERPROFILE".to_string(), home);
1372 env.remove(crate::runtime_dir::RUNTIME_DIR_ENV);
1375 if let Some(runtime_dir) = runtime_dir {
1376 env.insert(
1377 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
1378 runtime_dir.to_string_lossy().into_owned(),
1379 );
1380 }
1381 env.remove(crate::records_dir::RECORDS_DIR_ENV);
1383 if let Some(records_dir) = records_dir {
1384 env.insert(
1385 crate::records_dir::RECORDS_DIR_ENV.to_string(),
1386 records_dir.to_string_lossy().into_owned(),
1387 );
1388 }
1389 for name in crate::temp_dir::TEMP_DIR_ENV {
1391 env.remove(name);
1392 if let Some(tmpdir) = tmpdir {
1393 env.insert(name.to_string(), tmpdir.to_string());
1394 }
1395 }
1396
1397 Ok(env)
1398}
1399
1400pub(crate) const LINEAGE_ENV: &[&str] = &[
1407 crate::delegation::SPAWNER_ENV,
1408 crate::formation::FORMATION_ID_ENV,
1409 crate::lifeline::SPAWNER_LIFELINE_ENV,
1410 crate::host_warnings::HOST_WARNINGS_REPORTED_ENV,
1411 "MURMUR_ROOST_URL",
1412];
1413
1414pub(crate) fn subprocess_env(
1418 policy: &CapabilityPolicy,
1419 env_overrides: &[(String, String)],
1420 enforcement: &crate::sandbox::ShellEnforcement,
1421) -> Result<BTreeMap<OsString, OsString>, String> {
1422 if enforcement.tier == crate::sandbox::EnforcementTier::Unconfined {
1423 return Ok(build_unconfined_env(
1424 policy,
1425 std::env::vars_os(),
1426 env_overrides,
1427 enforcement.runtime_dir_path(),
1428 enforcement.records_dir_path(),
1429 ));
1430 }
1431 Ok(build_shell_env(
1432 policy,
1433 env_overrides,
1434 enforcement.home_dir_path(),
1435 enforcement.runtime_dir_path(),
1436 enforcement.records_dir_path(),
1437 enforcement.tmpdir_value().as_deref(),
1438 )?
1439 .into_iter()
1440 .map(|(key, value)| (OsString::from(key), OsString::from(value)))
1441 .collect())
1442}
1443
1444pub(crate) fn build_unconfined_env(
1454 policy: &CapabilityPolicy,
1455 base: impl IntoIterator<Item = (OsString, OsString)>,
1456 env_overrides: &[(String, String)],
1457 runtime_dir: Option<&std::path::Path>,
1458 records_dir: Option<&std::path::Path>,
1459) -> BTreeMap<OsString, OsString> {
1460 let mut env: BTreeMap<OsString, OsString> = base.into_iter().collect();
1461 for (key, value) in env_overrides {
1462 env.insert(OsString::from(key), OsString::from(value));
1463 }
1464 env.retain(|key, _| {
1465 let Some(key) = key.to_str() else {
1466 return true;
1467 };
1468 !(LINEAGE_ENV.contains(&key)
1469 || crate::formation::is_member_grant_env(key)
1470 || key == ARTIFACT_CONFIG_ENV
1471 || key == crate::runtime_dir::RUNTIME_DIR_ENV
1472 || key == crate::records_dir::RECORDS_DIR_ENV
1473 || policy
1474 .shell_strip_env
1475 .iter()
1476 .any(|pattern| env_name_matches_pattern(pattern, key)))
1477 });
1478 if let Some(runtime_dir) = runtime_dir {
1479 env.insert(
1480 OsString::from(crate::runtime_dir::RUNTIME_DIR_ENV),
1481 runtime_dir.as_os_str().to_owned(),
1482 );
1483 }
1484 if let Some(records_dir) = records_dir {
1485 env.insert(
1486 OsString::from(crate::records_dir::RECORDS_DIR_ENV),
1487 records_dir.as_os_str().to_owned(),
1488 );
1489 }
1490 env
1491}
1492
1493pub(crate) fn strip_credential_shaped_vars(
1501 env: &mut BTreeMap<String, String>,
1502 extra_patterns: &[String],
1503) {
1504 env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
1505}
1506
1507#[derive(Debug, Clone, PartialEq, Eq)]
1509pub enum BackstopPatternSource {
1510 Builtin,
1512 StripEnv,
1514}
1515
1516#[derive(Debug, Clone, PartialEq, Eq)]
1518pub struct BackstopMatch {
1519 pub pattern: String,
1520 pub source: BackstopPatternSource,
1521}
1522
1523pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
1532 CREDENTIAL_ENV_PATTERNS
1533 .iter()
1534 .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
1535 .chain(
1536 extra_patterns
1537 .iter()
1538 .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
1539 )
1540 .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
1541 .map(|(pattern, source)| BackstopMatch {
1542 pattern: pattern.to_string(),
1543 source,
1544 })
1545}
1546
1547pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
1551 credential_backstop_match(name, extra_patterns).is_some()
1552}
1553
1554pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
1570 let mut env = BTreeMap::new();
1571
1572 for key in &policy.env_allow {
1573 if key == ARTIFACT_CONFIG_ENV
1574 || crate::formation::is_member_grant_env(key)
1575 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1576 || key == crate::runtime_dir::RUNTIME_DIR_ENV
1577 || key == crate::records_dir::RECORDS_DIR_ENV
1578 {
1579 continue;
1580 }
1581 if let Ok(value) = std::env::var(key) {
1582 env.insert(key.clone(), value);
1583 }
1584 }
1585
1586 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1587
1588 env
1589}
1590
1591fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
1592 if let Some(rest) = pattern.strip_prefix('*') {
1593 if let Some(middle) = rest.strip_suffix('*') {
1594 return key.contains(middle);
1595 }
1596 return key.ends_with(rest);
1597 }
1598
1599 if let Some(prefix) = pattern.strip_suffix('*') {
1600 return key.starts_with(prefix);
1601 }
1602
1603 pattern == key
1604}
1605
1606fn log_command_line(binary: &str, args: &[&str]) -> String {
1608 if args.is_empty() {
1609 binary.to_string()
1610 } else {
1611 format!("{binary} {}", args.join(" "))
1612 }
1613}
1614
1615fn write_shell_output_log(
1623 records_root: &WorkdirRoot,
1624 file_stem: &str,
1625 binary: &str,
1626 args: &[&str],
1627 exit_code: i32,
1628 stdout: &str,
1629 stderr: &str,
1630) -> Result<String, String> {
1631 let log = crate::detached::output_path_for(records_root, file_stem);
1632 let command = log_command_line(binary, args);
1633
1634 let content = format!(
1635 "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
1636 );
1637
1638 records_root
1639 .write_file(&log.relative, content.as_bytes(), DEFAULT_FILE_MODE)
1640 .map_err(|error| format!("failed to write shell log: {error}"))?;
1641
1642 Ok(log.path)
1643}
1644
1645#[cfg(test)]
1646mod tests {
1647 use std::{fs, path::Path};
1648 use tempfile::tempdir;
1649
1650 use super::test_support::{process_dead_within, read_pid, KillPidFileOnDrop};
1651 use super::*;
1652 use crate::sandbox::ShellEnforcement;
1653
1654 #[test]
1660 fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
1661 let temp = tempdir().unwrap();
1662 let policy = CapabilityPolicy {
1663 shell_allow: vec!["bash".to_string()],
1664 resources: crate::resources::HostResourceLimits {
1665 max_open_files: 64,
1666 ..crate::resources::HostResourceLimits::default()
1667 },
1668 ..CapabilityPolicy::default()
1669 };
1670 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1671 enforcement.resource_limits = policy.resources;
1672
1673 let result = execute_shell(
1674 "bash",
1675 &["-c", "ulimit -Hn"],
1676 &[],
1677 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1678 &policy,
1679 &enforcement,
1680 )
1681 .expect("bash must run");
1682
1683 assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
1684 assert_eq!(result.resource_limit_hit, None);
1685 }
1686
1687 #[cfg(target_os = "linux")]
1689 fn run_bash_with_limits(
1690 limits: crate::resources::HostResourceLimits,
1691 script: &str,
1692 ) -> ShellResult {
1693 let temp = tempdir().unwrap();
1694 let policy = CapabilityPolicy {
1695 shell_allow: vec!["bash".to_string()],
1696 resources: limits,
1697 ..CapabilityPolicy::default()
1698 };
1699 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1700 enforcement.resource_limits = limits;
1701
1702 execute_shell(
1703 "bash",
1704 &["-c", script],
1705 &[],
1706 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1707 &policy,
1708 &enforcement,
1709 )
1710 .expect("bash must run")
1711 }
1712
1713 #[cfg(target_os = "linux")]
1719 #[test]
1720 fn a_command_that_ignores_sigxcpu_is_killed_one_second_later() {
1721 let result = run_bash_with_limits(
1722 crate::resources::HostResourceLimits {
1723 cpu_seconds: Some(1),
1724 ..crate::resources::HostResourceLimits::default()
1725 },
1726 "( trap '' XCPU; while :; do :; done ); echo \"status=$?\"; times",
1727 );
1728
1729 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1730 assert!(
1731 result.stdout.contains("status=137"),
1732 "the subshell must die of SIGKILL; stdout was: {}",
1733 result.stdout
1734 );
1735 assert_eq!(result.resource_limit_hit, None);
1736
1737 let children = result
1738 .stdout
1739 .lines()
1740 .last()
1741 .unwrap_or_else(|| panic!("no children line from `times`: {}", result.stdout));
1742 let cpu: f64 = children
1743 .split_whitespace()
1744 .map(|field| {
1745 let (minutes, seconds) = field.trim_end_matches('s').split_once('m').unwrap();
1746 minutes.parse::<f64>().unwrap() * 60.0 + seconds.parse::<f64>().unwrap()
1747 })
1748 .sum();
1749 assert!(
1750 cpu <= 2.5,
1751 "the subshell ran {cpu} CPU-seconds, past cpu_seconds + 1"
1752 );
1753 }
1754
1755 #[cfg(target_os = "linux")]
1759 #[test]
1760 #[allow(unsafe_code)]
1761 fn the_cpu_limit_warns_one_second_before_its_hard_ceiling() {
1762 let result = run_bash_with_limits(
1763 crate::resources::HostResourceLimits {
1764 cpu_seconds: Some(5),
1765 max_open_files: 64,
1766 ..crate::resources::HostResourceLimits::default()
1767 },
1768 r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)"; ulimit -Ht 10 2>/dev/null || echo "raise refused""#,
1769 );
1770
1771 let mut lines = result.stdout.lines();
1772 assert_eq!(
1773 lines.next(),
1774 Some("5 6 64 64"),
1775 "stderr was: {}",
1776 result.stderr
1777 );
1778 if unsafe { libc::geteuid() } != 0 {
1780 assert_eq!(lines.next(), Some("raise refused"));
1781 }
1782 }
1783
1784 #[cfg(target_os = "linux")]
1787 #[test]
1788 #[allow(clippy::unnecessary_cast)]
1789 fn with_no_cpu_seconds_a_command_keeps_the_inherited_cpu_limit() {
1790 let own = crate::resources::read_rlimit(libc::RLIMIT_CPU as u32)
1791 .expect("getrlimit(RLIMIT_CPU) failed");
1792 let render = |slot: libc::rlim_t| {
1793 if slot == libc::RLIM_INFINITY {
1794 "unlimited".to_string()
1795 } else {
1796 slot.to_string()
1797 }
1798 };
1799
1800 let result = run_bash_with_limits(
1801 crate::resources::HostResourceLimits::resolve(None),
1802 r#"echo "$(ulimit -St) $(ulimit -Ht)""#,
1803 );
1804
1805 assert_eq!(
1806 result.stdout.trim(),
1807 format!("{} {}", render(own.rlim_cur), render(own.rlim_max)),
1808 "stderr was: {}",
1809 result.stderr
1810 );
1811 }
1812
1813 #[cfg(target_os = "linux")]
1815 #[test]
1816 fn with_no_cpu_seconds_a_cpu_bound_command_runs_to_its_end() {
1817 let result = run_bash_with_limits(
1818 crate::resources::HostResourceLimits::resolve(None),
1819 "end=$((SECONDS + 3)); while [ \"$SECONDS\" -lt \"$end\" ]; do :; done; echo done",
1820 );
1821
1822 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1823 assert_eq!(result.stdout.trim(), "done");
1824 assert_eq!(result.resource_limit_hit, None);
1825 }
1826
1827 #[cfg(target_os = "linux")]
1832 #[test]
1833 fn a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors() {
1834 const TEST: &str = "a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors";
1835 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1836 return;
1837 }
1838 let temp = tempdir().unwrap();
1839 let policy = CapabilityPolicy {
1840 shell_allow: vec!["bash".to_string()],
1841 resources: crate::resources::HostResourceLimits {
1842 max_open_files: 16,
1843 ..crate::resources::HostResourceLimits::default()
1844 },
1845 ..CapabilityPolicy::default()
1846 };
1847 let enforcement = ShellEnforcement::resolve_declared(
1848 &policy,
1849 murmur_artifact::ContainmentClass::Scoped,
1850 crate::sandbox::HostProbe::probe(),
1851 )
1852 .unwrap()
1853 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1854 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1855 crate::runtime_err!(
1856 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1857 enforcement.tier
1858 );
1859 return;
1860 }
1861 let held: Vec<fs::File> = (0..128)
1862 .map(|_| fs::File::open("/dev/null").unwrap())
1863 .collect();
1864
1865 let result = execute_shell(
1866 "bash",
1867 &["-c", "ulimit -Hn"],
1868 &[],
1869 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1870 &policy,
1871 &enforcement,
1872 )
1873 .expect("bash must start under the limit");
1874 drop(held);
1875
1876 assert_eq!(result.stdout.trim(), "16", "stderr was: {}", result.stderr);
1877 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1878 }
1879
1880 #[cfg(target_os = "linux")]
1884 #[test]
1885 fn a_program_outside_the_allowlist_is_refused_with_exit_126() {
1886 const TEST: &str = "a_program_outside_the_allowlist_is_refused_with_exit_126";
1887 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1888 return;
1889 }
1890 let run = |allow: &[&str]| {
1891 let temp = tempdir().unwrap();
1892 let policy = CapabilityPolicy {
1893 shell_allow: allow.iter().map(|b| b.to_string()).collect(),
1894 ..CapabilityPolicy::default()
1895 };
1896 let enforcement = ShellEnforcement::resolve_declared(
1897 &policy,
1898 murmur_artifact::ContainmentClass::Scoped,
1899 crate::sandbox::HostProbe::probe(),
1900 )
1901 .unwrap()
1902 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1903 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1904 crate::runtime_err!(
1905 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1906 enforcement.tier
1907 );
1908 return None;
1909 }
1910 let result = execute_shell(
1911 "bash",
1912 &["-c", "cat /dev/null"],
1913 &[],
1914 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1915 &policy,
1916 &enforcement,
1917 )
1918 .expect("bash must start");
1919 Some(result)
1920 };
1921
1922 let Some(refused) = run(&["bash"]) else {
1923 return;
1924 };
1925 assert_eq!(refused.exit_code, 126, "stderr was: {}", refused.stderr);
1926 assert!(
1927 refused.stderr.contains("Permission denied"),
1928 "stderr was: {}",
1929 refused.stderr
1930 );
1931
1932 let allowed = run(&["bash", "cat"]).expect("the tier did not change between runs");
1933 assert_eq!(allowed.exit_code, 0, "stderr was: {}", allowed.stderr);
1934 crate::runtime_err!("{TEST}: ran on KernelFull; [bash] exited 126, [bash, cat] exited 0");
1935 }
1936
1937 #[cfg(target_os = "linux")]
1940 #[test]
1941 #[allow(clippy::print_stdout)]
1942 fn a_kernel_tier_command_gets_the_cpu_slots() {
1943 const TEST: &str = "a_kernel_tier_command_gets_the_cpu_slots";
1944 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1945 return;
1946 }
1947 let temp = tempdir().unwrap();
1948 let policy = CapabilityPolicy {
1949 shell_allow: vec!["bash".to_string()],
1950 resources: crate::resources::HostResourceLimits {
1951 cpu_seconds: Some(5),
1952 max_open_files: 64,
1953 ..crate::resources::HostResourceLimits::default()
1954 },
1955 ..CapabilityPolicy::default()
1956 };
1957 let enforcement = ShellEnforcement::resolve_declared(
1958 &policy,
1959 murmur_artifact::ContainmentClass::Scoped,
1960 crate::sandbox::HostProbe::probe(),
1961 )
1962 .unwrap()
1963 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1964 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1965 crate::runtime_err!(
1966 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1967 enforcement.tier
1968 );
1969 return;
1970 }
1971
1972 let result = execute_shell(
1973 "bash",
1974 &[
1975 "-c",
1976 r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)""#,
1977 ],
1978 &[],
1979 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1980 &policy,
1981 &enforcement,
1982 )
1983 .expect("bash must start under the limits");
1984 println!("{}", result.stdout.trim());
1985
1986 assert_eq!(
1987 result.stdout.trim(),
1988 "5 6 64 64",
1989 "stderr was: {}",
1990 result.stderr
1991 );
1992 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1993 }
1994
1995 #[cfg(target_os = "linux")]
1999 #[test]
2000 fn a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max() {
2001 const TEST: &str = "a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max";
2002 if !crate::cgroup::cgroup_delegation_available() {
2003 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
2004 return;
2005 }
2006
2007 let temp = tempdir().unwrap();
2008 let limits = crate::resources::HostResourceLimits {
2009 cgroup_pids_max: 16,
2010 ..crate::resources::HostResourceLimits::default()
2011 };
2012 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_fork_bomb", temp.path())
2013 .expect("this host delegates a cgroup scope")
2014 .scope;
2015 let policy = CapabilityPolicy {
2016 shell_allow: vec!["python3".to_string()],
2017 resources: limits,
2018 ..CapabilityPolicy::default()
2019 };
2020 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
2021 enforcement.resource_limits = limits;
2022 let enforcement = enforcement.with_host_bounding(scope, None);
2023
2024 let script = "import os, resource, signal, time\n\
2025 children = []\n\
2026 for _ in range(4096):\n\
2027 \x20 try:\n\
2028 \x20 pid = os.fork()\n\
2029 \x20 except OSError:\n\
2030 \x20 break\n\
2031 \x20 if pid == 0:\n\
2032 \x20 time.sleep(30)\n\
2033 \x20 os._exit(0)\n\
2034 \x20 children.append(pid)\n\
2035 for pid in children:\n\
2036 \x20 os.kill(pid, signal.SIGKILL)\n\
2037 for pid in children:\n\
2038 \x20 os.waitpid(pid, 0)\n\
2039 soft = resource.getrlimit(resource.RLIMIT_NPROC)[0]\n\
2040 print(len(children))\n\
2041 print('unlimited' if soft == resource.RLIM_INFINITY else soft)\n";
2042 let result = execute_shell(
2043 "python3",
2044 &["-c", script],
2045 &[],
2046 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2047 &policy,
2048 &enforcement,
2049 )
2050 .expect("python3 must run");
2051
2052 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
2053 let mut lines = result.stdout.lines();
2054 let reached: u64 = lines.next().unwrap().parse().unwrap();
2055 let soft = lines.next().unwrap();
2056 assert!(
2057 reached < 16,
2058 "pids.max is 16 and the python process holds one task, so at most 15 children can \
2059 be live; reached {reached}"
2060 );
2061 assert_eq!(
2062 result.resource_limit_hit,
2063 Some("cgroup_pids_max".to_string())
2064 );
2065 let (own_soft, _) = crate::resources::test_support::own_rlimit_nproc();
2066 assert_eq!(
2067 soft,
2068 crate::resources::test_support::rendered_rlimit(own_soft),
2069 "a scoped spawn inherits RLIMIT_NPROC unchanged"
2070 );
2071 }
2072
2073 #[test]
2080 fn home_folder_is_home_and_the_work_folder_gains_nothing() {
2081 let accessible = tempdir().unwrap();
2082 let runtime = tempdir().unwrap();
2083 let enforcement = ShellEnforcement::environment_only()
2084 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2085 let home = enforcement.home_dir_path().unwrap().to_path_buf();
2086 let policy = CapabilityPolicy {
2087 shell_allow: vec!["bash".to_string()],
2088 ..CapabilityPolicy::default()
2089 };
2090
2091 let result = execute_shell(
2092 "bash",
2093 &[
2094 "-c",
2095 "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && echo \"$HOME\" && pwd",
2096 ],
2097 &[],
2098 &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
2099 &policy,
2100 &enforcement,
2101 )
2102 .expect("bash must run");
2103
2104 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
2105 let mut lines = result.stdout.lines();
2106 assert_eq!(lines.next(), Some("marker"));
2107 assert_eq!(lines.next(), Some(home.to_str().unwrap()));
2108 assert_eq!(
2109 std::fs::canonicalize(lines.next().unwrap()).unwrap(),
2110 std::fs::canonicalize(accessible.path()).unwrap(),
2111 "the subprocess cwd is the work folder"
2112 );
2113 assert_eq!(fs::read_to_string(home.join("probe")).unwrap(), "marker\n");
2114 assert_eq!(
2115 fs::read_dir(accessible.path()).unwrap().count(),
2116 0,
2117 "the work folder gains nothing"
2118 );
2119 }
2120
2121 fn print_seq(workdir: &Path, enforcement: &ShellEnforcement, last: u32) -> ShellResult {
2124 let policy = CapabilityPolicy {
2125 shell_allow: vec!["bash".to_string()],
2126 ..CapabilityPolicy::default()
2127 };
2128 let command = format!("seq 1 {last}");
2129 execute_shell(
2130 "bash",
2131 &["-c", &command],
2132 &[],
2133 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
2134 &policy,
2135 enforcement,
2136 )
2137 .expect("bash must run")
2138 }
2139
2140 #[test]
2143 fn a_truncated_output_log_lands_in_the_records_directory() {
2144 let workdir = tempdir().unwrap();
2145 let runtime = tempdir().unwrap();
2146 let enforcement = ShellEnforcement::environment_only()
2147 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2148 let records_root = enforcement.records_root().unwrap().clone();
2149
2150 let result = print_seq(workdir.path(), &enforcement, 20_000);
2151
2152 assert!(result.truncated);
2153 assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
2154 let path = result
2155 .full_output_path
2156 .expect("a truncated command names its log");
2157 let file_name = Path::new(&path).file_name().unwrap().to_str().unwrap();
2158 let ms = file_name
2159 .strip_prefix("shell-")
2160 .and_then(|rest| rest.strip_suffix(".log"))
2161 .unwrap_or_else(|| panic!("{path} is not logs/shell-<ms>.log"));
2162 assert!(ms.chars().all(|c| c.is_ascii_digit()), "{path}");
2163 assert_eq!(
2164 Path::new(&path),
2165 records_root.host_path("logs").join(file_name),
2166 );
2167 assert!(Path::new(&path).is_absolute(), "{path}");
2168
2169 let log = fs::read_to_string(&path).unwrap();
2170 let expected: String = (1..=20_000).map(|n| format!("{n}\n")).collect();
2171 let stdout = log
2172 .split_once("\nStdout:\n")
2173 .and_then(|(_, rest)| rest.split_once("\n\nStderr:\n"))
2174 .map(|(stdout, _)| stdout)
2175 .expect("the log keeps its Stdout: and Stderr: sections");
2176 assert_eq!(stdout, expected, "the log holds the full stdout");
2177 assert!(log.starts_with("Command: bash -c seq 1 20000\nExit code: 0\n"));
2178 assert!(
2179 log.ends_with("\n\nStderr:\n\n"),
2180 "the log keeps the empty Stderr: section"
2181 );
2182
2183 assert!(
2184 !workdir.path().join("logs").exists(),
2185 "nothing is written into the accessible workdir"
2186 );
2187 assert_eq!(fs::read_dir(workdir.path()).unwrap().count(), 0);
2188 }
2189
2190 #[test]
2193 fn with_no_records_directory_a_truncated_command_writes_no_log() {
2194 let runtime = tempdir().unwrap();
2195 let open = |sub: &str| {
2196 let path = runtime.path().join(sub);
2197 fs::create_dir_all(&path).unwrap();
2198 crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
2199 };
2200 let roots_without_records = ShellEnforcement::environment_only().with_runtime_dir(
2201 crate::runtime_dir::RuntimeDirRoots::new(
2202 open(crate::runtime_dir::RUNTIME_SUBDIR),
2203 open(crate::runtime_dir::SEALED_ETC_SUBDIR),
2204 open(crate::runtime_dir::HARNESS_SUBDIR),
2205 )
2206 .with_home(open(crate::records_dir::HOME_SUBDIR)),
2207 None,
2208 );
2209
2210 let enforcement = &roots_without_records;
2211 let workdir = tempdir().unwrap();
2212 let result = print_seq(workdir.path(), enforcement, 20_000);
2213 assert!(result.truncated);
2214 assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
2215 assert_eq!(result.full_output_path, None);
2216 assert_eq!(
2217 fs::read_dir(workdir.path()).unwrap().count(),
2218 0,
2219 "nothing is written into the workdir"
2220 );
2221 assert!(!runtime.path().join("logs").exists());
2222 assert!(!runtime
2223 .path()
2224 .join(crate::runtime_dir::RUNTIME_SUBDIR)
2225 .join("logs")
2226 .exists());
2227 }
2228
2229 #[test]
2232 fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
2233 let temp = tempdir().unwrap();
2234 let policy = CapabilityPolicy {
2235 shell_allow: vec!["bash".to_string()],
2236 ..CapabilityPolicy::default()
2237 };
2238
2239 let result = execute_shell(
2240 "bash",
2241 &["-c", "exit 3"],
2242 &[],
2243 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2244 &policy,
2245 &ShellEnforcement::environment_only().with_test_session_dirs(),
2246 )
2247 .expect("bash must run");
2248
2249 assert_eq!(result.exit_code, 3);
2250 assert_eq!(result.resource_limit_hit, None);
2251 }
2252
2253 #[test]
2254 fn execute_shell_blocks_binary_not_in_allowlist() {
2255 let policy = CapabilityPolicy::default();
2256 let error = execute_shell(
2257 "bash",
2258 &["-c", "echo hi"],
2259 &[],
2260 &crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
2261 &policy,
2262 &ShellEnforcement::environment_only().with_test_session_dirs(),
2263 )
2264 .unwrap_err();
2265
2266 assert!(error.to_string().contains("capabilities.shell.allow"));
2267 }
2268
2269 #[test]
2270 fn execute_shell_returns_nonzero_exit_code_without_error() {
2271 let temp = tempdir().unwrap();
2272 let policy = CapabilityPolicy {
2273 shell_allow: vec!["bash".to_string()],
2274 ..CapabilityPolicy::default()
2275 };
2276
2277 let result = execute_shell(
2278 "bash",
2279 &["-c", "exit 42"],
2280 &[],
2281 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2282 &policy,
2283 &ShellEnforcement::environment_only().with_test_session_dirs(),
2284 )
2285 .unwrap();
2286 assert_eq!(result.exit_code, 42);
2287 }
2288
2289 #[test]
2293 fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
2294 let temp = tempdir().unwrap();
2295 let policy = CapabilityPolicy {
2296 shell_allow: vec!["bash".to_string()],
2297 ..CapabilityPolicy::default()
2298 };
2299
2300 let result = execute_shell(
2301 "bash",
2302 &["-c", "exit 0"],
2303 &[],
2304 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2305 &policy,
2306 &ShellEnforcement::environment_only().with_test_session_dirs(),
2307 )
2308 .unwrap();
2309
2310 assert!(
2311 Path::new(&result.binary).is_absolute(),
2312 "a binary found on PATH must be reported as an absolute path, got {:?}",
2313 result.binary
2314 );
2315 assert!(
2316 result.binary.ends_with("bash"),
2317 "the reported path must still name bash, got {:?}",
2318 result.binary
2319 );
2320 }
2321
2322 #[test]
2328 fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
2329 let temp = tempdir().unwrap();
2330 let tool = temp.path().join("local-tool");
2331 fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
2332 let mut perms = fs::metadata(&tool).unwrap().permissions();
2333 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
2334 fs::set_permissions(&tool, perms).unwrap();
2335
2336 let policy = CapabilityPolicy {
2337 shell_allow: vec!["./local-tool".to_string()],
2338 ..CapabilityPolicy::default()
2339 };
2340
2341 let result = execute_shell(
2342 "./local-tool",
2343 &[],
2344 &[],
2345 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2346 &policy,
2347 &ShellEnforcement::environment_only().with_test_session_dirs(),
2348 )
2349 .unwrap();
2350
2351 assert_eq!(result.exit_code, 7, "the subprocess must still have run");
2352 assert_eq!(
2353 result.binary, "./local-tool",
2354 "an unresolvable name degrades to the bare invoked name, never an error"
2355 );
2356 }
2357
2358 #[test]
2359 fn build_shell_env_sets_home_to_the_home_folder() {
2360 let temp = tempdir().unwrap();
2361 let policy = CapabilityPolicy::default();
2362
2363 let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2364
2365 let expected_home = temp.path().to_string_lossy().into_owned();
2366 assert_eq!(env.get("HOME"), Some(&expected_home));
2367 assert_eq!(env.get("USERPROFILE"), Some(&expected_home));
2368 }
2369
2370 #[test]
2371 fn no_home_folder_build_shell_env_refuses() {
2372 let error =
2373 build_shell_env(&CapabilityPolicy::default(), &[], None, None, None, None).unwrap_err();
2374 assert_eq!(error, NO_HOME_FOLDER_REFUSAL);
2375 assert!(error.contains("home folder"), "{error}");
2376 }
2377
2378 #[test]
2382 fn no_home_folder_execute_shell_refuses_and_creates_nothing() {
2383 let parent = tempdir().unwrap();
2384 let workdir = parent.path().join("work");
2385 fs::create_dir(&workdir).unwrap();
2386 let runtime = tempdir().unwrap();
2387 let roots = crate::sandbox::test_runtime_dir(runtime.path());
2388 let without_home = crate::runtime_dir::RuntimeDirRoots {
2389 home: None,
2390 ..roots
2391 };
2392 let policy = CapabilityPolicy {
2393 shell_allow: vec!["bash".to_string()],
2394 ..CapabilityPolicy::default()
2395 };
2396 for enforcement in [
2397 ShellEnforcement::environment_only(),
2398 ShellEnforcement::environment_only().with_runtime_dir(without_home.clone(), None),
2399 ] {
2400 let error = execute_shell(
2401 "bash",
2402 &["-c", "touch ran"],
2403 &[],
2404 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
2405 &policy,
2406 &enforcement,
2407 )
2408 .unwrap_err();
2409 assert!(error.to_string().contains("no home folder"), "{error}");
2410 }
2411 assert_eq!(fs::read_dir(&workdir).unwrap().count(), 0);
2412 assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 1);
2413 }
2414
2415 #[test]
2416 fn execute_shell_reports_the_home_folder_not_real_host_home() {
2417 let temp = tempdir().unwrap();
2418 let runtime = tempdir().unwrap();
2419 let enforcement = ShellEnforcement::environment_only()
2420 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2421 let policy = CapabilityPolicy {
2422 shell_allow: vec!["bash".to_string()],
2423 ..CapabilityPolicy::default()
2424 };
2425
2426 let result = execute_shell(
2427 "bash",
2428 &["-c", "echo -n $HOME"],
2429 &[],
2430 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2431 &policy,
2432 &enforcement,
2433 )
2434 .unwrap();
2435
2436 let expected_home = runtime.path().join("records/home");
2437 assert_eq!(result.stdout, expected_home.to_string_lossy());
2438 if let Ok(real_home) = std::env::var("HOME") {
2439 assert_ne!(result.stdout, real_home);
2440 }
2441 }
2442
2443 #[test]
2444 fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
2445 let temp = tempdir().unwrap();
2446 let policy = CapabilityPolicy::default();
2447 let overrides = vec![
2448 ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
2449 ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
2450 ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
2451 ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
2452 ("SAFE_VAR".to_string(), "kept".to_string()),
2453 ];
2454
2455 let env =
2456 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2457
2458 assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
2459 assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
2460 assert!(!env.contains_key("STRIPE_API_KEY"));
2461 assert!(!env.contains_key("GITHUB_TOKEN"));
2462 assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
2463 }
2464
2465 #[test]
2466 fn declared_env_is_empty_without_declarations() {
2467 std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
2468 let policy = CapabilityPolicy::default();
2469
2470 let env = build_declared_env(&policy);
2471
2472 assert!(env.is_empty());
2473 }
2474
2475 #[test]
2478 fn declared_and_shell_env_never_carry_the_formation_lifeline() {
2479 use crate::lifeline::FORMATION_LIFELINE_ENV;
2480 let _guard = crate::formation::FORMATION_ENV_LOCK
2481 .lock()
2482 .unwrap_or_else(|poisoned| poisoned.into_inner());
2483 let temp = tempdir().unwrap();
2484 std::env::set_var(FORMATION_LIFELINE_ENV, "7");
2485 let policy = CapabilityPolicy {
2486 env_allow: vec![FORMATION_LIFELINE_ENV.to_string()],
2487 shell_baseline_env: vec![FORMATION_LIFELINE_ENV.to_string()],
2488 ..CapabilityPolicy::default()
2489 };
2490 let declared = build_declared_env(&policy);
2491 let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2492 std::env::remove_var(FORMATION_LIFELINE_ENV);
2493 assert!(
2494 !declared.contains_key(FORMATION_LIFELINE_ENV),
2495 "{declared:?}"
2496 );
2497 assert!(!shell.contains_key(FORMATION_LIFELINE_ENV), "{shell:?}");
2498 }
2499
2500 #[test]
2502 fn declared_and_shell_env_never_carry_the_spawner_lifeline() {
2503 use crate::lifeline::SPAWNER_LIFELINE_ENV;
2504 let _guard = crate::formation::FORMATION_ENV_LOCK
2505 .lock()
2506 .unwrap_or_else(|poisoned| poisoned.into_inner());
2507 let temp = tempdir().unwrap();
2508 std::env::set_var(SPAWNER_LIFELINE_ENV, "7");
2509 let policy = CapabilityPolicy {
2510 env_allow: vec![SPAWNER_LIFELINE_ENV.to_string()],
2511 shell_baseline_env: vec![SPAWNER_LIFELINE_ENV.to_string()],
2512 ..CapabilityPolicy::default()
2513 };
2514 let declared = build_declared_env(&policy);
2515 let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2516 std::env::remove_var(SPAWNER_LIFELINE_ENV);
2517 assert!(!declared.contains_key(SPAWNER_LIFELINE_ENV), "{declared:?}");
2518 assert!(!shell.contains_key(SPAWNER_LIFELINE_ENV), "{shell:?}");
2519 }
2520
2521 #[test]
2522 fn declared_env_passes_through_declared_host_var() {
2523 std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
2524 let policy = CapabilityPolicy {
2525 env_allow: vec![
2526 "MURMUR_TEST_WASI_ALLOWED".to_string(),
2527 "MURMUR_TEST_WASI_NEVER_SET".to_string(),
2529 ],
2530 ..CapabilityPolicy::default()
2531 };
2532
2533 let env = build_declared_env(&policy);
2534
2535 assert_eq!(
2536 env.get("MURMUR_TEST_WASI_ALLOWED"),
2537 Some(&"host-value".to_string())
2538 );
2539 assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
2540 }
2541
2542 #[test]
2543 fn declared_env_strips_credential_shaped_declarations() {
2544 std::env::set_var("GITHUB_TOKEN", "leaked-token");
2545 std::env::set_var("STRIPE_API_KEY", "leaked-key");
2546 std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
2547 let policy = CapabilityPolicy {
2548 env_allow: vec![
2549 "GITHUB_TOKEN".to_string(),
2550 "STRIPE_API_KEY".to_string(),
2551 "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
2552 ],
2553 shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
2554 ..CapabilityPolicy::default()
2555 };
2556
2557 let env = build_declared_env(&policy);
2558
2559 assert!(env.is_empty(), "expected all names stripped, got {env:?}");
2561 }
2562
2563 #[test]
2566 fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
2567 std::env::set_var("PRIVATE_KEY", "host-private-key");
2568 std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
2569 let policy = CapabilityPolicy {
2570 env_allow: vec![
2571 "PRIVATE_KEY".to_string(),
2572 "MURMUR_TEST_WASI_PLAIN".to_string(),
2573 ],
2574 ..CapabilityPolicy::default()
2575 };
2576
2577 let env = build_declared_env(&policy);
2578
2579 assert_eq!(
2580 env.get("PRIVATE_KEY"),
2581 Some(&"host-private-key".to_string())
2582 );
2583 assert_eq!(
2584 env.get("MURMUR_TEST_WASI_PLAIN"),
2585 Some(&"host-plain".to_string())
2586 );
2587 }
2588
2589 #[test]
2590 fn build_shell_env_home_override_cannot_survive() {
2591 let temp = tempdir().unwrap();
2592 let policy = CapabilityPolicy {
2593 shell_baseline_env: vec!["HOME".to_string()],
2594 ..CapabilityPolicy::default()
2595 };
2596 let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
2597
2598 let env =
2599 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2600
2601 assert_eq!(
2602 env.get("HOME"),
2603 Some(&temp.path().to_string_lossy().into_owned())
2604 );
2605 }
2606
2607 #[test]
2608 fn build_shell_env_keeps_safe_baseline_vars() {
2609 let temp = tempdir().unwrap();
2610 let policy = CapabilityPolicy::default();
2611
2612 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
2613 let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2614 std::env::remove_var("CARGO_HOME");
2615
2616 assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
2617 }
2618
2619 #[test]
2622 fn build_shell_env_carries_the_hosts_account_names() {
2623 let temp = tempdir().unwrap();
2624 let root = crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap();
2625 let _guard = ACCOUNT_ENV_LOCK
2626 .lock()
2627 .unwrap_or_else(|poisoned| poisoned.into_inner());
2628 std::env::set_var("USER", "murmur-test-user");
2629 std::env::set_var("LOGNAME", "murmur-test-login");
2630
2631 let env = build_shell_env(
2632 &CapabilityPolicy::default(),
2633 &[],
2634 Some(root.path()),
2635 None,
2636 None,
2637 None,
2638 )
2639 .unwrap();
2640 let stripped = build_shell_env(
2641 &CapabilityPolicy {
2642 shell_strip_env: vec!["LOGNAME".to_string()],
2643 ..CapabilityPolicy::default()
2644 },
2645 &[],
2646 Some(root.path()),
2647 None,
2648 None,
2649 None,
2650 )
2651 .unwrap();
2652
2653 assert_eq!(
2654 env.get("USER").map(String::as_str),
2655 Some("murmur-test-user")
2656 );
2657 assert_eq!(
2658 env.get("LOGNAME").map(String::as_str),
2659 Some("murmur-test-login")
2660 );
2661 assert_eq!(
2662 env.get("HOME"),
2663 Some(&temp.path().to_string_lossy().into_owned())
2664 );
2665 assert!(!stripped.contains_key("LOGNAME"), "{stripped:?}");
2666 assert_eq!(
2667 stripped.get("USER").map(String::as_str),
2668 Some("murmur-test-user")
2669 );
2670 let guest = build_declared_env(&CapabilityPolicy::default());
2671 assert!(
2672 !guest.contains_key("USER") && !guest.contains_key("LOGNAME"),
2673 "a WASM guest is handed only what env.allow declares: {guest:?}"
2674 );
2675 }
2676
2677 #[test]
2678 fn host_account_with_both_names_set_reports_none_unset() {
2679 let account = HostAccountEnv::from_pairs(&[("USER", "alice"), ("LOGNAME", "alice-login")]);
2680 assert!(account.unset().is_empty());
2681 let mut env = BTreeMap::new();
2682 account.apply(&mut env);
2683 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2684 assert_eq!(env.get("LOGNAME").map(String::as_str), Some("alice-login"));
2685 }
2686
2687 #[test]
2689 fn host_account_with_one_name_set_never_copies_it_to_the_other() {
2690 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2691 assert_eq!(account.unset(), ["LOGNAME".to_string()]);
2692 let mut env = BTreeMap::new();
2693 account.apply(&mut env);
2694 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2695 assert!(!env.contains_key("LOGNAME"), "{env:?}");
2696 }
2697
2698 #[test]
2699 fn host_account_with_neither_name_set_reports_both_sorted() {
2700 let account = HostAccountEnv::from_pairs(&[]);
2701 assert_eq!(account.unset(), ["LOGNAME".to_string(), "USER".to_string()]);
2702 let mut env = BTreeMap::new();
2703 account.apply(&mut env);
2704 assert!(env.is_empty(), "{env:?}");
2705 }
2706
2707 #[test]
2710 fn host_account_apply_replaces_a_stale_value() {
2711 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2712 let mut env = BTreeMap::from([
2713 ("USER".to_string(), "someone-else".to_string()),
2714 ("LOGNAME".to_string(), "someone-else".to_string()),
2715 ("PATH".to_string(), "/bin".to_string()),
2716 ]);
2717 account.apply(&mut env);
2718 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2719 assert!(!env.contains_key("LOGNAME"), "{env:?}");
2720 assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
2721 }
2722
2723 #[test]
2724 fn build_shell_env_developer_declared_strip_env_still_composes() {
2725 let temp = tempdir().unwrap();
2726 let policy = CapabilityPolicy {
2727 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
2728 ..CapabilityPolicy::default()
2729 };
2730 let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
2731
2732 let env =
2733 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2734
2735 assert!(!env.contains_key("MYCOMPANY_SECRET"));
2736 }
2737
2738 #[test]
2742 fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
2743 let extra = vec!["*_SERVICE_SECRET".to_string()];
2744 let names = [
2745 "ANTHROPIC_API_KEY",
2746 "GITHUB_TOKEN",
2747 "GITHUB_TOKEN_2",
2748 "AWS_ACCESS_KEY_ID",
2749 "MY_AWS_KEY",
2750 "STRIPE_API_KEY",
2751 "KUBECONFIG",
2752 "MY_SERVICE_SECRET",
2753 "DATABASE_PASSWORD",
2754 "SLACK_BOT_TOKEN",
2755 "HOME",
2756 "TZ",
2757 ];
2758
2759 let mut env: BTreeMap<String, String> = names
2760 .iter()
2761 .map(|name| ((*name).to_string(), "value".to_string()))
2762 .collect();
2763 strip_credential_shaped_vars(&mut env, &extra);
2764
2765 for name in names {
2766 assert_eq!(
2767 credential_backstop_drops(name, &extra),
2768 !env.contains_key(name),
2769 "prediction and strip disagree about {name}"
2770 );
2771 assert_eq!(
2772 credential_backstop_match(name, &extra).is_some(),
2773 !env.contains_key(name),
2774 "match and strip disagree about {name}"
2775 );
2776 }
2777 }
2778
2779 #[test]
2780 fn credential_backstop_match_attributes_a_builtin_pattern() {
2781 assert_eq!(
2782 credential_backstop_match("AWS_REGION", &[]),
2783 Some(BackstopMatch {
2784 pattern: "AWS_*".to_string(),
2785 source: BackstopPatternSource::Builtin,
2786 })
2787 );
2788 assert_eq!(
2789 credential_backstop_match("GITHUB_TOKEN", &[]),
2790 Some(BackstopMatch {
2791 pattern: "GITHUB_TOKEN".to_string(),
2792 source: BackstopPatternSource::Builtin,
2793 })
2794 );
2795 }
2796
2797 #[test]
2798 fn credential_backstop_match_attributes_a_strip_env_pattern() {
2799 assert_eq!(
2800 credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
2801 Some(BackstopMatch {
2802 pattern: "*_SERVICE_SECRET".to_string(),
2803 source: BackstopPatternSource::StripEnv,
2804 })
2805 );
2806 }
2807
2808 #[test]
2811 fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
2812 assert_eq!(
2813 credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
2814 Some(BackstopMatch {
2815 pattern: "*_API_KEY".to_string(),
2816 source: BackstopPatternSource::Builtin,
2817 })
2818 );
2819 }
2820
2821 #[test]
2822 fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
2823 assert_eq!(credential_backstop_match("TZ", &[]), None);
2824 assert_eq!(
2825 credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
2826 None
2827 );
2828 }
2829
2830 #[test]
2831 fn env_name_matches_pattern_trailing_wildcard() {
2832 assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
2833 assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
2834 }
2835
2836 #[test]
2837 fn env_name_matches_pattern_leading_wildcard() {
2838 assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
2839 assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
2840 }
2841
2842 #[test]
2843 fn env_name_matches_pattern_exact_match() {
2844 assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
2845 assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
2846 }
2847
2848 #[test]
2849 fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
2850 let yaml = shell_tool_manifest_yaml("bash");
2851 serde_norway::from_str::<serde_norway::Value>(&yaml)
2852 .expect("bash manifest must be valid YAML");
2853 }
2854
2855 #[test]
2856 fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
2857 let yaml = shell_tool_manifest_yaml("curl");
2858 serde_norway::from_str::<serde_norway::Value>(&yaml)
2859 .expect("curl manifest must be valid YAML — embedded quotes break YAML parsing");
2860 }
2861
2862 #[test]
2863 fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
2864 let yaml = shell_tool_manifest_yaml("curl");
2865 let value: serde_norway::Value = serde_norway::from_str(&yaml).unwrap();
2866 assert_eq!(
2867 value.get("runtime").and_then(|v| v.as_str()),
2868 Some("tool"),
2869 "non-interpreter manifest must have runtime: tool so inventory picks it up"
2870 );
2871 }
2872
2873 #[test]
2876 fn a_direct_binary_description_states_the_argument_rule_and_nothing_else() {
2877 for binary in ["curl", "git", "jq", "python3"] {
2878 let yaml = shell_tool_manifest_yaml(binary);
2879 let value: serde_norway::Value = serde_norway::from_str(&yaml)
2880 .unwrap_or_else(|e| panic!("{binary} manifest must be valid YAML: {e}"));
2881 let description = value
2882 .get("description")
2883 .and_then(|v| v.as_str())
2884 .expect("the manifest has a description");
2885 assert_eq!(
2886 description,
2887 format!(
2888 "Run {binary} in the capsule workdir. The `command` field holds {binary}'s \
2889 arguments and not the binary name. It is split into arguments at whitespace; \
2890 single or double quotes group words, and a backslash escapes the next \
2891 character. No shell runs it, so nothing in it is expanded."
2892 )
2893 );
2894 assert!(!description.contains("example.com"), "{description}");
2895 assert!(!description.contains(" -s "), "{description}");
2896 assert_eq!(yaml, shell_tool_manifest_yaml(binary));
2897 }
2898 }
2899
2900 #[test]
2903 fn an_abandoned_command_demotes_before_its_grace() {
2904 an_abandoned_command_demotes_under(Some(Duration::from_secs(60)));
2905 }
2906
2907 #[test]
2909 fn an_abandoned_command_demotes_with_no_grace() {
2910 an_abandoned_command_demotes_under(None);
2911 }
2912
2913 fn an_abandoned_command_demotes_under(grace: Option<Duration>) {
2914 use std::sync::Arc;
2915
2916 use crate::{
2917 detached::{AbandonSignal, DetachPolicy, DetachedRegistry},
2918 origin::{TaskOrigin, TaskProvenance},
2919 };
2920
2921 let temp = tempdir().unwrap();
2922 let runtime = tempdir().unwrap();
2923 let policy = CapabilityPolicy {
2924 shell_allow: vec!["bash".to_string()],
2925 ..CapabilityPolicy::default()
2926 };
2927 let (registry, _reports) = DetachedRegistry::new();
2928 let abandon = AbandonSignal::new();
2929 let raiser = {
2930 let abandon = abandon.clone();
2931 std::thread::spawn(move || {
2932 std::thread::sleep(Duration::from_millis(200));
2933 abandon.raise();
2934 Instant::now()
2935 })
2936 };
2937
2938 let started = Instant::now();
2939 let outcome = run_shell(
2940 "bash",
2941 &["-c", "sleep 5"],
2944 &[],
2945 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2946 &policy,
2947 &ShellEnforcement::environment_only()
2948 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2949 Some(DetachPolicy {
2950 grace,
2951 delivered: true,
2952 registry: Arc::clone(®istry),
2953 command: "sleep 5".to_string(),
2954 context_id: "ctx_abandon_test".to_string(),
2955 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
2956 abandoned: Some(abandon),
2957 canceled: None,
2958 }),
2959 )
2960 .expect("a declared binary runs");
2961 let returned = Instant::now();
2962 let elapsed = returned - started;
2963 let raised = raiser.join().unwrap();
2964
2965 let ShellOutcome::Detached(info) = outcome else {
2966 panic!("an abandoned command must demote, got {outcome:?}");
2967 };
2968 assert!(
2969 elapsed < Duration::from_secs(2),
2970 "demotion waited {elapsed:?}, as if the command had to finish first"
2971 );
2972 assert!(
2973 returned.saturating_duration_since(raised) < Duration::from_secs(1),
2974 "demotion came {:?} after the abandon",
2975 returned.saturating_duration_since(raised)
2976 );
2977 assert_eq!(
2978 info.grace_ms,
2979 grace.map(|grace| u64::try_from(grace.as_millis()).unwrap())
2980 );
2981 assert!(
2982 registry
2983 .outstanding()
2984 .iter()
2985 .any(|work| work.work_id == info.work_id),
2986 "the demoted command is registered like one that outran its grace"
2987 );
2988 }
2989
2990 fn cancel_test_policy(
2992 registry: &std::sync::Arc<crate::detached::DetachedRegistry>,
2993 command: &str,
2994 canceled: crate::cancel::CancelSignal,
2995 grace: Option<Duration>,
2996 ) -> crate::detached::DetachPolicy {
2997 use crate::origin::{TaskOrigin, TaskProvenance};
2998 crate::detached::DetachPolicy {
2999 grace,
3000 delivered: true,
3001 registry: std::sync::Arc::clone(registry),
3002 command: command.to_string(),
3003 context_id: "ctx_cancel_test".to_string(),
3004 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3005 abandoned: None,
3006 canceled: Some(canceled),
3007 }
3008 }
3009
3010 #[test]
3013 fn a_canceled_task_demotes_its_foreground_command() {
3014 a_canceled_task_demotes_its_foreground_command_under(Some(Duration::from_secs(60)));
3015 }
3016
3017 #[test]
3019 fn a_canceled_task_demotes_its_foreground_command_with_no_grace() {
3020 a_canceled_task_demotes_its_foreground_command_under(None);
3021 }
3022
3023 fn a_canceled_task_demotes_its_foreground_command_under(grace: Option<Duration>) {
3024 use crate::{
3025 cancel::{CancelSignal, PHASE_SHELL},
3026 detached::{DetachedRegistry, KillRunningOnDrop},
3027 };
3028
3029 let temp = tempdir().unwrap();
3030 let runtime = tempdir().unwrap();
3031 let policy = CapabilityPolicy {
3032 shell_allow: vec!["bash".to_string()],
3033 ..CapabilityPolicy::default()
3034 };
3035 let (registry, _reports) = DetachedRegistry::new();
3036 let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
3037 let cancel = CancelSignal::new();
3038 let raiser = {
3039 let cancel = cancel.clone();
3040 std::thread::spawn(move || {
3041 std::thread::sleep(Duration::from_millis(200));
3042 cancel.cancel();
3043 Instant::now()
3044 })
3045 };
3046
3047 let started = Instant::now();
3048 let outcome = run_shell(
3049 "bash",
3050 &["-c", "sleep 30"],
3051 &[],
3052 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3053 &policy,
3054 &ShellEnforcement::environment_only()
3055 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3056 Some(cancel_test_policy(
3057 ®istry,
3058 "sleep 30",
3059 cancel.clone(),
3060 grace,
3061 )),
3062 )
3063 .expect("a declared binary runs");
3064 let returned = Instant::now();
3065 let elapsed = returned - started;
3066 let raised = raiser.join().unwrap();
3067
3068 let ShellOutcome::Detached(info) = outcome else {
3069 panic!("a canceled task's command must demote, got {outcome:?}");
3070 };
3071 assert!(
3072 elapsed < Duration::from_secs(2),
3073 "demotion waited {elapsed:?}, as if the command had to finish first"
3074 );
3075 assert!(
3076 returned.saturating_duration_since(raised) < Duration::from_secs(1),
3077 "demotion came {:?} after the cancel",
3078 returned.saturating_duration_since(raised)
3079 );
3080 assert!(
3081 registry
3082 .outstanding()
3083 .iter()
3084 .any(|work| work.work_id == info.work_id),
3085 "the demoted command is registered, so the cancel residue names it"
3086 );
3087 assert_eq!(cancel.phase(), PHASE_SHELL);
3088 }
3089
3090 #[test]
3093 fn an_earlier_claim_keeps_the_phase_over_the_shell() {
3094 use crate::{
3095 cancel::{CancelSignal, PHASE_INFERENCE},
3096 detached::{DetachedRegistry, KillRunningOnDrop},
3097 };
3098
3099 let temp = tempdir().unwrap();
3100 let runtime = tempdir().unwrap();
3101 let policy = CapabilityPolicy {
3102 shell_allow: vec!["bash".to_string()],
3103 ..CapabilityPolicy::default()
3104 };
3105 let (registry, _reports) = DetachedRegistry::new();
3106 let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
3107 let cancel = CancelSignal::new();
3108 cancel.note_phase(PHASE_INFERENCE);
3109 let raiser = {
3110 let cancel = cancel.clone();
3111 std::thread::spawn(move || {
3112 std::thread::sleep(Duration::from_millis(200));
3113 cancel.cancel();
3114 })
3115 };
3116
3117 let outcome = run_shell(
3118 "bash",
3119 &["-c", "sleep 30"],
3120 &[],
3121 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3122 &policy,
3123 &ShellEnforcement::environment_only()
3124 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3125 Some(cancel_test_policy(
3126 ®istry,
3127 "sleep 30",
3128 cancel.clone(),
3129 Some(Duration::from_secs(60)),
3130 )),
3131 )
3132 .expect("a declared binary runs");
3133 raiser.join().unwrap();
3134
3135 assert!(matches!(outcome, ShellOutcome::Detached(_)), "{outcome:?}");
3136 assert_eq!(cancel.phase(), PHASE_INFERENCE);
3137 }
3138
3139 #[test]
3141 fn a_canceled_task_starts_no_command() {
3142 a_canceled_task_starts_no_command_under(Some(Duration::from_secs(60)));
3143 }
3144
3145 #[test]
3147 fn a_canceled_task_starts_no_command_with_no_grace() {
3148 a_canceled_task_starts_no_command_under(None);
3149 }
3150
3151 fn a_canceled_task_starts_no_command_under(grace: Option<Duration>) {
3152 use crate::{cancel::CancelSignal, detached::DetachedRegistry};
3153
3154 let temp = tempdir().unwrap();
3155 let runtime = tempdir().unwrap();
3156 let policy = CapabilityPolicy {
3157 shell_allow: vec!["bash".to_string()],
3158 ..CapabilityPolicy::default()
3159 };
3160 let (registry, _reports) = DetachedRegistry::new();
3161 let cancel = CancelSignal::new();
3162 cancel.cancel();
3163
3164 let result = run_shell(
3165 "bash",
3166 &["-c", "touch started"],
3167 &[],
3168 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3169 &policy,
3170 &ShellEnforcement::environment_only()
3171 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3172 Some(cancel_test_policy(
3173 ®istry,
3174 "touch started",
3175 cancel.clone(),
3176 grace,
3177 )),
3178 );
3179
3180 match result {
3181 Err(ShellExecError::Failed(message)) => assert_eq!(message, CANCELED_BEFORE_SPAWN),
3182 other => panic!("a canceled task's call must be refused, got {other:?}"),
3183 }
3184 assert!(!temp.path().join("started").exists(), "nothing was spawned");
3185 assert!(registry.outstanding().is_empty(), "nothing was registered");
3186 }
3187
3188 fn run_until_stopped(
3191 workdir: &std::path::Path,
3192 runtime: &std::path::Path,
3193 script: &str,
3194 stop: ForegroundStop,
3195 ) -> ForegroundRun {
3196 let mut enforcement = ShellEnforcement::environment_only()
3197 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime), None);
3198 enforcement.resource_limits.max_processes = 1 << 20;
3202 run_shell_until_stopped(
3203 "bash",
3204 &["-c", script],
3205 &[],
3206 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
3207 &CapabilityPolicy {
3208 shell_allow: vec!["bash".to_string()],
3209 ..CapabilityPolicy::default()
3210 },
3211 &enforcement,
3212 stop,
3213 )
3214 .expect("a declared binary runs")
3215 }
3216
3217 const STOPPABLE_SCRIPT: &str = "sleep 300 & echo $! > sleeper.pid; echo started; sleep 300";
3218
3219 #[test]
3222 fn a_foreground_stop_kills_the_group_on_cancel() {
3223 let temp = tempdir().unwrap();
3224 let runtime = tempdir().unwrap();
3225 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3226 let cancel = crate::cancel::CancelSignal::new();
3227 let raiser = {
3228 let cancel = cancel.clone();
3229 let pid_file = temp.path().join("sleeper.pid");
3230 std::thread::spawn(move || {
3231 while !pid_file.exists() {
3232 std::thread::sleep(Duration::from_millis(10));
3233 }
3234 std::thread::sleep(Duration::from_millis(100));
3235 cancel.cancel();
3236 Instant::now()
3237 })
3238 };
3239
3240 let run = run_until_stopped(
3241 temp.path(),
3242 runtime.path(),
3243 STOPPABLE_SCRIPT,
3244 ForegroundStop {
3245 canceled: cancel,
3246 timeout: Duration::from_secs(60),
3247 },
3248 );
3249 let raised = raiser.join().unwrap();
3250
3251 assert!(
3252 raised.elapsed() < Duration::from_secs(1),
3253 "the call returned {:?} after the cancel",
3254 raised.elapsed()
3255 );
3256 assert_eq!(run.stopped, Some(ForegroundStopReason::Canceled));
3257 assert!(run.result.stdout.contains("started"), "{:?}", run.result);
3258 assert_eq!(run.result.resource_limit_hit, None);
3259 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3260 assert!(
3261 process_dead_within(sleeper, Duration::from_secs(1)),
3262 "the background sleeper {sleeper} outlived the stop"
3263 );
3264 }
3265
3266 #[test]
3268 fn a_foreground_stop_kills_the_group_at_the_timeout() {
3269 let temp = tempdir().unwrap();
3270 let runtime = tempdir().unwrap();
3271 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3272
3273 let started = Instant::now();
3274 let run = run_until_stopped(
3275 temp.path(),
3276 runtime.path(),
3277 STOPPABLE_SCRIPT,
3278 ForegroundStop {
3279 canceled: crate::cancel::CancelSignal::new(),
3280 timeout: Duration::from_secs(1),
3281 },
3282 );
3283 let elapsed = started.elapsed();
3284
3285 assert!(
3286 elapsed >= Duration::from_secs(1) && elapsed < Duration::from_secs(2),
3287 "the call returned after {elapsed:?} for a 1 s timeout"
3288 );
3289 assert_eq!(run.stopped, Some(ForegroundStopReason::TimedOut));
3290 assert!(run.result.stdout.contains("started"), "{:?}", run.result);
3291 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3292 assert!(
3293 process_dead_within(sleeper, Duration::from_secs(1)),
3294 "the background sleeper {sleeper} outlived the timeout"
3295 );
3296 }
3297
3298 #[test]
3300 fn a_foreground_stop_leaves_a_finished_command_alone() {
3301 let temp = tempdir().unwrap();
3302 let runtime = tempdir().unwrap();
3303 let run = run_until_stopped(
3304 temp.path(),
3305 runtime.path(),
3306 "echo out; echo err >&2; exit 3",
3307 ForegroundStop {
3308 canceled: crate::cancel::CancelSignal::new(),
3309 timeout: Duration::from_secs(60),
3310 },
3311 );
3312 assert_eq!(run.stopped, None);
3313 assert_eq!(run.result.exit_code, 3);
3314 assert_eq!(run.result.stdout, "out\n");
3315 assert_eq!(run.result.stderr, "err\n");
3316 }
3317
3318 fn returns_within_30_s<T: Send + 'static>(run: impl FnOnce() -> T + Send + 'static) -> T {
3320 let (sender, receiver) = std::sync::mpsc::channel();
3321 std::thread::spawn(move || {
3322 let _ = sender.send(run());
3323 });
3324 receiver
3325 .recv_timeout(Duration::from_secs(30))
3326 .unwrap_or_else(|_| panic!("the shell call did not return within 30 s"))
3327 }
3328
3329 #[test]
3332 fn a_background_holder_does_not_hold_a_graced_call() {
3333 use std::sync::Arc;
3334
3335 use crate::{
3336 detached::{DetachPolicy, DetachedRegistry},
3337 origin::{TaskOrigin, TaskProvenance},
3338 };
3339
3340 let temp = tempdir().unwrap();
3341 let runtime = tempdir().unwrap();
3342 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3343 let workdir = temp.path().to_path_buf();
3344 let runtime_base = runtime.path().to_path_buf();
3345 let script = "sleep 300 & echo $! > sleeper.pid; echo started";
3346
3347 let started = Instant::now();
3348 let outcome = returns_within_30_s(move || {
3349 let (registry, _reports) = DetachedRegistry::new();
3350 run_shell(
3351 "bash",
3352 &["-c", script],
3353 &[],
3354 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3355 &CapabilityPolicy {
3356 shell_allow: vec!["bash".to_string()],
3357 ..CapabilityPolicy::default()
3358 },
3359 &ShellEnforcement::environment_only()
3360 .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3361 Some(DetachPolicy {
3362 grace: Some(Duration::from_secs(5)),
3363 delivered: true,
3364 registry: Arc::clone(®istry),
3365 command: script.to_string(),
3366 context_id: "ctx_holder_test".to_string(),
3367 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3368 abandoned: None,
3369 canceled: None,
3370 }),
3371 )
3372 .expect("a declared binary runs")
3373 });
3374 let elapsed = started.elapsed();
3375
3376 let ShellOutcome::Finished(result) = outcome else {
3377 panic!("a command that exits at once must finish, got {outcome:?}");
3378 };
3379 assert!(
3380 elapsed < Duration::from_secs(5),
3381 "the call took {elapsed:?}, past its 5 s grace"
3382 );
3383 assert!(result.stdout.contains("started"), "{result:?}");
3384 assert_eq!(result.exit_code, 0, "{result:?}");
3385 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3386 assert!(
3387 process_dead_within(sleeper, Duration::from_secs(2)),
3388 "the background sleeper {sleeper} is still running"
3389 );
3390 }
3391
3392 #[test]
3395 fn a_background_holder_does_not_hold_an_ungraced_call() {
3396 let temp = tempdir().unwrap();
3397 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3398 let workdir = temp.path().to_path_buf();
3399
3400 let started = Instant::now();
3401 let result = returns_within_30_s(move || {
3402 execute_shell(
3403 "bash",
3404 &["-c", "sleep 300 & echo $! > sleeper.pid; echo started"],
3405 &[],
3406 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3407 &CapabilityPolicy {
3408 shell_allow: vec!["bash".to_string()],
3409 ..CapabilityPolicy::default()
3410 },
3411 &ShellEnforcement::environment_only().with_test_session_dirs(),
3412 )
3413 .expect("a declared binary runs")
3414 });
3415 let elapsed = started.elapsed();
3416
3417 assert!(
3418 elapsed < Duration::from_secs(10),
3419 "the call took {elapsed:?}"
3420 );
3421 assert!(result.stdout.contains("started"), "{result:?}");
3422 assert_eq!(result.exit_code, 0, "{result:?}");
3423 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3424 assert!(
3425 process_dead_within(sleeper, Duration::from_secs(2)),
3426 "the background sleeper {sleeper} is still running"
3427 );
3428 assert_eq!(result.output_holders, OutputHolders::Killed);
3429 }
3430
3431 #[test]
3434 fn output_written_just_after_exit_is_kept() {
3435 use std::sync::Arc;
3436
3437 use crate::{
3438 detached::{DetachPolicy, DetachedRegistry},
3439 origin::{TaskOrigin, TaskProvenance},
3440 };
3441
3442 let temp = tempdir().unwrap();
3443 let runtime = tempdir().unwrap();
3444 let (registry, _reports) = DetachedRegistry::new();
3445 let script = "(sleep 0.3; echo late) & echo early";
3446
3447 let outcome = run_shell(
3448 "bash",
3449 &["-c", script],
3450 &[],
3451 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3452 &CapabilityPolicy {
3453 shell_allow: vec!["bash".to_string()],
3454 ..CapabilityPolicy::default()
3455 },
3456 &ShellEnforcement::environment_only()
3457 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3458 Some(DetachPolicy {
3459 grace: Some(Duration::from_secs(5)),
3460 delivered: true,
3461 registry: Arc::clone(®istry),
3462 command: script.to_string(),
3463 context_id: "ctx_late_output_test".to_string(),
3464 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3465 abandoned: None,
3466 canceled: None,
3467 }),
3468 )
3469 .expect("a declared binary runs");
3470
3471 let ShellOutcome::Finished(result) = outcome else {
3472 panic!("a command that exits at once must finish, got {outcome:?}");
3473 };
3474 assert!(result.stdout.contains("early"), "{result:?}");
3475 assert!(result.stdout.contains("late"), "{result:?}");
3476 assert_eq!(result.output_holders, OutputHolders::Closed);
3477 assert!(OutputHolders::Closed.note().is_none());
3478 }
3479
3480 #[cfg(target_os = "linux")]
3483 #[test]
3484 fn a_holder_outside_the_process_group_is_reported_not_waited_for() {
3485 use std::sync::Arc;
3486
3487 use crate::{
3488 detached::{DetachPolicy, DetachedRegistry},
3489 origin::{TaskOrigin, TaskProvenance},
3490 };
3491
3492 let temp = tempdir().unwrap();
3493 let runtime = tempdir().unwrap();
3494 let pid_file = temp.path().join("escaped.pid");
3495 let escaped_guard = KillPidFileOnDrop(pid_file.clone());
3496 let workdir = temp.path().to_path_buf();
3497 let runtime_base = runtime.path().to_path_buf();
3498 let script = "setsid sleep 300 & echo $! > escaped.pid; echo started";
3499
3500 let started = Instant::now();
3501 let outcome = returns_within_30_s(move || {
3502 let (registry, _reports) = DetachedRegistry::new();
3503 run_shell(
3504 "bash",
3505 &["-c", script],
3506 &[],
3507 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3508 &CapabilityPolicy {
3509 shell_allow: vec!["bash".to_string()],
3510 ..CapabilityPolicy::default()
3511 },
3512 &ShellEnforcement::environment_only()
3513 .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3514 Some(DetachPolicy {
3515 grace: Some(Duration::from_secs(5)),
3516 delivered: true,
3517 registry: Arc::clone(®istry),
3518 command: script.to_string(),
3519 context_id: "ctx_escaped_test".to_string(),
3520 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3521 abandoned: None,
3522 canceled: None,
3523 }),
3524 )
3525 .expect("a declared binary runs")
3526 });
3527 let elapsed = started.elapsed();
3528
3529 let ShellOutcome::Finished(result) = outcome else {
3530 panic!("a command that exits at once must finish, got {outcome:?}");
3531 };
3532 let bound = Duration::from_secs(5) + LEFTOVER_KILL_WAIT + Duration::from_secs(2);
3533 assert!(elapsed < bound, "the call took {elapsed:?}, past {bound:?}");
3534 assert_eq!(result.stdout.trim(), "started", "{result:?}");
3535 assert_eq!(result.output_holders, OutputHolders::Escaped);
3536 let escaped = read_pid(&pid_file);
3537 assert!(
3538 !process_dead_within(escaped, Duration::ZERO),
3539 "a holder outside the process group is not the call's to kill"
3540 );
3541 drop(escaped_guard);
3542 assert!(process_dead_within(escaped, Duration::from_secs(2)));
3543 }
3544
3545 #[test]
3546 fn a_shared_window_attributes_no_cgroup_delta() {
3547 assert_eq!(
3548 attribute_resource_limit(None, Some("cgroup_memory_bytes"), true).as_deref(),
3549 Some("cgroup_memory_bytes"),
3550 "a call alone in the scope owns its delta"
3551 );
3552 assert_eq!(
3553 attribute_resource_limit(None, Some("cgroup_memory_bytes"), false),
3554 None,
3555 "a delta from a shared window could be a sibling's"
3556 );
3557 assert_eq!(
3558 attribute_resource_limit(Some("cpu_seconds"), Some("cgroup_memory_bytes"), false)
3559 .as_deref(),
3560 Some("cpu_seconds"),
3561 "the signal is per-process evidence and needs no exclusive window"
3562 );
3563 assert_eq!(attribute_resource_limit(None, None, true), None);
3564 }
3565
3566 #[test]
3570 fn build_shell_env_sets_the_runtime_dir_last_whatever_else_says() {
3571 let workdir = tempfile::tempdir().unwrap();
3572 let root = WorkdirRoot::open(workdir.path()).unwrap();
3573 std::env::set_var(crate::runtime_dir::RUNTIME_DIR_ENV, "/host/value");
3574 let policy = CapabilityPolicy {
3575 shell_baseline_env: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3576 env_allow: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3577 ..CapabilityPolicy::default()
3578 };
3579 let overrides = [(
3580 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
3581 "/override".to_string(),
3582 )];
3583
3584 let env = build_shell_env(
3585 &policy,
3586 &overrides,
3587 Some(root.path()),
3588 Some(std::path::Path::new("/runtime/dir")),
3589 None,
3590 None,
3591 )
3592 .unwrap();
3593 assert_eq!(
3594 env.get(crate::runtime_dir::RUNTIME_DIR_ENV)
3595 .map(String::as_str),
3596 Some("/runtime/dir")
3597 );
3598
3599 let without =
3600 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3601 assert!(!without.contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3602 assert!(!build_declared_env(&policy).contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3603 }
3604
3605 #[test]
3609 fn build_unconfined_env_inherits_everything_but_the_lineage_names() {
3610 let get = |env: &BTreeMap<OsString, OsString>, name: &str| {
3611 env.get(std::ffi::OsStr::new(name))
3612 .map(|value| value.to_string_lossy().into_owned())
3613 };
3614 let removed: Vec<&str> = LINEAGE_ENV
3615 .iter()
3616 .copied()
3617 .chain([
3618 crate::formation::FORMATION_PEERS_ENV,
3619 crate::formation_credentials::FORMATION_CHANNEL_ENV,
3620 crate::lifeline::FORMATION_LIFELINE_ENV,
3621 ARTIFACT_CONFIG_ENV,
3622 ])
3623 .collect();
3624 let host: BTreeMap<OsString, OsString> = removed
3627 .iter()
3628 .map(|name| (*name, "host-value"))
3629 .chain([
3630 ("GITHUB_TOKEN", "unconfined-sentinel"),
3631 ("MURMUR_TEST_UNCONFINED", "present"),
3632 ("MURMUR_TEST_STRIPPED", "gone"),
3633 ("HOME", "/home/operator"),
3634 ("TMPDIR", "/var/tmp/operator"),
3635 (crate::runtime_dir::RUNTIME_DIR_ENV, "/host/runtime"),
3636 (crate::records_dir::RECORDS_DIR_ENV, "/host/records"),
3637 ])
3638 .map(|(name, value)| (OsString::from(name), OsString::from(value)))
3639 .collect();
3640 let policy = CapabilityPolicy {
3641 shell_strip_env: vec!["MURMUR_TEST_STRIP*".to_string()],
3642 ..CapabilityPolicy::default()
3643 };
3644 let overrides = [
3645 (
3646 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
3647 "/override".to_string(),
3648 ),
3649 (
3650 crate::delegation::SPAWNER_ENV.to_string(),
3651 "override".to_string(),
3652 ),
3653 ("GUEST_SET".to_string(), "yes".to_string()),
3654 ];
3655
3656 let env = build_unconfined_env(
3657 &policy,
3658 host.clone(),
3659 &overrides,
3660 Some(std::path::Path::new("/session/runtime")),
3661 Some(std::path::Path::new("/session/records")),
3662 );
3663 for name in &removed {
3664 assert_eq!(
3665 get(&env, name),
3666 None,
3667 "{name} reached an unconfined command"
3668 );
3669 }
3670 assert_eq!(
3671 get(&env, "GITHUB_TOKEN").as_deref(),
3672 Some("unconfined-sentinel")
3673 );
3674 assert_eq!(
3675 get(&env, "MURMUR_TEST_UNCONFINED").as_deref(),
3676 Some("present")
3677 );
3678 assert_eq!(get(&env, "MURMUR_TEST_STRIPPED"), None);
3679 assert_eq!(get(&env, "HOME").as_deref(), Some("/home/operator"));
3680 assert_eq!(get(&env, "TMPDIR").as_deref(), Some("/var/tmp/operator"));
3681 assert_eq!(get(&env, "GUEST_SET").as_deref(), Some("yes"));
3682 assert_eq!(
3683 get(&env, crate::runtime_dir::RUNTIME_DIR_ENV).as_deref(),
3684 Some("/session/runtime")
3685 );
3686 assert_eq!(
3687 get(&env, crate::records_dir::RECORDS_DIR_ENV).as_deref(),
3688 Some("/session/records")
3689 );
3690 let without = build_unconfined_env(&policy, host, &overrides, None, None);
3692 assert_eq!(get(&without, crate::runtime_dir::RUNTIME_DIR_ENV), None);
3693 assert_eq!(get(&without, crate::records_dir::RECORDS_DIR_ENV), None);
3694 }
3695
3696 #[test]
3700 fn subprocess_env_follows_the_enforcement_tier() {
3701 let policy = CapabilityPolicy::default();
3702 let contained =
3703 crate::sandbox::ShellEnforcement::environment_only().with_test_session_dirs();
3704 let unconfined = crate::sandbox::ShellEnforcement {
3705 tier: crate::sandbox::EnforcementTier::Unconfined,
3706 ..contained.clone()
3707 };
3708 let session_home = contained.home_dir_path().unwrap().as_os_str().to_owned();
3709 let home = std::ffi::OsStr::new("HOME");
3710 assert_eq!(
3711 subprocess_env(&policy, &[], &contained).unwrap().get(home),
3712 Some(&session_home)
3713 );
3714 let unconfined_home = subprocess_env(&policy, &[], &unconfined)
3715 .unwrap()
3716 .get(home)
3717 .cloned();
3718 assert_eq!(unconfined_home, std::env::var_os("HOME"));
3719 assert_ne!(unconfined_home, Some(session_home));
3720 }
3721
3722 #[test]
3726 fn build_shell_env_sets_the_records_dir_last_whatever_else_says() {
3727 let workdir = tempfile::tempdir().unwrap();
3728 let root = WorkdirRoot::open(workdir.path()).unwrap();
3729 std::env::set_var(crate::records_dir::RECORDS_DIR_ENV, "/host/records");
3730 let policy = CapabilityPolicy {
3731 shell_baseline_env: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3732 env_allow: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3733 ..CapabilityPolicy::default()
3734 };
3735 let overrides = [(
3736 crate::records_dir::RECORDS_DIR_ENV.to_string(),
3737 "/override".to_string(),
3738 )];
3739
3740 let env = build_shell_env(
3741 &policy,
3742 &overrides,
3743 Some(root.path()),
3744 None,
3745 Some(std::path::Path::new("/records/dir")),
3746 None,
3747 )
3748 .unwrap();
3749 assert_eq!(
3750 env.get(crate::records_dir::RECORDS_DIR_ENV)
3751 .map(String::as_str),
3752 Some("/records/dir")
3753 );
3754
3755 let without =
3756 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3757 assert!(!without.contains_key(crate::records_dir::RECORDS_DIR_ENV));
3758 assert!(!build_declared_env(&policy).contains_key(crate::records_dir::RECORDS_DIR_ENV));
3759 }
3760
3761 #[test]
3765 fn build_shell_env_drops_the_artifact_config_name_whatever_else_says() {
3766 let workdir = tempfile::tempdir().unwrap();
3767 let root = WorkdirRoot::open(workdir.path()).unwrap();
3768 let names = [ARTIFACT_CONFIG_ENV];
3769 for name in names {
3770 std::env::set_var(name, "/host/value");
3771 }
3772 let policy = CapabilityPolicy {
3773 shell_baseline_env: names.iter().map(|name| name.to_string()).collect(),
3774 env_allow: names.iter().map(|name| name.to_string()).collect(),
3775 ..CapabilityPolicy::default()
3776 };
3777 let overrides: Vec<(String, String)> = names
3778 .iter()
3779 .map(|name| (name.to_string(), "/override".to_string()))
3780 .collect();
3781
3782 let env =
3783 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3784 let declared = build_declared_env(&policy);
3785 for name in names {
3786 std::env::remove_var(name);
3787 }
3788 for name in names {
3789 assert!(!env.contains_key(name), "{name}: {env:?}");
3790 assert!(!declared.contains_key(name), "{name}: {declared:?}");
3791 }
3792 }
3793
3794 #[test]
3798 fn build_shell_env_owns_tmpdir_tmp_and_temp() {
3799 use crate::temp_dir::{NotMappedReason, TempMapping, TEMP_DIR_ENV};
3800
3801 let workdir = tempfile::tempdir().unwrap();
3802 let root = WorkdirRoot::open(workdir.path()).unwrap();
3803 let base = tempfile::tempdir().unwrap();
3804 let roots = crate::sandbox::test_runtime_dir(base.path());
3805 let folder = roots.temp.as_ref().unwrap().path().to_path_buf();
3806 let host_tmp = std::env::temp_dir().join("murmur-host-tmp");
3809 std::fs::create_dir_all(&host_tmp).unwrap();
3810 let saved: Vec<_> = TEMP_DIR_ENV
3811 .iter()
3812 .map(|name| (*name, std::env::var_os(name)))
3813 .collect();
3814 for name in TEMP_DIR_ENV {
3815 std::env::set_var(name, &host_tmp);
3816 }
3817 let policy = CapabilityPolicy {
3818 shell_baseline_env: TEMP_DIR_ENV.iter().map(|name| name.to_string()).collect(),
3819 ..CapabilityPolicy::default()
3820 };
3821 let overrides: Vec<(String, String)> = TEMP_DIR_ENV
3822 .iter()
3823 .map(|name| (name.to_string(), "/override".to_string()))
3824 .collect();
3825
3826 let held = |tier, mapping| {
3827 crate::sandbox::ShellEnforcement {
3828 tier,
3829 ..crate::sandbox::ShellEnforcement::environment_only()
3830 }
3831 .with_runtime_dir(roots.clone(), None)
3832 .with_temp_mapping(mapping)
3833 };
3834 let folder_text = folder.to_string_lossy().into_owned();
3835 let cases = [
3836 (
3837 held(
3838 crate::sandbox::EnforcementTier::KernelFull,
3839 TempMapping::MountNamespace,
3840 ),
3841 Some("/tmp".to_string()),
3842 ),
3843 (
3844 held(
3845 crate::sandbox::EnforcementTier::KernelSealed,
3846 TempMapping::ComposedRoot,
3847 ),
3848 Some("/tmp".to_string()),
3849 ),
3850 (
3851 held(
3852 crate::sandbox::EnforcementTier::KernelFull,
3853 TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
3854 ),
3855 Some(folder_text.clone()),
3856 ),
3857 (
3858 held(
3859 crate::sandbox::EnforcementTier::KernelSeccompOnly,
3860 TempMapping::NotMapped(NotMappedReason::NoMountNamespaceNoLandlock),
3861 ),
3862 Some(folder_text.clone()),
3863 ),
3864 (
3865 held(
3866 crate::sandbox::EnforcementTier::KernelFull,
3867 TempMapping::NotMapped(NotMappedReason::WorkdirIsTmp),
3868 ),
3869 Some(folder_text.clone()),
3870 ),
3871 (
3872 held(
3873 crate::sandbox::EnforcementTier::EnvironmentOnly,
3874 TempMapping::NotMapped(NotMappedReason::NoKernelContainment),
3875 ),
3876 Some(folder_text.clone()),
3877 ),
3878 (crate::sandbox::ShellEnforcement::environment_only(), None),
3879 ];
3880 for (enforcement, expected) in cases {
3881 assert_eq!(
3882 enforcement.tmpdir_value(),
3883 expected,
3884 "{:?}",
3885 enforcement.tier
3886 );
3887 let env = build_shell_env(
3888 &policy,
3889 &overrides,
3890 Some(root.path()),
3891 None,
3892 None,
3893 enforcement.tmpdir_value().as_deref(),
3894 )
3895 .unwrap();
3896 for name in TEMP_DIR_ENV {
3897 assert_eq!(
3898 env.get(name),
3899 expected.as_ref(),
3900 "{name} on {:?}",
3901 enforcement.tier
3902 );
3903 }
3904 }
3905 for (name, value) in saved {
3906 match value {
3907 Some(value) => std::env::set_var(name, value),
3908 None => std::env::remove_var(name),
3909 }
3910 }
3911 }
3912
3913 #[test]
3915 fn the_env_baseline_names_no_temp_variable() {
3916 for name in crate::temp_dir::TEMP_DIR_ENV {
3917 assert!(!DEFAULT_ENV_BASELINE.contains(&name), "{name}");
3918 }
3919 }
3920}
3921
3922#[cfg(test)]
3924pub(crate) mod test_support {
3925 use std::path::{Path, PathBuf};
3926 use std::time::{Duration, Instant};
3927
3928 pub(crate) fn read_pid(pid_file: &Path) -> u32 {
3930 std::fs::read_to_string(pid_file)
3931 .expect("the command wrote its pid file")
3932 .trim()
3933 .parse()
3934 .expect("the pid file holds a pid")
3935 }
3936
3937 pub(crate) fn process_dead_within(pid: u32, timeout: Duration) -> bool {
3940 let deadline = Instant::now() + timeout;
3941 loop {
3942 if !crate::running::pid_is_alive(pid) {
3943 return true;
3944 }
3945 if Instant::now() >= deadline {
3946 return false;
3947 }
3948 std::thread::sleep(Duration::from_millis(20));
3949 }
3950 }
3951
3952 pub(crate) struct KillPidFileOnDrop(pub(crate) PathBuf);
3956
3957 impl Drop for KillPidFileOnDrop {
3958 fn drop(&mut self) {
3959 let Some(pid) = std::fs::read_to_string(&self.0)
3960 .ok()
3961 .and_then(|text| text.trim().parse::<libc::pid_t>().ok())
3962 .filter(|pid| *pid > 0)
3963 else {
3964 return;
3965 };
3966 if !process_dead_within(pid.unsigned_abs(), Duration::ZERO) {
3967 #[allow(unsafe_code)]
3970 unsafe {
3971 libc::kill(pid, libc::SIGKILL);
3972 }
3973 }
3974 }
3975 }
3976}