| 1 | //! Process hardening for Linux sandbox defense-in-depth (#2183). |
| 2 | //! |
| 3 | //! This module applies kernel-level restrictions to the codewhale-tui process |
| 4 | //! itself. These hardening measures protect the *parent* TUI process and its |
| 5 | //! descendants from information leaks and privilege-escalation vectors; they |
| 6 | //! are not a filesystem or network sandbox for child commands. The seccomp |
| 7 | //! source module is not wired into child execution yet. |
| 8 | //! |
| 9 | //! # Ordering constraints |
| 10 | //! |
| 11 | //! `apply_process_hardening()` MUST be called **before** the Tokio runtime is |
| 12 | //! booted and **before** any worker threads are spawned. The reasons: |
| 13 | //! |
| 14 | //! 1. `PR_SET_DUMPABLE` — once set to 0, the process cannot be ptraced and |
| 15 | //! `/proc/self/` becomes root-owned. This must happen before any threads |
| 16 | //! exist, because the kernel applies dumpable state per-thread-group and |
| 17 | //! changing it after threads are live can race with `/proc` lookups. |
| 18 | //! |
| 19 | //! 2. `PR_SET_NO_NEW_PRIVS` — prevents the process and all descendants from |
| 20 | //! ever gaining new privileges via setuid/setgid/fscaps. This is |
| 21 | //! irreversible and must be applied before executing any helper binaries or |
| 22 | //! subprocesses that might (incorrectly) rely on privilege boundaries. |
| 23 | //! Because this also blocks intentional privilege gains — `sudo`, `su`, |
| 24 | //! setuid helpers — a user who runs Codewhale as a wheel/wheel-equivalent |
| 25 | //! administrator and wants the model to be able to escalate can opt out of |
| 26 | //! exactly this one measure with `CODEWHALE_NO_NEW_PRIVS=0` (#5413), and a |
| 27 | //! session whose startup sandbox mode resolves to `danger-full-access` |
| 28 | //! skips it by default so full access means what it says (#5723). The |
| 29 | //! other two measures stay on in every posture. |
| 30 | //! |
| 31 | //! 3. `RLIMIT_CORE` — disables core dumps so that sensitive in-memory data |
| 32 | //! (API keys, tokens, prompt content) is never written to disk on a crash. |
| 33 | //! Setting this before any data is loaded into memory is the safest posture. |
| 34 | //! |
| 35 | //! # Platform support |
| 36 | //! |
| 37 | //! These hardening measures are Linux-only (they use `prctl` and `setrlimit` |
| 38 | //! from the `libc` crate). On non-Linux platforms, `apply_process_hardening()` |
| 39 | //! is a no-op that logs a debug-level message. |
| 40 | |
| 41 | /// Environment variable carrying the explicit no-new-privileges decision. |
| 42 | /// |
| 43 | /// `PR_SET_NO_NEW_PRIVS` is an irreversible, inherited-by-children kernel |
| 44 | /// flag, so applying it by default breaks workflows where the user *wants* |
| 45 | /// Codewhale to be able to escalate: `sudo`, `su`, setuid/fscaps helpers run |
| 46 | /// by a wheel-group administrator (#5413). Setting this variable to any |
| 47 | /// falsey value (`0`, `false`, `no`, `off`, `disabled`, or empty) skips |
| 48 | /// exactly this one measure; any other set value forces it on, and leaving it |
| 49 | /// unset lets the startup sandbox posture decide (#5723). `PR_SET_DUMPABLE` |
| 50 | /// and `RLIMIT_CORE` are never skipped. |
| 51 | pub(crate) const NO_NEW_PRIVS_ENV: &str = "CODEWHALE_NO_NEW_PRIVS"; |
| 52 | |
| 53 | /// Whether a `CODEWHALE_NO_NEW_PRIVS` value requests skipping the |
| 54 | /// no-new-privileges flag. Falsey per the workspace env convention — the same |
| 55 | /// value set (`""`, `0`, `false`, `no`, `off`, `disabled`) that |
| 56 | /// `docs/CONFIGURATION.md` treats as "not set". |
| 57 | fn is_no_new_privs_opt_out(value: &str) -> bool { |
| 58 | matches!( |
| 59 | value.trim().to_ascii_lowercase().as_str(), |
| 60 | "" | "0" | "false" | "no" | "off" | "disabled" |
| 61 | ) |
| 62 | } |
| 63 | |
| 64 | /// The explicit `CODEWHALE_NO_NEW_PRIVS` decision as the tri-state |
| 65 | /// [`should_apply_no_new_privs`] consumes: `Some(false)` opts out (falsey |
| 66 | /// value), `Some(true)` forces the flag on (any other set value), and `None` |
| 67 | /// leaves the decision to the startup posture. |
| 68 | fn no_new_privs_env_override() -> Option<bool> { |
| 69 | std::env::var_os(NO_NEW_PRIVS_ENV) |
| 70 | .map(|value| !is_no_new_privs_opt_out(&value.to_string_lossy())) |
| 71 | } |
| 72 | |
| 73 | /// Whether startup should set the kernel's irreversible no-new-privileges |
| 74 | /// flag, given the resolved startup sandbox mode and the explicit |
| 75 | /// `CODEWHALE_NO_NEW_PRIVS` override. |
| 76 | /// |
| 77 | /// Precedence (#5723): |
| 78 | /// |
| 79 | /// 1. An explicit override wins in both directions: a falsey value skips the |
| 80 | /// flag (#5413); a truthy value forces it on even under |
| 81 | /// `danger-full-access`. |
| 82 | /// 2. Otherwise a startup resolved to `danger-full-access` skips the flag so |
| 83 | /// "full access (sandbox disabled)" means it: `sudo`/`su`/setuid helpers |
| 84 | /// keep working from the agent shell. |
| 85 | /// 3. Every other — or unreadable — posture keeps the flag. Defense-in-depth |
| 86 | /// stays the default (#5413) and the decision is fail-closed. |
| 87 | /// |
| 88 | /// The mode string is normalized the same way config validation normalizes |
| 89 | /// `sandbox_mode` (`Config::validate`): trimmed, ASCII case-insensitive. An |
| 90 | /// unrecognized value keeps the flag on — it cannot silently relax into a |
| 91 | /// posture the runtime would reject. |
| 92 | pub(crate) fn should_apply_no_new_privs( |
| 93 | resolved_startup_mode: Option<&str>, |
| 94 | env_override: Option<bool>, |
| 95 | ) -> bool { |
| 96 | if let Some(apply) = env_override { |
| 97 | return apply; |
| 98 | } |
| 99 | !resolved_startup_mode |
| 100 | .is_some_and(|mode| mode.trim().eq_ignore_ascii_case("danger-full-access")) |
| 101 | } |
| 102 | |
| 103 | /// Live state of the kernel's no-new-privileges flag for this process tree. |
| 104 | /// |
| 105 | /// Returns `Some(true)`/`Some(false)` on Linux, where `PR_GET_NO_NEW_PRIVS` |
| 106 | /// reads back the irreversible flag set (or deliberately skipped) at startup, |
| 107 | /// and `None` on platforms where the flag does not exist or the query fails. |
| 108 | /// Status and denial surfaces use this to disclose the residual setuid block |
| 109 | /// truthfully instead of replaying the startup decision (#5723). |
| 110 | pub(crate) fn no_new_privs_active() -> Option<bool> { |
| 111 | #[cfg(all(target_os = "linux", not(target_env = "ohos")))] |
| 112 | { |
| 113 | // Safety: PR_GET_NO_NEW_PRIVS only reads the calling process's flag. |
| 114 | let result = unsafe { libc::prctl(libc::PR_GET_NO_NEW_PRIVS, 0i64, 0i64, 0i64, 0i64) }; |
| 115 | if result < 0 { None } else { Some(result == 1) } |
| 116 | } |
| 117 | #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))] |
| 118 | { |
| 119 | None |
| 120 | } |
| 121 | } |
| 122 | |
| 123 | /// Apply process-level hardening measures. |
| 124 | /// |
| 125 | /// On Linux, this: |
| 126 | /// - Sets `PR_SET_DUMPABLE` to 0 (prevents ptrace, core dumps) |
| 127 | /// - Sets `PR_SET_NO_NEW_PRIVS` to 1 (irreversible no-new-privileges), unless |
| 128 | /// [`should_apply_no_new_privs`] skips it: an explicit falsey |
| 129 | /// `CODEWHALE_NO_NEW_PRIVS` (#5413), or a startup sandbox mode resolved to |
| 130 | /// `danger-full-access` (#5723) |
| 131 | /// - Sets `RLIMIT_CORE` to 0 (disables core dumps) |
| 132 | /// |
| 133 | /// On non-Linux platforms this is a no-op. |
| 134 | /// |
| 135 | /// `resolved_startup_mode` is the narrow pre-parse read of the startup sandbox |
| 136 | /// mode (`CODEWHALE_SANDBOX_MODE`, else the config file's `sandbox_mode` key); |
| 137 | /// see `run_with_args` in `crate::lib` for the seam and its limits. |
| 138 | /// |
| 139 | /// # Panics |
| 140 | /// |
| 141 | /// Does NOT panic. Failures are logged via `tracing::warn` because the |
| 142 | /// hardening is defense-in-depth. A failure does not abort startup or change |
| 143 | /// whether a separately configured Seatbelt/bubblewrap command wrapper is |
| 144 | /// available. |
| 145 | pub fn apply_process_hardening(resolved_startup_mode: Option<&str>) { |
| 146 | #[cfg(all(target_os = "linux", not(target_env = "ohos")))] |
| 147 | { |
| 148 | apply_linux_hardening(resolved_startup_mode); |
| 149 | } |
| 150 | #[cfg(not(all(target_os = "linux", not(target_env = "ohos"))))] |
| 151 | { |
| 152 | let _ = resolved_startup_mode; |
| 153 | tracing::debug!("Process hardening skipped: not on Linux"); |
| 154 | } |
| 155 | } |
| 156 | |
| 157 | /// Linux-specific hardening implementation. |
| 158 | #[cfg(all(target_os = "linux", not(target_env = "ohos")))] |
| 159 | fn apply_linux_hardening(resolved_startup_mode: Option<&str>) { |
| 160 | // ── PR_SET_DUMPABLE = 0 ──────────────────────────────────────────────── |
| 161 | // |
| 162 | // When dumpable is 0: |
| 163 | // - The process cannot be ptraced by non-root |
| 164 | // - /proc/<pid>/ becomes owned by root:root (mode 0400) |
| 165 | // - No core dumps are produced |
| 166 | // |
| 167 | // Pattern from openai/codex codex-rs/codex-sandbox/src/linux.rs; reimplemented. |
| 168 | // |
| 169 | // Safety: prctl with PR_SET_DUMPABLE modifies only the calling process. |
| 170 | let result = unsafe { libc::prctl(libc::PR_SET_DUMPABLE, 0i64, 0i64, 0i64, 0i64) }; |
| 171 | if result != 0 { |
| 172 | let err = std::io::Error::last_os_error(); |
| 173 | tracing::warn!( |
| 174 | "PR_SET_DUMPABLE failed ({}); continuing without this hardening", |
| 175 | err |
| 176 | ); |
| 177 | } else { |
| 178 | tracing::debug!("PR_SET_DUMPABLE=0 applied"); |
| 179 | } |
| 180 | |
| 181 | // ── PR_SET_NO_NEW_PRIVS = 1 ──────────────────────────────────────────── |
| 182 | // |
| 183 | // Once set, neither this process nor any descendant can ever gain new |
| 184 | // privileges via setuid, setgid, file capabilities, or LSMs like SELinux |
| 185 | // transitions. This is the strongest anti-escalation primitive the kernel |
| 186 | // offers. |
| 187 | // |
| 188 | // That strength is also the flag's one legitimate break: a wheel-group |
| 189 | // administrator running Codewhale over ssh loses `sudo`/`su`/setuid |
| 190 | // helpers for the whole process tree (#5413). Two startup-level paths skip |
| 191 | // exactly this measure, before any thread exists — the same point in |
| 192 | // startup where the flag itself is applied: |
| 193 | // |
| 194 | // - `CODEWHALE_NO_NEW_PRIVS` with a falsey value (#5413). A truthy value |
| 195 | // is the opposite explicit decision: it forces the flag on even under a |
| 196 | // `danger-full-access` startup. |
| 197 | // - A startup sandbox mode resolved to `danger-full-access` (#5723): |
| 198 | // "full access (sandbox disabled)" must mean it, so the agent shell's |
| 199 | // `sudo`/setuid workflows keep working. Every narrower posture keeps |
| 200 | // the flag as defense-in-depth. |
| 201 | // |
| 202 | // Pattern from openai/codex codex-rs/codex-sandbox/src/linux.rs; reimplemented. |
| 203 | // |
| 204 | // Safety: prctl with PR_SET_NO_NEW_PRIVS modifies only the calling process |
| 205 | // and its future descendants. |
| 206 | let env_override = no_new_privs_env_override(); |
| 207 | if should_apply_no_new_privs(resolved_startup_mode, env_override) { |
| 208 | let result = unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1i64, 0i64, 0i64, 0i64) }; |
| 209 | if result != 0 { |
| 210 | let err = std::io::Error::last_os_error(); |
| 211 | tracing::warn!( |
| 212 | "PR_SET_NO_NEW_PRIVS failed ({}); continuing without this hardening", |
| 213 | err |
| 214 | ); |
| 215 | } else { |
| 216 | tracing::debug!("PR_SET_NO_NEW_PRIVS=1 applied"); |
| 217 | } |
| 218 | } else if env_override == Some(false) { |
| 219 | tracing::info!( |
| 220 | target: "sandbox", |
| 221 | "PR_SET_NO_NEW_PRIVS skipped via {NO_NEW_PRIVS_ENV}: setuid/sudo escalation is \ |
| 222 | allowed for this process tree" |
| 223 | ); |
| 224 | } else { |
| 225 | tracing::info!( |
| 226 | target: "sandbox", |
| 227 | "PR_SET_NO_NEW_PRIVS skipped: startup sandbox mode resolved to danger-full-access \ |
| 228 | (#5723); setuid/sudo escalation is allowed for this process tree" |
| 229 | ); |
| 230 | } |
| 231 | |
| 232 | // ── RLIMIT_CORE = 0 ──────────────────────────────────────────────────── |
| 233 | // |
| 234 | // Disables core dumps at the rlimit level. In combination with |
| 235 | // PR_SET_DUMPABLE=0, this provides a belt-and-suspenders guarantee that |
| 236 | // no core file will ever be written. |
| 237 | // |
| 238 | // Safety: setrlimit modifies resource limits for the calling process only. |
| 239 | let rlim_core = libc::rlimit { |
| 240 | rlim_cur: 0, |
| 241 | rlim_max: 0, |
| 242 | }; |
| 243 | let result = unsafe { libc::setrlimit(libc::RLIMIT_CORE, &raw const rlim_core) }; |
| 244 | if result != 0 { |
| 245 | let err = std::io::Error::last_os_error(); |
| 246 | tracing::warn!( |
| 247 | "RLIMIT_CORE failed ({}); continuing without this hardening", |
| 248 | err |
| 249 | ); |
| 250 | } else { |
| 251 | tracing::debug!("RLIMIT_CORE=0 applied"); |
| 252 | } |
| 253 | } |
| 254 | |
| 255 | #[cfg(test)] |
| 256 | mod tests { |
| 257 | use super::*; |
| 258 | |
| 259 | #[test] |
| 260 | fn test_apply_process_hardening_does_not_panic() { |
| 261 | // This test exists to ensure the function can be called without |
| 262 | // panicking, even on platforms where hardening is a no-op. |
| 263 | apply_process_hardening(None); |
| 264 | } |
| 265 | |
| 266 | #[test] |
| 267 | fn no_new_privs_opt_out_accepts_exactly_the_falsey_values() { |
| 268 | // The workspace env convention: "", 0, false, no, off, disabled. |
| 269 | for value in ["", "0", "false", "no", "off", "disabled"] { |
| 270 | assert!( |
| 271 | is_no_new_privs_opt_out(value), |
| 272 | "{value:?} should opt out of PR_SET_NO_NEW_PRIVS" |
| 273 | ); |
| 274 | // Case and surrounding whitespace must not change the answer. |
| 275 | assert!(is_no_new_privs_opt_out(&format!( |
| 276 | " {} ", |
| 277 | value.to_uppercase() |
| 278 | ))); |
| 279 | } |
| 280 | } |
| 281 | |
| 282 | #[test] |
| 283 | fn no_new_privs_opt_out_rejects_truthy_and_garbage_values() { |
| 284 | // Anything else — including a typo — keeps the hardening on, so the |
| 285 | // opt-out can never be entered by accident. |
| 286 | for value in [ |
| 287 | "1", |
| 288 | "true", |
| 289 | "yes", |
| 290 | "on", |
| 291 | "enabled", |
| 292 | "maybe", |
| 293 | "0x0", |
| 294 | "false-ish", |
| 295 | "off!", |
| 296 | ] { |
| 297 | assert!( |
| 298 | !is_no_new_privs_opt_out(value), |
| 299 | "{value:?} should NOT opt out of PR_SET_NO_NEW_PRIVS" |
| 300 | ); |
| 301 | } |
| 302 | } |
| 303 | |
| 304 | #[test] |
| 305 | fn no_new_privs_env_wiring_reads_the_documented_variable() { |
| 306 | // The env is process-global and tests run in parallel threads, so the |
| 307 | // wiring is asserted structurally: the constant is the documented |
| 308 | // name, and the reader is a pure projection of var_os over that |
| 309 | // constant. Setting/removing the variable here would race every other |
| 310 | // test in the process. |
| 311 | assert_eq!(NO_NEW_PRIVS_ENV, "CODEWHALE_NO_NEW_PRIVS"); |
| 312 | let projected = std::env::var_os(NO_NEW_PRIVS_ENV) |
| 313 | .map(|value| !is_no_new_privs_opt_out(&value.to_string_lossy())); |
| 314 | assert_eq!(projected, no_new_privs_env_override()); |
| 315 | } |
| 316 | |
| 317 | #[test] |
| 318 | fn should_apply_no_new_privs_defaults_on_without_full_access_startup() { |
| 319 | // No posture information at all, and every narrower posture, keeps |
| 320 | // the defense-in-depth default (#5413). |
| 321 | for mode in [ |
| 322 | None, |
| 323 | Some("read-only"), |
| 324 | Some("workspace-write"), |
| 325 | Some("external-sandbox"), |
| 326 | ] { |
| 327 | assert!( |
| 328 | should_apply_no_new_privs(mode, None), |
| 329 | "{mode:?} must keep PR_SET_NO_NEW_PRIVS" |
| 330 | ); |
| 331 | } |
| 332 | } |
| 333 | |
| 334 | #[test] |
| 335 | fn should_apply_no_new_privs_skips_for_danger_full_access_startup() { |
| 336 | // The product decision behind #5723: "full access (sandbox disabled)" |
| 337 | // must mean it, so the irreversible setuid block is relaxed when the |
| 338 | // startup posture resolves to danger-full-access. |
| 339 | assert!(!should_apply_no_new_privs(Some("danger-full-access"), None)); |
| 340 | // Mode strings are normalized the way config validation normalizes |
| 341 | // them: surrounding whitespace and ASCII case do not matter. |
| 342 | assert!(!should_apply_no_new_privs( |
| 343 | Some(" Danger-Full-Access "), |
| 344 | None |
| 345 | )); |
| 346 | } |
| 347 | |
| 348 | #[test] |
| 349 | fn should_apply_no_new_privs_env_override_wins_over_posture() { |
| 350 | // Explicit override beats posture in both directions (#5413, #5723): |
| 351 | // falsey opts out under a narrow posture, truthy forces the flag on |
| 352 | // under full access. |
| 353 | assert!(!should_apply_no_new_privs( |
| 354 | Some("workspace-write"), |
| 355 | Some(false) |
| 356 | )); |
| 357 | assert!(!should_apply_no_new_privs(None, Some(false))); |
| 358 | assert!(should_apply_no_new_privs( |
| 359 | Some("danger-full-access"), |
| 360 | Some(true) |
| 361 | )); |
| 362 | assert!(should_apply_no_new_privs( |
| 363 | Some("workspace-write"), |
| 364 | Some(true) |
| 365 | )); |
| 366 | } |
| 367 | |
| 368 | #[test] |
| 369 | fn should_apply_no_new_privs_is_fail_closed_for_unknown_modes() { |
| 370 | // A mode string the runtime would reject as invalid must never relax |
| 371 | // the flag by accident. |
| 372 | for mode in ["danger_full_access", "full-access", "yolo", "", "danger"] { |
| 373 | assert!( |
| 374 | should_apply_no_new_privs(Some(mode), None), |
| 375 | "{mode:?} must keep PR_SET_NO_NEW_PRIVS" |
| 376 | ); |
| 377 | } |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | /// Kernel-flag proof. `PR_SET_NO_NEW_PRIVS` is irreversible and inherited by |
| 382 | /// descendants, so the decision can only be observed end-to-end in a fresh |
| 383 | /// child process: the child applies hardening under one posture and reports |
| 384 | /// the kernel's own record from `/proc/self/status`. These tests exist only |
| 385 | /// on Linux; on other hosts `apply_process_hardening` is a no-op and the flag |
| 386 | /// does not exist. |
| 387 | #[cfg(all(test, target_os = "linux", not(target_env = "ohos")))] |
| 388 | mod linux_flag_tests { |
| 389 | use super::*; |
| 390 | |
| 391 | const CHILD_ENV: &str = "CODEWHALE_NNP_TEST_CHILD"; |
| 392 | const CHILD_MODE_ENV: &str = "CODEWHALE_NNP_TEST_MODE"; |
| 393 | |
| 394 | /// Child-process entry point. Run directly (child env marker unset) it is |
| 395 | /// a trivial pass; run under the parent tests below it applies hardening |
| 396 | /// with the posture named by `CODEWHALE_NNP_TEST_MODE` and prints the |
| 397 | /// kernel-recorded flag as `NNP=0|1`. |
| 398 | #[test] |
| 399 | fn no_new_privs_child_reports_kernel_flag() { |
| 400 | if std::env::var_os(CHILD_ENV).is_none() { |
| 401 | return; |
| 402 | } |
| 403 | let mode = std::env::var(CHILD_MODE_ENV).ok(); |
| 404 | apply_process_hardening(mode.as_deref()); |
| 405 | // Read the flag back through PR_GET_NO_NEW_PRIVS, not |
| 406 | // /proc/self/status: no_new_privs is per-task and /proc/self/status |
| 407 | // shows the *main* thread's flag, while libtest runs this test on a |
| 408 | // spawned thread — the /proc read always reports 0 here. |
| 409 | let flag = no_new_privs_active().expect("PR_GET_NO_NEW_PRIVS reads the flag on Linux"); |
| 410 | println!("NNP={}", if flag { "1" } else { "0" }); |
| 411 | } |
| 412 | |
| 413 | fn run_child(mode: Option<&str>, env_override: Option<&str>) -> String { |
| 414 | let output = std::process::Command::new(std::env::current_exe().expect("test binary")) |
| 415 | .args(["no_new_privs_child_reports_kernel_flag", "--nocapture"]) |
| 416 | .env(CHILD_ENV, "1") |
| 417 | // The ambient developer/CI environment must not leak into the |
| 418 | // matrix: the child decides from exactly the inputs passed here. |
| 419 | .env_remove(NO_NEW_PRIVS_ENV) |
| 420 | .env_remove(CHILD_MODE_ENV) |
| 421 | .envs(mode.map(|m| (CHILD_MODE_ENV, m))) |
| 422 | .envs(env_override.map(|v| (NO_NEW_PRIVS_ENV, v))) |
| 423 | .output() |
| 424 | .expect("spawn child test process"); |
| 425 | assert!( |
| 426 | output.status.success(), |
| 427 | "child failed: status={:?} stderr={}", |
| 428 | output.status.code(), |
| 429 | String::from_utf8_lossy(&output.stderr) |
| 430 | ); |
| 431 | String::from_utf8_lossy(&output.stdout).into_owned() |
| 432 | } |
| 433 | |
| 434 | #[test] |
| 435 | fn no_new_privs_flag_tracks_startup_posture_in_child_processes() { |
| 436 | // Never recurse when this test binary is itself the spawned child. |
| 437 | if std::env::var_os(CHILD_ENV).is_some() { |
| 438 | return; |
| 439 | } |
| 440 | for (mode, expected) in [ |
| 441 | (None, "1"), |
| 442 | (Some("workspace-write"), "1"), |
| 443 | (Some("read-only"), "1"), |
| 444 | (Some("external-sandbox"), "1"), |
| 445 | (Some("danger-full-access"), "0"), |
| 446 | ] { |
| 447 | let stdout = run_child(mode, None); |
| 448 | assert!( |
| 449 | stdout.contains(&format!("NNP={expected}")), |
| 450 | "mode {mode:?}: expected NoNewPrivs={expected}, got:\n{stdout}" |
| 451 | ); |
| 452 | } |
| 453 | } |
| 454 | |
| 455 | #[test] |
| 456 | fn no_new_privs_env_override_beats_posture_in_child_processes() { |
| 457 | if std::env::var_os(CHILD_ENV).is_some() { |
| 458 | return; |
| 459 | } |
| 460 | for (mode, override_value, expected) in [ |
| 461 | // Explicit truthy forces the flag on even under full access. |
| 462 | ("danger-full-access", "1", "1"), |
| 463 | // Explicit falsey opts out under a narrow posture (#5413). |
| 464 | ("workspace-write", "0", "0"), |
| 465 | ] { |
| 466 | let stdout = run_child(Some(mode), Some(override_value)); |
| 467 | assert!( |
| 468 | stdout.contains(&format!("NNP={expected}")), |
| 469 | "mode {mode:?} with {NO_NEW_PRIVS_ENV}={override_value}: \ |
| 470 | expected NoNewPrivs={expected}, got:\n{stdout}" |
| 471 | ); |
| 472 | } |
| 473 | } |
| 474 | } |
| 475 |