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