Skip to main content

areev_core/
proc.rs

1//! The one place Areev spawns a child process.
2//!
3//! Six seams shell out to host-supplied commands — `--tool-cmd`, `--embed-cmd`,
4//! `--anonymize-cmd`, `--llm-cmd`, `--analyzer-cmd`, and `areev eval`'s case
5//! runner. Every one of them hand-rolled the same thirty lines, and none of them
6//! had a wall-clock ceiling, an output cap, a working-directory control, or an
7//! environment scrub. The concrete consequences, all fixed here:
8//!
9//! * **A hung child wedged the caller forever.** `wait_with_output()` blocks
10//!   until EOF, so a tool that never exits parked a run-pool worker and, at the
11//!   next wave boundary, the driver itself.
12//! * **A chatty child could exhaust memory.** stdout was read to EOF into a
13//!   `Vec` with no ceiling.
14//! * **Secrets leaked into every child.** No seam called `env_clear` or
15//!   `env_remove`, so `--passphrase-env` (the memory's encryption passphrase)
16//!   and `--token-env` sat in the environment block of every subprocess Areev
17//!   started. The CLI carefully wrapped its own copy in `Zeroizing` and then
18//!   handed the raw variable to every child.
19//! * **Large stdin could deadlock.** Every seam wrote the whole payload before
20//!   reading a byte of output, so a child that wrote enough to fill the pipe
21//!   buffer while still reading its input blocked forever, and so did we.
22//!   Reading and writing now happen on separate threads.
23//!
24//! `areev-loop` cannot use this module — its `Cargo.toml` states the engine
25//! crate must never depend on an areev-* sibling — so it carries a private
26//! `proc` module mirroring this policy. `proc_contract.rs` in this crate's
27//! tests pins the two to the same observable behaviour.
28//!
29//! ## What this module deliberately does NOT do
30//!
31//! There is no resource limiting (`setrlimit`, cgroups, Job Objects) and no
32//! process-*group* kill. Both need a dependency — `libc` on unix, `win32job` on
33//! Windows — and both are tracked separately rather than smuggled in here. In
34//! particular [`SpawnOutput::timed_out`] means *the direct child was killed*: a
35//! `/bin/sh -c` child may have spawned grandchildren that outlive it, because
36//! killing a whole tree requires putting it in its own process group first.
37
38use std::collections::BTreeSet;
39use std::io::{self, Read, Write};
40use std::path::PathBuf;
41use std::process::{Child, Command, ExitStatus, Stdio};
42use std::time::{Duration, Instant};
43
44/// How the child's environment is derived from ours.
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub enum EnvPolicy {
47    /// Inherit the parent environment, minus `deny`.
48    ///
49    /// The default for the pre-existing seams. A blanket `env_clear` would be
50    /// stricter but would break every deployed `--llm-cmd` or `--embed-cmd`
51    /// that legitimately reads an API key out of the environment — so the
52    /// non-breaking fix is to remove the variables Areev *knows* hold secrets.
53    /// It knows them by name because the operator names them: `--passphrase-env
54    /// VAR` and `--token-env VAR` pass the variable name, not the value.
55    InheritExcept { deny: Vec<String> },
56    /// Clear the environment, passing through only `allow` (plus the per-call
57    /// extras, which are always set).
58    ///
59    /// Strictly better, and the default for surfaces introduced in 1.3, which
60    /// carry no backward-compatibility burden.
61    ClearExcept { allow: Vec<String> },
62}
63
64impl EnvPolicy {
65    /// Variables worth keeping under [`EnvPolicy::ClearExcept`] for a command
66    /// to be able to run at all. `PATH` is load-bearing — without it a bare
67    /// command name resolves to nothing.
68    pub fn minimal_allow() -> Vec<String> {
69        let base: &[&str] = if cfg!(windows) {
70            &["PATH", "PATHEXT", "SYSTEMROOT", "SYSTEMDRIVE", "COMSPEC", "TEMP", "TMP", "USERPROFILE"]
71        } else {
72            &["PATH", "HOME", "TMPDIR", "LANG", "LC_ALL", "TZ"]
73        };
74        base.iter().map(|s| s.to_string()).collect()
75    }
76}
77
78impl Default for EnvPolicy {
79    fn default() -> Self {
80        EnvPolicy::InheritExcept { deny: secret_env_vars() }
81    }
82}
83
84/// Environment variables this process has been told hold secrets.
85///
86/// Host config, never a file truth, and process-wide by nature: the operator
87/// names the variables once on the command line (`--passphrase-env VAR`,
88/// `--token-env VAR`) long before any seam spawns anything, and no seam has a
89/// path to that flag. A registry is what lets [`SpawnPolicy::default`] scrub
90/// them everywhere without every call site remembering to.
91static SECRET_ENV: std::sync::Mutex<Option<BTreeSet<String>>> = std::sync::Mutex::new(None);
92
93/// Register a variable whose value must never reach a child process.
94///
95/// Idempotent, and safe to call before or after other setup. Registering after
96/// a spawn does not retroactively protect that spawn, so hosts should call this
97/// while parsing arguments — which is also the only moment they know the name.
98pub fn deny_env_var(name: &str) {
99    if name.trim().is_empty() {
100        return;
101    }
102    let mut guard = SECRET_ENV.lock().unwrap_or_else(|e| e.into_inner());
103    guard.get_or_insert_with(BTreeSet::new).insert(name.to_string());
104}
105
106/// The registered secret variable names.
107pub fn secret_env_vars() -> Vec<String> {
108    let guard = SECRET_ENV.lock().unwrap_or_else(|e| e.into_inner());
109    guard.as_ref().map(|s| s.iter().cloned().collect()).unwrap_or_default()
110}
111
112/// Where the child's stderr goes.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum StderrMode {
115    /// Capture it, subject to the output cap. Use when stderr is part of the
116    /// error message the caller reports.
117    #[default]
118    Pipe,
119    /// Let it flow to the parent's stderr. Use where an operator is watching a
120    /// terminal and the child's diagnostics are the point.
121    Inherit,
122}
123
124/// The policy a spawn runs under.
125#[derive(Debug, Clone)]
126pub struct SpawnPolicy {
127    /// Wall-clock ceiling. `None` waits forever — the pre-1.3 behaviour, kept
128    /// expressible so a caller has to opt into it rather than get it by
129    /// forgetting.
130    pub timeout: Option<Duration>,
131    /// Maximum bytes retained per captured stream. Output past the cap is
132    /// drained and discarded — never left unread, which would block the child
133    /// on a full pipe — and the corresponding `*_truncated` flag is set.
134    pub max_output_bytes: usize,
135    pub env: EnvPolicy,
136    pub stderr: StderrMode,
137    /// Working directory for the child. `None` inherits ours.
138    pub current_dir: Option<PathBuf>,
139}
140
141/// 300s: long enough for a slow model call or a cold-start container, short
142/// enough that a wedged child surfaces the same day. Deliberately not
143/// optimistic.
144pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(300);
145
146/// 64 MiB per stream. Four times the 16 MiB maximum grain, so a legitimate
147/// payload cannot hit it, while a runaway `yes` is stopped well short of
148/// exhausting memory.
149pub const DEFAULT_MAX_OUTPUT: usize = 64 * 1024 * 1024;
150
151impl Default for SpawnPolicy {
152    fn default() -> Self {
153        SpawnPolicy {
154            timeout: Some(DEFAULT_TIMEOUT),
155            max_output_bytes: DEFAULT_MAX_OUTPUT,
156            env: EnvPolicy::default(),
157            stderr: StderrMode::default(),
158            current_dir: None,
159        }
160    }
161}
162
163impl SpawnPolicy {
164    /// Deny the named variables, ignoring any that are `None`. The shape the
165    /// hosts use: they hold `Option<String>` for `--passphrase-env` and
166    /// `--token-env` and want both scrubbed when set.
167    pub fn deny_vars<I: IntoIterator<Item = String>>(mut self, vars: I) -> Self {
168        // A cleared environment already denies everything not named, so
169        // denying more is a no-op — and rebuilding it as `InheritExcept`
170        // would silently WIDEN the child's environment to everything else
171        // this process holds, discarding the caller's allow list without a
172        // compile error. Leave it alone.
173        let mut denied: BTreeSet<String> = match self.env {
174            EnvPolicy::InheritExcept { deny } => deny.into_iter().collect(),
175            EnvPolicy::ClearExcept { allow } => {
176                self.env = EnvPolicy::ClearExcept { allow };
177                return self;
178            }
179        };
180        denied.extend(vars);
181        self.env = EnvPolicy::InheritExcept { deny: denied.into_iter().collect() };
182        self
183    }
184
185    pub fn timeout(mut self, timeout: Option<Duration>) -> Self {
186        self.timeout = timeout;
187        self
188    }
189
190    pub fn stderr(mut self, mode: StderrMode) -> Self {
191        self.stderr = mode;
192        self
193    }
194}
195
196/// What a spawn produced.
197#[derive(Debug)]
198pub struct SpawnOutput {
199    pub status: ExitStatus,
200    pub stdout: Vec<u8>,
201    pub stderr: Vec<u8>,
202    /// The child exceeded the policy's timeout and was killed. `status` then
203    /// reflects the kill, not the child's own exit.
204    pub timed_out: bool,
205    pub stdout_truncated: bool,
206    pub stderr_truncated: bool,
207}
208
209impl SpawnOutput {
210    /// Trimmed stderr, for error messages.
211    pub fn stderr_text(&self) -> String {
212        String::from_utf8_lossy(&self.stderr).trim().to_string()
213    }
214
215    /// The reason this spawn failed, or `None` if it succeeded. Centralised so
216    /// every seam words a timeout and a non-zero exit the same way.
217    pub fn failure(&self, what: &str) -> Option<String> {
218        if self.timed_out {
219            return Some(format!("{what} timed out and was killed"));
220        }
221        if !self.status.success() {
222            let err = self.stderr_text();
223            return Some(if err.is_empty() {
224                format!("{what} exited with {}", self.status)
225            } else {
226                format!("{what} exited with {}: {err}", self.status)
227            });
228        }
229        None
230    }
231}
232
233/// Run `cmd` to completion under `policy`, writing `stdin` to it.
234///
235/// The caller supplies a `Command` with its program and arguments already set —
236/// the shell-vs-argv choice belongs to the seam, and only the seam knows
237/// whether the Windows path needs `raw_arg`. Everything after that (stdio,
238/// environment, working directory, timeout, caps) is decided here.
239///
240/// `extra_env` is applied *after* the environment policy, so a seam's own
241/// variables (`AREEV_TOOL_NAME` and friends) survive `ClearExcept`.
242pub fn run(
243    mut cmd: Command,
244    stdin: Option<&[u8]>,
245    extra_env: &[(&str, &str)],
246    policy: &SpawnPolicy,
247) -> io::Result<SpawnOutput> {
248    match &policy.env {
249        EnvPolicy::InheritExcept { deny } => {
250            for var in deny {
251                cmd.env_remove(var);
252            }
253        }
254        EnvPolicy::ClearExcept { allow } => {
255            cmd.env_clear();
256            for var in allow {
257                if let Ok(val) = std::env::var(var) {
258                    cmd.env(var, val);
259                }
260            }
261        }
262    }
263    for (k, v) in extra_env {
264        cmd.env(k, v);
265    }
266    if let Some(dir) = &policy.current_dir {
267        cmd.current_dir(dir);
268    }
269
270    cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
271        .stdout(Stdio::piped())
272        .stderr(match policy.stderr {
273            StderrMode::Pipe => Stdio::piped(),
274            StderrMode::Inherit => Stdio::inherit(),
275        });
276
277    let mut child = cmd.spawn()?;
278
279    // stdin on its own thread: writing the whole payload before reading a byte
280    // of output deadlocks as soon as the child's output fills the pipe buffer
281    // while it is still reading its input.
282    let stdin_thread = child.stdin.take().map(|mut pipe| {
283        let payload = stdin.unwrap_or_default().to_vec();
284        std::thread::spawn(move || {
285            let _ = pipe.write_all(&payload);
286            // Dropping closes the pipe so the child sees EOF.
287        })
288    });
289
290    let cap = policy.max_output_bytes;
291    let out_thread = child.stdout.take().map(|pipe| std::thread::spawn(move || drain(pipe, cap)));
292    let err_thread = child.stderr.take().map(|pipe| std::thread::spawn(move || drain(pipe, cap)));
293
294    let (status, timed_out) = wait_bounded(&mut child, policy.timeout)?;
295
296    if let Some(t) = stdin_thread {
297        let _ = t.join();
298    }
299    let (stdout, stdout_truncated) = out_thread.and_then(|t| t.join().ok()).unwrap_or((Vec::new(), false));
300    let (stderr, stderr_truncated) = err_thread.and_then(|t| t.join().ok()).unwrap_or((Vec::new(), false));
301
302    Ok(SpawnOutput { status, stdout, stderr, timed_out, stdout_truncated, stderr_truncated })
303}
304
305/// Read to EOF, retaining at most `cap` bytes. Everything past the cap is read
306/// and dropped rather than left in the pipe — an unread pipe blocks the child
307/// forever, which would turn an output cap into a hang.
308fn drain<R: Read>(mut src: R, cap: usize) -> (Vec<u8>, bool) {
309    let mut kept = Vec::new();
310    let mut buf = [0u8; 16 * 1024];
311    let mut truncated = false;
312    loop {
313        match src.read(&mut buf) {
314            Ok(0) => break,
315            Ok(n) => {
316                if kept.len() < cap {
317                    let room = cap - kept.len();
318                    let take = room.min(n);
319                    kept.extend_from_slice(&buf[..take]);
320                    if take < n {
321                        truncated = true;
322                    }
323                } else {
324                    truncated = true;
325                }
326            }
327            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
328            Err(_) => break,
329        }
330    }
331    (kept, truncated)
332}
333
334/// Wait for `child`, killing it if `timeout` elapses first.
335///
336/// Polls rather than blocking because `wait()` has no timeout in std. The
337/// interval ramps from 1ms to 50ms so a fast command still returns promptly
338/// while a long one costs almost nothing to watch.
339fn wait_bounded(child: &mut Child, timeout: Option<Duration>) -> io::Result<(ExitStatus, bool)> {
340    let Some(limit) = timeout else {
341        return Ok((child.wait()?, false));
342    };
343    let deadline = Instant::now() + limit;
344    let mut nap = Duration::from_millis(1);
345    loop {
346        if let Some(status) = child.try_wait()? {
347            return Ok((status, false));
348        }
349        if Instant::now() >= deadline {
350            let _ = child.kill();
351            // Reap, so the child does not linger as a zombie.
352            let status = child.wait()?;
353            return Ok((status, true));
354        }
355        std::thread::sleep(nap);
356        nap = (nap * 2).min(Duration::from_millis(50));
357    }
358}
359
360#[cfg(test)]
361mod tests {
362    use super::*;
363
364    fn sh(script: &str) -> Command {
365        let mut c = Command::new("/bin/sh");
366        c.arg("-c").arg(script);
367        c
368    }
369
370    #[test]
371    #[cfg_attr(windows, ignore = "uses /bin/sh")]
372    fn captures_stdout_and_exit_status() {
373        let out = run(sh("printf hello"), None, &[], &SpawnPolicy::default()).unwrap();
374        assert!(out.status.success());
375        assert_eq!(out.stdout, b"hello");
376        assert!(!out.timed_out);
377        assert!(!out.stdout_truncated);
378    }
379
380    #[test]
381    #[cfg_attr(windows, ignore = "uses /bin/sh")]
382    fn stdin_reaches_the_child() {
383        let out = run(sh("cat"), Some(b"payload"), &[], &SpawnPolicy::default()).unwrap();
384        assert_eq!(out.stdout, b"payload");
385    }
386
387    #[test]
388    #[cfg_attr(windows, ignore = "uses /bin/sh")]
389    fn timeout_kills_a_hung_child() {
390        let policy = SpawnPolicy::default().timeout(Some(Duration::from_millis(150)));
391        let out = run(sh("sleep 30"), None, &[], &policy).unwrap();
392        assert!(out.timed_out, "expected the child to be killed");
393        assert!(!out.status.success());
394        assert!(out.failure("tool").unwrap().contains("timed out"));
395    }
396
397    #[test]
398    #[cfg_attr(windows, ignore = "uses /bin/sh")]
399    fn output_cap_truncates_without_hanging() {
400        // Far more than the cap, and the child only exits if we keep draining.
401        let policy = SpawnPolicy { max_output_bytes: 1024, ..SpawnPolicy::default() };
402        let out = run(sh("head -c 200000 /dev/zero"), None, &[], &policy).unwrap();
403        assert_eq!(out.stdout.len(), 1024);
404        assert!(out.stdout_truncated);
405        assert!(!out.timed_out, "draining past the cap must not stall the child");
406    }
407
408    #[test]
409    #[cfg_attr(windows, ignore = "uses /bin/sh")]
410    fn large_stdin_does_not_deadlock() {
411        // The child echoes while we are still writing: with a single-threaded
412        // write-then-read this deadlocks once the pipe buffer fills.
413        let big = vec![b'x'; 4 * 1024 * 1024];
414        let policy = SpawnPolicy::default().timeout(Some(Duration::from_secs(20)));
415        let out = run(sh("cat"), Some(&big), &[], &policy).unwrap();
416        assert!(!out.timed_out, "write-then-read deadlock");
417        assert_eq!(out.stdout.len(), big.len());
418    }
419
420    #[test]
421    #[cfg_attr(windows, ignore = "uses /bin/sh")]
422    fn denied_vars_do_not_reach_the_child() {
423        std::env::set_var("AREEV_TEST_SECRET", "hunter2");
424        let policy = SpawnPolicy::default().deny_vars(["AREEV_TEST_SECRET".to_string()]);
425        let out = run(sh("printf %s \"${AREEV_TEST_SECRET:-absent}\""), None, &[], &policy).unwrap();
426        std::env::remove_var("AREEV_TEST_SECRET");
427        assert_eq!(String::from_utf8_lossy(&out.stdout), "absent");
428    }
429
430    #[test]
431    #[cfg_attr(windows, ignore = "uses /bin/sh")]
432    fn inherited_vars_still_reach_the_child() {
433        // The non-breaking half of the contract: denying one variable must not
434        // strip the rest, or every deployed --llm-cmd loses its API key.
435        std::env::set_var("AREEV_TEST_KEEP", "kept");
436        std::env::set_var("AREEV_TEST_DROP", "dropped");
437        let policy = SpawnPolicy::default().deny_vars(["AREEV_TEST_DROP".to_string()]);
438        let out = run(sh("printf %s \"${AREEV_TEST_KEEP:-absent}\""), None, &[], &policy).unwrap();
439        std::env::remove_var("AREEV_TEST_KEEP");
440        std::env::remove_var("AREEV_TEST_DROP");
441        assert_eq!(String::from_utf8_lossy(&out.stdout), "kept");
442    }
443
444    #[test]
445    #[cfg_attr(windows, ignore = "uses /bin/sh")]
446    fn clear_except_drops_everything_unlisted_but_keeps_extras() {
447        std::env::set_var("AREEV_TEST_AMBIENT", "ambient");
448        let policy = SpawnPolicy {
449            env: EnvPolicy::ClearExcept { allow: EnvPolicy::minimal_allow() },
450            ..SpawnPolicy::default()
451        };
452        let out = run(
453            sh("printf %s \"${AREEV_TEST_AMBIENT:-absent}/${AREEV_EXTRA:-none}\""),
454            None,
455            &[("AREEV_EXTRA", "set")],
456            &policy,
457        )
458        .unwrap();
459        std::env::remove_var("AREEV_TEST_AMBIENT");
460        assert_eq!(String::from_utf8_lossy(&out.stdout), "absent/set");
461    }
462
463    #[test]
464    #[cfg_attr(windows, ignore = "uses /bin/sh")]
465    fn nonzero_exit_reports_stderr() {
466        let out = run(sh("echo boom >&2; exit 3"), None, &[], &SpawnPolicy::default()).unwrap();
467        let msg = out.failure("embed command").unwrap();
468        assert!(msg.contains("embed command"), "{msg}");
469        assert!(msg.contains("boom"), "{msg}");
470    }
471
472    #[test]
473    #[cfg_attr(windows, ignore = "uses /bin/sh")]
474    fn registered_secrets_are_scrubbed_without_the_seam_asking() {
475        // The whole point of the registry: a seam that just uses the default
476        // policy still must not leak a registered secret.
477        std::env::set_var("AREEV_TEST_REGISTERED", "hunter2");
478        deny_env_var("AREEV_TEST_REGISTERED");
479        let out = run(
480            sh("printf %s \"${AREEV_TEST_REGISTERED:-absent}\""),
481            None,
482            &[],
483            &SpawnPolicy::default(),
484        )
485        .unwrap();
486        std::env::remove_var("AREEV_TEST_REGISTERED");
487        assert_eq!(String::from_utf8_lossy(&out.stdout), "absent");
488    }
489
490    #[test]
491    fn deny_env_var_ignores_blanks() {
492        deny_env_var("   ");
493        deny_env_var("");
494        assert!(!secret_env_vars().iter().any(|v| v.trim().is_empty()));
495    }
496
497    #[test]
498    fn spawn_failure_is_an_error_not_a_panic() {
499        let cmd = Command::new("areev-no-such-binary-eaf1");
500        assert!(run(cmd, None, &[], &SpawnPolicy::default()).is_err());
501    }
502}