1use std::path::PathBuf;
18
19use serde::{Deserialize, Serialize};
20
21use crate::sandboxing::{NetworkAllowlistEntry, ResourceLimits, SandboxPolicy, WritableRoot};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct McpSandboxOverrides {
26 #[serde(default = "default_true")]
29 enforce_conservative_limits: bool,
30 #[serde(default = "default_memory_mb")]
32 max_memory_mb: u64,
33 #[serde(default = "default_max_pids")]
35 max_pids: u64,
36 #[serde(default = "default_cpu_secs")]
38 cpu_time_secs: u64,
39 #[serde(default = "default_timeout_secs")]
41 timeout_secs: u64,
42 #[serde(default)]
46 writable_root: Option<PathBuf>,
47 #[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#[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 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 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#[derive(Debug, Clone)]
218pub struct McpSandboxWrapper {
219 wrapper_binary: PathBuf,
221 policy_argv: Vec<String>,
224}
225
226impl McpSandboxWrapper {
227 #[must_use]
237 fn for_current_platform(policy: &SandboxPolicy) -> Option<Self> {
238 #[cfg(target_os = "macos")]
239 {
240 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 serde_json::to_string(policy).unwrap_or_else(|_| format!("{policy:?}"))
284}
285
286#[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 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 let _ = McpSandboxWrapper::for_current_platform(&policy);
457 }
458}