Skip to main content

lilo_rm_core/
spawn_context.rs

1use std::ffi::OsString;
2use std::path::PathBuf;
3
4use crate::{LaunchEnv, ShellResume};
5
6/// Exact env-var names dropped when forwarding caller env into a spawned runtime.
7///
8/// These either name the calling process's parent context (so forwarding lies to the spawned
9/// runtime about who its parent is) or are daemon/test internals the daemon re-sets correctly.
10pub 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
22/// Prefixes dropped when forwarding caller env. Used for variable families
23/// like `CLAUDE_CODE_*` and `CLAUDE_PLUGIN_*` that describe the calling claude
24/// instance, not user state.
25pub 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
39/// Capture the caller's environment, filtered through the denylist.
40///
41/// Iterates `std::env::vars_os()` so non-UTF-8 keys and values do not panic.
42/// Lossy decoding is applied via [`capture_env_from_os`]: we choose lossy over
43/// reject because env values are an open universe, and refusing on the first
44/// non-UTF-8 byte would break callers on systems with non-UTF-8 locales for
45/// reasons unrelated to anything rtm cares about.
46pub fn capture_caller_env() -> Vec<LaunchEnv> {
47    capture_env_from_os(std::env::vars_os())
48}
49
50/// Variant of [`capture_env_from`] that accepts `OsString` keys and values
51/// (the shape returned by `std::env::vars_os()`). Lossy-converts both before
52/// applying the denylist. Exposed for tests that want to feed `OsString`
53/// directly, exercising the same code path as `capture_caller_env`.
54pub 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
66/// Filter an iterator of `(String, String)` env entries through the denylist.
67/// Use [`capture_caller_env`] or [`capture_env_from_os`] for OS-sourced env;
68/// this lower-level variant is the right choice when env is already UTF-8.
69pub 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
91/// Capture the caller's current working directory.
92pub 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
130/// Placeholder cwd for call sites that only exercise launcher resolution
131/// (argv lookup, env builders) and never actually spawn a runtime in the
132/// returned directory. Centralized so future audits can grep for it and know
133/// "this cwd was deliberately not load-bearing."
134pub 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        // 0xFF is invalid as a leading UTF-8 byte. capture_env_from_os runs
180        // the same lossy conversion path as capture_caller_env (the runtime
181        // entry point), so this test protects the actual production path
182        // rather than the already-UTF-8 capture_env_from variant.
183        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        // Denylist must also run through the OsString path, not only the
193        // UTF-8 capture_env_from path.
194        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}