1use std::{
2 collections::HashMap,
3 fmt,
4 path::{Path, PathBuf},
5};
6
7use serde::{Deserialize, Serialize};
8
9use crate::{
10 config::{SandboxPath, expand_path},
11 detect::Ecosystem,
12};
13
14const DEFAULTS_YAML: &str = include_str!("defaults.yaml");
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct DomainPattern(pub String);
24
25impl DomainPattern {
26 pub fn matches(&self, host: &str) -> bool {
28 let pattern = &self.0;
29 if let Some(suffix) = pattern.strip_prefix("*.") {
30 host == suffix || host.ends_with(&format!(".{suffix}"))
31 } else {
32 host == pattern
33 }
34 }
35}
36
37impl fmt::Display for DomainPattern {
38 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39 f.write_str(&self.0)
40 }
41}
42
43impl From<&str> for DomainPattern {
44 fn from(s: &str) -> Self {
45 Self(s.to_owned())
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct SandboxProfile {
53 pub name: String,
55
56 #[serde(default)]
58 pub allow_write: Vec<SandboxPath>,
59
60 #[serde(default)]
62 pub deny_read: Vec<SandboxPath>,
63
64 #[serde(default)]
66 pub allow_domains: Vec<DomainPattern>,
67
68 #[serde(default)]
70 pub deny_exec: Vec<SandboxPath>,
71
72 #[serde(default)]
74 pub allow_exec: Vec<SandboxPath>,
75
76 #[serde(default = "default_true")]
78 pub enable_proxy: bool,
79
80 #[serde(default)]
82 pub allow_all_network: bool,
83
84 #[serde(default)]
89 pub allow_fetch: Vec<DomainPattern>,
90
91 #[serde(default)]
93 pub env: HashMap<String, String>,
94}
95
96fn default_true() -> bool {
97 true
98}
99
100impl SandboxProfile {
101 pub fn for_ecosystem(ecosystem: Ecosystem, home: &Path, pwd: &Path) -> Self {
103 let defaults: DefaultsFile =
104 serde_yaml::from_str(DEFAULTS_YAML).expect("embedded defaults.yaml is invalid");
105
106 let common = &defaults.common;
107 let profile_name = ecosystem.to_string();
108 let eco_cfg = defaults
109 .profiles
110 .get(&profile_name)
111 .unwrap_or_else(|| panic!("missing profile '{profile_name}' in defaults.yaml"));
112
113 let mut allow_exec: Vec<SandboxPath> = common
115 .allow_exec
116 .iter()
117 .chain(eco_cfg.allow_exec.iter())
118 .map(|p| expand_path(p, home, pwd))
119 .collect();
120
121 let mut deny_exec: Vec<SandboxPath> = common
123 .deny_exec
124 .iter()
125 .map(|p| expand_path(p, home, pwd))
126 .collect();
127 resolve_symlinks(&mut deny_exec);
128
129 let deny_read: Vec<SandboxPath> = common
131 .deny_read
132 .iter()
133 .map(|p| expand_path(p, home, pwd))
134 .collect();
135
136 let mut allow_write: Vec<SandboxPath> = eco_cfg
138 .allow_write
139 .iter()
140 .map(|p| expand_path(p, home, pwd))
141 .collect();
142
143 let allow_domains: Vec<DomainPattern> = eco_cfg
145 .allow_domains
146 .iter()
147 .map(|d| DomainPattern(d.clone()))
148 .collect();
149
150 if ecosystem == Ecosystem::Node
155 && let Some(git_root) = find_git_root(pwd)
156 && git_root != pwd
157 {
158 allow_exec.push(SandboxPath::dir(git_root.join("node_modules")));
159 allow_write.push(SandboxPath::dir(git_root.join("node_modules")));
160 allow_write.push(SandboxPath::file(git_root.join("package-lock.json")));
161 allow_write.push(SandboxPath::file(git_root.join("yarn.lock")));
162 allow_write.push(SandboxPath::file(git_root.join("pnpm-lock.yaml")));
163 allow_write.push(SandboxPath::dir(git_root.join(".yarn")));
164 allow_write.push(SandboxPath::file(git_root.join(".pnp.cjs")));
165 allow_write.push(SandboxPath::file(git_root.join(".pnp.loader.mjs")));
166 }
167
168 if ecosystem == Ecosystem::Rust {
170 if let Some(target_dir) = resolve_cargo_target_dir(home, pwd) {
171 allow_write.push(SandboxPath::dir(target_dir.clone()));
172 allow_exec.push(SandboxPath::dir(target_dir));
173 } else {
174 allow_exec.push(SandboxPath::dir(pwd.join("target")));
175 }
176 }
177
178 if ecosystem == Ecosystem::Java
180 && let Ok(java_home) = std::env::var("JAVA_HOME")
181 {
182 allow_exec.push(SandboxPath::dir(PathBuf::from(java_home)));
183 }
184
185 resolve_symlinks(&mut allow_exec);
189
190 SandboxProfile {
191 name: profile_name,
192 allow_write,
193 deny_read,
194 allow_domains,
195 deny_exec,
196 allow_exec,
197 enable_proxy: true,
198 allow_all_network: false,
199 allow_fetch: vec![],
200 env: Default::default(),
201 }
202 }
203
204 pub fn merge_overrides(&mut self, overrides: &ProfileOverrides) {
206 self.allow_write
207 .extend(overrides.allow_write.iter().cloned());
208 self.deny_read.extend(overrides.deny_read.iter().cloned());
209 self.allow_domains
210 .extend(overrides.allow_domains.iter().cloned());
211 self.deny_exec.extend(overrides.deny_exec.iter().cloned());
212 self.allow_exec.extend(overrides.allow_exec.iter().cloned());
213
214 if !overrides.deny_domains.is_empty() {
215 self.allow_domains
216 .retain(|d| !overrides.deny_domains.iter().any(|denied| denied.0 == d.0));
217 }
218
219 self.allow_fetch
220 .extend(overrides.allow_fetch.iter().cloned());
221
222 if overrides.allow_all_network {
223 self.allow_all_network = true;
224 self.enable_proxy = false;
225 }
226 if overrides.no_proxy {
227 self.enable_proxy = false;
228 }
229
230 for (k, v) in &overrides.env {
231 self.env.insert(k.clone(), v.clone());
232 }
233 }
234
235 pub fn finalize(&mut self) {
239 if !self.allow_fetch.is_empty() {
240 let curl = SandboxPath::file(PathBuf::from("/usr/bin/curl"));
241 let wget = SandboxPath::file(PathBuf::from("/usr/bin/wget"));
242 if !self.allow_exec.iter().any(|p| p.path == curl.path) {
243 self.allow_exec.push(curl);
244 }
245 if !self.allow_exec.iter().any(|p| p.path == wget.path) {
246 self.allow_exec.push(wget);
247 }
248
249 for domain in &self.allow_fetch {
250 if !self.allow_domains.iter().any(|d| d.0 == domain.0) {
251 self.allow_domains.push(domain.clone());
252 }
253 }
254 }
255 }
256}
257
258#[allow(clippy::disallowed_methods)]
268fn resolve_symlinks(paths: &mut Vec<SandboxPath>) {
269 let additional: Vec<SandboxPath> = paths
270 .iter()
271 .filter_map(|sp| {
272 let resolved = std::fs::canonicalize(&sp.path).ok()?;
273 if resolved == sp.path {
274 return None;
275 }
276 let resolved_str = resolved.to_string_lossy();
280 if let Some(cellar_idx) = resolved_str.find("/Cellar/") {
281 let after_cellar = &resolved_str[cellar_idx + 8..];
282 let parts: Vec<&str> = after_cellar.splitn(3, '/').collect();
283 if parts.len() >= 2 {
284 let pkg_root = format!(
285 "{}/Cellar/{}/{}",
286 &resolved_str[..cellar_idx],
287 parts[0],
288 parts[1]
289 );
290 return Some(SandboxPath::dir(PathBuf::from(pkg_root)));
291 }
292 }
293 Some(SandboxPath {
295 path: resolved,
296 is_dir: sp.is_dir,
297 })
298 })
299 .filter(|resolved| !paths.iter().any(|p| p.path == resolved.path))
300 .collect();
301 paths.extend(additional);
302}
303
304fn find_git_root(start: &Path) -> Option<PathBuf> {
306 let mut dir = start;
307 loop {
308 if dir.join(".git").exists() {
309 return Some(dir.to_path_buf());
310 }
311 dir = dir.parent()?;
312 }
313}
314
315#[derive(Debug, Default, Clone)]
317pub struct ProfileOverrides {
318 pub allow_write: Vec<SandboxPath>,
319 pub deny_read: Vec<SandboxPath>,
320 pub allow_domains: Vec<DomainPattern>,
321 pub deny_domains: Vec<DomainPattern>,
322 pub allow_exec: Vec<SandboxPath>,
323 pub deny_exec: Vec<SandboxPath>,
324 pub allow_fetch: Vec<DomainPattern>,
325 pub allow_all_network: bool,
326 pub no_proxy: bool,
327 pub env: HashMap<String, String>,
328}
329
330#[derive(Debug, Deserialize)]
333struct DefaultsFile {
334 common: CommonDefaults,
335 profiles: HashMap<String, EcosystemDefaults>,
336}
337
338#[derive(Debug, Deserialize)]
339#[serde(rename_all = "camelCase")]
340struct CommonDefaults {
341 #[serde(default)]
342 deny_read: Vec<String>,
343 #[serde(default)]
344 deny_exec: Vec<String>,
345 #[serde(default)]
346 allow_exec: Vec<String>,
347}
348
349#[derive(Debug, Deserialize)]
350#[serde(rename_all = "camelCase")]
351struct EcosystemDefaults {
352 #[serde(default)]
353 allow_write: Vec<String>,
354 #[serde(default)]
355 allow_domains: Vec<String>,
356 #[serde(default)]
357 allow_exec: Vec<String>,
358}
359
360fn resolve_cargo_target_dir(home: &Path, pwd: &Path) -> Option<PathBuf> {
364 if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") {
365 return Some(PathBuf::from(dir));
366 }
367 if let Ok(dir) = std::env::var("CARGO_BUILD_TARGET_DIR") {
368 return Some(PathBuf::from(dir));
369 }
370 if let Some(dir) = read_target_dir_from_cargo_config(&pwd.join(".cargo/config.toml")) {
371 return Some(dir);
372 }
373 if let Some(dir) = read_target_dir_from_cargo_config(&home.join(".cargo/config.toml")) {
374 return Some(dir);
375 }
376 None
377}
378
379#[allow(clippy::disallowed_methods)]
380fn read_target_dir_from_cargo_config(path: &Path) -> Option<PathBuf> {
381 let content = std::fs::read_to_string(path).ok()?;
382 let mut in_build_section = false;
383
384 for line in content.lines() {
385 let trimmed = line.trim();
386 if trimmed.starts_with('[') {
387 in_build_section = trimmed == "[build]";
388 continue;
389 }
390 if in_build_section && let Some(value) = trimmed.strip_prefix("target-dir") {
391 let value = value.trim().strip_prefix('=')?.trim();
392 let value = value
393 .strip_prefix('"')
394 .and_then(|v| v.strip_suffix('"'))
395 .unwrap_or(value);
396 return Some(PathBuf::from(value));
397 }
398 }
399 None
400}
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405
406 #[test]
407 fn test_should_match_exact_domain() {
408 let p = DomainPattern::from("registry.npmjs.org");
409 assert!(p.matches("registry.npmjs.org"));
410 assert!(!p.matches("evil.com"));
411 assert!(!p.matches("sub.registry.npmjs.org"));
412 }
413
414 #[test]
415 fn test_should_match_wildcard_domain() {
416 let p = DomainPattern::from("*.npmjs.org");
417 assert!(p.matches("registry.npmjs.org"));
418 assert!(p.matches("npmjs.org"));
419 assert!(p.matches("deep.sub.npmjs.org"));
420 assert!(!p.matches("evil.com"));
421 }
422
423 #[test]
424 fn test_should_load_all_ecosystems_from_yaml() {
425 let home = PathBuf::from("/Users/test");
426 let pwd = PathBuf::from("/Users/test/project");
427
428 for eco in Ecosystem::ALL {
429 let profile = SandboxProfile::for_ecosystem(eco, &home, &pwd);
430 assert_eq!(profile.name, eco.to_string());
431 assert!(!profile.allow_write.is_empty(), "no allow_write for {eco}");
432 assert!(!profile.deny_read.is_empty(), "no deny_read for {eco}");
433 assert!(
434 !profile.allow_domains.is_empty(),
435 "no allow_domains for {eco}"
436 );
437 assert!(!profile.deny_exec.is_empty(), "no deny_exec for {eco}");
438 assert!(!profile.allow_exec.is_empty(), "no allow_exec for {eco}");
439 }
440 }
441
442 fn has(paths: &[SandboxPath], path: &str) -> bool {
444 paths.iter().any(|sp| sp.has_path(Path::new(path)))
445 }
446
447 #[test]
448 fn test_should_expand_paths_in_defaults() {
449 let home = PathBuf::from("/Users/test");
450 let pwd = PathBuf::from("/Users/test/project");
451 let profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
452
453 assert!(has(&profile.deny_read, "/Users/test/.ssh"));
454 assert!(has(&profile.allow_write, "/Users/test/project"));
455 assert!(has(&profile.allow_write, "/Users/test/.npm"));
456 }
457
458 #[test]
459 fn test_should_include_common_exec_in_all_profiles() {
460 let home = PathBuf::from("/Users/test");
461 let pwd = PathBuf::from("/Users/test/project");
462
463 for eco in Ecosystem::ALL {
464 let profile = SandboxProfile::for_ecosystem(eco, &home, &pwd);
465 assert!(
466 has(&profile.allow_exec, "/bin/sh"),
467 "missing /bin/sh for {eco}"
468 );
469 assert!(
470 has(&profile.allow_exec, "/usr/bin/cc"),
471 "missing /usr/bin/cc for {eco}"
472 );
473 assert!(
474 has(&profile.deny_exec, "/usr/bin/osascript"),
475 "missing osascript deny for {eco}"
476 );
477 }
478 }
479
480 #[test]
481 fn test_should_merge_overrides() {
482 let home = PathBuf::from("/Users/test");
483 let pwd = PathBuf::from("/Users/test/project");
484 let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
485 let original_write_count = profile.allow_write.len();
486
487 let overrides = ProfileOverrides {
488 allow_write: vec![SandboxPath::dir(PathBuf::from("/extra/path"))],
489 deny_domains: vec![DomainPattern::from("registry.npmmirror.com")],
490 ..Default::default()
491 };
492 profile.merge_overrides(&overrides);
493
494 assert_eq!(profile.allow_write.len(), original_write_count + 1);
495 assert!(
496 !profile
497 .allow_domains
498 .iter()
499 .any(|d| d.0 == "registry.npmmirror.com")
500 );
501 }
502
503 #[test]
504 fn test_should_finalize_allow_fetch() {
505 let home = PathBuf::from("/Users/test");
506 let pwd = PathBuf::from("/Users/test/project");
507 let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
508
509 assert!(!has(&profile.allow_exec, "/usr/bin/curl"));
510
511 let overrides = ProfileOverrides {
512 allow_fetch: vec![DomainPattern::from("example.com")],
513 ..Default::default()
514 };
515 profile.merge_overrides(&overrides);
516 profile.finalize();
517
518 assert!(has(&profile.allow_exec, "/usr/bin/curl"));
519 assert!(has(&profile.allow_exec, "/usr/bin/wget"));
520 assert!(profile.allow_domains.iter().any(|d| d.0 == "example.com"));
521 }
522
523 #[test]
524 fn test_should_not_add_curl_without_allow_fetch() {
525 let home = PathBuf::from("/Users/test");
526 let pwd = PathBuf::from("/Users/test/project");
527 let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
528 profile.finalize();
529 assert!(!has(&profile.allow_exec, "/usr/bin/curl"));
530 }
531
532 #[test]
533 fn test_should_not_duplicate_domains_on_finalize() {
534 let home = PathBuf::from("/Users/test");
535 let pwd = PathBuf::from("/Users/test/project");
536 let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
537 let original_domain_count = profile.allow_domains.len();
538
539 let overrides = ProfileOverrides {
540 allow_fetch: vec![DomainPattern::from("github.com")],
541 ..Default::default()
542 };
543 profile.merge_overrides(&overrides);
544 profile.finalize();
545
546 assert_eq!(profile.allow_domains.len(), original_domain_count);
547 assert!(has(&profile.allow_exec, "/usr/bin/curl"));
548 }
549}