Skip to main content

oxicode_agent/mcp/
spawn.rs

1//! MCP server spawn validation policy hook.
2//!
3//! The SDK owns the [`SpawnValidator`] trait + [`NoopSpawnValidator`]
4//! reference impl. Consumers (oxicode-cli, oxios) own the *policy* โ€” i.e. which
5//! commands are safe to spawn, which environment variables must be stripped,
6//! and which paths are allowed.
7//!
8//! See `docs/oxicode-sdk-ownership.md` ยง2 (MCP transport / MCP spawn validation
9//! policy split).
10//!
11//! # Why a trait and not a config
12//!
13//! Spawn validation involves multi-step logic (command parsing, shell-metachar
14//! scanning, env var whitelisting/blacklisting, path resolution) that varies
15//! per consumer. A trait lets each consumer express its own policy without
16//! the SDK prescribing a checklist. A `NoopSpawnValidator` is provided for
17//! the default case where no policy is needed (preserves existing behavior).
18
19use std::collections::HashMap;
20
21/// Validates MCP server spawn commands and environment.
22///
23/// Consumers inject domain-specific safety policy (forbidden shells,
24/// dangerous env vars, path traversal checks) without modifying the SDK's
25/// MCP client. The SDK calls `validate_command` before spawning and
26/// `sanitize_env` before passing the environment to the child process.
27///
28/// This trait is `#[unstable]` initially โ€” the surface may evolve as we
29/// learn which signals consumers actually need.
30pub trait SpawnValidator: Send + Sync {
31    /// Validate the command + args before spawn. Return `Err(reason)` to block
32    /// the spawn (the error message is forwarded to the caller as
33    /// `McpError::SpawnValidation`).
34    fn validate_command(&self, cmd: &str, args: &[String]) -> Result<(), String>;
35
36    /// Sanitize or strip dangerous environment variables before spawn.
37    ///
38    /// Implementations SHOULD remove known loader-injection vectors
39    /// (`LD_PRELOAD`, `LD_LIBRARY_PATH`, `DYLD_INSERT_LIBRARIES`,
40    /// `PYTHONPATH`, etc.) and SHOULD resolve any relative paths in
41    /// remaining vars to absolute. The default noop leaves the env
42    /// untouched.
43    fn sanitize_env(&self, env: &mut HashMap<String, String>);
44}
45
46/// Default no-op validator โ€” preserves current behavior (no validation, no
47/// env scrubbing). Use this when no consumer-supplied policy is registered.
48pub struct NoopSpawnValidator;
49
50impl SpawnValidator for NoopSpawnValidator {
51    fn validate_command(&self, _cmd: &str, _args: &[String]) -> Result<(), String> {
52        Ok(())
53    }
54
55    fn sanitize_env(&self, _env: &mut HashMap<String, String>) {}
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn noop_validator_accepts_any_command() {
64        let v = NoopSpawnValidator;
65        assert!(v.validate_command("/bin/anything", &[]).is_ok());
66        assert!(
67            v.validate_command("/bin/sh", &["-c".into(), "rm -rf /".into()])
68                .is_ok()
69        );
70    }
71
72    #[test]
73    fn noop_validator_leaves_env_untouched() {
74        let v = NoopSpawnValidator;
75        let mut env = HashMap::new();
76        env.insert("LD_PRELOAD".into(), "/tmp/evil.so".into());
77        env.insert("PATH".into(), "/usr/bin".into());
78        v.sanitize_env(&mut env);
79        assert_eq!(
80            env.get("LD_PRELOAD").map(String::as_str),
81            Some("/tmp/evil.so")
82        );
83        assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin"));
84    }
85
86    /// A test policy that blocks any command containing "sh" and strips
87    /// `LD_*` env vars. Verifies the trait can carry a real consumer policy.
88    struct TestStrictPolicy;
89    impl SpawnValidator for TestStrictPolicy {
90        fn validate_command(&self, cmd: &str, _args: &[String]) -> Result<(), String> {
91            if cmd.contains("sh") {
92                Err(format!("shell not allowed: {cmd}"))
93            } else {
94                Ok(())
95            }
96        }
97        fn sanitize_env(&self, env: &mut HashMap<String, String>) {
98            env.retain(|k, _| !k.starts_with("LD_"));
99        }
100    }
101
102    #[test]
103    fn consumer_policy_can_block_commands() {
104        let v = TestStrictPolicy;
105        assert!(v.validate_command("/usr/bin/node", &[]).is_ok());
106        assert!(v.validate_command("/bin/sh", &[]).is_err());
107    }
108
109    #[test]
110    fn consumer_policy_can_scrub_env() {
111        let v = TestStrictPolicy;
112        let mut env = HashMap::new();
113        env.insert("LD_PRELOAD".into(), "evil".into());
114        env.insert("LD_LIBRARY_PATH".into(), "/evil".into());
115        env.insert("PATH".into(), "/usr/bin".into());
116        v.sanitize_env(&mut env);
117        assert!(!env.contains_key("LD_PRELOAD"));
118        assert!(!env.contains_key("LD_LIBRARY_PATH"));
119        assert_eq!(env.get("PATH").map(String::as_str), Some("/usr/bin"));
120    }
121}