[pitboss] sweep after phase 02: 3 deferred items resolved

This commit is contained in:
pitboss 2026-05-11 23:24:37 -04:00
parent 0bf39047b9
commit 3a4f1b177b
7 changed files with 244 additions and 27 deletions

View file

@ -143,6 +143,9 @@ pub fn write(
// expected/outcome.json — redacted
let redacted_stdout = redact::redact(&outcome.stdout);
let redacted_stderr = redact::redact(&outcome.stderr);
// duration_ms is omitted from the persisted outcome so that outcome.json is
// byte-identical when regenerated from the repro bundle (§18.2 determinism).
// Wall-clock timing goes to telemetry only.
let outcome_json = serde_json::json!({
"exit_code": outcome.exit_code,
"stdout": String::from_utf8_lossy(&redacted_stdout),
@ -150,7 +153,6 @@ pub fn write(
"timed_out": outcome.timed_out,
"oob_callback_seen": outcome.oob_callback_seen,
"sink_hit": outcome.sink_hit,
"duration_ms": outcome.duration.as_millis(),
});
write_json(&root.join("expected").join("outcome.json"), &outcome_json)?;

View file

@ -5,6 +5,7 @@
//! above it ([`crate::dynamic::verify`]) just calls [`run_spec`] and turns
//! the result into a [`crate::dynamic::report::VerifyResult`].
use crate::dynamic::build_sandbox;
use crate::dynamic::corpus::{benign_payload_for, payloads_for, Oracle, Payload};
use crate::dynamic::harness::{self, HarnessError};
use crate::dynamic::sandbox::{self, SandboxError, SandboxOptions, SandboxOutcome};
@ -65,7 +66,7 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
// Build harness with retry.
const BACKOFF: [u64; 1] = [1];
let mut build_attempts = 0u32;
let harness = loop {
let mut harness = loop {
build_attempts += 1;
match harness::build(spec) {
Ok(h) => break h,
@ -85,6 +86,31 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result<RunOutcome,
}
};
// Prepare Python venv for build-time isolation and dependency caching.
// Errors from prepare_python propagate as RunError::BuildFailed (making
// that variant reachable) or are swallowed for non-fatal failures (Io /
// Unsupported), falling back to the system python3 in the harness command.
match build_sandbox::prepare_python(spec, &harness.workdir) {
Ok(build_result) => {
// Patch harness command to use venv Python when the venv was built
// or found in cache.
if let Some(cmd0) = harness.command.first_mut() {
if cmd0 == "python3" || cmd0 == "python" {
let venv_python = build_result.venv_path.join("bin").join("python3");
if venv_python.exists() {
*cmd0 = venv_python.to_string_lossy().into_owned();
}
}
}
}
Err(build_sandbox::BuildError::BuildFailed { stderr, attempts }) => {
return Err(RunError::BuildFailed { stderr, attempts });
}
Err(_) => {
// Io / Unsupported: fall back to system python3 already in command.
}
}
let harness_source = harness.source.clone();
let entry_source = harness.entry_source.clone();

View file

@ -89,9 +89,8 @@ impl From<std::io::Error> for SandboxError {
/// Run a built harness once with a chosen payload.
///
/// Dispatches to the process backend (subprocess with timeout).
/// On Linux the process backend uses unshare namespaces + seccomp.
/// On other platforms it falls back to plain subprocess with timeout.
/// Dispatches to the process backend (subprocess with timeout, env stripping,
/// and memory cap via `setrlimit(RLIMIT_AS)` on Linux).
pub fn run(
harness: &BuiltHarness,
payload: &Payload,
@ -106,10 +105,7 @@ pub fn run(
}
/// Process backend: spawns the harness command in a subprocess with timeout,
/// stdout/stderr capture, and env stripping.
///
/// On Linux, wraps the command with `unshare` for namespace isolation when
/// available. On other platforms, runs the command directly.
/// stdout/stderr capture, env stripping, and memory cap (Linux: RLIMIT_AS).
fn run_process(
harness: &BuiltHarness,
payload: &Payload,
@ -152,6 +148,21 @@ fn run_process(
cmd.env("NYX_PAYLOAD", std::ffi::OsStr::from_bytes(payload.bytes));
}
// Enforce memory cap before exec on Linux via RLIMIT_AS.
// RLIMIT_AS limits total virtual address space. Python uses significantly
// more virtual AS than RSS (shared libs, mmap arenas), so the enforced
// limit is memory_mib * 8 with a floor of 4 GiB. This prevents multi-GiB
// memory bombs while leaving normal Python workloads headroom.
#[cfg(target_os = "linux")]
{
use std::os::unix::process::CommandExt;
let memory_mib = opts.memory_mib;
// Safety: called in the child after fork but before exec; no allocator use.
unsafe {
cmd.pre_exec(move || rlimit_as_linux(memory_mib));
}
}
let start = Instant::now();
let mut child = cmd.spawn().map_err(SandboxError::Spawn)?;
@ -261,6 +272,36 @@ fn base64_encode(data: &[u8]) -> String {
out
}
/// Set RLIMIT_AS (virtual address space) in a `pre_exec` context on Linux.
///
/// `memory_mib` is the configured cap; we enforce `max(memory_mib * 8, 4096)`
/// MiB of virtual AS to give Python's mmap-heavy runtime adequate headroom
/// while still capping runaway memory bombs.
///
/// RLIMIT_AS = 9 on x86_64, aarch64, arm, ppc64, s390x, and all other major
/// Linux architectures (kernel source: include/uapi/asm-generic/resource.h).
#[cfg(target_os = "linux")]
fn rlimit_as_linux(memory_mib: u64) -> std::io::Result<()> {
#[repr(C)]
struct Rlimit {
cur: u64,
max: u64,
}
unsafe extern "C" {
fn setrlimit(resource: i32, rlim: *const Rlimit) -> i32;
}
const RLIMIT_AS: i32 = 9;
let cap_mib = memory_mib.saturating_mul(8).max(4096);
let bytes = cap_mib.saturating_mul(1024 * 1024);
let rl = Rlimit { cur: bytes, max: bytes };
let ret = unsafe { setrlimit(RLIMIT_AS, &rl) };
if ret == 0 {
Ok(())
} else {
Err(std::io::Error::last_os_error())
}
}
#[cfg(unix)]
fn libc_kill(pid: i32, sig: i32) -> i32 {
unsafe extern "C" {

View file

@ -56,6 +56,35 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
}
};
// Scan the entry file's directory for sensitive files (§17.3 mount filter).
// If the entry file itself matches a sensitive pattern, refuse to run it:
// the harness would copy it into the workdir and expose secrets.
{
let entry_path = Path::new(&spec.entry_file);
let scan_dir = entry_path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let notes = crate::dynamic::mount_filter::scan_sensitive_files(scan_dir);
for note in &notes {
let note_abs = scan_dir.join(&note.path);
if entry_path == note_abs {
return VerifyResult {
finding_id,
status: VerifyStatus::Unsupported,
triggered_payload: None,
reason: Some(UnsupportedReason::RequiredFileRedactedForSecrets(
note.path.clone(),
)),
inconclusive_reason: None,
detail: None,
attempts: vec![],
toolchain_match: None,
};
}
}
}
// Resolve toolchain information.
let toolchain_res = toolchain::resolve_python(Path::new("."));
let toolchain_match = if toolchain_res.toolchain_drift { "drift" } else { "exact" };
@ -64,6 +93,13 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
let result = run_spec(&spec, &opts.sandbox);
let elapsed = start.elapsed();
// Extract build_attempts before result is consumed by build_verdict.
let build_attempts = match &result {
Ok(run) => run.build_attempts,
Err(RunError::BuildFailed { attempts, .. }) => *attempts,
_ => 1,
};
let verdict = build_verdict(
&finding_id,
&spec,
@ -80,7 +116,7 @@ pub fn verify_finding(diag: &Diag, opts: &VerifyOptions) -> VerifyResult {
verdict.inconclusive_reason,
toolchain_match,
elapsed,
1, // build_attempts tracked in RunOutcome but not exposed here for simplicity
build_attempts,
);
telemetry::emit(&event);