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