leviath_core/secrets.rs
1//! Which environment variables agent-supplied code may see.
2//!
3//! Three different questions live here, and the first two 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//! 3. **May a `.env` in the working directory set this variable?**
20//! ([`dotenv_var_allowed`]) Neither shape above fits. The whole point of
21//! loading a `.env` is to pick up credentials, so the credential test from
22//! (2) would refuse exactly what the feature exists for; and the names worth
23//! refusing are not secrets at all but the handful that *steer the process* -
24//! where config is read from, what gets executed. So this one is a small,
25//! closed denylist of process-steering names, and everything else passes.
26//!
27//! None is a substitute for the others, and all are shared rather than
28//! reimplemented per call site so a gap gets fixed once.
29
30/// Compare two secrets without leaking their contents through timing.
31///
32/// Runs over the full length of both inputs rather than returning at the first
33/// differing byte, so an attacker cannot recover a token one character at a time
34/// by measuring how long a wrong guess takes. The *length* still leaks, which is
35/// fine: these are fixed-shape tokens, and refusing early on a length mismatch
36/// avoids indexing past the end.
37///
38/// Shared so every secret comparison in the workspace is the same one. The API
39/// server had a correct implementation; the OAuth callback's `state` check used
40/// `==` and was the only comparison that differed.
41#[must_use]
42pub fn constant_time_eq(a: &str, b: &str) -> bool {
43 let (a, b) = (a.as_bytes(), b.as_bytes());
44 if a.len() != b.len() {
45 return false;
46 }
47 let mut diff = 0u8;
48 for (x, y) in a.iter().zip(b) {
49 diff |= x ^ y;
50 }
51 diff == 0
52}
53
54/// Render a secret for display, showing only its last four characters.
55///
56/// The **one** redaction policy for the workspace. There were two, and they
57/// disagreed about which end of the value to keep: the HTTP logger showed the
58/// last four, the setup wizard the first eight. Two answers to "how much of a
59/// secret is safe to print" means neither is a policy - and a prefix is the
60/// wrong half to keep, because API keys are structured at the front:
61/// `sk-ant-a…`, `sk-proj-…`, `ghp_…` all identify the issuer and, for a short
62/// token, a meaningful fraction of the value.
63///
64/// Counts **characters**, not bytes. `value.len() - 4` can land inside a
65/// multi-byte character and panic, and a 5-byte
66/// 2-character value is longer than 4 *bytes*, so a byte-based length check
67/// would print the whole thing behind four stars.
68#[must_use]
69pub fn redact(value: &str) -> String {
70 let chars: Vec<char> = value.chars().collect();
71 // Four visible characters out of five or fewer is most of the value.
72 if chars.len() <= 8 {
73 return "****".to_string();
74 }
75 format!(
76 "****{}",
77 chars[chars.len() - 4..].iter().collect::<String>()
78 )
79}
80
81/// Whether a header's value must be redacted before it is logged.
82///
83/// A substring match rather than an exact-name list. The list version named
84/// `authorization`, `x-api-key` and `api-key`, and therefore logged Gemini's
85/// `x-goog-api-key` **in full** under `--features debug-http` - the one
86/// provider whose header did not happen to be on it. A denylist of exact names
87/// has to be complete to be correct, and this one was not; matching on the
88/// shape of the name fails safe as new headers appear.
89#[must_use]
90pub fn is_secret_header(name: &str) -> bool {
91 let lower = name.to_ascii_lowercase();
92 ["auth", "key", "token", "secret", "cookie", "credential"]
93 .iter()
94 .any(|hint| lower.contains(hint))
95}
96
97/// Environment variables a spawned child (an MCP server, a shell tool) inherits.
98///
99/// Deliberately short. A child needs enough to find its interpreter and behave
100/// like a terminal program; it does not need the ambient credentials of whoever
101/// started the daemon. Anything else a server legitimately requires is declared
102/// in its own `env` block in config, which is applied *after* this filter and so
103/// always wins - that is the supported way to pass a server its token.
104const CHILD_ENV_ALLOWLIST: &[&str] = &[
105 // Finding and running programs.
106 "PATH",
107 "HOME",
108 "SHELL",
109 "USER",
110 "LOGNAME",
111 "TMPDIR",
112 "TEMP",
113 "TMP",
114 // Locale and terminal behaviour.
115 "LANG",
116 "LANGUAGE",
117 "TERM",
118 "TZ",
119 "COLORTERM",
120 "NO_COLOR",
121 // Windows equivalents of the above.
122 "SYSTEMROOT",
123 "WINDIR",
124 "COMSPEC",
125 "PATHEXT",
126 "APPDATA",
127 "LOCALAPPDATA",
128 "PROGRAMFILES",
129 "PROGRAMDATA",
130 "USERPROFILE",
131 "HOMEDRIVE",
132 "HOMEPATH",
133 "NUMBER_OF_PROCESSORS",
134 "PROCESSOR_ARCHITECTURE",
135 "OS",
136];
137
138/// Whether a spawned child process may inherit `name`.
139///
140/// Case-insensitive, because Windows environment variables are.
141pub fn child_env_allowed(name: &str) -> bool {
142 CHILD_ENV_ALLOWLIST
143 .iter()
144 .any(|allowed| allowed.eq_ignore_ascii_case(name))
145}
146
147/// Substrings that make a variable name look like it holds a credential.
148///
149/// Matched case-insensitively anywhere in the name, so `AWS_SECRET_ACCESS_KEY`,
150/// `GH_TOKEN`, `npm_config_//registry:_authToken`, and `DATABASE_PASSWORD` all
151/// hit. Broad on purpose: a false positive costs the user one allowlist entry,
152/// a false negative costs them the credential.
153const SECRET_NAME_HINTS: &[&str] = &[
154 "TOKEN",
155 "SECRET",
156 "PASSWORD",
157 "PASSWD",
158 "PASSPHRASE",
159 "CREDENTIAL",
160 "APIKEY",
161 "API_KEY",
162 "ACCESS_KEY",
163 "PRIVATE_KEY",
164 // Bare `KEY`, which subsumes the three above and catches everything they
165 // missed: `OPENAI_KEY`, `ENCRYPTION_KEY`, `MASTER_KEY`, `DEPLOY_KEY`. The
166 // longer forms stay for documentation value. A variable whose name merely
167 // contains "key" and is not a secret (`KEYBOARD_LAYOUT`, `KEYCHAIN_PATH`)
168 // costs its owner one `allow_env_vars` entry, which is the trade this list
169 // is meant to make.
170 "KEY",
171 // A personal access token, which is what `_PAT` conventionally means.
172 "_PAT",
173 "SESSION",
174 "COOKIE",
175 "AUTH",
176 "BEARER",
177 "SIGNATURE",
178 "SIGNING",
179 // Sentry DSNs embed a key; `.netrc` and kubeconfigs are credential files.
180 "DSN",
181 "NETRC",
182 "KUBECONFIG",
183];
184
185/// Exact names that are sensitive without matching any of [`SECRET_NAME_HINTS`].
186const SECRET_NAME_EXACT: &[&str] = &[
187 // Connection strings routinely embed a username and password.
188 "DATABASE_URL",
189 "DATABASE_DSN",
190 "REDIS_URL",
191 "MONGO_URL",
192 "MONGODB_URI",
193 "AMQP_URL",
194 // Points at an agent socket that can sign on the user's behalf.
195 "SSH_AUTH_SOCK",
196];
197
198/// Prefixes whose whole namespace is treated as sensitive.
199const SECRET_NAME_PREFIXES: &[&str] = &[
200 // Leviath's own: `LEVIATH_API_TOKEN` authenticates the agent-spawning API,
201 // and `LEVIATH_CONFIG_PATH` / `LEVIATH_HOME` redirect where secrets are read
202 // from. None of it is a script's business.
203 "LEVIATH_", // Cloud SDK credential namespaces.
204 "AWS_", "AZURE_", "GOOGLE_", "GCP_",
205];
206
207/// Exact names a `./.env` may not set, because each one steers the process
208/// rather than configuring it.
209/// A denylist has to be complete to be correct, and this one cannot be: a
210/// `.env` legitimately carries arbitrary application config, so there is no
211/// allowlist to invert to. What it can do is cover the *known* ways a file in a
212/// cloned repository turns an ordinary command into an arbitrary one. A new one
213/// belongs here when it appears; the durable answer for an untrusted repository
214/// is `[sandbox]`, not this list.
215const DOTENV_DENY_EXACT: &[&str] = &[
216 // Split and spawned as a program by the editor flow.
217 "EDITOR",
218 "VISUAL",
219 // Decide what a shell tool, an MCP server command, or a seed command
220 // resolves to.
221 "PATH",
222 "SHELL",
223 // Read by the shell itself before it runs anything. `BASH_ENV` is sourced
224 // by non-interactive bash, which is how the shell tool invokes it.
225 "BASH_ENV",
226 "ENV",
227 // The read-only `git` subcommands are on the default safe list, so anything
228 // that makes git run a program of the repository's choosing is a complete
229 // unprompted-execution chain - `git status` is enough to fire it.
230 "GIT_EXTERNAL_DIFF",
231 "GIT_SSH",
232 "GIT_SSH_COMMAND",
233 "GIT_PAGER",
234 "GIT_CONFIG_GLOBAL",
235 "GIT_CONFIG_SYSTEM",
236 // Pagers and openers other tools shell out to.
237 "PAGER",
238 "MANPAGER",
239 "LESSOPEN",
240 "LESSCLOSE",
241 // Language runtimes that load code named by an environment variable.
242 "NODE_OPTIONS",
243 "PYTHONSTARTUP",
244 "PYTHONPATH",
245 "PERL5OPT",
246 "PERL5LIB",
247 "RUBYOPT",
248 "JAVA_TOOL_OPTIONS",
249 "_JAVA_OPTIONS",
250 "RUSTC_WRAPPER",
251 "RUSTC",
252];
253
254/// Prefixes a `./.env` may not set.
255const DOTENV_DENY_PREFIXES: &[&str] = &[
256 // `LEVIATH_CONFIG_PATH` and `LEVIATH_HOME` relocate where config, agents and
257 // scripts are read from, so setting either replaces the whole trust base:
258 // `[mcp_servers]` commands, `[tool_permissions]`, `[sandbox]`, provider
259 // `base_url`. `LEVIATH_API_TOKEN` sets a known credential on the
260 // agent-spawning API. None of it is a repository's business.
261 "LEVIATH_", // Injected into every child the dynamic linker starts.
262 "LD_", "DYLD_",
263];
264
265/// Whether a `./.env` in the working directory may set `name`.
266///
267/// `lev` is designed to be run inside cloned repositories, so `./.env` is
268/// attacker-authored content on any repository the user did not write. dotenvy
269/// does not override an already-set variable, which protects `PATH` and `HOME`
270/// in practice - but not a variable that is normally *unset*, and the ones that
271/// matter most here are exactly those.
272///
273/// Deliberately **not** built on [`is_sensitive_env_name`]: that would refuse
274/// `ANTHROPIC_API_KEY`, which is the entire legitimate purpose of loading a
275/// `.env`. Credentials are what this feature is for; the denylist is only the
276/// names that decide where config comes from and what gets executed.
277///
278/// `OLLAMA_HOST` and `*_BASE_URL`-shaped names are a deliberate edge, left
279/// allowed: pointing your own inference endpoint from a repository's `.env` is
280/// something people do on purpose, and a repository that can already choose your
281/// model can already choose your outputs.
282#[must_use]
283pub fn dotenv_var_allowed(name: &str) -> bool {
284 let upper = name.to_ascii_uppercase();
285 !DOTENV_DENY_EXACT.contains(&upper.as_str())
286 && !DOTENV_DENY_PREFIXES.iter().any(|p| upper.starts_with(p))
287}
288
289/// How much of the daemon's environment a shell tool inherits.
290///
291/// A fourth question again, and a fourth shape. A shell tool is a child we hand
292/// over to, like an MCP server - but unlike one, it must keep behaving like the
293/// user's own shell, so [`child_env_allowed`]'s 28-name allowlist is wrong here:
294/// it would strip `CARGO_HOME`, `JAVA_HOME`, `NVM_DIR`, `VIRTUAL_ENV`, `GOPATH`
295/// and break every real toolchain. The name-shape denylist is the right
296/// instrument, and the only real question is how far it reaches.
297///
298/// Be honest about what this buys. With `cat` and `grep` on the default safe
299/// list, a granted shell can read `~/.leviath/config.toml` and find the provider
300/// key anyway. This is defence in depth against *accidental* leakage - an env
301/// dump in tool output, a `printenv` in a log, a subprocess that phones home -
302/// and it closes the seed-command case, where nothing was ever approved. It is
303/// not a boundary.
304#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
305#[serde(rename_all = "lowercase")]
306pub enum ShellEnvMode {
307 /// Withhold credential-shaped names, but hand over `SSH_AUTH_SOCK`.
308 ///
309 /// The carve-out is deliberate and is why this can be the default: the
310 /// agent socket is on the credential-name list, and withholding it breaks
311 /// `git push` over agent keys, which is one of the most ordinary things an
312 /// agent does in a shell.
313 #[default]
314 Filtered,
315 /// The full name-shape denylist, `SSH_AUTH_SOCK` included - and with it
316 /// `AWS_PROFILE`, `AWS_REGION`, `KUBECONFIG`, `NETRC`. Breaks `git push`,
317 /// `aws` and `kubectl` in a shell tool until those names are listed in
318 /// `[security] allow_env_vars`.
319 Strict,
320 /// Ignore the shape heuristic entirely: withhold exactly what
321 /// `[security] shell_env_withhold` names, and nothing else. For an
322 /// environment whose variable names the heuristic reads wrong in either
323 /// direction.
324 Custom,
325 /// Hand the whole environment over, as before this setting existed.
326 Inherit,
327}
328
329/// The variables in `names` a shell tool must not inherit.
330///
331/// Returns names rather than mutating a `Command`, so the decision is a pure
332/// function testable without spawning anything - which is what keeps its tests
333/// deterministic on Windows.
334///
335/// `allow_env_vars` wins under every mode. It already means "yes, this agent is
336/// meant to have that" for a Rhai `env_var` read, and one list with one meaning
337/// is worth more than a second list that means almost the same thing.
338pub fn withheld_child_vars<'a>(
339 names: impl Iterator<Item = &'a str>,
340 mode: ShellEnvMode,
341 allow_env_vars: &[String],
342 custom_withhold: &[String],
343) -> Vec<String> {
344 let listed = |list: &[String], name: &str| list.iter().any(|e| e.eq_ignore_ascii_case(name));
345 names
346 .filter(|name| match mode {
347 ShellEnvMode::Inherit => false,
348 ShellEnvMode::Custom => listed(custom_withhold, name) && !listed(allow_env_vars, name),
349 ShellEnvMode::Filtered if name.eq_ignore_ascii_case("SSH_AUTH_SOCK") => false,
350 ShellEnvMode::Filtered | ShellEnvMode::Strict => {
351 !script_env_allowed(name, allow_env_vars)
352 }
353 })
354 .map(str::to_string)
355 .collect()
356}
357
358/// Whether `name` looks like it holds a credential.
359///
360/// Used to decide whether an explicit `env_var("NAME")` read from an agent's
361/// Rhai script is refused. The check is on the *name*, never the value: a value
362/// test would have to read the secret to decide whether reading it was allowed.
363pub fn is_sensitive_env_name(name: &str) -> bool {
364 let upper = name.to_ascii_uppercase();
365 SECRET_NAME_HINTS.iter().any(|h| upper.contains(h))
366 || SECRET_NAME_EXACT.iter().any(|e| upper == *e)
367 || SECRET_NAME_PREFIXES.iter().any(|p| upper.starts_with(p))
368}
369
370/// Whether a script may read `name`, given the user's `[security] allow_env_vars`.
371///
372/// Non-credential names pass freely - a script reading `PATH`, `TZ`, or its own
373/// app's config variable is ordinary. A credential-shaped name passes only if the
374/// user listed it, which is them saying "yes, this agent is meant to have that".
375/// Matching the allowlist is case-insensitive and exact; no wildcards, because
376/// `allow_env_vars = ["*"]` would read as a shortcut rather than the decision it
377/// actually is.
378pub fn script_env_allowed(name: &str, allowlist: &[String]) -> bool {
379 !is_sensitive_env_name(name)
380 || allowlist
381 .iter()
382 .any(|allowed| allowed.eq_ignore_ascii_case(name))
383}
384
385#[cfg(test)]
386mod tests {
387 use super::*;
388
389 /// Runs over the full length of both inputs rather than returning at the
390 /// first differing byte, so a wrong token cannot be recovered one character
391 /// at a time. The length still leaks, which is fine for fixed-shape tokens.
392 #[test]
393 fn constant_time_eq_matches_ordinary_equality() {
394 assert!(constant_time_eq("secret", "secret"));
395 assert!(constant_time_eq("", ""));
396 assert!(!constant_time_eq("secret", "secreu"));
397 // Differing at the very first byte and at the very last must both be
398 // false - the loop does not short-circuit either way.
399 assert!(!constant_time_eq("Xecret", "secret"));
400 assert!(!constant_time_eq("secreX", "secret"));
401 // Length mismatch is refused before indexing.
402 assert!(!constant_time_eq("secret", "secretx"));
403 assert!(!constant_time_eq("secretx", "secret"));
404 }
405
406 /// The suffix is kept, not the prefix: API keys are structured at the front,
407 /// so showing `sk-ant-a` names the issuer and, on a short token, exposes a
408 /// meaningful fraction of the value.
409 #[test]
410 fn redact_keeps_only_a_short_suffix() {
411 assert_eq!(redact("sk-ant-api-key-12345"), "****2345");
412 assert!(!redact("sk-ant-api-key-12345").contains("sk-ant"));
413 assert!(!redact("ghp_realgithubtoken").contains("ghp_"));
414 }
415
416 /// A short value is hidden entirely - four visible characters out of eight
417 /// is most of it.
418 #[test]
419 fn redact_hides_short_values_completely() {
420 for value in ["", "a", "abcd", "12345678"] {
421 assert_eq!(redact(value), "****", "{value:?}");
422 }
423 }
424
425 /// A byte-based cut lands inside a multi-byte character and panics, and a
426 /// byte-length guard calls a short multi-byte value "long" and prints all
427 /// of it.
428 #[test]
429 fn redact_counts_characters_not_bytes() {
430 assert_eq!(redact("日本語日本語日本語"), "****語日本語");
431 assert_eq!(redact("日本"), "****");
432 }
433
434 /// The exact-name list this replaces missed `x-goog-api-key`, so Gemini
435 /// keys were logged in full under `--features debug-http`.
436 #[test]
437 fn secret_headers_are_matched_by_shape_not_an_exact_list() {
438 for name in [
439 "authorization",
440 "Authorization",
441 "x-api-key",
442 "api-key",
443 "x-goog-api-key",
444 "proxy-authorization",
445 "cookie",
446 "set-cookie",
447 "x-auth-token",
448 "x-amz-security-token",
449 ] {
450 assert!(is_secret_header(name), "{name} must be redacted");
451 }
452 }
453
454 #[test]
455 fn ordinary_headers_are_not_redacted() {
456 for name in [
457 "content-type",
458 "user-agent",
459 "accept",
460 "content-length",
461 "anthropic-version",
462 ] {
463 assert!(!is_secret_header(name), "{name} should log verbatim");
464 }
465 }
466
467 /// Every one of these slipped through the substring denylist this replaces.
468 #[test]
469 fn catches_what_the_old_denylist_missed() {
470 for name in [
471 "AWS_SECRET_ACCESS_KEY",
472 "AWS_SESSION_TOKEN",
473 "GITHUB_TOKEN",
474 "GH_TOKEN",
475 "NPM_TOKEN",
476 "HF_TOKEN",
477 "SLACK_TOKEN",
478 "DATABASE_URL",
479 "LEVIATH_API_TOKEN",
480 "SSH_AUTH_SOCK",
481 ] {
482 assert!(is_sensitive_env_name(name), "{name} should be sensitive");
483 assert!(!child_env_allowed(name), "{name} must not reach a child");
484 }
485 }
486
487 // ─── what a shell tool inherits ───────────────────────────────────────
488
489 /// A sample environment spanning what a real daemon holds: credentials it
490 /// must not hand over, and the toolchain variables every real command needs.
491 const SAMPLE_ENV: &[&str] = &[
492 "ANTHROPIC_API_KEY",
493 "GITHUB_TOKEN",
494 "AWS_SECRET_ACCESS_KEY",
495 "LEVIATH_API_TOKEN",
496 "DATABASE_URL",
497 "SSH_AUTH_SOCK",
498 "KUBECONFIG",
499 "PATH",
500 "HOME",
501 "CARGO_HOME",
502 "JAVA_HOME",
503 "VIRTUAL_ENV",
504 "NVM_DIR",
505 "GOPATH",
506 "DOCKER_HOST",
507 "TERM",
508 ];
509
510 fn withheld(mode: ShellEnvMode, allow: &[&str], custom: &[&str]) -> Vec<String> {
511 let allow: Vec<String> = allow.iter().map(|s| s.to_string()).collect();
512 let custom: Vec<String> = custom.iter().map(|s| s.to_string()).collect();
513 withheld_child_vars(SAMPLE_ENV.iter().copied(), mode, &allow, &custom)
514 }
515
516 /// The default. Credentials are withheld, every toolchain variable passes,
517 /// and `SSH_AUTH_SOCK` is the deliberate carve-out that lets this be the
518 /// default at all - without it `git push` over agent keys breaks in a shell
519 /// tool, which is one of the most ordinary things an agent does.
520 #[test]
521 fn filtered_withholds_credentials_and_keeps_toolchains() {
522 let out = withheld(ShellEnvMode::Filtered, &[], &[]);
523 for secret in [
524 "ANTHROPIC_API_KEY",
525 "GITHUB_TOKEN",
526 "AWS_SECRET_ACCESS_KEY",
527 "LEVIATH_API_TOKEN",
528 "DATABASE_URL",
529 ] {
530 assert!(out.iter().any(|n| n == secret), "{secret} must be withheld");
531 }
532 for kept in [
533 "SSH_AUTH_SOCK",
534 "PATH",
535 "HOME",
536 "CARGO_HOME",
537 "JAVA_HOME",
538 "VIRTUAL_ENV",
539 "NVM_DIR",
540 "GOPATH",
541 "DOCKER_HOST",
542 "TERM",
543 ] {
544 assert!(!out.iter().any(|n| n == kept), "{kept} must pass through");
545 }
546 }
547
548 /// `strict` drops the carve-out, which is the whole difference, and takes
549 /// the credential-file names with it.
550 #[test]
551 fn strict_also_withholds_the_agent_socket() {
552 let out = withheld(ShellEnvMode::Strict, &[], &[]);
553 assert!(out.iter().any(|n| n == "SSH_AUTH_SOCK"));
554 assert!(out.iter().any(|n| n == "KUBECONFIG"));
555 // Still not a toolchain-breaker.
556 assert!(!out.iter().any(|n| n == "PATH"));
557 assert!(!out.iter().any(|n| n == "CARGO_HOME"));
558 }
559
560 /// `custom` ignores the shape heuristic entirely, for an environment whose
561 /// names it reads wrong in either direction.
562 #[test]
563 fn custom_withholds_exactly_what_it_names() {
564 let out = withheld(ShellEnvMode::Custom, &[], &["home", "MY_UNUSUAL_NAME"]);
565 assert_eq!(out, ["HOME"], "case-insensitive, and nothing inferred");
566 assert!(
567 !out.iter().any(|n| n == "ANTHROPIC_API_KEY"),
568 "the heuristic is off, so a credential passes unless named"
569 );
570 }
571
572 #[test]
573 fn inherit_withholds_nothing() {
574 assert!(withheld(ShellEnvMode::Inherit, &[], &["PATH"]).is_empty());
575 }
576
577 /// `allow_env_vars` wins under every mode. One list with one meaning is
578 /// worth more than a second list that means almost the same thing.
579 #[test]
580 fn an_allowlisted_name_is_handed_over_under_every_mode() {
581 for mode in [
582 ShellEnvMode::Filtered,
583 ShellEnvMode::Strict,
584 ShellEnvMode::Custom,
585 ] {
586 let out = withheld(mode, &["anthropic_api_key", "HOME"], &["HOME"]);
587 assert!(
588 !out.iter().any(|n| n == "ANTHROPIC_API_KEY"),
589 "{mode:?} should honour allow_env_vars"
590 );
591 assert!(!out.iter().any(|n| n == "HOME"), "{mode:?}");
592 }
593 }
594
595 /// The names a cloned repository must not be able to set. Each one decides
596 /// where configuration is read from or what gets executed, which is a
597 /// different question from whether it holds a secret.
598 #[test]
599 fn a_dot_env_may_not_steer_the_process() {
600 for name in [
601 "LEVIATH_CONFIG_PATH",
602 "LEVIATH_HOME",
603 "LEVIATH_API_TOKEN",
604 "LEVIATH_RUNS_DIR",
605 "PATH",
606 "SHELL",
607 "EDITOR",
608 "VISUAL",
609 "LD_PRELOAD",
610 "LD_LIBRARY_PATH",
611 "DYLD_INSERT_LIBRARIES",
612 // Each of these turns a *safe-listed* command into an arbitrary
613 // one. `GIT_EXTERNAL_DIFF` is the sharpest: `git status` is on the
614 // default safe list, so a repository could set it and get
615 // unprompted execution from a command nobody would look twice at.
616 "GIT_EXTERNAL_DIFF",
617 "GIT_SSH_COMMAND",
618 "GIT_CONFIG_GLOBAL",
619 "GIT_PAGER",
620 "BASH_ENV",
621 "PAGER",
622 "LESSOPEN",
623 "NODE_OPTIONS",
624 "PYTHONSTARTUP",
625 "PERL5OPT",
626 "RUBYOPT",
627 "JAVA_TOOL_OPTIONS",
628 "RUSTC_WRAPPER",
629 ] {
630 assert!(
631 !dotenv_var_allowed(name),
632 "{name} must not be settable from a repository's .env"
633 );
634 // Case is not a way around it.
635 assert!(!dotenv_var_allowed(&name.to_ascii_lowercase()));
636 }
637 }
638
639 /// The credentials this feature exists to load still load. Reusing
640 /// `is_sensitive_env_name` here would have refused every one of them and
641 /// made `.env` support pointless.
642 #[test]
643 fn a_dot_env_may_still_carry_credentials_and_ordinary_config() {
644 for name in [
645 "ANTHROPIC_API_KEY",
646 "OPENAI_API_KEY",
647 "GITHUB_TOKEN",
648 "DATABASE_URL",
649 "AWS_SECRET_ACCESS_KEY",
650 "OLLAMA_HOST",
651 "MY_APP_REGION",
652 "RUST_LOG",
653 ] {
654 assert!(dotenv_var_allowed(name), "{name} should still load");
655 }
656 }
657
658 #[test]
659 fn catches_the_provider_keys() {
660 for name in [
661 "ANTHROPIC_API_KEY",
662 "OPENAI_API_KEY",
663 "GOOGLE_API_KEY",
664 "OPENROUTER_API_KEY",
665 ] {
666 assert!(is_sensitive_env_name(name), "{name}");
667 }
668 }
669
670 /// The list is the whole of what stands between a script tool or an MCP
671 /// header and a credential, so a common secret name it misses is a leak.
672 /// `KEY` was absent, so `OPENAI_KEY` sailed through while `OPENAI_API_KEY`
673 /// was caught.
674 #[test]
675 fn common_secret_names_are_all_recognised() {
676 for name in [
677 "OPENAI_KEY",
678 "ENCRYPTION_KEY",
679 "MASTER_KEY",
680 "DEPLOY_KEY",
681 "ANTHROPIC_API_KEY",
682 "AWS_SECRET_ACCESS_KEY",
683 "GITHUB_TOKEN",
684 "GITHUB_PAT",
685 "SENTRY_DSN",
686 "NETRC",
687 "KUBECONFIG",
688 "npm_password",
689 ] {
690 assert!(
691 is_sensitive_env_name(name),
692 "{name} must be treated as a secret"
693 );
694 }
695
696 // And the list has not become "everything": ordinary variables an agent
697 // legitimately reads still pass.
698 for name in [
699 "PATH",
700 "HOME",
701 "LANG",
702 "TERM",
703 "TZ",
704 "EDITOR",
705 "OLLAMA_HOST",
706 ] {
707 assert!(!is_sensitive_env_name(name), "{name} is not a secret");
708 }
709 }
710
711 #[test]
712 fn matching_is_case_insensitive() {
713 assert!(is_sensitive_env_name("github_token"));
714 assert!(is_sensitive_env_name("MyApp_Password"));
715 assert!(child_env_allowed("path"));
716 assert!(child_env_allowed("Path"));
717 }
718
719 #[test]
720 fn ordinary_names_are_not_sensitive() {
721 for name in ["PATH", "HOME", "TZ", "TERM", "EDITOR", "MY_APP_REGION"] {
722 assert!(!is_sensitive_env_name(name), "{name}");
723 }
724 }
725
726 #[test]
727 fn child_allowlist_is_the_short_list_not_the_environment() {
728 assert!(child_env_allowed("PATH"));
729 assert!(child_env_allowed("LANG"));
730 // Not sensitive, but still not a child's business by default - the point
731 // of an allowlist is that unknown names are excluded, not just secret
732 // ones. A server that needs it declares it in its own `env` block.
733 assert!(!child_env_allowed("MY_APP_REGION"));
734 assert!(!child_env_allowed("EDITOR"));
735 }
736
737 #[test]
738 fn script_reads_pass_unless_credential_shaped() {
739 let none: &[String] = &[];
740 assert!(script_env_allowed("PATH", none));
741 assert!(script_env_allowed("MY_APP_REGION", none));
742 assert!(!script_env_allowed("ANTHROPIC_API_KEY", none));
743 }
744
745 #[test]
746 fn allowlisting_a_credential_permits_exactly_that_one() {
747 let allow = vec!["MY_PROVIDER_KEY".to_string()];
748 assert!(script_env_allowed("MY_PROVIDER_KEY", &allow));
749 assert!(script_env_allowed("my_provider_key", &allow), "case");
750 assert!(!script_env_allowed("ANTHROPIC_API_KEY", &allow));
751 // No wildcard support: `*` is a literal name, not "everything".
752 assert!(!script_env_allowed("ANTHROPIC_API_KEY", &["*".to_string()]));
753 }
754}