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/// When no valid capture exists, falls back to probing the parent process's
204/// environment (#800). This covers the case where the lean-ctx MCP server was
205/// spawned by an agent that sets runtime vars only in native shell commands
206/// (e.g. Codex with CODEX_THREAD_ID) without going through `lean-ctx -c` first.
207#[must_use]
208pub fn load() -> BTreeMap<String, String> {
209    let Some(path) = store_path() else {
210        return BTreeMap::new();
211    };
212    migrate_legacy_key_file(&path);
213    let Some((vars, captured_at)) = read_store(&path) else {
214        // No capture file — try probing the parent process environment (#800).
215        let probed = probe_parent_env();
216        if !probed.is_empty() {
217            persist_probed_vars(&path, &probed);
218        }
219        return probed;
220    };
221    if now_secs().saturating_sub(captured_at) > TTL_SECS {
222        return BTreeMap::new();
223    }
224    // Defense-in-depth: a capture written by a build predating finding 2 may
225    // still hold credential-shaped vars. Drop them from the returned set, and if
226    // any were present rewrite (or delete) the file so the plaintext secret does
227    // not linger at rest — not just out of the forwarded env.
228    let cleaned: BTreeMap<String, String> = vars
229        .iter()
230        .filter(|(key, _)| is_forwardable(key))
231        .map(|(key, val)| (key.clone(), val.clone()))
232        .collect();
233    if cleaned.len() != vars.len() {
234        scrub_store(&path, &cleaned, captured_at);
235    }
236    cleaned
237}
238
239/// Probe the parent process's environment for forwardable agent runtime
240/// variables (#800). On Linux reads `/proc/<ppid>/environ`; on macOS uses
241/// `ps eww`. Returns only non-credential forwardable vars.
242fn probe_parent_env() -> BTreeMap<String, String> {
243    let ppid = parent_pid();
244    if ppid == 0 {
245        return BTreeMap::new();
246    }
247    let raw_env = read_parent_environ(ppid);
248    raw_env
249        .into_iter()
250        .filter(|(key, _)| is_forwardable(key))
251        .collect()
252}
253
254fn parent_pid() -> u32 {
255    #[cfg(unix)]
256    {
257        // SAFETY: getppid() is always safe — it reads the kernel's ppid for
258        // the calling process, has no side effects, and cannot fail.
259        unsafe { libc::getppid() as u32 }
260    }
261    #[cfg(not(unix))]
262    {
263        0
264    }
265}
266
267/// Read environment variables from a process. On Linux, reads
268/// `/proc/<pid>/environ` (NUL-separated key=value pairs). On macOS, falls
269/// back to parsing `ps eww <pid>` output.
270fn read_parent_environ(pid: u32) -> BTreeMap<String, String> {
271    // Linux: /proc/<pid>/environ is most reliable
272    #[cfg(target_os = "linux")]
273    if let Ok(data) = std::fs::read(format!("/proc/{pid}/environ")) {
274        return parse_null_separated_env(&data);
275    }
276
277    // macOS/fallback: ps eww gives env in a less structured format
278    #[cfg(target_os = "macos")]
279    if let Ok(output) = std::process::Command::new("ps")
280        .args(["eww", "-o", "command", "-p", &pid.to_string()])
281        .output()
282        && output.status.success()
283    {
284        return parse_ps_environ(&String::from_utf8_lossy(&output.stdout));
285    }
286
287    let _ = pid;
288    BTreeMap::new()
289}
290
291#[cfg(target_os = "linux")]
292fn parse_null_separated_env(data: &[u8]) -> BTreeMap<String, String> {
293    data.split(|&b| b == 0)
294        .filter_map(|entry| {
295            let s = std::str::from_utf8(entry).ok()?;
296            let (key, val) = s.split_once('=')?;
297            Some((key.to_string(), val.to_string()))
298        })
299        .collect()
300}
301
302#[cfg(target_os = "macos")]
303fn parse_ps_environ(output: &str) -> BTreeMap<String, String> {
304    let mut result = BTreeMap::new();
305    for line in output.lines().skip(1) {
306        for token in line.split_whitespace() {
307            if let Some((key, val)) = token.split_once('=')
308                && FORWARD_PREFIXES
309                    .iter()
310                    .any(|prefix| key.starts_with(prefix))
311            {
312                result.insert(key.to_string(), val.to_string());
313            }
314        }
315    }
316    result
317}
318
319fn persist_probed_vars(path: &Path, vars: &BTreeMap<String, String>) {
320    let payload = serde_json::json!({ "vars": vars, "captured_at": now_secs() });
321    if let Ok(json) = serde_json::to_string_pretty(&payload) {
322        if let Some(parent) = path.parent() {
323            let _ = std::fs::create_dir_all(parent);
324        }
325        let _ = crate::config_io::write_atomic(path, &json);
326    }
327}
328
329/// Rewrite the capture file with `vars` (preserving `captured_at`), or remove it
330/// entirely when nothing forwardable remains. Retroactively strips
331/// credential-shaped vars from captures written by older builds (finding 2).
332fn scrub_store(path: &Path, vars: &BTreeMap<String, String>, captured_at: u64) {
333    if vars.is_empty() {
334        let _ = std::fs::remove_file(path);
335        return;
336    }
337    let payload = serde_json::json!({ "vars": vars, "captured_at": captured_at });
338    if let Ok(json) = serde_json::to_string_pretty(&payload) {
339        let _ = crate::config_io::write_atomic(path, &json);
340    }
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn is_forwardable_matches_known_prefixes() {
349        assert!(is_forwardable("CODEX_THREAD_ID"));
350        assert!(is_forwardable("CLAUDE_SESSION"));
351        assert!(is_forwardable("OPENCODE_FOO"));
352        assert!(!is_forwardable("PATH"));
353        assert!(!is_forwardable("HOME"));
354        assert!(!is_forwardable("LEAN_CTX_DATA_DIR"));
355    }
356
357    #[test]
358    fn capture_then_load_roundtrips() {
359        let _iso = crate::core::data_dir::isolated_data_dir();
360        crate::test_env::set_var("CODEX_THREAD_ID", "thread-roundtrip");
361
362        capture();
363        let loaded = load();
364
365        crate::test_env::remove_var("CODEX_THREAD_ID");
366
367        assert_eq!(
368            loaded.get("CODEX_THREAD_ID").map(String::as_str),
369            Some("thread-roundtrip")
370        );
371    }
372
373    #[test]
374    fn capture_is_noop_without_forwardable_vars() {
375        let _iso = crate::core::data_dir::isolated_data_dir();
376        // Ensure no forwardable vars leak in from the host test environment.
377        for (key, _) in collect_from_process() {
378            crate::test_env::remove_var(key);
379        }
380
381        capture();
382        let exists = store_path().is_some_and(|p| p.exists());
383
384        assert!(!exists, "capture must not write a store with no vars");
385    }
386
387    #[test]
388    fn load_ignores_expired_capture() {
389        let _iso = crate::core::data_dir::isolated_data_dir();
390        let path = store_path().unwrap();
391        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
392
393        let stale = now_secs().saturating_sub(TTL_SECS + 60);
394        let payload =
395            serde_json::json!({ "vars": { "CODEX_THREAD_ID": "old" }, "captured_at": stale });
396        std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
397
398        let loaded = load();
399
400        assert!(loaded.is_empty(), "expired capture must not be loaded");
401    }
402
403    #[cfg(unix)]
404    #[test]
405    fn capture_sets_owner_only_permissions() {
406        use std::os::unix::fs::PermissionsExt;
407        let _iso = crate::core::data_dir::isolated_data_dir();
408        for (key, _) in collect_from_process() {
409            crate::test_env::remove_var(key);
410        }
411        // A forwardable (non-credential) var so a file is actually written; it
412        // can still hold session ids, so owner-only perms remain a requirement.
413        crate::test_env::set_var("CODEX_THREAD_ID", "session-id");
414
415        capture();
416        let path = store_path().unwrap();
417        crate::test_env::remove_var("CODEX_THREAD_ID");
418
419        let mode = std::fs::metadata(&path).unwrap().permissions().mode();
420        assert_eq!(
421            mode & 0o777,
422            0o600,
423            "captured runtime-env file must be owner-only"
424        );
425    }
426
427    // Finding 2 (GH security audit): credential-shaped vars must never be
428    // forwarded, even when they match a forwardable agent prefix.
429    #[test]
430    fn is_forwardable_rejects_credential_shaped_vars() {
431        // Session/thread identifiers cross the bridge.
432        assert!(is_forwardable("CODEX_THREAD_ID"));
433        assert!(is_forwardable("CLAUDE_SESSION_ID"));
434        assert!(is_forwardable("OPENCODE_SESSION"));
435        // Secrets matching a forwardable prefix do NOT.
436        assert!(!is_forwardable("GEMINI_API_KEY"));
437        assert!(!is_forwardable("OPENCODE_API_KEY"));
438        assert!(!is_forwardable("OPENCODE_SERVER_PASSWORD"));
439        assert!(!is_forwardable("CLAUDE_CODE_OAUTH_TOKEN"));
440        assert!(!is_forwardable("CODEX_ACCESS_TOKEN"));
441        assert!(!is_forwardable("CODEX_CLIENT_SECRET"));
442        assert!(!is_forwardable("CODEX_PRIVATE_KEY"));
443        assert!(!is_forwardable("GEMINI_CREDENTIALS"));
444    }
445
446    #[test]
447    fn capture_excludes_credentials() {
448        let _iso = crate::core::data_dir::isolated_data_dir();
449        for (key, _) in collect_from_process() {
450            crate::test_env::remove_var(key);
451        }
452        crate::test_env::set_var("CODEX_THREAD_ID", "thread-keep");
453        crate::test_env::set_var("GEMINI_API_KEY", "secret-drop");
454
455        capture();
456        let loaded = load();
457
458        crate::test_env::remove_var("CODEX_THREAD_ID");
459        crate::test_env::remove_var("GEMINI_API_KEY");
460
461        assert_eq!(
462            loaded.get("CODEX_THREAD_ID").map(String::as_str),
463            Some("thread-keep")
464        );
465        assert!(
466            !loaded.contains_key("GEMINI_API_KEY"),
467            "API key must never be captured or forwarded"
468        );
469    }
470
471    #[test]
472    fn load_scrubs_legacy_credentials_from_disk() {
473        let _iso = crate::core::data_dir::isolated_data_dir();
474        let path = store_path().unwrap();
475        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
476
477        // Simulate a capture written by an older build: holds a real secret.
478        let payload = serde_json::json!({
479            "vars": { "CODEX_THREAD_ID": "t", "OPENCODE_SERVER_PASSWORD": "p4ssw0rd" },
480            "captured_at": now_secs()
481        });
482        std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
483
484        let loaded = load();
485
486        assert!(
487            !loaded.contains_key("OPENCODE_SERVER_PASSWORD"),
488            "legacy credential must not be loaded"
489        );
490        assert_eq!(loaded.get("CODEX_THREAD_ID").map(String::as_str), Some("t"));
491
492        // The plaintext secret must be scrubbed from disk, not just the env.
493        let on_disk = std::fs::read_to_string(&path).unwrap();
494        assert!(
495            !on_disk.contains("OPENCODE_SERVER_PASSWORD") && !on_disk.contains("p4ssw0rd"),
496            "secret must be removed from the capture file at rest: {on_disk}"
497        );
498    }
499
500    #[test]
501    fn store_path_is_under_state_dir_not_config_dir_when_split() {
502        let _lock = crate::core::data_dir::test_env_lock();
503        let state = tempfile::tempdir().unwrap();
504        let config = tempfile::tempdir().unwrap();
505        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
506        crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", config.path());
507
508        let path = store_path().unwrap();
509
510        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
511        crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
512
513        assert!(
514            path.starts_with(state.path()),
515            "key file must live under the state dir: {}",
516            path.display()
517        );
518        assert!(
519            !path.starts_with(config.path()),
520            "key file must never resolve under the config dir"
521        );
522    }
523
524    #[test]
525    fn migrates_legacy_key_file_to_state_dir() {
526        let _lock = crate::core::data_dir::test_env_lock();
527        let data = tempfile::tempdir().unwrap();
528        let state = tempfile::tempdir().unwrap();
529        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
530        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
531
532        let legacy = data.path().join(FILE_NAME);
533        let payload =
534            serde_json::json!({ "vars": { "GEMINI_API_KEY": "k" }, "captured_at": now_secs() });
535        std::fs::write(&legacy, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
536
537        let state_path = store_path().unwrap();
538        migrate_legacy_key_file(&state_path);
539
540        let legacy_exists = legacy.exists();
541        let migrated = state_path.exists();
542        let parent_ok = state_path.parent() == Some(state.path());
543
544        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
545        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
546
547        assert!(migrated, "key file must be moved into the state dir");
548        assert!(!legacy_exists, "legacy key file must be removed after move");
549        assert!(
550            parent_ok,
551            "migrated file must sit directly in the state dir"
552        );
553    }
554
555    #[test]
556    fn removes_stale_legacy_when_state_copy_exists() {
557        let _lock = crate::core::data_dir::test_env_lock();
558        let data = tempfile::tempdir().unwrap();
559        let state = tempfile::tempdir().unwrap();
560        crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
561        crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
562
563        let legacy = data.path().join(FILE_NAME);
564        std::fs::write(&legacy, "{}").unwrap();
565        let state_path = store_path().unwrap();
566        std::fs::write(&state_path, "{}").unwrap();
567
568        migrate_legacy_key_file(&state_path);
569
570        let legacy_exists = legacy.exists();
571        crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
572        crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
573
574        assert!(
575            !legacy_exists,
576            "stale legacy key file must be removed when a state copy exists"
577        );
578    }
579}