Skip to main content

lean_ctx/core/
agent_runtime_env.rs

1//! Bridge agent runtime/session environment variables across lean-ctx processes.
2//!
3//! The lean-ctx MCP server is a long-lived child of the agent host. Some agents
4//! (notably Codex) expose runtime/session variables such as `CODEX_THREAD_ID`
5//! only in the *native agent shell* environment, not in the MCP server process
6//! (#370). `ctx_shell` runs inside the MCP server, so it cannot forward those
7//! variables by reading its own `std::env`.
8//!
9//! Short-lived lean-ctx processes that *do* run inside the agent environment —
10//! the hook handlers (`lean-ctx hook …`) and the `lean-ctx -c` shell wrapper —
11//! [`capture`] the relevant variables into a small file in the data dir. The MCP
12//! server then [`load`]s them when constructing the child environment for
13//! `ctx_shell` (see `crate::server::execute`).
14
15use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18/// Env var name prefixes identifying agent runtime/session state worth forwarding
19/// to `ctx_shell` child processes.
20pub const FORWARD_PREFIXES: &[&str] = &[
21    "CODEX_",
22    "CLAUDE_",
23    "CODEBUDDY_",
24    "OPENCODE_",
25    "HERMES_",
26    "GEMINI_",
27];
28
29/// Case-insensitive name substrings that mark an env var as credential-shaped.
30///
31/// A variable whose name contains any of these is NEVER forwarded to `ctx_shell`
32/// children — and never captured to disk — even when it matches a forwardable
33/// prefix. Forwarding API keys / tokens / passwords into every child shell is an
34/// exfiltration risk that output redaction cannot stop: a `curl … -d "$KEY"`
35/// child never prints the secret to stdout, so the redactor never sees it (GH
36/// security audit, finding 2). Only non-secret session/thread identifiers
37/// (`*_THREAD_ID`, `*_SESSION`, …) should cross the bridge.
38const CREDENTIAL_MARKERS: &[&str] = &[
39    "_KEY",       // *_API_KEY, *_ACCESS_KEY, *_PRIVATE_KEY, *_SECRET_KEY
40    "APIKEY",     // unseparated spelling
41    "SECRET",     // *_SECRET, *_CLIENT_SECRET
42    "TOKEN",      // *_TOKEN, *_ACCESS_TOKEN, *_REFRESH_TOKEN
43    "PASSWORD",   // *_PASSWORD, *_SERVER_PASSWORD
44    "PASSWD",     // unseparated spelling
45    "CREDENTIAL", // *_CREDENTIAL(S)
46    "AUTH",       // *_AUTH, *_OAUTH, *_AUTHORIZATION
47];
48
49/// Whether `key` looks like a secret/credential that must never be forwarded or
50/// persisted, regardless of any matching forwardable prefix.
51#[must_use]
52pub fn is_credential_shaped(key: &str) -> bool {
53    let upper = key.to_ascii_uppercase();
54    CREDENTIAL_MARKERS
55        .iter()
56        .any(|marker| upper.contains(marker))
57}
58
59const FILE_NAME: &str = "agent_runtime_env.json";
60
61/// Captured variables older than this are ignored: a stale session/thread id is
62/// worse than forwarding none, and a fresh session re-captures on its first hook.
63const TTL_SECS: u64 = 7_200;
64
65/// Whether `key` is an agent runtime variable lean-ctx forwards to child shells.
66///
67/// A variable qualifies only when it (1) matches a forwardable agent prefix AND
68/// (2) is not credential-shaped — session/thread identifiers cross the bridge,
69/// secrets never do (GH security audit, finding 2).
70#[must_use]
71pub fn is_forwardable(key: &str) -> bool {
72    FORWARD_PREFIXES
73        .iter()
74        .any(|prefix| key.starts_with(prefix))
75        && !is_credential_shaped(key)
76}
77
78/// Canonical capture-file location: the STATE dir (GH #408 / GL #605).
79///
80/// This file holds captured agent **session/thread identifiers** (e.g.
81/// `CODEX_THREAD_ID`); credential-shaped vars are filtered out by
82/// [`is_forwardable`] and never written here (GH security audit, finding 2). It
83/// still lives in the RW state category — never the RO/shareable config dir —
84/// and is always written `0o600` as defence-in-depth.
85fn store_path() -> Option<PathBuf> {
86    crate::core::paths::state_dir()
87        .ok()
88        .map(|dir| dir.join(FILE_NAME))
89}
90
91/// Legacy location: the file historically lived in the (config-shaped) data dir.
92/// Used only to migrate existing captures into [`store_path`].
93fn legacy_store_path() -> Option<PathBuf> {
94    crate::core::data_dir::lean_ctx_data_dir()
95        .ok()
96        .map(|dir| dir.join(FILE_NAME))
97}
98
99/// Relocate a pre-#408 key file from the data dir into the state dir, and never
100/// leave captured keys behind in the config-shaped legacy location.
101///
102/// Idempotent and a no-op in single-dir mode (where state == legacy). Safe to
103/// call on every access: the existence checks make the steady state one `stat`.
104fn migrate_legacy_key_file(state_path: &Path) {
105    let Some(legacy) = legacy_store_path() else {
106        return;
107    };
108    if legacy == *state_path || !legacy.exists() {
109        return;
110    }
111    if state_path.exists() {
112        // A state-dir copy already exists; drop the stale legacy file so keys
113        // never linger in the config-shaped data dir.
114        let _ = std::fs::remove_file(&legacy);
115        return;
116    }
117    if let Some(parent) = state_path.parent() {
118        let _ = std::fs::create_dir_all(parent);
119    }
120    if std::fs::rename(&legacy, state_path).is_err() {
121        // Cross-filesystem move: copy then remove the original.
122        if std::fs::copy(&legacy, state_path).is_ok() {
123            let _ = std::fs::remove_file(&legacy);
124        } else {
125            return;
126        }
127    }
128    restrict_key_file_permissions(state_path);
129}
130
131#[cfg(unix)]
132fn restrict_key_file_permissions(path: &Path) {
133    use std::os::unix::fs::PermissionsExt;
134    let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
135}
136
137#[cfg(not(unix))]
138fn restrict_key_file_permissions(_path: &Path) {}
139
140fn now_secs() -> u64 {
141    std::time::SystemTime::now()
142        .duration_since(std::time::UNIX_EPOCH)
143        .unwrap_or_default()
144        .as_secs()
145}
146
147/// Forwardable variables present in the current process environment.
148#[must_use]
149pub fn collect_from_process() -> BTreeMap<String, String> {
150    std::env::vars()
151        .filter(|(key, _)| is_forwardable(key))
152        .collect()
153}
154
155fn read_store(path: &Path) -> Option<(BTreeMap<String, String>, u64)> {
156    let content = std::fs::read_to_string(path).ok()?;
157    let value: serde_json::Value = serde_json::from_str(&content).ok()?;
158    let captured_at = value
159        .get("captured_at")
160        .and_then(serde_json::Value::as_u64)?;
161    let vars = value
162        .get("vars")
163        .and_then(serde_json::Value::as_object)?
164        .iter()
165        .filter_map(|(key, val)| val.as_str().map(|s| (key.clone(), s.to_string())))
166        .collect();
167    Some((vars, captured_at))
168}
169
170/// Capture forwardable variables from the current (agent) environment into the
171/// data dir so the MCP server can forward them to `ctx_shell` children.
172///
173/// No-op when the current environment carries no forwardable variables — this
174/// prevents a process with a stripped environment (e.g. the MCP server itself)
175/// from clobbering a good capture. The file is only rewritten when the variable
176/// set actually changes, keeping the cost of capturing on every shell command low.
177pub fn capture() {
178    let vars = collect_from_process();
179    if vars.is_empty() {
180        return;
181    }
182    let Some(path) = store_path() else {
183        return;
184    };
185    migrate_legacy_key_file(&path);
186    if let Some((existing, _)) = read_store(&path)
187        && existing == vars
188    {
189        return;
190    }
191    let payload = serde_json::json!({ "vars": vars, "captured_at": now_secs() });
192    let Ok(json) = serde_json::to_string_pretty(&payload) else {
193        return;
194    };
195    // Atomic write + `0o600` (owner-only): captured keys must never be
196    // group/world-readable. `write_atomic` also rejects symlinks and creates
197    // the state dir if needed.
198    let _ = crate::config_io::write_atomic(&path, &json);
199}
200
201/// Load captured agent runtime variables, honoring the freshness TTL.
202///
203/// Returns an empty map when no capture exists or it has expired.
204#[must_use]
205pub fn load() -> BTreeMap<String, String> {
206    let Some(path) = store_path() else {
207        return BTreeMap::new();
208    };
209    migrate_legacy_key_file(&path);
210    let Some((vars, captured_at)) = read_store(&path) else {
211        return BTreeMap::new();
212    };
213    if now_secs().saturating_sub(captured_at) > TTL_SECS {
214        return BTreeMap::new();
215    }
216    // Defense-in-depth: a capture written by a build predating finding 2 may
217    // still hold credential-shaped vars. Drop them from the returned set, and if
218    // any were present rewrite (or delete) the file so the plaintext secret does
219    // not linger at rest — not just out of the forwarded env.
220    let cleaned: BTreeMap<String, String> = vars
221        .iter()
222        .filter(|(key, _)| is_forwardable(key))
223        .map(|(key, val)| (key.clone(), val.clone()))
224        .collect();
225    if cleaned.len() != vars.len() {
226        scrub_store(&path, &cleaned, captured_at);
227    }
228    cleaned
229}
230
231/// Rewrite the capture file with `vars` (preserving `captured_at`), or remove it
232/// entirely when nothing forwardable remains. Retroactively strips
233/// credential-shaped vars from captures written by older builds (finding 2).
234fn scrub_store(path: &Path, vars: &BTreeMap<String, String>, captured_at: u64) {
235    if vars.is_empty() {
236        let _ = std::fs::remove_file(path);
237        return;
238    }
239    let payload = serde_json::json!({ "vars": vars, "captured_at": captured_at });
240    if let Ok(json) = serde_json::to_string_pretty(&payload) {
241        let _ = crate::config_io::write_atomic(path, &json);
242    }
243}
244
245#[cfg(test)]
246mod tests {
247    use super::*;
248
249    #[test]
250    fn is_forwardable_matches_known_prefixes() {
251        assert!(is_forwardable("CODEX_THREAD_ID"));
252        assert!(is_forwardable("CLAUDE_SESSION"));
253        assert!(is_forwardable("OPENCODE_FOO"));
254        assert!(!is_forwardable("PATH"));
255        assert!(!is_forwardable("HOME"));
256        assert!(!is_forwardable("LEAN_CTX_DATA_DIR"));
257    }
258
259    #[test]
260    fn capture_then_load_roundtrips() {
261        let _iso = crate::core::data_dir::isolated_data_dir();
262        crate::test_env::set_var("CODEX_THREAD_ID", "thread-roundtrip");
263
264        capture();
265        let loaded = load();
266
267        crate::test_env::remove_var("CODEX_THREAD_ID");
268
269        assert_eq!(
270            loaded.get("CODEX_THREAD_ID").map(String::as_str),
271            Some("thread-roundtrip")
272        );
273    }
274
275    #[test]
276    fn capture_is_noop_without_forwardable_vars() {
277        let _iso = crate::core::data_dir::isolated_data_dir();
278        // Ensure no forwardable vars leak in from the host test environment.
279        for (key, _) in collect_from_process() {
280            crate::test_env::remove_var(key);
281        }
282
283        capture();
284        let exists = store_path().is_some_and(|p| p.exists());
285
286        assert!(!exists, "capture must not write a store with no vars");
287    }
288
289    #[test]
290    fn load_ignores_expired_capture() {
291        let _iso = crate::core::data_dir::isolated_data_dir();
292        let path = store_path().unwrap();
293        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
294
295        let stale = now_secs().saturating_sub(TTL_SECS + 60);
296        let payload =
297            serde_json::json!({ "vars": { "CODEX_THREAD_ID": "old" }, "captured_at": stale });
298        std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
299
300        let loaded = load();
301
302        assert!(loaded.is_empty(), "expired capture must not be loaded");
303    }
304
305    #[cfg(unix)]
306    #[test]
307    fn capture_sets_owner_only_permissions() {
308        use std::os::unix::fs::PermissionsExt;
309        let _iso = crate::core::data_dir::isolated_data_dir();
310        for (key, _) in collect_from_process() {
311            crate::test_env::remove_var(key);
312        }
313        // A forwardable (non-credential) var so a file is actually written; it
314        // can still hold session ids, so owner-only perms remain a requirement.
315        crate::test_env::set_var("CODEX_THREAD_ID", "session-id");
316
317        capture();
318        let path = store_path().unwrap();
319        crate::test_env::remove_var("CODEX_THREAD_ID");
320
321        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
322        assert_eq!(
323            mode & 0o777,
324            0o600,
325            "captured runtime-env file must be owner-only"
326        );
327    }
328
329    // Finding 2 (GH security audit): credential-shaped vars must never be
330    // forwarded, even when they match a forwardable agent prefix.
331    #[test]
332    fn is_forwardable_rejects_credential_shaped_vars() {
333        // Session/thread identifiers cross the bridge.
334        assert!(is_forwardable("CODEX_THREAD_ID"));
335        assert!(is_forwardable("CLAUDE_SESSION_ID"));
336        assert!(is_forwardable("OPENCODE_SESSION"));
337        // Secrets matching a forwardable prefix do NOT.
338        assert!(!is_forwardable("GEMINI_API_KEY"));
339        assert!(!is_forwardable("OPENCODE_API_KEY"));
340        assert!(!is_forwardable("OPENCODE_SERVER_PASSWORD"));
341        assert!(!is_forwardable("CLAUDE_CODE_OAUTH_TOKEN"));
342        assert!(!is_forwardable("CODEX_ACCESS_TOKEN"));
343        assert!(!is_forwardable("CODEX_CLIENT_SECRET"));
344        assert!(!is_forwardable("CODEX_PRIVATE_KEY"));
345        assert!(!is_forwardable("GEMINI_CREDENTIALS"));
346    }
347
348    #[test]
349    fn capture_excludes_credentials() {
350        let _iso = crate::core::data_dir::isolated_data_dir();
351        for (key, _) in collect_from_process() {
352            crate::test_env::remove_var(key);
353        }
354        crate::test_env::set_var("CODEX_THREAD_ID", "thread-keep");
355        crate::test_env::set_var("GEMINI_API_KEY", "secret-drop");
356
357        capture();
358        let loaded = load();
359
360        crate::test_env::remove_var("CODEX_THREAD_ID");
361        crate::test_env::remove_var("GEMINI_API_KEY");
362
363        assert_eq!(
364            loaded.get("CODEX_THREAD_ID").map(String::as_str),
365            Some("thread-keep")
366        );
367        assert!(
368            !loaded.contains_key("GEMINI_API_KEY"),
369            "API key must never be captured or forwarded"
370        );
371    }
372
373    #[test]
374    fn load_scrubs_legacy_credentials_from_disk() {
375        let _iso = crate::core::data_dir::isolated_data_dir();
376        let path = store_path().unwrap();
377        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
378
379        // Simulate a capture written by an older build: holds a real secret.
380        let payload = serde_json::json!({
381            "vars": { "CODEX_THREAD_ID": "t", "OPENCODE_SERVER_PASSWORD": "p4ssw0rd" },
382            "captured_at": now_secs()
383        });
384        std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
385
386        let loaded = load();
387
388        assert!(
389            !loaded.contains_key("OPENCODE_SERVER_PASSWORD"),
390            "legacy credential must not be loaded"
391        );
392        assert_eq!(loaded.get("CODEX_THREAD_ID").map(String::as_str), Some("t"));
393
394        // The plaintext secret must be scrubbed from disk, not just the env.
395        let on_disk = std::fs::read_to_string(&path).unwrap();
396        assert!(
397            !on_disk.contains("OPENCODE_SERVER_PASSWORD") && !on_disk.contains("p4ssw0rd"),
398            "secret must be removed from the capture file at rest: {on_disk}"
399        );
400    }
401
402    #[test]
403    fn store_path_is_under_state_dir_not_config_dir_when_split() {
404        let _lock = crate::core::data_dir::test_env_lock();
405        let state = tempfile::tempdir().unwrap();
406        let config = tempfile::tempdir().unwrap();
407        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
408        crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", config.path());
409
410        let path = store_path().unwrap();
411
412        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
413        crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
414
415        assert!(
416            path.starts_with(state.path()),
417            "key file must live under the state dir: {}",
418            path.display()
419        );
420        assert!(
421            !path.starts_with(config.path()),
422            "key file must never resolve under the config dir"
423        );
424    }
425
426    #[test]
427    fn migrates_legacy_key_file_to_state_dir() {
428        let _lock = crate::core::data_dir::test_env_lock();
429        let data = tempfile::tempdir().unwrap();
430        let state = tempfile::tempdir().unwrap();
431        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
432        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
433
434        let legacy = data.path().join(FILE_NAME);
435        let payload =
436            serde_json::json!({ "vars": { "GEMINI_API_KEY": "k" }, "captured_at": now_secs() });
437        std::fs::write(&legacy, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
438
439        let state_path = store_path().unwrap();
440        migrate_legacy_key_file(&state_path);
441
442        let legacy_exists = legacy.exists();
443        let migrated = state_path.exists();
444        let parent_ok = state_path.parent() == Some(state.path());
445
446        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
447        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
448
449        assert!(migrated, "key file must be moved into the state dir");
450        assert!(!legacy_exists, "legacy key file must be removed after move");
451        assert!(
452            parent_ok,
453            "migrated file must sit directly in the state dir"
454        );
455    }
456
457    #[test]
458    fn removes_stale_legacy_when_state_copy_exists() {
459        let _lock = crate::core::data_dir::test_env_lock();
460        let data = tempfile::tempdir().unwrap();
461        let state = tempfile::tempdir().unwrap();
462        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
463        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
464
465        let legacy = data.path().join(FILE_NAME);
466        std::fs::write(&legacy, "{}").unwrap();
467        let state_path = store_path().unwrap();
468        std::fs::write(&state_path, "{}").unwrap();
469
470        migrate_legacy_key_file(&state_path);
471
472        let legacy_exists = legacy.exists();
473        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
474        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
475
476        assert!(
477            !legacy_exists,
478            "stale legacy key file must be removed when a state copy exists"
479        );
480    }
481}