1use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18pub const FORWARD_PREFIXES: &[&str] = &[
21 "CODEX_",
22 "CLAUDE_",
23 "CODEBUDDY_",
24 "OPENCODE_",
25 "HERMES_",
26 "GEMINI_",
27];
28
29const CREDENTIAL_MARKERS: &[&str] = &[
39 "_KEY", "APIKEY", "SECRET", "TOKEN", "PASSWORD", "PASSWD", "CREDENTIAL", "AUTH", ];
48
49#[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
61const TTL_SECS: u64 = 7_200;
64
65#[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
78fn store_path() -> Option<PathBuf> {
86 crate::core::paths::state_dir()
87 .ok()
88 .map(|dir| dir.join(FILE_NAME))
89}
90
91fn 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
99fn 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 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 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#[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
170pub 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 let _ = crate::config_io::write_atomic(&path, &json);
199}
200
201#[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 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 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
239fn 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 unsafe { libc::getppid() as u32 }
260 }
261 #[cfg(not(unix))]
262 {
263 0
264 }
265}
266
267fn read_parent_environ(pid: u32) -> BTreeMap<String, String> {
271 #[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 #[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
329fn 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 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 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 #[test]
430 fn is_forwardable_rejects_credential_shaped_vars() {
431 assert!(is_forwardable("CODEX_THREAD_ID"));
433 assert!(is_forwardable("CLAUDE_SESSION_ID"));
434 assert!(is_forwardable("OPENCODE_SESSION"));
435 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 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 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}