1use std::ffi::OsString;
2use std::path::PathBuf;
3
4use crate::{LaunchEnv, ShellResume};
5
6pub const CALLER_ENV_DENYLIST: &[&str] = &[
11 "CLAUDECODE",
12 "TMUX",
13 "TMUX_PANE",
14 "RTM_SOCKET_PATH",
15 "RTM_DB_PATH",
16 "HELIOY_SESSION_ID",
17 "HELIOY_RUNTIME",
18 "RTM_SESSION_ID",
19 "RTM_RUNTIME_KIND",
20];
21
22pub const CALLER_ENV_DENYLIST_PREFIXES: &[&str] = &["CLAUDE_CODE_", "CLAUDE_PLUGIN_"];
26
27const SHELL_RESUME_ENV_ALLOWLIST: &[&str] = &[
28 "COLORTERM",
29 "HOME",
30 "LANG",
31 "LC_ALL",
32 "LOGNAME",
33 "PATH",
34 "SHELL",
35 "TERM",
36 "USER",
37];
38
39pub fn capture_caller_env() -> Vec<LaunchEnv> {
47 capture_env_from_os(std::env::vars_os())
48}
49
50pub fn capture_env_from_os<I>(iter: I) -> Vec<LaunchEnv>
55where
56 I: IntoIterator<Item = (OsString, OsString)>,
57{
58 capture_env_from(iter.into_iter().map(|(k, v)| {
59 (
60 k.to_string_lossy().into_owned(),
61 v.to_string_lossy().into_owned(),
62 )
63 }))
64}
65
66pub fn capture_env_from<I, K, V>(iter: I) -> Vec<LaunchEnv>
70where
71 I: IntoIterator<Item = (K, V)>,
72 K: Into<String>,
73 V: Into<String>,
74{
75 iter.into_iter()
76 .map(|(k, v)| (k.into(), v.into()))
77 .filter(|(k, _)| !is_denied(k))
78 .map(|(k, v)| LaunchEnv::new(k, v))
79 .collect()
80}
81
82fn is_denied(key: &str) -> bool {
83 if CALLER_ENV_DENYLIST.contains(&key) {
84 return true;
85 }
86 CALLER_ENV_DENYLIST_PREFIXES
87 .iter()
88 .any(|prefix| key.starts_with(prefix))
89}
90
91pub fn capture_caller_cwd() -> std::io::Result<PathBuf> {
93 std::env::current_dir()
94}
95
96pub fn capture_shell_resume(cwd: PathBuf) -> ShellResume {
97 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_owned());
98 let mut env = capture_shell_resume_env(std::env::vars_os());
99 ensure_shell_env(&mut env, &shell);
100 ShellResume {
101 argv: vec![shell],
102 env,
103 cwd,
104 }
105}
106
107pub fn capture_shell_resume_env<I>(iter: I) -> Vec<LaunchEnv>
108where
109 I: IntoIterator<Item = (OsString, OsString)>,
110{
111 iter.into_iter()
112 .map(|(k, v)| {
113 (
114 k.to_string_lossy().into_owned(),
115 v.to_string_lossy().into_owned(),
116 )
117 })
118 .filter(|(k, _)| SHELL_RESUME_ENV_ALLOWLIST.contains(&k.as_str()))
119 .map(|(k, v)| LaunchEnv::new(k, v))
120 .collect()
121}
122
123fn ensure_shell_env(env: &mut Vec<LaunchEnv>, shell: &str) {
124 if env.iter().any(|entry| entry.key == "SHELL") {
125 return;
126 }
127 env.push(LaunchEnv::new("SHELL", shell));
128}
129
130pub fn launcher_probe_cwd() -> PathBuf {
135 PathBuf::from("/")
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141
142 #[test]
143 fn denylist_drops_parent_markers() {
144 let env = capture_env_from([
145 ("PATH", "/usr/bin"),
146 ("CLAUDECODE", "1"),
147 ("CLAUDE_CODE_SESSION_ID", "abc"),
148 ("CLAUDE_PLUGIN_DATA", "/tmp"),
149 ("TMUX", "/private/tmp/tmux"),
150 ("TMUX_PANE", "%4"),
151 ("RTM_SOCKET_PATH", "/tmp/rtm.sock"),
152 ("RTM_DB_PATH", "/tmp/rtm.db"),
153 ("HELIOY_SESSION_ID", "session"),
154 ("HELIOY_RUNTIME", "claude"),
155 ("RTM_SESSION_ID", "session"),
156 ("RTM_RUNTIME_KIND", "claude"),
157 ("HELIOY_PAT", "ghp_secret"),
158 ("ANTHROPIC_API_KEY", "sk-secret"),
159 ]);
160 let keys: Vec<&str> = env.iter().map(|e| e.key.as_str()).collect();
161 assert_eq!(keys, vec!["PATH", "HELIOY_PAT", "ANTHROPIC_API_KEY"]);
162 }
163
164 #[test]
165 fn denylist_keeps_user_state() {
166 let env = capture_env_from([
167 ("PATH", "/usr/bin"),
168 ("HOME", "/Users/alphab"),
169 ("LANG", "en_US.UTF-8"),
170 ("MISE_SHELL", "zsh"),
171 ]);
172 assert_eq!(env.len(), 4);
173 }
174
175 #[test]
176 fn capture_env_from_os_tolerates_non_utf8() {
177 use std::os::unix::ffi::OsStringExt;
178
179 let raw_value = OsString::from_vec(vec![b'A', 0xFF, b'B']);
184 let env = capture_env_from_os([(OsString::from("RTM_TEST_BAD_BYTES"), raw_value)]);
185 assert_eq!(env.len(), 1);
186 assert_eq!(env[0].key, "RTM_TEST_BAD_BYTES");
187 assert!(env[0].value.contains('\u{FFFD}'), "{:?}", env[0].value);
188 }
189
190 #[test]
191 fn capture_env_from_os_applies_denylist() {
192 let env = capture_env_from_os([
195 (OsString::from("PATH"), OsString::from("/usr/bin")),
196 (OsString::from("CLAUDECODE"), OsString::from("1")),
197 (
198 OsString::from("CLAUDE_CODE_SESSION_ID"),
199 OsString::from("abc"),
200 ),
201 ]);
202 let keys: Vec<&str> = env.iter().map(|e| e.key.as_str()).collect();
203 assert_eq!(keys, vec!["PATH"]);
204 }
205
206 #[test]
207 fn shell_resume_env_keeps_shell_state_without_runtime_secrets() {
208 let env = capture_shell_resume_env([
209 (OsString::from("SHELL"), OsString::from("/bin/zsh")),
210 (OsString::from("HOME"), OsString::from("/Users/test")),
211 (OsString::from("PATH"), OsString::from("/usr/bin")),
212 (OsString::from("TERM"), OsString::from("xterm-256color")),
213 (OsString::from("RTM_SESSION_ID"), OsString::from("secret")),
214 (
215 OsString::from("ANTHROPIC_API_KEY"),
216 OsString::from("secret"),
217 ),
218 ]);
219 let keys: Vec<&str> = env.iter().map(|e| e.key.as_str()).collect();
220 assert_eq!(keys, vec!["SHELL", "HOME", "PATH", "TERM"]);
221 }
222}