1use std::collections::HashMap;
64use std::path::{Path, PathBuf};
65use std::sync::{Mutex, OnceLock};
66
67use crate::sandbox::SandboxInputs;
68
69pub const DEFAULT_IMAGE: &str = "alpine:3";
73
74#[derive(Debug, Clone, Copy, PartialEq, Eq)]
76pub enum ContainerRuntime {
77 Docker,
78 Podman,
79 Nerdctl,
80 AppleContainer,
83}
84
85impl ContainerRuntime {
86 const PREFERENCE_ORDER: &'static [ContainerRuntime] = &[
88 ContainerRuntime::Docker,
89 ContainerRuntime::Podman,
90 ContainerRuntime::Nerdctl,
91 ContainerRuntime::AppleContainer,
92 ];
93
94 pub fn binary(self) -> &'static str {
96 match self {
97 ContainerRuntime::Docker => "docker",
98 ContainerRuntime::Podman => "podman",
99 ContainerRuntime::Nerdctl => "nerdctl",
100 ContainerRuntime::AppleContainer => "container",
101 }
102 }
103
104 pub(crate) fn client_env(self) -> std::collections::HashMap<String, String> {
108 let mut keys = vec![
109 "PATH",
110 "HOME",
111 "USER",
112 "LOGNAME",
113 "LANG",
114 "LC_ALL",
115 "LC_CTYPE",
116 "TMPDIR",
117 "XDG_CONFIG_HOME",
118 "XDG_RUNTIME_DIR",
119 "SSH_AUTH_SOCK",
120 "USERPROFILE",
121 "SystemRoot",
122 "ComSpec",
123 "APPDATA",
124 "LOCALAPPDATA",
125 "TEMP",
126 "TMP",
127 ];
128 match self {
129 Self::Docker => keys.extend([
130 "DOCKER_HOST",
131 "DOCKER_CONTEXT",
132 "DOCKER_CONFIG",
133 "DOCKER_TLS",
134 "DOCKER_TLS_VERIFY",
135 "DOCKER_CERT_PATH",
136 "DOCKER_API_VERSION",
137 ]),
138 Self::Podman => {
139 keys.extend(["CONTAINER_HOST", "CONTAINER_CONNECTION", "CONTAINER_SSHKEY"])
140 }
141 Self::Nerdctl => {
142 keys.extend(["CONTAINERD_ADDRESS", "CONTAINERD_NAMESPACE", "NERDCTL_TOML"])
143 }
144 Self::AppleContainer => {}
145 }
146 keys.into_iter()
147 .filter_map(|key| {
148 std::env::var(key)
149 .ok()
150 .map(|value| (key.to_string(), value))
151 })
152 .collect()
153 }
154}
155
156pub fn detect() -> Option<ContainerRuntime> {
158 detect_with(crate::sandbox::command_available)
159}
160
161pub fn host_supports_container_contract() -> bool {
185 if cfg!(target_os = "linux") {
186 return true;
187 }
188 if cfg!(target_os = "windows") {
189 return false;
190 }
191 let Some(runtime) = detect() else {
192 return false;
193 };
194 matches!(host_mount_contract_proof(runtime), MountProof::Proven)
195}
196
197pub fn host_mount_contract_proof(runtime: ContainerRuntime) -> MountProof {
201 let cwd = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
202 for root in [cwd.as_path(), std::env::temp_dir().as_path()] {
203 match cached_bind_mount_proof(runtime, root, DEFAULT_IMAGE) {
204 MountProof::Proven => {}
205 failed => return failed,
206 }
207 }
208 MountProof::Proven
209}
210
211pub const MOUNT_PROOF_GUEST_DIR: &str = "/kranz-mount-proof";
213
214#[cfg(any(target_os = "macos", target_os = "linux"))]
215mod mount_proof;
216
217#[derive(Debug, Clone, PartialEq, Eq)]
234pub enum MountProof {
235 Proven,
238 Failed(String),
240}
241
242pub fn mount_proof_argv(host_dir: &Path, image: &str, guest_sentinel: &str) -> Vec<String> {
248 vec![
249 "run".to_string(),
250 "--rm".to_string(),
251 "-v".to_string(),
252 format!("{}:{MOUNT_PROOF_GUEST_DIR}", container_host_path(host_dir)),
253 image.to_string(),
254 "sh".to_string(),
255 "-c".to_string(),
256 mount_proof_script(guest_sentinel),
257 ]
258}
259
260fn mount_proof_script(guest_sentinel: &str) -> String {
261 format!(
264 "if [ -r {MOUNT_PROOF_GUEST_DIR}/host.txt ]; then cat {MOUNT_PROOF_GUEST_DIR}/host.txt; \
265 else printf %s no-host-sentinel; fi; \
266 printf %s {guest_sentinel} > {MOUNT_PROOF_GUEST_DIR}/guest.txt 2>/dev/null || true"
267 )
268}
269
270pub fn prove_bind_mount(runtime: ContainerRuntime, host_dir: &Path, image: &str) -> MountProof {
277 #[cfg(any(target_os = "macos", target_os = "linux"))]
278 if runtime == ContainerRuntime::Docker {
279 return mount_proof::prove(host_dir, image);
280 }
281 MountProof::Failed(format!(
282 "{} bind-mount proof refused before spawn: owned helper cleanup is supported only \
283 with Docker on Linux/macOS (path {}, image {image})",
284 runtime.binary(),
285 host_dir.display()
286 ))
287}
288
289#[cfg(any(target_os = "macos", target_os = "linux"))]
293fn unshared_path_reason(runtime: ContainerRuntime, host_dir: &Path, symptom: &str) -> String {
294 let mut reason = format!(
295 "{} accepted a bind mount of {} and shared nothing: {symptom}. \
296 The runtime's daemon cannot see this host path, so the declared write set would \
297 not exist inside the container and a worker's output would be lost silently. \
298 Share this path with the runtime (Colima mounts only the home directory by \
299 default: `colima start --mount {}:w`; Docker Desktop keeps its own file-sharing \
300 list)",
301 runtime.binary(),
302 host_dir.display(),
303 host_dir.display()
304 );
305 if host_dir == crate::backend_claude::scratch_root_base() {
308 reason.push_str(&format!(
309 ", or move kranz's own scratch to a directory the runtime already shares by \
310 setting {}=<path> (this root is scratch, not your workspace)",
311 crate::backend_claude::SCRATCH_ROOT_ENV
312 ));
313 } else {
314 reason.push_str(" or point the mission's workspace at a path it already shares");
315 }
316 reason
317}
318
319fn proof_cache() -> &'static Mutex<HashMap<(String, String), MountProof>> {
325 static CACHE: OnceLock<Mutex<HashMap<(String, String), MountProof>>> = OnceLock::new();
326 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
327}
328
329pub fn cached_bind_mount_proof(
331 runtime: ContainerRuntime,
332 host_dir: &Path,
333 image: &str,
334) -> MountProof {
335 let key = (
336 runtime.binary().to_string(),
337 host_dir.to_string_lossy().into_owned(),
338 );
339 if let Ok(cache) = proof_cache().lock() {
340 if let Some(proof) = cache.get(&key) {
341 return proof.clone();
342 }
343 }
344 let proof = prove_bind_mount(runtime, host_dir, image);
345 if let Ok(mut cache) = proof_cache().lock() {
346 cache.insert(key, proof.clone());
347 }
348 proof
349}
350
351pub fn prove_mount_roots(runtime: ContainerRuntime, roots: &[PathBuf], image: &str) -> MountProof {
364 let mut seen = Vec::new();
365 for root in roots {
366 if root.as_os_str().is_empty() || seen.iter().any(|prior| prior == root) {
367 continue;
368 }
369 seen.push(root.clone());
370 match cached_bind_mount_proof(runtime, root, image) {
371 MountProof::Proven => {}
372 failed => return failed,
373 }
374 }
375 MountProof::Proven
376}
377
378pub fn declared_mount_roots(
387 session_cwd: &Path,
388 mission_dir: &Path,
389 extra_write: &[PathBuf],
390) -> Vec<PathBuf> {
391 let mut roots = vec![
392 session_cwd.parent().unwrap_or(session_cwd).to_path_buf(),
393 mission_dir.to_path_buf(),
394 crate::backend_claude::scratch_root_base(),
399 ];
400 roots.extend(extra_write.iter().cloned());
401 roots
402}
403
404pub fn container_contract_skip_detail() -> String {
411 if cfg!(target_os = "windows") {
412 return "the container provider refuses Windows: POSIX guest paths, Linux images, \
413 and /dev/null authority masks are not honored there"
414 .to_string();
415 }
416 match detect() {
417 None => "no docker/podman/nerdctl/container on PATH".to_string(),
418 Some(runtime) => match host_mount_contract_proof(runtime) {
419 MountProof::Proven => {
420 "the host contract is supported; this skip should not have fired".to_string()
421 }
422 MountProof::Failed(reason) => reason,
423 },
424 }
425}
426
427pub fn detect_with(lookup: impl Fn(&str) -> bool) -> Option<ContainerRuntime> {
429 ContainerRuntime::PREFERENCE_ORDER
430 .iter()
431 .copied()
432 .find(|runtime| lookup(runtime.binary()))
433}
434
435#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct ContainerSpec {
438 pub runtime: ContainerRuntime,
439 pub image: String,
440 pub network: Option<String>,
444 pub name: Option<String>,
448}
449
450fn mount_arg(host_abs: &str, read_only: bool) -> String {
466 format!(
467 "{host_abs}:{host_abs}{}",
468 if read_only { ":ro" } else { "" }
469 )
470}
471
472fn container_host_path(path: &Path) -> String {
487 let absolute = crate::sandbox::absolutize(path);
488 let rendered = absolute.as_os_str().to_string_lossy();
489 #[cfg(windows)]
490 if let Some(rest) = rendered.strip_prefix(r"\\?\") {
491 if !rest.starts_with("UNC") {
492 return rest.to_string();
493 }
494 }
495 rendered.into_owned()
496}
497
498const CONTAINER_PIDS_LIMIT: &str = "512";
503
504fn run_prologue(inputs: &SandboxInputs) -> Vec<String> {
525 let mut out = vec![
526 "run".to_string(),
527 "--rm".to_string(),
528 "-i".to_string(),
529 "--read-only".to_string(),
530 "--cap-drop".to_string(),
531 "ALL".to_string(),
532 "--security-opt".to_string(),
533 "no-new-privileges".to_string(),
534 "--pids-limit".to_string(),
535 CONTAINER_PIDS_LIMIT.to_string(),
536 ];
537 if let Some(owner) = crate::container_egress::mount_owner(&inputs.session_cwd) {
538 out.push("--user".to_string());
539 out.push(owner);
540 }
541 for key in [
544 "HTTP_PROXY",
545 "HTTPS_PROXY",
546 "FTP_PROXY",
547 "ALL_PROXY",
548 "NO_PROXY",
549 "http_proxy",
550 "https_proxy",
551 "ftp_proxy",
552 "all_proxy",
553 "no_proxy",
554 ] {
555 out.extend(["-e".to_string(), format!("{key}=")]);
556 }
557 out
558}
559
560fn push_policy_mounts(out: &mut Vec<String>, inputs: &SandboxInputs) {
570 let mut mounts: Vec<(String, bool)> = Vec::new();
571 let denied_dirs: Vec<_> = crate::sandbox::authority_read_deny_dirs(inputs)
572 .iter()
573 .map(|path| crate::sandbox::absolutize(path))
574 .collect();
575 let denied_files: Vec<_> = crate::sandbox::authority_read_deny_paths(inputs)
576 .iter()
577 .map(|path| crate::sandbox::absolutize(path))
578 .collect();
579 let mut add_mount = |path: &Path, ro: bool| {
580 let path = crate::sandbox::absolutize(path);
581 if denied_dirs.iter().any(|dir| path.starts_with(dir))
584 || denied_files.iter().any(|file| path.starts_with(file))
585 {
586 return;
587 }
588 let host = container_host_path(&path);
589 if !mounts.iter().any(|(existing, _)| existing == &host) {
590 mounts.push((host, ro));
591 }
592 };
593 add_mount(&inputs.session_cwd, false);
594 if let Some(missions) = inputs
595 .mission_dir
596 .parent()
597 .filter(|path| path.ends_with("missions") && path.is_dir())
598 {
599 add_mount(missions, true);
602 }
603 add_mount(&inputs.mission_dir, true);
604 add_mount(&inputs.tmpdir, false);
605 for extra in &inputs.extra_write {
606 if inputs
607 .mission_dir
608 .parent()
609 .filter(|p| p.ends_with("missions"))
610 .is_some_and(|missions| {
611 crate::sandbox::absolutize(extra).starts_with(crate::sandbox::absolutize(missions))
612 })
613 {
614 continue;
615 }
616 add_mount(extra, false);
617 }
618 for (host, ro) in mounts {
619 out.push("-v".to_string());
620 out.push(mount_arg(&host, ro));
621 }
622}
623
624fn under_writable_mount(path: &Path, inputs: &SandboxInputs) -> bool {
631 let candidate = crate::sandbox::absolutize(path);
632 std::iter::once(&inputs.session_cwd)
633 .chain(std::iter::once(&inputs.tmpdir))
634 .chain(inputs.extra_write.iter())
635 .any(|root| candidate.starts_with(crate::sandbox::absolutize(root)))
636}
637
638fn push_authority_masks(out: &mut Vec<String>, inputs: &SandboxInputs) {
642 for node in crate::sandbox::git_metadata_mount_nodes(inputs) {
647 let node = container_host_path(&node);
648 if !out.windows(2).any(|pair| {
649 pair[0] == "-v"
650 && (pair[1] == mount_arg(&node, false) || pair[1] == mount_arg(&node, true))
651 }) {
652 out.extend(["-v".to_string(), mount_arg(&node, false)]);
653 }
654 }
655 let masks: Vec<_> = crate::sandbox::authority_directory_masks(inputs)
656 .into_iter()
657 .filter(|mask| {
658 mask.path.ancestors().any(|ancestor| {
659 let path = container_host_path(ancestor);
660 out.windows(2).any(|pair| {
661 pair[0] == "-v"
662 && (pair[1] == mount_arg(&path, false) || pair[1] == mount_arg(&path, true))
663 })
664 })
665 })
666 .collect();
667 let masked_paths: std::collections::BTreeSet<_> =
668 masks.iter().map(|mask| mask.path.clone()).collect();
669 let mut filtered = Vec::new();
674 let mut index = 0;
675 while index < out.len() {
676 if out[index] == "-v" && index + 1 < out.len() {
677 let mount = &out[index + 1];
678 if masks.iter().any(|mask| {
679 let path = container_host_path(&mask.path);
680 mount == &mount_arg(&path, false) || mount == &mount_arg(&path, true)
681 }) {
682 index += 2;
683 continue;
684 }
685 }
686 filtered.push(out[index].clone());
687 index += 1;
688 }
689 *out = filtered;
690 for mask in &masks {
691 out.push("--tmpfs".to_string());
692 out.push(format!(
693 "{}:ro,noexec,nosuid,nodev,mode=755",
694 container_host_path(&mask.path)
695 ));
696 for path in &mask.visible_entries {
697 if masked_paths.contains(path) {
700 continue;
701 }
702 let path = container_host_path(path);
703 if !out.windows(2).any(|pair| {
706 pair[0] == "-v"
707 && (pair[1] == mount_arg(&path, false) || pair[1] == mount_arg(&path, true))
708 }) {
709 out.push("-v".to_string());
710 out.push(mount_arg(&path, true));
711 }
712 }
713 }
714
715 let writes = crate::sandbox::authority_write_denies(inputs);
718 let git = crate::sandbox::git_metadata_write_denies(inputs);
719 for path in writes
720 .files
721 .iter()
722 .chain(writes.dirs.iter())
723 .chain(git.files.iter().filter(|path| path.is_file()))
724 .chain(git.dirs.iter())
725 {
726 if !under_writable_mount(path, inputs)
727 || path.is_symlink()
728 || !path.exists()
729 || masks
730 .iter()
731 .any(|mask| crate::sandbox::absolutize(path).starts_with(&mask.path))
732 {
733 continue;
734 }
735 let host = container_host_path(path);
736 if !out
737 .windows(2)
738 .any(|pair| pair[0] == "-v" && pair[1] == mount_arg(&host, true))
739 {
740 out.extend(["-v".to_string(), mount_arg(&host, true)]);
741 }
742 }
743}
744
745fn push_workdir_and_scratch_env(out: &mut Vec<String>, inputs: &SandboxInputs) {
749 out.push("-w".to_string());
753 out.push(container_host_path(&inputs.session_cwd));
754 let scratch = container_host_path(&inputs.tmpdir);
755 out.push("-e".to_string());
756 out.push(format!("HOME={scratch}"));
757 out.push("-e".to_string());
758 out.push(format!("TMPDIR={scratch}"));
759}
760
761#[derive(Debug, Clone, Copy, PartialEq, Eq)]
763enum ToolchainMount {
764 Session,
776 Gate,
786}
787
788fn push_toolchain_caches(out: &mut Vec<String>, mode: ToolchainMount) {
796 let global = crate::sandbox::global_authority_dir();
797 for (var, default_subdir) in [
798 ("RUSTUP_HOME", ".rustup"),
799 ("CARGO_HOME", ".cargo"),
800 ("NPM_CONFIG_CACHE", ".npm"),
801 ] {
802 let host = std::env::var_os(var)
803 .map(std::path::PathBuf::from)
804 .or_else(|| {
805 std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(default_subdir))
806 });
807 if let Some(host) = host {
808 if global
809 .as_ref()
810 .is_some_and(|dir| crate::sandbox::absolutize(&host).starts_with(dir))
811 {
812 continue;
813 }
814 if var == "CARGO_HOME" {
815 let leaves: &[&str] = match mode {
831 ToolchainMount::Gate => &["bin"],
832 ToolchainMount::Session => &["bin", "registry", "git"],
833 };
834 let mut mounted_any = false;
835 for leaf in leaves {
836 let dir = host.join(leaf);
837 if dir.is_dir() {
838 let mounted = container_host_path(&dir);
839 out.push("-v".to_string());
840 out.push(mount_arg(&mounted, true));
841 mounted_any = true;
842 }
843 }
844 if mode == ToolchainMount::Session && mounted_any {
845 out.push("-e".to_string());
846 out.push(format!("CARGO_HOME={}", container_host_path(&host)));
847 }
848 continue;
849 }
850 if host.is_dir() {
851 let mounted = container_host_path(&host);
852 out.push("-v".to_string());
853 out.push(mount_arg(&mounted, true));
854 out.push("-e".to_string());
855 out.push(format!("{var}={mounted}"));
856 }
857 }
858 }
859}
860
861fn push_network(out: &mut Vec<String>, inputs: &SandboxInputs, proxy_url: Option<&str>) {
868 if inputs.enforce == crate::types::SandboxEnforce::FsNet {
869 if inputs.egress.is_empty() {
870 out.push("--network".to_string());
871 out.push("none".to_string());
872 } else if let Some(proxy_url) = proxy_url {
873 out.push("-e".to_string());
874 out.push(format!(
875 "{}={proxy_url}",
876 crate::egress_proxy::HTTPS_PROXY_ENV
877 ));
878 out.push("-e".to_string());
879 out.push(format!(
880 "{}={proxy_url}",
881 crate::egress_proxy::HTTP_PROXY_ENV
882 ));
883 out.push("-e".to_string());
884 out.push(format!(
885 "{}={}",
886 crate::egress_proxy::NO_PROXY_ENV,
887 crate::egress_proxy::NO_PROXY_VALUE
888 ));
889 }
890 }
891}
892
893pub fn container_run_args(
894 inputs: &SandboxInputs,
895 spec: &ContainerSpec,
896 binary: &Path,
897 args: &[String],
898 proxy_url: Option<&str>,
899) -> Vec<String> {
900 let mut out = run_prologue(inputs);
901 if let Some(name) = &spec.name {
902 out.push("--name".to_string());
903 out.push(name.clone());
904 }
905 push_policy_mounts(&mut out, inputs);
906 push_workdir_and_scratch_env(&mut out, inputs);
907 push_toolchain_caches(&mut out, ToolchainMount::Session);
908 push_authority_masks(&mut out, inputs);
909 if inputs.enforce == crate::types::SandboxEnforce::FsNet && !inputs.egress.is_empty() {
910 if let (Some(network), Some(_)) = (&spec.network, proxy_url) {
911 out.push("--network".to_string());
912 out.push(network.clone());
913 push_network(&mut out, inputs, proxy_url);
914 } else {
915 out.push("--network".to_string());
918 out.push("none".to_string());
919 }
920 } else {
921 push_network(&mut out, inputs, proxy_url);
922 }
923 out.push(spec.image.clone());
924 out.push(binary.display().to_string());
925 out.extend(args.iter().cloned());
926 out
927}
928
929const GATE_FORWARD_ENV_SKIP: &[&str] = &[
937 "HOME",
938 "TMPDIR",
939 "TMP",
940 "TEMP",
941 "RUSTUP_HOME",
942 "NPM_CONFIG_CACHE",
943];
944
945pub fn container_gate_run_args(
980 inputs: &SandboxInputs,
981 spec: &ContainerSpec,
982 command: &str,
983 env: &std::collections::HashMap<String, String>,
984 container_name: &str,
985) -> Vec<String> {
986 let mut out = run_prologue(inputs);
987 out.push("--name".to_string());
988 out.push(container_name.to_string());
989 push_policy_mounts(&mut out, inputs);
990 push_workdir_and_scratch_env(&mut out, inputs);
991 push_toolchain_caches(&mut out, ToolchainMount::Gate);
992 push_authority_masks(&mut out, inputs);
993 push_network(&mut out, inputs, None);
994 let mut forwarded: Vec<(&String, &String)> = env.iter().collect();
995 forwarded.sort_by_key(|(key, _)| *key);
996 for (key, value) in forwarded {
997 if GATE_FORWARD_ENV_SKIP.contains(&key.as_str()) {
998 continue;
999 }
1000 out.push("-e".to_string());
1001 out.push(format!("{key}={value}"));
1002 }
1003 out.push(spec.image.clone());
1004 out.push("sh".to_string());
1005 out.push("-c".to_string());
1006 out.push(command.to_string());
1007 out
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013
1014 fn live_runtime() -> Option<ContainerRuntime> {
1015 let runtime = detect();
1016 if let Some(runtime) = runtime {
1017 let probe = tokio::runtime::Builder::new_current_thread()
1018 .enable_all()
1019 .build()
1020 .unwrap()
1021 .block_on(crate::command_exec::run_bounded_argv(
1022 &std::env::current_dir().unwrap(),
1023 Path::new(runtime.binary()),
1024 &["info".into()],
1025 std::time::Duration::from_secs(5),
1026 &runtime.client_env(),
1027 ));
1028 if probe.0 == Some(0) {
1029 return Some(runtime);
1030 }
1031 }
1032 for flag in [
1033 "KRANZ_ACP_CONTAINER_TESTS",
1034 "KRANZ_GATE_CONTAINER_TESTS",
1035 "KRANZ_MOUNT_CONTAINER_TESTS",
1036 ] {
1037 assert!(
1038 std::env::var(flag).as_deref() != Ok("1"),
1039 "container daemon unavailable for explicitly requested proof: {flag}=1"
1040 );
1041 }
1042 crate::test_capability::skip(
1043 crate::test_capability::capability::CONTAINER,
1044 "container CLI or daemon unavailable (bounded info probe failed)",
1045 );
1046 None
1047 }
1048
1049 #[test]
1050 #[cfg(unix)]
1051 fn optional_container_daemon_probe_skips_but_requested_proofs_fail() {
1052 const CASE: &str = "sandbox_container::tests::optional_container_daemon_probe_skips_but_requested_proofs_fail";
1053 if std::env::var_os("KRANZ_DAEMON_PROBE_CHILD").is_some() {
1054 assert!(detect().is_some(), "fixture CLI must be discoverable");
1055 assert!(live_runtime().is_none());
1056 return;
1057 }
1058 use std::os::unix::fs::PermissionsExt;
1059 let dir = tempfile::tempdir().unwrap();
1060 let cli = dir.path().join("docker");
1061 std::fs::write(&cli, "#!/bin/sh\n[ \"$1\" != info ]\n").unwrap();
1062 std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o700)).unwrap();
1063 let flags = [
1064 "KRANZ_ACP_CONTAINER_TESTS",
1065 "KRANZ_GATE_CONTAINER_TESTS",
1066 "KRANZ_MOUNT_CONTAINER_TESTS",
1067 ];
1068 for required in std::iter::once(None).chain(flags.iter().copied().map(Some)) {
1069 let mut child = std::process::Command::new(std::env::current_exe().unwrap());
1070 child
1071 .args([CASE, "--exact", "--nocapture"])
1072 .env("KRANZ_DAEMON_PROBE_CHILD", "1")
1073 .env("PATH", dir.path())
1074 .env("KRANZ_REQUIRED_CAPABILITIES", "");
1075 for flag in flags {
1076 child.env_remove(flag);
1077 }
1078 if let Some(flag) = required {
1079 child.env(flag, "1");
1080 }
1081 let output = child.output().unwrap();
1082 let text = format!(
1083 "{}{}",
1084 String::from_utf8_lossy(&output.stdout),
1085 String::from_utf8_lossy(&output.stderr)
1086 );
1087 if let Some(flag) = required {
1088 assert!(!output.status.success(), "{flag} must fail: {text}");
1089 assert!(
1090 text.contains(&format!("explicitly requested proof: {flag}=1")),
1091 "{text}"
1092 );
1093 } else {
1094 assert!(output.status.success(), "{text}");
1095 assert!(
1096 text.contains(
1097 "KRANZ_TEST_SKIP: container: container CLI or daemon unavailable"
1098 ),
1099 "{text}"
1100 );
1101 assert!(text.contains("test result: ok. 1 passed"), "{text}");
1102 }
1103 }
1104 }
1105
1106 #[cfg(unix)]
1107 #[test]
1108 fn container_cache_probe_documents_readable_sources_and_denied_writes() {
1109 if std::env::var("KRANZ_ACP_CONTAINER_TESTS").as_deref() != Ok("1") {
1110 eprintln!("SKIP-ACP-CACHE: set KRANZ_ACP_CONTAINER_TESTS=1 for synthetic cache proof");
1111 return;
1112 }
1113 let (runtime, client_env) = {
1114 let _guard = crate::agent_env::EnvTestGuard::engage(&[]);
1115 let runtime = live_runtime().expect("explicit cache proof needs a daemon");
1116 (runtime, runtime.client_env())
1117 };
1118 let root = live_fixture();
1119 let operator = root.path().join("operator");
1120 let session = root.path().join("session");
1121 let scratch = root.path().join("scratch");
1122 let mission = session.join(".kranz/missions/m-cache");
1123 for directory in [&session, &scratch, &mission] {
1124 std::fs::create_dir_all(directory).unwrap();
1125 }
1126 let cache_paths = [
1127 ".rustup/toolchains/fixture",
1128 ".cargo/bin/fixture",
1129 ".cargo/registry/fixture",
1130 ".cargo/git/fixture",
1131 ".npm/fixture",
1132 ];
1133 for path in cache_paths {
1134 let path = operator.join(path);
1135 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
1136 std::fs::write(path, "synthetic-cache-source").unwrap();
1137 }
1138 std::fs::write(
1139 operator.join(".cargo/credentials.toml"),
1140 "synthetic-private-credential",
1141 )
1142 .unwrap();
1143 let input = SandboxInputs {
1144 enforce: SandboxEnforce::FsNet,
1145 session_cwd: session,
1146 mission_dir: mission,
1147 tmpdir: scratch,
1148 extra_write: vec![],
1149 egress: vec![],
1150 validator_read_deny_roots: vec![],
1151 };
1152 let args = {
1153 let cargo = operator.join(".cargo");
1154 let rustup = operator.join(".rustup");
1155 let npm = operator.join(".npm");
1156 let _env = crate::agent_env::EnvTestGuard::engage(&[
1157 ("HOME", operator.to_str().unwrap()),
1158 ("CARGO_HOME", cargo.to_str().unwrap()),
1159 ("RUSTUP_HOME", rustup.to_str().unwrap()),
1160 ("NPM_CONFIG_CACHE", npm.to_str().unwrap()),
1161 ]);
1162 let mut command = vec!["-c".into(),
1163 "set -eu; credential=$1; shift; test ! -r \"$credential\"; for cache do test \"$(cat \"$cache\")\" = synthetic-cache-source; if printf tampered > \"$cache\" 2>/dev/null; then exit 9; fi; done; printf 'CACHE-TRUST: sources readable; writes and Cargo credentials denied\\n'".into(),
1164 "probe".into(), cargo.join("credentials.toml").display().to_string()];
1165 command.extend(
1166 cache_paths
1167 .iter()
1168 .map(|p| operator.join(p).display().to_string()),
1169 );
1170 container_run_args(&input, &ContainerSpec {runtime, network:None, name:None,
1171 image:"python@sha256:540c7d91f98ff6880174c40e99067bf5941eb54d818a7a5e094d188b196a934d".into()},
1172 Path::new("/bin/sh"), &command, None)
1173 };
1174 let (code, output) = tokio::runtime::Builder::new_current_thread()
1175 .enable_all()
1176 .build()
1177 .unwrap()
1178 .block_on(crate::command_exec::run_bounded_argv(
1179 root.path(),
1180 Path::new(runtime.binary()),
1181 &args,
1182 std::time::Duration::from_secs(30),
1183 &client_env,
1184 ));
1185 assert_eq!(code, Some(0), "{output}");
1186 assert!(
1187 output.contains("CACHE-TRUST: sources readable; writes and Cargo credentials denied"),
1188 "{output}"
1189 );
1190 println!("{output}");
1191 for path in cache_paths {
1192 assert_eq!(
1193 std::fs::read_to_string(operator.join(path)).unwrap(),
1194 "synthetic-cache-source"
1195 );
1196 }
1197 }
1198 use crate::sandbox::SandboxInputs;
1199 use crate::types::SandboxEnforce;
1200 use std::path::PathBuf;
1201
1202 #[test]
1203 fn declared_roots_follow_the_scratch_override_not_the_temp_dir() {
1204 let case =
1205 "sandbox_container::tests::declared_roots_follow_the_scratch_override_not_the_temp_dir";
1206 if std::env::var("KRANZ_SCRATCH_TEST_CASE").as_deref() != Ok(case) {
1207 let shared = tempfile::tempdir().unwrap();
1208 let output = std::process::Command::new(std::env::current_exe().unwrap())
1209 .args([case, "--exact", "--nocapture"])
1210 .env("KRANZ_SCRATCH_TEST_CASE", case)
1211 .env(crate::backend_claude::SCRATCH_ROOT_ENV, shared.path())
1212 .output()
1213 .unwrap();
1214 assert!(
1215 output.status.success(),
1216 "{}",
1217 String::from_utf8_lossy(&output.stderr)
1218 );
1219 assert!(String::from_utf8_lossy(&output.stdout).contains("test result: ok. 1 passed;"));
1220 return;
1221 }
1222 let checkout = std::path::Path::new("/repos/app/worktree");
1223 let mission = std::path::Path::new("/repos/app/.kranz/missions/m-1");
1224 let shared =
1225 PathBuf::from(std::env::var_os(crate::backend_claude::SCRATCH_ROOT_ENV).unwrap());
1226 let roots = declared_mount_roots(checkout, mission, &[]);
1227
1228 assert!(roots.contains(&shared), "{roots:?}");
1232 assert!(!roots.contains(&std::env::temp_dir()), "{roots:?}");
1233 assert!(
1234 roots.contains(&std::path::PathBuf::from("/repos/app")),
1235 "the checkout's parent is mounted, not the worktree itself: {roots:?}"
1236 );
1237 }
1238
1239 #[test]
1240 fn mount_proof_argv_reads_the_host_sentinel_and_writes_the_guest_one() {
1241 let host = std::env::temp_dir();
1247 let argv = mount_proof_argv(&host, "alpine:3", "guestsentinel");
1248 let rendered = argv.join(" ");
1249 let expected_mount = format!("{}:/kranz-mount-proof", container_host_path(&host));
1250 assert!(rendered.contains(&expected_mount), "{rendered}");
1251 assert!(!expected_mount.starts_with(r"\\?\"), "{expected_mount}");
1252 assert!(
1255 rendered.contains("cat /kranz-mount-proof/host.txt"),
1256 "{rendered}"
1257 );
1258 assert!(
1259 rendered.contains("printf %s guestsentinel > /kranz-mount-proof/guest.txt"),
1260 "{rendered}"
1261 );
1262 assert!(rendered.contains("no-host-sentinel"), "{rendered}");
1265 assert!(rendered.starts_with("run --rm "), "{rendered}");
1266 }
1267
1268 #[test]
1269 fn live_bind_mount_round_trip_closes_under_the_checkout() {
1270 if cfg!(target_os = "windows") {
1273 crate::test_capability::skip(
1274 crate::test_capability::capability::CONTAINER,
1275 "the container provider refuses Windows, so a bind-mount probe proves nothing",
1276 );
1277 return;
1278 }
1279 let Some(runtime) = live_runtime() else {
1280 return;
1281 };
1282 let checkout = std::env::current_dir().expect("a working directory");
1285 let root = checkout.parent().unwrap_or(&checkout);
1286 match prove_bind_mount(runtime, root, DEFAULT_IMAGE) {
1287 MountProof::Proven => {}
1288 MountProof::Failed(reason) => panic!(
1289 "the bind-mount round trip under {} did not close, so a mission's \
1290 declared write set cannot be trusted here: {reason}",
1291 root.display()
1292 ),
1293 }
1294 }
1295
1296 #[test]
1297 fn detect_prefers_docker_then_podman_then_nerdctl_then_apple_container() {
1298 assert_eq!(detect_with(|_| false), None);
1299 assert_eq!(
1300 detect_with(|name| name == "container"),
1301 Some(ContainerRuntime::AppleContainer)
1302 );
1303 assert_eq!(
1304 detect_with(|name| name == "nerdctl" || name == "container"),
1305 Some(ContainerRuntime::Nerdctl)
1306 );
1307 assert_eq!(
1308 detect_with(|name| name == "podman" || name == "nerdctl"),
1309 Some(ContainerRuntime::Podman)
1310 );
1311 assert_eq!(
1312 detect_with(|name| name == "docker" || name == "podman"),
1313 Some(ContainerRuntime::Docker)
1314 );
1315 }
1316
1317 fn inputs(enforce: SandboxEnforce) -> SandboxInputs {
1318 SandboxInputs {
1319 enforce,
1320 session_cwd: PathBuf::from("/work/session"),
1321 mission_dir: PathBuf::from("/work/mission"),
1322 tmpdir: PathBuf::from("/work/scratch"),
1323 extra_write: vec![PathBuf::from("/home/op/.cargo")],
1324 egress: Vec::new(),
1325 validator_read_deny_roots: Vec::new(),
1326 }
1327 }
1328
1329 fn spec() -> ContainerSpec {
1330 ContainerSpec {
1331 runtime: ContainerRuntime::Docker,
1332 image: DEFAULT_IMAGE.to_string(),
1333 network: None,
1334 name: None,
1335 }
1336 }
1337
1338 fn live_fixture() -> tempfile::TempDir {
1339 tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap()
1343 }
1344
1345 #[test]
1346 fn container_run_args_fs_net_with_empty_egress_disables_network() {
1347 let args = container_run_args(
1348 &inputs(SandboxEnforce::FsNet),
1349 &spec(),
1350 Path::new("claude"),
1351 &["-p".to_string(), "hi".to_string()],
1352 None,
1353 );
1354 let network = args
1355 .windows(2)
1356 .find(|w| w[0] == "--network")
1357 .expect("fs+net must pass a --network flag");
1358 assert_eq!(network[1], "none");
1359 }
1360
1361 #[test]
1362 fn container_run_args_fs_net_with_egress_uses_internal_network_and_relay_env() {
1363 let mut inputs = inputs(SandboxEnforce::FsNet);
1364 inputs.egress = vec!["crates.io:443".to_string()];
1365 let mut spec = spec();
1366 spec.network = Some("kranz-egress-test".to_string());
1367 spec.name = Some("kranz-egress-worker-test".to_string());
1368 let args = container_run_args(
1369 &inputs,
1370 &spec,
1371 Path::new("claude"),
1372 &["-p".to_string(), "hi".to_string()],
1373 Some("http://kranz-egress:3128"),
1374 );
1375
1376 assert!(
1377 args.windows(2)
1378 .any(|w| w[0] == "--network" && w[1] == "kranz-egress-test"),
1379 "proxy-routed fs+net must use the per-run internal network: {args:?}"
1380 );
1381 assert!(
1382 args.windows(2)
1383 .any(|w| w[0] == "--name" && w[1] == "kranz-egress-worker-test"),
1384 "the daemon-owned worker must be named for timeout teardown: {args:?}"
1385 );
1386 for var in ["HTTPS_PROXY", "HTTP_PROXY"] {
1387 assert!(
1388 args.windows(2)
1389 .any(|w| w[0] == "-e" && w[1] == format!("{var}=http://kranz-egress:3128")),
1390 "missing -e {var}=…: {args:?}"
1391 );
1392 }
1393 assert!(
1394 args.windows(2)
1395 .any(|w| w[0] == "-e" && w[1] == "NO_PROXY=localhost,127.0.0.1"),
1396 "missing -e NO_PROXY…: {args:?}"
1397 );
1398 }
1399
1400 #[test]
1401 fn container_run_args_fs_net_with_egress_fails_closed_without_boundary() {
1402 let mut inputs = inputs(SandboxEnforce::FsNet);
1403 inputs.egress = vec!["crates.io:443".to_string()];
1404 let args = container_run_args(
1405 &inputs,
1406 &spec(),
1407 Path::new("claude"),
1408 &[],
1409 Some("http://kranz-egress:3128"),
1410 );
1411 assert!(
1412 args.windows(2)
1413 .any(|w| w[0] == "--network" && w[1] == "none"),
1414 "missing boundary state must disable networking: {args:?}"
1415 );
1416 assert!(
1417 args.iter()
1418 .filter(|a| a.starts_with("HTTPS_PROXY="))
1419 .all(|a| a == "HTTPS_PROXY="),
1420 "a relay env must not be emitted without its internal network: {args:?}"
1421 );
1422 }
1423
1424 #[test]
1425 fn container_run_args_fs_keeps_runtime_default_network() {
1426 let args = container_run_args(
1427 &inputs(SandboxEnforce::Fs),
1428 &spec(),
1429 Path::new("claude"),
1430 &[],
1431 None,
1432 );
1433 assert!(
1434 !args.iter().any(|a| a == "--network"),
1435 "fs must not restrict the network (runtime default bridge): {args:?}"
1436 );
1437 }
1438
1439 #[test]
1440 fn container_run_args_mounts_policy_and_runs_image() {
1441 let dir = tempfile::tempdir().unwrap();
1445 let session = dir.path().join("session");
1446 let mission = dir.path().join("mission");
1447 let scratch = dir.path().join("scratch");
1448 let cargo = dir.path().join("cargo");
1449 for path in [&session, &mission, &scratch, &cargo] {
1450 std::fs::create_dir_all(path).unwrap();
1451 }
1452 let inputs = SandboxInputs {
1453 enforce: SandboxEnforce::Fs,
1454 session_cwd: session.clone(),
1455 mission_dir: mission.clone(),
1456 tmpdir: scratch.clone(),
1457 extra_write: vec![cargo.clone()],
1458 egress: Vec::new(),
1459 validator_read_deny_roots: Vec::new(),
1460 };
1461 let args = container_run_args(
1462 &inputs,
1463 &spec(),
1464 Path::new("claude"),
1465 &["--print".to_string()],
1466 None,
1467 );
1468 let joined = args.join(" ");
1469 let abs = |p: &std::path::Path| container_host_path(p);
1470
1471 assert!(args.contains(&"--rm".to_string()));
1472 assert!(args.contains(&"--read-only".to_string()));
1473 assert!(joined.contains(&mount_arg(&abs(&session), false)));
1474 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1475 assert!(joined.contains(&mount_arg(&abs(&scratch), false)));
1476 assert!(joined.contains(&mount_arg(&abs(&cargo), false)));
1477 assert!(joined.contains(&format!("-w {}", abs(&session))));
1478 assert!(joined.contains(&format!("-e HOME={}", abs(&scratch))));
1479 assert!(
1480 joined.ends_with(&format!("{DEFAULT_IMAGE} claude --print")),
1481 "image then binary then args: {args:?}"
1482 );
1483 }
1484
1485 #[test]
1486 fn container_run_args_mask_authority_material_under_session_root() {
1487 let dir = tempfile::tempdir().unwrap();
1488 let session = dir.path().join("session");
1489 let kranz_dir = session.join(".kranz");
1490 std::fs::create_dir_all(&kranz_dir).unwrap();
1491 let masked_token_file = kranz_dir.join("serve.token");
1492 let config = kranz_dir.join("config.json");
1493 std::fs::write(&masked_token_file, "secret").unwrap();
1494 std::fs::write(&config, "{}").unwrap();
1495 let mut inputs = inputs(SandboxEnforce::Fs);
1496 inputs.session_cwd = session;
1497
1498 let args = container_run_args(
1499 &inputs,
1500 &spec(),
1501 Path::new("claude"),
1502 &["--print".to_string()],
1503 None,
1504 );
1505 let joined = args.join(" ");
1506 let abs = |p: &std::path::Path| container_host_path(p);
1507
1508 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz_dir))));
1509 for name in ["serve.token", "serve.read.token", "config.json"] {
1510 assert!(
1511 !joined.contains(&abs(&kranz_dir.join(name))),
1512 "authority must stay outside the private view: {args:?}"
1513 );
1514 }
1515 }
1516
1517 #[test]
1525 fn container_run_args_mask_the_whole_process_tier_authority_set() {
1526 let dir = tempfile::tempdir().unwrap();
1527 let session = dir.path().join("session");
1528 let kranz = session.join(".kranz");
1529 let mission = kranz.join("missions").join("m-x");
1530 std::fs::create_dir_all(mission.join("control")).unwrap();
1531 std::fs::create_dir_all(kranz.join("hook-status")).unwrap();
1532 std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
1533 std::fs::create_dir_all(kranz.join("queue")).unwrap();
1534 for name in ["serve.token", "config.json", "domain-terms.local"] {
1535 std::fs::write(kranz.join(name), "secret").unwrap();
1536 }
1537 let mut inputs = inputs(SandboxEnforce::Fs);
1538 inputs.session_cwd = session;
1539 inputs.mission_dir = mission.clone();
1540
1541 let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1542 let joined = args.join(" ");
1543 let abs = |p: &std::path::Path| container_host_path(p);
1544
1545 for name in [
1546 "serve.token",
1547 "config.json",
1548 "domain-terms.local",
1549 "hook-status",
1550 ] {
1551 assert!(
1552 !joined.contains(&abs(&kranz.join(name))),
1553 "authority must not be rebound: {args:?}"
1554 );
1555 }
1556 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz))));
1557 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1558 assert!(!joined.contains(&abs(&mission.join("control"))));
1559 for readable in [kranz.join("queue"), kranz.join("missions")] {
1564 assert!(
1565 joined.contains(&mount_arg(&abs(&readable), true)),
1566 "missing :ro self-bind for {}: {args:?}",
1567 readable.display()
1568 );
1569 }
1570 }
1571
1572 #[test]
1582 fn container_run_args_keep_write_denied_kranz_content_readable() {
1583 let dir = tempfile::tempdir().unwrap();
1584 let session = dir.path().join("repo");
1588 let kranz = session.join(".kranz");
1589 let mission = kranz.join("missions").join("m-x");
1590 std::fs::create_dir_all(mission.join("control")).unwrap();
1591 std::fs::create_dir_all(kranz.join("hook-status")).unwrap();
1592 std::fs::create_dir_all(kranz.join("tickets")).unwrap();
1593 std::fs::create_dir_all(kranz.join("lessons")).unwrap();
1594 std::fs::create_dir_all(kranz.join("queue")).unwrap();
1595 std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
1596 std::fs::write(kranz.join("tickets").join("some-ticket.md"), "# tracked").unwrap();
1597 std::fs::write(kranz.join("merge-gates.json"), "{}").unwrap();
1598 std::fs::write(kranz.join("secret-allowlist"), "OK_TOKEN\n").unwrap();
1599 for name in ["serve.token", "config.json"] {
1600 std::fs::write(kranz.join(name), "secret").unwrap();
1601 }
1602 let mut inputs = inputs(SandboxEnforce::Fs);
1603 inputs.session_cwd = session;
1604 inputs.mission_dir = mission.clone();
1605
1606 let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1607 let joined = args.join(" ");
1608 let abs = |p: &std::path::Path| container_host_path(p);
1609
1610 for readable in [
1612 kranz.join("tickets"),
1613 kranz.join("lessons"),
1614 kranz.join("queue"),
1615 kranz.join("missions"),
1616 ] {
1617 assert!(
1618 joined.contains(&mount_arg(&abs(&readable), true)),
1619 "{} must be a :ro self-bind, not a mask: {args:?}",
1620 readable.display()
1621 );
1622 assert!(
1623 !joined.contains(&format!("--tmpfs {}:ro", abs(&readable))),
1624 "{} must not be shadowed by an empty tmpfs: {args:?}",
1625 readable.display()
1626 );
1627 }
1628 for readable in [
1629 kranz.join("merge-gates.json"),
1630 kranz.join("secret-allowlist"),
1631 ] {
1632 assert!(
1633 joined.contains(&mount_arg(&abs(&readable), true)),
1634 "{} must be a :ro self-bind: {args:?}",
1635 readable.display()
1636 );
1637 assert!(
1638 !joined.contains(&format!("/dev/null:{}:ro", abs(&readable))),
1639 "{} must not read as zero bytes: {args:?}",
1640 readable.display()
1641 );
1642 }
1643
1644 for hidden in [
1646 kranz.join("serve.token"),
1647 kranz.join("config.json"),
1648 mission.join("control"),
1649 kranz.join("hook-status"),
1650 ] {
1651 assert!(
1652 !joined.contains(&abs(&hidden)),
1653 "read-denied entry was mounted: {args:?}"
1654 );
1655 }
1656 }
1657
1658 #[test]
1663 fn container_run_args_harden_the_worker_like_the_egress_relay() {
1664 let session = tempfile::tempdir().unwrap();
1667 let mut inputs = inputs(SandboxEnforce::Fs);
1668 inputs.session_cwd = session.path().to_path_buf();
1669 let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1670
1671 assert!(args
1672 .windows(2)
1673 .any(|w| w[0] == "--cap-drop" && w[1] == "ALL"));
1674 assert!(args
1675 .windows(2)
1676 .any(|w| w[0] == "--security-opt" && w[1] == "no-new-privileges"));
1677 assert!(args
1678 .windows(2)
1679 .any(|w| w[0] == "--pids-limit" && w[1] == CONTAINER_PIDS_LIMIT));
1680 #[cfg(unix)]
1681 {
1682 let expected = crate::container_egress::mount_owner(session.path())
1685 .expect("a stat-able path yields an owner");
1686 assert!(
1687 args.windows(2)
1688 .any(|w| w[0] == "--user" && w[1] == expected),
1689 "missing --user {expected}: {args:?}"
1690 );
1691 }
1692 }
1693
1694 #[test]
1701 fn container_run_args_never_mount_the_real_cargo_root_for_a_session() {
1702 let home = tempfile::tempdir().unwrap();
1703 let cargo = home.path().join(".cargo");
1704 for leaf in ["bin", "registry", "git"] {
1705 std::fs::create_dir_all(cargo.join(leaf)).unwrap();
1706 }
1707 std::fs::write(cargo.join("credentials.toml"), "[registry]\ntoken=\"x\"\n").unwrap();
1708 let _guard = crate::agent_env::EnvTestGuard::engage(&[
1709 ("CARGO_HOME", cargo.to_str().unwrap()),
1710 ("HOME", home.path().to_str().unwrap()),
1711 ]);
1712
1713 let mut out = Vec::new();
1714 push_toolchain_caches(&mut out, ToolchainMount::Session);
1715 let joined = out.join(" ");
1716 let root = container_host_path(&cargo);
1717
1718 assert!(
1719 !joined.contains(&mount_arg(&root, true)),
1720 "the credential-bearing Cargo root must never be mounted: {out:?}"
1721 );
1722 for leaf in ["bin", "registry", "git"] {
1723 let mounted = container_host_path(&cargo.join(leaf));
1724 assert!(
1725 joined.contains(&mount_arg(&mounted, true)),
1726 "the {leaf} cache leaf must still cross read-only: {out:?}"
1727 );
1728 }
1729 assert!(
1732 out.windows(2)
1733 .any(|w| w[0] == "-e" && w[1] == format!("CARGO_HOME={root}")),
1734 "session mode must forward the cache-only CARGO_HOME: {out:?}"
1735 );
1736 }
1737
1738 #[test]
1747 fn container_gate_wrap_args_mounts_policy_forwards_env_and_payload() {
1748 let dir = tempfile::tempdir().unwrap();
1749 let gate = dir.path().join("gate");
1750 let mission = dir.path().join("mission");
1751 let scratch = dir.path().join("scratch");
1752 let extra = dir.path().join("extra");
1753 for dir in [&gate, &mission, &scratch, &extra] {
1754 std::fs::create_dir_all(dir).unwrap();
1755 }
1756 let kranz_dir = gate.join(".kranz");
1757 std::fs::create_dir_all(&kranz_dir).unwrap();
1758 let masked_token_file = kranz_dir.join("serve.token");
1759 std::fs::write(&masked_token_file, "secret").unwrap();
1760 let inputs = SandboxInputs {
1761 enforce: SandboxEnforce::Fs,
1762 session_cwd: gate.clone(),
1763 mission_dir: mission.clone(),
1764 tmpdir: scratch.clone(),
1765 extra_write: vec![extra.clone()],
1766 egress: Vec::new(),
1767 validator_read_deny_roots: Vec::new(),
1768 };
1769 let env: std::collections::HashMap<String, String> = [
1770 ("ZZZ_BASE".to_string(), "deadbeef".to_string()),
1771 ("AAA_FIRST".to_string(), "1".to_string()),
1772 ("CARGO_HOME".to_string(), "/scratch/cache-only".to_string()),
1773 ("PATH".to_string(), "/usr/bin:/bin".to_string()),
1774 ("HOME".to_string(), "/caller/home".to_string()),
1777 ("TMPDIR".to_string(), "/caller/tmp".to_string()),
1778 ("RUSTUP_HOME".to_string(), "/caller/rustup".to_string()),
1779 ("NPM_CONFIG_CACHE".to_string(), "/caller/npm".to_string()),
1780 ]
1781 .into_iter()
1782 .collect();
1783
1784 let args = container_gate_run_args(
1785 &inputs,
1786 &spec(),
1787 "cargo test --workspace",
1788 &env,
1789 "kranz-gate-test",
1790 );
1791 let joined = args.join(" ");
1792 let abs = |p: &std::path::Path| container_host_path(p);
1793
1794 assert!(args.contains(&"--read-only".to_string()));
1796 assert!(joined.contains(&mount_arg(&abs(&gate), false)));
1797 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1798 assert!(joined.contains(&mount_arg(&abs(&scratch), false)));
1799 assert!(joined.contains(&mount_arg(&abs(&extra), false)));
1800 assert!(joined.contains(&format!("-w {}", abs(&gate))));
1801 assert!(joined.contains(&format!("-e HOME={}", abs(&scratch))));
1802 assert!(joined.contains(&format!("-e TMPDIR={}", abs(&scratch))));
1803 assert!(
1804 joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz_dir)))
1805 && !joined.contains(&abs(&masked_token_file)),
1806 "authority material must stay outside the private directory: {args:?}"
1807 );
1808
1809 assert!(
1811 args.windows(2)
1812 .any(|w| w[0] == "--name" && w[1] == "kranz-gate-test"),
1813 "the gate container must carry the caller-chosen name: {args:?}"
1814 );
1815 assert!(
1816 joined.ends_with(&format!("{DEFAULT_IMAGE} sh -c cargo test --workspace")),
1817 "image then sh -c payload: {args:?}"
1818 );
1819
1820 let index_of = |needle: &str| {
1822 args.windows(2)
1823 .position(|w| w[0] == "-e" && w[1] == needle)
1824 .unwrap_or_else(|| panic!("missing -e {needle}: {args:?}"))
1825 };
1826 assert!(index_of("AAA_FIRST=1") < index_of("ZZZ_BASE=deadbeef"));
1827 index_of("CARGO_HOME=/scratch/cache-only");
1828 index_of("PATH=/usr/bin:/bin");
1829 for skipped in [
1832 "-e HOME=/caller/home",
1833 "-e TMPDIR=/caller/tmp",
1834 "-e RUSTUP_HOME=/caller/rustup",
1835 "-e NPM_CONFIG_CACHE=/caller/npm",
1836 ] {
1837 assert!(
1838 !joined.contains(skipped),
1839 "builder-owned env key must not be forwarded with the caller value: {skipped}\n{args:?}"
1840 );
1841 }
1842 }
1843
1844 #[test]
1852 fn container_gate_wrap_args_never_mounts_the_real_cargo_root() {
1853 let cargo = tempfile::tempdir().unwrap();
1854 std::fs::create_dir_all(cargo.path().join("bin")).unwrap();
1855 std::fs::write(cargo.path().join("credentials.toml"), "operator-secret").unwrap();
1856 let _guard = crate::agent_env::EnvTestGuard::engage(&[(
1857 "CARGO_HOME",
1858 cargo.path().to_str().expect("utf-8 temp path"),
1859 )]);
1860
1861 let dir = tempfile::tempdir().unwrap();
1862 let inputs = SandboxInputs {
1863 enforce: SandboxEnforce::Fs,
1864 session_cwd: dir.path().join("gate"),
1865 mission_dir: dir.path().join("mission"),
1866 tmpdir: dir.path().join("scratch"),
1867 extra_write: Vec::new(),
1868 egress: Vec::new(),
1869 validator_read_deny_roots: Vec::new(),
1870 };
1871 let env: std::collections::HashMap<String, String> =
1872 [("CARGO_HOME".to_string(), "/scratch/cache-only".to_string())]
1873 .into_iter()
1874 .collect();
1875 let args = container_gate_run_args(&inputs, &spec(), "true", &env, "kranz-gate-test");
1876 let joined = args.join(" ");
1877 let abs = |p: &std::path::Path| container_host_path(p);
1878
1879 let root = abs(cargo.path());
1880 let bin = abs(&cargo.path().join("bin"));
1881 assert!(
1882 joined.contains(&mount_arg(&bin, true)),
1883 "the shim dir must cross read-only: {args:?}"
1884 );
1885 assert!(
1886 !joined.contains(&mount_arg(&root, true)),
1887 "the credential-bearing Cargo root must NEVER be mounted: {args:?}"
1888 );
1889 assert!(
1890 !joined.contains(&format!("-e CARGO_HOME={root}")),
1891 "no -e may point CARGO_HOME at the real root: {args:?}"
1892 );
1893 assert!(
1894 joined.contains("-e CARGO_HOME=/scratch/cache-only"),
1895 "the caller's cache-only CARGO_HOME crosses instead: {args:?}"
1896 );
1897 }
1898
1899 #[test]
1906 fn container_gate_wrap_args_fs_net_empty_egress_disables_network() {
1907 let env = std::collections::HashMap::new();
1908 let fs_net = container_gate_run_args(
1909 &inputs(SandboxEnforce::FsNet),
1910 &spec(),
1911 "true",
1912 &env,
1913 "kranz-gate-test",
1914 );
1915 let network = fs_net
1916 .windows(2)
1917 .find(|w| w[0] == "--network")
1918 .expect("fs+net must pass a --network flag");
1919 assert_eq!(network[1], "none");
1920 assert!(
1921 fs_net
1922 .iter()
1923 .filter(|a| a.starts_with("HTTPS_PROXY="))
1924 .all(|a| a == "HTTPS_PROXY="),
1925 "offline gates must suppress inherited proxy configuration: {fs_net:?}"
1926 );
1927
1928 let fs = container_gate_run_args(
1929 &inputs(SandboxEnforce::Fs),
1930 &spec(),
1931 "true",
1932 &env,
1933 "kranz-gate-test",
1934 );
1935 assert!(
1936 !fs.iter().any(|a| a == "--network"),
1937 "fs must not restrict the network (runtime default bridge): {fs:?}"
1938 );
1939 }
1940
1941 #[test]
1942 fn container_run_args_respects_image_override() {
1943 let spec = ContainerSpec {
1944 runtime: ContainerRuntime::Podman,
1945 image: "ghcr.io/example/kranz-worker:1".to_string(),
1946 network: None,
1947 name: None,
1948 };
1949 let args = container_run_args(
1950 &inputs(SandboxEnforce::Fs),
1951 &spec,
1952 Path::new("claude"),
1953 &[],
1954 None,
1955 );
1956 assert!(
1957 args.iter().any(|a| a == "ghcr.io/example/kranz-worker:1"),
1958 "configured image must be used: {args:?}"
1959 );
1960 assert!(!args.iter().any(|a| a == DEFAULT_IMAGE));
1961 }
1962
1963 #[test]
1970 fn container_provider_runs_a_trivial_worker_and_enforces_the_write_boundary() {
1971 if !host_supports_container_contract() {
1972 crate::test_capability::skip(
1973 crate::test_capability::capability::CONTAINER,
1974 &container_contract_skip_detail(),
1975 );
1976 return;
1977 }
1978 let Some(runtime) = live_runtime() else {
1979 return;
1980 };
1981
1982 let session = live_fixture();
1983 let mission = live_fixture();
1984 let scratch = live_fixture();
1985 let kranz_dir = session.path().join(".kranz");
1986 std::fs::create_dir_all(&kranz_dir).unwrap();
1987 std::fs::write(kranz_dir.join("serve.token"), "secret").unwrap();
1988 let inputs = SandboxInputs {
1989 enforce: SandboxEnforce::FsNet,
1990 session_cwd: session.path().to_path_buf(),
1991 mission_dir: mission.path().to_path_buf(),
1992 tmpdir: scratch.path().to_path_buf(),
1993 extra_write: Vec::new(),
1994 egress: Vec::new(),
1995 validator_read_deny_roots: Vec::new(),
1996 };
1997 let spec = ContainerSpec {
1998 runtime,
1999 image: DEFAULT_IMAGE.to_string(),
2000 network: None,
2001 name: None,
2002 };
2003 let ok_file = session.path().join("ok.txt");
2004 let args = container_run_args(
2005 &inputs,
2006 &spec,
2007 Path::new("sh"),
2008 &[
2009 "-c".to_string(),
2010 format!(
2011 "echo ok > {} && ! cat {} && echo nope > /etc/nope.txt",
2012 ok_file.display(),
2013 kranz_dir.join("serve.token").display()
2014 ),
2015 ],
2016 None,
2017 );
2018 let output = std::process::Command::new(runtime.binary())
2019 .args(&args)
2020 .stdin(std::process::Stdio::null())
2021 .output()
2022 .expect("failed to spawn container runtime");
2023
2024 assert!(
2025 ok_file.exists(),
2026 "write inside the mounted session_cwd must land on the host: {}",
2027 String::from_utf8_lossy(&output.stderr)
2028 );
2029 assert!(
2030 !output.status.success(),
2031 "write outside the declared policy (/etc) must be denied, failing the worker: {}",
2032 String::from_utf8_lossy(&output.stderr)
2033 );
2034 assert!(
2035 !String::from_utf8_lossy(&output.stdout).contains("secret"),
2036 "the /dev/null mask must hide serve.token content inside the container"
2037 );
2038 }
2039
2040 #[test]
2041 fn container_authority_directory_mask_covers_absent_and_future_tokens() {
2042 if crate::agent_env::isolated_global_home_test("sandbox_container::tests::container_authority_directory_mask_covers_absent_and_future_tokens") { return; }
2043 let home = tempfile::tempdir().unwrap();
2044 let _env = crate::agent_env::EnvTestGuard::engage(&[(
2045 if cfg!(windows) { "USERPROFILE" } else { "HOME" },
2046 home.path().to_str().unwrap(),
2047 )]);
2048 let global = home.path().join(".kranz");
2049 assert!(!global.exists());
2050 let mut inputs = inputs(SandboxEnforce::Fs);
2051 inputs.extra_write.extend([
2052 home.path().to_path_buf(),
2053 global.clone(),
2054 global.join("serve"),
2055 ]);
2056 for args in [
2057 container_run_args(&inputs, &spec(), Path::new("sh"), &[], None),
2058 container_gate_run_args(&inputs, &spec(), "true", &Default::default(), "test"),
2059 ] {
2060 assert!(
2061 args.windows(2).any(|pair| pair[0] == "--tmpfs"
2062 && pair[1]
2063 == format!(
2064 "{}:ro,noexec,nosuid,nodev,mode=755",
2065 container_host_path(&global)
2066 )),
2067 "authority mask missing: {args:?}"
2068 );
2069 assert!(
2070 !args
2071 .windows(2)
2072 .any(|pair| pair[0] == "-v"
2073 && pair[1].starts_with(&container_host_path(&global))),
2074 "nested mounts must not reopen global authority: {args:?}"
2075 );
2076 }
2077 assert!(!global.exists());
2079 }
2080
2081 #[cfg(unix)]
2082 #[test]
2083 fn container_authority_directory_hides_tokens_created_after_start() {
2084 if crate::agent_env::isolated_global_home_test("sandbox_container::tests::container_authority_directory_hides_tokens_created_after_start") { return; }
2085 use std::io::{BufRead as _, Write as _};
2086 let Some(runtime) = live_runtime() else {
2087 return;
2088 };
2089 let dir = live_fixture();
2090 let home = dir.path().join("operator");
2091 let session = dir.path().join("session");
2092 let mission = session.join(".kranz/missions/m-test");
2093 let scratch = dir.path().join("scratch");
2094 std::fs::create_dir_all(&home).unwrap();
2095 let authority_target = dir.path().join("private-authority");
2096 std::fs::create_dir(&authority_target).unwrap();
2097 std::os::unix::fs::symlink(&authority_target, home.join(".kranz")).unwrap();
2098 std::fs::create_dir_all(&mission).unwrap();
2099 std::fs::create_dir(&scratch).unwrap();
2100 let authority = home.join(".kranz/serve/later.token");
2101 let global_config = home.join(".kranz/config.json");
2102 let cargo = home.join(".cargo");
2103 std::fs::create_dir(&cargo).unwrap();
2104 let repo_token_path = session.join(".kranz/serve.token");
2105 let repo_read_token_path = session.join(".kranz/serve.read.token");
2106 let repo_config = session.join(".kranz/config.json");
2107 let cargo_credentials = cargo.join("credentials.toml");
2108 let policy = session.join(".kranz/merge-gates.json");
2109 std::fs::write(&repo_token_path, "original-token").unwrap();
2110 std::fs::write(&policy, "visible-policy").unwrap();
2111 let input = SandboxInputs {
2112 enforce: SandboxEnforce::FsNet,
2113 session_cwd: session.clone(),
2114 mission_dir: mission,
2115 tmpdir: scratch,
2116 extra_write: vec![home.clone(), home.join(".kranz/serve")],
2117 egress: Vec::new(),
2118 validator_read_deny_roots: Vec::new(),
2119 };
2120 let args = {
2121 let _env = crate::agent_env::EnvTestGuard::engage(&[
2122 ("HOME", home.to_str().unwrap()),
2123 ("CARGO_HOME", cargo.to_str().unwrap()),
2124 ]);
2125 container_run_args(
2126 &input,
2127 &ContainerSpec {
2128 runtime,
2129 network: None,
2130 name: None,
2131 image: DEFAULT_IMAGE.to_string(),
2132 },
2133 Path::new("sh"),
2134 &[
2135 "-c".to_string(),
2136 "printf 'ready\\n'; read -r proceed; test -s \"$1\" || exit 2; \
2137 for secret in \"$2\" \"$3\" \"$4\" \"$5\" \"$6\" \"$7\"; do \
2138 if cat \"$secret\"; then exit 3; fi; \
2139 if printf forged > \"$secret\"; then exit 4; fi; done; \
2140 if rm \"$9\"; then exit 5; fi; \
2141 test \"$(cat \"$8\")\" = visible-policy || exit 8; \
2142 printf work > \"$1-worker\""
2143 .to_string(),
2144 "test".to_string(),
2145 session.join("host-witness").display().to_string(),
2146 authority.display().to_string(),
2147 global_config.display().to_string(),
2148 repo_token_path.display().to_string(),
2149 repo_read_token_path.display().to_string(),
2150 repo_config.display().to_string(),
2151 cargo_credentials.display().to_string(),
2152 policy.display().to_string(),
2153 home.join(".kranz").display().to_string(),
2154 ],
2155 None,
2156 )
2157 };
2158 let _env = crate::agent_env::EnvTestGuard::engage(&[]);
2160 let mut child = std::process::Command::new(runtime.binary())
2161 .args(args)
2162 .stdin(std::process::Stdio::piped())
2163 .stdout(std::process::Stdio::piped())
2164 .stderr(std::process::Stdio::piped())
2165 .spawn()
2166 .unwrap();
2167 let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
2168 let mut line = String::new();
2169 stdout.read_line(&mut line).unwrap();
2170 if line != "ready\n" {
2171 let _ = child.kill();
2172 let output = child.wait_with_output().unwrap();
2173 panic!(
2174 "container did not start: {line:?}: {}",
2175 String::from_utf8_lossy(&output.stderr)
2176 );
2177 }
2178 std::fs::create_dir(authority.parent().unwrap()).unwrap();
2181 std::fs::write(&authority, "fake-authority").unwrap();
2182 std::fs::write(&global_config, "fake-config").unwrap();
2183 for path in [&repo_read_token_path, &repo_config, &cargo_credentials] {
2184 assert!(
2185 !path.exists(),
2186 "mount setup created a placeholder credential"
2187 );
2188 std::fs::write(path, "fake-authority").unwrap();
2189 }
2190 let rotated = session.join(".kranz/rotated.tmp");
2191 std::fs::write(&rotated, "rotated-token").unwrap();
2192 std::fs::rename(rotated, &repo_token_path).unwrap();
2193 std::fs::write(session.join("host-witness"), "visible").unwrap();
2194 child
2195 .stdin
2196 .take()
2197 .unwrap()
2198 .write_all(b"continue\n")
2199 .unwrap();
2200 let output = child.wait_with_output().unwrap();
2201 assert!(output.status.success(), "{output:?}");
2202 assert!(session.join("host-witness-worker").exists());
2203 assert!(
2204 home.join(".kranz").is_symlink(),
2205 "authority alias was replaced"
2206 );
2207 assert_eq!(
2208 std::fs::read_to_string(repo_token_path).unwrap(),
2209 "rotated-token"
2210 );
2211 for path in [&repo_read_token_path, &repo_config, &cargo_credentials] {
2212 assert_eq!(std::fs::read_to_string(path).unwrap(), "fake-authority");
2213 }
2214 assert_eq!(
2215 std::fs::read_to_string(global_config).unwrap(),
2216 "fake-config"
2217 );
2218 }
2219}
2220
2221#[cfg(test)]
2222mod git_mount_tests {
2223 use super::*;
2224
2225 #[test]
2226 fn git_config_mount_nodes_preserve_existing_readonly_destinations() {
2227 let root = tempfile::tempdir().unwrap();
2228 let root = crate::sandbox::absolutize(root.path());
2229 let git = root.join(".git");
2230 std::fs::create_dir(&git).unwrap();
2231 std::fs::write(git.join("config"), "[core]\nrepositoryformatversion = 0\n").unwrap();
2232 let inputs = SandboxInputs {
2233 enforce: crate::types::SandboxEnforce::Fs,
2234 session_cwd: root.clone(),
2235 mission_dir: root.join(".kranz/missions/m-fixture"),
2236 tmpdir: root.join("scratch"),
2237 extra_write: Vec::new(),
2238 egress: Vec::new(),
2239 validator_read_deny_roots: Vec::new(),
2240 };
2241 let root = container_host_path(&root);
2242 let git = container_host_path(&git);
2243 let mut args = vec![
2244 "-v".into(),
2245 mount_arg(&root, false),
2246 "-v".into(),
2247 mount_arg(&git, true),
2248 ];
2249 push_authority_masks(&mut args, &inputs);
2250 let duplicates = args
2251 .windows(2)
2252 .filter(|part| {
2253 part[0] == "-v"
2254 && (part[1] == mount_arg(&git, false) || part[1] == mount_arg(&git, true))
2255 })
2256 .count();
2257 assert_eq!(duplicates, 1, "{args:?}");
2258 assert!(args
2259 .windows(2)
2260 .any(|part| part[0] == "-v" && part[1] == mount_arg(&git, true)));
2261 }
2262}