Skip to main content

harn_hostlib/process/
handle.rs

1//! Process abstraction trait used by `tools/proc` and
2//! `tools/long_running`.
3//!
4//! Tier 1C of the de-flake epic (#1057). Production code spawns through
5//! the [`ProcessSpawner`] trait — the default implementation in
6//! `process::real` wraps `std::process::Child` and goes through
7//! `harn_vm::process_sandbox`. Tests install a `MockSpawner` (see
8//! `process::mock`) that returns deterministic [`MockProcess`] handles,
9//! so process-tool tests no longer depend on real subprocess scheduling
10//! or wall-clock timing.
11
12use std::collections::BTreeMap;
13use std::io::{self, Read, Write};
14use std::path::PathBuf;
15use std::sync::Arc;
16use std::time::Duration;
17
18pub use harn_vm::op_interrupt::{ProcessCleanupChild, ProcessCleanupReport};
19
20/// Resolved exit information for a finished process. Mirrors the subset of
21/// `std::process::ExitStatus` that the process-tool builtins surface.
22#[derive(Clone, Copy, Debug, PartialEq, Eq)]
23pub struct ExitStatus {
24    /// Exit code from `exit(2)` / `_exit(2)`. `None` means the process did not
25    /// exit normally (it was terminated by a signal).
26    pub code: Option<i32>,
27    /// Unix signal that terminated the process, when applicable. `None` on
28    /// non-Unix targets or when the process exited normally.
29    pub signal: Option<i32>,
30}
31
32impl ExitStatus {
33    /// Construct a normal exit with the given code.
34    pub fn from_code(code: i32) -> Self {
35        Self {
36            code: Some(code),
37            signal: None,
38        }
39    }
40
41    /// Construct a signal-terminated exit.
42    pub fn from_signal(signal: i32) -> Self {
43        Self {
44            code: None,
45            signal: Some(signal),
46        }
47    }
48}
49
50/// How a spawn should treat the parent's environment. Mirrors the legacy
51/// `EnvMode` from `tools/proc.rs`.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum EnvMode {
54    /// Inherit the parent's environment, then apply `env` overrides.
55    InheritClean,
56    /// Clear the environment, then apply `env`.
57    Replace,
58    /// Inherit the parent's environment and apply `env` (default behaviour).
59    Patch,
60}
61
62/// Explicit secret-bearing environment variable names that the agent's
63/// `run`/`command_run` tool must never leak into a child process (and thus
64/// into the model context, since the child's stdout is returned to the
65/// model as the tool result). These are matched case-insensitively in
66/// addition to the suffix patterns in [`is_sensitive_env_name`].
67const EXPLICIT_SENSITIVE_ENV_NAMES: &[&str] = &[
68    "GITHUB_TOKEN",
69    "GH_TOKEN",
70    "HARN_CLOUD_API_KEY",
71    "BURIN_ADMIN_TOKEN",
72    "AWS_SECRET_ACCESS_KEY",
73    "AWS_SESSION_TOKEN",
74];
75
76/// Provider-namespace prefixes whose entire family of variables is treated
77/// as secret-bearing (e.g. `ANTHROPIC_API_KEY`, `OPENAI_ORG_ID`). Matched
78/// case-insensitively against the start of the variable name.
79const SENSITIVE_ENV_PREFIXES: &[&str] = &[
80    "ANTHROPIC_",
81    "OPENAI_",
82    "OPENROUTER_",
83    "FIREWORKS_",
84    "TOGETHER_",
85    "XAI_",
86    "GROQ_",
87];
88
89/// Returns `true` when an environment variable name looks like it carries a
90/// secret (provider API key, access token, OAuth client secret, etc.) and so
91/// must be stripped from a child process spawned by the agent's `run` tool.
92///
93/// The check is deliberately conservative about credentials but permissive
94/// about ordinary build/toolchain variables: `PATH`, `HOME`, `LANG`,
95/// `CARGO_HOME`, language toolchain vars, etc. are *not* sensitive and stay
96/// in the child environment so builds and tests still work.
97///
98/// Matching is case-insensitive and covers:
99/// - suffix patterns `_API_KEY`, `_TOKEN`, `_SECRET`, `_KEY`, `_PASSWORD`,
100///   `_PASSWD`, `_CREDENTIALS`;
101/// - the provider prefixes in [`SENSITIVE_ENV_PREFIXES`];
102/// - the explicit names in [`EXPLICIT_SENSITIVE_ENV_NAMES`].
103pub fn is_sensitive_env_name(name: &str) -> bool {
104    let upper = name.to_ascii_uppercase();
105    if EXPLICIT_SENSITIVE_ENV_NAMES.contains(&upper.as_str()) {
106        return true;
107    }
108    if SENSITIVE_ENV_PREFIXES
109        .iter()
110        .any(|prefix| upper.starts_with(prefix))
111    {
112        return true;
113    }
114    // Suffix patterns catch the long tail of provider/service credentials
115    // (`*_API_KEY`, `*_TOKEN`, `*_SECRET`, `*_KEY`, `*_PASSWORD`, `*_PASSWD`,
116    // `*_CREDENTIALS`) without enumerating every vendor. `_KEY` is the broadest;
117    // these suffixes still exclude benign names like `PATH`/`HOME`/`LANG` and
118    // `BUILD_KEYBOARD` that don't end in one of them.
119    upper.ends_with("_API_KEY")
120        || upper.ends_with("_TOKEN")
121        || upper.ends_with("_SECRET")
122        || upper.ends_with("_KEY")
123        || upper.ends_with("_PASSWORD")
124        || upper.ends_with("_PASSWD")
125        || upper.ends_with("_CREDENTIALS")
126}
127
128/// Parameters describing a single spawn. The spawner is responsible for any
129/// sandbox setup (Linux seccomp/landlock, macOS sandbox-exec, etc.) and for
130/// configuring the child's process group when requested.
131#[derive(Clone, Debug)]
132pub struct SpawnSpec {
133    /// Builtin name surfaced in error messages (e.g. `"hostlib_tools_run_command"`).
134    pub builtin: &'static str,
135    /// Program to execute. Must be non-empty (validated by the spawner).
136    pub program: String,
137    /// Arguments to pass to the program.
138    pub args: Vec<String>,
139    /// Working directory for the child. `None` inherits the parent's cwd.
140    pub cwd: Option<PathBuf>,
141    /// Environment overrides to apply (interpretation depends on `env_mode`).
142    pub env: BTreeMap<String, String>,
143    /// Variable names to strip from the inherited environment before `env`
144    /// overrides apply. A key present in both `env_remove` and `env` ends up
145    /// set (explicit overrides win). No effect under `EnvMode::Replace`,
146    /// which already starts from an empty environment.
147    pub env_remove: Vec<String>,
148    /// How to treat the parent's environment.
149    pub env_mode: EnvMode,
150    /// Whether stdin is available. Captured-output processes receive a pipe;
151    /// inherited-output processes inherit the caller's terminal. `false`
152    /// connects the child to the null device.
153    pub use_stdin: bool,
154    /// Set the child's process group to its own pid (`setpgid(0, 0)`). Used
155    /// for long-running handles so the kill-by-pgid path works.
156    pub configure_process_group: bool,
157    /// How stdout/stderr should be attached for this child.
158    pub output_capture: OutputCapture,
159}
160
161/// Child stdout/stderr attachment strategy for a spawned process.
162#[derive(Clone, Debug)]
163pub enum OutputCapture {
164    /// Inherit the caller's stdout and stderr. When [`SpawnSpec::use_stdin`]
165    /// is true, stdin is inherited too; captured-output modes instead expose a
166    /// writable pipe. Intended for supervised CLI workloads whose output
167    /// remains user-facing rather than model-facing.
168    Inherit,
169    /// Capture stdout/stderr through pipes owned by the parent process.
170    Pipe,
171    /// Capture stdout/stderr through pre-created private files.
172    File {
173        /// Path attached to the child's stdout handle.
174        stdout_path: PathBuf,
175        /// Path attached to the child's stderr handle.
176        stderr_path: PathBuf,
177    },
178}
179
180/// Handle to a running (or finished) process. Used by both the synchronous
181/// `proc::run` path and the long-running waiter thread.
182///
183/// The trait is intentionally small: the legacy code already managed
184/// stdout/stderr drain on dedicated threads, and stdin is written once after
185/// spawn — wrapping those reads/writes via boxed trait objects keeps the
186/// real and mock paths uniform without forcing async into the rest of the
187/// hostlib.
188pub trait ProcessHandle: Send {
189    /// OS process id, when available.
190    fn pid(&self) -> Option<u32>;
191
192    /// OS process group id, when available. Falls back to [`Self::pid`] on
193    /// platforms that don't expose process groups.
194    fn process_group_id(&self) -> Option<u32>;
195
196    /// Returns a killer that can terminate the process even after the
197    /// stdout/stderr/wait halves have been moved into the waiter thread.
198    fn killer(&self) -> Arc<dyn ProcessKiller>;
199
200    /// Take ownership of the stdin pipe, if the spawn requested one.
201    fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>>;
202
203    /// Take ownership of the stdout reader.
204    fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>>;
205
206    /// Take ownership of the stderr reader.
207    fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>>;
208
209    /// Wait for the process to exit, optionally with a timeout, while
210    /// polling `interrupt`. On timeout the spawner kills the child process
211    /// tree (SIGKILL, historical semantics) and reports
212    /// [`WaitOutcome::TimedOut`]. When `interrupt` returns `true` (scope
213    /// cancellation, `deadline` expiry — see `harn_vm::op_interrupt`) the
214    /// spawner gracefully terminates the child's process tree (SIGTERM,
215    /// then SIGKILL after `harn_vm::op_interrupt::SUBPROCESS_TERM_GRACE`)
216    /// and reports [`WaitOutcome::Interrupted`].
217    fn wait_with_timeout(
218        &mut self,
219        timeout: Option<Duration>,
220        interrupt: &dyn Fn() -> bool,
221    ) -> io::Result<WaitOutcome>;
222
223    /// Block until the process exits, no timeout, no interrupt polling.
224    /// Used by the background (`background: true`) waiter thread, whose
225    /// children deliberately outlive scope cancellation and deadlines.
226    fn wait(&mut self) -> io::Result<ExitStatus>;
227}
228
229/// How a [`ProcessHandle::wait_with_timeout`] ended.
230#[derive(Clone, Debug, PartialEq, Eq)]
231pub enum WaitOutcome {
232    /// The process exited on its own.
233    Exited(ExitStatus),
234    /// The timeout elapsed; the spawner killed the child process tree.
235    TimedOut(ProcessCleanupReport),
236    /// The interrupt callback fired; the spawner gracefully terminated the
237    /// child's process tree.
238    Interrupted(ProcessCleanupReport),
239}
240
241/// Kill side of a [`ProcessHandle`]. Cloneable via `Arc` so cancellation
242/// works after the waiter thread has taken ownership of the handle itself.
243pub trait ProcessKiller: Send + Sync {
244    /// Send SIGKILL to the process tree/group when applicable.
245    fn kill(&self) -> ProcessCleanupReport;
246}
247
248/// Spawner abstraction: produces [`ProcessHandle`] instances.
249pub trait ProcessSpawner: Send + Sync {
250    /// Spawn the configured process.
251    fn spawn(&self, spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError>;
252}
253
254/// Errors raised by a spawner. These map onto `HostlibError::Backend` /
255/// `HostlibError::InvalidParameter` at the call site so the script-side
256/// surface stays unchanged.
257#[derive(Clone, Debug, thiserror::Error)]
258pub enum ProcessError {
259    /// `argv` was empty or otherwise malformed.
260    #[error("invalid argv: {0}")]
261    InvalidArgv(String),
262    /// Sandbox setup (e.g. landlock policy assembly) failed.
263    #[error("sandbox setup failed: {0}")]
264    SandboxSetup(String),
265    /// Sandbox rejected the supplied cwd.
266    #[error("sandbox cwd rejected: {0}")]
267    SandboxCwd(String),
268    /// Sandbox rejected the spawn at execve time.
269    #[error("sandbox rejected spawn: {0}")]
270    SandboxSpawn(String),
271    /// Generic spawn failure (typically io::Error from `Command::spawn`).
272    #[error("spawn failed: {0}")]
273    Spawn(String),
274    /// A never-approvable UNIVERSAL catastrophic command (machine/disk/data
275    /// destruction) was rejected by the floor BEFORE spawning. Enforced
276    /// unconditionally at [`spawn_process`] — no `command_policy` required —
277    /// so it is universal across every hostlib process tool, embedders, and
278    /// standalone Harn. See
279    /// [`harn_vm::orchestration::universal_catastrophic_reason`].
280    #[error("{0}")]
281    CatastrophicFloor(String),
282}
283
284use std::cell::RefCell;
285
286thread_local! {
287    static THREAD_SPAWNER: RefCell<Option<Arc<dyn ProcessSpawner>>> = const { RefCell::new(None) };
288}
289
290/// Install a per-thread spawner used by `spawn_process` from this thread.
291/// Returns a guard that restores the previous spawner on drop. Tests use
292/// this to install a [`super::mock::MockSpawner`]; production never calls
293/// it (the default real spawner runs whenever no per-thread spawner is
294/// installed).
295///
296/// Thread-local rather than global so parallel test execution is safe.
297/// Process-tool spawns happen on the test's thread; the long-running
298/// waiter threads operate on the handle that was already returned, so
299/// they don't perform spawner lookups themselves.
300pub fn install_spawner(spawner: Arc<dyn ProcessSpawner>) -> SpawnerGuard {
301    let prev = THREAD_SPAWNER.with(|slot| slot.replace(Some(spawner)));
302    SpawnerGuard { prev: Some(prev) }
303}
304
305/// Guard returned by [`install_spawner`]. Restores the previous spawner on
306/// drop so installs nest correctly across tests.
307pub struct SpawnerGuard {
308    // Outer Option distinguishes "guard already restored" (None) from
309    // "guard owes a restore" (Some(_)); inner Option carries the previous
310    // spawner slot value (which can itself be None when no spawner was set).
311    #[allow(clippy::option_option)]
312    prev: Option<Option<Arc<dyn ProcessSpawner>>>,
313}
314
315impl Drop for SpawnerGuard {
316    fn drop(&mut self) {
317        if let Some(prev) = self.prev.take() {
318            THREAD_SPAWNER.with(|slot| {
319                *slot.borrow_mut() = prev;
320            });
321        }
322    }
323}
324
325/// Return the currently installed spawner for this thread, falling back
326/// to the default real spawner.
327pub fn current_spawner() -> Arc<dyn ProcessSpawner> {
328    THREAD_SPAWNER
329        .with(|slot| slot.borrow().clone())
330        .unwrap_or_else(super::real::default_spawner)
331}
332
333/// Spawn a process via the currently installed spawner.
334///
335/// This is the single chokepoint every hostlib process tool funnels through
336/// (`run_command`, `run_test`, `run_build_command`, `manage_packages`, and the
337/// long-running background path). The UNIVERSAL catastrophic-command floor is
338/// enforced HERE, UNCONDITIONALLY — no `command_policy` on the stack is
339/// required — so a machine/disk/data-destroying command (`rm -rf /`, fork bomb,
340/// `mkfs`, `dd of=<device>`, `chmod -R 000`, `truncate -s 0` of a source file,
341/// redirect-over-source, project-root delete) and the textual git-destructive
342/// family (`git reset --hard`, `git clean -fd`, force-push) is rejected before
343/// the child is ever created, on standalone Harn and under every embedder
344/// alike. Structured git builtins remain the reviewed path for legitimate
345/// force-with-lease workflows. The spec's raw `program`/`args` are classified
346/// BEFORE any sandbox wrapper is applied, so a `sandbox-exec`/`bwrap` prefix
347/// can't bury the real command.
348pub fn spawn_process(spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError> {
349    validate_process_spec(&spec)?;
350    current_spawner().spawn(spec)
351}
352
353pub(crate) fn validate_process_spec(spec: &SpawnSpec) -> Result<(), ProcessError> {
354    let workspace_roots: Vec<String> = spec
355        .cwd
356        .as_ref()
357        .map(|cwd| vec![cwd.display().to_string()])
358        .unwrap_or_default();
359    if let Some(reason) = harn_vm::orchestration::universal_catastrophic_reason(
360        &spec.program,
361        &spec.args,
362        &workspace_roots,
363    ) {
364        return Err(ProcessError::CatastrophicFloor(reason));
365    }
366    Ok(())
367}
368
369#[cfg(test)]
370mod tests {
371    use super::is_sensitive_env_name;
372
373    #[test]
374    fn denies_secret_bearing_names() {
375        // Suffix patterns.
376        assert!(is_sensitive_env_name("ANTHROPIC_API_KEY"));
377        assert!(is_sensitive_env_name("OPENAI_API_KEY"));
378        assert!(is_sensitive_env_name("SOME_VENDOR_TOKEN"));
379        assert!(is_sensitive_env_name("MY_CLIENT_SECRET"));
380        assert!(is_sensitive_env_name("RANDOM_KEY"));
381        assert!(is_sensitive_env_name("DOCKER_PASSWORD"));
382        assert!(is_sensitive_env_name("TWINE_PASSWORD"));
383        assert!(is_sensitive_env_name("DATABASE_PASSWORD"));
384        assert!(is_sensitive_env_name("MYSQL_PASSWD"));
385        assert!(is_sensitive_env_name("GCP_CREDENTIALS"));
386        // Explicit names.
387        assert!(is_sensitive_env_name("GITHUB_TOKEN"));
388        assert!(is_sensitive_env_name("GH_TOKEN"));
389        assert!(is_sensitive_env_name("HARN_CLOUD_API_KEY"));
390        assert!(is_sensitive_env_name("BURIN_ADMIN_TOKEN"));
391        assert!(is_sensitive_env_name("AWS_SECRET_ACCESS_KEY"));
392        assert!(is_sensitive_env_name("AWS_SESSION_TOKEN"));
393        // Provider prefixes (even without a key/token suffix).
394        assert!(is_sensitive_env_name("OPENROUTER_BASE_URL"));
395        assert!(is_sensitive_env_name("FIREWORKS_ACCOUNT"));
396        assert!(is_sensitive_env_name("TOGETHER_ORG"));
397        assert!(is_sensitive_env_name("XAI_REGION"));
398        assert!(is_sensitive_env_name("GROQ_PROJECT"));
399    }
400
401    #[test]
402    fn allows_benign_build_and_toolchain_names() {
403        assert!(!is_sensitive_env_name("PATH"));
404        assert!(!is_sensitive_env_name("HOME"));
405        assert!(!is_sensitive_env_name("CARGO_HOME"));
406        assert!(!is_sensitive_env_name("LANG"));
407        assert!(!is_sensitive_env_name("LC_ALL"));
408        assert!(!is_sensitive_env_name("TERM"));
409        assert!(!is_sensitive_env_name("USER"));
410        assert!(!is_sensitive_env_name("RUSTUP_HOME"));
411        assert!(!is_sensitive_env_name("CARGO_TARGET_DIR"));
412        assert!(!is_sensitive_env_name("SHELL"));
413        // `_KEY`/`_PASSWORD` suffixes match only at the end, so words that
414        // merely embed those substrings are not swept.
415        assert!(!is_sensitive_env_name("BUILD_KEYBOARD"));
416        assert!(!is_sensitive_env_name("PASSWORD_HASH_ROUNDS"));
417        // Bare names without an underscore-prefixed suffix are not caught,
418        // matching the existing convention (bare `TOKEN` is not swept either).
419        assert!(!is_sensitive_env_name("PASSWORD"));
420        assert!(!is_sensitive_env_name("TOKEN"));
421    }
422
423    #[test]
424    fn matches_case_insensitively() {
425        assert!(is_sensitive_env_name("anthropic_api_key"));
426        assert!(is_sensitive_env_name("github_token"));
427        assert!(!is_sensitive_env_name("path"));
428    }
429}