use std::collections::BTreeMap;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use super::collector::collect_child_stdio;
use super::engine;
use super::evidence::{Evidence, ExecutionIdentity};
use super::fixture::{hash_bytes, Fixture};
use super::frozen;
use super::host_evidence::{ControlChannel, CONTROL_READ_BUDGET};
use super::killer::{self, KillOutcome, WaitKill};
use super::model::{Category, ScenarioResult};
use super::oracle;
use super::redact;
use super::sandbox_backend::{BackendKind, CanonicalPolicy, EnforcementReport, SandboxBackend};
pub const DIRECT_BACKEND: &str = "direct-exec (no sandbox; plumbing only)";
pub const DIRECT_TIER: &str = "direct";
pub const DEFAULT_DEADLINE: Duration = Duration::from_secs(30);
pub const EXIT_POLL: Duration = Duration::from_millis(10);
pub const DRAIN_BUDGET: Duration = Duration::from_secs(5);
pub const MAX_STDIO_BYTES: usize = 1 << 20;
pub const ENV_NONCE: &str = "VETTO_VNG_NONCE";
pub const ENV_HOME: &str = "VETTO_VNG_HOME";
pub const ENV_ROOT: &str = "VETTO_VNG_ROOT";
pub const PAYLOAD_REL: &str = "run.sh";
pub const HOME_MARKER_REL: &str = "marker.txt";
pub static RUNNER_SPAWN_COUNT: AtomicU64 = AtomicU64::new(0);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SpawnEvent {
pub run_id: String,
pub pid: u32,
}
pub type SpawnLog = Vec<SpawnEvent>;
pub struct ExecutionRequest<'a> {
pub scenario: &'a super::registry::Scenario,
pub policy: &'a crate::policy::Policy,
pub net_mode: &'a crate::config::NetMode,
pub interpreter: Vec<String>,
pub script_args: Vec<String>,
pub script: Vec<u8>,
pub sentinels: Vec<(String, Vec<u8>)>,
pub env_extra: BTreeMap<String, String>,
pub deadline: Duration,
pub enable_host_control: bool,
}
#[derive(Debug)]
pub struct ExecutionOutcome {
pub result: ScenarioResult,
pub nonce: String,
pub execution_identity: ExecutionIdentity,
pub backend_report: Option<EnforcementReport>,
pub backend_kind: BackendKind,
pub exit_code: Option<i32>,
pub timed_out: bool,
pub kill: Option<KillOutcome>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub stdio_eof: bool,
pub stdio_truncated: bool,
pub stdio_complete: bool,
pub evidence: Evidence,
pub payload_intact: bool,
pub sentinel_mutated: Vec<String>,
pub control_observed: bool,
pub violation_observed: bool,
pub home_marker: Option<Vec<u8>>,
pub home: PathBuf,
pub spawn_pid: Option<u32>,
pub duplicate_rejected: bool,
}
struct DirectChild {
child: std::process::Child,
stdout: Option<std::process::ChildStdout>,
stderr: Option<std::process::ChildStderr>,
pgid: Option<i32>,
}
impl WaitKill for DirectChild {
fn try_wait(&mut self) -> Option<i32> {
match self.child.try_wait() {
Ok(Some(status)) => Some(decode_exit(status)),
Ok(None) => None,
Err(_) => None,
}
}
fn terminate(&mut self) {
#[cfg(unix)]
if let Some(pgid) = self.pgid {
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
let _ = self.child.kill();
}
}
#[cfg(unix)]
fn decode_exit(status: std::process::ExitStatus) -> i32 {
use std::os::unix::process::ExitStatusExt;
status
.code()
.unwrap_or_else(|| status.signal().map(|s| -s).unwrap_or(-1))
}
#[cfg(not(unix))]
fn decode_exit(status: std::process::ExitStatus) -> i32 {
status.code().unwrap_or(-1)
}
fn harness_env(
home: &std::path::Path,
root: &std::path::Path,
nonce: &str,
) -> BTreeMap<String, String> {
let mut env = engine::run_env(home, nonce);
env.insert(ENV_ROOT.to_string(), root.display().to_string());
env
}
pub fn run_one(req: &ExecutionRequest<'_>, spawn_log: &mut SpawnLog) -> ExecutionOutcome {
let mut backend = super::sandbox_backend::DirectBackend::new();
run_one_with_backend(req, spawn_log, &mut backend)
}
pub fn run_one_with_backend(
req: &ExecutionRequest<'_>,
spawn_log: &mut SpawnLog,
backend: &mut dyn SandboxBackend,
) -> ExecutionOutcome {
let target = engine::current_target(None);
let poison = engine::detect_env_poison(false);
if !poison.is_empty() {
let result = engine::poisoned_result(req.scenario, target, &poison);
return ExecutionOutcome {
result,
nonce: String::new(),
execution_identity: ExecutionIdentity::new(&req.scenario.id, "", "", ""),
backend_report: None,
backend_kind: backend.kind(),
exit_code: None,
timed_out: false,
kill: None,
stdout: Vec::new(),
stderr: Vec::new(),
stdio_eof: false,
stdio_truncated: false,
stdio_complete: false,
evidence: Evidence::default(),
payload_intact: true,
sentinel_mutated: Vec::new(),
control_observed: false,
violation_observed: false,
home_marker: None,
home: PathBuf::new(),
spawn_pid: None,
duplicate_rejected: false,
};
}
let nonce = engine::new_nonce();
let backend_kind_snapshot = backend.kind();
let fail_closed = |detail: String, home: PathBuf| ExecutionOutcome {
result: ScenarioResult {
id: req.scenario.id.clone(),
category: req.scenario.category,
strength: req.scenario.strength_for(target),
verdict: super::model::Verdict::Inconclusive,
detail: redact::redact_text(&redact::mask_home(&detail, &home.display().to_string())),
},
nonce: nonce.clone(),
execution_identity: ExecutionIdentity::new(&req.scenario.id, nonce.as_str(), "", ""),
backend_report: None,
backend_kind: backend_kind_snapshot,
exit_code: None,
timed_out: false,
kill: None,
stdout: Vec::new(),
stderr: Vec::new(),
stdio_eof: false,
stdio_truncated: false,
stdio_complete: false,
evidence: Evidence::default(),
payload_intact: false,
sentinel_mutated: Vec::new(),
control_observed: false,
violation_observed: false,
home_marker: None,
home,
spawn_pid: None,
duplicate_rejected: false,
};
let mut fixture = match Fixture::create("exec") {
Ok(f) => f,
Err(e) => return fail_closed(format!("fixture create failed: {e}"), PathBuf::new()),
};
let home = fixture.home().to_path_buf();
let staged = match fixture.stage(PAYLOAD_REL, &req.script) {
Ok(p) => p,
Err(e) => return fail_closed(format!("payload stage failed: {e}"), home),
};
let mut sentinel_pre: Vec<(PathBuf, String)> = Vec::new();
for (rel, bytes) in &req.sentinels {
let abs = fixture.root().join(rel);
if let Some(parent) = abs.parent() {
if std::fs::create_dir_all(parent).is_err() {
return fail_closed(format!("sentinel dir failed: {rel}"), home);
}
}
if std::fs::write(&abs, bytes).is_err() {
return fail_closed(format!("sentinel stage failed: {rel}"), home);
}
sentinel_pre.push((abs, hash_bytes(bytes)));
}
let base = crate::sandbox::envfilter::filter_env(std::env::vars(), true);
let mut env: BTreeMap<String, String> = base.into_iter().collect();
env.insert("HOME".to_string(), home.display().to_string());
#[cfg(target_os = "windows")]
env.insert("USERPROFILE".to_string(), home.display().to_string());
for (k, v) in harness_env(&home, fixture.root(), &nonce) {
env.insert(k, v);
}
for (k, v) in &req.env_extra {
env.insert(k.clone(), v.clone());
}
let mut argv = req.interpreter.clone();
if argv.is_empty() || req.script.is_empty() {
return fail_closed(
"empty interpreter or script; refusing spawn".to_string(),
home,
);
}
argv.push(staged.display().to_string());
argv.extend(req.script_args.iter().cloned());
let cwd = fixture.root().to_path_buf();
let backend_label = backend.name().to_string();
let tier_label = DIRECT_TIER.to_string();
let registry_hash = super::registry::registry_hash_full(&super::registry::registry());
let spec = frozen::freeze_spec(
&req.scenario.id,
®istry_hash,
req.policy,
&tier_label,
req.net_mode,
&backend_label,
&argv,
&env,
&cwd,
&nonce,
);
let identity = ExecutionIdentity::new(
&req.scenario.id,
nonce.as_str(),
registry_hash.as_str(),
spec.hash().as_str(),
);
let canonical = CanonicalPolicy::from_frozen(&spec);
let control_channel: Option<ControlChannel> = if req.enable_host_control {
ControlChannel::create(&identity).ok()
} else {
None
};
let mut extra_rw = Vec::new();
if let Some(channel) = control_channel.as_ref() {
for (k, v) in channel.env_entries() {
if k == super::host_evidence::ENV_CONTROL_UPLINK {
let path = std::path::Path::new(&v);
if let Some(parent) = path.parent() {
extra_rw.push(parent.to_path_buf());
}
}
}
}
let prepare_ctx = super::sandbox_backend::PrepareContext { extra_rw };
backend.prepare_with_context(&canonical, &identity, &prepare_ctx);
let backend_kind = backend.kind();
let prepared_ok = backend
.enforcement()
.map(|report| report.preparation_ok && report.binds_identity(&identity))
.unwrap_or(false);
if !prepared_ok {
let detail = format!(
"backend preparation failed (fail-closed, no spawn): backend={} {}",
backend_kind.label(),
req.scenario.known_limitation,
);
let mut outcome = fail_closed(detail, home);
outcome.backend_report = backend.enforcement().cloned();
outcome.backend_kind = backend_kind;
backend.teardown();
return outcome;
}
let spec_env = env.clone();
if let Some(channel) = control_channel.as_ref() {
for (k, v) in channel.env_entries() {
env.insert(k, v);
}
}
let child_plan = backend.pre_exec_plan();
#[cfg(not(unix))]
if child_plan.is_some() {
let detail = format!(
"backend plan without unix spawn support (fail-closed): backend={} {}",
backend_kind.label(),
req.scenario.known_limitation,
);
let mut outcome = fail_closed(detail, home);
outcome.backend_report = backend.enforcement().cloned();
outcome.backend_kind = backend_kind;
backend.teardown();
return outcome;
}
let mut cmd = std::process::Command::new(&argv[0]);
cmd.args(&argv[1..])
.current_dir(&cwd)
.env_clear()
.envs(&env)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
if let Some(plan) = child_plan {
unsafe {
cmd.pre_exec(move || super::linux_enforce::apply_child_plan(&plan));
}
}
}
let spawn_res = {
let _serial = engine::spawn_serial().lock().unwrap();
cmd.spawn()
};
let mut child = match spawn_res {
Ok(c) => c,
Err(e) => {
backend.note_failed(super::sandbox_backend::PreparationFailureKind::SpawnRefused);
let mut outcome = fail_closed(format!("spawn failed (no retry): {e}"), home);
outcome.backend_report = backend.enforcement().cloned();
outcome.backend_kind = backend_kind;
backend.teardown();
return outcome;
}
};
let pid = child.id();
spawn_log.push(SpawnEvent {
run_id: nonce.clone(),
pid,
});
RUNNER_SPAWN_COUNT.fetch_add(1, Ordering::SeqCst);
backend.note_spawned(pid);
let verification = super::linux_enforce::verify_child_host(pid);
backend.note_host_verified(&verification);
let spec_after = frozen::freeze_spec(
&req.scenario.id,
®istry_hash,
req.policy,
&tier_label,
req.net_mode,
&backend_label,
&argv,
&spec_env,
&cwd,
&nonce,
);
let spec_ok = engine::verify_spec_continuity(&spec, &spec_after);
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let confined_pgroup = backend.pre_exec_plan().is_some();
let direct = DirectChild {
child,
stdout,
stderr,
pgid: if confined_pgroup {
Some(pid as i32)
} else {
None
},
};
let outcome = finish_run(
req,
target,
nonce,
identity,
control_channel,
home,
fixture,
sentinel_pre,
spec_ok,
direct,
pid,
backend,
);
backend.teardown();
outcome
}
#[allow(clippy::too_many_arguments)]
fn finish_run(
req: &ExecutionRequest<'_>,
target: super::registry::Target,
nonce: String,
identity: ExecutionIdentity,
control_channel: Option<ControlChannel>,
home: PathBuf,
fixture: Fixture,
sentinel_pre: Vec<(PathBuf, String)>,
spec_ok: bool,
mut direct: DirectChild,
pid: u32,
backend: &mut dyn SandboxBackend,
) -> ExecutionOutcome {
let deadline = Instant::now() + req.deadline;
let (kill, code) = killer::kill_on_deadline_with(&mut direct, deadline, EXIT_POLL);
let timed_out = kill == KillOutcome::KilledOnDeadline;
if direct.pgid.is_some() {
direct.terminate();
}
let stdout = direct.stdout.take();
let stderr = direct.stderr.take();
let drain_deadline = Instant::now() + DRAIN_BUDGET;
let collected = match (stdout, stderr) {
(Some(o), Some(e)) => collect_child_stdio(o, e, drain_deadline, MAX_STDIO_BYTES),
_ => super::collector::CollectedStdio {
stdout: Vec::new(),
stderr: Vec::new(),
eof: false,
truncated: false,
},
};
let exit_code = direct.try_wait().or(Some(code));
if direct.pgid.is_some() {
if let Some(outcome) = super::linux_enforce::sweep_tree_by_nonce(nonce.as_str(), pid) {
backend.note_tree_clean(outcome.clean);
backend.note_diagnostic(format!(
"tree-sweep clean={} killed={} residual={:?} subreaper={} blind={}",
outcome.clean, outcome.killed, outcome.residual, outcome.subreaper, outcome.blind
));
}
}
let payload_intact = fixture.verify_untouched().is_ok() && spec_ok;
let mut sentinel_mutated = Vec::new();
for (abs, before) in &sentinel_pre {
let after = std::fs::read(abs)
.map(|b| hash_bytes(&b))
.unwrap_or_else(|_| "unreadable".to_string());
if &after != before {
let rel = abs
.strip_prefix(fixture.root())
.map(|p| p.display().to_string())
.unwrap_or_else(|_| abs.display().to_string());
sentinel_mutated.push(rel);
}
}
let violation_observed = !sentinel_mutated.is_empty();
let home_marker = std::fs::read(home.join(HOME_MARKER_REL)).ok();
let mut evidence = Evidence::default();
if let Some(c) = exit_code {
evidence.host_fact("wait-status", format!("exit={c}"));
}
if timed_out {
evidence.host_fact("kill", "killed-on-deadline".to_string());
}
evidence.self_report(
"stdout",
String::from_utf8_lossy(&collected.stdout).into_owned(),
);
evidence.self_report(
"stderr",
String::from_utf8_lossy(&collected.stderr).into_owned(),
);
for rel in &sentinel_mutated {
evidence.host_fact("sentinel", format!("mutated:{rel}"));
}
let pass_capable = req.enable_host_control && req.scenario.category == Category::Aux;
let mut control_observed = false;
let mut control_state = if req.enable_host_control {
"challenge:unverified"
} else {
"disabled"
};
if let Some(channel) = control_channel {
if let Some(verified) = channel.verify(&identity, Instant::now() + CONTROL_READ_BUDGET) {
evidence.host_control_fact(&verified);
control_observed = true;
control_state = if pass_capable {
"challenge:verified"
} else {
"challenge:verified(non-aux,no-pass)"
};
}
}
let stdio_complete = collected.eof && !collected.truncated;
let bound_nonce: Option<String> = if control_observed && pass_capable {
Some(nonce.clone())
} else {
None
};
let agreeing_vectors: usize = if bound_nonce.is_some() { 1 } else { 0 };
let input = oracle::OracleInput {
scenario: req.scenario,
evidence: &evidence,
nonce: Some(nonce.as_str()),
probe_nonce: bound_nonce.as_deref(),
control_nonce: bound_nonce.as_deref(),
payload_intact,
env_poisoned: false,
agreeing_vectors,
violation_observed,
control_observed,
stdio_complete,
execution_identity: Some(&identity),
};
let strength = req.scenario.strength_for(target);
let judged = oracle::judge_with_ceiling(&input, strength, None);
let backend_kind = backend.kind();
let backend_report = backend.enforcement().cloned().unwrap_or_else(|| {
super::sandbox_backend::EnforcementReport {
backend: backend_kind,
scenario_id: identity.scenario_id.clone(),
session_nonce: nonce.clone(),
registry_hash: identity.registry_hash.clone(),
frozen_hash: identity.frozen_hash.clone(),
policy_hash: String::new(),
preparation_ok: false,
records: Vec::new(),
}
});
let verdict =
super::sandbox_backend::apply_backend_ceiling(judged, &backend_report, req.scenario);
let mut backend_summary = backend_report.render_deterministic();
if let Some(diag) = backend.diagnostic() {
backend_summary.push('|');
backend_summary.push_str(&diag);
}
let detail = redact::redact_text(&redact::mask_home(
&format!(
"backend={} run exit={} timeout={} stdout={}B stderr={}B eof={} trunc={} complete={} control={} sentinel_mut={} payload_intact={} spec_ok={} backend=[{}] — {}",
backend_kind.label(),
exit_code.map_or("-".to_string(), |c| c.to_string()),
timed_out,
collected.stdout.len(),
collected.stderr.len(),
collected.eof,
collected.truncated,
stdio_complete,
control_state,
sentinel_mutated.len(),
payload_intact,
spec_ok,
backend_summary,
req.scenario.known_limitation,
),
&home.display().to_string(),
));
ExecutionOutcome {
result: ScenarioResult {
id: req.scenario.id.clone(),
category: req.scenario.category,
strength,
verdict,
detail,
},
nonce,
execution_identity: identity,
backend_report: Some(backend_report),
backend_kind,
exit_code,
timed_out,
kill: Some(kill),
stdout: collected.stdout,
stderr: collected.stderr,
stdio_eof: collected.eof,
stdio_truncated: collected.truncated,
stdio_complete,
evidence,
payload_intact,
sentinel_mutated,
control_observed,
violation_observed,
home_marker,
home,
spawn_pid: Some(pid),
duplicate_rejected: false,
}
}
pub struct SuiteRunner {
executed: std::collections::HashSet<String>,
ledger: SpawnLog,
results: Vec<ScenarioResult>,
}
impl SuiteRunner {
pub fn new() -> Self {
SuiteRunner {
executed: std::collections::HashSet::new(),
ledger: Vec::new(),
results: Vec::new(),
}
}
pub fn run(&mut self, req: &ExecutionRequest<'_>) -> ExecutionOutcome {
let mut backend = super::sandbox_backend::DirectBackend::new();
self.run_with_backend(req, &mut backend)
}
pub fn run_with_backend(
&mut self,
req: &ExecutionRequest<'_>,
backend: &mut dyn SandboxBackend,
) -> ExecutionOutcome {
if !self.executed.insert(req.scenario.id.clone()) {
let target = engine::current_target(None);
let strength = req.scenario.strength_for(target);
let detail = redact::redact_text(&format!(
"duplicate execution rejected, no spawn; earlier verdict stands — {}",
req.scenario.known_limitation,
));
let outcome = ExecutionOutcome {
result: ScenarioResult {
id: req.scenario.id.clone(),
category: req.scenario.category,
strength,
verdict: super::model::Verdict::Inconclusive,
detail,
},
nonce: String::new(),
execution_identity: ExecutionIdentity::new(&req.scenario.id, "", "", ""),
backend_report: None,
backend_kind: backend.kind(),
exit_code: None,
timed_out: false,
kill: None,
stdout: Vec::new(),
stderr: Vec::new(),
stdio_eof: false,
stdio_truncated: false,
stdio_complete: false,
evidence: Evidence::default(),
payload_intact: true,
sentinel_mutated: Vec::new(),
control_observed: false,
violation_observed: false,
home_marker: None,
home: PathBuf::new(),
spawn_pid: None,
duplicate_rejected: true,
};
self.results.push(outcome.result.clone());
return outcome;
}
let outcome = run_one_with_backend(req, &mut self.ledger, backend);
self.results.push(outcome.result.clone());
outcome
}
pub fn ledger(&self) -> &[SpawnEvent] {
&self.ledger
}
pub fn results(&self) -> &[ScenarioResult] {
&self.results
}
}
impl Default for SuiteRunner {
fn default() -> Self {
SuiteRunner::new()
}
}