ai_agent/utils/subprocess_env.rs
1//! Env vars to strip from subprocess environments when running inside GitHub
2//! Actions. This prevents prompt-injection attacks from exfiltrating secrets
3//! via shell expansion (e.g., ${AI_API_KEY}) in Bash tool commands.
4//!
5//! The parent claude process keeps these vars (needed for API calls, lazy
6//! credential reads). Only child processes (bash, shell snapshot, MCP stdio, LSP, hooks) are scrubbed.
7//!
8//! GITHUB_TOKEN / GH_TOKEN are intentionally NOT scrubbed — wrapper scripts
9//! (gh.sh) need them to call the GitHub API. That token is job-scoped and
10//! expires when the workflow ends.
11
12use crate::constants::env::{ai, ai_code};
13use std::collections::HashMap;
14use std::env;
15
16/// Env vars to strip from subprocess environments when running inside GitHub Actions
17pub static GHA_SUBPROCESS_SCRUB: &[&str] = &[
18 // Anthropic auth — claude re-reads these per-request, subprocesses don't need them
19 ai::API_KEY,
20 ai_code::OAUTH_TOKEN,
21 ai::AUTH_TOKEN,
22 ai::FOUNDRY_API_KEY,
23 "ANTHROPIC_CUSTOM_HEADERS",
24 // OTLP exporter headers — documented to carry Authorization=Bearer tokens
25 // for monitoring backends; read in-process by OTEL SDK, subprocesses never need them
26 "OTEL_EXPORTER_OTLP_HEADERS",
27 "OTEL_EXPORTER_OTLP_LOGS_HEADERS",
28 "OTEL_EXPORTER_OTLP_METRICS_HEADERS",
29 "OTEL_EXPORTER_OTLP_TRACES_HEADERS",
30 // Cloud provider creds — same pattern (lazy SDK reads)
31 "AWS_SECRET_ACCESS_KEY",
32 "AWS_SESSION_TOKEN",
33 "AWS_BEARER_TOKEN_BEDROCK",
34 "GOOGLE_APPLICATION_CREDENTIALS",
35 "AZURE_CLIENT_SECRET",
36 "AZURE_CLIENT_CERTIFICATE_PATH",
37 // GitHub Actions OIDC — consumed by the action's JS before claude spawns;
38 // leaking these allows minting an App installation token → repo takeover
39 "ACTIONS_ID_TOKEN_REQUEST_TOKEN",
40 "ACTIONS_ID_TOKEN_REQUEST_URL",
41 // GitHub Actions artifact/cache API — cache poisoning → supply-chain pivot
42 "ACTIONS_RUNTIME_TOKEN",
43 "ACTIONS_RUNTIME_URL",
44 // claude-code-action-specific duplicates — action JS consumes these during
45 // prepare, before spawning claude. ALL_INPUTS contains anthropic_api_key as JSON.
46 "ALL_INPUTS",
47 "OVERRIDE_GITHUB_TOKEN",
48 "DEFAULT_WORKFLOW_TOKEN",
49 "SSH_SIGNING_KEY",
50];
51
52/// Registered by init.ts after the upstreamproxy module is dynamically imported
53/// in CCR sessions. Stays None in non-CCR startups so we never pull in the
54/// upstreamproxy module graph (upstreamproxy.ts + relay.ts) via a static import.
55static UPSTREAM_PROXY_ENV_FN: std::sync::OnceLock<
56 Box<dyn Fn() -> HashMap<String, String> + Send + Sync>,
57> = std::sync::OnceLock::new();
58
59/// Called from init.ts to wire up the proxy env function after the upstreamproxy
60/// module has been lazily loaded. Must be called before any subprocess is spawned.
61pub fn register_upstream_proxy_env_fn<F>(fn_: F)
62where
63 F: Fn() -> HashMap<String, String> + Send + Sync + 'static,
64{
65 let _ = UPSTREAM_PROXY_ENV_FN.set(Box::new(fn_));
66}
67
68/// Returns a copy of process.env with sensitive secrets stripped, for use when
69/// spawning subprocesses (Bash tool, shell snapshot, MCP stdio servers, LSP
70/// servers, shell hooks).
71///
72/// Gated on AI_CODE_SUBPROCESS_ENV_SCRUB. claude-code-action sets this
73/// automatically when `allowed_non_write_users` is configured — the flag that
74/// exposes a workflow to untrusted content (prompt injection surface).
75pub fn subprocess_env() -> HashMap<String, String> {
76 // CCR upstreamproxy: inject HTTPS_PROXY + CA bundle vars so curl/gh/python
77 // in agent subprocesses route through the local relay. Returns empty when the
78 // proxy is disabled or not registered (non-CCR), so this is a no-op outside
79 // CCR containers.
80 let proxy_env = UPSTREAM_PROXY_ENV_FN.get().map(|f| f()).unwrap_or_default();
81
82 let should_scrub = env::var("AI_CODE_SUBPROCESS_ENV_SCRUB")
83 .map(|v| v == "1" || v.to_lowercase() == "true")
84 .unwrap_or(false);
85
86 if !should_scrub {
87 if proxy_env.is_empty() {
88 return env::vars().collect();
89 }
90 let mut env: HashMap<String, String> = env::vars().collect();
91 env.extend(proxy_env);
92 return env;
93 }
94
95 let mut env: HashMap<String, String> = env::vars().collect();
96 env.extend(proxy_env);
97
98 for k in GHA_SUBPROCESS_SCRUB {
99 env.remove(*k);
100 // GitHub Actions auto-creates INPUT_<NAME> for `with:` inputs, duplicating
101 // secrets like INPUT_ANTHROPIC_API_KEY. No-op for vars that aren't action inputs.
102 env.remove(&format!("INPUT_{}", k));
103 }
104
105 env
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn test_subprocess_env_no_scrub() {
114 // When AI_CODE_SUBPROCESS_ENV_SCRUB is not set, should return all env vars
115 let env = subprocess_env();
116 assert!(!env.is_empty());
117 }
118
119 #[test]
120 fn test_subprocess_env_with_scrub() {
121 // Set the scrub flag
122 unsafe { env::set_var("AI_CODE_SUBPROCESS_ENV_SCRUB", "1") };
123
124 let env = subprocess_env();
125
126 // These should be removed
127 assert!(!env.contains_key(ai::API_KEY));
128 assert!(!env.contains_key("AWS_SECRET_ACCESS_KEY"));
129
130 // PATH should still be there
131 assert!(env.contains_key("PATH"));
132
133 // Clean up
134 unsafe { env::remove_var("AI_CODE_SUBPROCESS_ENV_SCRUB") };
135 }
136}