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        let mut denied: BTreeSet<String> = match self.env {
169            EnvPolicy::InheritExcept { deny } => deny.into_iter().collect(),
170            EnvPolicy::ClearExcept { .. } => BTreeSet::new(),
171        };
172        denied.extend(vars);
173        self.env = EnvPolicy::InheritExcept { deny: denied.into_iter().collect() };
174        self
175    }
176
177    pub fn timeout(mut self, timeout: Option<Duration>) -> Self {
178        self.timeout = timeout;
179        self
180    }
181
182    pub fn stderr(mut self, mode: StderrMode) -> Self {
183        self.stderr = mode;
184        self
185    }
186}
187
188/// What a spawn produced.
189#[derive(Debug)]
190pub struct SpawnOutput {
191    pub status: ExitStatus,
192    pub stdout: Vec<u8>,
193    pub stderr: Vec<u8>,
194    /// The child exceeded the policy's timeout and was killed. `status` then
195    /// reflects the kill, not the child's own exit.
196    pub timed_out: bool,
197    pub stdout_truncated: bool,
198    pub stderr_truncated: bool,
199}
200
201impl SpawnOutput {
202    /// Trimmed stderr, for error messages.
203    pub fn stderr_text(&self) -> String {
204        String::from_utf8_lossy(&self.stderr).trim().to_string()
205    }
206
207    /// The reason this spawn failed, or `None` if it succeeded. Centralised so
208    /// every seam words a timeout and a non-zero exit the same way.
209    pub fn failure(&self, what: &str) -> Option<String> {
210        if self.timed_out {
211            return Some(format!("{what} timed out and was killed"));
212        }
213        if !self.status.success() {
214            let err = self.stderr_text();
215            return Some(if err.is_empty() {
216                format!("{what} exited with {}", self.status)
217            } else {
218                format!("{what} exited with {}: {err}", self.status)
219            });
220        }
221        None
222    }
223}
224
225/// Run `cmd` to completion under `policy`, writing `stdin` to it.
226///
227/// The caller supplies a `Command` with its program and arguments already set —
228/// the shell-vs-argv choice belongs to the seam, and only the seam knows
229/// whether the Windows path needs `raw_arg`. Everything after that (stdio,
230/// environment, working directory, timeout, caps) is decided here.
231///
232/// `extra_env` is applied *after* the environment policy, so a seam's own
233/// variables (`AREEV_TOOL_NAME` and friends) survive `ClearExcept`.
234pub fn run(
235    mut cmd: Command,
236    stdin: Option<&[u8]>,
237    extra_env: &[(&str, &str)],
238    policy: &SpawnPolicy,
239) -> io::Result<SpawnOutput> {
240    match &policy.env {
241        EnvPolicy::InheritExcept { deny } => {
242            for var in deny {
243                cmd.env_remove(var);
244            }
245        }
246        EnvPolicy::ClearExcept { allow } => {
247            cmd.env_clear();
248            for var in allow {
249                if let Ok(val) = std::env::var(var) {
250                    cmd.env(var, val);
251                }
252            }
253        }
254    }
255    for (k, v) in extra_env {
256        cmd.env(k, v);
257    }
258    if let Some(dir) = &policy.current_dir {
259        cmd.current_dir(dir);
260    }
261
262    cmd.stdin(if stdin.is_some() { Stdio::piped() } else { Stdio::null() })
263        .stdout(Stdio::piped())
264        .stderr(match policy.stderr {
265            StderrMode::Pipe => Stdio::piped(),
266            StderrMode::Inherit => Stdio::inherit(),
267        });
268
269    let mut child = cmd.spawn()?;
270
271    // stdin on its own thread: writing the whole payload before reading a byte
272    // of output deadlocks as soon as the child's output fills the pipe buffer
273    // while it is still reading its input.
274    let stdin_thread = child.stdin.take().map(|mut pipe| {
275        let payload = stdin.unwrap_or_default().to_vec();
276        std::thread::spawn(move || {
277            let _ = pipe.write_all(&payload);
278            // Dropping closes the pipe so the child sees EOF.
279        })
280    });
281
282    let cap = policy.max_output_bytes;
283    let out_thread = child.stdout.take().map(|pipe| std::thread::spawn(move || drain(pipe, cap)));
284    let err_thread = child.stderr.take().map(|pipe| std::thread::spawn(move || drain(pipe, cap)));
285
286    let (status, timed_out) = wait_bounded(&mut child, policy.timeout)?;
287
288    if let Some(t) = stdin_thread {
289        let _ = t.join();
290    }
291    let (stdout, stdout_truncated) = out_thread.and_then(|t| t.join().ok()).unwrap_or((Vec::new(), false));
292    let (stderr, stderr_truncated) = err_thread.and_then(|t| t.join().ok()).unwrap_or((Vec::new(), false));
293
294    Ok(SpawnOutput { status, stdout, stderr, timed_out, stdout_truncated, stderr_truncated })
295}
296
297/// Read to EOF, retaining at most `cap` bytes. Everything past the cap is read
298/// and dropped rather than left in the pipe — an unread pipe blocks the child
299/// forever, which would turn an output cap into a hang.
300fn drain<R: Read>(mut src: R, cap: usize) -> (Vec<u8>, bool) {
301    let mut kept = Vec::new();
302    let mut buf = [0u8; 16 * 1024];
303    let mut truncated = false;
304    loop {
305        match src.read(&mut buf) {
306            Ok(0) => break,
307            Ok(n) => {
308                if kept.len() < cap {
309                    let room = cap - kept.len();
310                    let take = room.min(n);
311                    kept.extend_from_slice(&buf[..take]);
312                    if take < n {
313                        truncated = true;
314                    }
315                } else {
316                    truncated = true;
317                }
318            }
319            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
320            Err(_) => break,
321        }
322    }
323    (kept, truncated)
324}
325
326/// Wait for `child`, killing it if `timeout` elapses first.
327///
328/// Polls rather than blocking because `wait()` has no timeout in std. The
329/// interval ramps from 1ms to 50ms so a fast command still returns promptly
330/// while a long one costs almost nothing to watch.
331fn wait_bounded(child: &mut Child, timeout: Option<Duration>) -> io::Result<(ExitStatus, bool)> {
332    let Some(limit) = timeout else {
333        return Ok((child.wait()?, false));
334    };
335    let deadline = Instant::now() + limit;
336    let mut nap = Duration::from_millis(1);
337    loop {
338        if let Some(status) = child.try_wait()? {
339            return Ok((status, false));
340        }
341        if Instant::now() >= deadline {
342            let _ = child.kill();
343            // Reap, so the child does not linger as a zombie.
344            let status = child.wait()?;
345            return Ok((status, true));
346        }
347        std::thread::sleep(nap);
348        nap = (nap * 2).min(Duration::from_millis(50));
349    }
350}
351
352#[cfg(test)]
353mod tests {
354    use super::*;
355
356    fn sh(script: &str) -> Command {
357        let mut c = Command::new("/bin/sh");
358        c.arg("-c").arg(script);
359        c
360    }
361
362    #[test]
363    #[cfg_attr(windows, ignore = "uses /bin/sh")]
364    fn captures_stdout_and_exit_status() {
365        let out = run(sh("printf hello"), None, &[], &SpawnPolicy::default()).unwrap();
366        assert!(out.status.success());
367        assert_eq!(out.stdout, b"hello");
368        assert!(!out.timed_out);
369        assert!(!out.stdout_truncated);
370    }
371
372    #[test]
373    #[cfg_attr(windows, ignore = "uses /bin/sh")]
374    fn stdin_reaches_the_child() {
375        let out = run(sh("cat"), Some(b"payload"), &[], &SpawnPolicy::default()).unwrap();
376        assert_eq!(out.stdout, b"payload");
377    }
378
379    #[test]
380    #[cfg_attr(windows, ignore = "uses /bin/sh")]
381    fn timeout_kills_a_hung_child() {
382        let policy = SpawnPolicy::default().timeout(Some(Duration::from_millis(150)));
383        let out = run(sh("sleep 30"), None, &[], &policy).unwrap();
384        assert!(out.timed_out, "expected the child to be killed");
385        assert!(!out.status.success());
386        assert!(out.failure("tool").unwrap().contains("timed out"));
387    }
388
389    #[test]
390    #[cfg_attr(windows, ignore = "uses /bin/sh")]
391    fn output_cap_truncates_without_hanging() {
392        // Far more than the cap, and the child only exits if we keep draining.
393        let policy = SpawnPolicy { max_output_bytes: 1024, ..SpawnPolicy::default() };
394        let out = run(sh("head -c 200000 /dev/zero"), None, &[], &policy).unwrap();
395        assert_eq!(out.stdout.len(), 1024);
396        assert!(out.stdout_truncated);
397        assert!(!out.timed_out, "draining past the cap must not stall the child");
398    }
399
400    #[test]
401    #[cfg_attr(windows, ignore = "uses /bin/sh")]
402    fn large_stdin_does_not_deadlock() {
403        // The child echoes while we are still writing: with a single-threaded
404        // write-then-read this deadlocks once the pipe buffer fills.
405        let big = vec![b'x'; 4 * 1024 * 1024];
406        let policy = SpawnPolicy::default().timeout(Some(Duration::from_secs(20)));
407        let out = run(sh("cat"), Some(&big), &[], &policy).unwrap();
408        assert!(!out.timed_out, "write-then-read deadlock");
409        assert_eq!(out.stdout.len(), big.len());
410    }
411
412    #[test]
413    #[cfg_attr(windows, ignore = "uses /bin/sh")]
414    fn denied_vars_do_not_reach_the_child() {
415        std::env::set_var("AREEV_TEST_SECRET", "hunter2");
416        let policy = SpawnPolicy::default().deny_vars(["AREEV_TEST_SECRET".to_string()]);
417        let out = run(sh("printf %s \"${AREEV_TEST_SECRET:-absent}\""), None, &[], &policy).unwrap();
418        std::env::remove_var("AREEV_TEST_SECRET");
419        assert_eq!(String::from_utf8_lossy(&out.stdout), "absent");
420    }
421
422    #[test]
423    #[cfg_attr(windows, ignore = "uses /bin/sh")]
424    fn inherited_vars_still_reach_the_child() {
425        // The non-breaking half of the contract: denying one variable must not
426        // strip the rest, or every deployed --llm-cmd loses its API key.
427        std::env::set_var("AREEV_TEST_KEEP", "kept");
428        std::env::set_var("AREEV_TEST_DROP", "dropped");
429        let policy = SpawnPolicy::default().deny_vars(["AREEV_TEST_DROP".to_string()]);
430        let out = run(sh("printf %s \"${AREEV_TEST_KEEP:-absent}\""), None, &[], &policy).unwrap();
431        std::env::remove_var("AREEV_TEST_KEEP");
432        std::env::remove_var("AREEV_TEST_DROP");
433        assert_eq!(String::from_utf8_lossy(&out.stdout), "kept");
434    }
435
436    #[test]
437    #[cfg_attr(windows, ignore = "uses /bin/sh")]
438    fn clear_except_drops_everything_unlisted_but_keeps_extras() {
439        std::env::set_var("AREEV_TEST_AMBIENT", "ambient");
440        let policy = SpawnPolicy {
441            env: EnvPolicy::ClearExcept { allow: EnvPolicy::minimal_allow() },
442            ..SpawnPolicy::default()
443        };
444        let out = run(
445            sh("printf %s \"${AREEV_TEST_AMBIENT:-absent}/${AREEV_EXTRA:-none}\""),
446            None,
447            &[("AREEV_EXTRA", "set")],
448            &policy,
449        )
450        .unwrap();
451        std::env::remove_var("AREEV_TEST_AMBIENT");
452        assert_eq!(String::from_utf8_lossy(&out.stdout), "absent/set");
453    }
454
455    #[test]
456    #[cfg_attr(windows, ignore = "uses /bin/sh")]
457    fn nonzero_exit_reports_stderr() {
458        let out = run(sh("echo boom >&2; exit 3"), None, &[], &SpawnPolicy::default()).unwrap();
459        let msg = out.failure("embed command").unwrap();
460        assert!(msg.contains("embed command"), "{msg}");
461        assert!(msg.contains("boom"), "{msg}");
462    }
463
464    #[test]
465    #[cfg_attr(windows, ignore = "uses /bin/sh")]
466    fn registered_secrets_are_scrubbed_without_the_seam_asking() {
467        // The whole point of the registry: a seam that just uses the default
468        // policy still must not leak a registered secret.
469        std::env::set_var("AREEV_TEST_REGISTERED", "hunter2");
470        deny_env_var("AREEV_TEST_REGISTERED");
471        let out = run(
472            sh("printf %s \"${AREEV_TEST_REGISTERED:-absent}\""),
473            None,
474            &[],
475            &SpawnPolicy::default(),
476        )
477        .unwrap();
478        std::env::remove_var("AREEV_TEST_REGISTERED");
479        assert_eq!(String::from_utf8_lossy(&out.stdout), "absent");
480    }
481
482    #[test]
483    fn deny_env_var_ignores_blanks() {
484        deny_env_var("   ");
485        deny_env_var("");
486        assert!(!secret_env_vars().iter().any(|v| v.trim().is_empty()));
487    }
488
489    #[test]
490    fn spawn_failure_is_an_error_not_a_panic() {
491        let cmd = Command::new("areev-no-such-binary-eaf1");
492        assert!(run(cmd, None, &[], &SpawnPolicy::default()).is_err());
493    }
494}