1use std::{
2 collections::BTreeMap,
3 fs,
4 path::{Path, PathBuf},
5 process::{Command, Stdio},
6 time::{Duration, Instant},
7};
8
9use crate::{
10 artifact_config::ARTIFACT_CONFIG_ENV,
11 detached::{DetachPolicy, DetachedDispatchInfo},
12 types::CapabilityPolicy,
13};
14
15const MAX_SHELL_OUTPUT_BYTES: usize = 16 * 1024;
16
17const SHELL_INTERPRETERS: &[&str] = &["bash", "sh", "zsh", "fish", "dash", "ksh"];
18const DEFAULT_ENV_BASELINE: &[&str] = &[
19 "PATH",
20 "HOME",
21 "USER",
22 "LANG",
23 "LC_ALL",
24 "TMPDIR",
25 "TEMP",
26 "TMP",
27 "CARGO_HOME",
28 "RUSTUP_HOME",
29 "TERM",
30];
31
32pub(crate) const SYNTHETIC_HOME_DIR_NAME: &str = ".capsule-home";
45
46const CREDENTIAL_ENV_PATTERNS: &[&str] = &[
51 "ANTHROPIC_API_KEY",
52 "OPENAI_API_KEY",
53 "GOOGLE_API_KEY",
54 "HUGGING_FACE_HUB_TOKEN",
55 "NEXUS_API_KEY",
56 "AWS_*",
57 "GITHUB_TOKEN",
58 "GH_TOKEN",
59 "DOCKER_*",
60 "KUBECONFIG",
61 "NPM_TOKEN",
62 "PYPI_TOKEN",
63 "CARGO_REGISTRY_TOKEN",
64 "*_API_KEY",
65];
66
67#[derive(Debug)]
68pub(crate) struct ShellResult {
69 pub(crate) binary: String,
75 pub(crate) exit_code: i32,
76 pub(crate) stdout: String,
77 pub(crate) stderr: String,
78 pub(crate) duration_ms: u64,
79 pub(crate) truncated: bool,
80 pub(crate) full_output_path: Option<String>,
81 pub(crate) resource_limit_hit: Option<String>,
86}
87
88#[derive(Debug)]
98pub(crate) enum ShellExecError {
99 Failed(String),
103 SealedRootConstructionFailed { detail: String },
107}
108
109impl ShellExecError {
110 pub(crate) fn session_fatal(&self) -> Option<crate::errors::RuntimeError> {
116 match self {
117 Self::Failed(_) => None,
118 Self::SealedRootConstructionFailed { detail } => {
119 Some(crate::errors::RuntimeError::SealedRootConstructionFailed {
120 detail: detail.clone(),
121 })
122 }
123 }
124 }
125}
126
127impl std::fmt::Display for ShellExecError {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 match self {
130 Self::Failed(message) => f.write_str(message),
131 Self::SealedRootConstructionFailed { detail } => write!(
135 f,
136 "{}",
137 crate::errors::RuntimeError::SealedRootConstructionFailed {
138 detail: detail.clone(),
139 }
140 ),
141 }
142 }
143}
144
145impl From<String> for ShellExecError {
147 fn from(message: String) -> Self {
148 Self::Failed(message)
149 }
150}
151
152pub(crate) fn is_shell_interpreter(binary: &str) -> bool {
153 SHELL_INTERPRETERS.contains(&binary)
154}
155
156pub(crate) fn split_shell_words(command: &str) -> Vec<String> {
157 let mut words = Vec::new();
158 let mut current = String::new();
159 let mut quote = None;
160 let mut escaped = false;
161
162 for ch in command.chars() {
163 if escaped {
164 current.push(ch);
165 escaped = false;
166 continue;
167 }
168 if ch == '\\' {
169 escaped = true;
170 continue;
171 }
172 match (ch, quote) {
173 ('"' | '\'', None) => quote = Some(ch),
174 (q, Some(open)) if q == open => quote = None,
175 (c, None) if c.is_whitespace() => {
176 if !current.is_empty() {
177 words.push(std::mem::take(&mut current));
178 }
179 }
180 _ => current.push(ch),
181 }
182 }
183
184 if !current.is_empty() {
185 words.push(current);
186 }
187 words
188}
189
190pub(crate) fn shell_tool_manifest_yaml(binary: &str) -> String {
191 let description = if is_shell_interpreter(binary) {
192 format!("Run a shell command via {binary} in the capsule workdir. The `command` field is passed to {binary} via -c.")
193 } else {
194 format!("Run {binary} in the capsule workdir. The `command` field is the argument list — omit the binary name itself (pass -s http://example.com, not {binary} -s http://example.com).")
195 };
196
197 format!(
198 "name: {binary}\nversion: 0.0.0\nruntime: tool\nimplementation: native\ndescription: \"{description}\"\ninput_schema: '{{\"type\":\"object\",\"properties\":{{\"command\":{{\"type\":\"string\"}}}},\"required\":[\"command\"]}}'\n"
199 )
200}
201
202#[derive(Debug)]
204pub(crate) enum ShellOutcome {
205 Finished(ShellResult),
206 Detached(DetachedDispatchInfo),
207}
208
209const DETACH_POLL_INTERVAL: Duration = Duration::from_millis(20);
216
217#[allow(clippy::too_many_arguments)]
231pub(crate) fn run_shell(
232 binary: &str,
233 args: &[&str],
234 env_overrides: &[(String, String)],
235 accessible_workdir: &Path,
236 session_workdir: &Path,
237 policy: &CapabilityPolicy,
238 enforcement: &crate::sandbox::ShellEnforcement,
239 detach: Option<DetachPolicy>,
240) -> Result<ShellOutcome, ShellExecError> {
241 if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
242 return Err(ShellExecError::Failed(format!(
243 "binary '{binary}' is not in capabilities.shell.allow"
244 )));
245 }
246
247 enforcement.check_workdir_budget()?;
250
251 let env = build_shell_env(policy, env_overrides, session_workdir)?;
252
253 let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
256
257 let started = Instant::now();
258 let mut command = Command::new(binary);
259 command
260 .args(args)
261 .current_dir(accessible_workdir)
262 .env_clear()
263 .envs(env)
264 .stdout(Stdio::piped())
265 .stderr(Stdio::piped());
266
267 let supervisor = crate::sandbox::prepare_enforcement(
272 &mut command,
273 enforcement,
274 accessible_workdir,
275 session_workdir,
276 )?;
277
278 let cgroup_occupancy = enforcement.cgroup_scope.as_ref().map(|scope| scope.enter());
282 let cgroup_counters_before = enforcement
285 .cgroup_scope
286 .as_ref()
287 .map(|scope| scope.event_counters());
288
289 let mut child = match command.spawn() {
290 Ok(child) => child,
291 Err(error) => {
292 drop(command);
299 return Err(match supervisor.read_diagnostic() {
300 Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
306 ShellExecError::SealedRootConstructionFailed { detail }
307 }
308 Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
309 "sandbox: shell enforcement setup failed before exec: {detail}"
310 )),
311 _ => ShellExecError::Failed(error.to_string()),
312 });
313 }
314 };
315
316 let readers = OutputReaders::spawn(&mut child);
320
321 let status = match &detach {
322 None => child.wait().map_err(|error| error.to_string())?,
323 Some(policy) => {
324 let deadline = started + policy.grace;
325 loop {
326 match child.try_wait().map_err(|error| error.to_string())? {
327 Some(status) => break status,
328 None => {
329 let remaining = deadline.saturating_duration_since(Instant::now());
330 let abandoned = policy
333 .abandoned
334 .as_ref()
335 .is_some_and(crate::detached::AbandonSignal::is_raised);
336 if remaining.is_zero() || abandoned {
337 return Ok(ShellOutcome::Detached(demote(
339 child,
340 readers,
341 supervisor,
342 DemotionContext {
343 policy: detach.expect("the detach policy was matched above"),
344 accessible_workdir: accessible_workdir.to_path_buf(),
345 invoked_binary: binary.to_string(),
346 resolved_binary,
347 args: args.iter().map(|arg| (*arg).to_string()).collect(),
348 started,
349 cgroup_scope: enforcement.cgroup_scope.clone(),
350 cgroup_counters_before,
351 cgroup_occupancy,
352 },
353 )));
354 }
355 std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
356 }
357 }
358 }
359 }
360 };
361 let (stdout_bytes, stderr_bytes) = readers.join();
362 supervisor.join_best_effort();
363 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
364
365 let resource_limit_hit = classify_resource_limit(
366 &status,
367 enforcement.cgroup_scope.as_deref(),
368 cgroup_counters_before,
369 cgroup_occupancy.as_ref(),
370 );
371 drop(cgroup_occupancy);
372 let exit_code = exit_code_of(&status);
373
374 let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
375 let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
376 let mut truncated = false;
377 let mut full_output_path = None;
378
379 if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
380 truncated = true;
381 let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
382 let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
383 let full_stdout = std::mem::replace(
384 &mut stdout,
385 String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
386 );
387 let full_stderr = std::mem::replace(
388 &mut stderr,
389 String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
390 );
391 full_output_path = Some(write_shell_output_log(
392 accessible_workdir,
393 &format!("shell-{}", crate::trace::timestamp_ms()),
394 binary,
395 args,
396 exit_code,
397 &full_stdout,
398 &full_stderr,
399 )?);
400 }
401
402 Ok(ShellOutcome::Finished(ShellResult {
403 binary: resolved_binary,
404 exit_code,
405 stdout,
406 stderr,
407 duration_ms,
408 truncated,
409 full_output_path,
410 resource_limit_hit,
411 }))
412}
413
414pub(crate) fn execute_shell(
419 binary: &str,
420 args: &[&str],
421 env_overrides: &[(String, String)],
422 accessible_workdir: &Path,
423 session_workdir: &Path,
424 policy: &CapabilityPolicy,
425 enforcement: &crate::sandbox::ShellEnforcement,
426) -> Result<ShellResult, ShellExecError> {
427 match run_shell(
428 binary,
429 args,
430 env_overrides,
431 accessible_workdir,
432 session_workdir,
433 policy,
434 enforcement,
435 None,
436 )? {
437 ShellOutcome::Finished(result) => Ok(result),
438 ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
442 "shell command was demoted to the background as {} on a call site that cannot receive a completion",
443 info.work_id
444 ))),
445 }
446}
447
448struct OutputReaders {
454 stdout: Option<std::thread::JoinHandle<Vec<u8>>>,
455 stderr: Option<std::thread::JoinHandle<Vec<u8>>>,
456}
457
458impl OutputReaders {
459 fn spawn(child: &mut std::process::Child) -> Self {
460 fn drain(
461 pipe: Option<impl std::io::Read + Send + 'static>,
462 ) -> Option<std::thread::JoinHandle<Vec<u8>>> {
463 let mut pipe = pipe?;
464 Some(std::thread::spawn(move || {
465 let mut buffer = Vec::new();
466 let _ = pipe.read_to_end(&mut buffer);
470 buffer
471 }))
472 }
473
474 Self {
475 stdout: drain(child.stdout.take()),
476 stderr: drain(child.stderr.take()),
477 }
478 }
479
480 fn join(self) -> (Vec<u8>, Vec<u8>) {
483 let take = |handle: Option<std::thread::JoinHandle<Vec<u8>>>| {
484 handle
485 .map(|handle| handle.join().unwrap_or_default())
486 .unwrap_or_default()
487 };
488 (take(self.stdout), take(self.stderr))
489 }
490}
491
492struct DemotionContext {
494 policy: DetachPolicy,
495 accessible_workdir: PathBuf,
498 invoked_binary: String,
500 resolved_binary: String,
503 args: Vec<String>,
504 started: Instant,
505 cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
506 cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
507 cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
509}
510
511fn demote(
516 mut child: std::process::Child,
517 readers: OutputReaders,
518 supervisor: crate::sandbox::SupervisorHandle,
519 context: DemotionContext,
520) -> DetachedDispatchInfo {
521 let work_id = crate::detached::new_work_id();
522 let info = DetachedDispatchInfo {
523 work_id: work_id.clone(),
524 binary: context.resolved_binary.clone(),
525 command: context.policy.command.clone(),
526 grace_ms: context
527 .policy
528 .grace
529 .as_millis()
530 .try_into()
531 .unwrap_or(u64::MAX),
532 };
533
534 context
535 .policy
536 .registry
537 .register(crate::detached::DetachedWork {
538 work_id: work_id.clone(),
539 binary: context.resolved_binary.clone(),
540 command: context.policy.command.clone(),
541 started_at_ms: crate::trace::timestamp_ms().saturating_sub(
547 context
548 .started
549 .elapsed()
550 .as_millis()
551 .try_into()
552 .unwrap_or(u64::MAX),
553 ),
554 });
555
556 let provenance = context.policy.completion_provenance();
557 let DemotionContext {
558 policy,
559 accessible_workdir,
560 invoked_binary,
561 resolved_binary,
562 args,
563 started,
564 cgroup_scope,
565 cgroup_counters_before,
566 cgroup_occupancy,
567 } = context;
568
569 std::thread::spawn(move || {
570 let waited = child.wait();
571 let (stdout_bytes, stderr_bytes) = readers.join();
572 supervisor.join_best_effort();
576 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
577
578 let (exit_code, resource_limit, mut error) = match waited {
579 Ok(status) => (
580 exit_code_of(&status),
581 classify_resource_limit(
582 &status,
583 cgroup_scope.as_deref(),
584 cgroup_counters_before,
585 cgroup_occupancy.as_ref(),
586 ),
587 None,
588 ),
589 Err(failure) => (
590 -1,
591 None,
592 Some(format!("waiting for the command failed: {failure}")),
593 ),
594 };
595 drop(cgroup_occupancy);
596
597 let stdout = String::from_utf8_lossy(&stdout_bytes).into_owned();
598 let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
599 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
600 let output_path = crate::detached::output_path_for(&work_id);
601 if let Err(failure) = write_shell_output_log(
602 &accessible_workdir,
603 &work_id,
604 &invoked_binary,
605 &arg_refs,
606 exit_code,
607 &stdout,
608 &stderr,
609 ) {
610 error = Some(match error {
611 Some(existing) => format!("{existing}; {failure}"),
612 None => failure,
613 });
614 }
615 let output_bytes = std::fs::metadata(accessible_workdir.join(&output_path))
616 .map(|metadata| metadata.len())
617 .unwrap_or(0);
618
619 policy
620 .registry
621 .complete(crate::detached::DetachedCompletion {
622 work_id,
623 binary: resolved_binary,
624 command: policy.command.clone(),
625 exit_code,
626 duration_ms,
627 output_path,
628 output_bytes,
629 resource_limit,
630 context_id: policy.context_id.clone(),
631 provenance,
632 error,
633 });
634 });
635
636 info
637}
638
639pub(crate) fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
647 use std::os::unix::process::ExitStatusExt;
648
649 status
650 .code()
651 .or_else(|| status.signal().map(|signal| 128 + signal))
652 .unwrap_or(-1)
653}
654
655fn classify_resource_limit(
677 status: &std::process::ExitStatus,
678 cgroup_scope: Option<&crate::cgroup::CgroupScope>,
679 counters_before: Option<crate::cgroup::CgroupEventCounters>,
680 occupancy: Option<&crate::cgroup::ScopeOccupancy>,
681) -> Option<String> {
682 use std::os::unix::process::ExitStatusExt;
683
684 let signal_limit = status
685 .signal()
686 .and_then(crate::resources::limit_from_signal);
687 let counter_delta = match (cgroup_scope, counters_before) {
688 (Some(scope), Some(before)) => scope.event_counters().attribution_since(before),
689 _ => None,
690 };
691 let exclusive = occupancy.is_some_and(crate::cgroup::ScopeOccupancy::exclusive);
692 attribute_resource_limit(signal_limit, counter_delta, exclusive)
693}
694
695pub(crate) fn attribute_resource_limit(
702 signal_limit: Option<&'static str>,
703 counter_delta: Option<&'static str>,
704 exclusive: bool,
705) -> Option<String> {
706 signal_limit
707 .or(counter_delta.filter(|_| exclusive))
708 .map(str::to_string)
709}
710
711pub(crate) fn build_shell_env(
712 policy: &CapabilityPolicy,
713 env_overrides: &[(String, String)],
714 session_workdir: &Path,
715) -> Result<BTreeMap<String, String>, String> {
716 let mut env = BTreeMap::new();
717
718 for key in DEFAULT_ENV_BASELINE {
719 if let Ok(value) = std::env::var(key) {
720 env.insert((*key).to_string(), value);
721 }
722 }
723
724 for (key, value) in env_overrides {
725 env.insert(key.clone(), value.clone());
726 }
727
728 for key in &policy.shell_baseline_env {
729 if crate::formation::is_member_grant_env(key)
732 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
733 {
734 continue;
735 }
736 if let Ok(value) = std::env::var(key) {
737 env.insert(key.clone(), value);
738 }
739 }
740
741 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
742
743 let synthetic_home = synthetic_home_dir(session_workdir)?;
747 let synthetic_home = synthetic_home.to_string_lossy().into_owned();
748 env.insert("HOME".to_string(), synthetic_home.clone());
749 env.insert("USERPROFILE".to_string(), synthetic_home);
750
751 Ok(env)
752}
753
754pub(crate) fn strip_credential_shaped_vars(
762 env: &mut BTreeMap<String, String>,
763 extra_patterns: &[String],
764) {
765 env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
766}
767
768#[derive(Debug, Clone, PartialEq, Eq)]
770pub enum BackstopPatternSource {
771 Builtin,
773 StripEnv,
775}
776
777#[derive(Debug, Clone, PartialEq, Eq)]
779pub struct BackstopMatch {
780 pub pattern: String,
781 pub source: BackstopPatternSource,
782}
783
784pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
793 CREDENTIAL_ENV_PATTERNS
794 .iter()
795 .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
796 .chain(
797 extra_patterns
798 .iter()
799 .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
800 )
801 .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
802 .map(|(pattern, source)| BackstopMatch {
803 pattern: pattern.to_string(),
804 source,
805 })
806}
807
808pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
812 credential_backstop_match(name, extra_patterns).is_some()
813}
814
815pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
831 let mut env = BTreeMap::new();
832
833 for key in &policy.env_allow {
834 if key == ARTIFACT_CONFIG_ENV
835 || crate::formation::is_member_grant_env(key)
836 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
837 {
838 continue;
839 }
840 if let Ok(value) = std::env::var(key) {
841 env.insert(key.clone(), value);
842 }
843 }
844
845 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
846
847 env
848}
849
850fn synthetic_home_dir(session_workdir: &Path) -> Result<std::path::PathBuf, String> {
851 let home = session_workdir.join(SYNTHETIC_HOME_DIR_NAME);
852 fs::create_dir_all(&home).map_err(|error| {
853 format!(
854 "failed to create synthetic home directory {}: {error}",
855 home.display()
856 )
857 })?;
858 Ok(home)
859}
860
861fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
862 if let Some(rest) = pattern.strip_prefix('*') {
863 if let Some(middle) = rest.strip_suffix('*') {
864 return key.contains(middle);
865 }
866 return key.ends_with(rest);
867 }
868
869 if let Some(prefix) = pattern.strip_suffix('*') {
870 return key.starts_with(prefix);
871 }
872
873 pattern == key
874}
875
876fn write_shell_output_log(
883 accessible_workdir: &Path,
884 file_stem: &str,
885 binary: &str,
886 args: &[&str],
887 exit_code: i32,
888 stdout: &str,
889 stderr: &str,
890) -> Result<String, String> {
891 let logs_dir = accessible_workdir.join("logs");
892 fs::create_dir_all(&logs_dir)
893 .map_err(|error| format!("failed to create shell logs directory: {error}"))?;
894
895 let filename = format!("{file_stem}.log");
896 let relative_path = format!("logs/{filename}");
897 let path = logs_dir.join(&filename);
898
899 let command = if args.is_empty() {
900 binary.to_string()
901 } else {
902 format!("{binary} {}", args.join(" "))
903 };
904
905 let content = format!(
906 "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
907 );
908
909 fs::write(&path, content)
910 .map_err(|error| format!("failed to write shell log {}: {error}", path.display()))?;
911
912 Ok(relative_path)
913}
914
915#[cfg(test)]
916mod tests {
917 use tempfile::tempdir;
918
919 use super::*;
920 use crate::sandbox::ShellEnforcement;
921
922 #[test]
928 fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
929 let temp = tempdir().unwrap();
930 let policy = CapabilityPolicy {
931 shell_allow: vec!["bash".to_string()],
932 resources: crate::resources::HostResourceLimits {
933 max_open_files: 64,
934 ..crate::resources::HostResourceLimits::default()
935 },
936 ..CapabilityPolicy::default()
937 };
938 let mut enforcement = ShellEnforcement::environment_only();
939 enforcement.resource_limits = policy.resources;
940
941 let result = execute_shell(
942 "bash",
943 &["-c", "ulimit -Hn"],
944 &[],
945 temp.path(),
946 temp.path(),
947 &policy,
948 &enforcement,
949 )
950 .expect("bash must run");
951
952 assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
953 assert_eq!(result.resource_limit_hit, None);
954 }
955
956 #[cfg(target_os = "linux")]
960 #[test]
961 fn a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max() {
962 const TEST: &str = "a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max";
963 if !crate::cgroup::cgroup_delegation_available() {
964 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
965 return;
966 }
967
968 let temp = tempdir().unwrap();
969 let limits = crate::resources::HostResourceLimits {
970 cgroup_pids_max: 16,
971 ..crate::resources::HostResourceLimits::default()
972 };
973 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_fork_bomb", temp.path())
974 .expect("this host delegates a cgroup scope")
975 .scope;
976 let policy = CapabilityPolicy {
977 shell_allow: vec!["python3".to_string()],
978 resources: limits,
979 ..CapabilityPolicy::default()
980 };
981 let mut enforcement = ShellEnforcement::environment_only();
982 enforcement.resource_limits = limits;
983 let enforcement = enforcement.with_host_bounding(scope, None);
984
985 let script = "import os, resource, signal, time\n\
986 children = []\n\
987 for _ in range(4096):\n\
988 \x20 try:\n\
989 \x20 pid = os.fork()\n\
990 \x20 except OSError:\n\
991 \x20 break\n\
992 \x20 if pid == 0:\n\
993 \x20 time.sleep(30)\n\
994 \x20 os._exit(0)\n\
995 \x20 children.append(pid)\n\
996 for pid in children:\n\
997 \x20 os.kill(pid, signal.SIGKILL)\n\
998 for pid in children:\n\
999 \x20 os.waitpid(pid, 0)\n\
1000 soft = resource.getrlimit(resource.RLIMIT_NPROC)[0]\n\
1001 print(len(children))\n\
1002 print('unlimited' if soft == resource.RLIM_INFINITY else soft)\n";
1003 let result = execute_shell(
1004 "python3",
1005 &["-c", script],
1006 &[],
1007 temp.path(),
1008 temp.path(),
1009 &policy,
1010 &enforcement,
1011 )
1012 .expect("python3 must run");
1013
1014 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1015 let mut lines = result.stdout.lines();
1016 let reached: u64 = lines.next().unwrap().parse().unwrap();
1017 let soft = lines.next().unwrap();
1018 assert!(
1019 reached < 16,
1020 "pids.max is 16 and the python process holds one task, so at most 15 children can \
1021 be live; reached {reached}"
1022 );
1023 assert_eq!(
1024 result.resource_limit_hit,
1025 Some("cgroup_pids_max".to_string())
1026 );
1027 let (own_soft, _) = crate::resources::test_support::own_rlimit_nproc();
1028 assert_eq!(
1029 soft,
1030 crate::resources::test_support::rendered_rlimit(own_soft),
1031 "a scoped spawn inherits RLIMIT_NPROC unchanged"
1032 );
1033 }
1034
1035 #[test]
1044 fn a_shell_command_runs_in_the_accessible_workdir_with_home_inside_the_session_one() {
1045 let accessible = tempdir().unwrap();
1046 let session = accessible.path().join(".murmur").join("ses_shell_test");
1047 fs::create_dir_all(&session).unwrap();
1048 let policy = CapabilityPolicy {
1049 shell_allow: vec!["bash".to_string()],
1050 ..CapabilityPolicy::default()
1051 };
1052
1053 let result = execute_shell(
1054 "bash",
1055 &[
1056 "-c",
1057 "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && pwd",
1058 ],
1059 &[],
1060 accessible.path(),
1061 &session,
1062 &policy,
1063 &ShellEnforcement::environment_only(),
1064 )
1065 .expect("bash must run");
1066
1067 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1068 let mut lines = result.stdout.lines();
1069 assert_eq!(lines.next(), Some("marker"));
1070 assert_eq!(
1071 std::fs::canonicalize(lines.next().unwrap()).unwrap(),
1072 std::fs::canonicalize(accessible.path()).unwrap(),
1073 "the subprocess cwd is the directory the capsule was asked to produce"
1074 );
1075
1076 assert_eq!(
1077 fs::read_to_string(session.join(SYNTHETIC_HOME_DIR_NAME).join("probe")).unwrap(),
1078 "marker\n"
1079 );
1080 assert!(
1081 !accessible.path().join(SYNTHETIC_HOME_DIR_NAME).exists(),
1082 "the synthetic home must add nothing to the top level of the accessible workdir"
1083 );
1084 }
1085
1086 #[test]
1089 fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
1090 let temp = tempdir().unwrap();
1091 let policy = CapabilityPolicy {
1092 shell_allow: vec!["bash".to_string()],
1093 ..CapabilityPolicy::default()
1094 };
1095
1096 let result = execute_shell(
1097 "bash",
1098 &["-c", "exit 3"],
1099 &[],
1100 temp.path(),
1101 temp.path(),
1102 &policy,
1103 &ShellEnforcement::environment_only(),
1104 )
1105 .expect("bash must run");
1106
1107 assert_eq!(result.exit_code, 3);
1108 assert_eq!(result.resource_limit_hit, None);
1109 }
1110
1111 #[test]
1112 fn execute_shell_blocks_binary_not_in_allowlist() {
1113 let policy = CapabilityPolicy::default();
1114 let error = execute_shell(
1115 "bash",
1116 &["-c", "echo hi"],
1117 &[],
1118 Path::new("."),
1119 Path::new("."),
1120 &policy,
1121 &ShellEnforcement::environment_only(),
1122 )
1123 .unwrap_err();
1124
1125 assert!(error.to_string().contains("capabilities.shell.allow"));
1126 }
1127
1128 #[test]
1129 fn execute_shell_returns_nonzero_exit_code_without_error() {
1130 let temp = tempdir().unwrap();
1131 let policy = CapabilityPolicy {
1132 shell_allow: vec!["bash".to_string()],
1133 ..CapabilityPolicy::default()
1134 };
1135
1136 let result = execute_shell(
1137 "bash",
1138 &["-c", "exit 42"],
1139 &[],
1140 temp.path(),
1141 temp.path(),
1142 &policy,
1143 &ShellEnforcement::environment_only(),
1144 )
1145 .unwrap();
1146 assert_eq!(result.exit_code, 42);
1147 }
1148
1149 #[test]
1153 fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
1154 let temp = tempdir().unwrap();
1155 let policy = CapabilityPolicy {
1156 shell_allow: vec!["bash".to_string()],
1157 ..CapabilityPolicy::default()
1158 };
1159
1160 let result = execute_shell(
1161 "bash",
1162 &["-c", "exit 0"],
1163 &[],
1164 temp.path(),
1165 temp.path(),
1166 &policy,
1167 &ShellEnforcement::environment_only(),
1168 )
1169 .unwrap();
1170
1171 assert!(
1172 Path::new(&result.binary).is_absolute(),
1173 "a binary found on PATH must be reported as an absolute path, got {:?}",
1174 result.binary
1175 );
1176 assert!(
1177 result.binary.ends_with("bash"),
1178 "the reported path must still name bash, got {:?}",
1179 result.binary
1180 );
1181 }
1182
1183 #[test]
1189 fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
1190 let temp = tempdir().unwrap();
1191 let tool = temp.path().join("local-tool");
1192 fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
1193 let mut perms = fs::metadata(&tool).unwrap().permissions();
1194 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
1195 fs::set_permissions(&tool, perms).unwrap();
1196
1197 let policy = CapabilityPolicy {
1198 shell_allow: vec!["./local-tool".to_string()],
1199 ..CapabilityPolicy::default()
1200 };
1201
1202 let result = execute_shell(
1203 "./local-tool",
1204 &[],
1205 &[],
1206 temp.path(),
1207 temp.path(),
1208 &policy,
1209 &ShellEnforcement::environment_only(),
1210 )
1211 .unwrap();
1212
1213 assert_eq!(result.exit_code, 7, "the subprocess must still have run");
1214 assert_eq!(
1215 result.binary, "./local-tool",
1216 "an unresolvable name degrades to the bare invoked name, never an error"
1217 );
1218 }
1219
1220 #[test]
1221 fn build_shell_env_sets_synthetic_home_under_workdir() {
1222 let temp = tempdir().unwrap();
1223 let policy = CapabilityPolicy::default();
1224
1225 let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1226
1227 let expected_home = temp.path().join(".capsule-home");
1228 assert_eq!(
1229 env.get("HOME"),
1230 Some(&expected_home.to_string_lossy().into_owned())
1231 );
1232 assert_eq!(
1233 env.get("USERPROFILE"),
1234 Some(&expected_home.to_string_lossy().into_owned())
1235 );
1236 assert!(expected_home.is_dir());
1237 }
1238
1239 #[test]
1240 fn build_shell_env_reports_synthetic_home_creation_failure() {
1241 let temp = tempdir().unwrap();
1242 let blocking_file = temp.path().join("blocked");
1243 std::fs::write(&blocking_file, b"not a directory").unwrap();
1244 let workdir = blocking_file.join("subdir");
1245 let policy = CapabilityPolicy::default();
1246
1247 let error = build_shell_env(&policy, &[], &workdir).unwrap_err();
1248
1249 let expected_home = workdir.join(".capsule-home");
1250 assert!(error.contains(&expected_home.to_string_lossy().into_owned()));
1251 }
1252
1253 #[test]
1254 fn execute_shell_does_not_spawn_when_synthetic_home_creation_fails() {
1255 let temp = tempdir().unwrap();
1256 let blocking_file = temp.path().join("blocked");
1257 std::fs::write(&blocking_file, b"not a directory").unwrap();
1258 let workdir = blocking_file.join("subdir");
1259 let policy = CapabilityPolicy {
1260 shell_allow: vec!["bash".to_string()],
1261 ..CapabilityPolicy::default()
1262 };
1263
1264 let error = execute_shell(
1265 "bash",
1266 &["-c", "echo should-not-run"],
1267 &[],
1268 &workdir,
1269 &workdir,
1270 &policy,
1271 &ShellEnforcement::environment_only(),
1272 )
1273 .unwrap_err();
1274
1275 assert!(error.to_string().contains(".capsule-home"));
1276 }
1277
1278 #[test]
1279 fn execute_shell_reports_synthetic_home_not_real_host_home() {
1280 let temp = tempdir().unwrap();
1281 let policy = CapabilityPolicy {
1282 shell_allow: vec!["bash".to_string()],
1283 ..CapabilityPolicy::default()
1284 };
1285
1286 let result = execute_shell(
1287 "bash",
1288 &["-c", "echo -n $HOME"],
1289 &[],
1290 temp.path(),
1291 temp.path(),
1292 &policy,
1293 &ShellEnforcement::environment_only(),
1294 )
1295 .unwrap();
1296
1297 let expected_home = temp.path().join(".capsule-home");
1298 assert_eq!(result.stdout, expected_home.to_string_lossy());
1299 if let Ok(real_home) = std::env::var("HOME") {
1300 assert_ne!(result.stdout, real_home);
1301 }
1302 }
1303
1304 #[test]
1305 fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
1306 let temp = tempdir().unwrap();
1307 let policy = CapabilityPolicy::default();
1308 let overrides = vec![
1309 ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
1310 ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
1311 ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
1312 ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
1313 ("SAFE_VAR".to_string(), "kept".to_string()),
1314 ];
1315
1316 let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1317
1318 assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
1319 assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
1320 assert!(!env.contains_key("STRIPE_API_KEY"));
1321 assert!(!env.contains_key("GITHUB_TOKEN"));
1322 assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
1323 }
1324
1325 #[test]
1326 fn declared_env_is_empty_without_declarations() {
1327 std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
1328 let policy = CapabilityPolicy::default();
1329
1330 let env = build_declared_env(&policy);
1331
1332 assert!(env.is_empty());
1333 }
1334
1335 #[test]
1338 fn declared_and_shell_env_never_carry_the_formation_lifeline() {
1339 use crate::lifeline::FORMATION_LIFELINE_ENV;
1340 let _guard = crate::formation::FORMATION_ENV_LOCK
1341 .lock()
1342 .unwrap_or_else(|poisoned| poisoned.into_inner());
1343 let temp = tempdir().unwrap();
1344 std::env::set_var(FORMATION_LIFELINE_ENV, "7");
1345 let policy = CapabilityPolicy {
1346 env_allow: vec![FORMATION_LIFELINE_ENV.to_string()],
1347 shell_baseline_env: vec![FORMATION_LIFELINE_ENV.to_string()],
1348 ..CapabilityPolicy::default()
1349 };
1350 let declared = build_declared_env(&policy);
1351 let shell = build_shell_env(&policy, &[], temp.path()).unwrap();
1352 std::env::remove_var(FORMATION_LIFELINE_ENV);
1353 assert!(
1354 !declared.contains_key(FORMATION_LIFELINE_ENV),
1355 "{declared:?}"
1356 );
1357 assert!(!shell.contains_key(FORMATION_LIFELINE_ENV), "{shell:?}");
1358 }
1359
1360 #[test]
1362 fn declared_and_shell_env_never_carry_the_spawner_lifeline() {
1363 use crate::lifeline::SPAWNER_LIFELINE_ENV;
1364 let _guard = crate::formation::FORMATION_ENV_LOCK
1365 .lock()
1366 .unwrap_or_else(|poisoned| poisoned.into_inner());
1367 let temp = tempdir().unwrap();
1368 std::env::set_var(SPAWNER_LIFELINE_ENV, "7");
1369 let policy = CapabilityPolicy {
1370 env_allow: vec![SPAWNER_LIFELINE_ENV.to_string()],
1371 shell_baseline_env: vec![SPAWNER_LIFELINE_ENV.to_string()],
1372 ..CapabilityPolicy::default()
1373 };
1374 let declared = build_declared_env(&policy);
1375 let shell = build_shell_env(&policy, &[], temp.path()).unwrap();
1376 std::env::remove_var(SPAWNER_LIFELINE_ENV);
1377 assert!(!declared.contains_key(SPAWNER_LIFELINE_ENV), "{declared:?}");
1378 assert!(!shell.contains_key(SPAWNER_LIFELINE_ENV), "{shell:?}");
1379 }
1380
1381 #[test]
1382 fn declared_env_passes_through_declared_host_var() {
1383 std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
1384 let policy = CapabilityPolicy {
1385 env_allow: vec![
1386 "MURMUR_TEST_WASI_ALLOWED".to_string(),
1387 "MURMUR_TEST_WASI_NEVER_SET".to_string(),
1389 ],
1390 ..CapabilityPolicy::default()
1391 };
1392
1393 let env = build_declared_env(&policy);
1394
1395 assert_eq!(
1396 env.get("MURMUR_TEST_WASI_ALLOWED"),
1397 Some(&"host-value".to_string())
1398 );
1399 assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
1400 }
1401
1402 #[test]
1403 fn declared_env_strips_credential_shaped_declarations() {
1404 std::env::set_var("GITHUB_TOKEN", "leaked-token");
1405 std::env::set_var("STRIPE_API_KEY", "leaked-key");
1406 std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
1407 let policy = CapabilityPolicy {
1408 env_allow: vec![
1409 "GITHUB_TOKEN".to_string(),
1410 "STRIPE_API_KEY".to_string(),
1411 "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
1412 ],
1413 shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
1414 ..CapabilityPolicy::default()
1415 };
1416
1417 let env = build_declared_env(&policy);
1418
1419 assert!(env.is_empty(), "expected all names stripped, got {env:?}");
1421 }
1422
1423 #[test]
1426 fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
1427 std::env::set_var("PRIVATE_KEY", "host-private-key");
1428 std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
1429 let policy = CapabilityPolicy {
1430 env_allow: vec![
1431 "PRIVATE_KEY".to_string(),
1432 "MURMUR_TEST_WASI_PLAIN".to_string(),
1433 ],
1434 ..CapabilityPolicy::default()
1435 };
1436
1437 let env = build_declared_env(&policy);
1438
1439 assert_eq!(
1440 env.get("PRIVATE_KEY"),
1441 Some(&"host-private-key".to_string())
1442 );
1443 assert_eq!(
1444 env.get("MURMUR_TEST_WASI_PLAIN"),
1445 Some(&"host-plain".to_string())
1446 );
1447 }
1448
1449 #[test]
1450 fn build_shell_env_home_override_cannot_survive() {
1451 let temp = tempdir().unwrap();
1452 let policy = CapabilityPolicy {
1453 shell_baseline_env: vec!["HOME".to_string()],
1454 ..CapabilityPolicy::default()
1455 };
1456 let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
1457
1458 let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1459
1460 let expected_home = temp.path().join(".capsule-home");
1461 assert_eq!(
1462 env.get("HOME"),
1463 Some(&expected_home.to_string_lossy().into_owned())
1464 );
1465 }
1466
1467 #[test]
1468 fn build_shell_env_keeps_safe_baseline_vars() {
1469 let temp = tempdir().unwrap();
1470 let policy = CapabilityPolicy::default();
1471
1472 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
1473 let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1474 std::env::remove_var("CARGO_HOME");
1475
1476 assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
1477 }
1478
1479 #[test]
1480 fn build_shell_env_developer_declared_strip_env_still_composes() {
1481 let temp = tempdir().unwrap();
1482 let policy = CapabilityPolicy {
1483 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
1484 ..CapabilityPolicy::default()
1485 };
1486 let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
1487
1488 let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1489
1490 assert!(!env.contains_key("MYCOMPANY_SECRET"));
1491 }
1492
1493 #[test]
1497 fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
1498 let extra = vec!["*_SERVICE_SECRET".to_string()];
1499 let names = [
1500 "ANTHROPIC_API_KEY",
1501 "GITHUB_TOKEN",
1502 "GITHUB_TOKEN_2",
1503 "AWS_ACCESS_KEY_ID",
1504 "MY_AWS_KEY",
1505 "STRIPE_API_KEY",
1506 "KUBECONFIG",
1507 "MY_SERVICE_SECRET",
1508 "DATABASE_PASSWORD",
1509 "SLACK_BOT_TOKEN",
1510 "HOME",
1511 "TZ",
1512 ];
1513
1514 let mut env: BTreeMap<String, String> = names
1515 .iter()
1516 .map(|name| ((*name).to_string(), "value".to_string()))
1517 .collect();
1518 strip_credential_shaped_vars(&mut env, &extra);
1519
1520 for name in names {
1521 assert_eq!(
1522 credential_backstop_drops(name, &extra),
1523 !env.contains_key(name),
1524 "prediction and strip disagree about {name}"
1525 );
1526 assert_eq!(
1527 credential_backstop_match(name, &extra).is_some(),
1528 !env.contains_key(name),
1529 "match and strip disagree about {name}"
1530 );
1531 }
1532 }
1533
1534 #[test]
1535 fn credential_backstop_match_attributes_a_builtin_pattern() {
1536 assert_eq!(
1537 credential_backstop_match("AWS_REGION", &[]),
1538 Some(BackstopMatch {
1539 pattern: "AWS_*".to_string(),
1540 source: BackstopPatternSource::Builtin,
1541 })
1542 );
1543 assert_eq!(
1544 credential_backstop_match("GITHUB_TOKEN", &[]),
1545 Some(BackstopMatch {
1546 pattern: "GITHUB_TOKEN".to_string(),
1547 source: BackstopPatternSource::Builtin,
1548 })
1549 );
1550 }
1551
1552 #[test]
1553 fn credential_backstop_match_attributes_a_strip_env_pattern() {
1554 assert_eq!(
1555 credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
1556 Some(BackstopMatch {
1557 pattern: "*_SERVICE_SECRET".to_string(),
1558 source: BackstopPatternSource::StripEnv,
1559 })
1560 );
1561 }
1562
1563 #[test]
1566 fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
1567 assert_eq!(
1568 credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
1569 Some(BackstopMatch {
1570 pattern: "*_API_KEY".to_string(),
1571 source: BackstopPatternSource::Builtin,
1572 })
1573 );
1574 }
1575
1576 #[test]
1577 fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
1578 assert_eq!(credential_backstop_match("TZ", &[]), None);
1579 assert_eq!(
1580 credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
1581 None
1582 );
1583 }
1584
1585 #[test]
1586 fn env_name_matches_pattern_trailing_wildcard() {
1587 assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
1588 assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
1589 }
1590
1591 #[test]
1592 fn env_name_matches_pattern_leading_wildcard() {
1593 assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
1594 assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
1595 }
1596
1597 #[test]
1598 fn env_name_matches_pattern_exact_match() {
1599 assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
1600 assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
1601 }
1602
1603 #[test]
1604 fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
1605 let yaml = shell_tool_manifest_yaml("bash");
1606 serde_yaml::from_str::<serde_yaml::Value>(&yaml).expect("bash manifest must be valid YAML");
1607 }
1608
1609 #[test]
1610 fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
1611 let yaml = shell_tool_manifest_yaml("curl");
1612 serde_yaml::from_str::<serde_yaml::Value>(&yaml)
1613 .expect("curl manifest must be valid YAML — embedded quotes break serde_yaml parsing");
1614 }
1615
1616 #[test]
1617 fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
1618 let yaml = shell_tool_manifest_yaml("curl");
1619 let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
1620 assert_eq!(
1621 value.get("runtime").and_then(|v| v.as_str()),
1622 Some("tool"),
1623 "non-interpreter manifest must have runtime: tool so inventory picks it up"
1624 );
1625 }
1626
1627 #[test]
1630 fn an_abandoned_command_demotes_before_its_grace() {
1631 use std::sync::Arc;
1632
1633 use crate::{
1634 detached::{AbandonSignal, DetachPolicy, DetachedRegistry},
1635 origin::{TaskOrigin, TaskProvenance},
1636 };
1637
1638 let temp = tempdir().unwrap();
1639 let policy = CapabilityPolicy {
1640 shell_allow: vec!["bash".to_string()],
1641 ..CapabilityPolicy::default()
1642 };
1643 let (registry, _reports) = DetachedRegistry::new();
1644 let abandon = AbandonSignal::new();
1645 let raiser = {
1646 let abandon = abandon.clone();
1647 std::thread::spawn(move || {
1648 std::thread::sleep(Duration::from_millis(200));
1649 abandon.raise();
1650 })
1651 };
1652
1653 let started = Instant::now();
1654 let outcome = run_shell(
1655 "bash",
1656 &["-c", "sleep 5"],
1659 &[],
1660 temp.path(),
1661 temp.path(),
1662 &policy,
1663 &ShellEnforcement::environment_only(),
1664 Some(DetachPolicy {
1665 grace: Duration::from_secs(60),
1666 registry: Arc::clone(®istry),
1667 command: "sleep 5".to_string(),
1668 context_id: "ctx_abandon_test".to_string(),
1669 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
1670 abandoned: Some(abandon),
1671 }),
1672 )
1673 .expect("a declared binary runs");
1674 let elapsed = started.elapsed();
1675 raiser.join().unwrap();
1676
1677 let ShellOutcome::Detached(info) = outcome else {
1678 panic!("an abandoned command must demote, got {outcome:?}");
1679 };
1680 assert!(
1681 elapsed < Duration::from_secs(2),
1682 "demotion waited {elapsed:?}, as if the 60 s grace still applied"
1683 );
1684 assert!(
1685 registry
1686 .outstanding()
1687 .iter()
1688 .any(|work| work.work_id == info.work_id),
1689 "the demoted command is registered like one that outran its grace"
1690 );
1691 }
1692
1693 #[test]
1694 fn a_shared_window_attributes_no_cgroup_delta() {
1695 assert_eq!(
1696 attribute_resource_limit(None, Some("cgroup_memory_bytes"), true).as_deref(),
1697 Some("cgroup_memory_bytes"),
1698 "a call alone in the scope owns its delta"
1699 );
1700 assert_eq!(
1701 attribute_resource_limit(None, Some("cgroup_memory_bytes"), false),
1702 None,
1703 "a delta from a shared window could be a sibling's"
1704 );
1705 assert_eq!(
1706 attribute_resource_limit(Some("cpu_seconds"), Some("cgroup_memory_bytes"), false)
1707 .as_deref(),
1708 Some("cpu_seconds"),
1709 "the signal is per-process evidence and needs no exclusive window"
1710 );
1711 assert_eq!(attribute_resource_limit(None, None, true), None);
1712 }
1713}