Skip to main content

sbe_core/profile/
mod.rs

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
14/// Embedded default profiles YAML, compiled into the binary.
15const DEFAULTS_YAML: &str = include_str!("defaults.yaml");
16
17/// A pattern for matching domain names.
18///
19/// Supports exact match (`"registry.npmjs.org"`) and wildcard prefix
20/// (`"*.npmjs.org"` matches any subdomain).
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(transparent)]
23pub struct DomainPattern(pub String);
24
25impl DomainPattern {
26    /// Check whether a given hostname matches this pattern.
27    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/// The resolved set of sandbox permissions for a single execution.
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct SandboxProfile {
53    /// Human-readable name (e.g., "node", "rust", "custom:my-app").
54    pub name: String,
55
56    /// Paths allowed for writing (expanded, absolute).
57    #[serde(default)]
58    pub allow_write: Vec<SandboxPath>,
59
60    /// Paths denied for reading (expanded, absolute).
61    #[serde(default)]
62    pub deny_read: Vec<SandboxPath>,
63
64    /// Domains allowed for outbound HTTPS.
65    #[serde(default)]
66    pub allow_domains: Vec<DomainPattern>,
67
68    /// Binary paths denied for execution.
69    #[serde(default)]
70    pub deny_exec: Vec<SandboxPath>,
71
72    /// Binary paths explicitly allowed for execution.
73    #[serde(default)]
74    pub allow_exec: Vec<SandboxPath>,
75
76    /// Whether to enable the domain-filtering proxy.
77    #[serde(default = "default_true")]
78    pub enable_proxy: bool,
79
80    /// Whether to allow all network (disables proxy, allows all outbound).
81    #[serde(default)]
82    pub allow_all_network: bool,
83
84    /// Domains that build scripts are allowed to fetch from.
85    ///
86    /// When non-empty, `curl` and `wget` are added to `allow_exec` and these
87    /// domains are merged into the proxy allowlist.
88    #[serde(default)]
89    pub allow_fetch: Vec<DomainPattern>,
90
91    /// Additional environment variables to inject.
92    #[serde(default)]
93    pub env: HashMap<String, String>,
94}
95
96fn default_true() -> bool {
97    true
98}
99
100impl SandboxProfile {
101    /// Build the default profile for an ecosystem from the embedded YAML defaults.
102    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        // Build allow_exec: common + ecosystem-specific
114        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        // Build deny_exec: from common (also resolve symlinks for deny rules)
122        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        // Build deny_read: from common
130        let deny_read: Vec<SandboxPath> = common
131            .deny_read
132            .iter()
133            .map(|p| expand_path(p, home, pwd))
134            .collect();
135
136        // Build allow_write: from ecosystem
137        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        // Build allow_domains: from ecosystem
144        let allow_domains: Vec<DomainPattern> = eco_cfg
145            .allow_domains
146            .iter()
147            .map(|d| DomainPattern(d.clone()))
148            .collect();
149
150        // Node-specific: monorepos hoist node_modules and lock files to the
151        // workspace root. Only allow writes to specific paths npm needs —
152        // NOT the entire git root, which would let a malicious postinstall
153        // script modify source files in sibling packages or CI configs.
154        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        // Rust-specific: resolve cargo target dir for write + exec
169        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        // Java-specific: allow JAVA_HOME
179        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: SBPL checks the real path after kernel symlink
186        // resolution, so /opt/homebrew/bin/zig (symlink) won't match unless
187        // we also allow the resolved /opt/homebrew/Cellar/.../zig path.
188        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    /// Merge CLI overrides into this profile.
205    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    /// Finalize the profile: apply allow_fetch effects to allow_exec and allow_domains.
236    ///
237    /// Must be called after all merging is complete, before SBPL generation.
238    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/// For each path in the list, if it's a symlink, also add the resolved real path.
259///
260/// macOS sandbox-exec resolves symlinks before checking SBPL rules, so
261/// `/opt/homebrew/bin/zig` (a symlink to `/opt/homebrew/Cellar/zig/.../zig`)
262/// requires the Cellar path to be in the allow list too.
263///
264/// For Homebrew Cellar paths, we add the package root directory (e.g.,
265/// `/opt/homebrew/Cellar/zig/0.15.2/`) rather than just the binary, because
266/// tools like zig spawn sub-tools from their lib/ directory.
267#[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            // For Homebrew Cellar paths, allow the entire package directory.
277            // Structure: /opt/homebrew/Cellar/<pkg>/<version>/bin/<binary>
278            // We want:   /opt/homebrew/Cellar/<pkg>/<version>/
279            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            // Preserve the original is_dir flag for non-Cellar symlinks
294            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
304/// Find the git root by walking up from `start`.
305fn 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/// Overrides from CLI flags that get merged into the resolved profile.
316#[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// --- Embedded YAML deserialization types ---
331
332#[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
360// --- Rust-specific cargo target dir resolution ---
361
362/// Resolve the cargo target directory from environment or cargo config.
363fn 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    /// Helper: check if a path list contains a given path (ignoring is_dir).
443    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}