1#[cfg(any(target_os = "macos", target_os = "linux"))]
139pub(crate) mod evaluator_io;
140
141use std::collections::HashMap;
142use std::time::Duration;
143use tokio::io::{AsyncRead, AsyncReadExt};
144
145const COMMAND_OUTPUT_TAIL: usize = 1500;
147
148const COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
150
151pub(crate) fn run_with_timeout(
160 program: &std::path::Path,
161 args: &[String],
162 timeout: Duration,
163) -> Option<std::process::Output> {
164 let mut child = std::process::Command::new(program)
165 .args(args)
166 .stdin(std::process::Stdio::null())
167 .stdout(std::process::Stdio::piped())
168 .stderr(std::process::Stdio::piped())
169 .spawn()
170 .ok()?;
171 let start = std::time::Instant::now();
172 loop {
173 match child.try_wait() {
174 Ok(Some(_)) => return child.wait_with_output().ok(),
175 Ok(None) => {
176 if start.elapsed() >= timeout {
177 let _ = child.kill();
178 let _ = child.wait();
179 return None;
180 }
181 std::thread::sleep(Duration::from_millis(20));
182 }
183 Err(_) => return None,
184 }
185 }
186}
187
188pub(crate) fn last_chars_local(text: &str, max: usize) -> String {
191 let chars: Vec<char> = text.chars().collect();
192 let start = chars.len().saturating_sub(max);
193 chars[start..].iter().collect()
194}
195
196pub(crate) fn is_git_repo(root: &std::path::Path) -> bool {
200 root.join(".git").exists()
201}
202
203#[cfg(all(test, unix))]
227pub(crate) async fn run_shell_command(
228 cwd: &std::path::Path,
229 command: &str,
230 env: &HashMap<String, String>,
231) -> (bool, String) {
232 run_shell_command_with_timeout(cwd, command, COMMAND_TIMEOUT, env).await
233}
234
235#[cfg(test)]
250pub(crate) async fn run_shell_command_with_code(
251 cwd: &std::path::Path,
252 command: &str,
253 env: &HashMap<String, String>,
254) -> (Option<i32>, String) {
255 run_shell_command_with_timeout_env(cwd, command, COMMAND_TIMEOUT, env, false).await
256}
257
258pub(crate) async fn run_shell_command_with_code_cleared(
273 cwd: &std::path::Path,
274 command: &str,
275 env: &HashMap<String, String>,
276) -> (Option<i32>, String) {
277 run_shell_command_with_timeout_env(cwd, command, COMMAND_TIMEOUT, env, true).await
278}
279
280#[cfg(all(test, unix))]
297async fn run_shell_command_with_timeout(
298 cwd: &std::path::Path,
299 command: &str,
300 timeout: Duration,
301 env: &HashMap<String, String>,
302) -> (bool, String) {
303 let (code, output) = run_shell_command_with_timeout_env(cwd, command, timeout, env, true).await;
304 (code == Some(0), output)
305}
306
307async fn run_shell_command_with_timeout_env(
308 cwd: &std::path::Path,
309 command: &str,
310 timeout: Duration,
311 env: &HashMap<String, String>,
312 clear_env: bool,
313) -> (Option<i32>, String) {
314 let (program, args) = shell_argv(command);
315 let mut cmd = tokio::process::Command::new(program);
316 cmd.args(args);
317 if clear_env {
318 cmd.env_clear();
319 }
320 run_command_bounded(configure_bounded_child(cmd, cwd, env), timeout).await
321}
322
323fn shell_argv(command: &str) -> (std::path::PathBuf, Vec<String>) {
328 #[cfg(windows)]
329 {
330 (
331 std::path::PathBuf::from("cmd"),
332 vec!["/C".to_string(), command.to_string()],
333 )
334 }
335 #[cfg(not(windows))]
336 {
337 (
338 std::path::PathBuf::from("sh"),
339 vec!["-c".to_string(), command.to_string()],
340 )
341 }
342}
343
344pub(crate) async fn run_bounded_argv(
352 cwd: &std::path::Path,
353 program: &std::path::Path,
354 args: &[String],
355 timeout: Duration,
356 env: &HashMap<String, String>,
357) -> (Option<i32>, String) {
358 let mut cmd = tokio::process::Command::new(program);
359 cmd.args(args);
360 cmd.env_clear();
361 run_command_bounded(configure_bounded_child(cmd, cwd, env), timeout).await
362}
363
364#[derive(Debug)]
387pub(crate) enum GateSandbox {
388 Disabled,
390 Seatbelt {
392 enforce: crate::types::SandboxEnforce,
393 profile_path: std::path::PathBuf,
394 },
395 Bubblewrap {
399 inputs: Box<crate::sandbox::SandboxInputs>,
400 },
401 AppContainer {
405 inputs: Box<crate::sandbox::SandboxInputs>,
406 #[cfg(windows)]
407 context: crate::appcontainer_windows::AppContainerLaunchContext,
408 },
409 Container {
414 inputs: Box<crate::sandbox::SandboxInputs>,
415 spec: crate::sandbox_container::ContainerSpec,
416 },
417}
418
419pub(crate) struct WrappedCommand {
423 pub program: std::path::PathBuf,
424 pub args: Vec<String>,
425 pub timeout_teardown: Option<(std::path::PathBuf, Vec<String>)>,
435 #[cfg(windows)]
440 _appcontainer_context: Option<crate::appcontainer_windows::AppContainerLaunchContext>,
441}
442
443impl GateSandbox {
444 #[cfg(any(target_os = "macos", target_os = "linux", test))]
448 fn wrap_control_shell(
449 &self,
450 cwd: &std::path::Path,
451 command: &str,
452 env: &HashMap<String, String>,
453 ) -> crate::error::Result<WrappedCommand> {
454 match self {
455 Self::Seatbelt { .. } => self.wrap_shell(command, env),
456 Self::Bubblewrap { inputs } => Ok(WrappedCommand {
457 program: "bwrap".into(),
458 args: crate::sandbox::bubblewrap_args(
459 inputs,
460 std::path::Path::new("/bin/sh"),
461 &[
462 "-c".into(),
463 "cd -- \"$1\" && exec /bin/sh -c \"$2\"".into(),
464 "kranz-control".into(),
465 cwd.display().to_string(),
466 command.into(),
467 ],
468 )?,
469 timeout_teardown: None,
470 #[cfg(windows)]
471 _appcontainer_context: None,
472 }),
473 _ => Err(crate::error::EngineError::Config(
474 "negative controls require native macOS/Linux containment".into(),
475 )),
476 }
477 }
478
479 pub(crate) fn enforce(&self) -> crate::types::SandboxEnforce {
482 match self {
483 GateSandbox::Disabled => crate::types::SandboxEnforce::Off,
484 GateSandbox::Seatbelt { enforce, .. } => *enforce,
485 GateSandbox::Bubblewrap { inputs } => inputs.enforce,
486 GateSandbox::AppContainer { inputs, .. } => inputs.enforce,
487 GateSandbox::Container { inputs, .. } => inputs.enforce,
488 }
489 }
490
491 pub(crate) fn cleanup(&mut self) -> crate::error::Result<()> {
495 #[cfg(windows)]
496 if let GateSandbox::AppContainer { context, .. } = self {
497 return context.cleanup();
498 }
499 Ok(())
500 }
501
502 fn wrap_shell(
514 &self,
515 command: &str,
516 env: &HashMap<String, String>,
517 ) -> crate::error::Result<WrappedCommand> {
518 match self {
519 GateSandbox::Disabled => {
520 let (program, args) = shell_argv(command);
521 Ok(WrappedCommand {
522 program,
523 args,
524 timeout_teardown: None,
525 #[cfg(windows)]
526 _appcontainer_context: None,
527 })
528 }
529 GateSandbox::Seatbelt { profile_path, .. } => {
530 let (program, args) = crate::backend_claude::sandbox_command(
531 profile_path,
532 std::path::Path::new("/bin/sh"),
533 &["-c".to_string(), command.to_string()],
534 );
535 Ok(WrappedCommand {
536 program,
537 args,
538 timeout_teardown: None,
539 #[cfg(windows)]
540 _appcontainer_context: None,
541 })
542 }
543 GateSandbox::Bubblewrap { inputs } => {
544 let args = crate::sandbox::bubblewrap_args(
545 inputs,
546 std::path::Path::new("/bin/sh"),
547 &["-c".to_string(), command.to_string()],
548 )?;
549 Ok(WrappedCommand {
550 program: std::path::PathBuf::from("bwrap"),
551 args,
552 timeout_teardown: None,
553 #[cfg(windows)]
554 _appcontainer_context: None,
555 })
556 }
557 GateSandbox::AppContainer {
558 inputs,
559 #[cfg(windows)]
560 context,
561 } => {
562 #[cfg(windows)]
563 {
564 let (program, args) = shell_argv(command);
565 let prepared = crate::appcontainer_windows::prepare_launch_in_context(
566 context, inputs, &program, &args, env,
567 )?;
568 Ok(WrappedCommand {
569 program: prepared.program,
570 args: prepared.args,
571 timeout_teardown: None,
572 _appcontainer_context: Some(context.clone()),
573 })
574 }
575 #[cfg(not(windows))]
576 {
577 let _ = (inputs, command, env);
578 Err(crate::error::EngineError::Backend(
579 "AppContainer gate wrapper is unavailable on this host".to_string(),
580 ))
581 }
582 }
583 GateSandbox::Container { inputs, spec } => {
584 let name = format!("kranz-gate-{}", uuid::Uuid::new_v4().simple());
588 let args = crate::sandbox_container::container_gate_run_args(
589 inputs, spec, command, env, &name,
590 );
591 Ok(WrappedCommand {
592 program: std::path::PathBuf::from(spec.runtime.binary()),
593 args,
594 timeout_teardown: Some((
595 std::path::PathBuf::from(spec.runtime.binary()),
596 vec!["rm".to_string(), "-f".to_string(), name],
597 )),
598 #[cfg(windows)]
599 _appcontainer_context: None,
600 })
601 }
602 }
603 }
604}
605
606#[derive(Debug)]
615pub(crate) struct GateSandboxResolution {
616 pub sandbox: GateSandbox,
617 pub note: Option<String>,
618 #[cfg_attr(not(all(test, target_os = "macos")), allow(dead_code))]
626 pub prewarmed_xcrun: bool,
627}
628
629fn gate_profile_extras() -> String {
688 let mut extras = String::from(
736 "\n(allow file-write* (literal \"/dev/null\") (literal \"/dev/ptmx\"))\n\
737 (allow file-read* (literal \"/dev/ptmx\"))\n\
738 (allow file-read* file-write* (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))\n\
739 (allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))\n\
740 (allow signal (target same-sandbox))\n",
741 );
742 extras.push_str(&crate::sandbox::tty_deny_block(
743 &crate::sandbox::operator_tty_paths(),
744 ));
745 extras
746}
747
748#[cfg(target_os = "macos")]
766pub(crate) fn prewarm_xcrun_cache_outside_sandbox() {
767 let _ = run_with_timeout(
768 std::path::Path::new("git"),
769 &["--version".to_string()],
770 Duration::from_secs(10),
771 );
772}
773
774fn container_gate_note(enforce: crate::types::SandboxEnforce) -> String {
787 format!(
788 "sandbox provider:container with enforce:{} wraps engine-run gates in the mission \
789 container, but no container runtime (docker/podman/nerdctl/container) was found on \
790 PATH; refusing to run engine-run gates unsandboxed (fail closed, mirroring container \
791 session resolution) — install a runtime or set worker.sandbox.provider to \"process\"",
792 enforce.as_str()
793 )
794}
795
796pub(crate) fn resolve_gate_sandbox(
811 sandbox_cfg: &crate::types::SandboxConfig,
812 gate_cwd: &std::path::Path,
813 mission_dir: &std::path::Path,
814 scratch_home: &std::path::Path,
815 profile_dir: &std::path::Path,
816) -> crate::error::Result<GateSandboxResolution> {
817 let runtime = crate::sandbox_container::detect();
818 resolve_gate_sandbox_target(
819 sandbox_cfg,
820 gate_cwd,
821 mission_dir,
822 scratch_home,
823 profile_dir,
824 std::env::consts::OS,
825 crate::sandbox::command_available("bwrap"),
826 runtime,
827 crate::sandbox::session_mount_proof(sandbox_cfg, gate_cwd, mission_dir, runtime),
832 )
833}
834
835pub fn worker_gate_sandbox(
839 config: &crate::types::MissionConfig,
840) -> crate::error::Result<crate::types::SandboxConfig> {
841 let mut sandbox = config.worker.sandbox.clone();
842 if let Some(profile) = &config.worker.acp_profile {
843 profile.validate_config(
844 crate::types::Role::Worker,
845 &config.worker,
846 config.worker_isolation,
847 )?;
848 sandbox.egress.clear();
849 }
850 Ok(sandbox)
851}
852
853fn gate_sandbox_inputs(
861 sandbox_cfg: &crate::types::SandboxConfig,
862 gate_cwd: &std::path::Path,
863 mission_dir: &std::path::Path,
864 scratch_home: &std::path::Path,
865) -> crate::sandbox::SandboxInputs {
866 crate::sandbox::SandboxInputs {
867 enforce: sandbox_cfg.enforce,
868 session_cwd: gate_cwd.to_path_buf(),
869 mission_dir: mission_dir.to_path_buf(),
870 tmpdir: scratch_home.to_path_buf(),
871 extra_write: sandbox_cfg
872 .extra_write
873 .iter()
874 .map(|raw| crate::sandbox::expand_tilde(raw))
875 .collect(),
876 egress: sandbox_cfg.egress.clone(),
877 validator_read_deny_roots: Vec::new(),
878 }
879}
880
881#[allow(clippy::too_many_arguments)]
885fn resolve_gate_sandbox_target(
886 sandbox_cfg: &crate::types::SandboxConfig,
887 gate_cwd: &std::path::Path,
888 mission_dir: &std::path::Path,
889 scratch_home: &std::path::Path,
890 profile_dir: &std::path::Path,
891 target_os: &str,
892 bwrap_available: bool,
893 container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
894 container_mount_proof: Option<crate::sandbox_container::MountProof>,
895) -> crate::error::Result<GateSandboxResolution> {
896 use crate::types::{SandboxEnforce, SandboxProvider};
897 let disabled = |note: Option<String>| {
898 Ok(GateSandboxResolution {
899 sandbox: GateSandbox::Disabled,
900 note,
901 prewarmed_xcrun: false,
902 })
903 };
904 if sandbox_cfg.enforce == SandboxEnforce::Off {
905 return disabled(None);
906 }
907 crate::sandbox::validate_git_config_protection(
908 &gate_sandbox_inputs(sandbox_cfg, gate_cwd, mission_dir, scratch_home),
909 sandbox_cfg.provider == SandboxProvider::Container || target_os == "linux",
910 )?;
911 if sandbox_cfg.provider == SandboxProvider::Container {
912 if target_os == "windows" {
924 return Err(crate::error::EngineError::Config(format!(
925 "sandbox provider:container with enforce:{} is not supported on target_os=windows: the shipped contract uses POSIX guest paths, Linux images, and /dev/null authority masks that Windows containers do not honor; refusing to run engine-run gates under an unverified container mount contract",
926 sandbox_cfg.enforce.as_str()
927 )));
928 }
929 if target_os != "linux" {
930 match container_mount_proof {
931 Some(crate::sandbox_container::MountProof::Proven) => {}
932 Some(crate::sandbox_container::MountProof::Failed(reason)) => {
933 return Err(crate::error::EngineError::Config(format!(
934 "sandbox provider:container with enforce:{} refused for engine-run gates on target_os={target_os}: {reason}",
935 sandbox_cfg.enforce.as_str()
936 )));
937 }
938 None => {
939 return Err(crate::error::EngineError::Config(format!(
940 "sandbox provider:container with enforce:{} on target_os={target_os} requires a bind-mount proof on this host and none was taken; refusing to run engine-run gates under an unverified container mount contract; use sandbox.provider=\"process\" for native host containment",
941 sandbox_cfg.enforce.as_str()
942 )));
943 }
944 }
945 }
946 let Some(runtime) = container_runtime else {
947 return Err(crate::error::EngineError::Config(container_gate_note(
948 sandbox_cfg.enforce,
949 )));
950 };
951 if sandbox_cfg.enforce == SandboxEnforce::FsNet
959 && !sandbox_cfg
960 .provider
961 .enforces_hard_net_boundary(&sandbox_cfg.egress)
962 {
963 return Err(crate::error::EngineError::Config(
964 "sandbox provider:container with enforce:fs+net and a non-empty egress list is \
965 advisory-only for engine-run gates (no egress proxy exists engine-side); use an \
966 empty egress list (the hard `--network none` boundary) or sandbox.provider \
967 \"process\" — refusing to run engine-run gates with an advisory boundary"
968 .to_string(),
969 ));
970 }
971 return Ok(GateSandboxResolution {
972 sandbox: GateSandbox::Container {
973 inputs: Box::new(gate_sandbox_inputs(
974 sandbox_cfg,
975 gate_cwd,
976 mission_dir,
977 scratch_home,
978 )),
979 spec: crate::sandbox_container::ContainerSpec {
980 runtime,
981 image: sandbox_cfg
982 .image
983 .clone()
984 .unwrap_or_else(|| crate::sandbox_container::DEFAULT_IMAGE.to_string()),
985 network: None,
986 name: None,
987 },
988 },
989 note: None,
990 prewarmed_xcrun: false,
991 });
992 }
993 match crate::sandbox::platform_support(sandbox_cfg.enforce, target_os) {
994 crate::sandbox::SandboxDecision::Off => disabled(None),
997 crate::sandbox::SandboxDecision::UnsupportedWarn => {
1003 Err(crate::error::EngineError::Config(format!(
1004 "sandbox enforce:{} requested but unsupported on target_os={target_os}; refusing \
1005 to run engine-run gates unsandboxed",
1006 sandbox_cfg.enforce.as_str()
1007 )))
1008 }
1009 crate::sandbox::SandboxDecision::Enforce(crate::sandbox::SandboxBackend::Bubblewrap)
1010 if !bwrap_available =>
1011 {
1012 Err(crate::error::EngineError::Config(format!(
1013 "sandbox enforce:{} requested on linux but `bwrap` was not found; refusing \
1014 to run engine-run gates unsandboxed",
1015 sandbox_cfg.enforce.as_str()
1016 )))
1017 }
1018 crate::sandbox::SandboxDecision::Enforce(backend) => {
1019 let inputs = gate_sandbox_inputs(sandbox_cfg, gate_cwd, mission_dir, scratch_home);
1020 match backend {
1021 crate::sandbox::SandboxBackend::Seatbelt => {
1022 #[cfg(target_os = "macos")]
1027 prewarm_xcrun_cache_outside_sandbox();
1028 let mut profile = crate::sandbox::generate_profile(&inputs);
1032 profile.push_str(&gate_profile_extras());
1033 let profile_path = crate::sandbox::write_profile_file(profile_dir, &profile)?;
1034 Ok(GateSandboxResolution {
1035 sandbox: GateSandbox::Seatbelt {
1036 enforce: sandbox_cfg.enforce,
1037 profile_path,
1038 },
1039 note: None,
1040 prewarmed_xcrun: cfg!(target_os = "macos"),
1042 })
1043 }
1044 crate::sandbox::SandboxBackend::Bubblewrap => Ok(GateSandboxResolution {
1045 sandbox: GateSandbox::Bubblewrap {
1046 inputs: Box::new(inputs),
1047 },
1048 note: None,
1049 prewarmed_xcrun: false,
1050 }),
1051 crate::sandbox::SandboxBackend::AppContainer => Ok(GateSandboxResolution {
1052 sandbox: GateSandbox::AppContainer {
1053 inputs: Box::new(inputs),
1054 #[cfg(windows)]
1055 context: crate::appcontainer_windows::new_launch_context(),
1056 },
1057 note: None,
1058 prewarmed_xcrun: false,
1059 }),
1060 crate::sandbox::SandboxBackend::Container => {
1064 unreachable!("container provider returned above")
1065 }
1066 }
1067 }
1068 }
1069}
1070
1071pub(crate) fn gate_env_for_sandbox(
1079 env: &HashMap<String, String>,
1080 sandbox: &GateSandbox,
1081) -> HashMap<String, String> {
1082 let mut env = env.clone();
1083 if sandbox.enforce() == crate::types::SandboxEnforce::FsNet {
1084 env.insert("CARGO_NET_OFFLINE".to_string(), "true".to_string());
1085 }
1086 env
1087}
1088
1089pub(crate) fn prepare_gate_command(
1098 command: &str,
1099 env: &HashMap<String, String>,
1100 sandbox: &GateSandbox,
1101) -> crate::error::Result<(WrappedCommand, HashMap<String, String>)> {
1102 let env = gate_env_for_sandbox(env, sandbox);
1103 let wrapped = sandbox.wrap_shell(command, &env)?;
1104 Ok((wrapped, env))
1105}
1106
1107pub(crate) async fn run_shell_command_sandboxed(
1121 cwd: &std::path::Path,
1122 command: &str,
1123 env: &HashMap<String, String>,
1124 sandbox: &GateSandbox,
1125) -> (bool, String) {
1126 let (code, output) =
1127 run_shell_command_sandboxed_with_code(cwd, command, COMMAND_TIMEOUT, env, sandbox).await;
1128 (code == Some(0), output)
1129}
1130
1131pub(crate) async fn run_shell_command_sandboxed_with_code(
1135 cwd: &std::path::Path,
1136 command: &str,
1137 timeout: Duration,
1138 env: &HashMap<String, String>,
1139 sandbox: &GateSandbox,
1140) -> (Option<i32>, String) {
1141 let env = gate_env_for_sandbox(env, sandbox);
1145 let wrapped = match sandbox.wrap_shell(command, &env) {
1146 Ok(wrapped) => wrapped,
1147 Err(error) => {
1148 return (
1149 None,
1150 format!("gate sandbox wrap failed closed (the command did not run): {error}"),
1151 )
1152 }
1153 };
1154 let client_env = match sandbox {
1157 GateSandbox::Container { spec, .. } => spec.runtime.client_env(),
1158 _ => env,
1159 };
1160 let (code, output) =
1161 run_bounded_argv(cwd, &wrapped.program, &wrapped.args, timeout, &client_env).await;
1162 if code.is_none() {
1163 if let Some((program, args)) = wrapped.timeout_teardown {
1164 let _ =
1166 run_bounded_argv(cwd, &program, &args, Duration::from_secs(30), &client_env).await;
1167 }
1168 }
1169 (code, output)
1170}
1171
1172#[cfg(windows)]
1178pub(crate) fn run_bounded_gate_command_resolved_with_code(
1179 cwd: &std::path::Path,
1180 command: &str,
1181 env: &HashMap<String, String>,
1182 sandbox: &GateSandbox,
1183) -> (Option<i32>, String) {
1184 let runtime = match tokio::runtime::Builder::new_current_thread()
1185 .enable_all()
1186 .build()
1187 {
1188 Ok(runtime) => runtime,
1189 Err(error) => return (None, format!("failed to create gate runtime: {error}")),
1190 };
1191 runtime.block_on(run_shell_command_sandboxed_with_code(
1192 cwd,
1193 command,
1194 COMMAND_TIMEOUT,
1195 env,
1196 sandbox,
1197 ))
1198}
1199
1200pub(crate) fn run_shell_command_sandboxed_blocking(
1210 cwd: &std::path::Path,
1211 command: &str,
1212 timeout: Duration,
1213 env: &HashMap<String, String>,
1214 sandbox: &GateSandbox,
1215) -> (Option<i32>, String) {
1216 std::thread::scope(|scope| {
1217 let worker = scope.spawn(|| {
1218 let runtime = match tokio::runtime::Builder::new_current_thread()
1219 .enable_all()
1220 .build()
1221 {
1222 Ok(runtime) => runtime,
1223 Err(error) => {
1224 return (
1225 None,
1226 format!("failed to create approval gate runtime: {error}"),
1227 )
1228 }
1229 };
1230 runtime.block_on(run_shell_command_sandboxed_with_code(
1231 cwd, command, timeout, env, sandbox,
1232 ))
1233 });
1234 worker.join().unwrap_or_else(|_| {
1235 (
1236 None,
1237 "approval gate runner panicked before producing a verdict".to_string(),
1238 )
1239 })
1240 })
1241}
1242
1243pub(crate) fn run_control_command_sandboxed_blocking(
1247 cwd: &std::path::Path,
1248 command: &str,
1249 timeout: Duration,
1250 env: &HashMap<String, String>,
1251 sandbox: &GateSandbox,
1252 cancelled: &std::sync::atomic::AtomicBool,
1253) -> (Option<i32>, String) {
1254 #[cfg(any(target_os = "macos", target_os = "linux"))]
1255 {
1256 std::thread::scope(|scope| {
1257 scope
1258 .spawn(|| {
1259 let env = gate_env_for_sandbox(env, sandbox);
1260 let wrapped = match sandbox.wrap_control_shell(cwd, command, &env) {
1261 Ok(wrapped) => wrapped,
1262 Err(error) => return (None, format!("control wrap failed: {error}")),
1263 };
1264 let runtime = match tokio::runtime::Builder::new_current_thread()
1265 .enable_all()
1266 .build()
1267 {
1268 Ok(runtime) => runtime,
1269 Err(error) => return (None, format!("control runtime failed: {error}")),
1270 };
1271 let mut cmd = tokio::process::Command::new(&wrapped.program);
1272 cmd.args(&wrapped.args).env_clear();
1273 runtime.block_on(run_control_command_bounded(
1274 configure_bounded_child(cmd, cwd, &env),
1275 timeout,
1276 cancelled,
1277 ))
1278 })
1279 .join()
1280 .unwrap_or_else(|_| (None, "control runner panicked".into()))
1281 })
1282 }
1283 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1284 {
1285 let _ = (cwd, command, timeout, env, sandbox, cancelled);
1286 (
1287 None,
1288 "negative controls require native macOS/Linux containment".into(),
1289 )
1290 }
1291}
1292
1293#[cfg(any(target_os = "macos", target_os = "linux"))]
1294pub(crate) struct ControlChild(pub(crate) tokio::process::Child);
1295
1296#[cfg(any(target_os = "macos", target_os = "linux"))]
1297impl Drop for ControlChild {
1298 fn drop(&mut self) {
1299 crate::backend_claude::kill_unreaped_group(&self.0);
1301 }
1302}
1303
1304#[cfg(any(target_os = "macos", target_os = "linux"))]
1305pub(crate) async fn control_leader_exited(pid: u32) -> std::io::Result<()> {
1306 loop {
1307 let exited = {
1308 let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
1312 let result = unsafe {
1313 libc::waitid(
1314 libc::P_PID,
1315 pid as libc::id_t,
1316 &mut info,
1317 libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
1318 )
1319 };
1320 if result != 0 {
1321 let error = std::io::Error::last_os_error();
1322 if error.kind() != std::io::ErrorKind::Interrupted {
1323 return Err(error);
1324 }
1325 false
1326 } else {
1327 unsafe { info.si_pid() != 0 }
1328 }
1329 };
1330 if exited {
1331 return Ok(());
1332 }
1333 tokio::time::sleep(Duration::from_millis(10)).await;
1334 }
1335}
1336
1337#[cfg(any(target_os = "macos", target_os = "linux"))]
1338async fn run_control_command_bounded(
1339 mut cmd: tokio::process::Command,
1340 timeout: Duration,
1341 cancelled: &std::sync::atomic::AtomicBool,
1342) -> (Option<i32>, String) {
1343 use std::sync::atomic::Ordering;
1344 if cancelled.load(Ordering::Acquire) {
1345 return (None, "control evaluation cancelled".into());
1346 }
1347 let mut child = match cmd.spawn() {
1348 Ok(child) => ControlChild(child),
1349 Err(error) => return (None, format!("failed to spawn control: {error}")),
1350 };
1351 let stdout = child.0.stdout.take().expect("stdout is piped");
1352 let stderr = child.0.stderr.take().expect("stderr is piped");
1353 let capture = async { tokio::try_join!(read_stream_tail(stdout), read_stream_tail(stderr)) };
1354 tokio::pin!(capture);
1355 let leader = control_leader_exited(child.0.id().expect("unreaped child has an id"));
1356 tokio::pin!(leader);
1357 let cancellation = async {
1358 while !cancelled.load(Ordering::Acquire) {
1359 tokio::time::sleep(Duration::from_millis(10)).await;
1360 }
1361 };
1362 tokio::pin!(cancellation);
1363 let mut output = None;
1364 let execution = async {
1365 loop {
1366 tokio::select! {
1367 result = &mut leader => return result.map_err(|error| format!("control wait failed: {error}")),
1368 () = &mut cancellation => return Err("control evaluation cancelled".into()),
1369 result = &mut capture, if output.is_none() => {
1370 output = Some(result.map_err(|error| format!("control output failed: {error}"))?);
1371 }
1372 }
1373 }
1374 };
1375 let result = match tokio::time::timeout(timeout, execution).await {
1376 Ok(result) => result,
1377 Err(_) => Err(format!("timed out after {}s", timeout.as_secs())),
1378 };
1379 crate::backend_claude::kill_unreaped_group(&child.0);
1382 if result.is_err() {
1383 let _ = child.0.start_kill();
1386 }
1387 if let Err(error) = result {
1388 let _ = child.0.wait().await;
1389 return (None, error);
1390 }
1391 let drained = match output {
1392 Some(output) => Ok(output),
1393 None => {
1394 let drain = async {
1399 loop {
1400 tokio::select! {
1401 result = &mut capture => break result.map_err(|error| format!("control output failed: {error}")),
1402 _ = tokio::time::sleep(Duration::from_millis(50)) => {
1403 crate::backend_claude::kill_unreaped_group(&child.0);
1404 }
1405 }
1406 }
1407 };
1408 tokio::time::timeout(Duration::from_secs(5), drain)
1409 .await
1410 .unwrap_or_else(|_| Err("control output remained open after group cleanup".into()))
1411 }
1412 };
1413 crate::backend_claude::kill_unreaped_group(&child.0);
1414 let status = match child.0.wait().await {
1415 Ok(status) => status,
1416 Err(error) => return (None, format!("control reap failed: {error}")),
1417 };
1418 let (stdout, stderr) = match drained {
1419 Ok(output) => output,
1420 Err(error) => return (None, error),
1421 };
1422 let mut combined = stdout;
1423 if !stderr.trim().is_empty() {
1424 combined.push_str("\n--- stderr ---\n");
1425 combined.push_str(stderr.trim_end());
1426 }
1427 (
1428 status.code(),
1429 tail_chars(combined.trim_end(), COMMAND_OUTPUT_TAIL),
1430 )
1431}
1432
1433fn configure_bounded_child(
1438 mut cmd: tokio::process::Command,
1439 cwd: &std::path::Path,
1440 env: &HashMap<String, String>,
1441) -> tokio::process::Command {
1442 cmd.current_dir(cwd)
1443 .envs(env)
1444 .stdin(std::process::Stdio::null())
1445 .stdout(std::process::Stdio::piped())
1446 .stderr(std::process::Stdio::piped())
1447 .kill_on_drop(true);
1448 #[cfg(unix)]
1449 cmd.process_group(0);
1450 cmd
1451}
1452
1453async fn run_command_bounded(
1473 cmd: tokio::process::Command,
1474 timeout: Duration,
1475) -> (Option<i32>, String) {
1476 let mut cmd = cmd;
1477 let mut child = match cmd.spawn() {
1478 Ok(child) => child,
1479 Err(e) => return (None, format!("failed to spawn shell: {e}")),
1480 };
1481 let stdout = child.stdout.take().expect("stdout was configured as piped");
1482 let stderr = child.stderr.take().expect("stderr was configured as piped");
1483 #[cfg(unix)]
1484 let group_pid = child.id();
1485
1486 #[cfg(windows)]
1493 let job = match child.raw_handle() {
1494 Some(handle) => crate::backend_claude::win_job::JobHandle::create_and_assign(handle)
1495 .map_err(|e| {
1496 tracing::warn!(error = %e, "failed to create Job Object for shell command; \
1497 timeout will kill only the spawned child");
1498 })
1499 .ok(),
1500 None => None,
1501 };
1502
1503 let execution = async {
1504 let (status, stdout, stderr) = tokio::join!(
1505 child.wait(),
1506 read_stream_tail(stdout),
1507 read_stream_tail(stderr)
1508 );
1509 Ok::<_, String>((
1510 status.map_err(|e| format!("failed waiting for shell: {e}"))?,
1511 stdout.map_err(|e| format!("failed reading shell stdout: {e}"))?,
1512 stderr.map_err(|e| format!("failed reading shell stderr: {e}"))?,
1513 ))
1514 };
1515 match tokio::time::timeout(timeout, execution).await {
1516 Err(_elapsed) => {
1517 #[cfg(unix)]
1521 if let Some(pid) = group_pid {
1522 unsafe {
1524 libc::kill(-(pid as i32), libc::SIGKILL);
1525 }
1526 }
1527 #[cfg(windows)]
1531 if let Some(job) = &job {
1532 job.kill();
1533 }
1534 let _ = child.kill().await;
1535 let _ = child.wait().await;
1536 (None, format!("timed out after {}s", timeout.as_secs()))
1537 }
1538 Ok(Err(error)) => (None, error),
1539 Ok(Ok((status, stdout, stderr))) => {
1540 let mut combined = stdout;
1541 if !stderr.trim().is_empty() {
1542 combined.push_str("\n--- stderr ---\n");
1543 combined.push_str(stderr.trim_end());
1544 }
1545 (
1548 status.code(),
1549 tail_chars(combined.trim_end(), COMMAND_OUTPUT_TAIL),
1550 )
1551 }
1552 }
1553}
1554
1555async fn read_stream_tail<R>(mut reader: R) -> std::io::Result<String>
1556where
1557 R: AsyncRead + Unpin,
1558{
1559 let max_bytes = COMMAND_OUTPUT_TAIL * 4;
1560 let mut tail = Vec::with_capacity(max_bytes);
1561 let mut chunk = [0u8; 8192];
1562 loop {
1563 let read = reader.read(&mut chunk).await?;
1564 if read == 0 {
1565 break;
1566 }
1567 if read >= max_bytes {
1568 tail.clear();
1569 tail.extend_from_slice(&chunk[read - max_bytes..read]);
1570 continue;
1571 }
1572 let excess = tail.len().saturating_add(read).saturating_sub(max_bytes);
1573 if excess > 0 {
1574 tail.drain(..excess);
1575 }
1576 tail.extend_from_slice(&chunk[..read]);
1577 }
1578 Ok(tail_chars(
1579 &String::from_utf8_lossy(&tail),
1580 COMMAND_OUTPUT_TAIL,
1581 ))
1582}
1583
1584pub fn run_bounded_gate_command(cwd: &std::path::Path, command: &str) -> (bool, String) {
1607 let (code, output) = run_bounded_gate_command_with_code(cwd, command);
1608 (code == Some(0), output)
1609}
1610
1611fn run_bounded_gate_command_with_code(
1612 cwd: &std::path::Path,
1613 command: &str,
1614) -> (Option<i32>, String) {
1615 let cargo_home = crate::agent_env::cache_only_cargo_home(std::env::temp_dir().as_path());
1622 if !cargo_home.is_dir() {
1623 return (
1624 None,
1625 format!(
1626 "could not create the gate's cache-only Cargo home at {}",
1627 cargo_home.display()
1628 ),
1629 );
1630 }
1631 let mut env = sanitized_gate_env();
1632 env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
1633 let runtime = match tokio::runtime::Builder::new_current_thread()
1634 .enable_all()
1635 .build()
1636 {
1637 Ok(runtime) => runtime,
1638 Err(error) => return (None, format!("failed to create gate runtime: {error}")),
1639 };
1640 let (code, output) = runtime.block_on(run_shell_command_with_timeout_env(
1641 cwd,
1642 command,
1643 COMMAND_TIMEOUT,
1644 &env,
1645 true,
1646 ));
1647 let _ = std::fs::remove_dir_all(&cargo_home);
1648 (code, output)
1649}
1650
1651pub struct MergeGatePolicy {
1661 pub sandbox: crate::types::SandboxConfig,
1662 pub mission_dir: std::path::PathBuf,
1663}
1664
1665impl MergeGatePolicy {
1666 pub fn disabled() -> Self {
1668 MergeGatePolicy {
1669 sandbox: crate::types::SandboxConfig::default(),
1670 mission_dir: std::path::PathBuf::new(),
1671 }
1672 }
1673
1674 pub fn enforces_on_this_host(&self) -> bool {
1687 if self.sandbox.enforce == crate::types::SandboxEnforce::Off {
1688 return false;
1689 }
1690 match self.sandbox.provider {
1691 crate::types::SandboxProvider::Process => !matches!(
1692 crate::sandbox::platform_support(self.sandbox.enforce, std::env::consts::OS),
1693 crate::sandbox::SandboxDecision::Off
1694 ),
1695 crate::types::SandboxProvider::Container => true,
1696 }
1697 }
1698
1699 pub fn degradation_note(&self) -> Option<String> {
1710 self.degradation_note_target(crate::sandbox_container::detect())
1711 }
1712
1713 pub(crate) fn degradation_note_target(
1717 &self,
1718 container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
1719 ) -> Option<String> {
1720 if self.sandbox.provider == crate::types::SandboxProvider::Container
1721 && self.sandbox.enforce != crate::types::SandboxEnforce::Off
1722 && container_runtime.is_none()
1723 {
1724 Some(container_gate_note(self.sandbox.enforce))
1725 } else {
1726 None
1727 }
1728 }
1729}
1730
1731pub fn run_bounded_gate_command_sandboxed(
1755 cwd: &std::path::Path,
1756 command: &str,
1757 policy: &MergeGatePolicy,
1758) -> (bool, String) {
1759 let (code, output) = run_bounded_gate_command_sandboxed_with_code(cwd, command, policy);
1760 (code == Some(0), output)
1761}
1762
1763pub(crate) fn run_bounded_gate_command_sandboxed_with_code(
1768 cwd: &std::path::Path,
1769 command: &str,
1770 policy: &MergeGatePolicy,
1771) -> (Option<i32>, String) {
1772 if !policy.enforces_on_this_host() {
1773 return run_bounded_gate_command_with_code(cwd, command);
1774 }
1775 let scratch =
1776 std::env::temp_dir().join(format!("kranz-gate-{}", uuid::Uuid::new_v4().simple()));
1777 if std::fs::create_dir_all(scratch.join("tmp")).is_err() {
1778 return (
1779 None,
1780 format!(
1781 "could not create the gate's sandbox scratch at {}",
1782 scratch.display()
1783 ),
1784 );
1785 }
1786 let cargo_home = crate::agent_env::cache_only_cargo_home(scratch.as_path());
1787 if !cargo_home.is_dir() {
1788 let _ = std::fs::remove_dir_all(&scratch);
1789 return (
1790 None,
1791 format!(
1792 "could not create the gate's cache-only Cargo home at {}",
1793 cargo_home.display()
1794 ),
1795 );
1796 }
1797 let runtime = match tokio::runtime::Builder::new_current_thread()
1798 .enable_all()
1799 .build()
1800 {
1801 Ok(runtime) => runtime,
1802 Err(error) => {
1803 let _ = std::fs::remove_dir_all(&scratch);
1804 return (None, format!("failed to create gate runtime: {error}"));
1805 }
1806 };
1807 let mut resolution = match resolve_gate_sandbox(
1808 &policy.sandbox,
1809 cwd,
1810 &policy.mission_dir,
1811 &scratch,
1812 &scratch,
1813 ) {
1814 Ok(resolution) => resolution,
1815 Err(error) => {
1816 let _ = std::fs::remove_dir_all(&scratch);
1817 return (
1818 None,
1819 format!("could not resolve the gate sandbox (failing closed): {error}"),
1820 );
1821 }
1822 };
1823 if let Some(note) = &resolution.note {
1824 tracing::warn!(note = %note, "merge gate sandbox degraded to a no-op");
1827 }
1828 let mut env = sanitized_gate_env();
1829 env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
1830 #[cfg(windows)]
1831 crate::agent_env::redirect_windows_profile_env(&mut env, &scratch);
1832 #[cfg(not(windows))]
1833 for var in ["TMPDIR", "TMP", "TEMP"] {
1834 env.insert(var.to_string(), scratch.join("tmp").display().to_string());
1835 }
1836 let (code, output) = runtime.block_on(run_shell_command_sandboxed_with_code(
1837 cwd,
1838 command,
1839 COMMAND_TIMEOUT,
1840 &env,
1841 &resolution.sandbox,
1842 ));
1843 if let Err(error) = resolution.sandbox.cleanup() {
1844 let _ = std::fs::remove_dir_all(&scratch);
1845 return (
1846 None,
1847 format!("gate sandbox cleanup failed closed after command execution: {error}"),
1848 );
1849 }
1850 let _ = std::fs::remove_dir_all(&scratch);
1851 (code, output)
1852}
1853
1854pub(crate) fn sanitized_gate_env() -> HashMap<String, String> {
1855 const SAFE: &[&str] = &[
1862 "PATH",
1863 "HOME",
1864 "USERPROFILE",
1865 "TMPDIR",
1866 "TMP",
1867 "TEMP",
1868 "RUSTUP_HOME",
1869 "NPM_CONFIG_CACHE",
1870 "CI",
1871 "TERM",
1872 "LANG",
1873 "LC_ALL",
1874 "TZ",
1875 ];
1876 let env: HashMap<String, String> = SAFE
1877 .iter()
1878 .filter_map(|key| {
1879 std::env::var_os(key).map(|value| ((*key).to_string(), value.to_string_lossy().into()))
1880 })
1881 .collect();
1882 #[cfg(windows)]
1883 let env = {
1884 let mut env = env;
1885 crate::agent_env::extend_windows_process_env(&mut env);
1886 crate::agent_env::extend_noncredential_toolchain_env(&mut env);
1892 env
1893 };
1894 env
1895}
1896
1897pub(crate) fn tail_chars(text: &str, max: usize) -> String {
1899 let count = text.chars().count();
1900 if count <= max {
1901 return text.to_string();
1902 }
1903 text.chars().skip(count - max).collect()
1904}
1905
1906#[cfg(test)]
1909mod tests {
1910 use super::*;
1911 #[cfg(unix)]
1912 use crate::runner;
1913
1914 #[test]
1915 fn control_wrapper_keeps_scratch_mounts_and_positional_snapshot_cwd() {
1916 let root = tempfile::tempdir().unwrap();
1917 let scratch = root.path().join("scratch");
1918 let snapshot = root.path().join("readonly snapshot's checkout");
1919 std::fs::create_dir(&scratch).unwrap();
1920 std::fs::create_dir(&snapshot).unwrap();
1921 let inputs = gate_sandbox_inputs(
1922 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
1923 &scratch,
1924 &root.path().join(".kranz/missions/control"),
1925 &scratch,
1926 );
1927 let sandbox = GateSandbox::Bubblewrap {
1928 inputs: Box::new(inputs),
1929 };
1930 let command = "sh check.sh && printf '%s' \"$HOME\"";
1931 let wrapped = sandbox
1932 .wrap_control_shell(&snapshot, command, &HashMap::new())
1933 .unwrap();
1934 let chdir = wrapped
1935 .args
1936 .iter()
1937 .position(|arg| arg == "--chdir")
1938 .unwrap();
1939 assert_eq!(
1940 wrapped.args[chdir + 1],
1941 std::fs::canonicalize(&scratch)
1942 .unwrap()
1943 .display()
1944 .to_string()
1945 );
1946 assert_eq!(
1947 &wrapped.args[chdir + 2..],
1948 &[
1949 "--",
1950 "/bin/sh",
1951 "-c",
1952 "cd -- \"$1\" && exec /bin/sh -c \"$2\"",
1953 "kranz-control",
1954 &snapshot.display().to_string(),
1955 command,
1956 ]
1957 );
1958 let writes: Vec<_> = wrapped
1959 .args
1960 .windows(3)
1961 .filter(|args| args[0] == "--bind")
1962 .map(|args| args[2].clone())
1963 .collect();
1964 assert!(writes.contains(
1965 &std::fs::canonicalize(&scratch)
1966 .unwrap()
1967 .display()
1968 .to_string()
1969 ));
1970 assert!(!writes.contains(&snapshot.display().to_string()));
1971 assert!(GateSandbox::Disabled
1972 .wrap_control_shell(&snapshot, command, &HashMap::new())
1973 .is_err());
1974 }
1975
1976 #[cfg(any(target_os = "macos", target_os = "linux"))]
1977 #[tokio::test]
1978 async fn control_wait_retains_the_leader_until_group_cleanup() {
1979 let root = tempfile::tempdir().unwrap();
1980 let mut command = tokio::process::Command::new("/bin/sh");
1981 command.args(["-c", "exit 7"]).env_clear();
1982 let mut child = ControlChild(
1983 configure_bounded_child(command, root.path(), &HashMap::new())
1984 .spawn()
1985 .unwrap(),
1986 );
1987 let pid = child.0.id().unwrap();
1988 for _ in 0..2 {
1989 tokio::time::timeout(Duration::from_secs(3), control_leader_exited(pid))
1990 .await
1991 .unwrap()
1992 .unwrap();
1993 }
1994 crate::backend_claude::kill_unreaped_group(&child.0);
1995 assert_eq!(child.0.wait().await.unwrap().code(), Some(7));
1996 assert!(
1997 child.0.id().is_none(),
1998 "the drop guard cannot signal a reaped PID"
1999 );
2000 }
2001
2002 #[cfg(any(target_os = "macos", target_os = "linux"))]
2003 #[tokio::test]
2004 async fn control_timeout_kills_a_leader_outside_its_original_group() {
2005 let root = tempfile::tempdir().unwrap();
2006 let ready = root.path().join("escaped-leader");
2007 let mut command = tokio::process::Command::new(std::env::current_exe().unwrap());
2008 command
2009 .args([
2010 "--ignored",
2011 "--exact",
2012 "command_exec::tests::control_escaped_leader_fixture",
2013 "--nocapture",
2014 ])
2015 .env_clear();
2016 let command = configure_bounded_child(
2017 command,
2018 root.path(),
2019 &HashMap::from([(
2020 "KRANZ_CONTROL_ESCAPED_LEADER".into(),
2021 ready.display().to_string(),
2022 )]),
2023 );
2024 let (code, output) = tokio::time::timeout(
2025 Duration::from_secs(5),
2026 run_control_command_bounded(
2027 command,
2028 Duration::from_secs(1),
2029 &std::sync::atomic::AtomicBool::new(false),
2030 ),
2031 )
2032 .await
2033 .expect("cleanup must terminate the escaped direct child before waiting");
2034 let evidence =
2035 std::fs::read_to_string(ready).expect("fixture moved out of its original group");
2036 let (pid, group) = evidence.split_once(' ').unwrap();
2037 assert_ne!(pid, group, "fixture must leave its original group");
2038 assert_eq!(code, None, "{output}");
2039 assert!(output.contains("timed out"), "{output}");
2040 }
2041
2042 #[cfg(any(target_os = "macos", target_os = "linux"))]
2043 #[test]
2044 #[ignore = "disposable subprocess fixture for direct-child timeout cleanup"]
2045 fn control_escaped_leader_fixture() {
2046 let Some(ready) = std::env::var_os("KRANZ_CONTROL_ESCAPED_LEADER") else {
2047 return;
2048 };
2049 let group = unsafe { libc::getpgid(libc::getppid()) };
2052 assert!(group > 0);
2053 assert_eq!(unsafe { libc::setpgid(0, group) }, 0);
2054 std::fs::write(ready, format!("{} {group}", std::process::id())).unwrap();
2055 std::thread::sleep(Duration::from_secs(30));
2056 }
2057
2058 #[cfg(any(target_os = "macos", target_os = "linux"))]
2059 #[tokio::test]
2060 async fn control_abort_cleans_unreaped_descendants() {
2061 let root = tempfile::tempdir().unwrap();
2062 let ready = root.path().join("ready");
2063 let marker = root.path().join("survived");
2064 let mut command = tokio::process::Command::new("/bin/sh");
2065 command
2066 .args([
2067 "-c",
2068 "(sleep 1; printf survived > \"$MARKER\") >/dev/null 2>&1 & printf ready > \"$READY\"; wait",
2069 ])
2070 .env_clear();
2071 let command = configure_bounded_child(
2072 command,
2073 root.path(),
2074 &HashMap::from([
2075 ("PATH".into(), "/usr/bin:/bin".into()),
2076 ("READY".into(), ready.display().to_string()),
2077 ("MARKER".into(), marker.display().to_string()),
2078 ]),
2079 );
2080 let task = tokio::spawn(async move {
2081 run_control_command_bounded(
2082 command,
2083 Duration::from_secs(5),
2084 &std::sync::atomic::AtomicBool::new(false),
2085 )
2086 .await
2087 });
2088 tokio::time::timeout(Duration::from_secs(3), async {
2089 while !ready.exists() {
2090 tokio::time::sleep(Duration::from_millis(10)).await;
2091 }
2092 })
2093 .await
2094 .expect("checker started before cancellation");
2095 task.abort();
2096 assert!(task.await.unwrap_err().is_cancelled());
2097 tokio::time::sleep(Duration::from_millis(1200)).await;
2098 assert!(!marker.exists(), "aborted runner left a live descendant");
2099 }
2100
2101 #[cfg(any(target_os = "macos", target_os = "linux"))]
2102 #[test]
2103 fn control_wrapper_reads_snapshot_and_cleans_every_exit() {
2104 use std::sync::atomic::{AtomicBool, Ordering};
2105 let _lock = GATE_SANDBOX_WRAP_LOCK
2106 .lock()
2107 .unwrap_or_else(|error| error.into_inner());
2108 if !gate_wrap_enforcement_available() {
2109 return;
2110 }
2111 let _env = crate::agent_env::EnvTestGuard::engage(&[(
2112 "KRANZ_CONTROL_AMBIENT_SENTINEL",
2113 "not-authorized",
2114 )]);
2115 let (repo, mission) = gate_wrap_layout();
2116 let snapshot = repo.path().join("readonly snapshot's checkout");
2117 std::fs::create_dir(&snapshot).unwrap();
2118 std::fs::write(snapshot.join("checker-input"), "approved").unwrap();
2119 let checker = r#"set -eu
2120[ "$(cat checker-input)" = approved ]
2121[ -z "${KRANZ_CONTROL_AMBIENT_SENTINEL+x}" ]
2122[ "$CARGO_NET_OFFLINE" = true ]
2123if (printf changed > checker-input) 2>/dev/null; then exit 90; fi
2124if [ "$MODE" = inherited ]; then
2125 (sleep 2; printf survived > "$CONTROL_MARKER") &
2126else
2127 (sleep 2; printf survived > "$CONTROL_MARKER") >/dev/null 2>&1 &
2128fi
2129printf ready > "$CONTROL_READY"
2130printf control-stdout
2131printf control-stderr >&2
2132case "$MODE" in
2133 nonzero) exit 7;;
2134 timeout|cancel) wait;;
2135esac
2136"#;
2137 std::fs::write(snapshot.join("check.sh"), checker).unwrap();
2138 let scratch = tempfile::tempdir().unwrap();
2139 let sandbox = resolve_gate_sandbox(
2140 &fs_sandbox_config(crate::types::SandboxEnforce::FsNet),
2141 scratch.path(),
2142 &mission,
2143 scratch.path(),
2144 scratch.path(),
2145 )
2146 .unwrap()
2147 .sandbox;
2148 let mut markers = Vec::new();
2149 for mode in ["success", "nonzero", "inherited", "timeout", "cancel"] {
2150 let marker = scratch.path().join(format!("{mode}.survived"));
2151 let ready = scratch.path().join(format!("{mode}.ready"));
2152 let env = HashMap::from([
2153 ("PATH".into(), "/usr/bin:/bin".into()),
2154 ("MODE".into(), mode.into()),
2155 ("CONTROL_MARKER".into(), marker.display().to_string()),
2156 ("CONTROL_READY".into(), ready.display().to_string()),
2157 ]);
2158 let cancelled = AtomicBool::new(false);
2159 let (code, output) = std::thread::scope(|scope| {
2160 let ready = &ready;
2161 let cancelled = &cancelled;
2162 if mode == "cancel" {
2163 scope.spawn(move || {
2164 let deadline = std::time::Instant::now() + Duration::from_secs(5);
2165 while !ready.exists() {
2166 assert!(
2167 std::time::Instant::now() < deadline,
2168 "checker did not start"
2169 );
2170 std::thread::sleep(Duration::from_millis(10));
2171 }
2172 cancelled.store(true, Ordering::Release);
2173 });
2174 }
2175 run_control_command_sandboxed_blocking(
2176 &snapshot,
2177 "sh check.sh",
2178 Duration::from_secs(if mode == "timeout" { 1 } else { 5 }),
2179 &env,
2180 &sandbox,
2181 cancelled,
2182 )
2183 });
2184 assert!(ready.exists(), "{mode}: checker did not run: {output}");
2185 match mode {
2186 "timeout" => {
2187 assert_eq!(code, None);
2188 assert!(output.contains("timed out"));
2189 }
2190 "cancel" => {
2191 assert_eq!(code, None);
2192 assert!(output.contains("cancelled"));
2193 }
2194 _ => {
2195 assert_eq!(
2196 code,
2197 Some(if mode == "nonzero" { 7 } else { 0 }),
2198 "{mode}: {output}"
2199 );
2200 assert!(output.contains("control-stdout"), "{output}");
2201 assert!(output.contains("control-stderr"), "{output}");
2202 }
2203 }
2204 markers.push(marker);
2205 }
2206 let control = scratch.path().join("unsupervised.survived");
2209 let mut positive = std::process::Command::new("/bin/sh")
2210 .args([
2211 "-c",
2212 "sleep 2; printf survived > \"$1\"",
2213 "positive",
2214 &control.display().to_string(),
2215 ])
2216 .spawn()
2217 .unwrap();
2218 assert!(positive.wait().unwrap().success());
2219 assert!(control.exists());
2220 for marker in markers {
2221 assert!(
2222 !marker.exists(),
2223 "descendant survived cleanup: {}",
2224 marker.display()
2225 );
2226 }
2227 assert_eq!(
2228 std::fs::read_to_string(snapshot.join("checker-input")).unwrap(),
2229 "approved"
2230 );
2231 }
2232
2233 #[test]
2234 fn tail_chars_keeps_the_end() {
2235 assert_eq!(tail_chars("abcdef", 3), "def");
2236 assert_eq!(tail_chars("ab", 3), "ab");
2237 assert_eq!(tail_chars("héllo", 2), "lo");
2238 }
2239
2240 #[cfg(unix)]
2246 #[tokio::test]
2247 async fn shell_command_timeout_kills_the_whole_process_tree() {
2248 let dir = tempfile::tempdir().unwrap();
2249 let pidfile = dir.path().join("child.pid");
2250 let command = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
2253
2254 let (ok, output) = tokio::time::timeout(
2255 Duration::from_secs(10),
2256 run_shell_command_with_timeout(
2257 dir.path(),
2258 &command,
2259 Duration::from_millis(500),
2260 &std::collections::HashMap::new(),
2261 ),
2262 )
2263 .await
2264 .expect("timed-out command must return promptly");
2265 assert!(!ok, "command must be reported failed: {output}");
2266 assert!(output.contains("timed out"), "got: {output}");
2267
2268 let pid: i32 = std::fs::read_to_string(&pidfile)
2269 .expect("shell wrote the background pid before the timeout")
2270 .trim()
2271 .parse()
2272 .expect("pidfile contains a pid");
2273
2274 let deadline = std::time::Instant::now() + Duration::from_secs(5);
2277 while unsafe { libc::kill(pid, 0) } == 0 {
2278 assert!(
2279 std::time::Instant::now() < deadline,
2280 "background child {pid} survived the group kill"
2281 );
2282 tokio::time::sleep(Duration::from_millis(50)).await;
2283 }
2284 }
2285
2286 #[cfg(unix)]
2287 #[tokio::test]
2288 async fn shell_command_drains_large_output_while_running_and_keeps_only_the_tail() {
2289 let dir = tempfile::tempdir().unwrap();
2290 let command = "i=0; while [ \"$i\" -lt 20000 ]; do \
2291 printf '0123456789abcdef0123456789abcdef\\n'; \
2292 i=$((i + 1)); done; printf 'OUTPUT-END'";
2293
2294 let (ok, output) = run_shell_command_with_timeout(
2295 dir.path(),
2296 command,
2297 Duration::from_secs(10),
2298 &std::collections::HashMap::new(),
2299 )
2300 .await;
2301
2302 assert!(ok, "large-output command must complete: {output}");
2303 assert!(output.ends_with("OUTPUT-END"), "{output}");
2304 assert!(
2305 output.chars().count() <= COMMAND_OUTPUT_TAIL,
2306 "retained output exceeded the cap: {} chars",
2307 output.chars().count()
2308 );
2309 }
2310
2311 #[test]
2312 fn merge_gate_environment_excludes_server_secrets() {
2313 let env = sanitized_gate_env();
2314 for secret in [
2315 "ANTHROPIC_API_KEY",
2316 "OPENAI_API_KEY",
2317 "SLACK_BOT_TOKEN",
2318 "GITHUB_TOKEN",
2319 "GH_TOKEN",
2320 "SSH_AUTH_SOCK",
2321 "AWS_SECRET_ACCESS_KEY",
2322 ] {
2323 assert!(!env.contains_key(secret), "gate env leaked {secret}");
2324 }
2325 assert!(
2326 !env.contains_key("CARGO_HOME"),
2327 "the ambient Cargo root is a credential directory; \
2328 run_bounded_gate_command substitutes a cache-only home"
2329 );
2330 assert!(env.keys().all(|key| matches!(
2331 key.as_str(),
2332 "PATH"
2333 | "HOME"
2334 | "USERPROFILE"
2335 | "TMPDIR"
2336 | "TMP"
2337 | "TEMP"
2338 | "APPDATA"
2339 | "LOCALAPPDATA"
2340 | "SystemRoot"
2341 | "ComSpec"
2342 | "PATHEXT"
2343 | "SystemDrive"
2344 | "windir"
2345 | "OS"
2346 | "PROCESSOR_ARCHITECTURE"
2347 | "PSModulePath"
2348 | "RUSTUP_HOME"
2349 | "NPM_CONFIG_CACHE"
2350 | "CI"
2351 | "TERM"
2352 | "LANG"
2353 | "LC_ALL"
2354 | "TZ"
2355 )));
2356 }
2357
2358 #[cfg(unix)]
2364 #[test]
2365 fn contract_cargo_home_replaces_ambient_root_in_merge_gates() {
2366 let source = tempfile::tempdir().unwrap();
2367 std::fs::create_dir_all(source.path().join("registry")).unwrap();
2368 std::fs::write(source.path().join("registry/cache-marker"), "registry").unwrap();
2369 std::fs::write(source.path().join("credentials.toml"), "operator-secret").unwrap();
2370 let _guard = crate::agent_env::EnvTestGuard::engage(&[(
2371 "CARGO_HOME",
2372 source.path().to_str().expect("utf-8 temp path"),
2373 )]);
2374 let dir = tempfile::tempdir().unwrap();
2375
2376 let (ok, output) = run_bounded_gate_command(
2377 dir.path(),
2378 "printf '%s' \"$CARGO_HOME\" \
2379 && test -f \"$CARGO_HOME/registry/cache-marker\" \
2380 && test ! -e \"$CARGO_HOME/credentials.toml\"",
2381 );
2382 assert!(
2383 ok,
2384 "gate command must see a seeded, credential-free Cargo home: {output}"
2385 );
2386 assert!(
2387 !output.is_empty() && output != source.path().to_string_lossy().as_ref(),
2388 "the gate must NOT receive the ambient Cargo root: {output}"
2389 );
2390 }
2391 #[cfg(unix)]
2397 #[tokio::test]
2398 async fn contract_command_cannot_see_ambient_secrets() {
2399 let _poison = crate::agent_env::EnvTestGuard::engage(&[
2400 ("GH_TOKEN", "hunter2"),
2401 ("SLACK_BOT_TOKEN", "x"),
2402 ("AWS_SECRET_ACCESS_KEY", "y"),
2403 ]);
2404 let dir = tempfile::tempdir().unwrap();
2405 let scratch = tempfile::tempdir().unwrap();
2406 let env = crate::agent_env::contract_command_env(scratch.path(), Some("deadbeef"), &[]);
2407
2408 let (ok, output) = run_shell_command(
2410 dir.path(),
2411 "test -z \"$GH_TOKEN\" && test -z \"$SLACK_BOT_TOKEN\" && test -z \"$AWS_SECRET_ACCESS_KEY\"",
2412 &env,
2413 )
2414 .await;
2415 assert!(
2416 ok,
2417 "poisoned ambient vars reached the contract command: {output}"
2418 );
2419
2420 let (ok, names) =
2425 run_shell_command(dir.path(), "env | sed 's/=.*//' | LC_ALL=C sort", &env).await;
2426 assert!(ok, "{names}");
2427 for leaked in ["GH_TOKEN", "SLACK_BOT_TOKEN", "AWS_SECRET_ACCESS_KEY"] {
2428 assert!(
2429 !names.lines().any(|name| name == leaked),
2430 "contract env leaked {leaked}:\n{names}"
2431 );
2432 }
2433 assert!(
2434 names.lines().any(|name| name == "PATH"),
2435 "PATH must cross:\n{names}"
2436 );
2437
2438 let (ok, managed) = run_shell_command(
2439 dir.path(),
2440 "printf 'HOME=%s\nKRANZ_BASE_SHA=%s\nCARGO_HOME=%s\n' \"$HOME\" \"$KRANZ_BASE_SHA\" \"$CARGO_HOME\"",
2441 &env,
2442 )
2443 .await;
2444 assert!(ok, "{managed}");
2445 assert!(
2446 managed.contains(&format!("HOME={}", scratch.path().display())),
2447 "HOME must be the per-mission scratch:\n{managed}"
2448 );
2449 assert!(
2450 managed.contains("KRANZ_BASE_SHA=deadbeef"),
2451 "base sha must reach the contract env:\n{managed}"
2452 );
2453 let cargo_home = env.get("CARGO_HOME").expect("CARGO_HOME");
2454 assert!(
2455 std::path::Path::new(cargo_home).starts_with(scratch.path()),
2456 "contract CARGO_HOME must live under mission scratch: {cargo_home}"
2457 );
2458 assert!(
2459 managed.contains(&format!("CARGO_HOME={cargo_home}")),
2460 "cache-only Cargo home must reach the child:\n{managed}"
2461 );
2462 }
2463
2464 #[cfg(unix)]
2467 #[tokio::test]
2468 async fn contract_env_passthrough_admits_only_the_named_var() {
2469 let _guard = crate::agent_env::EnvTestGuard::engage(&[
2470 ("KRANZ_CONTRACT_TEST_CRED", "cred-value"),
2471 ("GH_TOKEN", "hunter2"),
2472 ]);
2473 let dir = tempfile::tempdir().unwrap();
2474 let scratch = tempfile::tempdir().unwrap();
2475
2476 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
2478 let (ok, output) =
2479 run_shell_command(dir.path(), "test -z \"$KRANZ_CONTRACT_TEST_CRED\"", &env).await;
2480 assert!(
2481 ok,
2482 "an unconfigured var must not reach the contract env: {output}"
2483 );
2484
2485 let env = crate::agent_env::contract_command_env(
2488 scratch.path(),
2489 None,
2490 &["KRANZ_CONTRACT_TEST_CRED".to_string()],
2491 );
2492 let (ok, output) = run_shell_command(
2493 dir.path(),
2494 "test \"$KRANZ_CONTRACT_TEST_CRED\" = cred-value && test -z \"$GH_TOKEN\"",
2495 &env,
2496 )
2497 .await;
2498 assert!(
2499 ok,
2500 "the passthrough-named var must cross, nothing else: {output}"
2501 );
2502 }
2503
2504 #[cfg(unix)]
2509 #[tokio::test]
2510 async fn base_sha_reaches_final_gate_env() {
2511 let dir = tempfile::tempdir().unwrap();
2512 let env = runner::contract_env(Some("deadbeefcafe"));
2513 let (ok, output) = run_shell_command_with_timeout(
2514 dir.path(),
2515 "test \"$KRANZ_BASE_SHA\" = deadbeefcafe",
2516 Duration::from_secs(10),
2517 &env,
2518 )
2519 .await;
2520 assert!(ok, "expected command to succeed: {output}");
2521 }
2522
2523 #[tokio::test]
2528 async fn shell_command_with_code_reports_the_real_exit_code() {
2529 let dir = tempfile::tempdir().unwrap();
2530 let env = std::collections::HashMap::new();
2531
2532 let (code, output) = run_shell_command_with_code(dir.path(), "echo hi", &env).await;
2533 assert_eq!(code, Some(0), "{output}");
2534 assert!(output.contains("hi"), "{output}");
2535
2536 let (code, output) = run_shell_command_with_code(dir.path(), "exit 3", &env).await;
2537 assert_eq!(code, Some(3), "{output}");
2538 }
2539
2540 #[cfg(unix)]
2546 #[tokio::test]
2547 async fn bounded_argv_timeout_kills_the_whole_process_tree() {
2548 let dir = tempfile::tempdir().unwrap();
2549 let pidfile = dir.path().join("child.pid");
2550 let script = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
2551 let env = std::collections::HashMap::new();
2552
2553 let (code, output) = tokio::time::timeout(
2554 Duration::from_secs(10),
2555 run_bounded_argv(
2556 dir.path(),
2557 std::path::Path::new("/bin/sh"),
2558 &["-c".to_string(), script],
2559 Duration::from_millis(500),
2560 &env,
2561 ),
2562 )
2563 .await
2564 .expect("timed-out command must return promptly");
2565 assert_eq!(code, None, "a timeout yields no exit code: {output}");
2566 assert!(output.contains("timed out"), "got: {output}");
2567
2568 let pid: i32 = std::fs::read_to_string(&pidfile)
2569 .expect("shell wrote the background pid before the timeout")
2570 .trim()
2571 .parse()
2572 .expect("pidfile contains a pid");
2573
2574 let deadline = std::time::Instant::now() + Duration::from_secs(5);
2577 while unsafe { libc::kill(pid, 0) } == 0 {
2578 assert!(
2579 std::time::Instant::now() < deadline,
2580 "background child {pid} survived the group kill"
2581 );
2582 tokio::time::sleep(Duration::from_millis(50)).await;
2583 }
2584 }
2585
2586 #[cfg(unix)]
2591 #[tokio::test]
2592 async fn bounded_argv_drains_large_output_and_reports_exit_codes() {
2593 let dir = tempfile::tempdir().unwrap();
2594 let env = std::collections::HashMap::new();
2595 let big = "i=0; while [ \"$i\" -lt 20000 ]; do \
2596 printf '0123456789abcdef0123456789abcdef\\n'; \
2597 i=$((i + 1)); done; printf 'OUTPUT-END'";
2598
2599 let (code, output) = run_bounded_argv(
2600 dir.path(),
2601 std::path::Path::new("/bin/sh"),
2602 &["-c".to_string(), big.to_string()],
2603 Duration::from_secs(10),
2604 &env,
2605 )
2606 .await;
2607
2608 assert_eq!(
2609 code,
2610 Some(0),
2611 "large-output command must complete: {output}"
2612 );
2613 assert!(output.ends_with("OUTPUT-END"), "{output}");
2614 assert!(
2615 output.chars().count() <= COMMAND_OUTPUT_TAIL,
2616 "retained output exceeded the cap: {} chars",
2617 output.chars().count()
2618 );
2619
2620 let (code, output) = run_bounded_argv(
2621 dir.path(),
2622 std::path::Path::new("/bin/sh"),
2623 &["-c".to_string(), "exit 3".to_string()],
2624 Duration::from_secs(10),
2625 &env,
2626 )
2627 .await;
2628 assert_eq!(code, Some(3), "{output}");
2629 }
2630
2631 #[cfg(unix)]
2642 static GATE_SANDBOX_WRAP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2643
2644 #[cfg(target_os = "macos")]
2645 fn gate_wrap_sandbox_exec_can_apply() -> bool {
2646 let found = std::process::Command::new("which")
2647 .arg("sandbox-exec")
2648 .output()
2649 .map(|o| o.status.success())
2650 .unwrap_or(false);
2651 if !found {
2652 crate::test_capability::skip(
2653 crate::test_capability::capability::SANDBOX_EXEC,
2654 "sandbox-exec not found on this host",
2655 );
2656 return false;
2657 }
2658 let smoke = std::process::Command::new("sandbox-exec")
2659 .arg("-p")
2660 .arg("(version 1)\n(allow default)\n")
2661 .arg("/usr/bin/true")
2662 .output();
2663 match smoke {
2664 Ok(output) if output.status.success() => true,
2665 Ok(output) => {
2666 eprintln!(
2667 "sandbox-exec cannot apply a smoke profile on this host; skipping: {}",
2668 String::from_utf8_lossy(&output.stderr)
2669 );
2670 false
2671 }
2672 Err(e) => {
2673 eprintln!("sandbox-exec smoke probe failed; skipping: {e}");
2674 false
2675 }
2676 }
2677 }
2678
2679 #[cfg(target_os = "linux")]
2680 fn gate_wrap_bwrap_can_apply() -> bool {
2681 if !crate::sandbox::command_available("bwrap") {
2682 crate::test_capability::skip(
2683 crate::test_capability::capability::BWRAP,
2684 "bwrap not found on this host",
2685 );
2686 return false;
2687 }
2688 let smoke = std::process::Command::new("bwrap")
2689 .args([
2690 "--die-with-parent",
2691 "--ro-bind",
2692 "/",
2693 "/",
2694 "--dev",
2695 "/dev",
2696 "--proc",
2697 "/proc",
2698 "--",
2699 "/bin/true",
2700 ])
2701 .output();
2702 match smoke {
2703 Ok(output) if output.status.success() => true,
2704 Ok(output) => {
2705 eprintln!(
2706 "bwrap cannot apply a smoke sandbox on this host; skipping: {}",
2707 String::from_utf8_lossy(&output.stderr)
2708 );
2709 false
2710 }
2711 Err(e) => {
2712 eprintln!("bwrap smoke probe failed; skipping: {e}");
2713 false
2714 }
2715 }
2716 }
2717
2718 #[cfg(unix)]
2721 fn gate_wrap_enforcement_available() -> bool {
2722 #[cfg(target_os = "macos")]
2723 {
2724 gate_wrap_sandbox_exec_can_apply()
2725 }
2726 #[cfg(target_os = "linux")]
2727 {
2728 gate_wrap_bwrap_can_apply()
2729 }
2730 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2731 {
2732 false
2733 }
2734 }
2735
2736 fn fs_sandbox_config(enforce: crate::types::SandboxEnforce) -> crate::types::SandboxConfig {
2737 crate::types::SandboxConfig {
2738 enforce,
2739 provider: crate::types::SandboxProvider::Process,
2740 image: None,
2741 extra_write: vec![],
2742 egress: vec![],
2743 }
2744 }
2745
2746 #[cfg(unix)]
2751 fn gate_wrap_layout() -> (tempfile::TempDir, std::path::PathBuf) {
2752 gate_wrap_layout_with_repo(tempfile::tempdir().unwrap())
2753 }
2754
2755 #[cfg(unix)]
2756 fn gate_wrap_layout_with_repo(
2757 repo: tempfile::TempDir,
2758 ) -> (tempfile::TempDir, std::path::PathBuf) {
2759 let kranz_dir = repo.path().join(".kranz");
2760 let mission = kranz_dir.join("missions").join("m-gate");
2761 std::fs::create_dir_all(mission.join("runs")).unwrap();
2762 std::fs::create_dir_all(mission.join("control")).unwrap();
2763 std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
2764 std::fs::write(mission.join("state.json"), "{}").unwrap();
2765 for name in ["serve.token", "serve.read.token", "config.json"] {
2766 std::fs::write(kranz_dir.join(name), "secret").unwrap();
2767 }
2768 std::fs::write(repo.path().join("public.txt"), "public").unwrap();
2769 (repo, mission)
2770 }
2771
2772 #[test]
2781 fn gate_sandbox_wrap_resolve_matrix() {
2782 let repo = tempfile::tempdir().unwrap();
2783 let mission = repo.path().join(".kranz").join("missions").join("m-x");
2784 std::fs::create_dir_all(&mission).unwrap();
2785 let scratch = tempfile::tempdir().unwrap();
2786 let off = crate::types::SandboxConfig::default();
2787 let fs = fs_sandbox_config(crate::types::SandboxEnforce::Fs);
2788
2789 let resolution = resolve_gate_sandbox_target(
2791 &off,
2792 repo.path(),
2793 &mission,
2794 scratch.path(),
2795 scratch.path(),
2796 "macos",
2797 false,
2798 None,
2799 None,
2800 )
2801 .unwrap();
2802 assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
2803 assert!(resolution.note.is_none());
2804
2805 let resolution = resolve_gate_sandbox_target(
2808 &fs,
2809 repo.path(),
2810 &mission,
2811 scratch.path(),
2812 scratch.path(),
2813 "macos",
2814 false,
2815 None,
2816 None,
2817 )
2818 .unwrap();
2819 assert!(resolution.note.is_none());
2820 let GateSandbox::Seatbelt {
2821 enforce,
2822 profile_path,
2823 } = &resolution.sandbox
2824 else {
2825 panic!("fs on macOS must resolve to Seatbelt");
2826 };
2827 assert_eq!(*enforce, crate::types::SandboxEnforce::Fs);
2828 let profile = std::fs::read_to_string(profile_path).unwrap();
2829 assert!(profile.contains("(deny default)"), "{profile}");
2830 assert!(
2831 profile.contains("(literal \"/dev/null\")"),
2832 "the gate profile must add the /dev/null device write allow:\n{profile}"
2833 );
2834 assert!(
2835 profile.contains("(literal \"/dev/ptmx\")"),
2836 "pty harness support (pty-functional-validation): the gate profile must \
2837 permit the ptmx multiplexer:\n{profile}"
2838 );
2839 assert!(
2843 profile.contains(
2844 "(allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))"
2845 ),
2846 "the grantpt/unlockpt ioctl allow must be scoped to /dev/ptmx and the \
2847 tty slave nodes:\n{profile}"
2848 );
2849 assert!(
2850 !profile.contains("(allow file-ioctl)"),
2851 "the ioctl allow must never be unscoped again (every device the gate \
2852 can open becomes ioctl-able):\n{profile}"
2853 );
2854 assert!(
2855 !profile.contains("xcrun_db"),
2856 "13th-pass review (P1): the gate profile must NOT permit writes to the \
2857 shared per-user xcrun cache (prewarm + deny posture):\n{profile}"
2858 );
2859 assert!(
2860 profile.contains("events.jsonl"),
2861 "mission metadata write denies must ride along:\n{profile}"
2862 );
2863 assert!(
2864 profile.contains("serve.token"),
2865 "authority read denies must ride along:\n{profile}"
2866 );
2867
2868 let resolution = resolve_gate_sandbox_target(
2870 &fs,
2871 repo.path(),
2872 &mission,
2873 scratch.path(),
2874 scratch.path(),
2875 "linux",
2876 true,
2877 None,
2878 None,
2879 )
2880 .unwrap();
2881 let GateSandbox::Bubblewrap { inputs } = &resolution.sandbox else {
2882 panic!("fs on linux with bwrap must resolve to Bubblewrap");
2883 };
2884 assert_eq!(inputs.session_cwd, repo.path());
2885 assert_eq!(inputs.tmpdir, scratch.path());
2886 assert_eq!(inputs.mission_dir, mission);
2887
2888 let error = resolve_gate_sandbox_target(
2891 &fs,
2892 repo.path(),
2893 &mission,
2894 scratch.path(),
2895 scratch.path(),
2896 "linux",
2897 false,
2898 None,
2899 None,
2900 )
2901 .expect_err("linux without bwrap must fail closed");
2902 assert!(error.to_string().contains("bwrap"), "{error}");
2903
2904 let resolution = resolve_gate_sandbox_target(
2906 &fs,
2907 repo.path(),
2908 &mission,
2909 scratch.path(),
2910 scratch.path(),
2911 "windows",
2912 false,
2913 None,
2914 None,
2915 )
2916 .expect("Windows process gates resolve AppContainer");
2917 let GateSandbox::AppContainer { inputs, .. } = &resolution.sandbox else {
2918 panic!("fs on Windows must resolve AppContainer");
2919 };
2920 assert_eq!(inputs.session_cwd, repo.path());
2921 assert_eq!(inputs.tmpdir, scratch.path());
2922 assert_eq!(inputs.mission_dir, mission);
2923
2924 let error = resolve_gate_sandbox_target(
2929 &fs,
2930 repo.path(),
2931 &mission,
2932 scratch.path(),
2933 scratch.path(),
2934 "solaris",
2935 false,
2936 None,
2937 None,
2938 )
2939 .expect_err("an unknown platform must fail closed");
2940 assert!(error.to_string().contains("unsupported"), "{error}");
2941 assert!(
2942 error
2943 .to_string()
2944 .contains("refusing to run engine-run gates unsandboxed"),
2945 "{error}"
2946 );
2947 }
2948
2949 #[test]
2959 fn gate_profile_extras_scopes_file_ioctl_to_pty_devices() {
2960 let extras = gate_profile_extras();
2961 assert!(
2962 extras.contains(
2963 "(allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))"
2964 ),
2965 "the ioctl allow must be scoped to the pty device pair:\n{extras}"
2966 );
2967 assert!(
2968 !extras.contains("(allow file-ioctl)"),
2969 "the unrestricted ioctl allow must not return:\n{extras}"
2970 );
2971 assert!(extras.contains("(literal \"/dev/ptmx\")"), "{extras}");
2974 assert!(extras.contains("^/dev/tty[p-t][0-9a-f]+$"), "{extras}");
2975 assert!(
2976 extras.contains("(allow signal (target same-sandbox))"),
2977 "{extras}"
2978 );
2979 }
2980
2981 #[test]
2989 fn gate_profile_extras_deny_the_operators_own_terminal() {
2990 let extras = gate_profile_extras();
2991 let ttys = crate::sandbox::operator_tty_paths();
2992 if ttys.is_empty() {
2993 assert!(
2998 !extras.contains("(deny file-read* file-write* file-ioctl"),
2999 "no tty means no deny block:\n{extras}"
3000 );
3001 return;
3002 }
3003 assert!(
3004 extras.contains("(deny file-read* file-write* file-ioctl"),
3005 "a controlling terminal must produce a deny block:\n{extras}"
3006 );
3007 for tty in &ttys {
3008 let expected = format!("(literal \"{}\")", crate::sandbox::escape_sbpl_literal(tty));
3009 assert!(
3010 extras.contains(&expected),
3011 "the operator terminal {} must be denied:\n{extras}",
3012 tty.display()
3013 );
3014 }
3015 let allow = extras
3018 .find("(allow file-ioctl (literal \"/dev/ptmx\")")
3019 .expect("the pty ioctl allow");
3020 let deny = extras
3021 .find("(deny file-read* file-write* file-ioctl")
3022 .expect("the terminal deny");
3023 assert!(deny > allow, "the deny must follow the allows:\n{extras}");
3024 }
3025
3026 #[test]
3030 fn session_profile_denies_the_operators_own_terminal() {
3031 let repo = tempfile::tempdir().unwrap();
3032 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3033 std::fs::create_dir_all(&mission).unwrap();
3034 let scratch = tempfile::tempdir().unwrap();
3035 let profile = crate::sandbox::generate_profile(&crate::sandbox::SandboxInputs {
3036 enforce: crate::types::SandboxEnforce::Fs,
3037 session_cwd: repo.path().to_path_buf(),
3038 mission_dir: mission,
3039 tmpdir: scratch.path().to_path_buf(),
3040 extra_write: vec![],
3041 egress: vec![],
3042 validator_read_deny_roots: vec![],
3043 });
3044
3045 for tty in crate::sandbox::operator_tty_paths() {
3046 let expected = format!(
3047 "(literal \"{}\")",
3048 crate::sandbox::escape_sbpl_literal(&tty)
3049 );
3050 assert!(
3051 profile.contains(&expected),
3052 "the session profile must deny the operator terminal {}:\n{profile}",
3053 tty.display()
3054 );
3055 }
3056 }
3057
3058 #[test]
3070 fn container_gate_wrap_resolve_matrix() {
3071 let repo = tempfile::tempdir().unwrap();
3072 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3073 std::fs::create_dir_all(&mission).unwrap();
3074 let scratch = tempfile::tempdir().unwrap();
3075 let container = |enforce| crate::types::SandboxConfig {
3076 enforce,
3077 provider: crate::types::SandboxProvider::Container,
3078 image: None,
3079 extra_write: vec![],
3080 egress: vec![],
3081 };
3082 let runtime = Some(crate::sandbox_container::ContainerRuntime::Docker);
3083
3084 let resolution = resolve_gate_sandbox_target(
3086 &container(crate::types::SandboxEnforce::Fs),
3087 repo.path(),
3088 &mission,
3089 scratch.path(),
3090 scratch.path(),
3091 "linux",
3092 false,
3093 runtime,
3094 None,
3095 )
3096 .unwrap();
3097 assert!(resolution.note.is_none());
3098 let GateSandbox::Container { inputs, spec } = &resolution.sandbox else {
3099 panic!("container + runtime must resolve to GateSandbox::Container on linux");
3100 };
3101 assert_eq!(inputs.session_cwd, repo.path());
3102 assert_eq!(inputs.tmpdir, scratch.path());
3103 assert_eq!(inputs.mission_dir, mission);
3104 assert_eq!(inputs.enforce, crate::types::SandboxEnforce::Fs);
3105 assert_eq!(
3106 spec.runtime,
3107 crate::sandbox_container::ContainerRuntime::Docker
3108 );
3109 assert_eq!(spec.image, crate::sandbox_container::DEFAULT_IMAGE);
3110
3111 let proven = resolve_gate_sandbox_target(
3115 &container(crate::types::SandboxEnforce::Fs),
3116 repo.path(),
3117 &mission,
3118 scratch.path(),
3119 scratch.path(),
3120 "macos",
3121 false,
3122 runtime,
3123 Some(crate::sandbox_container::MountProof::Proven),
3124 )
3125 .expect("a proven macOS host must resolve its container gate");
3126 assert!(
3127 matches!(proven.sandbox, GateSandbox::Container { .. }),
3128 "{:?}",
3129 proven.sandbox
3130 );
3131
3132 let unshared = resolve_gate_sandbox_target(
3135 &container(crate::types::SandboxEnforce::Fs),
3136 repo.path(),
3137 &mission,
3138 scratch.path(),
3139 scratch.path(),
3140 "macos",
3141 false,
3142 runtime,
3143 Some(crate::sandbox_container::MountProof::Failed(
3144 "docker accepted a bind mount of /var/folders/x and shared nothing".to_string(),
3145 )),
3146 )
3147 .expect_err("a failed proof must refuse the gate");
3148 assert!(
3149 unshared.to_string().contains("/var/folders/x"),
3150 "{unshared}"
3151 );
3152
3153 for target_os in ["macos", "windows"] {
3157 let error = resolve_gate_sandbox_target(
3158 &container(crate::types::SandboxEnforce::Fs),
3159 repo.path(),
3160 &mission,
3161 scratch.path(),
3162 scratch.path(),
3163 target_os,
3164 false,
3165 runtime,
3166 None,
3167 )
3168 .expect_err("an unproved container gate must fail closed");
3169 assert!(
3170 error
3171 .to_string()
3172 .contains("unverified container mount contract"),
3173 "{error}"
3174 );
3175 if target_os == "macos" {
3176 assert!(
3177 error.to_string().contains("requires a bind-mount proof"),
3178 "{error}"
3179 );
3180 assert!(
3181 error.to_string().contains("sandbox.provider=\"process\""),
3182 "{error}"
3183 );
3184 }
3185 }
3186
3187 let mut imaged = container(crate::types::SandboxEnforce::Fs);
3190 imaged.image = Some("ghcr.io/example/kranz-worker:1".to_string());
3191 let resolution = resolve_gate_sandbox_target(
3192 &imaged,
3193 repo.path(),
3194 &mission,
3195 scratch.path(),
3196 scratch.path(),
3197 "linux",
3198 false,
3199 runtime,
3200 None,
3201 )
3202 .unwrap();
3203 let GateSandbox::Container { spec, .. } = &resolution.sandbox else {
3204 panic!("container + runtime must resolve to GateSandbox::Container");
3205 };
3206 assert_eq!(spec.image, "ghcr.io/example/kranz-worker:1");
3207
3208 let error = resolve_gate_sandbox_target(
3211 &container(crate::types::SandboxEnforce::Fs),
3212 repo.path(),
3213 &mission,
3214 scratch.path(),
3215 scratch.path(),
3216 "linux",
3217 false,
3218 None,
3219 None,
3220 )
3221 .expect_err("container without a runtime must fail closed");
3222 assert!(
3223 error.to_string().contains("no container runtime"),
3224 "{error}"
3225 );
3226 assert!(
3227 error
3228 .to_string()
3229 .contains("refusing to run engine-run gates unsandboxed"),
3230 "{error}"
3231 );
3232
3233 let mut egress = container(crate::types::SandboxEnforce::FsNet);
3237 egress.egress = vec!["crates.io:443".to_string()];
3238 let error = resolve_gate_sandbox_target(
3239 &egress,
3240 repo.path(),
3241 &mission,
3242 scratch.path(),
3243 scratch.path(),
3244 "linux",
3245 false,
3246 runtime,
3247 None,
3248 )
3249 .expect_err("container fs+net with an egress list must fail closed");
3250 assert!(error.to_string().contains("advisory"), "{error}");
3251
3252 let resolution = resolve_gate_sandbox_target(
3256 &container(crate::types::SandboxEnforce::FsNet),
3257 repo.path(),
3258 &mission,
3259 scratch.path(),
3260 scratch.path(),
3261 "linux",
3262 false,
3263 runtime,
3264 None,
3265 )
3266 .unwrap();
3267 assert_eq!(
3268 resolution.sandbox.enforce(),
3269 crate::types::SandboxEnforce::FsNet
3270 );
3271
3272 let resolution = resolve_gate_sandbox_target(
3275 &container(crate::types::SandboxEnforce::Off),
3276 repo.path(),
3277 &mission,
3278 scratch.path(),
3279 scratch.path(),
3280 "macos",
3281 false,
3282 None,
3283 None,
3284 )
3285 .unwrap();
3286 assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
3287 assert!(resolution.note.is_none());
3288 }
3289
3290 #[test]
3295 fn windows_enforced_gate_process_resolves_appcontainer_while_container_fails_closed() {
3296 let repo = tempfile::tempdir().unwrap();
3297 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3298 std::fs::create_dir_all(&mission).unwrap();
3299 let scratch = tempfile::tempdir().unwrap();
3300 let runtime = Some(crate::sandbox_container::ContainerRuntime::Docker);
3301
3302 for enforce in [
3303 crate::types::SandboxEnforce::Fs,
3304 crate::types::SandboxEnforce::FsNet,
3305 ] {
3306 let process = fs_sandbox_config(enforce);
3307 let resolution = resolve_gate_sandbox_target(
3308 &process,
3309 repo.path(),
3310 &mission,
3311 scratch.path(),
3312 scratch.path(),
3313 "windows",
3314 false,
3315 runtime,
3316 None,
3317 )
3318 .expect("Windows process gate enforcement resolves");
3319 assert!(resolution.note.is_none(), "{:?}", resolution.note);
3320 let GateSandbox::AppContainer { inputs, .. } = resolution.sandbox else {
3321 panic!("Windows process gate must resolve AppContainer");
3322 };
3323 assert_eq!(inputs.enforce, enforce);
3324 assert_eq!(inputs.session_cwd, repo.path());
3325 assert_eq!(inputs.mission_dir, mission);
3326
3327 let container = crate::types::SandboxConfig {
3328 enforce,
3329 provider: crate::types::SandboxProvider::Container,
3330 image: None,
3331 extra_write: vec![],
3332 egress: vec![],
3333 };
3334 let error = resolve_gate_sandbox_target(
3335 &container,
3336 repo.path(),
3337 &mission,
3338 scratch.path(),
3339 scratch.path(),
3340 "windows",
3341 false,
3342 runtime,
3343 None,
3344 )
3345 .expect_err("an unproved Windows container gate must fail closed");
3346 assert!(error
3349 .to_string()
3350 .contains("not supported on target_os=windows"));
3351 assert!(error
3352 .to_string()
3353 .contains("unverified container mount contract"));
3354 }
3355 }
3356
3357 #[cfg(target_os = "macos")]
3364 #[test]
3365 fn gate_xcrun_deny_prewarm_runs_once_per_resolve_not_per_command() {
3366 let repo = tempfile::tempdir().unwrap();
3367 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3368 std::fs::create_dir_all(&mission).unwrap();
3369 let scratch = tempfile::tempdir().unwrap();
3370 let cfg = fs_sandbox_config(crate::types::SandboxEnforce::Fs);
3371 let resolve = || {
3372 resolve_gate_sandbox(&cfg, repo.path(), &mission, scratch.path(), scratch.path())
3373 .unwrap()
3374 };
3375
3376 let resolution = resolve();
3380 assert!(resolution.prewarmed_xcrun, "one prewarm per resolve");
3381
3382 let env = std::collections::HashMap::new();
3385 let _argv_one = resolution.sandbox.wrap_shell("true", &env).unwrap();
3386 let _argv_two = resolution.sandbox.wrap_shell("echo hi", &env).unwrap();
3387 assert!(
3388 resolution.prewarmed_xcrun,
3389 "command wraps neither prewarm nor reset the record"
3390 );
3391
3392 let second = resolve();
3394 assert!(second.prewarmed_xcrun, "each resolve prewarms exactly once");
3395 }
3396
3397 #[test]
3405 fn container_gate_wrap_merge_policy_enforces_or_notes_the_fail_closed() {
3406 let container = |enforce| crate::types::SandboxConfig {
3407 enforce,
3408 provider: crate::types::SandboxProvider::Container,
3409 image: None,
3410 extra_write: vec![],
3411 egress: vec![],
3412 };
3413 let policy = MergeGatePolicy {
3414 sandbox: container(crate::types::SandboxEnforce::Fs),
3415 mission_dir: std::path::PathBuf::new(),
3416 };
3417 assert!(policy.enforces_on_this_host());
3421 assert!(policy
3423 .degradation_note_target(Some(crate::sandbox_container::ContainerRuntime::Docker))
3424 .is_none());
3425 let note = policy
3427 .degradation_note_target(None)
3428 .expect("the runtime-unavailable container posture must be noted");
3429 assert!(note.contains("no container runtime"), "{note}");
3430 assert!(
3431 note.contains("refusing to run engine-run gates unsandboxed"),
3432 "{note}"
3433 );
3434 let repo = tempfile::tempdir().unwrap();
3438 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3439 std::fs::create_dir_all(&mission).unwrap();
3440 let scratch = tempfile::tempdir().unwrap();
3441 let error = resolve_gate_sandbox_target(
3442 &policy.sandbox,
3443 repo.path(),
3444 &mission,
3445 scratch.path(),
3446 scratch.path(),
3447 "linux",
3448 false,
3449 None,
3450 None,
3451 )
3452 .expect_err("container without a runtime must fail closed");
3453 assert_eq!(
3454 error.to_string(),
3455 format!("configuration error: {note}"),
3456 "the engine-path resolve error and the merge-path note must match"
3457 );
3458
3459 let off = MergeGatePolicy {
3463 sandbox: container(crate::types::SandboxEnforce::Off),
3464 mission_dir: std::path::PathBuf::new(),
3465 };
3466 assert!(off.degradation_note_target(None).is_none());
3467 assert!(!off.enforces_on_this_host());
3468 let process = MergeGatePolicy {
3469 sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3470 mission_dir: std::path::PathBuf::new(),
3471 };
3472 assert!(process.degradation_note_target(None).is_none());
3473 }
3474
3475 #[cfg(unix)]
3480 #[tokio::test]
3481 async fn gate_sandbox_wrap_off_keeps_byte_identical_behavior() {
3482 let dir = tempfile::tempdir().unwrap();
3483 let outside = tempfile::tempdir().unwrap();
3484 let env = std::collections::HashMap::new();
3485
3486 let resolution = resolve_gate_sandbox(
3487 &crate::types::SandboxConfig::default(),
3488 dir.path(),
3489 dir.path(),
3490 dir.path(),
3491 dir.path(),
3492 )
3493 .unwrap();
3494 assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
3495 assert!(resolution.note.is_none());
3496
3497 let marker = outside.path().join("gate_sandbox_wrap_off_marker");
3498 let command = format!("echo hi > '{}' && printf MARKER", marker.display());
3499 let (ok_reference, out_reference) = run_shell_command(dir.path(), &command, &env).await;
3500 let (ok_wrapped, out_wrapped) =
3501 run_shell_command_sandboxed(dir.path(), &command, &env, &GateSandbox::Disabled).await;
3502 assert!(ok_reference, "reference run failed: {out_reference}");
3503 assert!(ok_wrapped, "disabled wrap run failed: {out_wrapped}");
3504 assert_eq!(
3505 out_reference, out_wrapped,
3506 "the Disabled wrap must reproduce the pre-wrap runner byte-for-byte"
3507 );
3508 assert!(
3509 marker.exists(),
3510 "with enforce == off a write outside any allowlist succeeds (today's posture)"
3511 );
3512 }
3513
3514 #[cfg(unix)]
3516 #[tokio::test]
3517 #[allow(clippy::await_holding_lock)]
3518 async fn container_gate_runtime_context_survives_timeout_without_worker_or_ambient_secrets() {
3519 use std::os::unix::fs::PermissionsExt as _;
3520 let fixture = tempfile::tempdir().unwrap();
3521 let home = fixture.path().join("operator");
3522 let scratch = fixture.path().join("worker");
3523 std::fs::create_dir(&home).unwrap();
3524 std::fs::create_dir(&scratch).unwrap();
3525 let stub = fixture.path().join("docker");
3526 std::fs::write(&stub, format!(
3527 "#!/bin/sh\nprintf '%s\\n' \"$HOME\" \"$DOCKER_HOST\" \"${{GH_TOKEN-unset}}\" > '{}/'$1.env\nprintf '%s\\n' \"$@\" > '{}/'$1.args\nif [ \"$1\" = run ]; then sleep 30; fi\n",
3528 fixture.path().display(), fixture.path().display(),
3529 )).unwrap();
3530 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o700)).unwrap();
3531 let path = format!(
3532 "{}:{}",
3533 fixture.path().display(),
3534 std::env::var("PATH").unwrap_or_default()
3535 );
3536 let _guard = crate::agent_env::EnvTestGuard::engage(&[
3537 ("PATH", &path),
3538 ("HOME", home.to_str().unwrap()),
3539 ("DOCKER_HOST", "unix:///operator-context.sock"),
3540 ("GH_TOKEN", "host-secret"),
3541 ]);
3542 let sandbox = GateSandbox::Container {
3543 inputs: Box::new(crate::sandbox::SandboxInputs {
3544 enforce: crate::types::SandboxEnforce::Fs,
3545 session_cwd: scratch.clone(),
3546 mission_dir: scratch.join("mission"),
3547 tmpdir: scratch.clone(),
3548 extra_write: vec![],
3549 egress: vec![],
3550 validator_read_deny_roots: vec![],
3551 }),
3552 spec: crate::sandbox_container::ContainerSpec {
3553 runtime: crate::sandbox_container::ContainerRuntime::Docker,
3554 image: "fixture".to_string(),
3555 network: None,
3556 name: None,
3557 },
3558 };
3559 let env = HashMap::from([
3560 ("HOME".to_string(), scratch.display().to_string()),
3561 (
3562 "DOCKER_HOST".to_string(),
3563 "unix:///worker-request.sock".to_string(),
3564 ),
3565 ("WORKER_SENTINEL".to_string(), "allowed".to_string()),
3566 ]);
3567 let (code, output) = run_shell_command_sandboxed_with_code(
3568 &scratch,
3569 "true",
3570 Duration::from_millis(500),
3571 &env,
3572 &sandbox,
3573 )
3574 .await;
3575 assert_eq!(
3576 code, None,
3577 "the fixture must exercise timeout cleanup: {output}"
3578 );
3579 for action in ["run", "rm"] {
3580 assert_eq!(
3581 std::fs::read_to_string(fixture.path().join(format!("{action}.env"))).unwrap(),
3582 format!("{}\nunix:///operator-context.sock\nunset\n", home.display())
3583 );
3584 }
3585 let args = std::fs::read_to_string(fixture.path().join("run.args")).unwrap();
3586 assert!(args.contains("WORKER_SENTINEL=allowed"));
3587 assert!(args.contains("DOCKER_HOST=unix:///worker-request.sock"));
3588 assert!(!args.contains("host-secret"));
3589 }
3590
3591 #[cfg(unix)]
3613 #[tokio::test]
3614 #[allow(clippy::await_holding_lock)]
3615 async fn gate_sandbox_wrap_denies_outside_writes_metadata_and_authority_reads() {
3616 let _guard = GATE_SANDBOX_WRAP_LOCK
3617 .lock()
3618 .unwrap_or_else(|p| p.into_inner());
3619 if !gate_wrap_enforcement_available() {
3620 return;
3621 }
3622
3623 let (repo, mission) = gate_wrap_layout();
3624 let kranz_dir = repo.path().join(".kranz");
3625 let scratch = tempfile::tempdir().unwrap();
3626 let outside = tempfile::tempdir().unwrap();
3627 let temp_root_marker =
3630 std::env::temp_dir().join(format!("kranz-gate-wrap-{}", uuid::Uuid::new_v4()));
3631
3632 let resolution = resolve_gate_sandbox(
3633 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3634 repo.path(),
3635 &mission,
3636 scratch.path(),
3637 scratch.path(),
3638 )
3639 .unwrap();
3640 assert!(resolution.note.is_none());
3641 let sandbox = resolution.sandbox;
3642 assert!(sandbox.enforce() == crate::types::SandboxEnforce::Fs);
3643 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3644
3645 for allowed in [
3647 repo.path().join("src.txt"),
3648 scratch.path().join("notes.txt"),
3649 ] {
3650 let (ok, output) = run_shell_command_sandboxed(
3651 repo.path(),
3652 &format!("echo ok > '{}'", allowed.display()),
3653 &env,
3654 &sandbox,
3655 )
3656 .await;
3657 assert!(
3658 ok && allowed.exists(),
3659 "write inside the gate roots must succeed: {output}"
3660 );
3661 }
3662
3663 let (ok, output) =
3666 run_shell_command_sandboxed(repo.path(), "echo hi > /dev/null 2>&1", &env, &sandbox)
3667 .await;
3668 assert!(ok, "/dev/null redirect must succeed: {output}");
3669
3670 let outside_file = outside.path().join("gate_sandbox_wrap_marker");
3672 for probe in [
3673 format!("echo x > '{}'", outside_file.display()),
3674 format!("echo x > '{}'", temp_root_marker.display()),
3675 ] {
3676 let (ok, output) =
3677 run_shell_command_sandboxed(repo.path(), &probe, &env, &sandbox).await;
3678 assert!(
3679 !ok,
3680 "write outside the allowlist must fail under enforcement: {probe}\n{output}"
3681 );
3682 }
3683 assert!(
3684 !outside_file.exists(),
3685 "denied write must not create the file"
3686 );
3687 assert!(
3688 !temp_root_marker.exists(),
3689 "denied temp-root write must not create the marker"
3690 );
3691
3692 let (ok, _) = run_shell_command_sandboxed(
3695 repo.path(),
3696 &format!(
3697 "echo tampered >> '{}'",
3698 mission.join("events.jsonl").display()
3699 ),
3700 &env,
3701 &sandbox,
3702 )
3703 .await;
3704 if cfg!(target_os = "macos") {
3705 assert!(!ok, "events.jsonl append must be denied under Seatbelt");
3706 }
3707 assert_eq!(
3708 std::fs::read_to_string(mission.join("events.jsonl")).unwrap(),
3709 "{\"seq\":1}\n",
3710 "the audit log must be untouched by the sandboxed gate"
3711 );
3712 let (ok, _) = run_shell_command_sandboxed(
3713 repo.path(),
3714 &format!(
3715 "echo x > '{}'",
3716 mission.join("control/approve.json").display()
3717 ),
3718 &env,
3719 &sandbox,
3720 )
3721 .await;
3722 if cfg!(target_os = "macos") {
3723 assert!(!ok, "control/ writes must be denied under Seatbelt");
3724 }
3725 assert!(
3726 std::fs::read_dir(mission.join("control"))
3727 .unwrap()
3728 .next()
3729 .is_none(),
3730 "the control inbox must stay empty on the host"
3731 );
3732
3733 for name in ["serve.token", "serve.read.token", "config.json"] {
3737 let (ok, output) = run_shell_command_sandboxed(
3738 repo.path(),
3739 &format!("test -s '{}'", kranz_dir.join(name).display()),
3740 &env,
3741 &sandbox,
3742 )
3743 .await;
3744 assert!(
3745 !ok,
3746 "a read of denied authority path .kranz/{name} must fail: {output}"
3747 );
3748 }
3749 let (ok, output) = run_shell_command_sandboxed(
3750 repo.path(),
3751 &format!("test -s '{}'", repo.path().join("public.txt").display()),
3752 &env,
3753 &sandbox,
3754 )
3755 .await;
3756 assert!(ok, "ordinary repo reads must keep working: {output}");
3757
3758 let off_env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3762 for probe in [
3763 format!("echo x > '{}'", outside_file.display()),
3764 format!("echo x > '{}'", temp_root_marker.display()),
3765 format!(
3766 "echo tampered >> '{}'",
3767 mission.join("events.jsonl").display()
3768 ),
3769 format!("test -s '{}'", kranz_dir.join("serve.token").display()),
3770 ] {
3771 let (ok, output) =
3772 run_shell_command_sandboxed(repo.path(), &probe, &off_env, &GateSandbox::Disabled)
3773 .await;
3774 assert!(
3775 ok,
3776 "with enforce == off the probe succeeds (today's posture): {probe}\n{output}"
3777 );
3778 }
3779 std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
3782 let _ = std::fs::remove_file(&temp_root_marker);
3783 }
3784
3785 #[cfg(unix)]
3791 #[tokio::test]
3792 #[allow(clippy::await_holding_lock)]
3793 async fn gate_sandbox_wrap_timeout_kills_the_whole_process_tree() {
3794 let _guard = GATE_SANDBOX_WRAP_LOCK
3795 .lock()
3796 .unwrap_or_else(|p| p.into_inner());
3797 if !gate_wrap_enforcement_available() {
3798 return;
3799 }
3800
3801 let (repo, mission) = gate_wrap_layout();
3802 let scratch = tempfile::tempdir().unwrap();
3803 let resolution = resolve_gate_sandbox(
3804 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3805 repo.path(),
3806 &mission,
3807 scratch.path(),
3808 scratch.path(),
3809 )
3810 .unwrap();
3811 let sandbox = resolution.sandbox;
3812 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3813
3814 let pidfile = scratch.path().join("child.pid");
3815 let command = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
3816 #[cfg(target_os = "linux")]
3817 let namespace_file = scratch.path().join("child.pid-namespace");
3818 #[cfg(target_os = "linux")]
3819 let command = format!(
3820 "readlink /proc/self/ns/pid > '{}'; {command}",
3821 namespace_file.display()
3822 );
3823 let (code, output) = tokio::time::timeout(
3824 Duration::from_secs(15),
3825 run_shell_command_sandboxed_with_code(
3826 repo.path(),
3827 &command,
3828 Duration::from_millis(500),
3829 &env,
3830 &sandbox,
3831 ),
3832 )
3833 .await
3834 .expect("timed-out command must return promptly");
3835 assert_eq!(code, None, "a timeout yields no exit code: {output}");
3836 assert!(output.contains("timed out"), "got: {output}");
3837
3838 let pid: i32 = std::fs::read_to_string(&pidfile)
3839 .expect("the wrapped shell wrote the background pid before the timeout")
3840 .trim()
3841 .parse()
3842 .expect("pidfile contains a pid");
3843 #[cfg(target_os = "linux")]
3844 let namespace = std::fs::read_to_string(namespace_file).unwrap();
3845 let child_alive = || {
3846 #[cfg(target_os = "linux")]
3847 {
3848 std::fs::read_dir("/proc").unwrap().flatten().any(|entry| {
3852 std::fs::read_link(entry.path().join("ns/pid"))
3853 .is_ok_and(|link| link.to_string_lossy() == namespace.trim())
3854 })
3855 }
3856 #[cfg(not(target_os = "linux"))]
3857 {
3858 (unsafe { libc::kill(pid, 0) }) == 0
3859 }
3860 };
3861 let deadline = std::time::Instant::now() + Duration::from_secs(5);
3862 while child_alive() {
3863 assert!(
3864 std::time::Instant::now() < deadline,
3865 "background child {pid} survived the group kill through the sandbox wrapper"
3866 );
3867 tokio::time::sleep(Duration::from_millis(50)).await;
3868 }
3869 }
3870
3871 #[cfg(target_os = "macos")]
3897 #[tokio::test]
3898 #[allow(clippy::await_holding_lock)]
3899 async fn gate_sandbox_wrap_dogfood_supervision_allows_tree_denies_host() {
3900 let _guard = GATE_SANDBOX_WRAP_LOCK
3901 .lock()
3902 .unwrap_or_else(|p| p.into_inner());
3903 if !gate_wrap_enforcement_available() {
3904 return;
3905 }
3906
3907 let (repo, mission) = gate_wrap_layout();
3908 let scratch = tempfile::tempdir().unwrap();
3909 let resolution = resolve_gate_sandbox(
3910 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3911 repo.path(),
3912 &mission,
3913 scratch.path(),
3914 scratch.path(),
3915 )
3916 .unwrap();
3917 let sandbox = resolution.sandbox;
3918 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3919
3920 let mut host = std::process::Command::new("sleep")
3923 .arg("300")
3924 .spawn()
3925 .expect("spawn host sleeper");
3926 let host_pid = host.id();
3927
3928 let (ok, output) = run_shell_command_sandboxed(
3932 repo.path(),
3933 "sleep 300 & child=$!; kill -0 \"$child\" && kill -TERM \"$child\"",
3934 &env,
3935 &sandbox,
3936 )
3937 .await;
3938 assert!(
3939 ok,
3940 "the wrapped gate must signal its own tree (same-sandbox): {output}"
3941 );
3942
3943 let (ok, output) = run_shell_command_sandboxed(
3946 repo.path(),
3947 &format!("kill -0 {host_pid}"),
3948 &env,
3949 &sandbox,
3950 )
3951 .await;
3952 assert!(
3953 !ok,
3954 "no host-wide signal capability under the wrap (EPERM expected): {output}"
3955 );
3956 let (ok, output) = run_shell_command_sandboxed(
3957 repo.path(),
3958 &format!("ps -p {host_pid} -o command="),
3959 &env,
3960 &sandbox,
3961 )
3962 .await;
3963 assert!(
3964 !ok,
3965 "no ps inspection under the wrap (setuid exec denied): {output}"
3966 );
3967
3968 let (ok, output) = run_shell_command_sandboxed(
3971 repo.path(),
3972 &format!("kill -0 {host_pid} && ps -p {host_pid} -o command="),
3973 &env,
3974 &GateSandbox::Disabled,
3975 )
3976 .await;
3977 assert!(
3978 ok,
3979 "with enforce == off the host probes succeed (today's posture): {output}"
3980 );
3981
3982 let _ = host.kill();
3983 let _ = host.wait();
3984 }
3985
3986 #[cfg(target_os = "macos")]
4026 #[test]
4027 #[ignore = "wrapped-suite proving ground — run manually or via the rust-macos-wrapped-suite CI job"]
4028 fn gate_sandbox_wrap_dogfood_supervision_workspace_suite() {
4029 if !gate_wrap_sandbox_exec_can_apply() {
4030 return;
4031 }
4032 let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4033 .parent()
4034 .and_then(std::path::Path::parent)
4035 .expect("crates/engine has a repo-root ancestor")
4036 .to_path_buf();
4037 let payload = std::env::var("KRANZ_DOGFOOD_SUITE_CMD")
4038 .unwrap_or_else(|_| "cargo test --workspace".to_string());
4039
4040 let scratch =
4047 std::env::temp_dir().join(format!("kranz-gate-{}", uuid::Uuid::new_v4().simple()));
4048 std::fs::create_dir_all(scratch.join("tmp")).unwrap();
4049 let cargo_home = crate::agent_env::cache_only_cargo_home(scratch.as_path());
4050 assert!(
4051 cargo_home.is_dir(),
4052 "could not create the fixture's cache-only Cargo home at {}",
4053 cargo_home.display()
4054 );
4055 let (_layout_guard, mission) = gate_wrap_layout();
4058 let resolution = resolve_gate_sandbox(
4059 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4060 &repo_root,
4061 &mission,
4062 &scratch,
4063 &scratch,
4064 )
4065 .expect("the fixture's gate sandbox resolves on a host that applied the smoke profile");
4066 let mut env = sanitized_gate_env();
4067 env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
4068 for var in ["TMPDIR", "TMP", "TEMP"] {
4069 env.insert(var.to_string(), scratch.join("tmp").display().to_string());
4070 }
4071 let kranz_home = scratch.join("kranz-home");
4078 std::fs::create_dir_all(&kranz_home).unwrap();
4079 env.insert("KRANZ_HOME".to_string(), kranz_home.display().to_string());
4080 let suite_log = scratch.join("tmp").join("dogfood-suite.log");
4081 let command = format!("{payload} > '{}' 2>&1", suite_log.display());
4082
4083 let runtime = tokio::runtime::Builder::new_current_thread()
4084 .enable_all()
4085 .build()
4086 .expect("fixture runtime");
4087 let start = std::time::Instant::now();
4088 let (code, output) = runtime.block_on(run_shell_command_sandboxed_with_code(
4089 &repo_root,
4090 &command,
4091 Duration::from_secs(3600),
4092 &env,
4093 &resolution.sandbox,
4094 ));
4095 let elapsed = start.elapsed();
4096
4097 let log = std::fs::read_to_string(&suite_log)
4098 .unwrap_or_else(|_| format!("<no suite log captured; runner tail: {output}>"));
4099 let skip_count = log
4100 .matches("SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood)")
4101 .count();
4102 println!(
4103 "dogfood wrapped suite `{payload}`: exit={code:?} elapsed={elapsed:.1?} \
4104 skip-under-wrap markers={skip_count} log={}",
4105 suite_log.display()
4106 );
4107 for line in log.lines().filter(|l| l.contains("test result:")) {
4108 println!(" {line}");
4109 }
4110 let tail: Vec<&str> = log.lines().collect();
4114 let tail = &tail[tail.len().saturating_sub(40)..];
4115 assert_eq!(
4116 code,
4117 Some(0),
4118 "cargo test --workspace must run GREEN as a wrapped contract command \
4119 (skip-under-wrap markers seen: {skip_count})\n--- suite log tail ---\n{}",
4120 tail.join("\n")
4121 );
4122 let _ = std::fs::remove_dir_all(&scratch);
4128 }
4129
4130 #[cfg(unix)]
4138 #[test]
4139 fn gate_sandbox_wrap_merge_gate_reads_git_identity_from_read_only_home() {
4140 let _wrap_guard = GATE_SANDBOX_WRAP_LOCK
4141 .lock()
4142 .unwrap_or_else(|p| p.into_inner());
4143 if !gate_wrap_enforcement_available() {
4144 return;
4145 }
4146
4147 let (repo, mission) = gate_wrap_layout();
4148 let fake_home = tempfile::tempdir().unwrap();
4149 std::fs::write(
4150 fake_home.path().join(".gitconfig"),
4151 "[user]\n\tname = Gate Wrap Test\n",
4152 )
4153 .unwrap();
4154 let _home = crate::agent_env::EnvTestGuard::engage(&[(
4155 "HOME",
4156 fake_home.path().to_str().expect("utf-8 temp path"),
4157 )]);
4158 let policy = MergeGatePolicy {
4159 sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4160 mission_dir: mission.clone(),
4161 };
4162 assert!(policy.enforces_on_this_host());
4163
4164 let (ok, output) = run_bounded_gate_command_sandboxed(
4165 repo.path(),
4166 "test \"$(git config user.name)\" = 'Gate Wrap Test' \
4167 && ! touch \"$HOME/gate_sandbox_wrap_marker\" \
4168 && case \"$TMPDIR\" in *kranz-gate-*/tmp) true ;; *) false ;; esac \
4169 && case \"$CARGO_HOME\" in *kranz-gate-*/.cargo-cache-only-*) true ;; *) false ;; esac",
4170 &policy,
4171 );
4172 assert!(
4173 ok,
4174 "git identity must read from the read-only HOME, $HOME writes must be \
4175 denied, and TMPDIR/CARGO_HOME must sit in the per-run scratch: {output}"
4176 );
4177 assert!(
4178 !fake_home.path().join("gate_sandbox_wrap_marker").exists(),
4179 "the denied $HOME write must not have created the marker"
4180 );
4181
4182 let (ok, output) = run_bounded_gate_command_sandboxed(
4184 repo.path(),
4185 "touch \"$HOME/gate_sandbox_wrap_off_marker\"",
4186 &MergeGatePolicy::disabled(),
4187 );
4188 assert!(
4189 ok,
4190 "with enforce == off the $HOME write succeeds (today's posture): {output}"
4191 );
4192 let _ = std::fs::remove_file(fake_home.path().join("gate_sandbox_wrap_off_marker"));
4193 }
4194
4195 #[cfg(unix)]
4205 #[tokio::test]
4206 #[allow(clippy::await_holding_lock)]
4207 async fn gate_sandbox_wrap_cache_write_deny_reads_cache_but_cannot_write() {
4208 let _guard = GATE_SANDBOX_WRAP_LOCK
4209 .lock()
4210 .unwrap_or_else(|p| p.into_inner());
4211 if !gate_wrap_enforcement_available() {
4212 return;
4213 }
4214
4215 let (repo, mission) = gate_wrap_layout();
4216 let scratch = tempfile::tempdir().unwrap();
4217 let cargo = tempfile::tempdir().unwrap();
4221 std::fs::create_dir_all(cargo.path().join("registry")).unwrap();
4222 std::fs::write(cargo.path().join("registry/cache-marker"), "cached").unwrap();
4223 let _cargo = crate::agent_env::EnvTestGuard::engage(&[(
4224 "CARGO_HOME",
4225 cargo.path().to_str().expect("utf-8 temp path"),
4226 )]);
4227
4228 let resolution = resolve_gate_sandbox(
4229 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4230 repo.path(),
4231 &mission,
4232 scratch.path(),
4233 scratch.path(),
4234 )
4235 .unwrap();
4236 let sandbox = resolution.sandbox;
4237 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
4238
4239 let (ok, output) = run_shell_command_sandboxed(
4241 repo.path(),
4242 &format!(
4243 "test -s '{}'",
4244 cargo.path().join("registry/cache-marker").display()
4245 ),
4246 &env,
4247 &sandbox,
4248 )
4249 .await;
4250 assert!(ok, "the wrapped gate must read the shared cache: {output}");
4251
4252 let poison = cargo.path().join("registry/poisoned-crate");
4256 let (ok, output) = run_shell_command_sandboxed(
4257 repo.path(),
4258 &format!("echo x > '{}'", poison.display()),
4259 &env,
4260 &sandbox,
4261 )
4262 .await;
4263 assert!(
4264 !ok,
4265 "a write to the operator's real cargo cache must fail under enforcement: {output}"
4266 );
4267 assert!(
4268 !poison.exists(),
4269 "the denied cache write must not create the file"
4270 );
4271
4272 let (ok, output) = run_shell_command_sandboxed(
4275 repo.path(),
4276 &format!("echo x > '{}'", poison.display()),
4277 &env,
4278 &GateSandbox::Disabled,
4279 )
4280 .await;
4281 assert!(
4282 ok,
4283 "with enforce == off the cache write succeeds (documented trade): {output}"
4284 );
4285 let _ = std::fs::remove_file(&poison);
4286 }
4287
4288 #[cfg(unix)]
4293 #[test]
4294 fn gate_sandbox_wrap_disabled_merge_policy_matches_todays_gate_shape() {
4295 let dir = tempfile::tempdir().unwrap();
4296 let temp = std::env::temp_dir().display().to_string();
4299 let temp = temp.trim_end_matches('/');
4300 let ambient_tmpdir = std::env::var("TMPDIR").unwrap_or_else(|_| "unset".to_string());
4301 let command = format!(
4302 "test \"$(dirname \"$CARGO_HOME\")\" = '{temp}' \
4303 && test \"${{TMPDIR:-unset}}\" = '{ambient_tmpdir}'"
4304 );
4305 let (ok, output) =
4306 run_bounded_gate_command_sandboxed(dir.path(), &command, &MergeGatePolicy::disabled());
4307 assert!(
4308 ok,
4309 "the off path must keep today's gate shape (cache-only home under the \
4310 system temp root, ambient TMPDIR): {output}"
4311 );
4312 }
4313
4314 #[test]
4320 fn gate_sandbox_wrap_fs_net_forces_cargo_offline() {
4321 let base: HashMap<String, String> = HashMap::new();
4322 let fs_net = GateSandbox::Seatbelt {
4323 enforce: crate::types::SandboxEnforce::FsNet,
4324 profile_path: std::path::PathBuf::from("/nonexistent"),
4325 };
4326 let env = gate_env_for_sandbox(&base, &fs_net);
4327 assert_eq!(
4328 env.get("CARGO_NET_OFFLINE").map(String::as_str),
4329 Some("true"),
4330 "fs+net gates run cargo offline-by-cache"
4331 );
4332 let fs = GateSandbox::Seatbelt {
4333 enforce: crate::types::SandboxEnforce::Fs,
4334 profile_path: std::path::PathBuf::from("/nonexistent"),
4335 };
4336 assert!(
4337 !gate_env_for_sandbox(&base, &fs).contains_key("CARGO_NET_OFFLINE"),
4338 "fs keeps full egress — no offline flag"
4339 );
4340 assert!(
4341 !gate_env_for_sandbox(&base, &GateSandbox::Disabled).contains_key("CARGO_NET_OFFLINE"),
4342 "the off path is byte-identical — no offline flag"
4343 );
4344 let container_fs_net = GateSandbox::Container {
4348 inputs: Box::new(crate::sandbox::SandboxInputs {
4349 enforce: crate::types::SandboxEnforce::FsNet,
4350 session_cwd: std::path::PathBuf::from("/nonexistent"),
4351 mission_dir: std::path::PathBuf::from("/nonexistent"),
4352 tmpdir: std::path::PathBuf::from("/nonexistent"),
4353 extra_write: Vec::new(),
4354 egress: Vec::new(),
4355 validator_read_deny_roots: Vec::new(),
4356 }),
4357 spec: crate::sandbox_container::ContainerSpec {
4358 runtime: crate::sandbox_container::ContainerRuntime::Docker,
4359 image: crate::sandbox_container::DEFAULT_IMAGE.to_string(),
4360 network: None,
4361 name: None,
4362 },
4363 };
4364 assert_eq!(
4365 gate_env_for_sandbox(&base, &container_fs_net)
4366 .get("CARGO_NET_OFFLINE")
4367 .map(String::as_str),
4368 Some("true"),
4369 "fs+net container gates run cargo offline-by-cache"
4370 );
4371 assert!(
4372 !base.contains_key("CARGO_NET_OFFLINE"),
4373 "the caller's env map is never mutated"
4374 );
4375 }
4376
4377 #[test]
4386 fn container_gate_wrap_shell_shape_names_the_container_and_teardown() {
4387 let inputs = crate::sandbox::SandboxInputs {
4388 enforce: crate::types::SandboxEnforce::Fs,
4389 session_cwd: std::path::PathBuf::from("/nonexistent"),
4390 mission_dir: std::path::PathBuf::from("/nonexistent-m"),
4391 tmpdir: std::path::PathBuf::from("/nonexistent-s"),
4392 extra_write: Vec::new(),
4393 egress: Vec::new(),
4394 validator_read_deny_roots: Vec::new(),
4395 };
4396 let container = GateSandbox::Container {
4397 inputs: Box::new(inputs),
4398 spec: crate::sandbox_container::ContainerSpec {
4399 runtime: crate::sandbox_container::ContainerRuntime::Docker,
4400 image: crate::sandbox_container::DEFAULT_IMAGE.to_string(),
4401 network: None,
4402 name: None,
4403 },
4404 };
4405 let env: HashMap<String, String> = [("KRANZ_BASE_SHA".to_string(), "deadbeef".to_string())]
4406 .into_iter()
4407 .collect();
4408
4409 let one = container.wrap_shell("echo hi", &env).unwrap();
4410 let two = container.wrap_shell("echo hi", &env).unwrap();
4411 assert_eq!(one.program, std::path::PathBuf::from("docker"));
4412 let name_of = |wrapped: &WrappedCommand| {
4413 wrapped
4414 .args
4415 .windows(2)
4416 .find(|w| w[0] == "--name")
4417 .map(|w| w[1].clone())
4418 .expect("the container argv must name its container")
4419 };
4420 let (name_one, name_two) = (name_of(&one), name_of(&two));
4421 assert!(
4422 name_one.starts_with("kranz-gate-"),
4423 "gate containers carry the kranz-gate- prefix: {name_one}"
4424 );
4425 assert_ne!(
4426 name_one, name_two,
4427 "container names are per command, never per resolve — parallel \
4428 gate commands from one resolution must not collide"
4429 );
4430 assert_eq!(
4431 one.timeout_teardown,
4432 Some((
4433 std::path::PathBuf::from("docker"),
4434 vec!["rm".to_string(), "-f".to_string(), name_one]
4435 )),
4436 "the teardown force-removes exactly this command's container"
4437 );
4438 assert!(
4439 one.args.ends_with(&[
4440 crate::sandbox_container::DEFAULT_IMAGE.to_string(),
4441 "sh".to_string(),
4442 "-c".to_string(),
4443 "echo hi".to_string()
4444 ]),
4445 "image then sh -c payload: {:?}",
4446 one.args
4447 );
4448
4449 let seatbelt = GateSandbox::Seatbelt {
4451 enforce: crate::types::SandboxEnforce::Fs,
4452 profile_path: std::path::PathBuf::from("/nonexistent"),
4453 };
4454 assert!(seatbelt
4455 .wrap_shell("true", &env)
4456 .unwrap()
4457 .timeout_teardown
4458 .is_none());
4459 assert!(GateSandbox::Disabled
4460 .wrap_shell("true", &env)
4461 .unwrap()
4462 .timeout_teardown
4463 .is_none());
4464 }
4465
4466 #[cfg(unix)]
4485 #[tokio::test]
4486 #[allow(clippy::await_holding_lock)]
4487 async fn container_gate_wrap_runs_contract_command_inside_the_container() {
4488 let _env = crate::agent_env::EnvTestGuard::engage(&[]);
4489 if !crate::sandbox_container::host_supports_container_contract() {
4490 crate::test_capability::skip(
4491 crate::test_capability::capability::CONTAINER,
4492 &crate::sandbox_container::container_contract_skip_detail(),
4493 );
4494 return;
4495 }
4496 if crate::sandbox_container::detect().is_none() {
4497 eprintln!(
4498 "no container runtime (docker/podman/nerdctl/container) on PATH; skipping \
4499 container gate wrap fixture"
4500 );
4501 return;
4502 }
4503
4504 let (repo, mission) = gate_wrap_layout_with_repo(
4507 tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(),
4508 );
4509 let kranz_dir = repo.path().join(".kranz");
4510 let scratch = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
4511 let outside = tempfile::tempdir().unwrap();
4512 let container_cfg = crate::types::SandboxConfig {
4513 enforce: crate::types::SandboxEnforce::Fs,
4514 provider: crate::types::SandboxProvider::Container,
4515 image: None,
4516 extra_write: vec![],
4517 egress: vec![],
4518 };
4519 let resolution = resolve_gate_sandbox(
4520 &container_cfg,
4521 repo.path(),
4522 &mission,
4523 scratch.path(),
4524 scratch.path(),
4525 )
4526 .unwrap();
4527 assert!(resolution.note.is_none());
4528 let sandbox = resolution.sandbox;
4529 assert!(
4530 matches!(sandbox, GateSandbox::Container { .. }),
4531 "provider:container with a runtime must resolve to the container wrap"
4532 );
4533 let env = crate::agent_env::contract_command_env(scratch.path(), Some("deadbeef"), &[]);
4534
4535 let ok_file = repo.path().join("container_gate_wrap_ok.txt");
4539 let (ok, output) = run_shell_command_sandboxed(
4540 repo.path(),
4541 &format!(
4542 "echo ok > '{}' && echo scratch > \"$HOME/container_gate_wrap_scratch.txt\" \
4543 && test \"$KRANZ_BASE_SHA\" = deadbeef",
4544 ok_file.display()
4545 ),
4546 &env,
4547 &sandbox,
4548 )
4549 .await;
4550 assert!(
4551 ok && ok_file.exists()
4552 && scratch
4553 .path()
4554 .join("container_gate_wrap_scratch.txt")
4555 .exists(),
4556 "writes inside the mount set and the forwarded env must work: {output}"
4557 );
4558
4559 let outside_file = outside.path().join("container_gate_wrap_marker");
4562 for probe in [
4563 "echo nope > /etc/container_gate_wrap_nope".to_string(),
4564 format!("echo x > '{}'", outside_file.display()),
4565 ] {
4566 let (ok, output) =
4567 run_shell_command_sandboxed(repo.path(), &probe, &env, &sandbox).await;
4568 assert!(
4569 !ok,
4570 "write outside the mount set must fail inside the container: {probe}\n{output}"
4571 );
4572 }
4573 assert!(
4574 !outside_file.exists(),
4575 "the denied write must not create the host file"
4576 );
4577
4578 let (ok, _) = run_shell_command_sandboxed(
4581 repo.path(),
4582 &format!(
4583 "echo tampered >> '{}'",
4584 mission.join("events.jsonl").display()
4585 ),
4586 &env,
4587 &sandbox,
4588 )
4589 .await;
4590 assert!(!ok, "the events.jsonl append must fail on the ro mount");
4591 assert_eq!(
4592 std::fs::read_to_string(mission.join("events.jsonl")).unwrap(),
4593 "{\"seq\":1}\n",
4594 "the audit log must be untouched by the container gate"
4595 );
4596
4597 for name in ["serve.token", "serve.read.token", "config.json"] {
4601 let (ok, output) = run_shell_command_sandboxed(
4602 repo.path(),
4603 &format!("test -s '{}'", kranz_dir.join(name).display()),
4604 &env,
4605 &sandbox,
4606 )
4607 .await;
4608 assert!(
4609 !ok,
4610 ".kranz/{name} must be /dev/null-masked inside the container: {output}"
4611 );
4612 }
4613 let (ok, output) = run_shell_command_sandboxed(
4614 repo.path(),
4615 &format!("test -s '{}'", repo.path().join("public.txt").display()),
4616 &env,
4617 &sandbox,
4618 )
4619 .await;
4620 assert!(ok, "ordinary repo reads must keep working: {output}");
4621
4622 let (ok, output) = run_shell_command_sandboxed(
4625 repo.path(),
4626 &format!(
4627 "echo x > '{}' && test -s '{}'",
4628 outside_file.display(),
4629 kranz_dir.join("serve.token").display()
4630 ),
4631 &env,
4632 &GateSandbox::Disabled,
4633 )
4634 .await;
4635 assert!(
4636 ok,
4637 "with enforce == off the probes succeed (today's posture): {output}"
4638 );
4639 let _ = std::fs::remove_file(&outside_file);
4640 }
4641
4642 #[cfg(target_os = "linux")]
4646 #[tokio::test]
4647 #[ignore = "live bubblewrap receipt — run by the protected Linux CI leg"]
4648 #[allow(clippy::await_holding_lock)]
4649 async fn linux_bubblewrap_hostile_live_receipt() {
4650 let _guard = GATE_SANDBOX_WRAP_LOCK
4651 .lock()
4652 .unwrap_or_else(|poisoned| poisoned.into_inner());
4653 assert!(
4654 gate_wrap_bwrap_can_apply(),
4655 "the live-proof host must provide a working bubblewrap boundary"
4656 );
4657
4658 let primary = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4659 .parent()
4660 .and_then(std::path::Path::parent)
4661 .expect("crates/engine has a repository root");
4662 let git = |args: &[&str]| {
4663 let output = std::process::Command::new("git")
4664 .args(args)
4665 .current_dir(primary)
4666 .output()
4667 .expect("git must run on the live-proof checkout");
4668 assert!(output.status.success(), "git {args:?} failed");
4669 String::from_utf8_lossy(&output.stdout).trim().to_string()
4670 };
4671 let head_before = git(&["rev-parse", "HEAD"]);
4672 let status_before = git(&["status", "--porcelain", "--untracked-files=no"]);
4673 assert!(
4674 status_before.is_empty(),
4675 "the live proof requires a clean tracked primary checkout: {status_before}"
4676 );
4677
4678 let (repo, mission) = gate_wrap_layout();
4679 let scratch = tempfile::tempdir().expect("private proof scratch");
4680 let outside = tempfile::tempdir().expect("sibling canary root");
4681 let resolution = resolve_gate_sandbox(
4682 &fs_sandbox_config(crate::types::SandboxEnforce::FsNet),
4683 repo.path(),
4684 &mission,
4685 scratch.path(),
4686 scratch.path(),
4687 )
4688 .expect("fs+net must resolve to bubblewrap on the proof host");
4689 assert!(resolution.note.is_none());
4690 assert!(matches!(resolution.sandbox, GateSandbox::Bubblewrap { .. }));
4691 let sandbox = resolution.sandbox;
4692 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
4693
4694 let canary = outside.path().join("kranz-linux-hostile-canary");
4695 let (write_ok, write_output) = run_shell_command_sandboxed(
4696 repo.path(),
4697 &format!("printf escaped > '{}'", canary.display()),
4698 &env,
4699 &sandbox,
4700 )
4701 .await;
4702 assert!(
4703 !write_ok,
4704 "sibling write escaped bubblewrap: {write_output}"
4705 );
4706 assert!(
4707 !canary.exists(),
4708 "the denied sibling canary must stay absent"
4709 );
4710
4711 let listener =
4712 std::net::TcpListener::bind("127.0.0.1:0").expect("host loopback proof listener");
4713 listener
4714 .set_nonblocking(true)
4715 .expect("nonblocking proof listener");
4716 let port = listener.local_addr().expect("listener address").port();
4717 let (stop_tx, stop_rx) = std::sync::mpsc::channel();
4718 let acceptor = std::thread::spawn(move || {
4719 let started = std::time::Instant::now();
4720 let mut accepted = 0usize;
4721 while started.elapsed() < Duration::from_secs(10) {
4722 match listener.accept() {
4723 Ok(_) => accepted += 1,
4724 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
4725 Err(error) => panic!("proof listener failed: {error}"),
4726 }
4727 if stop_rx.try_recv().is_ok() {
4728 break;
4729 }
4730 std::thread::sleep(Duration::from_millis(10));
4731 }
4732 accepted
4733 });
4734 let connect = format!(
4735 "python3 -c 'import socket; socket.create_connection((\"127.0.0.1\", {port}), 2).close()'"
4736 );
4737 let (off_connect_ok, off_connect_output) =
4738 run_shell_command_sandboxed(repo.path(), &connect, &env, &GateSandbox::Disabled).await;
4739 assert!(
4740 off_connect_ok,
4741 "the network anti-vacuity probe must reach the host listener without enforcement: {off_connect_output}"
4742 );
4743 let (wrapped_connect_ok, wrapped_connect_output) =
4744 run_shell_command_sandboxed(repo.path(), &connect, &env, &sandbox).await;
4745 assert!(
4746 !wrapped_connect_ok,
4747 "the fs+net namespace reached the host listener: {wrapped_connect_output}"
4748 );
4749 let _ = stop_tx.send(());
4750 assert_eq!(
4751 acceptor.join().expect("proof listener thread"),
4752 1,
4753 "only the unwrapped anti-vacuity connection may reach the host"
4754 );
4755
4756 let gate = "node -e \"let n=0; for(let i=0;i<100000;i++)n=(n+i)>>>0; if(n!==704982704)process.exit(2); setTimeout(()=>console.log('kranz-linux-node-ok'),750)\"";
4757 for (label, posture) in [
4758 ("unwrapped warm-up", &GateSandbox::Disabled),
4759 ("bubblewrap warm-up", &sandbox),
4760 ] {
4761 let (ok, output) = run_shell_command_sandboxed(repo.path(), gate, &env, posture).await;
4762 assert!(
4763 ok && output.contains("kranz-linux-node-ok"),
4764 "{label} failed: {output}"
4765 );
4766 }
4767
4768 let mut off_samples_ms = Vec::with_capacity(7);
4769 let mut wrapped_samples_ms = Vec::with_capacity(7);
4770 for index in 0..7 {
4771 for wrapped in [index % 2 == 1, index % 2 == 0] {
4772 let started = std::time::Instant::now();
4773 let posture = if wrapped {
4774 &sandbox
4775 } else {
4776 &GateSandbox::Disabled
4777 };
4778 let (ok, output) =
4779 run_shell_command_sandboxed(repo.path(), gate, &env, posture).await;
4780 assert!(
4781 ok && output.contains("kranz-linux-node-ok"),
4782 "timed gate failed: {output}"
4783 );
4784 let elapsed = started.elapsed().as_secs_f64() * 1_000.0;
4785 if wrapped {
4786 wrapped_samples_ms.push(elapsed);
4787 } else {
4788 off_samples_ms.push(elapsed);
4789 }
4790 }
4791 }
4792 let median = |samples: &[f64]| {
4793 let mut sorted = samples.to_vec();
4794 sorted.sort_by(f64::total_cmp);
4795 sorted[sorted.len() / 2]
4796 };
4797 let off_median_ms = median(&off_samples_ms);
4798 let wrapped_median_ms = median(&wrapped_samples_ms);
4799 let overhead_percent = (wrapped_median_ms / off_median_ms - 1.0) * 100.0;
4800
4801 let head_after = git(&["rev-parse", "HEAD"]);
4802 let status_after = git(&["status", "--porcelain", "--untracked-files=no"]);
4803 assert_eq!(head_after, head_before, "the primary checkout HEAD moved");
4804 assert_eq!(
4805 status_after, status_before,
4806 "the primary checkout's tracked bytes changed"
4807 );
4808
4809 let host = |program: &str, args: &[&str]| {
4810 std::process::Command::new(program)
4811 .args(args)
4812 .output()
4813 .ok()
4814 .filter(|output| output.status.success())
4815 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
4816 .unwrap_or_else(|| "unavailable".to_string())
4817 };
4818 let receipt = serde_json::json!({
4819 "hostOs": std::env::consts::OS,
4820 "hostArch": std::env::consts::ARCH,
4821 "kernel": host("uname", &["-sr"]),
4822 "bubblewrap": host("bwrap", &["--version"]),
4823 "node": host("node", &["--version"]),
4824 "enforcement": "fs+net",
4825 "provider": "process/bubblewrap",
4826 "siblingWriteDenied": !write_ok && !canary.exists(),
4827 "networkDenied": !wrapped_connect_ok,
4828 "networkAntiVacuityPassed": off_connect_ok,
4829 "normalGatePassed": true,
4830 "primaryCheckoutUntouched": head_after == head_before && status_after == status_before,
4831 "repetitions": 7,
4832 "offSamplesMs": off_samples_ms,
4833 "bubblewrapSamplesMs": wrapped_samples_ms,
4834 "offMedianMs": off_median_ms,
4835 "bubblewrapMedianMs": wrapped_median_ms,
4836 "overheadPercent": overhead_percent,
4837 "overheadTargetPercent": 10.0,
4838 "withinTarget": overhead_percent <= 10.0,
4839 "head": head_before,
4840 });
4841 println!("KRANZ_LINUX_LIVE_RECEIPT={receipt}");
4842 }
4843
4844 #[cfg(target_os = "macos")]
4876 #[test]
4877 #[ignore = "measurement harness — run manually, never a CI gate"]
4878 fn gate_sandbox_wrap_measure() {
4879 if !gate_wrap_sandbox_exec_can_apply() {
4880 return;
4881 }
4882 let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4883 .parent()
4884 .and_then(std::path::Path::parent)
4885 .expect("crates/engine has a repo-root ancestor")
4886 .to_path_buf();
4887 let payload = std::env::var("KRANZ_GATE_MEASURE_CMD").unwrap_or_else(|_| {
4888 "cargo test -p kranz-engine --lib -- \
4889 --skip timeout_kills \
4890 --skip kills_a_hung_binary \
4891 --skip approval_lint_runner_times_out_slow_command \
4892 --skip identity_token \
4893 --skip pid_reuse \
4894 --skip pool_checkpoint_hooks_disabled_against_planted_fsmonitor_and_hook \
4895 --skip sandbox_preflight_probes_disposable_worktree_not_primary"
4896 .to_string()
4897 });
4898 let reps: u32 = std::env::var("KRANZ_GATE_MEASURE_REPS")
4899 .ok()
4900 .and_then(|v| v.parse().ok())
4901 .unwrap_or(3);
4902 let (_layout_guard, mission) = gate_wrap_layout();
4905 let policy = MergeGatePolicy {
4906 sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4907 mission_dir: mission,
4908 };
4909
4910 let time = |label: &str, command: &str, wrapped: bool, reps: u32| {
4911 let mut samples = Vec::new();
4912 for _ in 0..reps {
4913 let start = std::time::Instant::now();
4914 let (ok, output) = if wrapped {
4915 run_bounded_gate_command_sandboxed(&repo_root, command, &policy)
4916 } else {
4917 run_bounded_gate_command(&repo_root, command)
4918 };
4919 let elapsed = start.elapsed();
4920 assert!(ok, "{label} run failed: {output}");
4921 samples.push(elapsed);
4922 }
4923 let total: Duration = samples.iter().sum();
4924 let mean = total / samples.len() as u32;
4925 let min = samples.iter().min().unwrap();
4926 println!("{label}: reps={reps} mean={mean:.3?} min={min:.3?} all={samples:?}");
4927 mean
4928 };
4929
4930 let micro_unwrapped = time("micro unwrapped (true)", "true", false, 50);
4931 let micro_wrapped = time("micro wrapped (true)", "true", true, 50);
4932 println!(
4933 "micro delta per spawn: {:?} ({:+.1}%)",
4934 micro_wrapped.saturating_sub(micro_unwrapped),
4935 (micro_wrapped.as_secs_f64() / micro_unwrapped.as_secs_f64() - 1.0) * 100.0
4936 );
4937 let gate_unwrapped = time("gate unwrapped", &payload, false, reps);
4938 let gate_wrapped = time("gate wrapped ", &payload, true, reps);
4939 println!(
4940 "gate delta: {:?} ({:+.2}%) on `{}`",
4941 gate_wrapped.saturating_sub(gate_unwrapped),
4942 (gate_wrapped.as_secs_f64() / gate_unwrapped.as_secs_f64() - 1.0) * 100.0,
4943 payload
4944 );
4945 }
4946}