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