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#[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#[derive(Debug, Clone, PartialEq, Eq, Hash)]
54pub struct DomainPattern(pub String);
55
56impl DomainPattern {
57 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 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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
147#[serde(rename_all = "camelCase")]
148pub enum NetworkMode {
149 DenyAll,
151 Proxy,
153 DirectHttps443,
155 AllowAll,
157}
158
159#[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#[derive(Debug, Clone, Serialize, Deserialize)]
206#[serde(rename_all = "camelCase")]
207pub struct SandboxProfile {
208 pub name: String,
210
211 #[serde(default)]
213 pub allow_write: Vec<SandboxPath>,
214
215 #[serde(default)]
223 pub deny_read: Vec<SandboxPath>,
224
225 #[serde(default)]
232 pub allow_read: Vec<SandboxPath>,
233
234 #[serde(default)]
236 pub allow_domains: Vec<DomainPattern>,
237
238 #[serde(default)]
240 pub deny_exec: Vec<SandboxPath>,
241
242 #[serde(default)]
244 pub allow_exec: Vec<SandboxPath>,
245
246 #[serde(skip)]
248 pub enable_proxy: bool,
249
250 #[serde(skip)]
252 pub allow_all_network: bool,
253
254 pub network_mode: NetworkMode,
257
258 pub network_origin: GrantOrigin,
261
262 #[serde(default)]
267 pub allow_fetch: Vec<DomainPattern>,
268
269 #[serde(default)]
271 pub env: HashMap<String, String>,
272
273 #[serde(default)]
276 pub allow_degraded: bool,
277
278 #[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 #[serde(skip)]
294 pub ephemeral_write_exec: Vec<PathBuf>,
295
296 pub grant_origins: Vec<GrantRecord>,
298}
299
300impl SandboxProfile {
301 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 #[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 #[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 let deny_read: Vec<SandboxPath> = common
335 .deny_read
336 .iter()
337 .map(|p| expand_path(p, home, pwd))
338 .collect();
339
340 let allow_write: Vec<SandboxPath> = eco_cfg
342 .allow_write
343 .iter()
344 .map(|p| expand_path(p, home, pwd))
345 .collect();
346
347 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 #[cfg(target_os = "macos")]
365 resolve_symlinks(&mut allow_exec);
366
367 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 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 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 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 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 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 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 #[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 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 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)] fn 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#[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 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 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#[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#[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 #[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 #[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 #[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 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 #[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}