1use std::path::{Path, PathBuf};
31use std::sync::OnceLock;
32
33use serde::{Deserialize, Serialize};
34
35use crate::verify::process::{CommandRunner, Invocation, LocalRunner, Outcome};
36
37#[cfg(any(target_os = "macos", target_os = "linux"))]
38mod denials;
39#[cfg(any(target_os = "macos", target_os = "linux"))]
40pub mod procage;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(rename_all = "lowercase")]
45pub enum Network {
46 Off,
48 Loopback,
51 On,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum Mode {
58 Auto,
60 Required,
62 Off,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct Config {
69 pub mode: Mode,
71 pub network: Network,
73 pub network_allow: Vec<Vec<String>>,
75 pub network_allow_prefix: Vec<Vec<String>>,
77 pub writable: Vec<String>,
79}
80
81impl Default for Config {
82 fn default() -> Self {
83 Self {
84 mode: Mode::Auto,
85 network: Network::Loopback,
86 network_allow: Vec::new(),
87 network_allow_prefix: Vec::new(),
88 writable: Vec::new(),
89 }
90 }
91}
92
93#[derive(Debug, Default, Deserialize)]
94#[serde(deny_unknown_fields)]
95struct Raw {
96 mode: Option<String>,
97 network: Option<String>,
98 #[serde(default)]
99 network_allow: Vec<Vec<String>>,
100 #[serde(default)]
101 network_allow_prefix: Vec<Vec<String>>,
102 #[serde(default)]
103 writable: Vec<String>,
104}
105
106pub fn parse(text: &str) -> Result<Config, String> {
108 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
109 let Some(v) = table.get("sandbox") else {
110 return Ok(Config::default());
111 };
112 let raw: Raw = v
113 .clone()
114 .try_into()
115 .map_err(|e| format!("[sandbox]: {e}"))?;
116 let mode = match raw.mode.as_deref() {
117 None | Some("auto") => Mode::Auto,
118 Some("required") => Mode::Required,
119 Some("off") => Mode::Off,
120 Some(o) => return Err(format!("[sandbox] mode = \"{o}\" (auto, required or off)")),
121 };
122 let network = match raw.network.as_deref() {
123 None | Some("loopback") => Network::Loopback,
124 Some("off") => Network::Off,
125 Some("on") => Network::On,
126 Some(o) => return Err(format!("[sandbox] network = \"{o}\" (off, loopback or on)")),
127 };
128 if let Some(why) = raw
129 .network_allow_prefix
130 .iter()
131 .find_map(|p| crate::tools::policy::prefix_refusal(p))
132 {
133 return Err(format!("[sandbox] network_allow_prefix: {why}"));
134 }
135 Ok(Config {
136 mode,
137 network,
138 network_allow: raw
139 .network_allow
140 .into_iter()
141 .filter(|p| !p.is_empty())
142 .collect(),
143 network_allow_prefix: raw.network_allow_prefix,
144 writable: raw.writable,
145 })
146}
147
148pub const HIDDEN: &[&str] = &[
150 ".ssh",
151 ".aws",
152 ".azure",
153 ".gnupg",
154 ".netrc",
155 ".docker",
156 ".kube",
157 ".npmrc",
158 ".pypirc",
159 ".git-credentials",
160 ".password-store",
161 ".config/gh",
162 ".config/gcloud",
163 ".config/hub",
164 ".cargo/credentials",
165 ".cargo/credentials.toml",
166 ".local/share/keyrings",
167 "Library/Keychains",
168];
169
170pub const CACHES: &[&str] = &[
173 ".cargo/registry",
174 ".cargo/git",
175 ".cargo/.package-cache",
176 ".cargo/.package-cache-mutate",
177 ".cargo/.global-cache",
178 ".cache",
179 ".npm",
180 ".pnpm-store",
181 ".yarn/berry/cache",
182 "go/pkg/mod",
183 "Library/Caches",
184];
185
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(deny_unknown_fields)]
189pub struct Profile {
190 pub writable: Vec<PathBuf>,
192 pub hidden: Vec<PathBuf>,
194 pub read_only: Vec<PathBuf>,
197 pub network: Network,
199 pub tmp: PathBuf,
201 #[serde(default)]
204 pub unenforced: Unenforced,
205}
206
207#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
212#[serde(deny_unknown_fields, default)]
213pub struct Unenforced {
214 pub trust_handoff: bool,
216 pub workspace_sockets: bool,
218 pub signal_scope: bool,
221}
222
223fn canonical(p: &Path) -> PathBuf {
224 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
225}
226
227impl Profile {
228 #[must_use]
230 pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
231 let root = canonical(root);
232 let expand = |p: &str| -> Option<PathBuf> {
233 match (p.strip_prefix("~/"), home) {
234 (Some(rest), Some(h)) => Some(h.join(rest)),
235 (Some(_), None) => None,
236 (None, _) => Some(PathBuf::from(p)),
237 }
238 };
239 let home = home.map(canonical);
240 let home = home.as_deref();
241 let mut writable = vec![root.clone()];
242 writable.extend(
243 home.into_iter()
244 .flat_map(|h| CACHES.iter().map(move |c| h.join(c))),
245 );
246 writable.extend(
247 config
248 .writable
249 .iter()
250 .filter_map(|p| expand(p))
251 .map(|p| canonical(&p)),
252 );
253 let hidden = home
254 .into_iter()
255 .flat_map(|h| HIDDEN.iter().map(move |c| h.join(c)))
256 .collect();
257 let digest = {
258 use sha2::{Digest as _, Sha256};
259 let d = Sha256::digest(root.to_string_lossy().as_bytes());
260 d.iter()
261 .take(6)
262 .map(|b| format!("{b:02x}"))
263 .collect::<String>()
264 };
265 let tmp = canonical(&std::env::temp_dir()).join(format!("speccoder-{digest}"));
267 Self {
268 writable,
269 hidden,
270 read_only: Vec::new(),
271 network: config.network,
272 tmp,
273 unenforced: Unenforced::default(),
274 }
275 }
276
277 #[must_use]
280 pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
281 self.read_only
282 .extend(read_only.iter().map(|p| canonical(p)));
283 self.hidden.extend(hidden.iter().map(|p| canonical(p)));
284 self
285 }
286
287 #[must_use]
289 pub fn with_network(mut self, network: Network) -> Self {
290 self.network = network;
291 self
292 }
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
300pub struct Backend {
301 pub helper: Option<PathBuf>,
303}
304
305impl Backend {
306 #[must_use]
309 pub fn mechanism(&self) -> &'static str {
310 if cfg!(target_os = "macos") {
311 "Seatbelt"
312 } else {
313 "Landlock+seccomp"
314 }
315 }
316}
317
318pub const HELPER_ARG: &str = "__sandbox";
320
321pub fn backend() -> Result<Backend, String> {
323 static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
324 CACHED.get_or_init(detect).clone()
325}
326
327fn detect() -> Result<Backend, String> {
328 available()?;
329 Ok(Backend {
330 helper: helper_binary(),
331 })
332}
333
334pub fn available() -> Result<(), String> {
340 #[cfg(any(target_os = "macos", target_os = "linux"))]
341 return procage::available();
342 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
343 Err(format!("no sandbox for {}", std::env::consts::OS))
344}
345
346fn helper_binary() -> Option<PathBuf> {
349 std::env::var_os("SPECCODER_SANDBOX_HELPER")
350 .map(PathBuf::from)
351 .or_else(|| {
352 std::env::current_exe()
353 .ok()
354 .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
355 })
356}
357
358pub fn fit(profile: &Profile) -> Result<(Profile, Option<String>), String> {
365 #[cfg(any(target_os = "macos", target_os = "linux"))]
366 return procage::fit(profile);
367 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
368 {
369 let _ = profile;
370 Err(format!("no sandbox for {}", std::env::consts::OS))
371 }
372}
373
374pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
376 let helper = backend.helper.as_ref().ok_or_else(|| {
378 "servers start through the speccoder binary, which was not found (SPECCODER_SANDBOX_HELPER)"
379 .to_owned()
380 })?;
381 let mut out = vec![
382 helper.to_string_lossy().into_owned(),
383 HELPER_ARG.to_owned(),
384 serde_json::to_string(p).map_err(|e| e.to_string())?,
385 "--".to_owned(),
386 ];
387 out.extend_from_slice(argv);
388 Ok(out)
389}
390
391#[derive(Debug, Clone)]
393pub struct Sandboxed {
394 pub profile: Profile,
396 pub backend: Backend,
398 pub network_allow: Vec<Vec<String>>,
400 pub network_allow_prefix: Vec<Vec<String>>,
402}
403
404impl Sandboxed {
405 #[must_use]
408 pub fn network_for(&self, argv: &[String]) -> Network {
409 if self.network_allow.iter().any(|p| p.as_slice() == argv)
410 || self
411 .network_allow_prefix
412 .iter()
413 .any(|p| crate::tools::policy::prefix_matches(p, argv))
414 {
415 Network::On
416 } else {
417 self.profile.network
418 }
419 }
420
421 #[must_use]
423 pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
424 let profile = self.profile.clone().with_network(network);
425 let failed = |e: String| Outcome {
426 exit_code: None,
427 timed_out: false,
428 spawn_error: Some(e),
429 started: std::time::SystemTime::now(),
430 duration: std::time::Duration::ZERO,
431 stdout: crate::verify::process::Captured::default(),
432 stderr: crate::verify::process::Captured::default(),
433 sandbox: Default::default(),
434 };
435 if let Some(program) = inv.argv.first()
438 && !resolvable(program, &inv.cwd, &inv.env)
439 {
440 return failed(format!(
441 "could not start `{program}`: No such file or directory"
442 ));
443 }
444 if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
445 return failed(format!("the sandbox's temporary directory: {e}"));
446 }
447 let tmp = profile.tmp.to_string_lossy().into_owned();
448 let mut env: Vec<(String, String)> = inv
449 .env
450 .iter()
451 .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
452 .cloned()
453 .collect();
454 env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
455 #[cfg(any(target_os = "macos", target_os = "linux"))]
456 return procage::run(&profile, &Invocation { env, ..inv.clone() });
457 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
458 {
459 let _ = env;
460 failed(format!("no sandbox for {}", std::env::consts::OS))
461 }
462 }
463}
464
465#[must_use]
472pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
473 let candidate = |p: PathBuf| {
474 executable_file(&p)
475 .then(|| std::fs::canonicalize(&p).ok())
476 .flatten()
477 };
478 if program.is_empty() {
479 return None;
480 }
481 if program.contains('/') {
482 return candidate(cwd.join(program));
483 }
484 let path = env
485 .iter()
486 .find(|(k, _)| k == "PATH")
487 .map(|(_, v)| v.as_str());
488 path.unwrap_or("/usr/bin:/bin")
489 .split(':')
490 .filter(|d| !d.is_empty())
491 .find_map(|d| candidate(cwd.join(d).join(program)))
492}
493
494fn executable_file(p: &Path) -> bool {
496 #[cfg(unix)]
497 {
498 use std::os::unix::fs::PermissionsExt as _;
499 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
500 }
501 #[cfg(not(unix))]
502 {
503 p.is_file()
504 }
505}
506
507fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
509 resolve_executable(program, cwd, env).is_some()
510}
511
512impl CommandRunner for Sandboxed {
513 fn run(&self, inv: &Invocation) -> Outcome {
514 self.run_with_network(inv, self.network_for(&inv.argv))
515 }
516}
517
518pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
522 if config.mode == Mode::Off {
523 return Ok((
524 Runner::Plain,
525 Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
526 ));
527 }
528 let home = std::env::var_os("HOME").map(PathBuf::from);
529 let profile = Profile::new(root, config, home.as_deref());
530 match backend().and_then(|backend| Ok((backend, fit(&profile)?))) {
533 Ok((backend, (profile, notice))) => Ok((
534 Runner::Sandboxed(Sandboxed {
535 profile,
536 backend,
537 network_allow: config.network_allow.clone(),
538 network_allow_prefix: config.network_allow_prefix.clone(),
539 }),
540 notice,
541 )),
542 Err(e) if config.mode == Mode::Required => Err(format!(
543 "[sandbox] mode = \"required\", but there is no sandbox: {e}"
544 )),
545 Err(e) => Ok((
546 Runner::Plain,
547 Some(format!("commands run unsandboxed: {e}")),
548 )),
549 }
550}
551
552pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
554 let text = crate::verify::find_config(root)
555 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
556 .unwrap_or_default();
557 let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
558 runner(root, &config)
559}
560
561impl Runner {
562 #[must_use]
566 pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
567 match self {
568 Self::Sandboxed(mut s) => {
569 let carved = s.profile.clone().carve(read_only, hidden);
570 match fit(&carved) {
571 Ok((fitted, notice)) => {
572 s.profile = fitted;
573 (Self::Sandboxed(s), notice)
574 }
575 Err(e) => (
576 Self::Sandboxed(s),
577 Some(format!(
578 "the sandbox cannot carve here ({e}): commands can write `.git` and read ignored secrets in place; the run stops if `.git` changes"
579 )),
580 ),
581 }
582 }
583 Self::Plain => (Self::Plain, None),
584 }
585 }
586}
587
588#[derive(Debug, Clone)]
590pub enum Runner {
591 Sandboxed(Sandboxed),
593 Plain,
595}
596
597impl Runner {
598 #[must_use]
600 pub fn is_sandboxed(&self) -> bool {
601 matches!(self, Self::Sandboxed(_))
602 }
603
604 #[must_use]
609 pub fn with_trust_handoff_writable(self) -> Self {
610 match self {
611 Self::Sandboxed(mut s) => {
612 s.profile.unenforced.trust_handoff = true;
613 Self::Sandboxed(s)
614 }
615 Self::Plain => Self::Plain,
616 }
617 }
618
619 #[must_use]
621 pub fn with_writable(self, dir: &Path) -> Self {
622 match self {
623 Self::Sandboxed(mut s) => {
624 s.profile.writable.push(canonical(dir));
625 Self::Sandboxed(s)
626 }
627 Self::Plain => Self::Plain,
628 }
629 }
630
631 #[must_use]
634 pub fn allows_network(&self, argv: &[String]) -> bool {
635 matches!(self, Self::Sandboxed(s) if s.network_for(argv) == Network::On)
636 }
637
638 #[must_use]
640 pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
641 match self {
642 Self::Plain => LocalRunner.run(inv),
643 Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
644 Self::Sandboxed(s) => s.run(inv),
645 }
646 }
647
648 #[must_use]
651 pub fn scratch(&self) -> Option<PathBuf> {
652 match self {
653 Self::Plain => None,
654 Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
655 .ok()
656 .map(|()| s.profile.tmp.clone()),
657 }
658 }
659
660 pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
662 match self {
663 Self::Plain => Ok((argv.to_vec(), None)),
664 Self::Sandboxed(s) => {
665 let profile = s.profile.clone().with_network(s.network_for(argv));
666 std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
667 Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
668 }
669 }
670 }
671}
672
673impl CommandRunner for Runner {
674 fn run(&self, inv: &Invocation) -> Outcome {
675 self.run_with(inv, false)
676 }
677}
678
679pub fn helper_main(args: &[String]) -> String {
682 let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
683 return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
684 };
685 let profile: Profile = match serde_json::from_str(profile) {
686 Ok(p) => p,
687 Err(e) => return format!("the sandbox profile: {e}"),
688 };
689 let argv = &args[2..];
690 if argv.is_empty() {
691 return "nothing to run".to_owned();
692 }
693 #[cfg(any(target_os = "macos", target_os = "linux"))]
694 return procage::exec(&profile, argv);
695 #[cfg(not(any(target_os = "macos", target_os = "linux")))]
696 format!("no sandbox for {}", std::env::consts::OS)
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702
703 #[test]
704 fn config_parses_and_defaults_to_loopback() {
705 assert_eq!(parse("").unwrap_or_default(), Config::default());
706 let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
707 .unwrap_or_default();
708 assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
709 assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
710 }
711
712 #[test]
713 fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
714 let home = Path::new("/home/u");
715 let p = Profile::new(
716 Path::new("/s/proj/worktrees/001"),
717 &Config::default(),
718 Some(home),
719 );
720 assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
721 assert!(
722 !p.writable
723 .iter()
724 .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
725 );
726 assert!(p.hidden.contains(&home.join(".ssh")));
727 assert!(p.writable.contains(&home.join(".cargo/registry")));
728 assert_eq!(p.unenforced, Unenforced::default());
729 }
730
731 #[test]
734 fn the_signal_scope_is_enforced_unless_the_profile_says_otherwise() {
735 let p = Profile::new(Path::new("/w"), &Config::default(), None);
736 let mut json: serde_json::Value = serde_json::to_value(&p).unwrap();
737 json["unenforced"] =
738 serde_json::json!({"trust_handoff": false, "workspace_sockets": false});
739 let old: Profile = serde_json::from_value(json).unwrap();
740 assert!(!old.unenforced.signal_scope);
741 let mut fitted = p.clone();
742 fitted.unenforced.signal_scope = true;
743 let back: Profile = serde_json::from_str(&serde_json::to_string(&fitted).unwrap()).unwrap();
744 assert!(back.unenforced.signal_scope);
745 }
746
747 #[test]
748 fn the_executable_is_the_first_on_the_path_canonicalised() {
749 use std::os::unix::fs::PermissionsExt;
750 let dir = tempfile::tempdir().unwrap();
751 let root = std::fs::canonicalize(dir.path()).unwrap();
752 for d in ["bin", "usr"] {
753 std::fs::create_dir_all(root.join(d)).unwrap();
754 let f = root.join(d).join("make");
755 std::fs::write(&f, "#!/bin/sh\n").unwrap();
756 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
757 }
758 let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
759 let usr = format!("{}", root.join("usr").display());
760 assert_eq!(
761 resolve_executable("make", &root, &env(&usr)),
762 Some(root.join("usr/make"))
763 );
764 assert_eq!(
766 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
767 Some(root.join("bin/make"))
768 );
769 assert_eq!(
770 resolve_executable("./bin/make", &root, &env("")),
771 Some(root.join("bin/make"))
772 );
773 assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
774 assert!(!resolvable("nope", &root, &env(&usr)));
775 std::fs::set_permissions(
777 root.join("bin/make"),
778 std::fs::Permissions::from_mode(0o644),
779 )
780 .unwrap();
781 assert_eq!(
782 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
783 Some(root.join("usr/make"))
784 );
785 assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
786 }
787
788 #[test]
790 fn the_engine_key_is_refused_by_name() {
791 let e = parse("[sandbox]\nengine = \"procage\"\n").unwrap_err();
792 assert!(e.contains("engine"), "{e}");
793 }
794
795 #[cfg(target_os = "macos")]
799 #[test]
800 fn an_unconfinable_profile_follows_the_mode() {
801 if backend().is_err() {
802 return; }
804 let dir = tempfile::tempdir().unwrap();
805 let odd = dir.path().join("procage\u{1}odd");
806 std::fs::create_dir_all(&odd).unwrap();
807 let mut config = Config {
808 writable: vec![odd.to_string_lossy().into_owned()],
809 ..Config::default()
810 };
811 let (plain, notice) = runner(dir.path(), &config).unwrap();
812 assert!(!plain.is_sandboxed());
813 assert!(
814 notice.is_some_and(|n| n.starts_with("commands run unsandboxed: ")),
815 "{plain:?}"
816 );
817 config.mode = Mode::Required;
818 let e = runner(dir.path(), &config).unwrap_err();
819 assert!(e.contains("mode = \"required\""), "{e}");
820 }
821
822 #[test]
823 fn a_confinable_profile_is_sandboxed() {
824 let dir = tempfile::tempdir().unwrap();
825 let Ok((Runner::Sandboxed(s), _)) = runner(dir.path(), &Config::default()) else {
826 assert!(
827 backend().is_err(),
828 "a sandbox exists but the runner is plain"
829 );
830 return;
831 };
832 assert_eq!(s.backend, backend().unwrap());
833 }
834
835 #[test]
836 fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
837 let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
838 let s = Sandboxed {
839 profile: Profile::new(Path::new("/w"), &c, None),
840 backend: Backend { helper: None },
841 network_allow: c.network_allow.clone(),
842 network_allow_prefix: c.network_allow_prefix.clone(),
843 };
844 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
845 assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
846 assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
847 assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
848 assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
849 }
850}