1use std::{
2 collections::BTreeMap,
3 process::{Command, Stdio},
4 time::{Duration, Instant},
5};
6
7use crate::{
8 artifact_config::ARTIFACT_CONFIG_ENV,
9 detached::{DetachPolicy, DetachedDispatchInfo},
10 types::CapabilityPolicy,
11 workdir_fs::{WorkdirRoot, DEFAULT_FILE_MODE},
12};
13
14pub(crate) const MAX_SHELL_OUTPUT_BYTES: usize = 16 * 1024;
15
16const SHELL_INTERPRETERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
17const DEFAULT_ENV_BASELINE: &[&str] = &[
18 "PATH",
19 "HOME",
20 "USER",
21 "LOGNAME",
22 "LANG",
23 "LC_ALL",
24 "CARGO_HOME",
25 "RUSTUP_HOME",
26 "TERM",
27];
28
29pub(crate) const ACCOUNT_ENV: [&str; 2] = ["LOGNAME", "USER"];
33
34#[cfg(test)]
37pub(crate) static ACCOUNT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
38
39#[derive(Debug, Clone, PartialEq, Eq)]
46pub(crate) struct HostAccountEnv {
47 set: Vec<(String, String)>,
48 unset: Vec<String>,
49}
50
51impl HostAccountEnv {
52 pub(crate) fn from_host() -> Self {
54 Self::from_lookup(|name| std::env::var(name).ok())
55 }
56
57 pub(crate) fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
59 let mut set = Vec::new();
60 let mut unset = Vec::new();
61 for name in ACCOUNT_ENV {
62 match lookup(name) {
63 Some(value) => set.push((name.to_string(), value)),
64 None => unset.push(name.to_string()),
65 }
66 }
67 Self { set, unset }
68 }
69
70 #[cfg(test)]
72 pub(crate) fn from_pairs(pairs: &[(&str, &str)]) -> Self {
73 Self::from_lookup(|name| {
74 pairs
75 .iter()
76 .find(|(key, _)| *key == name)
77 .map(|(_, value)| value.to_string())
78 })
79 }
80
81 pub(crate) fn unset(&self) -> &[String] {
83 &self.unset
84 }
85
86 pub(crate) fn set(&self) -> &[(String, String)] {
88 &self.set
89 }
90
91 pub(crate) fn apply(&self, env: &mut BTreeMap<String, String>) {
94 for name in ACCOUNT_ENV {
95 env.remove(name);
96 }
97 env.extend(self.set.iter().cloned());
98 }
99}
100
101pub(crate) const NO_HOME_FOLDER_REFUSAL: &str =
104 "no home folder: this command's enforcement holds no session home folder (home/ in the \
105 session's records directory) to be its $HOME, so it is not run";
106
107const CREDENTIAL_ENV_PATTERNS: &[&str] = &[
112 "ANTHROPIC_API_KEY",
113 "OPENAI_API_KEY",
114 "GOOGLE_API_KEY",
115 "HUGGING_FACE_HUB_TOKEN",
116 "AWS_*",
117 "GITHUB_TOKEN",
118 "GH_TOKEN",
119 "DOCKER_*",
120 "KUBECONFIG",
121 "NPM_TOKEN",
122 "PYPI_TOKEN",
123 "CARGO_REGISTRY_TOKEN",
124 "*_API_KEY",
125];
126
127#[derive(Debug)]
128pub(crate) struct ShellResult {
129 pub(crate) binary: String,
135 pub(crate) exit_code: i32,
136 pub(crate) stdout: String,
137 pub(crate) stderr: String,
138 pub(crate) duration_ms: u64,
139 pub(crate) truncated: bool,
140 pub(crate) full_output_path: Option<String>,
145 pub(crate) resource_limit_hit: Option<String>,
150 pub(crate) output_holders: OutputHolders,
153}
154
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub(crate) enum OutputHolders {
163 Closed,
165 Killed,
168 Escaped,
172}
173
174impl OutputHolders {
175 pub(crate) fn note(self) -> Option<String> {
178 const REDIRECT: &str = "A process meant to keep running in the background needs its \
179 output redirected away from the call, e.g. `server >server.log 2>&1 &`.";
180 match self {
181 Self::Closed => None,
182 Self::Killed => Some(format!(
183 "The command exited, but processes it started were still holding its output \
184 after the wait and were killed. {REDIRECT}"
185 )),
186 Self::Escaped => Some(format!(
187 "The command exited, but processes it started outside its process group still \
188 hold its output; the result has what was read before the call returned, and \
189 those processes keep running until the session ends. {REDIRECT}"
190 )),
191 }
192 }
193}
194
195const PIPE_DRAIN_AFTER_EXIT: Duration = Duration::from_secs(1);
201
202pub(crate) const LEFTOVER_KILL_WAIT: Duration = Duration::from_secs(2);
205
206const PIPE_POLL_INTERVAL: Duration = Duration::from_millis(10);
208
209#[derive(Debug)]
219pub(crate) enum ShellExecError {
220 Failed(String),
224 SealedRootConstructionFailed { detail: String },
228}
229
230impl ShellExecError {
231 pub(crate) fn session_fatal(&self) -> Option<crate::errors::RuntimeError> {
237 match self {
238 Self::Failed(_) => None,
239 Self::SealedRootConstructionFailed { detail } => {
240 Some(crate::errors::RuntimeError::SealedRootConstructionFailed {
241 detail: detail.clone(),
242 })
243 }
244 }
245 }
246}
247
248pub(crate) fn enforced_spawn_failure(
259 command: Command,
260 supervisor: &crate::sandbox::SupervisorHandle,
261 subject: &str,
262 spawn_error: String,
263) -> ShellExecError {
264 drop(command);
265 match supervisor.read_diagnostic() {
266 Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
272 ShellExecError::SealedRootConstructionFailed { detail }
273 }
274 Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
275 "sandbox: {subject} enforcement setup failed before exec: {detail}"
276 )),
277 _ => ShellExecError::Failed(spawn_error),
278 }
279}
280
281impl std::fmt::Display for ShellExecError {
282 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
283 match self {
284 Self::Failed(message) => f.write_str(message),
285 Self::SealedRootConstructionFailed { detail } => write!(
289 f,
290 "{}",
291 crate::errors::RuntimeError::SealedRootConstructionFailed {
292 detail: detail.clone(),
293 }
294 ),
295 }
296 }
297}
298
299impl From<String> for ShellExecError {
301 fn from(message: String) -> Self {
302 Self::Failed(message)
303 }
304}
305
306pub(crate) fn is_shell_interpreter(binary: &str) -> bool {
307 SHELL_INTERPRETERS.contains(&binary)
308}
309
310pub(crate) use murmur_artifact::split_shell_words;
311
312pub(crate) fn shell_tool_manifest_yaml(binary: &str) -> String {
313 let description = if is_shell_interpreter(binary) {
314 format!("Run a shell command via {binary} in the capsule workdir. The `command` field is passed to {binary} via -c.")
315 } else {
316 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.")
317 };
318
319 format!(
320 "name: {binary}\nversion: 0.0.0\nruntime: tool\nimplementation: native\ndescription: \"{description}\"\ninput_schema: '{{\"type\":\"object\",\"properties\":{{\"command\":{{\"type\":\"string\"}}}},\"required\":[\"command\"]}}'\n"
321 )
322}
323
324thread_local! {
325 static SPAWNED_GROUP: std::cell::RefCell<Option<std::sync::Arc<std::sync::atomic::AtomicI32>>> =
328 const { std::cell::RefCell::new(None) };
329}
330
331pub(crate) fn report_spawned_group_to(sink: std::sync::Arc<std::sync::atomic::AtomicI32>) {
334 SPAWNED_GROUP.with(|slot| *slot.borrow_mut() = Some(sink));
335}
336
337#[derive(Debug)]
339pub(crate) enum ShellOutcome {
340 Finished(ShellResult),
341 Detached(DetachedDispatchInfo),
342}
343
344const DETACH_POLL_INTERVAL: Duration = Duration::from_millis(20);
351
352pub(crate) const CANCELED_BEFORE_SPAWN: &str =
354 "the task was canceled before this command started, so it was not run";
355
356#[allow(clippy::too_many_arguments)]
384pub(crate) fn run_shell(
385 binary: &str,
386 args: &[&str],
387 env_overrides: &[(String, String)],
388 accessible_workdir: &WorkdirRoot,
389 policy: &CapabilityPolicy,
390 enforcement: &crate::sandbox::ShellEnforcement,
391 detach: Option<DetachPolicy>,
392) -> Result<ShellOutcome, ShellExecError> {
393 let wait = detach.map_or(Wait::Blocking, Wait::Detach);
394 spawn_and_wait(
395 binary,
396 args,
397 env_overrides,
398 accessible_workdir,
399 policy,
400 enforcement,
401 wait,
402 )
403 .map(|(outcome, _)| outcome)
404}
405
406pub(crate) struct ForegroundStop {
408 pub canceled: crate::cancel::CancelSignal,
410 pub timeout: Duration,
412}
413
414#[derive(Debug, Clone, Copy, PartialEq, Eq)]
416pub(crate) enum ForegroundStopReason {
417 Canceled,
419 TimedOut,
421}
422
423#[derive(Debug)]
425pub(crate) struct ForegroundRun {
426 pub result: ShellResult,
427 pub stopped: Option<ForegroundStopReason>,
428}
429
430pub(crate) fn run_shell_until_stopped(
438 binary: &str,
439 args: &[&str],
440 env_overrides: &[(String, String)],
441 accessible_workdir: &WorkdirRoot,
442 policy: &CapabilityPolicy,
443 enforcement: &crate::sandbox::ShellEnforcement,
444 stop: ForegroundStop,
445) -> Result<ForegroundRun, ShellExecError> {
446 match spawn_and_wait(
447 binary,
448 args,
449 env_overrides,
450 accessible_workdir,
451 policy,
452 enforcement,
453 Wait::Stop(stop),
454 )? {
455 (ShellOutcome::Finished(result), stopped) => Ok(ForegroundRun { result, stopped }),
456 (ShellOutcome::Detached(info), _) => Err(ShellExecError::Failed(format!(
458 "a foreground command was demoted to the background as {}",
459 info.work_id
460 ))),
461 }
462}
463
464enum Wait {
466 Blocking,
468 Detach(DetachPolicy),
470 Stop(ForegroundStop),
473}
474
475fn spawn_and_wait(
479 binary: &str,
480 args: &[&str],
481 env_overrides: &[(String, String)],
482 accessible_workdir: &WorkdirRoot,
483 policy: &CapabilityPolicy,
484 enforcement: &crate::sandbox::ShellEnforcement,
485 wait: Wait,
486) -> Result<(ShellOutcome, Option<ForegroundStopReason>), ShellExecError> {
487 use std::os::unix::process::CommandExt as _;
488
489 if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
490 return Err(ShellExecError::Failed(format!(
491 "binary '{binary}' is not in capabilities.shell.allow"
492 )));
493 }
494
495 if let Wait::Detach(DetachPolicy {
498 canceled: Some(canceled),
499 ..
500 }) = &wait
501 {
502 if canceled.is_canceled() {
503 return Err(ShellExecError::Failed(CANCELED_BEFORE_SPAWN.to_string()));
504 }
505 }
506
507 enforcement.check_workdir_budget()?;
510
511 let env = build_shell_env(
512 policy,
513 env_overrides,
514 enforcement.home_dir_path(),
515 enforcement.runtime_dir_path(),
516 enforcement.records_dir_path(),
517 enforcement.tmpdir_value().as_deref(),
518 )?;
519
520 let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
523
524 let started = Instant::now();
525 let mut command = Command::new(binary);
526 command
527 .args(args)
528 .current_dir(accessible_workdir.path())
529 .env_clear()
530 .envs(env)
531 .stdin(Stdio::null())
534 .stdout(Stdio::piped())
535 .stderr(Stdio::piped())
536 .process_group(0);
539
540 let supervisor =
545 crate::sandbox::prepare_enforcement(&mut command, enforcement, accessible_workdir.path())?;
546
547 let cgroup_occupancy = enforcement.cgroup_scope.as_ref().map(|scope| scope.enter());
551 let cgroup_counters_before = enforcement
554 .cgroup_scope
555 .as_ref()
556 .map(|scope| scope.event_counters());
557
558 let mut child = match command.spawn() {
559 Ok(child) => child,
560 Err(error) => {
561 return Err(enforced_spawn_failure(
562 command,
563 &supervisor,
564 "shell",
565 error.to_string(),
566 ))
567 }
568 };
569 let pgid = child.id() as i32;
571 SPAWNED_GROUP.with(|slot| {
572 if let Some(sink) = slot.borrow().as_ref() {
573 sink.store(pgid, std::sync::atomic::Ordering::SeqCst);
574 }
575 });
576
577 let readers = OutputReaders::spawn(&mut child);
581
582 let records_root = enforcement.records_root();
585 let wait = match wait {
586 Wait::Detach(_) if records_root.is_none() => Wait::Blocking,
587 wait => wait,
588 };
589
590 let mut stopped = None;
591 let (status, drain_deadline) = match wait {
592 Wait::Blocking => {
593 let status = child.wait().map_err(|error| error.to_string())?;
594 (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT)
595 }
596 Wait::Stop(stop) => {
597 let deadline = started + stop.timeout;
598 loop {
599 if let Some(status) = child.try_wait().map_err(|error| error.to_string())? {
600 break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
601 }
602 let remaining = deadline.saturating_duration_since(Instant::now());
603 let reason = if stop.canceled.is_canceled() {
604 Some(ForegroundStopReason::Canceled)
605 } else if remaining.is_zero() {
606 Some(ForegroundStopReason::TimedOut)
607 } else {
608 None
609 };
610 if let Some(reason) = reason {
611 crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL);
613 let status = child.wait().map_err(|error| error.to_string())?;
614 stopped = Some(reason);
615 break (status, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
616 }
617 std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
618 }
619 }
620 Wait::Detach(policy) => {
621 let deadline = started + policy.grace;
622 loop {
623 match child.try_wait().map_err(|error| error.to_string())? {
624 Some(status) => {
625 break (
626 status,
627 (Instant::now() + PIPE_DRAIN_AFTER_EXIT).min(deadline),
628 )
629 }
630 None => {
631 let remaining = deadline.saturating_duration_since(Instant::now());
632 let abandoned = policy
635 .abandoned
636 .as_ref()
637 .is_some_and(crate::detached::AbandonSignal::is_raised);
638 let canceled = policy
641 .canceled
642 .as_ref()
643 .filter(|signal| signal.is_canceled());
644 if let Some(signal) = canceled {
645 signal.note_phase(crate::cancel::PHASE_SHELL);
646 }
647 if remaining.is_zero() || abandoned || canceled.is_some() {
648 let records_root = records_root
650 .expect("a detach policy is kept only with a records root")
651 .clone();
652 let detached = demote(
653 child,
654 readers,
655 DemotionContext {
656 policy,
657 records_root,
658 invoked_binary: binary.to_string(),
659 resolved_binary,
660 args: args.iter().map(|arg| (*arg).to_string()).collect(),
661 started,
662 pgid,
663 cgroup_scope: enforcement.cgroup_scope.clone(),
664 cgroup_counters_before,
665 cgroup_occupancy,
666 },
667 );
668 return Ok((ShellOutcome::Detached(detached), None));
669 }
670 std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
671 }
672 }
673 }
674 }
675 };
676 let (stdout_bytes, stderr_bytes, output_holders) = finish_output(readers, pgid, drain_deadline);
677 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
678
679 let resource_limit_hit = classify_resource_limit(
682 &status,
683 enforcement.cgroup_scope.as_deref(),
684 cgroup_counters_before,
685 cgroup_occupancy.as_ref(),
686 )
687 .filter(|_| stopped.is_none());
688 drop(cgroup_occupancy);
689 let exit_code = exit_code_of(&status);
690
691 let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
692 let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
693 let mut truncated = false;
694 let mut full_output_path = None;
695
696 if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
697 truncated = true;
698 let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
699 let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
700 let full_stdout = std::mem::replace(
701 &mut stdout,
702 String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
703 );
704 let full_stderr = std::mem::replace(
705 &mut stderr,
706 String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
707 );
708 if let Some(records_root) = records_root {
709 full_output_path = Some(write_shell_output_log(
710 records_root,
711 &format!("shell-{}", crate::trace::timestamp_ms()),
712 binary,
713 args,
714 exit_code,
715 &full_stdout,
716 &full_stderr,
717 )?);
718 }
719 }
720
721 Ok((
722 ShellOutcome::Finished(ShellResult {
723 binary: resolved_binary,
724 exit_code,
725 stdout,
726 stderr,
727 duration_ms,
728 truncated,
729 full_output_path,
730 resource_limit_hit,
731 output_holders,
732 }),
733 stopped,
734 ))
735}
736
737pub(crate) fn execute_shell(
742 binary: &str,
743 args: &[&str],
744 env_overrides: &[(String, String)],
745 accessible_workdir: &WorkdirRoot,
746 policy: &CapabilityPolicy,
747 enforcement: &crate::sandbox::ShellEnforcement,
748) -> Result<ShellResult, ShellExecError> {
749 match run_shell(
750 binary,
751 args,
752 env_overrides,
753 accessible_workdir,
754 policy,
755 enforcement,
756 None,
757 )? {
758 ShellOutcome::Finished(result) => Ok(result),
759 ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
763 "shell command was demoted to the background as {} on a call site that cannot receive a completion",
764 info.work_id
765 ))),
766 }
767}
768
769struct OutputReaders {
780 stdout: Option<PipeReader>,
781 stderr: Option<PipeReader>,
782}
783
784enum PipeSink {
786 Buffer(Vec<u8>),
788 Live(std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>),
790 Discard,
793}
794
795struct PipeReader {
797 thread: std::thread::JoinHandle<()>,
798 sink: std::sync::Arc<std::sync::Mutex<PipeSink>>,
799}
800
801fn lock_sink(sink: &std::sync::Mutex<PipeSink>) -> std::sync::MutexGuard<'_, PipeSink> {
802 sink.lock()
803 .unwrap_or_else(std::sync::PoisonError::into_inner)
804}
805
806impl PipeReader {
807 fn spawn(mut pipe: impl std::io::Read + Send + 'static) -> Self {
808 let sink = std::sync::Arc::new(std::sync::Mutex::new(PipeSink::Buffer(Vec::new())));
809 let thread = {
810 let sink = std::sync::Arc::clone(&sink);
811 std::thread::spawn(move || {
812 let mut chunk = [0u8; 8192];
813 loop {
814 match pipe.read(&mut chunk) {
815 Ok(0) => break,
816 Ok(read) => match &mut *lock_sink(&sink) {
817 PipeSink::Buffer(buffer) => buffer.extend_from_slice(&chunk[..read]),
818 PipeSink::Live(output) => output
819 .lock()
820 .unwrap_or_else(std::sync::PoisonError::into_inner)
821 .append(&chunk[..read]),
822 PipeSink::Discard => {}
823 },
824 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
825 Err(_) => break,
829 }
830 }
831 })
832 };
833 Self { thread, sink }
834 }
835
836 fn take(self) -> Vec<u8> {
839 let taken = match std::mem::replace(&mut *lock_sink(&self.sink), PipeSink::Discard) {
840 PipeSink::Buffer(buffer) => buffer,
841 PipeSink::Live(_) | PipeSink::Discard => Vec::new(),
842 };
843 if self.thread.is_finished() && self.thread.join().is_err() {
844 return Vec::new();
845 }
846 taken
847 }
848}
849
850impl OutputReaders {
851 fn spawn(child: &mut std::process::Child) -> Self {
852 Self {
853 stdout: child.stdout.take().map(PipeReader::spawn),
854 stderr: child.stderr.take().map(PipeReader::spawn),
855 }
856 }
857
858 fn go_live(&self, output: &std::sync::Arc<std::sync::Mutex<crate::detached::LiveOutput>>) {
864 let mut sinks: Vec<_> = [&self.stdout, &self.stderr]
865 .into_iter()
866 .flatten()
867 .map(|reader| lock_sink(&reader.sink))
868 .collect();
869 let mut live = output
870 .lock()
871 .unwrap_or_else(std::sync::PoisonError::into_inner);
872 for sink in &mut sinks {
873 if let PipeSink::Buffer(buffer) =
874 std::mem::replace(&mut **sink, PipeSink::Live(std::sync::Arc::clone(output)))
875 {
876 live.append(&buffer);
877 }
878 }
879 }
880
881 fn at_eof(&self) -> bool {
883 [&self.stdout, &self.stderr]
884 .into_iter()
885 .flatten()
886 .all(|reader| reader.thread.is_finished())
887 }
888
889 fn wait_for_eof(&self, deadline: Instant) -> bool {
892 loop {
893 if self.at_eof() {
894 return true;
895 }
896 let remaining = deadline.saturating_duration_since(Instant::now());
897 if remaining.is_zero() {
898 return false;
899 }
900 std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
901 }
902 }
903
904 fn take(self) -> (Vec<u8>, Vec<u8>) {
907 let take = |reader: Option<PipeReader>| reader.map(PipeReader::take).unwrap_or_default();
908 (take(self.stdout), take(self.stderr))
909 }
910}
911
912fn finish_output(
921 readers: OutputReaders,
922 pgid: i32,
923 drain_deadline: Instant,
924) -> (Vec<u8>, Vec<u8>, OutputHolders) {
925 if readers.wait_for_eof(drain_deadline) {
926 let (stdout, stderr) = readers.take();
927 return (stdout, stderr, OutputHolders::Closed);
928 }
929
930 let give_up = Instant::now() + LEFTOVER_KILL_WAIT;
931 let mut group_alive = true;
932 let mut killed = false;
933 let closed = loop {
934 if group_alive {
935 if crate::formation_launch::signal_group(i64::from(pgid), libc::SIGKILL) {
937 killed = true;
938 } else {
939 group_alive = false;
940 }
941 }
942 if readers.at_eof() {
943 break true;
944 }
945 let remaining = give_up.saturating_duration_since(Instant::now());
946 if remaining.is_zero() {
947 break false;
948 }
949 std::thread::sleep(remaining.min(PIPE_POLL_INTERVAL));
950 };
951
952 let (stdout, stderr) = readers.take();
953 let holders = match (closed, killed) {
954 (true, true) => OutputHolders::Killed,
955 (true, false) => OutputHolders::Closed,
957 (false, _) => OutputHolders::Escaped,
958 };
959 (stdout, stderr, holders)
960}
961
962struct DemotionContext {
964 policy: DetachPolicy,
965 records_root: WorkdirRoot,
968 invoked_binary: String,
970 resolved_binary: String,
973 args: Vec<String>,
974 started: Instant,
975 pgid: i32,
978 cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
979 cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
980 cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
982}
983
984fn demote(
997 mut child: std::process::Child,
998 readers: OutputReaders,
999 context: DemotionContext,
1000) -> DetachedDispatchInfo {
1001 let work_id = crate::detached::new_work_id();
1002 let info = DetachedDispatchInfo {
1003 work_id: work_id.clone(),
1004 binary: context.resolved_binary.clone(),
1005 command: context.policy.command.clone(),
1006 grace_ms: context
1007 .policy
1008 .grace
1009 .as_millis()
1010 .try_into()
1011 .unwrap_or(u64::MAX),
1012 };
1013
1014 let arg_refs: Vec<&str> = context.args.iter().map(String::as_str).collect();
1015 let log = crate::detached::output_path_for(&context.records_root, &work_id);
1016 let output = std::sync::Arc::new(std::sync::Mutex::new(crate::detached::LiveOutput::open(
1017 &context.records_root,
1018 &log.relative,
1019 &log_command_line(&context.invoked_binary, &arg_refs),
1020 )));
1021 readers.go_live(&output);
1022
1023 context.policy.registry.register(
1024 crate::detached::DetachedWork {
1025 work_id: work_id.clone(),
1026 binary: context.resolved_binary.clone(),
1027 command: context.policy.command.clone(),
1028 started_at_ms: crate::trace::timestamp_ms().saturating_sub(
1034 context
1035 .started
1036 .elapsed()
1037 .as_millis()
1038 .try_into()
1039 .unwrap_or(u64::MAX),
1040 ),
1041 },
1042 context.started,
1043 context.pgid,
1044 log.path.clone(),
1045 std::sync::Arc::clone(&output),
1046 );
1047
1048 let provenance = context.policy.completion_provenance();
1049 let DemotionContext {
1050 policy,
1051 records_root,
1052 resolved_binary,
1053 started,
1054 pgid,
1055 cgroup_scope,
1056 cgroup_counters_before,
1057 cgroup_occupancy,
1058 ..
1059 } = context;
1060
1061 std::thread::spawn(move || {
1062 let observed = wait_for_exit_unreaped(pgid);
1063 policy.registry.leader_exited(&work_id);
1064 let waited = child.wait();
1065 let (_, _, output_holders) =
1066 finish_output(readers, pgid, Instant::now() + PIPE_DRAIN_AFTER_EXIT);
1067 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1068
1069 let (exit_code, resource_limit, mut error) = match waited {
1070 Ok(status) => (
1071 exit_code_of(&status),
1072 classify_resource_limit(
1073 &status,
1074 cgroup_scope.as_deref(),
1075 cgroup_counters_before,
1076 cgroup_occupancy.as_ref(),
1077 ),
1078 None,
1079 ),
1080 Err(failure) => (
1081 -1,
1082 None,
1083 Some(format!("waiting for the command failed: {failure}")),
1084 ),
1085 };
1086 drop(cgroup_occupancy);
1087 if let (Err(failure), None) = (observed, &error) {
1088 error = Some(format!("observing the command's exit failed: {failure}"));
1089 }
1090
1091 let write_error = output
1092 .lock()
1093 .unwrap_or_else(std::sync::PoisonError::into_inner)
1094 .finish(exit_code);
1095 if let Some(failure) = write_error {
1096 error = Some(match error {
1097 Some(existing) => format!("{existing}; {failure}"),
1098 None => failure,
1099 });
1100 }
1101 let output_bytes = records_root
1102 .file_len(&log.relative)
1103 .ok()
1104 .flatten()
1105 .unwrap_or(0);
1106
1107 policy
1108 .registry
1109 .complete(crate::detached::DetachedCompletion {
1110 work_id,
1111 binary: resolved_binary,
1112 command: policy.command.clone(),
1113 exit_code,
1114 duration_ms,
1115 output_path: log.path,
1116 output_bytes,
1117 resource_limit,
1118 output_holders,
1119 context_id: policy.context_id.clone(),
1120 provenance,
1121 error,
1122 });
1123 });
1124
1125 info
1126}
1127
1128fn wait_for_exit_unreaped(pid: i32) -> std::io::Result<()> {
1134 use rustix::process::{waitid, Pid, WaitId, WaitIdOptions};
1135
1136 let pid = (pid > 0)
1137 .then(|| Pid::from_raw(pid))
1138 .flatten()
1139 .ok_or_else(|| std::io::Error::from(std::io::ErrorKind::InvalidInput))?;
1140 loop {
1141 match waitid(
1142 WaitId::Pid(pid),
1143 WaitIdOptions::EXITED | WaitIdOptions::NOWAIT,
1144 ) {
1145 Ok(_) => return Ok(()),
1146 Err(rustix::io::Errno::INTR) => {}
1147 Err(errno) => return Err(errno.into()),
1148 }
1149 }
1150}
1151
1152pub(crate) fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
1160 use std::os::unix::process::ExitStatusExt;
1161
1162 status
1163 .code()
1164 .or_else(|| status.signal().map(|signal| 128 + signal))
1165 .unwrap_or(-1)
1166}
1167
1168fn classify_resource_limit(
1190 status: &std::process::ExitStatus,
1191 cgroup_scope: Option<&crate::cgroup::CgroupScope>,
1192 counters_before: Option<crate::cgroup::CgroupEventCounters>,
1193 occupancy: Option<&crate::cgroup::ScopeOccupancy>,
1194) -> Option<String> {
1195 use std::os::unix::process::ExitStatusExt;
1196
1197 let signal_limit = status
1198 .signal()
1199 .and_then(crate::resources::limit_from_signal);
1200 let counter_delta = match (cgroup_scope, counters_before) {
1201 (Some(scope), Some(before)) => scope.event_counters().attribution_since(before),
1202 _ => None,
1203 };
1204 let exclusive = occupancy.is_some_and(crate::cgroup::ScopeOccupancy::exclusive);
1205 attribute_resource_limit(signal_limit, counter_delta, exclusive)
1206}
1207
1208pub(crate) fn attribute_resource_limit(
1215 signal_limit: Option<&'static str>,
1216 counter_delta: Option<&'static str>,
1217 exclusive: bool,
1218) -> Option<String> {
1219 signal_limit
1220 .or(counter_delta.filter(|_| exclusive))
1221 .map(str::to_string)
1222}
1223
1224pub(crate) fn build_shell_env(
1233 policy: &CapabilityPolicy,
1234 env_overrides: &[(String, String)],
1235 home_dir: Option<&std::path::Path>,
1236 runtime_dir: Option<&std::path::Path>,
1237 records_dir: Option<&std::path::Path>,
1238 tmpdir: Option<&str>,
1239) -> Result<BTreeMap<String, String>, String> {
1240 let mut env = BTreeMap::new();
1241
1242 for key in DEFAULT_ENV_BASELINE {
1243 if let Ok(value) = std::env::var(key) {
1244 env.insert((*key).to_string(), value);
1245 }
1246 }
1247
1248 for (key, value) in env_overrides {
1249 env.insert(key.clone(), value.clone());
1250 }
1251
1252 for key in &policy.shell_baseline_env {
1253 if crate::formation::is_member_grant_env(key)
1256 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1257 {
1258 continue;
1259 }
1260 if let Ok(value) = std::env::var(key) {
1261 env.insert(key.clone(), value);
1262 }
1263 }
1264
1265 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1266
1267 let home = home_dir.ok_or_else(|| NO_HOME_FOLDER_REFUSAL.to_string())?;
1271 let home = home.to_string_lossy().into_owned();
1272 env.insert("HOME".to_string(), home.clone());
1273 env.insert("USERPROFILE".to_string(), home);
1274 env.remove(crate::runtime_dir::RUNTIME_DIR_ENV);
1277 if let Some(runtime_dir) = runtime_dir {
1278 env.insert(
1279 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
1280 runtime_dir.to_string_lossy().into_owned(),
1281 );
1282 }
1283 env.remove(crate::records_dir::RECORDS_DIR_ENV);
1285 if let Some(records_dir) = records_dir {
1286 env.insert(
1287 crate::records_dir::RECORDS_DIR_ENV.to_string(),
1288 records_dir.to_string_lossy().into_owned(),
1289 );
1290 }
1291 for name in crate::temp_dir::TEMP_DIR_ENV {
1293 env.remove(name);
1294 if let Some(tmpdir) = tmpdir {
1295 env.insert(name.to_string(), tmpdir.to_string());
1296 }
1297 }
1298
1299 Ok(env)
1300}
1301
1302pub(crate) fn strip_credential_shaped_vars(
1310 env: &mut BTreeMap<String, String>,
1311 extra_patterns: &[String],
1312) {
1313 env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
1314}
1315
1316#[derive(Debug, Clone, PartialEq, Eq)]
1318pub enum BackstopPatternSource {
1319 Builtin,
1321 StripEnv,
1323}
1324
1325#[derive(Debug, Clone, PartialEq, Eq)]
1327pub struct BackstopMatch {
1328 pub pattern: String,
1329 pub source: BackstopPatternSource,
1330}
1331
1332pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
1341 CREDENTIAL_ENV_PATTERNS
1342 .iter()
1343 .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
1344 .chain(
1345 extra_patterns
1346 .iter()
1347 .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
1348 )
1349 .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
1350 .map(|(pattern, source)| BackstopMatch {
1351 pattern: pattern.to_string(),
1352 source,
1353 })
1354}
1355
1356pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
1360 credential_backstop_match(name, extra_patterns).is_some()
1361}
1362
1363pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
1379 let mut env = BTreeMap::new();
1380
1381 for key in &policy.env_allow {
1382 if key == ARTIFACT_CONFIG_ENV
1383 || crate::formation::is_member_grant_env(key)
1384 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
1385 || key == crate::runtime_dir::RUNTIME_DIR_ENV
1386 || key == crate::records_dir::RECORDS_DIR_ENV
1387 {
1388 continue;
1389 }
1390 if let Ok(value) = std::env::var(key) {
1391 env.insert(key.clone(), value);
1392 }
1393 }
1394
1395 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
1396
1397 env
1398}
1399
1400fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
1401 if let Some(rest) = pattern.strip_prefix('*') {
1402 if let Some(middle) = rest.strip_suffix('*') {
1403 return key.contains(middle);
1404 }
1405 return key.ends_with(rest);
1406 }
1407
1408 if let Some(prefix) = pattern.strip_suffix('*') {
1409 return key.starts_with(prefix);
1410 }
1411
1412 pattern == key
1413}
1414
1415fn log_command_line(binary: &str, args: &[&str]) -> String {
1417 if args.is_empty() {
1418 binary.to_string()
1419 } else {
1420 format!("{binary} {}", args.join(" "))
1421 }
1422}
1423
1424fn write_shell_output_log(
1432 records_root: &WorkdirRoot,
1433 file_stem: &str,
1434 binary: &str,
1435 args: &[&str],
1436 exit_code: i32,
1437 stdout: &str,
1438 stderr: &str,
1439) -> Result<String, String> {
1440 let log = crate::detached::output_path_for(records_root, file_stem);
1441 let command = log_command_line(binary, args);
1442
1443 let content = format!(
1444 "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
1445 );
1446
1447 records_root
1448 .write_file(&log.relative, content.as_bytes(), DEFAULT_FILE_MODE)
1449 .map_err(|error| format!("failed to write shell log: {error}"))?;
1450
1451 Ok(log.path)
1452}
1453
1454#[cfg(test)]
1455mod tests {
1456 use std::{fs, path::Path};
1457 use tempfile::tempdir;
1458
1459 use super::test_support::{process_dead_within, read_pid, KillPidFileOnDrop};
1460 use super::*;
1461 use crate::sandbox::ShellEnforcement;
1462
1463 #[test]
1469 fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
1470 let temp = tempdir().unwrap();
1471 let policy = CapabilityPolicy {
1472 shell_allow: vec!["bash".to_string()],
1473 resources: crate::resources::HostResourceLimits {
1474 max_open_files: 64,
1475 ..crate::resources::HostResourceLimits::default()
1476 },
1477 ..CapabilityPolicy::default()
1478 };
1479 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1480 enforcement.resource_limits = policy.resources;
1481
1482 let result = execute_shell(
1483 "bash",
1484 &["-c", "ulimit -Hn"],
1485 &[],
1486 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1487 &policy,
1488 &enforcement,
1489 )
1490 .expect("bash must run");
1491
1492 assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
1493 assert_eq!(result.resource_limit_hit, None);
1494 }
1495
1496 #[cfg(target_os = "linux")]
1498 fn run_bash_with_limits(
1499 limits: crate::resources::HostResourceLimits,
1500 script: &str,
1501 ) -> ShellResult {
1502 let temp = tempdir().unwrap();
1503 let policy = CapabilityPolicy {
1504 shell_allow: vec!["bash".to_string()],
1505 resources: limits,
1506 ..CapabilityPolicy::default()
1507 };
1508 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1509 enforcement.resource_limits = limits;
1510
1511 execute_shell(
1512 "bash",
1513 &["-c", script],
1514 &[],
1515 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1516 &policy,
1517 &enforcement,
1518 )
1519 .expect("bash must run")
1520 }
1521
1522 #[cfg(target_os = "linux")]
1528 #[test]
1529 fn a_command_that_ignores_sigxcpu_is_killed_one_second_later() {
1530 let result = run_bash_with_limits(
1531 crate::resources::HostResourceLimits {
1532 cpu_seconds: 1,
1533 ..crate::resources::HostResourceLimits::default()
1534 },
1535 "( trap '' XCPU; while :; do :; done ); echo \"status=$?\"; times",
1536 );
1537
1538 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1539 assert!(
1540 result.stdout.contains("status=137"),
1541 "the subshell must die of SIGKILL; stdout was: {}",
1542 result.stdout
1543 );
1544 assert_eq!(result.resource_limit_hit, None);
1545
1546 let children = result
1547 .stdout
1548 .lines()
1549 .last()
1550 .unwrap_or_else(|| panic!("no children line from `times`: {}", result.stdout));
1551 let cpu: f64 = children
1552 .split_whitespace()
1553 .map(|field| {
1554 let (minutes, seconds) = field.trim_end_matches('s').split_once('m').unwrap();
1555 minutes.parse::<f64>().unwrap() * 60.0 + seconds.parse::<f64>().unwrap()
1556 })
1557 .sum();
1558 assert!(
1559 cpu <= 2.5,
1560 "the subshell ran {cpu} CPU-seconds, past cpu_seconds + 1"
1561 );
1562 }
1563
1564 #[cfg(target_os = "linux")]
1568 #[test]
1569 #[allow(unsafe_code)]
1570 fn the_cpu_limit_warns_one_second_before_its_hard_ceiling() {
1571 let result = run_bash_with_limits(
1572 crate::resources::HostResourceLimits {
1573 cpu_seconds: 5,
1574 max_open_files: 64,
1575 ..crate::resources::HostResourceLimits::default()
1576 },
1577 r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)"; ulimit -Ht 10 2>/dev/null || echo "raise refused""#,
1578 );
1579
1580 let mut lines = result.stdout.lines();
1581 assert_eq!(
1582 lines.next(),
1583 Some("5 6 64 64"),
1584 "stderr was: {}",
1585 result.stderr
1586 );
1587 if unsafe { libc::geteuid() } != 0 {
1589 assert_eq!(lines.next(), Some("raise refused"));
1590 }
1591 }
1592
1593 #[cfg(target_os = "linux")]
1598 #[test]
1599 fn a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors() {
1600 const TEST: &str = "a_kernel_tier_command_starts_under_a_limit_below_the_held_descriptors";
1601 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1602 return;
1603 }
1604 let temp = tempdir().unwrap();
1605 let policy = CapabilityPolicy {
1606 shell_allow: vec!["bash".to_string()],
1607 resources: crate::resources::HostResourceLimits {
1608 max_open_files: 16,
1609 ..crate::resources::HostResourceLimits::default()
1610 },
1611 ..CapabilityPolicy::default()
1612 };
1613 let enforcement = ShellEnforcement::resolve_declared(
1614 &policy,
1615 murmur_artifact::ContainmentClass::Scoped,
1616 crate::sandbox::HostProbe::probe(),
1617 )
1618 .unwrap()
1619 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1620 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1621 crate::runtime_err!(
1622 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1623 enforcement.tier
1624 );
1625 return;
1626 }
1627 let held: Vec<fs::File> = (0..128)
1628 .map(|_| fs::File::open("/dev/null").unwrap())
1629 .collect();
1630
1631 let result = execute_shell(
1632 "bash",
1633 &["-c", "ulimit -Hn"],
1634 &[],
1635 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1636 &policy,
1637 &enforcement,
1638 )
1639 .expect("bash must start under the limit");
1640 drop(held);
1641
1642 assert_eq!(result.stdout.trim(), "16", "stderr was: {}", result.stderr);
1643 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1644 }
1645
1646 #[cfg(target_os = "linux")]
1650 #[test]
1651 fn a_program_outside_the_allowlist_is_refused_with_exit_126() {
1652 const TEST: &str = "a_program_outside_the_allowlist_is_refused_with_exit_126";
1653 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1654 return;
1655 }
1656 let run = |allow: &[&str]| {
1657 let temp = tempdir().unwrap();
1658 let policy = CapabilityPolicy {
1659 shell_allow: allow.iter().map(|b| b.to_string()).collect(),
1660 ..CapabilityPolicy::default()
1661 };
1662 let enforcement = ShellEnforcement::resolve_declared(
1663 &policy,
1664 murmur_artifact::ContainmentClass::Scoped,
1665 crate::sandbox::HostProbe::probe(),
1666 )
1667 .unwrap()
1668 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1669 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1670 crate::runtime_err!(
1671 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1672 enforcement.tier
1673 );
1674 return None;
1675 }
1676 let result = execute_shell(
1677 "bash",
1678 &["-c", "cat /dev/null"],
1679 &[],
1680 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1681 &policy,
1682 &enforcement,
1683 )
1684 .expect("bash must start");
1685 Some(result)
1686 };
1687
1688 let Some(refused) = run(&["bash"]) else {
1689 return;
1690 };
1691 assert_eq!(refused.exit_code, 126, "stderr was: {}", refused.stderr);
1692 assert!(
1693 refused.stderr.contains("Permission denied"),
1694 "stderr was: {}",
1695 refused.stderr
1696 );
1697
1698 let allowed = run(&["bash", "cat"]).expect("the tier did not change between runs");
1699 assert_eq!(allowed.exit_code, 0, "stderr was: {}", allowed.stderr);
1700 crate::runtime_err!("{TEST}: ran on KernelFull; [bash] exited 126, [bash, cat] exited 0");
1701 }
1702
1703 #[cfg(target_os = "linux")]
1706 #[test]
1707 #[allow(clippy::print_stdout)]
1708 fn a_kernel_tier_command_gets_the_cpu_slots() {
1709 const TEST: &str = "a_kernel_tier_command_gets_the_cpu_slots";
1710 if crate::network_namespace::skip_without_egress_namespace(TEST) {
1711 return;
1712 }
1713 let temp = tempdir().unwrap();
1714 let policy = CapabilityPolicy {
1715 shell_allow: vec!["bash".to_string()],
1716 resources: crate::resources::HostResourceLimits {
1717 cpu_seconds: 5,
1718 max_open_files: 64,
1719 ..crate::resources::HostResourceLimits::default()
1720 },
1721 ..CapabilityPolicy::default()
1722 };
1723 let enforcement = ShellEnforcement::resolve_declared(
1724 &policy,
1725 murmur_artifact::ContainmentClass::Scoped,
1726 crate::sandbox::HostProbe::probe(),
1727 )
1728 .unwrap()
1729 .with_runtime_dir(crate::sandbox::test_runtime_dir(temp.path()), None);
1730 if enforcement.tier != crate::sandbox::EnforcementTier::KernelFull {
1731 crate::runtime_err!(
1732 "[SKIP-HOST] {TEST}: this host resolves {:?}, the test needs KernelFull",
1733 enforcement.tier
1734 );
1735 return;
1736 }
1737
1738 let result = execute_shell(
1739 "bash",
1740 &[
1741 "-c",
1742 r#"echo "$(ulimit -St) $(ulimit -Ht) $(ulimit -Sn) $(ulimit -Hn)""#,
1743 ],
1744 &[],
1745 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1746 &policy,
1747 &enforcement,
1748 )
1749 .expect("bash must start under the limits");
1750 println!("{}", result.stdout.trim());
1751
1752 assert_eq!(
1753 result.stdout.trim(),
1754 "5 6 64 64",
1755 "stderr was: {}",
1756 result.stderr
1757 );
1758 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1759 }
1760
1761 #[cfg(target_os = "linux")]
1765 #[test]
1766 fn a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max() {
1767 const TEST: &str = "a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max";
1768 if !crate::cgroup::cgroup_delegation_available() {
1769 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
1770 return;
1771 }
1772
1773 let temp = tempdir().unwrap();
1774 let limits = crate::resources::HostResourceLimits {
1775 cgroup_pids_max: 16,
1776 ..crate::resources::HostResourceLimits::default()
1777 };
1778 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_fork_bomb", temp.path())
1779 .expect("this host delegates a cgroup scope")
1780 .scope;
1781 let policy = CapabilityPolicy {
1782 shell_allow: vec!["python3".to_string()],
1783 resources: limits,
1784 ..CapabilityPolicy::default()
1785 };
1786 let mut enforcement = ShellEnforcement::environment_only().with_test_session_dirs();
1787 enforcement.resource_limits = limits;
1788 let enforcement = enforcement.with_host_bounding(scope, None);
1789
1790 let script = "import os, resource, signal, time\n\
1791 children = []\n\
1792 for _ in range(4096):\n\
1793 \x20 try:\n\
1794 \x20 pid = os.fork()\n\
1795 \x20 except OSError:\n\
1796 \x20 break\n\
1797 \x20 if pid == 0:\n\
1798 \x20 time.sleep(30)\n\
1799 \x20 os._exit(0)\n\
1800 \x20 children.append(pid)\n\
1801 for pid in children:\n\
1802 \x20 os.kill(pid, signal.SIGKILL)\n\
1803 for pid in children:\n\
1804 \x20 os.waitpid(pid, 0)\n\
1805 soft = resource.getrlimit(resource.RLIMIT_NPROC)[0]\n\
1806 print(len(children))\n\
1807 print('unlimited' if soft == resource.RLIM_INFINITY else soft)\n";
1808 let result = execute_shell(
1809 "python3",
1810 &["-c", script],
1811 &[],
1812 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1813 &policy,
1814 &enforcement,
1815 )
1816 .expect("python3 must run");
1817
1818 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1819 let mut lines = result.stdout.lines();
1820 let reached: u64 = lines.next().unwrap().parse().unwrap();
1821 let soft = lines.next().unwrap();
1822 assert!(
1823 reached < 16,
1824 "pids.max is 16 and the python process holds one task, so at most 15 children can \
1825 be live; reached {reached}"
1826 );
1827 assert_eq!(
1828 result.resource_limit_hit,
1829 Some("cgroup_pids_max".to_string())
1830 );
1831 let (own_soft, _) = crate::resources::test_support::own_rlimit_nproc();
1832 assert_eq!(
1833 soft,
1834 crate::resources::test_support::rendered_rlimit(own_soft),
1835 "a scoped spawn inherits RLIMIT_NPROC unchanged"
1836 );
1837 }
1838
1839 #[test]
1846 fn home_folder_is_home_and_the_work_folder_gains_nothing() {
1847 let accessible = tempdir().unwrap();
1848 let runtime = tempdir().unwrap();
1849 let enforcement = ShellEnforcement::environment_only()
1850 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
1851 let home = enforcement.home_dir_path().unwrap().to_path_buf();
1852 let policy = CapabilityPolicy {
1853 shell_allow: vec!["bash".to_string()],
1854 ..CapabilityPolicy::default()
1855 };
1856
1857 let result = execute_shell(
1858 "bash",
1859 &[
1860 "-c",
1861 "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && echo \"$HOME\" && pwd",
1862 ],
1863 &[],
1864 &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
1865 &policy,
1866 &enforcement,
1867 )
1868 .expect("bash must run");
1869
1870 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1871 let mut lines = result.stdout.lines();
1872 assert_eq!(lines.next(), Some("marker"));
1873 assert_eq!(lines.next(), Some(home.to_str().unwrap()));
1874 assert_eq!(
1875 std::fs::canonicalize(lines.next().unwrap()).unwrap(),
1876 std::fs::canonicalize(accessible.path()).unwrap(),
1877 "the subprocess cwd is the work folder"
1878 );
1879 assert_eq!(fs::read_to_string(home.join("probe")).unwrap(), "marker\n");
1880 assert_eq!(
1881 fs::read_dir(accessible.path()).unwrap().count(),
1882 0,
1883 "the work folder gains nothing"
1884 );
1885 }
1886
1887 fn print_seq(workdir: &Path, enforcement: &ShellEnforcement, last: u32) -> ShellResult {
1890 let policy = CapabilityPolicy {
1891 shell_allow: vec!["bash".to_string()],
1892 ..CapabilityPolicy::default()
1893 };
1894 let command = format!("seq 1 {last}");
1895 execute_shell(
1896 "bash",
1897 &["-c", &command],
1898 &[],
1899 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
1900 &policy,
1901 enforcement,
1902 )
1903 .expect("bash must run")
1904 }
1905
1906 #[test]
1909 fn a_truncated_output_log_lands_in_the_records_directory() {
1910 let workdir = tempdir().unwrap();
1911 let runtime = tempdir().unwrap();
1912 let enforcement = ShellEnforcement::environment_only()
1913 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
1914 let records_root = enforcement.records_root().unwrap().clone();
1915
1916 let result = print_seq(workdir.path(), &enforcement, 20_000);
1917
1918 assert!(result.truncated);
1919 assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
1920 let path = result
1921 .full_output_path
1922 .expect("a truncated command names its log");
1923 let file_name = Path::new(&path).file_name().unwrap().to_str().unwrap();
1924 let ms = file_name
1925 .strip_prefix("shell-")
1926 .and_then(|rest| rest.strip_suffix(".log"))
1927 .unwrap_or_else(|| panic!("{path} is not logs/shell-<ms>.log"));
1928 assert!(ms.chars().all(|c| c.is_ascii_digit()), "{path}");
1929 assert_eq!(
1930 Path::new(&path),
1931 records_root.host_path("logs").join(file_name),
1932 );
1933 assert!(Path::new(&path).is_absolute(), "{path}");
1934
1935 let log = fs::read_to_string(&path).unwrap();
1936 let expected: String = (1..=20_000).map(|n| format!("{n}\n")).collect();
1937 let stdout = log
1938 .split_once("\nStdout:\n")
1939 .and_then(|(_, rest)| rest.split_once("\n\nStderr:\n"))
1940 .map(|(stdout, _)| stdout)
1941 .expect("the log keeps its Stdout: and Stderr: sections");
1942 assert_eq!(stdout, expected, "the log holds the full stdout");
1943 assert!(log.starts_with("Command: bash -c seq 1 20000\nExit code: 0\n"));
1944
1945 assert!(
1946 !workdir.path().join("logs").exists(),
1947 "nothing is written into the accessible workdir"
1948 );
1949 assert_eq!(fs::read_dir(workdir.path()).unwrap().count(), 0);
1950 }
1951
1952 #[test]
1955 fn with_no_records_directory_a_truncated_command_writes_no_log() {
1956 let runtime = tempdir().unwrap();
1957 let open = |sub: &str| {
1958 let path = runtime.path().join(sub);
1959 fs::create_dir_all(&path).unwrap();
1960 crate::workdir_fs::WorkdirRoot::open(&path).unwrap()
1961 };
1962 let roots_without_records = ShellEnforcement::environment_only().with_runtime_dir(
1963 crate::runtime_dir::RuntimeDirRoots::new(
1964 open(crate::runtime_dir::RUNTIME_SUBDIR),
1965 open(crate::runtime_dir::SEALED_ETC_SUBDIR),
1966 open(crate::runtime_dir::HARNESS_SUBDIR),
1967 )
1968 .with_home(open(crate::records_dir::HOME_SUBDIR)),
1969 None,
1970 );
1971
1972 let enforcement = &roots_without_records;
1973 let workdir = tempdir().unwrap();
1974 let result = print_seq(workdir.path(), enforcement, 20_000);
1975 assert!(result.truncated);
1976 assert_eq!(result.stdout.len(), MAX_SHELL_OUTPUT_BYTES);
1977 assert_eq!(result.full_output_path, None);
1978 assert_eq!(
1979 fs::read_dir(workdir.path()).unwrap().count(),
1980 0,
1981 "nothing is written into the workdir"
1982 );
1983 assert!(!runtime.path().join("logs").exists());
1984 assert!(!runtime
1985 .path()
1986 .join(crate::runtime_dir::RUNTIME_SUBDIR)
1987 .join("logs")
1988 .exists());
1989 }
1990
1991 #[test]
1994 fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
1995 let temp = tempdir().unwrap();
1996 let policy = CapabilityPolicy {
1997 shell_allow: vec!["bash".to_string()],
1998 ..CapabilityPolicy::default()
1999 };
2000
2001 let result = execute_shell(
2002 "bash",
2003 &["-c", "exit 3"],
2004 &[],
2005 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2006 &policy,
2007 &ShellEnforcement::environment_only().with_test_session_dirs(),
2008 )
2009 .expect("bash must run");
2010
2011 assert_eq!(result.exit_code, 3);
2012 assert_eq!(result.resource_limit_hit, None);
2013 }
2014
2015 #[test]
2016 fn execute_shell_blocks_binary_not_in_allowlist() {
2017 let policy = CapabilityPolicy::default();
2018 let error = execute_shell(
2019 "bash",
2020 &["-c", "echo hi"],
2021 &[],
2022 &crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
2023 &policy,
2024 &ShellEnforcement::environment_only().with_test_session_dirs(),
2025 )
2026 .unwrap_err();
2027
2028 assert!(error.to_string().contains("capabilities.shell.allow"));
2029 }
2030
2031 #[test]
2032 fn execute_shell_returns_nonzero_exit_code_without_error() {
2033 let temp = tempdir().unwrap();
2034 let policy = CapabilityPolicy {
2035 shell_allow: vec!["bash".to_string()],
2036 ..CapabilityPolicy::default()
2037 };
2038
2039 let result = execute_shell(
2040 "bash",
2041 &["-c", "exit 42"],
2042 &[],
2043 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2044 &policy,
2045 &ShellEnforcement::environment_only().with_test_session_dirs(),
2046 )
2047 .unwrap();
2048 assert_eq!(result.exit_code, 42);
2049 }
2050
2051 #[test]
2055 fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
2056 let temp = tempdir().unwrap();
2057 let policy = CapabilityPolicy {
2058 shell_allow: vec!["bash".to_string()],
2059 ..CapabilityPolicy::default()
2060 };
2061
2062 let result = execute_shell(
2063 "bash",
2064 &["-c", "exit 0"],
2065 &[],
2066 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2067 &policy,
2068 &ShellEnforcement::environment_only().with_test_session_dirs(),
2069 )
2070 .unwrap();
2071
2072 assert!(
2073 Path::new(&result.binary).is_absolute(),
2074 "a binary found on PATH must be reported as an absolute path, got {:?}",
2075 result.binary
2076 );
2077 assert!(
2078 result.binary.ends_with("bash"),
2079 "the reported path must still name bash, got {:?}",
2080 result.binary
2081 );
2082 }
2083
2084 #[test]
2090 fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
2091 let temp = tempdir().unwrap();
2092 let tool = temp.path().join("local-tool");
2093 fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
2094 let mut perms = fs::metadata(&tool).unwrap().permissions();
2095 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
2096 fs::set_permissions(&tool, perms).unwrap();
2097
2098 let policy = CapabilityPolicy {
2099 shell_allow: vec!["./local-tool".to_string()],
2100 ..CapabilityPolicy::default()
2101 };
2102
2103 let result = execute_shell(
2104 "./local-tool",
2105 &[],
2106 &[],
2107 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2108 &policy,
2109 &ShellEnforcement::environment_only().with_test_session_dirs(),
2110 )
2111 .unwrap();
2112
2113 assert_eq!(result.exit_code, 7, "the subprocess must still have run");
2114 assert_eq!(
2115 result.binary, "./local-tool",
2116 "an unresolvable name degrades to the bare invoked name, never an error"
2117 );
2118 }
2119
2120 #[test]
2121 fn build_shell_env_sets_home_to_the_home_folder() {
2122 let temp = tempdir().unwrap();
2123 let policy = CapabilityPolicy::default();
2124
2125 let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2126
2127 let expected_home = temp.path().to_string_lossy().into_owned();
2128 assert_eq!(env.get("HOME"), Some(&expected_home));
2129 assert_eq!(env.get("USERPROFILE"), Some(&expected_home));
2130 }
2131
2132 #[test]
2133 fn no_home_folder_build_shell_env_refuses() {
2134 let error =
2135 build_shell_env(&CapabilityPolicy::default(), &[], None, None, None, None).unwrap_err();
2136 assert_eq!(error, NO_HOME_FOLDER_REFUSAL);
2137 assert!(error.contains("home folder"), "{error}");
2138 }
2139
2140 #[test]
2144 fn no_home_folder_execute_shell_refuses_and_creates_nothing() {
2145 let parent = tempdir().unwrap();
2146 let workdir = parent.path().join("work");
2147 fs::create_dir(&workdir).unwrap();
2148 let runtime = tempdir().unwrap();
2149 let roots = crate::sandbox::test_runtime_dir(runtime.path());
2150 let without_home = crate::runtime_dir::RuntimeDirRoots {
2151 home: None,
2152 ..roots
2153 };
2154 let policy = CapabilityPolicy {
2155 shell_allow: vec!["bash".to_string()],
2156 ..CapabilityPolicy::default()
2157 };
2158 for enforcement in [
2159 ShellEnforcement::environment_only(),
2160 ShellEnforcement::environment_only().with_runtime_dir(without_home.clone(), None),
2161 ] {
2162 let error = execute_shell(
2163 "bash",
2164 &["-c", "touch ran"],
2165 &[],
2166 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
2167 &policy,
2168 &enforcement,
2169 )
2170 .unwrap_err();
2171 assert!(error.to_string().contains("no home folder"), "{error}");
2172 }
2173 assert_eq!(fs::read_dir(&workdir).unwrap().count(), 0);
2174 assert_eq!(fs::read_dir(parent.path()).unwrap().count(), 1);
2175 }
2176
2177 #[test]
2178 fn execute_shell_reports_the_home_folder_not_real_host_home() {
2179 let temp = tempdir().unwrap();
2180 let runtime = tempdir().unwrap();
2181 let enforcement = ShellEnforcement::environment_only()
2182 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None);
2183 let policy = CapabilityPolicy {
2184 shell_allow: vec!["bash".to_string()],
2185 ..CapabilityPolicy::default()
2186 };
2187
2188 let result = execute_shell(
2189 "bash",
2190 &["-c", "echo -n $HOME"],
2191 &[],
2192 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2193 &policy,
2194 &enforcement,
2195 )
2196 .unwrap();
2197
2198 let expected_home = runtime.path().join("records/home");
2199 assert_eq!(result.stdout, expected_home.to_string_lossy());
2200 if let Ok(real_home) = std::env::var("HOME") {
2201 assert_ne!(result.stdout, real_home);
2202 }
2203 }
2204
2205 #[test]
2206 fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
2207 let temp = tempdir().unwrap();
2208 let policy = CapabilityPolicy::default();
2209 let overrides = vec![
2210 ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
2211 ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
2212 ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
2213 ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
2214 ("SAFE_VAR".to_string(), "kept".to_string()),
2215 ];
2216
2217 let env =
2218 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2219
2220 assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
2221 assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
2222 assert!(!env.contains_key("STRIPE_API_KEY"));
2223 assert!(!env.contains_key("GITHUB_TOKEN"));
2224 assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
2225 }
2226
2227 #[test]
2228 fn declared_env_is_empty_without_declarations() {
2229 std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
2230 let policy = CapabilityPolicy::default();
2231
2232 let env = build_declared_env(&policy);
2233
2234 assert!(env.is_empty());
2235 }
2236
2237 #[test]
2240 fn declared_and_shell_env_never_carry_the_formation_lifeline() {
2241 use crate::lifeline::FORMATION_LIFELINE_ENV;
2242 let _guard = crate::formation::FORMATION_ENV_LOCK
2243 .lock()
2244 .unwrap_or_else(|poisoned| poisoned.into_inner());
2245 let temp = tempdir().unwrap();
2246 std::env::set_var(FORMATION_LIFELINE_ENV, "7");
2247 let policy = CapabilityPolicy {
2248 env_allow: vec![FORMATION_LIFELINE_ENV.to_string()],
2249 shell_baseline_env: vec![FORMATION_LIFELINE_ENV.to_string()],
2250 ..CapabilityPolicy::default()
2251 };
2252 let declared = build_declared_env(&policy);
2253 let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2254 std::env::remove_var(FORMATION_LIFELINE_ENV);
2255 assert!(
2256 !declared.contains_key(FORMATION_LIFELINE_ENV),
2257 "{declared:?}"
2258 );
2259 assert!(!shell.contains_key(FORMATION_LIFELINE_ENV), "{shell:?}");
2260 }
2261
2262 #[test]
2264 fn declared_and_shell_env_never_carry_the_spawner_lifeline() {
2265 use crate::lifeline::SPAWNER_LIFELINE_ENV;
2266 let _guard = crate::formation::FORMATION_ENV_LOCK
2267 .lock()
2268 .unwrap_or_else(|poisoned| poisoned.into_inner());
2269 let temp = tempdir().unwrap();
2270 std::env::set_var(SPAWNER_LIFELINE_ENV, "7");
2271 let policy = CapabilityPolicy {
2272 env_allow: vec![SPAWNER_LIFELINE_ENV.to_string()],
2273 shell_baseline_env: vec![SPAWNER_LIFELINE_ENV.to_string()],
2274 ..CapabilityPolicy::default()
2275 };
2276 let declared = build_declared_env(&policy);
2277 let shell = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2278 std::env::remove_var(SPAWNER_LIFELINE_ENV);
2279 assert!(!declared.contains_key(SPAWNER_LIFELINE_ENV), "{declared:?}");
2280 assert!(!shell.contains_key(SPAWNER_LIFELINE_ENV), "{shell:?}");
2281 }
2282
2283 #[test]
2284 fn declared_env_passes_through_declared_host_var() {
2285 std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
2286 let policy = CapabilityPolicy {
2287 env_allow: vec![
2288 "MURMUR_TEST_WASI_ALLOWED".to_string(),
2289 "MURMUR_TEST_WASI_NEVER_SET".to_string(),
2291 ],
2292 ..CapabilityPolicy::default()
2293 };
2294
2295 let env = build_declared_env(&policy);
2296
2297 assert_eq!(
2298 env.get("MURMUR_TEST_WASI_ALLOWED"),
2299 Some(&"host-value".to_string())
2300 );
2301 assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
2302 }
2303
2304 #[test]
2305 fn declared_env_strips_credential_shaped_declarations() {
2306 std::env::set_var("GITHUB_TOKEN", "leaked-token");
2307 std::env::set_var("STRIPE_API_KEY", "leaked-key");
2308 std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
2309 let policy = CapabilityPolicy {
2310 env_allow: vec![
2311 "GITHUB_TOKEN".to_string(),
2312 "STRIPE_API_KEY".to_string(),
2313 "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
2314 ],
2315 shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
2316 ..CapabilityPolicy::default()
2317 };
2318
2319 let env = build_declared_env(&policy);
2320
2321 assert!(env.is_empty(), "expected all names stripped, got {env:?}");
2323 }
2324
2325 #[test]
2328 fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
2329 std::env::set_var("PRIVATE_KEY", "host-private-key");
2330 std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
2331 let policy = CapabilityPolicy {
2332 env_allow: vec![
2333 "PRIVATE_KEY".to_string(),
2334 "MURMUR_TEST_WASI_PLAIN".to_string(),
2335 ],
2336 ..CapabilityPolicy::default()
2337 };
2338
2339 let env = build_declared_env(&policy);
2340
2341 assert_eq!(
2342 env.get("PRIVATE_KEY"),
2343 Some(&"host-private-key".to_string())
2344 );
2345 assert_eq!(
2346 env.get("MURMUR_TEST_WASI_PLAIN"),
2347 Some(&"host-plain".to_string())
2348 );
2349 }
2350
2351 #[test]
2352 fn build_shell_env_home_override_cannot_survive() {
2353 let temp = tempdir().unwrap();
2354 let policy = CapabilityPolicy {
2355 shell_baseline_env: vec!["HOME".to_string()],
2356 ..CapabilityPolicy::default()
2357 };
2358 let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
2359
2360 let env =
2361 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2362
2363 assert_eq!(
2364 env.get("HOME"),
2365 Some(&temp.path().to_string_lossy().into_owned())
2366 );
2367 }
2368
2369 #[test]
2370 fn build_shell_env_keeps_safe_baseline_vars() {
2371 let temp = tempdir().unwrap();
2372 let policy = CapabilityPolicy::default();
2373
2374 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
2375 let env = build_shell_env(&policy, &[], Some(temp.path()), None, None, None).unwrap();
2376 std::env::remove_var("CARGO_HOME");
2377
2378 assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
2379 }
2380
2381 #[test]
2384 fn build_shell_env_carries_the_hosts_account_names() {
2385 let temp = tempdir().unwrap();
2386 let root = crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap();
2387 let _guard = ACCOUNT_ENV_LOCK
2388 .lock()
2389 .unwrap_or_else(|poisoned| poisoned.into_inner());
2390 std::env::set_var("USER", "murmur-test-user");
2391 std::env::set_var("LOGNAME", "murmur-test-login");
2392
2393 let env = build_shell_env(
2394 &CapabilityPolicy::default(),
2395 &[],
2396 Some(root.path()),
2397 None,
2398 None,
2399 None,
2400 )
2401 .unwrap();
2402 let stripped = build_shell_env(
2403 &CapabilityPolicy {
2404 shell_strip_env: vec!["LOGNAME".to_string()],
2405 ..CapabilityPolicy::default()
2406 },
2407 &[],
2408 Some(root.path()),
2409 None,
2410 None,
2411 None,
2412 )
2413 .unwrap();
2414
2415 assert_eq!(
2416 env.get("USER").map(String::as_str),
2417 Some("murmur-test-user")
2418 );
2419 assert_eq!(
2420 env.get("LOGNAME").map(String::as_str),
2421 Some("murmur-test-login")
2422 );
2423 assert_eq!(
2424 env.get("HOME"),
2425 Some(&temp.path().to_string_lossy().into_owned())
2426 );
2427 assert!(!stripped.contains_key("LOGNAME"), "{stripped:?}");
2428 assert_eq!(
2429 stripped.get("USER").map(String::as_str),
2430 Some("murmur-test-user")
2431 );
2432 let guest = build_declared_env(&CapabilityPolicy::default());
2433 assert!(
2434 !guest.contains_key("USER") && !guest.contains_key("LOGNAME"),
2435 "a WASM guest is handed only what env.allow declares: {guest:?}"
2436 );
2437 }
2438
2439 #[test]
2440 fn host_account_with_both_names_set_reports_none_unset() {
2441 let account = HostAccountEnv::from_pairs(&[("USER", "alice"), ("LOGNAME", "alice-login")]);
2442 assert!(account.unset().is_empty());
2443 let mut env = BTreeMap::new();
2444 account.apply(&mut env);
2445 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2446 assert_eq!(env.get("LOGNAME").map(String::as_str), Some("alice-login"));
2447 }
2448
2449 #[test]
2451 fn host_account_with_one_name_set_never_copies_it_to_the_other() {
2452 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2453 assert_eq!(account.unset(), ["LOGNAME".to_string()]);
2454 let mut env = BTreeMap::new();
2455 account.apply(&mut env);
2456 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2457 assert!(!env.contains_key("LOGNAME"), "{env:?}");
2458 }
2459
2460 #[test]
2461 fn host_account_with_neither_name_set_reports_both_sorted() {
2462 let account = HostAccountEnv::from_pairs(&[]);
2463 assert_eq!(account.unset(), ["LOGNAME".to_string(), "USER".to_string()]);
2464 let mut env = BTreeMap::new();
2465 account.apply(&mut env);
2466 assert!(env.is_empty(), "{env:?}");
2467 }
2468
2469 #[test]
2472 fn host_account_apply_replaces_a_stale_value() {
2473 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
2474 let mut env = BTreeMap::from([
2475 ("USER".to_string(), "someone-else".to_string()),
2476 ("LOGNAME".to_string(), "someone-else".to_string()),
2477 ("PATH".to_string(), "/bin".to_string()),
2478 ]);
2479 account.apply(&mut env);
2480 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
2481 assert!(!env.contains_key("LOGNAME"), "{env:?}");
2482 assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
2483 }
2484
2485 #[test]
2486 fn build_shell_env_developer_declared_strip_env_still_composes() {
2487 let temp = tempdir().unwrap();
2488 let policy = CapabilityPolicy {
2489 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
2490 ..CapabilityPolicy::default()
2491 };
2492 let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
2493
2494 let env =
2495 build_shell_env(&policy, &overrides, Some(temp.path()), None, None, None).unwrap();
2496
2497 assert!(!env.contains_key("MYCOMPANY_SECRET"));
2498 }
2499
2500 #[test]
2504 fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
2505 let extra = vec!["*_SERVICE_SECRET".to_string()];
2506 let names = [
2507 "ANTHROPIC_API_KEY",
2508 "GITHUB_TOKEN",
2509 "GITHUB_TOKEN_2",
2510 "AWS_ACCESS_KEY_ID",
2511 "MY_AWS_KEY",
2512 "STRIPE_API_KEY",
2513 "KUBECONFIG",
2514 "MY_SERVICE_SECRET",
2515 "DATABASE_PASSWORD",
2516 "SLACK_BOT_TOKEN",
2517 "HOME",
2518 "TZ",
2519 ];
2520
2521 let mut env: BTreeMap<String, String> = names
2522 .iter()
2523 .map(|name| ((*name).to_string(), "value".to_string()))
2524 .collect();
2525 strip_credential_shaped_vars(&mut env, &extra);
2526
2527 for name in names {
2528 assert_eq!(
2529 credential_backstop_drops(name, &extra),
2530 !env.contains_key(name),
2531 "prediction and strip disagree about {name}"
2532 );
2533 assert_eq!(
2534 credential_backstop_match(name, &extra).is_some(),
2535 !env.contains_key(name),
2536 "match and strip disagree about {name}"
2537 );
2538 }
2539 }
2540
2541 #[test]
2542 fn credential_backstop_match_attributes_a_builtin_pattern() {
2543 assert_eq!(
2544 credential_backstop_match("AWS_REGION", &[]),
2545 Some(BackstopMatch {
2546 pattern: "AWS_*".to_string(),
2547 source: BackstopPatternSource::Builtin,
2548 })
2549 );
2550 assert_eq!(
2551 credential_backstop_match("GITHUB_TOKEN", &[]),
2552 Some(BackstopMatch {
2553 pattern: "GITHUB_TOKEN".to_string(),
2554 source: BackstopPatternSource::Builtin,
2555 })
2556 );
2557 }
2558
2559 #[test]
2560 fn credential_backstop_match_attributes_a_strip_env_pattern() {
2561 assert_eq!(
2562 credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
2563 Some(BackstopMatch {
2564 pattern: "*_SERVICE_SECRET".to_string(),
2565 source: BackstopPatternSource::StripEnv,
2566 })
2567 );
2568 }
2569
2570 #[test]
2573 fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
2574 assert_eq!(
2575 credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
2576 Some(BackstopMatch {
2577 pattern: "*_API_KEY".to_string(),
2578 source: BackstopPatternSource::Builtin,
2579 })
2580 );
2581 }
2582
2583 #[test]
2584 fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
2585 assert_eq!(credential_backstop_match("TZ", &[]), None);
2586 assert_eq!(
2587 credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
2588 None
2589 );
2590 }
2591
2592 #[test]
2593 fn env_name_matches_pattern_trailing_wildcard() {
2594 assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
2595 assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
2596 }
2597
2598 #[test]
2599 fn env_name_matches_pattern_leading_wildcard() {
2600 assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
2601 assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
2602 }
2603
2604 #[test]
2605 fn env_name_matches_pattern_exact_match() {
2606 assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
2607 assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
2608 }
2609
2610 #[test]
2611 fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
2612 let yaml = shell_tool_manifest_yaml("bash");
2613 serde_norway::from_str::<serde_norway::Value>(&yaml)
2614 .expect("bash manifest must be valid YAML");
2615 }
2616
2617 #[test]
2618 fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
2619 let yaml = shell_tool_manifest_yaml("curl");
2620 serde_norway::from_str::<serde_norway::Value>(&yaml)
2621 .expect("curl manifest must be valid YAML — embedded quotes break YAML parsing");
2622 }
2623
2624 #[test]
2625 fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
2626 let yaml = shell_tool_manifest_yaml("curl");
2627 let value: serde_norway::Value = serde_norway::from_str(&yaml).unwrap();
2628 assert_eq!(
2629 value.get("runtime").and_then(|v| v.as_str()),
2630 Some("tool"),
2631 "non-interpreter manifest must have runtime: tool so inventory picks it up"
2632 );
2633 }
2634
2635 #[test]
2638 fn a_direct_binary_description_states_the_argument_rule_and_nothing_else() {
2639 for binary in ["curl", "git", "jq", "python3"] {
2640 let yaml = shell_tool_manifest_yaml(binary);
2641 let value: serde_norway::Value = serde_norway::from_str(&yaml)
2642 .unwrap_or_else(|e| panic!("{binary} manifest must be valid YAML: {e}"));
2643 let description = value
2644 .get("description")
2645 .and_then(|v| v.as_str())
2646 .expect("the manifest has a description");
2647 assert_eq!(
2648 description,
2649 format!(
2650 "Run {binary} in the capsule workdir. The `command` field holds {binary}'s \
2651 arguments and not the binary name. It is split into arguments at whitespace; \
2652 single or double quotes group words, and a backslash escapes the next \
2653 character. No shell runs it, so nothing in it is expanded."
2654 )
2655 );
2656 assert!(!description.contains("example.com"), "{description}");
2657 assert!(!description.contains(" -s "), "{description}");
2658 assert_eq!(yaml, shell_tool_manifest_yaml(binary));
2659 }
2660 }
2661
2662 #[test]
2665 fn an_abandoned_command_demotes_before_its_grace() {
2666 use std::sync::Arc;
2667
2668 use crate::{
2669 detached::{AbandonSignal, DetachPolicy, DetachedRegistry},
2670 origin::{TaskOrigin, TaskProvenance},
2671 };
2672
2673 let temp = tempdir().unwrap();
2674 let runtime = tempdir().unwrap();
2675 let policy = CapabilityPolicy {
2676 shell_allow: vec!["bash".to_string()],
2677 ..CapabilityPolicy::default()
2678 };
2679 let (registry, _reports) = DetachedRegistry::new();
2680 let abandon = AbandonSignal::new();
2681 let raiser = {
2682 let abandon = abandon.clone();
2683 std::thread::spawn(move || {
2684 std::thread::sleep(Duration::from_millis(200));
2685 abandon.raise();
2686 })
2687 };
2688
2689 let started = Instant::now();
2690 let outcome = run_shell(
2691 "bash",
2692 &["-c", "sleep 5"],
2695 &[],
2696 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2697 &policy,
2698 &ShellEnforcement::environment_only()
2699 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2700 Some(DetachPolicy {
2701 grace: Duration::from_secs(60),
2702 delivered: true,
2703 registry: Arc::clone(®istry),
2704 command: "sleep 5".to_string(),
2705 context_id: "ctx_abandon_test".to_string(),
2706 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
2707 abandoned: Some(abandon),
2708 canceled: None,
2709 }),
2710 )
2711 .expect("a declared binary runs");
2712 let elapsed = started.elapsed();
2713 raiser.join().unwrap();
2714
2715 let ShellOutcome::Detached(info) = outcome else {
2716 panic!("an abandoned command must demote, got {outcome:?}");
2717 };
2718 assert!(
2719 elapsed < Duration::from_secs(2),
2720 "demotion waited {elapsed:?}, as if the 60 s grace still applied"
2721 );
2722 assert!(
2723 registry
2724 .outstanding()
2725 .iter()
2726 .any(|work| work.work_id == info.work_id),
2727 "the demoted command is registered like one that outran its grace"
2728 );
2729 }
2730
2731 fn cancel_test_policy(
2733 registry: &std::sync::Arc<crate::detached::DetachedRegistry>,
2734 command: &str,
2735 canceled: crate::cancel::CancelSignal,
2736 ) -> crate::detached::DetachPolicy {
2737 use crate::origin::{TaskOrigin, TaskProvenance};
2738 crate::detached::DetachPolicy {
2739 grace: Duration::from_secs(60),
2740 delivered: true,
2741 registry: std::sync::Arc::clone(registry),
2742 command: command.to_string(),
2743 context_id: "ctx_cancel_test".to_string(),
2744 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
2745 abandoned: None,
2746 canceled: Some(canceled),
2747 }
2748 }
2749
2750 #[test]
2753 fn a_canceled_task_demotes_its_foreground_command() {
2754 use crate::{
2755 cancel::{CancelSignal, PHASE_SHELL},
2756 detached::{DetachedRegistry, KillRunningOnDrop},
2757 };
2758
2759 let temp = tempdir().unwrap();
2760 let runtime = tempdir().unwrap();
2761 let policy = CapabilityPolicy {
2762 shell_allow: vec!["bash".to_string()],
2763 ..CapabilityPolicy::default()
2764 };
2765 let (registry, _reports) = DetachedRegistry::new();
2766 let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
2767 let cancel = CancelSignal::new();
2768 let raiser = {
2769 let cancel = cancel.clone();
2770 std::thread::spawn(move || {
2771 std::thread::sleep(Duration::from_millis(200));
2772 cancel.cancel();
2773 })
2774 };
2775
2776 let started = Instant::now();
2777 let outcome = run_shell(
2778 "bash",
2779 &["-c", "sleep 30"],
2780 &[],
2781 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2782 &policy,
2783 &ShellEnforcement::environment_only()
2784 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2785 Some(cancel_test_policy(®istry, "sleep 30", cancel.clone())),
2786 )
2787 .expect("a declared binary runs");
2788 let elapsed = started.elapsed();
2789 raiser.join().unwrap();
2790
2791 let ShellOutcome::Detached(info) = outcome else {
2792 panic!("a canceled task's command must demote, got {outcome:?}");
2793 };
2794 assert!(
2795 elapsed < Duration::from_secs(2),
2796 "demotion waited {elapsed:?}, as if the 60 s grace still applied"
2797 );
2798 assert!(
2799 registry
2800 .outstanding()
2801 .iter()
2802 .any(|work| work.work_id == info.work_id),
2803 "the demoted command is registered, so the cancel residue names it"
2804 );
2805 assert_eq!(cancel.phase(), PHASE_SHELL);
2806 }
2807
2808 #[test]
2811 fn an_earlier_claim_keeps_the_phase_over_the_shell() {
2812 use crate::{
2813 cancel::{CancelSignal, PHASE_INFERENCE},
2814 detached::{DetachedRegistry, KillRunningOnDrop},
2815 };
2816
2817 let temp = tempdir().unwrap();
2818 let runtime = tempdir().unwrap();
2819 let policy = CapabilityPolicy {
2820 shell_allow: vec!["bash".to_string()],
2821 ..CapabilityPolicy::default()
2822 };
2823 let (registry, _reports) = DetachedRegistry::new();
2824 let _kill = KillRunningOnDrop(std::sync::Arc::clone(®istry));
2825 let cancel = CancelSignal::new();
2826 cancel.note_phase(PHASE_INFERENCE);
2827 let raiser = {
2828 let cancel = cancel.clone();
2829 std::thread::spawn(move || {
2830 std::thread::sleep(Duration::from_millis(200));
2831 cancel.cancel();
2832 })
2833 };
2834
2835 let outcome = run_shell(
2836 "bash",
2837 &["-c", "sleep 30"],
2838 &[],
2839 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2840 &policy,
2841 &ShellEnforcement::environment_only()
2842 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2843 Some(cancel_test_policy(®istry, "sleep 30", cancel.clone())),
2844 )
2845 .expect("a declared binary runs");
2846 raiser.join().unwrap();
2847
2848 assert!(matches!(outcome, ShellOutcome::Detached(_)), "{outcome:?}");
2849 assert_eq!(cancel.phase(), PHASE_INFERENCE);
2850 }
2851
2852 #[test]
2854 fn a_canceled_task_starts_no_command() {
2855 use crate::{cancel::CancelSignal, detached::DetachedRegistry};
2856
2857 let temp = tempdir().unwrap();
2858 let runtime = tempdir().unwrap();
2859 let policy = CapabilityPolicy {
2860 shell_allow: vec!["bash".to_string()],
2861 ..CapabilityPolicy::default()
2862 };
2863 let (registry, _reports) = DetachedRegistry::new();
2864 let cancel = CancelSignal::new();
2865 cancel.cancel();
2866
2867 let result = run_shell(
2868 "bash",
2869 &["-c", "touch started"],
2870 &[],
2871 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
2872 &policy,
2873 &ShellEnforcement::environment_only()
2874 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
2875 Some(cancel_test_policy(
2876 ®istry,
2877 "touch started",
2878 cancel.clone(),
2879 )),
2880 );
2881
2882 match result {
2883 Err(ShellExecError::Failed(message)) => assert_eq!(message, CANCELED_BEFORE_SPAWN),
2884 other => panic!("a canceled task's call must be refused, got {other:?}"),
2885 }
2886 assert!(!temp.path().join("started").exists(), "nothing was spawned");
2887 assert!(registry.outstanding().is_empty(), "nothing was registered");
2888 }
2889
2890 fn run_until_stopped(
2893 workdir: &std::path::Path,
2894 runtime: &std::path::Path,
2895 script: &str,
2896 stop: ForegroundStop,
2897 ) -> ForegroundRun {
2898 let mut enforcement = ShellEnforcement::environment_only()
2899 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime), None);
2900 enforcement.resource_limits.max_processes = 1 << 20;
2904 run_shell_until_stopped(
2905 "bash",
2906 &["-c", script],
2907 &[],
2908 &crate::workdir_fs::WorkdirRoot::open(workdir).unwrap(),
2909 &CapabilityPolicy {
2910 shell_allow: vec!["bash".to_string()],
2911 ..CapabilityPolicy::default()
2912 },
2913 &enforcement,
2914 stop,
2915 )
2916 .expect("a declared binary runs")
2917 }
2918
2919 const STOPPABLE_SCRIPT: &str = "sleep 300 & echo $! > sleeper.pid; echo started; sleep 300";
2920
2921 #[test]
2924 fn a_foreground_stop_kills_the_group_on_cancel() {
2925 let temp = tempdir().unwrap();
2926 let runtime = tempdir().unwrap();
2927 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
2928 let cancel = crate::cancel::CancelSignal::new();
2929 let raiser = {
2930 let cancel = cancel.clone();
2931 let pid_file = temp.path().join("sleeper.pid");
2932 std::thread::spawn(move || {
2933 while !pid_file.exists() {
2934 std::thread::sleep(Duration::from_millis(10));
2935 }
2936 std::thread::sleep(Duration::from_millis(100));
2937 cancel.cancel();
2938 Instant::now()
2939 })
2940 };
2941
2942 let run = run_until_stopped(
2943 temp.path(),
2944 runtime.path(),
2945 STOPPABLE_SCRIPT,
2946 ForegroundStop {
2947 canceled: cancel,
2948 timeout: Duration::from_secs(60),
2949 },
2950 );
2951 let raised = raiser.join().unwrap();
2952
2953 assert!(
2954 raised.elapsed() < Duration::from_secs(1),
2955 "the call returned {:?} after the cancel",
2956 raised.elapsed()
2957 );
2958 assert_eq!(run.stopped, Some(ForegroundStopReason::Canceled));
2959 assert!(run.result.stdout.contains("started"), "{:?}", run.result);
2960 assert_eq!(run.result.resource_limit_hit, None);
2961 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
2962 assert!(
2963 process_dead_within(sleeper, Duration::from_secs(1)),
2964 "the background sleeper {sleeper} outlived the stop"
2965 );
2966 }
2967
2968 #[test]
2970 fn a_foreground_stop_kills_the_group_at_the_timeout() {
2971 let temp = tempdir().unwrap();
2972 let runtime = tempdir().unwrap();
2973 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
2974
2975 let started = Instant::now();
2976 let run = run_until_stopped(
2977 temp.path(),
2978 runtime.path(),
2979 STOPPABLE_SCRIPT,
2980 ForegroundStop {
2981 canceled: crate::cancel::CancelSignal::new(),
2982 timeout: Duration::from_secs(1),
2983 },
2984 );
2985 let elapsed = started.elapsed();
2986
2987 assert!(
2988 elapsed >= Duration::from_secs(1) && elapsed < Duration::from_secs(2),
2989 "the call returned after {elapsed:?} for a 1 s timeout"
2990 );
2991 assert_eq!(run.stopped, Some(ForegroundStopReason::TimedOut));
2992 assert!(run.result.stdout.contains("started"), "{:?}", run.result);
2993 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
2994 assert!(
2995 process_dead_within(sleeper, Duration::from_secs(1)),
2996 "the background sleeper {sleeper} outlived the timeout"
2997 );
2998 }
2999
3000 #[test]
3002 fn a_foreground_stop_leaves_a_finished_command_alone() {
3003 let temp = tempdir().unwrap();
3004 let runtime = tempdir().unwrap();
3005 let run = run_until_stopped(
3006 temp.path(),
3007 runtime.path(),
3008 "echo out; echo err >&2; exit 3",
3009 ForegroundStop {
3010 canceled: crate::cancel::CancelSignal::new(),
3011 timeout: Duration::from_secs(60),
3012 },
3013 );
3014 assert_eq!(run.stopped, None);
3015 assert_eq!(run.result.exit_code, 3);
3016 assert_eq!(run.result.stdout, "out\n");
3017 assert_eq!(run.result.stderr, "err\n");
3018 }
3019
3020 fn returns_within_30_s<T: Send + 'static>(run: impl FnOnce() -> T + Send + 'static) -> T {
3022 let (sender, receiver) = std::sync::mpsc::channel();
3023 std::thread::spawn(move || {
3024 let _ = sender.send(run());
3025 });
3026 receiver
3027 .recv_timeout(Duration::from_secs(30))
3028 .unwrap_or_else(|_| panic!("the shell call did not return within 30 s"))
3029 }
3030
3031 #[test]
3034 fn a_background_holder_does_not_hold_a_graced_call() {
3035 use std::sync::Arc;
3036
3037 use crate::{
3038 detached::{DetachPolicy, DetachedRegistry},
3039 origin::{TaskOrigin, TaskProvenance},
3040 };
3041
3042 let temp = tempdir().unwrap();
3043 let runtime = tempdir().unwrap();
3044 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3045 let workdir = temp.path().to_path_buf();
3046 let runtime_base = runtime.path().to_path_buf();
3047 let script = "sleep 300 & echo $! > sleeper.pid; echo started";
3048
3049 let started = Instant::now();
3050 let outcome = returns_within_30_s(move || {
3051 let (registry, _reports) = DetachedRegistry::new();
3052 run_shell(
3053 "bash",
3054 &["-c", script],
3055 &[],
3056 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3057 &CapabilityPolicy {
3058 shell_allow: vec!["bash".to_string()],
3059 ..CapabilityPolicy::default()
3060 },
3061 &ShellEnforcement::environment_only()
3062 .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3063 Some(DetachPolicy {
3064 grace: Duration::from_secs(5),
3065 delivered: true,
3066 registry: Arc::clone(®istry),
3067 command: script.to_string(),
3068 context_id: "ctx_holder_test".to_string(),
3069 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3070 abandoned: None,
3071 canceled: None,
3072 }),
3073 )
3074 .expect("a declared binary runs")
3075 });
3076 let elapsed = started.elapsed();
3077
3078 let ShellOutcome::Finished(result) = outcome else {
3079 panic!("a command that exits at once must finish, got {outcome:?}");
3080 };
3081 assert!(
3082 elapsed < Duration::from_secs(5),
3083 "the call took {elapsed:?}, past its 5 s grace"
3084 );
3085 assert!(result.stdout.contains("started"), "{result:?}");
3086 assert_eq!(result.exit_code, 0, "{result:?}");
3087 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3088 assert!(
3089 process_dead_within(sleeper, Duration::from_secs(2)),
3090 "the background sleeper {sleeper} is still running"
3091 );
3092 }
3093
3094 #[test]
3097 fn a_background_holder_does_not_hold_an_ungraced_call() {
3098 let temp = tempdir().unwrap();
3099 let _sleeper = KillPidFileOnDrop(temp.path().join("sleeper.pid"));
3100 let workdir = temp.path().to_path_buf();
3101
3102 let started = Instant::now();
3103 let result = returns_within_30_s(move || {
3104 execute_shell(
3105 "bash",
3106 &["-c", "sleep 300 & echo $! > sleeper.pid; echo started"],
3107 &[],
3108 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3109 &CapabilityPolicy {
3110 shell_allow: vec!["bash".to_string()],
3111 ..CapabilityPolicy::default()
3112 },
3113 &ShellEnforcement::environment_only().with_test_session_dirs(),
3114 )
3115 .expect("a declared binary runs")
3116 });
3117 let elapsed = started.elapsed();
3118
3119 assert!(
3120 elapsed < Duration::from_secs(10),
3121 "the call took {elapsed:?}"
3122 );
3123 assert!(result.stdout.contains("started"), "{result:?}");
3124 assert_eq!(result.exit_code, 0, "{result:?}");
3125 let sleeper = read_pid(&temp.path().join("sleeper.pid"));
3126 assert!(
3127 process_dead_within(sleeper, Duration::from_secs(2)),
3128 "the background sleeper {sleeper} is still running"
3129 );
3130 assert_eq!(result.output_holders, OutputHolders::Killed);
3131 }
3132
3133 #[test]
3136 fn output_written_just_after_exit_is_kept() {
3137 use std::sync::Arc;
3138
3139 use crate::{
3140 detached::{DetachPolicy, DetachedRegistry},
3141 origin::{TaskOrigin, TaskProvenance},
3142 };
3143
3144 let temp = tempdir().unwrap();
3145 let runtime = tempdir().unwrap();
3146 let (registry, _reports) = DetachedRegistry::new();
3147 let script = "(sleep 0.3; echo late) & echo early";
3148
3149 let outcome = run_shell(
3150 "bash",
3151 &["-c", script],
3152 &[],
3153 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
3154 &CapabilityPolicy {
3155 shell_allow: vec!["bash".to_string()],
3156 ..CapabilityPolicy::default()
3157 },
3158 &ShellEnforcement::environment_only()
3159 .with_runtime_dir(crate::sandbox::test_runtime_dir(runtime.path()), None),
3160 Some(DetachPolicy {
3161 grace: Duration::from_secs(5),
3162 delivered: true,
3163 registry: Arc::clone(®istry),
3164 command: script.to_string(),
3165 context_id: "ctx_late_output_test".to_string(),
3166 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3167 abandoned: None,
3168 canceled: None,
3169 }),
3170 )
3171 .expect("a declared binary runs");
3172
3173 let ShellOutcome::Finished(result) = outcome else {
3174 panic!("a command that exits at once must finish, got {outcome:?}");
3175 };
3176 assert!(result.stdout.contains("early"), "{result:?}");
3177 assert!(result.stdout.contains("late"), "{result:?}");
3178 assert_eq!(result.output_holders, OutputHolders::Closed);
3179 assert!(OutputHolders::Closed.note().is_none());
3180 }
3181
3182 #[cfg(target_os = "linux")]
3185 #[test]
3186 fn a_holder_outside_the_process_group_is_reported_not_waited_for() {
3187 use std::sync::Arc;
3188
3189 use crate::{
3190 detached::{DetachPolicy, DetachedRegistry},
3191 origin::{TaskOrigin, TaskProvenance},
3192 };
3193
3194 let temp = tempdir().unwrap();
3195 let runtime = tempdir().unwrap();
3196 let pid_file = temp.path().join("escaped.pid");
3197 let escaped_guard = KillPidFileOnDrop(pid_file.clone());
3198 let workdir = temp.path().to_path_buf();
3199 let runtime_base = runtime.path().to_path_buf();
3200 let script = "setsid sleep 300 & echo $! > escaped.pid; echo started";
3201
3202 let started = Instant::now();
3203 let outcome = returns_within_30_s(move || {
3204 let (registry, _reports) = DetachedRegistry::new();
3205 run_shell(
3206 "bash",
3207 &["-c", script],
3208 &[],
3209 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
3210 &CapabilityPolicy {
3211 shell_allow: vec!["bash".to_string()],
3212 ..CapabilityPolicy::default()
3213 },
3214 &ShellEnforcement::environment_only()
3215 .with_runtime_dir(crate::sandbox::test_runtime_dir(&runtime_base), None),
3216 Some(DetachPolicy {
3217 grace: Duration::from_secs(5),
3218 delivered: true,
3219 registry: Arc::clone(®istry),
3220 command: script.to_string(),
3221 context_id: "ctx_escaped_test".to_string(),
3222 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
3223 abandoned: None,
3224 canceled: None,
3225 }),
3226 )
3227 .expect("a declared binary runs")
3228 });
3229 let elapsed = started.elapsed();
3230
3231 let ShellOutcome::Finished(result) = outcome else {
3232 panic!("a command that exits at once must finish, got {outcome:?}");
3233 };
3234 let bound = Duration::from_secs(5) + LEFTOVER_KILL_WAIT + Duration::from_secs(2);
3235 assert!(elapsed < bound, "the call took {elapsed:?}, past {bound:?}");
3236 assert_eq!(result.stdout.trim(), "started", "{result:?}");
3237 assert_eq!(result.output_holders, OutputHolders::Escaped);
3238 let escaped = read_pid(&pid_file);
3239 assert!(
3240 !process_dead_within(escaped, Duration::ZERO),
3241 "a holder outside the process group is not the call's to kill"
3242 );
3243 drop(escaped_guard);
3244 assert!(process_dead_within(escaped, Duration::from_secs(2)));
3245 }
3246
3247 #[test]
3248 fn a_shared_window_attributes_no_cgroup_delta() {
3249 assert_eq!(
3250 attribute_resource_limit(None, Some("cgroup_memory_bytes"), true).as_deref(),
3251 Some("cgroup_memory_bytes"),
3252 "a call alone in the scope owns its delta"
3253 );
3254 assert_eq!(
3255 attribute_resource_limit(None, Some("cgroup_memory_bytes"), false),
3256 None,
3257 "a delta from a shared window could be a sibling's"
3258 );
3259 assert_eq!(
3260 attribute_resource_limit(Some("cpu_seconds"), Some("cgroup_memory_bytes"), false)
3261 .as_deref(),
3262 Some("cpu_seconds"),
3263 "the signal is per-process evidence and needs no exclusive window"
3264 );
3265 assert_eq!(attribute_resource_limit(None, None, true), None);
3266 }
3267
3268 #[test]
3272 fn build_shell_env_sets_the_runtime_dir_last_whatever_else_says() {
3273 let workdir = tempfile::tempdir().unwrap();
3274 let root = WorkdirRoot::open(workdir.path()).unwrap();
3275 std::env::set_var(crate::runtime_dir::RUNTIME_DIR_ENV, "/host/value");
3276 let policy = CapabilityPolicy {
3277 shell_baseline_env: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3278 env_allow: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
3279 ..CapabilityPolicy::default()
3280 };
3281 let overrides = [(
3282 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
3283 "/override".to_string(),
3284 )];
3285
3286 let env = build_shell_env(
3287 &policy,
3288 &overrides,
3289 Some(root.path()),
3290 Some(std::path::Path::new("/runtime/dir")),
3291 None,
3292 None,
3293 )
3294 .unwrap();
3295 assert_eq!(
3296 env.get(crate::runtime_dir::RUNTIME_DIR_ENV)
3297 .map(String::as_str),
3298 Some("/runtime/dir")
3299 );
3300
3301 let without =
3302 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3303 assert!(!without.contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3304 assert!(!build_declared_env(&policy).contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
3305 }
3306
3307 #[test]
3311 fn build_shell_env_sets_the_records_dir_last_whatever_else_says() {
3312 let workdir = tempfile::tempdir().unwrap();
3313 let root = WorkdirRoot::open(workdir.path()).unwrap();
3314 std::env::set_var(crate::records_dir::RECORDS_DIR_ENV, "/host/records");
3315 let policy = CapabilityPolicy {
3316 shell_baseline_env: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3317 env_allow: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
3318 ..CapabilityPolicy::default()
3319 };
3320 let overrides = [(
3321 crate::records_dir::RECORDS_DIR_ENV.to_string(),
3322 "/override".to_string(),
3323 )];
3324
3325 let env = build_shell_env(
3326 &policy,
3327 &overrides,
3328 Some(root.path()),
3329 None,
3330 Some(std::path::Path::new("/records/dir")),
3331 None,
3332 )
3333 .unwrap();
3334 assert_eq!(
3335 env.get(crate::records_dir::RECORDS_DIR_ENV)
3336 .map(String::as_str),
3337 Some("/records/dir")
3338 );
3339
3340 let without =
3341 build_shell_env(&policy, &overrides, Some(root.path()), None, None, None).unwrap();
3342 assert!(!without.contains_key(crate::records_dir::RECORDS_DIR_ENV));
3343 assert!(!build_declared_env(&policy).contains_key(crate::records_dir::RECORDS_DIR_ENV));
3344 }
3345
3346 #[test]
3350 fn build_shell_env_owns_tmpdir_tmp_and_temp() {
3351 use crate::temp_dir::{NotMappedReason, TempMapping, TEMP_DIR_ENV};
3352
3353 let workdir = tempfile::tempdir().unwrap();
3354 let root = WorkdirRoot::open(workdir.path()).unwrap();
3355 let base = tempfile::tempdir().unwrap();
3356 let roots = crate::sandbox::test_runtime_dir(base.path());
3357 let folder = roots.temp.as_ref().unwrap().path().to_path_buf();
3358 let host_tmp = std::env::temp_dir().join("murmur-host-tmp");
3361 std::fs::create_dir_all(&host_tmp).unwrap();
3362 let saved: Vec<_> = TEMP_DIR_ENV
3363 .iter()
3364 .map(|name| (*name, std::env::var_os(name)))
3365 .collect();
3366 for name in TEMP_DIR_ENV {
3367 std::env::set_var(name, &host_tmp);
3368 }
3369 let policy = CapabilityPolicy {
3370 shell_baseline_env: TEMP_DIR_ENV.iter().map(|name| name.to_string()).collect(),
3371 ..CapabilityPolicy::default()
3372 };
3373 let overrides: Vec<(String, String)> = TEMP_DIR_ENV
3374 .iter()
3375 .map(|name| (name.to_string(), "/override".to_string()))
3376 .collect();
3377
3378 let held = |tier, mapping| {
3379 crate::sandbox::ShellEnforcement {
3380 tier,
3381 ..crate::sandbox::ShellEnforcement::environment_only()
3382 }
3383 .with_runtime_dir(roots.clone(), None)
3384 .with_temp_mapping(mapping)
3385 };
3386 let folder_text = folder.to_string_lossy().into_owned();
3387 let cases = [
3388 (
3389 held(
3390 crate::sandbox::EnforcementTier::KernelFull,
3391 TempMapping::MountNamespace,
3392 ),
3393 Some("/tmp".to_string()),
3394 ),
3395 (
3396 held(
3397 crate::sandbox::EnforcementTier::KernelSealed,
3398 TempMapping::ComposedRoot,
3399 ),
3400 Some("/tmp".to_string()),
3401 ),
3402 (
3403 held(
3404 crate::sandbox::EnforcementTier::KernelFull,
3405 TempMapping::NotMapped(NotMappedReason::NoMountNamespace),
3406 ),
3407 Some(folder_text.clone()),
3408 ),
3409 (
3410 held(
3411 crate::sandbox::EnforcementTier::KernelSeccompOnly,
3412 TempMapping::NotMapped(NotMappedReason::NoMountNamespaceNoLandlock),
3413 ),
3414 Some(folder_text.clone()),
3415 ),
3416 (
3417 held(
3418 crate::sandbox::EnforcementTier::KernelFull,
3419 TempMapping::NotMapped(NotMappedReason::WorkdirIsTmp),
3420 ),
3421 Some(folder_text.clone()),
3422 ),
3423 (
3424 held(
3425 crate::sandbox::EnforcementTier::EnvironmentOnly,
3426 TempMapping::NotMapped(NotMappedReason::NoKernelContainment),
3427 ),
3428 Some(folder_text.clone()),
3429 ),
3430 (crate::sandbox::ShellEnforcement::environment_only(), None),
3431 ];
3432 for (enforcement, expected) in cases {
3433 assert_eq!(
3434 enforcement.tmpdir_value(),
3435 expected,
3436 "{:?}",
3437 enforcement.tier
3438 );
3439 let env = build_shell_env(
3440 &policy,
3441 &overrides,
3442 Some(root.path()),
3443 None,
3444 None,
3445 enforcement.tmpdir_value().as_deref(),
3446 )
3447 .unwrap();
3448 for name in TEMP_DIR_ENV {
3449 assert_eq!(
3450 env.get(name),
3451 expected.as_ref(),
3452 "{name} on {:?}",
3453 enforcement.tier
3454 );
3455 }
3456 }
3457 for (name, value) in saved {
3458 match value {
3459 Some(value) => std::env::set_var(name, value),
3460 None => std::env::remove_var(name),
3461 }
3462 }
3463 }
3464
3465 #[test]
3467 fn the_env_baseline_names_no_temp_variable() {
3468 for name in crate::temp_dir::TEMP_DIR_ENV {
3469 assert!(!DEFAULT_ENV_BASELINE.contains(&name), "{name}");
3470 }
3471 }
3472}
3473
3474#[cfg(test)]
3476pub(crate) mod test_support {
3477 use std::path::{Path, PathBuf};
3478 use std::time::{Duration, Instant};
3479
3480 pub(crate) fn read_pid(pid_file: &Path) -> u32 {
3482 std::fs::read_to_string(pid_file)
3483 .expect("the command wrote its pid file")
3484 .trim()
3485 .parse()
3486 .expect("the pid file holds a pid")
3487 }
3488
3489 pub(crate) fn process_dead_within(pid: u32, timeout: Duration) -> bool {
3492 let deadline = Instant::now() + timeout;
3493 loop {
3494 if !crate::running::pid_is_alive(pid) {
3495 return true;
3496 }
3497 if Instant::now() >= deadline {
3498 return false;
3499 }
3500 std::thread::sleep(Duration::from_millis(20));
3501 }
3502 }
3503
3504 pub(crate) struct KillPidFileOnDrop(pub(crate) PathBuf);
3508
3509 impl Drop for KillPidFileOnDrop {
3510 fn drop(&mut self) {
3511 let Some(pid) = std::fs::read_to_string(&self.0)
3512 .ok()
3513 .and_then(|text| text.trim().parse::<libc::pid_t>().ok())
3514 .filter(|pid| *pid > 0)
3515 else {
3516 return;
3517 };
3518 if !process_dead_within(pid.unsigned_abs(), Duration::ZERO) {
3519 #[allow(unsafe_code)]
3522 unsafe {
3523 libc::kill(pid, libc::SIGKILL);
3524 }
3525 }
3526 }
3527 }
3528}