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//! [`McpSandboxWrapper`] type describes the platform-specific command
14//! prepending (Seatbelt on macOS, `sandbox-executable` on Linux) so the runloop
15//! can apply it at spawn time.
16
17use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20
21use crate::sandboxing::{NetworkAllowlistEntry, ResourceLimits, SandboxPolicy, WritableRoot};
22
23/// Per-MCP-server sandbox settings.
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct McpSandboxOverrides {
26    /// When true, [`derive_mcp_sandbox_policy`] applies the conservative
27    /// resource caps below regardless of the parent policy. Default `true`.
28    #[serde(default = "default_true")]
29    enforce_conservative_limits: bool,
30    /// Memory cap (MB) applied to MCP child processes. Default `512`.
31    #[serde(default = "default_memory_mb")]
32    max_memory_mb: u64,
33    /// Maximum number of processes / pids in the sandbox. Default `64`.
34    #[serde(default = "default_max_pids")]
35    max_pids: u64,
36    /// CPU time budget in seconds. Default `60`.
37    #[serde(default = "default_cpu_secs")]
38    cpu_time_secs: u64,
39    /// Wall-clock timeout in seconds. Default `120`.
40    #[serde(default = "default_timeout_secs")]
41    timeout_secs: u64,
42    /// Optional writable root. When set, the sandbox restricts writes to this
43    /// directory (plus the workspace, if the parent policy permits workspace
44    /// writes). When unset, the sandbox inherits the parent's writable set.
45    #[serde(default)]
46    writable_root: Option<PathBuf>,
47    /// Network allow-list override. When set, MCP servers may only talk to
48    /// these hosts — even when the parent policy would otherwise allow egress.
49    #[serde(default)]
50    allowed_endpoints: Vec<NetworkAllowlistEntry>,
51}
52
53impl Default for McpSandboxOverrides {
54    fn default() -> Self {
55        Self {
56            enforce_conservative_limits: true,
57            max_memory_mb: 512,
58            max_pids: 64,
59            cpu_time_secs: 60,
60            timeout_secs: 120,
61            writable_root: None,
62            allowed_endpoints: Vec::new(),
63        }
64    }
65}
66
67fn default_true() -> bool {
68    true
69}
70fn default_memory_mb() -> u64 {
71    512
72}
73fn default_max_pids() -> u64 {
74    64
75}
76fn default_cpu_secs() -> u64 {
77    60
78}
79fn default_timeout_secs() -> u64 {
80    120
81}
82
83/// Derive a [`SandboxPolicy`] for a single MCP server from the parent policy
84/// and per-server overrides.
85///
86/// The derived policy:
87///
88/// - inherits the parent's sensitive-path denials and write roots,
89/// - narrows the writable set to `writable_root` when set,
90/// - restricts outbound network to `allowed_endpoints` when non-empty,
91/// - applies conservative resource caps when `enforce_conservative_limits` is
92///   true.
93///
94/// `DangerFullAccess` parents are downgraded to `WorkspaceWrite` around the
95/// server's writable root; `ReadOnly` parents are left unchanged.
96#[must_use]
97fn derive_mcp_sandbox_policy(parent: &SandboxPolicy, overrides: &McpSandboxOverrides) -> SandboxPolicy {
98    let mut derived = parent.clone();
99
100    if let Some(root) = &overrides.writable_root {
101        // Replace the writable set with the override root. We coerce into a
102        // `WorkspaceWrite` if the parent was `DangerFullAccess` so we still
103        // have a place to hang the restrictions.
104        derived = match derived {
105            SandboxPolicy::WorkspaceWrite {
106                writable_roots: _,
107                network_access,
108                network_allowlist,
109                sensitive_paths,
110                mut resource_limits,
111                seccomp_profile,
112                exclude_tmpdir_env_var,
113                exclude_slash_tmp,
114            } => {
115                if overrides.enforce_conservative_limits {
116                    resource_limits = conservative_resource_limits(overrides);
117                }
118                SandboxPolicy::WorkspaceWrite {
119                    writable_roots: vec![WritableRoot::new(root.clone())],
120                    network_access,
121                    network_allowlist: if overrides.allowed_endpoints.is_empty() {
122                        network_allowlist
123                    } else {
124                        overrides.allowed_endpoints.clone()
125                    },
126                    sensitive_paths,
127                    resource_limits,
128                    seccomp_profile,
129                    exclude_tmpdir_env_var,
130                    exclude_slash_tmp,
131                }
132            }
133            SandboxPolicy::ReadOnly { mut network_allowlist, .. } => {
134                if !overrides.allowed_endpoints.is_empty() {
135                    network_allowlist = overrides.allowed_endpoints.clone();
136                }
137                SandboxPolicy::ReadOnly {
138                    network_access: !network_allowlist.is_empty(),
139                    network_allowlist,
140                }
141            }
142            SandboxPolicy::DangerFullAccess => {
143                let mut resource_limits = ResourceLimits::default();
144                if overrides.enforce_conservative_limits {
145                    resource_limits = conservative_resource_limits(overrides);
146                }
147                SandboxPolicy::WorkspaceWrite {
148                    writable_roots: vec![WritableRoot::new(root.clone())],
149                    network_access: false,
150                    network_allowlist: overrides.allowed_endpoints.clone(),
151                    sensitive_paths: None,
152                    resource_limits,
153                    seccomp_profile: Default::default(),
154                    exclude_tmpdir_env_var: false,
155                    exclude_slash_tmp: false,
156                }
157            }
158            SandboxPolicy::ExternalSandbox { description } => SandboxPolicy::ExternalSandbox { description },
159        };
160    } else if !overrides.allowed_endpoints.is_empty() {
161        // No writable_root override but the network allow-list should still
162        // be applied.
163        derived = match derived {
164            SandboxPolicy::WorkspaceWrite {
165                writable_roots,
166                network_access,
167                sensitive_paths,
168                mut resource_limits,
169                seccomp_profile,
170                exclude_tmpdir_env_var,
171                exclude_slash_tmp,
172                ..
173            } => {
174                if overrides.enforce_conservative_limits {
175                    resource_limits = conservative_resource_limits(overrides);
176                }
177                SandboxPolicy::WorkspaceWrite {
178                    writable_roots,
179                    network_access,
180                    network_allowlist: overrides.allowed_endpoints.clone(),
181                    sensitive_paths,
182                    resource_limits,
183                    seccomp_profile,
184                    exclude_tmpdir_env_var,
185                    exclude_slash_tmp,
186                }
187            }
188            SandboxPolicy::ReadOnly { .. } => SandboxPolicy::ReadOnly {
189                network_access: true,
190                network_allowlist: overrides.allowed_endpoints.clone(),
191            },
192            other => other,
193        };
194    }
195
196    if overrides.enforce_conservative_limits
197        && let SandboxPolicy::WorkspaceWrite { ref mut resource_limits, .. } = derived
198    {
199        *resource_limits = conservative_resource_limits(overrides);
200    }
201
202    derived
203}
204
205fn conservative_resource_limits(overrides: &McpSandboxOverrides) -> ResourceLimits {
206    ResourceLimits {
207        max_memory_mb: overrides.max_memory_mb,
208        max_pids: u32::try_from(overrides.max_pids).unwrap_or(u32::MAX),
209        max_disk_mb: 1024,
210        cpu_time_secs: overrides.cpu_time_secs,
211        timeout_secs: overrides.timeout_secs,
212    }
213}
214
215/// Description of the per-MCP-server sandbox profile that will be applied at
216/// launch time.
217#[derive(Debug, Clone)]
218pub struct McpSandboxWrapper {
219    /// Platform-specific wrapper binary (e.g. `/usr/bin/sandbox-exec` on macOS).
220    wrapper_binary: PathBuf,
221    /// Argument(s) needed to point the wrapper at the policy (e.g. `-p
222    /// <profile>` on macOS or `--sandbox-policy <json>` on Linux).
223    policy_argv: Vec<String>,
224}
225
226impl McpSandboxWrapper {
227    /// Build the wrapper for the current platform.
228    ///
229    /// - **macOS**: uses `/usr/bin/sandbox-exec -p <profile>` with a serialized
230    ///   Seatbelt policy summary.
231    /// - **Linux**: emits `--sandbox-policy <json>` so the `sandbox-executable`
232    ///   helper can apply Landlock + seccomp from the same JSON shape the rest
233    ///   of VTCode uses (`ResourceLimits::to_json`).
234    /// - **Windows**: returns `None` (sandboxing is stubbed; see
235    ///   `crates/codegen/vtcode-safety/src/sandboxing/manager.rs`).
236    #[must_use]
237    fn for_current_platform(policy: &SandboxPolicy) -> Option<Self> {
238        #[cfg(target_os = "macos")]
239        {
240            // macOS Seatbelt profiles are out of scope for this helper — the
241            // caller is expected to call `crate::sandboxing::manager::SandboxManager`
242            // to render the full profile. We emit a summary token here so the
243            // wrapper still functions when the runloop hasn't built the full
244            // Seatbelt profile (tests, MCP dry-run mode).
245            let summary = match policy {
246                SandboxPolicy::ReadOnly { network_allowlist, .. } => format!(
247                    "(version 1) (allow default) (deny file-write*) (allow network* (remote ip {network_allowlist:?}))",
248                ),
249                SandboxPolicy::WorkspaceWrite { writable_roots, network_allowlist, .. } => format!(
250                    "(version 1) (allow default) (allow file-write* (subpath {writable_roots:?})) (allow network* (remote ip {network_allowlist:?}))",
251                ),
252                SandboxPolicy::DangerFullAccess => "(version 1) (allow default)".to_owned(),
253                SandboxPolicy::ExternalSandbox { description } => description.clone(),
254            };
255            Some(Self {
256                wrapper_binary: PathBuf::from("/usr/bin/sandbox-exec"),
257                policy_argv: vec!["-p".to_owned(), summary],
258            })
259        }
260        #[cfg(target_os = "linux")]
261        {
262            let payload = policy_to_json(policy);
263            Some(Self {
264                wrapper_binary: PathBuf::from("sandbox-executable"),
265                policy_argv: vec!["--sandbox-policy".to_owned(), payload],
266            })
267        }
268        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
269        {
270            let _ = policy;
271            tracing::warn!("MCP server sandboxing is not supported on this platform");
272            None
273        }
274    }
275}
276
277#[cfg(target_os = "linux")]
278fn policy_to_json(policy: &SandboxPolicy) -> String {
279    // The Linux helper consumes the same JSON shape the harness uses for
280    // SeccompProfile + ResourceLimits. Falling back to the Debug representation
281    // when serialization isn't available keeps the wrapper informative even if
282    // the helper can't fully parse it.
283    serde_json::to_string(policy).unwrap_or_else(|_| format!("{policy:?}"))
284}
285
286/// Apply the per-server sandbox wrapper to a stdio command.
287///
288/// On macOS the wrapper invokes `/usr/bin/sandbox-exec -p <profile>`; on
289/// Linux it prepends `sandbox-executable --sandbox-policy <json>`; on Windows
290/// the wrapper is a no-op (with a `tracing::warn!` from
291/// [`McpSandboxWrapper::for_current_platform`]).
292///
293/// stdio configuration is inherited by default — callers that need to
294/// redirect stdin/stdout/stderr should configure the returned command after
295/// this call.
296#[must_use]
297pub fn wrap_stdio_command(command: std::process::Command, sandbox_policy: &SandboxPolicy) -> std::process::Command {
298    let Some(wrapper) = McpSandboxWrapper::for_current_platform(sandbox_policy) else {
299        return command;
300    };
301
302    let original_program = command.get_program().to_owned();
303    let original_args: Vec<_> = command.get_args().map(|s| s.to_owned()).collect();
304    let current_dir = command.get_current_dir().map(|p| p.to_owned());
305    let envs: Vec<_> = command
306        .get_envs()
307        .filter_map(|(k, v)| v.map(|val| (k.to_owned(), val.to_owned())))
308        .collect();
309
310    let mut new_command = std::process::Command::new(&wrapper.wrapper_binary);
311    for arg in &wrapper.policy_argv {
312        new_command.arg(arg);
313    }
314    new_command.arg(&original_program);
315    for arg in &original_args {
316        new_command.arg(arg);
317    }
318    if let Some(dir) = current_dir {
319        new_command.current_dir(dir);
320    }
321    for (key, val) in envs {
322        new_command.env(key, val);
323    }
324    new_command
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use crate::sandboxing::SeccompProfile;
331
332    fn workspace_parent() -> SandboxPolicy {
333        SandboxPolicy::WorkspaceWrite {
334            writable_roots: vec![WritableRoot::new(PathBuf::from("/workspace"))],
335            network_access: true,
336            network_allowlist: vec![NetworkAllowlistEntry::https("api.example.com")],
337            sensitive_paths: None,
338            resource_limits: ResourceLimits::unlimited(),
339            seccomp_profile: SeccompProfile::permissive(),
340            exclude_tmpdir_env_var: false,
341            exclude_slash_tmp: false,
342        }
343    }
344
345    #[test]
346    fn derive_narrows_writable_root_when_set() {
347        let parent = workspace_parent();
348        let overrides = McpSandboxOverrides {
349            writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
350            ..McpSandboxOverrides::default()
351        };
352        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
353        match derived {
354            SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
355                assert_eq!(writable_roots.len(), 1);
356                assert_eq!(writable_roots[0].root, PathBuf::from("/tmp/mcp-scratch"));
357            }
358            other => panic!("expected WorkspaceWrite, got {other:?}"),
359        }
360    }
361
362    #[test]
363    fn derive_keeps_parent_roots_when_override_missing() {
364        let parent = workspace_parent();
365        let overrides = McpSandboxOverrides::default();
366        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
367        match derived {
368            SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
369                assert_eq!(writable_roots.len(), 1);
370                assert_eq!(writable_roots[0].root, PathBuf::from("/workspace"));
371            }
372            other => panic!("expected WorkspaceWrite, got {other:?}"),
373        }
374    }
375
376    #[test]
377    fn derive_restricts_network_to_allowed_endpoints() {
378        let parent = workspace_parent();
379        let overrides = McpSandboxOverrides {
380            allowed_endpoints: vec![NetworkAllowlistEntry::https("mcp.internal")],
381            ..McpSandboxOverrides::default()
382        };
383        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
384        match derived {
385            SandboxPolicy::WorkspaceWrite { network_allowlist, .. } => {
386                assert_eq!(network_allowlist.len(), 1);
387                assert_eq!(network_allowlist[0].domain, "mcp.internal");
388            }
389            other => panic!("expected WorkspaceWrite, got {other:?}"),
390        }
391    }
392
393    #[test]
394    fn derive_applies_conservative_resource_caps_by_default() {
395        let parent = workspace_parent();
396        let overrides = McpSandboxOverrides::default();
397        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
398        match derived {
399            SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
400                assert_eq!(resource_limits.max_memory_mb, 512);
401                assert_eq!(resource_limits.max_pids, 64);
402                assert_eq!(resource_limits.cpu_time_secs, 60);
403                assert_eq!(resource_limits.timeout_secs, 120);
404            }
405            other => panic!("expected WorkspaceWrite, got {other:?}"),
406        }
407    }
408
409    #[test]
410    fn derive_respects_disabled_conservative_caps() {
411        let parent = workspace_parent();
412        let overrides = McpSandboxOverrides {
413            enforce_conservative_limits: false,
414            ..McpSandboxOverrides::default()
415        };
416        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
417        match derived {
418            SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
419                // Parent had `ResourceLimits::unlimited()` which is all zeros.
420                assert_eq!(resource_limits.max_memory_mb, 0);
421            }
422            other => panic!("expected WorkspaceWrite, got {other:?}"),
423        }
424    }
425
426    #[test]
427    fn derive_downgrades_danger_full_access_with_writable_root() {
428        let overrides = McpSandboxOverrides {
429            writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
430            ..McpSandboxOverrides::default()
431        };
432        let derived = derive_mcp_sandbox_policy(&SandboxPolicy::DangerFullAccess, &overrides);
433        match derived {
434            SandboxPolicy::WorkspaceWrite { writable_roots, network_access, .. } => {
435                assert_eq!(writable_roots.len(), 1);
436                assert!(!network_access);
437            }
438            other => panic!("expected WorkspaceWrite downgrade, got {other:?}"),
439        }
440    }
441
442    #[test]
443    fn overrides_default_is_conservative() {
444        let defaults = McpSandboxOverrides::default();
445        assert!(defaults.enforce_conservative_limits);
446        assert_eq!(defaults.max_memory_mb, 512);
447        assert!(defaults.allowed_endpoints.is_empty());
448        assert!(defaults.writable_root.is_none());
449    }
450
451    #[test]
452    fn wrapper_for_current_platform_handles_unsupported_targets() {
453        let policy = workspace_parent();
454        // We can't predict the platform from here, but the function must
455        // either return Some wrapper (macOS / Linux) or None (Windows /
456        // others) without panicking.
457        let _ = McpSandboxWrapper::for_current_platform(&policy);
458    }
459}