Skip to main content

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}