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_declared_env(policy: &CapabilityPolicy) -> BTreeMap<String, String> {
785 let mut env = BTreeMap::new();
786
787 for key in &policy.env_allow {
788 if key == ARTIFACT_CONFIG_ENV {
789 continue;
790 }
791 if let Ok(value) = std::env::var(key) {
792 env.insert(key.clone(), value);
793 }
794 }
795
796 strip_credential_shaped_vars(&mut env, &policy.shell_strip_env);
797
798 env
799}
800
801fn synthetic_home_dir(session_workdir: &Path) -> Result<std::path::PathBuf, String> {
802 let home = session_workdir.join(SYNTHETIC_HOME_DIR_NAME);
803 fs::create_dir_all(&home).map_err(|error| {
804 format!(
805 "failed to create synthetic home directory {}: {error}",
806 home.display()
807 )
808 })?;
809 Ok(home)
810}
811
812fn env_name_matches_pattern(pattern: &str, key: &str) -> bool {
813 if let Some(rest) = pattern.strip_prefix('*') {
814 if let Some(middle) = rest.strip_suffix('*') {
815 return key.contains(middle);
816 }
817 return key.ends_with(rest);
818 }
819
820 if let Some(prefix) = pattern.strip_suffix('*') {
821 return key.starts_with(prefix);
822 }
823
824 pattern == key
825}
826
827fn write_shell_output_log(
834 accessible_workdir: &Path,
835 file_stem: &str,
836 binary: &str,
837 args: &[&str],
838 exit_code: i32,
839 stdout: &str,
840 stderr: &str,
841) -> Result<String, String> {
842 let logs_dir = accessible_workdir.join("logs");
843 fs::create_dir_all(&logs_dir)
844 .map_err(|error| format!("failed to create shell logs directory: {error}"))?;
845
846 let filename = format!("{file_stem}.log");
847 let relative_path = format!("logs/{filename}");
848 let path = logs_dir.join(&filename);
849
850 let command = if args.is_empty() {
851 binary.to_string()
852 } else {
853 format!("{binary} {}", args.join(" "))
854 };
855
856 let content = format!(
857 "Command: {command}\nExit code: {exit_code}\n\nStdout:\n{stdout}\n\nStderr:\n{stderr}\n"
858 );
859
860 fs::write(&path, content)
861 .map_err(|error| format!("failed to write shell log {}: {error}", path.display()))?;
862
863 Ok(relative_path)
864}
865
866#[cfg(test)]
867mod tests {
868 use tempfile::tempdir;
869
870 use super::*;
871 use crate::sandbox::ShellEnforcement;
872
873 #[test]
879 fn execute_shell_applies_the_declared_limit_as_a_hard_ceiling() {
880 let temp = tempdir().unwrap();
881 let policy = CapabilityPolicy {
882 shell_allow: vec!["bash".to_string()],
883 resources: crate::resources::HostResourceLimits {
884 max_open_files: 64,
885 ..crate::resources::HostResourceLimits::default()
886 },
887 ..CapabilityPolicy::default()
888 };
889 let mut enforcement = ShellEnforcement::environment_only();
890 enforcement.resource_limits = policy.resources;
891
892 let result = execute_shell(
893 "bash",
894 &["-c", "ulimit -Hn"],
895 &[],
896 temp.path(),
897 temp.path(),
898 &policy,
899 &enforcement,
900 )
901 .expect("bash must run");
902
903 assert_eq!(result.stdout.trim(), "64", "stderr was: {}", result.stderr);
904 assert_eq!(result.resource_limit_hit, None);
905 }
906
907 #[test]
916 fn a_shell_command_runs_in_the_accessible_workdir_with_home_inside_the_session_one() {
917 let accessible = tempdir().unwrap();
918 let session = accessible.path().join(".murmur").join("ses_shell_test");
919 fs::create_dir_all(&session).unwrap();
920 let policy = CapabilityPolicy {
921 shell_allow: vec!["bash".to_string()],
922 ..CapabilityPolicy::default()
923 };
924
925 let result = execute_shell(
926 "bash",
927 &[
928 "-c",
929 "echo marker > \"$HOME/probe\" && cat \"$HOME/probe\" && pwd",
930 ],
931 &[],
932 accessible.path(),
933 &session,
934 &policy,
935 &ShellEnforcement::environment_only(),
936 )
937 .expect("bash must run");
938
939 assert_eq!(result.exit_code, 0, "stderr was: {}", result.stderr);
940 let mut lines = result.stdout.lines();
941 assert_eq!(lines.next(), Some("marker"));
942 assert_eq!(
943 std::fs::canonicalize(lines.next().unwrap()).unwrap(),
944 std::fs::canonicalize(accessible.path()).unwrap(),
945 "the subprocess cwd is the directory the capsule was asked to produce"
946 );
947
948 assert_eq!(
949 fs::read_to_string(session.join(SYNTHETIC_HOME_DIR_NAME).join("probe")).unwrap(),
950 "marker\n"
951 );
952 assert!(
953 !accessible.path().join(SYNTHETIC_HOME_DIR_NAME).exists(),
954 "the synthetic home must add nothing to the top level of the accessible workdir"
955 );
956 }
957
958 #[test]
961 fn execute_shell_reports_no_resource_limit_for_an_ordinary_exit() {
962 let temp = tempdir().unwrap();
963 let policy = CapabilityPolicy {
964 shell_allow: vec!["bash".to_string()],
965 ..CapabilityPolicy::default()
966 };
967
968 let result = execute_shell(
969 "bash",
970 &["-c", "exit 3"],
971 &[],
972 temp.path(),
973 temp.path(),
974 &policy,
975 &ShellEnforcement::environment_only(),
976 )
977 .expect("bash must run");
978
979 assert_eq!(result.exit_code, 3);
980 assert_eq!(result.resource_limit_hit, None);
981 }
982
983 #[test]
984 fn execute_shell_blocks_binary_not_in_allowlist() {
985 let policy = CapabilityPolicy::default();
986 let error = execute_shell(
987 "bash",
988 &["-c", "echo hi"],
989 &[],
990 Path::new("."),
991 Path::new("."),
992 &policy,
993 &ShellEnforcement::environment_only(),
994 )
995 .unwrap_err();
996
997 assert!(error.to_string().contains("capabilities.shell.allow"));
998 }
999
1000 #[test]
1001 fn execute_shell_returns_nonzero_exit_code_without_error() {
1002 let temp = tempdir().unwrap();
1003 let policy = CapabilityPolicy {
1004 shell_allow: vec!["bash".to_string()],
1005 ..CapabilityPolicy::default()
1006 };
1007
1008 let result = execute_shell(
1009 "bash",
1010 &["-c", "exit 42"],
1011 &[],
1012 temp.path(),
1013 temp.path(),
1014 &policy,
1015 &ShellEnforcement::environment_only(),
1016 )
1017 .unwrap();
1018 assert_eq!(result.exit_code, 42);
1019 }
1020
1021 #[test]
1025 fn execute_shell_reports_the_resolved_path_of_the_invoked_binary() {
1026 let temp = tempdir().unwrap();
1027 let policy = CapabilityPolicy {
1028 shell_allow: vec!["bash".to_string()],
1029 ..CapabilityPolicy::default()
1030 };
1031
1032 let result = execute_shell(
1033 "bash",
1034 &["-c", "exit 0"],
1035 &[],
1036 temp.path(),
1037 temp.path(),
1038 &policy,
1039 &ShellEnforcement::environment_only(),
1040 )
1041 .unwrap();
1042
1043 assert!(
1044 Path::new(&result.binary).is_absolute(),
1045 "a binary found on PATH must be reported as an absolute path, got {:?}",
1046 result.binary
1047 );
1048 assert!(
1049 result.binary.ends_with("bash"),
1050 "the reported path must still name bash, got {:?}",
1051 result.binary
1052 );
1053 }
1054
1055 #[test]
1061 fn execute_shell_falls_back_to_the_bare_name_when_nothing_resolves() {
1062 let temp = tempdir().unwrap();
1063 let tool = temp.path().join("local-tool");
1064 fs::write(&tool, "#!/bin/sh\nexit 7\n").unwrap();
1065 let mut perms = fs::metadata(&tool).unwrap().permissions();
1066 std::os::unix::fs::PermissionsExt::set_mode(&mut perms, 0o755);
1067 fs::set_permissions(&tool, perms).unwrap();
1068
1069 let policy = CapabilityPolicy {
1070 shell_allow: vec!["./local-tool".to_string()],
1071 ..CapabilityPolicy::default()
1072 };
1073
1074 let result = execute_shell(
1075 "./local-tool",
1076 &[],
1077 &[],
1078 temp.path(),
1079 temp.path(),
1080 &policy,
1081 &ShellEnforcement::environment_only(),
1082 )
1083 .unwrap();
1084
1085 assert_eq!(result.exit_code, 7, "the subprocess must still have run");
1086 assert_eq!(
1087 result.binary, "./local-tool",
1088 "an unresolvable name degrades to the bare invoked name, never an error"
1089 );
1090 }
1091
1092 #[test]
1093 fn build_shell_env_sets_synthetic_home_under_workdir() {
1094 let temp = tempdir().unwrap();
1095 let policy = CapabilityPolicy::default();
1096
1097 let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1098
1099 let expected_home = temp.path().join(".capsule-home");
1100 assert_eq!(
1101 env.get("HOME"),
1102 Some(&expected_home.to_string_lossy().into_owned())
1103 );
1104 assert_eq!(
1105 env.get("USERPROFILE"),
1106 Some(&expected_home.to_string_lossy().into_owned())
1107 );
1108 assert!(expected_home.is_dir());
1109 }
1110
1111 #[test]
1112 fn build_shell_env_reports_synthetic_home_creation_failure() {
1113 let temp = tempdir().unwrap();
1114 let blocking_file = temp.path().join("blocked");
1115 std::fs::write(&blocking_file, b"not a directory").unwrap();
1116 let workdir = blocking_file.join("subdir");
1117 let policy = CapabilityPolicy::default();
1118
1119 let error = build_shell_env(&policy, &[], &workdir).unwrap_err();
1120
1121 let expected_home = workdir.join(".capsule-home");
1122 assert!(error.contains(&expected_home.to_string_lossy().into_owned()));
1123 }
1124
1125 #[test]
1126 fn execute_shell_does_not_spawn_when_synthetic_home_creation_fails() {
1127 let temp = tempdir().unwrap();
1128 let blocking_file = temp.path().join("blocked");
1129 std::fs::write(&blocking_file, b"not a directory").unwrap();
1130 let workdir = blocking_file.join("subdir");
1131 let policy = CapabilityPolicy {
1132 shell_allow: vec!["bash".to_string()],
1133 ..CapabilityPolicy::default()
1134 };
1135
1136 let error = execute_shell(
1137 "bash",
1138 &["-c", "echo should-not-run"],
1139 &[],
1140 &workdir,
1141 &workdir,
1142 &policy,
1143 &ShellEnforcement::environment_only(),
1144 )
1145 .unwrap_err();
1146
1147 assert!(error.to_string().contains(".capsule-home"));
1148 }
1149
1150 #[test]
1151 fn execute_shell_reports_synthetic_home_not_real_host_home() {
1152 let temp = tempdir().unwrap();
1153 let policy = CapabilityPolicy {
1154 shell_allow: vec!["bash".to_string()],
1155 ..CapabilityPolicy::default()
1156 };
1157
1158 let result = execute_shell(
1159 "bash",
1160 &["-c", "echo -n $HOME"],
1161 &[],
1162 temp.path(),
1163 temp.path(),
1164 &policy,
1165 &ShellEnforcement::environment_only(),
1166 )
1167 .unwrap();
1168
1169 let expected_home = temp.path().join(".capsule-home");
1170 assert_eq!(result.stdout, expected_home.to_string_lossy());
1171 if let Ok(real_home) = std::env::var("HOME") {
1172 assert_ne!(result.stdout, real_home);
1173 }
1174 }
1175
1176 #[test]
1177 fn build_shell_env_strips_wildcard_credential_patterns_from_overrides() {
1178 let temp = tempdir().unwrap();
1179 let policy = CapabilityPolicy::default();
1180 let overrides = vec![
1181 ("AWS_ACCESS_KEY_ID".to_string(), "leaked".to_string()),
1182 ("DOCKER_AUTH_CONFIG".to_string(), "leaked".to_string()),
1183 ("STRIPE_API_KEY".to_string(), "leaked".to_string()),
1184 ("GITHUB_TOKEN".to_string(), "leaked".to_string()),
1185 ("SAFE_VAR".to_string(), "kept".to_string()),
1186 ];
1187
1188 let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1189
1190 assert!(!env.contains_key("AWS_ACCESS_KEY_ID"));
1191 assert!(!env.contains_key("DOCKER_AUTH_CONFIG"));
1192 assert!(!env.contains_key("STRIPE_API_KEY"));
1193 assert!(!env.contains_key("GITHUB_TOKEN"));
1194 assert_eq!(env.get("SAFE_VAR"), Some(&"kept".to_string()));
1195 }
1196
1197 #[test]
1198 fn declared_env_is_empty_without_declarations() {
1199 std::env::set_var("MURMUR_TEST_WASI_UNDECLARED", "host-value");
1200 let policy = CapabilityPolicy::default();
1201
1202 let env = build_declared_env(&policy);
1203
1204 assert!(env.is_empty());
1205 }
1206
1207 #[test]
1208 fn declared_env_passes_through_declared_host_var() {
1209 std::env::set_var("MURMUR_TEST_WASI_ALLOWED", "host-value");
1210 let policy = CapabilityPolicy {
1211 env_allow: vec![
1212 "MURMUR_TEST_WASI_ALLOWED".to_string(),
1213 "MURMUR_TEST_WASI_NEVER_SET".to_string(),
1215 ],
1216 ..CapabilityPolicy::default()
1217 };
1218
1219 let env = build_declared_env(&policy);
1220
1221 assert_eq!(
1222 env.get("MURMUR_TEST_WASI_ALLOWED"),
1223 Some(&"host-value".to_string())
1224 );
1225 assert!(!env.contains_key("MURMUR_TEST_WASI_NEVER_SET"));
1226 }
1227
1228 #[test]
1229 fn declared_env_strips_credential_shaped_declarations() {
1230 std::env::set_var("GITHUB_TOKEN", "leaked-token");
1231 std::env::set_var("STRIPE_API_KEY", "leaked-key");
1232 std::env::set_var("MURMUR_TEST_WASI_CUSTOM_SECRET", "leaked-secret");
1233 let policy = CapabilityPolicy {
1234 env_allow: vec![
1235 "GITHUB_TOKEN".to_string(),
1236 "STRIPE_API_KEY".to_string(),
1237 "MURMUR_TEST_WASI_CUSTOM_SECRET".to_string(),
1238 ],
1239 shell_strip_env: vec!["*_CUSTOM_SECRET".to_string()],
1240 ..CapabilityPolicy::default()
1241 };
1242
1243 let env = build_declared_env(&policy);
1244
1245 assert!(env.is_empty(), "expected all names stripped, got {env:?}");
1247 }
1248
1249 #[test]
1252 fn declared_env_delivers_a_credential_shaped_name_the_backstop_keeps() {
1253 std::env::set_var("PRIVATE_KEY", "host-private-key");
1254 std::env::set_var("MURMUR_TEST_WASI_PLAIN", "host-plain");
1255 let policy = CapabilityPolicy {
1256 env_allow: vec![
1257 "PRIVATE_KEY".to_string(),
1258 "MURMUR_TEST_WASI_PLAIN".to_string(),
1259 ],
1260 ..CapabilityPolicy::default()
1261 };
1262
1263 let env = build_declared_env(&policy);
1264
1265 assert_eq!(
1266 env.get("PRIVATE_KEY"),
1267 Some(&"host-private-key".to_string())
1268 );
1269 assert_eq!(
1270 env.get("MURMUR_TEST_WASI_PLAIN"),
1271 Some(&"host-plain".to_string())
1272 );
1273 }
1274
1275 #[test]
1276 fn build_shell_env_home_override_cannot_survive() {
1277 let temp = tempdir().unwrap();
1278 let policy = CapabilityPolicy {
1279 shell_baseline_env: vec!["HOME".to_string()],
1280 ..CapabilityPolicy::default()
1281 };
1282 let overrides = vec![("HOME".to_string(), "/tmp/guest-controlled".to_string())];
1283
1284 let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1285
1286 let expected_home = temp.path().join(".capsule-home");
1287 assert_eq!(
1288 env.get("HOME"),
1289 Some(&expected_home.to_string_lossy().into_owned())
1290 );
1291 }
1292
1293 #[test]
1294 fn build_shell_env_keeps_safe_baseline_vars() {
1295 let temp = tempdir().unwrap();
1296 let policy = CapabilityPolicy::default();
1297
1298 std::env::set_var("CARGO_HOME", "/fake/cargo/home");
1299 let env = build_shell_env(&policy, &[], temp.path()).unwrap();
1300 std::env::remove_var("CARGO_HOME");
1301
1302 assert_eq!(env.get("CARGO_HOME"), Some(&"/fake/cargo/home".to_string()));
1303 }
1304
1305 #[test]
1306 fn build_shell_env_developer_declared_strip_env_still_composes() {
1307 let temp = tempdir().unwrap();
1308 let policy = CapabilityPolicy {
1309 shell_strip_env: vec!["MYCOMPANY_*".to_string()],
1310 ..CapabilityPolicy::default()
1311 };
1312 let overrides = vec![("MYCOMPANY_SECRET".to_string(), "leaked".to_string())];
1313
1314 let env = build_shell_env(&policy, &overrides, temp.path()).unwrap();
1315
1316 assert!(!env.contains_key("MYCOMPANY_SECRET"));
1317 }
1318
1319 #[test]
1323 fn credential_backstop_drops_agrees_with_the_strip_it_predicts() {
1324 let extra = vec!["*_SERVICE_SECRET".to_string()];
1325 let names = [
1326 "ANTHROPIC_API_KEY",
1327 "GITHUB_TOKEN",
1328 "GITHUB_TOKEN_2",
1329 "AWS_ACCESS_KEY_ID",
1330 "MY_AWS_KEY",
1331 "STRIPE_API_KEY",
1332 "KUBECONFIG",
1333 "MY_SERVICE_SECRET",
1334 "DATABASE_PASSWORD",
1335 "SLACK_BOT_TOKEN",
1336 "HOME",
1337 "TZ",
1338 ];
1339
1340 let mut env: BTreeMap<String, String> = names
1341 .iter()
1342 .map(|name| ((*name).to_string(), "value".to_string()))
1343 .collect();
1344 strip_credential_shaped_vars(&mut env, &extra);
1345
1346 for name in names {
1347 assert_eq!(
1348 credential_backstop_drops(name, &extra),
1349 !env.contains_key(name),
1350 "prediction and strip disagree about {name}"
1351 );
1352 assert_eq!(
1353 credential_backstop_match(name, &extra).is_some(),
1354 !env.contains_key(name),
1355 "match and strip disagree about {name}"
1356 );
1357 }
1358 }
1359
1360 #[test]
1361 fn credential_backstop_match_attributes_a_builtin_pattern() {
1362 assert_eq!(
1363 credential_backstop_match("AWS_REGION", &[]),
1364 Some(BackstopMatch {
1365 pattern: "AWS_*".to_string(),
1366 source: BackstopPatternSource::Builtin,
1367 })
1368 );
1369 assert_eq!(
1370 credential_backstop_match("GITHUB_TOKEN", &[]),
1371 Some(BackstopMatch {
1372 pattern: "GITHUB_TOKEN".to_string(),
1373 source: BackstopPatternSource::Builtin,
1374 })
1375 );
1376 }
1377
1378 #[test]
1379 fn credential_backstop_match_attributes_a_strip_env_pattern() {
1380 assert_eq!(
1381 credential_backstop_match("MY_SERVICE_SECRET", &["*_SERVICE_SECRET".to_string()]),
1382 Some(BackstopMatch {
1383 pattern: "*_SERVICE_SECRET".to_string(),
1384 source: BackstopPatternSource::StripEnv,
1385 })
1386 );
1387 }
1388
1389 #[test]
1392 fn credential_backstop_match_prefers_the_builtin_pattern_when_both_match() {
1393 assert_eq!(
1394 credential_backstop_match("STRIPE_API_KEY", &["STRIPE_*".to_string()]),
1395 Some(BackstopMatch {
1396 pattern: "*_API_KEY".to_string(),
1397 source: BackstopPatternSource::Builtin,
1398 })
1399 );
1400 }
1401
1402 #[test]
1403 fn credential_backstop_match_is_none_for_a_name_the_backstop_keeps() {
1404 assert_eq!(credential_backstop_match("TZ", &[]), None);
1405 assert_eq!(
1406 credential_backstop_match("PRIVATE_KEY", &["*_SERVICE_SECRET".to_string()]),
1407 None
1408 );
1409 }
1410
1411 #[test]
1412 fn env_name_matches_pattern_trailing_wildcard() {
1413 assert!(env_name_matches_pattern("AWS_*", "AWS_ACCESS_KEY_ID"));
1414 assert!(!env_name_matches_pattern("AWS_*", "MY_AWS_KEY"));
1415 }
1416
1417 #[test]
1418 fn env_name_matches_pattern_leading_wildcard() {
1419 assert!(env_name_matches_pattern("*_API_KEY", "STRIPE_API_KEY"));
1420 assert!(!env_name_matches_pattern("*_API_KEY", "API_KEY_ID"));
1421 }
1422
1423 #[test]
1424 fn env_name_matches_pattern_exact_match() {
1425 assert!(env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN"));
1426 assert!(!env_name_matches_pattern("GITHUB_TOKEN", "GITHUB_TOKEN_2"));
1427 }
1428
1429 #[test]
1430 fn shell_tool_manifest_yaml_is_valid_yaml_for_interpreter() {
1431 let yaml = shell_tool_manifest_yaml("bash");
1432 serde_yaml::from_str::<serde_yaml::Value>(&yaml).expect("bash manifest must be valid YAML");
1433 }
1434
1435 #[test]
1436 fn shell_tool_manifest_yaml_is_valid_yaml_for_non_interpreter() {
1437 let yaml = shell_tool_manifest_yaml("curl");
1438 serde_yaml::from_str::<serde_yaml::Value>(&yaml)
1439 .expect("curl manifest must be valid YAML — embedded quotes break serde_yaml parsing");
1440 }
1441
1442 #[test]
1443 fn shell_tool_manifest_yaml_non_interpreter_has_tool_runtime() {
1444 let yaml = shell_tool_manifest_yaml("curl");
1445 let value: serde_yaml::Value = serde_yaml::from_str(&yaml).unwrap();
1446 assert_eq!(
1447 value.get("runtime").and_then(|v| v.as_str()),
1448 Some("tool"),
1449 "non-interpreter manifest must have runtime: tool so inventory picks it up"
1450 );
1451 }
1452}