1use std::{
2 collections::{HashMap, HashSet},
3 io::Read,
4 os::unix::fs::{MetadataExt, OpenOptionsExt},
5 path::{Component, Path, PathBuf},
6};
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11 error::CoreError,
12 profile::{DomainPattern, GrantKind, GrantOrigin, GrantRecord, SandboxProfile},
13};
14
15#[derive(Debug, Default, Clone, Serialize, Deserialize)]
17#[serde(rename_all = "camelCase", deny_unknown_fields)]
18pub struct SbeConfig {
19 #[serde(default)]
21 pub profiles: HashMap<String, ProfileConfig>,
22}
23
24#[derive(Debug, Default, Clone, Serialize, Deserialize)]
26#[serde(rename_all = "camelCase", deny_unknown_fields)]
27pub struct ProfileConfig {
28 #[serde(skip_serializing_if = "Option::is_none")]
30 pub extends: Option<String>,
31
32 #[serde(default)]
33 pub allow_write: Vec<String>,
34
35 #[serde(default)]
36 pub deny_read: Vec<String>,
37
38 #[serde(default)]
40 pub allow_read: Vec<String>,
41
42 #[serde(default)]
43 pub allow_domains: Vec<String>,
44
45 #[serde(default)]
47 pub deny_domains: Vec<String>,
48
49 #[serde(default)]
50 pub deny_exec: Vec<String>,
51
52 #[serde(default)]
53 pub allow_exec: Vec<String>,
54
55 #[serde(default)]
61 pub allow_fetch: Vec<String>,
62
63 #[serde(skip_serializing_if = "Option::is_none")]
65 pub allow_all_network: Option<bool>,
66
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub enable_proxy: Option<bool>,
70
71 #[serde(skip_serializing_if = "Option::is_none")]
74 pub allow_degraded: Option<bool>,
75
76 #[serde(default)]
77 pub env: HashMap<String, String>,
78}
79
80const MAX_CONFIG_BYTES: usize = 1024 * 1024;
81const MAX_PROFILES: usize = 128;
82const MAX_LIST_ITEMS: usize = 1024;
83const MAX_PATH_BYTES: usize = 4096;
84const MAX_ENV_VALUE_BYTES: usize = 8192;
85const MAX_ENV_VARS: usize = 128;
86
87const RESERVED_ENV: &[&str] = &[
88 "HTTP_PROXY",
89 "HTTPS_PROXY",
90 "NO_PROXY",
91 "http_proxy",
92 "https_proxy",
93 "no_proxy",
94 "TMPDIR",
95 "TMP",
96 "TEMP",
97 "XDG_RUNTIME_DIR",
98 "CARGO_TARGET_DIR",
99 "CARGO_BUILD_TARGET_DIR",
100 "CARGO_BUILD_BUILD_DIR",
101 "PIP_CACHE_DIR",
102 "UV_CACHE_DIR",
103 "MIX_BUILD_ROOT",
104 "MIX_DEPS_PATH",
105 "REBAR_CACHE_DIR",
106 "GRADLE_USER_HOME",
107 "COURSIER_CACHE",
108 "SBT_OPTS",
109 "JAVA_TOOL_OPTIONS",
110 "SBE_PROXY_TOKEN",
111];
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
115#[serde(rename_all = "camelCase")]
116pub enum ConfigOrigin {
117 Global,
118 Project,
119 Explicit,
120}
121
122#[derive(Debug, Clone)]
125pub struct LoadedConfig {
126 pub config: SbeConfig,
127 pub origin: ConfigOrigin,
128 pub path: PathBuf,
129 pub trusted: bool,
130}
131
132impl SbeConfig {
133 pub async fn load(path: &Path) -> Result<Option<Self>, CoreError> {
135 let owned_path = path.to_path_buf();
136 let contents = tokio::task::spawn_blocking(move || read_config_bytes(&owned_path))
137 .await
138 .map_err(|error| {
139 CoreError::Backend(format!("configuration reader failed: {error}"))
140 })??;
141 let Some(contents) = contents else {
142 return Ok(None);
143 };
144 let contents = String::from_utf8(contents).map_err(|error| CoreError::ConfigLoad {
145 path: path.to_path_buf(),
146 source: Box::new(error),
147 })?;
148 let config: Self =
149 serde_yaml::from_str(&contents).map_err(|error| CoreError::ConfigLoad {
150 path: path.to_path_buf(),
151 source: Box::new(error),
152 })?;
153 config.validate(path)?;
154 Ok(Some(config))
155 }
156
157 pub fn find_project_config(start: &Path) -> Option<PathBuf> {
160 let mut dir = start;
161 loop {
162 for name in [".sbe.yaml", ".sbe.yml"] {
163 let candidate = dir.join(name);
164 if candidate.exists() {
165 return Some(candidate);
166 }
167 }
168 if dir.join(".git").exists() {
170 return None;
171 }
172 dir = dir.parent()?;
173 }
174 }
175
176 pub fn global_config_path() -> Option<PathBuf> {
178 dirs::config_dir().map(|d| d.join("sbe/config.yaml"))
179 }
180
181 fn validate(&self, path: &Path) -> Result<(), CoreError> {
182 if self.profiles.len() > MAX_PROFILES {
183 return Err(config_policy(path, "too many profiles"));
184 }
185 for (name, profile) in &self.profiles {
186 if name.is_empty() || name.len() > 128 || name.chars().any(char::is_control) {
187 return Err(config_policy(
188 path,
189 format!("invalid profile name '{name}'"),
190 ));
191 }
192 profile.validate(path)?;
193 }
194 Ok(())
195 }
196}
197
198#[allow(clippy::disallowed_types)] fn read_config_bytes(path: &Path) -> Result<Option<Vec<u8>>, CoreError> {
200 let file = match std::fs::OpenOptions::new()
201 .read(true)
202 .custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK)
203 .open(path)
204 {
205 Ok(file) => file,
206 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
207 Err(source) => {
208 return Err(CoreError::ConfigLoad {
209 path: path.to_path_buf(),
210 source: Box::new(source),
211 });
212 }
213 };
214 let metadata = file.metadata().map_err(|source| CoreError::ConfigLoad {
215 path: path.to_path_buf(),
216 source: Box::new(source),
217 })?;
218 if !metadata.is_file() {
219 return Err(config_policy(path, "configuration is not a regular file"));
220 }
221 if metadata.len() > MAX_CONFIG_BYTES as u64 {
222 return Err(config_policy(
223 path,
224 format!(
225 "file is {} bytes; maximum is {MAX_CONFIG_BYTES}",
226 metadata.len()
227 ),
228 ));
229 }
230 let mut contents = Vec::new();
231 file.take(MAX_CONFIG_BYTES as u64 + 1)
232 .read_to_end(&mut contents)
233 .map_err(|source| CoreError::ConfigLoad {
234 path: path.to_path_buf(),
235 source: Box::new(source),
236 })?;
237 if contents.len() > MAX_CONFIG_BYTES {
238 return Err(config_policy(path, "configuration grew beyond 1 MiB"));
239 }
240 Ok(Some(contents))
241}
242
243impl ProfileConfig {
244 pub fn apply_to(
248 &self,
249 profile: &mut SandboxProfile,
250 home: &Path,
251 pwd: &Path,
252 origin: &GrantOrigin,
253 ) -> Result<(), CoreError> {
254 if !self.allow_domains.is_empty()
255 || !self.deny_domains.is_empty()
256 || !self.allow_fetch.is_empty()
257 || self.allow_all_network.is_some()
258 || self.enable_proxy.is_some()
259 {
260 profile.network_origin = origin.clone();
261 }
262 for p in &self.allow_write {
263 let path = expand_path(p, home, pwd);
264 record_path(profile, GrantKind::AllowWrite, &path, origin);
265 profile.allow_write.push(path);
266 }
267 for p in &self.deny_read {
268 let path = expand_path(p, home, pwd);
269 record_path(profile, GrantKind::DenyRead, &path, origin);
270 profile.deny_read.push(path);
271 }
272 for p in &self.allow_read {
273 let path = expand_path(p, home, pwd);
274 record_path(profile, GrantKind::AllowRead, &path, origin);
275 profile.allow_read.push(path);
276 }
277 for d in &self.allow_domains {
278 let domain = DomainPattern::new(d).map_err(CoreError::ProfileLint)?;
279 profile.grant_origins.push(GrantRecord {
280 kind: GrantKind::AllowDomain,
281 value: domain.0.clone(),
282 origin: origin.clone(),
283 });
284 profile.allow_domains.push(domain);
285 }
286 for p in &self.deny_exec {
287 profile.add_deny_exec(expand_path(p, home, pwd), origin.clone());
288 }
289 for p in &self.allow_exec {
290 profile.add_allow_exec(expand_path(p, home, pwd), origin.clone());
291 }
292 for d in &self.allow_fetch {
293 let domain = DomainPattern::new(d).map_err(CoreError::ProfileLint)?;
294 profile.grant_origins.push(GrantRecord {
295 kind: GrantKind::AllowFetch,
296 value: domain.0.clone(),
297 origin: origin.clone(),
298 });
299 profile.allow_fetch.push(domain);
300 }
301 let denied_domains = self
302 .deny_domains
303 .iter()
304 .map(|domain| DomainPattern::new(domain).map_err(CoreError::ProfileLint))
305 .collect::<Result<Vec<_>, _>>()?;
306 profile.remove_denied_domains(&denied_domains);
307 if let Some(allow_all) = self.allow_all_network {
308 profile.allow_all_network = allow_all;
309 }
310 if let Some(enable_proxy) = self.enable_proxy {
311 profile.enable_proxy = enable_proxy;
312 }
313 if let Some(allow_degraded) = self.allow_degraded {
314 profile.allow_degraded = allow_degraded;
315 }
316 for (k, v) in &self.env {
317 profile.env.insert(k.clone(), v.clone());
318 profile.grant_origins.push(GrantRecord {
319 kind: GrantKind::Environment,
320 value: k.clone(),
321 origin: origin.clone(),
322 });
323 }
324 profile.recompute_network_mode();
325 Ok(())
326 }
327
328 fn validate(&self, path: &Path) -> Result<(), CoreError> {
329 if self.allow_degraded == Some(true) {
330 return Err(config_policy(
331 path,
332 "allowDegraded in configuration is no longer accepted; use the capability-specific CLI option --allow-insecure-linux-network for one trusted invocation",
333 ));
334 }
335 let lists = [
336 ("allowWrite", &self.allow_write),
337 ("denyRead", &self.deny_read),
338 ("allowRead", &self.allow_read),
339 ("allowDomains", &self.allow_domains),
340 ("denyDomains", &self.deny_domains),
341 ("denyExec", &self.deny_exec),
342 ("allowExec", &self.allow_exec),
343 ("allowFetch", &self.allow_fetch),
344 ];
345 for (name, values) in lists {
346 if values.len() > MAX_LIST_ITEMS {
347 return Err(config_policy(path, format!("{name} has too many entries")));
348 }
349 for value in values {
350 if value.is_empty()
351 || value.len() > MAX_PATH_BYTES
352 || value.contains('\0')
353 || value.chars().any(char::is_control)
354 {
355 return Err(config_policy(path, format!("invalid value in {name}")));
356 }
357 }
358 }
359 for (name, values) in [
360 ("allowWrite", &self.allow_write),
361 ("denyRead", &self.deny_read),
362 ("allowRead", &self.allow_read),
363 ("denyExec", &self.deny_exec),
364 ("allowExec", &self.allow_exec),
365 ] {
366 for value in values {
367 let without_directory_marker = value.strip_suffix('/').unwrap_or(value);
368 if Path::new(without_directory_marker)
369 .components()
370 .any(|component| component == Component::ParentDir)
371 {
372 return Err(config_policy(
373 path,
374 format!("{name} path must not contain '..'"),
375 ));
376 }
377 }
378 }
379 for domain in self
380 .allow_domains
381 .iter()
382 .chain(&self.deny_domains)
383 .chain(&self.allow_fetch)
384 {
385 DomainPattern::new(domain).map_err(|reason| config_policy(path, reason))?;
386 }
387 if self.env.len() > MAX_ENV_VARS {
388 return Err(config_policy(path, "too many environment variables"));
389 }
390 for (name, value) in &self.env {
391 if !is_valid_env_name(name) || RESERVED_ENV.contains(&name.as_str()) {
392 return Err(config_policy(
393 path,
394 format!("invalid or reserved environment variable '{name}'"),
395 ));
396 }
397 if value.len() > MAX_ENV_VALUE_BYTES || value.contains('\0') {
398 return Err(config_policy(
399 path,
400 format!("invalid value for environment variable '{name}'"),
401 ));
402 }
403 }
404 Ok(())
405 }
406
407 fn validate_untrusted_project(&self, path: &Path) -> Result<(), CoreError> {
408 let expands_authority = self.extends.is_some()
409 || !self.allow_write.is_empty()
410 || !self.allow_read.is_empty()
411 || !self.allow_domains.is_empty()
412 || !self.allow_exec.is_empty()
413 || !self.allow_fetch.is_empty()
414 || !self.env.is_empty()
415 || self.allow_all_network == Some(true)
416 || self.enable_proxy == Some(false)
417 || self.allow_degraded == Some(true);
418 if expands_authority {
419 return Err(config_policy(
420 path,
421 "project configuration may only add denyRead/denyExec or explicitly disable allowAllNetwork/allowDegraded; pass --trust-project-config to authorize expansion",
422 ));
423 }
424 Ok(())
425 }
426}
427
428fn record_path(
429 profile: &mut SandboxProfile,
430 kind: GrantKind,
431 path: &SandboxPath,
432 origin: &GrantOrigin,
433) {
434 profile.grant_origins.push(GrantRecord {
435 kind,
436 value: path.path.to_string_lossy().into_owned(),
437 origin: origin.clone(),
438 });
439}
440
441fn is_valid_env_name(name: &str) -> bool {
442 let mut bytes = name.bytes();
443 matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_'))
444 && bytes.all(|b| b.is_ascii_alphanumeric() || b == b'_')
445}
446
447fn config_policy(path: &Path, reason: impl Into<String>) -> CoreError {
448 CoreError::ConfigPolicy {
449 path: path.to_path_buf(),
450 reason: reason.into(),
451 }
452}
453
454#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
456#[serde(rename_all = "camelCase")]
457pub enum PathKind {
458 Subpath,
460 Literal,
462 Regex,
465}
466
467#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474pub struct SandboxPath {
475 pub path: PathBuf,
476 pub kind: PathKind,
477}
478
479impl SandboxPath {
480 pub fn dir(path: PathBuf) -> Self {
481 Self {
482 path,
483 kind: PathKind::Subpath,
484 }
485 }
486
487 pub fn file(path: PathBuf) -> Self {
488 Self {
489 path,
490 kind: PathKind::Literal,
491 }
492 }
493
494 pub fn regex(pattern: PathBuf) -> Self {
496 Self {
497 path: pattern,
498 kind: PathKind::Regex,
499 }
500 }
501
502 pub fn has_path(&self, path: &Path) -> bool {
504 self.path == path
505 }
506}
507
508impl std::fmt::Display for SandboxPath {
509 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510 self.path.display().fmt(f)
511 }
512}
513
514pub fn expand_path(raw: &str, home: &Path, pwd: &Path) -> SandboxPath {
520 let kind = if raw.ends_with('/') {
521 PathKind::Subpath
522 } else {
523 PathKind::Literal
524 };
525 let raw = raw.strip_suffix('/').unwrap_or(raw);
526
527 let path = if raw == "$PWD" {
528 pwd.to_path_buf()
529 } else if let Some(rest) = raw.strip_prefix("$PWD/") {
530 pwd.join(rest)
531 } else if raw == "$HOME" {
532 home.to_path_buf()
533 } else if let Some(rest) = raw.strip_prefix("$HOME/") {
534 home.join(rest)
535 } else if let Some(rest) = raw.strip_prefix("~/") {
536 home.join(rest)
537 } else if raw == "~" {
538 home.to_path_buf()
539 } else if let Some(rest) = raw.strip_prefix("./") {
540 pwd.join(rest)
541 } else if raw == "." {
542 pwd.to_path_buf()
543 } else if raw.starts_with('/') {
544 PathBuf::from(raw)
545 } else {
546 pwd.join(raw)
547 };
548
549 SandboxPath { path, kind }
550}
551
552pub async fn load_configs(
562 pwd: &Path,
563 explicit_config: Option<&Path>,
564 trust_project_config: bool,
565) -> Result<Vec<LoadedConfig>, CoreError> {
566 let mut configs = Vec::new();
567 let explicit_path = explicit_config.map(|path| {
568 if path.is_absolute() {
569 path.to_path_buf()
570 } else {
571 pwd.join(path)
572 }
573 });
574
575 if let Some(global_path) = SbeConfig::global_config_path()
577 && let Some(cfg) = SbeConfig::load(&global_path).await?
578 {
579 configs.push(LoadedConfig {
580 config: cfg,
581 origin: ConfigOrigin::Global,
582 path: global_path,
583 trusted: true,
584 });
585 }
586
587 if let Some(project_path) = SbeConfig::find_project_config(pwd) {
589 let selected_explicitly = if let Some(explicit) = &explicit_path {
590 same_configuration_file(&project_path, explicit).await
591 } else {
592 false
593 };
594 if !selected_explicitly && let Some(cfg) = SbeConfig::load(&project_path).await? {
595 configs.push(LoadedConfig {
596 config: cfg,
597 origin: ConfigOrigin::Project,
598 path: project_path,
599 trusted: trust_project_config,
600 });
601 }
602 }
603
604 if let Some(explicit) = explicit_path
606 && let Some(cfg) = SbeConfig::load(&explicit).await?
607 {
608 configs.push(LoadedConfig {
609 config: cfg,
610 origin: ConfigOrigin::Explicit,
611 path: explicit,
612 trusted: true,
613 });
614 }
615
616 Ok(configs)
617}
618
619async fn same_configuration_file(left: &Path, right: &Path) -> bool {
624 if left == right {
625 return true;
626 }
627 let (left_metadata, right_metadata) =
628 tokio::join!(tokio::fs::metadata(left), tokio::fs::metadata(right));
629 matches!(
630 (left_metadata, right_metadata),
631 (Ok(left), Ok(right)) if left.dev() == right.dev() && left.ino() == right.ino()
632 )
633}
634
635pub fn resolve_profile(
637 base: &mut SandboxProfile,
638 configs: &[LoadedConfig],
639 home: &Path,
640 pwd: &Path,
641) -> Result<(), CoreError> {
642 let profile_name = base.name.clone();
643
644 for loaded in configs {
645 let config = &loaded.config;
646 if let Some(pc) = config.profiles.get(&profile_name) {
648 if loaded.origin == ConfigOrigin::Project && !loaded.trusted {
649 pc.validate_untrusted_project(&loaded.path)?;
650 }
651 let grant_origin = match &loaded.origin {
652 ConfigOrigin::Global => GrantOrigin::Global(loaded.path.clone()),
653 ConfigOrigin::Project => GrantOrigin::Project(loaded.path.clone()),
654 ConfigOrigin::Explicit => GrantOrigin::Explicit(loaded.path.clone()),
655 };
656 apply_profile_recursive(
657 &profile_name,
658 config,
659 base,
660 home,
661 pwd,
662 &grant_origin,
663 &mut HashSet::new(),
664 )?;
665 }
666 }
667 base.recompute_network_mode();
668 Ok(())
669}
670
671fn apply_profile_recursive(
672 name: &str,
673 config: &SbeConfig,
674 profile: &mut SandboxProfile,
675 home: &Path,
676 pwd: &Path,
677 origin: &GrantOrigin,
678 visiting: &mut HashSet<String>,
679) -> Result<(), CoreError> {
680 if !visiting.insert(name.to_owned()) {
681 return Err(CoreError::ProfileLint(format!(
682 "cyclic profile extends involving '{name}'"
683 )));
684 }
685 let pc = config
686 .profiles
687 .get(name)
688 .ok_or_else(|| CoreError::UnknownBaseProfile {
689 child: profile.name.clone(),
690 base: name.to_owned(),
691 })?;
692 if let Some(parent) = &pc.extends {
693 if !config.profiles.contains_key(parent) {
694 return Err(CoreError::UnknownBaseProfile {
695 child: name.to_owned(),
696 base: parent.clone(),
697 });
698 }
699 apply_profile_recursive(parent, config, profile, home, pwd, origin, visiting)?;
700 }
701 pc.apply_to(profile, home, pwd, origin)?;
702 visiting.remove(name);
703 Ok(())
704}
705
706#[cfg(test)]
707mod tests {
708 use super::*;
709
710 #[test]
711 fn test_should_expand_home_path() {
712 let home = PathBuf::from("/Users/test");
713 let pwd = PathBuf::from("/Users/test/project");
714 let sp = expand_path("~/.ssh/", &home, &pwd);
715 assert_eq!(sp.path, PathBuf::from("/Users/test/.ssh"));
716 assert_eq!(sp.kind, PathKind::Subpath);
717 }
718
719 #[test]
720 fn test_should_expand_relative_path() {
721 let home = PathBuf::from("/Users/test");
722 let pwd = PathBuf::from("/Users/test/project");
723 let sp = expand_path("./node_modules/", &home, &pwd);
724 assert_eq!(sp.path, PathBuf::from("/Users/test/project/node_modules"));
725 assert_eq!(sp.kind, PathKind::Subpath);
726 }
727
728 #[test]
729 fn test_should_keep_absolute_path_as_file() {
730 let home = PathBuf::from("/Users/test");
731 let pwd = PathBuf::from("/Users/test/project");
732 let sp = expand_path("/usr/bin/osascript", &home, &pwd);
733 assert_eq!(sp.path, PathBuf::from("/usr/bin/osascript"));
734 assert_eq!(sp.kind, PathKind::Literal);
735 }
736
737 #[test]
738 fn test_should_detect_dir_from_trailing_slash() {
739 let home = PathBuf::from("/Users/test");
740 let pwd = PathBuf::from("/Users/test/project");
741
742 let dir = expand_path("~/.cargo/bin/", &home, &pwd);
743 assert_eq!(dir.kind, PathKind::Subpath);
744
745 let file = expand_path("~/.cargo/credentials.toml", &home, &pwd);
746 assert_eq!(file.kind, PathKind::Literal);
747
748 let pwd_dir = expand_path("$PWD/", &home, &pwd);
749 assert_eq!(pwd_dir.kind, PathKind::Subpath);
750 }
751
752 #[test]
753 fn test_should_parse_config_yaml() {
754 let yaml = r#"
755profiles:
756 node:
757 allowWrite:
758 - "./node_modules"
759 - "~/.npm"
760 denyRead:
761 - "~/.ssh"
762 allowDomains:
763 - "registry.npmjs.org"
764 env:
765 NODE_ENV: production
766 my-app:
767 extends: node
768 allowDomains:
769 - "api.mycompany.com"
770"#;
771 let config: SbeConfig = serde_yaml::from_str(yaml).unwrap();
772 assert_eq!(config.profiles.len(), 2);
773 assert_eq!(config.profiles["node"].allow_write.len(), 2);
774 assert_eq!(config.profiles["my-app"].extends.as_deref(), Some("node"));
775 }
776
777 #[test]
778 fn test_should_apply_profile_config() {
779 let home = PathBuf::from("/Users/test");
780 let pwd = PathBuf::from("/Users/test/project");
781 let pc = ProfileConfig {
782 allow_write: vec!["./extra".to_owned()],
783 allow_domains: vec!["extra.com".to_owned()],
784 ..Default::default()
785 };
786 let mut profile =
787 SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
788 let original_write = profile.allow_write.len();
789 let original_domains = profile.allow_domains.len();
790
791 pc.apply_to(
792 &mut profile,
793 &home,
794 &pwd,
795 &GrantOrigin::Explicit(PathBuf::from("test.yaml")),
796 )
797 .unwrap();
798
799 assert_eq!(profile.allow_write.len(), original_write + 1);
800 assert_eq!(profile.allow_domains.len(), original_domains + 1);
801 }
802
803 #[test]
804 fn profile_config_denials_cover_wildcards_and_same_layer_fetches() {
805 let home = PathBuf::from("/Users/test");
806 let pwd = PathBuf::from("/Users/test/project");
807 let pc = ProfileConfig {
808 allow_domains: vec!["*.example.com".to_owned()],
809 deny_domains: vec!["bad.example.com".to_owned()],
810 allow_fetch: vec!["bad.example.com".to_owned()],
811 ..Default::default()
812 };
813 let mut profile =
814 SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
815
816 pc.apply_to(
817 &mut profile,
818 &home,
819 &pwd,
820 &GrantOrigin::Explicit(PathBuf::from("test.yaml")),
821 )
822 .unwrap();
823 profile.finalize();
824
825 assert!(profile.allow_fetch.is_empty());
826 assert!(
827 !profile
828 .allow_domains
829 .iter()
830 .any(|domain| domain.matches("bad.example.com"))
831 );
832 }
833
834 #[test]
835 fn test_should_reject_unknown_config_fields() {
836 let yaml = "profiles:\n node:\n allowNetwrok: true\n";
837 assert!(serde_yaml::from_str::<SbeConfig>(yaml).is_err());
838 }
839
840 #[tokio::test]
841 async fn test_should_refuse_symlinked_configuration() {
842 let directory = tempfile::tempdir().unwrap();
843 let target = directory.path().join("target.yaml");
844 tokio::fs::write(&target, "profiles: {}\n").await.unwrap();
845 let link = directory.path().join(".sbe.yaml");
846 std::os::unix::fs::symlink(target, &link).unwrap();
847 assert!(SbeConfig::load(&link).await.is_err());
848 }
849
850 #[tokio::test]
851 async fn test_should_reject_oversized_configuration() {
852 let directory = tempfile::tempdir().unwrap();
853 let path = directory.path().join("large.yaml");
854 tokio::fs::write(&path, vec![b' '; MAX_CONFIG_BYTES + 1])
855 .await
856 .unwrap();
857 assert!(SbeConfig::load(&path).await.is_err());
858 }
859
860 #[tokio::test]
861 async fn explicit_project_config_is_loaded_once_as_trusted() {
862 let project = tempfile::tempdir().unwrap();
863 let path = project.path().join(".sbe.yaml");
864 tokio::fs::write(
865 &path,
866 "profiles:\n rust:\n allowWrite:\n - '$PWD/generated/'\n",
867 )
868 .await
869 .unwrap();
870
871 let configs = load_configs(project.path(), Some(Path::new(".sbe.yaml")), false)
872 .await
873 .unwrap();
874 let selected: Vec<_> = configs
875 .iter()
876 .filter(|config| config.path == path)
877 .collect();
878 assert_eq!(selected.len(), 1);
879 assert_eq!(selected[0].origin, ConfigOrigin::Explicit);
880 assert!(selected[0].trusted);
881 }
882
883 #[tokio::test]
884 async fn configuration_identity_recognizes_hard_links() {
885 let directory = tempfile::tempdir().unwrap();
886 let original = directory.path().join("original.yaml");
887 let alias = directory.path().join("alias.yaml");
888 tokio::fs::write(&original, "profiles: {}\n").await.unwrap();
889 tokio::fs::hard_link(&original, &alias).await.unwrap();
890
891 assert!(same_configuration_file(&original, &alias).await);
892 }
893
894 #[test]
895 fn test_should_reject_reserved_environment_variable() {
896 let yaml = r#"
897profiles:
898 rust:
899 env:
900 HTTPS_PROXY: http://attacker.invalid
901"#;
902 let config: SbeConfig = serde_yaml::from_str(yaml).unwrap();
903 assert!(config.validate(Path::new("config.yaml")).is_err());
904 }
905
906 #[test]
907 fn test_should_reject_parent_traversal_in_policy_paths() {
908 let yaml = r#"
909profiles:
910 rust:
911 denyRead:
912 - "$PWD/output/../.ssh/"
913"#;
914 let config: SbeConfig = serde_yaml::from_str(yaml).unwrap();
915 assert!(config.validate(Path::new("config.yaml")).is_err());
916 }
917
918 #[test]
919 fn test_should_reject_untrusted_project_expansion() {
920 let home = PathBuf::from("/home/test");
921 let pwd = PathBuf::from("/work/project");
922 let mut config = SbeConfig::default();
923 config.profiles.insert(
924 "node".to_owned(),
925 ProfileConfig {
926 allow_all_network: Some(true),
927 allow_write: vec!["$HOME/".to_owned()],
928 ..ProfileConfig::default()
929 },
930 );
931 let loaded = LoadedConfig {
932 config,
933 origin: ConfigOrigin::Project,
934 path: pwd.join(".sbe.yaml"),
935 trusted: false,
936 };
937 let mut profile =
938 SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
939 assert!(resolve_profile(&mut profile, &[loaded], &home, &pwd).is_err());
940 }
941
942 #[test]
943 fn test_should_allow_untrusted_project_restrictions() {
944 let home = PathBuf::from("/home/test");
945 let pwd = PathBuf::from("/work/project");
946 let mut config = SbeConfig::default();
947 config.profiles.insert(
948 "node".to_owned(),
949 ProfileConfig {
950 deny_exec: vec!["/usr/bin/git".to_owned()],
951 deny_domains: vec!["registry.npmjs.org".to_owned()],
952 allow_all_network: Some(false),
953 ..ProfileConfig::default()
954 },
955 );
956 let loaded = LoadedConfig {
957 config,
958 origin: ConfigOrigin::Project,
959 path: pwd.join(".sbe.yaml"),
960 trusted: false,
961 };
962 let mut profile =
963 SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
964 resolve_profile(&mut profile, &[loaded], &home, &pwd).unwrap();
965 assert!(
966 profile
967 .deny_exec
968 .iter()
969 .any(|path| path.path == Path::new("/usr/bin/git"))
970 );
971 assert!(
972 !profile
973 .allow_exec
974 .iter()
975 .any(|path| path.path == Path::new("/usr/bin/git"))
976 );
977 assert!(
978 !profile
979 .allow_domains
980 .iter()
981 .any(|domain| domain.0 == "registry.npmjs.org")
982 );
983 }
984
985 #[test]
986 fn test_should_reject_cyclic_extends() {
987 let home = PathBuf::from("/home/test");
988 let pwd = PathBuf::from("/work/project");
989 let mut config = SbeConfig::default();
990 config.profiles.insert(
991 "node".to_owned(),
992 ProfileConfig {
993 extends: Some("base".to_owned()),
994 ..ProfileConfig::default()
995 },
996 );
997 config.profiles.insert(
998 "base".to_owned(),
999 ProfileConfig {
1000 extends: Some("node".to_owned()),
1001 ..ProfileConfig::default()
1002 },
1003 );
1004 let loaded = LoadedConfig {
1005 config,
1006 origin: ConfigOrigin::Explicit,
1007 path: pwd.join("policy.yaml"),
1008 trusted: true,
1009 };
1010 let mut profile =
1011 SandboxProfile::for_ecosystem(crate::detect::Ecosystem::Node, &home, &pwd);
1012 assert!(resolve_profile(&mut profile, &[loaded], &home, &pwd).is_err());
1013 }
1014}