use std::collections::HashMap;
use std::time::Duration;
use tokio::io::{AsyncRead, AsyncReadExt};
const COMMAND_OUTPUT_TAIL: usize = 1500;
const COMMAND_TIMEOUT: Duration = Duration::from_secs(600);
pub(crate) fn run_with_timeout(
program: &std::path::Path,
args: &[String],
timeout: Duration,
) -> Option<std::process::Output> {
let mut child = std::process::Command::new(program)
.args(args)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.ok()?;
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => return child.wait_with_output().ok(),
Ok(None) => {
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(20));
}
Err(_) => return None,
}
}
}
pub(crate) fn last_chars_local(text: &str, max: usize) -> String {
let chars: Vec<char> = text.chars().collect();
let start = chars.len().saturating_sub(max);
chars[start..].iter().collect()
}
pub(crate) fn is_git_repo(root: &std::path::Path) -> bool {
root.join(".git").exists()
}
#[cfg(all(test, unix))]
pub(crate) async fn run_shell_command(
cwd: &std::path::Path,
command: &str,
env: &HashMap<String, String>,
) -> (bool, String) {
run_shell_command_with_timeout(cwd, command, COMMAND_TIMEOUT, env).await
}
#[cfg(test)]
pub(crate) async fn run_shell_command_with_code(
cwd: &std::path::Path,
command: &str,
env: &HashMap<String, String>,
) -> (Option<i32>, String) {
run_shell_command_with_timeout_env(cwd, command, COMMAND_TIMEOUT, env, false).await
}
pub(crate) async fn run_shell_command_with_code_cleared(
cwd: &std::path::Path,
command: &str,
env: &HashMap<String, String>,
) -> (Option<i32>, String) {
run_shell_command_with_timeout_env(cwd, command, COMMAND_TIMEOUT, env, true).await
}
#[cfg(all(test, unix))]
async fn run_shell_command_with_timeout(
cwd: &std::path::Path,
command: &str,
timeout: Duration,
env: &HashMap<String, String>,
) -> (bool, String) {
let (code, output) = run_shell_command_with_timeout_env(cwd, command, timeout, env, true).await;
(code == Some(0), output)
}
async fn run_shell_command_with_timeout_env(
cwd: &std::path::Path,
command: &str,
timeout: Duration,
env: &HashMap<String, String>,
clear_env: bool,
) -> (Option<i32>, String) {
let (program, args) = shell_argv(command);
let mut cmd = tokio::process::Command::new(program);
cmd.args(args);
if clear_env {
cmd.env_clear();
}
run_command_bounded(configure_bounded_child(cmd, cwd, env), timeout).await
}
fn shell_argv(command: &str) -> (std::path::PathBuf, Vec<String>) {
#[cfg(windows)]
{
(
std::path::PathBuf::from("cmd"),
vec!["/C".to_string(), command.to_string()],
)
}
#[cfg(not(windows))]
{
(
std::path::PathBuf::from("sh"),
vec!["-c".to_string(), command.to_string()],
)
}
}
pub(crate) async fn run_bounded_argv(
cwd: &std::path::Path,
program: &std::path::Path,
args: &[String],
timeout: Duration,
env: &HashMap<String, String>,
) -> (Option<i32>, String) {
let mut cmd = tokio::process::Command::new(program);
cmd.args(args);
cmd.env_clear();
run_command_bounded(configure_bounded_child(cmd, cwd, env), timeout).await
}
#[derive(Debug)]
pub(crate) enum GateSandbox {
Disabled,
Seatbelt {
enforce: crate::types::SandboxEnforce,
profile_path: std::path::PathBuf,
},
Bubblewrap {
inputs: Box<crate::sandbox::SandboxInputs>,
},
AppContainer {
inputs: Box<crate::sandbox::SandboxInputs>,
#[cfg(windows)]
context: crate::appcontainer_windows::AppContainerLaunchContext,
},
Container {
inputs: Box<crate::sandbox::SandboxInputs>,
spec: crate::sandbox_container::ContainerSpec,
},
}
pub(crate) struct WrappedCommand {
pub program: std::path::PathBuf,
pub args: Vec<String>,
pub timeout_teardown: Option<(std::path::PathBuf, Vec<String>)>,
#[cfg(windows)]
_appcontainer_context: Option<crate::appcontainer_windows::AppContainerLaunchContext>,
}
impl GateSandbox {
#[cfg(any(target_os = "macos", target_os = "linux", test))]
fn wrap_control_shell(
&self,
cwd: &std::path::Path,
command: &str,
env: &HashMap<String, String>,
) -> crate::error::Result<WrappedCommand> {
match self {
Self::Seatbelt { .. } => self.wrap_shell(command, env),
Self::Bubblewrap { inputs } => Ok(WrappedCommand {
program: "bwrap".into(),
args: crate::sandbox::bubblewrap_args(
inputs,
std::path::Path::new("/bin/sh"),
&[
"-c".into(),
"cd -- \"$1\" && exec /bin/sh -c \"$2\"".into(),
"kranz-control".into(),
cwd.display().to_string(),
command.into(),
],
)?,
timeout_teardown: None,
#[cfg(windows)]
_appcontainer_context: None,
}),
_ => Err(crate::error::EngineError::Config(
"negative controls require native macOS/Linux containment".into(),
)),
}
}
pub(crate) fn enforce(&self) -> crate::types::SandboxEnforce {
match self {
GateSandbox::Disabled => crate::types::SandboxEnforce::Off,
GateSandbox::Seatbelt { enforce, .. } => *enforce,
GateSandbox::Bubblewrap { inputs } => inputs.enforce,
GateSandbox::AppContainer { inputs, .. } => inputs.enforce,
GateSandbox::Container { inputs, .. } => inputs.enforce,
}
}
pub(crate) fn cleanup(&mut self) -> crate::error::Result<()> {
#[cfg(windows)]
if let GateSandbox::AppContainer { context, .. } = self {
return context.cleanup();
}
Ok(())
}
fn wrap_shell(
&self,
command: &str,
env: &HashMap<String, String>,
) -> crate::error::Result<WrappedCommand> {
match self {
GateSandbox::Disabled => {
let (program, args) = shell_argv(command);
Ok(WrappedCommand {
program,
args,
timeout_teardown: None,
#[cfg(windows)]
_appcontainer_context: None,
})
}
GateSandbox::Seatbelt { profile_path, .. } => {
let (program, args) = crate::backend_claude::sandbox_command(
profile_path,
std::path::Path::new("/bin/sh"),
&["-c".to_string(), command.to_string()],
);
Ok(WrappedCommand {
program,
args,
timeout_teardown: None,
#[cfg(windows)]
_appcontainer_context: None,
})
}
GateSandbox::Bubblewrap { inputs } => {
let args = crate::sandbox::bubblewrap_args(
inputs,
std::path::Path::new("/bin/sh"),
&["-c".to_string(), command.to_string()],
)?;
Ok(WrappedCommand {
program: std::path::PathBuf::from("bwrap"),
args,
timeout_teardown: None,
#[cfg(windows)]
_appcontainer_context: None,
})
}
GateSandbox::AppContainer {
inputs,
#[cfg(windows)]
context,
} => {
#[cfg(windows)]
{
let (program, args) = shell_argv(command);
let prepared = crate::appcontainer_windows::prepare_launch_in_context(
context, inputs, &program, &args, env,
)?;
Ok(WrappedCommand {
program: prepared.program,
args: prepared.args,
timeout_teardown: None,
_appcontainer_context: Some(context.clone()),
})
}
#[cfg(not(windows))]
{
let _ = (inputs, command, env);
Err(crate::error::EngineError::Backend(
"AppContainer gate wrapper is unavailable on this host".to_string(),
))
}
}
GateSandbox::Container { inputs, spec } => {
let name = format!("kranz-gate-{}", uuid::Uuid::new_v4().simple());
let args = crate::sandbox_container::container_gate_run_args(
inputs, spec, command, env, &name,
);
Ok(WrappedCommand {
program: std::path::PathBuf::from(spec.runtime.binary()),
args,
timeout_teardown: Some((
std::path::PathBuf::from(spec.runtime.binary()),
vec!["rm".to_string(), "-f".to_string(), name],
)),
#[cfg(windows)]
_appcontainer_context: None,
})
}
}
}
}
#[derive(Debug)]
pub(crate) struct GateSandboxResolution {
pub sandbox: GateSandbox,
pub note: Option<String>,
#[cfg_attr(not(all(test, target_os = "macos")), allow(dead_code))]
pub prewarmed_xcrun: bool,
}
fn gate_profile_extras() -> String {
let mut extras = String::from(
"\n(allow file-write* (literal \"/dev/null\") (literal \"/dev/ptmx\"))\n\
(allow file-read* (literal \"/dev/ptmx\"))\n\
(allow file-read* file-write* (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))\n\
(allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))\n\
(allow signal (target same-sandbox))\n",
);
extras.push_str(&crate::sandbox::tty_deny_block(
&crate::sandbox::operator_tty_paths(),
));
extras
}
#[cfg(target_os = "macos")]
pub(crate) fn prewarm_xcrun_cache_outside_sandbox() {
let _ = run_with_timeout(
std::path::Path::new("git"),
&["--version".to_string()],
Duration::from_secs(10),
);
}
fn container_gate_note(enforce: crate::types::SandboxEnforce) -> String {
format!(
"sandbox provider:container with enforce:{} wraps engine-run gates in the mission \
container, but no container runtime (docker/podman/nerdctl/container) was found on \
PATH; refusing to run engine-run gates unsandboxed (fail closed, mirroring container \
session resolution) — install a runtime or set worker.sandbox.provider to \"process\"",
enforce.as_str()
)
}
pub(crate) fn resolve_gate_sandbox(
sandbox_cfg: &crate::types::SandboxConfig,
gate_cwd: &std::path::Path,
mission_dir: &std::path::Path,
scratch_home: &std::path::Path,
profile_dir: &std::path::Path,
) -> crate::error::Result<GateSandboxResolution> {
let runtime = crate::sandbox_container::detect();
resolve_gate_sandbox_target(
sandbox_cfg,
gate_cwd,
mission_dir,
scratch_home,
profile_dir,
std::env::consts::OS,
crate::sandbox::command_available("bwrap"),
runtime,
crate::sandbox::session_mount_proof(sandbox_cfg, gate_cwd, mission_dir, runtime),
)
}
fn gate_sandbox_inputs(
sandbox_cfg: &crate::types::SandboxConfig,
gate_cwd: &std::path::Path,
mission_dir: &std::path::Path,
scratch_home: &std::path::Path,
) -> crate::sandbox::SandboxInputs {
crate::sandbox::SandboxInputs {
enforce: sandbox_cfg.enforce,
session_cwd: gate_cwd.to_path_buf(),
mission_dir: mission_dir.to_path_buf(),
tmpdir: scratch_home.to_path_buf(),
extra_write: sandbox_cfg
.extra_write
.iter()
.map(|raw| crate::sandbox::expand_tilde(raw))
.collect(),
egress: sandbox_cfg.egress.clone(),
validator_read_deny_roots: Vec::new(),
}
}
#[allow(clippy::too_many_arguments)]
fn resolve_gate_sandbox_target(
sandbox_cfg: &crate::types::SandboxConfig,
gate_cwd: &std::path::Path,
mission_dir: &std::path::Path,
scratch_home: &std::path::Path,
profile_dir: &std::path::Path,
target_os: &str,
bwrap_available: bool,
container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
container_mount_proof: Option<crate::sandbox_container::MountProof>,
) -> crate::error::Result<GateSandboxResolution> {
use crate::types::{SandboxEnforce, SandboxProvider};
let disabled = |note: Option<String>| {
Ok(GateSandboxResolution {
sandbox: GateSandbox::Disabled,
note,
prewarmed_xcrun: false,
})
};
if sandbox_cfg.enforce == SandboxEnforce::Off {
return disabled(None);
}
crate::sandbox::validate_git_config_protection(
&gate_sandbox_inputs(sandbox_cfg, gate_cwd, mission_dir, scratch_home),
sandbox_cfg.provider == SandboxProvider::Container || target_os == "linux",
)?;
if sandbox_cfg.provider == SandboxProvider::Container {
if target_os == "windows" {
return Err(crate::error::EngineError::Config(format!(
"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",
sandbox_cfg.enforce.as_str()
)));
}
if target_os != "linux" {
match container_mount_proof {
Some(crate::sandbox_container::MountProof::Proven) => {}
Some(crate::sandbox_container::MountProof::Failed(reason)) => {
return Err(crate::error::EngineError::Config(format!(
"sandbox provider:container with enforce:{} refused for engine-run gates on target_os={target_os}: {reason}",
sandbox_cfg.enforce.as_str()
)));
}
None => {
return Err(crate::error::EngineError::Config(format!(
"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",
sandbox_cfg.enforce.as_str()
)));
}
}
}
let Some(runtime) = container_runtime else {
return Err(crate::error::EngineError::Config(container_gate_note(
sandbox_cfg.enforce,
)));
};
if sandbox_cfg.enforce == SandboxEnforce::FsNet
&& !sandbox_cfg
.provider
.enforces_hard_net_boundary(&sandbox_cfg.egress)
{
return Err(crate::error::EngineError::Config(
"sandbox provider:container with enforce:fs+net and a non-empty egress list is \
advisory-only for engine-run gates (no egress proxy exists engine-side); use an \
empty egress list (the hard `--network none` boundary) or sandbox.provider \
\"process\" — refusing to run engine-run gates with an advisory boundary"
.to_string(),
));
}
return Ok(GateSandboxResolution {
sandbox: GateSandbox::Container {
inputs: Box::new(gate_sandbox_inputs(
sandbox_cfg,
gate_cwd,
mission_dir,
scratch_home,
)),
spec: crate::sandbox_container::ContainerSpec {
runtime,
image: sandbox_cfg
.image
.clone()
.unwrap_or_else(|| crate::sandbox_container::DEFAULT_IMAGE.to_string()),
network: None,
name: None,
},
},
note: None,
prewarmed_xcrun: false,
});
}
match crate::sandbox::platform_support(sandbox_cfg.enforce, target_os) {
crate::sandbox::SandboxDecision::Off => disabled(None),
crate::sandbox::SandboxDecision::UnsupportedWarn => {
Err(crate::error::EngineError::Config(format!(
"sandbox enforce:{} requested but unsupported on target_os={target_os}; refusing \
to run engine-run gates unsandboxed",
sandbox_cfg.enforce.as_str()
)))
}
crate::sandbox::SandboxDecision::Enforce(crate::sandbox::SandboxBackend::Bubblewrap)
if !bwrap_available =>
{
Err(crate::error::EngineError::Config(format!(
"sandbox enforce:{} requested on linux but `bwrap` was not found; refusing \
to run engine-run gates unsandboxed",
sandbox_cfg.enforce.as_str()
)))
}
crate::sandbox::SandboxDecision::Enforce(backend) => {
let inputs = gate_sandbox_inputs(sandbox_cfg, gate_cwd, mission_dir, scratch_home);
match backend {
crate::sandbox::SandboxBackend::Seatbelt => {
#[cfg(target_os = "macos")]
prewarm_xcrun_cache_outside_sandbox();
let mut profile = crate::sandbox::generate_profile(&inputs);
profile.push_str(&gate_profile_extras());
let profile_path = crate::sandbox::write_profile_file(profile_dir, &profile)?;
Ok(GateSandboxResolution {
sandbox: GateSandbox::Seatbelt {
enforce: sandbox_cfg.enforce,
profile_path,
},
note: None,
prewarmed_xcrun: cfg!(target_os = "macos"),
})
}
crate::sandbox::SandboxBackend::Bubblewrap => Ok(GateSandboxResolution {
sandbox: GateSandbox::Bubblewrap {
inputs: Box::new(inputs),
},
note: None,
prewarmed_xcrun: false,
}),
crate::sandbox::SandboxBackend::AppContainer => Ok(GateSandboxResolution {
sandbox: GateSandbox::AppContainer {
inputs: Box::new(inputs),
#[cfg(windows)]
context: crate::appcontainer_windows::new_launch_context(),
},
note: None,
prewarmed_xcrun: false,
}),
crate::sandbox::SandboxBackend::Container => {
unreachable!("container provider returned above")
}
}
}
}
}
pub(crate) fn gate_env_for_sandbox(
env: &HashMap<String, String>,
sandbox: &GateSandbox,
) -> HashMap<String, String> {
let mut env = env.clone();
if sandbox.enforce() == crate::types::SandboxEnforce::FsNet {
env.insert("CARGO_NET_OFFLINE".to_string(), "true".to_string());
}
env
}
pub(crate) fn prepare_gate_command(
command: &str,
env: &HashMap<String, String>,
sandbox: &GateSandbox,
) -> crate::error::Result<(WrappedCommand, HashMap<String, String>)> {
let env = gate_env_for_sandbox(env, sandbox);
let wrapped = sandbox.wrap_shell(command, &env)?;
Ok((wrapped, env))
}
pub(crate) async fn run_shell_command_sandboxed(
cwd: &std::path::Path,
command: &str,
env: &HashMap<String, String>,
sandbox: &GateSandbox,
) -> (bool, String) {
let (code, output) =
run_shell_command_sandboxed_with_code(cwd, command, COMMAND_TIMEOUT, env, sandbox).await;
(code == Some(0), output)
}
async fn run_shell_command_sandboxed_with_code(
cwd: &std::path::Path,
command: &str,
timeout: Duration,
env: &HashMap<String, String>,
sandbox: &GateSandbox,
) -> (Option<i32>, String) {
let env = gate_env_for_sandbox(env, sandbox);
let wrapped = match sandbox.wrap_shell(command, &env) {
Ok(wrapped) => wrapped,
Err(error) => {
return (
None,
format!("gate sandbox wrap failed closed (the command did not run): {error}"),
)
}
};
let client_env = match sandbox {
GateSandbox::Container { spec, .. } => spec.runtime.client_env(),
_ => env,
};
let (code, output) =
run_bounded_argv(cwd, &wrapped.program, &wrapped.args, timeout, &client_env).await;
if code.is_none() {
if let Some((program, args)) = wrapped.timeout_teardown {
let _ =
run_bounded_argv(cwd, &program, &args, Duration::from_secs(30), &client_env).await;
}
}
(code, output)
}
#[cfg(windows)]
pub(crate) fn run_bounded_gate_command_resolved_with_code(
cwd: &std::path::Path,
command: &str,
env: &HashMap<String, String>,
sandbox: &GateSandbox,
) -> (Option<i32>, String) {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => return (None, format!("failed to create gate runtime: {error}")),
};
runtime.block_on(run_shell_command_sandboxed_with_code(
cwd,
command,
COMMAND_TIMEOUT,
env,
sandbox,
))
}
pub(crate) fn run_shell_command_sandboxed_blocking(
cwd: &std::path::Path,
command: &str,
timeout: Duration,
env: &HashMap<String, String>,
sandbox: &GateSandbox,
) -> (Option<i32>, String) {
std::thread::scope(|scope| {
let worker = scope.spawn(|| {
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
return (
None,
format!("failed to create approval gate runtime: {error}"),
)
}
};
runtime.block_on(run_shell_command_sandboxed_with_code(
cwd, command, timeout, env, sandbox,
))
});
worker.join().unwrap_or_else(|_| {
(
None,
"approval gate runner panicked before producing a verdict".to_string(),
)
})
})
}
pub(crate) fn run_control_command_sandboxed_blocking(
cwd: &std::path::Path,
command: &str,
timeout: Duration,
env: &HashMap<String, String>,
sandbox: &GateSandbox,
cancelled: &std::sync::atomic::AtomicBool,
) -> (Option<i32>, String) {
#[cfg(any(target_os = "macos", target_os = "linux"))]
{
std::thread::scope(|scope| {
scope
.spawn(|| {
let env = gate_env_for_sandbox(env, sandbox);
let wrapped = match sandbox.wrap_control_shell(cwd, command, &env) {
Ok(wrapped) => wrapped,
Err(error) => return (None, format!("control wrap failed: {error}")),
};
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => return (None, format!("control runtime failed: {error}")),
};
let mut cmd = tokio::process::Command::new(&wrapped.program);
cmd.args(&wrapped.args).env_clear();
runtime.block_on(run_control_command_bounded(
configure_bounded_child(cmd, cwd, &env),
timeout,
cancelled,
))
})
.join()
.unwrap_or_else(|_| (None, "control runner panicked".into()))
})
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
let _ = (cwd, command, timeout, env, sandbox, cancelled);
(
None,
"negative controls require native macOS/Linux containment".into(),
)
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
struct ControlChild(tokio::process::Child);
#[cfg(any(target_os = "macos", target_os = "linux"))]
impl Drop for ControlChild {
fn drop(&mut self) {
crate::backend_claude::kill_unreaped_group(&self.0);
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
async fn control_leader_exited(pid: u32) -> std::io::Result<()> {
loop {
let exited = {
let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
let result = unsafe {
libc::waitid(
libc::P_PID,
pid as libc::id_t,
&mut info,
libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
)
};
if result != 0 {
let error = std::io::Error::last_os_error();
if error.kind() != std::io::ErrorKind::Interrupted {
return Err(error);
}
false
} else {
unsafe { info.si_pid() != 0 }
}
};
if exited {
return Ok(());
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
async fn run_control_command_bounded(
mut cmd: tokio::process::Command,
timeout: Duration,
cancelled: &std::sync::atomic::AtomicBool,
) -> (Option<i32>, String) {
use std::sync::atomic::Ordering;
if cancelled.load(Ordering::Acquire) {
return (None, "control evaluation cancelled".into());
}
let mut child = match cmd.spawn() {
Ok(child) => ControlChild(child),
Err(error) => return (None, format!("failed to spawn control: {error}")),
};
let stdout = child.0.stdout.take().expect("stdout is piped");
let stderr = child.0.stderr.take().expect("stderr is piped");
let capture = async { tokio::try_join!(read_stream_tail(stdout), read_stream_tail(stderr)) };
tokio::pin!(capture);
let leader = control_leader_exited(child.0.id().expect("unreaped child has an id"));
tokio::pin!(leader);
let cancellation = async {
while !cancelled.load(Ordering::Acquire) {
tokio::time::sleep(Duration::from_millis(10)).await;
}
};
tokio::pin!(cancellation);
let mut output = None;
let execution = async {
loop {
tokio::select! {
result = &mut leader => return result.map_err(|error| format!("control wait failed: {error}")),
() = &mut cancellation => return Err("control evaluation cancelled".into()),
result = &mut capture, if output.is_none() => {
output = Some(result.map_err(|error| format!("control output failed: {error}"))?);
}
}
}
};
let result = match tokio::time::timeout(timeout, execution).await {
Ok(result) => result,
Err(_) => Err(format!("timed out after {}s", timeout.as_secs())),
};
crate::backend_claude::kill_unreaped_group(&child.0);
if result.is_err() {
let _ = child.0.start_kill();
}
let status = child.0.wait().await;
if let Err(error) = result {
return (None, error);
}
let status = match status {
Ok(status) => status,
Err(error) => return (None, format!("control reap failed: {error}")),
};
let (stdout, stderr) = match output {
Some(output) => output,
None => match tokio::time::timeout(Duration::from_secs(1), &mut capture).await {
Ok(Ok(output)) => output,
Ok(Err(error)) => return (None, format!("control output failed: {error}")),
Err(_) => {
return (
None,
"control output remained open after group cleanup".into(),
)
}
},
};
let mut combined = stdout;
if !stderr.trim().is_empty() {
combined.push_str("\n--- stderr ---\n");
combined.push_str(stderr.trim_end());
}
(
status.code(),
tail_chars(combined.trim_end(), COMMAND_OUTPUT_TAIL),
)
}
fn configure_bounded_child(
mut cmd: tokio::process::Command,
cwd: &std::path::Path,
env: &HashMap<String, String>,
) -> tokio::process::Command {
cmd.current_dir(cwd)
.envs(env)
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
#[cfg(unix)]
cmd.process_group(0);
cmd
}
async fn run_command_bounded(
cmd: tokio::process::Command,
timeout: Duration,
) -> (Option<i32>, String) {
let mut cmd = cmd;
let mut child = match cmd.spawn() {
Ok(child) => child,
Err(e) => return (None, format!("failed to spawn shell: {e}")),
};
let stdout = child.stdout.take().expect("stdout was configured as piped");
let stderr = child.stderr.take().expect("stderr was configured as piped");
#[cfg(unix)]
let group_pid = child.id();
#[cfg(windows)]
let job = match child.raw_handle() {
Some(handle) => crate::backend_claude::win_job::JobHandle::create_and_assign(handle)
.map_err(|e| {
tracing::warn!(error = %e, "failed to create Job Object for shell command; \
timeout will kill only the spawned child");
})
.ok(),
None => None,
};
let execution = async {
let (status, stdout, stderr) = tokio::join!(
child.wait(),
read_stream_tail(stdout),
read_stream_tail(stderr)
);
Ok::<_, String>((
status.map_err(|e| format!("failed waiting for shell: {e}"))?,
stdout.map_err(|e| format!("failed reading shell stdout: {e}"))?,
stderr.map_err(|e| format!("failed reading shell stderr: {e}"))?,
))
};
match tokio::time::timeout(timeout, execution).await {
Err(_elapsed) => {
#[cfg(unix)]
if let Some(pid) = group_pid {
unsafe {
libc::kill(-(pid as i32), libc::SIGKILL);
}
}
#[cfg(windows)]
if let Some(job) = &job {
job.kill();
}
let _ = child.kill().await;
let _ = child.wait().await;
(None, format!("timed out after {}s", timeout.as_secs()))
}
Ok(Err(error)) => (None, error),
Ok(Ok((status, stdout, stderr))) => {
let mut combined = stdout;
if !stderr.trim().is_empty() {
combined.push_str("\n--- stderr ---\n");
combined.push_str(stderr.trim_end());
}
(
status.code(),
tail_chars(combined.trim_end(), COMMAND_OUTPUT_TAIL),
)
}
}
}
async fn read_stream_tail<R>(mut reader: R) -> std::io::Result<String>
where
R: AsyncRead + Unpin,
{
let max_bytes = COMMAND_OUTPUT_TAIL * 4;
let mut tail = Vec::with_capacity(max_bytes);
let mut chunk = [0u8; 8192];
loop {
let read = reader.read(&mut chunk).await?;
if read == 0 {
break;
}
if read >= max_bytes {
tail.clear();
tail.extend_from_slice(&chunk[read - max_bytes..read]);
continue;
}
let excess = tail.len().saturating_add(read).saturating_sub(max_bytes);
if excess > 0 {
tail.drain(..excess);
}
tail.extend_from_slice(&chunk[..read]);
}
Ok(tail_chars(
&String::from_utf8_lossy(&tail),
COMMAND_OUTPUT_TAIL,
))
}
pub fn run_bounded_gate_command(cwd: &std::path::Path, command: &str) -> (bool, String) {
let cargo_home = crate::agent_env::cache_only_cargo_home(std::env::temp_dir().as_path());
if !cargo_home.is_dir() {
return (
false,
format!(
"could not create the gate's cache-only Cargo home at {}",
cargo_home.display()
),
);
}
let mut env = sanitized_gate_env();
env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => return (false, format!("failed to create gate runtime: {error}")),
};
let (code, output) = runtime.block_on(run_shell_command_with_timeout_env(
cwd,
command,
COMMAND_TIMEOUT,
&env,
true,
));
let _ = std::fs::remove_dir_all(&cargo_home);
(code == Some(0), output)
}
pub struct MergeGatePolicy {
pub sandbox: crate::types::SandboxConfig,
pub mission_dir: std::path::PathBuf,
}
impl MergeGatePolicy {
pub fn disabled() -> Self {
MergeGatePolicy {
sandbox: crate::types::SandboxConfig::default(),
mission_dir: std::path::PathBuf::new(),
}
}
pub fn enforces_on_this_host(&self) -> bool {
if self.sandbox.enforce == crate::types::SandboxEnforce::Off {
return false;
}
match self.sandbox.provider {
crate::types::SandboxProvider::Process => !matches!(
crate::sandbox::platform_support(self.sandbox.enforce, std::env::consts::OS),
crate::sandbox::SandboxDecision::Off
),
crate::types::SandboxProvider::Container => true,
}
}
pub fn degradation_note(&self) -> Option<String> {
self.degradation_note_target(crate::sandbox_container::detect())
}
pub(crate) fn degradation_note_target(
&self,
container_runtime: Option<crate::sandbox_container::ContainerRuntime>,
) -> Option<String> {
if self.sandbox.provider == crate::types::SandboxProvider::Container
&& self.sandbox.enforce != crate::types::SandboxEnforce::Off
&& container_runtime.is_none()
{
Some(container_gate_note(self.sandbox.enforce))
} else {
None
}
}
}
pub fn run_bounded_gate_command_sandboxed(
cwd: &std::path::Path,
command: &str,
policy: &MergeGatePolicy,
) -> (bool, String) {
let (code, output) = run_bounded_gate_command_sandboxed_with_code(cwd, command, policy);
(code == Some(0), output)
}
pub(crate) fn run_bounded_gate_command_sandboxed_with_code(
cwd: &std::path::Path,
command: &str,
policy: &MergeGatePolicy,
) -> (Option<i32>, String) {
if !policy.enforces_on_this_host() {
let (ok, output) = run_bounded_gate_command(cwd, command);
return (Some(i32::from(!ok)), output);
}
let scratch =
std::env::temp_dir().join(format!("kranz-gate-{}", uuid::Uuid::new_v4().simple()));
if std::fs::create_dir_all(scratch.join("tmp")).is_err() {
return (
None,
format!(
"could not create the gate's sandbox scratch at {}",
scratch.display()
),
);
}
let cargo_home = crate::agent_env::cache_only_cargo_home(scratch.as_path());
if !cargo_home.is_dir() {
let _ = std::fs::remove_dir_all(&scratch);
return (
None,
format!(
"could not create the gate's cache-only Cargo home at {}",
cargo_home.display()
),
);
}
let runtime = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(runtime) => runtime,
Err(error) => {
let _ = std::fs::remove_dir_all(&scratch);
return (None, format!("failed to create gate runtime: {error}"));
}
};
let mut resolution = match resolve_gate_sandbox(
&policy.sandbox,
cwd,
&policy.mission_dir,
&scratch,
&scratch,
) {
Ok(resolution) => resolution,
Err(error) => {
let _ = std::fs::remove_dir_all(&scratch);
return (
None,
format!("could not resolve the gate sandbox (failing closed): {error}"),
);
}
};
if let Some(note) = &resolution.note {
tracing::warn!(note = %note, "merge gate sandbox degraded to a no-op");
}
let mut env = sanitized_gate_env();
env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
#[cfg(windows)]
crate::agent_env::redirect_windows_profile_env(&mut env, &scratch);
#[cfg(not(windows))]
for var in ["TMPDIR", "TMP", "TEMP"] {
env.insert(var.to_string(), scratch.join("tmp").display().to_string());
}
let (code, output) = runtime.block_on(run_shell_command_sandboxed_with_code(
cwd,
command,
COMMAND_TIMEOUT,
&env,
&resolution.sandbox,
));
if let Err(error) = resolution.sandbox.cleanup() {
let _ = std::fs::remove_dir_all(&scratch);
return (
None,
format!("gate sandbox cleanup failed closed after command execution: {error}"),
);
}
let _ = std::fs::remove_dir_all(&scratch);
(code, output)
}
pub(crate) fn sanitized_gate_env() -> HashMap<String, String> {
const SAFE: &[&str] = &[
"PATH",
"HOME",
"USERPROFILE",
"TMPDIR",
"TMP",
"TEMP",
"RUSTUP_HOME",
"NPM_CONFIG_CACHE",
"CI",
"TERM",
"LANG",
"LC_ALL",
"TZ",
];
let env: HashMap<String, String> = SAFE
.iter()
.filter_map(|key| {
std::env::var_os(key).map(|value| ((*key).to_string(), value.to_string_lossy().into()))
})
.collect();
#[cfg(windows)]
let env = {
let mut env = env;
crate::agent_env::extend_windows_process_env(&mut env);
crate::agent_env::extend_noncredential_toolchain_env(&mut env);
env
};
env
}
pub(crate) fn tail_chars(text: &str, max: usize) -> String {
let count = text.chars().count();
if count <= max {
return text.to_string();
}
text.chars().skip(count - max).collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use crate::runner;
#[test]
fn control_wrapper_keeps_scratch_mounts_and_positional_snapshot_cwd() {
let root = tempfile::tempdir().unwrap();
let scratch = root.path().join("scratch");
let snapshot = root.path().join("readonly snapshot's checkout");
std::fs::create_dir(&scratch).unwrap();
std::fs::create_dir(&snapshot).unwrap();
let inputs = gate_sandbox_inputs(
&fs_sandbox_config(crate::types::SandboxEnforce::Fs),
&scratch,
&root.path().join(".kranz/missions/control"),
&scratch,
);
let sandbox = GateSandbox::Bubblewrap {
inputs: Box::new(inputs),
};
let command = "sh check.sh && printf '%s' \"$HOME\"";
let wrapped = sandbox
.wrap_control_shell(&snapshot, command, &HashMap::new())
.unwrap();
let chdir = wrapped
.args
.iter()
.position(|arg| arg == "--chdir")
.unwrap();
assert_eq!(
wrapped.args[chdir + 1],
std::fs::canonicalize(&scratch)
.unwrap()
.display()
.to_string()
);
assert_eq!(
&wrapped.args[chdir + 2..],
&[
"--",
"/bin/sh",
"-c",
"cd -- \"$1\" && exec /bin/sh -c \"$2\"",
"kranz-control",
&snapshot.display().to_string(),
command,
]
);
let writes: Vec<_> = wrapped
.args
.windows(3)
.filter(|args| args[0] == "--bind")
.map(|args| args[2].clone())
.collect();
assert!(writes.contains(
&std::fs::canonicalize(&scratch)
.unwrap()
.display()
.to_string()
));
assert!(!writes.contains(&snapshot.display().to_string()));
assert!(GateSandbox::Disabled
.wrap_control_shell(&snapshot, command, &HashMap::new())
.is_err());
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
async fn control_wait_retains_the_leader_until_group_cleanup() {
let root = tempfile::tempdir().unwrap();
let mut command = tokio::process::Command::new("/bin/sh");
command.args(["-c", "exit 7"]).env_clear();
let mut child = ControlChild(
configure_bounded_child(command, root.path(), &HashMap::new())
.spawn()
.unwrap(),
);
let pid = child.0.id().unwrap();
for _ in 0..2 {
tokio::time::timeout(Duration::from_secs(3), control_leader_exited(pid))
.await
.unwrap()
.unwrap();
}
crate::backend_claude::kill_unreaped_group(&child.0);
assert_eq!(child.0.wait().await.unwrap().code(), Some(7));
assert!(
child.0.id().is_none(),
"the drop guard cannot signal a reaped PID"
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
async fn control_timeout_kills_a_leader_outside_its_original_group() {
let root = tempfile::tempdir().unwrap();
let ready = root.path().join("escaped-leader");
let mut command = tokio::process::Command::new(std::env::current_exe().unwrap());
command
.args([
"--ignored",
"--exact",
"command_exec::tests::control_escaped_leader_fixture",
"--nocapture",
])
.env_clear();
let command = configure_bounded_child(
command,
root.path(),
&HashMap::from([(
"KRANZ_CONTROL_ESCAPED_LEADER".into(),
ready.display().to_string(),
)]),
);
let (code, output) = tokio::time::timeout(
Duration::from_secs(5),
run_control_command_bounded(
command,
Duration::from_secs(1),
&std::sync::atomic::AtomicBool::new(false),
),
)
.await
.expect("cleanup must terminate the escaped direct child before waiting");
let evidence =
std::fs::read_to_string(ready).expect("fixture moved out of its original group");
let (pid, group) = evidence.split_once(' ').unwrap();
assert_ne!(pid, group, "fixture must leave its original group");
assert_eq!(code, None, "{output}");
assert!(output.contains("timed out"), "{output}");
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
#[ignore = "disposable subprocess fixture for direct-child timeout cleanup"]
fn control_escaped_leader_fixture() {
let Some(ready) = std::env::var_os("KRANZ_CONTROL_ESCAPED_LEADER") else {
return;
};
let group = unsafe { libc::getpgid(libc::getppid()) };
assert!(group > 0);
assert_eq!(unsafe { libc::setpgid(0, group) }, 0);
std::fs::write(ready, format!("{} {group}", std::process::id())).unwrap();
std::thread::sleep(Duration::from_secs(30));
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[tokio::test]
async fn control_abort_cleans_unreaped_descendants() {
let root = tempfile::tempdir().unwrap();
let ready = root.path().join("ready");
let marker = root.path().join("survived");
let mut command = tokio::process::Command::new("/bin/sh");
command
.args([
"-c",
"(sleep 1; printf survived > \"$MARKER\") >/dev/null 2>&1 & printf ready > \"$READY\"; wait",
])
.env_clear();
let command = configure_bounded_child(
command,
root.path(),
&HashMap::from([
("PATH".into(), "/usr/bin:/bin".into()),
("READY".into(), ready.display().to_string()),
("MARKER".into(), marker.display().to_string()),
]),
);
let task = tokio::spawn(async move {
run_control_command_bounded(
command,
Duration::from_secs(5),
&std::sync::atomic::AtomicBool::new(false),
)
.await
});
tokio::time::timeout(Duration::from_secs(3), async {
while !ready.exists() {
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("checker started before cancellation");
task.abort();
assert!(task.await.unwrap_err().is_cancelled());
tokio::time::sleep(Duration::from_millis(1200)).await;
assert!(!marker.exists(), "aborted runner left a live descendant");
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn control_wrapper_reads_snapshot_and_cleans_every_exit() {
use std::sync::atomic::{AtomicBool, Ordering};
let _lock = GATE_SANDBOX_WRAP_LOCK
.lock()
.unwrap_or_else(|error| error.into_inner());
if !gate_wrap_enforcement_available() {
return;
}
let _env = crate::agent_env::EnvTestGuard::engage(&[(
"KRANZ_CONTROL_AMBIENT_SENTINEL",
"not-authorized",
)]);
let (repo, mission) = gate_wrap_layout();
let snapshot = repo.path().join("readonly snapshot's checkout");
std::fs::create_dir(&snapshot).unwrap();
std::fs::write(snapshot.join("checker-input"), "approved").unwrap();
let checker = r#"set -eu
[ "$(cat checker-input)" = approved ]
[ -z "${KRANZ_CONTROL_AMBIENT_SENTINEL+x}" ]
[ "$CARGO_NET_OFFLINE" = true ]
if (printf changed > checker-input) 2>/dev/null; then exit 90; fi
if [ "$MODE" = inherited ]; then
(sleep 2; printf survived > "$CONTROL_MARKER") &
else
(sleep 2; printf survived > "$CONTROL_MARKER") >/dev/null 2>&1 &
fi
printf ready > "$CONTROL_READY"
printf control-stdout
printf control-stderr >&2
case "$MODE" in
nonzero) exit 7;;
timeout|cancel) wait;;
esac
"#;
std::fs::write(snapshot.join("check.sh"), checker).unwrap();
let scratch = tempfile::tempdir().unwrap();
let sandbox = resolve_gate_sandbox(
&fs_sandbox_config(crate::types::SandboxEnforce::FsNet),
scratch.path(),
&mission,
scratch.path(),
scratch.path(),
)
.unwrap()
.sandbox;
let mut markers = Vec::new();
for mode in ["success", "nonzero", "inherited", "timeout", "cancel"] {
let marker = scratch.path().join(format!("{mode}.survived"));
let ready = scratch.path().join(format!("{mode}.ready"));
let env = HashMap::from([
("PATH".into(), "/usr/bin:/bin".into()),
("MODE".into(), mode.into()),
("CONTROL_MARKER".into(), marker.display().to_string()),
("CONTROL_READY".into(), ready.display().to_string()),
]);
let cancelled = AtomicBool::new(false);
let (code, output) = std::thread::scope(|scope| {
let ready = &ready;
let cancelled = &cancelled;
if mode == "cancel" {
scope.spawn(move || {
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while !ready.exists() {
assert!(
std::time::Instant::now() < deadline,
"checker did not start"
);
std::thread::sleep(Duration::from_millis(10));
}
cancelled.store(true, Ordering::Release);
});
}
run_control_command_sandboxed_blocking(
&snapshot,
"sh check.sh",
Duration::from_secs(if mode == "timeout" { 1 } else { 5 }),
&env,
&sandbox,
cancelled,
)
});
assert!(ready.exists(), "{mode}: checker did not run: {output}");
match mode {
"timeout" => {
assert_eq!(code, None);
assert!(output.contains("timed out"));
}
"cancel" => {
assert_eq!(code, None);
assert!(output.contains("cancelled"));
}
_ => {
assert_eq!(
code,
Some(if mode == "nonzero" { 7 } else { 0 }),
"{output}"
);
assert!(output.contains("control-stdout"), "{output}");
assert!(output.contains("control-stderr"), "{output}");
}
}
markers.push(marker);
}
let control = scratch.path().join("unsupervised.survived");
let mut positive = std::process::Command::new("/bin/sh")
.args([
"-c",
"sleep 2; printf survived > \"$1\"",
"positive",
&control.display().to_string(),
])
.spawn()
.unwrap();
assert!(positive.wait().unwrap().success());
assert!(control.exists());
for marker in markers {
assert!(
!marker.exists(),
"descendant survived cleanup: {}",
marker.display()
);
}
assert_eq!(
std::fs::read_to_string(snapshot.join("checker-input")).unwrap(),
"approved"
);
}
#[test]
fn tail_chars_keeps_the_end() {
assert_eq!(tail_chars("abcdef", 3), "def");
assert_eq!(tail_chars("ab", 3), "ab");
assert_eq!(tail_chars("héllo", 2), "lo");
}
#[cfg(unix)]
#[tokio::test]
async fn shell_command_timeout_kills_the_whole_process_tree() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("child.pid");
let command = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
let (ok, output) = tokio::time::timeout(
Duration::from_secs(10),
run_shell_command_with_timeout(
dir.path(),
&command,
Duration::from_millis(500),
&std::collections::HashMap::new(),
),
)
.await
.expect("timed-out command must return promptly");
assert!(!ok, "command must be reported failed: {output}");
assert!(output.contains("timed out"), "got: {output}");
let pid: i32 = std::fs::read_to_string(&pidfile)
.expect("shell wrote the background pid before the timeout")
.trim()
.parse()
.expect("pidfile contains a pid");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(
std::time::Instant::now() < deadline,
"background child {pid} survived the group kill"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(unix)]
#[tokio::test]
async fn shell_command_drains_large_output_while_running_and_keeps_only_the_tail() {
let dir = tempfile::tempdir().unwrap();
let command = "i=0; while [ \"$i\" -lt 20000 ]; do \
printf '0123456789abcdef0123456789abcdef\\n'; \
i=$((i + 1)); done; printf 'OUTPUT-END'";
let (ok, output) = run_shell_command_with_timeout(
dir.path(),
command,
Duration::from_secs(10),
&std::collections::HashMap::new(),
)
.await;
assert!(ok, "large-output command must complete: {output}");
assert!(output.ends_with("OUTPUT-END"), "{output}");
assert!(
output.chars().count() <= COMMAND_OUTPUT_TAIL,
"retained output exceeded the cap: {} chars",
output.chars().count()
);
}
#[test]
fn merge_gate_environment_excludes_server_secrets() {
let env = sanitized_gate_env();
for secret in [
"ANTHROPIC_API_KEY",
"OPENAI_API_KEY",
"SLACK_BOT_TOKEN",
"GITHUB_TOKEN",
"GH_TOKEN",
"SSH_AUTH_SOCK",
"AWS_SECRET_ACCESS_KEY",
] {
assert!(!env.contains_key(secret), "gate env leaked {secret}");
}
assert!(
!env.contains_key("CARGO_HOME"),
"the ambient Cargo root is a credential directory; \
run_bounded_gate_command substitutes a cache-only home"
);
assert!(env.keys().all(|key| matches!(
key.as_str(),
"PATH"
| "HOME"
| "USERPROFILE"
| "TMPDIR"
| "TMP"
| "TEMP"
| "APPDATA"
| "LOCALAPPDATA"
| "SystemRoot"
| "ComSpec"
| "PATHEXT"
| "SystemDrive"
| "windir"
| "OS"
| "PROCESSOR_ARCHITECTURE"
| "PSModulePath"
| "RUSTUP_HOME"
| "NPM_CONFIG_CACHE"
| "CI"
| "TERM"
| "LANG"
| "LC_ALL"
| "TZ"
)));
}
#[cfg(unix)]
#[test]
fn contract_cargo_home_replaces_ambient_root_in_merge_gates() {
let source = tempfile::tempdir().unwrap();
std::fs::create_dir_all(source.path().join("registry")).unwrap();
std::fs::write(source.path().join("registry/cache-marker"), "registry").unwrap();
std::fs::write(source.path().join("credentials.toml"), "operator-secret").unwrap();
let _guard = crate::agent_env::EnvTestGuard::engage(&[(
"CARGO_HOME",
source.path().to_str().expect("utf-8 temp path"),
)]);
let dir = tempfile::tempdir().unwrap();
let (ok, output) = run_bounded_gate_command(
dir.path(),
"printf '%s' \"$CARGO_HOME\" \
&& test -f \"$CARGO_HOME/registry/cache-marker\" \
&& test ! -e \"$CARGO_HOME/credentials.toml\"",
);
assert!(
ok,
"gate command must see a seeded, credential-free Cargo home: {output}"
);
assert!(
!output.is_empty() && output != source.path().to_string_lossy().as_ref(),
"the gate must NOT receive the ambient Cargo root: {output}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn contract_command_cannot_see_ambient_secrets() {
let _poison = crate::agent_env::EnvTestGuard::engage(&[
("GH_TOKEN", "hunter2"),
("SLACK_BOT_TOKEN", "x"),
("AWS_SECRET_ACCESS_KEY", "y"),
]);
let dir = tempfile::tempdir().unwrap();
let scratch = tempfile::tempdir().unwrap();
let env = crate::agent_env::contract_command_env(scratch.path(), Some("deadbeef"), &[]);
let (ok, output) = run_shell_command(
dir.path(),
"test -z \"$GH_TOKEN\" && test -z \"$SLACK_BOT_TOKEN\" && test -z \"$AWS_SECRET_ACCESS_KEY\"",
&env,
)
.await;
assert!(
ok,
"poisoned ambient vars reached the contract command: {output}"
);
let (ok, names) =
run_shell_command(dir.path(), "env | sed 's/=.*//' | LC_ALL=C sort", &env).await;
assert!(ok, "{names}");
for leaked in ["GH_TOKEN", "SLACK_BOT_TOKEN", "AWS_SECRET_ACCESS_KEY"] {
assert!(
!names.lines().any(|name| name == leaked),
"contract env leaked {leaked}:\n{names}"
);
}
assert!(
names.lines().any(|name| name == "PATH"),
"PATH must cross:\n{names}"
);
let (ok, managed) = run_shell_command(
dir.path(),
"printf 'HOME=%s\nKRANZ_BASE_SHA=%s\nCARGO_HOME=%s\n' \"$HOME\" \"$KRANZ_BASE_SHA\" \"$CARGO_HOME\"",
&env,
)
.await;
assert!(ok, "{managed}");
assert!(
managed.contains(&format!("HOME={}", scratch.path().display())),
"HOME must be the per-mission scratch:\n{managed}"
);
assert!(
managed.contains("KRANZ_BASE_SHA=deadbeef"),
"base sha must reach the contract env:\n{managed}"
);
let cargo_home = env.get("CARGO_HOME").expect("CARGO_HOME");
assert!(
std::path::Path::new(cargo_home).starts_with(scratch.path()),
"contract CARGO_HOME must live under mission scratch: {cargo_home}"
);
assert!(
managed.contains(&format!("CARGO_HOME={cargo_home}")),
"cache-only Cargo home must reach the child:\n{managed}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn contract_env_passthrough_admits_only_the_named_var() {
let _guard = crate::agent_env::EnvTestGuard::engage(&[
("KRANZ_CONTRACT_TEST_CRED", "cred-value"),
("GH_TOKEN", "hunter2"),
]);
let dir = tempfile::tempdir().unwrap();
let scratch = tempfile::tempdir().unwrap();
let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
let (ok, output) =
run_shell_command(dir.path(), "test -z \"$KRANZ_CONTRACT_TEST_CRED\"", &env).await;
assert!(
ok,
"an unconfigured var must not reach the contract env: {output}"
);
let env = crate::agent_env::contract_command_env(
scratch.path(),
None,
&["KRANZ_CONTRACT_TEST_CRED".to_string()],
);
let (ok, output) = run_shell_command(
dir.path(),
"test \"$KRANZ_CONTRACT_TEST_CRED\" = cred-value && test -z \"$GH_TOKEN\"",
&env,
)
.await;
assert!(
ok,
"the passthrough-named var must cross, nothing else: {output}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn base_sha_reaches_final_gate_env() {
let dir = tempfile::tempdir().unwrap();
let env = runner::contract_env(Some("deadbeefcafe"));
let (ok, output) = run_shell_command_with_timeout(
dir.path(),
"test \"$KRANZ_BASE_SHA\" = deadbeefcafe",
Duration::from_secs(10),
&env,
)
.await;
assert!(ok, "expected command to succeed: {output}");
}
#[tokio::test]
async fn shell_command_with_code_reports_the_real_exit_code() {
let dir = tempfile::tempdir().unwrap();
let env = std::collections::HashMap::new();
let (code, output) = run_shell_command_with_code(dir.path(), "echo hi", &env).await;
assert_eq!(code, Some(0), "{output}");
assert!(output.contains("hi"), "{output}");
let (code, output) = run_shell_command_with_code(dir.path(), "exit 3", &env).await;
assert_eq!(code, Some(3), "{output}");
}
#[cfg(unix)]
#[tokio::test]
async fn bounded_argv_timeout_kills_the_whole_process_tree() {
let dir = tempfile::tempdir().unwrap();
let pidfile = dir.path().join("child.pid");
let script = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
let env = std::collections::HashMap::new();
let (code, output) = tokio::time::timeout(
Duration::from_secs(10),
run_bounded_argv(
dir.path(),
std::path::Path::new("/bin/sh"),
&["-c".to_string(), script],
Duration::from_millis(500),
&env,
),
)
.await
.expect("timed-out command must return promptly");
assert_eq!(code, None, "a timeout yields no exit code: {output}");
assert!(output.contains("timed out"), "got: {output}");
let pid: i32 = std::fs::read_to_string(&pidfile)
.expect("shell wrote the background pid before the timeout")
.trim()
.parse()
.expect("pidfile contains a pid");
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while unsafe { libc::kill(pid, 0) } == 0 {
assert!(
std::time::Instant::now() < deadline,
"background child {pid} survived the group kill"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(unix)]
#[tokio::test]
async fn bounded_argv_drains_large_output_and_reports_exit_codes() {
let dir = tempfile::tempdir().unwrap();
let env = std::collections::HashMap::new();
let big = "i=0; while [ \"$i\" -lt 20000 ]; do \
printf '0123456789abcdef0123456789abcdef\\n'; \
i=$((i + 1)); done; printf 'OUTPUT-END'";
let (code, output) = run_bounded_argv(
dir.path(),
std::path::Path::new("/bin/sh"),
&["-c".to_string(), big.to_string()],
Duration::from_secs(10),
&env,
)
.await;
assert_eq!(
code,
Some(0),
"large-output command must complete: {output}"
);
assert!(output.ends_with("OUTPUT-END"), "{output}");
assert!(
output.chars().count() <= COMMAND_OUTPUT_TAIL,
"retained output exceeded the cap: {} chars",
output.chars().count()
);
let (code, output) = run_bounded_argv(
dir.path(),
std::path::Path::new("/bin/sh"),
&["-c".to_string(), "exit 3".to_string()],
Duration::from_secs(10),
&env,
)
.await;
assert_eq!(code, Some(3), "{output}");
}
#[cfg(unix)]
static GATE_SANDBOX_WRAP_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(target_os = "macos")]
fn gate_wrap_sandbox_exec_can_apply() -> bool {
let found = std::process::Command::new("which")
.arg("sandbox-exec")
.output()
.map(|o| o.status.success())
.unwrap_or(false);
if !found {
crate::test_capability::skip(
crate::test_capability::capability::SANDBOX_EXEC,
"sandbox-exec not found on this host",
);
return false;
}
let smoke = std::process::Command::new("sandbox-exec")
.arg("-p")
.arg("(version 1)\n(allow default)\n")
.arg("/usr/bin/true")
.output();
match smoke {
Ok(output) if output.status.success() => true,
Ok(output) => {
eprintln!(
"sandbox-exec cannot apply a smoke profile on this host; skipping: {}",
String::from_utf8_lossy(&output.stderr)
);
false
}
Err(e) => {
eprintln!("sandbox-exec smoke probe failed; skipping: {e}");
false
}
}
}
#[cfg(target_os = "linux")]
fn gate_wrap_bwrap_can_apply() -> bool {
if !crate::sandbox::command_available("bwrap") {
crate::test_capability::skip(
crate::test_capability::capability::BWRAP,
"bwrap not found on this host",
);
return false;
}
let smoke = std::process::Command::new("bwrap")
.args([
"--die-with-parent",
"--ro-bind",
"/",
"/",
"--dev",
"/dev",
"--proc",
"/proc",
"--",
"/bin/true",
])
.output();
match smoke {
Ok(output) if output.status.success() => true,
Ok(output) => {
eprintln!(
"bwrap cannot apply a smoke sandbox on this host; skipping: {}",
String::from_utf8_lossy(&output.stderr)
);
false
}
Err(e) => {
eprintln!("bwrap smoke probe failed; skipping: {e}");
false
}
}
}
#[cfg(unix)]
fn gate_wrap_enforcement_available() -> bool {
#[cfg(target_os = "macos")]
{
gate_wrap_sandbox_exec_can_apply()
}
#[cfg(target_os = "linux")]
{
gate_wrap_bwrap_can_apply()
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
false
}
}
fn fs_sandbox_config(enforce: crate::types::SandboxEnforce) -> crate::types::SandboxConfig {
crate::types::SandboxConfig {
enforce,
provider: crate::types::SandboxProvider::Process,
image: None,
extra_write: vec![],
egress: vec![],
}
}
#[cfg(unix)]
fn gate_wrap_layout() -> (tempfile::TempDir, std::path::PathBuf) {
gate_wrap_layout_with_repo(tempfile::tempdir().unwrap())
}
#[cfg(unix)]
fn gate_wrap_layout_with_repo(
repo: tempfile::TempDir,
) -> (tempfile::TempDir, std::path::PathBuf) {
let kranz_dir = repo.path().join(".kranz");
let mission = kranz_dir.join("missions").join("m-gate");
std::fs::create_dir_all(mission.join("runs")).unwrap();
std::fs::create_dir_all(mission.join("control")).unwrap();
std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
std::fs::write(mission.join("state.json"), "{}").unwrap();
for name in ["serve.token", "serve.read.token", "config.json"] {
std::fs::write(kranz_dir.join(name), "secret").unwrap();
}
std::fs::write(repo.path().join("public.txt"), "public").unwrap();
(repo, mission)
}
#[test]
fn gate_sandbox_wrap_resolve_matrix() {
let repo = tempfile::tempdir().unwrap();
let mission = repo.path().join(".kranz").join("missions").join("m-x");
std::fs::create_dir_all(&mission).unwrap();
let scratch = tempfile::tempdir().unwrap();
let off = crate::types::SandboxConfig::default();
let fs = fs_sandbox_config(crate::types::SandboxEnforce::Fs);
let resolution = resolve_gate_sandbox_target(
&off,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"macos",
false,
None,
None,
)
.unwrap();
assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
assert!(resolution.note.is_none());
let resolution = resolve_gate_sandbox_target(
&fs,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"macos",
false,
None,
None,
)
.unwrap();
assert!(resolution.note.is_none());
let GateSandbox::Seatbelt {
enforce,
profile_path,
} = &resolution.sandbox
else {
panic!("fs on macOS must resolve to Seatbelt");
};
assert_eq!(*enforce, crate::types::SandboxEnforce::Fs);
let profile = std::fs::read_to_string(profile_path).unwrap();
assert!(profile.contains("(deny default)"), "{profile}");
assert!(
profile.contains("(literal \"/dev/null\")"),
"the gate profile must add the /dev/null device write allow:\n{profile}"
);
assert!(
profile.contains("(literal \"/dev/ptmx\")"),
"pty harness support (pty-functional-validation): the gate profile must \
permit the ptmx multiplexer:\n{profile}"
);
assert!(
profile.contains(
"(allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))"
),
"the grantpt/unlockpt ioctl allow must be scoped to /dev/ptmx and the \
tty slave nodes:\n{profile}"
);
assert!(
!profile.contains("(allow file-ioctl)"),
"the ioctl allow must never be unscoped again (every device the gate \
can open becomes ioctl-able):\n{profile}"
);
assert!(
!profile.contains("xcrun_db"),
"13th-pass review (P1): the gate profile must NOT permit writes to the \
shared per-user xcrun cache (prewarm + deny posture):\n{profile}"
);
assert!(
profile.contains("events.jsonl"),
"mission metadata write denies must ride along:\n{profile}"
);
assert!(
profile.contains("serve.token"),
"authority read denies must ride along:\n{profile}"
);
let resolution = resolve_gate_sandbox_target(
&fs,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"linux",
true,
None,
None,
)
.unwrap();
let GateSandbox::Bubblewrap { inputs } = &resolution.sandbox else {
panic!("fs on linux with bwrap must resolve to Bubblewrap");
};
assert_eq!(inputs.session_cwd, repo.path());
assert_eq!(inputs.tmpdir, scratch.path());
assert_eq!(inputs.mission_dir, mission);
let error = resolve_gate_sandbox_target(
&fs,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"linux",
false,
None,
None,
)
.expect_err("linux without bwrap must fail closed");
assert!(error.to_string().contains("bwrap"), "{error}");
let resolution = resolve_gate_sandbox_target(
&fs,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"windows",
false,
None,
None,
)
.expect("Windows process gates resolve AppContainer");
let GateSandbox::AppContainer { inputs, .. } = &resolution.sandbox else {
panic!("fs on Windows must resolve AppContainer");
};
assert_eq!(inputs.session_cwd, repo.path());
assert_eq!(inputs.tmpdir, scratch.path());
assert_eq!(inputs.mission_dir, mission);
let error = resolve_gate_sandbox_target(
&fs,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"solaris",
false,
None,
None,
)
.expect_err("an unknown platform must fail closed");
assert!(error.to_string().contains("unsupported"), "{error}");
assert!(
error
.to_string()
.contains("refusing to run engine-run gates unsandboxed"),
"{error}"
);
}
#[test]
fn gate_profile_extras_scopes_file_ioctl_to_pty_devices() {
let extras = gate_profile_extras();
assert!(
extras.contains(
"(allow file-ioctl (literal \"/dev/ptmx\") (regex #\"^/dev/tty[p-t][0-9a-f]+$\"))"
),
"the ioctl allow must be scoped to the pty device pair:\n{extras}"
);
assert!(
!extras.contains("(allow file-ioctl)"),
"the unrestricted ioctl allow must not return:\n{extras}"
);
assert!(extras.contains("(literal \"/dev/ptmx\")"), "{extras}");
assert!(extras.contains("^/dev/tty[p-t][0-9a-f]+$"), "{extras}");
assert!(
extras.contains("(allow signal (target same-sandbox))"),
"{extras}"
);
}
#[test]
fn gate_profile_extras_deny_the_operators_own_terminal() {
let extras = gate_profile_extras();
let ttys = crate::sandbox::operator_tty_paths();
if ttys.is_empty() {
assert!(
!extras.contains("(deny file-read* file-write* file-ioctl"),
"no tty means no deny block:\n{extras}"
);
return;
}
assert!(
extras.contains("(deny file-read* file-write* file-ioctl"),
"a controlling terminal must produce a deny block:\n{extras}"
);
for tty in &ttys {
let expected = format!("(literal \"{}\")", crate::sandbox::escape_sbpl_literal(tty));
assert!(
extras.contains(&expected),
"the operator terminal {} must be denied:\n{extras}",
tty.display()
);
}
let allow = extras
.find("(allow file-ioctl (literal \"/dev/ptmx\")")
.expect("the pty ioctl allow");
let deny = extras
.find("(deny file-read* file-write* file-ioctl")
.expect("the terminal deny");
assert!(deny > allow, "the deny must follow the allows:\n{extras}");
}
#[test]
fn session_profile_denies_the_operators_own_terminal() {
let repo = tempfile::tempdir().unwrap();
let mission = repo.path().join(".kranz").join("missions").join("m-x");
std::fs::create_dir_all(&mission).unwrap();
let scratch = tempfile::tempdir().unwrap();
let profile = crate::sandbox::generate_profile(&crate::sandbox::SandboxInputs {
enforce: crate::types::SandboxEnforce::Fs,
session_cwd: repo.path().to_path_buf(),
mission_dir: mission,
tmpdir: scratch.path().to_path_buf(),
extra_write: vec![],
egress: vec![],
validator_read_deny_roots: vec![],
});
for tty in crate::sandbox::operator_tty_paths() {
let expected = format!(
"(literal \"{}\")",
crate::sandbox::escape_sbpl_literal(&tty)
);
assert!(
profile.contains(&expected),
"the session profile must deny the operator terminal {}:\n{profile}",
tty.display()
);
}
}
#[test]
fn container_gate_wrap_resolve_matrix() {
let repo = tempfile::tempdir().unwrap();
let mission = repo.path().join(".kranz").join("missions").join("m-x");
std::fs::create_dir_all(&mission).unwrap();
let scratch = tempfile::tempdir().unwrap();
let container = |enforce| crate::types::SandboxConfig {
enforce,
provider: crate::types::SandboxProvider::Container,
image: None,
extra_write: vec![],
egress: vec![],
};
let runtime = Some(crate::sandbox_container::ContainerRuntime::Docker);
let resolution = resolve_gate_sandbox_target(
&container(crate::types::SandboxEnforce::Fs),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"linux",
false,
runtime,
None,
)
.unwrap();
assert!(resolution.note.is_none());
let GateSandbox::Container { inputs, spec } = &resolution.sandbox else {
panic!("container + runtime must resolve to GateSandbox::Container on linux");
};
assert_eq!(inputs.session_cwd, repo.path());
assert_eq!(inputs.tmpdir, scratch.path());
assert_eq!(inputs.mission_dir, mission);
assert_eq!(inputs.enforce, crate::types::SandboxEnforce::Fs);
assert_eq!(
spec.runtime,
crate::sandbox_container::ContainerRuntime::Docker
);
assert_eq!(spec.image, crate::sandbox_container::DEFAULT_IMAGE);
let proven = resolve_gate_sandbox_target(
&container(crate::types::SandboxEnforce::Fs),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"macos",
false,
runtime,
Some(crate::sandbox_container::MountProof::Proven),
)
.expect("a proven macOS host must resolve its container gate");
assert!(
matches!(proven.sandbox, GateSandbox::Container { .. }),
"{:?}",
proven.sandbox
);
let unshared = resolve_gate_sandbox_target(
&container(crate::types::SandboxEnforce::Fs),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"macos",
false,
runtime,
Some(crate::sandbox_container::MountProof::Failed(
"docker accepted a bind mount of /var/folders/x and shared nothing".to_string(),
)),
)
.expect_err("a failed proof must refuse the gate");
assert!(
unshared.to_string().contains("/var/folders/x"),
"{unshared}"
);
for target_os in ["macos", "windows"] {
let error = resolve_gate_sandbox_target(
&container(crate::types::SandboxEnforce::Fs),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
target_os,
false,
runtime,
None,
)
.expect_err("an unproved container gate must fail closed");
assert!(
error
.to_string()
.contains("unverified container mount contract"),
"{error}"
);
if target_os == "macos" {
assert!(
error.to_string().contains("requires a bind-mount proof"),
"{error}"
);
assert!(
error.to_string().contains("sandbox.provider=\"process\""),
"{error}"
);
}
}
let mut imaged = container(crate::types::SandboxEnforce::Fs);
imaged.image = Some("ghcr.io/example/kranz-worker:1".to_string());
let resolution = resolve_gate_sandbox_target(
&imaged,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"linux",
false,
runtime,
None,
)
.unwrap();
let GateSandbox::Container { spec, .. } = &resolution.sandbox else {
panic!("container + runtime must resolve to GateSandbox::Container");
};
assert_eq!(spec.image, "ghcr.io/example/kranz-worker:1");
let error = resolve_gate_sandbox_target(
&container(crate::types::SandboxEnforce::Fs),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"linux",
false,
None,
None,
)
.expect_err("container without a runtime must fail closed");
assert!(
error.to_string().contains("no container runtime"),
"{error}"
);
assert!(
error
.to_string()
.contains("refusing to run engine-run gates unsandboxed"),
"{error}"
);
let mut egress = container(crate::types::SandboxEnforce::FsNet);
egress.egress = vec!["crates.io:443".to_string()];
let error = resolve_gate_sandbox_target(
&egress,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"linux",
false,
runtime,
None,
)
.expect_err("container fs+net with an egress list must fail closed");
assert!(error.to_string().contains("advisory"), "{error}");
let resolution = resolve_gate_sandbox_target(
&container(crate::types::SandboxEnforce::FsNet),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"linux",
false,
runtime,
None,
)
.unwrap();
assert_eq!(
resolution.sandbox.enforce(),
crate::types::SandboxEnforce::FsNet
);
let resolution = resolve_gate_sandbox_target(
&container(crate::types::SandboxEnforce::Off),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"macos",
false,
None,
None,
)
.unwrap();
assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
assert!(resolution.note.is_none());
}
#[test]
fn windows_enforced_gate_process_resolves_appcontainer_while_container_fails_closed() {
let repo = tempfile::tempdir().unwrap();
let mission = repo.path().join(".kranz").join("missions").join("m-x");
std::fs::create_dir_all(&mission).unwrap();
let scratch = tempfile::tempdir().unwrap();
let runtime = Some(crate::sandbox_container::ContainerRuntime::Docker);
for enforce in [
crate::types::SandboxEnforce::Fs,
crate::types::SandboxEnforce::FsNet,
] {
let process = fs_sandbox_config(enforce);
let resolution = resolve_gate_sandbox_target(
&process,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"windows",
false,
runtime,
None,
)
.expect("Windows process gate enforcement resolves");
assert!(resolution.note.is_none(), "{:?}", resolution.note);
let GateSandbox::AppContainer { inputs, .. } = resolution.sandbox else {
panic!("Windows process gate must resolve AppContainer");
};
assert_eq!(inputs.enforce, enforce);
assert_eq!(inputs.session_cwd, repo.path());
assert_eq!(inputs.mission_dir, mission);
let container = crate::types::SandboxConfig {
enforce,
provider: crate::types::SandboxProvider::Container,
image: None,
extra_write: vec![],
egress: vec![],
};
let error = resolve_gate_sandbox_target(
&container,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"windows",
false,
runtime,
None,
)
.expect_err("an unproved Windows container gate must fail closed");
assert!(error
.to_string()
.contains("not supported on target_os=windows"));
assert!(error
.to_string()
.contains("unverified container mount contract"));
}
}
#[cfg(target_os = "macos")]
#[test]
fn gate_xcrun_deny_prewarm_runs_once_per_resolve_not_per_command() {
let repo = tempfile::tempdir().unwrap();
let mission = repo.path().join(".kranz").join("missions").join("m-x");
std::fs::create_dir_all(&mission).unwrap();
let scratch = tempfile::tempdir().unwrap();
let cfg = fs_sandbox_config(crate::types::SandboxEnforce::Fs);
let resolve = || {
resolve_gate_sandbox(&cfg, repo.path(), &mission, scratch.path(), scratch.path())
.unwrap()
};
let resolution = resolve();
assert!(resolution.prewarmed_xcrun, "one prewarm per resolve");
let env = std::collections::HashMap::new();
let _argv_one = resolution.sandbox.wrap_shell("true", &env).unwrap();
let _argv_two = resolution.sandbox.wrap_shell("echo hi", &env).unwrap();
assert!(
resolution.prewarmed_xcrun,
"command wraps neither prewarm nor reset the record"
);
let second = resolve();
assert!(second.prewarmed_xcrun, "each resolve prewarms exactly once");
}
#[test]
fn container_gate_wrap_merge_policy_enforces_or_notes_the_fail_closed() {
let container = |enforce| crate::types::SandboxConfig {
enforce,
provider: crate::types::SandboxProvider::Container,
image: None,
extra_write: vec![],
egress: vec![],
};
let policy = MergeGatePolicy {
sandbox: container(crate::types::SandboxEnforce::Fs),
mission_dir: std::path::PathBuf::new(),
};
assert!(policy.enforces_on_this_host());
assert!(policy
.degradation_note_target(Some(crate::sandbox_container::ContainerRuntime::Docker))
.is_none());
let note = policy
.degradation_note_target(None)
.expect("the runtime-unavailable container posture must be noted");
assert!(note.contains("no container runtime"), "{note}");
assert!(
note.contains("refusing to run engine-run gates unsandboxed"),
"{note}"
);
let repo = tempfile::tempdir().unwrap();
let mission = repo.path().join(".kranz").join("missions").join("m-x");
std::fs::create_dir_all(&mission).unwrap();
let scratch = tempfile::tempdir().unwrap();
let error = resolve_gate_sandbox_target(
&policy.sandbox,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
"linux",
false,
None,
None,
)
.expect_err("container without a runtime must fail closed");
assert_eq!(
error.to_string(),
format!("configuration error: {note}"),
"the engine-path resolve error and the merge-path note must match"
);
let off = MergeGatePolicy {
sandbox: container(crate::types::SandboxEnforce::Off),
mission_dir: std::path::PathBuf::new(),
};
assert!(off.degradation_note_target(None).is_none());
assert!(!off.enforces_on_this_host());
let process = MergeGatePolicy {
sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
mission_dir: std::path::PathBuf::new(),
};
assert!(process.degradation_note_target(None).is_none());
}
#[cfg(unix)]
#[tokio::test]
async fn gate_sandbox_wrap_off_keeps_byte_identical_behavior() {
let dir = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let env = std::collections::HashMap::new();
let resolution = resolve_gate_sandbox(
&crate::types::SandboxConfig::default(),
dir.path(),
dir.path(),
dir.path(),
dir.path(),
)
.unwrap();
assert!(matches!(resolution.sandbox, GateSandbox::Disabled));
assert!(resolution.note.is_none());
let marker = outside.path().join("gate_sandbox_wrap_off_marker");
let command = format!("echo hi > '{}' && printf MARKER", marker.display());
let (ok_reference, out_reference) = run_shell_command(dir.path(), &command, &env).await;
let (ok_wrapped, out_wrapped) =
run_shell_command_sandboxed(dir.path(), &command, &env, &GateSandbox::Disabled).await;
assert!(ok_reference, "reference run failed: {out_reference}");
assert!(ok_wrapped, "disabled wrap run failed: {out_wrapped}");
assert_eq!(
out_reference, out_wrapped,
"the Disabled wrap must reproduce the pre-wrap runner byte-for-byte"
);
assert!(
marker.exists(),
"with enforce == off a write outside any allowlist succeeds (today's posture)"
);
}
#[cfg(unix)]
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn container_gate_runtime_context_survives_timeout_without_worker_or_ambient_secrets() {
use std::os::unix::fs::PermissionsExt as _;
let fixture = tempfile::tempdir().unwrap();
let home = fixture.path().join("operator");
let scratch = fixture.path().join("worker");
std::fs::create_dir(&home).unwrap();
std::fs::create_dir(&scratch).unwrap();
let stub = fixture.path().join("docker");
std::fs::write(&stub, format!(
"#!/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",
fixture.path().display(), fixture.path().display(),
)).unwrap();
std::fs::set_permissions(&stub, std::fs::Permissions::from_mode(0o700)).unwrap();
let path = format!(
"{}:{}",
fixture.path().display(),
std::env::var("PATH").unwrap_or_default()
);
let _guard = crate::agent_env::EnvTestGuard::engage(&[
("PATH", &path),
("HOME", home.to_str().unwrap()),
("DOCKER_HOST", "unix:///operator-context.sock"),
("GH_TOKEN", "host-secret"),
]);
let sandbox = GateSandbox::Container {
inputs: Box::new(crate::sandbox::SandboxInputs {
enforce: crate::types::SandboxEnforce::Fs,
session_cwd: scratch.clone(),
mission_dir: scratch.join("mission"),
tmpdir: scratch.clone(),
extra_write: vec![],
egress: vec![],
validator_read_deny_roots: vec![],
}),
spec: crate::sandbox_container::ContainerSpec {
runtime: crate::sandbox_container::ContainerRuntime::Docker,
image: "fixture".to_string(),
network: None,
name: None,
},
};
let env = HashMap::from([
("HOME".to_string(), scratch.display().to_string()),
(
"DOCKER_HOST".to_string(),
"unix:///worker-request.sock".to_string(),
),
("WORKER_SENTINEL".to_string(), "allowed".to_string()),
]);
let (code, output) = run_shell_command_sandboxed_with_code(
&scratch,
"true",
Duration::from_millis(500),
&env,
&sandbox,
)
.await;
assert_eq!(
code, None,
"the fixture must exercise timeout cleanup: {output}"
);
for action in ["run", "rm"] {
assert_eq!(
std::fs::read_to_string(fixture.path().join(format!("{action}.env"))).unwrap(),
format!("{}\nunix:///operator-context.sock\nunset\n", home.display())
);
}
let args = std::fs::read_to_string(fixture.path().join("run.args")).unwrap();
assert!(args.contains("WORKER_SENTINEL=allowed"));
assert!(args.contains("DOCKER_HOST=unix:///worker-request.sock"));
assert!(!args.contains("host-secret"));
}
#[cfg(unix)]
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn gate_sandbox_wrap_denies_outside_writes_metadata_and_authority_reads() {
let _guard = GATE_SANDBOX_WRAP_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
if !gate_wrap_enforcement_available() {
return;
}
let (repo, mission) = gate_wrap_layout();
let kranz_dir = repo.path().join(".kranz");
let scratch = tempfile::tempdir().unwrap();
let outside = tempfile::tempdir().unwrap();
let temp_root_marker =
std::env::temp_dir().join(format!("kranz-gate-wrap-{}", uuid::Uuid::new_v4()));
let resolution = resolve_gate_sandbox(
&fs_sandbox_config(crate::types::SandboxEnforce::Fs),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
)
.unwrap();
assert!(resolution.note.is_none());
let sandbox = resolution.sandbox;
assert!(sandbox.enforce() == crate::types::SandboxEnforce::Fs);
let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
for allowed in [
repo.path().join("src.txt"),
scratch.path().join("notes.txt"),
] {
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("echo ok > '{}'", allowed.display()),
&env,
&sandbox,
)
.await;
assert!(
ok && allowed.exists(),
"write inside the gate roots must succeed: {output}"
);
}
let (ok, output) =
run_shell_command_sandboxed(repo.path(), "echo hi > /dev/null 2>&1", &env, &sandbox)
.await;
assert!(ok, "/dev/null redirect must succeed: {output}");
let outside_file = outside.path().join("gate_sandbox_wrap_marker");
for probe in [
format!("echo x > '{}'", outside_file.display()),
format!("echo x > '{}'", temp_root_marker.display()),
] {
let (ok, output) =
run_shell_command_sandboxed(repo.path(), &probe, &env, &sandbox).await;
assert!(
!ok,
"write outside the allowlist must fail under enforcement: {probe}\n{output}"
);
}
assert!(
!outside_file.exists(),
"denied write must not create the file"
);
assert!(
!temp_root_marker.exists(),
"denied temp-root write must not create the marker"
);
let (ok, _) = run_shell_command_sandboxed(
repo.path(),
&format!(
"echo tampered >> '{}'",
mission.join("events.jsonl").display()
),
&env,
&sandbox,
)
.await;
if cfg!(target_os = "macos") {
assert!(!ok, "events.jsonl append must be denied under Seatbelt");
}
assert_eq!(
std::fs::read_to_string(mission.join("events.jsonl")).unwrap(),
"{\"seq\":1}\n",
"the audit log must be untouched by the sandboxed gate"
);
let (ok, _) = run_shell_command_sandboxed(
repo.path(),
&format!(
"echo x > '{}'",
mission.join("control/approve.json").display()
),
&env,
&sandbox,
)
.await;
if cfg!(target_os = "macos") {
assert!(!ok, "control/ writes must be denied under Seatbelt");
}
assert!(
std::fs::read_dir(mission.join("control"))
.unwrap()
.next()
.is_none(),
"the control inbox must stay empty on the host"
);
for name in ["serve.token", "serve.read.token", "config.json"] {
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("test -s '{}'", kranz_dir.join(name).display()),
&env,
&sandbox,
)
.await;
assert!(
!ok,
"a read of denied authority path .kranz/{name} must fail: {output}"
);
}
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("test -s '{}'", repo.path().join("public.txt").display()),
&env,
&sandbox,
)
.await;
assert!(ok, "ordinary repo reads must keep working: {output}");
let off_env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
for probe in [
format!("echo x > '{}'", outside_file.display()),
format!("echo x > '{}'", temp_root_marker.display()),
format!(
"echo tampered >> '{}'",
mission.join("events.jsonl").display()
),
format!("test -s '{}'", kranz_dir.join("serve.token").display()),
] {
let (ok, output) =
run_shell_command_sandboxed(repo.path(), &probe, &off_env, &GateSandbox::Disabled)
.await;
assert!(
ok,
"with enforce == off the probe succeeds (today's posture): {probe}\n{output}"
);
}
std::fs::write(mission.join("events.jsonl"), "{\"seq\":1}\n").unwrap();
let _ = std::fs::remove_file(&temp_root_marker);
}
#[cfg(unix)]
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn gate_sandbox_wrap_timeout_kills_the_whole_process_tree() {
let _guard = GATE_SANDBOX_WRAP_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
if !gate_wrap_enforcement_available() {
return;
}
let (repo, mission) = gate_wrap_layout();
let scratch = tempfile::tempdir().unwrap();
let resolution = resolve_gate_sandbox(
&fs_sandbox_config(crate::types::SandboxEnforce::Fs),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
)
.unwrap();
let sandbox = resolution.sandbox;
let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
let pidfile = scratch.path().join("child.pid");
let command = format!("sleep 300 & echo $! > '{}'; wait", pidfile.display());
#[cfg(target_os = "linux")]
let namespace_file = scratch.path().join("child.pid-namespace");
#[cfg(target_os = "linux")]
let command = format!(
"readlink /proc/self/ns/pid > '{}'; {command}",
namespace_file.display()
);
let (code, output) = tokio::time::timeout(
Duration::from_secs(15),
run_shell_command_sandboxed_with_code(
repo.path(),
&command,
Duration::from_millis(500),
&env,
&sandbox,
),
)
.await
.expect("timed-out command must return promptly");
assert_eq!(code, None, "a timeout yields no exit code: {output}");
assert!(output.contains("timed out"), "got: {output}");
let pid: i32 = std::fs::read_to_string(&pidfile)
.expect("the wrapped shell wrote the background pid before the timeout")
.trim()
.parse()
.expect("pidfile contains a pid");
#[cfg(target_os = "linux")]
let namespace = std::fs::read_to_string(namespace_file).unwrap();
let child_alive = || {
#[cfg(target_os = "linux")]
{
std::fs::read_dir("/proc").unwrap().flatten().any(|entry| {
std::fs::read_link(entry.path().join("ns/pid"))
.is_ok_and(|link| link.to_string_lossy() == namespace.trim())
})
}
#[cfg(not(target_os = "linux"))]
{
(unsafe { libc::kill(pid, 0) }) == 0
}
};
let deadline = std::time::Instant::now() + Duration::from_secs(5);
while child_alive() {
assert!(
std::time::Instant::now() < deadline,
"background child {pid} survived the group kill through the sandbox wrapper"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(target_os = "macos")]
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn gate_sandbox_wrap_dogfood_supervision_allows_tree_denies_host() {
let _guard = GATE_SANDBOX_WRAP_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
if !gate_wrap_enforcement_available() {
return;
}
let (repo, mission) = gate_wrap_layout();
let scratch = tempfile::tempdir().unwrap();
let resolution = resolve_gate_sandbox(
&fs_sandbox_config(crate::types::SandboxEnforce::Fs),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
)
.unwrap();
let sandbox = resolution.sandbox;
let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
let mut host = std::process::Command::new("sleep")
.arg("300")
.spawn()
.expect("spawn host sleeper");
let host_pid = host.id();
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
"sleep 300 & child=$!; kill -0 \"$child\" && kill -TERM \"$child\"",
&env,
&sandbox,
)
.await;
assert!(
ok,
"the wrapped gate must signal its own tree (same-sandbox): {output}"
);
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("kill -0 {host_pid}"),
&env,
&sandbox,
)
.await;
assert!(
!ok,
"no host-wide signal capability under the wrap (EPERM expected): {output}"
);
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("ps -p {host_pid} -o command="),
&env,
&sandbox,
)
.await;
assert!(
!ok,
"no ps inspection under the wrap (setuid exec denied): {output}"
);
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("kill -0 {host_pid} && ps -p {host_pid} -o command="),
&env,
&GateSandbox::Disabled,
)
.await;
assert!(
ok,
"with enforce == off the host probes succeed (today's posture): {output}"
);
let _ = host.kill();
let _ = host.wait();
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "wrapped-suite proving ground — run manually or via the rust-macos-wrapped-suite CI job"]
fn gate_sandbox_wrap_dogfood_supervision_workspace_suite() {
if !gate_wrap_sandbox_exec_can_apply() {
return;
}
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("crates/engine has a repo-root ancestor")
.to_path_buf();
let payload = std::env::var("KRANZ_DOGFOOD_SUITE_CMD")
.unwrap_or_else(|_| "cargo test --workspace".to_string());
let scratch =
std::env::temp_dir().join(format!("kranz-gate-{}", uuid::Uuid::new_v4().simple()));
std::fs::create_dir_all(scratch.join("tmp")).unwrap();
let cargo_home = crate::agent_env::cache_only_cargo_home(scratch.as_path());
assert!(
cargo_home.is_dir(),
"could not create the fixture's cache-only Cargo home at {}",
cargo_home.display()
);
let (_layout_guard, mission) = gate_wrap_layout();
let resolution = resolve_gate_sandbox(
&fs_sandbox_config(crate::types::SandboxEnforce::Fs),
&repo_root,
&mission,
&scratch,
&scratch,
)
.expect("the fixture's gate sandbox resolves on a host that applied the smoke profile");
let mut env = sanitized_gate_env();
env.insert("CARGO_HOME".to_string(), cargo_home.display().to_string());
for var in ["TMPDIR", "TMP", "TEMP"] {
env.insert(var.to_string(), scratch.join("tmp").display().to_string());
}
let kranz_home = scratch.join("kranz-home");
std::fs::create_dir_all(&kranz_home).unwrap();
env.insert("KRANZ_HOME".to_string(), kranz_home.display().to_string());
let suite_log = scratch.join("tmp").join("dogfood-suite.log");
let command = format!("{payload} > '{}' 2>&1", suite_log.display());
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("fixture runtime");
let start = std::time::Instant::now();
let (code, output) = runtime.block_on(run_shell_command_sandboxed_with_code(
&repo_root,
&command,
Duration::from_secs(3600),
&env,
&resolution.sandbox,
));
let elapsed = start.elapsed();
let log = std::fs::read_to_string(&suite_log)
.unwrap_or_else(|_| format!("<no suite log captured; runner tail: {output}>"));
let skip_count = log
.matches("SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood)")
.count();
println!(
"dogfood wrapped suite `{payload}`: exit={code:?} elapsed={elapsed:.1?} \
skip-under-wrap markers={skip_count} log={}",
suite_log.display()
);
for line in log.lines().filter(|l| l.contains("test result:")) {
println!(" {line}");
}
let tail: Vec<&str> = log.lines().collect();
let tail = &tail[tail.len().saturating_sub(40)..];
assert_eq!(
code,
Some(0),
"cargo test --workspace must run GREEN as a wrapped contract command \
(skip-under-wrap markers seen: {skip_count})\n--- suite log tail ---\n{}",
tail.join("\n")
);
let _ = std::fs::remove_dir_all(&scratch);
}
#[cfg(unix)]
#[test]
fn gate_sandbox_wrap_merge_gate_reads_git_identity_from_read_only_home() {
let _wrap_guard = GATE_SANDBOX_WRAP_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
if !gate_wrap_enforcement_available() {
return;
}
let (repo, mission) = gate_wrap_layout();
let fake_home = tempfile::tempdir().unwrap();
std::fs::write(
fake_home.path().join(".gitconfig"),
"[user]\n\tname = Gate Wrap Test\n",
)
.unwrap();
let _home = crate::agent_env::EnvTestGuard::engage(&[(
"HOME",
fake_home.path().to_str().expect("utf-8 temp path"),
)]);
let policy = MergeGatePolicy {
sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
mission_dir: mission.clone(),
};
assert!(policy.enforces_on_this_host());
let (ok, output) = run_bounded_gate_command_sandboxed(
repo.path(),
"test \"$(git config user.name)\" = 'Gate Wrap Test' \
&& ! touch \"$HOME/gate_sandbox_wrap_marker\" \
&& case \"$TMPDIR\" in *kranz-gate-*/tmp) true ;; *) false ;; esac \
&& case \"$CARGO_HOME\" in *kranz-gate-*/.cargo-cache-only-*) true ;; *) false ;; esac",
&policy,
);
assert!(
ok,
"git identity must read from the read-only HOME, $HOME writes must be \
denied, and TMPDIR/CARGO_HOME must sit in the per-run scratch: {output}"
);
assert!(
!fake_home.path().join("gate_sandbox_wrap_marker").exists(),
"the denied $HOME write must not have created the marker"
);
let (ok, output) = run_bounded_gate_command_sandboxed(
repo.path(),
"touch \"$HOME/gate_sandbox_wrap_off_marker\"",
&MergeGatePolicy::disabled(),
);
assert!(
ok,
"with enforce == off the $HOME write succeeds (today's posture): {output}"
);
let _ = std::fs::remove_file(fake_home.path().join("gate_sandbox_wrap_off_marker"));
}
#[cfg(unix)]
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn gate_sandbox_wrap_cache_write_deny_reads_cache_but_cannot_write() {
let _guard = GATE_SANDBOX_WRAP_LOCK
.lock()
.unwrap_or_else(|p| p.into_inner());
if !gate_wrap_enforcement_available() {
return;
}
let (repo, mission) = gate_wrap_layout();
let scratch = tempfile::tempdir().unwrap();
let cargo = tempfile::tempdir().unwrap();
std::fs::create_dir_all(cargo.path().join("registry")).unwrap();
std::fs::write(cargo.path().join("registry/cache-marker"), "cached").unwrap();
let _cargo = crate::agent_env::EnvTestGuard::engage(&[(
"CARGO_HOME",
cargo.path().to_str().expect("utf-8 temp path"),
)]);
let resolution = resolve_gate_sandbox(
&fs_sandbox_config(crate::types::SandboxEnforce::Fs),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
)
.unwrap();
let sandbox = resolution.sandbox;
let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!(
"test -s '{}'",
cargo.path().join("registry/cache-marker").display()
),
&env,
&sandbox,
)
.await;
assert!(ok, "the wrapped gate must read the shared cache: {output}");
let poison = cargo.path().join("registry/poisoned-crate");
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("echo x > '{}'", poison.display()),
&env,
&sandbox,
)
.await;
assert!(
!ok,
"a write to the operator's real cargo cache must fail under enforcement: {output}"
);
assert!(
!poison.exists(),
"the denied cache write must not create the file"
);
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("echo x > '{}'", poison.display()),
&env,
&GateSandbox::Disabled,
)
.await;
assert!(
ok,
"with enforce == off the cache write succeeds (documented trade): {output}"
);
let _ = std::fs::remove_file(&poison);
}
#[cfg(unix)]
#[test]
fn gate_sandbox_wrap_disabled_merge_policy_matches_todays_gate_shape() {
let dir = tempfile::tempdir().unwrap();
let temp = std::env::temp_dir().display().to_string();
let temp = temp.trim_end_matches('/');
let ambient_tmpdir = std::env::var("TMPDIR").unwrap_or_else(|_| "unset".to_string());
let command = format!(
"test \"$(dirname \"$CARGO_HOME\")\" = '{temp}' \
&& test \"${{TMPDIR:-unset}}\" = '{ambient_tmpdir}'"
);
let (ok, output) =
run_bounded_gate_command_sandboxed(dir.path(), &command, &MergeGatePolicy::disabled());
assert!(
ok,
"the off path must keep today's gate shape (cache-only home under the \
system temp root, ambient TMPDIR): {output}"
);
}
#[test]
fn gate_sandbox_wrap_fs_net_forces_cargo_offline() {
let base: HashMap<String, String> = HashMap::new();
let fs_net = GateSandbox::Seatbelt {
enforce: crate::types::SandboxEnforce::FsNet,
profile_path: std::path::PathBuf::from("/nonexistent"),
};
let env = gate_env_for_sandbox(&base, &fs_net);
assert_eq!(
env.get("CARGO_NET_OFFLINE").map(String::as_str),
Some("true"),
"fs+net gates run cargo offline-by-cache"
);
let fs = GateSandbox::Seatbelt {
enforce: crate::types::SandboxEnforce::Fs,
profile_path: std::path::PathBuf::from("/nonexistent"),
};
assert!(
!gate_env_for_sandbox(&base, &fs).contains_key("CARGO_NET_OFFLINE"),
"fs keeps full egress — no offline flag"
);
assert!(
!gate_env_for_sandbox(&base, &GateSandbox::Disabled).contains_key("CARGO_NET_OFFLINE"),
"the off path is byte-identical — no offline flag"
);
let container_fs_net = GateSandbox::Container {
inputs: Box::new(crate::sandbox::SandboxInputs {
enforce: crate::types::SandboxEnforce::FsNet,
session_cwd: std::path::PathBuf::from("/nonexistent"),
mission_dir: std::path::PathBuf::from("/nonexistent"),
tmpdir: std::path::PathBuf::from("/nonexistent"),
extra_write: Vec::new(),
egress: Vec::new(),
validator_read_deny_roots: Vec::new(),
}),
spec: crate::sandbox_container::ContainerSpec {
runtime: crate::sandbox_container::ContainerRuntime::Docker,
image: crate::sandbox_container::DEFAULT_IMAGE.to_string(),
network: None,
name: None,
},
};
assert_eq!(
gate_env_for_sandbox(&base, &container_fs_net)
.get("CARGO_NET_OFFLINE")
.map(String::as_str),
Some("true"),
"fs+net container gates run cargo offline-by-cache"
);
assert!(
!base.contains_key("CARGO_NET_OFFLINE"),
"the caller's env map is never mutated"
);
}
#[test]
fn container_gate_wrap_shell_shape_names_the_container_and_teardown() {
let inputs = crate::sandbox::SandboxInputs {
enforce: crate::types::SandboxEnforce::Fs,
session_cwd: std::path::PathBuf::from("/nonexistent"),
mission_dir: std::path::PathBuf::from("/nonexistent-m"),
tmpdir: std::path::PathBuf::from("/nonexistent-s"),
extra_write: Vec::new(),
egress: Vec::new(),
validator_read_deny_roots: Vec::new(),
};
let container = GateSandbox::Container {
inputs: Box::new(inputs),
spec: crate::sandbox_container::ContainerSpec {
runtime: crate::sandbox_container::ContainerRuntime::Docker,
image: crate::sandbox_container::DEFAULT_IMAGE.to_string(),
network: None,
name: None,
},
};
let env: HashMap<String, String> = [("KRANZ_BASE_SHA".to_string(), "deadbeef".to_string())]
.into_iter()
.collect();
let one = container.wrap_shell("echo hi", &env).unwrap();
let two = container.wrap_shell("echo hi", &env).unwrap();
assert_eq!(one.program, std::path::PathBuf::from("docker"));
let name_of = |wrapped: &WrappedCommand| {
wrapped
.args
.windows(2)
.find(|w| w[0] == "--name")
.map(|w| w[1].clone())
.expect("the container argv must name its container")
};
let (name_one, name_two) = (name_of(&one), name_of(&two));
assert!(
name_one.starts_with("kranz-gate-"),
"gate containers carry the kranz-gate- prefix: {name_one}"
);
assert_ne!(
name_one, name_two,
"container names are per command, never per resolve — parallel \
gate commands from one resolution must not collide"
);
assert_eq!(
one.timeout_teardown,
Some((
std::path::PathBuf::from("docker"),
vec!["rm".to_string(), "-f".to_string(), name_one]
)),
"the teardown force-removes exactly this command's container"
);
assert!(
one.args.ends_with(&[
crate::sandbox_container::DEFAULT_IMAGE.to_string(),
"sh".to_string(),
"-c".to_string(),
"echo hi".to_string()
]),
"image then sh -c payload: {:?}",
one.args
);
let seatbelt = GateSandbox::Seatbelt {
enforce: crate::types::SandboxEnforce::Fs,
profile_path: std::path::PathBuf::from("/nonexistent"),
};
assert!(seatbelt
.wrap_shell("true", &env)
.unwrap()
.timeout_teardown
.is_none());
assert!(GateSandbox::Disabled
.wrap_shell("true", &env)
.unwrap()
.timeout_teardown
.is_none());
}
#[cfg(unix)]
#[tokio::test]
#[allow(clippy::await_holding_lock)]
async fn container_gate_wrap_runs_contract_command_inside_the_container() {
let _env = crate::agent_env::EnvTestGuard::engage(&[]);
if !crate::sandbox_container::host_supports_container_contract() {
crate::test_capability::skip(
crate::test_capability::capability::CONTAINER,
&crate::sandbox_container::container_contract_skip_detail(),
);
return;
}
if crate::sandbox_container::detect().is_none() {
eprintln!(
"no container runtime (docker/podman/nerdctl/container) on PATH; skipping \
container gate wrap fixture"
);
return;
}
let (repo, mission) = gate_wrap_layout_with_repo(
tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap(),
);
let kranz_dir = repo.path().join(".kranz");
let scratch = tempfile::tempdir_in(std::env::current_dir().unwrap()).unwrap();
let outside = tempfile::tempdir().unwrap();
let container_cfg = crate::types::SandboxConfig {
enforce: crate::types::SandboxEnforce::Fs,
provider: crate::types::SandboxProvider::Container,
image: None,
extra_write: vec![],
egress: vec![],
};
let resolution = resolve_gate_sandbox(
&container_cfg,
repo.path(),
&mission,
scratch.path(),
scratch.path(),
)
.unwrap();
assert!(resolution.note.is_none());
let sandbox = resolution.sandbox;
assert!(
matches!(sandbox, GateSandbox::Container { .. }),
"provider:container with a runtime must resolve to the container wrap"
);
let env = crate::agent_env::contract_command_env(scratch.path(), Some("deadbeef"), &[]);
let ok_file = repo.path().join("container_gate_wrap_ok.txt");
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!(
"echo ok > '{}' && echo scratch > \"$HOME/container_gate_wrap_scratch.txt\" \
&& test \"$KRANZ_BASE_SHA\" = deadbeef",
ok_file.display()
),
&env,
&sandbox,
)
.await;
assert!(
ok && ok_file.exists()
&& scratch
.path()
.join("container_gate_wrap_scratch.txt")
.exists(),
"writes inside the mount set and the forwarded env must work: {output}"
);
let outside_file = outside.path().join("container_gate_wrap_marker");
for probe in [
"echo nope > /etc/container_gate_wrap_nope".to_string(),
format!("echo x > '{}'", outside_file.display()),
] {
let (ok, output) =
run_shell_command_sandboxed(repo.path(), &probe, &env, &sandbox).await;
assert!(
!ok,
"write outside the mount set must fail inside the container: {probe}\n{output}"
);
}
assert!(
!outside_file.exists(),
"the denied write must not create the host file"
);
let (ok, _) = run_shell_command_sandboxed(
repo.path(),
&format!(
"echo tampered >> '{}'",
mission.join("events.jsonl").display()
),
&env,
&sandbox,
)
.await;
assert!(!ok, "the events.jsonl append must fail on the ro mount");
assert_eq!(
std::fs::read_to_string(mission.join("events.jsonl")).unwrap(),
"{\"seq\":1}\n",
"the audit log must be untouched by the container gate"
);
for name in ["serve.token", "serve.read.token", "config.json"] {
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("test -s '{}'", kranz_dir.join(name).display()),
&env,
&sandbox,
)
.await;
assert!(
!ok,
".kranz/{name} must be /dev/null-masked inside the container: {output}"
);
}
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!("test -s '{}'", repo.path().join("public.txt").display()),
&env,
&sandbox,
)
.await;
assert!(ok, "ordinary repo reads must keep working: {output}");
let (ok, output) = run_shell_command_sandboxed(
repo.path(),
&format!(
"echo x > '{}' && test -s '{}'",
outside_file.display(),
kranz_dir.join("serve.token").display()
),
&env,
&GateSandbox::Disabled,
)
.await;
assert!(
ok,
"with enforce == off the probes succeed (today's posture): {output}"
);
let _ = std::fs::remove_file(&outside_file);
}
#[cfg(target_os = "linux")]
#[tokio::test]
#[ignore = "live bubblewrap receipt — run by the protected Linux CI leg"]
#[allow(clippy::await_holding_lock)]
async fn linux_bubblewrap_hostile_live_receipt() {
let _guard = GATE_SANDBOX_WRAP_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
assert!(
gate_wrap_bwrap_can_apply(),
"the live-proof host must provide a working bubblewrap boundary"
);
let primary = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("crates/engine has a repository root");
let git = |args: &[&str]| {
let output = std::process::Command::new("git")
.args(args)
.current_dir(primary)
.output()
.expect("git must run on the live-proof checkout");
assert!(output.status.success(), "git {args:?} failed");
String::from_utf8_lossy(&output.stdout).trim().to_string()
};
let head_before = git(&["rev-parse", "HEAD"]);
let status_before = git(&["status", "--porcelain", "--untracked-files=no"]);
assert!(
status_before.is_empty(),
"the live proof requires a clean tracked primary checkout: {status_before}"
);
let (repo, mission) = gate_wrap_layout();
let scratch = tempfile::tempdir().expect("private proof scratch");
let outside = tempfile::tempdir().expect("sibling canary root");
let resolution = resolve_gate_sandbox(
&fs_sandbox_config(crate::types::SandboxEnforce::FsNet),
repo.path(),
&mission,
scratch.path(),
scratch.path(),
)
.expect("fs+net must resolve to bubblewrap on the proof host");
assert!(resolution.note.is_none());
assert!(matches!(resolution.sandbox, GateSandbox::Bubblewrap { .. }));
let sandbox = resolution.sandbox;
let env = crate::agent_env::contract_command_env(scratch.path(), None, &[]);
let canary = outside.path().join("kranz-linux-hostile-canary");
let (write_ok, write_output) = run_shell_command_sandboxed(
repo.path(),
&format!("printf escaped > '{}'", canary.display()),
&env,
&sandbox,
)
.await;
assert!(
!write_ok,
"sibling write escaped bubblewrap: {write_output}"
);
assert!(
!canary.exists(),
"the denied sibling canary must stay absent"
);
let listener =
std::net::TcpListener::bind("127.0.0.1:0").expect("host loopback proof listener");
listener
.set_nonblocking(true)
.expect("nonblocking proof listener");
let port = listener.local_addr().expect("listener address").port();
let (stop_tx, stop_rx) = std::sync::mpsc::channel();
let acceptor = std::thread::spawn(move || {
let started = std::time::Instant::now();
let mut accepted = 0usize;
while started.elapsed() < Duration::from_secs(10) {
match listener.accept() {
Ok(_) => accepted += 1,
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Err(error) => panic!("proof listener failed: {error}"),
}
if stop_rx.try_recv().is_ok() {
break;
}
std::thread::sleep(Duration::from_millis(10));
}
accepted
});
let connect = format!(
"python3 -c 'import socket; socket.create_connection((\"127.0.0.1\", {port}), 2).close()'"
);
let (off_connect_ok, off_connect_output) =
run_shell_command_sandboxed(repo.path(), &connect, &env, &GateSandbox::Disabled).await;
assert!(
off_connect_ok,
"the network anti-vacuity probe must reach the host listener without enforcement: {off_connect_output}"
);
let (wrapped_connect_ok, wrapped_connect_output) =
run_shell_command_sandboxed(repo.path(), &connect, &env, &sandbox).await;
assert!(
!wrapped_connect_ok,
"the fs+net namespace reached the host listener: {wrapped_connect_output}"
);
let _ = stop_tx.send(());
assert_eq!(
acceptor.join().expect("proof listener thread"),
1,
"only the unwrapped anti-vacuity connection may reach the host"
);
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)\"";
for (label, posture) in [
("unwrapped warm-up", &GateSandbox::Disabled),
("bubblewrap warm-up", &sandbox),
] {
let (ok, output) = run_shell_command_sandboxed(repo.path(), gate, &env, posture).await;
assert!(
ok && output.contains("kranz-linux-node-ok"),
"{label} failed: {output}"
);
}
let mut off_samples_ms = Vec::with_capacity(7);
let mut wrapped_samples_ms = Vec::with_capacity(7);
for index in 0..7 {
for wrapped in [index % 2 == 1, index % 2 == 0] {
let started = std::time::Instant::now();
let posture = if wrapped {
&sandbox
} else {
&GateSandbox::Disabled
};
let (ok, output) =
run_shell_command_sandboxed(repo.path(), gate, &env, posture).await;
assert!(
ok && output.contains("kranz-linux-node-ok"),
"timed gate failed: {output}"
);
let elapsed = started.elapsed().as_secs_f64() * 1_000.0;
if wrapped {
wrapped_samples_ms.push(elapsed);
} else {
off_samples_ms.push(elapsed);
}
}
}
let median = |samples: &[f64]| {
let mut sorted = samples.to_vec();
sorted.sort_by(f64::total_cmp);
sorted[sorted.len() / 2]
};
let off_median_ms = median(&off_samples_ms);
let wrapped_median_ms = median(&wrapped_samples_ms);
let overhead_percent = (wrapped_median_ms / off_median_ms - 1.0) * 100.0;
let head_after = git(&["rev-parse", "HEAD"]);
let status_after = git(&["status", "--porcelain", "--untracked-files=no"]);
assert_eq!(head_after, head_before, "the primary checkout HEAD moved");
assert_eq!(
status_after, status_before,
"the primary checkout's tracked bytes changed"
);
let host = |program: &str, args: &[&str]| {
std::process::Command::new(program)
.args(args)
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_string())
.unwrap_or_else(|| "unavailable".to_string())
};
let receipt = serde_json::json!({
"hostOs": std::env::consts::OS,
"hostArch": std::env::consts::ARCH,
"kernel": host("uname", &["-sr"]),
"bubblewrap": host("bwrap", &["--version"]),
"node": host("node", &["--version"]),
"enforcement": "fs+net",
"provider": "process/bubblewrap",
"siblingWriteDenied": !write_ok && !canary.exists(),
"networkDenied": !wrapped_connect_ok,
"networkAntiVacuityPassed": off_connect_ok,
"normalGatePassed": true,
"primaryCheckoutUntouched": head_after == head_before && status_after == status_before,
"repetitions": 7,
"offSamplesMs": off_samples_ms,
"bubblewrapSamplesMs": wrapped_samples_ms,
"offMedianMs": off_median_ms,
"bubblewrapMedianMs": wrapped_median_ms,
"overheadPercent": overhead_percent,
"overheadTargetPercent": 10.0,
"withinTarget": overhead_percent <= 10.0,
"head": head_before,
});
println!("KRANZ_LINUX_LIVE_RECEIPT={receipt}");
}
#[cfg(target_os = "macos")]
#[test]
#[ignore = "measurement harness — run manually, never a CI gate"]
fn gate_sandbox_wrap_measure() {
if !gate_wrap_sandbox_exec_can_apply() {
return;
}
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(std::path::Path::parent)
.expect("crates/engine has a repo-root ancestor")
.to_path_buf();
let payload = std::env::var("KRANZ_GATE_MEASURE_CMD").unwrap_or_else(|_| {
"cargo test -p kranz-engine --lib -- \
--skip timeout_kills \
--skip kills_a_hung_binary \
--skip approval_lint_runner_times_out_slow_command \
--skip identity_token \
--skip pid_reuse \
--skip pool_checkpoint_hooks_disabled_against_planted_fsmonitor_and_hook \
--skip sandbox_preflight_probes_disposable_worktree_not_primary"
.to_string()
});
let reps: u32 = std::env::var("KRANZ_GATE_MEASURE_REPS")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(3);
let (_layout_guard, mission) = gate_wrap_layout();
let policy = MergeGatePolicy {
sandbox: fs_sandbox_config(crate::types::SandboxEnforce::Fs),
mission_dir: mission,
};
let time = |label: &str, command: &str, wrapped: bool, reps: u32| {
let mut samples = Vec::new();
for _ in 0..reps {
let start = std::time::Instant::now();
let (ok, output) = if wrapped {
run_bounded_gate_command_sandboxed(&repo_root, command, &policy)
} else {
run_bounded_gate_command(&repo_root, command)
};
let elapsed = start.elapsed();
assert!(ok, "{label} run failed: {output}");
samples.push(elapsed);
}
let total: Duration = samples.iter().sum();
let mean = total / samples.len() as u32;
let min = samples.iter().min().unwrap();
println!("{label}: reps={reps} mean={mean:.3?} min={min:.3?} all={samples:?}");
mean
};
let micro_unwrapped = time("micro unwrapped (true)", "true", false, 50);
let micro_wrapped = time("micro wrapped (true)", "true", true, 50);
println!(
"micro delta per spawn: {:?} ({:+.1}%)",
micro_wrapped.saturating_sub(micro_unwrapped),
(micro_wrapped.as_secs_f64() / micro_unwrapped.as_secs_f64() - 1.0) * 100.0
);
let gate_unwrapped = time("gate unwrapped", &payload, false, reps);
let gate_wrapped = time("gate wrapped ", &payload, true, reps);
println!(
"gate delta: {:?} ({:+.2}%) on `{}`",
gate_wrapped.saturating_sub(gate_unwrapped),
(gate_wrapped.as_secs_f64() / gate_unwrapped.as_secs_f64() - 1.0) * 100.0,
payload
);
}
}