1use std::collections::BTreeMap;
16use std::path::{Path, PathBuf};
17
18pub(crate) 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(crate) 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(crate) 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>) {
323 use std::io::Write;
324 let payload = serde_json::json!({ "vars": vars, "captured_at": now_secs() });
325 let Ok(json) = serde_json::to_string_pretty(&payload) else {
326 return;
327 };
328 if let Some(parent) = path.parent() {
329 let _ = std::fs::create_dir_all(parent);
330 }
331 let mut options = std::fs::OpenOptions::new();
332 options.write(true).create_new(true);
333 #[cfg(unix)]
334 {
335 use std::os::unix::fs::OpenOptionsExt;
336 options.mode(0o600);
337 }
338 if let Ok(mut file) = options.open(path) {
339 let _ = file.write_all(json.as_bytes());
340 }
341}
342
343fn scrub_store(path: &Path, vars: &BTreeMap<String, String>, captured_at: u64) {
347 if vars.is_empty() {
348 let _ = std::fs::remove_file(path);
349 return;
350 }
351 let payload = serde_json::json!({ "vars": vars, "captured_at": captured_at });
352 if let Ok(json) = serde_json::to_string_pretty(&payload) {
353 let _ = crate::config_io::write_atomic(path, &json);
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::*;
360
361 #[test]
362 fn is_forwardable_matches_known_prefixes() {
363 assert!(is_forwardable("CODEX_THREAD_ID"));
364 assert!(is_forwardable("CLAUDE_SESSION"));
365 assert!(is_forwardable("OPENCODE_FOO"));
366 assert!(!is_forwardable("PATH"));
367 assert!(!is_forwardable("HOME"));
368 assert!(!is_forwardable("LEAN_CTX_DATA_DIR"));
369 }
370
371 #[test]
372 fn capture_then_load_roundtrips() {
373 let _iso = crate::core::data_dir::isolated_data_dir();
374 crate::test_env::set_var("CODEX_THREAD_ID", "thread-roundtrip");
375
376 capture();
377 let loaded = load();
378
379 crate::test_env::remove_var("CODEX_THREAD_ID");
380
381 assert_eq!(
382 loaded.get("CODEX_THREAD_ID").map(String::as_str),
383 Some("thread-roundtrip")
384 );
385 }
386
387 #[test]
388 fn probe_persist_never_replaces_a_concurrent_capture() {
389 let _iso = crate::core::data_dir::isolated_data_dir();
390 crate::test_env::set_var("CODEX_THREAD_ID", "thread-from-hook");
391 capture();
392 crate::test_env::remove_var("CODEX_THREAD_ID");
393
394 let probed = BTreeMap::from([("CLAUDE_PID".to_string(), "1".to_string())]);
396 persist_probed_vars(&store_path().unwrap(), &probed);
397
398 assert_eq!(
399 load().get("CODEX_THREAD_ID").map(String::as_str),
400 Some("thread-from-hook")
401 );
402 }
403
404 #[test]
405 fn capture_is_noop_without_forwardable_vars() {
406 let _iso = crate::core::data_dir::isolated_data_dir();
407 for (key, _) in collect_from_process() {
409 crate::test_env::remove_var(key);
410 }
411
412 capture();
413 let exists = store_path().is_some_and(|p| p.exists());
414
415 assert!(!exists, "capture must not write a store with no vars");
416 }
417
418 #[test]
419 fn load_ignores_expired_capture() {
420 let _iso = crate::core::data_dir::isolated_data_dir();
421 let path = store_path().unwrap();
422 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
423
424 let stale = now_secs().saturating_sub(TTL_SECS + 60);
425 let payload =
426 serde_json::json!({ "vars": { "CODEX_THREAD_ID": "old" }, "captured_at": stale });
427 std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
428
429 let loaded = load();
430
431 assert!(loaded.is_empty(), "expired capture must not be loaded");
432 }
433
434 #[cfg(unix)]
435 #[test]
436 fn capture_sets_owner_only_permissions() {
437 use std::os::unix::fs::PermissionsExt;
438 let _iso = crate::core::data_dir::isolated_data_dir();
439 for (key, _) in collect_from_process() {
440 crate::test_env::remove_var(key);
441 }
442 crate::test_env::set_var("CODEX_THREAD_ID", "session-id");
445
446 capture();
447 let path = store_path().unwrap();
448 crate::test_env::remove_var("CODEX_THREAD_ID");
449
450 let mode = std::fs::metadata(&path).unwrap().permissions().mode();
451 assert_eq!(
452 mode & 0o777,
453 0o600,
454 "captured runtime-env file must be owner-only"
455 );
456 }
457
458 #[test]
461 fn is_forwardable_rejects_credential_shaped_vars() {
462 assert!(is_forwardable("CODEX_THREAD_ID"));
464 assert!(is_forwardable("CLAUDE_SESSION_ID"));
465 assert!(is_forwardable("OPENCODE_SESSION"));
466 assert!(!is_forwardable("GEMINI_API_KEY"));
468 assert!(!is_forwardable("OPENCODE_API_KEY"));
469 assert!(!is_forwardable("OPENCODE_SERVER_PASSWORD"));
470 assert!(!is_forwardable("CLAUDE_CODE_OAUTH_TOKEN"));
471 assert!(!is_forwardable("CODEX_ACCESS_TOKEN"));
472 assert!(!is_forwardable("CODEX_CLIENT_SECRET"));
473 assert!(!is_forwardable("CODEX_PRIVATE_KEY"));
474 assert!(!is_forwardable("GEMINI_CREDENTIALS"));
475 }
476
477 #[test]
478 fn capture_excludes_credentials() {
479 let _iso = crate::core::data_dir::isolated_data_dir();
480 for (key, _) in collect_from_process() {
481 crate::test_env::remove_var(key);
482 }
483 crate::test_env::set_var("CODEX_THREAD_ID", "thread-keep");
484 crate::test_env::set_var("GEMINI_API_KEY", "secret-drop");
485
486 capture();
487 let loaded = load();
488
489 crate::test_env::remove_var("CODEX_THREAD_ID");
490 crate::test_env::remove_var("GEMINI_API_KEY");
491
492 assert_eq!(
493 loaded.get("CODEX_THREAD_ID").map(String::as_str),
494 Some("thread-keep")
495 );
496 assert!(
497 !loaded.contains_key("GEMINI_API_KEY"),
498 "API key must never be captured or forwarded"
499 );
500 }
501
502 #[test]
503 fn load_scrubs_legacy_credentials_from_disk() {
504 let _iso = crate::core::data_dir::isolated_data_dir();
505 let path = store_path().unwrap();
506 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
507
508 let payload = serde_json::json!({
510 "vars": { "CODEX_THREAD_ID": "t", "OPENCODE_SERVER_PASSWORD": "p4ssw0rd" },
511 "captured_at": now_secs()
512 });
513 std::fs::write(&path, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
514
515 let loaded = load();
516
517 assert!(
518 !loaded.contains_key("OPENCODE_SERVER_PASSWORD"),
519 "legacy credential must not be loaded"
520 );
521 assert_eq!(loaded.get("CODEX_THREAD_ID").map(String::as_str), Some("t"));
522
523 let on_disk = std::fs::read_to_string(&path).unwrap();
525 assert!(
526 !on_disk.contains("OPENCODE_SERVER_PASSWORD") && !on_disk.contains("p4ssw0rd"),
527 "secret must be removed from the capture file at rest: {on_disk}"
528 );
529 }
530
531 #[test]
532 fn store_path_is_under_state_dir_not_config_dir_when_split() {
533 let _lock = crate::core::data_dir::test_env_lock();
534 let state = tempfile::tempdir().unwrap();
535 let config = tempfile::tempdir().unwrap();
536 crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
537 crate::test_env::set_var("LEAN_CTX_CONFIG_DIR", config.path());
538
539 let path = store_path().unwrap();
540
541 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
542 crate::test_env::remove_var("LEAN_CTX_CONFIG_DIR");
543
544 assert!(
545 path.starts_with(state.path()),
546 "key file must live under the state dir: {}",
547 path.display()
548 );
549 assert!(
550 !path.starts_with(config.path()),
551 "key file must never resolve under the config dir"
552 );
553 }
554
555 #[test]
556 fn migrates_legacy_key_file_to_state_dir() {
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 let payload =
565 serde_json::json!({ "vars": { "GEMINI_API_KEY": "k" }, "captured_at": now_secs() });
566 std::fs::write(&legacy, serde_json::to_string_pretty(&payload).unwrap()).unwrap();
567
568 let state_path = store_path().unwrap();
569 migrate_legacy_key_file(&state_path);
570
571 let legacy_exists = legacy.exists();
572 let migrated = state_path.exists();
573 let parent_ok = state_path.parent() == Some(state.path());
574
575 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
576 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
577
578 assert!(migrated, "key file must be moved into the state dir");
579 assert!(!legacy_exists, "legacy key file must be removed after move");
580 assert!(
581 parent_ok,
582 "migrated file must sit directly in the state dir"
583 );
584 }
585
586 #[test]
587 fn removes_stale_legacy_when_state_copy_exists() {
588 let _lock = crate::core::data_dir::test_env_lock();
589 let data = tempfile::tempdir().unwrap();
590 let state = tempfile::tempdir().unwrap();
591 crate::test_env::set_var("LEAN_CTX_DATA_DIR", data.path());
592 crate::test_env::set_var("LEAN_CTX_STATE_DIR", state.path());
593
594 let legacy = data.path().join(FILE_NAME);
595 std::fs::write(&legacy, "{}").unwrap();
596 let state_path = store_path().unwrap();
597 std::fs::write(&state_path, "{}").unwrap();
598
599 migrate_legacy_key_file(&state_path);
600
601 let legacy_exists = legacy.exists();
602 crate::test_env::remove_var("LEAN_CTX_DATA_DIR");
603 crate::test_env::remove_var("LEAN_CTX_STATE_DIR");
604
605 assert!(
606 !legacy_exists,
607 "stale legacy key file must be removed when a state copy exists"
608 );
609 }
610}