leviath_core/secrets.rs
1//! Which environment variables agent-supplied code may see.
2//!
3//! Two different questions live here, and they want opposite shapes:
4//!
5//! 1. **What does a child process inherit?** ([`child_env_allowed`]) We are
6//! *choosing what to hand over*, so an allowlist is right. A denylist here has
7//! to enumerate every secret name in the ecosystem and loses the moment a new
8//! one appears - which is exactly what happened: the MCP spawner's substring
9//! denylist passed `AWS_SECRET_ACCESS_KEY` (it matches neither `API_SECRET`
10//! nor `SECRET_KEY`), `GITHUB_TOKEN`, `NPM_TOKEN`, `DATABASE_URL`, and
11//! Leviath's own `LEVIATH_API_TOKEN`.
12//!
13//! 2. **May a script read *this named* variable?** ([`is_sensitive_env_name`]) A
14//! script asks for one name it already knows. An allowlist cannot work - no
15//! fixed list covers every legitimate variable a provider script might read -
16//! so the rule inverts: anything that *looks like* a credential is refused
17//! unless the user allowlisted it, and everything else is fine.
18//!
19//! Neither is a substitute for the other, and both are shared rather than
20//! reimplemented per call site so a gap gets fixed once.
21
22/// Compare two secrets without leaking their contents through timing.
23///
24/// Runs over the full length of both inputs rather than returning at the first
25/// differing byte, so an attacker cannot recover a token one character at a time
26/// by measuring how long a wrong guess takes. The *length* still leaks, which is
27/// fine: these are fixed-shape tokens, and refusing early on a length mismatch
28/// avoids indexing past the end.
29///
30/// Shared so every secret comparison in the workspace is the same one. The API
31/// server had a correct implementation; the OAuth callback's `state` check used
32/// `==` and was the only comparison that differed.
33#[must_use]
34pub fn constant_time_eq(a: &str, b: &str) -> bool {
35 let (a, b) = (a.as_bytes(), b.as_bytes());
36 if a.len() != b.len() {
37 return false;
38 }
39 let mut diff = 0u8;
40 for (x, y) in a.iter().zip(b) {
41 diff |= x ^ y;
42 }
43 diff == 0
44}
45
46/// Render a secret for display, showing only its last four characters.
47///
48/// The **one** redaction policy for the workspace. There were two, and they
49/// disagreed about which end of the value to keep: the HTTP logger showed the
50/// last four, the setup wizard the first eight. Two answers to "how much of a
51/// secret is safe to print" means neither is a policy - and a prefix is the
52/// wrong half to keep, because API keys are structured at the front:
53/// `sk-ant-a…`, `sk-proj-…`, `ghp_…` all identify the issuer and, for a short
54/// token, a meaningful fraction of the value.
55///
56/// Counts **characters**, not bytes. `value.len() - 4` can land inside a
57/// multi-byte character and panic, and a 5-byte
58/// 2-character value is longer than 4 *bytes*, so a byte-based length check
59/// would print the whole thing behind four stars.
60#[must_use]
61pub fn redact(value: &str) -> String {
62 let chars: Vec<char> = value.chars().collect();
63 // Four visible characters out of five or fewer is most of the value.
64 if chars.len() <= 8 {
65 return "****".to_string();
66 }
67 format!(
68 "****{}",
69 chars[chars.len() - 4..].iter().collect::<String>()
70 )
71}
72
73/// Whether a header's value must be redacted before it is logged.
74///
75/// A substring match rather than an exact-name list. The list version named
76/// `authorization`, `x-api-key` and `api-key`, and therefore logged Gemini's
77/// `x-goog-api-key` **in full** under `--features debug-http` - the one
78/// provider whose header did not happen to be on it. A denylist of exact names
79/// has to be complete to be correct, and this one was not; matching on the
80/// shape of the name fails safe as new headers appear.
81#[must_use]
82pub fn is_secret_header(name: &str) -> bool {
83 let lower = name.to_ascii_lowercase();
84 ["auth", "key", "token", "secret", "cookie", "credential"]
85 .iter()
86 .any(|hint| lower.contains(hint))
87}
88
89/// Environment variables a spawned child (an MCP server, a shell tool) inherits.
90///
91/// Deliberately short. A child needs enough to find its interpreter and behave
92/// like a terminal program; it does not need the ambient credentials of whoever
93/// started the daemon. Anything else a server legitimately requires is declared
94/// in its own `env` block in config, which is applied *after* this filter and so
95/// always wins - that is the supported way to pass a server its token.
96const CHILD_ENV_ALLOWLIST: &[&str] = &[
97 // Finding and running programs.
98 "PATH",
99 "HOME",
100 "SHELL",
101 "USER",
102 "LOGNAME",
103 "TMPDIR",
104 "TEMP",
105 "TMP",
106 // Locale and terminal behaviour.
107 "LANG",
108 "LANGUAGE",
109 "TERM",
110 "TZ",
111 "COLORTERM",
112 "NO_COLOR",
113 // Windows equivalents of the above.
114 "SYSTEMROOT",
115 "WINDIR",
116 "COMSPEC",
117 "PATHEXT",
118 "APPDATA",
119 "LOCALAPPDATA",
120 "PROGRAMFILES",
121 "PROGRAMDATA",
122 "USERPROFILE",
123 "HOMEDRIVE",
124 "HOMEPATH",
125 "NUMBER_OF_PROCESSORS",
126 "PROCESSOR_ARCHITECTURE",
127 "OS",
128];
129
130/// Whether a spawned child process may inherit `name`.
131///
132/// Case-insensitive, because Windows environment variables are.
133pub fn child_env_allowed(name: &str) -> bool {
134 CHILD_ENV_ALLOWLIST
135 .iter()
136 .any(|allowed| allowed.eq_ignore_ascii_case(name))
137}
138
139/// Substrings that make a variable name look like it holds a credential.
140///
141/// Matched case-insensitively anywhere in the name, so `AWS_SECRET_ACCESS_KEY`,
142/// `GH_TOKEN`, `npm_config_//registry:_authToken`, and `DATABASE_PASSWORD` all
143/// hit. Broad on purpose: a false positive costs the user one allowlist entry,
144/// a false negative costs them the credential.
145const SECRET_NAME_HINTS: &[&str] = &[
146 "TOKEN",
147 "SECRET",
148 "PASSWORD",
149 "PASSWD",
150 "PASSPHRASE",
151 "CREDENTIAL",
152 "APIKEY",
153 "API_KEY",
154 "ACCESS_KEY",
155 "PRIVATE_KEY",
156 // Bare `KEY`, which subsumes the three above and catches everything they
157 // missed: `OPENAI_KEY`, `ENCRYPTION_KEY`, `MASTER_KEY`, `DEPLOY_KEY`. The
158 // longer forms stay for documentation value. A variable whose name merely
159 // contains "key" and is not a secret (`KEYBOARD_LAYOUT`, `KEYCHAIN_PATH`)
160 // costs its owner one `allow_env_vars` entry, which is the trade this list
161 // is meant to make.
162 "KEY",
163 // A personal access token, which is what `_PAT` conventionally means.
164 "_PAT",
165 "SESSION",
166 "COOKIE",
167 "AUTH",
168 "BEARER",
169 "SIGNATURE",
170 "SIGNING",
171 // Sentry DSNs embed a key; `.netrc` and kubeconfigs are credential files.
172 "DSN",
173 "NETRC",
174 "KUBECONFIG",
175];
176
177/// Exact names that are sensitive without matching any of [`SECRET_NAME_HINTS`].
178const SECRET_NAME_EXACT: &[&str] = &[
179 // Connection strings routinely embed a username and password.
180 "DATABASE_URL",
181 "DATABASE_DSN",
182 "REDIS_URL",
183 "MONGO_URL",
184 "MONGODB_URI",
185 "AMQP_URL",
186 // Points at an agent socket that can sign on the user's behalf.
187 "SSH_AUTH_SOCK",
188];
189
190/// Prefixes whose whole namespace is treated as sensitive.
191const SECRET_NAME_PREFIXES: &[&str] = &[
192 // Leviath's own: `LEVIATH_API_TOKEN` authenticates the agent-spawning API,
193 // and `LEVIATH_CONFIG_PATH` / `LEVIATH_HOME` redirect where secrets are read
194 // from. None of it is a script's business.
195 "LEVIATH_", // Cloud SDK credential namespaces.
196 "AWS_", "AZURE_", "GOOGLE_", "GCP_",
197];
198
199/// Whether `name` looks like it holds a credential.
200///
201/// Used to decide whether an explicit `env_var("NAME")` read from an agent's
202/// Rhai script is refused. The check is on the *name*, never the value: a value
203/// test would have to read the secret to decide whether reading it was allowed.
204pub fn is_sensitive_env_name(name: &str) -> bool {
205 let upper = name.to_ascii_uppercase();
206 SECRET_NAME_HINTS.iter().any(|h| upper.contains(h))
207 || SECRET_NAME_EXACT.iter().any(|e| upper == *e)
208 || SECRET_NAME_PREFIXES.iter().any(|p| upper.starts_with(p))
209}
210
211/// Whether a script may read `name`, given the user's `[security] allow_env_vars`.
212///
213/// Non-credential names pass freely - a script reading `PATH`, `TZ`, or its own
214/// app's config variable is ordinary. A credential-shaped name passes only if the
215/// user listed it, which is them saying "yes, this agent is meant to have that".
216/// Matching the allowlist is case-insensitive and exact; no wildcards, because
217/// `allow_env_vars = ["*"]` would read as a shortcut rather than the decision it
218/// actually is.
219pub fn script_env_allowed(name: &str, allowlist: &[String]) -> bool {
220 !is_sensitive_env_name(name)
221 || allowlist
222 .iter()
223 .any(|allowed| allowed.eq_ignore_ascii_case(name))
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 /// Runs over the full length of both inputs rather than returning at the
231 /// first differing byte, so a wrong token cannot be recovered one character
232 /// at a time. The length still leaks, which is fine for fixed-shape tokens.
233 #[test]
234 fn constant_time_eq_matches_ordinary_equality() {
235 assert!(constant_time_eq("secret", "secret"));
236 assert!(constant_time_eq("", ""));
237 assert!(!constant_time_eq("secret", "secreu"));
238 // Differing at the very first byte and at the very last must both be
239 // false - the loop does not short-circuit either way.
240 assert!(!constant_time_eq("Xecret", "secret"));
241 assert!(!constant_time_eq("secreX", "secret"));
242 // Length mismatch is refused before indexing.
243 assert!(!constant_time_eq("secret", "secretx"));
244 assert!(!constant_time_eq("secretx", "secret"));
245 }
246
247 /// The suffix is kept, not the prefix: API keys are structured at the front,
248 /// so showing `sk-ant-a` names the issuer and, on a short token, exposes a
249 /// meaningful fraction of the value.
250 #[test]
251 fn redact_keeps_only_a_short_suffix() {
252 assert_eq!(redact("sk-ant-api-key-12345"), "****2345");
253 assert!(!redact("sk-ant-api-key-12345").contains("sk-ant"));
254 assert!(!redact("ghp_realgithubtoken").contains("ghp_"));
255 }
256
257 /// A short value is hidden entirely - four visible characters out of eight
258 /// is most of it.
259 #[test]
260 fn redact_hides_short_values_completely() {
261 for value in ["", "a", "abcd", "12345678"] {
262 assert_eq!(redact(value), "****", "{value:?}");
263 }
264 }
265
266 /// A byte-based cut lands inside a multi-byte character and panics, and a
267 /// byte-length guard calls a short multi-byte value "long" and prints all
268 /// of it.
269 #[test]
270 fn redact_counts_characters_not_bytes() {
271 assert_eq!(redact("日本語日本語日本語"), "****語日本語");
272 assert_eq!(redact("日本"), "****");
273 }
274
275 /// The exact-name list this replaces missed `x-goog-api-key`, so Gemini
276 /// keys were logged in full under `--features debug-http`.
277 #[test]
278 fn secret_headers_are_matched_by_shape_not_an_exact_list() {
279 for name in [
280 "authorization",
281 "Authorization",
282 "x-api-key",
283 "api-key",
284 "x-goog-api-key",
285 "proxy-authorization",
286 "cookie",
287 "set-cookie",
288 "x-auth-token",
289 "x-amz-security-token",
290 ] {
291 assert!(is_secret_header(name), "{name} must be redacted");
292 }
293 }
294
295 #[test]
296 fn ordinary_headers_are_not_redacted() {
297 for name in [
298 "content-type",
299 "user-agent",
300 "accept",
301 "content-length",
302 "anthropic-version",
303 ] {
304 assert!(!is_secret_header(name), "{name} should log verbatim");
305 }
306 }
307
308 /// Every one of these slipped through the substring denylist this replaces.
309 #[test]
310 fn catches_what_the_old_denylist_missed() {
311 for name in [
312 "AWS_SECRET_ACCESS_KEY",
313 "AWS_SESSION_TOKEN",
314 "GITHUB_TOKEN",
315 "GH_TOKEN",
316 "NPM_TOKEN",
317 "HF_TOKEN",
318 "SLACK_TOKEN",
319 "DATABASE_URL",
320 "LEVIATH_API_TOKEN",
321 "SSH_AUTH_SOCK",
322 ] {
323 assert!(is_sensitive_env_name(name), "{name} should be sensitive");
324 assert!(!child_env_allowed(name), "{name} must not reach a child");
325 }
326 }
327
328 #[test]
329 fn catches_the_provider_keys() {
330 for name in [
331 "ANTHROPIC_API_KEY",
332 "OPENAI_API_KEY",
333 "GOOGLE_API_KEY",
334 "OPENROUTER_API_KEY",
335 ] {
336 assert!(is_sensitive_env_name(name), "{name}");
337 }
338 }
339
340 /// The list is the whole of what stands between a script tool or an MCP
341 /// header and a credential, so a common secret name it misses is a leak.
342 /// `KEY` was absent, so `OPENAI_KEY` sailed through while `OPENAI_API_KEY`
343 /// was caught.
344 #[test]
345 fn common_secret_names_are_all_recognised() {
346 for name in [
347 "OPENAI_KEY",
348 "ENCRYPTION_KEY",
349 "MASTER_KEY",
350 "DEPLOY_KEY",
351 "ANTHROPIC_API_KEY",
352 "AWS_SECRET_ACCESS_KEY",
353 "GITHUB_TOKEN",
354 "GITHUB_PAT",
355 "SENTRY_DSN",
356 "NETRC",
357 "KUBECONFIG",
358 "npm_password",
359 ] {
360 assert!(
361 is_sensitive_env_name(name),
362 "{name} must be treated as a secret"
363 );
364 }
365
366 // And the list has not become "everything": ordinary variables an agent
367 // legitimately reads still pass.
368 for name in [
369 "PATH",
370 "HOME",
371 "LANG",
372 "TERM",
373 "TZ",
374 "EDITOR",
375 "OLLAMA_HOST",
376 ] {
377 assert!(!is_sensitive_env_name(name), "{name} is not a secret");
378 }
379 }
380
381 #[test]
382 fn matching_is_case_insensitive() {
383 assert!(is_sensitive_env_name("github_token"));
384 assert!(is_sensitive_env_name("MyApp_Password"));
385 assert!(child_env_allowed("path"));
386 assert!(child_env_allowed("Path"));
387 }
388
389 #[test]
390 fn ordinary_names_are_not_sensitive() {
391 for name in ["PATH", "HOME", "TZ", "TERM", "EDITOR", "MY_APP_REGION"] {
392 assert!(!is_sensitive_env_name(name), "{name}");
393 }
394 }
395
396 #[test]
397 fn child_allowlist_is_the_short_list_not_the_environment() {
398 assert!(child_env_allowed("PATH"));
399 assert!(child_env_allowed("LANG"));
400 // Not sensitive, but still not a child's business by default - the point
401 // of an allowlist is that unknown names are excluded, not just secret
402 // ones. A server that needs it declares it in its own `env` block.
403 assert!(!child_env_allowed("MY_APP_REGION"));
404 assert!(!child_env_allowed("EDITOR"));
405 }
406
407 #[test]
408 fn script_reads_pass_unless_credential_shaped() {
409 let none: &[String] = &[];
410 assert!(script_env_allowed("PATH", none));
411 assert!(script_env_allowed("MY_APP_REGION", none));
412 assert!(!script_env_allowed("ANTHROPIC_API_KEY", none));
413 }
414
415 #[test]
416 fn allowlisting_a_credential_permits_exactly_that_one() {
417 let allow = vec!["MY_PROVIDER_KEY".to_string()];
418 assert!(script_env_allowed("MY_PROVIDER_KEY", &allow));
419 assert!(script_env_allowed("my_provider_key", &allow), "case");
420 assert!(!script_env_allowed("ANTHROPIC_API_KEY", &allow));
421 // No wildcard support: `*` is a literal name, not "everything".
422 assert!(!script_env_allowed("ANTHROPIC_API_KEY", &["*".to_string()]));
423 }
424}