Skip to main content

astrid_plugins/
sandbox.rs

1//! OS-level sandbox profiles for plugin MCP server processes.
2//!
3//! Generates platform-specific sandbox configurations that constrain
4//! what a plugin's child process can access:
5//!
6//! - **Linux**: Landlock (kernel 5.13+, network restrictions require ABI v5 / kernel 6.7+)
7//! - **macOS**: `sandbox-exec` with Scheme DSL profiles (deprecated but functional)
8//! - **Other**: No-op with a warning
9//!
10//! These profiles are applied to the `tokio::process::Command` before
11//! spawning the MCP server process.
12
13use std::path::PathBuf;
14
15#[cfg(not(target_os = "macos"))]
16use tracing::warn;
17
18#[cfg(target_os = "macos")]
19use crate::error::PluginError;
20use crate::error::PluginResult;
21
22/// Sandbox profile for constraining a plugin MCP server process.
23///
24/// The profile grants:
25/// - Read+write access to `workspace_root`
26/// - Read-only access to `plugin_dir` and system library paths
27/// - Network access restricted to `allowed_network` hosts (best-effort)
28#[derive(Debug, Clone)]
29pub struct SandboxProfile {
30    /// Workspace root — plugin gets read+write access.
31    pub workspace_root: PathBuf,
32    /// Plugin directory — read-only access for loading plugin files.
33    pub plugin_dir: PathBuf,
34    /// Additional paths the plugin may read (e.g. config dirs).
35    pub extra_read_paths: Vec<PathBuf>,
36    /// Allowed network destinations (host or host:port patterns).
37    /// Empty means no network restrictions are applied.
38    pub allowed_network: Vec<String>,
39}
40
41impl SandboxProfile {
42    /// Create a new sandbox profile.
43    #[must_use]
44    pub fn new(workspace_root: PathBuf, plugin_dir: PathBuf) -> Self {
45        Self {
46            workspace_root,
47            plugin_dir,
48            extra_read_paths: Vec::new(),
49            allowed_network: Vec::new(),
50        }
51    }
52
53    /// Add extra read-only paths.
54    #[must_use]
55    pub fn with_extra_read_paths(mut self, paths: Vec<PathBuf>) -> Self {
56        self.extra_read_paths = paths;
57        self
58    }
59
60    /// Set allowed network destinations.
61    #[must_use]
62    pub fn with_allowed_network(mut self, hosts: Vec<String>) -> Self {
63        self.allowed_network = hosts;
64        self
65    }
66
67    /// Wrap a command with platform-specific sandbox enforcement.
68    ///
69    /// On macOS, this prepends `sandbox-exec -f <profile>` to the command.
70    /// On Linux, Landlock rules are applied via environment-based setup.
71    /// On unsupported platforms, returns the command unchanged with a warning.
72    ///
73    /// # Errors
74    ///
75    /// Returns an error if the sandbox profile cannot be generated.
76    pub fn wrap_command(
77        &self,
78        command: &str,
79        args: &[String],
80    ) -> PluginResult<(String, Vec<String>)> {
81        self.platform_wrap_command(command, args)
82    }
83
84    #[cfg(target_os = "macos")]
85    fn platform_wrap_command(
86        &self,
87        command: &str,
88        args: &[String],
89    ) -> PluginResult<(String, Vec<String>)> {
90        let profile_content = self.generate_macos_profile(command);
91
92        // Write the profile to a temp file
93        let profile_path =
94            std::env::temp_dir().join(format!("astrid-sandbox-{}.sb", std::process::id()));
95        std::fs::write(&profile_path, &profile_content).map_err(|e| {
96            PluginError::SandboxError(format!("Failed to write sandbox profile: {e}"))
97        })?;
98
99        let mut sandbox_args = vec![
100            "-f".to_string(),
101            profile_path.to_string_lossy().to_string(),
102            command.to_string(),
103        ];
104        sandbox_args.extend(args.iter().cloned());
105
106        Ok(("sandbox-exec".to_string(), sandbox_args))
107    }
108
109    #[cfg(target_os = "linux")]
110    #[allow(clippy::unused_self, clippy::unnecessary_wraps)]
111    fn platform_wrap_command(
112        &self,
113        command: &str,
114        args: &[String],
115    ) -> PluginResult<(String, Vec<String>)> {
116        // On Linux, Landlock is applied programmatically before exec.
117        // For child processes, we set environment variables that a helper
118        // wrapper can use to apply Landlock rules. In practice, the runtime
119        // applies Landlock to the child process via pre_exec hooks.
120        //
121        // For now, return the command unchanged — Landlock integration
122        // requires pre_exec hooks which are set up in McpPlugin::load().
123        warn!(
124            "Linux Landlock sandbox profiles are applied via pre_exec hooks, \
125             not command wrapping. Command returned unchanged."
126        );
127        Ok((command.to_string(), args.to_vec()))
128    }
129
130    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
131    fn platform_wrap_command(
132        &self,
133        command: &str,
134        args: &[String],
135    ) -> PluginResult<(String, Vec<String>)> {
136        warn!(
137            "OS-level sandboxing is not available on this platform. \
138             Plugin process will run without sandbox restrictions."
139        );
140        Ok((command.to_string(), args.to_vec()))
141    }
142
143    /// Generate a macOS sandbox-exec Scheme DSL profile.
144    ///
145    /// `sandbox-exec` is deprecated since macOS 10.x but still functional
146    /// and used by Chromium, VS Code, and other major projects.
147    #[cfg(target_os = "macos")]
148    fn generate_macos_profile(&self, command: &str) -> String {
149        use std::fmt::Write;
150
151        let mut profile = String::new();
152        profile.push_str("(version 1)\n");
153        profile.push_str("(deny default)\n\n");
154
155        // Allow reading plugin dir (plugin code + dependencies)
156        let _ = writeln!(
157            profile,
158            "(allow file-read* (subpath \"{}\"))",
159            self.plugin_dir.display()
160        );
161
162        // Allow read+write to workspace root
163        let _ = writeln!(
164            profile,
165            "(allow file-read* (subpath \"{}\"))",
166            self.workspace_root.display()
167        );
168        let _ = writeln!(
169            profile,
170            "(allow file-write* (subpath \"{}\"))",
171            self.workspace_root.display()
172        );
173
174        // System library paths (node, shared libs)
175        for sys_path in &[
176            "/usr/lib",
177            "/usr/local/lib",
178            "/usr/local/bin",
179            "/usr/bin",
180            "/opt/homebrew",
181            "/private/var/folders", // temp dirs
182        ] {
183            let _ = writeln!(profile, "(allow file-read* (subpath \"{sys_path}\"))");
184        }
185
186        // Extra read paths
187        for path in &self.extra_read_paths {
188            let _ = writeln!(
189                profile,
190                "(allow file-read* (subpath \"{}\"))",
191                path.display()
192            );
193        }
194
195        // Allow process execution for the command and node runtime
196        let _ = writeln!(profile, "(allow process-exec (literal \"{command}\"))");
197        if let Ok(node_path) = which::which("node") {
198            let _ = writeln!(
199                profile,
200                "(allow process-exec (literal \"{}\"))",
201                node_path.display()
202            );
203        }
204        profile.push_str("(allow process-fork)\n");
205
206        // Allow sysctl and mach lookups (required for process execution)
207        profile.push_str("(allow sysctl-read)\n");
208        profile.push_str("(allow mach-lookup)\n");
209
210        // Network access
211        if self.allowed_network.is_empty() {
212            // No restrictions specified — allow all outbound
213            profile.push_str("(allow network-outbound)\n");
214            profile.push_str("(allow network-inbound)\n");
215        } else {
216            // Allow loopback (required for stdio transport)
217            profile.push_str("(allow network-outbound (local ip \"localhost:*\"))\n");
218            for host in &self.allowed_network {
219                let _ = writeln!(profile, "(allow network-outbound (remote ip \"{host}:*\"))");
220            }
221        }
222
223        profile
224    }
225
226    /// Get the Landlock rule specifications for Linux.
227    ///
228    /// Returns a list of `(path, access_flags)` tuples suitable for
229    /// Landlock `PathBeneath` rules. Caller is responsible for applying
230    /// these via Landlock ABI.
231    #[cfg(target_os = "linux")]
232    #[must_use]
233    pub fn landlock_rules(&self) -> Vec<LandlockPathRule> {
234        use std::path::Path;
235        let mut rules = Vec::new();
236
237        // Workspace: read + write
238        rules.push(LandlockPathRule {
239            path: self.workspace_root.clone(),
240            read: true,
241            write: true,
242        });
243
244        // Plugin dir: read only
245        rules.push(LandlockPathRule {
246            path: self.plugin_dir.clone(),
247            read: true,
248            write: false,
249        });
250
251        // System paths: read only
252        for sys_path in &[
253            Path::new("/usr/lib"),
254            Path::new("/usr/local/lib"),
255            Path::new("/usr/bin"),
256            Path::new("/usr/local/bin"),
257        ] {
258            if sys_path.exists() {
259                rules.push(LandlockPathRule {
260                    path: sys_path.to_path_buf(),
261                    read: true,
262                    write: false,
263                });
264            }
265        }
266
267        // Extra read paths
268        for path in &self.extra_read_paths {
269            rules.push(LandlockPathRule {
270                path: path.clone(),
271                read: true,
272                write: false,
273            });
274        }
275
276        rules
277    }
278}
279
280/// A Landlock path rule specification.
281#[cfg(target_os = "linux")]
282#[derive(Debug, Clone)]
283pub struct LandlockPathRule {
284    /// Filesystem path.
285    pub path: PathBuf,
286    /// Allow read access.
287    pub read: bool,
288    /// Allow write access.
289    pub write: bool,
290}
291
292#[cfg(test)]
293mod tests {
294    use super::*;
295
296    #[test]
297    fn test_sandbox_profile_creation() {
298        let profile = SandboxProfile::new(
299            PathBuf::from("/workspace"),
300            PathBuf::from("/plugins/my-plugin"),
301        );
302        assert_eq!(profile.workspace_root, PathBuf::from("/workspace"));
303        assert_eq!(profile.plugin_dir, PathBuf::from("/plugins/my-plugin"));
304        assert!(profile.extra_read_paths.is_empty());
305        assert!(profile.allowed_network.is_empty());
306    }
307
308    #[test]
309    fn test_sandbox_profile_builder() {
310        let profile = SandboxProfile::new(
311            PathBuf::from("/workspace"),
312            PathBuf::from("/plugins/my-plugin"),
313        )
314        .with_extra_read_paths(vec![PathBuf::from("/etc/ssl")])
315        .with_allowed_network(vec!["api.github.com".to_string()]);
316
317        assert_eq!(profile.extra_read_paths.len(), 1);
318        assert_eq!(profile.allowed_network.len(), 1);
319    }
320
321    #[test]
322    fn test_wrap_command_returns_valid_output() {
323        let profile = SandboxProfile::new(
324            PathBuf::from("/workspace"),
325            PathBuf::from("/plugins/my-plugin"),
326        );
327        let (cmd, args) = profile
328            .wrap_command("node", &["dist/index.js".to_string()])
329            .unwrap();
330
331        // On any platform, should return some command + args
332        assert!(!cmd.is_empty());
333        assert!(!args.is_empty() || cmd == "node");
334    }
335
336    #[cfg(target_os = "macos")]
337    #[test]
338    fn test_macos_profile_generation() {
339        let profile = SandboxProfile::new(
340            PathBuf::from("/workspace"),
341            PathBuf::from("/plugins/my-plugin"),
342        )
343        .with_allowed_network(vec!["api.github.com".to_string()]);
344
345        let content = profile.generate_macos_profile("node");
346        assert!(content.contains("(version 1)"));
347        assert!(content.contains("(deny default)"));
348        assert!(content.contains("/workspace"));
349        assert!(content.contains("/plugins/my-plugin"));
350        assert!(content.contains("api.github.com"));
351    }
352
353    #[cfg(target_os = "linux")]
354    #[test]
355    fn test_landlock_rules() {
356        let profile = SandboxProfile::new(
357            PathBuf::from("/workspace"),
358            PathBuf::from("/plugins/my-plugin"),
359        )
360        .with_extra_read_paths(vec![PathBuf::from("/etc/ssl")]);
361
362        let rules = profile.landlock_rules();
363        // At minimum: workspace (rw) + plugin dir (ro) + extra (ro)
364        assert!(rules.len() >= 3);
365
366        // Workspace should be read+write
367        let ws_rule = rules.iter().find(|r| r.path == PathBuf::from("/workspace"));
368        assert!(ws_rule.is_some());
369        assert!(ws_rule.unwrap().read);
370        assert!(ws_rule.unwrap().write);
371
372        // Plugin dir should be read-only
373        let pd_rule = rules
374            .iter()
375            .find(|r| r.path == PathBuf::from("/plugins/my-plugin"));
376        assert!(pd_rule.is_some());
377        assert!(pd_rule.unwrap().read);
378        assert!(!pd_rule.unwrap().write);
379    }
380}