Skip to main content

vtcode_safety/
mcp_sandbox.rs

1//! Per-MCP-server sandbox derivation.
2//!
3//! ยง18.4.4 of *The Hitchhiker's Guide to Agentic AI* calls for per-tool
4//! sandboxing: every code-executing surface needs an isolation profile, an
5//! audit trail, and resource limits. Today, MCP servers run as plain child
6//! processes (`crates/codegen/vtcode-mcp/src/provider.rs::connect_stdio`) or as direct HTTP
7//! clients (`crates/codegen/vtcode-mcp/src/rmcp_client.rs`) โ€” only the general-purpose
8//! `SandboxPolicy` protects the harness; MCP servers themselves have no
9//! isolation beyond command / endpoint allow-lists.
10//!
11//! This module composes a derived [`SandboxPolicy`] for each MCP server from
12//! the user-supplied parent policy and the per-server configuration. The
13//! The stdio wrapper delegates to the canonical sandbox manager so the runloop
14//! cannot accidentally launch an MCP server outside the requested boundary.
15
16use std::collections::HashMap;
17use std::path::PathBuf;
18
19use anyhow::{Context, Result, anyhow};
20use serde::{Deserialize, Serialize};
21
22use crate::sandboxing::{NetworkAllowlistEntry, ResourceLimits, SandboxPolicy, WritableRoot};
23
24/// Per-MCP-server sandbox settings.
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct McpSandboxOverrides {
27    /// When true, [`derive_mcp_sandbox_policy`] applies the conservative
28    /// resource caps below regardless of the parent policy. Default `true`.
29    #[serde(default = "default_true")]
30    enforce_conservative_limits: bool,
31    /// Memory cap (MB) applied to MCP child processes. Default `512`.
32    #[serde(default = "default_memory_mb")]
33    max_memory_mb: u64,
34    /// Maximum number of processes / pids in the sandbox. Default `64`.
35    #[serde(default = "default_max_pids")]
36    max_pids: u64,
37    /// CPU time budget in seconds. Default `60`.
38    #[serde(default = "default_cpu_secs")]
39    cpu_time_secs: u64,
40    /// Wall-clock timeout in seconds. Default `120`.
41    #[serde(default = "default_timeout_secs")]
42    timeout_secs: u64,
43    /// Optional writable root. When set, the sandbox restricts writes to this
44    /// directory (plus the workspace, if the parent policy permits workspace
45    /// writes). When unset, the sandbox inherits the parent's writable set.
46    #[serde(default)]
47    writable_root: Option<PathBuf>,
48    /// Network allow-list override. When set, MCP servers may only talk to
49    /// these hosts โ€” even when the parent policy would otherwise allow egress.
50    #[serde(default)]
51    allowed_endpoints: Vec<NetworkAllowlistEntry>,
52}
53
54impl Default for McpSandboxOverrides {
55    fn default() -> Self {
56        Self {
57            enforce_conservative_limits: true,
58            max_memory_mb: 512,
59            max_pids: 64,
60            cpu_time_secs: 60,
61            timeout_secs: 120,
62            writable_root: None,
63            allowed_endpoints: Vec::new(),
64        }
65    }
66}
67
68fn default_true() -> bool {
69    true
70}
71fn default_memory_mb() -> u64 {
72    512
73}
74fn default_max_pids() -> u64 {
75    64
76}
77fn default_cpu_secs() -> u64 {
78    60
79}
80fn default_timeout_secs() -> u64 {
81    120
82}
83
84/// Derive a [`SandboxPolicy`] for a single MCP server from the parent policy
85/// and per-server overrides.
86///
87/// Modular proof: the derivation itself establishes narrowing (writable set,
88/// network allow-list, conservative resource caps), so launchers can trust
89/// the derived policy instead of re-checking each restriction at every
90/// spawn site.
91///
92/// The derived policy:
93///
94/// - inherits the parent's sensitive-path denials and write roots,
95/// - narrows the writable set to `writable_root` when set,
96/// - restricts outbound network to `allowed_endpoints` when non-empty,
97/// - applies conservative resource caps when `enforce_conservative_limits` is
98///   true.
99///
100/// `DangerFullAccess` parents are downgraded to `WorkspaceWrite` around the
101/// server's writable root; `ReadOnly` parents are left unchanged.
102#[must_use]
103pub fn derive_mcp_sandbox_policy(parent: &SandboxPolicy, overrides: &McpSandboxOverrides) -> SandboxPolicy {
104    let mut derived = parent.clone();
105
106    if let Some(root) = &overrides.writable_root {
107        // Replace the writable set with the override root. We coerce into a
108        // `WorkspaceWrite` if the parent was `DangerFullAccess` so we still
109        // have a place to hang the restrictions.
110        derived = match derived {
111            SandboxPolicy::WorkspaceWrite {
112                writable_roots: _,
113                network_access,
114                network_allowlist,
115                sensitive_paths,
116                mut resource_limits,
117                seccomp_profile,
118                exclude_tmpdir_env_var,
119                exclude_slash_tmp,
120            } => {
121                if overrides.enforce_conservative_limits {
122                    resource_limits = conservative_resource_limits(overrides);
123                }
124                SandboxPolicy::WorkspaceWrite {
125                    writable_roots: vec![WritableRoot::new(root.clone())],
126                    network_access,
127                    network_allowlist: if overrides.allowed_endpoints.is_empty() {
128                        network_allowlist
129                    } else {
130                        overrides.allowed_endpoints.clone()
131                    },
132                    sensitive_paths,
133                    resource_limits,
134                    seccomp_profile,
135                    exclude_tmpdir_env_var,
136                    exclude_slash_tmp,
137                }
138            }
139            SandboxPolicy::ReadOnly { mut network_allowlist, .. } => {
140                if !overrides.allowed_endpoints.is_empty() {
141                    network_allowlist = overrides.allowed_endpoints.clone();
142                }
143                SandboxPolicy::ReadOnly {
144                    network_access: !network_allowlist.is_empty(),
145                    network_allowlist,
146                }
147            }
148            SandboxPolicy::DangerFullAccess => {
149                let mut resource_limits = ResourceLimits::default();
150                if overrides.enforce_conservative_limits {
151                    resource_limits = conservative_resource_limits(overrides);
152                }
153                SandboxPolicy::WorkspaceWrite {
154                    writable_roots: vec![WritableRoot::new(root.clone())],
155                    network_access: false,
156                    network_allowlist: overrides.allowed_endpoints.clone(),
157                    sensitive_paths: None,
158                    resource_limits,
159                    seccomp_profile: Default::default(),
160                    exclude_tmpdir_env_var: false,
161                    exclude_slash_tmp: false,
162                }
163            }
164            SandboxPolicy::ExternalSandbox { description } => SandboxPolicy::ExternalSandbox { description },
165        };
166    } else if !overrides.allowed_endpoints.is_empty() {
167        // No writable_root override but the network allow-list should still
168        // be applied.
169        derived = match derived {
170            SandboxPolicy::WorkspaceWrite {
171                writable_roots,
172                network_access,
173                sensitive_paths,
174                mut resource_limits,
175                seccomp_profile,
176                exclude_tmpdir_env_var,
177                exclude_slash_tmp,
178                ..
179            } => {
180                if overrides.enforce_conservative_limits {
181                    resource_limits = conservative_resource_limits(overrides);
182                }
183                SandboxPolicy::WorkspaceWrite {
184                    writable_roots,
185                    network_access,
186                    network_allowlist: overrides.allowed_endpoints.clone(),
187                    sensitive_paths,
188                    resource_limits,
189                    seccomp_profile,
190                    exclude_tmpdir_env_var,
191                    exclude_slash_tmp,
192                }
193            }
194            SandboxPolicy::ReadOnly { .. } => SandboxPolicy::ReadOnly {
195                network_access: true,
196                network_allowlist: overrides.allowed_endpoints.clone(),
197            },
198            other => other,
199        };
200    }
201
202    if overrides.enforce_conservative_limits
203        && let SandboxPolicy::WorkspaceWrite { ref mut resource_limits, .. } = derived
204    {
205        *resource_limits = conservative_resource_limits(overrides);
206    }
207
208    derived
209}
210
211fn conservative_resource_limits(overrides: &McpSandboxOverrides) -> ResourceLimits {
212    ResourceLimits {
213        max_memory_mb: overrides.max_memory_mb,
214        max_pids: u32::try_from(overrides.max_pids).unwrap_or(u32::MAX),
215        max_disk_mb: 1024,
216        cpu_time_secs: overrides.cpu_time_secs,
217        timeout_secs: overrides.timeout_secs,
218    }
219}
220
221/// Apply the per-server sandbox wrapper to a stdio command.
222///
223/// This delegates to the same [`crate::sandboxing::SandboxManager`] used by
224/// command execution. Unsupported platforms, missing Linux helpers, external
225/// policies, and policies whose network rules cannot be enforced exactly all
226/// return an error; MCP is never silently launched without its requested
227/// boundary.
228///
229/// stdio configuration is inherited by default โ€” callers that need to
230/// redirect stdin/stdout/stderr should configure the returned command after
231/// this call.
232#[expect(
233    unused_results,
234    reason = "MCP command builder methods configure the owned command and return a fluent mutable reference."
235)]
236pub fn wrap_stdio_command(
237    command: std::process::Command,
238    sandbox_policy: &SandboxPolicy,
239) -> Result<std::process::Command> {
240    if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
241        return Ok(command);
242    }
243    if matches!(sandbox_policy, SandboxPolicy::ExternalSandbox { .. }) {
244        return Err(anyhow!("MCP stdio cannot use an external sandbox policy without an external launcher"));
245    }
246
247    let original_program = command.get_program().to_owned();
248    let original_args: Vec<_> = command.get_args().map(|s| s.to_owned()).collect();
249    let current_dir = command
250        .get_current_dir()
251        .map(PathBuf::from)
252        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
253    let envs: HashMap<_, _> = command
254        .get_envs()
255        .filter_map(|(k, v)| v.map(|val| (k.to_owned(), val.to_owned())))
256        .collect();
257
258    let linux_launcher = crate::sandboxing::LinuxSandboxLauncher::resolve();
259    let spec = crate::sandboxing::CommandSpec::new(original_program)
260        .with_args(original_args.into_iter().map(|arg| arg.to_string_lossy().into_owned()))
261        .with_cwd(current_dir.clone())
262        .with_env(
263            envs.into_iter()
264                .map(|(key, value)| (key.to_string_lossy().into_owned(), value.to_string_lossy().into_owned()))
265                .collect(),
266        );
267    let exec_env = crate::sandboxing::SandboxManager::new()
268        .transform(spec, sandbox_policy, &current_dir, linux_launcher.as_ref())
269        .context("transform MCP stdio command with the sandbox policy")?;
270
271    let mut new_command = std::process::Command::new(exec_env.program);
272    new_command.args(exec_env.args).current_dir(exec_env.cwd).env_clear();
273    for (key, value) in exec_env.env {
274        new_command.env(key, value);
275    }
276    Ok(new_command)
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282    use crate::sandboxing::SeccompProfile;
283
284    fn workspace_parent() -> SandboxPolicy {
285        SandboxPolicy::WorkspaceWrite {
286            writable_roots: vec![WritableRoot::new(PathBuf::from("/workspace"))],
287            network_access: true,
288            network_allowlist: vec![NetworkAllowlistEntry::https("api.example.com")],
289            sensitive_paths: None,
290            resource_limits: ResourceLimits::unlimited(),
291            seccomp_profile: SeccompProfile::permissive(),
292            exclude_tmpdir_env_var: false,
293            exclude_slash_tmp: false,
294        }
295    }
296
297    #[test]
298    fn derive_narrows_writable_root_when_set() {
299        let parent = workspace_parent();
300        let overrides = McpSandboxOverrides {
301            writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
302            ..McpSandboxOverrides::default()
303        };
304        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
305        match derived {
306            SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
307                assert_eq!(writable_roots.len(), 1);
308                assert_eq!(writable_roots[0].root, PathBuf::from("/tmp/mcp-scratch"));
309            }
310            other => panic!("expected WorkspaceWrite, got {other:?}"),
311        }
312    }
313
314    #[test]
315    fn derive_keeps_parent_roots_when_override_missing() {
316        let parent = workspace_parent();
317        let overrides = McpSandboxOverrides::default();
318        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
319        match derived {
320            SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
321                assert_eq!(writable_roots.len(), 1);
322                assert_eq!(writable_roots[0].root, PathBuf::from("/workspace"));
323            }
324            other => panic!("expected WorkspaceWrite, got {other:?}"),
325        }
326    }
327
328    #[test]
329    fn derive_restricts_network_to_allowed_endpoints() {
330        let parent = workspace_parent();
331        let overrides = McpSandboxOverrides {
332            allowed_endpoints: vec![NetworkAllowlistEntry::https("mcp.internal")],
333            ..McpSandboxOverrides::default()
334        };
335        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
336        match derived {
337            SandboxPolicy::WorkspaceWrite { network_allowlist, .. } => {
338                assert_eq!(network_allowlist.len(), 1);
339                assert_eq!(network_allowlist[0].domain, "mcp.internal");
340            }
341            other => panic!("expected WorkspaceWrite, got {other:?}"),
342        }
343    }
344
345    #[test]
346    fn derive_applies_conservative_resource_caps_by_default() {
347        let parent = workspace_parent();
348        let overrides = McpSandboxOverrides::default();
349        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
350        match derived {
351            SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
352                assert_eq!(resource_limits.max_memory_mb, 512);
353                assert_eq!(resource_limits.max_pids, 64);
354                assert_eq!(resource_limits.cpu_time_secs, 60);
355                assert_eq!(resource_limits.timeout_secs, 120);
356            }
357            other => panic!("expected WorkspaceWrite, got {other:?}"),
358        }
359    }
360
361    #[test]
362    fn derive_respects_disabled_conservative_caps() {
363        let parent = workspace_parent();
364        let overrides = McpSandboxOverrides {
365            enforce_conservative_limits: false,
366            ..McpSandboxOverrides::default()
367        };
368        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
369        match derived {
370            SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
371                // Parent had `ResourceLimits::unlimited()` which is all zeros.
372                assert_eq!(resource_limits.max_memory_mb, 0);
373            }
374            other => panic!("expected WorkspaceWrite, got {other:?}"),
375        }
376    }
377
378    #[test]
379    fn derive_downgrades_danger_full_access_with_writable_root() {
380        let overrides = McpSandboxOverrides {
381            writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
382            ..McpSandboxOverrides::default()
383        };
384        let derived = derive_mcp_sandbox_policy(&SandboxPolicy::DangerFullAccess, &overrides);
385        match derived {
386            SandboxPolicy::WorkspaceWrite { writable_roots, network_access, .. } => {
387                assert_eq!(writable_roots.len(), 1);
388                assert!(!network_access);
389            }
390            other => panic!("expected WorkspaceWrite downgrade, got {other:?}"),
391        }
392    }
393
394    #[test]
395    fn overrides_default_is_conservative() {
396        let defaults = McpSandboxOverrides::default();
397        assert!(defaults.enforce_conservative_limits);
398        assert_eq!(defaults.max_memory_mb, 512);
399        assert!(defaults.allowed_endpoints.is_empty());
400        assert!(defaults.writable_root.is_none());
401    }
402
403    #[test]
404    fn wrapper_for_current_platform_handles_unsupported_targets() {
405        let policy = workspace_parent();
406        // A policy with a hostname allowlist must fail closed until the
407        // platform can enforce the destination exactly.
408        let command = std::process::Command::new("mcp-server");
409        let result = wrap_stdio_command(command, &policy);
410        assert!(result.is_err());
411    }
412}