1use std::collections::HashMap;
64use std::path::{Path, PathBuf};
65use std::sync::{Mutex, OnceLock};
66use std::time::Duration;
67
68use crate::sandbox::SandboxInputs;
69
70pub const DEFAULT_IMAGE: &str = "alpine:3";
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum ContainerRuntime {
78 Docker,
79 Podman,
80 Nerdctl,
81 AppleContainer,
84}
85
86impl ContainerRuntime {
87 const PREFERENCE_ORDER: &'static [ContainerRuntime] = &[
89 ContainerRuntime::Docker,
90 ContainerRuntime::Podman,
91 ContainerRuntime::Nerdctl,
92 ContainerRuntime::AppleContainer,
93 ];
94
95 pub fn binary(self) -> &'static str {
97 match self {
98 ContainerRuntime::Docker => "docker",
99 ContainerRuntime::Podman => "podman",
100 ContainerRuntime::Nerdctl => "nerdctl",
101 ContainerRuntime::AppleContainer => "container",
102 }
103 }
104
105 pub(crate) fn client_env(self) -> std::collections::HashMap<String, String> {
109 let mut keys = vec![
110 "PATH",
111 "HOME",
112 "USER",
113 "LOGNAME",
114 "LANG",
115 "LC_ALL",
116 "LC_CTYPE",
117 "TMPDIR",
118 "XDG_CONFIG_HOME",
119 "XDG_RUNTIME_DIR",
120 "SSH_AUTH_SOCK",
121 "USERPROFILE",
122 "SystemRoot",
123 "ComSpec",
124 "APPDATA",
125 "LOCALAPPDATA",
126 "TEMP",
127 "TMP",
128 ];
129 match self {
130 Self::Docker => keys.extend([
131 "DOCKER_HOST",
132 "DOCKER_CONTEXT",
133 "DOCKER_CONFIG",
134 "DOCKER_TLS",
135 "DOCKER_TLS_VERIFY",
136 "DOCKER_CERT_PATH",
137 "DOCKER_API_VERSION",
138 ]),
139 Self::Podman => {
140 keys.extend(["CONTAINER_HOST", "CONTAINER_CONNECTION", "CONTAINER_SSHKEY"])
141 }
142 Self::Nerdctl => {
143 keys.extend(["CONTAINERD_ADDRESS", "CONTAINERD_NAMESPACE", "NERDCTL_TOML"])
144 }
145 Self::AppleContainer => {}
146 }
147 keys.into_iter()
148 .filter_map(|key| {
149 std::env::var(key)
150 .ok()
151 .map(|value| (key.to_string(), value))
152 })
153 .collect()
154 }
155}
156
157pub fn detect() -> Option<ContainerRuntime> {
159 detect_with(crate::sandbox::command_available)
160}
161
162pub fn host_supports_container_contract() -> bool {
186 if cfg!(target_os = "linux") {
187 return true;
188 }
189 if cfg!(target_os = "windows") {
190 return false;
191 }
192 let Some(runtime) = detect() else {
193 return false;
194 };
195 matches!(host_mount_contract_proof(runtime), MountProof::Proven)
196}
197
198pub fn host_mount_contract_proof(runtime: ContainerRuntime) -> MountProof {
202 let cwd = std::env::current_dir().unwrap_or_else(|_| std::env::temp_dir());
203 for root in [cwd.as_path(), std::env::temp_dir().as_path()] {
204 match cached_bind_mount_proof(runtime, root, DEFAULT_IMAGE) {
205 MountProof::Proven => {}
206 failed => return failed,
207 }
208 }
209 MountProof::Proven
210}
211
212pub const MOUNT_PROOF_GUEST_DIR: &str = "/kranz-mount-proof";
214
215const MOUNT_PROOF_TIMEOUT: Duration = Duration::from_secs(90);
217
218#[derive(Debug, Clone, PartialEq, Eq)]
235pub enum MountProof {
236 Proven,
239 Failed(String),
241}
242
243pub fn mount_proof_argv(host_dir: &Path, image: &str, guest_sentinel: &str) -> Vec<String> {
247 vec![
248 "run".to_string(),
249 "--rm".to_string(),
250 "-v".to_string(),
251 format!(
252 "{}:{MOUNT_PROOF_GUEST_DIR}",
253 container_host_path(host_dir)
254 ),
255 image.to_string(),
256 "sh".to_string(),
257 "-c".to_string(),
258 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}
270
271pub fn prove_bind_mount(runtime: ContainerRuntime, host_dir: &Path, image: &str) -> MountProof {
278 let probe = host_dir.join(format!(
279 "kranz-mount-proof-{}",
280 uuid::Uuid::new_v4().simple()
281 ));
282 if let Err(error) = std::fs::create_dir_all(&probe) {
283 return MountProof::Failed(format!(
284 "could not create the mount probe directory {}: {error}",
285 probe.display()
286 ));
287 }
288 let host_sentinel = uuid::Uuid::new_v4().simple().to_string();
289 let guest_sentinel = uuid::Uuid::new_v4().simple().to_string();
290 let proof = run_mount_proof(
291 runtime,
292 host_dir,
293 &probe,
294 image,
295 &host_sentinel,
296 &guest_sentinel,
297 );
298 let _ = std::fs::remove_dir_all(&probe);
299 proof
300}
301
302fn run_mount_proof(
303 runtime: ContainerRuntime,
304 host_dir: &Path,
305 probe: &Path,
306 image: &str,
307 host_sentinel: &str,
308 guest_sentinel: &str,
309) -> MountProof {
310 if let Err(error) = std::fs::write(probe.join("host.txt"), host_sentinel) {
311 return MountProof::Failed(format!(
312 "could not write the host sentinel in {}: {error}",
313 probe.display()
314 ));
315 }
316 let argv = mount_proof_argv(probe, image, guest_sentinel);
317 let Some(output) = crate::command_exec::run_with_timeout(
318 Path::new(runtime.binary()),
319 &argv,
320 MOUNT_PROOF_TIMEOUT,
321 ) else {
322 return MountProof::Failed(format!(
323 "the {} mount proof did not finish within {}s: {}",
324 runtime.binary(),
325 MOUNT_PROOF_TIMEOUT.as_secs(),
326 argv.join(" ")
327 ));
328 };
329 let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
330 if !output.status.success() {
331 return MountProof::Failed(format!(
332 "the {} mount proof exited {:?}: {}",
333 runtime.binary(),
334 output.status.code(),
335 String::from_utf8_lossy(&output.stderr).trim()
336 ));
337 }
338 if stdout != host_sentinel {
339 return MountProof::Failed(unshared_path_reason(
340 runtime,
341 host_dir,
342 "the host sentinel was not visible inside the container",
343 ));
344 }
345 match std::fs::read_to_string(probe.join("guest.txt")) {
346 Ok(written) if written.trim() == guest_sentinel => MountProof::Proven,
347 Ok(_) | Err(_) => MountProof::Failed(unshared_path_reason(
348 runtime,
349 host_dir,
350 "the container's write did not reach the host",
351 )),
352 }
353}
354
355fn unshared_path_reason(runtime: ContainerRuntime, host_dir: &Path, symptom: &str) -> String {
359 let mut reason = format!(
360 "{} accepted a bind mount of {} and shared nothing: {symptom}. \
361 The runtime's daemon cannot see this host path, so the declared write set would \
362 not exist inside the container and a worker's output would be lost silently. \
363 Share this path with the runtime (Colima mounts only the home directory by \
364 default: `colima start --mount {}:w`; Docker Desktop keeps its own file-sharing \
365 list)",
366 runtime.binary(),
367 host_dir.display(),
368 host_dir.display()
369 );
370 if host_dir == crate::backend_claude::scratch_root_base() {
373 reason.push_str(&format!(
374 ", or move kranz's own scratch to a directory the runtime already shares by \
375 setting {}=<path> (this root is scratch, not your workspace)",
376 crate::backend_claude::SCRATCH_ROOT_ENV
377 ));
378 } else {
379 reason.push_str(" or point the mission's workspace at a path it already shares");
380 }
381 reason
382}
383
384fn proof_cache() -> &'static Mutex<HashMap<(String, String), MountProof>> {
390 static CACHE: OnceLock<Mutex<HashMap<(String, String), MountProof>>> = OnceLock::new();
391 CACHE.get_or_init(|| Mutex::new(HashMap::new()))
392}
393
394pub fn cached_bind_mount_proof(
396 runtime: ContainerRuntime,
397 host_dir: &Path,
398 image: &str,
399) -> MountProof {
400 let key = (
401 runtime.binary().to_string(),
402 host_dir.to_string_lossy().into_owned(),
403 );
404 if let Ok(cache) = proof_cache().lock() {
405 if let Some(proof) = cache.get(&key) {
406 return proof.clone();
407 }
408 }
409 let proof = prove_bind_mount(runtime, host_dir, image);
410 if let Ok(mut cache) = proof_cache().lock() {
411 cache.insert(key, proof.clone());
412 }
413 proof
414}
415
416pub fn prove_mount_roots(runtime: ContainerRuntime, roots: &[PathBuf], image: &str) -> MountProof {
429 let mut seen = Vec::new();
430 for root in roots {
431 if root.as_os_str().is_empty() || seen.iter().any(|prior| prior == root) {
432 continue;
433 }
434 seen.push(root.clone());
435 match cached_bind_mount_proof(runtime, root, image) {
436 MountProof::Proven => {}
437 failed => return failed,
438 }
439 }
440 MountProof::Proven
441}
442
443pub fn declared_mount_roots(
452 session_cwd: &Path,
453 mission_dir: &Path,
454 extra_write: &[PathBuf],
455) -> Vec<PathBuf> {
456 let mut roots = vec![
457 session_cwd.parent().unwrap_or(session_cwd).to_path_buf(),
458 mission_dir.to_path_buf(),
459 crate::backend_claude::scratch_root_base(),
464 ];
465 roots.extend(extra_write.iter().cloned());
466 roots
467}
468
469pub fn container_contract_skip_detail() -> String {
476 if cfg!(target_os = "windows") {
477 return "the container provider refuses Windows: POSIX guest paths, Linux images, \
478 and /dev/null authority masks are not honored there"
479 .to_string();
480 }
481 match detect() {
482 None => "no docker/podman/nerdctl/container on PATH".to_string(),
483 Some(runtime) => match host_mount_contract_proof(runtime) {
484 MountProof::Proven => {
485 "the host contract is supported; this skip should not have fired".to_string()
486 }
487 MountProof::Failed(reason) => reason,
488 },
489 }
490}
491
492pub fn detect_with(lookup: impl Fn(&str) -> bool) -> Option<ContainerRuntime> {
494 ContainerRuntime::PREFERENCE_ORDER
495 .iter()
496 .copied()
497 .find(|runtime| lookup(runtime.binary()))
498}
499
500#[derive(Debug, Clone, PartialEq, Eq)]
502pub struct ContainerSpec {
503 pub runtime: ContainerRuntime,
504 pub image: String,
505 pub network: Option<String>,
509 pub name: Option<String>,
513}
514
515fn mount_arg(host_abs: &str, read_only: bool) -> String {
531 format!(
532 "{host_abs}:{host_abs}{}",
533 if read_only { ":ro" } else { "" }
534 )
535}
536
537fn container_host_path(path: &Path) -> String {
552 let absolute = crate::sandbox::absolutize(path);
553 let rendered = absolute.as_os_str().to_string_lossy();
554 #[cfg(windows)]
555 if let Some(rest) = rendered.strip_prefix(r"\\?\") {
556 if !rest.starts_with("UNC") {
557 return rest.to_string();
558 }
559 }
560 rendered.into_owned()
561}
562
563const CONTAINER_PIDS_LIMIT: &str = "512";
568
569fn run_prologue(inputs: &SandboxInputs) -> Vec<String> {
590 let mut out = vec![
591 "run".to_string(),
592 "--rm".to_string(),
593 "-i".to_string(),
594 "--read-only".to_string(),
595 "--cap-drop".to_string(),
596 "ALL".to_string(),
597 "--security-opt".to_string(),
598 "no-new-privileges".to_string(),
599 "--pids-limit".to_string(),
600 CONTAINER_PIDS_LIMIT.to_string(),
601 ];
602 if let Some(owner) = crate::container_egress::mount_owner(&inputs.session_cwd) {
603 out.push("--user".to_string());
604 out.push(owner);
605 }
606 for key in [
609 "HTTP_PROXY",
610 "HTTPS_PROXY",
611 "FTP_PROXY",
612 "ALL_PROXY",
613 "NO_PROXY",
614 "http_proxy",
615 "https_proxy",
616 "ftp_proxy",
617 "all_proxy",
618 "no_proxy",
619 ] {
620 out.extend(["-e".to_string(), format!("{key}=")]);
621 }
622 out
623}
624
625fn push_policy_mounts(out: &mut Vec<String>, inputs: &SandboxInputs) {
635 let mut mounts: Vec<(String, bool)> = Vec::new();
636 let denied_dirs: Vec<_> = crate::sandbox::authority_read_deny_dirs(inputs)
637 .iter()
638 .map(|path| crate::sandbox::absolutize(path))
639 .collect();
640 let denied_files: Vec<_> = crate::sandbox::authority_read_deny_paths(inputs)
641 .iter()
642 .map(|path| crate::sandbox::absolutize(path))
643 .collect();
644 let mut add_mount = |path: &Path, ro: bool| {
645 let path = crate::sandbox::absolutize(path);
646 if denied_dirs.iter().any(|dir| path.starts_with(dir))
649 || denied_files.iter().any(|file| path.starts_with(file))
650 {
651 return;
652 }
653 let host = container_host_path(&path);
654 if !mounts.iter().any(|(existing, _)| existing == &host) {
655 mounts.push((host, ro));
656 }
657 };
658 add_mount(&inputs.session_cwd, false);
659 if let Some(missions) = inputs
660 .mission_dir
661 .parent()
662 .filter(|path| path.ends_with("missions") && path.is_dir())
663 {
664 add_mount(missions, true);
667 }
668 add_mount(&inputs.mission_dir, true);
669 add_mount(&inputs.tmpdir, false);
670 for extra in &inputs.extra_write {
671 if inputs
672 .mission_dir
673 .parent()
674 .filter(|p| p.ends_with("missions"))
675 .is_some_and(|missions| {
676 crate::sandbox::absolutize(extra).starts_with(crate::sandbox::absolutize(missions))
677 })
678 {
679 continue;
680 }
681 add_mount(extra, false);
682 }
683 for (host, ro) in mounts {
684 out.push("-v".to_string());
685 out.push(mount_arg(&host, ro));
686 }
687}
688
689fn under_writable_mount(path: &Path, inputs: &SandboxInputs) -> bool {
696 let candidate = crate::sandbox::absolutize(path);
697 std::iter::once(&inputs.session_cwd)
698 .chain(std::iter::once(&inputs.tmpdir))
699 .chain(inputs.extra_write.iter())
700 .any(|root| candidate.starts_with(crate::sandbox::absolutize(root)))
701}
702
703fn push_authority_masks(out: &mut Vec<String>, inputs: &SandboxInputs) {
707 for node in crate::sandbox::git_metadata_mount_nodes(inputs) {
712 let node = container_host_path(&node);
713 if !out.windows(2).any(|pair| {
714 pair[0] == "-v"
715 && (pair[1] == mount_arg(&node, false) || pair[1] == mount_arg(&node, true))
716 }) {
717 out.extend(["-v".to_string(), mount_arg(&node, false)]);
718 }
719 }
720 let masks: Vec<_> = crate::sandbox::authority_directory_masks(inputs)
721 .into_iter()
722 .filter(|mask| {
723 mask.path.ancestors().any(|ancestor| {
724 let path = container_host_path(ancestor);
725 out.windows(2).any(|pair| {
726 pair[0] == "-v"
727 && (pair[1] == mount_arg(&path, false) || pair[1] == mount_arg(&path, true))
728 })
729 })
730 })
731 .collect();
732 let masked_paths: std::collections::BTreeSet<_> =
733 masks.iter().map(|mask| mask.path.clone()).collect();
734 let mut filtered = Vec::new();
739 let mut index = 0;
740 while index < out.len() {
741 if out[index] == "-v" && index + 1 < out.len() {
742 let mount = &out[index + 1];
743 if masks.iter().any(|mask| {
744 let path = container_host_path(&mask.path);
745 mount == &mount_arg(&path, false) || mount == &mount_arg(&path, true)
746 }) {
747 index += 2;
748 continue;
749 }
750 }
751 filtered.push(out[index].clone());
752 index += 1;
753 }
754 *out = filtered;
755 for mask in &masks {
756 out.push("--tmpfs".to_string());
757 out.push(format!(
758 "{}:ro,noexec,nosuid,nodev,mode=755",
759 container_host_path(&mask.path)
760 ));
761 for path in &mask.visible_entries {
762 if masked_paths.contains(path) {
765 continue;
766 }
767 let path = container_host_path(path);
768 if !out.windows(2).any(|pair| {
771 pair[0] == "-v"
772 && (pair[1] == mount_arg(&path, false) || pair[1] == mount_arg(&path, true))
773 }) {
774 out.push("-v".to_string());
775 out.push(mount_arg(&path, true));
776 }
777 }
778 }
779
780 let writes = crate::sandbox::authority_write_denies(inputs);
783 let git = crate::sandbox::git_metadata_write_denies(inputs);
784 for path in writes
785 .files
786 .iter()
787 .chain(writes.dirs.iter())
788 .chain(git.files.iter().filter(|path| path.is_file()))
789 .chain(git.dirs.iter())
790 {
791 if !under_writable_mount(path, inputs)
792 || path.is_symlink()
793 || !path.exists()
794 || masks
795 .iter()
796 .any(|mask| crate::sandbox::absolutize(path).starts_with(&mask.path))
797 {
798 continue;
799 }
800 let host = container_host_path(path);
801 if !out
802 .windows(2)
803 .any(|pair| pair[0] == "-v" && pair[1] == mount_arg(&host, true))
804 {
805 out.extend(["-v".to_string(), mount_arg(&host, true)]);
806 }
807 }
808}
809
810fn push_workdir_and_scratch_env(out: &mut Vec<String>, inputs: &SandboxInputs) {
814 out.push("-w".to_string());
818 out.push(container_host_path(&inputs.session_cwd));
819 let scratch = container_host_path(&inputs.tmpdir);
820 out.push("-e".to_string());
821 out.push(format!("HOME={scratch}"));
822 out.push("-e".to_string());
823 out.push(format!("TMPDIR={scratch}"));
824}
825
826#[derive(Debug, Clone, Copy, PartialEq, Eq)]
828enum ToolchainMount {
829 Session,
841 Gate,
851}
852
853fn push_toolchain_caches(out: &mut Vec<String>, mode: ToolchainMount) {
861 let global = crate::sandbox::global_authority_dir();
862 for (var, default_subdir) in [
863 ("RUSTUP_HOME", ".rustup"),
864 ("CARGO_HOME", ".cargo"),
865 ("NPM_CONFIG_CACHE", ".npm"),
866 ] {
867 let host = std::env::var_os(var)
868 .map(std::path::PathBuf::from)
869 .or_else(|| {
870 std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(default_subdir))
871 });
872 if let Some(host) = host {
873 if global
874 .as_ref()
875 .is_some_and(|dir| crate::sandbox::absolutize(&host).starts_with(dir))
876 {
877 continue;
878 }
879 if var == "CARGO_HOME" {
880 let leaves: &[&str] = match mode {
896 ToolchainMount::Gate => &["bin"],
897 ToolchainMount::Session => &["bin", "registry", "git"],
898 };
899 let mut mounted_any = false;
900 for leaf in leaves {
901 let dir = host.join(leaf);
902 if dir.is_dir() {
903 let mounted = container_host_path(&dir);
904 out.push("-v".to_string());
905 out.push(mount_arg(&mounted, true));
906 mounted_any = true;
907 }
908 }
909 if mode == ToolchainMount::Session && mounted_any {
910 out.push("-e".to_string());
911 out.push(format!("CARGO_HOME={}", container_host_path(&host)));
912 }
913 continue;
914 }
915 if host.is_dir() {
916 let mounted = container_host_path(&host);
917 out.push("-v".to_string());
918 out.push(mount_arg(&mounted, true));
919 out.push("-e".to_string());
920 out.push(format!("{var}={mounted}"));
921 }
922 }
923 }
924}
925
926fn push_network(out: &mut Vec<String>, inputs: &SandboxInputs, proxy_url: Option<&str>) {
933 if inputs.enforce == crate::types::SandboxEnforce::FsNet {
934 if inputs.egress.is_empty() {
935 out.push("--network".to_string());
936 out.push("none".to_string());
937 } else if let Some(proxy_url) = proxy_url {
938 out.push("-e".to_string());
939 out.push(format!(
940 "{}={proxy_url}",
941 crate::egress_proxy::HTTPS_PROXY_ENV
942 ));
943 out.push("-e".to_string());
944 out.push(format!(
945 "{}={proxy_url}",
946 crate::egress_proxy::HTTP_PROXY_ENV
947 ));
948 out.push("-e".to_string());
949 out.push(format!(
950 "{}={}",
951 crate::egress_proxy::NO_PROXY_ENV,
952 crate::egress_proxy::NO_PROXY_VALUE
953 ));
954 }
955 }
956}
957
958pub fn container_run_args(
959 inputs: &SandboxInputs,
960 spec: &ContainerSpec,
961 binary: &Path,
962 args: &[String],
963 proxy_url: Option<&str>,
964) -> Vec<String> {
965 let mut out = run_prologue(inputs);
966 if let Some(name) = &spec.name {
967 out.push("--name".to_string());
968 out.push(name.clone());
969 }
970 push_policy_mounts(&mut out, inputs);
971 push_workdir_and_scratch_env(&mut out, inputs);
972 push_toolchain_caches(&mut out, ToolchainMount::Session);
973 push_authority_masks(&mut out, inputs);
974 if inputs.enforce == crate::types::SandboxEnforce::FsNet && !inputs.egress.is_empty() {
975 if let (Some(network), Some(_)) = (&spec.network, proxy_url) {
976 out.push("--network".to_string());
977 out.push(network.clone());
978 push_network(&mut out, inputs, proxy_url);
979 } else {
980 out.push("--network".to_string());
983 out.push("none".to_string());
984 }
985 } else {
986 push_network(&mut out, inputs, proxy_url);
987 }
988 out.push(spec.image.clone());
989 out.push(binary.display().to_string());
990 out.extend(args.iter().cloned());
991 out
992}
993
994const GATE_FORWARD_ENV_SKIP: &[&str] = &[
1002 "HOME",
1003 "TMPDIR",
1004 "TMP",
1005 "TEMP",
1006 "RUSTUP_HOME",
1007 "NPM_CONFIG_CACHE",
1008];
1009
1010pub fn container_gate_run_args(
1045 inputs: &SandboxInputs,
1046 spec: &ContainerSpec,
1047 command: &str,
1048 env: &std::collections::HashMap<String, String>,
1049 container_name: &str,
1050) -> Vec<String> {
1051 let mut out = run_prologue(inputs);
1052 out.push("--name".to_string());
1053 out.push(container_name.to_string());
1054 push_policy_mounts(&mut out, inputs);
1055 push_workdir_and_scratch_env(&mut out, inputs);
1056 push_toolchain_caches(&mut out, ToolchainMount::Gate);
1057 push_authority_masks(&mut out, inputs);
1058 push_network(&mut out, inputs, None);
1059 let mut forwarded: Vec<(&String, &String)> = env.iter().collect();
1060 forwarded.sort_by_key(|(key, _)| *key);
1061 for (key, value) in forwarded {
1062 if GATE_FORWARD_ENV_SKIP.contains(&key.as_str()) {
1063 continue;
1064 }
1065 out.push("-e".to_string());
1066 out.push(format!("{key}={value}"));
1067 }
1068 out.push(spec.image.clone());
1069 out.push("sh".to_string());
1070 out.push("-c".to_string());
1071 out.push(command.to_string());
1072 out
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use super::*;
1078 use crate::sandbox::SandboxInputs;
1079 use crate::types::SandboxEnforce;
1080 use std::path::PathBuf;
1081
1082 #[test]
1083 fn declared_roots_follow_the_scratch_override_not_the_temp_dir() {
1084 let case =
1085 "sandbox_container::tests::declared_roots_follow_the_scratch_override_not_the_temp_dir";
1086 if std::env::var("KRANZ_SCRATCH_TEST_CASE").as_deref() != Ok(case) {
1087 let shared = tempfile::tempdir().unwrap();
1088 let output = std::process::Command::new(std::env::current_exe().unwrap())
1089 .args([case, "--exact", "--nocapture"])
1090 .env("KRANZ_SCRATCH_TEST_CASE", case)
1091 .env(crate::backend_claude::SCRATCH_ROOT_ENV, shared.path())
1092 .output()
1093 .unwrap();
1094 assert!(
1095 output.status.success(),
1096 "{}",
1097 String::from_utf8_lossy(&output.stderr)
1098 );
1099 assert!(String::from_utf8_lossy(&output.stdout).contains("test result: ok. 1 passed;"));
1100 return;
1101 }
1102 let checkout = std::path::Path::new("/repos/app/worktree");
1103 let mission = std::path::Path::new("/repos/app/.kranz/missions/m-1");
1104 let shared =
1105 PathBuf::from(std::env::var_os(crate::backend_claude::SCRATCH_ROOT_ENV).unwrap());
1106 let roots = declared_mount_roots(checkout, mission, &[]);
1107
1108 assert!(roots.contains(&shared), "{roots:?}");
1112 assert!(!roots.contains(&std::env::temp_dir()), "{roots:?}");
1113 assert!(
1114 roots.contains(&std::path::PathBuf::from("/repos/app")),
1115 "the checkout's parent is mounted, not the worktree itself: {roots:?}"
1116 );
1117 }
1118
1119 #[test]
1120 fn mount_proof_argv_reads_the_host_sentinel_and_writes_the_guest_one() {
1121 let host = std::env::temp_dir();
1127 let argv = mount_proof_argv(&host, "alpine:3", "guestsentinel");
1128 let rendered = argv.join(" ");
1129 let expected_mount = format!("{}:/kranz-mount-proof", container_host_path(&host));
1130 assert!(rendered.contains(&expected_mount), "{rendered}");
1131 assert!(!expected_mount.starts_with(r"\\?\"), "{expected_mount}");
1132 assert!(
1135 rendered.contains("cat /kranz-mount-proof/host.txt"),
1136 "{rendered}"
1137 );
1138 assert!(
1139 rendered.contains("printf %s guestsentinel > /kranz-mount-proof/guest.txt"),
1140 "{rendered}"
1141 );
1142 assert!(rendered.contains("no-host-sentinel"), "{rendered}");
1145 assert!(rendered.starts_with("run --rm "), "{rendered}");
1146 }
1147
1148 #[test]
1149 fn live_bind_mount_round_trip_closes_under_the_checkout() {
1150 if cfg!(target_os = "windows") {
1153 crate::test_capability::skip(
1154 crate::test_capability::capability::CONTAINER,
1155 "the container provider refuses Windows, so a bind-mount probe proves nothing",
1156 );
1157 return;
1158 }
1159 let Some(runtime) = detect() else {
1160 crate::test_capability::skip(
1161 crate::test_capability::capability::CONTAINER,
1162 "no container runtime on PATH, so the bind-mount round trip cannot be proven",
1163 );
1164 return;
1165 };
1166 let checkout = std::env::current_dir().expect("a working directory");
1169 let root = checkout.parent().unwrap_or(&checkout);
1170 match prove_bind_mount(runtime, root, DEFAULT_IMAGE) {
1171 MountProof::Proven => {}
1172 MountProof::Failed(reason) => panic!(
1173 "the bind-mount round trip under {} did not close, so a mission's \
1174 declared write set cannot be trusted here: {reason}",
1175 root.display()
1176 ),
1177 }
1178 }
1179
1180 #[test]
1181 fn detect_prefers_docker_then_podman_then_nerdctl_then_apple_container() {
1182 assert_eq!(detect_with(|_| false), None);
1183 assert_eq!(
1184 detect_with(|name| name == "container"),
1185 Some(ContainerRuntime::AppleContainer)
1186 );
1187 assert_eq!(
1188 detect_with(|name| name == "nerdctl" || name == "container"),
1189 Some(ContainerRuntime::Nerdctl)
1190 );
1191 assert_eq!(
1192 detect_with(|name| name == "podman" || name == "nerdctl"),
1193 Some(ContainerRuntime::Podman)
1194 );
1195 assert_eq!(
1196 detect_with(|name| name == "docker" || name == "podman"),
1197 Some(ContainerRuntime::Docker)
1198 );
1199 }
1200
1201 fn inputs(enforce: SandboxEnforce) -> SandboxInputs {
1202 SandboxInputs {
1203 enforce,
1204 session_cwd: PathBuf::from("/work/session"),
1205 mission_dir: PathBuf::from("/work/mission"),
1206 tmpdir: PathBuf::from("/work/scratch"),
1207 extra_write: vec![PathBuf::from("/home/op/.cargo")],
1208 egress: Vec::new(),
1209 validator_read_deny_roots: Vec::new(),
1210 }
1211 }
1212
1213 fn spec() -> ContainerSpec {
1214 ContainerSpec {
1215 runtime: ContainerRuntime::Docker,
1216 image: DEFAULT_IMAGE.to_string(),
1217 network: None,
1218 name: None,
1219 }
1220 }
1221
1222 fn live_fixture() -> tempfile::TempDir {
1223 tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap()
1227 }
1228
1229 #[test]
1230 fn container_run_args_fs_net_with_empty_egress_disables_network() {
1231 let args = container_run_args(
1232 &inputs(SandboxEnforce::FsNet),
1233 &spec(),
1234 Path::new("claude"),
1235 &["-p".to_string(), "hi".to_string()],
1236 None,
1237 );
1238 let network = args
1239 .windows(2)
1240 .find(|w| w[0] == "--network")
1241 .expect("fs+net must pass a --network flag");
1242 assert_eq!(network[1], "none");
1243 }
1244
1245 #[test]
1246 fn container_run_args_fs_net_with_egress_uses_internal_network_and_relay_env() {
1247 let mut inputs = inputs(SandboxEnforce::FsNet);
1248 inputs.egress = vec!["crates.io:443".to_string()];
1249 let mut spec = spec();
1250 spec.network = Some("kranz-egress-test".to_string());
1251 spec.name = Some("kranz-egress-worker-test".to_string());
1252 let args = container_run_args(
1253 &inputs,
1254 &spec,
1255 Path::new("claude"),
1256 &["-p".to_string(), "hi".to_string()],
1257 Some("http://kranz-egress:3128"),
1258 );
1259
1260 assert!(
1261 args.windows(2)
1262 .any(|w| w[0] == "--network" && w[1] == "kranz-egress-test"),
1263 "proxy-routed fs+net must use the per-run internal network: {args:?}"
1264 );
1265 assert!(
1266 args.windows(2)
1267 .any(|w| w[0] == "--name" && w[1] == "kranz-egress-worker-test"),
1268 "the daemon-owned worker must be named for timeout teardown: {args:?}"
1269 );
1270 for var in ["HTTPS_PROXY", "HTTP_PROXY"] {
1271 assert!(
1272 args.windows(2)
1273 .any(|w| w[0] == "-e" && w[1] == format!("{var}=http://kranz-egress:3128")),
1274 "missing -e {var}=…: {args:?}"
1275 );
1276 }
1277 assert!(
1278 args.windows(2)
1279 .any(|w| w[0] == "-e" && w[1] == "NO_PROXY=localhost,127.0.0.1"),
1280 "missing -e NO_PROXY…: {args:?}"
1281 );
1282 }
1283
1284 #[test]
1285 fn container_run_args_fs_net_with_egress_fails_closed_without_boundary() {
1286 let mut inputs = inputs(SandboxEnforce::FsNet);
1287 inputs.egress = vec!["crates.io:443".to_string()];
1288 let args = container_run_args(
1289 &inputs,
1290 &spec(),
1291 Path::new("claude"),
1292 &[],
1293 Some("http://kranz-egress:3128"),
1294 );
1295 assert!(
1296 args.windows(2)
1297 .any(|w| w[0] == "--network" && w[1] == "none"),
1298 "missing boundary state must disable networking: {args:?}"
1299 );
1300 assert!(
1301 args.iter()
1302 .filter(|a| a.starts_with("HTTPS_PROXY="))
1303 .all(|a| a == "HTTPS_PROXY="),
1304 "a relay env must not be emitted without its internal network: {args:?}"
1305 );
1306 }
1307
1308 #[test]
1309 fn container_run_args_fs_keeps_runtime_default_network() {
1310 let args = container_run_args(
1311 &inputs(SandboxEnforce::Fs),
1312 &spec(),
1313 Path::new("claude"),
1314 &[],
1315 None,
1316 );
1317 assert!(
1318 !args.iter().any(|a| a == "--network"),
1319 "fs must not restrict the network (runtime default bridge): {args:?}"
1320 );
1321 }
1322
1323 #[test]
1324 fn container_run_args_mounts_policy_and_runs_image() {
1325 let dir = tempfile::tempdir().unwrap();
1329 let session = dir.path().join("session");
1330 let mission = dir.path().join("mission");
1331 let scratch = dir.path().join("scratch");
1332 let cargo = dir.path().join("cargo");
1333 for path in [&session, &mission, &scratch, &cargo] {
1334 std::fs::create_dir_all(path).unwrap();
1335 }
1336 let inputs = SandboxInputs {
1337 enforce: SandboxEnforce::Fs,
1338 session_cwd: session.clone(),
1339 mission_dir: mission.clone(),
1340 tmpdir: scratch.clone(),
1341 extra_write: vec![cargo.clone()],
1342 egress: Vec::new(),
1343 validator_read_deny_roots: Vec::new(),
1344 };
1345 let args = container_run_args(
1346 &inputs,
1347 &spec(),
1348 Path::new("claude"),
1349 &["--print".to_string()],
1350 None,
1351 );
1352 let joined = args.join(" ");
1353 let abs = |p: &std::path::Path| container_host_path(p);
1354
1355 assert!(args.contains(&"--rm".to_string()));
1356 assert!(args.contains(&"--read-only".to_string()));
1357 assert!(joined.contains(&mount_arg(&abs(&session), false)));
1358 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1359 assert!(joined.contains(&mount_arg(&abs(&scratch), false)));
1360 assert!(joined.contains(&mount_arg(&abs(&cargo), false)));
1361 assert!(joined.contains(&format!("-w {}", abs(&session))));
1362 assert!(joined.contains(&format!("-e HOME={}", abs(&scratch))));
1363 assert!(
1364 joined.ends_with(&format!("{DEFAULT_IMAGE} claude --print")),
1365 "image then binary then args: {args:?}"
1366 );
1367 }
1368
1369 #[test]
1370 fn container_run_args_mask_authority_material_under_session_root() {
1371 let dir = tempfile::tempdir().unwrap();
1372 let session = dir.path().join("session");
1373 let kranz_dir = session.join(".kranz");
1374 std::fs::create_dir_all(&kranz_dir).unwrap();
1375 let masked_token_file = kranz_dir.join("serve.token");
1376 let config = kranz_dir.join("config.json");
1377 std::fs::write(&masked_token_file, "secret").unwrap();
1378 std::fs::write(&config, "{}").unwrap();
1379 let mut inputs = inputs(SandboxEnforce::Fs);
1380 inputs.session_cwd = session;
1381
1382 let args = container_run_args(
1383 &inputs,
1384 &spec(),
1385 Path::new("claude"),
1386 &["--print".to_string()],
1387 None,
1388 );
1389 let joined = args.join(" ");
1390 let abs = |p: &std::path::Path| container_host_path(p);
1391
1392 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz_dir))));
1393 for name in ["serve.token", "serve.read.token", "config.json"] {
1394 assert!(
1395 !joined.contains(&abs(&kranz_dir.join(name))),
1396 "authority must stay outside the private view: {args:?}"
1397 );
1398 }
1399 }
1400
1401 #[test]
1409 fn container_run_args_mask_the_whole_process_tier_authority_set() {
1410 let dir = tempfile::tempdir().unwrap();
1411 let session = dir.path().join("session");
1412 let kranz = session.join(".kranz");
1413 let mission = kranz.join("missions").join("m-x");
1414 std::fs::create_dir_all(mission.join("control")).unwrap();
1415 std::fs::create_dir_all(kranz.join("hook-status")).unwrap();
1416 std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
1417 std::fs::create_dir_all(kranz.join("queue")).unwrap();
1418 for name in ["serve.token", "config.json", "domain-terms.local"] {
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 name in [
1430 "serve.token",
1431 "config.json",
1432 "domain-terms.local",
1433 "hook-status",
1434 ] {
1435 assert!(
1436 !joined.contains(&abs(&kranz.join(name))),
1437 "authority must not be rebound: {args:?}"
1438 );
1439 }
1440 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz))));
1441 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1442 assert!(!joined.contains(&abs(&mission.join("control"))));
1443 for readable in [kranz.join("queue"), kranz.join("missions")] {
1448 assert!(
1449 joined.contains(&mount_arg(&abs(&readable), true)),
1450 "missing :ro self-bind for {}: {args:?}",
1451 readable.display()
1452 );
1453 }
1454 }
1455
1456 #[test]
1466 fn container_run_args_keep_write_denied_kranz_content_readable() {
1467 let dir = tempfile::tempdir().unwrap();
1468 let session = dir.path().join("repo");
1472 let kranz = session.join(".kranz");
1473 let mission = kranz.join("missions").join("m-x");
1474 std::fs::create_dir_all(mission.join("control")).unwrap();
1475 std::fs::create_dir_all(kranz.join("hook-status")).unwrap();
1476 std::fs::create_dir_all(kranz.join("tickets")).unwrap();
1477 std::fs::create_dir_all(kranz.join("lessons")).unwrap();
1478 std::fs::create_dir_all(kranz.join("queue")).unwrap();
1479 std::fs::create_dir_all(kranz.join("missions").join("m-other")).unwrap();
1480 std::fs::write(kranz.join("tickets").join("some-ticket.md"), "# tracked").unwrap();
1481 std::fs::write(kranz.join("merge-gates.json"), "{}").unwrap();
1482 std::fs::write(kranz.join("secret-allowlist"), "OK_TOKEN\n").unwrap();
1483 for name in ["serve.token", "config.json"] {
1484 std::fs::write(kranz.join(name), "secret").unwrap();
1485 }
1486 let mut inputs = inputs(SandboxEnforce::Fs);
1487 inputs.session_cwd = session;
1488 inputs.mission_dir = mission.clone();
1489
1490 let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1491 let joined = args.join(" ");
1492 let abs = |p: &std::path::Path| container_host_path(p);
1493
1494 for readable in [
1496 kranz.join("tickets"),
1497 kranz.join("lessons"),
1498 kranz.join("queue"),
1499 kranz.join("missions"),
1500 ] {
1501 assert!(
1502 joined.contains(&mount_arg(&abs(&readable), true)),
1503 "{} must be a :ro self-bind, not a mask: {args:?}",
1504 readable.display()
1505 );
1506 assert!(
1507 !joined.contains(&format!("--tmpfs {}:ro", abs(&readable))),
1508 "{} must not be shadowed by an empty tmpfs: {args:?}",
1509 readable.display()
1510 );
1511 }
1512 for readable in [
1513 kranz.join("merge-gates.json"),
1514 kranz.join("secret-allowlist"),
1515 ] {
1516 assert!(
1517 joined.contains(&mount_arg(&abs(&readable), true)),
1518 "{} must be a :ro self-bind: {args:?}",
1519 readable.display()
1520 );
1521 assert!(
1522 !joined.contains(&format!("/dev/null:{}:ro", abs(&readable))),
1523 "{} must not read as zero bytes: {args:?}",
1524 readable.display()
1525 );
1526 }
1527
1528 for hidden in [
1530 kranz.join("serve.token"),
1531 kranz.join("config.json"),
1532 mission.join("control"),
1533 kranz.join("hook-status"),
1534 ] {
1535 assert!(
1536 !joined.contains(&abs(&hidden)),
1537 "read-denied entry was mounted: {args:?}"
1538 );
1539 }
1540 }
1541
1542 #[test]
1547 fn container_run_args_harden_the_worker_like_the_egress_relay() {
1548 let session = tempfile::tempdir().unwrap();
1551 let mut inputs = inputs(SandboxEnforce::Fs);
1552 inputs.session_cwd = session.path().to_path_buf();
1553 let args = container_run_args(&inputs, &spec(), Path::new("claude"), &[], None);
1554
1555 assert!(args
1556 .windows(2)
1557 .any(|w| w[0] == "--cap-drop" && w[1] == "ALL"));
1558 assert!(args
1559 .windows(2)
1560 .any(|w| w[0] == "--security-opt" && w[1] == "no-new-privileges"));
1561 assert!(args
1562 .windows(2)
1563 .any(|w| w[0] == "--pids-limit" && w[1] == CONTAINER_PIDS_LIMIT));
1564 #[cfg(unix)]
1565 {
1566 let expected = crate::container_egress::mount_owner(session.path())
1569 .expect("a stat-able path yields an owner");
1570 assert!(
1571 args.windows(2)
1572 .any(|w| w[0] == "--user" && w[1] == expected),
1573 "missing --user {expected}: {args:?}"
1574 );
1575 }
1576 }
1577
1578 #[test]
1585 fn container_run_args_never_mount_the_real_cargo_root_for_a_session() {
1586 let home = tempfile::tempdir().unwrap();
1587 let cargo = home.path().join(".cargo");
1588 for leaf in ["bin", "registry", "git"] {
1589 std::fs::create_dir_all(cargo.join(leaf)).unwrap();
1590 }
1591 std::fs::write(cargo.join("credentials.toml"), "[registry]\ntoken=\"x\"\n").unwrap();
1592 let _guard = crate::agent_env::EnvTestGuard::engage(&[
1593 ("CARGO_HOME", cargo.to_str().unwrap()),
1594 ("HOME", home.path().to_str().unwrap()),
1595 ]);
1596
1597 let mut out = Vec::new();
1598 push_toolchain_caches(&mut out, ToolchainMount::Session);
1599 let joined = out.join(" ");
1600 let root = container_host_path(&cargo);
1601
1602 assert!(
1603 !joined.contains(&mount_arg(&root, true)),
1604 "the credential-bearing Cargo root must never be mounted: {out:?}"
1605 );
1606 for leaf in ["bin", "registry", "git"] {
1607 let mounted = container_host_path(&cargo.join(leaf));
1608 assert!(
1609 joined.contains(&mount_arg(&mounted, true)),
1610 "the {leaf} cache leaf must still cross read-only: {out:?}"
1611 );
1612 }
1613 assert!(
1616 out.windows(2)
1617 .any(|w| w[0] == "-e" && w[1] == format!("CARGO_HOME={root}")),
1618 "session mode must forward the cache-only CARGO_HOME: {out:?}"
1619 );
1620 }
1621
1622 #[test]
1631 fn container_gate_wrap_args_mounts_policy_forwards_env_and_payload() {
1632 let dir = tempfile::tempdir().unwrap();
1633 let gate = dir.path().join("gate");
1634 let mission = dir.path().join("mission");
1635 let scratch = dir.path().join("scratch");
1636 let extra = dir.path().join("extra");
1637 for dir in [&gate, &mission, &scratch, &extra] {
1638 std::fs::create_dir_all(dir).unwrap();
1639 }
1640 let kranz_dir = gate.join(".kranz");
1641 std::fs::create_dir_all(&kranz_dir).unwrap();
1642 let masked_token_file = kranz_dir.join("serve.token");
1643 std::fs::write(&masked_token_file, "secret").unwrap();
1644 let inputs = SandboxInputs {
1645 enforce: SandboxEnforce::Fs,
1646 session_cwd: gate.clone(),
1647 mission_dir: mission.clone(),
1648 tmpdir: scratch.clone(),
1649 extra_write: vec![extra.clone()],
1650 egress: Vec::new(),
1651 validator_read_deny_roots: Vec::new(),
1652 };
1653 let env: std::collections::HashMap<String, String> = [
1654 ("ZZZ_BASE".to_string(), "deadbeef".to_string()),
1655 ("AAA_FIRST".to_string(), "1".to_string()),
1656 ("CARGO_HOME".to_string(), "/scratch/cache-only".to_string()),
1657 ("PATH".to_string(), "/usr/bin:/bin".to_string()),
1658 ("HOME".to_string(), "/caller/home".to_string()),
1661 ("TMPDIR".to_string(), "/caller/tmp".to_string()),
1662 ("RUSTUP_HOME".to_string(), "/caller/rustup".to_string()),
1663 ("NPM_CONFIG_CACHE".to_string(), "/caller/npm".to_string()),
1664 ]
1665 .into_iter()
1666 .collect();
1667
1668 let args = container_gate_run_args(
1669 &inputs,
1670 &spec(),
1671 "cargo test --workspace",
1672 &env,
1673 "kranz-gate-test",
1674 );
1675 let joined = args.join(" ");
1676 let abs = |p: &std::path::Path| container_host_path(p);
1677
1678 assert!(args.contains(&"--read-only".to_string()));
1680 assert!(joined.contains(&mount_arg(&abs(&gate), false)));
1681 assert!(joined.contains(&format!("--tmpfs {}:ro,", abs(&mission))));
1682 assert!(joined.contains(&mount_arg(&abs(&scratch), false)));
1683 assert!(joined.contains(&mount_arg(&abs(&extra), false)));
1684 assert!(joined.contains(&format!("-w {}", abs(&gate))));
1685 assert!(joined.contains(&format!("-e HOME={}", abs(&scratch))));
1686 assert!(joined.contains(&format!("-e TMPDIR={}", abs(&scratch))));
1687 assert!(
1688 joined.contains(&format!("--tmpfs {}:ro,", abs(&kranz_dir)))
1689 && !joined.contains(&abs(&masked_token_file)),
1690 "authority material must stay outside the private directory: {args:?}"
1691 );
1692
1693 assert!(
1695 args.windows(2)
1696 .any(|w| w[0] == "--name" && w[1] == "kranz-gate-test"),
1697 "the gate container must carry the caller-chosen name: {args:?}"
1698 );
1699 assert!(
1700 joined.ends_with(&format!("{DEFAULT_IMAGE} sh -c cargo test --workspace")),
1701 "image then sh -c payload: {args:?}"
1702 );
1703
1704 let index_of = |needle: &str| {
1706 args.windows(2)
1707 .position(|w| w[0] == "-e" && w[1] == needle)
1708 .unwrap_or_else(|| panic!("missing -e {needle}: {args:?}"))
1709 };
1710 assert!(index_of("AAA_FIRST=1") < index_of("ZZZ_BASE=deadbeef"));
1711 index_of("CARGO_HOME=/scratch/cache-only");
1712 index_of("PATH=/usr/bin:/bin");
1713 for skipped in [
1716 "-e HOME=/caller/home",
1717 "-e TMPDIR=/caller/tmp",
1718 "-e RUSTUP_HOME=/caller/rustup",
1719 "-e NPM_CONFIG_CACHE=/caller/npm",
1720 ] {
1721 assert!(
1722 !joined.contains(skipped),
1723 "builder-owned env key must not be forwarded with the caller value: {skipped}\n{args:?}"
1724 );
1725 }
1726 }
1727
1728 #[test]
1736 fn container_gate_wrap_args_never_mounts_the_real_cargo_root() {
1737 let cargo = tempfile::tempdir().unwrap();
1738 std::fs::create_dir_all(cargo.path().join("bin")).unwrap();
1739 std::fs::write(cargo.path().join("credentials.toml"), "operator-secret").unwrap();
1740 let _guard = crate::agent_env::EnvTestGuard::engage(&[(
1741 "CARGO_HOME",
1742 cargo.path().to_str().expect("utf-8 temp path"),
1743 )]);
1744
1745 let dir = tempfile::tempdir().unwrap();
1746 let inputs = SandboxInputs {
1747 enforce: SandboxEnforce::Fs,
1748 session_cwd: dir.path().join("gate"),
1749 mission_dir: dir.path().join("mission"),
1750 tmpdir: dir.path().join("scratch"),
1751 extra_write: Vec::new(),
1752 egress: Vec::new(),
1753 validator_read_deny_roots: Vec::new(),
1754 };
1755 let env: std::collections::HashMap<String, String> =
1756 [("CARGO_HOME".to_string(), "/scratch/cache-only".to_string())]
1757 .into_iter()
1758 .collect();
1759 let args = container_gate_run_args(&inputs, &spec(), "true", &env, "kranz-gate-test");
1760 let joined = args.join(" ");
1761 let abs = |p: &std::path::Path| container_host_path(p);
1762
1763 let root = abs(cargo.path());
1764 let bin = abs(&cargo.path().join("bin"));
1765 assert!(
1766 joined.contains(&mount_arg(&bin, true)),
1767 "the shim dir must cross read-only: {args:?}"
1768 );
1769 assert!(
1770 !joined.contains(&mount_arg(&root, true)),
1771 "the credential-bearing Cargo root must NEVER be mounted: {args:?}"
1772 );
1773 assert!(
1774 !joined.contains(&format!("-e CARGO_HOME={root}")),
1775 "no -e may point CARGO_HOME at the real root: {args:?}"
1776 );
1777 assert!(
1778 joined.contains("-e CARGO_HOME=/scratch/cache-only"),
1779 "the caller's cache-only CARGO_HOME crosses instead: {args:?}"
1780 );
1781 }
1782
1783 #[test]
1790 fn container_gate_wrap_args_fs_net_empty_egress_disables_network() {
1791 let env = std::collections::HashMap::new();
1792 let fs_net = container_gate_run_args(
1793 &inputs(SandboxEnforce::FsNet),
1794 &spec(),
1795 "true",
1796 &env,
1797 "kranz-gate-test",
1798 );
1799 let network = fs_net
1800 .windows(2)
1801 .find(|w| w[0] == "--network")
1802 .expect("fs+net must pass a --network flag");
1803 assert_eq!(network[1], "none");
1804 assert!(
1805 fs_net
1806 .iter()
1807 .filter(|a| a.starts_with("HTTPS_PROXY="))
1808 .all(|a| a == "HTTPS_PROXY="),
1809 "offline gates must suppress inherited proxy configuration: {fs_net:?}"
1810 );
1811
1812 let fs = container_gate_run_args(
1813 &inputs(SandboxEnforce::Fs),
1814 &spec(),
1815 "true",
1816 &env,
1817 "kranz-gate-test",
1818 );
1819 assert!(
1820 !fs.iter().any(|a| a == "--network"),
1821 "fs must not restrict the network (runtime default bridge): {fs:?}"
1822 );
1823 }
1824
1825 #[test]
1826 fn container_run_args_respects_image_override() {
1827 let spec = ContainerSpec {
1828 runtime: ContainerRuntime::Podman,
1829 image: "ghcr.io/example/kranz-worker:1".to_string(),
1830 network: None,
1831 name: None,
1832 };
1833 let args = container_run_args(
1834 &inputs(SandboxEnforce::Fs),
1835 &spec,
1836 Path::new("claude"),
1837 &[],
1838 None,
1839 );
1840 assert!(
1841 args.iter().any(|a| a == "ghcr.io/example/kranz-worker:1"),
1842 "configured image must be used: {args:?}"
1843 );
1844 assert!(!args.iter().any(|a| a == DEFAULT_IMAGE));
1845 }
1846
1847 #[test]
1854 fn container_provider_runs_a_trivial_worker_and_enforces_the_write_boundary() {
1855 if !host_supports_container_contract() {
1856 crate::test_capability::skip(
1857 crate::test_capability::capability::CONTAINER,
1858 &container_contract_skip_detail(),
1859 );
1860 return;
1861 }
1862 let Some(runtime) = detect() else {
1863 crate::test_capability::skip(
1864 crate::test_capability::capability::CONTAINER,
1865 "no docker/podman/nerdctl/container on PATH",
1866 );
1867 return;
1868 };
1869
1870 let session = live_fixture();
1871 let mission = live_fixture();
1872 let scratch = live_fixture();
1873 let kranz_dir = session.path().join(".kranz");
1874 std::fs::create_dir_all(&kranz_dir).unwrap();
1875 std::fs::write(kranz_dir.join("serve.token"), "secret").unwrap();
1876 let inputs = SandboxInputs {
1877 enforce: SandboxEnforce::FsNet,
1878 session_cwd: session.path().to_path_buf(),
1879 mission_dir: mission.path().to_path_buf(),
1880 tmpdir: scratch.path().to_path_buf(),
1881 extra_write: Vec::new(),
1882 egress: Vec::new(),
1883 validator_read_deny_roots: Vec::new(),
1884 };
1885 let spec = ContainerSpec {
1886 runtime,
1887 image: DEFAULT_IMAGE.to_string(),
1888 network: None,
1889 name: None,
1890 };
1891 let ok_file = session.path().join("ok.txt");
1892 let args = container_run_args(
1893 &inputs,
1894 &spec,
1895 Path::new("sh"),
1896 &[
1897 "-c".to_string(),
1898 format!(
1899 "echo ok > {} && ! cat {} && echo nope > /etc/nope.txt",
1900 ok_file.display(),
1901 kranz_dir.join("serve.token").display()
1902 ),
1903 ],
1904 None,
1905 );
1906 let output = std::process::Command::new(runtime.binary())
1907 .args(&args)
1908 .stdin(std::process::Stdio::null())
1909 .output()
1910 .expect("failed to spawn container runtime");
1911
1912 assert!(
1913 ok_file.exists(),
1914 "write inside the mounted session_cwd must land on the host: {}",
1915 String::from_utf8_lossy(&output.stderr)
1916 );
1917 assert!(
1918 !output.status.success(),
1919 "write outside the declared policy (/etc) must be denied, failing the worker: {}",
1920 String::from_utf8_lossy(&output.stderr)
1921 );
1922 assert!(
1923 !String::from_utf8_lossy(&output.stdout).contains("secret"),
1924 "the /dev/null mask must hide serve.token content inside the container"
1925 );
1926 }
1927
1928 #[test]
1929 fn container_authority_directory_mask_covers_absent_and_future_tokens() {
1930 if crate::agent_env::isolated_global_home_test("sandbox_container::tests::container_authority_directory_mask_covers_absent_and_future_tokens") { return; }
1931 let home = tempfile::tempdir().unwrap();
1932 let _env = crate::agent_env::EnvTestGuard::engage(&[(
1933 if cfg!(windows) { "USERPROFILE" } else { "HOME" },
1934 home.path().to_str().unwrap(),
1935 )]);
1936 let global = home.path().join(".kranz");
1937 assert!(!global.exists());
1938 let mut inputs = inputs(SandboxEnforce::Fs);
1939 inputs.extra_write.extend([
1940 home.path().to_path_buf(),
1941 global.clone(),
1942 global.join("serve"),
1943 ]);
1944 for args in [
1945 container_run_args(&inputs, &spec(), Path::new("sh"), &[], None),
1946 container_gate_run_args(&inputs, &spec(), "true", &Default::default(), "test"),
1947 ] {
1948 assert!(
1949 args.windows(2).any(|pair| pair[0] == "--tmpfs"
1950 && pair[1]
1951 == format!(
1952 "{}:ro,noexec,nosuid,nodev,mode=755",
1953 container_host_path(&global)
1954 )),
1955 "authority mask missing: {args:?}"
1956 );
1957 assert!(
1958 !args
1959 .windows(2)
1960 .any(|pair| pair[0] == "-v"
1961 && pair[1].starts_with(&container_host_path(&global))),
1962 "nested mounts must not reopen global authority: {args:?}"
1963 );
1964 }
1965 assert!(!global.exists());
1967 }
1968
1969 #[cfg(unix)]
1970 #[test]
1971 fn container_authority_directory_hides_tokens_created_after_start() {
1972 if crate::agent_env::isolated_global_home_test("sandbox_container::tests::container_authority_directory_hides_tokens_created_after_start") { return; }
1973 use std::io::{BufRead as _, Write as _};
1974 let Some(runtime) = detect() else {
1975 eprintln!("no container runtime; skipping live authority test");
1976 return;
1977 };
1978 let dir = live_fixture();
1979 let home = dir.path().join("operator");
1980 let session = dir.path().join("session");
1981 let mission = session.join(".kranz/missions/m-test");
1982 let scratch = dir.path().join("scratch");
1983 std::fs::create_dir_all(&home).unwrap();
1984 let authority_target = dir.path().join("private-authority");
1985 std::fs::create_dir(&authority_target).unwrap();
1986 std::os::unix::fs::symlink(&authority_target, home.join(".kranz")).unwrap();
1987 std::fs::create_dir_all(&mission).unwrap();
1988 std::fs::create_dir(&scratch).unwrap();
1989 let authority = home.join(".kranz/serve/later.token");
1990 let global_config = home.join(".kranz/config.json");
1991 let cargo = home.join(".cargo");
1992 std::fs::create_dir(&cargo).unwrap();
1993 let repo_token_path = session.join(".kranz/serve.token");
1994 let repo_read_token_path = session.join(".kranz/serve.read.token");
1995 let repo_config = session.join(".kranz/config.json");
1996 let cargo_credentials = cargo.join("credentials.toml");
1997 let policy = session.join(".kranz/merge-gates.json");
1998 std::fs::write(&repo_token_path, "original-token").unwrap();
1999 std::fs::write(&policy, "visible-policy").unwrap();
2000 let input = SandboxInputs {
2001 enforce: SandboxEnforce::FsNet,
2002 session_cwd: session.clone(),
2003 mission_dir: mission,
2004 tmpdir: scratch,
2005 extra_write: vec![home.clone(), home.join(".kranz/serve")],
2006 egress: Vec::new(),
2007 validator_read_deny_roots: Vec::new(),
2008 };
2009 let args = {
2010 let _env = crate::agent_env::EnvTestGuard::engage(&[
2011 ("HOME", home.to_str().unwrap()),
2012 ("CARGO_HOME", cargo.to_str().unwrap()),
2013 ]);
2014 container_run_args(
2015 &input,
2016 &ContainerSpec {
2017 runtime,
2018 network: None,
2019 name: None,
2020 image: DEFAULT_IMAGE.to_string(),
2021 },
2022 Path::new("sh"),
2023 &[
2024 "-c".to_string(),
2025 "printf 'ready\\n'; read -r proceed; test -s \"$1\" || exit 2; \
2026 for secret in \"$2\" \"$3\" \"$4\" \"$5\" \"$6\" \"$7\"; do \
2027 if cat \"$secret\"; then exit 3; fi; \
2028 if printf forged > \"$secret\"; then exit 4; fi; done; \
2029 if rm \"$9\"; then exit 5; fi; \
2030 test \"$(cat \"$8\")\" = visible-policy || exit 8; \
2031 printf work > \"$1-worker\""
2032 .to_string(),
2033 "test".to_string(),
2034 session.join("host-witness").display().to_string(),
2035 authority.display().to_string(),
2036 global_config.display().to_string(),
2037 repo_token_path.display().to_string(),
2038 repo_read_token_path.display().to_string(),
2039 repo_config.display().to_string(),
2040 cargo_credentials.display().to_string(),
2041 policy.display().to_string(),
2042 home.join(".kranz").display().to_string(),
2043 ],
2044 None,
2045 )
2046 };
2047 let _env = crate::agent_env::EnvTestGuard::engage(&[]);
2049 let mut child = std::process::Command::new(runtime.binary())
2050 .args(args)
2051 .stdin(std::process::Stdio::piped())
2052 .stdout(std::process::Stdio::piped())
2053 .stderr(std::process::Stdio::piped())
2054 .spawn()
2055 .unwrap();
2056 let mut stdout = std::io::BufReader::new(child.stdout.take().unwrap());
2057 let mut line = String::new();
2058 stdout.read_line(&mut line).unwrap();
2059 if line != "ready\n" {
2060 let _ = child.kill();
2061 let output = child.wait_with_output().unwrap();
2062 panic!(
2063 "container did not start: {line:?}: {}",
2064 String::from_utf8_lossy(&output.stderr)
2065 );
2066 }
2067 std::fs::create_dir(authority.parent().unwrap()).unwrap();
2070 std::fs::write(&authority, "fake-authority").unwrap();
2071 std::fs::write(&global_config, "fake-config").unwrap();
2072 for path in [&repo_read_token_path, &repo_config, &cargo_credentials] {
2073 assert!(
2074 !path.exists(),
2075 "mount setup created a placeholder credential"
2076 );
2077 std::fs::write(path, "fake-authority").unwrap();
2078 }
2079 let rotated = session.join(".kranz/rotated.tmp");
2080 std::fs::write(&rotated, "rotated-token").unwrap();
2081 std::fs::rename(rotated, &repo_token_path).unwrap();
2082 std::fs::write(session.join("host-witness"), "visible").unwrap();
2083 child
2084 .stdin
2085 .take()
2086 .unwrap()
2087 .write_all(b"continue\n")
2088 .unwrap();
2089 let output = child.wait_with_output().unwrap();
2090 assert!(output.status.success(), "{output:?}");
2091 assert!(session.join("host-witness-worker").exists());
2092 assert!(
2093 home.join(".kranz").is_symlink(),
2094 "authority alias was replaced"
2095 );
2096 assert_eq!(
2097 std::fs::read_to_string(repo_token_path).unwrap(),
2098 "rotated-token"
2099 );
2100 for path in [&repo_read_token_path, &repo_config, &cargo_credentials] {
2101 assert_eq!(std::fs::read_to_string(path).unwrap(), "fake-authority");
2102 }
2103 assert_eq!(
2104 std::fs::read_to_string(global_config).unwrap(),
2105 "fake-config"
2106 );
2107 }
2108}
2109
2110#[cfg(test)]
2111mod git_mount_tests {
2112 use super::*;
2113
2114 #[test]
2115 fn git_config_mount_nodes_preserve_existing_readonly_destinations() {
2116 let root = tempfile::tempdir().unwrap();
2117 let root = crate::sandbox::absolutize(root.path());
2118 let git = root.join(".git");
2119 std::fs::create_dir(&git).unwrap();
2120 std::fs::write(git.join("config"), "[core]\nrepositoryformatversion = 0\n").unwrap();
2121 let inputs = SandboxInputs {
2122 enforce: crate::types::SandboxEnforce::Fs,
2123 session_cwd: root.clone(),
2124 mission_dir: root.join(".kranz/missions/m-fixture"),
2125 tmpdir: root.join("scratch"),
2126 extra_write: Vec::new(),
2127 egress: Vec::new(),
2128 validator_read_deny_roots: Vec::new(),
2129 };
2130 let root = container_host_path(&root);
2131 let git = container_host_path(&git);
2132 let mut args = vec![
2133 "-v".into(),
2134 mount_arg(&root, false),
2135 "-v".into(),
2136 mount_arg(&git, true),
2137 ];
2138 push_authority_masks(&mut args, &inputs);
2139 let duplicates = args
2140 .windows(2)
2141 .filter(|part| {
2142 part[0] == "-v"
2143 && (part[1] == mount_arg(&git, false) || part[1] == mount_arg(&git, true))
2144 })
2145 .count();
2146 assert_eq!(duplicates, 1, "{args:?}");
2147 assert!(args
2148 .windows(2)
2149 .any(|part| part[0] == "-v" && part[1] == mount_arg(&git, true)));
2150 }
2151}