1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct McpSandboxOverrides {
27 #[serde(default = "default_true")]
30 enforce_conservative_limits: bool,
31 #[serde(default = "default_memory_mb")]
33 max_memory_mb: u64,
34 #[serde(default = "default_max_pids")]
36 max_pids: u64,
37 #[serde(default = "default_cpu_secs")]
39 cpu_time_secs: u64,
40 #[serde(default = "default_timeout_secs")]
42 timeout_secs: u64,
43 #[serde(default)]
47 writable_root: Option<PathBuf>,
48 #[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#[must_use]
103pub fn derive_mcp_sandbox_policy(parent: &SandboxPolicy, overrides: &McpSandboxOverrides) -> SandboxPolicy {
104 let mut derived = parent.clone();
105
106 if let Some(root) = &overrides.writable_root {
107 derived = match derived {
111 SandboxPolicy::WorkspaceWrite {
112 writable_roots: _,
113 network_access,
114 network_allowlist,
115 sensitive_paths,
116 mut resource_limits,
117 seccomp_profile,
118 exclude_tmpdir_env_var,
119 exclude_slash_tmp,
120 } => {
121 if overrides.enforce_conservative_limits {
122 resource_limits = conservative_resource_limits(overrides);
123 }
124 SandboxPolicy::WorkspaceWrite {
125 writable_roots: vec![WritableRoot::new(root.clone())],
126 network_access,
127 network_allowlist: if overrides.allowed_endpoints.is_empty() {
128 network_allowlist
129 } else {
130 overrides.allowed_endpoints.clone()
131 },
132 sensitive_paths,
133 resource_limits,
134 seccomp_profile,
135 exclude_tmpdir_env_var,
136 exclude_slash_tmp,
137 }
138 }
139 SandboxPolicy::ReadOnly { mut network_allowlist, .. } => {
140 if !overrides.allowed_endpoints.is_empty() {
141 network_allowlist = overrides.allowed_endpoints.clone();
142 }
143 SandboxPolicy::ReadOnly {
144 network_access: !network_allowlist.is_empty(),
145 network_allowlist,
146 }
147 }
148 SandboxPolicy::DangerFullAccess => {
149 let mut resource_limits = ResourceLimits::default();
150 if overrides.enforce_conservative_limits {
151 resource_limits = conservative_resource_limits(overrides);
152 }
153 SandboxPolicy::WorkspaceWrite {
154 writable_roots: vec![WritableRoot::new(root.clone())],
155 network_access: false,
156 network_allowlist: overrides.allowed_endpoints.clone(),
157 sensitive_paths: None,
158 resource_limits,
159 seccomp_profile: Default::default(),
160 exclude_tmpdir_env_var: false,
161 exclude_slash_tmp: false,
162 }
163 }
164 SandboxPolicy::ExternalSandbox { description } => SandboxPolicy::ExternalSandbox { description },
165 };
166 } else if !overrides.allowed_endpoints.is_empty() {
167 derived = match derived {
170 SandboxPolicy::WorkspaceWrite {
171 writable_roots,
172 network_access,
173 sensitive_paths,
174 mut resource_limits,
175 seccomp_profile,
176 exclude_tmpdir_env_var,
177 exclude_slash_tmp,
178 ..
179 } => {
180 if overrides.enforce_conservative_limits {
181 resource_limits = conservative_resource_limits(overrides);
182 }
183 SandboxPolicy::WorkspaceWrite {
184 writable_roots,
185 network_access,
186 network_allowlist: overrides.allowed_endpoints.clone(),
187 sensitive_paths,
188 resource_limits,
189 seccomp_profile,
190 exclude_tmpdir_env_var,
191 exclude_slash_tmp,
192 }
193 }
194 SandboxPolicy::ReadOnly { .. } => SandboxPolicy::ReadOnly {
195 network_access: true,
196 network_allowlist: overrides.allowed_endpoints.clone(),
197 },
198 other => other,
199 };
200 }
201
202 if overrides.enforce_conservative_limits
203 && let SandboxPolicy::WorkspaceWrite { ref mut resource_limits, .. } = derived
204 {
205 *resource_limits = conservative_resource_limits(overrides);
206 }
207
208 derived
209}
210
211fn conservative_resource_limits(overrides: &McpSandboxOverrides) -> ResourceLimits {
212 ResourceLimits {
213 max_memory_mb: overrides.max_memory_mb,
214 max_pids: u32::try_from(overrides.max_pids).unwrap_or(u32::MAX),
215 max_disk_mb: 1024,
216 cpu_time_secs: overrides.cpu_time_secs,
217 timeout_secs: overrides.timeout_secs,
218 }
219}
220
221#[expect(
233 unused_results,
234 reason = "MCP command builder methods configure the owned command and return a fluent mutable reference."
235)]
236pub fn wrap_stdio_command(
237 command: std::process::Command,
238 sandbox_policy: &SandboxPolicy,
239) -> Result<std::process::Command> {
240 if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
241 return Ok(command);
242 }
243 if matches!(sandbox_policy, SandboxPolicy::ExternalSandbox { .. }) {
244 return Err(anyhow!("MCP stdio cannot use an external sandbox policy without an external launcher"));
245 }
246
247 let original_program = command.get_program().to_owned();
248 let original_args: Vec<_> = command.get_args().map(|s| s.to_owned()).collect();
249 let current_dir = command
250 .get_current_dir()
251 .map(PathBuf::from)
252 .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
253 let envs: HashMap<_, _> = command
254 .get_envs()
255 .filter_map(|(k, v)| v.map(|val| (k.to_owned(), val.to_owned())))
256 .collect();
257
258 let linux_launcher = crate::sandboxing::LinuxSandboxLauncher::resolve();
259 let spec = crate::sandboxing::CommandSpec::new(original_program)
260 .with_args(original_args.into_iter().map(|arg| arg.to_string_lossy().into_owned()))
261 .with_cwd(current_dir.clone())
262 .with_env(
263 envs.into_iter()
264 .map(|(key, value)| (key.to_string_lossy().into_owned(), value.to_string_lossy().into_owned()))
265 .collect(),
266 );
267 let exec_env = crate::sandboxing::SandboxManager::new()
268 .transform(spec, sandbox_policy, ¤t_dir, linux_launcher.as_ref())
269 .context("transform MCP stdio command with the sandbox policy")?;
270
271 let mut new_command = std::process::Command::new(exec_env.program);
272 new_command.args(exec_env.args).current_dir(exec_env.cwd).env_clear();
273 for (key, value) in exec_env.env {
274 new_command.env(key, value);
275 }
276 Ok(new_command)
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::sandboxing::SeccompProfile;
283
284 fn workspace_parent() -> SandboxPolicy {
285 SandboxPolicy::WorkspaceWrite {
286 writable_roots: vec![WritableRoot::new(PathBuf::from("/workspace"))],
287 network_access: true,
288 network_allowlist: vec![NetworkAllowlistEntry::https("api.example.com")],
289 sensitive_paths: None,
290 resource_limits: ResourceLimits::unlimited(),
291 seccomp_profile: SeccompProfile::permissive(),
292 exclude_tmpdir_env_var: false,
293 exclude_slash_tmp: false,
294 }
295 }
296
297 #[test]
298 fn derive_narrows_writable_root_when_set() {
299 let parent = workspace_parent();
300 let overrides = McpSandboxOverrides {
301 writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
302 ..McpSandboxOverrides::default()
303 };
304 let derived = derive_mcp_sandbox_policy(&parent, &overrides);
305 match derived {
306 SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
307 assert_eq!(writable_roots.len(), 1);
308 assert_eq!(writable_roots[0].root, PathBuf::from("/tmp/mcp-scratch"));
309 }
310 other => panic!("expected WorkspaceWrite, got {other:?}"),
311 }
312 }
313
314 #[test]
315 fn derive_keeps_parent_roots_when_override_missing() {
316 let parent = workspace_parent();
317 let overrides = McpSandboxOverrides::default();
318 let derived = derive_mcp_sandbox_policy(&parent, &overrides);
319 match derived {
320 SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
321 assert_eq!(writable_roots.len(), 1);
322 assert_eq!(writable_roots[0].root, PathBuf::from("/workspace"));
323 }
324 other => panic!("expected WorkspaceWrite, got {other:?}"),
325 }
326 }
327
328 #[test]
329 fn derive_restricts_network_to_allowed_endpoints() {
330 let parent = workspace_parent();
331 let overrides = McpSandboxOverrides {
332 allowed_endpoints: vec![NetworkAllowlistEntry::https("mcp.internal")],
333 ..McpSandboxOverrides::default()
334 };
335 let derived = derive_mcp_sandbox_policy(&parent, &overrides);
336 match derived {
337 SandboxPolicy::WorkspaceWrite { network_allowlist, .. } => {
338 assert_eq!(network_allowlist.len(), 1);
339 assert_eq!(network_allowlist[0].domain, "mcp.internal");
340 }
341 other => panic!("expected WorkspaceWrite, got {other:?}"),
342 }
343 }
344
345 #[test]
346 fn derive_applies_conservative_resource_caps_by_default() {
347 let parent = workspace_parent();
348 let overrides = McpSandboxOverrides::default();
349 let derived = derive_mcp_sandbox_policy(&parent, &overrides);
350 match derived {
351 SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
352 assert_eq!(resource_limits.max_memory_mb, 512);
353 assert_eq!(resource_limits.max_pids, 64);
354 assert_eq!(resource_limits.cpu_time_secs, 60);
355 assert_eq!(resource_limits.timeout_secs, 120);
356 }
357 other => panic!("expected WorkspaceWrite, got {other:?}"),
358 }
359 }
360
361 #[test]
362 fn derive_respects_disabled_conservative_caps() {
363 let parent = workspace_parent();
364 let overrides = McpSandboxOverrides {
365 enforce_conservative_limits: false,
366 ..McpSandboxOverrides::default()
367 };
368 let derived = derive_mcp_sandbox_policy(&parent, &overrides);
369 match derived {
370 SandboxPolicy::WorkspaceWrite { resource_limits, .. } => {
371 assert_eq!(resource_limits.max_memory_mb, 0);
373 }
374 other => panic!("expected WorkspaceWrite, got {other:?}"),
375 }
376 }
377
378 #[test]
379 fn derive_downgrades_danger_full_access_with_writable_root() {
380 let overrides = McpSandboxOverrides {
381 writable_root: Some(PathBuf::from("/tmp/mcp-scratch")),
382 ..McpSandboxOverrides::default()
383 };
384 let derived = derive_mcp_sandbox_policy(&SandboxPolicy::DangerFullAccess, &overrides);
385 match derived {
386 SandboxPolicy::WorkspaceWrite { writable_roots, network_access, .. } => {
387 assert_eq!(writable_roots.len(), 1);
388 assert!(!network_access);
389 }
390 other => panic!("expected WorkspaceWrite downgrade, got {other:?}"),
391 }
392 }
393
394 #[test]
395 fn overrides_default_is_conservative() {
396 let defaults = McpSandboxOverrides::default();
397 assert!(defaults.enforce_conservative_limits);
398 assert_eq!(defaults.max_memory_mb, 512);
399 assert!(defaults.allowed_endpoints.is_empty());
400 assert!(defaults.writable_root.is_none());
401 }
402
403 #[test]
404 fn wrapper_for_current_platform_handles_unsupported_targets() {
405 let policy = workspace_parent();
406 let command = std::process::Command::new("mcp-server");
409 let result = wrap_stdio_command(command, &policy);
410 assert!(result.is_err());
411 }
412}