Skip to main content

sqlite_graphrag/spawn/
env_whitelist.rs

1//! Env whitelist for LLM subprocess spawners (v1.0.83, ADR-0041).
2//!
3//! Unifies the duplicated `env_clear()` + re-injection logic that previously
4//! lived in `src/commands/{claude_runner,codex_spawn,ingest_claude}.rs`.
5//!
6//! ## OAuth-only mandate preserved
7//!
8//! `ANTHROPIC_API_KEY` and `OPENAI_API_KEY` are INTENTIONALLY ABSENT —
9//! rejected by upstream guards in `claude_runner.rs`, `codex_spawn.rs`,
10//! `ingest_claude.rs` and `extract/llm_embedding.rs` per ADR-0011, ADR-0025
11//! and ADR-0041. The guards reject these vars regardless of whether they
12//! reach the subprocess; the env whitelist is the SECOND line of defence.
13//!
14//! ## Custom provider support (v1.0.83)
15//!
16//! `ANTHROPIC_AUTH_TOKEN` and `ANTHROPIC_BASE_URL` are preserved so that
17//! Claude Code can authenticate against a custom Anthropic-compatible
18//! endpoint (MiniMax/api.minimax.io, OpenRouter, corporate gateways). The
19//! `--bare` flag remains PROHIBITED — these vars only flow to the
20//! subprocess when the user opts into a custom provider via env vars.
21//!
22//! ## Strict mode (compliance)
23//!
24//! When `SQLITE_GRAPHRAG_STRICT_ENV_CLEAR=1` (or `--strict-env-clear` flag)
25//! is active, only `PATH` is preserved. This covers environments that
26//! forbid credential forwarding via env vars entirely.
27
28use std::process::Command;
29
30/// Environment variables preserved when spawning Claude/Codex subprocesses.
31///
32/// Order is purely cosmetic — `env_clear()` followed by per-var `env()` is
33/// independent of iteration order.
34pub const PRESERVED_ENV_VARS: &[&str] = &[
35    // Standard POSIX / XDG base directory
36    "PATH",
37    "HOME",
38    "USER",
39    "SHELL",
40    "TERM",
41    "LANG",
42    "XDG_CONFIG_HOME",
43    "XDG_DATA_HOME",
44    "XDG_RUNTIME_DIR",
45    "XDG_CACHE_HOME",
46    // Temporary directories
47    "TMPDIR",
48    "TMP",
49    "TEMP",
50    // macOS dynamic linker fallback path
51    "DYLD_FALLBACK_LIBRARY_PATH",
52    // Claude Code specific
53    "CLAUDE_CONFIG_DIR",
54    // v1.0.83 (ADR-0041): custom provider credentials for Claude Code
55    "ANTHROPIC_AUTH_TOKEN",
56    "ANTHROPIC_BASE_URL",
57    "CLAUDE_CODE_ENTRYPOINT",
58    // v1.0.83 (ADR-0041): custom provider credentials for Codex CLI
59    "CODEX_ACCESS_TOKEN",
60    "OPENAI_BASE_URL",
61    // v1.1.8 G-T-TEL-01: do NOT forward OTEL / remote telemetry env vars to
62    // subprocesses. Local logging only; no phone-home.
63];
64
65/// Windows-only environment variables preserved alongside the POSIX set.
66#[cfg(windows)]
67pub const PRESERVED_ENV_VARS_WINDOWS: &[&str] = &[
68    "LOCALAPPDATA",
69    "APPDATA",
70    "USERPROFILE",
71    "SystemRoot",
72    "COMSPEC",
73    "PATHEXT",
74    "HOMEPATH",
75    "HOMEDRIVE",
76];
77
78/// Apply the v1.0.83 env whitelist to a `Command`.
79///
80/// In strict mode, only `PATH` is preserved (compliance environments).
81/// In default mode, the full `PRESERVED_ENV_VARS` set is applied.
82pub fn apply_env_whitelist(cmd: &mut Command, strict: bool) {
83    cmd.env_clear();
84    if strict {
85        if let Ok(path) = std::env::var("PATH") {
86            cmd.env("PATH", path);
87        }
88        return;
89    }
90    for var in PRESERVED_ENV_VARS {
91        if let Ok(val) = std::env::var(var) {
92            cmd.env(var, val);
93        }
94    }
95    #[cfg(windows)]
96    for var in PRESERVED_ENV_VARS_WINDOWS {
97        if let Ok(val) = std::env::var(var) {
98            cmd.env(var, val);
99        }
100    }
101}
102
103/// Detect whether strict env-clear mode is requested.
104///
105/// Returns true when `SQLITE_GRAPHRAG_STRICT_ENV_CLEAR` is `1`, `true`,
106/// `TRUE` or `yes` (case-insensitive for `true`/`yes`).
107pub fn is_strict_env_clear() -> bool {
108    if crate::runtime_config::get().strict_env_clear {
109        return true;
110    }
111    crate::config::get_setting("spawn.strict_env_clear")
112        .ok()
113        .flatten()
114        .is_some_and(|v| {
115            matches!(
116                v.trim().to_ascii_lowercase().as_str(),
117                "1" | "true" | "yes" | "on"
118            )
119        })
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    /// Helper that records the env vars set on a Command without spawning it.
127    fn captured_env(cmd: &Command) -> Vec<(String, String)> {
128        cmd.get_envs()
129            .filter_map(|(k, v)| {
130                let k = k.to_str()?.to_string();
131                let v = v?.to_str()?.to_string();
132                Some((k, v))
133            })
134            .collect()
135    }
136
137    #[test]
138    #[serial_test::serial(env)]
139    fn whitelist_includes_custom_provider_vars() {
140        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
141        unsafe {
142            std::env::set_var("ANTHROPIC_AUTH_TOKEN", "sk-cp-test");
143            std::env::set_var("ANTHROPIC_BASE_URL", "https://api.minimax.io/anthropic");
144            std::env::set_var("OPENAI_BASE_URL", "https://api.openrouter.ai/v1");
145        }
146        let mut cmd = std::process::Command::new("/usr/bin/false");
147        apply_env_whitelist(&mut cmd, false);
148        let envs = captured_env(&cmd);
149        let has_token = envs
150            .iter()
151            .any(|(k, v)| k == "ANTHROPIC_AUTH_TOKEN" && v == "sk-cp-test");
152        let has_anthropic_url = envs
153            .iter()
154            .any(|(k, v)| k == "ANTHROPIC_BASE_URL" && v == "https://api.minimax.io/anthropic");
155        let has_openai_url = envs
156            .iter()
157            .any(|(k, v)| k == "OPENAI_BASE_URL" && v == "https://api.openrouter.ai/v1");
158        unsafe {
159            std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
160            std::env::remove_var("ANTHROPIC_BASE_URL");
161            std::env::remove_var("OPENAI_BASE_URL");
162        }
163        assert!(has_token, "ANTHROPIC_AUTH_TOKEN not preserved");
164        assert!(has_anthropic_url, "ANTHROPIC_BASE_URL not preserved");
165        assert!(has_openai_url, "OPENAI_BASE_URL not preserved");
166    }
167
168    #[test]
169    #[serial_test::serial(env)]
170    fn whitelist_excludes_api_key_vars() {
171        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
172        unsafe {
173            std::env::set_var("ANTHROPIC_API_KEY", "sk-ant-violation");
174            std::env::set_var("OPENAI_API_KEY", "sk-violation");
175        }
176        let mut cmd = std::process::Command::new("/usr/bin/false");
177        apply_env_whitelist(&mut cmd, false);
178        let envs = captured_env(&cmd);
179        let has_anthropic_key = envs.iter().any(|(k, _)| k == "ANTHROPIC_API_KEY");
180        let has_openai_key = envs.iter().any(|(k, _)| k == "OPENAI_API_KEY");
181        unsafe {
182            std::env::remove_var("ANTHROPIC_API_KEY");
183            std::env::remove_var("OPENAI_API_KEY");
184        }
185        assert!(
186            !has_anthropic_key,
187            "ANTHROPIC_API_KEY must NEVER reach subprocess"
188        );
189        assert!(
190            !has_openai_key,
191            "OPENAI_API_KEY must NEVER reach subprocess"
192        );
193    }
194
195    #[test]
196    #[serial_test::serial(env)]
197    fn strict_mode_drops_credentials() {
198        // SAFETY: serial_test::serial(env) ensures no parallel mutation.
199        unsafe {
200            std::env::set_var("ANTHROPIC_AUTH_TOKEN", "sk-cp-strict-test");
201            std::env::set_var("PATH", "/usr/bin:/bin");
202        }
203        let mut cmd = std::process::Command::new("/usr/bin/false");
204        apply_env_whitelist(&mut cmd, true);
205        let envs = captured_env(&cmd);
206        let has_token = envs.iter().any(|(k, _)| k == "ANTHROPIC_AUTH_TOKEN");
207        let has_path = envs
208            .iter()
209            .any(|(k, v)| k == "PATH" && v == "/usr/bin:/bin");
210        unsafe {
211            std::env::remove_var("ANTHROPIC_AUTH_TOKEN");
212        }
213        assert!(!has_token, "strict mode must drop credentials");
214        assert!(has_path, "strict mode preserves PATH only");
215    }
216}