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_counters_before = enforcement
281 .cgroup_scope
282 .as_ref()
283 .map(|scope| scope.event_counters());
284
285 let mut child = match command.spawn() {
286 Ok(child) => child,
287 Err(error) => {
288 drop(command);
295 return Err(match supervisor.read_diagnostic() {
296 Some(detail) if detail.contains(crate::sealed::SEALED_ROOT_FAILURE_PREFIX) => {
302 ShellExecError::SealedRootConstructionFailed { detail }
303 }
304 Some(detail) if !detail.is_empty() => ShellExecError::Failed(format!(
305 "sandbox: shell enforcement setup failed before exec: {detail}"
306 )),
307 _ => ShellExecError::Failed(error.to_string()),
308 });
309 }
310 };
311
312 let readers = OutputReaders::spawn(&mut child);
316
317 let status = match &detach {
318 None => child.wait().map_err(|error| error.to_string())?,
319 Some(policy) => {
320 let deadline = started + policy.grace;
321 loop {
322 match child.try_wait().map_err(|error| error.to_string())? {
323 Some(status) => break status,
324 None => {
325 let remaining = deadline.saturating_duration_since(Instant::now());
326 if remaining.is_zero() {
327 return Ok(ShellOutcome::Detached(demote(
329 child,
330 readers,
331 supervisor,
332 DemotionContext {
333 policy: detach.expect("the detach policy was matched above"),
334 accessible_workdir: accessible_workdir.to_path_buf(),
335 invoked_binary: binary.to_string(),
336 resolved_binary,
337 args: args.iter().map(|arg| (*arg).to_string()).collect(),
338 started,
339 cgroup_scope: enforcement.cgroup_scope.clone(),
340 cgroup_counters_before,
341 },
342 )));
343 }
344 std::thread::sleep(remaining.min(DETACH_POLL_INTERVAL));
345 }
346 }
347 }
348 }
349 };
350 let (stdout_bytes, stderr_bytes) = readers.join();
351 supervisor.join_best_effort();
352 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
353
354 let resource_limit_hit = classify_resource_limit(
355 &status,
356 enforcement.cgroup_scope.as_deref(),
357 cgroup_counters_before,
358 );
359 let exit_code = exit_code_of(&status);
360
361 let mut stdout = String::from_utf8_lossy(&stdout_bytes).to_string();
362 let mut stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
363 let mut truncated = false;
364 let mut full_output_path = None;
365
366 if stdout_bytes.len() > MAX_SHELL_OUTPUT_BYTES || stderr_bytes.len() > MAX_SHELL_OUTPUT_BYTES {
367 truncated = true;
368 let stdout_cut = stdout_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
369 let stderr_cut = stderr_bytes.len().min(MAX_SHELL_OUTPUT_BYTES);
370 let full_stdout = std::mem::replace(
371 &mut stdout,
372 String::from_utf8_lossy(&stdout_bytes[..stdout_cut]).to_string(),
373 );
374 let full_stderr = std::mem::replace(
375 &mut stderr,
376 String::from_utf8_lossy(&stderr_bytes[..stderr_cut]).to_string(),
377 );
378 full_output_path = Some(write_shell_output_log(
379 accessible_workdir,
380 &format!("shell-{}", crate::trace::timestamp_ms()),
381 binary,
382 args,
383 exit_code,
384 &full_stdout,
385 &full_stderr,
386 )?);
387 }
388
389 Ok(ShellOutcome::Finished(ShellResult {
390 binary: resolved_binary,
391 exit_code,
392 stdout,
393 stderr,
394 duration_ms,
395 truncated,
396 full_output_path,
397 resource_limit_hit,
398 }))
399}
400
401pub(crate) fn execute_shell(
406 binary: &str,
407 args: &[&str],
408 env_overrides: &[(String, String)],
409 accessible_workdir: &Path,
410 session_workdir: &Path,
411 policy: &CapabilityPolicy,
412 enforcement: &crate::sandbox::ShellEnforcement,
413) -> Result<ShellResult, ShellExecError> {
414 match run_shell(
415 binary,
416 args,
417 env_overrides,
418 accessible_workdir,
419 session_workdir,
420 policy,
421 enforcement,
422 None,
423 )? {
424 ShellOutcome::Finished(result) => Ok(result),
425 ShellOutcome::Detached(info) => Err(ShellExecError::Failed(format!(
429 "shell command was demoted to the background as {} on a call site that cannot receive a completion",
430 info.work_id
431 ))),
432 }
433}
434
435struct OutputReaders {
441 stdout: Option<std::thread::JoinHandle<Vec<u8>>>,
442 stderr: Option<std::thread::JoinHandle<Vec<u8>>>,
443}
444
445impl OutputReaders {
446 fn spawn(child: &mut std::process::Child) -> Self {
447 fn drain(
448 pipe: Option<impl std::io::Read + Send + 'static>,
449 ) -> Option<std::thread::JoinHandle<Vec<u8>>> {
450 let mut pipe = pipe?;
451 Some(std::thread::spawn(move || {
452 let mut buffer = Vec::new();
453 let _ = pipe.read_to_end(&mut buffer);
457 buffer
458 }))
459 }
460
461 Self {
462 stdout: drain(child.stdout.take()),
463 stderr: drain(child.stderr.take()),
464 }
465 }
466
467 fn join(self) -> (Vec<u8>, Vec<u8>) {
470 let take = |handle: Option<std::thread::JoinHandle<Vec<u8>>>| {
471 handle
472 .map(|handle| handle.join().unwrap_or_default())
473 .unwrap_or_default()
474 };
475 (take(self.stdout), take(self.stderr))
476 }
477}
478
479struct DemotionContext {
481 policy: DetachPolicy,
482 accessible_workdir: PathBuf,
485 invoked_binary: String,
487 resolved_binary: String,
490 args: Vec<String>,
491 started: Instant,
492 cgroup_scope: Option<std::sync::Arc<crate::cgroup::CgroupScope>>,
493 cgroup_counters_before: Option<crate::cgroup::CgroupEventCounters>,
494}
495
496fn demote(
501 mut child: std::process::Child,
502 readers: OutputReaders,
503 supervisor: crate::sandbox::SupervisorHandle,
504 context: DemotionContext,
505) -> DetachedDispatchInfo {
506 let work_id = crate::detached::new_work_id();
507 let info = DetachedDispatchInfo {
508 work_id: work_id.clone(),
509 binary: context.resolved_binary.clone(),
510 command: context.policy.command.clone(),
511 grace_ms: context
512 .policy
513 .grace
514 .as_millis()
515 .try_into()
516 .unwrap_or(u64::MAX),
517 };
518
519 context
520 .policy
521 .registry
522 .register(crate::detached::DetachedWork {
523 work_id: work_id.clone(),
524 binary: context.resolved_binary.clone(),
525 command: context.policy.command.clone(),
526 started_at_ms: crate::trace::timestamp_ms().saturating_sub(
532 context
533 .started
534 .elapsed()
535 .as_millis()
536 .try_into()
537 .unwrap_or(u64::MAX),
538 ),
539 });
540
541 let provenance = context.policy.completion_provenance();
542 let DemotionContext {
543 policy,
544 accessible_workdir,
545 invoked_binary,
546 resolved_binary,
547 args,
548 started,
549 cgroup_scope,
550 cgroup_counters_before,
551 } = context;
552
553 std::thread::spawn(move || {
554 let waited = child.wait();
555 let (stdout_bytes, stderr_bytes) = readers.join();
556 supervisor.join_best_effort();
560 let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
561
562 let (exit_code, resource_limit, mut error) = match waited {
563 Ok(status) => (
564 exit_code_of(&status),
565 classify_resource_limit(&status, cgroup_scope.as_deref(), cgroup_counters_before),
566 None,
567 ),
568 Err(failure) => (
569 -1,
570 None,
571 Some(format!("waiting for the command failed: {failure}")),
572 ),
573 };
574
575 let stdout = String::from_utf8_lossy(&stdout_bytes).into_owned();
576 let stderr = String::from_utf8_lossy(&stderr_bytes).into_owned();
577 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
578 let output_path = crate::detached::output_path_for(&work_id);
579 if let Err(failure) = write_shell_output_log(
580 &accessible_workdir,
581 &work_id,
582 &invoked_binary,
583 &arg_refs,
584 exit_code,
585 &stdout,
586 &stderr,
587 ) {
588 error = Some(match error {
589 Some(existing) => format!("{existing}; {failure}"),
590 None => failure,
591 });
592 }
593 let output_bytes = std::fs::metadata(accessible_workdir.join(&output_path))
594 .map(|metadata| metadata.len())
595 .unwrap_or(0);
596
597 policy
598 .registry
599 .complete(crate::detached::DetachedCompletion {
600 work_id,
601 binary: resolved_binary,
602 command: policy.command.clone(),
603 exit_code,
604 duration_ms,
605 output_path,
606 output_bytes,
607 resource_limit,
608 context_id: policy.context_id.clone(),
609 provenance,
610 error,
611 });
612 });
613
614 info
615}
616
617fn exit_code_of(status: &std::process::ExitStatus) -> i32 {
625 use std::os::unix::process::ExitStatusExt;
626
627 status
628 .code()
629 .or_else(|| status.signal().map(|signal| 128 + signal))
630 .unwrap_or(-1)
631}
632
633fn classify_resource_limit(
654 status: &std::process::ExitStatus,
655 cgroup_scope: Option<&crate::cgroup::CgroupScope>,
656 counters_before: Option<crate::cgroup::CgroupEventCounters>,
657) -> Option<String> {
658 use std::os::unix::process::ExitStatusExt;
659
660 if let Some(limit) = status
661 .signal()
662 .and_then(crate::resources::limit_from_signal)
663 {
664 return Some(limit.to_string());
665 }
666
667 let (scope, before) = (cgroup_scope?, counters_before?);
668 scope
669 .event_counters()
670 .attribution_since(before)
671 .map(str::to_string)
672}
673
674pub(crate) fn build_shell_env(
675 policy: &CapabilityPolicy,
676 env_overrides: &[(String, String)],
677 session_workdir: &Path,
678) -> Result<BTreeMap<String, String>, String> {
679 let mut env = BTreeMap::new();
680
681 for key in DEFAULT_ENV_BASELINE {
682 if let Ok(value) = std::env::var(key) {
683 env.insert((*key).to_string(), value);
684 }
685 }
686
687 for (key, value) in env_overrides {
688 env.insert(key.clone(), value.clone());
689 }
690
691 for key in &policy.shell_baseline_env {
692 if let Ok(value) = std::env::var(key) {
693 env.insert(key.clone(), value);
694 }
695 }
696
697 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
698
699 let synthetic_home = synthetic_home_dir(session_workdir)?;
703 let synthetic_home = synthetic_home.to_string_lossy().into_owned();
704 env.insert("HOME".to_string(), synthetic_home.clone());
705 env.insert("USERPROFILE".to_string(), synthetic_home);
706
707 Ok(env)
708}
709
710pub(crate) fn strip_credential_shaped_vars(
718 env: &mut BTreeMap<String, String>,
719 extra_patterns: &[String],
720) {
721 env.retain(|key, _| !credential_backstop_drops(key, extra_patterns));
722}
723
724#[derive(Debug, Clone, PartialEq, Eq)]
726pub enum BackstopPatternSource {
727 Builtin,
729 StripEnv,
731}
732
733#[derive(Debug, Clone, PartialEq, Eq)]
735pub struct BackstopMatch {
736 pub pattern: String,
737 pub source: BackstopPatternSource,
738}
739
740pub fn credential_backstop_match(name: &str, extra_patterns: &[String]) -> Option<BackstopMatch> {
749 CREDENTIAL_ENV_PATTERNS
750 .iter()
751 .map(|pattern| (*pattern, BackstopPatternSource::Builtin))
752 .chain(
753 extra_patterns
754 .iter()
755 .map(|pattern| (pattern.as_str(), BackstopPatternSource::StripEnv)),
756 )
757 .find(|(pattern, _)| env_name_matches_pattern(pattern, name))
758 .map(|(pattern, source)| BackstopMatch {
759 pattern: pattern.to_string(),
760 source,
761 })
762}
763
764pub fn credential_backstop_drops(name: &str, extra_patterns: &[String]) -> bool {
768 credential_backstop_match(name, extra_patterns).is_some()
769}
770
771pub(crate) fn build_wasi_env_allowlist(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
780 let mut env = BTreeMap::new();
781
782 for key in &policy.env_allow {
783 if key == ARTIFACT_CONFIG_ENV {
784 continue;
785 }
786 if let Ok(value) = std::env::var(key) {
787 env.insert(key.clone(), value);
788 }
789 }
790
791 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
792
793 env
794}
795
796fn synthetic_home_dir(session_workdir: &Path) -> Result<std::path::PathBuf, String> {
797 let home = session_workdir.join(SYNTHETIC_HOME_DIR_NAME);
798 fs::create_dir_all(&home).map_err(|error| {
799 format!(
800 "failed to create synthetic home directory {}: {error}",
801 home.display()
802 )
803 })?;
804 Ok(home)
805}
806
807fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
808 if let Some(rest) = pattern.strip_prefix('*') {
809 if let Some(middle) = rest.strip_suffix('*') {
810 return key.contains(middle);
811 }
812 return key.ends_with(rest);
813 }
814
815 if let Some(prefix) = pattern.strip_suffix('*') {
816 return key.starts_with(prefix);
817 }
818
819 pattern == key
820}
821
822fn write_shell_output_log(
829 accessible_workdir: &Path,
830 file_stem: &str,
831 binary: &str,
832 args: &[&str],
833 exit_code: i32,
834 stdout: &str,
835 stderr: &str,
836) -> Result<String, String> {
837 let logs_dir = accessible_workdir.join("logs");
838 fs::create_dir_all(&logs_dir)
839 .map_err(|error| format!("failed to create shell logs directory: {error}"))?;
840
841 let filename = format!("{file_stem}.log");
842 let relative_path = format!("logs/{filename}");
843 let path = logs_dir.join(&filename);
844
845 let command = if args.is_empty() {
846 binary.to_string()
847 } else {
848 format!("{binary} {}", args.join(" "))
849 };
850
851 let content = format!(
852 "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
853 );
854
855 fs::write(&path, content)
856 .map_err(|error| format!("failed to write shell log {}: {error}", path.display()))?;
857
858 Ok(relative_path)
859}
860
861#[cfg(test)]
862mod tests {
863 use tempfile::tempdir;
864
865 use super::*;
866 use crate::sandbox::ShellEnforcement;
867
868 #[test]
874 fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
875 let temp = tempdir().unwrap();
876 let policy = CapabilityPolicy {
877 shell_allow: vec!["bash".to_string()],
878 resources: crate::resources::HostResourceLimits {
879 max_open_files: 64,
880 ..crate::resources::HostResourceLimits::default()
881 },
882 ..CapabilityPolicy::default()
883 };
884 let mut enforcement = ShellEnforcement::environment_only();
885 enforcement.resource_limits = policy.resources;
886
887 let result = execute_shell(
888 "bash",
889 &["-c", "ulimit -Hn"],
890 &[],
891 temp.path(),
892 temp.path(),
893 &policy,
894 &enforcement,
895 )
896 .expect("bash must run");
897
898 assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
899 assert_eq!(result.resource_limit_hit, None);
900 }
901
902 #[test]
911 fn a_shell_command_runs_in_the_accessible_workdir_with_home_inside_the_session_one() {
912 let accessible = tempdir().unwrap();
913 let session = accessible.path().join(".murmur").join("ses_shell_test");
914 fs::create_dir_all(&session).unwrap();
915 let policy = CapabilityPolicy {
916 shell_allow: vec!["bash".to_string()],
917 ..CapabilityPolicy::default()
918 };
919
920 let result = execute_shell(
921 "bash",
922 &[
923 "-c",
924 "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && pwd",
925 ],
926 &[],
927 accessible.path(),
928 &session,
929 &policy,
930 &ShellEnforcement::environment_only(),
931 )
932 .expect("bash must run");
933
934 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
935 let mut lines = result.stdout.lines();
936 assert_eq!(lines.next(), Some("marker"));
937 assert_eq!(
938 std::fs::canonicalize(lines.next().unwrap()).unwrap(),
939 std::fs::canonicalize(accessible.path()).unwrap(),
940 "the subprocess cwd is the directory the capsule was asked to produce"
941 );
942
943 assert_eq!(
944 fs::read_to_string(session.join(SYNTHETIC_HOME_DIR_NAME).join("probe")).unwrap(),
945 "marker\n"
946 );
947 assert!(
948 !accessible.path().join(SYNTHETIC_HOME_DIR_NAME).exists(),
949 "the synthetic home must add nothing to the top level of the accessible workdir"
950 );
951 }
952
953 #[test]
956 fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
957 let temp = tempdir().unwrap();
958 let policy = CapabilityPolicy {
959 shell_allow: vec!["bash".to_string()],
960 ..CapabilityPolicy::default()
961 };
962
963 let result = execute_shell(
964 "bash",
965 &["-c", "exit 3"],
966 &[],
967 temp.path(),
968 temp.path(),
969 &policy,
970 &ShellEnforcement::environment_only(),
971 )
972 .expect("bash must run");
973
974 assert_eq!(result.exit_code, 3);
975 assert_eq!(result.resource_limit_hit, None);
976 }
977
978 #[test]
979 fn execute_shell_blocks_binary_not_in_allowlist() {
980 let policy = CapabilityPolicy::default();
981 let error = execute_shell(
982 "bash",
983 &["-c", "echo hi"],
984 &[],
985 Path::new("."),
986 Path::new("."),
987 &policy,
988 &ShellEnforcement::environment_only(),
989 )
990 .unwrap_err();
991
992 assert!(error.to_string().contains("capabilities.shell.allow"));
993 }
994
995 #[test]
996 fn execute_shell_returns_nonzero_exit_code_without_error() {
997 let temp = tempdir().unwrap();
998 let policy = CapabilityPolicy {
999 shell_allow: vec!["bash".to_string()],
1000 ..CapabilityPolicy::default()
1001 };
1002
1003 let result = execute_shell(
1004 "bash",
1005 &["-c", "exit 42"],
1006 &[],
1007 temp.path(),
1008 temp.path(),
1009 &policy,
1010 &ShellEnforcement::environment_only(),
1011 )
1012 .unwrap();
1013 assert_eq!(result.exit_code, 42);
1014 }
1015
1016 #[test]
1020 fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
1021 let temp = tempdir().unwrap();
1022 let policy = CapabilityPolicy {
1023 shell_allow: vec!["bash".to_string()],
1024 ..CapabilityPolicy::default()
1025 };
1026
1027 let result = execute_shell(
1028 "bash",
1029 &["-c", "exit 0"],
1030 &[],
1031 temp.path(),
1032 temp.path(),
1033 &policy,
1034 &ShellEnforcement::environment_only(),
1035 )
1036 .unwrap();
1037
1038 assert!(
1039 Path::new(&result.binary).is_absolute(),
1040 "a binary found on PATH must be reported as an absolute path, got {:?}",
1041 result.binary
1042 );
1043 assert!(
1044 result.binary.ends_with("bash"),
1045 "the reported path must still name bash, got {:?}",
1046 result.binary
1047 );
1048 }
1049
1050 #[test]
1056 fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
1057 let temp = tempdir().unwrap();
1058 let tool = temp.path().join("local-tool");
1059 fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
1060 let mut perms = fs::metadata(&tool).unwrap().permissions();
1061 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
1062 fs::set_permissions(&tool, perms).unwrap();
1063
1064 let policy = CapabilityPolicy {
1065 shell_allow: vec!["./local-tool".to_string()],
1066 ..CapabilityPolicy::default()
1067 };
1068
1069 let result = execute_shell(
1070 "./local-tool",
1071 &[],
1072 &[],
1073 temp.path(),
1074 temp.path(),
1075 &policy,
1076 &ShellEnforcement::environment_only(),
1077 )
1078 .unwrap();
1079
1080 assert_eq!(result.exit_code, 7, "the subprocess must still have run");
1081 assert_eq!(
1082 result.binary, "./local-tool",
1083 "an unresolvable name degrades to the bare invoked name, never an error"
1084 );
1085 }
1086
1087 #[test]
1088 fn build_shell_env_sets_synthetic_home_under_workdir() {
1089 let temp = tempdir().unwrap();
1090 let policy = CapabilityPolicy::default();
1091
1092 let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1093
1094 let expected_home = temp.path().join(".capsule-home");
1095 assert_eq!(
1096 env.get("HOME"),
1097 Some(&expected_home.to_string_lossy().into_owned())
1098 );
1099 assert_eq!(
1100 env.get("USERPROFILE"),
1101 Some(&expected_home.to_string_lossy().into_owned())
1102 );
1103 assert!(expected_home.is_dir());
1104 }
1105
1106 #[test]
1107 fn build_shell_env_reports_synthetic_home_creation_failure() {
1108 let temp = tempdir().unwrap();
1109 let blocking_file = temp.path().join("blocked");
1110 std::fs::write(&blocking_file, b"not a directory").unwrap();
1111 let workdir = blocking_file.join("subdir");
1112 let policy = CapabilityPolicy::default();
1113
1114 let error = build_shell_env(&policy, &[], &workdir).unwrap_err();
1115
1116 let expected_home = workdir.join(".capsule-home");
1117 assert!(error.contains(&expected_home.to_string_lossy().into_owned()));
1118 }
1119
1120 #[test]
1121 fn execute_shell_does_not_spawn_when_synthetic_home_creation_fails() {
1122 let temp = tempdir().unwrap();
1123 let blocking_file = temp.path().join("blocked");
1124 std::fs::write(&blocking_file, b"not a directory").unwrap();
1125 let workdir = blocking_file.join("subdir");
1126 let policy = CapabilityPolicy {
1127 shell_allow: vec!["bash".to_string()],
1128 ..CapabilityPolicy::default()
1129 };
1130
1131 let error = execute_shell(
1132 "bash",
1133 &["-c", "echo should-not-run"],
1134 &[],
1135 &workdir,
1136 &workdir,
1137 &policy,
1138 &ShellEnforcement::environment_only(),
1139 )
1140 .unwrap_err();
1141
1142 assert!(error.to_string().contains(".capsule-home"));
1143 }
1144
1145 #[test]
1146 fn execute_shell_reports_synthetic_home_not_real_host_home() {
1147 let temp = tempdir().unwrap();
1148 let policy = CapabilityPolicy {
1149 shell_allow: vec!["bash".to_string()],
1150 ..CapabilityPolicy::default()
1151 };
1152
1153 let result = execute_shell(
1154 "bash",
1155 &["-c", "echo -n $HOME"],
1156 &[],
1157 temp.path(),
1158 temp.path(),
1159 &policy,
1160 &ShellEnforcement::environment_only(),
1161 )
1162 .unwrap();
1163
1164 let expected_home = temp.path().join(".capsule-home");
1165 assert_eq!(result.stdout, expected_home.to_string_lossy());
1166 if let Ok(real_home) = std::env::var("HOME") {
1167 assert_ne!(result.stdout, real_home);
1168 }
1169 }
1170
1171 #[test]
1172 fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
1173 let temp = tempdir().unwrap();
1174 let policy = CapabilityPolicy::default();
1175 let overrides = vec![
1176 ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
1177 ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
1178 ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
1179 ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
1180 ("SAFE_VAR".to_string(), "kept".to_string()),
1181 ];
1182
1183 let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1184
1185 assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
1186 assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
1187 assert!(!env.contains_key("STRIPE_API_KEY"));
1188 assert!(!env.contains_key("GITHUB_TOKEN"));
1189 assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
1190 }
1191
1192 #[test]
1193 fn build_wasi_env_allowlist_is_empty_without_declarations() {
1194 std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
1195 let policy = CapabilityPolicy::default();
1196
1197 let env = build_wasi_env_allowlist(&policy);
1198
1199 assert!(env.is_empty());
1200 }
1201
1202 #[test]
1203 fn build_wasi_env_allowlist_passes_through_declared_host_var() {
1204 std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
1205 let policy = CapabilityPolicy {
1206 env_allow: vec![
1207 "MURMUR_TEST_WASI_ALLOWED".to_string(),
1208 "MURMUR_TEST_WASI_NEVER_SET".to_string(),
1210 ],
1211 ..CapabilityPolicy::default()
1212 };
1213
1214 let env = build_wasi_env_allowlist(&policy);
1215
1216 assert_eq!(
1217 env.get("MURMUR_TEST_WASI_ALLOWED"),
1218 Some(&"host-value".to_string())
1219 );
1220 assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
1221 }
1222
1223 #[test]
1224 fn build_wasi_env_allowlist_strips_credential_shaped_declarations() {
1225 std::env::set_var("GITHUB_TOKEN", "leaked-token");
1226 std::env::set_var("STRIPE_API_KEY", "leaked-key");
1227 std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
1228 let policy = CapabilityPolicy {
1229 env_allow: vec![
1230 "GITHUB_TOKEN".to_string(),
1231 "STRIPE_API_KEY".to_string(),
1232 "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
1233 ],
1234 shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
1235 ..CapabilityPolicy::default()
1236 };
1237
1238 let env = build_wasi_env_allowlist(&policy);
1239
1240 assert!(env.is_empty(), "expected all names stripped, got {env:?}");
1242 }
1243
1244 #[test]
1247 fn build_wasi_env_allowlist_delivers_a_credential_shaped_name_the_backstop_keeps() {
1248 std::env::set_var("PRIVATE_KEY", "host-private-key");
1249 std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
1250 let policy = CapabilityPolicy {
1251 env_allow: vec![
1252 "PRIVATE_KEY".to_string(),
1253 "MURMUR_TEST_WASI_PLAIN".to_string(),
1254 ],
1255 ..CapabilityPolicy::default()
1256 };
1257
1258 let env = build_wasi_env_allowlist(&policy);
1259
1260 assert_eq!(
1261 env.get("PRIVATE_KEY"),
1262 Some(&"host-private-key".to_string())
1263 );
1264 assert_eq!(
1265 env.get("MURMUR_TEST_WASI_PLAIN"),
1266 Some(&"host-plain".to_string())
1267 );
1268 }
1269
1270 #[test]
1271 fn build_shell_env_home_override_cannot_survive() {
1272 let temp = tempdir().unwrap();
1273 let policy = CapabilityPolicy {
1274 shell_baseline_env: vec!["HOME".to_string()],
1275 ..CapabilityPolicy::default()
1276 };
1277 let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
1278
1279 let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1280
1281 let expected_home = temp.path().join(".capsule-home");
1282 assert_eq!(
1283 env.get("HOME"),
1284 Some(&expected_home.to_string_lossy().into_owned())
1285 );
1286 }
1287
1288 #[test]
1289 fn build_shell_env_keeps_safe_baseline_vars() {
1290 let temp = tempdir().unwrap();
1291 let policy = CapabilityPolicy::default();
1292
1293 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
1294 let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1295 std::env::remove_var("CARGO_HOME");
1296
1297 assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
1298 }
1299
1300 #[test]
1301 fn build_shell_env_developer_declared_strip_env_still_composes() {
1302 let temp = tempdir().unwrap();
1303 let policy = CapabilityPolicy {
1304 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
1305 ..CapabilityPolicy::default()
1306 };
1307 let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
1308
1309 let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1310
1311 assert!(!env.contains_key("MYCOMPANY_SECRET"));
1312 }
1313
1314 #[test]
1318 fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
1319 let extra = vec!["*_SERVICE_SECRET".to_string()];
1320 let names = [
1321 "ANTHROPIC_API_KEY",
1322 "GITHUB_TOKEN",
1323 "GITHUB_TOKEN_2",
1324 "AWS_ACCESS_KEY_ID",
1325 "MY_AWS_KEY",
1326 "STRIPE_API_KEY",
1327 "KUBECONFIG",
1328 "MY_SERVICE_SECRET",
1329 "DATABASE_PASSWORD",
1330 "SLACK_BOT_TOKEN",
1331 "HOME",
1332 "TZ",
1333 ];
1334
1335 let mut env: BTreeMap<String, String> = names
1336 .iter()
1337 .map(|name| ((*name).to_string(), "value".to_string()))
1338 .collect();
1339 strip_credential_shaped_vars(&mut env, &extra);
1340
1341 for name in names {
1342 assert_eq!(
1343 credential_backstop_drops(name, &extra),
1344 !env.contains_key(name),
1345 "prediction and strip disagree about {name}"
1346 );
1347 assert_eq!(
1348 credential_backstop_match(name, &extra).is_some(),
1349 !env.contains_key(name),
1350 "match and strip disagree about {name}"
1351 );
1352 }
1353 }
1354
1355 #[test]
1356 fn credential_backstop_match_attributes_a_builtin_pattern() {
1357 assert_eq!(
1358 credential_backstop_match("AWS_REGION", &[]),
1359 Some(BackstopMatch {
1360 pattern: "AWS_*".to_string(),
1361 source: BackstopPatternSource::Builtin,
1362 })
1363 );
1364 assert_eq!(
1365 credential_backstop_match("GITHUB_TOKEN", &[]),
1366 Some(BackstopMatch {
1367 pattern: "GITHUB_TOKEN".to_string(),
1368 source: BackstopPatternSource::Builtin,
1369 })
1370 );
1371 }
1372
1373 #[test]
1374 fn credential_backstop_match_attributes_a_strip_env_pattern() {
1375 assert_eq!(
1376 credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
1377 Some(BackstopMatch {
1378 pattern: "*_SERVICE_SECRET".to_string(),
1379 source: BackstopPatternSource::StripEnv,
1380 })
1381 );
1382 }
1383
1384 #[test]
1387 fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
1388 assert_eq!(
1389 credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
1390 Some(BackstopMatch {
1391 pattern: "*_API_KEY".to_string(),
1392 source: BackstopPatternSource::Builtin,
1393 })
1394 );
1395 }
1396
1397 #[test]
1398 fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
1399 assert_eq!(credential_backstop_match("TZ", &[]), None);
1400 assert_eq!(
1401 credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
1402 None
1403 );
1404 }
1405
1406 #[test]
1407 fn env_name_matches_pattern_trailing_wildcard() {
1408 assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
1409 assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
1410 }
1411
1412 #[test]
1413 fn env_name_matches_pattern_leading_wildcard() {
1414 assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
1415 assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
1416 }
1417
1418 #[test]
1419 fn env_name_matches_pattern_exact_match() {
1420 assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
1421 assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
1422 }
1423
1424 #[test]
1425 fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
1426 let yaml = shell_tool_manifest_yaml("bash");
1427 serde_yaml::from_str::<serde_yaml::Value>(&yaml).expect("bash manifest must be valid YAML");
1428 }
1429
1430 #[test]
1431 fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
1432 let yaml = shell_tool_manifest_yaml("curl");
1433 serde_yaml::from_str::<serde_yaml::Value>(&yaml)
1434 .expect("curl manifest must be valid YAML — embedded quotes break serde_yaml parsing");
1435 }
1436
1437 #[test]
1438 fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
1439 let yaml = shell_tool_manifest_yaml("curl");
1440 let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
1441 assert_eq!(
1442 value.get("runtime").and_then(|v| v.as_str()),
1443 Some("tool"),
1444 "non-interpreter manifest must have runtime: tool so inventory picks it up"
1445 );
1446 }
1447}