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_DIR_MODE, DEFAULT_FILE_MODE},
12};
13
14const 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 "TMPDIR",
25 "TEMP",
26 "TMP",
27 "CARGO_HOME",
28 "RUSTUP_HOME",
29 "TERM",
30];
31
32pub(crate) const ACCOUNT_ENV: [&str; 2] = ["LOGNAME", "USER"];
36
37#[cfg(test)]
40pub(crate) static ACCOUNT_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
41
42#[derive(Debug, Clone, PartialEq, Eq)]
49pub(crate) struct HostAccountEnv {
50 set: Vec<(String, String)>,
51 unset: Vec<String>,
52}
53
54impl HostAccountEnv {
55 pub(crate) fn from_host() -> Self {
57 Self::from_lookup(|name| std::env::var(name).ok())
58 }
59
60 pub(crate) fn from_lookup(lookup: impl Fn(&str) -> Option<String>) -> Self {
62 let mut set = Vec::new();
63 let mut unset = Vec::new();
64 for name in ACCOUNT_ENV {
65 match lookup(name) {
66 Some(value) => set.push((name.to_string(), value)),
67 None => unset.push(name.to_string()),
68 }
69 }
70 Self { set, unset }
71 }
72
73 #[cfg(test)]
75 pub(crate) fn from_pairs(pairs: &[(&str, &str)]) -> Self {
76 Self::from_lookup(|name| {
77 pairs
78 .iter()
79 .find(|(key, _)| *key == name)
80 .map(|(_, value)| value.to_string())
81 })
82 }
83
84 pub(crate) fn unset(&self) -> &[String] {
86 &self.unset
87 }
88
89 pub(crate) fn set(&self) -> &[(String, String)] {
91 &self.set
92 }
93
94 pub(crate) fn apply(&self, env: &mut BTreeMap<String, String>) {
97 for name in ACCOUNT_ENV {
98 env.remove(name);
99 }
100 env.extend(self.set.iter().cloned());
101 }
102}
103
104pub(crate) const SYNTHETIC_HOME_DIR_NAME: &str = ".capsule-home";
117
118const CREDENTIAL_ENV_PATTERNS: &[&str] = &[
123 "ANTHROPIC_API_KEY",
124 "OPENAI_API_KEY",
125 "GOOGLE_API_KEY",
126 "HUGGING_FACE_HUB_TOKEN",
127 "AWS_*",
128 "GITHUB_TOKEN",
129 "GH_TOKEN",
130 "DOCKER_*",
131 "KUBECONFIG",
132 "NPM_TOKEN",
133 "PYPI_TOKEN",
134 "CARGO_REGISTRY_TOKEN",
135 "*_API_KEY",
136];
137
138#[derive(Debug)]
139pub(crate) struct ShellResult {
140 pub(crate) binary: String,
146 pub(crate) exit_code: i32,
147 pub(crate) stdout: String,
148 pub(crate) stderr: String,
149 pub(crate) duration_ms: u64,
150 pub(crate) truncated: bool,
151 pub(crate) full_output_path: Option<String>,
152 pub(crate) resource_limit_hit: Option<String>,
157}
158
159#[derive(Debug)]
169pub(crate) enum ShellExecError {
170 Failed(String),
174 SealedRootConstructionFailed { detail: String },
178}
179
180impl ShellExecError {
181 pub(crate) fn session_fatal(&self) -> Option<crate::errors::RuntimeError> {
187 match self {
188 Self::Failed(_) => None,
189 Self::SealedRootConstructionFailed { detail } => {
190 Some(crate::errors::RuntimeError::SealedRootConstructionFailed {
191 detail: detail.clone(),
192 })
193 }
194 }
195 }
196}
197
198pub(crate) fn enforced_spawn_failure(
209 command: Command,
210 supervisor: &crate::sandbox::SupervisorHandle,
211 subject: &str,
212 spawn_error: String,
213) -> ShellExecError {
214 drop(command);
215 match supervisor.read_diagnostic() {
216 Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
222 ShellExecError::SealedRootConstructionFailed { detail }
223 }
224 Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
225 "sandbox: {subject} enforcement setup failed before exec: {detail}"
226 )),
227 _ => ShellExecError::Failed(spawn_error),
228 }
229}
230
231impl std::fmt::Display for ShellExecError {
232 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
233 match self {
234 Self::Failed(message) => f.write_str(message),
235 Self::SealedRootConstructionFailed { detail } => write!(
239 f,
240 "{}",
241 crate::errors::RuntimeError::SealedRootConstructionFailed {
242 detail: detail.clone(),
243 }
244 ),
245 }
246 }
247}
248
249impl From<String> for ShellExecError {
251 fn from(message: String) -> Self {
252 Self::Failed(message)
253 }
254}
255
256pub(crate) fn is_shell_interpreter(binary: &str) -> bool {
257 SHELL_INTERPRETERS.contains(&binary)
258}
259
260pub(crate) fn split_shell_words(command: &str) -> Vec<String> {
261 let mut words = Vec::new();
262 let mut current = String::new();
263 let mut quote = None;
264 let mut escaped = false;
265
266 for ch in command.chars() {
267 if escaped {
268 current.push(ch);
269 escaped = false;
270 continue;
271 }
272 if ch == '\\' {
273 escaped = true;
274 continue;
275 }
276 match (ch, quote) {
277 ('"' | '\'', None) => quote = Some(ch),
278 (q, Some(open)) if q == open => quote = None,
279 (c, None) if c.is_whitespace() => {
280 if !current.is_empty() {
281 words.push(std::mem::take(&mut current));
282 }
283 }
284 _ => current.push(ch),
285 }
286 }
287
288 if !current.is_empty() {
289 words.push(current);
290 }
291 words
292}
293
294pub(crate) fn shell_tool_manifest_yaml(binary: &str) -> String {
295 let description = if is_shell_interpreter(binary) {
296 format!("Run a shell command via {binary} in the capsule workdir. The `command` field is passed to {binary} via -c.")
297 } else {
298 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).")
299 };
300
301 format!(
302 "name: {binary}\nversion: 0.0.0\nruntime: tool\nimplementation: native\ndescription: \"{description}\"\ninput_schema: '{{\"type\":\"object\",\"properties\":{{\"command\":{{\"type\":\"string\"}}}},\"required\":[\"command\"]}}'\n"
303 )
304}
305
306#[derive(Debug)]
308pub(crate) enum ShellOutcome {
309 Finished(ShellResult),
310 Detached(DetachedDispatchInfo),
311}
312
313const DETACH_POLL_INTERVAL: Duration = Duration::from_millis(20);
320
321#[allow(clippy::too_many_arguments)]
338pub(crate) fn run_shell(
339 binary: &str,
340 args: &[&str],
341 env_overrides: &[(String, String)],
342 accessible_workdir: &WorkdirRoot,
343 session_workdir: &WorkdirRoot,
344 policy: &CapabilityPolicy,
345 enforcement: &crate::sandbox::ShellEnforcement,
346 detach: Option<DetachPolicy>,
347) -> Result<ShellOutcome, ShellExecError> {
348 if !policy.shell_allow.iter().any(|allowed| allowed == binary) {
349 return Err(ShellExecError::Failed(format!(
350 "binary '{binary}' is not in capabilities.shell.allow"
351 )));
352 }
353
354 enforcement.check_workdir_budget()?;
357
358 let env = build_shell_env(
359 policy,
360 env_overrides,
361 session_workdir,
362 enforcement.runtime_dir_path(),
363 enforcement.records_dir_path(),
364 )?;
365
366 let resolved_binary = crate::sandbox::resolve_invoked_binary_path(binary);
369
370 let started = Instant::now();
371 let mut command = Command::new(binary);
372 command
373 .args(args)
374 .current_dir(accessible_workdir.path())
375 .env_clear()
376 .envs(env)
377 .stdout(Stdio::piped())
378 .stderr(Stdio::piped());
379
380 let supervisor = crate::sandbox::prepare_enforcement(
385 &mut command,
386 enforcement,
387 accessible_workdir.path(),
388 session_workdir,
389 )?;
390
391 let cgroup_occupancy = enforcement.cgroup_scope.as_ref().map(|scope| scope.enter());
395 let cgroup_counters_before = enforcement
398 .cgroup_scope
399 .as_ref()
400 .map(|scope| scope.event_counters());
401
402 let mut child = match command.spawn() {
403 Ok(child) => child,
404 Err(error) => {
405 return Err(enforced_spawn_failure(
406 command,
407 &supervisor,
408 "shell",
409 error.to_string(),
410 ))
411 }
412 };
413
414 let readers = OutputReaders::spawn(&mut child);
418
419 let status = match &detach {
420 None => child.wait().map_err(|error| error.to_string())?,
421 Some(policy) => {
422 let deadline = started + policy.grace;
423 loop {
424 match child.try_wait().map_err(|error| error.to_string())? {
425 Some(status) => break status,
426 None => {
427 let remaining = deadline.saturating_duration_since(Instant::now());
428 let abandoned = policy
431 .abandoned
432 .as_ref()
433 .is_some_and(crate::detached::AbandonSignal::is_raised);
434 if remaining.is_zero() || abandoned {
435 return Ok(ShellOutcome::Detached(demote(
437 child,
438 readers,
439 DemotionContext {
440 policy: detach.expect("the detach policy was matched above"),
441 accessible_workdir: accessible_workdir.clone(),
442 invoked_binary: binary.to_string(),
443 resolved_binary,
444 args: args.iter().map(|arg| (*arg).to_string()).collect(),
445 started,
446 cgroup_scope: enforcement.cgroup_scope.clone(),
447 cgroup_counters_before,
448 cgroup_occupancy,
449 },
450 )));
451 }
452 std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
453 }
454 }
455 }
456 }
457 };
458 let (stdout_bytes, stderr_bytes) = readers.join();
459 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
460
461 let resource_limit_hit = classify_resource_limit(
462 &status,
463 enforcement.cgroup_scope.as_deref(),
464 cgroup_counters_before,
465 cgroup_occupancy.as_ref(),
466 );
467 drop(cgroup_occupancy);
468 let exit_code = exit_code_of(&status);
469
470 let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
471 let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
472 let mut truncated = false;
473 let mut full_output_path = None;
474
475 if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
476 truncated = true;
477 let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
478 let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
479 let full_stdout = std::mem::replace(
480 &mut stdout,
481 String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
482 );
483 let full_stderr = std::mem::replace(
484 &mut stderr,
485 String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
486 );
487 full_output_path = Some(write_shell_output_log(
488 accessible_workdir,
489 &format!("shell-{}", crate::trace::timestamp_ms()),
490 binary,
491 args,
492 exit_code,
493 &full_stdout,
494 &full_stderr,
495 )?);
496 }
497
498 Ok(ShellOutcome::Finished(ShellResult {
499 binary: resolved_binary,
500 exit_code,
501 stdout,
502 stderr,
503 duration_ms,
504 truncated,
505 full_output_path,
506 resource_limit_hit,
507 }))
508}
509
510pub(crate) fn execute_shell(
515 binary: &str,
516 args: &[&str],
517 env_overrides: &[(String, String)],
518 accessible_workdir: &WorkdirRoot,
519 session_workdir: &WorkdirRoot,
520 policy: &CapabilityPolicy,
521 enforcement: &crate::sandbox::ShellEnforcement,
522) -> Result<ShellResult, ShellExecError> {
523 match run_shell(
524 binary,
525 args,
526 env_overrides,
527 accessible_workdir,
528 session_workdir,
529 policy,
530 enforcement,
531 None,
532 )? {
533 ShellOutcome::Finished(result) => Ok(result),
534 ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
538 "shell command was demoted to the background as {} on a call site that cannot receive a completion",
539 info.work_id
540 ))),
541 }
542}
543
544struct OutputReaders {
550 stdout: Option<std::thread::JoinHandle<Vec<u8>>>,
551 stderr: Option<std::thread::JoinHandle<Vec<u8>>>,
552}
553
554impl OutputReaders {
555 fn spawn(child: &mut std::process::Child) -> Self {
556 fn drain(
557 pipe: Option<impl std::io::Read + Send + 'static>,
558 ) -> Option<std::thread::JoinHandle<Vec<u8>>> {
559 let mut pipe = pipe?;
560 Some(std::thread::spawn(move || {
561 let mut buffer = Vec::new();
562 let _ = pipe.read_to_end(&mut buffer);
566 buffer
567 }))
568 }
569
570 Self {
571 stdout: drain(child.stdout.take()),
572 stderr: drain(child.stderr.take()),
573 }
574 }
575
576 fn join(self) -> (Vec<u8>, Vec<u8>) {
579 let take = |handle: Option<std::thread::JoinHandle<Vec<u8>>>| {
580 handle
581 .map(|handle| handle.join().unwrap_or_default())
582 .unwrap_or_default()
583 };
584 (take(self.stdout), take(self.stderr))
585 }
586}
587
588struct DemotionContext {
590 policy: DetachPolicy,
591 accessible_workdir: WorkdirRoot,
594 invoked_binary: String,
596 resolved_binary: String,
599 args: Vec<String>,
600 started: Instant,
601 cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
602 cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
603 cgroup_occupancy: Option<crate::cgroup::ScopeOccupancy>,
605}
606
607fn demote(
612 mut child: std::process::Child,
613 readers: OutputReaders,
614 context: DemotionContext,
615) -> DetachedDispatchInfo {
616 let work_id = crate::detached::new_work_id();
617 let info = DetachedDispatchInfo {
618 work_id: work_id.clone(),
619 binary: context.resolved_binary.clone(),
620 command: context.policy.command.clone(),
621 grace_ms: context
622 .policy
623 .grace
624 .as_millis()
625 .try_into()
626 .unwrap_or(u64::MAX),
627 };
628
629 context
630 .policy
631 .registry
632 .register(crate::detached::DetachedWork {
633 work_id: work_id.clone(),
634 binary: context.resolved_binary.clone(),
635 command: context.policy.command.clone(),
636 started_at_ms: crate::trace::timestamp_ms().saturating_sub(
642 context
643 .started
644 .elapsed()
645 .as_millis()
646 .try_into()
647 .unwrap_or(u64::MAX),
648 ),
649 });
650
651 let provenance = context.policy.completion_provenance();
652 let DemotionContext {
653 policy,
654 accessible_workdir,
655 invoked_binary,
656 resolved_binary,
657 args,
658 started,
659 cgroup_scope,
660 cgroup_counters_before,
661 cgroup_occupancy,
662 } = context;
663
664 std::thread::spawn(move || {
665 let waited = child.wait();
666 let (stdout_bytes, stderr_bytes) = readers.join();
667 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
668
669 let (exit_code, resource_limit, mut error) = match waited {
670 Ok(status) => (
671 exit_code_of(&status),
672 classify_resource_limit(
673 &status,
674 cgroup_scope.as_deref(),
675 cgroup_counters_before,
676 cgroup_occupancy.as_ref(),
677 ),
678 None,
679 ),
680 Err(failure) => (
681 -1,
682 None,
683 Some(format!("waiting for the command failed: {failure}")),
684 ),
685 };
686 drop(cgroup_occupancy);
687
688 let stdout = String::from_utf8_lossy(&stdout_bytes).into_owned();
689 let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
690 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
691 let output_path = crate::detached::output_path_for(&work_id);
692 if let Err(failure) = write_shell_output_log(
693 &accessible_workdir,
694 &work_id,
695 &invoked_binary,
696 &arg_refs,
697 exit_code,
698 &stdout,
699 &stderr,
700 ) {
701 error = Some(match error {
702 Some(existing) => format!("{existing}; {failure}"),
703 None => failure,
704 });
705 }
706 let output_bytes = accessible_workdir
707 .file_len(&output_path)
708 .ok()
709 .flatten()
710 .unwrap_or(0);
711
712 policy
713 .registry
714 .complete(crate::detached::DetachedCompletion {
715 work_id,
716 binary: resolved_binary,
717 command: policy.command.clone(),
718 exit_code,
719 duration_ms,
720 output_path,
721 output_bytes,
722 resource_limit,
723 context_id: policy.context_id.clone(),
724 provenance,
725 error,
726 });
727 });
728
729 info
730}
731
732pub(crate) fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
740 use std::os::unix::process::ExitStatusExt;
741
742 status
743 .code()
744 .or_else(|| status.signal().map(|signal| 128 + signal))
745 .unwrap_or(-1)
746}
747
748fn classify_resource_limit(
770 status: &std::process::ExitStatus,
771 cgroup_scope: Option<&crate::cgroup::CgroupScope>,
772 counters_before: Option<crate::cgroup::CgroupEventCounters>,
773 occupancy: Option<&crate::cgroup::ScopeOccupancy>,
774) -> Option<String> {
775 use std::os::unix::process::ExitStatusExt;
776
777 let signal_limit = status
778 .signal()
779 .and_then(crate::resources::limit_from_signal);
780 let counter_delta = match (cgroup_scope, counters_before) {
781 (Some(scope), Some(before)) => scope.event_counters().attribution_since(before),
782 _ => None,
783 };
784 let exclusive = occupancy.is_some_and(crate::cgroup::ScopeOccupancy::exclusive);
785 attribute_resource_limit(signal_limit, counter_delta, exclusive)
786}
787
788pub(crate) fn attribute_resource_limit(
795 signal_limit: Option<&'static str>,
796 counter_delta: Option<&'static str>,
797 exclusive: bool,
798) -> Option<String> {
799 signal_limit
800 .or(counter_delta.filter(|_| exclusive))
801 .map(str::to_string)
802}
803
804pub(crate) fn build_shell_env(
805 policy: &CapabilityPolicy,
806 env_overrides: &[(String, String)],
807 session_workdir: &WorkdirRoot,
808 runtime_dir: Option<&std::path::Path>,
809 records_dir: Option<&std::path::Path>,
810) -> Result<BTreeMap<String, String>, String> {
811 let mut env = BTreeMap::new();
812
813 for key in DEFAULT_ENV_BASELINE {
814 if let Ok(value) = std::env::var(key) {
815 env.insert((*key).to_string(), value);
816 }
817 }
818
819 for (key, value) in env_overrides {
820 env.insert(key.clone(), value.clone());
821 }
822
823 for key in &policy.shell_baseline_env {
824 if crate::formation::is_member_grant_env(key)
827 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
828 {
829 continue;
830 }
831 if let Ok(value) = std::env::var(key) {
832 env.insert(key.clone(), value);
833 }
834 }
835
836 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
837
838 let synthetic_home = synthetic_home_dir(session_workdir)?;
842 let synthetic_home = synthetic_home.to_string_lossy().into_owned();
843 env.insert("HOME".to_string(), synthetic_home.clone());
844 env.insert("USERPROFILE".to_string(), synthetic_home);
845 env.remove(crate::runtime_dir::RUNTIME_DIR_ENV);
848 if let Some(runtime_dir) = runtime_dir {
849 env.insert(
850 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
851 runtime_dir.to_string_lossy().into_owned(),
852 );
853 }
854 env.remove(crate::records_dir::RECORDS_DIR_ENV);
856 if let Some(records_dir) = records_dir {
857 env.insert(
858 crate::records_dir::RECORDS_DIR_ENV.to_string(),
859 records_dir.to_string_lossy().into_owned(),
860 );
861 }
862
863 Ok(env)
864}
865
866pub(crate) fn strip_credential_shaped_vars(
874 env: &mut BTreeMap<String, String>,
875 extra_patterns: &[String],
876) {
877 env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
878}
879
880#[derive(Debug, Clone, PartialEq, Eq)]
882pub enum BackstopPatternSource {
883 Builtin,
885 StripEnv,
887}
888
889#[derive(Debug, Clone, PartialEq, Eq)]
891pub struct BackstopMatch {
892 pub pattern: String,
893 pub source: BackstopPatternSource,
894}
895
896pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
905 CREDENTIAL_ENV_PATTERNS
906 .iter()
907 .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
908 .chain(
909 extra_patterns
910 .iter()
911 .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
912 )
913 .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
914 .map(|(pattern, source)| BackstopMatch {
915 pattern: pattern.to_string(),
916 source,
917 })
918}
919
920pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
924 credential_backstop_match(name, extra_patterns).is_some()
925}
926
927pub(crate) fn build_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
943 let mut env = BTreeMap::new();
944
945 for key in &policy.env_allow {
946 if key == ARTIFACT_CONFIG_ENV
947 || crate::formation::is_member_grant_env(key)
948 || key == crate::lifeline::SPAWNER_LIFELINE_ENV
949 || key == crate::runtime_dir::RUNTIME_DIR_ENV
950 || key == crate::records_dir::RECORDS_DIR_ENV
951 {
952 continue;
953 }
954 if let Ok(value) = std::env::var(key) {
955 env.insert(key.clone(), value);
956 }
957 }
958
959 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
960
961 env
962}
963
964fn synthetic_home_dir(session_workdir: &WorkdirRoot) -> Result<std::path::PathBuf, String> {
965 session_workdir
966 .create_dir_all(SYNTHETIC_HOME_DIR_NAME, DEFAULT_DIR_MODE)
967 .map_err(|error| format!("failed to create synthetic home directory: {error}"))?;
968 Ok(session_workdir.host_path(SYNTHETIC_HOME_DIR_NAME))
969}
970
971fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
972 if let Some(rest) = pattern.strip_prefix('*') {
973 if let Some(middle) = rest.strip_suffix('*') {
974 return key.contains(middle);
975 }
976 return key.ends_with(rest);
977 }
978
979 if let Some(prefix) = pattern.strip_suffix('*') {
980 return key.starts_with(prefix);
981 }
982
983 pattern == key
984}
985
986fn write_shell_output_log(
993 accessible_workdir: &WorkdirRoot,
994 file_stem: &str,
995 binary: &str,
996 args: &[&str],
997 exit_code: i32,
998 stdout: &str,
999 stderr: &str,
1000) -> Result<String, String> {
1001 let relative_path = format!("logs/{file_stem}.log");
1002
1003 let command = if args.is_empty() {
1004 binary.to_string()
1005 } else {
1006 format!("{binary} {}", args.join(" "))
1007 };
1008
1009 let content = format!(
1010 "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
1011 );
1012
1013 accessible_workdir
1014 .write_file(&relative_path, content.as_bytes(), DEFAULT_FILE_MODE)
1015 .map_err(|error| format!("failed to write shell log: {error}"))?;
1016
1017 Ok(relative_path)
1018}
1019
1020#[cfg(test)]
1021mod tests {
1022 use std::{fs, path::Path};
1023 use tempfile::tempdir;
1024
1025 use super::*;
1026 use crate::sandbox::ShellEnforcement;
1027
1028 #[test]
1034 fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
1035 let temp = tempdir().unwrap();
1036 let policy = CapabilityPolicy {
1037 shell_allow: vec!["bash".to_string()],
1038 resources: crate::resources::HostResourceLimits {
1039 max_open_files: 64,
1040 ..crate::resources::HostResourceLimits::default()
1041 },
1042 ..CapabilityPolicy::default()
1043 };
1044 let mut enforcement = ShellEnforcement::environment_only();
1045 enforcement.resource_limits = policy.resources;
1046
1047 let result = execute_shell(
1048 "bash",
1049 &["-c", "ulimit -Hn"],
1050 &[],
1051 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1052 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1053 &policy,
1054 &enforcement,
1055 )
1056 .expect("bash must run");
1057
1058 assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
1059 assert_eq!(result.resource_limit_hit, None);
1060 }
1061
1062 #[cfg(target_os = "linux")]
1066 #[test]
1067 fn a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max() {
1068 const TEST: &str = "a_fork_bomb_in_a_scoped_capsule_is_stopped_by_pids_max";
1069 if !crate::cgroup::cgroup_delegation_available() {
1070 crate::runtime_err!("[SKIP-HOST] {TEST}: this host cannot delegate a cgroup v2 scope");
1071 return;
1072 }
1073
1074 let temp = tempdir().unwrap();
1075 let limits = crate::resources::HostResourceLimits {
1076 cgroup_pids_max: 16,
1077 ..crate::resources::HostResourceLimits::default()
1078 };
1079 let scope = crate::cgroup::prepare_scope(true, &limits, "ses_fork_bomb", temp.path())
1080 .expect("this host delegates a cgroup scope")
1081 .scope;
1082 let policy = CapabilityPolicy {
1083 shell_allow: vec!["python3".to_string()],
1084 resources: limits,
1085 ..CapabilityPolicy::default()
1086 };
1087 let mut enforcement = ShellEnforcement::environment_only();
1088 enforcement.resource_limits = limits;
1089 let enforcement = enforcement.with_host_bounding(scope, None);
1090
1091 let script = "import os, resource, signal, time\n\
1092 children = []\n\
1093 for _ in range(4096):\n\
1094 \x20 try:\n\
1095 \x20 pid = os.fork()\n\
1096 \x20 except OSError:\n\
1097 \x20 break\n\
1098 \x20 if pid == 0:\n\
1099 \x20 time.sleep(30)\n\
1100 \x20 os._exit(0)\n\
1101 \x20 children.append(pid)\n\
1102 for pid in children:\n\
1103 \x20 os.kill(pid, signal.SIGKILL)\n\
1104 for pid in children:\n\
1105 \x20 os.waitpid(pid, 0)\n\
1106 soft = resource.getrlimit(resource.RLIMIT_NPROC)[0]\n\
1107 print(len(children))\n\
1108 print('unlimited' if soft == resource.RLIM_INFINITY else soft)\n";
1109 let result = execute_shell(
1110 "python3",
1111 &["-c", script],
1112 &[],
1113 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1114 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1115 &policy,
1116 &enforcement,
1117 )
1118 .expect("python3 must run");
1119
1120 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1121 let mut lines = result.stdout.lines();
1122 let reached: u64 = lines.next().unwrap().parse().unwrap();
1123 let soft = lines.next().unwrap();
1124 assert!(
1125 reached < 16,
1126 "pids.max is 16 and the python process holds one task, so at most 15 children can \
1127 be live; reached {reached}"
1128 );
1129 assert_eq!(
1130 result.resource_limit_hit,
1131 Some("cgroup_pids_max".to_string())
1132 );
1133 let (own_soft, _) = crate::resources::test_support::own_rlimit_nproc();
1134 assert_eq!(
1135 soft,
1136 crate::resources::test_support::rendered_rlimit(own_soft),
1137 "a scoped spawn inherits RLIMIT_NPROC unchanged"
1138 );
1139 }
1140
1141 #[test]
1150 fn a_shell_command_runs_in_the_accessible_workdir_with_home_inside_the_session_one() {
1151 let accessible = tempdir().unwrap();
1152 let session = accessible.path().join(".murmur").join("ses_shell_test");
1153 fs::create_dir_all(&session).unwrap();
1154 let policy = CapabilityPolicy {
1155 shell_allow: vec!["bash".to_string()],
1156 ..CapabilityPolicy::default()
1157 };
1158
1159 let result = execute_shell(
1160 "bash",
1161 &[
1162 "-c",
1163 "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && pwd",
1164 ],
1165 &[],
1166 &crate::workdir_fs::WorkdirRoot::open(accessible.path()).unwrap(),
1167 &crate::workdir_fs::WorkdirRoot::open(&session).unwrap(),
1168 &policy,
1169 &ShellEnforcement::environment_only(),
1170 )
1171 .expect("bash must run");
1172
1173 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
1174 let mut lines = result.stdout.lines();
1175 assert_eq!(lines.next(), Some("marker"));
1176 assert_eq!(
1177 std::fs::canonicalize(lines.next().unwrap()).unwrap(),
1178 std::fs::canonicalize(accessible.path()).unwrap(),
1179 "the subprocess cwd is the directory the capsule was asked to produce"
1180 );
1181
1182 assert_eq!(
1183 fs::read_to_string(session.join(SYNTHETIC_HOME_DIR_NAME).join("probe")).unwrap(),
1184 "marker\n"
1185 );
1186 assert!(
1187 !accessible.path().join(SYNTHETIC_HOME_DIR_NAME).exists(),
1188 "the synthetic home must add nothing to the top level of the accessible workdir"
1189 );
1190 }
1191
1192 #[test]
1195 fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
1196 let temp = tempdir().unwrap();
1197 let policy = CapabilityPolicy {
1198 shell_allow: vec!["bash".to_string()],
1199 ..CapabilityPolicy::default()
1200 };
1201
1202 let result = execute_shell(
1203 "bash",
1204 &["-c", "exit 3"],
1205 &[],
1206 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1207 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1208 &policy,
1209 &ShellEnforcement::environment_only(),
1210 )
1211 .expect("bash must run");
1212
1213 assert_eq!(result.exit_code, 3);
1214 assert_eq!(result.resource_limit_hit, None);
1215 }
1216
1217 #[test]
1218 fn execute_shell_blocks_binary_not_in_allowlist() {
1219 let policy = CapabilityPolicy::default();
1220 let error = execute_shell(
1221 "bash",
1222 &["-c", "echo hi"],
1223 &[],
1224 &crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
1225 &crate::workdir_fs::WorkdirRoot::open(Path::new(".")).unwrap(),
1226 &policy,
1227 &ShellEnforcement::environment_only(),
1228 )
1229 .unwrap_err();
1230
1231 assert!(error.to_string().contains("capabilities.shell.allow"));
1232 }
1233
1234 #[test]
1235 fn execute_shell_returns_nonzero_exit_code_without_error() {
1236 let temp = tempdir().unwrap();
1237 let policy = CapabilityPolicy {
1238 shell_allow: vec!["bash".to_string()],
1239 ..CapabilityPolicy::default()
1240 };
1241
1242 let result = execute_shell(
1243 "bash",
1244 &["-c", "exit 42"],
1245 &[],
1246 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1247 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1248 &policy,
1249 &ShellEnforcement::environment_only(),
1250 )
1251 .unwrap();
1252 assert_eq!(result.exit_code, 42);
1253 }
1254
1255 #[test]
1259 fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
1260 let temp = tempdir().unwrap();
1261 let policy = CapabilityPolicy {
1262 shell_allow: vec!["bash".to_string()],
1263 ..CapabilityPolicy::default()
1264 };
1265
1266 let result = execute_shell(
1267 "bash",
1268 &["-c", "exit 0"],
1269 &[],
1270 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1271 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1272 &policy,
1273 &ShellEnforcement::environment_only(),
1274 )
1275 .unwrap();
1276
1277 assert!(
1278 Path::new(&result.binary).is_absolute(),
1279 "a binary found on PATH must be reported as an absolute path, got {:?}",
1280 result.binary
1281 );
1282 assert!(
1283 result.binary.ends_with("bash"),
1284 "the reported path must still name bash, got {:?}",
1285 result.binary
1286 );
1287 }
1288
1289 #[test]
1295 fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
1296 let temp = tempdir().unwrap();
1297 let tool = temp.path().join("local-tool");
1298 fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
1299 let mut perms = fs::metadata(&tool).unwrap().permissions();
1300 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
1301 fs::set_permissions(&tool, perms).unwrap();
1302
1303 let policy = CapabilityPolicy {
1304 shell_allow: vec!["./local-tool".to_string()],
1305 ..CapabilityPolicy::default()
1306 };
1307
1308 let result = execute_shell(
1309 "./local-tool",
1310 &[],
1311 &[],
1312 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1313 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1314 &policy,
1315 &ShellEnforcement::environment_only(),
1316 )
1317 .unwrap();
1318
1319 assert_eq!(result.exit_code, 7, "the subprocess must still have run");
1320 assert_eq!(
1321 result.binary, "./local-tool",
1322 "an unresolvable name degrades to the bare invoked name, never an error"
1323 );
1324 }
1325
1326 #[test]
1327 fn build_shell_env_sets_synthetic_home_under_workdir() {
1328 let temp = tempdir().unwrap();
1329 let policy = CapabilityPolicy::default();
1330
1331 let env = build_shell_env(
1332 &policy,
1333 &[],
1334 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1335 None,
1336 None,
1337 )
1338 .unwrap();
1339
1340 let expected_home = temp.path().join(".capsule-home");
1341 assert_eq!(
1342 env.get("HOME"),
1343 Some(&expected_home.to_string_lossy().into_owned())
1344 );
1345 assert_eq!(
1346 env.get("USERPROFILE"),
1347 Some(&expected_home.to_string_lossy().into_owned())
1348 );
1349 assert!(expected_home.is_dir());
1350 }
1351
1352 #[test]
1353 fn build_shell_env_reports_synthetic_home_creation_failure() {
1354 let temp = tempdir().unwrap();
1355 std::fs::write(
1357 temp.path().join(SYNTHETIC_HOME_DIR_NAME),
1358 b"not a directory",
1359 )
1360 .unwrap();
1361 let policy = CapabilityPolicy::default();
1362
1363 let error = build_shell_env(
1364 &policy,
1365 &[],
1366 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1367 None,
1368 None,
1369 )
1370 .unwrap_err();
1371
1372 assert!(error.contains(SYNTHETIC_HOME_DIR_NAME), "{error}");
1373 }
1374
1375 #[test]
1376 fn execute_shell_does_not_spawn_when_synthetic_home_creation_fails() {
1377 let temp = tempdir().unwrap();
1378 std::fs::write(
1379 temp.path().join(SYNTHETIC_HOME_DIR_NAME),
1380 b"not a directory",
1381 )
1382 .unwrap();
1383 let workdir = temp.path().to_path_buf();
1384 let policy = CapabilityPolicy {
1385 shell_allow: vec!["bash".to_string()],
1386 ..CapabilityPolicy::default()
1387 };
1388
1389 let error = execute_shell(
1390 "bash",
1391 &["-c", "echo should-not-run"],
1392 &[],
1393 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
1394 &crate::workdir_fs::WorkdirRoot::open(&workdir).unwrap(),
1395 &policy,
1396 &ShellEnforcement::environment_only(),
1397 )
1398 .unwrap_err();
1399
1400 assert!(error.to_string().contains(".capsule-home"));
1401 }
1402
1403 #[test]
1404 fn execute_shell_reports_synthetic_home_not_real_host_home() {
1405 let temp = tempdir().unwrap();
1406 let policy = CapabilityPolicy {
1407 shell_allow: vec!["bash".to_string()],
1408 ..CapabilityPolicy::default()
1409 };
1410
1411 let result = execute_shell(
1412 "bash",
1413 &["-c", "echo -n $HOME"],
1414 &[],
1415 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1416 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1417 &policy,
1418 &ShellEnforcement::environment_only(),
1419 )
1420 .unwrap();
1421
1422 let expected_home = temp.path().join(".capsule-home");
1423 assert_eq!(result.stdout, expected_home.to_string_lossy());
1424 if let Ok(real_home) = std::env::var("HOME") {
1425 assert_ne!(result.stdout, real_home);
1426 }
1427 }
1428
1429 #[test]
1430 fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
1431 let temp = tempdir().unwrap();
1432 let policy = CapabilityPolicy::default();
1433 let overrides = vec![
1434 ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
1435 ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
1436 ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
1437 ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
1438 ("SAFE_VAR".to_string(), "kept".to_string()),
1439 ];
1440
1441 let env = build_shell_env(
1442 &policy,
1443 &overrides,
1444 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1445 None,
1446 None,
1447 )
1448 .unwrap();
1449
1450 assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
1451 assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
1452 assert!(!env.contains_key("STRIPE_API_KEY"));
1453 assert!(!env.contains_key("GITHUB_TOKEN"));
1454 assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
1455 }
1456
1457 #[test]
1458 fn declared_env_is_empty_without_declarations() {
1459 std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
1460 let policy = CapabilityPolicy::default();
1461
1462 let env = build_declared_env(&policy);
1463
1464 assert!(env.is_empty());
1465 }
1466
1467 #[test]
1470 fn declared_and_shell_env_never_carry_the_formation_lifeline() {
1471 use crate::lifeline::FORMATION_LIFELINE_ENV;
1472 let _guard = crate::formation::FORMATION_ENV_LOCK
1473 .lock()
1474 .unwrap_or_else(|poisoned| poisoned.into_inner());
1475 let temp = tempdir().unwrap();
1476 std::env::set_var(FORMATION_LIFELINE_ENV, "7");
1477 let policy = CapabilityPolicy {
1478 env_allow: vec![FORMATION_LIFELINE_ENV.to_string()],
1479 shell_baseline_env: vec![FORMATION_LIFELINE_ENV.to_string()],
1480 ..CapabilityPolicy::default()
1481 };
1482 let declared = build_declared_env(&policy);
1483 let shell = build_shell_env(
1484 &policy,
1485 &[],
1486 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1487 None,
1488 None,
1489 )
1490 .unwrap();
1491 std::env::remove_var(FORMATION_LIFELINE_ENV);
1492 assert!(
1493 !declared.contains_key(FORMATION_LIFELINE_ENV),
1494 "{declared:?}"
1495 );
1496 assert!(!shell.contains_key(FORMATION_LIFELINE_ENV), "{shell:?}");
1497 }
1498
1499 #[test]
1501 fn declared_and_shell_env_never_carry_the_spawner_lifeline() {
1502 use crate::lifeline::SPAWNER_LIFELINE_ENV;
1503 let _guard = crate::formation::FORMATION_ENV_LOCK
1504 .lock()
1505 .unwrap_or_else(|poisoned| poisoned.into_inner());
1506 let temp = tempdir().unwrap();
1507 std::env::set_var(SPAWNER_LIFELINE_ENV, "7");
1508 let policy = CapabilityPolicy {
1509 env_allow: vec![SPAWNER_LIFELINE_ENV.to_string()],
1510 shell_baseline_env: vec![SPAWNER_LIFELINE_ENV.to_string()],
1511 ..CapabilityPolicy::default()
1512 };
1513 let declared = build_declared_env(&policy);
1514 let shell = build_shell_env(
1515 &policy,
1516 &[],
1517 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1518 None,
1519 None,
1520 )
1521 .unwrap();
1522 std::env::remove_var(SPAWNER_LIFELINE_ENV);
1523 assert!(!declared.contains_key(SPAWNER_LIFELINE_ENV), "{declared:?}");
1524 assert!(!shell.contains_key(SPAWNER_LIFELINE_ENV), "{shell:?}");
1525 }
1526
1527 #[test]
1528 fn declared_env_passes_through_declared_host_var() {
1529 std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
1530 let policy = CapabilityPolicy {
1531 env_allow: vec![
1532 "MURMUR_TEST_WASI_ALLOWED".to_string(),
1533 "MURMUR_TEST_WASI_NEVER_SET".to_string(),
1535 ],
1536 ..CapabilityPolicy::default()
1537 };
1538
1539 let env = build_declared_env(&policy);
1540
1541 assert_eq!(
1542 env.get("MURMUR_TEST_WASI_ALLOWED"),
1543 Some(&"host-value".to_string())
1544 );
1545 assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
1546 }
1547
1548 #[test]
1549 fn declared_env_strips_credential_shaped_declarations() {
1550 std::env::set_var("GITHUB_TOKEN", "leaked-token");
1551 std::env::set_var("STRIPE_API_KEY", "leaked-key");
1552 std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
1553 let policy = CapabilityPolicy {
1554 env_allow: vec![
1555 "GITHUB_TOKEN".to_string(),
1556 "STRIPE_API_KEY".to_string(),
1557 "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
1558 ],
1559 shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
1560 ..CapabilityPolicy::default()
1561 };
1562
1563 let env = build_declared_env(&policy);
1564
1565 assert!(env.is_empty(), "expected all names stripped, got {env:?}");
1567 }
1568
1569 #[test]
1572 fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
1573 std::env::set_var("PRIVATE_KEY", "host-private-key");
1574 std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
1575 let policy = CapabilityPolicy {
1576 env_allow: vec![
1577 "PRIVATE_KEY".to_string(),
1578 "MURMUR_TEST_WASI_PLAIN".to_string(),
1579 ],
1580 ..CapabilityPolicy::default()
1581 };
1582
1583 let env = build_declared_env(&policy);
1584
1585 assert_eq!(
1586 env.get("PRIVATE_KEY"),
1587 Some(&"host-private-key".to_string())
1588 );
1589 assert_eq!(
1590 env.get("MURMUR_TEST_WASI_PLAIN"),
1591 Some(&"host-plain".to_string())
1592 );
1593 }
1594
1595 #[test]
1596 fn build_shell_env_home_override_cannot_survive() {
1597 let temp = tempdir().unwrap();
1598 let policy = CapabilityPolicy {
1599 shell_baseline_env: vec!["HOME".to_string()],
1600 ..CapabilityPolicy::default()
1601 };
1602 let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
1603
1604 let env = build_shell_env(
1605 &policy,
1606 &overrides,
1607 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1608 None,
1609 None,
1610 )
1611 .unwrap();
1612
1613 let expected_home = temp.path().join(".capsule-home");
1614 assert_eq!(
1615 env.get("HOME"),
1616 Some(&expected_home.to_string_lossy().into_owned())
1617 );
1618 }
1619
1620 #[test]
1621 fn build_shell_env_keeps_safe_baseline_vars() {
1622 let temp = tempdir().unwrap();
1623 let policy = CapabilityPolicy::default();
1624
1625 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
1626 let env = build_shell_env(
1627 &policy,
1628 &[],
1629 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1630 None,
1631 None,
1632 )
1633 .unwrap();
1634 std::env::remove_var("CARGO_HOME");
1635
1636 assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
1637 }
1638
1639 #[test]
1642 fn build_shell_env_carries_the_hosts_account_names() {
1643 let temp = tempdir().unwrap();
1644 let root = crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap();
1645 let _guard = ACCOUNT_ENV_LOCK
1646 .lock()
1647 .unwrap_or_else(|poisoned| poisoned.into_inner());
1648 std::env::set_var("USER", "murmur-test-user");
1649 std::env::set_var("LOGNAME", "murmur-test-login");
1650
1651 let env = build_shell_env(&CapabilityPolicy::default(), &[], &root, None, None).unwrap();
1652 let stripped = build_shell_env(
1653 &CapabilityPolicy {
1654 shell_strip_env: vec!["LOGNAME".to_string()],
1655 ..CapabilityPolicy::default()
1656 },
1657 &[],
1658 &root,
1659 None,
1660 None,
1661 )
1662 .unwrap();
1663
1664 assert_eq!(
1665 env.get("USER").map(String::as_str),
1666 Some("murmur-test-user")
1667 );
1668 assert_eq!(
1669 env.get("LOGNAME").map(String::as_str),
1670 Some("murmur-test-login")
1671 );
1672 let expected_home = temp.path().join(SYNTHETIC_HOME_DIR_NAME);
1673 assert_eq!(
1674 env.get("HOME"),
1675 Some(&expected_home.to_string_lossy().into_owned())
1676 );
1677 assert!(!stripped.contains_key("LOGNAME"), "{stripped:?}");
1678 assert_eq!(
1679 stripped.get("USER").map(String::as_str),
1680 Some("murmur-test-user")
1681 );
1682 let guest = build_declared_env(&CapabilityPolicy::default());
1683 assert!(
1684 !guest.contains_key("USER") && !guest.contains_key("LOGNAME"),
1685 "a WASM guest is handed only what env.allow declares: {guest:?}"
1686 );
1687 }
1688
1689 #[test]
1690 fn host_account_with_both_names_set_reports_none_unset() {
1691 let account = HostAccountEnv::from_pairs(&[("USER", "alice"), ("LOGNAME", "alice-login")]);
1692 assert!(account.unset().is_empty());
1693 let mut env = BTreeMap::new();
1694 account.apply(&mut env);
1695 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
1696 assert_eq!(env.get("LOGNAME").map(String::as_str), Some("alice-login"));
1697 }
1698
1699 #[test]
1701 fn host_account_with_one_name_set_never_copies_it_to_the_other() {
1702 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
1703 assert_eq!(account.unset(), ["LOGNAME".to_string()]);
1704 let mut env = BTreeMap::new();
1705 account.apply(&mut env);
1706 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
1707 assert!(!env.contains_key("LOGNAME"), "{env:?}");
1708 }
1709
1710 #[test]
1711 fn host_account_with_neither_name_set_reports_both_sorted() {
1712 let account = HostAccountEnv::from_pairs(&[]);
1713 assert_eq!(account.unset(), ["LOGNAME".to_string(), "USER".to_string()]);
1714 let mut env = BTreeMap::new();
1715 account.apply(&mut env);
1716 assert!(env.is_empty(), "{env:?}");
1717 }
1718
1719 #[test]
1722 fn host_account_apply_replaces_a_stale_value() {
1723 let account = HostAccountEnv::from_pairs(&[("USER", "alice")]);
1724 let mut env = BTreeMap::from([
1725 ("USER".to_string(), "someone-else".to_string()),
1726 ("LOGNAME".to_string(), "someone-else".to_string()),
1727 ("PATH".to_string(), "/bin".to_string()),
1728 ]);
1729 account.apply(&mut env);
1730 assert_eq!(env.get("USER").map(String::as_str), Some("alice"));
1731 assert!(!env.contains_key("LOGNAME"), "{env:?}");
1732 assert_eq!(env.get("PATH").map(String::as_str), Some("/bin"));
1733 }
1734
1735 #[test]
1736 fn build_shell_env_developer_declared_strip_env_still_composes() {
1737 let temp = tempdir().unwrap();
1738 let policy = CapabilityPolicy {
1739 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
1740 ..CapabilityPolicy::default()
1741 };
1742 let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
1743
1744 let env = build_shell_env(
1745 &policy,
1746 &overrides,
1747 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1748 None,
1749 None,
1750 )
1751 .unwrap();
1752
1753 assert!(!env.contains_key("MYCOMPANY_SECRET"));
1754 }
1755
1756 #[test]
1760 fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
1761 let extra = vec!["*_SERVICE_SECRET".to_string()];
1762 let names = [
1763 "ANTHROPIC_API_KEY",
1764 "GITHUB_TOKEN",
1765 "GITHUB_TOKEN_2",
1766 "AWS_ACCESS_KEY_ID",
1767 "MY_AWS_KEY",
1768 "STRIPE_API_KEY",
1769 "KUBECONFIG",
1770 "MY_SERVICE_SECRET",
1771 "DATABASE_PASSWORD",
1772 "SLACK_BOT_TOKEN",
1773 "HOME",
1774 "TZ",
1775 ];
1776
1777 let mut env: BTreeMap<String, String> = names
1778 .iter()
1779 .map(|name| ((*name).to_string(), "value".to_string()))
1780 .collect();
1781 strip_credential_shaped_vars(&mut env, &extra);
1782
1783 for name in names {
1784 assert_eq!(
1785 credential_backstop_drops(name, &extra),
1786 !env.contains_key(name),
1787 "prediction and strip disagree about {name}"
1788 );
1789 assert_eq!(
1790 credential_backstop_match(name, &extra).is_some(),
1791 !env.contains_key(name),
1792 "match and strip disagree about {name}"
1793 );
1794 }
1795 }
1796
1797 #[test]
1798 fn credential_backstop_match_attributes_a_builtin_pattern() {
1799 assert_eq!(
1800 credential_backstop_match("AWS_REGION", &[]),
1801 Some(BackstopMatch {
1802 pattern: "AWS_*".to_string(),
1803 source: BackstopPatternSource::Builtin,
1804 })
1805 );
1806 assert_eq!(
1807 credential_backstop_match("GITHUB_TOKEN", &[]),
1808 Some(BackstopMatch {
1809 pattern: "GITHUB_TOKEN".to_string(),
1810 source: BackstopPatternSource::Builtin,
1811 })
1812 );
1813 }
1814
1815 #[test]
1816 fn credential_backstop_match_attributes_a_strip_env_pattern() {
1817 assert_eq!(
1818 credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
1819 Some(BackstopMatch {
1820 pattern: "*_SERVICE_SECRET".to_string(),
1821 source: BackstopPatternSource::StripEnv,
1822 })
1823 );
1824 }
1825
1826 #[test]
1829 fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
1830 assert_eq!(
1831 credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
1832 Some(BackstopMatch {
1833 pattern: "*_API_KEY".to_string(),
1834 source: BackstopPatternSource::Builtin,
1835 })
1836 );
1837 }
1838
1839 #[test]
1840 fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
1841 assert_eq!(credential_backstop_match("TZ", &[]), None);
1842 assert_eq!(
1843 credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
1844 None
1845 );
1846 }
1847
1848 #[test]
1849 fn env_name_matches_pattern_trailing_wildcard() {
1850 assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
1851 assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
1852 }
1853
1854 #[test]
1855 fn env_name_matches_pattern_leading_wildcard() {
1856 assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
1857 assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
1858 }
1859
1860 #[test]
1861 fn env_name_matches_pattern_exact_match() {
1862 assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
1863 assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
1864 }
1865
1866 #[test]
1867 fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
1868 let yaml = shell_tool_manifest_yaml("bash");
1869 serde_yaml::from_str::<serde_yaml::Value>(&yaml).expect("bash manifest must be valid YAML");
1870 }
1871
1872 #[test]
1873 fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
1874 let yaml = shell_tool_manifest_yaml("curl");
1875 serde_yaml::from_str::<serde_yaml::Value>(&yaml)
1876 .expect("curl manifest must be valid YAML — embedded quotes break serde_yaml parsing");
1877 }
1878
1879 #[test]
1880 fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
1881 let yaml = shell_tool_manifest_yaml("curl");
1882 let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
1883 assert_eq!(
1884 value.get("runtime").and_then(|v| v.as_str()),
1885 Some("tool"),
1886 "non-interpreter manifest must have runtime: tool so inventory picks it up"
1887 );
1888 }
1889
1890 #[test]
1893 fn an_abandoned_command_demotes_before_its_grace() {
1894 use std::sync::Arc;
1895
1896 use crate::{
1897 detached::{AbandonSignal, DetachPolicy, DetachedRegistry},
1898 origin::{TaskOrigin, TaskProvenance},
1899 };
1900
1901 let temp = tempdir().unwrap();
1902 let policy = CapabilityPolicy {
1903 shell_allow: vec!["bash".to_string()],
1904 ..CapabilityPolicy::default()
1905 };
1906 let (registry, _reports) = DetachedRegistry::new();
1907 let abandon = AbandonSignal::new();
1908 let raiser = {
1909 let abandon = abandon.clone();
1910 std::thread::spawn(move || {
1911 std::thread::sleep(Duration::from_millis(200));
1912 abandon.raise();
1913 })
1914 };
1915
1916 let started = Instant::now();
1917 let outcome = run_shell(
1918 "bash",
1919 &["-c", "sleep 5"],
1922 &[],
1923 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1924 &crate::workdir_fs::WorkdirRoot::open(temp.path()).unwrap(),
1925 &policy,
1926 &ShellEnforcement::environment_only(),
1927 Some(DetachPolicy {
1928 grace: Duration::from_secs(60),
1929 registry: Arc::clone(®istry),
1930 command: "sleep 5".to_string(),
1931 context_id: "ctx_abandon_test".to_string(),
1932 provenance: Some(TaskProvenance::derive(TaskOrigin::User, None)),
1933 abandoned: Some(abandon),
1934 }),
1935 )
1936 .expect("a declared binary runs");
1937 let elapsed = started.elapsed();
1938 raiser.join().unwrap();
1939
1940 let ShellOutcome::Detached(info) = outcome else {
1941 panic!("an abandoned command must demote, got {outcome:?}");
1942 };
1943 assert!(
1944 elapsed < Duration::from_secs(2),
1945 "demotion waited {elapsed:?}, as if the 60 s grace still applied"
1946 );
1947 assert!(
1948 registry
1949 .outstanding()
1950 .iter()
1951 .any(|work| work.work_id == info.work_id),
1952 "the demoted command is registered like one that outran its grace"
1953 );
1954 }
1955
1956 #[test]
1957 fn a_shared_window_attributes_no_cgroup_delta() {
1958 assert_eq!(
1959 attribute_resource_limit(None, Some("cgroup_memory_bytes"), true).as_deref(),
1960 Some("cgroup_memory_bytes"),
1961 "a call alone in the scope owns its delta"
1962 );
1963 assert_eq!(
1964 attribute_resource_limit(None, Some("cgroup_memory_bytes"), false),
1965 None,
1966 "a delta from a shared window could be a sibling's"
1967 );
1968 assert_eq!(
1969 attribute_resource_limit(Some("cpu_seconds"), Some("cgroup_memory_bytes"), false)
1970 .as_deref(),
1971 Some("cpu_seconds"),
1972 "the signal is per-process evidence and needs no exclusive window"
1973 );
1974 assert_eq!(attribute_resource_limit(None, None, true), None);
1975 }
1976
1977 #[test]
1981 fn build_shell_env_sets_the_runtime_dir_last_whatever_else_says() {
1982 let workdir = tempfile::tempdir().unwrap();
1983 let root = WorkdirRoot::open(workdir.path()).unwrap();
1984 std::env::set_var(crate::runtime_dir::RUNTIME_DIR_ENV, "/host/value");
1985 let policy = CapabilityPolicy {
1986 shell_baseline_env: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
1987 env_allow: vec![crate::runtime_dir::RUNTIME_DIR_ENV.to_string()],
1988 ..CapabilityPolicy::default()
1989 };
1990 let overrides = [(
1991 crate::runtime_dir::RUNTIME_DIR_ENV.to_string(),
1992 "/override".to_string(),
1993 )];
1994
1995 let env = build_shell_env(
1996 &policy,
1997 &overrides,
1998 &root,
1999 Some(std::path::Path::new("/runtime/dir")),
2000 None,
2001 )
2002 .unwrap();
2003 assert_eq!(
2004 env.get(crate::runtime_dir::RUNTIME_DIR_ENV)
2005 .map(String::as_str),
2006 Some("/runtime/dir")
2007 );
2008
2009 let without = build_shell_env(&policy, &overrides, &root, None, None).unwrap();
2010 assert!(!without.contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
2011 assert!(!build_declared_env(&policy).contains_key(crate::runtime_dir::RUNTIME_DIR_ENV));
2012 }
2013
2014 #[test]
2018 fn build_shell_env_sets_the_records_dir_last_whatever_else_says() {
2019 let workdir = tempfile::tempdir().unwrap();
2020 let root = WorkdirRoot::open(workdir.path()).unwrap();
2021 std::env::set_var(crate::records_dir::RECORDS_DIR_ENV, "/host/records");
2022 let policy = CapabilityPolicy {
2023 shell_baseline_env: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
2024 env_allow: vec![crate::records_dir::RECORDS_DIR_ENV.to_string()],
2025 ..CapabilityPolicy::default()
2026 };
2027 let overrides = [(
2028 crate::records_dir::RECORDS_DIR_ENV.to_string(),
2029 "/override".to_string(),
2030 )];
2031
2032 let env = build_shell_env(
2033 &policy,
2034 &overrides,
2035 &root,
2036 None,
2037 Some(std::path::Path::new("/records/dir")),
2038 )
2039 .unwrap();
2040 assert_eq!(
2041 env.get(crate::records_dir::RECORDS_DIR_ENV)
2042 .map(String::as_str),
2043 Some("/records/dir")
2044 );
2045
2046 let without = build_shell_env(&policy, &overrides, &root, None, None).unwrap();
2047 assert!(!without.contains_key(crate::records_dir::RECORDS_DIR_ENV));
2048 assert!(!build_declared_env(&policy).contains_key(crate::records_dir::RECORDS_DIR_ENV));
2049 }
2050}