use crate::command_exec::{is_git_repo, tail_chars};
use crate::contract_sweep;
use crate::orchestrator::MissionEngine;
use crate::paths::MissionPaths;
use crate::types::*;
use std::net::ToSocketAddrs;
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreflightIssue {
pub severity: &'static str,
pub message: String,
}
pub const PREFLIGHT_CLEAR_SUMMARY: &str = "preflight: clear — no advisory issues recorded";
impl MissionEngine {
pub fn preflight(&self) -> Vec<PreflightIssue> {
let mut issues = Vec::new();
if !is_git_repo(self.paths.repo_root.as_path()) {
issues.push(PreflightIssue {
severity: "error",
message: format!("{} is not a git repository", self.paths.repo_root.display()),
});
}
if !kranz_dir_is_writable(&self.paths) {
issues.push(PreflightIssue {
severity: "error",
message: ".kranz directory is not writable".to_string(),
});
}
for role in [
Role::Orchestrator,
Role::Worker,
Role::ValidatorScrutiny,
Role::ValidatorFunctional,
] {
let role_key = role_config_key(role);
match self.state.config.backend_kind(role) {
BackendKind::Codex => {
if let Err(err) = crate::backend_codex::discover_codex_binary(None) {
issues.push(PreflightIssue {
severity: "warn",
message: format!(
"{role_key}.backend is \"codex\" but no codex binary was found \
({err}); that role will fall back to the claude backend"
),
});
}
}
BackendKind::Droid => {
if let Err(err) = crate::backend_droid::discover_droid_binary(None) {
issues.push(PreflightIssue {
severity: "warn",
message: format!(
"{role_key}.backend is \"droid\" but no droid binary was found \
({err}); that role will fall back to the claude backend"
),
});
}
}
BackendKind::Kimi => {
if let Err(err) = crate::backend_kimi::discover_kimi_binary(None) {
issues.push(PreflightIssue {
severity: "warn",
message: format!(
"{role_key}.backend is \"kimi\" but no kimi binary was found \
({err}); that role will fall back to the claude backend"
),
});
}
}
BackendKind::Cursor => {
if let Err(err) = crate::backend_cursor::discover_cursor_binary(None) {
issues.push(PreflightIssue {
severity: "warn",
message: format!(
"{role_key}.backend is \"cursor\" but no cursor agent binary was \
found ({err}); that role will fall back to the claude backend"
),
});
}
}
BackendKind::Claude => {}
BackendKind::Acp => {
}
BackendKind::Local => {
if let Some(base_url) = self.state.config.role(role).base_url.as_deref() {
if !probe_local_endpoint_reachable(base_url) {
issues.push(PreflightIssue {
severity: "warn",
message: format!(
"{role_key}.backend is \"local\" but {base_url} did not \
respond to a reachability probe; that role's HTTP calls \
may fail"
),
});
}
}
}
}
}
let mut probed: std::collections::HashSet<String> = std::collections::HashSet::new();
for assertion in &self.state.mission.validation_contract {
let command = match assertion.check {
AssertionCheck::Command => assertion.command.as_deref(),
AssertionCheck::PtyScript => {
assertion.pty_script.as_ref().map(|s| s.command.as_str())
}
AssertionCheck::AgentJudgement => None,
};
let Some(command) = command else {
continue;
};
let Some(program) = leading_program(command) else {
continue;
};
if !probed.insert(program.clone()) {
continue; }
if !program_resolves(&program) {
let kind = if assertion.check == AssertionCheck::PtyScript {
"pty-script"
} else {
"command"
};
issues.push(PreflightIssue {
severity: "warn",
message: format!(
"{kind} assertion [{}] uses '{program}', which was not found on PATH",
assertion.id
),
});
}
if assertion.check == AssertionCheck::Command
&& !contract_sweep::cargo_test_has_anti_vacuity(command)
{
issues.push(PreflightIssue {
severity: "warn",
message: format!(
"command assertion [{}] runs `cargo test` without anti-vacuity \
(`ok. [1-9]`); a zero-test filter would pass vacuously",
assertion.id
),
});
}
}
if self.state.config.worker.sandbox.enforce != crate::types::SandboxEnforce::Off {
let mission_dir = self.paths.mission_dir();
let (_resolved, warn) = crate::sandbox::resolve_for_session(
&self.state.config.worker.sandbox,
self.paths.repo_root.as_path(),
&mission_dir,
);
if let Some(warn) = warn {
issues.push(PreflightIssue {
severity: "warn",
message: warn,
});
}
}
if matches!(
self.state.config.worker.sandbox.enforce,
crate::types::SandboxEnforce::Fs | crate::types::SandboxEnforce::FsNet
) && cfg!(target_os = "macos")
{
issues.extend(self.sandbox_command_preflight());
}
issues
}
fn sandbox_command_preflight(&self) -> Vec<PreflightIssue> {
let mission_dir = self.paths.mission_dir();
let worktree_path = self.paths.runs_dir().join("preflight-worktree");
let (resolved, _warn) = crate::sandbox::resolve_for_session(
&self.state.config.worker.sandbox,
&worktree_path,
&mission_dir,
);
let Some(resolved) = resolved else {
return Vec::new();
};
if resolved.backend != crate::sandbox::SandboxBackend::Seatbelt {
return Vec::new();
}
let base = self
.state
.mission
.base_sha
.clone()
.unwrap_or_else(|| self.state.mission.base_branch.clone());
let _worktree = match DisposableWorktree::create(&self.repo, &worktree_path, &base) {
Ok(guard) => guard,
Err(err) => {
return vec![PreflightIssue {
severity: "warn",
message: format!(
"sandbox command preflight skipped: could not create disposable \
worktree at {base}: {err}"
),
}];
}
};
let profile = crate::sandbox::generate_profile(&resolved.inputs);
let profile_path =
match crate::sandbox::write_profile_file(&self.paths.runs_dir(), &profile)
.or_else(|_| crate::sandbox::write_profile_file(&resolved.inputs.tmpdir, &profile))
{
Ok(path) => path,
Err(_) => return Vec::new(),
};
let env = crate::agent_env::contract_command_env(
&self.paths.runs_dir().join("contract-home"),
self.state.mission.base_sha.as_deref(),
&self.state.config.contract_env_passthrough,
);
const MAX_PROBES: usize = 20;
const TIMEOUT: Duration = Duration::from_secs(5);
let mut probes: Vec<(String, std::path::PathBuf, Vec<String>)> = Vec::new();
let mut probed: std::collections::HashSet<&str> = std::collections::HashSet::new();
for assertion in &self.state.mission.validation_contract {
if probes.len() >= MAX_PROBES {
break;
}
if assertion.check != AssertionCheck::Command {
continue;
}
let Some(command) = assertion.command.as_deref() else {
continue;
};
if !probed.insert(command) {
continue; }
let (program, args) = crate::backend_claude::sandbox_command(
&profile_path,
std::path::Path::new("/bin/sh"),
&["-c".to_string(), command.to_string()],
);
probes.push((assertion.id.clone(), program, args));
}
if probes.is_empty() {
return Vec::new();
}
let probe_cwd = worktree_path.clone();
let worker = std::thread::spawn(move || {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let Ok(runtime) = runtime else {
return Vec::new(); };
runtime.block_on(async move {
let mut issues = Vec::new();
for (id, program, args) in probes {
match crate::command_exec::run_bounded_argv(
&probe_cwd, &program, &args, TIMEOUT, &env,
)
.await
{
(Some(0), _) | (None, _) => {}
(Some(_), output) => {
let tail = tail_chars(&output, 200);
issues.push(PreflightIssue {
severity: "warn",
message: format!(
"command assertion [{id}] fails under the fs sandbox \
profile: {tail}"
),
});
}
}
}
issues
})
});
worker.join().unwrap_or_default()
}
}
struct DisposableWorktree {
repo: crate::git_ops::GitRepo,
path: std::path::PathBuf,
}
impl DisposableWorktree {
fn create(
repo: &crate::git_ops::GitRepo,
path: &std::path::Path,
base: &str,
) -> crate::error::Result<Self> {
let _ = repo.remove_worktree(path);
let _ = std::fs::remove_dir_all(path);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
crate::error::EngineError::Git(format!("create {}: {e}", parent.display()))
})?;
}
repo.add_detached_worktree(path, base)?;
Ok(Self {
repo: repo.clone(),
path: path.to_path_buf(),
})
}
}
impl Drop for DisposableWorktree {
fn drop(&mut self) {
let _ = self.repo.remove_worktree(&self.path);
let _ = std::fs::remove_dir_all(&self.path);
let _ = self.repo.prune_worktrees();
}
}
fn role_config_key(role: Role) -> &'static str {
match role {
Role::Orchestrator => "orchestrator",
Role::Worker => "worker",
Role::ValidatorScrutiny => "validatorScrutiny",
Role::ValidatorFunctional => "validatorFunctional",
}
}
fn leading_program(command: &str) -> Option<String> {
let mut token = None;
for tok in command.split_whitespace() {
if is_env_assignment(tok) {
continue;
}
token = Some(tok);
break;
}
let token = token?;
if token.is_empty() {
return None;
}
Some(token.to_string())
}
fn is_env_assignment(token: &str) -> bool {
match token.split_once('=') {
Some((name, _)) if !name.is_empty() => {
let mut chars = name.chars();
chars
.next()
.is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
_ => false,
}
}
fn program_resolves(program: &str) -> bool {
const BUILTINS: &[&str] = &[
"cd", ":", "true", "false", "echo", "test", "[", "set", "export", "unset", "exit",
];
#[cfg(windows)]
const CMD_BUILTINS: &[&str] = &[
"if", "for", "call", "goto", "rem", "pushd", "popd", "ver", "type", "del", "copy", "move",
"md", "mkdir", "rd", "rmdir",
];
if BUILTINS.contains(&program) {
return true;
}
#[cfg(windows)]
{
let folded = program.to_ascii_lowercase();
if BUILTINS.contains(&folded.as_str()) || CMD_BUILTINS.contains(&folded.as_str()) {
return true;
}
}
if program.contains('/') || program.contains('\\') {
return path_is_executable(std::path::Path::new(program));
}
let Some(path) = std::env::var_os("PATH") else {
return true;
};
for dir in std::env::split_paths(&path) {
if dir.as_os_str().is_empty() {
continue;
}
if path_is_executable(&dir.join(program)) {
return true;
}
#[cfg(windows)]
for ext in ["exe", "bat", "cmd", "com"] {
if path_is_executable(&dir.join(format!("{program}.{ext}"))) {
return true;
}
}
}
false
}
fn path_is_executable(path: &std::path::Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
fn probe_local_endpoint_reachable(base_url: &str) -> bool {
let Ok(url) = reqwest::Url::parse(base_url) else {
return true; };
let (Some(host), Some(port)) = (url.host_str(), url.port_or_known_default()) else {
return true;
};
let addr = match (host, port).to_socket_addrs() {
Ok(mut addrs) => addrs.next(),
Err(_) => None,
};
let Some(addr) = addr else {
return true; };
std::net::TcpStream::connect_timeout(&addr, Duration::from_millis(1500)).is_ok()
}
fn kranz_dir_is_writable(paths: &MissionPaths) -> bool {
let dir = paths.kranz_dir();
if std::fs::create_dir_all(&dir).is_err() {
return false;
}
let probe = dir.join(format!(".preflight-{}", uuid::Uuid::new_v4().simple()));
match std::fs::write(&probe, b"") {
Ok(()) => {
let _ = std::fs::remove_file(&probe);
true
}
Err(_) => false,
}
}
#[cfg(test)]
pub(crate) static DROID_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[cfg(test)]
pub(crate) struct DroidEnvGuard {
prev_bin: Option<std::ffi::OsString>,
_lock: std::sync::MutexGuard<'static, ()>,
}
#[cfg(test)]
impl DroidEnvGuard {
pub(crate) fn engage() -> Self {
let lock = DROID_ENV_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let prev_bin = std::env::var_os("KRANZ_DROID_BIN");
std::env::set_var(
"KRANZ_DROID_BIN",
"/nonexistent/kranz-test-droid-binary-absent",
);
DroidEnvGuard {
prev_bin,
_lock: lock,
}
}
}
#[cfg(test)]
impl Drop for DroidEnvGuard {
fn drop(&mut self) {
match self.prev_bin.take() {
Some(v) => std::env::set_var("KRANZ_DROID_BIN", v),
None => std::env::remove_var("KRANZ_DROID_BIN"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::backend::AgentBackend;
use std::sync::Arc;
#[test]
fn droid_preflight_warns_when_binary_absent() {
let dir = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
let _ = std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["config", "user.name", "test"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&root)
.output();
std::fs::write(root.join("README.md"), "seed\n").unwrap();
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "seed"])
.current_dir(&root)
.output();
let mut cfg = MissionConfig::default();
cfg.validator_scrutiny.backend = Some("droid".to_string());
let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
let engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
let env_guard = DroidEnvGuard::engage();
let issues = engine.preflight();
drop(env_guard);
assert!(
issues
.iter()
.any(|i| i.severity == "warn" && i.message.contains("droid")),
"expected a droid preflight warning, got {issues:?}"
);
}
#[test]
fn local_preflight_warns_when_base_url_unreachable() {
let dir = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
let _ = std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["config", "user.name", "test"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&root)
.output();
std::fs::write(root.join("README.md"), "seed\n").unwrap();
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "seed"])
.current_dir(&root)
.output();
let mut cfg = MissionConfig::default();
cfg.worker.backend = Some("local".to_string());
cfg.worker.base_url = Some("http://127.0.0.1:0/v1".to_string());
cfg.worker.context_budget = Some(8192);
cfg.allow_below_default_worker_model = true;
let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
let engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
let issues = engine.preflight();
assert!(
issues
.iter()
.any(|i| i.severity == "warn" && i.message.contains("127.0.0.1:0")),
"expected a local preflight warning, got {issues:?}"
);
assert!(
!issues.iter().any(|i| i.severity == "error"),
"local reachability must never escalate to an error, got {issues:?}"
);
}
#[tokio::test]
async fn local_preflight_warns_inside_runtime_without_panic() {
let dir = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
let _ = std::process::Command::new("git")
.args(["init", "-b", "main"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["config", "user.name", "test"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&root)
.output();
std::fs::write(root.join("README.md"), "seed\n").unwrap();
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "seed"])
.current_dir(&root)
.output();
let mut cfg = MissionConfig::default();
cfg.worker.backend = Some("local".to_string());
cfg.worker.base_url = Some("http://127.0.0.1:0/v1".to_string());
cfg.worker.context_budget = Some(8192);
cfg.allow_below_default_worker_model = true;
let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
let engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
let issues = engine.preflight();
assert!(
issues
.iter()
.any(|i| i.severity == "warn" && i.message.contains("127.0.0.1:0")),
"expected a local preflight warning, got {issues:?}"
);
assert!(
!issues.iter().any(|i| i.severity == "error"),
"local reachability must never escalate to an error, got {issues:?}"
);
}
#[cfg(target_os = "macos")]
fn seeded_git_repo() -> (tempfile::TempDir, std::path::PathBuf, String) {
let dir = tempfile::tempdir().expect("tempdir");
let root = std::fs::canonicalize(dir.path()).unwrap_or_else(|_| dir.path().to_path_buf());
for args in [
vec!["init", "-b", "main"],
vec!["config", "user.name", "test"],
vec!["config", "user.email", "test@example.com"],
] {
let _ = std::process::Command::new("git")
.args(&args)
.current_dir(&root)
.output();
}
std::fs::write(root.join("README.md"), "seed\n").unwrap();
let _ = std::process::Command::new("git")
.args(["add", "-A"])
.current_dir(&root)
.output();
let _ = std::process::Command::new("git")
.args(["commit", "-m", "seed"])
.current_dir(&root)
.output();
let sha = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&root)
.output()
.expect("rev-parse HEAD");
let sha = String::from_utf8_lossy(&sha.stdout).trim().to_string();
(dir, root, sha)
}
#[cfg(target_os = "macos")]
fn command_assertion(id: &str, command: &str) -> Assertion {
Assertion {
id: id.to_string(),
statement: "the check passes".to_string(),
check: AssertionCheck::Command,
command: Some(command.to_string()),
negative_control: None,
pty_script: None,
}
}
#[cfg(target_os = "macos")]
fn git_status_porcelain(root: &std::path::Path) -> String {
let out = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(root)
.output()
.expect("git status");
String::from_utf8_lossy(&out.stdout).into_owned()
}
#[cfg(target_os = "macos")]
fn sandbox_exec_available() -> 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!(
"SKIP-UNDER-WRAP (gate-sandbox-supervision-dogfood): \
sandbox-exec cannot apply a smoke profile here (nested apply is denied \
inside the gate sandbox wrap); skipping: {}",
String::from_utf8_lossy(&output.stderr)
);
false
}
Err(e) => {
eprintln!("sandbox-exec smoke probe failed; skipping: {e}");
false
}
}
}
#[cfg(target_os = "macos")]
#[test]
fn sandbox_preflight_probes_disposable_worktree_not_primary() {
if !sandbox_exec_available() {
return;
}
let (_dir, root, sha) = seeded_git_repo();
let mut cfg = MissionConfig::default();
cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
let mut engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
engine.state.mission.base_sha = Some(sha);
let worktree_path = engine.paths.runs_dir().join("preflight-worktree");
let home_marker = format!("kranz_pf_{}", uuid::Uuid::new_v4());
let real_home = std::env::var("HOME").expect("HOME must be set for this test");
engine.state.mission.validation_contract = vec![
command_assertion("a-rel-write", "echo x > preflight-marker.txt"),
command_assertion(
"a-cwd",
&format!("[ \"$(pwd -P)\" = '{}' ]", worktree_path.display()),
),
command_assertion(
"a-outside",
&format!("echo x > '{real_home}/{home_marker}'"),
),
];
let status_before = git_status_porcelain(&root);
let issues = engine.preflight();
assert!(
issues.iter().any(|i| i.severity == "warn"
&& i.message.contains("[a-outside]")
&& i.message.contains("fs sandbox profile")),
"expected a sandbox warn for the out-of-allowlist write: {issues:?}"
);
for id in ["a-rel-write", "a-cwd"] {
let needle = format!("[{id}]");
assert!(
!issues.iter().any(|i| i.message.contains(&needle)),
"{id} must not warn — probes run with cwd = the disposable worktree: {issues:?}"
);
}
assert!(
!issues.iter().any(|i| i.severity == "error"),
"sandbox preflight must never escalate to error: {issues:?}"
);
assert_eq!(
status_before,
git_status_porcelain(&root),
"primary checkout changed across preflight"
);
assert!(
!root.join("preflight-marker.txt").exists(),
"the probe's cwd-relative write landed in the primary checkout"
);
assert!(
!worktree_path.exists(),
"disposable preflight worktree leaked at {}",
worktree_path.display()
);
if let Ok(home) = std::env::var("HOME") {
let _ = std::fs::remove_file(std::path::Path::new(&home).join(&home_marker));
}
}
#[cfg(target_os = "macos")]
#[test]
fn sandbox_preflight_worktree_creation_failure_is_advisory() {
if !sandbox_exec_available() {
crate::test_capability::skip(
crate::test_capability::capability::SANDBOX_EXEC,
"sandbox-exec not found on this host",
);
return;
}
let (_dir, root, _sha) = seeded_git_repo();
let mut cfg = MissionConfig::default();
cfg.worker.sandbox.enforce = crate::types::SandboxEnforce::Fs;
let backend: Arc<dyn AgentBackend> = Arc::new(crate::backend_mock::MockBackend::new());
let mut engine = MissionEngine::create(backend, &root, "goal", cfg).expect("create engine");
engine.state.mission.base_sha = Some("0".repeat(40));
engine.state.mission.validation_contract = vec![command_assertion("a-1", "true")];
let issues = engine.sandbox_command_preflight();
assert_eq!(
issues.len(),
1,
"exactly one advisory issue for the worktree failure: {issues:?}"
);
assert_eq!(issues[0].severity, "warn");
assert!(
issues[0]
.message
.contains("could not create disposable worktree"),
"the warn names the worktree failure: {}",
issues[0].message
);
assert!(
!engine.paths.runs_dir().join("preflight-worktree").exists(),
"a failed worktree creation must not leave a tree behind"
);
}
}