返回 CodeWhale
process_hardening.rs
根目录 / crates / tui / src / sandbox / process_hardening.rs
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
475 lines RUST