Skip to main content

apollo/tools/
child_proc.rs

1//! Spawning child processes from tools: the environment they inherit, and
2//! waiting on them without leaking a live process.
3//!
4//! apollo's own process environment holds every secret the operator put in
5//! `.env` — the generated `run.sh` does `set -a; source .env; set +a`, so
6//! ANTHROPIC_API_KEY, GITHUB_TOKEN and the channel tokens are all exported.
7//! The file sandbox refuses to read `.env`, which is worth nothing if `exec`
8//! can simply run `env`.
9//!
10//! A deny-list is used rather than an allow-list on purpose. Ordinary tool use
11//! depends on a long and open-ended tail of environment: CARGO_HOME,
12//! RUSTUP_HOME, GOPATH, NVM_*, PKG_CONFIG_PATH, SSH_AUTH_SOCK, XDG_*, LC_*,
13//! HTTP(S)_PROXY, VIRTUAL_ENV, CI markers. An allow-list would break those
14//! silently and be re-widened until it stopped meaning anything, whereas
15//! secret-bearing names are conventional and few.
16
17/// Substrings that mark a variable as secret-bearing regardless of prefix.
18const SECRET_MARKERS: &[&str] = &[
19    "KEY",
20    "TOKEN",
21    "SECRET",
22    "PASSWORD",
23    "PASSWD",
24    "CREDENTIAL",
25    "AUTH",
26    "SESSION",
27    "COOKIE",
28    "PRIVATE",
29    "SIGNATURE",
30];
31
32/// Prefixes denied outright: apollo's own configuration surface, plus the
33/// cloud providers whose SDKs read credentials straight from the environment.
34const DENIED_PREFIXES: &[&str] = &["APOLLO_"];
35
36/// Names that match a marker but carry no secret and are load-bearing for
37/// ordinary work.
38const MARKER_EXCEPTIONS: &[&str] = &[
39    "SSH_AUTH_SOCK",
40    "GPG_AGENT_INFO",
41    "XDG_SESSION_TYPE",
42    "XDG_SESSION_CLASS",
43    "XDG_SESSION_ID",
44    "XDG_SESSION_DESKTOP",
45    "KEYBOARD_LAYOUT",
46];
47
48/// True when a variable must not reach a child process.
49pub fn is_secret_env_name(name: &str) -> bool {
50    let upper = name.to_ascii_uppercase();
51    if DENIED_PREFIXES.iter().any(|p| upper.starts_with(p)) {
52        return true;
53    }
54    if MARKER_EXCEPTIONS.contains(&upper.as_str()) {
55        return false;
56    }
57    SECRET_MARKERS.iter().any(|m| upper.contains(m))
58}
59
60/// apollo's environment with every secret-bearing variable removed.
61pub fn child_env() -> Vec<(String, String)> {
62    std::env::vars()
63        .filter(|(name, _)| !is_secret_env_name(name))
64        .collect()
65}
66
67/// Apply the filtered environment to a command, clearing the inherited one
68/// first so nothing new leaks in by default.
69pub fn scrub(command: &mut tokio::process::Command) -> &mut tokio::process::Command {
70    command.env_clear().envs(child_env())
71}
72
73/// Wait for a child, reading both pipes concurrently so a chatty command
74/// cannot deadlock by filling a pipe buffer, and killing it if the deadline
75/// passes.
76///
77/// `wait_with_output` consumes the `Child`, which is why the timeout path
78/// there leaks a live process: there is nothing left to kill. Borrowing
79/// instead keeps the handle available.
80pub async fn wait_with_timeout(
81    child: &mut tokio::process::Child,
82    timeout: std::time::Duration,
83) -> std::io::Result<Option<std::process::Output>> {
84    use tokio::io::AsyncReadExt;
85
86    let mut stdout_pipe = child.stdout.take();
87    let mut stderr_pipe = child.stderr.take();
88
89    let collected = {
90        let drain = async {
91            let mut stdout = Vec::new();
92            let mut stderr = Vec::new();
93            let status = {
94                let read_out = async {
95                    if let Some(pipe) = stdout_pipe.as_mut() {
96                        let _ = pipe.read_to_end(&mut stdout).await;
97                    }
98                };
99                let read_err = async {
100                    if let Some(pipe) = stderr_pipe.as_mut() {
101                        let _ = pipe.read_to_end(&mut stderr).await;
102                    }
103                };
104                let (_, _, status) = tokio::join!(read_out, read_err, child.wait());
105                status?
106            };
107            Ok::<_, std::io::Error>(std::process::Output {
108                status,
109                stdout,
110                stderr,
111            })
112        };
113        tokio::time::timeout(timeout, drain).await
114    };
115
116    match collected {
117        Ok(output) => output.map(Some),
118        Err(_) => {
119            let _ = child.kill().await;
120            Ok(None)
121        }
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn denies_the_secrets_the_sandbox_refuses_to_read() {
131        for name in [
132            "ANTHROPIC_API_KEY",
133            "OPENAI_API_KEY",
134            "GITHUB_TOKEN",
135            "APOLLO_TELEGRAM_TOKEN",
136            "APOLLO_DISCORD_TOKEN",
137            "apollo_telegram_token",
138            "AWS_SECRET_ACCESS_KEY",
139            "AWS_ACCESS_KEY_ID",
140            "DB_PASSWORD",
141            "NPM_TOKEN",
142            "GOOGLE_APPLICATION_CREDENTIALS",
143            "SSH_PRIVATE_KEY",
144            "SLACK_SIGNING_SECRET",
145            "HF_TOKEN",
146        ] {
147            assert!(is_secret_env_name(name), "should be denied: {name}");
148        }
149    }
150
151    #[test]
152    fn keeps_what_ordinary_tool_use_needs() {
153        for name in [
154            "PATH",
155            "HOME",
156            "USER",
157            "SHELL",
158            "TERM",
159            "LANG",
160            "LC_ALL",
161            "TMPDIR",
162            "PWD",
163            "CARGO_HOME",
164            "RUSTUP_HOME",
165            "GOPATH",
166            "PKG_CONFIG_PATH",
167            "VIRTUAL_ENV",
168            "HTTPS_PROXY",
169            "SSH_AUTH_SOCK",
170            "XDG_SESSION_TYPE",
171        ] {
172            assert!(!is_secret_env_name(name), "should be kept: {name}");
173        }
174    }
175
176    #[test]
177    fn child_env_carries_path_and_no_secret_names() {
178        let env = child_env();
179        assert!(env.iter().any(|(k, _)| k == "PATH"));
180        assert!(
181            !env.iter().any(|(k, _)| is_secret_env_name(k)),
182            "the filtered environment must contain no secret-bearing names"
183        );
184    }
185}