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!`).
291///
292/// stdio configuration is inherited by default — callers that need to
293/// redirect stdin/stdout/stderr should configure the returned command after
294/// this call.
295#[must_use]
296pub fn wrap_stdio_command(command: std::process::Command, sandbox_policy: &SandboxPolicy) -> std::process::Command {
297    let Some(wrapper) = McpSandboxWrapper::for_current_platform(sandbox_policy) else {
298        return command;
299    };
300
301    let original_program = command.get_program().to_owned();
302    let original_args: Vec<_> = command.get_args().map(|s| s.to_owned()).collect();
303    let current_dir = command.get_current_dir().map(|p| p.to_owned());
304    let envs: Vec<_> = command
305        .get_envs()
306        .filter_map(|(k, v)| v.map(|val| (k.to_owned(), val.to_owned())))
307        .collect();
308
309    let mut new_command = std::process::Command::new(&wrapper.wrapper_binary);
310    for arg in &wrapper.policy_argv {
311        new_command.arg(arg);
312    }
313    new_command.arg(&original_program);
314    for arg in &original_args {
315        new_command.arg(arg);
316    }
317    if let Some(dir) = current_dir {
318        new_command.current_dir(dir);
319    }
320    for (key, val) in envs {
321        new_command.env(key, val);
322    }
323    new_command
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use crate::sandboxing::SeccompProfile;
330
331    fn workspace_parent() -> SandboxPolicy {
332        SandboxPolicy::WorkspaceWrite {
333            writable_roots: vec![WritableRoot::new(PathBuf::from("/workspace"))],
334            network_access: true,
335            network_allowlist: vec![NetworkAllowlistEntry::https("api.example.com")],
336            sensitive_paths: None,
337            resource_limits: ResourceLimits::unlimited(),
338            seccomp_profile: SeccompProfile::permissive(),
339            exclude_tmpdir_env_var: false,
340            exclude_slash_tmp: false,
341        }
342    }
343
344    #[test]
345    fn derive_narrows_writable_root_when_set() {
346        let parent = workspace_parent();
347        let overrides = McpSandboxOverrides {
348            writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
349            ..McpSandboxOverrides::default()
350        };
351        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
352        match derived {
353            SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
354                assert_eq!(writable_roots.len(), 1);
355                assert_eq!(writable_roots[0].root, PathBuf::from("/tmp/mcp-scratch"));
356            }
357            other => panic!("expected WorkspaceWrite, got {other:?}"),
358        }
359    }
360
361    #[test]
362    fn derive_keeps_parent_roots_when_override_missing() {
363        let parent = workspace_parent();
364        let overrides = McpSandboxOverrides::default();
365        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
366        match derived {
367            SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
368                assert_eq!(writable_roots.len(), 1);
369                assert_eq!(writable_roots[0].root, PathBuf::from("/workspace"));
370            }
371            other => panic!("expected WorkspaceWrite, got {other:?}"),
372        }
373    }
374
375    #[test]
376    fn derive_restricts_network_to_allowed_endpoints() {
377        let parent = workspace_parent();
378        let overrides = McpSandboxOverrides {
379            allowed_endpoints: vec![NetworkAllowlistEntry::https("mcp.internal")],
380            ..McpSandboxOverrides::default()
381        };
382        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
383        match derived {
384            SandboxPolicy::WorkspaceWrite { network_allowlist, .. } => {
385                assert_eq!(network_allowlist.len(), 1);
386                assert_eq!(network_allowlist[0].domain, "mcp.internal");
387            }
388            other => panic!("expected WorkspaceWrite, got {other:?}"),
389        }
390    }
391
392    #[test]
393    fn derive_applies_conservative_resource_caps_by_default() {
394        let parent = workspace_parent();
395        let overrides = McpSandboxOverrides::default();
396        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
397        match derived {
398            SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
399                assert_eq!(resource_limits.max_memory_mb, 512);
400                assert_eq!(resource_limits.max_pids, 64);
401                assert_eq!(resource_limits.cpu_time_secs, 60);
402                assert_eq!(resource_limits.timeout_secs, 120);
403            }
404            other => panic!("expected WorkspaceWrite, got {other:?}"),
405        }
406    }
407
408    #[test]
409    fn derive_respects_disabled_conservative_caps() {
410        let parent = workspace_parent();
411        let overrides = McpSandboxOverrides {
412            enforce_conservative_limits: false,
413            ..McpSandboxOverrides::default()
414        };
415        let derived = derive_mcp_sandbox_policy(&parent, &overrides);
416        match derived {
417            SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
418                // Parent had `ResourceLimits::unlimited()` which is all zeros.
419                assert_eq!(resource_limits.max_memory_mb, 0);
420            }
421            other => panic!("expected WorkspaceWrite, got {other:?}"),
422        }
423    }
424
425    #[test]
426    fn derive_downgrades_danger_full_access_with_writable_root() {
427        let overrides = McpSandboxOverrides {
428            writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
429            ..McpSandboxOverrides::default()
430        };
431        let derived = derive_mcp_sandbox_policy(&SandboxPolicy::DangerFullAccess, &overrides);
432        match derived {
433            SandboxPolicy::WorkspaceWrite { writable_roots, network_access, .. } => {
434                assert_eq!(writable_roots.len(), 1);
435                assert!(!network_access);
436            }
437            other => panic!("expected WorkspaceWrite downgrade, got {other:?}"),
438        }
439    }
440
441    #[test]
442    fn overrides_default_is_conservative() {
443        let defaults = McpSandboxOverrides::default();
444        assert!(defaults.enforce_conservative_limits);
445        assert_eq!(defaults.max_memory_mb, 512);
446        assert!(defaults.allowed_endpoints.is_empty());
447        assert!(defaults.writable_root.is_none());
448    }
449
450    #[test]
451    fn wrapper_for_current_platform_handles_unsupported_targets() {
452        let policy = workspace_parent();
453        // We can't predict the platform from here, but the function must
454        // either return Some wrapper (macOS / Linux) or None (Windows /
455        // others) without panicking.
456        let _ = McpSandboxWrapper::for_current_platform(&policy);
457    }
458}