leviath-core 0.1.0

Core types and traits for Leviath: context regions, memory layouts, blueprints, and lifecycle policies
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
//! Which environment variables agent-supplied code may see.
//!
//! Two different questions live here, and they want opposite shapes:
//!
//! 1. **What does a child process inherit?** ([`child_env_allowed`]) We are
//!    *choosing what to hand over*, so an allowlist is right. A denylist here has
//!    to enumerate every secret name in the ecosystem and loses the moment a new
//!    one appears - which is exactly what happened: the MCP spawner's substring
//!    denylist passed `AWS_SECRET_ACCESS_KEY` (it matches neither `API_SECRET`
//!    nor `SECRET_KEY`), `GITHUB_TOKEN`, `NPM_TOKEN`, `DATABASE_URL`, and
//!    Leviath's own `LEVIATH_API_TOKEN`.
//!
//! 2. **May a script read *this named* variable?** ([`is_sensitive_env_name`]) A
//!    script asks for one name it already knows. An allowlist cannot work - no
//!    fixed list covers every legitimate variable a provider script might read -
//!    so the rule inverts: anything that *looks like* a credential is refused
//!    unless the user allowlisted it, and everything else is fine.
//!
//! Neither is a substitute for the other, and both are shared rather than
//! reimplemented per call site so a gap gets fixed once.

/// Compare two secrets without leaking their contents through timing.
///
/// Runs over the full length of both inputs rather than returning at the first
/// differing byte, so an attacker cannot recover a token one character at a time
/// by measuring how long a wrong guess takes. The *length* still leaks, which is
/// fine: these are fixed-shape tokens, and refusing early on a length mismatch
/// avoids indexing past the end.
///
/// Shared so every secret comparison in the workspace is the same one. The API
/// server had a correct implementation; the OAuth callback's `state` check used
/// `==` and was the only comparison that differed.
#[must_use]
pub fn constant_time_eq(a: &str, b: &str) -> bool {
    let (a, b) = (a.as_bytes(), b.as_bytes());
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b) {
        diff |= x ^ y;
    }
    diff == 0
}

/// Render a secret for display, showing only its last four characters.
///
/// The **one** redaction policy for the workspace. There were two, and they
/// disagreed about which end of the value to keep: the HTTP logger showed the
/// last four, the setup wizard the first eight. Two answers to "how much of a
/// secret is safe to print" means neither is a policy - and a prefix is the
/// wrong half to keep, because API keys are structured at the front:
/// `sk-ant-a…`, `sk-proj-…`, `ghp_…` all identify the issuer and, for a short
/// token, a meaningful fraction of the value.
///
/// Counts **characters**, not bytes. `value.len() - 4` can land inside a
/// multi-byte character and panic, and a 5-byte
/// 2-character value is longer than 4 *bytes*, so a byte-based length check
/// would print the whole thing behind four stars.
#[must_use]
pub fn redact(value: &str) -> String {
    let chars: Vec<char> = value.chars().collect();
    // Four visible characters out of five or fewer is most of the value.
    if chars.len() <= 8 {
        return "****".to_string();
    }
    format!(
        "****{}",
        chars[chars.len() - 4..].iter().collect::<String>()
    )
}

/// Whether a header's value must be redacted before it is logged.
///
/// A substring match rather than an exact-name list. The list version named
/// `authorization`, `x-api-key` and `api-key`, and therefore logged Gemini's
/// `x-goog-api-key` **in full** under `--features debug-http` - the one
/// provider whose header did not happen to be on it. A denylist of exact names
/// has to be complete to be correct, and this one was not; matching on the
/// shape of the name fails safe as new headers appear.
#[must_use]
pub fn is_secret_header(name: &str) -> bool {
    let lower = name.to_ascii_lowercase();
    ["auth", "key", "token", "secret", "cookie", "credential"]
        .iter()
        .any(|hint| lower.contains(hint))
}

/// Environment variables a spawned child (an MCP server, a shell tool) inherits.
///
/// Deliberately short. A child needs enough to find its interpreter and behave
/// like a terminal program; it does not need the ambient credentials of whoever
/// started the daemon. Anything else a server legitimately requires is declared
/// in its own `env` block in config, which is applied *after* this filter and so
/// always wins - that is the supported way to pass a server its token.
const CHILD_ENV_ALLOWLIST: &[&str] = &[
    // Finding and running programs.
    "PATH",
    "HOME",
    "SHELL",
    "USER",
    "LOGNAME",
    "TMPDIR",
    "TEMP",
    "TMP",
    // Locale and terminal behaviour.
    "LANG",
    "LANGUAGE",
    "TERM",
    "TZ",
    "COLORTERM",
    "NO_COLOR",
    // Windows equivalents of the above.
    "SYSTEMROOT",
    "WINDIR",
    "COMSPEC",
    "PATHEXT",
    "APPDATA",
    "LOCALAPPDATA",
    "PROGRAMFILES",
    "PROGRAMDATA",
    "USERPROFILE",
    "HOMEDRIVE",
    "HOMEPATH",
    "NUMBER_OF_PROCESSORS",
    "PROCESSOR_ARCHITECTURE",
    "OS",
];

/// Whether a spawned child process may inherit `name`.
///
/// Case-insensitive, because Windows environment variables are.
pub fn child_env_allowed(name: &str) -> bool {
    CHILD_ENV_ALLOWLIST
        .iter()
        .any(|allowed| allowed.eq_ignore_ascii_case(name))
}

/// Substrings that make a variable name look like it holds a credential.
///
/// Matched case-insensitively anywhere in the name, so `AWS_SECRET_ACCESS_KEY`,
/// `GH_TOKEN`, `npm_config_//registry:_authToken`, and `DATABASE_PASSWORD` all
/// hit. Broad on purpose: a false positive costs the user one allowlist entry,
/// a false negative costs them the credential.
const SECRET_NAME_HINTS: &[&str] = &[
    "TOKEN",
    "SECRET",
    "PASSWORD",
    "PASSWD",
    "PASSPHRASE",
    "CREDENTIAL",
    "APIKEY",
    "API_KEY",
    "ACCESS_KEY",
    "PRIVATE_KEY",
    // Bare `KEY`, which subsumes the three above and catches everything they
    // missed: `OPENAI_KEY`, `ENCRYPTION_KEY`, `MASTER_KEY`, `DEPLOY_KEY`. The
    // longer forms stay for documentation value. A variable whose name merely
    // contains "key" and is not a secret (`KEYBOARD_LAYOUT`, `KEYCHAIN_PATH`)
    // costs its owner one `allow_env_vars` entry, which is the trade this list
    // is meant to make.
    "KEY",
    // A personal access token, which is what `_PAT` conventionally means.
    "_PAT",
    "SESSION",
    "COOKIE",
    "AUTH",
    "BEARER",
    "SIGNATURE",
    "SIGNING",
    // Sentry DSNs embed a key; `.netrc` and kubeconfigs are credential files.
    "DSN",
    "NETRC",
    "KUBECONFIG",
];

/// Exact names that are sensitive without matching any of [`SECRET_NAME_HINTS`].
const SECRET_NAME_EXACT: &[&str] = &[
    // Connection strings routinely embed a username and password.
    "DATABASE_URL",
    "DATABASE_DSN",
    "REDIS_URL",
    "MONGO_URL",
    "MONGODB_URI",
    "AMQP_URL",
    // Points at an agent socket that can sign on the user's behalf.
    "SSH_AUTH_SOCK",
];

/// Prefixes whose whole namespace is treated as sensitive.
const SECRET_NAME_PREFIXES: &[&str] = &[
    // Leviath's own: `LEVIATH_API_TOKEN` authenticates the agent-spawning API,
    // and `LEVIATH_CONFIG_PATH` / `LEVIATH_HOME` redirect where secrets are read
    // from. None of it is a script's business.
    "LEVIATH_", // Cloud SDK credential namespaces.
    "AWS_", "AZURE_", "GOOGLE_", "GCP_",
];

/// Whether `name` looks like it holds a credential.
///
/// Used to decide whether an explicit `env_var("NAME")` read from an agent's
/// Rhai script is refused. The check is on the *name*, never the value: a value
/// test would have to read the secret to decide whether reading it was allowed.
pub fn is_sensitive_env_name(name: &str) -> bool {
    let upper = name.to_ascii_uppercase();
    SECRET_NAME_HINTS.iter().any(|h| upper.contains(h))
        || SECRET_NAME_EXACT.iter().any(|e| upper == *e)
        || SECRET_NAME_PREFIXES.iter().any(|p| upper.starts_with(p))
}

/// Whether a script may read `name`, given the user's `[security] allow_env_vars`.
///
/// Non-credential names pass freely - a script reading `PATH`, `TZ`, or its own
/// app's config variable is ordinary. A credential-shaped name passes only if the
/// user listed it, which is them saying "yes, this agent is meant to have that".
/// Matching the allowlist is case-insensitive and exact; no wildcards, because
/// `allow_env_vars = ["*"]` would read as a shortcut rather than the decision it
/// actually is.
pub fn script_env_allowed(name: &str, allowlist: &[String]) -> bool {
    !is_sensitive_env_name(name)
        || allowlist
            .iter()
            .any(|allowed| allowed.eq_ignore_ascii_case(name))
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Runs over the full length of both inputs rather than returning at the
    /// first differing byte, so a wrong token cannot be recovered one character
    /// at a time. The length still leaks, which is fine for fixed-shape tokens.
    #[test]
    fn constant_time_eq_matches_ordinary_equality() {
        assert!(constant_time_eq("secret", "secret"));
        assert!(constant_time_eq("", ""));
        assert!(!constant_time_eq("secret", "secreu"));
        // Differing at the very first byte and at the very last must both be
        // false - the loop does not short-circuit either way.
        assert!(!constant_time_eq("Xecret", "secret"));
        assert!(!constant_time_eq("secreX", "secret"));
        // Length mismatch is refused before indexing.
        assert!(!constant_time_eq("secret", "secretx"));
        assert!(!constant_time_eq("secretx", "secret"));
    }

    /// The suffix is kept, not the prefix: API keys are structured at the front,
    /// so showing `sk-ant-a` names the issuer and, on a short token, exposes a
    /// meaningful fraction of the value.
    #[test]
    fn redact_keeps_only_a_short_suffix() {
        assert_eq!(redact("sk-ant-api-key-12345"), "****2345");
        assert!(!redact("sk-ant-api-key-12345").contains("sk-ant"));
        assert!(!redact("ghp_realgithubtoken").contains("ghp_"));
    }

    /// A short value is hidden entirely - four visible characters out of eight
    /// is most of it.
    #[test]
    fn redact_hides_short_values_completely() {
        for value in ["", "a", "abcd", "12345678"] {
            assert_eq!(redact(value), "****", "{value:?}");
        }
    }

    /// A byte-based cut lands inside a multi-byte character and panics, and a
    /// byte-length guard calls a short multi-byte value "long" and prints all
    /// of it.
    #[test]
    fn redact_counts_characters_not_bytes() {
        assert_eq!(redact("日本語日本語日本語"), "****語日本語");
        assert_eq!(redact("日本"), "****");
    }

    /// The exact-name list this replaces missed `x-goog-api-key`, so Gemini
    /// keys were logged in full under `--features debug-http`.
    #[test]
    fn secret_headers_are_matched_by_shape_not_an_exact_list() {
        for name in [
            "authorization",
            "Authorization",
            "x-api-key",
            "api-key",
            "x-goog-api-key",
            "proxy-authorization",
            "cookie",
            "set-cookie",
            "x-auth-token",
            "x-amz-security-token",
        ] {
            assert!(is_secret_header(name), "{name} must be redacted");
        }
    }

    #[test]
    fn ordinary_headers_are_not_redacted() {
        for name in [
            "content-type",
            "user-agent",
            "accept",
            "content-length",
            "anthropic-version",
        ] {
            assert!(!is_secret_header(name), "{name} should log verbatim");
        }
    }

    /// Every one of these slipped through the substring denylist this replaces.
    #[test]
    fn catches_what_the_old_denylist_missed() {
        for name in [
            "AWS_SECRET_ACCESS_KEY",
            "AWS_SESSION_TOKEN",
            "GITHUB_TOKEN",
            "GH_TOKEN",
            "NPM_TOKEN",
            "HF_TOKEN",
            "SLACK_TOKEN",
            "DATABASE_URL",
            "LEVIATH_API_TOKEN",
            "SSH_AUTH_SOCK",
        ] {
            assert!(is_sensitive_env_name(name), "{name} should be sensitive");
            assert!(!child_env_allowed(name), "{name} must not reach a child");
        }
    }

    #[test]
    fn catches_the_provider_keys() {
        for name in [
            "ANTHROPIC_API_KEY",
            "OPENAI_API_KEY",
            "GOOGLE_API_KEY",
            "OPENROUTER_API_KEY",
        ] {
            assert!(is_sensitive_env_name(name), "{name}");
        }
    }

    /// The list is the whole of what stands between a script tool or an MCP
    /// header and a credential, so a common secret name it misses is a leak.
    /// `KEY` was absent, so `OPENAI_KEY` sailed through while `OPENAI_API_KEY`
    /// was caught.
    #[test]
    fn common_secret_names_are_all_recognised() {
        for name in [
            "OPENAI_KEY",
            "ENCRYPTION_KEY",
            "MASTER_KEY",
            "DEPLOY_KEY",
            "ANTHROPIC_API_KEY",
            "AWS_SECRET_ACCESS_KEY",
            "GITHUB_TOKEN",
            "GITHUB_PAT",
            "SENTRY_DSN",
            "NETRC",
            "KUBECONFIG",
            "npm_password",
        ] {
            assert!(
                is_sensitive_env_name(name),
                "{name} must be treated as a secret"
            );
        }

        // And the list has not become "everything": ordinary variables an agent
        // legitimately reads still pass.
        for name in [
            "PATH",
            "HOME",
            "LANG",
            "TERM",
            "TZ",
            "EDITOR",
            "OLLAMA_HOST",
        ] {
            assert!(!is_sensitive_env_name(name), "{name} is not a secret");
        }
    }

    #[test]
    fn matching_is_case_insensitive() {
        assert!(is_sensitive_env_name("github_token"));
        assert!(is_sensitive_env_name("MyApp_Password"));
        assert!(child_env_allowed("path"));
        assert!(child_env_allowed("Path"));
    }

    #[test]
    fn ordinary_names_are_not_sensitive() {
        for name in ["PATH", "HOME", "TZ", "TERM", "EDITOR", "MY_APP_REGION"] {
            assert!(!is_sensitive_env_name(name), "{name}");
        }
    }

    #[test]
    fn child_allowlist_is_the_short_list_not_the_environment() {
        assert!(child_env_allowed("PATH"));
        assert!(child_env_allowed("LANG"));
        // Not sensitive, but still not a child's business by default - the point
        // of an allowlist is that unknown names are excluded, not just secret
        // ones. A server that needs it declares it in its own `env` block.
        assert!(!child_env_allowed("MY_APP_REGION"));
        assert!(!child_env_allowed("EDITOR"));
    }

    #[test]
    fn script_reads_pass_unless_credential_shaped() {
        let none: &[String] = &[];
        assert!(script_env_allowed("PATH", none));
        assert!(script_env_allowed("MY_APP_REGION", none));
        assert!(!script_env_allowed("ANTHROPIC_API_KEY", none));
    }

    #[test]
    fn allowlisting_a_credential_permits_exactly_that_one() {
        let allow = vec!["MY_PROVIDER_KEY".to_string()];
        assert!(script_env_allowed("MY_PROVIDER_KEY", &allow));
        assert!(script_env_allowed("my_provider_key", &allow), "case");
        assert!(!script_env_allowed("ANTHROPIC_API_KEY", &allow));
        // No wildcard support: `*` is a literal name, not "everything".
        assert!(!script_env_allowed("ANTHROPIC_API_KEY", &["*".to_string()]));
    }
}