//! The model child's environment: an allowlist, not the daemon's. //! //! `hive-claude` and `hive-runtime` set their variables on top of the //! parent's environment, and the daemon's holds the store identity paths and //! the queue coordinates. So the child is spawned as this same binary with //! [`EXEC_CLAUDE`] or [`EXEC_OPENCODE`] naming the real one: //! [`exec_if_requested`] runs first in `main`, clears the environment down to //! that runtime's allowlist and execs the real binary with the same //! arguments, in the same process. use std::ffi::OsString; use std::os::unix::process::CommandExt as _; use std::process::Command; /// Set on the child only: the `claude` binary to exec. pub const EXEC_CLAUDE: &str = "SWARM_REVIEW_CLASSIFIER_EXEC_CLAUDE"; /// Set on the child only: the `opencode` binary to exec. pub const EXEC_OPENCODE: &str = "SWARM_REVIEW_CLASSIFIER_EXEC_OPENCODE"; /// Every variable `claude` keeps: the key, what it needs to run, and its /// telemetry settings. const CLAUDE_ALLOWED: &[&str] = &[ "ANTHROPIC_API_KEY", "PATH", "HOME", "CLAUDE_CONFIG_DIR", "CLAUDE_CODE_ENABLE_TELEMETRY", "OTEL_METRICS_EXPORTER", "OTEL_EXPORTER_OTLP_ENDPOINT", "OTEL_EXPORTER_OTLP_PROTOCOL", "OTEL_EXPORTER_OTLP_METRICS_TEMPORALITY_PREFERENCE", "OTEL_RESOURCE_ATTRIBUTES", ]; /// Every variable `opencode` keeps: the provider key, what it needs to run, /// and the config [`crate::acp`] renders for it. const OPENCODE_ALLOWED: &[&str] = &[ crate::acp::PROVIDER_KEY_VAR, "PATH", "HOME", "OPENCODE_CONFIG", "OPENCODE_PERMISSION", "OPENCODE_DISABLE_PROJECT_CONFIG", ]; /// The variables of `vars` on `allowed`. fn allowed( allowed: &[&str], vars: impl IntoIterator, ) -> Vec<(OsString, OsString)> { vars.into_iter() .filter(|(name, _)| allowed.iter().any(|a| name == a)) .collect() } /// When this process is the trampoline, exec the real binary and never /// return. pub fn exec_if_requested() { for (var, allow) in [ (EXEC_CLAUDE, CLAUDE_ALLOWED), (EXEC_OPENCODE, OPENCODE_ALLOWED), ] { let Some(program) = std::env::var_os(var) else { continue; }; let error = Command::new(&program) .args(std::env::args_os().skip(1)) .env_clear() .envs(allowed(allow, std::env::vars_os())) .exec(); eprintln!("exec {}: {error}", program.to_string_lossy()); std::process::exit(127); } } #[cfg(test)] mod tests { use super::*; /// The daemon's environment on a run of either runtime. fn daemon() -> Vec<(OsString, OsString)> { [ ("ANTHROPIC_API_KEY", "k"), (crate::acp::PROVIDER_KEY_VAR, "p"), ("HOME", "/tmp/run"), ("OTEL_RESOURCE_ATTRIBUTES", "repo=o/r,pr=7"), ("OPENCODE_PERMISSION", r#"{"*":"deny"}"#), ( "BAO_CLIENT_KEY", "/run/credentials/x/review-classifier-bao-key", ), ("BAO_ADDR", "https://bao.example:8200"), ("CREDENTIALS_DIRECTORY", "/run/credentials/x"), ( "SWARM_REVIEW_CLASSIFIER_NATS_URL", "tls://nats.example:4222", ), (EXEC_CLAUDE, "/bin/claude"), (EXEC_OPENCODE, "/bin/opencode"), ] .map(|(n, v)| (OsString::from(n), OsString::from(v))) .into() } fn kept(allow: &[&str]) -> Vec { allowed(allow, daemon()) .into_iter() .map(|(n, _)| n.to_string_lossy().into_owned()) .collect() } #[test] fn only_the_allowlist_reaches_claude() { assert_eq!( kept(CLAUDE_ALLOWED), ["ANTHROPIC_API_KEY", "HOME", "OTEL_RESOURCE_ATTRIBUTES"] ); } #[test] fn only_the_allowlist_reaches_opencode() { assert_eq!( kept(OPENCODE_ALLOWED), [crate::acp::PROVIDER_KEY_VAR, "HOME", "OPENCODE_PERMISSION"] ); } }