1use std::path::{Path, PathBuf};
21use std::sync::OnceLock;
22
23use serde::{Deserialize, Serialize};
24
25use crate::verify::process::{CommandRunner, Invocation, LocalRunner, Outcome};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "lowercase")]
30pub enum Network {
31 Off,
33 Loopback,
36 On,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum Mode {
43 Auto,
45 Required,
47 Off,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Config {
54 pub mode: Mode,
56 pub network: Network,
58 pub network_allow: Vec<Vec<String>>,
60 pub network_allow_prefix: Vec<Vec<String>>,
62 pub writable: Vec<String>,
64}
65
66impl Default for Config {
67 fn default() -> Self {
68 Self {
69 mode: Mode::Auto,
70 network: Network::Loopback,
71 network_allow: Vec::new(),
72 network_allow_prefix: Vec::new(),
73 writable: Vec::new(),
74 }
75 }
76}
77
78#[derive(Debug, Default, Deserialize)]
79#[serde(deny_unknown_fields)]
80struct Raw {
81 mode: Option<String>,
82 network: Option<String>,
83 #[serde(default)]
84 network_allow: Vec<Vec<String>>,
85 #[serde(default)]
86 network_allow_prefix: Vec<Vec<String>>,
87 #[serde(default)]
88 writable: Vec<String>,
89}
90
91pub fn parse(text: &str) -> Result<Config, String> {
93 let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
94 let Some(v) = table.get("sandbox") else {
95 return Ok(Config::default());
96 };
97 let raw: Raw = v
98 .clone()
99 .try_into()
100 .map_err(|e| format!("[sandbox]: {e}"))?;
101 let mode = match raw.mode.as_deref() {
102 None | Some("auto") => Mode::Auto,
103 Some("required") => Mode::Required,
104 Some("off") => Mode::Off,
105 Some(o) => return Err(format!("[sandbox] mode = \"{o}\" (auto, required or off)")),
106 };
107 let network = match raw.network.as_deref() {
108 None | Some("loopback") => Network::Loopback,
109 Some("off") => Network::Off,
110 Some("on") => Network::On,
111 Some(o) => return Err(format!("[sandbox] network = \"{o}\" (off, loopback or on)")),
112 };
113 if let Some(why) = raw
114 .network_allow_prefix
115 .iter()
116 .find_map(|p| crate::tools::policy::prefix_refusal(p))
117 {
118 return Err(format!("[sandbox] network_allow_prefix: {why}"));
119 }
120 Ok(Config {
121 mode,
122 network,
123 network_allow: raw
124 .network_allow
125 .into_iter()
126 .filter(|p| !p.is_empty())
127 .collect(),
128 network_allow_prefix: raw.network_allow_prefix,
129 writable: raw.writable,
130 })
131}
132
133pub const HIDDEN: &[&str] = &[
135 ".ssh",
136 ".aws",
137 ".azure",
138 ".gnupg",
139 ".netrc",
140 ".docker",
141 ".kube",
142 ".npmrc",
143 ".pypirc",
144 ".git-credentials",
145 ".password-store",
146 ".config/gh",
147 ".config/gcloud",
148 ".config/hub",
149 ".cargo/credentials",
150 ".cargo/credentials.toml",
151 ".local/share/keyrings",
152 "Library/Keychains",
153];
154
155pub const CACHES: &[&str] = &[
158 ".cargo/registry",
159 ".cargo/git",
160 ".cargo/.package-cache",
161 ".cargo/.package-cache-mutate",
162 ".cargo/.global-cache",
163 ".cache",
164 ".npm",
165 ".pnpm-store",
166 ".yarn/berry/cache",
167 "go/pkg/mod",
168 "Library/Caches",
169];
170
171#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
173pub struct Profile {
174 pub writable: Vec<PathBuf>,
176 pub hidden: Vec<PathBuf>,
178 pub read_only: Vec<PathBuf>,
181 pub network: Network,
183 pub tmp: PathBuf,
185}
186
187fn canonical(p: &Path) -> PathBuf {
188 std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
189}
190
191impl Profile {
192 #[must_use]
194 pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
195 let root = canonical(root);
196 let expand = |p: &str| -> Option<PathBuf> {
197 match (p.strip_prefix("~/"), home) {
198 (Some(rest), Some(h)) => Some(h.join(rest)),
199 (Some(_), None) => None,
200 (None, _) => Some(PathBuf::from(p)),
201 }
202 };
203 let home = home.map(canonical);
204 let home = home.as_deref();
205 let mut writable = vec![root.clone()];
206 writable.extend(
207 home.into_iter()
208 .flat_map(|h| CACHES.iter().map(move |c| h.join(c))),
209 );
210 writable.extend(
211 config
212 .writable
213 .iter()
214 .filter_map(|p| expand(p))
215 .map(|p| canonical(&p)),
216 );
217 let hidden = home
218 .into_iter()
219 .flat_map(|h| HIDDEN.iter().map(move |c| h.join(c)))
220 .collect();
221 let digest = {
222 use sha2::{Digest as _, Sha256};
223 let d = Sha256::digest(root.to_string_lossy().as_bytes());
224 d.iter()
225 .take(6)
226 .map(|b| format!("{b:02x}"))
227 .collect::<String>()
228 };
229 let tmp = canonical(&std::env::temp_dir()).join(format!("speccoder-{digest}"));
231 Self {
232 writable,
233 hidden,
234 read_only: Vec::new(),
235 network: config.network,
236 tmp,
237 }
238 }
239
240 #[must_use]
243 pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
244 self.read_only
245 .extend(read_only.iter().map(|p| canonical(p)));
246 self.hidden.extend(hidden.iter().map(|p| canonical(p)));
247 self
248 }
249
250 #[must_use]
252 pub fn with_network(mut self, network: Network) -> Self {
253 self.network = network;
254 self
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq)]
260pub enum Backend {
261 Seatbelt,
263 Landlock(PathBuf),
265}
266
267const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
268
269pub const HELPER_ARG: &str = "__sandbox";
271
272pub fn backend() -> Result<Backend, String> {
274 static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
275 CACHED.get_or_init(detect).clone()
276}
277
278fn detect() -> Result<Backend, String> {
279 if cfg!(target_os = "macos") {
280 return if Path::new(SANDBOX_EXEC).exists() {
281 Ok(Backend::Seatbelt)
282 } else {
283 Err(format!("{SANDBOX_EXEC} is missing"))
284 };
285 }
286 if cfg!(target_os = "linux") {
287 let helper = std::env::var_os("SPECCODER_SANDBOX_HELPER")
288 .map(PathBuf::from)
289 .or_else(|| {
290 std::env::current_exe()
291 .ok()
292 .filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
293 })
294 .ok_or_else(|| "the speccoder helper binary was not found".to_owned())?;
295 return with_helper(helper);
296 }
297 Err(format!("no sandbox for {}", std::env::consts::OS))
298}
299
300pub fn with_helper(helper: PathBuf) -> Result<Backend, String> {
303 linux_supported()?;
304 Ok(Backend::Landlock(helper))
305}
306
307#[cfg(target_os = "linux")]
308fn linux_supported() -> Result<(), String> {
309 linux::probe()
310}
311
312#[cfg(not(target_os = "linux"))]
313fn linux_supported() -> Result<(), String> {
314 Err("not Linux".to_owned())
315}
316
317fn quote(p: &Path) -> String {
318 let s = p.to_string_lossy();
319 format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
320}
321
322fn regex_escape(text: &str) -> String {
324 let mut out = String::with_capacity(text.len());
325 for c in text.chars() {
326 if "\\^$.|?*+()[]{}\"".contains(c) {
327 out.push('\\');
328 }
329 out.push(c);
330 }
331 out
332}
333
334#[must_use]
336pub fn seatbelt(p: &Profile) -> String {
337 let mut out = String::from("(version 1)\n(allow default)\n(deny file-write*)\n");
338 out.push_str("(allow file-write*");
339 for w in p.writable.iter().chain([&p.tmp]) {
340 out.push_str(&format!(" (subpath {})", quote(w)));
341 }
342 if let Some(user_tmp) = p.tmp.parent() {
346 out.push_str(&format!(
347 " (regex #\"^{}/tmp\\.[A-Za-z0-9]+(/|$)\")",
348 regex_escape(&user_tmp.to_string_lossy())
349 ));
350 }
351 out.push_str(
352 " (literal \"/dev/null\") (literal \"/dev/zero\") (literal \"/dev/tty\") (literal \"/dev/dtracehelper\") (regex #\"^/dev/fd/\") (regex #\"^/dev/ttys\"))\n",
353 );
354 if !p.read_only.is_empty() {
355 out.push_str("(deny file-write*");
356 for r in &p.read_only {
357 out.push_str(&format!(" (subpath {})", quote(r)));
358 }
359 out.push_str(")\n");
360 }
361 if !p.hidden.is_empty() {
362 out.push_str("(deny file-read* file-write*");
363 for h in &p.hidden {
364 out.push_str(&format!(" (subpath {})", quote(h)));
365 }
366 out.push_str(")\n");
367 }
368 out.push_str("(deny appleevent-send)\n");
370 out.push_str("(deny mach-lookup (global-name-regex #\"^com\\.apple\\.lsd\\.\") (global-name \"com.apple.coreservices.launchservicesd\") (global-name \"com.apple.coreservices.appleevents\"))\n");
371 match p.network {
372 Network::On => {}
373 Network::Off | Network::Loopback => {
374 out.push_str("(deny network*)\n");
375 if p.network == Network::Loopback {
376 out.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n(allow network-bind network-inbound (local ip \"localhost:*\"))\n");
379 }
380 out.push_str("(allow network-outbound network-bind network-inbound");
382 for w in p.writable.iter().chain([&p.tmp]) {
383 out.push_str(&format!(
384 " (remote unix-socket (subpath {q})) (local unix-socket (subpath {q}))",
385 q = quote(w)
386 ));
387 }
388 out.push_str(")\n");
389 }
390 }
391 out
392}
393
394pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
396 match backend {
397 Backend::Seatbelt => {
398 let mut out = vec![SANDBOX_EXEC.to_owned(), "-p".to_owned(), seatbelt(p)];
399 out.extend_from_slice(argv);
400 Ok(out)
401 }
402 Backend::Landlock(helper) => {
403 let mut out = vec![
404 helper.to_string_lossy().into_owned(),
405 HELPER_ARG.to_owned(),
406 serde_json::to_string(p).map_err(|e| e.to_string())?,
407 "--".to_owned(),
408 ];
409 out.extend_from_slice(argv);
410 Ok(out)
411 }
412 }
413}
414
415#[derive(Debug, Clone)]
417pub struct Sandboxed {
418 pub profile: Profile,
420 pub backend: Backend,
422 pub network_allow: Vec<Vec<String>>,
424 pub network_allow_prefix: Vec<Vec<String>>,
426}
427
428impl Sandboxed {
429 #[must_use]
432 pub fn network_for(&self, argv: &[String]) -> Network {
433 if self.network_allow.iter().any(|p| p.as_slice() == argv)
434 || self
435 .network_allow_prefix
436 .iter()
437 .any(|p| crate::tools::policy::prefix_matches(p, argv))
438 {
439 Network::On
440 } else {
441 self.profile.network
442 }
443 }
444
445 #[must_use]
447 pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
448 let profile = self.profile.clone().with_network(network);
449 let failed = |e: String| Outcome {
450 exit_code: None,
451 timed_out: false,
452 spawn_error: Some(e),
453 started: std::time::SystemTime::now(),
454 duration: std::time::Duration::ZERO,
455 stdout: crate::verify::process::Captured::default(),
456 stderr: crate::verify::process::Captured::default(),
457 };
458 if let Some(program) = inv.argv.first()
461 && !resolvable(program, &inv.cwd, &inv.env)
462 {
463 return failed(format!(
464 "could not start `{program}`: No such file or directory"
465 ));
466 }
467 if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
468 return failed(format!("the sandbox's temporary directory: {e}"));
469 }
470 let argv = match wrap(&profile, &self.backend, &inv.argv) {
471 Ok(a) => a,
472 Err(e) => return failed(e),
473 };
474 let tmp = profile.tmp.to_string_lossy().into_owned();
475 let mut env: Vec<(String, String)> = inv
476 .env
477 .iter()
478 .filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
479 .cloned()
480 .collect();
481 env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
482 LocalRunner.run(&Invocation {
483 argv,
484 env,
485 ..inv.clone()
486 })
487 }
488}
489
490#[must_use]
497pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
498 let candidate = |p: PathBuf| {
499 executable_file(&p)
500 .then(|| std::fs::canonicalize(&p).ok())
501 .flatten()
502 };
503 if program.is_empty() {
504 return None;
505 }
506 if program.contains('/') {
507 return candidate(cwd.join(program));
508 }
509 let path = env
510 .iter()
511 .find(|(k, _)| k == "PATH")
512 .map(|(_, v)| v.as_str());
513 path.unwrap_or("/usr/bin:/bin")
514 .split(':')
515 .filter(|d| !d.is_empty())
516 .find_map(|d| candidate(cwd.join(d).join(program)))
517}
518
519fn executable_file(p: &Path) -> bool {
521 #[cfg(unix)]
522 {
523 use std::os::unix::fs::PermissionsExt as _;
524 std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
525 }
526 #[cfg(not(unix))]
527 {
528 p.is_file()
529 }
530}
531
532fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
534 resolve_executable(program, cwd, env).is_some()
535}
536
537impl CommandRunner for Sandboxed {
538 fn run(&self, inv: &Invocation) -> Outcome {
539 self.run_with_network(inv, self.network_for(&inv.argv))
540 }
541}
542
543pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
547 if config.mode == Mode::Off {
548 return Ok((
549 Runner::Plain,
550 Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
551 ));
552 }
553 match backend() {
554 Ok(backend) => {
555 let home = std::env::var_os("HOME").map(PathBuf::from);
556 Ok((
557 Runner::Sandboxed(Sandboxed {
558 profile: Profile::new(root, config, home.as_deref()),
559 backend,
560 network_allow: config.network_allow.clone(),
561 network_allow_prefix: config.network_allow_prefix.clone(),
562 }),
563 None,
564 ))
565 }
566 Err(e) if config.mode == Mode::Required => Err(format!(
567 "[sandbox] mode = \"required\", but there is no sandbox: {e}"
568 )),
569 Err(e) => Ok((
570 Runner::Plain,
571 Some(format!("commands run unsandboxed: {e}")),
572 )),
573 }
574}
575
576pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
578 let text = crate::verify::find_config(root)
579 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
580 .unwrap_or_default();
581 let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
582 runner(root, &config)
583}
584
585impl Runner {
586 #[must_use]
590 pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
591 match self {
592 Self::Sandboxed(mut s) => {
593 if let Backend::Landlock(helper) = &s.backend
596 && !mounts_supported(helper)
597 {
598 return (
599 Self::Sandboxed(s),
600 Some("this machine does not allow user namespaces (on Ubuntu 23.10+: AppArmor), so commands can write `.git`, read ignored secrets in place and reach daemon sockets such as Docker's; the run stops if `.git` changes".to_owned()),
601 );
602 }
603 s.profile = s.profile.carve(read_only, hidden);
604 (Self::Sandboxed(s), None)
605 }
606 Self::Plain => (Self::Plain, None),
607 }
608 }
609}
610
611#[derive(Debug, Clone)]
613pub enum Runner {
614 Sandboxed(Sandboxed),
616 Plain,
618}
619
620impl Runner {
621 #[must_use]
623 pub fn is_sandboxed(&self) -> bool {
624 matches!(self, Self::Sandboxed(_))
625 }
626
627 #[must_use]
629 pub fn with_writable(self, dir: &Path) -> Self {
630 match self {
631 Self::Sandboxed(mut s) => {
632 s.profile.writable.push(canonical(dir));
633 Self::Sandboxed(s)
634 }
635 Self::Plain => Self::Plain,
636 }
637 }
638
639 #[must_use]
642 pub fn allows_network(&self, argv: &[String]) -> bool {
643 matches!(self, Self::Sandboxed(s) if s.network_for(argv) == Network::On)
644 }
645
646 #[must_use]
648 pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
649 match self {
650 Self::Plain => LocalRunner.run(inv),
651 Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
652 Self::Sandboxed(s) => s.run(inv),
653 }
654 }
655
656 #[must_use]
659 pub fn scratch(&self) -> Option<PathBuf> {
660 match self {
661 Self::Plain => None,
662 Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
663 .ok()
664 .map(|()| s.profile.tmp.clone()),
665 }
666 }
667
668 pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
670 match self {
671 Self::Plain => Ok((argv.to_vec(), None)),
672 Self::Sandboxed(s) => {
673 let profile = s.profile.clone().with_network(s.network_for(argv));
674 std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
675 Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
676 }
677 }
678 }
679}
680
681impl CommandRunner for Runner {
682 fn run(&self, inv: &Invocation) -> Outcome {
683 self.run_with(inv, false)
684 }
685}
686
687pub fn helper_main(args: &[String]) -> String {
690 if args.first().map(String::as_str) == Some(PROBE_MOUNTS) {
691 return match probe_mounts() {
692 Ok(()) => std::process::exit(0),
693 Err(e) => e,
694 };
695 }
696 let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
697 return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
698 };
699 let profile: Profile = match serde_json::from_str(profile) {
700 Ok(p) => p,
701 Err(e) => return format!("the sandbox profile: {e}"),
702 };
703 let argv = &args[2..];
704 if argv.is_empty() {
705 return "nothing to run".to_owned();
706 }
707 enter(&profile, argv)
708}
709
710#[cfg(target_os = "linux")]
711fn enter(profile: &Profile, argv: &[String]) -> String {
712 linux::enter(profile, argv)
713}
714
715const PROBE_MOUNTS: &str = "--probe-mounts";
717
718fn mounts_supported(helper: &Path) -> bool {
721 static CACHED: OnceLock<bool> = OnceLock::new();
722 *CACHED.get_or_init(|| {
723 std::process::Command::new(helper)
724 .args([HELPER_ARG, PROBE_MOUNTS])
725 .stdout(std::process::Stdio::null())
726 .stderr(std::process::Stdio::null())
727 .status()
728 .is_ok_and(|s| s.success())
729 })
730}
731
732#[cfg(target_os = "linux")]
733fn probe_mounts() -> Result<(), String> {
734 linux::probe_mounts()
735}
736
737#[cfg(not(target_os = "linux"))]
738fn probe_mounts() -> Result<(), String> {
739 Err("mount carve-outs are for Linux".to_owned())
740}
741
742#[cfg(not(target_os = "linux"))]
743fn enter(_profile: &Profile, _argv: &[String]) -> String {
744 format!("the {HELPER_ARG} helper is for Linux")
745}
746
747#[cfg(target_os = "linux")]
748mod linux {
749 use std::collections::BTreeMap;
752 use std::path::{Path, PathBuf};
753
754 use landlock::{
755 ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
756 RulesetCreatedAttr, RulesetStatus,
757 };
758 use seccompiler::{
759 SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
760 SeccompRule, TargetArch,
761 };
762
763 use super::{Network, Profile};
764
765 const ABI_LEVEL: ABI = ABI::V3;
766
767 pub(super) fn probe() -> Result<(), String> {
768 Ruleset::default()
769 .set_compatibility(CompatLevel::HardRequirement)
770 .handle_access(AccessFs::from_all(ABI_LEVEL))
771 .and_then(|r| r.create())
772 .map(|_| ())
773 .map_err(|e| format!("Landlock is not available: {e}"))
774 }
775
776 fn mount_carve(profile: &Profile) -> Result<(), String> {
782 use nix::mount::{MsFlags, mount};
783 let inside = |p: &PathBuf| profile.writable.iter().any(|w| p.starts_with(w));
784 let hidden: Vec<&PathBuf> = profile
785 .hidden
786 .iter()
787 .filter(|h| inside(h) && h.exists())
788 .collect();
789 let sockets: Vec<PathBuf> = daemon_sockets()
790 .into_iter()
791 .filter(|p| !inside(p))
792 .collect();
793 let carved = !profile.read_only.is_empty() || !hidden.is_empty();
794 if !carved && sockets.is_empty() {
795 return Ok(());
796 }
797 if let Err(e) = private_namespace() {
801 return if carved { Err(e) } else { Ok(()) };
802 }
803 let none: Option<&str> = None;
804 for p in profile.read_only.iter().filter(|p| p.exists()) {
805 mount(Some(p), p, none, MsFlags::MS_BIND | MsFlags::MS_REC, none)
806 .map_err(|e| format!("bind {}: {e}", p.display()))?;
807 mount(
808 none,
809 p,
810 none,
811 MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY | locked(p),
812 none,
813 )
814 .map_err(|e| format!("read-only {}: {e}", p.display()))?;
815 }
816 for h in hidden {
817 if h.is_dir() {
818 mount(
819 Some("tmpfs"),
820 h,
821 Some("tmpfs"),
822 MsFlags::MS_RDONLY | MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
823 Some("size=4k"),
824 )
825 } else {
826 mount(Some("/dev/null"), h, none, MsFlags::MS_BIND, none)
827 }
828 .map_err(|e| format!("hide {}: {e}", h.display()))?;
829 }
830 for p in &sockets {
834 mount(Some("/dev/null"), p, none, MsFlags::MS_BIND, none)
835 .map_err(|e| format!("cover {}: {e}", p.display()))?;
836 }
837 Ok(())
838 }
839
840 fn daemon_sockets() -> Vec<PathBuf> {
843 use std::os::unix::fs::FileTypeExt as _;
844 fn walk(dir: &Path, depth: u8, out: &mut Vec<PathBuf>) {
845 let Ok(entries) = std::fs::read_dir(dir) else {
846 return;
847 };
848 for e in entries.flatten() {
849 let Ok(t) = e.file_type() else { continue };
850 if t.is_socket() {
851 out.push(e.path());
852 } else if t.is_dir() && depth > 0 {
853 walk(&e.path(), depth - 1, out);
854 }
855 }
856 }
857 let mut roots = vec![
858 PathBuf::from("/run"),
859 PathBuf::from("/var/run"),
860 PathBuf::from("/tmp"),
861 ];
862 if let Some(x) = std::env::var_os("XDG_RUNTIME_DIR") {
863 roots.push(PathBuf::from(x));
864 }
865 let mut seen = std::collections::BTreeSet::new();
866 let mut out = Vec::new();
867 for root in roots {
868 let Ok(real) = root.canonicalize() else {
870 continue;
871 };
872 if !seen.insert(real.clone()) {
873 continue;
874 }
875 walk(&real, 3, &mut out);
876 }
877 out.sort();
878 out.dedup();
879 out
880 }
881
882 fn private_namespace() -> Result<(), String> {
885 use nix::mount::{MsFlags, mount};
886 use nix::sched::{CloneFlags, unshare};
887 let (uid, gid) = (nix::unistd::getuid(), nix::unistd::getgid());
888 unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
889 .map_err(|e| format!("user namespace: {e}"))?;
890 let write = |file: &str, text: String| {
891 std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
892 };
893 write("/proc/self/setgroups", "deny".to_owned())?;
894 write("/proc/self/uid_map", format!("{uid} {uid} 1"))?;
895 write("/proc/self/gid_map", format!("{gid} {gid} 1"))?;
896 let none: Option<&str> = None;
897 mount(none, "/", none, MsFlags::MS_REC | MsFlags::MS_PRIVATE, none)
898 .map_err(|e| format!("private mounts: {e}"))
899 }
900
901 fn locked(p: &Path) -> nix::mount::MsFlags {
903 use nix::mount::MsFlags;
904 use nix::sys::statvfs::{FsFlags, statvfs};
905 let Ok(st) = statvfs(p) else {
906 return MsFlags::empty();
907 };
908 let f = st.flags();
909 let mut out = MsFlags::empty();
910 for (fs, ms) in [
911 (FsFlags::ST_NOSUID, MsFlags::MS_NOSUID),
912 (FsFlags::ST_NODEV, MsFlags::MS_NODEV),
913 (FsFlags::ST_NOEXEC, MsFlags::MS_NOEXEC),
914 (FsFlags::ST_NOATIME, MsFlags::MS_NOATIME),
915 (FsFlags::ST_NODIRATIME, MsFlags::MS_NODIRATIME),
916 (FsFlags::ST_RELATIME, MsFlags::MS_RELATIME),
917 ] {
918 if f.contains(fs) {
919 out |= ms;
920 }
921 }
922 out
923 }
924
925 pub(super) fn probe_mounts() -> Result<(), String> {
928 let dir = std::env::temp_dir().join(format!("speccoder-probe-{}", std::process::id()));
929 std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
930 let profile = Profile {
931 writable: Vec::new(),
932 hidden: Vec::new(),
933 read_only: vec![dir.clone()],
934 network: Network::On,
935 tmp: dir.clone(),
936 };
937 let carved = mount_carve(&profile);
938 let refused = carved.is_ok() && std::fs::write(dir.join("x"), "x").is_err();
939 let _ = std::fs::remove_dir_all(&dir);
940 carved.and_then(|()| {
941 refused
942 .then_some(())
943 .ok_or_else(|| "the read-only bind did not hold".to_owned())
944 })
945 }
946
947 fn readable(dir: &Path, hidden: &[PathBuf], out: &mut Vec<PathBuf>) {
950 if hidden.iter().any(|h| h == dir) {
951 return;
952 }
953 if !hidden.iter().any(|h| h.starts_with(dir)) {
954 out.push(dir.to_path_buf());
955 return;
956 }
957 if let Ok(entries) = std::fs::read_dir(dir) {
958 for e in entries.flatten() {
959 readable(&e.path(), hidden, out);
960 }
961 }
962 }
963
964 pub(super) fn enter(profile: &Profile, argv: &[String]) -> String {
965 match mount_carve(profile).and_then(|()| confine(profile)) {
966 Ok(()) => {
967 use std::os::unix::process::CommandExt as _;
968 let e = std::process::Command::new(&argv[0]).args(&argv[1..]).exec();
969 format!("could not start `{}`: {e}", argv[0])
970 }
971 Err(e) => format!("the sandbox could not be applied: {e}"),
972 }
973 }
974
975 fn confine(profile: &Profile) -> Result<(), String> {
976 let read = AccessFs::from_read(ABI_LEVEL);
977 let all = AccessFs::from_all(ABI_LEVEL);
978 let mut readable_paths = Vec::new();
979 readable(Path::new("/"), &profile.hidden, &mut readable_paths);
980 let mut ruleset = Ruleset::default()
981 .set_compatibility(CompatLevel::HardRequirement)
982 .handle_access(all)
983 .and_then(|r| r.create())
984 .map_err(|e| e.to_string())?;
985 for p in &readable_paths {
986 if let Ok(fd) = PathFd::new(p) {
987 let access = if p.is_dir() {
988 read
989 } else {
990 read & AccessFs::from_file(ABI_LEVEL)
991 };
992 ruleset = ruleset
993 .add_rule(PathBeneath::new(fd, access))
994 .map_err(|e| e.to_string())?;
995 }
996 }
997 for w in profile.writable.iter().chain([&profile.tmp]) {
998 if let Ok(fd) = PathFd::new(w) {
999 let access = if w.is_dir() {
1000 all
1001 } else {
1002 AccessFs::from_file(ABI_LEVEL)
1003 };
1004 ruleset = ruleset
1005 .add_rule(PathBeneath::new(fd, access))
1006 .map_err(|e| e.to_string())?;
1007 }
1008 }
1009 for dev in ["/dev/null", "/dev/zero", "/dev/tty", "/dev/ptmx"] {
1010 if let Ok(fd) = PathFd::new(dev) {
1011 ruleset = ruleset
1012 .add_rule(PathBeneath::new(fd, AccessFs::from_file(ABI_LEVEL)))
1013 .map_err(|e| e.to_string())?;
1014 }
1015 }
1016 let status = ruleset.restrict_self().map_err(|e| e.to_string())?;
1017 if status.ruleset != RulesetStatus::FullyEnforced {
1018 return Err(format!("Landlock only {:?}", status.ruleset));
1019 }
1020 if profile.network != Network::On {
1021 no_inet()?;
1022 }
1023 Ok(())
1024 }
1025
1026 fn no_inet() -> Result<(), String> {
1028 let rule = |domain: u64| {
1029 SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, domain)
1030 .and_then(|c| SeccompRule::new(vec![c]))
1031 .map_err(|e| e.to_string())
1032 };
1033 let rules = vec![
1034 rule(u64::from(libc::AF_INET.unsigned_abs()))?,
1035 rule(u64::from(libc::AF_INET6.unsigned_abs()))?,
1036 ];
1037 let arch: TargetArch = std::env::consts::ARCH
1038 .try_into()
1039 .map_err(|e: seccompiler::BackendError| e.to_string())?;
1040 let filter = SeccompFilter::new(
1041 BTreeMap::from([(libc::SYS_socket, rules)]),
1042 SeccompAction::Allow,
1043 SeccompAction::Errno(libc::EACCES.unsigned_abs()),
1044 arch,
1045 )
1046 .map_err(|e| e.to_string())?;
1047 let program: seccompiler::BpfProgram = filter
1048 .try_into()
1049 .map_err(|e: seccompiler::BackendError| e.to_string())?;
1050 seccompiler::apply_filter(&program).map_err(|e| e.to_string())
1051 }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057
1058 #[test]
1059 fn config_parses_and_defaults_to_loopback() {
1060 assert_eq!(parse("").unwrap_or_default(), Config::default());
1061 let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
1062 .unwrap_or_default();
1063 assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
1064 assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
1065 }
1066
1067 #[test]
1068 fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
1069 let home = Path::new("/home/u");
1070 let p = Profile::new(
1071 Path::new("/s/proj/worktrees/001"),
1072 &Config::default(),
1073 Some(home),
1074 );
1075 assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
1076 assert!(
1077 !p.writable
1078 .iter()
1079 .any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
1080 );
1081 assert!(p.hidden.contains(&home.join(".ssh")));
1082 let sb = seatbelt(&p);
1083 assert!(sb.contains("(deny network*)"));
1084 assert!(sb.contains("(subpath \"/home/u/.cargo/registry\")"));
1085 assert!(!sb.contains("(local ip \"localhost:*\"))\n(allow network*"));
1086 }
1087
1088 #[test]
1089 fn the_executable_is_the_first_on_the_path_canonicalised() {
1090 use std::os::unix::fs::PermissionsExt;
1091 let dir = tempfile::tempdir().unwrap();
1092 let root = std::fs::canonicalize(dir.path()).unwrap();
1093 for d in ["bin", "usr"] {
1094 std::fs::create_dir_all(root.join(d)).unwrap();
1095 let f = root.join(d).join("make");
1096 std::fs::write(&f, "#!/bin/sh\n").unwrap();
1097 std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
1098 }
1099 let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
1100 let usr = format!("{}", root.join("usr").display());
1101 assert_eq!(
1102 resolve_executable("make", &root, &env(&usr)),
1103 Some(root.join("usr/make"))
1104 );
1105 assert_eq!(
1107 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
1108 Some(root.join("bin/make"))
1109 );
1110 assert_eq!(
1111 resolve_executable("./bin/make", &root, &env("")),
1112 Some(root.join("bin/make"))
1113 );
1114 assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
1115 assert!(!resolvable("nope", &root, &env(&usr)));
1116 std::fs::set_permissions(
1118 root.join("bin/make"),
1119 std::fs::Permissions::from_mode(0o644),
1120 )
1121 .unwrap();
1122 assert_eq!(
1123 resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
1124 Some(root.join("usr/make"))
1125 );
1126 assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
1127 }
1128
1129 #[test]
1130 fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
1131 let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
1132 let s = Sandboxed {
1133 profile: Profile::new(Path::new("/w"), &c, None),
1134 backend: Backend::Seatbelt,
1135 network_allow: c.network_allow.clone(),
1136 network_allow_prefix: c.network_allow_prefix.clone(),
1137 };
1138 let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
1139 assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
1140 assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
1141 assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
1142 assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
1143 }
1144}