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 use crate::sandbox::SandboxInputs;
1014 use crate::types::SandboxEnforce;
1015 use std::path::PathBuf;
1016
1017 #[test]
1018 fn declared_roots_follow_the_scratch_override_not_the_temp_dir() {
1019 let case =
1020 "sandbox_container::tests::declared_roots_follow_the_scratch_override_not_the_temp_dir";
1021 if std::env::var("KRANZ_SCRATCH_TEST_CASE").as_deref() != Ok(case) {
1022 let shared = tempfile::tempdir().unwrap();
1023 let output = std::process::Command::new(std::env::current_exe().unwrap())
1024 .args([case, "--exact", "--nocapture"])
1025 .env("KRANZ_SCRATCH_TEST_CASE", case)
1026 .env(crate::backend_claude::SCRATCH_ROOT_ENV, shared.path())
1027 .output()
1028 .unwrap();
1029 assert!(
1030 output.status.success(),
1031 "{}",
1032 String::from_utf8_lossy(&output.stderr)
1033 );
1034 assert!(String::from_utf8_lossy(&output.stdout).contains("test result: ok. 1 passed;"));
1035 return;
1036 }
1037 let checkout = std::path::Path::new("/repos/app/worktree");
1038 let mission = std::path::Path::new("/repos/app/.kranz/missions/m-1");
1039 let shared =
1040 PathBuf::from(std::env::var_os(crate::backend_claude::SCRATCH_ROOT_ENV).unwrap());
1041 let roots = declared_mount_roots(checkout, mission, &[]);
1042
1043 assert!(roots.contains(&shared), "{roots:?}");
1047 assert!(!roots.contains(&std::env::temp_dir()), "{roots:?}");
1048 assert!(
1049 roots.contains(&std::path::PathBuf::from("/repos/app")),
1050 "the checkout's parent is mounted, not the worktree itself: {roots:?}"
1051 );
1052 }
1053
1054 #[test]
1055 fn mount_proof_argv_reads_the_host_sentinel_and_writes_the_guest_one() {
1056 let host = std::env::temp_dir();
1062 let argv = mount_proof_argv(&host, "alpine:3", "guestsentinel");
1063 let rendered = argv.join(" ");
1064 let expected_mount = format!("{}:/kranz-mount-proof", container_host_path(&host));
1065 assert!(rendered.contains(&expected_mount), "{rendered}");
1066 assert!(!expected_mount.starts_with(r"\\?\"), "{expected_mount}");
1067 assert!(
1070 rendered.contains("cat /kranz-mount-proof/host.txt"),
1071 "{rendered}"
1072 );
1073 assert!(
1074 rendered.contains("printf %s guestsentinel > /kranz-mount-proof/guest.txt"),
1075 "{rendered}"
1076 );
1077 assert!(rendered.contains("no-host-sentinel"), "{rendered}");
1080 assert!(rendered.starts_with("run --rm "), "{rendered}");
1081 }
1082
1083 #[test]
1084 fn live_bind_mount_round_trip_closes_under_the_checkout() {
1085 if cfg!(target_os = "windows") {
1088 crate::test_capability::skip(
1089 crate::test_capability::capability::CONTAINER,
1090 "the container provider refuses Windows, so a bind-mount probe proves nothing",
1091 );
1092 return;
1093 }
1094 let Some(runtime) = detect() else {
1095 crate::test_capability::skip(
1096 crate::test_capability::capability::CONTAINER,
1097 "no container runtime on PATH, so the bind-mount round trip cannot be proven",
1098 );
1099 return;
1100 };
1101 let checkout = std::env::current_dir().expect("a working directory");
1104 let root = checkout.parent().unwrap_or(&checkout);
1105 match prove_bind_mount(runtime, root, DEFAULT_IMAGE) {
1106 MountProof::Proven => {}
1107 MountProof::Failed(reason) => panic!(
1108 "the bind-mount round trip under {} did not close, so a mission's \
1109 declared write set cannot be trusted here: {reason}",
1110 root.display()
1111 ),
1112 }
1113 }
1114
1115 #[test]
1116 fn detect_prefers_docker_then_podman_then_nerdctl_then_apple_container() {
1117 assert_eq!(detect_with(|_| false), None);
1118 assert_eq!(
1119 detect_with(|name| name == "container"),
1120 Some(ContainerRuntime::AppleContainer)
1121 );
1122 assert_eq!(
1123 detect_with(|name| name == "nerdctl" || name == "container"),
1124 Some(ContainerRuntime::Nerdctl)
1125 );
1126 assert_eq!(
1127 detect_with(|name| name == "podman" || name == "nerdctl"),
1128 Some(ContainerRuntime::Podman)
1129 );
1130 assert_eq!(
1131 detect_with(|name| name == "docker" || name == "podman"),
1132 Some(ContainerRuntime::Docker)
1133 );
1134 }
1135
1136 fn inputs(enforce: SandboxEnforce) -> SandboxInputs {
1137 SandboxInputs {
1138 enforce,
1139 session_cwd: PathBuf::from("/work/session"),
1140 mission_dir: PathBuf::from("/work/mission"),
1141 tmpdir: PathBuf::from("/work/scratch"),
1142 extra_write: vec![PathBuf::from("/home/op/.cargo")],
1143 egress: Vec::new(),
1144 validator_read_deny_roots: Vec::new(),
1145 }
1146 }
1147
1148 fn spec() -> ContainerSpec {
1149 ContainerSpec {
1150 runtime: ContainerRuntime::Docker,
1151 image: DEFAULT_IMAGE.to_string(),
1152 network: None,
1153 name: None,
1154 }
1155 }
1156
1157 fn live_fixture() -> tempfile::TempDir {
1158 tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap()
1162 }
1163
1164 #[test]
1165 fn container_run_args_fs_net_with_empty_egress_disables_network() {
1166 let args = container_run_args(
1167 &inputs(SandboxEnforce::FsNet),
1168 &spec(),
1169 Path::new("claude"),
1170 &["-p".to_string(), "hi".to_string()],
1171 None,
1172 );
1173 let network = args
1174 .windows(2)
1175 .find(|w| w[0] == "--network")
1176 .expect("fs+net must pass a --network flag");
1177 assert_eq!(network[1], "none");
1178 }
1179
1180 #[test]
1181 fn container_run_args_fs_net_with_egress_uses_internal_network_and_relay_env() {
1182 let mut inputs = inputs(SandboxEnforce::FsNet);
1183 inputs.egress = vec!["crates.io:443".to_string()];
1184 let mut spec = spec();
1185 spec.network = Some("kranz-egress-test".to_string());
1186 spec.name = Some("kranz-egress-worker-test".to_string());
1187 let args = container_run_args(
1188 &inputs,
1189 &spec,
1190 Path::new("claude"),
1191 &["-p".to_string(), "hi".to_string()],
1192 Some("http://kranz-egress:3128"),
1193 );
1194
1195 assert!(
1196 args.windows(2)
1197 .any(|w| w[0] == "--network" && w[1] == "kranz-egress-test"),
1198 "proxy-routed fs+net must use the per-run internal network: {args:?}"
1199 );
1200 assert!(
1201 args.windows(2)
1202 .any(|w| w[0] == "--name" && w[1] == "kranz-egress-worker-test"),
1203 "the daemon-owned worker must be named for timeout teardown: {args:?}"
1204 );
1205 for var in ["HTTPS_PROXY", "HTTP_PROXY"] {
1206 assert!(
1207 args.windows(2)
1208 .any(|w| w[0] == "-e" && w[1] == format!("{var}=http://kranz-egress:3128")),
1209 "missing -e {var}=…: {args:?}"
1210 );
1211 }
1212 assert!(
1213 args.windows(2)
1214 .any(|w| w[0] == "-e" && w[1] == "NO_PROXY=localhost,127.0.0.1"),
1215 "missing -e NO_PROXY…: {args:?}"
1216 );
1217 }
1218
1219 #[test]
1220 fn container_run_args_fs_net_with_egress_fails_closed_without_boundary() {
1221 let mut inputs = inputs(SandboxEnforce::FsNet);
1222 inputs.egress = vec!["crates.io:443".to_string()];
1223 let args = container_run_args(
1224 &inputs,
1225 &spec(),
1226 Path::new("claude"),
1227 &[],
1228 Some("http://kranz-egress:3128"),
1229 );
1230 assert!(
1231 args.windows(2)
1232 .any(|w| w[0] == "--network" && w[1] == "none"),
1233 "missing boundary state must disable networking: {args:?}"
1234 );
1235 assert!(
1236 args.iter()
1237 .filter(|a| a.starts_with("HTTPS_PROXY="))
1238 .all(|a| a == "HTTPS_PROXY="),
1239 "a relay env must not be emitted without its internal network: {args:?}"
1240 );
1241 }
1242
1243 #[test]
1244 fn container_run_args_fs_keeps_runtime_default_network() {
1245 let args = container_run_args(
1246 &inputs(SandboxEnforce::Fs),
1247 &spec(),
1248 Path::new("claude"),
1249 &[],
1250 None,
1251 );
1252 assert!(
1253 !args.iter().any(|a| a == "--network"),
1254 "fs must not restrict the network (runtime default bridge): {args:?}"
1255 );
1256 }
1257
1258 #[test]
1259 fn container_run_args_mounts_policy_and_runs_image() {
1260 let dir = tempfile::tempdir().unwrap();
1264 let session = dir.path().join("session");
1265 let mission = dir.path().join("mission");
1266 let scratch = dir.path().join("scratch");
1267 let cargo = dir.path().join("cargo");
1268 for path in [&session, &mission, &scratch, &cargo] {
1269 std::fs::create_dir_all(path).unwrap();
1270 }
1271 let inputs = SandboxInputs {
1272 enforce: SandboxEnforce::Fs,
1273 session_cwd: session.clone(),
1274 mission_dir: mission.clone(),
1275 tmpdir: scratch.clone(),
1276 extra_write: vec![cargo.clone()],
1277 egress: Vec::new(),
1278 validator_read_deny_roots: Vec::new(),
1279 };
1280 let args = container_run_args(
1281 &inputs,
1282 &spec(),
1283 Path::new("claude"),
1284 &["--print".to_string()],
1285 None,
1286 );
1287 let joined = args.join(" ");
1288 let abs = |p: &std::path::Path| container_host_path(p);
1289
1290 assert!(args.contains(&"--rm".to_string()));
1291 assert!(args.contains(&"--read-only".to_string()));
1292 assert!(joined.contains(&mount_arg(&abs(&session), false)));
1293 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1294 assert!(joined.contains(&mount_arg(&abs(&scratch), false)));
1295 assert!(joined.contains(&mount_arg(&abs(&cargo), false)));
1296 assert!(joined.contains(&format!("-w {}", abs(&session))));
1297 assert!(joined.contains(&format!("-e HOME={}", abs(&scratch))));
1298 assert!(
1299 joined.ends_with(&format!("{DEFAULT_IMAGE} claude --print")),
1300 "image then binary then args: {args:?}"
1301 );
1302 }
1303
1304 #[test]
1305 fn container_run_args_mask_authority_material_under_session_root() {
1306 let dir = tempfile::tempdir().unwrap();
1307 let session = dir.path().join("session");
1308 let kranz_dir = session.join(".kranz");
1309 std::fs::create_dir_all(&kranz_dir).unwrap();
1310 let masked_token_file = kranz_dir.join("serve.token");
1311 let config = kranz_dir.join("config.json");
1312 std::fs::write(&masked_token_file, "secret").unwrap();
1313 std::fs::write(&config, "{}").unwrap();
1314 let mut inputs = inputs(SandboxEnforce::Fs);
1315 inputs.session_cwd = session;
1316
1317 let args = container_run_args(
1318 &inputs,
1319 &spec(),
1320 Path::new("claude"),
1321 &["--print".to_string()],
1322 None,
1323 );
1324 let joined = args.join(" ");
1325 let abs = |p: &std::path::Path| container_host_path(p);
1326
1327 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz_dir))));
1328 for name in ["serve.token", "serve.read.token", "config.json"] {
1329 assert!(
1330 !joined.contains(&abs(&kranz_dir.join(name))),
1331 "authority must stay outside the private view: {args:?}"
1332 );
1333 }
1334 }
1335
1336 #[test]
1344 fn container_run_args_mask_the_whole_process_tier_authority_set() {
1345 let dir = tempfile::tempdir().unwrap();
1346 let session = dir.path().join("session");
1347 let kranz = session.join(".kranz");
1348 let mission = kranz.join("missions").join("m-x");
1349 std::fs::create_dir_all(mission.join("control")).unwrap();
1350 std::fs::create_dir_all(kranz.join("hook-status")).unwrap();
1351 std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
1352 std::fs::create_dir_all(kranz.join("queue")).unwrap();
1353 for name in ["serve.token", "config.json", "domain-terms.local"] {
1354 std::fs::write(kranz.join(name), "secret").unwrap();
1355 }
1356 let mut inputs = inputs(SandboxEnforce::Fs);
1357 inputs.session_cwd = session;
1358 inputs.mission_dir = mission.clone();
1359
1360 let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1361 let joined = args.join(" ");
1362 let abs = |p: &std::path::Path| container_host_path(p);
1363
1364 for name in [
1365 "serve.token",
1366 "config.json",
1367 "domain-terms.local",
1368 "hook-status",
1369 ] {
1370 assert!(
1371 !joined.contains(&abs(&kranz.join(name))),
1372 "authority must not be rebound: {args:?}"
1373 );
1374 }
1375 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz))));
1376 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1377 assert!(!joined.contains(&abs(&mission.join("control"))));
1378 for readable in [kranz.join("queue"), kranz.join("missions")] {
1383 assert!(
1384 joined.contains(&mount_arg(&abs(&readable), true)),
1385 "missing :ro self-bind for {}: {args:?}",
1386 readable.display()
1387 );
1388 }
1389 }
1390
1391 #[test]
1401 fn container_run_args_keep_write_denied_kranz_content_readable() {
1402 let dir = tempfile::tempdir().unwrap();
1403 let session = dir.path().join("repo");
1407 let kranz = session.join(".kranz");
1408 let mission = kranz.join("missions").join("m-x");
1409 std::fs::create_dir_all(mission.join("control")).unwrap();
1410 std::fs::create_dir_all(kranz.join("hook-status")).unwrap();
1411 std::fs::create_dir_all(kranz.join("tickets")).unwrap();
1412 std::fs::create_dir_all(kranz.join("lessons")).unwrap();
1413 std::fs::create_dir_all(kranz.join("queue")).unwrap();
1414 std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
1415 std::fs::write(kranz.join("tickets").join("some-ticket.md"), "# tracked").unwrap();
1416 std::fs::write(kranz.join("merge-gates.json"), "{}").unwrap();
1417 std::fs::write(kranz.join("secret-allowlist"), "OK_TOKEN\n").unwrap();
1418 for name in ["serve.token", "config.json"] {
1419 std::fs::write(kranz.join(name), "secret").unwrap();
1420 }
1421 let mut inputs = inputs(SandboxEnforce::Fs);
1422 inputs.session_cwd = session;
1423 inputs.mission_dir = mission.clone();
1424
1425 let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1426 let joined = args.join(" ");
1427 let abs = |p: &std::path::Path| container_host_path(p);
1428
1429 for readable in [
1431 kranz.join("tickets"),
1432 kranz.join("lessons"),
1433 kranz.join("queue"),
1434 kranz.join("missions"),
1435 ] {
1436 assert!(
1437 joined.contains(&mount_arg(&abs(&readable), true)),
1438 "{} must be a :ro self-bind, not a mask: {args:?}",
1439 readable.display()
1440 );
1441 assert!(
1442 !joined.contains(&format!("--tmpfs {}:ro", abs(&readable))),
1443 "{} must not be shadowed by an empty tmpfs: {args:?}",
1444 readable.display()
1445 );
1446 }
1447 for readable in [
1448 kranz.join("merge-gates.json"),
1449 kranz.join("secret-allowlist"),
1450 ] {
1451 assert!(
1452 joined.contains(&mount_arg(&abs(&readable), true)),
1453 "{} must be a :ro self-bind: {args:?}",
1454 readable.display()
1455 );
1456 assert!(
1457 !joined.contains(&format!("/dev/null:{}:ro", abs(&readable))),
1458 "{} must not read as zero bytes: {args:?}",
1459 readable.display()
1460 );
1461 }
1462
1463 for hidden in [
1465 kranz.join("serve.token"),
1466 kranz.join("config.json"),
1467 mission.join("control"),
1468 kranz.join("hook-status"),
1469 ] {
1470 assert!(
1471 !joined.contains(&abs(&hidden)),
1472 "read-denied entry was mounted: {args:?}"
1473 );
1474 }
1475 }
1476
1477 #[test]
1482 fn container_run_args_harden_the_worker_like_the_egress_relay() {
1483 let session = tempfile::tempdir().unwrap();
1486 let mut inputs = inputs(SandboxEnforce::Fs);
1487 inputs.session_cwd = session.path().to_path_buf();
1488 let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1489
1490 assert!(args
1491 .windows(2)
1492 .any(|w| w[0] == "--cap-drop" && w[1] == "ALL"));
1493 assert!(args
1494 .windows(2)
1495 .any(|w| w[0] == "--security-opt" && w[1] == "no-new-privileges"));
1496 assert!(args
1497 .windows(2)
1498 .any(|w| w[0] == "--pids-limit" && w[1] == CONTAINER_PIDS_LIMIT));
1499 #[cfg(unix)]
1500 {
1501 let expected = crate::container_egress::mount_owner(session.path())
1504 .expect("a stat-able path yields an owner");
1505 assert!(
1506 args.windows(2)
1507 .any(|w| w[0] == "--user" && w[1] == expected),
1508 "missing --user {expected}: {args:?}"
1509 );
1510 }
1511 }
1512
1513 #[test]
1520 fn container_run_args_never_mount_the_real_cargo_root_for_a_session() {
1521 let home = tempfile::tempdir().unwrap();
1522 let cargo = home.path().join(".cargo");
1523 for leaf in ["bin", "registry", "git"] {
1524 std::fs::create_dir_all(cargo.join(leaf)).unwrap();
1525 }
1526 std::fs::write(cargo.join("credentials.toml"), "[registry]\ntoken=\"x\"\n").unwrap();
1527 let _guard = crate::agent_env::EnvTestGuard::engage(&[
1528 ("CARGO_HOME", cargo.to_str().unwrap()),
1529 ("HOME", home.path().to_str().unwrap()),
1530 ]);
1531
1532 let mut out = Vec::new();
1533 push_toolchain_caches(&mut out, ToolchainMount::Session);
1534 let joined = out.join(" ");
1535 let root = container_host_path(&cargo);
1536
1537 assert!(
1538 !joined.contains(&mount_arg(&root, true)),
1539 "the credential-bearing Cargo root must never be mounted: {out:?}"
1540 );
1541 for leaf in ["bin", "registry", "git"] {
1542 let mounted = container_host_path(&cargo.join(leaf));
1543 assert!(
1544 joined.contains(&mount_arg(&mounted, true)),
1545 "the {leaf} cache leaf must still cross read-only: {out:?}"
1546 );
1547 }
1548 assert!(
1551 out.windows(2)
1552 .any(|w| w[0] == "-e" && w[1] == format!("CARGO_HOME={root}")),
1553 "session mode must forward the cache-only CARGO_HOME: {out:?}"
1554 );
1555 }
1556
1557 #[test]
1566 fn container_gate_wrap_args_mounts_policy_forwards_env_and_payload() {
1567 let dir = tempfile::tempdir().unwrap();
1568 let gate = dir.path().join("gate");
1569 let mission = dir.path().join("mission");
1570 let scratch = dir.path().join("scratch");
1571 let extra = dir.path().join("extra");
1572 for dir in [&gate, &mission, &scratch, &extra] {
1573 std::fs::create_dir_all(dir).unwrap();
1574 }
1575 let kranz_dir = gate.join(".kranz");
1576 std::fs::create_dir_all(&kranz_dir).unwrap();
1577 let masked_token_file = kranz_dir.join("serve.token");
1578 std::fs::write(&masked_token_file, "secret").unwrap();
1579 let inputs = SandboxInputs {
1580 enforce: SandboxEnforce::Fs,
1581 session_cwd: gate.clone(),
1582 mission_dir: mission.clone(),
1583 tmpdir: scratch.clone(),
1584 extra_write: vec![extra.clone()],
1585 egress: Vec::new(),
1586 validator_read_deny_roots: Vec::new(),
1587 };
1588 let env: std::collections::HashMap<String, String> = [
1589 ("ZZZ_BASE".to_string(), "deadbeef".to_string()),
1590 ("AAA_FIRST".to_string(), "1".to_string()),
1591 ("CARGO_HOME".to_string(), "/scratch/cache-only".to_string()),
1592 ("PATH".to_string(), "/usr/bin:/bin".to_string()),
1593 ("HOME".to_string(), "/caller/home".to_string()),
1596 ("TMPDIR".to_string(), "/caller/tmp".to_string()),
1597 ("RUSTUP_HOME".to_string(), "/caller/rustup".to_string()),
1598 ("NPM_CONFIG_CACHE".to_string(), "/caller/npm".to_string()),
1599 ]
1600 .into_iter()
1601 .collect();
1602
1603 let args = container_gate_run_args(
1604 &inputs,
1605 &spec(),
1606 "cargo test --workspace",
1607 &env,
1608 "kranz-gate-test",
1609 );
1610 let joined = args.join(" ");
1611 let abs = |p: &std::path::Path| container_host_path(p);
1612
1613 assert!(args.contains(&"--read-only".to_string()));
1615 assert!(joined.contains(&mount_arg(&abs(&gate), false)));
1616 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1617 assert!(joined.contains(&mount_arg(&abs(&scratch), false)));
1618 assert!(joined.contains(&mount_arg(&abs(&extra), false)));
1619 assert!(joined.contains(&format!("-w {}", abs(&gate))));
1620 assert!(joined.contains(&format!("-e HOME={}", abs(&scratch))));
1621 assert!(joined.contains(&format!("-e TMPDIR={}", abs(&scratch))));
1622 assert!(
1623 joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz_dir)))
1624 && !joined.contains(&abs(&masked_token_file)),
1625 "authority material must stay outside the private directory: {args:?}"
1626 );
1627
1628 assert!(
1630 args.windows(2)
1631 .any(|w| w[0] == "--name" && w[1] == "kranz-gate-test"),
1632 "the gate container must carry the caller-chosen name: {args:?}"
1633 );
1634 assert!(
1635 joined.ends_with(&format!("{DEFAULT_IMAGE} sh -c cargo test --workspace")),
1636 "image then sh -c payload: {args:?}"
1637 );
1638
1639 let index_of = |needle: &str| {
1641 args.windows(2)
1642 .position(|w| w[0] == "-e" && w[1] == needle)
1643 .unwrap_or_else(|| panic!("missing -e {needle}: {args:?}"))
1644 };
1645 assert!(index_of("AAA_FIRST=1") < index_of("ZZZ_BASE=deadbeef"));
1646 index_of("CARGO_HOME=/scratch/cache-only");
1647 index_of("PATH=/usr/bin:/bin");
1648 for skipped in [
1651 "-e HOME=/caller/home",
1652 "-e TMPDIR=/caller/tmp",
1653 "-e RUSTUP_HOME=/caller/rustup",
1654 "-e NPM_CONFIG_CACHE=/caller/npm",
1655 ] {
1656 assert!(
1657 !joined.contains(skipped),
1658 "builder-owned env key must not be forwarded with the caller value: {skipped}\n{args:?}"
1659 );
1660 }
1661 }
1662
1663 #[test]
1671 fn container_gate_wrap_args_never_mounts_the_real_cargo_root() {
1672 let cargo = tempfile::tempdir().unwrap();
1673 std::fs::create_dir_all(cargo.path().join("bin")).unwrap();
1674 std::fs::write(cargo.path().join("credentials.toml"), "operator-secret").unwrap();
1675 let _guard = crate::agent_env::EnvTestGuard::engage(&[(
1676 "CARGO_HOME",
1677 cargo.path().to_str().expect("utf-8 temp path"),
1678 )]);
1679
1680 let dir = tempfile::tempdir().unwrap();
1681 let inputs = SandboxInputs {
1682 enforce: SandboxEnforce::Fs,
1683 session_cwd: dir.path().join("gate"),
1684 mission_dir: dir.path().join("mission"),
1685 tmpdir: dir.path().join("scratch"),
1686 extra_write: Vec::new(),
1687 egress: Vec::new(),
1688 validator_read_deny_roots: Vec::new(),
1689 };
1690 let env: std::collections::HashMap<String, String> =
1691 [("CARGO_HOME".to_string(), "/scratch/cache-only".to_string())]
1692 .into_iter()
1693 .collect();
1694 let args = container_gate_run_args(&inputs, &spec(), "true", &env, "kranz-gate-test");
1695 let joined = args.join(" ");
1696 let abs = |p: &std::path::Path| container_host_path(p);
1697
1698 let root = abs(cargo.path());
1699 let bin = abs(&cargo.path().join("bin"));
1700 assert!(
1701 joined.contains(&mount_arg(&bin, true)),
1702 "the shim dir must cross read-only: {args:?}"
1703 );
1704 assert!(
1705 !joined.contains(&mount_arg(&root, true)),
1706 "the credential-bearing Cargo root must NEVER be mounted: {args:?}"
1707 );
1708 assert!(
1709 !joined.contains(&format!("-e CARGO_HOME={root}")),
1710 "no -e may point CARGO_HOME at the real root: {args:?}"
1711 );
1712 assert!(
1713 joined.contains("-e CARGO_HOME=/scratch/cache-only"),
1714 "the caller's cache-only CARGO_HOME crosses instead: {args:?}"
1715 );
1716 }
1717
1718 #[test]
1725 fn container_gate_wrap_args_fs_net_empty_egress_disables_network() {
1726 let env = std::collections::HashMap::new();
1727 let fs_net = container_gate_run_args(
1728 &inputs(SandboxEnforce::FsNet),
1729 &spec(),
1730 "true",
1731 &env,
1732 "kranz-gate-test",
1733 );
1734 let network = fs_net
1735 .windows(2)
1736 .find(|w| w[0] == "--network")
1737 .expect("fs+net must pass a --network flag");
1738 assert_eq!(network[1], "none");
1739 assert!(
1740 fs_net
1741 .iter()
1742 .filter(|a| a.starts_with("HTTPS_PROXY="))
1743 .all(|a| a == "HTTPS_PROXY="),
1744 "offline gates must suppress inherited proxy configuration: {fs_net:?}"
1745 );
1746
1747 let fs = container_gate_run_args(
1748 &inputs(SandboxEnforce::Fs),
1749 &spec(),
1750 "true",
1751 &env,
1752 "kranz-gate-test",
1753 );
1754 assert!(
1755 !fs.iter().any(|a| a == "--network"),
1756 "fs must not restrict the network (runtime default bridge): {fs:?}"
1757 );
1758 }
1759
1760 #[test]
1761 fn container_run_args_respects_image_override() {
1762 let spec = ContainerSpec {
1763 runtime: ContainerRuntime::Podman,
1764 image: "ghcr.io/example/kranz-worker:1".to_string(),
1765 network: None,
1766 name: None,
1767 };
1768 let args = container_run_args(
1769 &inputs(SandboxEnforce::Fs),
1770 &spec,
1771 Path::new("claude"),
1772 &[],
1773 None,
1774 );
1775 assert!(
1776 args.iter().any(|a| a == "ghcr.io/example/kranz-worker:1"),
1777 "configured image must be used: {args:?}"
1778 );
1779 assert!(!args.iter().any(|a| a == DEFAULT_IMAGE));
1780 }
1781
1782 #[test]
1789 fn container_provider_runs_a_trivial_worker_and_enforces_the_write_boundary() {
1790 if !host_supports_container_contract() {
1791 crate::test_capability::skip(
1792 crate::test_capability::capability::CONTAINER,
1793 &container_contract_skip_detail(),
1794 );
1795 return;
1796 }
1797 let Some(runtime) = detect() else {
1798 crate::test_capability::skip(
1799 crate::test_capability::capability::CONTAINER,
1800 "no docker/podman/nerdctl/container on PATH",
1801 );
1802 return;
1803 };
1804
1805 let session = live_fixture();
1806 let mission = live_fixture();
1807 let scratch = live_fixture();
1808 let kranz_dir = session.path().join(".kranz");
1809 std::fs::create_dir_all(&kranz_dir).unwrap();
1810 std::fs::write(kranz_dir.join("serve.token"), "secret").unwrap();
1811 let inputs = SandboxInputs {
1812 enforce: SandboxEnforce::FsNet,
1813 session_cwd: session.path().to_path_buf(),
1814 mission_dir: mission.path().to_path_buf(),
1815 tmpdir: scratch.path().to_path_buf(),
1816 extra_write: Vec::new(),
1817 egress: Vec::new(),
1818 validator_read_deny_roots: Vec::new(),
1819 };
1820 let spec = ContainerSpec {
1821 runtime,
1822 image: DEFAULT_IMAGE.to_string(),
1823 network: None,
1824 name: None,
1825 };
1826 let ok_file = session.path().join("ok.txt");
1827 let args = container_run_args(
1828 &inputs,
1829 &spec,
1830 Path::new("sh"),
1831 &[
1832 "-c".to_string(),
1833 format!(
1834 "echo ok > {} && ! cat {} && echo nope > /etc/nope.txt",
1835 ok_file.display(),
1836 kranz_dir.join("serve.token").display()
1837 ),
1838 ],
1839 None,
1840 );
1841 let output = std::process::Command::new(runtime.binary())
1842 .args(&args)
1843 .stdin(std::process::Stdio::null())
1844 .output()
1845 .expect("failed to spawn container runtime");
1846
1847 assert!(
1848 ok_file.exists(),
1849 "write inside the mounted session_cwd must land on the host: {}",
1850 String::from_utf8_lossy(&output.stderr)
1851 );
1852 assert!(
1853 !output.status.success(),
1854 "write outside the declared policy (/etc) must be denied, failing the worker: {}",
1855 String::from_utf8_lossy(&output.stderr)
1856 );
1857 assert!(
1858 !String::from_utf8_lossy(&output.stdout).contains("secret"),
1859 "the /dev/null mask must hide serve.token content inside the container"
1860 );
1861 }
1862
1863 #[test]
1864 fn container_authority_directory_mask_covers_absent_and_future_tokens() {
1865 if crate::agent_env::isolated_global_home_test("sandbox_container::tests::container_authority_directory_mask_covers_absent_and_future_tokens") { return; }
1866 let home = tempfile::tempdir().unwrap();
1867 let _env = crate::agent_env::EnvTestGuard::engage(&[(
1868 if cfg!(windows) { "USERPROFILE" } else { "HOME" },
1869 home.path().to_str().unwrap(),
1870 )]);
1871 let global = home.path().join(".kranz");
1872 assert!(!global.exists());
1873 let mut inputs = inputs(SandboxEnforce::Fs);
1874 inputs.extra_write.extend([
1875 home.path().to_path_buf(),
1876 global.clone(),
1877 global.join("serve"),
1878 ]);
1879 for args in [
1880 container_run_args(&inputs, &spec(), Path::new("sh"), &[], None),
1881 container_gate_run_args(&inputs, &spec(), "true", &Default::default(), "test"),
1882 ] {
1883 assert!(
1884 args.windows(2).any(|pair| pair[0] == "--tmpfs"
1885 && pair[1]
1886 == format!(
1887 "{}:ro,noexec,nosuid,nodev,mode=755",
1888 container_host_path(&global)
1889 )),
1890 "authority mask missing: {args:?}"
1891 );
1892 assert!(
1893 !args
1894 .windows(2)
1895 .any(|pair| pair[0] == "-v"
1896 && pair[1].starts_with(&container_host_path(&global))),
1897 "nested mounts must not reopen global authority: {args:?}"
1898 );
1899 }
1900 assert!(!global.exists());
1902 }
1903
1904 #[cfg(unix)]
1905 #[test]
1906 fn container_authority_directory_hides_tokens_created_after_start() {
1907 if crate::agent_env::isolated_global_home_test("sandbox_container::tests::container_authority_directory_hides_tokens_created_after_start") { return; }
1908 use std::io::{BufRead as _, Write as _};
1909 let Some(runtime) = detect() else {
1910 eprintln!("no container runtime; skipping live authority test");
1911 return;
1912 };
1913 let dir = live_fixture();
1914 let home = dir.path().join("operator");
1915 let session = dir.path().join("session");
1916 let mission = session.join(".kranz/missions/m-test");
1917 let scratch = dir.path().join("scratch");
1918 std::fs::create_dir_all(&home).unwrap();
1919 let authority_target = dir.path().join("private-authority");
1920 std::fs::create_dir(&authority_target).unwrap();
1921 std::os::unix::fs::symlink(&authority_target, home.join(".kranz")).unwrap();
1922 std::fs::create_dir_all(&mission).unwrap();
1923 std::fs::create_dir(&scratch).unwrap();
1924 let authority = home.join(".kranz/serve/later.token");
1925 let global_config = home.join(".kranz/config.json");
1926 let cargo = home.join(".cargo");
1927 std::fs::create_dir(&cargo).unwrap();
1928 let repo_token_path = session.join(".kranz/serve.token");
1929 let repo_read_token_path = session.join(".kranz/serve.read.token");
1930 let repo_config = session.join(".kranz/config.json");
1931 let cargo_credentials = cargo.join("credentials.toml");
1932 let policy = session.join(".kranz/merge-gates.json");
1933 std::fs::write(&repo_token_path, "original-token").unwrap();
1934 std::fs::write(&policy, "visible-policy").unwrap();
1935 let input = SandboxInputs {
1936 enforce: SandboxEnforce::FsNet,
1937 session_cwd: session.clone(),
1938 mission_dir: mission,
1939 tmpdir: scratch,
1940 extra_write: vec![home.clone(), home.join(".kranz/serve")],
1941 egress: Vec::new(),
1942 validator_read_deny_roots: Vec::new(),
1943 };
1944 let args = {
1945 let _env = crate::agent_env::EnvTestGuard::engage(&[
1946 ("HOME", home.to_str().unwrap()),
1947 ("CARGO_HOME", cargo.to_str().unwrap()),
1948 ]);
1949 container_run_args(
1950 &input,
1951 &ContainerSpec {
1952 runtime,
1953 network: None,
1954 name: None,
1955 image: DEFAULT_IMAGE.to_string(),
1956 },
1957 Path::new("sh"),
1958 &[
1959 "-c".to_string(),
1960 "printf 'ready\\n'; read -r proceed; test -s \"$1\" || exit 2; \
1961 for secret in \"$2\" \"$3\" \"$4\" \"$5\" \"$6\" \"$7\"; do \
1962 if cat \"$secret\"; then exit 3; fi; \
1963 if printf forged > \"$secret\"; then exit 4; fi; done; \
1964 if rm \"$9\"; then exit 5; fi; \
1965 test \"$(cat \"$8\")\" = visible-policy || exit 8; \
1966 printf work > \"$1-worker\""
1967 .to_string(),
1968 "test".to_string(),
1969 session.join("host-witness").display().to_string(),
1970 authority.display().to_string(),
1971 global_config.display().to_string(),
1972 repo_token_path.display().to_string(),
1973 repo_read_token_path.display().to_string(),
1974 repo_config.display().to_string(),
1975 cargo_credentials.display().to_string(),
1976 policy.display().to_string(),
1977 home.join(".kranz").display().to_string(),
1978 ],
1979 None,
1980 )
1981 };
1982 let _env = crate::agent_env::EnvTestGuard::engage(&[]);
1984 let mut child = std::process::Command::new(runtime.binary())
1985 .args(args)
1986 .stdin(std::process::Stdio::piped())
1987 .stdout(std::process::Stdio::piped())
1988 .stderr(std::process::Stdio::piped())
1989 .spawn()
1990 .unwrap();
1991 let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
1992 let mut line = String::new();
1993 stdout.read_line(&mut line).unwrap();
1994 if line != "ready\n" {
1995 let _ = child.kill();
1996 let output = child.wait_with_output().unwrap();
1997 panic!(
1998 "container did not start: {line:?}: {}",
1999 String::from_utf8_lossy(&output.stderr)
2000 );
2001 }
2002 std::fs::create_dir(authority.parent().unwrap()).unwrap();
2005 std::fs::write(&authority, "fake-authority").unwrap();
2006 std::fs::write(&global_config, "fake-config").unwrap();
2007 for path in [&repo_read_token_path, &repo_config, &cargo_credentials] {
2008 assert!(
2009 !path.exists(),
2010 "mount setup created a placeholder credential"
2011 );
2012 std::fs::write(path, "fake-authority").unwrap();
2013 }
2014 let rotated = session.join(".kranz/rotated.tmp");
2015 std::fs::write(&rotated, "rotated-token").unwrap();
2016 std::fs::rename(rotated, &repo_token_path).unwrap();
2017 std::fs::write(session.join("host-witness"), "visible").unwrap();
2018 child
2019 .stdin
2020 .take()
2021 .unwrap()
2022 .write_all(b"continue\n")
2023 .unwrap();
2024 let output = child.wait_with_output().unwrap();
2025 assert!(output.status.success(), "{output:?}");
2026 assert!(session.join("host-witness-worker").exists());
2027 assert!(
2028 home.join(".kranz").is_symlink(),
2029 "authority alias was replaced"
2030 );
2031 assert_eq!(
2032 std::fs::read_to_string(repo_token_path).unwrap(),
2033 "rotated-token"
2034 );
2035 for path in [&repo_read_token_path, &repo_config, &cargo_credentials] {
2036 assert_eq!(std::fs::read_to_string(path).unwrap(), "fake-authority");
2037 }
2038 assert_eq!(
2039 std::fs::read_to_string(global_config).unwrap(),
2040 "fake-config"
2041 );
2042 }
2043}
2044
2045#[cfg(test)]
2046mod git_mount_tests {
2047 use super::*;
2048
2049 #[test]
2050 fn git_config_mount_nodes_preserve_existing_readonly_destinations() {
2051 let root = tempfile::tempdir().unwrap();
2052 let root = crate::sandbox::absolutize(root.path());
2053 let git = root.join(".git");
2054 std::fs::create_dir(&git).unwrap();
2055 std::fs::write(git.join("config"), "[core]\nrepositoryformatversion = 0\n").unwrap();
2056 let inputs = SandboxInputs {
2057 enforce: crate::types::SandboxEnforce::Fs,
2058 session_cwd: root.clone(),
2059 mission_dir: root.join(".kranz/missions/m-fixture"),
2060 tmpdir: root.join("scratch"),
2061 extra_write: Vec::new(),
2062 egress: Vec::new(),
2063 validator_read_deny_roots: Vec::new(),
2064 };
2065 let root = container_host_path(&root);
2066 let git = container_host_path(&git);
2067 let mut args = vec![
2068 "-v".into(),
2069 mount_arg(&root, false),
2070 "-v".into(),
2071 mount_arg(&git, true),
2072 ];
2073 push_authority_masks(&mut args, &inputs);
2074 let duplicates = args
2075 .windows(2)
2076 .filter(|part| {
2077 part[0] == "-v"
2078 && (part[1] == mount_arg(&git, false) || part[1] == mount_arg(&git, true))
2079 })
2080 .count();
2081 assert_eq!(duplicates, 1, "{args:?}");
2082 assert!(args
2083 .windows(2)
2084 .any(|part| part[0] == "-v" && part[1] == mount_arg(&git, true)));
2085 }
2086}