Skip to main content

kernex_core/
spawn.rs

1//! Helpers for spawning subprocesses with skill/MCP-supplied environment maps.
2//!
3//! Skill metadata (`mcp.json`, `toolbox.json`) is authored by third parties
4//! and only loosely trusted. The dynamic linker honours `LD_PRELOAD` /
5//! `DYLD_INSERT_LIBRARIES` and similar variables *before* any sandbox
6//! restriction runs in `pre_exec`, so a hostile skill that injects one of
7//! these env keys can hijack the spawned process and bypass Landlock /
8//! Seatbelt entirely.
9//!
10//! Route every skill-controlled environment map through
11//! [`filter_unsafe_env`] before applying it to a `Command`.
12
13use std::collections::HashMap;
14
15/// Environment variable names the dynamic linker honours that, if attacker-
16/// controlled, can subvert the spawned process before any sandbox is applied.
17///
18/// Names are matched case-insensitively. Includes Linux (LD_*), macOS
19/// (DYLD_*), and the auditing/profiler hooks that are equivalent in effect.
20pub const UNSAFE_ENV_KEYS: &[&str] = &[
21    "LD_PRELOAD",
22    "LD_LIBRARY_PATH",
23    "LD_AUDIT",
24    "LD_DEBUG",
25    "LD_DEBUG_OUTPUT",
26    "DYLD_INSERT_LIBRARIES",
27    "DYLD_LIBRARY_PATH",
28    "DYLD_FALLBACK_LIBRARY_PATH",
29    "DYLD_FRAMEWORK_PATH",
30    "DYLD_FALLBACK_FRAMEWORK_PATH",
31    "DYLD_PRINT_LIBRARIES",
32    "DYLD_FORCE_FLAT_NAMESPACE",
33];
34
35/// Returns a copy of `env` with every dynamic-linker key from
36/// [`UNSAFE_ENV_KEYS`] removed. Dropped keys are returned as the second
37/// element so callers can `tracing::warn!` on them.
38///
39/// Matching is ASCII case-insensitive.
40pub fn filter_unsafe_env(env: &HashMap<String, String>) -> (HashMap<String, String>, Vec<String>) {
41    let mut safe = HashMap::with_capacity(env.len());
42    let mut dropped = Vec::new();
43    for (k, v) in env {
44        if is_unsafe_env_key(k) {
45            dropped.push(k.clone());
46        } else {
47            safe.insert(k.clone(), v.clone());
48        }
49    }
50    (safe, dropped)
51}
52
53/// True if `k` matches any entry in [`UNSAFE_ENV_KEYS`] (case-insensitive).
54pub fn is_unsafe_env_key(k: &str) -> bool {
55    UNSAFE_ENV_KEYS
56        .iter()
57        .any(|banned| k.eq_ignore_ascii_case(banned))
58}
59
60#[cfg(test)]
61mod tests {
62    use super::*;
63
64    #[test]
65    fn ld_preload_dropped() {
66        let mut env = HashMap::new();
67        env.insert("LD_PRELOAD".into(), "/tmp/x.so".into());
68        env.insert("PATH".into(), "/usr/bin".into());
69        let (safe, dropped) = filter_unsafe_env(&env);
70        assert!(!safe.contains_key("LD_PRELOAD"));
71        assert_eq!(safe.get("PATH").map(String::as_str), Some("/usr/bin"));
72        assert_eq!(dropped, vec!["LD_PRELOAD".to_string()]);
73    }
74
75    #[test]
76    fn case_insensitive_dyld() {
77        let mut env = HashMap::new();
78        env.insert("dyld_insert_libraries".into(), "/tmp/x.dylib".into());
79        let (safe, dropped) = filter_unsafe_env(&env);
80        assert!(safe.is_empty());
81        assert_eq!(dropped.len(), 1);
82    }
83
84    #[test]
85    fn benign_keys_preserved() {
86        let mut env = HashMap::new();
87        env.insert("HOME".into(), "/home/user".into());
88        env.insert("MY_TOOL_TOKEN".into(), "abc".into());
89        let (safe, dropped) = filter_unsafe_env(&env);
90        assert_eq!(safe.len(), 2);
91        assert!(dropped.is_empty());
92    }
93
94    #[test]
95    fn unsafe_key_check() {
96        assert!(is_unsafe_env_key("LD_PRELOAD"));
97        assert!(is_unsafe_env_key("ld_preload"));
98        assert!(is_unsafe_env_key("DYLD_LIBRARY_PATH"));
99        assert!(!is_unsafe_env_key("PATH"));
100        assert!(!is_unsafe_env_key("LD_PRELOADX"));
101    }
102}