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/// The derived policy:
88///
89/// - inherits the parent's sensitive-path denials and write roots,
90/// - narrows the writable set to `writable_root` when set,
91/// - restricts outbound network to `allowed_endpoints` when non-empty,
92/// - applies conservative resource caps when `enforce_conservative_limits` is
93///   true.
94///
95/// `DangerFullAccess` parents are downgraded to `WorkspaceWrite` around the
96/// server's writable root; `ReadOnly` parents are left unchanged.
97#[must_use]
98fn derive_mcp_sandbox_policy(parent: &SandboxPolicy, overrides: &McpSandboxOverrides) -> SandboxPolicy {
99    let mut derived = parent.clone();
100
101    if let Some(root) = &overrides.writable_root {
102        // Replace the writable set with the override root. We coerce into a
103        // `WorkspaceWrite` if the parent was `DangerFullAccess` so we still
104        // have a place to hang the restrictions.
105        derived = match derived {
106            SandboxPolicy::WorkspaceWrite {
107                writable_roots: _,
108                network_access,
109                network_allowlist,
110                sensitive_paths,
111                mut resource_limits,
112                seccomp_profile,
113                exclude_tmpdir_env_var,
114                exclude_slash_tmp,
115            } => {
116                if overrides.enforce_conservative_limits {
117                    resource_limits = conservative_resource_limits(overrides);
118                }
119                SandboxPolicy::WorkspaceWrite {
120                    writable_roots: vec![WritableRoot::new(root.clone())],
121                    network_access,
122                    network_allowlist: if overrides.allowed_endpoints.is_empty() {
123                        network_allowlist
124                    } else {
125                        overrides.allowed_endpoints.clone()
126                    },
127                    sensitive_paths,
128                    resource_limits,
129                    seccomp_profile,
130                    exclude_tmpdir_env_var,
131                    exclude_slash_tmp,
132                }
133            }
134            SandboxPolicy::ReadOnly { mut network_allowlist, .. } => {
135                if !overrides.allowed_endpoints.is_empty() {
136                    network_allowlist = overrides.allowed_endpoints.clone();
137                }
138                SandboxPolicy::ReadOnly {
139                    network_access: !network_allowlist.is_empty(),
140                    network_allowlist,
141                }
142            }
143            SandboxPolicy::DangerFullAccess => {
144                let mut resource_limits = ResourceLimits::default();
145                if overrides.enforce_conservative_limits {
146                    resource_limits = conservative_resource_limits(overrides);
147                }
148                SandboxPolicy::WorkspaceWrite {
149                    writable_roots: vec![WritableRoot::new(root.clone())],
150                    network_access: false,
151                    network_allowlist: overrides.allowed_endpoints.clone(),
152                    sensitive_paths: None,
153                    resource_limits,
154                    seccomp_profile: Default::default(),
155                    exclude_tmpdir_env_var: false,
156                    exclude_slash_tmp: false,
157                }
158            }
159            SandboxPolicy::ExternalSandbox { description } => SandboxPolicy::ExternalSandbox { description },
160        };
161    } else if !overrides.allowed_endpoints.is_empty() {
162        // No writable_root override but the network allow-list should still
163        // be applied.
164        derived = match derived {
165            SandboxPolicy::WorkspaceWrite {
166                writable_roots,
167                network_access,
168                sensitive_paths,
169                mut resource_limits,
170                seccomp_profile,
171                exclude_tmpdir_env_var,
172                exclude_slash_tmp,
173                ..
174            } => {
175                if overrides.enforce_conservative_limits {
176                    resource_limits = conservative_resource_limits(overrides);
177                }
178                SandboxPolicy::WorkspaceWrite {
179                    writable_roots,
180                    network_access,
181                    network_allowlist: overrides.allowed_endpoints.clone(),
182                    sensitive_paths,
183                    resource_limits,
184                    seccomp_profile,
185                    exclude_tmpdir_env_var,
186                    exclude_slash_tmp,
187                }
188            }
189            SandboxPolicy::ReadOnly { .. } => SandboxPolicy::ReadOnly {
190                network_access: true,
191                network_allowlist: overrides.allowed_endpoints.clone(),
192            },
193            other => other,
194        };
195    }
196
197    if overrides.enforce_conservative_limits
198        && let SandboxPolicy::WorkspaceWrite { ref mut resource_limits, .. } = derived
199    {
200        *resource_limits = conservative_resource_limits(overrides);
201    }
202
203    derived
204}
205
206fn conservative_resource_limits(overrides: &McpSandboxOverrides) -> ResourceLimits {
207    ResourceLimits {
208        max_memory_mb: overrides.max_memory_mb,
209        max_pids: u32::try_from(overrides.max_pids).unwrap_or(u32::MAX),
210        max_disk_mb: 1024,
211        cpu_time_secs: overrides.cpu_time_secs,
212        timeout_secs: overrides.timeout_secs,
213    }
214}
215
216/// Apply the per-server sandbox wrapper to a stdio command.
217///
218/// This delegates to the same [`crate::sandboxing::SandboxManager`] used by
219/// command execution. Unsupported platforms, missing Linux helpers, external
220/// policies, and policies whose network rules cannot be enforced exactly all
221/// return an error; MCP is never silently launched without its requested
222/// boundary.
223///
224/// stdio configuration is inherited by default โ€” callers that need to
225/// redirect stdin/stdout/stderr should configure the returned command after
226/// this call.
227#[expect(
228    unused_results,
229    reason = "MCP command builder methods configure the owned command and return a fluent mutable reference."
230)]
231pub fn wrap_stdio_command(
232    command: std::process::Command,
233    sandbox_policy: &SandboxPolicy,
234) -> Result<std::process::Command> {
235    if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
236        return Ok(command);
237    }
238    if matches!(sandbox_policy, SandboxPolicy::ExternalSandbox { .. }) {
239        return Err(anyhow!("MCP stdio cannot use an external sandbox policy without an external launcher"));
240    }
241
242    let original_program = command.get_program().to_owned();
243    let original_args: Vec<_> = command.get_args().map(|s| s.to_owned()).collect();
244    let current_dir = command
245        .get_current_dir()
246        .map(PathBuf::from)
247        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
248    let envs: HashMap<_, _> = command
249        .get_envs()
250        .filter_map(|(k, v)| v.map(|val| (k.to_owned(), val.to_owned())))
251        .collect();
252
253    let sandbox_executable = std::env::var_os("VTCODE_LINUX_SANDBOX_EXECUTABLE").map(PathBuf::from);
254    let spec = crate::sandboxing::CommandSpec::new(original_program)
255        .with_args(original_args.into_iter().map(|arg| arg.to_string_lossy().into_owned()))
256        .with_cwd(current_dir.clone())
257        .with_env(
258            envs.into_iter()
259                .map(|(key, value)| (key.to_string_lossy().into_owned(), value.to_string_lossy().into_owned()))
260                .collect(),
261        );
262    let exec_env = crate::sandboxing::SandboxManager::new()
263        .transform(spec, sandbox_policy, &current_dir, sandbox_executable.as_deref())
264        .context("transform MCP stdio command with the sandbox policy")?;
265
266    let mut new_command = std::process::Command::new(exec_env.program);
267    new_command.args(exec_env.args).current_dir(exec_env.cwd).env_clear();
268    for (key, value) in exec_env.env {
269        new_command.env(key, value);
270    }
271    Ok(new_command)
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::sandboxing::SeccompProfile;
278
279    fn workspace_parent() -> SandboxPolicy {
280        SandboxPolicy::WorkspaceWrite {
281            writable_roots: vec![WritableRoot::new(PathBuf::from("/workspace"))],
282            network_access: true,
283            network_allowlist: vec![NetworkAllowlistEntry::https("api.example.com")],
284            sensitive_paths: None,
285            resource_limits: ResourceLimits::unlimited(),
286            seccomp_profile: SeccompProfile::permissive(),
287            exclude_tmpdir_env_var: false,
288            exclude_slash_tmp: false,
289        }
290    }
291
292    #[test]
293    fn derive_narrows_writable_root_when_set() {
294        let parent = workspace_parent();
295        let overrides = McpSandboxOverrides {
296            writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
297            ..McpSandboxOverrides::default()
298        };
299        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
300        match derived {
301            SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
302                assert_eq!(writable_roots.len(), 1);
303                assert_eq!(writable_roots[0].root, PathBuf::from("/tmp/mcp-scratch"));
304            }
305            other => panic!("expected WorkspaceWrite, got {other:?}"),
306        }
307    }
308
309    #[test]
310    fn derive_keeps_parent_roots_when_override_missing() {
311        let parent = workspace_parent();
312        let overrides = McpSandboxOverrides::default();
313        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
314        match derived {
315            SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
316                assert_eq!(writable_roots.len(), 1);
317                assert_eq!(writable_roots[0].root, PathBuf::from("/workspace"));
318            }
319            other => panic!("expected WorkspaceWrite, got {other:?}"),
320        }
321    }
322
323    #[test]
324    fn derive_restricts_network_to_allowed_endpoints() {
325        let parent = workspace_parent();
326        let overrides = McpSandboxOverrides {
327            allowed_endpoints: vec![NetworkAllowlistEntry::https("mcp.internal")],
328            ..McpSandboxOverrides::default()
329        };
330        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
331        match derived {
332            SandboxPolicy::WorkspaceWrite { network_allowlist, .. } => {
333                assert_eq!(network_allowlist.len(), 1);
334                assert_eq!(network_allowlist[0].domain, "mcp.internal");
335            }
336            other => panic!("expected WorkspaceWrite, got {other:?}"),
337        }
338    }
339
340    #[test]
341    fn derive_applies_conservative_resource_caps_by_default() {
342        let parent = workspace_parent();
343        let overrides = McpSandboxOverrides::default();
344        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
345        match derived {
346            SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
347                assert_eq!(resource_limits.max_memory_mb, 512);
348                assert_eq!(resource_limits.max_pids, 64);
349                assert_eq!(resource_limits.cpu_time_secs, 60);
350                assert_eq!(resource_limits.timeout_secs, 120);
351            }
352            other => panic!("expected WorkspaceWrite, got {other:?}"),
353        }
354    }
355
356    #[test]
357    fn derive_respects_disabled_conservative_caps() {
358        let parent = workspace_parent();
359        let overrides = McpSandboxOverrides {
360            enforce_conservative_limits: false,
361            ..McpSandboxOverrides::default()
362        };
363        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
364        match derived {
365            SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
366                // Parent had `ResourceLimits::unlimited()` which is all zeros.
367                assert_eq!(resource_limits.max_memory_mb, 0);
368            }
369            other => panic!("expected WorkspaceWrite, got {other:?}"),
370        }
371    }
372
373    #[test]
374    fn derive_downgrades_danger_full_access_with_writable_root() {
375        let overrides = McpSandboxOverrides {
376            writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
377            ..McpSandboxOverrides::default()
378        };
379        let derived = derive_mcp_sandbox_policy(&SandboxPolicy::DangerFullAccess, &overrides);
380        match derived {
381            SandboxPolicy::WorkspaceWrite { writable_roots, network_access, .. } => {
382                assert_eq!(writable_roots.len(), 1);
383                assert!(!network_access);
384            }
385            other => panic!("expected WorkspaceWrite downgrade, got {other:?}"),
386        }
387    }
388
389    #[test]
390    fn overrides_default_is_conservative() {
391        let defaults = McpSandboxOverrides::default();
392        assert!(defaults.enforce_conservative_limits);
393        assert_eq!(defaults.max_memory_mb, 512);
394        assert!(defaults.allowed_endpoints.is_empty());
395        assert!(defaults.writable_root.is_none());
396    }
397
398    #[test]
399    fn wrapper_for_current_platform_handles_unsupported_targets() {
400        let policy = workspace_parent();
401        // A policy with a hostname allowlist must fail closed until the
402        // platform can enforce the destination exactly.
403        let command = std::process::Command::new("mcp-server");
404        let result = wrap_stdio_command(command, &policy);
405        assert!(result.is_err());
406    }
407}