From 356fcaf71e23d6c5dc454a54ee44269323d290c9 Mon Sep 17 00:00:00 2001 From: pitboss Date: Sun, 17 May 2026 02:01:36 -0500 Subject: [PATCH] [pitboss/grind] deferred session-0006 (20260517T044708Z-e058) --- src/dynamic/build_sandbox.rs | 115 ++++++++++++++---- src/dynamic/runner.rs | 5 +- src/dynamic/sandbox/seccomp/mod.rs | 20 +++ .../sandbox/seccomp/seccomp_policy.toml | 13 ++ src/dynamic/sandbox/seccomp/syscalls.rs | 6 + 5 files changed, 133 insertions(+), 26 deletions(-) diff --git a/src/dynamic/build_sandbox.rs b/src/dynamic/build_sandbox.rs index 9c8abac7..b177e0a2 100644 --- a/src/dynamic/build_sandbox.rs +++ b/src/dynamic/build_sandbox.rs @@ -12,6 +12,7 @@ //! Failed-build retry policy (§12 Q4): one retry on `BuildFailed` with //! backoff (1s, 4s), then `Inconclusive(BuildFailed, attempts: 2)`. +use crate::dynamic::sandbox::ProcessHardeningProfile; use crate::dynamic::spec::HarnessSpec; use blake3::Hasher; use directories::ProjectDirs; @@ -817,8 +818,13 @@ fn compute_php_lockfile_hash(workdir: &Path) -> String { /// `cc -O0 -g -o nyx_harness main.c` in `workdir`. /// /// Build isolation is NOT yet implemented (deferred). `cc` runs on the host. -pub fn prepare_c(spec: &HarnessSpec, workdir: &Path) -> Result { - let source_hash = compute_c_source_hash(workdir); +pub fn prepare_c( + spec: &HarnessSpec, + workdir: &Path, + profile: ProcessHardeningProfile, +) -> Result { + let static_link = static_link_for_profile(profile); + let source_hash = compute_c_source_hash(workdir, static_link); let cache_path = build_cache_path(&source_hash, "c", &spec.toolchain_id)?; let binary = cache_path.join("nyx_harness"); @@ -842,7 +848,7 @@ pub fn prepare_c(spec: &HarnessSpec, workdir: &Path) -> Result { return Ok(BuildResult { venv_path: cache_path, @@ -860,18 +866,18 @@ pub fn prepare_c(spec: &HarnessSpec, workdir: &Path) -> Result Result<(), String> { +fn try_build_c_binary(workdir: &Path, binary_dest: &Path, static_link: bool) -> Result<(), String> { let cc_bin = std::env::var("NYX_CC_BIN").unwrap_or_else(|_| "cc".to_owned()); - // When `NYX_BUILD_STATIC=1` (typically set by the Linux Strict-profile - // path so the harness survives `chroot(workdir)`), try `cc -static` - // first. Fall back to the dynamic link if static fails — the host may - // lack `libc.a` (musl-cross or `libc6-dev` are the usual sources) and - // a dynamic-linked binary still works for non-chroot runs. The - // fallback is announced via `NYX_BUILD_STATIC_FALLBACK=1` so downstream - // chroot-acceptance tests can skip the leg they need static linking - // for instead of asserting against a broken harness. - if static_link_requested() { + // When the Linux Strict-profile path requests it (or an operator sets + // `NYX_BUILD_STATIC=1`), try `cc -static` first so the harness survives + // `chroot(workdir)`. Fall back to the dynamic link if static fails — + // the host may lack `libc.a` (musl-cross or `libc6-dev` are the usual + // sources) and a dynamic-linked binary still works for non-chroot runs. + // The fallback is announced via `NYX_BUILD_STATIC_FALLBACK=1` so + // downstream chroot-acceptance tests can skip the leg they need static + // linking for instead of asserting against a broken harness. + if static_link { match run_cc(&cc_bin, workdir, binary_dest, &["-static", "-O0", "-g"]) { Ok(()) => return Ok(()), Err(stderr) => { @@ -885,7 +891,25 @@ fn try_build_c_binary(workdir: &Path, binary_dest: &Path) -> Result<(), String> run_cc(&cc_bin, workdir, binary_dest, &["-O0", "-g"]) } -fn static_link_requested() -> bool { +/// Decide whether the C harness should be linked with `-static`. +/// +/// Returns `true` when the caller's hardening profile is +/// [`ProcessHardeningProfile::Strict`] — chroot to the workdir hides the +/// host's `/lib`/`/lib64` from the dynamic loader, so a dynamic-linked +/// binary aborts before `main()`. Operators can also force the static +/// path on a `Standard` run via `NYX_BUILD_STATIC=1` (or `=true`) without +/// flipping the wider hardening profile. +pub(crate) fn static_link_for_profile(profile: ProcessHardeningProfile) -> bool { + if profile == ProcessHardeningProfile::Strict { + return true; + } + static_link_env_override() +} + +/// Manual operator override read from `NYX_BUILD_STATIC`. Lives separately +/// from [`static_link_for_profile`] so the env-var contract stays testable +/// without standing up a full `ProcessHardeningProfile` plumb. +pub(crate) fn static_link_env_override() -> bool { matches!( std::env::var("NYX_BUILD_STATIC").as_deref(), Ok("1") | Ok("true") @@ -912,7 +936,7 @@ fn run_cc(cc_bin: &str, workdir: &Path, binary_dest: &Path, leading_flags: &[&st Ok(()) } -fn compute_c_source_hash(workdir: &Path) -> String { +fn compute_c_source_hash(workdir: &Path, static_link: bool) -> String { let mut h = Hasher::new(); for fname in &["main.c", "entry.c", "Makefile"] { if let Ok(content) = std::fs::read(workdir.join(fname)) { @@ -923,7 +947,7 @@ fn compute_c_source_hash(workdir: &Path) -> String { // Fold the static-link toggle into the cache key so a single workdir // can produce both a static and a dynamic binary without one shadowing // the other in the cache (`prepare_c` keys on this hash). - if static_link_requested() { + if static_link { h.update(b"static"); } let out = h.finalize(); @@ -1377,17 +1401,19 @@ mod tests { fn unset_env_means_dynamic_link() { let _lock = ENV_LOCK.lock().unwrap(); let _g = EnvGuard::set(None); - assert!(!static_link_requested()); + assert!(!static_link_env_override()); + assert!(!static_link_for_profile(ProcessHardeningProfile::Standard)); } #[test] fn truthy_env_requests_static_link() { let _lock = ENV_LOCK.lock().unwrap(); let _g = EnvGuard::set(Some("1")); - assert!(static_link_requested()); + assert!(static_link_env_override()); + assert!(static_link_for_profile(ProcessHardeningProfile::Standard)); let _g2 = EnvGuard::set(Some("true")); - assert!(static_link_requested()); + assert!(static_link_env_override()); } #[test] @@ -1396,28 +1422,67 @@ mod tests { for value in &["0", "false", "yes", "static", ""] { let _g = EnvGuard::set(Some(value)); assert!( - !static_link_requested(), + !static_link_env_override(), "value {value:?} must not request static link", ); + assert!( + !static_link_for_profile(ProcessHardeningProfile::Standard), + "value {value:?} must not request static link via Standard profile", + ); } } + #[test] + fn strict_profile_forces_static_link() { + let _lock = ENV_LOCK.lock().unwrap(); + // Even with the env var absent, Strict must pick the static + // leg so chroot(workdir) does not strand the dynamic loader. + let _g = EnvGuard::set(None); + assert!(static_link_for_profile(ProcessHardeningProfile::Strict)); + + // Env var off should not flip Strict back to dynamic. + let _g2 = EnvGuard::set(Some("0")); + assert!(static_link_for_profile(ProcessHardeningProfile::Strict)); + } + #[test] fn source_hash_includes_static_marker() { let _lock = ENV_LOCK.lock().unwrap(); let dir = tempfile::TempDir::new().unwrap(); std::fs::write(dir.path().join("main.c"), "int main(){return 0;}").unwrap(); - let _g = EnvGuard::set(None); - let dyn_hash = compute_c_source_hash(dir.path()); - - let _g2 = EnvGuard::set(Some("1")); - let static_hash = compute_c_source_hash(dir.path()); + let dyn_hash = compute_c_source_hash(dir.path(), false); + let static_hash = compute_c_source_hash(dir.path(), true); assert_ne!( dyn_hash, static_hash, "static and dynamic builds must key into different cache slots", ); } + + #[test] + fn strict_profile_and_standard_profile_produce_distinct_cache_keys() { + let _lock = ENV_LOCK.lock().unwrap(); + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write(dir.path().join("main.c"), "int main(){return 0;}").unwrap(); + + // No env override; the static bit is derived from the profile. + let _g = EnvGuard::set(None); + let standard_hash = compute_c_source_hash( + dir.path(), + static_link_for_profile(ProcessHardeningProfile::Standard), + ); + let strict_hash = compute_c_source_hash( + dir.path(), + static_link_for_profile(ProcessHardeningProfile::Strict), + ); + + assert_ne!( + standard_hash, strict_hash, + "Strict-profile builds must key into a different cache slot \ + from Standard-profile builds so a chroot-bound static binary \ + does not shadow the dynamic one (or vice versa)", + ); + } } } diff --git a/src/dynamic/runner.rs b/src/dynamic/runner.rs index 112c8dba..acca0455 100644 --- a/src/dynamic/runner.rs +++ b/src/dynamic/runner.rs @@ -257,7 +257,10 @@ pub fn run_spec(spec: &HarnessSpec, opts: &SandboxOptions) -> Result { // Compile the harness binary with `cc -o nyx_harness main.c`. - match build_sandbox::prepare_c(spec, &harness.workdir) { + // Pass the sandbox profile so the build chooses `-static` when + // the run will chroot into `harness.workdir` and the dynamic + // loader would otherwise miss `/lib*`. + match build_sandbox::prepare_c(spec, &harness.workdir, opts.process_hardening) { Ok(build_result) => { let binary = build_result.venv_path.join("nyx_harness"); if binary.exists() { diff --git a/src/dynamic/sandbox/seccomp/mod.rs b/src/dynamic/sandbox/seccomp/mod.rs index d30695e9..30ba4208 100644 --- a/src/dynamic/sandbox/seccomp/mod.rs +++ b/src/dynamic/sandbox/seccomp/mod.rs @@ -168,4 +168,24 @@ mod tests { assert!(nrs.contains(&write)); assert!(nrs.contains(&close)); } + + /// `BASE` carries the interpreter cold-start trio: + /// `socketpair` (Node worker init), `umask` (Python tempfile init), + /// `setrlimit` (older glibc fallback for `prlimit64`). Without these + /// a Python or Node harness aborts before printing a single line and + /// the Confirmed-via-`verify_finding` path is structurally + /// unreachable, so a regression that drops one is a load-bearing + /// outage rather than a code-cleanliness slip. + #[test] + fn base_allows_interpreter_cold_start_syscalls() { + let nrs = allowed_syscall_numbers(0); + for name in ["socketpair", "umask", "setrlimit"] { + let nr = syscall_number(name) + .unwrap_or_else(|| panic!("{name} missing from per-arch syscall map")); + assert!( + nrs.contains(&nr), + "BASE allowlist must include {name} (interpreter cold-start)", + ); + } + } } diff --git a/src/dynamic/sandbox/seccomp/seccomp_policy.toml b/src/dynamic/sandbox/seccomp/seccomp_policy.toml index f29fa708..74cdf2ef 100644 --- a/src/dynamic/sandbox/seccomp/seccomp_policy.toml +++ b/src/dynamic/sandbox/seccomp/seccomp_policy.toml @@ -99,6 +99,19 @@ allow = [ "sched_yield", "prctl", "membarrier", + # Interpreter cold-start additions. These are universal enough that + # cap-gating them buys nothing while breaking real harnesses: + # - `socketpair(AF_UNIX, ...)` — Node v18+ binds an internal worker + # thread via an anonymous Unix-domain pair; not a network reach. + # - `umask` — Python's `tempfile` calls it during stdlib init; only + # mutates the calling process's file-creation mask. + # - `setrlimit` — older glibc `__libc_setrlimit` shims fall through to + # the legacy syscall instead of `prlimit64`; the caller can only + # lower its own limits (raise is gated by the hard limit set by the + # parent before exec). + "socketpair", + "umask", + "setrlimit", ] [cap.SQL_QUERY] diff --git a/src/dynamic/sandbox/seccomp/syscalls.rs b/src/dynamic/sandbox/seccomp/syscalls.rs index a2147582..19b3cdad 100644 --- a/src/dynamic/sandbox/seccomp/syscalls.rs +++ b/src/dynamic/sandbox/seccomp/syscalls.rs @@ -57,6 +57,7 @@ pub fn syscall_number(name: &str) -> Option { "listen" => 50, "getsockname" => 51, "getpeername" => 52, + "socketpair" => 53, "setsockopt" => 54, "getsockopt" => 55, "clone" => 56, @@ -77,11 +78,13 @@ pub fn syscall_number(name: &str) -> Option { "readlink" => 89, "fchmod" => 91, "fchown" => 93, + "umask" => 95, "getuid" => 102, "getgid" => 104, "geteuid" => 107, "getegid" => 108, "sigaltstack" => 131, + "setrlimit" => 160, "arch_prctl" => 158, "gettid" => 186, "futex" => 202, @@ -231,6 +234,8 @@ pub fn syscall_number(name: &str) -> Option { "wait4" => 260, "prlimit64" => 261, "getrlimit" => 163, + "setrlimit" => 164, + "umask" => 166, "prctl" => 167, "fchmod" => 52, "fchmodat" => 53, @@ -241,6 +246,7 @@ pub fn syscall_number(name: &str) -> Option { "getgid" => 176, "getegid" => 177, "socket" => 198, + "socketpair" => 199, "bind" => 200, "listen" => 201, "accept" => 202,