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 /// How to treat the parent's environment.
137 pub env_mode: EnvMode,
138 /// Whether stdin will be written to (`true`) or piped to /dev/null (`false`).
139 pub use_stdin: bool,
140 /// Set the child's process group to its own pid (`setpgid(0, 0)`). Used
141 /// for long-running handles so the kill-by-pgid path works.
142 pub configure_process_group: bool,
143}
144
145/// Handle to a running (or finished) process. Used by both the synchronous
146/// `proc::run` path and the long-running waiter thread.
147///
148/// The trait is intentionally small: the legacy code already managed
149/// stdout/stderr drain on dedicated threads, and stdin is written once after
150/// spawn — wrapping those reads/writes via boxed trait objects keeps the
151/// real and mock paths uniform without forcing async into the rest of the
152/// hostlib.
153pub trait ProcessHandle: Send {
154 /// OS process id, when available.
155 fn pid(&self) -> Option<u32>;
156
157 /// OS process group id, when available. Falls back to [`Self::pid`] on
158 /// platforms that don't expose process groups.
159 fn process_group_id(&self) -> Option<u32>;
160
161 /// Returns a killer that can terminate the process even after the
162 /// stdout/stderr/wait halves have been moved into the waiter thread.
163 fn killer(&self) -> Arc<dyn ProcessKiller>;
164
165 /// Take ownership of the stdin pipe, if the spawn requested one.
166 fn take_stdin(&mut self) -> Option<Box<dyn Write + Send>>;
167
168 /// Take ownership of the stdout reader.
169 fn take_stdout(&mut self) -> Option<Box<dyn Read + Send>>;
170
171 /// Take ownership of the stderr reader.
172 fn take_stderr(&mut self) -> Option<Box<dyn Read + Send>>;
173
174 /// Wait for the process to exit, optionally with a timeout. Returns
175 /// `(Some(status), false)` when the process exited cleanly,
176 /// `(None, true)` when the timeout elapsed (and the spawner killed the
177 /// child), or `(None, false)` when the wait failed for a reason other
178 /// than the timeout.
179 fn wait_with_timeout(
180 &mut self,
181 timeout: Option<Duration>,
182 ) -> io::Result<(Option<ExitStatus>, bool)>;
183
184 /// Block until the process exits, no timeout.
185 fn wait(&mut self) -> io::Result<ExitStatus>;
186}
187
188/// Kill side of a [`ProcessHandle`]. Cloneable via `Arc` so cancellation
189/// works after the waiter thread has taken ownership of the handle itself.
190pub trait ProcessKiller: Send + Sync {
191 /// Send SIGKILL to the process (and its process group, when applicable).
192 fn kill(&self);
193}
194
195/// Spawner abstraction: produces [`ProcessHandle`] instances.
196pub trait ProcessSpawner: Send + Sync {
197 /// Spawn the configured process.
198 fn spawn(&self, spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError>;
199}
200
201/// Errors raised by a spawner. These map onto `HostlibError::Backend` /
202/// `HostlibError::InvalidParameter` at the call site so the script-side
203/// surface stays unchanged.
204#[derive(Clone, Debug, thiserror::Error)]
205pub enum ProcessError {
206 /// `argv` was empty or otherwise malformed.
207 #[error("invalid argv: {0}")]
208 InvalidArgv(String),
209 /// Sandbox setup (e.g. landlock policy assembly) failed.
210 #[error("sandbox setup failed: {0}")]
211 SandboxSetup(String),
212 /// Sandbox rejected the supplied cwd.
213 #[error("sandbox cwd rejected: {0}")]
214 SandboxCwd(String),
215 /// Sandbox rejected the spawn at execve time.
216 #[error("sandbox rejected spawn: {0}")]
217 SandboxSpawn(String),
218 /// Generic spawn failure (typically io::Error from `Command::spawn`).
219 #[error("spawn failed: {0}")]
220 Spawn(String),
221}
222
223use std::cell::RefCell;
224
225thread_local! {
226 static THREAD_SPAWNER: RefCell<Option<Arc<dyn ProcessSpawner>>> = const { RefCell::new(None) };
227}
228
229/// Install a per-thread spawner used by `spawn_process` from this thread.
230/// Returns a guard that restores the previous spawner on drop. Tests use
231/// this to install a [`super::mock::MockSpawner`]; production never calls
232/// it (the default real spawner runs whenever no per-thread spawner is
233/// installed).
234///
235/// Thread-local rather than global so parallel test execution is safe.
236/// Process-tool spawns happen on the test's thread; the long-running
237/// waiter threads operate on the handle that was already returned, so
238/// they don't perform spawner lookups themselves.
239pub fn install_spawner(spawner: Arc<dyn ProcessSpawner>) -> SpawnerGuard {
240 let prev = THREAD_SPAWNER.with(|slot| slot.replace(Some(spawner)));
241 SpawnerGuard { prev: Some(prev) }
242}
243
244/// Guard returned by [`install_spawner`]. Restores the previous spawner on
245/// drop so installs nest correctly across tests.
246pub struct SpawnerGuard {
247 // Outer Option distinguishes "guard already restored" (None) from
248 // "guard owes a restore" (Some(_)); inner Option carries the previous
249 // spawner slot value (which can itself be None when no spawner was set).
250 #[allow(clippy::option_option)]
251 prev: Option<Option<Arc<dyn ProcessSpawner>>>,
252}
253
254impl Drop for SpawnerGuard {
255 fn drop(&mut self) {
256 if let Some(prev) = self.prev.take() {
257 THREAD_SPAWNER.with(|slot| {
258 *slot.borrow_mut() = prev;
259 });
260 }
261 }
262}
263
264/// Return the currently installed spawner for this thread, falling back
265/// to the default real spawner.
266pub fn current_spawner() -> Arc<dyn ProcessSpawner> {
267 THREAD_SPAWNER
268 .with(|slot| slot.borrow().clone())
269 .unwrap_or_else(super::real::default_spawner)
270}
271
272/// Spawn a process via the currently installed spawner.
273pub fn spawn_process(spec: SpawnSpec) -> Result<Box<dyn ProcessHandle>, ProcessError> {
274 current_spawner().spawn(spec)
275}
276
277#[cfg(test)]
278mod tests {
279 use super::is_sensitive_env_name;
280
281 #[test]
282 fn denies_secret_bearing_names() {
283 // Suffix patterns.
284 assert!(is_sensitive_env_name("ANTHROPIC_API_KEY"));
285 assert!(is_sensitive_env_name("OPENAI_API_KEY"));
286 assert!(is_sensitive_env_name("SOME_VENDOR_TOKEN"));
287 assert!(is_sensitive_env_name("MY_CLIENT_SECRET"));
288 assert!(is_sensitive_env_name("RANDOM_KEY"));
289 // Explicit names.
290 assert!(is_sensitive_env_name("GITHUB_TOKEN"));
291 assert!(is_sensitive_env_name("GH_TOKEN"));
292 assert!(is_sensitive_env_name("HARN_CLOUD_API_KEY"));
293 assert!(is_sensitive_env_name("BURIN_ADMIN_TOKEN"));
294 assert!(is_sensitive_env_name("AWS_SECRET_ACCESS_KEY"));
295 assert!(is_sensitive_env_name("AWS_SESSION_TOKEN"));
296 // Provider prefixes (even without a key/token suffix).
297 assert!(is_sensitive_env_name("OPENROUTER_BASE_URL"));
298 assert!(is_sensitive_env_name("FIREWORKS_ACCOUNT"));
299 assert!(is_sensitive_env_name("TOGETHER_ORG"));
300 assert!(is_sensitive_env_name("XAI_REGION"));
301 assert!(is_sensitive_env_name("GROQ_PROJECT"));
302 }
303
304 #[test]
305 fn allows_benign_build_and_toolchain_names() {
306 assert!(!is_sensitive_env_name("PATH"));
307 assert!(!is_sensitive_env_name("HOME"));
308 assert!(!is_sensitive_env_name("CARGO_HOME"));
309 assert!(!is_sensitive_env_name("LANG"));
310 assert!(!is_sensitive_env_name("LC_ALL"));
311 assert!(!is_sensitive_env_name("TERM"));
312 assert!(!is_sensitive_env_name("USER"));
313 assert!(!is_sensitive_env_name("RUSTUP_HOME"));
314 assert!(!is_sensitive_env_name("CARGO_TARGET_DIR"));
315 assert!(!is_sensitive_env_name("SHELL"));
316 }
317
318 #[test]
319 fn matches_case_insensitively() {
320 assert!(is_sensitive_env_name("anthropic_api_key"));
321 assert!(is_sensitive_env_name("github_token"));
322 assert!(!is_sensitive_env_name("path"));
323 }
324}