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]
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 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 let _ = McpSandboxWrapper::for_current_platform(&policy);
458 }
459}