1use std::collections::HashMap;
139use std::time::Duration;
140use tokio::io::{AsyncRead, AsyncReadExt};
141
142const COMMAND_OUTPUT_TAIL: usize = 1500;
144
145const COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
147
148pub(crate) fn run_with_timeout(
157 program: &std::path::Path,
158 args: &[String],
159 timeout: Duration,
160) -> Option<std::process::Output> {
161 let mut child = std::process::Command::new(program)
162 .args(args)
163 .stdin(std::process::Stdio::null())
164 .stdout(std::process::Stdio::piped())
165 .stderr(std::process::Stdio::piped())
166 .spawn()
167 .ok()?;
168 let start = std::time::Instant::now();
169 loop {
170 match child.try_wait() {
171 Ok(Some(_)) => return child.wait_with_output().ok(),
172 Ok(None) => {
173 if start.elapsed() >= timeout {
174 let _ = child.kill();
175 let _ = child.wait();
176 return None;
177 }
178 std::thread::sleep(Duration::from_millis(20));
179 }
180 Err(_) => return None,
181 }
182 }
183}
184
185pub(crate) fn last_chars_local(text: &str, max: usize) -> String {
188 let chars: Vec<char> = text.chars().collect();
189 let start = chars.len().saturating_sub(max);
190 chars[start..].iter().collect()
191}
192
193pub(crate) fn is_git_repo(root: &std::path::Path) -> bool {
197 root.join(".git").exists()
198}
199
200#[cfg(all(test, unix))]
224pub(crate) async fn run_shell_command(
225 cwd: &std::path::Path,
226 command: &str,
227 env: &HashMap<String, String>,
228) -> (bool, String) {
229 run_shell_command_with_timeout(cwd, command, COMMAND_TIMEOUT, env).await
230}
231
232#[cfg(test)]
247pub(crate) async fn run_shell_command_with_code(
248 cwd: &std::path::Path,
249 command: &str,
250 env: &HashMap<String, String>,
251) -> (Option<i32>, String) {
252 run_shell_command_with_timeout_env(cwd, command, COMMAND_TIMEOUT, env, false).await
253}
254
255pub(crate) async fn run_shell_command_with_code_cleared(
270 cwd: &std::path::Path,
271 command: &str,
272 env: &HashMap<String, String>,
273) -> (Option<i32>, String) {
274 run_shell_command_with_timeout_env(cwd, command, COMMAND_TIMEOUT, env, true).await
275}
276
277#[cfg(all(test, unix))]
294async fn run_shell_command_with_timeout(
295 cwd: &std::path::Path,
296 command: &str,
297 timeout: Duration,
298 env: &HashMap<String, String>,
299) -> (bool, String) {
300 let (code, output) = run_shell_command_with_timeout_env(cwd, command, timeout, env, true).await;
301 (code == Some(0), output)
302}
303
304async fn run_shell_command_with_timeout_env(
305 cwd: &std::path::Path,
306 command: &str,
307 timeout: Duration,
308 env: &HashMap<String, String>,
309 clear_env: bool,
310) -> (Option<i32>, String) {
311 let (program, args) = shell_argv(command);
312 let mut cmd = tokio::process::Command::new(program);
313 cmd.args(args);
314 if clear_env {
315 cmd.env_clear();
316 }
317 run_command_bounded(configure_bounded_child(cmd, cwd, env), timeout).await
318}
319
320fn shell_argv(command: &str) -> (std::path::PathBuf, Vec<String>) {
325 #[cfg(windows)]
326 {
327 (
328 std::path::PathBuf::from("cmd"),
329 vec!["/C".to_string(), command.to_string()],
330 )
331 }
332 #[cfg(not(windows))]
333 {
334 (
335 std::path::PathBuf::from("sh"),
336 vec!["-c".to_string(), command.to_string()],
337 )
338 }
339}
340
341pub(crate) async fn run_bounded_argv(
349 cwd: &std::path::Path,
350 program: &std::path::Path,
351 args: &[String],
352 timeout: Duration,
353 env: &HashMap<String, String>,
354) -> (Option<i32>, String) {
355 let mut cmd = tokio::process::Command::new(program);
356 cmd.args(args);
357 cmd.env_clear();
358 run_command_bounded(configure_bounded_child(cmd, cwd, env), timeout).await
359}
360
361#[derive(Debug)]
384pub(crate) enum GateSandbox {
385 Disabled,
387 Seatbelt {
389 enforce: crate::types::SandboxEnforce,
390 profile_path: std::path::PathBuf,
391 },
392 Bubblewrap {
396 inputs: Box<crate::sandbox::SandboxInputs>,
397 },
398 AppContainer {
402 inputs: Box<crate::sandbox::SandboxInputs>,
403 #[cfg(windows)]
404 context: crate::appcontainer_windows::AppContainerLaunchContext,
405 },
406 Container {
411 inputs: Box<crate::sandbox::SandboxInputs>,
412 spec: crate::sandbox_container::ContainerSpec,
413 },
414}
415
416pub(crate) struct WrappedCommand {
420 pub program: std::path::PathBuf,
421 pub args: Vec<String>,
422 pub timeout_teardown: Option<(std::path::PathBuf, Vec<String>)>,
432 #[cfg(windows)]
437 _appcontainer_context: Option<crate::appcontainer_windows::AppContainerLaunchContext>,
438}
439
440impl GateSandbox {
441 #[cfg(any(target_os = "macos", target_os = "linux", test))]
445 fn wrap_control_shell(
446 &self,
447 cwd: &std::path::Path,
448 command: &str,
449 env: &HashMap<String, String>,
450 ) -> crate::error::Result<WrappedCommand> {
451 match self {
452 Self::Seatbelt { .. } => self.wrap_shell(command, env),
453 Self::Bubblewrap { inputs } => Ok(WrappedCommand {
454 program: "bwrap".into(),
455 args: crate::sandbox::bubblewrap_args(
456 inputs,
457 std::path::Path::new("/bin/sh"),
458 &[
459 "-c".into(),
460 "cd -- \"$1\" && exec /bin/sh -c \"$2\"".into(),
461 "kranz-control".into(),
462 cwd.display().to_string(),
463 command.into(),
464 ],
465 )?,
466 timeout_teardown: None,
467 #[cfg(windows)]
468 _appcontainer_context: None,
469 }),
470 _ => Err(crate::error::EngineError::Config(
471 "negative controls require native macOS/Linux containment".into(),
472 )),
473 }
474 }
475
476 pub(crate) fn enforce(&self) -> crate::types::SandboxEnforce {
479 match self {
480 GateSandbox::Disabled => crate::types::SandboxEnforce::Off,
481 GateSandbox::Seatbelt { enforce, .. } => *enforce,
482 GateSandbox::Bubblewrap { inputs } => inputs.enforce,
483 GateSandbox::AppContainer { inputs, .. } => inputs.enforce,
484 GateSandbox::Container { inputs, .. } => inputs.enforce,
485 }
486 }
487
488 pub(crate) fn cleanup(&mut self) -> crate::error::Result<()> {
492 #[cfg(windows)]
493 if let GateSandbox::AppContainer { context, .. } = self {
494 return context.cleanup();
495 }
496 Ok(())
497 }
498
499 fn wrap_shell(
511 &self,
512 command: &str,
513 env: &HashMap<String, String>,
514 ) -> crate::error::Result<WrappedCommand> {
515 match self {
516 GateSandbox::Disabled => {
517 let (program, args) = shell_argv(command);
518 Ok(WrappedCommand {
519 program,
520 args,
521 timeout_teardown: None,
522 #[cfg(windows)]
523 _appcontainer_context: None,
524 })
525 }
526 GateSandbox::Seatbelt { profile_path, .. } => {
527 let (program, args) = crate::backend_claude::sandbox_command(
528 profile_path,
529 std::path::Path::new("/bin/sh"),
530 &["-c".to_string(), command.to_string()],
531 );
532 Ok(WrappedCommand {
533 program,
534 args,
535 timeout_teardown: None,
536 #[cfg(windows)]
537 _appcontainer_context: None,
538 })
539 }
540 GateSandbox::Bubblewrap { inputs } => {
541 let args = crate::sandbox::bubblewrap_args(
542 inputs,
543 std::path::Path::new("/bin/sh"),
544 &["-c".to_string(), command.to_string()],
545 )?;
546 Ok(WrappedCommand {
547 program: std::path::PathBuf::from("bwrap"),
548 args,
549 timeout_teardown: None,
550 #[cfg(windows)]
551 _appcontainer_context: None,
552 })
553 }
554 GateSandbox::AppContainer {
555 inputs,
556 #[cfg(windows)]
557 context,
558 } => {
559 #[cfg(windows)]
560 {
561 let (program, args) = shell_argv(command);
562 let prepared = crate::appcontainer_windows::prepare_launch_in_context(
563 context, inputs, &program, &args, env,
564 )?;
565 Ok(WrappedCommand {
566 program: prepared.program,
567 args: prepared.args,
568 timeout_teardown: None,
569 _appcontainer_context: Some(context.clone()),
570 })
571 }
572 #[cfg(not(windows))]
573 {
574 let _ = (inputs, command, env);
575 Err(crate::error::EngineError::Backend(
576 "AppContainer gate wrapper is unavailable on this host".to_string(),
577 ))
578 }
579 }
580 GateSandbox::Container { inputs, spec } => {
581 let name = format!("kranz-gate-{}", uuid::Uuid::new_v4().simple());
585 let args = crate::sandbox_container::container_gate_run_args(
586 inputs, spec, command, env, &name,
587 );
588 Ok(WrappedCommand {
589 program: std::path::PathBuf::from(spec.runtime.binary()),
590 args,
591 timeout_teardown: Some((
592 std::path::PathBuf::from(spec.runtime.binary()),
593 vec!["rm".to_string(), "-f".to_string(), name],
594 )),
595 #[cfg(windows)]
596 _appcontainer_context: None,
597 })
598 }
599 }
600 }
601}
602
603#[derive(Debug)]
612pub(crate) struct GateSandboxResolution {
613 pub sandbox: GateSandbox,
614 pub note: Option<String>,
615 #[cfg_attr(not(all(test, target_os = "macos")), allow(dead_code))]
623 pub prewarmed_xcrun: bool,
624}
625
626fn gate_profile_extras() -> String {
685 let mut extras = String::from(
733 "\n(allow file-write* (literal \"/dev/null\") (literal \"/dev/ptmx\"))\n\
734 (allow file-read* (literal \"/dev/ptmx\"))\n\
735 (allow file-read* file-write* (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))\n\
736 (allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))\n\
737 (allow signal (target same-sandbox))\n",
738 );
739 extras.push_str(&crate::sandbox::tty_deny_block(
740 &crate::sandbox::operator_tty_paths(),
741 ));
742 extras
743}
744
745#[cfg(target_os = "macos")]
763pub(crate) fn prewarm_xcrun_cache_outside_sandbox() {
764 let _ = run_with_timeout(
765 std::path::Path::new("git"),
766 &["--version".to_string()],
767 Duration::from_secs(10),
768 );
769}
770
771fn container_gate_note(enforce: crate::types::SandboxEnforce) -> String {
784 format!(
785 "sandbox provider:container with enforce:{} wraps engine-run gates in the mission \
786 container, but no container runtime (docker/podman/nerdctl/container) was found on \
787 PATH; refusing to run engine-run gates unsandboxed (fail closed, mirroring container \
788 session resolution) — install a runtime or set worker.sandbox.provider to \"process\"",
789 enforce.as_str()
790 )
791}
792
793pub(crate) fn resolve_gate_sandbox(
808 sandbox_cfg: &crate::types::SandboxConfig,
809 gate_cwd: &std::path::Path,
810 mission_dir: &std::path::Path,
811 scratch_home: &std::path::Path,
812 profile_dir: &std::path::Path,
813) -> crate::error::Result<GateSandboxResolution> {
814 let runtime = crate::sandbox_container::detect();
815 resolve_gate_sandbox_target(
816 sandbox_cfg,
817 gate_cwd,
818 mission_dir,
819 scratch_home,
820 profile_dir,
821 std::env::consts::OS,
822 crate::sandbox::command_available("bwrap"),
823 runtime,
824 crate::sandbox::session_mount_proof(sandbox_cfg, gate_cwd, mission_dir, runtime),
829 )
830}
831
832fn gate_sandbox_inputs(
840 sandbox_cfg: &crate::types::SandboxConfig,
841 gate_cwd: &std::path::Path,
842 mission_dir: &std::path::Path,
843 scratch_home: &std::path::Path,
844) -> crate::sandbox::SandboxInputs {
845 crate::sandbox::SandboxInputs {
846 enforce: sandbox_cfg.enforce,
847 session_cwd: gate_cwd.to_path_buf(),
848 mission_dir: mission_dir.to_path_buf(),
849 tmpdir: scratch_home.to_path_buf(),
850 extra_write: sandbox_cfg
851 .extra_write
852 .iter()
853 .map(|raw| crate::sandbox::expand_tilde(raw))
854 .collect(),
855 egress: sandbox_cfg.egress.clone(),
856 validator_read_deny_roots: Vec::new(),
857 }
858}
859
860#[allow(clippy::too_many_arguments)]
864fn resolve_gate_sandbox_target(
865 sandbox_cfg: &crate::types::SandboxConfig,
866 gate_cwd: &std::path::Path,
867 mission_dir: &std::path::Path,
868 scratch_home: &std::path::Path,
869 profile_dir: &std::path::Path,
870 target_os: &str,
871 bwrap_available: bool,
872 container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
873 container_mount_proof: Option<crate::sandbox_container::MountProof>,
874) -> crate::error::Result<GateSandboxResolution> {
875 use crate::types::{SandboxEnforce, SandboxProvider};
876 let disabled = |note: Option<String>| {
877 Ok(GateSandboxResolution {
878 sandbox: GateSandbox::Disabled,
879 note,
880 prewarmed_xcrun: false,
881 })
882 };
883 if sandbox_cfg.enforce == SandboxEnforce::Off {
884 return disabled(None);
885 }
886 crate::sandbox::validate_git_config_protection(
887 &gate_sandbox_inputs(sandbox_cfg, gate_cwd, mission_dir, scratch_home),
888 sandbox_cfg.provider == SandboxProvider::Container || target_os == "linux",
889 )?;
890 if sandbox_cfg.provider == SandboxProvider::Container {
891 if target_os == "windows" {
903 return Err(crate::error::EngineError::Config(format!(
904 "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",
905 sandbox_cfg.enforce.as_str()
906 )));
907 }
908 if target_os != "linux" {
909 match container_mount_proof {
910 Some(crate::sandbox_container::MountProof::Proven) => {}
911 Some(crate::sandbox_container::MountProof::Failed(reason)) => {
912 return Err(crate::error::EngineError::Config(format!(
913 "sandbox provider:container with enforce:{} refused for engine-run gates on target_os={target_os}: {reason}",
914 sandbox_cfg.enforce.as_str()
915 )));
916 }
917 None => {
918 return Err(crate::error::EngineError::Config(format!(
919 "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",
920 sandbox_cfg.enforce.as_str()
921 )));
922 }
923 }
924 }
925 let Some(runtime) = container_runtime else {
926 return Err(crate::error::EngineError::Config(container_gate_note(
927 sandbox_cfg.enforce,
928 )));
929 };
930 if sandbox_cfg.enforce == SandboxEnforce::FsNet
938 && !sandbox_cfg
939 .provider
940 .enforces_hard_net_boundary(&sandbox_cfg.egress)
941 {
942 return Err(crate::error::EngineError::Config(
943 "sandbox provider:container with enforce:fs+net and a non-empty egress list is \
944 advisory-only for engine-run gates (no egress proxy exists engine-side); use an \
945 empty egress list (the hard `--network none` boundary) or sandbox.provider \
946 \"process\" — refusing to run engine-run gates with an advisory boundary"
947 .to_string(),
948 ));
949 }
950 return Ok(GateSandboxResolution {
951 sandbox: GateSandbox::Container {
952 inputs: Box::new(gate_sandbox_inputs(
953 sandbox_cfg,
954 gate_cwd,
955 mission_dir,
956 scratch_home,
957 )),
958 spec: crate::sandbox_container::ContainerSpec {
959 runtime,
960 image: sandbox_cfg
961 .image
962 .clone()
963 .unwrap_or_else(|| crate::sandbox_container::DEFAULT_IMAGE.to_string()),
964 network: None,
965 name: None,
966 },
967 },
968 note: None,
969 prewarmed_xcrun: false,
970 });
971 }
972 match crate::sandbox::platform_support(sandbox_cfg.enforce, target_os) {
973 crate::sandbox::SandboxDecision::Off => disabled(None),
976 crate::sandbox::SandboxDecision::UnsupportedWarn => {
982 Err(crate::error::EngineError::Config(format!(
983 "sandbox enforce:{} requested but unsupported on target_os={target_os}; refusing \
984 to run engine-run gates unsandboxed",
985 sandbox_cfg.enforce.as_str()
986 )))
987 }
988 crate::sandbox::SandboxDecision::Enforce(crate::sandbox::SandboxBackend::Bubblewrap)
989 if !bwrap_available =>
990 {
991 Err(crate::error::EngineError::Config(format!(
992 "sandbox enforce:{} requested on linux but `bwrap` was not found; refusing \
993 to run engine-run gates unsandboxed",
994 sandbox_cfg.enforce.as_str()
995 )))
996 }
997 crate::sandbox::SandboxDecision::Enforce(backend) => {
998 let inputs = gate_sandbox_inputs(sandbox_cfg, gate_cwd, mission_dir, scratch_home);
999 match backend {
1000 crate::sandbox::SandboxBackend::Seatbelt => {
1001 #[cfg(target_os = "macos")]
1006 prewarm_xcrun_cache_outside_sandbox();
1007 let mut profile = crate::sandbox::generate_profile(&inputs);
1011 profile.push_str(&gate_profile_extras());
1012 let profile_path = crate::sandbox::write_profile_file(profile_dir, &profile)?;
1013 Ok(GateSandboxResolution {
1014 sandbox: GateSandbox::Seatbelt {
1015 enforce: sandbox_cfg.enforce,
1016 profile_path,
1017 },
1018 note: None,
1019 prewarmed_xcrun: cfg!(target_os = "macos"),
1021 })
1022 }
1023 crate::sandbox::SandboxBackend::Bubblewrap => Ok(GateSandboxResolution {
1024 sandbox: GateSandbox::Bubblewrap {
1025 inputs: Box::new(inputs),
1026 },
1027 note: None,
1028 prewarmed_xcrun: false,
1029 }),
1030 crate::sandbox::SandboxBackend::AppContainer => Ok(GateSandboxResolution {
1031 sandbox: GateSandbox::AppContainer {
1032 inputs: Box::new(inputs),
1033 #[cfg(windows)]
1034 context: crate::appcontainer_windows::new_launch_context(),
1035 },
1036 note: None,
1037 prewarmed_xcrun: false,
1038 }),
1039 crate::sandbox::SandboxBackend::Container => {
1043 unreachable!("container provider returned above")
1044 }
1045 }
1046 }
1047 }
1048}
1049
1050pub(crate) fn gate_env_for_sandbox(
1058 env: &HashMap<String, String>,
1059 sandbox: &GateSandbox,
1060) -> HashMap<String, String> {
1061 let mut env = env.clone();
1062 if sandbox.enforce() == crate::types::SandboxEnforce::FsNet {
1063 env.insert("CARGO_NET_OFFLINE".to_string(), "true".to_string());
1064 }
1065 env
1066}
1067
1068pub(crate) fn prepare_gate_command(
1077 command: &str,
1078 env: &HashMap<String, String>,
1079 sandbox: &GateSandbox,
1080) -> crate::error::Result<(WrappedCommand, HashMap<String, String>)> {
1081 let env = gate_env_for_sandbox(env, sandbox);
1082 let wrapped = sandbox.wrap_shell(command, &env)?;
1083 Ok((wrapped, env))
1084}
1085
1086pub(crate) async fn run_shell_command_sandboxed(
1100 cwd: &std::path::Path,
1101 command: &str,
1102 env: &HashMap<String, String>,
1103 sandbox: &GateSandbox,
1104) -> (bool, String) {
1105 let (code, output) =
1106 run_shell_command_sandboxed_with_code(cwd, command, COMMAND_TIMEOUT, env, sandbox).await;
1107 (code == Some(0), output)
1108}
1109
1110async fn run_shell_command_sandboxed_with_code(
1114 cwd: &std::path::Path,
1115 command: &str,
1116 timeout: Duration,
1117 env: &HashMap<String, String>,
1118 sandbox: &GateSandbox,
1119) -> (Option<i32>, String) {
1120 let env = gate_env_for_sandbox(env, sandbox);
1124 let wrapped = match sandbox.wrap_shell(command, &env) {
1125 Ok(wrapped) => wrapped,
1126 Err(error) => {
1127 return (
1128 None,
1129 format!("gate sandbox wrap failed closed (the command did not run): {error}"),
1130 )
1131 }
1132 };
1133 let client_env = match sandbox {
1136 GateSandbox::Container { spec, .. } => spec.runtime.client_env(),
1137 _ => env,
1138 };
1139 let (code, output) =
1140 run_bounded_argv(cwd, &wrapped.program, &wrapped.args, timeout, &client_env).await;
1141 if code.is_none() {
1142 if let Some((program, args)) = wrapped.timeout_teardown {
1143 let _ =
1145 run_bounded_argv(cwd, &program, &args, Duration::from_secs(30), &client_env).await;
1146 }
1147 }
1148 (code, output)
1149}
1150
1151#[cfg(windows)]
1157pub(crate) fn run_bounded_gate_command_resolved_with_code(
1158 cwd: &std::path::Path,
1159 command: &str,
1160 env: &HashMap<String, String>,
1161 sandbox: &GateSandbox,
1162) -> (Option<i32>, String) {
1163 let runtime = match tokio::runtime::Builder::new_current_thread()
1164 .enable_all()
1165 .build()
1166 {
1167 Ok(runtime) => runtime,
1168 Err(error) => return (None, format!("failed to create gate runtime: {error}")),
1169 };
1170 runtime.block_on(run_shell_command_sandboxed_with_code(
1171 cwd,
1172 command,
1173 COMMAND_TIMEOUT,
1174 env,
1175 sandbox,
1176 ))
1177}
1178
1179pub(crate) fn run_shell_command_sandboxed_blocking(
1189 cwd: &std::path::Path,
1190 command: &str,
1191 timeout: Duration,
1192 env: &HashMap<String, String>,
1193 sandbox: &GateSandbox,
1194) -> (Option<i32>, String) {
1195 std::thread::scope(|scope| {
1196 let worker = scope.spawn(|| {
1197 let runtime = match tokio::runtime::Builder::new_current_thread()
1198 .enable_all()
1199 .build()
1200 {
1201 Ok(runtime) => runtime,
1202 Err(error) => {
1203 return (
1204 None,
1205 format!("failed to create approval gate runtime: {error}"),
1206 )
1207 }
1208 };
1209 runtime.block_on(run_shell_command_sandboxed_with_code(
1210 cwd, command, timeout, env, sandbox,
1211 ))
1212 });
1213 worker.join().unwrap_or_else(|_| {
1214 (
1215 None,
1216 "approval gate runner panicked before producing a verdict".to_string(),
1217 )
1218 })
1219 })
1220}
1221
1222pub(crate) fn run_control_command_sandboxed_blocking(
1226 cwd: &std::path::Path,
1227 command: &str,
1228 timeout: Duration,
1229 env: &HashMap<String, String>,
1230 sandbox: &GateSandbox,
1231 cancelled: &std::sync::atomic::AtomicBool,
1232) -> (Option<i32>, String) {
1233 #[cfg(any(target_os = "macos", target_os = "linux"))]
1234 {
1235 std::thread::scope(|scope| {
1236 scope
1237 .spawn(|| {
1238 let env = gate_env_for_sandbox(env, sandbox);
1239 let wrapped = match sandbox.wrap_control_shell(cwd, command, &env) {
1240 Ok(wrapped) => wrapped,
1241 Err(error) => return (None, format!("control wrap failed: {error}")),
1242 };
1243 let runtime = match tokio::runtime::Builder::new_current_thread()
1244 .enable_all()
1245 .build()
1246 {
1247 Ok(runtime) => runtime,
1248 Err(error) => return (None, format!("control runtime failed: {error}")),
1249 };
1250 let mut cmd = tokio::process::Command::new(&wrapped.program);
1251 cmd.args(&wrapped.args).env_clear();
1252 runtime.block_on(run_control_command_bounded(
1253 configure_bounded_child(cmd, cwd, &env),
1254 timeout,
1255 cancelled,
1256 ))
1257 })
1258 .join()
1259 .unwrap_or_else(|_| (None, "control runner panicked".into()))
1260 })
1261 }
1262 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
1263 {
1264 let _ = (cwd, command, timeout, env, sandbox, cancelled);
1265 (
1266 None,
1267 "negative controls require native macOS/Linux containment".into(),
1268 )
1269 }
1270}
1271
1272#[cfg(any(target_os = "macos", target_os = "linux"))]
1273struct ControlChild(tokio::process::Child);
1274
1275#[cfg(any(target_os = "macos", target_os = "linux"))]
1276impl Drop for ControlChild {
1277 fn drop(&mut self) {
1278 crate::backend_claude::kill_unreaped_group(&self.0);
1280 }
1281}
1282
1283#[cfg(any(target_os = "macos", target_os = "linux"))]
1284async fn control_leader_exited(pid: u32) -> std::io::Result<()> {
1285 loop {
1286 let exited = {
1287 let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
1291 let result = unsafe {
1292 libc::waitid(
1293 libc::P_PID,
1294 pid as libc::id_t,
1295 &mut info,
1296 libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
1297 )
1298 };
1299 if result != 0 {
1300 let error = std::io::Error::last_os_error();
1301 if error.kind() != std::io::ErrorKind::Interrupted {
1302 return Err(error);
1303 }
1304 false
1305 } else {
1306 unsafe { info.si_pid() != 0 }
1307 }
1308 };
1309 if exited {
1310 return Ok(());
1311 }
1312 tokio::time::sleep(Duration::from_millis(10)).await;
1313 }
1314}
1315
1316#[cfg(any(target_os = "macos", target_os = "linux"))]
1317async fn run_control_command_bounded(
1318 mut cmd: tokio::process::Command,
1319 timeout: Duration,
1320 cancelled: &std::sync::atomic::AtomicBool,
1321) -> (Option<i32>, String) {
1322 use std::sync::atomic::Ordering;
1323 if cancelled.load(Ordering::Acquire) {
1324 return (None, "control evaluation cancelled".into());
1325 }
1326 let mut child = match cmd.spawn() {
1327 Ok(child) => ControlChild(child),
1328 Err(error) => return (None, format!("failed to spawn control: {error}")),
1329 };
1330 let stdout = child.0.stdout.take().expect("stdout is piped");
1331 let stderr = child.0.stderr.take().expect("stderr is piped");
1332 let capture = async { tokio::try_join!(read_stream_tail(stdout), read_stream_tail(stderr)) };
1333 tokio::pin!(capture);
1334 let leader = control_leader_exited(child.0.id().expect("unreaped child has an id"));
1335 tokio::pin!(leader);
1336 let cancellation = async {
1337 while !cancelled.load(Ordering::Acquire) {
1338 tokio::time::sleep(Duration::from_millis(10)).await;
1339 }
1340 };
1341 tokio::pin!(cancellation);
1342 let mut output = None;
1343 let execution = async {
1344 loop {
1345 tokio::select! {
1346 result = &mut leader => return result.map_err(|error| format!("control wait failed: {error}")),
1347 () = &mut cancellation => return Err("control evaluation cancelled".into()),
1348 result = &mut capture, if output.is_none() => {
1349 output = Some(result.map_err(|error| format!("control output failed: {error}"))?);
1350 }
1351 }
1352 }
1353 };
1354 let result = match tokio::time::timeout(timeout, execution).await {
1355 Ok(result) => result,
1356 Err(_) => Err(format!("timed out after {}s", timeout.as_secs())),
1357 };
1358 crate::backend_claude::kill_unreaped_group(&child.0);
1361 if result.is_err() {
1362 let _ = child.0.start_kill();
1365 }
1366 let status = child.0.wait().await;
1367 if let Err(error) = result {
1368 return (None, error);
1369 }
1370 let status = match status {
1371 Ok(status) => status,
1372 Err(error) => return (None, format!("control reap failed: {error}")),
1373 };
1374 let (stdout, stderr) = match output {
1375 Some(output) => output,
1376 None => match tokio::time::timeout(Duration::from_secs(1), &mut capture).await {
1377 Ok(Ok(output)) => output,
1378 Ok(Err(error)) => return (None, format!("control output failed: {error}")),
1379 Err(_) => {
1380 return (
1381 None,
1382 "control output remained open after group cleanup".into(),
1383 )
1384 }
1385 },
1386 };
1387 let mut combined = stdout;
1388 if !stderr.trim().is_empty() {
1389 combined.push_str("\n--- stderr ---\n");
1390 combined.push_str(stderr.trim_end());
1391 }
1392 (
1393 status.code(),
1394 tail_chars(combined.trim_end(), COMMAND_OUTPUT_TAIL),
1395 )
1396}
1397
1398fn configure_bounded_child(
1403 mut cmd: tokio::process::Command,
1404 cwd: &std::path::Path,
1405 env: &HashMap<String, String>,
1406) -> tokio::process::Command {
1407 cmd.current_dir(cwd)
1408 .envs(env)
1409 .stdin(std::process::Stdio::null())
1410 .stdout(std::process::Stdio::piped())
1411 .stderr(std::process::Stdio::piped())
1412 .kill_on_drop(true);
1413 #[cfg(unix)]
1414 cmd.process_group(0);
1415 cmd
1416}
1417
1418async fn run_command_bounded(
1438 cmd: tokio::process::Command,
1439 timeout: Duration,
1440) -> (Option<i32>, String) {
1441 let mut cmd = cmd;
1442 let mut child = match cmd.spawn() {
1443 Ok(child) => child,
1444 Err(e) => return (None, format!("failed to spawn shell: {e}")),
1445 };
1446 let stdout = child.stdout.take().expect("stdout was configured as piped");
1447 let stderr = child.stderr.take().expect("stderr was configured as piped");
1448 #[cfg(unix)]
1449 let group_pid = child.id();
1450
1451 #[cfg(windows)]
1458 let job = match child.raw_handle() {
1459 Some(handle) => crate::backend_claude::win_job::JobHandle::create_and_assign(handle)
1460 .map_err(|e| {
1461 tracing::warn!(error = %e, "failed to create Job Object for shell command; \
1462 timeout will kill only the spawned child");
1463 })
1464 .ok(),
1465 None => None,
1466 };
1467
1468 let execution = async {
1469 let (status, stdout, stderr) = tokio::join!(
1470 child.wait(),
1471 read_stream_tail(stdout),
1472 read_stream_tail(stderr)
1473 );
1474 Ok::<_, String>((
1475 status.map_err(|e| format!("failed waiting for shell: {e}"))?,
1476 stdout.map_err(|e| format!("failed reading shell stdout: {e}"))?,
1477 stderr.map_err(|e| format!("failed reading shell stderr: {e}"))?,
1478 ))
1479 };
1480 match tokio::time::timeout(timeout, execution).await {
1481 Err(_elapsed) => {
1482 #[cfg(unix)]
1486 if let Some(pid) = group_pid {
1487 unsafe {
1489 libc::kill(-(pid as i32), libc::SIGKILL);
1490 }
1491 }
1492 #[cfg(windows)]
1496 if let Some(job) = &job {
1497 job.kill();
1498 }
1499 let _ = child.kill().await;
1500 let _ = child.wait().await;
1501 (None, format!("timed out after {}s", timeout.as_secs()))
1502 }
1503 Ok(Err(error)) => (None, error),
1504 Ok(Ok((status, stdout, stderr))) => {
1505 let mut combined = stdout;
1506 if !stderr.trim().is_empty() {
1507 combined.push_str("\n--- stderr ---\n");
1508 combined.push_str(stderr.trim_end());
1509 }
1510 (
1513 status.code(),
1514 tail_chars(combined.trim_end(), COMMAND_OUTPUT_TAIL),
1515 )
1516 }
1517 }
1518}
1519
1520async fn read_stream_tail<R>(mut reader: R) -> std::io::Result<String>
1521where
1522 R: AsyncRead + Unpin,
1523{
1524 let max_bytes = COMMAND_OUTPUT_TAIL * 4;
1525 let mut tail = Vec::with_capacity(max_bytes);
1526 let mut chunk = [0u8; 8192];
1527 loop {
1528 let read = reader.read(&mut chunk).await?;
1529 if read == 0 {
1530 break;
1531 }
1532 if read >= max_bytes {
1533 tail.clear();
1534 tail.extend_from_slice(&chunk[read - max_bytes..read]);
1535 continue;
1536 }
1537 let excess = tail.len().saturating_add(read).saturating_sub(max_bytes);
1538 if excess > 0 {
1539 tail.drain(..excess);
1540 }
1541 tail.extend_from_slice(&chunk[..read]);
1542 }
1543 Ok(tail_chars(
1544 &String::from_utf8_lossy(&tail),
1545 COMMAND_OUTPUT_TAIL,
1546 ))
1547}
1548
1549pub fn run_bounded_gate_command(cwd: &std::path::Path, command: &str) -> (bool, String) {
1572 let cargo_home = crate::agent_env::cache_only_cargo_home(std::env::temp_dir().as_path());
1579 if !cargo_home.is_dir() {
1580 return (
1581 false,
1582 format!(
1583 "could not create the gate's cache-only Cargo home at {}",
1584 cargo_home.display()
1585 ),
1586 );
1587 }
1588 let mut env = sanitized_gate_env();
1589 env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
1590 let runtime = match tokio::runtime::Builder::new_current_thread()
1591 .enable_all()
1592 .build()
1593 {
1594 Ok(runtime) => runtime,
1595 Err(error) => return (false, format!("failed to create gate runtime: {error}")),
1596 };
1597 let (code, output) = runtime.block_on(run_shell_command_with_timeout_env(
1598 cwd,
1599 command,
1600 COMMAND_TIMEOUT,
1601 &env,
1602 true,
1603 ));
1604 let _ = std::fs::remove_dir_all(&cargo_home);
1605 (code == Some(0), output)
1606}
1607
1608pub struct MergeGatePolicy {
1618 pub sandbox: crate::types::SandboxConfig,
1619 pub mission_dir: std::path::PathBuf,
1620}
1621
1622impl MergeGatePolicy {
1623 pub fn disabled() -> Self {
1625 MergeGatePolicy {
1626 sandbox: crate::types::SandboxConfig::default(),
1627 mission_dir: std::path::PathBuf::new(),
1628 }
1629 }
1630
1631 pub fn enforces_on_this_host(&self) -> bool {
1644 if self.sandbox.enforce == crate::types::SandboxEnforce::Off {
1645 return false;
1646 }
1647 match self.sandbox.provider {
1648 crate::types::SandboxProvider::Process => !matches!(
1649 crate::sandbox::platform_support(self.sandbox.enforce, std::env::consts::OS),
1650 crate::sandbox::SandboxDecision::Off
1651 ),
1652 crate::types::SandboxProvider::Container => true,
1653 }
1654 }
1655
1656 pub fn degradation_note(&self) -> Option<String> {
1667 self.degradation_note_target(crate::sandbox_container::detect())
1668 }
1669
1670 pub(crate) fn degradation_note_target(
1674 &self,
1675 container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
1676 ) -> Option<String> {
1677 if self.sandbox.provider == crate::types::SandboxProvider::Container
1678 && self.sandbox.enforce != crate::types::SandboxEnforce::Off
1679 && container_runtime.is_none()
1680 {
1681 Some(container_gate_note(self.sandbox.enforce))
1682 } else {
1683 None
1684 }
1685 }
1686}
1687
1688pub fn run_bounded_gate_command_sandboxed(
1712 cwd: &std::path::Path,
1713 command: &str,
1714 policy: &MergeGatePolicy,
1715) -> (bool, String) {
1716 let (code, output) = run_bounded_gate_command_sandboxed_with_code(cwd, command, policy);
1717 (code == Some(0), output)
1718}
1719
1720pub(crate) fn run_bounded_gate_command_sandboxed_with_code(
1725 cwd: &std::path::Path,
1726 command: &str,
1727 policy: &MergeGatePolicy,
1728) -> (Option<i32>, String) {
1729 if !policy.enforces_on_this_host() {
1730 let (ok, output) = run_bounded_gate_command(cwd, command);
1731 return (Some(i32::from(!ok)), output);
1732 }
1733 let scratch =
1734 std::env::temp_dir().join(format!("kranz-gate-{}", uuid::Uuid::new_v4().simple()));
1735 if std::fs::create_dir_all(scratch.join("tmp")).is_err() {
1736 return (
1737 None,
1738 format!(
1739 "could not create the gate's sandbox scratch at {}",
1740 scratch.display()
1741 ),
1742 );
1743 }
1744 let cargo_home = crate::agent_env::cache_only_cargo_home(scratch.as_path());
1745 if !cargo_home.is_dir() {
1746 let _ = std::fs::remove_dir_all(&scratch);
1747 return (
1748 None,
1749 format!(
1750 "could not create the gate's cache-only Cargo home at {}",
1751 cargo_home.display()
1752 ),
1753 );
1754 }
1755 let runtime = match tokio::runtime::Builder::new_current_thread()
1756 .enable_all()
1757 .build()
1758 {
1759 Ok(runtime) => runtime,
1760 Err(error) => {
1761 let _ = std::fs::remove_dir_all(&scratch);
1762 return (None, format!("failed to create gate runtime: {error}"));
1763 }
1764 };
1765 let mut resolution = match resolve_gate_sandbox(
1766 &policy.sandbox,
1767 cwd,
1768 &policy.mission_dir,
1769 &scratch,
1770 &scratch,
1771 ) {
1772 Ok(resolution) => resolution,
1773 Err(error) => {
1774 let _ = std::fs::remove_dir_all(&scratch);
1775 return (
1776 None,
1777 format!("could not resolve the gate sandbox (failing closed): {error}"),
1778 );
1779 }
1780 };
1781 if let Some(note) = &resolution.note {
1782 tracing::warn!(note = %note, "merge gate sandbox degraded to a no-op");
1785 }
1786 let mut env = sanitized_gate_env();
1787 env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
1788 #[cfg(windows)]
1789 crate::agent_env::redirect_windows_profile_env(&mut env, &scratch);
1790 #[cfg(not(windows))]
1791 for var in ["TMPDIR", "TMP", "TEMP"] {
1792 env.insert(var.to_string(), scratch.join("tmp").display().to_string());
1793 }
1794 let (code, output) = runtime.block_on(run_shell_command_sandboxed_with_code(
1795 cwd,
1796 command,
1797 COMMAND_TIMEOUT,
1798 &env,
1799 &resolution.sandbox,
1800 ));
1801 if let Err(error) = resolution.sandbox.cleanup() {
1802 let _ = std::fs::remove_dir_all(&scratch);
1803 return (
1804 None,
1805 format!("gate sandbox cleanup failed closed after command execution: {error}"),
1806 );
1807 }
1808 let _ = std::fs::remove_dir_all(&scratch);
1809 (code, output)
1810}
1811
1812pub(crate) fn sanitized_gate_env() -> HashMap<String, String> {
1813 const SAFE: &[&str] = &[
1820 "PATH",
1821 "HOME",
1822 "USERPROFILE",
1823 "TMPDIR",
1824 "TMP",
1825 "TEMP",
1826 "RUSTUP_HOME",
1827 "NPM_CONFIG_CACHE",
1828 "CI",
1829 "TERM",
1830 "LANG",
1831 "LC_ALL",
1832 "TZ",
1833 ];
1834 let env: HashMap<String, String> = SAFE
1835 .iter()
1836 .filter_map(|key| {
1837 std::env::var_os(key).map(|value| ((*key).to_string(), value.to_string_lossy().into()))
1838 })
1839 .collect();
1840 #[cfg(windows)]
1841 let env = {
1842 let mut env = env;
1843 crate::agent_env::extend_windows_process_env(&mut env);
1844 crate::agent_env::extend_noncredential_toolchain_env(&mut env);
1850 env
1851 };
1852 env
1853}
1854
1855pub(crate) fn tail_chars(text: &str, max: usize) -> String {
1857 let count = text.chars().count();
1858 if count <= max {
1859 return text.to_string();
1860 }
1861 text.chars().skip(count - max).collect()
1862}
1863
1864#[cfg(test)]
1867mod tests {
1868 use super::*;
1869 #[cfg(unix)]
1870 use crate::runner;
1871
1872 #[test]
1873 fn control_wrapper_keeps_scratch_mounts_and_positional_snapshot_cwd() {
1874 let root = tempfile::tempdir().unwrap();
1875 let scratch = root.path().join("scratch");
1876 let snapshot = root.path().join("readonly snapshot's checkout");
1877 std::fs::create_dir(&scratch).unwrap();
1878 std::fs::create_dir(&snapshot).unwrap();
1879 let inputs = gate_sandbox_inputs(
1880 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
1881 &scratch,
1882 &root.path().join(".kranz/missions/control"),
1883 &scratch,
1884 );
1885 let sandbox = GateSandbox::Bubblewrap {
1886 inputs: Box::new(inputs),
1887 };
1888 let command = "sh check.sh && printf '%s' \"$HOME\"";
1889 let wrapped = sandbox
1890 .wrap_control_shell(&snapshot, command, &HashMap::new())
1891 .unwrap();
1892 let chdir = wrapped
1893 .args
1894 .iter()
1895 .position(|arg| arg == "--chdir")
1896 .unwrap();
1897 assert_eq!(
1898 wrapped.args[chdir + 1],
1899 std::fs::canonicalize(&scratch)
1900 .unwrap()
1901 .display()
1902 .to_string()
1903 );
1904 assert_eq!(
1905 &wrapped.args[chdir + 2..],
1906 &[
1907 "--",
1908 "/bin/sh",
1909 "-c",
1910 "cd -- \"$1\" && exec /bin/sh -c \"$2\"",
1911 "kranz-control",
1912 &snapshot.display().to_string(),
1913 command,
1914 ]
1915 );
1916 let writes: Vec<_> = wrapped
1917 .args
1918 .windows(3)
1919 .filter(|args| args[0] == "--bind")
1920 .map(|args| args[2].clone())
1921 .collect();
1922 assert!(writes.contains(
1923 &std::fs::canonicalize(&scratch)
1924 .unwrap()
1925 .display()
1926 .to_string()
1927 ));
1928 assert!(!writes.contains(&snapshot.display().to_string()));
1929 assert!(GateSandbox::Disabled
1930 .wrap_control_shell(&snapshot, command, &HashMap::new())
1931 .is_err());
1932 }
1933
1934 #[cfg(any(target_os = "macos", target_os = "linux"))]
1935 #[tokio::test]
1936 async fn control_wait_retains_the_leader_until_group_cleanup() {
1937 let root = tempfile::tempdir().unwrap();
1938 let mut command = tokio::process::Command::new("/bin/sh");
1939 command.args(["-c", "exit 7"]).env_clear();
1940 let mut child = ControlChild(
1941 configure_bounded_child(command, root.path(), &HashMap::new())
1942 .spawn()
1943 .unwrap(),
1944 );
1945 let pid = child.0.id().unwrap();
1946 for _ in 0..2 {
1947 tokio::time::timeout(Duration::from_secs(3), control_leader_exited(pid))
1948 .await
1949 .unwrap()
1950 .unwrap();
1951 }
1952 crate::backend_claude::kill_unreaped_group(&child.0);
1953 assert_eq!(child.0.wait().await.unwrap().code(), Some(7));
1954 assert!(
1955 child.0.id().is_none(),
1956 "the drop guard cannot signal a reaped PID"
1957 );
1958 }
1959
1960 #[cfg(any(target_os = "macos", target_os = "linux"))]
1961 #[tokio::test]
1962 async fn control_timeout_kills_a_leader_outside_its_original_group() {
1963 let root = tempfile::tempdir().unwrap();
1964 let ready = root.path().join("escaped-leader");
1965 let mut command = tokio::process::Command::new(std::env::current_exe().unwrap());
1966 command
1967 .args([
1968 "--ignored",
1969 "--exact",
1970 "command_exec::tests::control_escaped_leader_fixture",
1971 "--nocapture",
1972 ])
1973 .env_clear();
1974 let command = configure_bounded_child(
1975 command,
1976 root.path(),
1977 &HashMap::from([(
1978 "KRANZ_CONTROL_ESCAPED_LEADER".into(),
1979 ready.display().to_string(),
1980 )]),
1981 );
1982 let (code, output) = tokio::time::timeout(
1983 Duration::from_secs(5),
1984 run_control_command_bounded(
1985 command,
1986 Duration::from_secs(1),
1987 &std::sync::atomic::AtomicBool::new(false),
1988 ),
1989 )
1990 .await
1991 .expect("cleanup must terminate the escaped direct child before waiting");
1992 let evidence =
1993 std::fs::read_to_string(ready).expect("fixture moved out of its original group");
1994 let (pid, group) = evidence.split_once(' ').unwrap();
1995 assert_ne!(pid, group, "fixture must leave its original group");
1996 assert_eq!(code, None, "{output}");
1997 assert!(output.contains("timed out"), "{output}");
1998 }
1999
2000 #[cfg(any(target_os = "macos", target_os = "linux"))]
2001 #[test]
2002 #[ignore = "disposable subprocess fixture for direct-child timeout cleanup"]
2003 fn control_escaped_leader_fixture() {
2004 let Some(ready) = std::env::var_os("KRANZ_CONTROL_ESCAPED_LEADER") else {
2005 return;
2006 };
2007 let group = unsafe { libc::getpgid(libc::getppid()) };
2010 assert!(group > 0);
2011 assert_eq!(unsafe { libc::setpgid(0, group) }, 0);
2012 std::fs::write(ready, format!("{} {group}", std::process::id())).unwrap();
2013 std::thread::sleep(Duration::from_secs(30));
2014 }
2015
2016 #[cfg(any(target_os = "macos", target_os = "linux"))]
2017 #[tokio::test]
2018 async fn control_abort_cleans_unreaped_descendants() {
2019 let root = tempfile::tempdir().unwrap();
2020 let ready = root.path().join("ready");
2021 let marker = root.path().join("survived");
2022 let mut command = tokio::process::Command::new("/bin/sh");
2023 command
2024 .args([
2025 "-c",
2026 "(sleep 1; printf survived > \"$MARKER\") >/dev/null 2>&1 & printf ready > \"$READY\"; wait",
2027 ])
2028 .env_clear();
2029 let command = configure_bounded_child(
2030 command,
2031 root.path(),
2032 &HashMap::from([
2033 ("PATH".into(), "/usr/bin:/bin".into()),
2034 ("READY".into(), ready.display().to_string()),
2035 ("MARKER".into(), marker.display().to_string()),
2036 ]),
2037 );
2038 let task = tokio::spawn(async move {
2039 run_control_command_bounded(
2040 command,
2041 Duration::from_secs(5),
2042 &std::sync::atomic::AtomicBool::new(false),
2043 )
2044 .await
2045 });
2046 tokio::time::timeout(Duration::from_secs(3), async {
2047 while !ready.exists() {
2048 tokio::time::sleep(Duration::from_millis(10)).await;
2049 }
2050 })
2051 .await
2052 .expect("checker started before cancellation");
2053 task.abort();
2054 assert!(task.await.unwrap_err().is_cancelled());
2055 tokio::time::sleep(Duration::from_millis(1200)).await;
2056 assert!(!marker.exists(), "aborted runner left a live descendant");
2057 }
2058
2059 #[cfg(any(target_os = "macos", target_os = "linux"))]
2060 #[test]
2061 fn control_wrapper_reads_snapshot_and_cleans_every_exit() {
2062 use std::sync::atomic::{AtomicBool, Ordering};
2063 let _lock = GATE_SANDBOX_WRAP_LOCK
2064 .lock()
2065 .unwrap_or_else(|error| error.into_inner());
2066 if !gate_wrap_enforcement_available() {
2067 return;
2068 }
2069 let _env = crate::agent_env::EnvTestGuard::engage(&[(
2070 "KRANZ_CONTROL_AMBIENT_SENTINEL",
2071 "not-authorized",
2072 )]);
2073 let (repo, mission) = gate_wrap_layout();
2074 let snapshot = repo.path().join("readonly snapshot's checkout");
2075 std::fs::create_dir(&snapshot).unwrap();
2076 std::fs::write(snapshot.join("checker-input"), "approved").unwrap();
2077 let checker = r#"set -eu
2078[ "$(cat checker-input)" = approved ]
2079[ -z "${KRANZ_CONTROL_AMBIENT_SENTINEL+x}" ]
2080[ "$CARGO_NET_OFFLINE" = true ]
2081if (printf changed > checker-input) 2>/dev/null; then exit 90; fi
2082if [ "$MODE" = inherited ]; then
2083 (sleep 2; printf survived > "$CONTROL_MARKER") &
2084else
2085 (sleep 2; printf survived > "$CONTROL_MARKER") >/dev/null 2>&1 &
2086fi
2087printf ready > "$CONTROL_READY"
2088printf control-stdout
2089printf control-stderr >&2
2090case "$MODE" in
2091 nonzero) exit 7;;
2092 timeout|cancel) wait;;
2093esac
2094"#;
2095 std::fs::write(snapshot.join("check.sh"), checker).unwrap();
2096 let scratch = tempfile::tempdir().unwrap();
2097 let sandbox = resolve_gate_sandbox(
2098 &fs_sandbox_config(crate::types::SandboxEnforce::FsNet),
2099 scratch.path(),
2100 &mission,
2101 scratch.path(),
2102 scratch.path(),
2103 )
2104 .unwrap()
2105 .sandbox;
2106 let mut markers = Vec::new();
2107 for mode in ["success", "nonzero", "inherited", "timeout", "cancel"] {
2108 let marker = scratch.path().join(format!("{mode}.survived"));
2109 let ready = scratch.path().join(format!("{mode}.ready"));
2110 let env = HashMap::from([
2111 ("PATH".into(), "/usr/bin:/bin".into()),
2112 ("MODE".into(), mode.into()),
2113 ("CONTROL_MARKER".into(), marker.display().to_string()),
2114 ("CONTROL_READY".into(), ready.display().to_string()),
2115 ]);
2116 let cancelled = AtomicBool::new(false);
2117 let (code, output) = std::thread::scope(|scope| {
2118 let ready = &ready;
2119 let cancelled = &cancelled;
2120 if mode == "cancel" {
2121 scope.spawn(move || {
2122 let deadline = std::time::Instant::now() + Duration::from_secs(5);
2123 while !ready.exists() {
2124 assert!(
2125 std::time::Instant::now() < deadline,
2126 "checker did not start"
2127 );
2128 std::thread::sleep(Duration::from_millis(10));
2129 }
2130 cancelled.store(true, Ordering::Release);
2131 });
2132 }
2133 run_control_command_sandboxed_blocking(
2134 &snapshot,
2135 "sh check.sh",
2136 Duration::from_secs(if mode == "timeout" { 1 } else { 5 }),
2137 &env,
2138 &sandbox,
2139 cancelled,
2140 )
2141 });
2142 assert!(ready.exists(), "{mode}: checker did not run: {output}");
2143 match mode {
2144 "timeout" => {
2145 assert_eq!(code, None);
2146 assert!(output.contains("timed out"));
2147 }
2148 "cancel" => {
2149 assert_eq!(code, None);
2150 assert!(output.contains("cancelled"));
2151 }
2152 _ => {
2153 assert_eq!(
2154 code,
2155 Some(if mode == "nonzero" { 7 } else { 0 }),
2156 "{output}"
2157 );
2158 assert!(output.contains("control-stdout"), "{output}");
2159 assert!(output.contains("control-stderr"), "{output}");
2160 }
2161 }
2162 markers.push(marker);
2163 }
2164 let control = scratch.path().join("unsupervised.survived");
2167 let mut positive = std::process::Command::new("/bin/sh")
2168 .args([
2169 "-c",
2170 "sleep 2; printf survived > \"$1\"",
2171 "positive",
2172 &control.display().to_string(),
2173 ])
2174 .spawn()
2175 .unwrap();
2176 assert!(positive.wait().unwrap().success());
2177 assert!(control.exists());
2178 for marker in markers {
2179 assert!(
2180 !marker.exists(),
2181 "descendant survived cleanup: {}",
2182 marker.display()
2183 );
2184 }
2185 assert_eq!(
2186 std::fs::read_to_string(snapshot.join("checker-input")).unwrap(),
2187 "approved"
2188 );
2189 }
2190
2191 #[test]
2192 fn tail_chars_keeps_the_end() {
2193 assert_eq!(tail_chars("abcdef", 3), "def");
2194 assert_eq!(tail_chars("ab", 3), "ab");
2195 assert_eq!(tail_chars("héllo", 2), "lo");
2196 }
2197
2198 #[cfg(unix)]
2204 #[tokio::test]
2205 async fn shell_command_timeout_kills_the_whole_process_tree() {
2206 let dir = tempfile::tempdir().unwrap();
2207 let pidfile = dir.path().join("child.pid");
2208 let command = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
2211
2212 let (ok, output) = tokio::time::timeout(
2213 Duration::from_secs(10),
2214 run_shell_command_with_timeout(
2215 dir.path(),
2216 &command,
2217 Duration::from_millis(500),
2218 &std::collections::HashMap::new(),
2219 ),
2220 )
2221 .await
2222 .expect("timed-out command must return promptly");
2223 assert!(!ok, "command must be reported failed: {output}");
2224 assert!(output.contains("timed out"), "got: {output}");
2225
2226 let pid: i32 = std::fs::read_to_string(&pidfile)
2227 .expect("shell wrote the background pid before the timeout")
2228 .trim()
2229 .parse()
2230 .expect("pidfile contains a pid");
2231
2232 let deadline = std::time::Instant::now() + Duration::from_secs(5);
2235 while unsafe { libc::kill(pid, 0) } == 0 {
2236 assert!(
2237 std::time::Instant::now() < deadline,
2238 "background child {pid} survived the group kill"
2239 );
2240 tokio::time::sleep(Duration::from_millis(50)).await;
2241 }
2242 }
2243
2244 #[cfg(unix)]
2245 #[tokio::test]
2246 async fn shell_command_drains_large_output_while_running_and_keeps_only_the_tail() {
2247 let dir = tempfile::tempdir().unwrap();
2248 let command = "i=0; while [ \"$i\" -lt 20000 ]; do \
2249 printf '0123456789abcdef0123456789abcdef\\n'; \
2250 i=$((i + 1)); done; printf 'OUTPUT-END'";
2251
2252 let (ok, output) = run_shell_command_with_timeout(
2253 dir.path(),
2254 command,
2255 Duration::from_secs(10),
2256 &std::collections::HashMap::new(),
2257 )
2258 .await;
2259
2260 assert!(ok, "large-output command must complete: {output}");
2261 assert!(output.ends_with("OUTPUT-END"), "{output}");
2262 assert!(
2263 output.chars().count() <= COMMAND_OUTPUT_TAIL,
2264 "retained output exceeded the cap: {} chars",
2265 output.chars().count()
2266 );
2267 }
2268
2269 #[test]
2270 fn merge_gate_environment_excludes_server_secrets() {
2271 let env = sanitized_gate_env();
2272 for secret in [
2273 "ANTHROPIC_API_KEY",
2274 "OPENAI_API_KEY",
2275 "SLACK_BOT_TOKEN",
2276 "GITHUB_TOKEN",
2277 "GH_TOKEN",
2278 "SSH_AUTH_SOCK",
2279 "AWS_SECRET_ACCESS_KEY",
2280 ] {
2281 assert!(!env.contains_key(secret), "gate env leaked {secret}");
2282 }
2283 assert!(
2284 !env.contains_key("CARGO_HOME"),
2285 "the ambient Cargo root is a credential directory; \
2286 run_bounded_gate_command substitutes a cache-only home"
2287 );
2288 assert!(env.keys().all(|key| matches!(
2289 key.as_str(),
2290 "PATH"
2291 | "HOME"
2292 | "USERPROFILE"
2293 | "TMPDIR"
2294 | "TMP"
2295 | "TEMP"
2296 | "APPDATA"
2297 | "LOCALAPPDATA"
2298 | "SystemRoot"
2299 | "ComSpec"
2300 | "PATHEXT"
2301 | "SystemDrive"
2302 | "windir"
2303 | "OS"
2304 | "PROCESSOR_ARCHITECTURE"
2305 | "PSModulePath"
2306 | "RUSTUP_HOME"
2307 | "NPM_CONFIG_CACHE"
2308 | "CI"
2309 | "TERM"
2310 | "LANG"
2311 | "LC_ALL"
2312 | "TZ"
2313 )));
2314 }
2315
2316 #[cfg(unix)]
2322 #[test]
2323 fn contract_cargo_home_replaces_ambient_root_in_merge_gates() {
2324 let source = tempfile::tempdir().unwrap();
2325 std::fs::create_dir_all(source.path().join("registry")).unwrap();
2326 std::fs::write(source.path().join("registry/cache-marker"), "registry").unwrap();
2327 std::fs::write(source.path().join("credentials.toml"), "operator-secret").unwrap();
2328 let _guard = crate::agent_env::EnvTestGuard::engage(&[(
2329 "CARGO_HOME",
2330 source.path().to_str().expect("utf-8 temp path"),
2331 )]);
2332 let dir = tempfile::tempdir().unwrap();
2333
2334 let (ok, output) = run_bounded_gate_command(
2335 dir.path(),
2336 "printf '%s' \"$CARGO_HOME\" \
2337 && test -f \"$CARGO_HOME/registry/cache-marker\" \
2338 && test ! -e \"$CARGO_HOME/credentials.toml\"",
2339 );
2340 assert!(
2341 ok,
2342 "gate command must see a seeded, credential-free Cargo home: {output}"
2343 );
2344 assert!(
2345 !output.is_empty() && output != source.path().to_string_lossy().as_ref(),
2346 "the gate must NOT receive the ambient Cargo root: {output}"
2347 );
2348 }
2349 #[cfg(unix)]
2355 #[tokio::test]
2356 async fn contract_command_cannot_see_ambient_secrets() {
2357 let _poison = crate::agent_env::EnvTestGuard::engage(&[
2358 ("GH_TOKEN", "hunter2"),
2359 ("SLACK_BOT_TOKEN", "x"),
2360 ("AWS_SECRET_ACCESS_KEY", "y"),
2361 ]);
2362 let dir = tempfile::tempdir().unwrap();
2363 let scratch = tempfile::tempdir().unwrap();
2364 let env = crate::agent_env::contract_command_env(scratch.path(), Some("deadbeef"), &[]);
2365
2366 let (ok, output) = run_shell_command(
2368 dir.path(),
2369 "test -z \"$GH_TOKEN\" && test -z \"$SLACK_BOT_TOKEN\" && test -z \"$AWS_SECRET_ACCESS_KEY\"",
2370 &env,
2371 )
2372 .await;
2373 assert!(
2374 ok,
2375 "poisoned ambient vars reached the contract command: {output}"
2376 );
2377
2378 let (ok, names) =
2383 run_shell_command(dir.path(), "env | sed 's/=.*//' | LC_ALL=C sort", &env).await;
2384 assert!(ok, "{names}");
2385 for leaked in ["GH_TOKEN", "SLACK_BOT_TOKEN", "AWS_SECRET_ACCESS_KEY"] {
2386 assert!(
2387 !names.lines().any(|name| name == leaked),
2388 "contract env leaked {leaked}:\n{names}"
2389 );
2390 }
2391 assert!(
2392 names.lines().any(|name| name == "PATH"),
2393 "PATH must cross:\n{names}"
2394 );
2395
2396 let (ok, managed) = run_shell_command(
2397 dir.path(),
2398 "printf 'HOME=%s\nKRANZ_BASE_SHA=%s\nCARGO_HOME=%s\n' \"$HOME\" \"$KRANZ_BASE_SHA\" \"$CARGO_HOME\"",
2399 &env,
2400 )
2401 .await;
2402 assert!(ok, "{managed}");
2403 assert!(
2404 managed.contains(&format!("HOME={}", scratch.path().display())),
2405 "HOME must be the per-mission scratch:\n{managed}"
2406 );
2407 assert!(
2408 managed.contains("KRANZ_BASE_SHA=deadbeef"),
2409 "base sha must reach the contract env:\n{managed}"
2410 );
2411 let cargo_home = env.get("CARGO_HOME").expect("CARGO_HOME");
2412 assert!(
2413 std::path::Path::new(cargo_home).starts_with(scratch.path()),
2414 "contract CARGO_HOME must live under mission scratch: {cargo_home}"
2415 );
2416 assert!(
2417 managed.contains(&format!("CARGO_HOME={cargo_home}")),
2418 "cache-only Cargo home must reach the child:\n{managed}"
2419 );
2420 }
2421
2422 #[cfg(unix)]
2425 #[tokio::test]
2426 async fn contract_env_passthrough_admits_only_the_named_var() {
2427 let _guard = crate::agent_env::EnvTestGuard::engage(&[
2428 ("KRANZ_CONTRACT_TEST_CRED", "cred-value"),
2429 ("GH_TOKEN", "hunter2"),
2430 ]);
2431 let dir = tempfile::tempdir().unwrap();
2432 let scratch = tempfile::tempdir().unwrap();
2433
2434 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
2436 let (ok, output) =
2437 run_shell_command(dir.path(), "test -z \"$KRANZ_CONTRACT_TEST_CRED\"", &env).await;
2438 assert!(
2439 ok,
2440 "an unconfigured var must not reach the contract env: {output}"
2441 );
2442
2443 let env = crate::agent_env::contract_command_env(
2446 scratch.path(),
2447 None,
2448 &["KRANZ_CONTRACT_TEST_CRED".to_string()],
2449 );
2450 let (ok, output) = run_shell_command(
2451 dir.path(),
2452 "test \"$KRANZ_CONTRACT_TEST_CRED\" = cred-value && test -z \"$GH_TOKEN\"",
2453 &env,
2454 )
2455 .await;
2456 assert!(
2457 ok,
2458 "the passthrough-named var must cross, nothing else: {output}"
2459 );
2460 }
2461
2462 #[cfg(unix)]
2467 #[tokio::test]
2468 async fn base_sha_reaches_final_gate_env() {
2469 let dir = tempfile::tempdir().unwrap();
2470 let env = runner::contract_env(Some("deadbeefcafe"));
2471 let (ok, output) = run_shell_command_with_timeout(
2472 dir.path(),
2473 "test \"$KRANZ_BASE_SHA\" = deadbeefcafe",
2474 Duration::from_secs(10),
2475 &env,
2476 )
2477 .await;
2478 assert!(ok, "expected command to succeed: {output}");
2479 }
2480
2481 #[tokio::test]
2486 async fn shell_command_with_code_reports_the_real_exit_code() {
2487 let dir = tempfile::tempdir().unwrap();
2488 let env = std::collections::HashMap::new();
2489
2490 let (code, output) = run_shell_command_with_code(dir.path(), "echo hi", &env).await;
2491 assert_eq!(code, Some(0), "{output}");
2492 assert!(output.contains("hi"), "{output}");
2493
2494 let (code, output) = run_shell_command_with_code(dir.path(), "exit 3", &env).await;
2495 assert_eq!(code, Some(3), "{output}");
2496 }
2497
2498 #[cfg(unix)]
2504 #[tokio::test]
2505 async fn bounded_argv_timeout_kills_the_whole_process_tree() {
2506 let dir = tempfile::tempdir().unwrap();
2507 let pidfile = dir.path().join("child.pid");
2508 let script = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
2509 let env = std::collections::HashMap::new();
2510
2511 let (code, output) = tokio::time::timeout(
2512 Duration::from_secs(10),
2513 run_bounded_argv(
2514 dir.path(),
2515 std::path::Path::new("/bin/sh"),
2516 &["-c".to_string(), script],
2517 Duration::from_millis(500),
2518 &env,
2519 ),
2520 )
2521 .await
2522 .expect("timed-out command must return promptly");
2523 assert_eq!(code, None, "a timeout yields no exit code: {output}");
2524 assert!(output.contains("timed out"), "got: {output}");
2525
2526 let pid: i32 = std::fs::read_to_string(&pidfile)
2527 .expect("shell wrote the background pid before the timeout")
2528 .trim()
2529 .parse()
2530 .expect("pidfile contains a pid");
2531
2532 let deadline = std::time::Instant::now() + Duration::from_secs(5);
2535 while unsafe { libc::kill(pid, 0) } == 0 {
2536 assert!(
2537 std::time::Instant::now() < deadline,
2538 "background child {pid} survived the group kill"
2539 );
2540 tokio::time::sleep(Duration::from_millis(50)).await;
2541 }
2542 }
2543
2544 #[cfg(unix)]
2549 #[tokio::test]
2550 async fn bounded_argv_drains_large_output_and_reports_exit_codes() {
2551 let dir = tempfile::tempdir().unwrap();
2552 let env = std::collections::HashMap::new();
2553 let big = "i=0; while [ \"$i\" -lt 20000 ]; do \
2554 printf '0123456789abcdef0123456789abcdef\\n'; \
2555 i=$((i + 1)); done; printf 'OUTPUT-END'";
2556
2557 let (code, output) = run_bounded_argv(
2558 dir.path(),
2559 std::path::Path::new("/bin/sh"),
2560 &["-c".to_string(), big.to_string()],
2561 Duration::from_secs(10),
2562 &env,
2563 )
2564 .await;
2565
2566 assert_eq!(
2567 code,
2568 Some(0),
2569 "large-output command must complete: {output}"
2570 );
2571 assert!(output.ends_with("OUTPUT-END"), "{output}");
2572 assert!(
2573 output.chars().count() <= COMMAND_OUTPUT_TAIL,
2574 "retained output exceeded the cap: {} chars",
2575 output.chars().count()
2576 );
2577
2578 let (code, output) = run_bounded_argv(
2579 dir.path(),
2580 std::path::Path::new("/bin/sh"),
2581 &["-c".to_string(), "exit 3".to_string()],
2582 Duration::from_secs(10),
2583 &env,
2584 )
2585 .await;
2586 assert_eq!(code, Some(3), "{output}");
2587 }
2588
2589 #[cfg(unix)]
2600 static GATE_SANDBOX_WRAP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
2601
2602 #[cfg(target_os = "macos")]
2603 fn gate_wrap_sandbox_exec_can_apply() -> bool {
2604 let found = std::process::Command::new("which")
2605 .arg("sandbox-exec")
2606 .output()
2607 .map(|o| o.status.success())
2608 .unwrap_or(false);
2609 if !found {
2610 crate::test_capability::skip(
2611 crate::test_capability::capability::SANDBOX_EXEC,
2612 "sandbox-exec not found on this host",
2613 );
2614 return false;
2615 }
2616 let smoke = std::process::Command::new("sandbox-exec")
2617 .arg("-p")
2618 .arg("(version 1)\n(allow default)\n")
2619 .arg("/usr/bin/true")
2620 .output();
2621 match smoke {
2622 Ok(output) if output.status.success() => true,
2623 Ok(output) => {
2624 eprintln!(
2625 "sandbox-exec cannot apply a smoke profile on this host; skipping: {}",
2626 String::from_utf8_lossy(&output.stderr)
2627 );
2628 false
2629 }
2630 Err(e) => {
2631 eprintln!("sandbox-exec smoke probe failed; skipping: {e}");
2632 false
2633 }
2634 }
2635 }
2636
2637 #[cfg(target_os = "linux")]
2638 fn gate_wrap_bwrap_can_apply() -> bool {
2639 if !crate::sandbox::command_available("bwrap") {
2640 crate::test_capability::skip(
2641 crate::test_capability::capability::BWRAP,
2642 "bwrap not found on this host",
2643 );
2644 return false;
2645 }
2646 let smoke = std::process::Command::new("bwrap")
2647 .args([
2648 "--die-with-parent",
2649 "--ro-bind",
2650 "/",
2651 "/",
2652 "--dev",
2653 "/dev",
2654 "--proc",
2655 "/proc",
2656 "--",
2657 "/bin/true",
2658 ])
2659 .output();
2660 match smoke {
2661 Ok(output) if output.status.success() => true,
2662 Ok(output) => {
2663 eprintln!(
2664 "bwrap cannot apply a smoke sandbox on this host; skipping: {}",
2665 String::from_utf8_lossy(&output.stderr)
2666 );
2667 false
2668 }
2669 Err(e) => {
2670 eprintln!("bwrap smoke probe failed; skipping: {e}");
2671 false
2672 }
2673 }
2674 }
2675
2676 #[cfg(unix)]
2679 fn gate_wrap_enforcement_available() -> bool {
2680 #[cfg(target_os = "macos")]
2681 {
2682 gate_wrap_sandbox_exec_can_apply()
2683 }
2684 #[cfg(target_os = "linux")]
2685 {
2686 gate_wrap_bwrap_can_apply()
2687 }
2688 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
2689 {
2690 false
2691 }
2692 }
2693
2694 fn fs_sandbox_config(enforce: crate::types::SandboxEnforce) -> crate::types::SandboxConfig {
2695 crate::types::SandboxConfig {
2696 enforce,
2697 provider: crate::types::SandboxProvider::Process,
2698 image: None,
2699 extra_write: vec![],
2700 egress: vec![],
2701 }
2702 }
2703
2704 #[cfg(unix)]
2709 fn gate_wrap_layout() -> (tempfile::TempDir, std::path::PathBuf) {
2710 gate_wrap_layout_with_repo(tempfile::tempdir().unwrap())
2711 }
2712
2713 #[cfg(unix)]
2714 fn gate_wrap_layout_with_repo(
2715 repo: tempfile::TempDir,
2716 ) -> (tempfile::TempDir, std::path::PathBuf) {
2717 let kranz_dir = repo.path().join(".kranz");
2718 let mission = kranz_dir.join("missions").join("m-gate");
2719 std::fs::create_dir_all(mission.join("runs")).unwrap();
2720 std::fs::create_dir_all(mission.join("control")).unwrap();
2721 std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
2722 std::fs::write(mission.join("state.json"), "{}").unwrap();
2723 for name in ["serve.token", "serve.read.token", "config.json"] {
2724 std::fs::write(kranz_dir.join(name), "secret").unwrap();
2725 }
2726 std::fs::write(repo.path().join("public.txt"), "public").unwrap();
2727 (repo, mission)
2728 }
2729
2730 #[test]
2739 fn gate_sandbox_wrap_resolve_matrix() {
2740 let repo = tempfile::tempdir().unwrap();
2741 let mission = repo.path().join(".kranz").join("missions").join("m-x");
2742 std::fs::create_dir_all(&mission).unwrap();
2743 let scratch = tempfile::tempdir().unwrap();
2744 let off = crate::types::SandboxConfig::default();
2745 let fs = fs_sandbox_config(crate::types::SandboxEnforce::Fs);
2746
2747 let resolution = resolve_gate_sandbox_target(
2749 &off,
2750 repo.path(),
2751 &mission,
2752 scratch.path(),
2753 scratch.path(),
2754 "macos",
2755 false,
2756 None,
2757 None,
2758 )
2759 .unwrap();
2760 assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
2761 assert!(resolution.note.is_none());
2762
2763 let resolution = resolve_gate_sandbox_target(
2766 &fs,
2767 repo.path(),
2768 &mission,
2769 scratch.path(),
2770 scratch.path(),
2771 "macos",
2772 false,
2773 None,
2774 None,
2775 )
2776 .unwrap();
2777 assert!(resolution.note.is_none());
2778 let GateSandbox::Seatbelt {
2779 enforce,
2780 profile_path,
2781 } = &resolution.sandbox
2782 else {
2783 panic!("fs on macOS must resolve to Seatbelt");
2784 };
2785 assert_eq!(*enforce, crate::types::SandboxEnforce::Fs);
2786 let profile = std::fs::read_to_string(profile_path).unwrap();
2787 assert!(profile.contains("(deny default)"), "{profile}");
2788 assert!(
2789 profile.contains("(literal \"/dev/null\")"),
2790 "the gate profile must add the /dev/null device write allow:\n{profile}"
2791 );
2792 assert!(
2793 profile.contains("(literal \"/dev/ptmx\")"),
2794 "pty harness support (pty-functional-validation): the gate profile must \
2795 permit the ptmx multiplexer:\n{profile}"
2796 );
2797 assert!(
2801 profile.contains(
2802 "(allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))"
2803 ),
2804 "the grantpt/unlockpt ioctl allow must be scoped to /dev/ptmx and the \
2805 tty slave nodes:\n{profile}"
2806 );
2807 assert!(
2808 !profile.contains("(allow file-ioctl)"),
2809 "the ioctl allow must never be unscoped again (every device the gate \
2810 can open becomes ioctl-able):\n{profile}"
2811 );
2812 assert!(
2813 !profile.contains("xcrun_db"),
2814 "13th-pass review (P1): the gate profile must NOT permit writes to the \
2815 shared per-user xcrun cache (prewarm + deny posture):\n{profile}"
2816 );
2817 assert!(
2818 profile.contains("events.jsonl"),
2819 "mission metadata write denies must ride along:\n{profile}"
2820 );
2821 assert!(
2822 profile.contains("serve.token"),
2823 "authority read denies must ride along:\n{profile}"
2824 );
2825
2826 let resolution = resolve_gate_sandbox_target(
2828 &fs,
2829 repo.path(),
2830 &mission,
2831 scratch.path(),
2832 scratch.path(),
2833 "linux",
2834 true,
2835 None,
2836 None,
2837 )
2838 .unwrap();
2839 let GateSandbox::Bubblewrap { inputs } = &resolution.sandbox else {
2840 panic!("fs on linux with bwrap must resolve to Bubblewrap");
2841 };
2842 assert_eq!(inputs.session_cwd, repo.path());
2843 assert_eq!(inputs.tmpdir, scratch.path());
2844 assert_eq!(inputs.mission_dir, mission);
2845
2846 let error = resolve_gate_sandbox_target(
2849 &fs,
2850 repo.path(),
2851 &mission,
2852 scratch.path(),
2853 scratch.path(),
2854 "linux",
2855 false,
2856 None,
2857 None,
2858 )
2859 .expect_err("linux without bwrap must fail closed");
2860 assert!(error.to_string().contains("bwrap"), "{error}");
2861
2862 let resolution = resolve_gate_sandbox_target(
2864 &fs,
2865 repo.path(),
2866 &mission,
2867 scratch.path(),
2868 scratch.path(),
2869 "windows",
2870 false,
2871 None,
2872 None,
2873 )
2874 .expect("Windows process gates resolve AppContainer");
2875 let GateSandbox::AppContainer { inputs, .. } = &resolution.sandbox else {
2876 panic!("fs on Windows must resolve AppContainer");
2877 };
2878 assert_eq!(inputs.session_cwd, repo.path());
2879 assert_eq!(inputs.tmpdir, scratch.path());
2880 assert_eq!(inputs.mission_dir, mission);
2881
2882 let error = resolve_gate_sandbox_target(
2887 &fs,
2888 repo.path(),
2889 &mission,
2890 scratch.path(),
2891 scratch.path(),
2892 "solaris",
2893 false,
2894 None,
2895 None,
2896 )
2897 .expect_err("an unknown platform must fail closed");
2898 assert!(error.to_string().contains("unsupported"), "{error}");
2899 assert!(
2900 error
2901 .to_string()
2902 .contains("refusing to run engine-run gates unsandboxed"),
2903 "{error}"
2904 );
2905 }
2906
2907 #[test]
2917 fn gate_profile_extras_scopes_file_ioctl_to_pty_devices() {
2918 let extras = gate_profile_extras();
2919 assert!(
2920 extras.contains(
2921 "(allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))"
2922 ),
2923 "the ioctl allow must be scoped to the pty device pair:\n{extras}"
2924 );
2925 assert!(
2926 !extras.contains("(allow file-ioctl)"),
2927 "the unrestricted ioctl allow must not return:\n{extras}"
2928 );
2929 assert!(extras.contains("(literal \"/dev/ptmx\")"), "{extras}");
2932 assert!(extras.contains("^/dev/tty[p-t][0-9a-f]+$"), "{extras}");
2933 assert!(
2934 extras.contains("(allow signal (target same-sandbox))"),
2935 "{extras}"
2936 );
2937 }
2938
2939 #[test]
2947 fn gate_profile_extras_deny_the_operators_own_terminal() {
2948 let extras = gate_profile_extras();
2949 let ttys = crate::sandbox::operator_tty_paths();
2950 if ttys.is_empty() {
2951 assert!(
2956 !extras.contains("(deny file-read* file-write* file-ioctl"),
2957 "no tty means no deny block:\n{extras}"
2958 );
2959 return;
2960 }
2961 assert!(
2962 extras.contains("(deny file-read* file-write* file-ioctl"),
2963 "a controlling terminal must produce a deny block:\n{extras}"
2964 );
2965 for tty in &ttys {
2966 let expected = format!("(literal \"{}\")", crate::sandbox::escape_sbpl_literal(tty));
2967 assert!(
2968 extras.contains(&expected),
2969 "the operator terminal {} must be denied:\n{extras}",
2970 tty.display()
2971 );
2972 }
2973 let allow = extras
2976 .find("(allow file-ioctl (literal \"/dev/ptmx\")")
2977 .expect("the pty ioctl allow");
2978 let deny = extras
2979 .find("(deny file-read* file-write* file-ioctl")
2980 .expect("the terminal deny");
2981 assert!(deny > allow, "the deny must follow the allows:\n{extras}");
2982 }
2983
2984 #[test]
2988 fn session_profile_denies_the_operators_own_terminal() {
2989 let repo = tempfile::tempdir().unwrap();
2990 let mission = repo.path().join(".kranz").join("missions").join("m-x");
2991 std::fs::create_dir_all(&mission).unwrap();
2992 let scratch = tempfile::tempdir().unwrap();
2993 let profile = crate::sandbox::generate_profile(&crate::sandbox::SandboxInputs {
2994 enforce: crate::types::SandboxEnforce::Fs,
2995 session_cwd: repo.path().to_path_buf(),
2996 mission_dir: mission,
2997 tmpdir: scratch.path().to_path_buf(),
2998 extra_write: vec![],
2999 egress: vec![],
3000 validator_read_deny_roots: vec![],
3001 });
3002
3003 for tty in crate::sandbox::operator_tty_paths() {
3004 let expected = format!(
3005 "(literal \"{}\")",
3006 crate::sandbox::escape_sbpl_literal(&tty)
3007 );
3008 assert!(
3009 profile.contains(&expected),
3010 "the session profile must deny the operator terminal {}:\n{profile}",
3011 tty.display()
3012 );
3013 }
3014 }
3015
3016 #[test]
3028 fn container_gate_wrap_resolve_matrix() {
3029 let repo = tempfile::tempdir().unwrap();
3030 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3031 std::fs::create_dir_all(&mission).unwrap();
3032 let scratch = tempfile::tempdir().unwrap();
3033 let container = |enforce| crate::types::SandboxConfig {
3034 enforce,
3035 provider: crate::types::SandboxProvider::Container,
3036 image: None,
3037 extra_write: vec![],
3038 egress: vec![],
3039 };
3040 let runtime = Some(crate::sandbox_container::ContainerRuntime::Docker);
3041
3042 let resolution = resolve_gate_sandbox_target(
3044 &container(crate::types::SandboxEnforce::Fs),
3045 repo.path(),
3046 &mission,
3047 scratch.path(),
3048 scratch.path(),
3049 "linux",
3050 false,
3051 runtime,
3052 None,
3053 )
3054 .unwrap();
3055 assert!(resolution.note.is_none());
3056 let GateSandbox::Container { inputs, spec } = &resolution.sandbox else {
3057 panic!("container + runtime must resolve to GateSandbox::Container on linux");
3058 };
3059 assert_eq!(inputs.session_cwd, repo.path());
3060 assert_eq!(inputs.tmpdir, scratch.path());
3061 assert_eq!(inputs.mission_dir, mission);
3062 assert_eq!(inputs.enforce, crate::types::SandboxEnforce::Fs);
3063 assert_eq!(
3064 spec.runtime,
3065 crate::sandbox_container::ContainerRuntime::Docker
3066 );
3067 assert_eq!(spec.image, crate::sandbox_container::DEFAULT_IMAGE);
3068
3069 let proven = resolve_gate_sandbox_target(
3073 &container(crate::types::SandboxEnforce::Fs),
3074 repo.path(),
3075 &mission,
3076 scratch.path(),
3077 scratch.path(),
3078 "macos",
3079 false,
3080 runtime,
3081 Some(crate::sandbox_container::MountProof::Proven),
3082 )
3083 .expect("a proven macOS host must resolve its container gate");
3084 assert!(
3085 matches!(proven.sandbox, GateSandbox::Container { .. }),
3086 "{:?}",
3087 proven.sandbox
3088 );
3089
3090 let unshared = resolve_gate_sandbox_target(
3093 &container(crate::types::SandboxEnforce::Fs),
3094 repo.path(),
3095 &mission,
3096 scratch.path(),
3097 scratch.path(),
3098 "macos",
3099 false,
3100 runtime,
3101 Some(crate::sandbox_container::MountProof::Failed(
3102 "docker accepted a bind mount of /var/folders/x and shared nothing".to_string(),
3103 )),
3104 )
3105 .expect_err("a failed proof must refuse the gate");
3106 assert!(
3107 unshared.to_string().contains("/var/folders/x"),
3108 "{unshared}"
3109 );
3110
3111 for target_os in ["macos", "windows"] {
3115 let error = resolve_gate_sandbox_target(
3116 &container(crate::types::SandboxEnforce::Fs),
3117 repo.path(),
3118 &mission,
3119 scratch.path(),
3120 scratch.path(),
3121 target_os,
3122 false,
3123 runtime,
3124 None,
3125 )
3126 .expect_err("an unproved container gate must fail closed");
3127 assert!(
3128 error
3129 .to_string()
3130 .contains("unverified container mount contract"),
3131 "{error}"
3132 );
3133 if target_os == "macos" {
3134 assert!(
3135 error.to_string().contains("requires a bind-mount proof"),
3136 "{error}"
3137 );
3138 assert!(
3139 error.to_string().contains("sandbox.provider=\"process\""),
3140 "{error}"
3141 );
3142 }
3143 }
3144
3145 let mut imaged = container(crate::types::SandboxEnforce::Fs);
3148 imaged.image = Some("ghcr.io/example/kranz-worker:1".to_string());
3149 let resolution = resolve_gate_sandbox_target(
3150 &imaged,
3151 repo.path(),
3152 &mission,
3153 scratch.path(),
3154 scratch.path(),
3155 "linux",
3156 false,
3157 runtime,
3158 None,
3159 )
3160 .unwrap();
3161 let GateSandbox::Container { spec, .. } = &resolution.sandbox else {
3162 panic!("container + runtime must resolve to GateSandbox::Container");
3163 };
3164 assert_eq!(spec.image, "ghcr.io/example/kranz-worker:1");
3165
3166 let error = resolve_gate_sandbox_target(
3169 &container(crate::types::SandboxEnforce::Fs),
3170 repo.path(),
3171 &mission,
3172 scratch.path(),
3173 scratch.path(),
3174 "linux",
3175 false,
3176 None,
3177 None,
3178 )
3179 .expect_err("container without a runtime must fail closed");
3180 assert!(
3181 error.to_string().contains("no container runtime"),
3182 "{error}"
3183 );
3184 assert!(
3185 error
3186 .to_string()
3187 .contains("refusing to run engine-run gates unsandboxed"),
3188 "{error}"
3189 );
3190
3191 let mut egress = container(crate::types::SandboxEnforce::FsNet);
3195 egress.egress = vec!["crates.io:443".to_string()];
3196 let error = resolve_gate_sandbox_target(
3197 &egress,
3198 repo.path(),
3199 &mission,
3200 scratch.path(),
3201 scratch.path(),
3202 "linux",
3203 false,
3204 runtime,
3205 None,
3206 )
3207 .expect_err("container fs+net with an egress list must fail closed");
3208 assert!(error.to_string().contains("advisory"), "{error}");
3209
3210 let resolution = resolve_gate_sandbox_target(
3214 &container(crate::types::SandboxEnforce::FsNet),
3215 repo.path(),
3216 &mission,
3217 scratch.path(),
3218 scratch.path(),
3219 "linux",
3220 false,
3221 runtime,
3222 None,
3223 )
3224 .unwrap();
3225 assert_eq!(
3226 resolution.sandbox.enforce(),
3227 crate::types::SandboxEnforce::FsNet
3228 );
3229
3230 let resolution = resolve_gate_sandbox_target(
3233 &container(crate::types::SandboxEnforce::Off),
3234 repo.path(),
3235 &mission,
3236 scratch.path(),
3237 scratch.path(),
3238 "macos",
3239 false,
3240 None,
3241 None,
3242 )
3243 .unwrap();
3244 assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
3245 assert!(resolution.note.is_none());
3246 }
3247
3248 #[test]
3253 fn windows_enforced_gate_process_resolves_appcontainer_while_container_fails_closed() {
3254 let repo = tempfile::tempdir().unwrap();
3255 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3256 std::fs::create_dir_all(&mission).unwrap();
3257 let scratch = tempfile::tempdir().unwrap();
3258 let runtime = Some(crate::sandbox_container::ContainerRuntime::Docker);
3259
3260 for enforce in [
3261 crate::types::SandboxEnforce::Fs,
3262 crate::types::SandboxEnforce::FsNet,
3263 ] {
3264 let process = fs_sandbox_config(enforce);
3265 let resolution = resolve_gate_sandbox_target(
3266 &process,
3267 repo.path(),
3268 &mission,
3269 scratch.path(),
3270 scratch.path(),
3271 "windows",
3272 false,
3273 runtime,
3274 None,
3275 )
3276 .expect("Windows process gate enforcement resolves");
3277 assert!(resolution.note.is_none(), "{:?}", resolution.note);
3278 let GateSandbox::AppContainer { inputs, .. } = resolution.sandbox else {
3279 panic!("Windows process gate must resolve AppContainer");
3280 };
3281 assert_eq!(inputs.enforce, enforce);
3282 assert_eq!(inputs.session_cwd, repo.path());
3283 assert_eq!(inputs.mission_dir, mission);
3284
3285 let container = crate::types::SandboxConfig {
3286 enforce,
3287 provider: crate::types::SandboxProvider::Container,
3288 image: None,
3289 extra_write: vec![],
3290 egress: vec![],
3291 };
3292 let error = resolve_gate_sandbox_target(
3293 &container,
3294 repo.path(),
3295 &mission,
3296 scratch.path(),
3297 scratch.path(),
3298 "windows",
3299 false,
3300 runtime,
3301 None,
3302 )
3303 .expect_err("an unproved Windows container gate must fail closed");
3304 assert!(error
3307 .to_string()
3308 .contains("not supported on target_os=windows"));
3309 assert!(error
3310 .to_string()
3311 .contains("unverified container mount contract"));
3312 }
3313 }
3314
3315 #[cfg(target_os = "macos")]
3322 #[test]
3323 fn gate_xcrun_deny_prewarm_runs_once_per_resolve_not_per_command() {
3324 let repo = tempfile::tempdir().unwrap();
3325 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3326 std::fs::create_dir_all(&mission).unwrap();
3327 let scratch = tempfile::tempdir().unwrap();
3328 let cfg = fs_sandbox_config(crate::types::SandboxEnforce::Fs);
3329 let resolve = || {
3330 resolve_gate_sandbox(&cfg, repo.path(), &mission, scratch.path(), scratch.path())
3331 .unwrap()
3332 };
3333
3334 let resolution = resolve();
3338 assert!(resolution.prewarmed_xcrun, "one prewarm per resolve");
3339
3340 let env = std::collections::HashMap::new();
3343 let _argv_one = resolution.sandbox.wrap_shell("true", &env).unwrap();
3344 let _argv_two = resolution.sandbox.wrap_shell("echo hi", &env).unwrap();
3345 assert!(
3346 resolution.prewarmed_xcrun,
3347 "command wraps neither prewarm nor reset the record"
3348 );
3349
3350 let second = resolve();
3352 assert!(second.prewarmed_xcrun, "each resolve prewarms exactly once");
3353 }
3354
3355 #[test]
3363 fn container_gate_wrap_merge_policy_enforces_or_notes_the_fail_closed() {
3364 let container = |enforce| crate::types::SandboxConfig {
3365 enforce,
3366 provider: crate::types::SandboxProvider::Container,
3367 image: None,
3368 extra_write: vec![],
3369 egress: vec![],
3370 };
3371 let policy = MergeGatePolicy {
3372 sandbox: container(crate::types::SandboxEnforce::Fs),
3373 mission_dir: std::path::PathBuf::new(),
3374 };
3375 assert!(policy.enforces_on_this_host());
3379 assert!(policy
3381 .degradation_note_target(Some(crate::sandbox_container::ContainerRuntime::Docker))
3382 .is_none());
3383 let note = policy
3385 .degradation_note_target(None)
3386 .expect("the runtime-unavailable container posture must be noted");
3387 assert!(note.contains("no container runtime"), "{note}");
3388 assert!(
3389 note.contains("refusing to run engine-run gates unsandboxed"),
3390 "{note}"
3391 );
3392 let repo = tempfile::tempdir().unwrap();
3396 let mission = repo.path().join(".kranz").join("missions").join("m-x");
3397 std::fs::create_dir_all(&mission).unwrap();
3398 let scratch = tempfile::tempdir().unwrap();
3399 let error = resolve_gate_sandbox_target(
3400 &policy.sandbox,
3401 repo.path(),
3402 &mission,
3403 scratch.path(),
3404 scratch.path(),
3405 "linux",
3406 false,
3407 None,
3408 None,
3409 )
3410 .expect_err("container without a runtime must fail closed");
3411 assert_eq!(
3412 error.to_string(),
3413 format!("configuration error: {note}"),
3414 "the engine-path resolve error and the merge-path note must match"
3415 );
3416
3417 let off = MergeGatePolicy {
3421 sandbox: container(crate::types::SandboxEnforce::Off),
3422 mission_dir: std::path::PathBuf::new(),
3423 };
3424 assert!(off.degradation_note_target(None).is_none());
3425 assert!(!off.enforces_on_this_host());
3426 let process = MergeGatePolicy {
3427 sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3428 mission_dir: std::path::PathBuf::new(),
3429 };
3430 assert!(process.degradation_note_target(None).is_none());
3431 }
3432
3433 #[cfg(unix)]
3438 #[tokio::test]
3439 async fn gate_sandbox_wrap_off_keeps_byte_identical_behavior() {
3440 let dir = tempfile::tempdir().unwrap();
3441 let outside = tempfile::tempdir().unwrap();
3442 let env = std::collections::HashMap::new();
3443
3444 let resolution = resolve_gate_sandbox(
3445 &crate::types::SandboxConfig::default(),
3446 dir.path(),
3447 dir.path(),
3448 dir.path(),
3449 dir.path(),
3450 )
3451 .unwrap();
3452 assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
3453 assert!(resolution.note.is_none());
3454
3455 let marker = outside.path().join("gate_sandbox_wrap_off_marker");
3456 let command = format!("echo hi > '{}' && printf MARKER", marker.display());
3457 let (ok_reference, out_reference) = run_shell_command(dir.path(), &command, &env).await;
3458 let (ok_wrapped, out_wrapped) =
3459 run_shell_command_sandboxed(dir.path(), &command, &env, &GateSandbox::Disabled).await;
3460 assert!(ok_reference, "reference run failed: {out_reference}");
3461 assert!(ok_wrapped, "disabled wrap run failed: {out_wrapped}");
3462 assert_eq!(
3463 out_reference, out_wrapped,
3464 "the Disabled wrap must reproduce the pre-wrap runner byte-for-byte"
3465 );
3466 assert!(
3467 marker.exists(),
3468 "with enforce == off a write outside any allowlist succeeds (today's posture)"
3469 );
3470 }
3471
3472 #[cfg(unix)]
3474 #[tokio::test]
3475 #[allow(clippy::await_holding_lock)]
3476 async fn container_gate_runtime_context_survives_timeout_without_worker_or_ambient_secrets() {
3477 use std::os::unix::fs::PermissionsExt as _;
3478 let fixture = tempfile::tempdir().unwrap();
3479 let home = fixture.path().join("operator");
3480 let scratch = fixture.path().join("worker");
3481 std::fs::create_dir(&home).unwrap();
3482 std::fs::create_dir(&scratch).unwrap();
3483 let stub = fixture.path().join("docker");
3484 std::fs::write(&stub, format!(
3485 "#!/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",
3486 fixture.path().display(), fixture.path().display(),
3487 )).unwrap();
3488 std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o700)).unwrap();
3489 let path = format!(
3490 "{}:{}",
3491 fixture.path().display(),
3492 std::env::var("PATH").unwrap_or_default()
3493 );
3494 let _guard = crate::agent_env::EnvTestGuard::engage(&[
3495 ("PATH", &path),
3496 ("HOME", home.to_str().unwrap()),
3497 ("DOCKER_HOST", "unix:///operator-context.sock"),
3498 ("GH_TOKEN", "host-secret"),
3499 ]);
3500 let sandbox = GateSandbox::Container {
3501 inputs: Box::new(crate::sandbox::SandboxInputs {
3502 enforce: crate::types::SandboxEnforce::Fs,
3503 session_cwd: scratch.clone(),
3504 mission_dir: scratch.join("mission"),
3505 tmpdir: scratch.clone(),
3506 extra_write: vec![],
3507 egress: vec![],
3508 validator_read_deny_roots: vec![],
3509 }),
3510 spec: crate::sandbox_container::ContainerSpec {
3511 runtime: crate::sandbox_container::ContainerRuntime::Docker,
3512 image: "fixture".to_string(),
3513 network: None,
3514 name: None,
3515 },
3516 };
3517 let env = HashMap::from([
3518 ("HOME".to_string(), scratch.display().to_string()),
3519 (
3520 "DOCKER_HOST".to_string(),
3521 "unix:///worker-request.sock".to_string(),
3522 ),
3523 ("WORKER_SENTINEL".to_string(), "allowed".to_string()),
3524 ]);
3525 let (code, output) = run_shell_command_sandboxed_with_code(
3526 &scratch,
3527 "true",
3528 Duration::from_millis(500),
3529 &env,
3530 &sandbox,
3531 )
3532 .await;
3533 assert_eq!(
3534 code, None,
3535 "the fixture must exercise timeout cleanup: {output}"
3536 );
3537 for action in ["run", "rm"] {
3538 assert_eq!(
3539 std::fs::read_to_string(fixture.path().join(format!("{action}.env"))).unwrap(),
3540 format!("{}\nunix:///operator-context.sock\nunset\n", home.display())
3541 );
3542 }
3543 let args = std::fs::read_to_string(fixture.path().join("run.args")).unwrap();
3544 assert!(args.contains("WORKER_SENTINEL=allowed"));
3545 assert!(args.contains("DOCKER_HOST=unix:///worker-request.sock"));
3546 assert!(!args.contains("host-secret"));
3547 }
3548
3549 #[cfg(unix)]
3571 #[tokio::test]
3572 #[allow(clippy::await_holding_lock)]
3573 async fn gate_sandbox_wrap_denies_outside_writes_metadata_and_authority_reads() {
3574 let _guard = GATE_SANDBOX_WRAP_LOCK
3575 .lock()
3576 .unwrap_or_else(|p| p.into_inner());
3577 if !gate_wrap_enforcement_available() {
3578 return;
3579 }
3580
3581 let (repo, mission) = gate_wrap_layout();
3582 let kranz_dir = repo.path().join(".kranz");
3583 let scratch = tempfile::tempdir().unwrap();
3584 let outside = tempfile::tempdir().unwrap();
3585 let temp_root_marker =
3588 std::env::temp_dir().join(format!("kranz-gate-wrap-{}", uuid::Uuid::new_v4()));
3589
3590 let resolution = resolve_gate_sandbox(
3591 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3592 repo.path(),
3593 &mission,
3594 scratch.path(),
3595 scratch.path(),
3596 )
3597 .unwrap();
3598 assert!(resolution.note.is_none());
3599 let sandbox = resolution.sandbox;
3600 assert!(sandbox.enforce() == crate::types::SandboxEnforce::Fs);
3601 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3602
3603 for allowed in [
3605 repo.path().join("src.txt"),
3606 scratch.path().join("notes.txt"),
3607 ] {
3608 let (ok, output) = run_shell_command_sandboxed(
3609 repo.path(),
3610 &format!("echo ok > '{}'", allowed.display()),
3611 &env,
3612 &sandbox,
3613 )
3614 .await;
3615 assert!(
3616 ok && allowed.exists(),
3617 "write inside the gate roots must succeed: {output}"
3618 );
3619 }
3620
3621 let (ok, output) =
3624 run_shell_command_sandboxed(repo.path(), "echo hi > /dev/null 2>&1", &env, &sandbox)
3625 .await;
3626 assert!(ok, "/dev/null redirect must succeed: {output}");
3627
3628 let outside_file = outside.path().join("gate_sandbox_wrap_marker");
3630 for probe in [
3631 format!("echo x > '{}'", outside_file.display()),
3632 format!("echo x > '{}'", temp_root_marker.display()),
3633 ] {
3634 let (ok, output) =
3635 run_shell_command_sandboxed(repo.path(), &probe, &env, &sandbox).await;
3636 assert!(
3637 !ok,
3638 "write outside the allowlist must fail under enforcement: {probe}\n{output}"
3639 );
3640 }
3641 assert!(
3642 !outside_file.exists(),
3643 "denied write must not create the file"
3644 );
3645 assert!(
3646 !temp_root_marker.exists(),
3647 "denied temp-root write must not create the marker"
3648 );
3649
3650 let (ok, _) = run_shell_command_sandboxed(
3653 repo.path(),
3654 &format!(
3655 "echo tampered >> '{}'",
3656 mission.join("events.jsonl").display()
3657 ),
3658 &env,
3659 &sandbox,
3660 )
3661 .await;
3662 if cfg!(target_os = "macos") {
3663 assert!(!ok, "events.jsonl append must be denied under Seatbelt");
3664 }
3665 assert_eq!(
3666 std::fs::read_to_string(mission.join("events.jsonl")).unwrap(),
3667 "{\"seq\":1}\n",
3668 "the audit log must be untouched by the sandboxed gate"
3669 );
3670 let (ok, _) = run_shell_command_sandboxed(
3671 repo.path(),
3672 &format!(
3673 "echo x > '{}'",
3674 mission.join("control/approve.json").display()
3675 ),
3676 &env,
3677 &sandbox,
3678 )
3679 .await;
3680 if cfg!(target_os = "macos") {
3681 assert!(!ok, "control/ writes must be denied under Seatbelt");
3682 }
3683 assert!(
3684 std::fs::read_dir(mission.join("control"))
3685 .unwrap()
3686 .next()
3687 .is_none(),
3688 "the control inbox must stay empty on the host"
3689 );
3690
3691 for name in ["serve.token", "serve.read.token", "config.json"] {
3695 let (ok, output) = run_shell_command_sandboxed(
3696 repo.path(),
3697 &format!("test -s '{}'", kranz_dir.join(name).display()),
3698 &env,
3699 &sandbox,
3700 )
3701 .await;
3702 assert!(
3703 !ok,
3704 "a read of denied authority path .kranz/{name} must fail: {output}"
3705 );
3706 }
3707 let (ok, output) = run_shell_command_sandboxed(
3708 repo.path(),
3709 &format!("test -s '{}'", repo.path().join("public.txt").display()),
3710 &env,
3711 &sandbox,
3712 )
3713 .await;
3714 assert!(ok, "ordinary repo reads must keep working: {output}");
3715
3716 let off_env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3720 for probe in [
3721 format!("echo x > '{}'", outside_file.display()),
3722 format!("echo x > '{}'", temp_root_marker.display()),
3723 format!(
3724 "echo tampered >> '{}'",
3725 mission.join("events.jsonl").display()
3726 ),
3727 format!("test -s '{}'", kranz_dir.join("serve.token").display()),
3728 ] {
3729 let (ok, output) =
3730 run_shell_command_sandboxed(repo.path(), &probe, &off_env, &GateSandbox::Disabled)
3731 .await;
3732 assert!(
3733 ok,
3734 "with enforce == off the probe succeeds (today's posture): {probe}\n{output}"
3735 );
3736 }
3737 std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
3740 let _ = std::fs::remove_file(&temp_root_marker);
3741 }
3742
3743 #[cfg(unix)]
3749 #[tokio::test]
3750 #[allow(clippy::await_holding_lock)]
3751 async fn gate_sandbox_wrap_timeout_kills_the_whole_process_tree() {
3752 let _guard = GATE_SANDBOX_WRAP_LOCK
3753 .lock()
3754 .unwrap_or_else(|p| p.into_inner());
3755 if !gate_wrap_enforcement_available() {
3756 return;
3757 }
3758
3759 let (repo, mission) = gate_wrap_layout();
3760 let scratch = tempfile::tempdir().unwrap();
3761 let resolution = resolve_gate_sandbox(
3762 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3763 repo.path(),
3764 &mission,
3765 scratch.path(),
3766 scratch.path(),
3767 )
3768 .unwrap();
3769 let sandbox = resolution.sandbox;
3770 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3771
3772 let pidfile = scratch.path().join("child.pid");
3773 let command = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
3774 #[cfg(target_os = "linux")]
3775 let namespace_file = scratch.path().join("child.pid-namespace");
3776 #[cfg(target_os = "linux")]
3777 let command = format!(
3778 "readlink /proc/self/ns/pid > '{}'; {command}",
3779 namespace_file.display()
3780 );
3781 let (code, output) = tokio::time::timeout(
3782 Duration::from_secs(15),
3783 run_shell_command_sandboxed_with_code(
3784 repo.path(),
3785 &command,
3786 Duration::from_millis(500),
3787 &env,
3788 &sandbox,
3789 ),
3790 )
3791 .await
3792 .expect("timed-out command must return promptly");
3793 assert_eq!(code, None, "a timeout yields no exit code: {output}");
3794 assert!(output.contains("timed out"), "got: {output}");
3795
3796 let pid: i32 = std::fs::read_to_string(&pidfile)
3797 .expect("the wrapped shell wrote the background pid before the timeout")
3798 .trim()
3799 .parse()
3800 .expect("pidfile contains a pid");
3801 #[cfg(target_os = "linux")]
3802 let namespace = std::fs::read_to_string(namespace_file).unwrap();
3803 let child_alive = || {
3804 #[cfg(target_os = "linux")]
3805 {
3806 std::fs::read_dir("/proc").unwrap().flatten().any(|entry| {
3810 std::fs::read_link(entry.path().join("ns/pid"))
3811 .is_ok_and(|link| link.to_string_lossy() == namespace.trim())
3812 })
3813 }
3814 #[cfg(not(target_os = "linux"))]
3815 {
3816 (unsafe { libc::kill(pid, 0) }) == 0
3817 }
3818 };
3819 let deadline = std::time::Instant::now() + Duration::from_secs(5);
3820 while child_alive() {
3821 assert!(
3822 std::time::Instant::now() < deadline,
3823 "background child {pid} survived the group kill through the sandbox wrapper"
3824 );
3825 tokio::time::sleep(Duration::from_millis(50)).await;
3826 }
3827 }
3828
3829 #[cfg(target_os = "macos")]
3855 #[tokio::test]
3856 #[allow(clippy::await_holding_lock)]
3857 async fn gate_sandbox_wrap_dogfood_supervision_allows_tree_denies_host() {
3858 let _guard = GATE_SANDBOX_WRAP_LOCK
3859 .lock()
3860 .unwrap_or_else(|p| p.into_inner());
3861 if !gate_wrap_enforcement_available() {
3862 return;
3863 }
3864
3865 let (repo, mission) = gate_wrap_layout();
3866 let scratch = tempfile::tempdir().unwrap();
3867 let resolution = resolve_gate_sandbox(
3868 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
3869 repo.path(),
3870 &mission,
3871 scratch.path(),
3872 scratch.path(),
3873 )
3874 .unwrap();
3875 let sandbox = resolution.sandbox;
3876 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
3877
3878 let mut host = std::process::Command::new("sleep")
3881 .arg("300")
3882 .spawn()
3883 .expect("spawn host sleeper");
3884 let host_pid = host.id();
3885
3886 let (ok, output) = run_shell_command_sandboxed(
3890 repo.path(),
3891 "sleep 300 & child=$!; kill -0 \"$child\" && kill -TERM \"$child\"",
3892 &env,
3893 &sandbox,
3894 )
3895 .await;
3896 assert!(
3897 ok,
3898 "the wrapped gate must signal its own tree (same-sandbox): {output}"
3899 );
3900
3901 let (ok, output) = run_shell_command_sandboxed(
3904 repo.path(),
3905 &format!("kill -0 {host_pid}"),
3906 &env,
3907 &sandbox,
3908 )
3909 .await;
3910 assert!(
3911 !ok,
3912 "no host-wide signal capability under the wrap (EPERM expected): {output}"
3913 );
3914 let (ok, output) = run_shell_command_sandboxed(
3915 repo.path(),
3916 &format!("ps -p {host_pid} -o command="),
3917 &env,
3918 &sandbox,
3919 )
3920 .await;
3921 assert!(
3922 !ok,
3923 "no ps inspection under the wrap (setuid exec denied): {output}"
3924 );
3925
3926 let (ok, output) = run_shell_command_sandboxed(
3929 repo.path(),
3930 &format!("kill -0 {host_pid} && ps -p {host_pid} -o command="),
3931 &env,
3932 &GateSandbox::Disabled,
3933 )
3934 .await;
3935 assert!(
3936 ok,
3937 "with enforce == off the host probes succeed (today's posture): {output}"
3938 );
3939
3940 let _ = host.kill();
3941 let _ = host.wait();
3942 }
3943
3944 #[cfg(target_os = "macos")]
3984 #[test]
3985 #[ignore = "wrapped-suite proving ground — run manually or via the rust-macos-wrapped-suite CI job"]
3986 fn gate_sandbox_wrap_dogfood_supervision_workspace_suite() {
3987 if !gate_wrap_sandbox_exec_can_apply() {
3988 return;
3989 }
3990 let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3991 .parent()
3992 .and_then(std::path::Path::parent)
3993 .expect("crates/engine has a repo-root ancestor")
3994 .to_path_buf();
3995 let payload = std::env::var("KRANZ_DOGFOOD_SUITE_CMD")
3996 .unwrap_or_else(|_| "cargo test --workspace".to_string());
3997
3998 let scratch =
4005 std::env::temp_dir().join(format!("kranz-gate-{}", uuid::Uuid::new_v4().simple()));
4006 std::fs::create_dir_all(scratch.join("tmp")).unwrap();
4007 let cargo_home = crate::agent_env::cache_only_cargo_home(scratch.as_path());
4008 assert!(
4009 cargo_home.is_dir(),
4010 "could not create the fixture's cache-only Cargo home at {}",
4011 cargo_home.display()
4012 );
4013 let (_layout_guard, mission) = gate_wrap_layout();
4016 let resolution = resolve_gate_sandbox(
4017 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4018 &repo_root,
4019 &mission,
4020 &scratch,
4021 &scratch,
4022 )
4023 .expect("the fixture's gate sandbox resolves on a host that applied the smoke profile");
4024 let mut env = sanitized_gate_env();
4025 env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
4026 for var in ["TMPDIR", "TMP", "TEMP"] {
4027 env.insert(var.to_string(), scratch.join("tmp").display().to_string());
4028 }
4029 let kranz_home = scratch.join("kranz-home");
4036 std::fs::create_dir_all(&kranz_home).unwrap();
4037 env.insert("KRANZ_HOME".to_string(), kranz_home.display().to_string());
4038 let suite_log = scratch.join("tmp").join("dogfood-suite.log");
4039 let command = format!("{payload} > '{}' 2>&1", suite_log.display());
4040
4041 let runtime = tokio::runtime::Builder::new_current_thread()
4042 .enable_all()
4043 .build()
4044 .expect("fixture runtime");
4045 let start = std::time::Instant::now();
4046 let (code, output) = runtime.block_on(run_shell_command_sandboxed_with_code(
4047 &repo_root,
4048 &command,
4049 Duration::from_secs(3600),
4050 &env,
4051 &resolution.sandbox,
4052 ));
4053 let elapsed = start.elapsed();
4054
4055 let log = std::fs::read_to_string(&suite_log)
4056 .unwrap_or_else(|_| format!("<no suite log captured; runner tail: {output}>"));
4057 let skip_count = log
4058 .matches("SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood)")
4059 .count();
4060 println!(
4061 "dogfood wrapped suite `{payload}`: exit={code:?} elapsed={elapsed:.1?} \
4062 skip-under-wrap markers={skip_count} log={}",
4063 suite_log.display()
4064 );
4065 for line in log.lines().filter(|l| l.contains("test result:")) {
4066 println!(" {line}");
4067 }
4068 let tail: Vec<&str> = log.lines().collect();
4072 let tail = &tail[tail.len().saturating_sub(40)..];
4073 assert_eq!(
4074 code,
4075 Some(0),
4076 "cargo test --workspace must run GREEN as a wrapped contract command \
4077 (skip-under-wrap markers seen: {skip_count})\n--- suite log tail ---\n{}",
4078 tail.join("\n")
4079 );
4080 let _ = std::fs::remove_dir_all(&scratch);
4086 }
4087
4088 #[cfg(unix)]
4096 #[test]
4097 fn gate_sandbox_wrap_merge_gate_reads_git_identity_from_read_only_home() {
4098 let _wrap_guard = GATE_SANDBOX_WRAP_LOCK
4099 .lock()
4100 .unwrap_or_else(|p| p.into_inner());
4101 if !gate_wrap_enforcement_available() {
4102 return;
4103 }
4104
4105 let (repo, mission) = gate_wrap_layout();
4106 let fake_home = tempfile::tempdir().unwrap();
4107 std::fs::write(
4108 fake_home.path().join(".gitconfig"),
4109 "[user]\n\tname = Gate Wrap Test\n",
4110 )
4111 .unwrap();
4112 let _home = crate::agent_env::EnvTestGuard::engage(&[(
4113 "HOME",
4114 fake_home.path().to_str().expect("utf-8 temp path"),
4115 )]);
4116 let policy = MergeGatePolicy {
4117 sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4118 mission_dir: mission.clone(),
4119 };
4120 assert!(policy.enforces_on_this_host());
4121
4122 let (ok, output) = run_bounded_gate_command_sandboxed(
4123 repo.path(),
4124 "test \"$(git config user.name)\" = 'Gate Wrap Test' \
4125 && ! touch \"$HOME/gate_sandbox_wrap_marker\" \
4126 && case \"$TMPDIR\" in *kranz-gate-*/tmp) true ;; *) false ;; esac \
4127 && case \"$CARGO_HOME\" in *kranz-gate-*/.cargo-cache-only-*) true ;; *) false ;; esac",
4128 &policy,
4129 );
4130 assert!(
4131 ok,
4132 "git identity must read from the read-only HOME, $HOME writes must be \
4133 denied, and TMPDIR/CARGO_HOME must sit in the per-run scratch: {output}"
4134 );
4135 assert!(
4136 !fake_home.path().join("gate_sandbox_wrap_marker").exists(),
4137 "the denied $HOME write must not have created the marker"
4138 );
4139
4140 let (ok, output) = run_bounded_gate_command_sandboxed(
4142 repo.path(),
4143 "touch \"$HOME/gate_sandbox_wrap_off_marker\"",
4144 &MergeGatePolicy::disabled(),
4145 );
4146 assert!(
4147 ok,
4148 "with enforce == off the $HOME write succeeds (today's posture): {output}"
4149 );
4150 let _ = std::fs::remove_file(fake_home.path().join("gate_sandbox_wrap_off_marker"));
4151 }
4152
4153 #[cfg(unix)]
4163 #[tokio::test]
4164 #[allow(clippy::await_holding_lock)]
4165 async fn gate_sandbox_wrap_cache_write_deny_reads_cache_but_cannot_write() {
4166 let _guard = GATE_SANDBOX_WRAP_LOCK
4167 .lock()
4168 .unwrap_or_else(|p| p.into_inner());
4169 if !gate_wrap_enforcement_available() {
4170 return;
4171 }
4172
4173 let (repo, mission) = gate_wrap_layout();
4174 let scratch = tempfile::tempdir().unwrap();
4175 let cargo = tempfile::tempdir().unwrap();
4179 std::fs::create_dir_all(cargo.path().join("registry")).unwrap();
4180 std::fs::write(cargo.path().join("registry/cache-marker"), "cached").unwrap();
4181 let _cargo = crate::agent_env::EnvTestGuard::engage(&[(
4182 "CARGO_HOME",
4183 cargo.path().to_str().expect("utf-8 temp path"),
4184 )]);
4185
4186 let resolution = resolve_gate_sandbox(
4187 &fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4188 repo.path(),
4189 &mission,
4190 scratch.path(),
4191 scratch.path(),
4192 )
4193 .unwrap();
4194 let sandbox = resolution.sandbox;
4195 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
4196
4197 let (ok, output) = run_shell_command_sandboxed(
4199 repo.path(),
4200 &format!(
4201 "test -s '{}'",
4202 cargo.path().join("registry/cache-marker").display()
4203 ),
4204 &env,
4205 &sandbox,
4206 )
4207 .await;
4208 assert!(ok, "the wrapped gate must read the shared cache: {output}");
4209
4210 let poison = cargo.path().join("registry/poisoned-crate");
4214 let (ok, output) = run_shell_command_sandboxed(
4215 repo.path(),
4216 &format!("echo x > '{}'", poison.display()),
4217 &env,
4218 &sandbox,
4219 )
4220 .await;
4221 assert!(
4222 !ok,
4223 "a write to the operator's real cargo cache must fail under enforcement: {output}"
4224 );
4225 assert!(
4226 !poison.exists(),
4227 "the denied cache write must not create the file"
4228 );
4229
4230 let (ok, output) = run_shell_command_sandboxed(
4233 repo.path(),
4234 &format!("echo x > '{}'", poison.display()),
4235 &env,
4236 &GateSandbox::Disabled,
4237 )
4238 .await;
4239 assert!(
4240 ok,
4241 "with enforce == off the cache write succeeds (documented trade): {output}"
4242 );
4243 let _ = std::fs::remove_file(&poison);
4244 }
4245
4246 #[cfg(unix)]
4251 #[test]
4252 fn gate_sandbox_wrap_disabled_merge_policy_matches_todays_gate_shape() {
4253 let dir = tempfile::tempdir().unwrap();
4254 let temp = std::env::temp_dir().display().to_string();
4257 let temp = temp.trim_end_matches('/');
4258 let ambient_tmpdir = std::env::var("TMPDIR").unwrap_or_else(|_| "unset".to_string());
4259 let command = format!(
4260 "test \"$(dirname \"$CARGO_HOME\")\" = '{temp}' \
4261 && test \"${{TMPDIR:-unset}}\" = '{ambient_tmpdir}'"
4262 );
4263 let (ok, output) =
4264 run_bounded_gate_command_sandboxed(dir.path(), &command, &MergeGatePolicy::disabled());
4265 assert!(
4266 ok,
4267 "the off path must keep today's gate shape (cache-only home under the \
4268 system temp root, ambient TMPDIR): {output}"
4269 );
4270 }
4271
4272 #[test]
4278 fn gate_sandbox_wrap_fs_net_forces_cargo_offline() {
4279 let base: HashMap<String, String> = HashMap::new();
4280 let fs_net = GateSandbox::Seatbelt {
4281 enforce: crate::types::SandboxEnforce::FsNet,
4282 profile_path: std::path::PathBuf::from("/nonexistent"),
4283 };
4284 let env = gate_env_for_sandbox(&base, &fs_net);
4285 assert_eq!(
4286 env.get("CARGO_NET_OFFLINE").map(String::as_str),
4287 Some("true"),
4288 "fs+net gates run cargo offline-by-cache"
4289 );
4290 let fs = GateSandbox::Seatbelt {
4291 enforce: crate::types::SandboxEnforce::Fs,
4292 profile_path: std::path::PathBuf::from("/nonexistent"),
4293 };
4294 assert!(
4295 !gate_env_for_sandbox(&base, &fs).contains_key("CARGO_NET_OFFLINE"),
4296 "fs keeps full egress — no offline flag"
4297 );
4298 assert!(
4299 !gate_env_for_sandbox(&base, &GateSandbox::Disabled).contains_key("CARGO_NET_OFFLINE"),
4300 "the off path is byte-identical — no offline flag"
4301 );
4302 let container_fs_net = GateSandbox::Container {
4306 inputs: Box::new(crate::sandbox::SandboxInputs {
4307 enforce: crate::types::SandboxEnforce::FsNet,
4308 session_cwd: std::path::PathBuf::from("/nonexistent"),
4309 mission_dir: std::path::PathBuf::from("/nonexistent"),
4310 tmpdir: std::path::PathBuf::from("/nonexistent"),
4311 extra_write: Vec::new(),
4312 egress: Vec::new(),
4313 validator_read_deny_roots: Vec::new(),
4314 }),
4315 spec: crate::sandbox_container::ContainerSpec {
4316 runtime: crate::sandbox_container::ContainerRuntime::Docker,
4317 image: crate::sandbox_container::DEFAULT_IMAGE.to_string(),
4318 network: None,
4319 name: None,
4320 },
4321 };
4322 assert_eq!(
4323 gate_env_for_sandbox(&base, &container_fs_net)
4324 .get("CARGO_NET_OFFLINE")
4325 .map(String::as_str),
4326 Some("true"),
4327 "fs+net container gates run cargo offline-by-cache"
4328 );
4329 assert!(
4330 !base.contains_key("CARGO_NET_OFFLINE"),
4331 "the caller's env map is never mutated"
4332 );
4333 }
4334
4335 #[test]
4344 fn container_gate_wrap_shell_shape_names_the_container_and_teardown() {
4345 let inputs = crate::sandbox::SandboxInputs {
4346 enforce: crate::types::SandboxEnforce::Fs,
4347 session_cwd: std::path::PathBuf::from("/nonexistent"),
4348 mission_dir: std::path::PathBuf::from("/nonexistent-m"),
4349 tmpdir: std::path::PathBuf::from("/nonexistent-s"),
4350 extra_write: Vec::new(),
4351 egress: Vec::new(),
4352 validator_read_deny_roots: Vec::new(),
4353 };
4354 let container = GateSandbox::Container {
4355 inputs: Box::new(inputs),
4356 spec: crate::sandbox_container::ContainerSpec {
4357 runtime: crate::sandbox_container::ContainerRuntime::Docker,
4358 image: crate::sandbox_container::DEFAULT_IMAGE.to_string(),
4359 network: None,
4360 name: None,
4361 },
4362 };
4363 let env: HashMap<String, String> = [("KRANZ_BASE_SHA".to_string(), "deadbeef".to_string())]
4364 .into_iter()
4365 .collect();
4366
4367 let one = container.wrap_shell("echo hi", &env).unwrap();
4368 let two = container.wrap_shell("echo hi", &env).unwrap();
4369 assert_eq!(one.program, std::path::PathBuf::from("docker"));
4370 let name_of = |wrapped: &WrappedCommand| {
4371 wrapped
4372 .args
4373 .windows(2)
4374 .find(|w| w[0] == "--name")
4375 .map(|w| w[1].clone())
4376 .expect("the container argv must name its container")
4377 };
4378 let (name_one, name_two) = (name_of(&one), name_of(&two));
4379 assert!(
4380 name_one.starts_with("kranz-gate-"),
4381 "gate containers carry the kranz-gate- prefix: {name_one}"
4382 );
4383 assert_ne!(
4384 name_one, name_two,
4385 "container names are per command, never per resolve — parallel \
4386 gate commands from one resolution must not collide"
4387 );
4388 assert_eq!(
4389 one.timeout_teardown,
4390 Some((
4391 std::path::PathBuf::from("docker"),
4392 vec!["rm".to_string(), "-f".to_string(), name_one]
4393 )),
4394 "the teardown force-removes exactly this command's container"
4395 );
4396 assert!(
4397 one.args.ends_with(&[
4398 crate::sandbox_container::DEFAULT_IMAGE.to_string(),
4399 "sh".to_string(),
4400 "-c".to_string(),
4401 "echo hi".to_string()
4402 ]),
4403 "image then sh -c payload: {:?}",
4404 one.args
4405 );
4406
4407 let seatbelt = GateSandbox::Seatbelt {
4409 enforce: crate::types::SandboxEnforce::Fs,
4410 profile_path: std::path::PathBuf::from("/nonexistent"),
4411 };
4412 assert!(seatbelt
4413 .wrap_shell("true", &env)
4414 .unwrap()
4415 .timeout_teardown
4416 .is_none());
4417 assert!(GateSandbox::Disabled
4418 .wrap_shell("true", &env)
4419 .unwrap()
4420 .timeout_teardown
4421 .is_none());
4422 }
4423
4424 #[cfg(unix)]
4443 #[tokio::test]
4444 #[allow(clippy::await_holding_lock)]
4445 async fn container_gate_wrap_runs_contract_command_inside_the_container() {
4446 let _env = crate::agent_env::EnvTestGuard::engage(&[]);
4447 if !crate::sandbox_container::host_supports_container_contract() {
4448 crate::test_capability::skip(
4449 crate::test_capability::capability::CONTAINER,
4450 &crate::sandbox_container::container_contract_skip_detail(),
4451 );
4452 return;
4453 }
4454 if crate::sandbox_container::detect().is_none() {
4455 eprintln!(
4456 "no container runtime (docker/podman/nerdctl/container) on PATH; skipping \
4457 container gate wrap fixture"
4458 );
4459 return;
4460 }
4461
4462 let (repo, mission) = gate_wrap_layout_with_repo(
4465 tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(),
4466 );
4467 let kranz_dir = repo.path().join(".kranz");
4468 let scratch = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
4469 let outside = tempfile::tempdir().unwrap();
4470 let container_cfg = crate::types::SandboxConfig {
4471 enforce: crate::types::SandboxEnforce::Fs,
4472 provider: crate::types::SandboxProvider::Container,
4473 image: None,
4474 extra_write: vec![],
4475 egress: vec![],
4476 };
4477 let resolution = resolve_gate_sandbox(
4478 &container_cfg,
4479 repo.path(),
4480 &mission,
4481 scratch.path(),
4482 scratch.path(),
4483 )
4484 .unwrap();
4485 assert!(resolution.note.is_none());
4486 let sandbox = resolution.sandbox;
4487 assert!(
4488 matches!(sandbox, GateSandbox::Container { .. }),
4489 "provider:container with a runtime must resolve to the container wrap"
4490 );
4491 let env = crate::agent_env::contract_command_env(scratch.path(), Some("deadbeef"), &[]);
4492
4493 let ok_file = repo.path().join("container_gate_wrap_ok.txt");
4497 let (ok, output) = run_shell_command_sandboxed(
4498 repo.path(),
4499 &format!(
4500 "echo ok > '{}' && echo scratch > \"$HOME/container_gate_wrap_scratch.txt\" \
4501 && test \"$KRANZ_BASE_SHA\" = deadbeef",
4502 ok_file.display()
4503 ),
4504 &env,
4505 &sandbox,
4506 )
4507 .await;
4508 assert!(
4509 ok && ok_file.exists()
4510 && scratch
4511 .path()
4512 .join("container_gate_wrap_scratch.txt")
4513 .exists(),
4514 "writes inside the mount set and the forwarded env must work: {output}"
4515 );
4516
4517 let outside_file = outside.path().join("container_gate_wrap_marker");
4520 for probe in [
4521 "echo nope > /etc/container_gate_wrap_nope".to_string(),
4522 format!("echo x > '{}'", outside_file.display()),
4523 ] {
4524 let (ok, output) =
4525 run_shell_command_sandboxed(repo.path(), &probe, &env, &sandbox).await;
4526 assert!(
4527 !ok,
4528 "write outside the mount set must fail inside the container: {probe}\n{output}"
4529 );
4530 }
4531 assert!(
4532 !outside_file.exists(),
4533 "the denied write must not create the host file"
4534 );
4535
4536 let (ok, _) = run_shell_command_sandboxed(
4539 repo.path(),
4540 &format!(
4541 "echo tampered >> '{}'",
4542 mission.join("events.jsonl").display()
4543 ),
4544 &env,
4545 &sandbox,
4546 )
4547 .await;
4548 assert!(!ok, "the events.jsonl append must fail on the ro mount");
4549 assert_eq!(
4550 std::fs::read_to_string(mission.join("events.jsonl")).unwrap(),
4551 "{\"seq\":1}\n",
4552 "the audit log must be untouched by the container gate"
4553 );
4554
4555 for name in ["serve.token", "serve.read.token", "config.json"] {
4559 let (ok, output) = run_shell_command_sandboxed(
4560 repo.path(),
4561 &format!("test -s '{}'", kranz_dir.join(name).display()),
4562 &env,
4563 &sandbox,
4564 )
4565 .await;
4566 assert!(
4567 !ok,
4568 ".kranz/{name} must be /dev/null-masked inside the container: {output}"
4569 );
4570 }
4571 let (ok, output) = run_shell_command_sandboxed(
4572 repo.path(),
4573 &format!("test -s '{}'", repo.path().join("public.txt").display()),
4574 &env,
4575 &sandbox,
4576 )
4577 .await;
4578 assert!(ok, "ordinary repo reads must keep working: {output}");
4579
4580 let (ok, output) = run_shell_command_sandboxed(
4583 repo.path(),
4584 &format!(
4585 "echo x > '{}' && test -s '{}'",
4586 outside_file.display(),
4587 kranz_dir.join("serve.token").display()
4588 ),
4589 &env,
4590 &GateSandbox::Disabled,
4591 )
4592 .await;
4593 assert!(
4594 ok,
4595 "with enforce == off the probes succeed (today's posture): {output}"
4596 );
4597 let _ = std::fs::remove_file(&outside_file);
4598 }
4599
4600 #[cfg(target_os = "linux")]
4604 #[tokio::test]
4605 #[ignore = "live bubblewrap receipt — run by the protected Linux CI leg"]
4606 #[allow(clippy::await_holding_lock)]
4607 async fn linux_bubblewrap_hostile_live_receipt() {
4608 let _guard = GATE_SANDBOX_WRAP_LOCK
4609 .lock()
4610 .unwrap_or_else(|poisoned| poisoned.into_inner());
4611 assert!(
4612 gate_wrap_bwrap_can_apply(),
4613 "the live-proof host must provide a working bubblewrap boundary"
4614 );
4615
4616 let primary = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4617 .parent()
4618 .and_then(std::path::Path::parent)
4619 .expect("crates/engine has a repository root");
4620 let git = |args: &[&str]| {
4621 let output = std::process::Command::new("git")
4622 .args(args)
4623 .current_dir(primary)
4624 .output()
4625 .expect("git must run on the live-proof checkout");
4626 assert!(output.status.success(), "git {args:?} failed");
4627 String::from_utf8_lossy(&output.stdout).trim().to_string()
4628 };
4629 let head_before = git(&["rev-parse", "HEAD"]);
4630 let status_before = git(&["status", "--porcelain", "--untracked-files=no"]);
4631 assert!(
4632 status_before.is_empty(),
4633 "the live proof requires a clean tracked primary checkout: {status_before}"
4634 );
4635
4636 let (repo, mission) = gate_wrap_layout();
4637 let scratch = tempfile::tempdir().expect("private proof scratch");
4638 let outside = tempfile::tempdir().expect("sibling canary root");
4639 let resolution = resolve_gate_sandbox(
4640 &fs_sandbox_config(crate::types::SandboxEnforce::FsNet),
4641 repo.path(),
4642 &mission,
4643 scratch.path(),
4644 scratch.path(),
4645 )
4646 .expect("fs+net must resolve to bubblewrap on the proof host");
4647 assert!(resolution.note.is_none());
4648 assert!(matches!(resolution.sandbox, GateSandbox::Bubblewrap { .. }));
4649 let sandbox = resolution.sandbox;
4650 let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
4651
4652 let canary = outside.path().join("kranz-linux-hostile-canary");
4653 let (write_ok, write_output) = run_shell_command_sandboxed(
4654 repo.path(),
4655 &format!("printf escaped > '{}'", canary.display()),
4656 &env,
4657 &sandbox,
4658 )
4659 .await;
4660 assert!(
4661 !write_ok,
4662 "sibling write escaped bubblewrap: {write_output}"
4663 );
4664 assert!(
4665 !canary.exists(),
4666 "the denied sibling canary must stay absent"
4667 );
4668
4669 let listener =
4670 std::net::TcpListener::bind("127.0.0.1:0").expect("host loopback proof listener");
4671 listener
4672 .set_nonblocking(true)
4673 .expect("nonblocking proof listener");
4674 let port = listener.local_addr().expect("listener address").port();
4675 let (stop_tx, stop_rx) = std::sync::mpsc::channel();
4676 let acceptor = std::thread::spawn(move || {
4677 let started = std::time::Instant::now();
4678 let mut accepted = 0usize;
4679 while started.elapsed() < Duration::from_secs(10) {
4680 match listener.accept() {
4681 Ok(_) => accepted += 1,
4682 Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
4683 Err(error) => panic!("proof listener failed: {error}"),
4684 }
4685 if stop_rx.try_recv().is_ok() {
4686 break;
4687 }
4688 std::thread::sleep(Duration::from_millis(10));
4689 }
4690 accepted
4691 });
4692 let connect = format!(
4693 "python3 -c 'import socket; socket.create_connection((\"127.0.0.1\", {port}), 2).close()'"
4694 );
4695 let (off_connect_ok, off_connect_output) =
4696 run_shell_command_sandboxed(repo.path(), &connect, &env, &GateSandbox::Disabled).await;
4697 assert!(
4698 off_connect_ok,
4699 "the network anti-vacuity probe must reach the host listener without enforcement: {off_connect_output}"
4700 );
4701 let (wrapped_connect_ok, wrapped_connect_output) =
4702 run_shell_command_sandboxed(repo.path(), &connect, &env, &sandbox).await;
4703 assert!(
4704 !wrapped_connect_ok,
4705 "the fs+net namespace reached the host listener: {wrapped_connect_output}"
4706 );
4707 let _ = stop_tx.send(());
4708 assert_eq!(
4709 acceptor.join().expect("proof listener thread"),
4710 1,
4711 "only the unwrapped anti-vacuity connection may reach the host"
4712 );
4713
4714 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)\"";
4715 for (label, posture) in [
4716 ("unwrapped warm-up", &GateSandbox::Disabled),
4717 ("bubblewrap warm-up", &sandbox),
4718 ] {
4719 let (ok, output) = run_shell_command_sandboxed(repo.path(), gate, &env, posture).await;
4720 assert!(
4721 ok && output.contains("kranz-linux-node-ok"),
4722 "{label} failed: {output}"
4723 );
4724 }
4725
4726 let mut off_samples_ms = Vec::with_capacity(7);
4727 let mut wrapped_samples_ms = Vec::with_capacity(7);
4728 for index in 0..7 {
4729 for wrapped in [index % 2 == 1, index % 2 == 0] {
4730 let started = std::time::Instant::now();
4731 let posture = if wrapped {
4732 &sandbox
4733 } else {
4734 &GateSandbox::Disabled
4735 };
4736 let (ok, output) =
4737 run_shell_command_sandboxed(repo.path(), gate, &env, posture).await;
4738 assert!(
4739 ok && output.contains("kranz-linux-node-ok"),
4740 "timed gate failed: {output}"
4741 );
4742 let elapsed = started.elapsed().as_secs_f64() * 1_000.0;
4743 if wrapped {
4744 wrapped_samples_ms.push(elapsed);
4745 } else {
4746 off_samples_ms.push(elapsed);
4747 }
4748 }
4749 }
4750 let median = |samples: &[f64]| {
4751 let mut sorted = samples.to_vec();
4752 sorted.sort_by(f64::total_cmp);
4753 sorted[sorted.len() / 2]
4754 };
4755 let off_median_ms = median(&off_samples_ms);
4756 let wrapped_median_ms = median(&wrapped_samples_ms);
4757 let overhead_percent = (wrapped_median_ms / off_median_ms - 1.0) * 100.0;
4758
4759 let head_after = git(&["rev-parse", "HEAD"]);
4760 let status_after = git(&["status", "--porcelain", "--untracked-files=no"]);
4761 assert_eq!(head_after, head_before, "the primary checkout HEAD moved");
4762 assert_eq!(
4763 status_after, status_before,
4764 "the primary checkout's tracked bytes changed"
4765 );
4766
4767 let host = |program: &str, args: &[&str]| {
4768 std::process::Command::new(program)
4769 .args(args)
4770 .output()
4771 .ok()
4772 .filter(|output| output.status.success())
4773 .map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
4774 .unwrap_or_else(|| "unavailable".to_string())
4775 };
4776 let receipt = serde_json::json!({
4777 "hostOs": std::env::consts::OS,
4778 "hostArch": std::env::consts::ARCH,
4779 "kernel": host("uname", &["-sr"]),
4780 "bubblewrap": host("bwrap", &["--version"]),
4781 "node": host("node", &["--version"]),
4782 "enforcement": "fs+net",
4783 "provider": "process/bubblewrap",
4784 "siblingWriteDenied": !write_ok && !canary.exists(),
4785 "networkDenied": !wrapped_connect_ok,
4786 "networkAntiVacuityPassed": off_connect_ok,
4787 "normalGatePassed": true,
4788 "primaryCheckoutUntouched": head_after == head_before && status_after == status_before,
4789 "repetitions": 7,
4790 "offSamplesMs": off_samples_ms,
4791 "bubblewrapSamplesMs": wrapped_samples_ms,
4792 "offMedianMs": off_median_ms,
4793 "bubblewrapMedianMs": wrapped_median_ms,
4794 "overheadPercent": overhead_percent,
4795 "overheadTargetPercent": 10.0,
4796 "withinTarget": overhead_percent <= 10.0,
4797 "head": head_before,
4798 });
4799 println!("KRANZ_LINUX_LIVE_RECEIPT={receipt}");
4800 }
4801
4802 #[cfg(target_os = "macos")]
4834 #[test]
4835 #[ignore = "measurement harness — run manually, never a CI gate"]
4836 fn gate_sandbox_wrap_measure() {
4837 if !gate_wrap_sandbox_exec_can_apply() {
4838 return;
4839 }
4840 let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
4841 .parent()
4842 .and_then(std::path::Path::parent)
4843 .expect("crates/engine has a repo-root ancestor")
4844 .to_path_buf();
4845 let payload = std::env::var("KRANZ_GATE_MEASURE_CMD").unwrap_or_else(|_| {
4846 "cargo test -p kranz-engine --lib -- \
4847 --skip timeout_kills \
4848 --skip kills_a_hung_binary \
4849 --skip approval_lint_runner_times_out_slow_command \
4850 --skip identity_token \
4851 --skip pid_reuse \
4852 --skip pool_checkpoint_hooks_disabled_against_planted_fsmonitor_and_hook \
4853 --skip sandbox_preflight_probes_disposable_worktree_not_primary"
4854 .to_string()
4855 });
4856 let reps: u32 = std::env::var("KRANZ_GATE_MEASURE_REPS")
4857 .ok()
4858 .and_then(|v| v.parse().ok())
4859 .unwrap_or(3);
4860 let (_layout_guard, mission) = gate_wrap_layout();
4863 let policy = MergeGatePolicy {
4864 sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
4865 mission_dir: mission,
4866 };
4867
4868 let time = |label: &str, command: &str, wrapped: bool, reps: u32| {
4869 let mut samples = Vec::new();
4870 for _ in 0..reps {
4871 let start = std::time::Instant::now();
4872 let (ok, output) = if wrapped {
4873 run_bounded_gate_command_sandboxed(&repo_root, command, &policy)
4874 } else {
4875 run_bounded_gate_command(&repo_root, command)
4876 };
4877 let elapsed = start.elapsed();
4878 assert!(ok, "{label} run failed: {output}");
4879 samples.push(elapsed);
4880 }
4881 let total: Duration = samples.iter().sum();
4882 let mean = total / samples.len() as u32;
4883 let min = samples.iter().min().unwrap();
4884 println!("{label}: reps={reps} mean={mean:.3?} min={min:.3?} all={samples:?}");
4885 mean
4886 };
4887
4888 let micro_unwrapped = time("micro unwrapped (true)", "true", false, 50);
4889 let micro_wrapped = time("micro wrapped (true)", "true", true, 50);
4890 println!(
4891 "micro delta per spawn: {:?} ({:+.1}%)",
4892 micro_wrapped.saturating_sub(micro_unwrapped),
4893 (micro_wrapped.as_secs_f64() / micro_unwrapped.as_secs_f64() - 1.0) * 100.0
4894 );
4895 let gate_unwrapped = time("gate unwrapped", &payload, false, reps);
4896 let gate_wrapped = time("gate wrapped ", &payload, true, reps);
4897 println!(
4898 "gate delta: {:?} ({:+.2}%) on `{}`",
4899 gate_wrapped.saturating_sub(gate_unwrapped),
4900 (gate_wrapped.as_secs_f64() / gate_unwrapped.as_secs_f64() - 1.0) * 100.0,
4901 payload
4902 );
4903 }
4904}