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