Skip to main content

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}