1use 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#[derive(Debug, Clone)]
29pub struct SandboxProfile {
30 pub workspace_root: PathBuf,
32 pub plugin_dir: PathBuf,
34 pub extra_read_paths: Vec<PathBuf>,
36 pub allowed_network: Vec<String>,
39}
40
41impl SandboxProfile {
42 #[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 #[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 #[must_use]
62 pub fn with_allowed_network(mut self, hosts: Vec<String>) -> Self {
63 self.allowed_network = hosts;
64 self
65 }
66
67 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 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 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 #[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 let _ = writeln!(
157 profile,
158 "(allow file-read* (subpath \"{}\"))",
159 self.plugin_dir.display()
160 );
161
162 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 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", ] {
183 let _ = writeln!(profile, "(allow file-read* (subpath \"{sys_path}\"))");
184 }
185
186 for path in &self.extra_read_paths {
188 let _ = writeln!(
189 profile,
190 "(allow file-read* (subpath \"{}\"))",
191 path.display()
192 );
193 }
194
195 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 profile.push_str("(allow sysctl-read)\n");
208 profile.push_str("(allow mach-lookup)\n");
209
210 if self.allowed_network.is_empty() {
212 profile.push_str("(allow network-outbound)\n");
214 profile.push_str("(allow network-inbound)\n");
215 } else {
216 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 #[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 rules.push(LandlockPathRule {
239 path: self.workspace_root.clone(),
240 read: true,
241 write: true,
242 });
243
244 rules.push(LandlockPathRule {
246 path: self.plugin_dir.clone(),
247 read: true,
248 write: false,
249 });
250
251 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 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#[cfg(target_os = "linux")]
282#[derive(Debug, Clone)]
283pub struct LandlockPathRule {
284 pub path: PathBuf,
286 pub read: bool,
288 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 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 assert!(rules.len() >= 3);
365
366 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 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}