Skip to main content

sbe_core/profile/
mod.rs

1use std::{
2    collections::HashMap,
3    fmt,
4    net::IpAddr,
5    path::{Path, PathBuf},
6    str::FromStr,
7};
8
9#[cfg(unix)]
10use std::os::unix::fs::MetadataExt;
11
12use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
13
14use crate::{
15    config::{PathKind, SandboxPath, expand_path},
16    detect::Ecosystem,
17};
18
19#[cfg(unix)]
20const MAX_WRITABLE_ALIAS_SCAN_ENTRIES: usize = 1_000_000;
21
22#[cfg(unix)]
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24struct FileIdentity {
25    device: u64,
26    inode: u64,
27}
28
29#[cfg(unix)]
30struct HardLinkObservation {
31    link_count: u64,
32    writable_paths: std::collections::HashSet<PathBuf>,
33}
34
35/// Embedded default profiles YAML, compiled into the binary.
36///
37/// Selection is `cfg(target_os = ...)` so each binary ships exactly the
38/// defaults that match its sandbox backend. Both files deserialize through
39/// the same [`DefaultsFile`] schema (verified in tests).
40#[cfg(target_os = "macos")]
41const DEFAULTS_YAML: &str = include_str!("defaults-macos.yaml");
42
43#[cfg(target_os = "linux")]
44const DEFAULTS_YAML: &str = include_str!("defaults-linux.yaml");
45
46#[cfg(not(any(target_os = "macos", target_os = "linux")))]
47const DEFAULTS_YAML: &str = include_str!("defaults-macos.yaml");
48
49/// A pattern for matching domain names.
50///
51/// Supports exact match (`"registry.npmjs.org"`) and wildcard prefix
52/// (`"*.npmjs.org"` matches any subdomain).
53#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub struct DomainPattern(pub String);
55
56impl DomainPattern {
57    /// Parse and canonicalize an exact or `*.` wildcard DNS pattern.
58    pub fn new(raw: &str) -> Result<Self, String> {
59        let raw = raw.trim().trim_end_matches('.');
60        let (wildcard, name) = match raw.strip_prefix("*.") {
61            Some(name) => (true, name),
62            None => (false, raw),
63        };
64        if name.is_empty() || name.len() > 253 || name.contains(['/', ':', '\0']) {
65            return Err(format!("invalid domain pattern '{raw}'"));
66        }
67
68        let ascii = idna::domain_to_ascii(name)
69            .map_err(|_| format!("invalid IDNA domain pattern '{raw}'"))?
70            .to_ascii_lowercase();
71        if IpAddr::from_str(&ascii).is_ok() {
72            return Err(format!("IP literals are not domain patterns: '{raw}'"));
73        }
74        for label in ascii.split('.') {
75            if label.is_empty()
76                || label.len() > 63
77                || label.starts_with('-')
78                || label.ends_with('-')
79                || !label
80                    .bytes()
81                    .all(|b| b.is_ascii_alphanumeric() || b == b'-')
82            {
83                return Err(format!("invalid DNS label in domain pattern '{raw}'"));
84            }
85        }
86
87        Ok(Self(if wildcard {
88            format!("*.{ascii}")
89        } else {
90            ascii
91        }))
92    }
93
94    /// Check whether a given hostname matches this pattern.
95    pub fn matches(&self, host: &str) -> bool {
96        let pattern = &self.0;
97        if let Some(suffix) = pattern.strip_prefix("*.") {
98            host == suffix || host.ends_with(&format!(".{suffix}"))
99        } else {
100            host == pattern
101        }
102    }
103
104    /// Return whether two allow/deny patterns authorize at least one common
105    /// hostname. Since the proxy has no separate denylist, an overlapping
106    /// allow pattern must be removed in full for a denial to remain effective.
107    pub fn overlaps(&self, other: &Self) -> bool {
108        let self_root = self.0.strip_prefix("*.").unwrap_or(&self.0);
109        let other_root = other.0.strip_prefix("*.").unwrap_or(&other.0);
110        self.matches(other_root) || other.matches(self_root)
111    }
112}
113
114impl fmt::Display for DomainPattern {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        f.write_str(&self.0)
117    }
118}
119
120impl From<&str> for DomainPattern {
121    fn from(s: &str) -> Self {
122        Self::new(s).expect("invalid built-in domain pattern")
123    }
124}
125
126impl Serialize for DomainPattern {
127    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
128    where
129        S: Serializer,
130    {
131        serializer.serialize_str(&self.0)
132    }
133}
134
135impl<'de> Deserialize<'de> for DomainPattern {
136    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
137    where
138        D: Deserializer<'de>,
139    {
140        let raw = String::deserialize(deserializer)?;
141        Self::new(&raw).map_err(de::Error::custom)
142    }
143}
144
145/// Final, validated network behavior after all configuration is merged.
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub enum NetworkMode {
149    /// No outbound or inbound network access.
150    DenyAll,
151    /// HTTPS is mediated by SBE's domain-filtering CONNECT proxy.
152    Proxy,
153    /// Compatibility mode: direct outbound TCP is limited to port 443 only.
154    DirectHttps443,
155    /// Network sandboxing is disabled by an explicit trusted choice.
156    AllowAll,
157}
158
159/// Provenance retained for every permission-bearing profile entry.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase")]
162pub enum GrantOrigin {
163    BuiltIn,
164    Global(PathBuf),
165    Project(PathBuf),
166    Explicit(PathBuf),
167    Cli,
168    ParentEnvironment,
169    Runtime,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "camelCase")]
174pub enum GrantKind {
175    AllowWrite,
176    DenyRead,
177    AllowRead,
178    AllowDomain,
179    DenyExec,
180    AllowExec,
181    AllowFetch,
182    Environment,
183}
184
185#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct GrantRecord {
188    pub kind: GrantKind,
189    pub value: String,
190    pub origin: GrantOrigin,
191}
192
193impl NetworkMode {
194    pub const fn as_str(self) -> &'static str {
195        match self {
196            Self::DenyAll => "denyAll",
197            Self::Proxy => "proxy",
198            Self::DirectHttps443 => "directHttps443Compatibility",
199            Self::AllowAll => "allowAll",
200        }
201    }
202}
203
204/// The resolved set of sandbox permissions for a single execution.
205#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct SandboxProfile {
208    /// Human-readable name (e.g., "node", "rust", "custom:my-app").
209    pub name: String,
210
211    /// Paths allowed for writing (expanded, absolute).
212    #[serde(default)]
213    pub allow_write: Vec<SandboxPath>,
214
215    /// Paths denied for reading (expanded, absolute).
216    ///
217    /// On macOS this is a subtractive `(deny file-read* …)` rule. On Linux
218    /// Landlock cannot subtract from an allowed subtree, so the backend
219    /// instead treats this list as a *sealed forbidden-list*: paths here are
220    /// guaranteed never to be silently added to [`Self::allow_read`], and
221    /// any user config that would overlap is rejected.
222    #[serde(default)]
223    pub deny_read: Vec<SandboxPath>,
224
225    /// Read-allowlist extensions on Linux (no-op on macOS).
226    ///
227    /// macOS uses an "allow all reads then subtract" model, so this field
228    /// goes unused there. On Linux the backend merges these into the
229    /// curated read-anchors and runs the [`Self::deny_read`] forbidden-list
230    /// lint against the merged set.
231    #[serde(default)]
232    pub allow_read: Vec<SandboxPath>,
233
234    /// Domains allowed for outbound HTTPS.
235    #[serde(default)]
236    pub allow_domains: Vec<DomainPattern>,
237
238    /// Binary paths denied for execution.
239    #[serde(default)]
240    pub deny_exec: Vec<SandboxPath>,
241
242    /// Binary paths explicitly allowed for execution.
243    #[serde(default)]
244    pub allow_exec: Vec<SandboxPath>,
245
246    /// Requested proxy setting retained while configuration is merged.
247    #[serde(skip)]
248    pub enable_proxy: bool,
249
250    /// Requested allow-all setting retained while configuration is merged.
251    #[serde(skip)]
252    pub allow_all_network: bool,
253
254    /// Validated effective network policy. Backends must switch exhaustively
255    /// on this field and must not infer a fallback from the legacy booleans.
256    pub network_mode: NetworkMode,
257
258    /// Highest-precedence source that selected or materially narrowed the
259    /// effective network policy.
260    pub network_origin: GrantOrigin,
261
262    /// Domains that build scripts are allowed to fetch from.
263    ///
264    /// When non-empty, `curl` and `wget` are added to `allow_exec` and these
265    /// domains are merged into the proxy allowlist.
266    #[serde(default)]
267    pub allow_fetch: Vec<DomainPattern>,
268
269    /// Additional environment variables to inject.
270    #[serde(default)]
271    pub env: HashMap<String, String>,
272
273    /// Legacy compatibility bit. It maps only to the explicit insecure Linux
274    /// network mode and never bypasses filesystem or policy lints.
275    #[serde(default)]
276    pub allow_degraded: bool,
277
278    /// Per-field boundary marker: indices `< first_user_*` were populated
279    /// from the curated per-OS defaults; indices `>=` came from user
280    /// `.sbe.yaml` or CLI overrides. The Linux backend's `denyRead`
281    /// forbidden-list seal lint only inspects user additions so that
282    /// intentional default overlaps (e.g. `$PWD/` covers `$PWD/.env`)
283    /// don't trip on every project.
284    #[serde(skip)]
285    pub first_user_allow_write: usize,
286    #[serde(skip)]
287    pub first_user_allow_exec: usize,
288    #[serde(skip)]
289    pub first_user_allow_read: usize,
290
291    /// Per-run roots that may be both writable and executable because they
292    /// are mode-0700 and deleted when the invocation completes.
293    #[serde(skip)]
294    pub ephemeral_write_exec: Vec<PathBuf>,
295
296    /// Audit trail used by `inspect` and policy lints.
297    pub grant_origins: Vec<GrantRecord>,
298}
299
300impl SandboxProfile {
301    /// Build the default profile for an ecosystem from the embedded YAML defaults.
302    pub fn for_ecosystem(ecosystem: Ecosystem, home: &Path, pwd: &Path) -> Self {
303        let defaults: DefaultsFile =
304            serde_yaml::from_str(DEFAULTS_YAML).expect("embedded defaults.yaml is invalid");
305
306        let common = &defaults.common;
307        let profile_name = ecosystem.to_string();
308        let eco_cfg = defaults
309            .profiles
310            .get(&profile_name)
311            .unwrap_or_else(|| panic!("missing profile '{profile_name}' in defaults.yaml"));
312
313        // Build allow_exec: common + ecosystem-specific
314        #[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
315        let mut allow_exec: Vec<SandboxPath> = common
316            .allow_exec
317            .iter()
318            .chain(eco_cfg.allow_exec.iter())
319            .map(|p| expand_path(p, home, pwd))
320            .collect();
321
322        // Build deny_exec: from common (also resolve symlinks for deny rules
323        // on macOS, whose kernel evaluates the resolved executable path).
324        #[cfg_attr(not(target_os = "macos"), allow(unused_mut))]
325        let mut deny_exec: Vec<SandboxPath> = common
326            .deny_exec
327            .iter()
328            .map(|p| expand_path(p, home, pwd))
329            .collect();
330        #[cfg(target_os = "macos")]
331        resolve_symlinks(&mut deny_exec);
332
333        // Build deny_read: from common
334        let deny_read: Vec<SandboxPath> = common
335            .deny_read
336            .iter()
337            .map(|p| expand_path(p, home, pwd))
338            .collect();
339
340        // Build allow_write: from ecosystem
341        let allow_write: Vec<SandboxPath> = eco_cfg
342            .allow_write
343            .iter()
344            .map(|p| expand_path(p, home, pwd))
345            .collect();
346
347        // Build allow_domains: from ecosystem
348        let allow_domains: Vec<DomainPattern> = eco_cfg
349            .allow_domains
350            .iter()
351            .map(|d| DomainPattern::new(d).expect("invalid built-in domain pattern"))
352            .collect();
353
354        // Build output locations are selected by SBE-owned environment
355        // variables and limited to dedicated profile outputs or the private
356        // per-invocation tree. Project-controlled Cargo config and ambient
357        // JAVA_HOME therefore cannot create implicit grants here.
358
359        // Resolve symlinks: SBPL on macOS checks the real path after kernel
360        // symlink resolution, so /opt/homebrew/bin/zig (a symlink to
361        // /opt/homebrew/Cellar/.../zig) won't match unless we also allow the
362        // resolved Cellar path. Landlock on Linux dereferences via the
363        // preopened FD; symlink resolution there is a non-issue.
364        #[cfg(target_os = "macos")]
365        resolve_symlinks(&mut allow_exec);
366
367        // Linux read-allowlist additions from defaults (macOS ignores).
368        let allow_read: Vec<SandboxPath> = common
369            .allow_read
370            .iter()
371            .chain(eco_cfg.allow_read.iter())
372            .map(|p| expand_path(p, home, pwd))
373            .collect();
374
375        // After this point everything appended to allow_* is treated as
376        // user-supplied. Snapshot the lengths now so the seal lint can
377        // identify user additions later.
378        let first_user_allow_write = allow_write.len();
379        let first_user_allow_exec = allow_exec.len();
380        let first_user_allow_read = allow_read.len();
381
382        let enable_proxy = eco_cfg.enable_proxy.unwrap_or(true);
383        let network_mode = if enable_proxy {
384            if allow_domains.is_empty() {
385                NetworkMode::DenyAll
386            } else {
387                NetworkMode::Proxy
388            }
389        } else {
390            NetworkMode::DirectHttps443
391        };
392
393        let mut grant_origins = Vec::new();
394        record_paths(
395            &mut grant_origins,
396            GrantKind::AllowWrite,
397            &allow_write,
398            GrantOrigin::BuiltIn,
399        );
400        record_paths(
401            &mut grant_origins,
402            GrantKind::DenyRead,
403            &deny_read,
404            GrantOrigin::BuiltIn,
405        );
406        record_paths(
407            &mut grant_origins,
408            GrantKind::AllowRead,
409            &allow_read,
410            GrantOrigin::BuiltIn,
411        );
412        record_paths(
413            &mut grant_origins,
414            GrantKind::DenyExec,
415            &deny_exec,
416            GrantOrigin::BuiltIn,
417        );
418        record_paths(
419            &mut grant_origins,
420            GrantKind::AllowExec,
421            &allow_exec,
422            GrantOrigin::BuiltIn,
423        );
424        for domain in &allow_domains {
425            grant_origins.push(GrantRecord {
426                kind: GrantKind::AllowDomain,
427                value: domain.0.clone(),
428                origin: GrantOrigin::BuiltIn,
429            });
430        }
431
432        SandboxProfile {
433            name: profile_name,
434            allow_write,
435            deny_read,
436            allow_read,
437            allow_domains,
438            deny_exec,
439            allow_exec,
440            enable_proxy,
441            allow_all_network: false,
442            network_mode,
443            network_origin: GrantOrigin::BuiltIn,
444            allow_fetch: vec![],
445            env: Default::default(),
446            allow_degraded: false,
447            first_user_allow_write,
448            first_user_allow_exec,
449            first_user_allow_read,
450            ephemeral_write_exec: Vec::new(),
451            grant_origins,
452        }
453    }
454
455    /// Merge CLI overrides into this profile.
456    pub fn merge_overrides(&mut self, overrides: &ProfileOverrides) {
457        if !overrides.allow_domains.is_empty()
458            || !overrides.deny_domains.is_empty()
459            || !overrides.allow_fetch.is_empty()
460            || overrides.allow_all_network
461            || overrides.no_proxy
462        {
463            self.network_origin = GrantOrigin::Cli;
464        }
465        record_paths(
466            &mut self.grant_origins,
467            GrantKind::AllowWrite,
468            &overrides.allow_write,
469            GrantOrigin::Cli,
470        );
471        record_paths(
472            &mut self.grant_origins,
473            GrantKind::DenyRead,
474            &overrides.deny_read,
475            GrantOrigin::Cli,
476        );
477        record_paths(
478            &mut self.grant_origins,
479            GrantKind::AllowRead,
480            &overrides.allow_read,
481            GrantOrigin::Cli,
482        );
483        for domain in &overrides.allow_domains {
484            self.grant_origins.push(GrantRecord {
485                kind: GrantKind::AllowDomain,
486                value: domain.0.clone(),
487                origin: GrantOrigin::Cli,
488            });
489        }
490        for domain in &overrides.allow_fetch {
491            self.grant_origins.push(GrantRecord {
492                kind: GrantKind::AllowFetch,
493                value: domain.0.clone(),
494                origin: GrantOrigin::Cli,
495            });
496        }
497        self.allow_write
498            .extend(overrides.allow_write.iter().cloned());
499        self.deny_read.extend(overrides.deny_read.iter().cloned());
500        self.allow_read.extend(overrides.allow_read.iter().cloned());
501        self.allow_domains
502            .extend(overrides.allow_domains.iter().cloned());
503        for path in &overrides.deny_exec {
504            self.add_deny_exec(path.clone(), GrantOrigin::Cli);
505        }
506        for path in &overrides.allow_exec {
507            self.add_allow_exec(path.clone(), GrantOrigin::Cli);
508        }
509
510        self.allow_fetch
511            .extend(overrides.allow_fetch.iter().cloned());
512        self.remove_denied_domains(&overrides.deny_domains);
513
514        if overrides.allow_all_network {
515            self.allow_all_network = true;
516        }
517        if overrides.no_proxy {
518            self.enable_proxy = false;
519        }
520        if overrides.allow_degraded {
521            self.allow_degraded = true;
522        }
523
524        for (k, v) in &overrides.env {
525            self.env.insert(k.clone(), v.clone());
526            self.grant_origins.push(GrantRecord {
527                kind: GrantKind::Environment,
528                value: k.clone(),
529                origin: GrantOrigin::Cli,
530            });
531        }
532    }
533
534    /// Apply a higher-precedence domain denial to every network grant that
535    /// has been accumulated so far. Pattern intersection matters: retaining
536    /// `*.example.com` would otherwise defeat a denial for `bad.example.com`.
537    pub(crate) fn remove_denied_domains(&mut self, denied: &[DomainPattern]) {
538        if denied.is_empty() {
539            return;
540        }
541        self.allow_domains
542            .retain(|allowed| !denied.iter().any(|pattern| pattern.overlaps(allowed)));
543        self.allow_fetch
544            .retain(|allowed| !denied.iter().any(|pattern| pattern.overlaps(allowed)));
545        self.grant_origins.retain(|record| {
546            if !matches!(record.kind, GrantKind::AllowDomain | GrantKind::AllowFetch) {
547                return true;
548            }
549            DomainPattern::new(&record.value)
550                .is_ok_and(|allowed| !denied.iter().any(|pattern| pattern.overlaps(&allowed)))
551        });
552    }
553
554    /// Finalize the profile: apply allow_fetch effects to allow_exec and allow_domains.
555    ///
556    /// Must be called after all merging is complete, before SBPL generation.
557    pub fn finalize(&mut self) {
558        if !self.allow_fetch.is_empty() {
559            let curl = SandboxPath::file(PathBuf::from("/usr/bin/curl"));
560            let wget = SandboxPath::file(PathBuf::from("/usr/bin/wget"));
561            let fetch_origin = self
562                .grant_origins
563                .iter()
564                .rev()
565                .find(|record| record.kind == GrantKind::AllowFetch)
566                .map(|record| record.origin.clone())
567                .unwrap_or(GrantOrigin::Runtime);
568            if !self.allow_exec.iter().any(|p| p.path == curl.path) {
569                self.grant_origins.push(GrantRecord {
570                    kind: GrantKind::AllowExec,
571                    value: curl.path.to_string_lossy().into_owned(),
572                    origin: fetch_origin.clone(),
573                });
574                self.allow_exec.push(curl);
575            }
576            if !self.allow_exec.iter().any(|p| p.path == wget.path) {
577                self.grant_origins.push(GrantRecord {
578                    kind: GrantKind::AllowExec,
579                    value: wget.path.to_string_lossy().into_owned(),
580                    origin: fetch_origin,
581                });
582                self.allow_exec.push(wget);
583            }
584
585            for domain in &self.allow_fetch {
586                if !self.allow_domains.iter().any(|d| d.0 == domain.0) {
587                    self.allow_domains.push(domain.clone());
588                    let origin = self
589                        .grant_origins
590                        .iter()
591                        .rev()
592                        .find(|record| {
593                            record.kind == GrantKind::AllowFetch && record.value == domain.0
594                        })
595                        .map(|record| record.origin.clone())
596                        .unwrap_or(GrantOrigin::Runtime);
597                    self.grant_origins.push(GrantRecord {
598                        kind: GrantKind::AllowDomain,
599                        value: domain.0.clone(),
600                        origin,
601                    });
602                }
603            }
604        }
605
606        self.recompute_network_mode();
607    }
608
609    /// Recompute the effective mode without carrying stateful side effects
610    /// across configuration precedence boundaries.
611    pub fn recompute_network_mode(&mut self) {
612        self.network_mode = if self.allow_all_network {
613            NetworkMode::AllowAll
614        } else if self.enable_proxy {
615            if self.allow_domains.is_empty() {
616                NetworkMode::DenyAll
617            } else {
618                NetworkMode::Proxy
619            }
620        } else {
621            NetworkMode::DirectHttps443
622        };
623    }
624
625    /// Reject persistent write/execute overlap and writable hard-link aliases
626    /// that escape the writable roots. Mutable executable or protected source
627    /// content is a persistence boundary, not merely a filesystem convenience.
628    pub fn validate_security_invariants(&self) -> Result<(), crate::error::CoreError> {
629        self.validate_structural_security_invariants()?;
630        #[cfg(unix)]
631        self.reject_hard_linked_write_aliases()?;
632        Ok(())
633    }
634
635    /// Validate invariants that depend only on the resolved profile. The
636    /// Linux launcher repeats this after decoding the already-validated
637    /// parent payload without rescanning the host filesystem.
638    #[doc(hidden)]
639    pub fn validate_structural_security_invariants(&self) -> Result<(), crate::error::CoreError> {
640        for write in &self.allow_write {
641            for execute in &self.allow_exec {
642                if paths_overlap(&write.path, &execute.path)
643                    && !self
644                        .ephemeral_write_exec
645                        .iter()
646                        .any(|root| write.path.starts_with(root) && execute.path.starts_with(root))
647                {
648                    return Err(crate::error::CoreError::ProfileLint(format!(
649                        "persistent path is both writable ('{}') and executable ('{}'); use a \
650                         private per-run output or split the grants",
651                        write.path.display(),
652                        execute.path.display()
653                    )));
654                }
655            }
656        }
657        Ok(())
658    }
659
660    #[cfg(unix)]
661    fn reject_hard_linked_write_aliases(&self) -> Result<(), crate::error::CoreError> {
662        let mut inspected = 0_usize;
663        let mut observations: HashMap<FileIdentity, HardLinkObservation> = HashMap::new();
664        for writable in &self.allow_write {
665            if self
666                .ephemeral_write_exec
667                .iter()
668                .any(|root| writable.path.starts_with(root))
669            {
670                continue;
671            }
672            visit_regular_files(writable, &mut inspected, &mut |path, metadata| {
673                if metadata.nlink() <= 1 {
674                    return Ok(());
675                }
676                let identity = file_identity(metadata);
677                let observation =
678                    observations
679                        .entry(identity)
680                        .or_insert_with(|| HardLinkObservation {
681                            link_count: metadata.nlink(),
682                            writable_paths: std::collections::HashSet::new(),
683                        });
684                observation.link_count = observation.link_count.max(metadata.nlink());
685                observation.writable_paths.insert(path.to_path_buf());
686                Ok(())
687            })?;
688        }
689
690        for observation in observations.values() {
691            if (observation.writable_paths.len() as u64) >= observation.link_count {
692                continue;
693            }
694            let writable = observation
695                .writable_paths
696                .iter()
697                .next()
698                .expect("hard-link observation has a writable path");
699            return Err(crate::error::CoreError::ProfileLint(format!(
700                "persistent writable path '{}' has {} hard links but only {} are contained in \
701                 writable roots; remove every cross-boundary alias",
702                writable.display(),
703                observation.link_count,
704                observation.writable_paths.len(),
705            )));
706        }
707        Ok(())
708    }
709
710    /// Apply an execute denial at the current precedence level. Landlock has
711    /// no subtractive rule, so an overlapping allow entry is removed in full;
712    /// this can be stricter than the requested path but never weaker.
713    pub(crate) fn add_deny_exec(&mut self, path: SandboxPath, origin: GrantOrigin) {
714        let old_boundary = self.first_user_allow_exec;
715        let mut built_in_remaining = 0_usize;
716        let mut removed = Vec::new();
717        self.allow_exec = self
718            .allow_exec
719            .drain(..)
720            .enumerate()
721            .filter_map(|(index, allowed)| {
722                if paths_overlap(&allowed.path, &path.path) {
723                    removed.push(allowed.path.to_string_lossy().into_owned());
724                    None
725                } else {
726                    if index < old_boundary {
727                        built_in_remaining += 1;
728                    }
729                    Some(allowed)
730                }
731            })
732            .collect();
733        self.first_user_allow_exec = built_in_remaining;
734        self.grant_origins.retain(|record| {
735            record.kind != GrantKind::AllowExec || !removed.contains(&record.value)
736        });
737        self.grant_origins.push(GrantRecord {
738            kind: GrantKind::DenyExec,
739            value: path.path.to_string_lossy().into_owned(),
740            origin,
741        });
742        self.deny_exec.push(path);
743    }
744
745    /// Apply an execute grant at the current precedence level, removing an
746    /// earlier overlapping denial so trusted later sources can re-authorize.
747    pub(crate) fn add_allow_exec(&mut self, path: SandboxPath, origin: GrantOrigin) {
748        let mut removed = Vec::new();
749        self.deny_exec.retain(|denied| {
750            let overlaps = paths_overlap(&denied.path, &path.path);
751            if overlaps {
752                removed.push(denied.path.to_string_lossy().into_owned());
753            }
754            !overlaps
755        });
756        self.grant_origins.retain(|record| {
757            record.kind != GrantKind::DenyExec || !removed.contains(&record.value)
758        });
759        self.grant_origins.push(GrantRecord {
760            kind: GrantKind::AllowExec,
761            value: path.path.to_string_lossy().into_owned(),
762            origin,
763        });
764        self.allow_exec.push(path);
765    }
766}
767
768#[cfg(unix)]
769fn file_identity(metadata: &std::fs::Metadata) -> FileIdentity {
770    FileIdentity {
771        device: metadata.dev(),
772        inode: metadata.ino(),
773    }
774}
775
776#[cfg(unix)]
777#[allow(
778    clippy::disallowed_methods,
779    reason = "the pre-launch invariant scan must inspect existing filesystem identities"
780)]
781fn visit_regular_files(
782    root: &SandboxPath,
783    inspected: &mut usize,
784    visitor: &mut dyn FnMut(&Path, &std::fs::Metadata) -> Result<(), crate::error::CoreError>,
785) -> Result<(), crate::error::CoreError> {
786    let metadata = match std::fs::symlink_metadata(&root.path) {
787        Ok(metadata) => metadata,
788        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
789        Err(error) => {
790            return Err(crate::error::CoreError::ProfileLint(format!(
791                "cannot inspect sandbox path '{}' for hard-link aliases: {error}",
792                root.path.display()
793            )));
794        }
795    };
796    if metadata.file_type().is_symlink() {
797        let resolved = match std::fs::canonicalize(&root.path) {
798            Ok(resolved) => resolved,
799            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()),
800            Err(error) => {
801                return Err(crate::error::CoreError::ProfileLint(format!(
802                    "cannot resolve sandbox path '{}' for hard-link aliases: {error}",
803                    root.path.display()
804                )));
805            }
806        };
807        return visit_regular_files(
808            &SandboxPath {
809                path: resolved,
810                kind: root.kind,
811            },
812            inspected,
813            visitor,
814        );
815    }
816    if metadata.is_file() {
817        return visitor(&root.path, &metadata);
818    }
819    if !metadata.is_dir() || root.kind != PathKind::Subpath {
820        return Ok(());
821    }
822
823    let mut pending = vec![root.path.clone()];
824    while let Some(directory) = pending.pop() {
825        let entries = std::fs::read_dir(&directory).map_err(|error| {
826            crate::error::CoreError::ProfileLint(format!(
827                "cannot inspect sandbox directory '{}' for hard-link aliases: {error}",
828                directory.display()
829            ))
830        })?;
831        for entry in entries {
832            let entry = entry.map_err(|error| {
833                crate::error::CoreError::ProfileLint(format!(
834                    "cannot enumerate sandbox directory '{}' for hard-link aliases: {error}",
835                    directory.display()
836                ))
837            })?;
838            *inspected = inspected.saturating_add(1);
839            if *inspected > MAX_WRITABLE_ALIAS_SCAN_ENTRIES {
840                return Err(crate::error::CoreError::ProfileLint(format!(
841                    "persistent writable-alias scan exceeds {MAX_WRITABLE_ALIAS_SCAN_ENTRIES} entries"
842                )));
843            }
844            let path = entry.path();
845            let metadata = match std::fs::symlink_metadata(&path) {
846                Ok(metadata) => metadata,
847                Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
848                Err(error) => {
849                    return Err(crate::error::CoreError::ProfileLint(format!(
850                        "cannot inspect sandbox path '{}' for hard-link aliases: {error}",
851                        path.display()
852                    )));
853                }
854            };
855            if metadata.file_type().is_symlink() {
856                continue;
857            }
858            if metadata.is_dir() {
859                pending.push(path);
860            } else if metadata.is_file() {
861                visitor(&path, &metadata)?;
862            }
863        }
864    }
865    Ok(())
866}
867
868fn record_paths(
869    records: &mut Vec<GrantRecord>,
870    kind: GrantKind,
871    paths: &[SandboxPath],
872    origin: GrantOrigin,
873) {
874    records.extend(paths.iter().map(|path| GrantRecord {
875        kind,
876        value: path.path.to_string_lossy().into_owned(),
877        origin: origin.clone(),
878    }));
879}
880
881#[allow(clippy::disallowed_methods)] // Synchronous invariant used by both backends before spawn.
882fn paths_overlap(left: &Path, right: &Path) -> bool {
883    if left == right || left.starts_with(right) || right.starts_with(left) {
884        return true;
885    }
886    match (std::fs::canonicalize(left), std::fs::canonicalize(right)) {
887        (Ok(left), Ok(right)) => {
888            left == right || left.starts_with(&right) || right.starts_with(&left)
889        }
890        _ => false,
891    }
892}
893
894/// For each path in the list, if it's a symlink, also add the resolved real path.
895///
896/// macOS sandbox-exec resolves symlinks before checking SBPL rules, so
897/// `/opt/homebrew/bin/zig` (a symlink to `/opt/homebrew/Cellar/zig/.../zig`)
898/// requires the Cellar path to be in the allow list too.
899///
900/// For Homebrew Cellar paths, we add the package root directory (e.g.,
901/// `/opt/homebrew/Cellar/zig/0.15.2/`) rather than just the binary, because
902/// tools like zig spawn sub-tools from their lib/ directory.
903#[cfg(target_os = "macos")]
904#[allow(clippy::disallowed_methods)]
905fn resolve_symlinks(paths: &mut Vec<SandboxPath>) {
906    let additional: Vec<SandboxPath> = paths
907        .iter()
908        .filter_map(|sp| {
909            let resolved = std::fs::canonicalize(&sp.path).ok()?;
910            if resolved == sp.path {
911                return None;
912            }
913            // For Homebrew Cellar paths, allow the entire package directory.
914            // Structure: /opt/homebrew/Cellar/<pkg>/<version>/bin/<binary>
915            // We want:   /opt/homebrew/Cellar/<pkg>/<version>/
916            let resolved_str = resolved.to_string_lossy();
917            if let Some(cellar_idx) = resolved_str.find("/Cellar/") {
918                let after_cellar = &resolved_str[cellar_idx + 8..];
919                let parts: Vec<&str> = after_cellar.splitn(3, '/').collect();
920                if parts.len() >= 2 {
921                    let pkg_root = format!(
922                        "{}/Cellar/{}/{}",
923                        &resolved_str[..cellar_idx],
924                        parts[0],
925                        parts[1]
926                    );
927                    return Some(SandboxPath::dir(PathBuf::from(pkg_root)));
928                }
929            }
930            // Preserve the original kind for non-Cellar symlinks
931            Some(SandboxPath {
932                path: resolved,
933                kind: sp.kind,
934            })
935        })
936        .filter(|resolved| !paths.iter().any(|p| p.path == resolved.path))
937        .collect();
938    paths.extend(additional);
939}
940
941/// Overrides from CLI flags that get merged into the resolved profile.
942#[derive(Debug, Default, Clone)]
943pub struct ProfileOverrides {
944    pub allow_write: Vec<SandboxPath>,
945    pub deny_read: Vec<SandboxPath>,
946    pub allow_read: Vec<SandboxPath>,
947    pub allow_domains: Vec<DomainPattern>,
948    pub deny_domains: Vec<DomainPattern>,
949    pub allow_exec: Vec<SandboxPath>,
950    pub deny_exec: Vec<SandboxPath>,
951    pub allow_fetch: Vec<DomainPattern>,
952    pub allow_all_network: bool,
953    pub no_proxy: bool,
954    pub allow_degraded: bool,
955    pub env: HashMap<String, String>,
956}
957
958// --- Embedded YAML deserialization types ---
959
960#[derive(Debug, Deserialize)]
961struct DefaultsFile {
962    common: CommonDefaults,
963    profiles: HashMap<String, EcosystemDefaults>,
964}
965
966#[derive(Debug, Deserialize)]
967#[serde(rename_all = "camelCase")]
968struct CommonDefaults {
969    #[serde(default)]
970    deny_read: Vec<String>,
971    #[serde(default)]
972    allow_read: Vec<String>,
973    #[serde(default)]
974    deny_exec: Vec<String>,
975    #[serde(default)]
976    allow_exec: Vec<String>,
977}
978
979#[derive(Debug, Deserialize)]
980#[serde(rename_all = "camelCase")]
981struct EcosystemDefaults {
982    #[serde(default)]
983    allow_write: Vec<String>,
984    #[serde(default)]
985    allow_read: Vec<String>,
986    #[serde(default)]
987    allow_domains: Vec<String>,
988    #[serde(default)]
989    allow_exec: Vec<String>,
990    /// Whether to start the domain-filtering proxy. Some ecosystems whose
991    /// HTTP stack does not respect `HTTP_PROXY` env (notably JVM tools like
992    /// Maven and Gradle) cannot benefit from the proxy and need the kernel
993    /// to open port 443 directly. Set this to `false` in those profiles —
994    /// kernel TCP filter still enforces "egress on port 443 only", but
995    /// domain filtering is delegated to the proxy when set to true.
996    /// Defaults to `true`.
997    #[serde(default)]
998    enable_proxy: Option<bool>,
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004
1005    #[test]
1006    fn test_should_match_exact_domain() {
1007        let p = DomainPattern::from("registry.npmjs.org");
1008        assert!(p.matches("registry.npmjs.org"));
1009        assert!(!p.matches("evil.com"));
1010        assert!(!p.matches("sub.registry.npmjs.org"));
1011    }
1012
1013    #[test]
1014    fn test_should_match_wildcard_domain() {
1015        let p = DomainPattern::from("*.npmjs.org");
1016        assert!(p.matches("registry.npmjs.org"));
1017        assert!(p.matches("npmjs.org"));
1018        assert!(p.matches("deep.sub.npmjs.org"));
1019        assert!(!p.matches("evil.com"));
1020    }
1021
1022    #[test]
1023    fn test_should_canonicalize_and_validate_domains() {
1024        assert_eq!(
1025            DomainPattern::new("BÜCHER.Example.").unwrap().0,
1026            "xn--bcher-kva.example"
1027        );
1028        assert!(DomainPattern::new("127.0.0.1").is_err());
1029        assert!(DomainPattern::new("bad..example").is_err());
1030        assert!(DomainPattern::new("evil.com:443").is_err());
1031    }
1032
1033    #[test]
1034    fn test_should_reject_persistent_write_execute_overlap() {
1035        let home = PathBuf::from("/home/test");
1036        let pwd = PathBuf::from("/work/project");
1037        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
1038        profile
1039            .allow_write
1040            .push(SandboxPath::dir(home.join("mutable-tool")));
1041        profile
1042            .allow_exec
1043            .push(SandboxPath::dir(home.join("mutable-tool/bin")));
1044        assert!(profile.validate_security_invariants().is_err());
1045    }
1046
1047    #[test]
1048    fn test_should_allow_ephemeral_write_execute_overlap() {
1049        let home = PathBuf::from("/home/test");
1050        let pwd = PathBuf::from("/work/project");
1051        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
1052        let root = PathBuf::from("/tmp/sbe-test-private");
1053        profile.allow_write.push(SandboxPath::dir(root.clone()));
1054        profile.allow_exec.push(SandboxPath::dir(root.clone()));
1055        profile.ephemeral_write_exec.push(root);
1056        assert!(profile.validate_security_invariants().is_ok());
1057    }
1058
1059    #[test]
1060    fn test_should_load_all_ecosystems_from_yaml() {
1061        let home = PathBuf::from("/Users/test");
1062        let pwd = PathBuf::from("/Users/test/project");
1063
1064        for eco in Ecosystem::ALL {
1065            let profile = SandboxProfile::for_ecosystem(eco, &home, &pwd);
1066            assert_eq!(profile.name, eco.to_string());
1067            assert!(!profile.allow_write.is_empty(), "no allow_write for {eco}");
1068            assert!(!profile.deny_read.is_empty(), "no deny_read for {eco}");
1069            assert!(
1070                !profile.allow_domains.is_empty(),
1071                "no allow_domains for {eco}"
1072            );
1073            assert!(!profile.allow_exec.is_empty(), "no allow_exec for {eco}");
1074            // Linux defaults need no subtractive entries because execution is
1075            // allowlist-only; macOS keeps explicit defense-in-depth denials.
1076            #[cfg(target_os = "macos")]
1077            assert!(!profile.deny_exec.is_empty(), "no deny_exec for {eco}");
1078        }
1079    }
1080
1081    #[cfg(target_os = "linux")]
1082    #[test]
1083    fn python_profile_allows_github_setup_python_toolchains() {
1084        let profile = SandboxProfile::for_ecosystem(
1085            Ecosystem::Python,
1086            &PathBuf::from("/home/test"),
1087            &PathBuf::from("/work/project"),
1088        );
1089
1090        assert!(has(&profile.allow_exec, "/opt/hostedtoolcache/Python"));
1091    }
1092
1093    #[test]
1094    fn default_profiles_satisfy_persistent_write_xor_execute() {
1095        let home = PathBuf::from("/Users/test");
1096        let pwd = PathBuf::from("/Users/test/project");
1097
1098        for ecosystem in Ecosystem::ALL {
1099            let profile = SandboxProfile::for_ecosystem(ecosystem, &home, &pwd);
1100            profile
1101                .validate_security_invariants()
1102                .unwrap_or_else(|error| panic!("invalid default {ecosystem} profile: {error}"));
1103        }
1104    }
1105
1106    #[tokio::test]
1107    async fn project_cargo_target_dir_never_creates_a_write_grant() {
1108        let home = tempfile::tempdir().unwrap();
1109        let project = tempfile::tempdir().unwrap();
1110        let cargo = project.path().join(".cargo");
1111        let sensitive = home.path().join("sensitive");
1112        tokio::fs::create_dir(&cargo).await.unwrap();
1113        tokio::fs::write(
1114            cargo.join("config.toml"),
1115            format!("[build]\ntarget-dir = {:?}\n", sensitive),
1116        )
1117        .await
1118        .unwrap();
1119
1120        let profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, home.path(), project.path());
1121        assert!(
1122            profile
1123                .allow_write
1124                .iter()
1125                .all(|grant| !paths_overlap(&grant.path, &sensitive)),
1126            "project-controlled Cargo target-dir created write authority"
1127        );
1128    }
1129
1130    #[test]
1131    fn network_mode_precedence_recomputes_without_stale_fallbacks() {
1132        let home = PathBuf::from("/Users/test");
1133        let pwd = PathBuf::from("/Users/test/project");
1134        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
1135
1136        assert_eq!(profile.network_mode, NetworkMode::Proxy);
1137        profile.allow_all_network = true;
1138        profile.network_origin = GrantOrigin::Global(PathBuf::from("global.yaml"));
1139        profile.recompute_network_mode();
1140        assert_eq!(profile.network_mode, NetworkMode::AllowAll);
1141
1142        profile.allow_all_network = false;
1143        profile.enable_proxy = false;
1144        profile.recompute_network_mode();
1145        assert_eq!(profile.network_mode, NetworkMode::DirectHttps443);
1146
1147        profile.enable_proxy = true;
1148        profile.allow_domains.clear();
1149        profile.recompute_network_mode();
1150        assert_eq!(profile.network_mode, NetworkMode::DenyAll);
1151
1152        profile
1153            .allow_domains
1154            .push(DomainPattern::from("example.com"));
1155        profile.network_origin = GrantOrigin::Cli;
1156        profile.recompute_network_mode();
1157        assert_eq!(profile.network_mode, NetworkMode::Proxy);
1158        assert_eq!(profile.network_origin, GrantOrigin::Cli);
1159    }
1160
1161    #[test]
1162    fn later_execute_sources_revoke_and_can_explicitly_reauthorize() {
1163        let home = PathBuf::from("/home/test");
1164        let pwd = PathBuf::from("/work/project");
1165        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
1166        let git = SandboxPath::file(PathBuf::from("/usr/bin/git"));
1167        assert!(profile.allow_exec.iter().any(|path| path.path == git.path));
1168
1169        profile.add_deny_exec(git.clone(), GrantOrigin::Project(pwd.join(".sbe.yaml")));
1170        assert!(!profile.allow_exec.iter().any(|path| path.path == git.path));
1171        assert!(profile.deny_exec.iter().any(|path| path.path == git.path));
1172
1173        profile.add_allow_exec(git.clone(), GrantOrigin::Cli);
1174        assert!(profile.allow_exec.iter().any(|path| path.path == git.path));
1175        assert!(!profile.deny_exec.iter().any(|path| path.path == git.path));
1176        assert!(profile.grant_origins.iter().any(|record| {
1177            record.kind == GrantKind::AllowExec
1178                && record.value == "/usr/bin/git"
1179                && record.origin == GrantOrigin::Cli
1180        }));
1181    }
1182
1183    /// Both YAML defaults files must deserialize through [`DefaultsFile`]
1184    /// (regression guard for the macOS/Linux schema split).
1185    #[test]
1186    fn test_should_parse_both_defaults_files() {
1187        let macos: DefaultsFile =
1188            serde_yaml::from_str(include_str!("defaults-macos.yaml")).expect("macOS defaults");
1189        let linux: DefaultsFile =
1190            serde_yaml::from_str(include_str!("defaults-linux.yaml")).expect("Linux defaults");
1191        for name in ["node", "rust", "python", "elixir", "java"] {
1192            assert!(macos.profiles.contains_key(name), "macos missing {name}");
1193            assert!(linux.profiles.contains_key(name), "linux missing {name}");
1194        }
1195        for (platform, defaults) in [("macos", &macos), ("linux", &linux)] {
1196            assert!(
1197                defaults.profiles["node"]
1198                    .allow_write
1199                    .iter()
1200                    .any(|path| path == "$PWD/bun.lock"),
1201                "{platform} Node profile missing bun.lock"
1202            );
1203        }
1204    }
1205
1206    #[test]
1207    fn macos_defaults_allow_github_actions_toolcache_runtimes() {
1208        let macos: DefaultsFile =
1209            serde_yaml::from_str(include_str!("defaults-macos.yaml")).expect("macOS defaults");
1210        for (profile, path) in [
1211            ("node", "/Users/runner/hostedtoolcache/node/"),
1212            ("python", "/Users/runner/hostedtoolcache/Python/"),
1213            ("python", "/Library/Frameworks/Python.framework/Versions/"),
1214            (
1215                "java",
1216                "/Users/runner/hostedtoolcache/Java_Temurin-Hotspot_jdk/",
1217            ),
1218        ] {
1219            assert!(
1220                macos.profiles[profile]
1221                    .allow_exec
1222                    .iter()
1223                    .any(|entry| entry == path),
1224                "macOS {profile} profile missing {path}"
1225            );
1226        }
1227    }
1228
1229    /// Helper: check if a path list contains a given path (ignoring is_dir).
1230    fn has(paths: &[SandboxPath], path: &str) -> bool {
1231        paths.iter().any(|sp| sp.has_path(Path::new(path)))
1232    }
1233
1234    #[test]
1235    fn test_should_expand_paths_in_defaults() {
1236        let home = PathBuf::from("/Users/test");
1237        let pwd = PathBuf::from("/Users/test/project");
1238        let profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
1239
1240        assert!(has(&profile.deny_read, "/Users/test/.ssh"));
1241        assert!(has(
1242            &profile.allow_write,
1243            "/Users/test/project/node_modules"
1244        ));
1245        assert!(!has(&profile.allow_write, "/Users/test/project"));
1246        assert!(has(&profile.allow_write, "/Users/test/.npm"));
1247    }
1248
1249    #[test]
1250    fn test_should_include_common_exec_in_all_profiles() {
1251        let home = PathBuf::from("/Users/test");
1252        let pwd = PathBuf::from("/Users/test/project");
1253
1254        for eco in Ecosystem::ALL {
1255            let profile = SandboxProfile::for_ecosystem(eco, &home, &pwd);
1256            assert!(
1257                has(&profile.allow_exec, "/bin/sh"),
1258                "missing /bin/sh for {eco}"
1259            );
1260            assert!(
1261                has(&profile.allow_exec, "/usr/bin/cc"),
1262                "missing /usr/bin/cc for {eco}"
1263            );
1264            // osascript deny only exists in the macOS defaults.
1265            #[cfg(target_os = "macos")]
1266            assert!(
1267                has(&profile.deny_exec, "/usr/bin/osascript"),
1268                "missing osascript deny for {eco}"
1269            );
1270        }
1271    }
1272
1273    #[test]
1274    fn test_should_merge_overrides() {
1275        let home = PathBuf::from("/Users/test");
1276        let pwd = PathBuf::from("/Users/test/project");
1277        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
1278        let original_write_count = profile.allow_write.len();
1279
1280        let overrides = ProfileOverrides {
1281            allow_write: vec![SandboxPath::dir(PathBuf::from("/extra/path"))],
1282            deny_domains: vec![DomainPattern::from("registry.npmmirror.com")],
1283            ..Default::default()
1284        };
1285        profile.merge_overrides(&overrides);
1286
1287        assert_eq!(profile.allow_write.len(), original_write_count + 1);
1288        assert!(
1289            !profile
1290                .allow_domains
1291                .iter()
1292                .any(|d| d.0 == "registry.npmmirror.com")
1293        );
1294    }
1295
1296    #[test]
1297    fn test_should_finalize_allow_fetch() {
1298        let home = PathBuf::from("/Users/test");
1299        let pwd = PathBuf::from("/Users/test/project");
1300        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
1301
1302        assert!(!has(&profile.allow_exec, "/usr/bin/curl"));
1303
1304        let overrides = ProfileOverrides {
1305            allow_fetch: vec![DomainPattern::from("example.com")],
1306            ..Default::default()
1307        };
1308        profile.merge_overrides(&overrides);
1309        profile.finalize();
1310
1311        assert!(has(&profile.allow_exec, "/usr/bin/curl"));
1312        assert!(has(&profile.allow_exec, "/usr/bin/wget"));
1313        assert!(profile.allow_domains.iter().any(|d| d.0 == "example.com"));
1314    }
1315
1316    #[test]
1317    fn domain_denials_remove_fetch_grants_before_finalize() {
1318        let home = PathBuf::from("/Users/test");
1319        let pwd = PathBuf::from("/Users/test/project");
1320        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
1321        let overrides = ProfileOverrides {
1322            allow_fetch: vec![DomainPattern::from("downloads.example.com")],
1323            deny_domains: vec![DomainPattern::from("downloads.example.com")],
1324            ..Default::default()
1325        };
1326
1327        profile.merge_overrides(&overrides);
1328        profile.finalize();
1329
1330        assert!(profile.allow_fetch.is_empty());
1331        assert!(
1332            !profile
1333                .allow_domains
1334                .iter()
1335                .any(|domain| domain.matches("downloads.example.com"))
1336        );
1337    }
1338
1339    #[test]
1340    fn domain_denials_remove_intersecting_exact_and_wildcard_grants() {
1341        let home = PathBuf::from("/Users/test");
1342        let pwd = PathBuf::from("/Users/test/project");
1343        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
1344        profile.allow_domains = vec![
1345            DomainPattern::from("*.example.com"),
1346            DomainPattern::from("api.other.test"),
1347        ];
1348
1349        profile.remove_denied_domains(&[DomainPattern::from("bad.example.com")]);
1350        assert_eq!(
1351            profile.allow_domains,
1352            vec![DomainPattern::from("api.other.test")]
1353        );
1354
1355        profile.allow_domains = vec![DomainPattern::from("api.example.com")];
1356        profile.remove_denied_domains(&[DomainPattern::from("*.example.com")]);
1357        assert!(profile.allow_domains.is_empty());
1358    }
1359
1360    #[test]
1361    fn test_should_not_add_curl_without_allow_fetch() {
1362        let home = PathBuf::from("/Users/test");
1363        let pwd = PathBuf::from("/Users/test/project");
1364        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Node, &home, &pwd);
1365        profile.finalize();
1366        assert!(!has(&profile.allow_exec, "/usr/bin/curl"));
1367    }
1368
1369    #[test]
1370    fn test_should_not_duplicate_domains_on_finalize() {
1371        let home = PathBuf::from("/Users/test");
1372        let pwd = PathBuf::from("/Users/test/project");
1373        let mut profile = SandboxProfile::for_ecosystem(Ecosystem::Rust, &home, &pwd);
1374        let original_domain_count = profile.allow_domains.len();
1375
1376        let overrides = ProfileOverrides {
1377            allow_fetch: vec![DomainPattern::from("github.com")],
1378            ..Default::default()
1379        };
1380        profile.merge_overrides(&overrides);
1381        profile.finalize();
1382
1383        assert_eq!(profile.allow_domains.len(), original_domain_count);
1384        assert!(has(&profile.allow_exec, "/usr/bin/curl"));
1385    }
1386
1387    #[cfg(unix)]
1388    #[tokio::test]
1389    async fn test_should_reject_write_execute_overlap_through_symlink_alias() {
1390        let directory = tempfile::tempdir().unwrap();
1391        let mutable = directory.path().join("mutable");
1392        tokio::fs::create_dir(&mutable).await.unwrap();
1393        let alias = directory.path().join("alias");
1394        std::os::unix::fs::symlink(&mutable, &alias).unwrap();
1395        let mut profile =
1396            SandboxProfile::for_ecosystem(Ecosystem::Rust, directory.path(), directory.path());
1397        profile.allow_write = vec![SandboxPath::dir(mutable)];
1398        profile.allow_exec = vec![SandboxPath::dir(alias)];
1399        assert!(profile.validate_security_invariants().is_err());
1400    }
1401
1402    #[cfg(unix)]
1403    #[test]
1404    #[allow(
1405        clippy::disallowed_methods,
1406        reason = "synchronous filesystem setup is isolated to this invariant unit test"
1407    )]
1408    fn test_should_reject_hard_link_alias_outside_writable_roots() {
1409        let directory = tempfile::tempdir().unwrap();
1410        let writable = directory.path().join("writable");
1411        let protected = directory.path().join("protected");
1412        std::fs::create_dir(&writable).unwrap();
1413        std::fs::create_dir(&protected).unwrap();
1414        let workflow = protected.join("ci.yml");
1415        std::fs::write(&workflow, "protected workflow").unwrap();
1416        let alias = writable.join("workflow-alias");
1417        std::fs::hard_link(&workflow, &alias).unwrap();
1418
1419        let mut profile =
1420            SandboxProfile::for_ecosystem(Ecosystem::Rust, directory.path(), directory.path());
1421        profile.allow_write = vec![SandboxPath::dir(writable.clone())];
1422        profile.allow_exec.clear();
1423        let error = profile.validate_security_invariants().unwrap_err();
1424        assert!(format!("{error}").contains("hard links"));
1425
1426        let internal = writable.join("internal");
1427        std::fs::write(&internal, "cache entry").unwrap();
1428        let internal_alias = writable.join("internal-alias");
1429        std::fs::hard_link(&internal, internal_alias).unwrap();
1430        std::fs::remove_file(alias).unwrap();
1431        assert!(
1432            profile.validate_security_invariants().is_ok(),
1433            "hard links wholly contained in writable roots are safe"
1434        );
1435    }
1436}