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}