use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use serde::{Deserialize, Serialize};
use crate::verify::process::{CommandRunner, Invocation, LocalRunner, Outcome};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Network {
Off,
Loopback,
On,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Mode {
Auto,
Required,
Off,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Config {
pub mode: Mode,
pub network: Network,
pub network_allow: Vec<Vec<String>>,
pub network_allow_prefix: Vec<Vec<String>>,
pub writable: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
Self {
mode: Mode::Auto,
network: Network::Loopback,
network_allow: Vec::new(),
network_allow_prefix: Vec::new(),
writable: Vec::new(),
}
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(deny_unknown_fields)]
struct Raw {
mode: Option<String>,
network: Option<String>,
#[serde(default)]
network_allow: Vec<Vec<String>>,
#[serde(default)]
network_allow_prefix: Vec<Vec<String>>,
#[serde(default)]
writable: Vec<String>,
}
pub fn parse(text: &str) -> Result<Config, String> {
let table: toml::Table = toml::from_str(text).map_err(|e| e.to_string())?;
let Some(v) = table.get("sandbox") else {
return Ok(Config::default());
};
let raw: Raw = v
.clone()
.try_into()
.map_err(|e| format!("[sandbox]: {e}"))?;
let mode = match raw.mode.as_deref() {
None | Some("auto") => Mode::Auto,
Some("required") => Mode::Required,
Some("off") => Mode::Off,
Some(o) => return Err(format!("[sandbox] mode = \"{o}\" (auto, required or off)")),
};
let network = match raw.network.as_deref() {
None | Some("loopback") => Network::Loopback,
Some("off") => Network::Off,
Some("on") => Network::On,
Some(o) => return Err(format!("[sandbox] network = \"{o}\" (off, loopback or on)")),
};
if let Some(why) = raw
.network_allow_prefix
.iter()
.find_map(|p| crate::tools::policy::prefix_refusal(p))
{
return Err(format!("[sandbox] network_allow_prefix: {why}"));
}
Ok(Config {
mode,
network,
network_allow: raw
.network_allow
.into_iter()
.filter(|p| !p.is_empty())
.collect(),
network_allow_prefix: raw.network_allow_prefix,
writable: raw.writable,
})
}
pub const HIDDEN: &[&str] = &[
".ssh",
".aws",
".azure",
".gnupg",
".netrc",
".docker",
".kube",
".npmrc",
".pypirc",
".git-credentials",
".password-store",
".config/gh",
".config/gcloud",
".config/hub",
".cargo/credentials",
".cargo/credentials.toml",
".local/share/keyrings",
"Library/Keychains",
];
pub const CACHES: &[&str] = &[
".cargo/registry",
".cargo/git",
".cargo/.package-cache",
".cargo/.package-cache-mutate",
".cargo/.global-cache",
".cache",
".npm",
".pnpm-store",
".yarn/berry/cache",
"go/pkg/mod",
"Library/Caches",
];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Profile {
pub writable: Vec<PathBuf>,
pub hidden: Vec<PathBuf>,
pub read_only: Vec<PathBuf>,
pub network: Network,
pub tmp: PathBuf,
}
fn canonical(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
impl Profile {
#[must_use]
pub fn new(root: &Path, config: &Config, home: Option<&Path>) -> Self {
let root = canonical(root);
let expand = |p: &str| -> Option<PathBuf> {
match (p.strip_prefix("~/"), home) {
(Some(rest), Some(h)) => Some(h.join(rest)),
(Some(_), None) => None,
(None, _) => Some(PathBuf::from(p)),
}
};
let home = home.map(canonical);
let home = home.as_deref();
let mut writable = vec![root.clone()];
writable.extend(
home.into_iter()
.flat_map(|h| CACHES.iter().map(move |c| h.join(c))),
);
writable.extend(
config
.writable
.iter()
.filter_map(|p| expand(p))
.map(|p| canonical(&p)),
);
let hidden = home
.into_iter()
.flat_map(|h| HIDDEN.iter().map(move |c| h.join(c)))
.collect();
let digest = {
use sha2::{Digest as _, Sha256};
let d = Sha256::digest(root.to_string_lossy().as_bytes());
d.iter()
.take(6)
.map(|b| format!("{b:02x}"))
.collect::<String>()
};
let tmp = canonical(&std::env::temp_dir()).join(format!("speccoder-{digest}"));
Self {
writable,
hidden,
read_only: Vec::new(),
network: config.network,
tmp,
}
}
#[must_use]
pub fn carve(mut self, read_only: &[PathBuf], hidden: &[PathBuf]) -> Self {
self.read_only
.extend(read_only.iter().map(|p| canonical(p)));
self.hidden.extend(hidden.iter().map(|p| canonical(p)));
self
}
#[must_use]
pub fn with_network(mut self, network: Network) -> Self {
self.network = network;
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Backend {
Seatbelt,
Landlock(PathBuf),
}
const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec";
pub const HELPER_ARG: &str = "__sandbox";
pub fn backend() -> Result<Backend, String> {
static CACHED: OnceLock<Result<Backend, String>> = OnceLock::new();
CACHED.get_or_init(detect).clone()
}
fn detect() -> Result<Backend, String> {
if cfg!(target_os = "macos") {
return if Path::new(SANDBOX_EXEC).exists() {
Ok(Backend::Seatbelt)
} else {
Err(format!("{SANDBOX_EXEC} is missing"))
};
}
if cfg!(target_os = "linux") {
let helper = std::env::var_os("SPECCODER_SANDBOX_HELPER")
.map(PathBuf::from)
.or_else(|| {
std::env::current_exe()
.ok()
.filter(|p| p.file_stem().is_some_and(|n| n == "speccoder"))
})
.ok_or_else(|| "the speccoder helper binary was not found".to_owned())?;
return with_helper(helper);
}
Err(format!("no sandbox for {}", std::env::consts::OS))
}
pub fn with_helper(helper: PathBuf) -> Result<Backend, String> {
linux_supported()?;
Ok(Backend::Landlock(helper))
}
#[cfg(target_os = "linux")]
fn linux_supported() -> Result<(), String> {
linux::probe()
}
#[cfg(not(target_os = "linux"))]
fn linux_supported() -> Result<(), String> {
Err("not Linux".to_owned())
}
fn quote(p: &Path) -> String {
let s = p.to_string_lossy();
format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\""))
}
fn regex_escape(text: &str) -> String {
let mut out = String::with_capacity(text.len());
for c in text.chars() {
if "\\^$.|?*+()[]{}\"".contains(c) {
out.push('\\');
}
out.push(c);
}
out
}
#[must_use]
pub fn seatbelt(p: &Profile) -> String {
let mut out = String::from("(version 1)\n(allow default)\n(deny file-write*)\n");
out.push_str("(allow file-write*");
for w in p.writable.iter().chain([&p.tmp]) {
out.push_str(&format!(" (subpath {})", quote(w)));
}
if let Some(user_tmp) = p.tmp.parent() {
out.push_str(&format!(
" (regex #\"^{}/tmp\\.[A-Za-z0-9]+(/|$)\")",
regex_escape(&user_tmp.to_string_lossy())
));
}
out.push_str(
" (literal \"/dev/null\") (literal \"/dev/zero\") (literal \"/dev/tty\") (literal \"/dev/dtracehelper\") (regex #\"^/dev/fd/\") (regex #\"^/dev/ttys\"))\n",
);
if !p.read_only.is_empty() {
out.push_str("(deny file-write*");
for r in &p.read_only {
out.push_str(&format!(" (subpath {})", quote(r)));
}
out.push_str(")\n");
}
if !p.hidden.is_empty() {
out.push_str("(deny file-read* file-write*");
for h in &p.hidden {
out.push_str(&format!(" (subpath {})", quote(h)));
}
out.push_str(")\n");
}
out.push_str("(deny appleevent-send)\n");
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");
match p.network {
Network::On => {}
Network::Off | Network::Loopback => {
out.push_str("(deny network*)\n");
if p.network == Network::Loopback {
out.push_str("(allow network-outbound (remote ip \"localhost:*\"))\n(allow network-bind network-inbound (local ip \"localhost:*\"))\n");
}
out.push_str("(allow network-outbound network-bind network-inbound");
for w in p.writable.iter().chain([&p.tmp]) {
out.push_str(&format!(
" (remote unix-socket (subpath {q})) (local unix-socket (subpath {q}))",
q = quote(w)
));
}
out.push_str(")\n");
}
}
out
}
pub fn wrap(p: &Profile, backend: &Backend, argv: &[String]) -> Result<Vec<String>, String> {
match backend {
Backend::Seatbelt => {
let mut out = vec![SANDBOX_EXEC.to_owned(), "-p".to_owned(), seatbelt(p)];
out.extend_from_slice(argv);
Ok(out)
}
Backend::Landlock(helper) => {
let mut out = vec![
helper.to_string_lossy().into_owned(),
HELPER_ARG.to_owned(),
serde_json::to_string(p).map_err(|e| e.to_string())?,
"--".to_owned(),
];
out.extend_from_slice(argv);
Ok(out)
}
}
}
#[derive(Debug, Clone)]
pub struct Sandboxed {
pub profile: Profile,
pub backend: Backend,
pub network_allow: Vec<Vec<String>>,
pub network_allow_prefix: Vec<Vec<String>>,
}
impl Sandboxed {
#[must_use]
pub fn network_for(&self, argv: &[String]) -> Network {
if self.network_allow.iter().any(|p| p.as_slice() == argv)
|| self
.network_allow_prefix
.iter()
.any(|p| crate::tools::policy::prefix_matches(p, argv))
{
Network::On
} else {
self.profile.network
}
}
#[must_use]
pub fn run_with_network(&self, inv: &Invocation, network: Network) -> Outcome {
let profile = self.profile.clone().with_network(network);
let failed = |e: String| Outcome {
exit_code: None,
timed_out: false,
spawn_error: Some(e),
started: std::time::SystemTime::now(),
duration: std::time::Duration::ZERO,
stdout: crate::verify::process::Captured::default(),
stderr: crate::verify::process::Captured::default(),
};
if let Some(program) = inv.argv.first()
&& !resolvable(program, &inv.cwd, &inv.env)
{
return failed(format!(
"could not start `{program}`: No such file or directory"
));
}
if let Err(e) = std::fs::create_dir_all(&profile.tmp) {
return failed(format!("the sandbox's temporary directory: {e}"));
}
let argv = match wrap(&profile, &self.backend, &inv.argv) {
Ok(a) => a,
Err(e) => return failed(e),
};
let tmp = profile.tmp.to_string_lossy().into_owned();
let mut env: Vec<(String, String)> = inv
.env
.iter()
.filter(|(k, _)| !matches!(k.as_str(), "TMPDIR" | "TMP" | "TEMP"))
.cloned()
.collect();
env.extend(["TMPDIR", "TMP", "TEMP"].map(|k| (k.to_owned(), tmp.clone())));
LocalRunner.run(&Invocation {
argv,
env,
..inv.clone()
})
}
}
#[must_use]
pub fn resolve_executable(program: &str, cwd: &Path, env: &[(String, String)]) -> Option<PathBuf> {
let candidate = |p: PathBuf| {
executable_file(&p)
.then(|| std::fs::canonicalize(&p).ok())
.flatten()
};
if program.is_empty() {
return None;
}
if program.contains('/') {
return candidate(cwd.join(program));
}
let path = env
.iter()
.find(|(k, _)| k == "PATH")
.map(|(_, v)| v.as_str());
path.unwrap_or("/usr/bin:/bin")
.split(':')
.filter(|d| !d.is_empty())
.find_map(|d| candidate(cwd.join(d).join(program)))
}
fn executable_file(p: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::metadata(p).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
}
#[cfg(not(unix))]
{
p.is_file()
}
}
fn resolvable(program: &str, cwd: &Path, env: &[(String, String)]) -> bool {
resolve_executable(program, cwd, env).is_some()
}
impl CommandRunner for Sandboxed {
fn run(&self, inv: &Invocation) -> Outcome {
self.run_with_network(inv, self.network_for(&inv.argv))
}
}
pub fn runner(root: &Path, config: &Config) -> Result<(Runner, Option<String>), String> {
if config.mode == Mode::Off {
return Ok((
Runner::Plain,
Some("commands run unsandboxed ([sandbox] mode = \"off\")".to_owned()),
));
}
match backend() {
Ok(backend) => {
let home = std::env::var_os("HOME").map(PathBuf::from);
Ok((
Runner::Sandboxed(Sandboxed {
profile: Profile::new(root, config, home.as_deref()),
backend,
network_allow: config.network_allow.clone(),
network_allow_prefix: config.network_allow_prefix.clone(),
}),
None,
))
}
Err(e) if config.mode == Mode::Required => Err(format!(
"[sandbox] mode = \"required\", but there is no sandbox: {e}"
)),
Err(e) => Ok((
Runner::Plain,
Some(format!("commands run unsandboxed: {e}")),
)),
}
}
pub fn runner_at(root: &Path) -> Result<(Runner, Option<String>), String> {
let text = crate::verify::find_config(root)
.and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
.unwrap_or_default();
let config = parse(&text).map_err(|e| format!("speccoder.toml: {e}"))?;
runner(root, &config)
}
impl Runner {
#[must_use]
pub fn carve(self, read_only: &[PathBuf], hidden: &[PathBuf]) -> (Self, Option<String>) {
match self {
Self::Sandboxed(mut s) => {
if let Backend::Landlock(helper) = &s.backend
&& !mounts_supported(helper)
{
return (
Self::Sandboxed(s),
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()),
);
}
s.profile = s.profile.carve(read_only, hidden);
(Self::Sandboxed(s), None)
}
Self::Plain => (Self::Plain, None),
}
}
}
#[derive(Debug, Clone)]
pub enum Runner {
Sandboxed(Sandboxed),
Plain,
}
impl Runner {
#[must_use]
pub fn is_sandboxed(&self) -> bool {
matches!(self, Self::Sandboxed(_))
}
#[must_use]
pub fn with_writable(self, dir: &Path) -> Self {
match self {
Self::Sandboxed(mut s) => {
s.profile.writable.push(canonical(dir));
Self::Sandboxed(s)
}
Self::Plain => Self::Plain,
}
}
#[must_use]
pub fn allows_network(&self, argv: &[String]) -> bool {
matches!(self, Self::Sandboxed(s) if s.network_for(argv) == Network::On)
}
#[must_use]
pub fn run_with(&self, inv: &Invocation, network: bool) -> Outcome {
match self {
Self::Plain => LocalRunner.run(inv),
Self::Sandboxed(s) if network => s.run_with_network(inv, Network::On),
Self::Sandboxed(s) => s.run(inv),
}
}
#[must_use]
pub fn scratch(&self) -> Option<PathBuf> {
match self {
Self::Plain => None,
Self::Sandboxed(s) => std::fs::create_dir_all(&s.profile.tmp)
.ok()
.map(|()| s.profile.tmp.clone()),
}
}
pub fn wrap(&self, argv: &[String]) -> Result<(Vec<String>, Option<PathBuf>), String> {
match self {
Self::Plain => Ok((argv.to_vec(), None)),
Self::Sandboxed(s) => {
let profile = s.profile.clone().with_network(s.network_for(argv));
std::fs::create_dir_all(&profile.tmp).map_err(|e| e.to_string())?;
Ok((wrap(&profile, &s.backend, argv)?, Some(profile.tmp)))
}
}
}
}
impl CommandRunner for Runner {
fn run(&self, inv: &Invocation) -> Outcome {
self.run_with(inv, false)
}
}
pub fn helper_main(args: &[String]) -> String {
if args.first().map(String::as_str) == Some(PROBE_MOUNTS) {
return match probe_mounts() {
Ok(()) => std::process::exit(0),
Err(e) => e,
};
}
let (Some(profile), Some("--")) = (args.first(), args.get(1).map(String::as_str)) else {
return format!("usage: speccoder {HELPER_ARG} <profile> -- <argv…>");
};
let profile: Profile = match serde_json::from_str(profile) {
Ok(p) => p,
Err(e) => return format!("the sandbox profile: {e}"),
};
let argv = &args[2..];
if argv.is_empty() {
return "nothing to run".to_owned();
}
enter(&profile, argv)
}
#[cfg(target_os = "linux")]
fn enter(profile: &Profile, argv: &[String]) -> String {
linux::enter(profile, argv)
}
const PROBE_MOUNTS: &str = "--probe-mounts";
fn mounts_supported(helper: &Path) -> bool {
static CACHED: OnceLock<bool> = OnceLock::new();
*CACHED.get_or_init(|| {
std::process::Command::new(helper)
.args([HELPER_ARG, PROBE_MOUNTS])
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok_and(|s| s.success())
})
}
#[cfg(target_os = "linux")]
fn probe_mounts() -> Result<(), String> {
linux::probe_mounts()
}
#[cfg(not(target_os = "linux"))]
fn probe_mounts() -> Result<(), String> {
Err("mount carve-outs are for Linux".to_owned())
}
#[cfg(not(target_os = "linux"))]
fn enter(_profile: &Profile, _argv: &[String]) -> String {
format!("the {HELPER_ARG} helper is for Linux")
}
#[cfg(target_os = "linux")]
mod linux {
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use landlock::{
ABI, Access, AccessFs, CompatLevel, Compatible, PathBeneath, PathFd, Ruleset, RulesetAttr,
RulesetCreatedAttr, RulesetStatus,
};
use seccompiler::{
SeccompAction, SeccompCmpArgLen, SeccompCmpOp, SeccompCondition, SeccompFilter,
SeccompRule, TargetArch,
};
use super::{Network, Profile};
const ABI_LEVEL: ABI = ABI::V3;
pub(super) fn probe() -> Result<(), String> {
Ruleset::default()
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(AccessFs::from_all(ABI_LEVEL))
.and_then(|r| r.create())
.map(|_| ())
.map_err(|e| format!("Landlock is not available: {e}"))
}
fn mount_carve(profile: &Profile) -> Result<(), String> {
use nix::mount::{MsFlags, mount};
let inside = |p: &PathBuf| profile.writable.iter().any(|w| p.starts_with(w));
let hidden: Vec<&PathBuf> = profile
.hidden
.iter()
.filter(|h| inside(h) && h.exists())
.collect();
let sockets: Vec<PathBuf> = daemon_sockets()
.into_iter()
.filter(|p| !inside(p))
.collect();
let carved = !profile.read_only.is_empty() || !hidden.is_empty();
if !carved && sockets.is_empty() {
return Ok(());
}
if let Err(e) = private_namespace() {
return if carved { Err(e) } else { Ok(()) };
}
let none: Option<&str> = None;
for p in profile.read_only.iter().filter(|p| p.exists()) {
mount(Some(p), p, none, MsFlags::MS_BIND | MsFlags::MS_REC, none)
.map_err(|e| format!("bind {}: {e}", p.display()))?;
mount(
none,
p,
none,
MsFlags::MS_BIND | MsFlags::MS_REMOUNT | MsFlags::MS_RDONLY | locked(p),
none,
)
.map_err(|e| format!("read-only {}: {e}", p.display()))?;
}
for h in hidden {
if h.is_dir() {
mount(
Some("tmpfs"),
h,
Some("tmpfs"),
MsFlags::MS_RDONLY | MsFlags::MS_NOSUID | MsFlags::MS_NODEV,
Some("size=4k"),
)
} else {
mount(Some("/dev/null"), h, none, MsFlags::MS_BIND, none)
}
.map_err(|e| format!("hide {}: {e}", h.display()))?;
}
for p in &sockets {
mount(Some("/dev/null"), p, none, MsFlags::MS_BIND, none)
.map_err(|e| format!("cover {}: {e}", p.display()))?;
}
Ok(())
}
fn daemon_sockets() -> Vec<PathBuf> {
use std::os::unix::fs::FileTypeExt as _;
fn walk(dir: &Path, depth: u8, out: &mut Vec<PathBuf>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for e in entries.flatten() {
let Ok(t) = e.file_type() else { continue };
if t.is_socket() {
out.push(e.path());
} else if t.is_dir() && depth > 0 {
walk(&e.path(), depth - 1, out);
}
}
}
let mut roots = vec![
PathBuf::from("/run"),
PathBuf::from("/var/run"),
PathBuf::from("/tmp"),
];
if let Some(x) = std::env::var_os("XDG_RUNTIME_DIR") {
roots.push(PathBuf::from(x));
}
let mut seen = std::collections::BTreeSet::new();
let mut out = Vec::new();
for root in roots {
let Ok(real) = root.canonicalize() else {
continue;
};
if !seen.insert(real.clone()) {
continue;
}
walk(&real, 3, &mut out);
}
out.sort();
out.dedup();
out
}
fn private_namespace() -> Result<(), String> {
use nix::mount::{MsFlags, mount};
use nix::sched::{CloneFlags, unshare};
let (uid, gid) = (nix::unistd::getuid(), nix::unistd::getgid());
unshare(CloneFlags::CLONE_NEWUSER | CloneFlags::CLONE_NEWNS)
.map_err(|e| format!("user namespace: {e}"))?;
let write = |file: &str, text: String| {
std::fs::write(file, text).map_err(|e| format!("{file}: {e}"))
};
write("/proc/self/setgroups", "deny".to_owned())?;
write("/proc/self/uid_map", format!("{uid} {uid} 1"))?;
write("/proc/self/gid_map", format!("{gid} {gid} 1"))?;
let none: Option<&str> = None;
mount(none, "/", none, MsFlags::MS_REC | MsFlags::MS_PRIVATE, none)
.map_err(|e| format!("private mounts: {e}"))
}
fn locked(p: &Path) -> nix::mount::MsFlags {
use nix::mount::MsFlags;
use nix::sys::statvfs::{FsFlags, statvfs};
let Ok(st) = statvfs(p) else {
return MsFlags::empty();
};
let f = st.flags();
let mut out = MsFlags::empty();
for (fs, ms) in [
(FsFlags::ST_NOSUID, MsFlags::MS_NOSUID),
(FsFlags::ST_NODEV, MsFlags::MS_NODEV),
(FsFlags::ST_NOEXEC, MsFlags::MS_NOEXEC),
(FsFlags::ST_NOATIME, MsFlags::MS_NOATIME),
(FsFlags::ST_NODIRATIME, MsFlags::MS_NODIRATIME),
(FsFlags::ST_RELATIME, MsFlags::MS_RELATIME),
] {
if f.contains(fs) {
out |= ms;
}
}
out
}
pub(super) fn probe_mounts() -> Result<(), String> {
let dir = std::env::temp_dir().join(format!("speccoder-probe-{}", std::process::id()));
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let profile = Profile {
writable: Vec::new(),
hidden: Vec::new(),
read_only: vec![dir.clone()],
network: Network::On,
tmp: dir.clone(),
};
let carved = mount_carve(&profile);
let refused = carved.is_ok() && std::fs::write(dir.join("x"), "x").is_err();
let _ = std::fs::remove_dir_all(&dir);
carved.and_then(|()| {
refused
.then_some(())
.ok_or_else(|| "the read-only bind did not hold".to_owned())
})
}
fn readable(dir: &Path, hidden: &[PathBuf], out: &mut Vec<PathBuf>) {
if hidden.iter().any(|h| h == dir) {
return;
}
if !hidden.iter().any(|h| h.starts_with(dir)) {
out.push(dir.to_path_buf());
return;
}
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
readable(&e.path(), hidden, out);
}
}
}
pub(super) fn enter(profile: &Profile, argv: &[String]) -> String {
match mount_carve(profile).and_then(|()| confine(profile)) {
Ok(()) => {
use std::os::unix::process::CommandExt as _;
let e = std::process::Command::new(&argv[0]).args(&argv[1..]).exec();
format!("could not start `{}`: {e}", argv[0])
}
Err(e) => format!("the sandbox could not be applied: {e}"),
}
}
fn confine(profile: &Profile) -> Result<(), String> {
let read = AccessFs::from_read(ABI_LEVEL);
let all = AccessFs::from_all(ABI_LEVEL);
let mut readable_paths = Vec::new();
readable(Path::new("/"), &profile.hidden, &mut readable_paths);
let mut ruleset = Ruleset::default()
.set_compatibility(CompatLevel::HardRequirement)
.handle_access(all)
.and_then(|r| r.create())
.map_err(|e| e.to_string())?;
for p in &readable_paths {
if let Ok(fd) = PathFd::new(p) {
let access = if p.is_dir() {
read
} else {
read & AccessFs::from_file(ABI_LEVEL)
};
ruleset = ruleset
.add_rule(PathBeneath::new(fd, access))
.map_err(|e| e.to_string())?;
}
}
for w in profile.writable.iter().chain([&profile.tmp]) {
if let Ok(fd) = PathFd::new(w) {
let access = if w.is_dir() {
all
} else {
AccessFs::from_file(ABI_LEVEL)
};
ruleset = ruleset
.add_rule(PathBeneath::new(fd, access))
.map_err(|e| e.to_string())?;
}
}
for dev in ["/dev/null", "/dev/zero", "/dev/tty", "/dev/ptmx"] {
if let Ok(fd) = PathFd::new(dev) {
ruleset = ruleset
.add_rule(PathBeneath::new(fd, AccessFs::from_file(ABI_LEVEL)))
.map_err(|e| e.to_string())?;
}
}
let status = ruleset.restrict_self().map_err(|e| e.to_string())?;
if status.ruleset != RulesetStatus::FullyEnforced {
return Err(format!("Landlock only {:?}", status.ruleset));
}
if profile.network != Network::On {
no_inet()?;
}
Ok(())
}
fn no_inet() -> Result<(), String> {
let rule = |domain: u64| {
SeccompCondition::new(0, SeccompCmpArgLen::Dword, SeccompCmpOp::Eq, domain)
.and_then(|c| SeccompRule::new(vec![c]))
.map_err(|e| e.to_string())
};
let rules = vec![
rule(u64::from(libc::AF_INET.unsigned_abs()))?,
rule(u64::from(libc::AF_INET6.unsigned_abs()))?,
];
let arch: TargetArch = std::env::consts::ARCH
.try_into()
.map_err(|e: seccompiler::BackendError| e.to_string())?;
let filter = SeccompFilter::new(
BTreeMap::from([(libc::SYS_socket, rules)]),
SeccompAction::Allow,
SeccompAction::Errno(libc::EACCES.unsigned_abs()),
arch,
)
.map_err(|e| e.to_string())?;
let program: seccompiler::BpfProgram = filter
.try_into()
.map_err(|e: seccompiler::BackendError| e.to_string())?;
seccompiler::apply_filter(&program).map_err(|e| e.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn config_parses_and_defaults_to_loopback() {
assert_eq!(parse("").unwrap_or_default(), Config::default());
let c = parse("[sandbox]\nmode = \"required\"\nnetwork = \"off\"\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nwritable = [\"~/.m2\"]\n")
.unwrap_or_default();
assert_eq!((c.mode, c.network), (Mode::Required, Network::Off));
assert!(parse("[sandbox]\nnetwork = \"some\"\n").is_err());
}
#[test]
fn the_profile_keeps_the_state_directory_and_cargo_bin_out() {
let home = Path::new("/home/u");
let p = Profile::new(
Path::new("/s/proj/worktrees/001"),
&Config::default(),
Some(home),
);
assert_eq!(p.writable[0], Path::new("/s/proj/worktrees/001"));
assert!(
!p.writable
.iter()
.any(|w| w == Path::new("/s/proj") || w == &home.join(".cargo"))
);
assert!(p.hidden.contains(&home.join(".ssh")));
let sb = seatbelt(&p);
assert!(sb.contains("(deny network*)"));
assert!(sb.contains("(subpath \"/home/u/.cargo/registry\")"));
assert!(!sb.contains("(local ip \"localhost:*\"))\n(allow network*"));
}
#[test]
fn the_executable_is_the_first_on_the_path_canonicalised() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let root = std::fs::canonicalize(dir.path()).unwrap();
for d in ["bin", "usr"] {
std::fs::create_dir_all(root.join(d)).unwrap();
let f = root.join(d).join("make");
std::fs::write(&f, "#!/bin/sh\n").unwrap();
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let env = |path: &str| vec![("PATH".to_owned(), path.to_owned())];
let usr = format!("{}", root.join("usr").display());
assert_eq!(
resolve_executable("make", &root, &env(&usr)),
Some(root.join("usr/make"))
);
assert_eq!(
resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
Some(root.join("bin/make"))
);
assert_eq!(
resolve_executable("./bin/make", &root, &env("")),
Some(root.join("bin/make"))
);
assert_eq!(resolve_executable("nope", &root, &env(&usr)), None);
assert!(!resolvable("nope", &root, &env(&usr)));
std::fs::set_permissions(
root.join("bin/make"),
std::fs::Permissions::from_mode(0o644),
)
.unwrap();
assert_eq!(
resolve_executable("make", &root, &env(&format!("bin:{usr}"))),
Some(root.join("usr/make"))
);
assert_eq!(resolve_executable("./bin/make", &root, &env("")), None);
}
#[test]
fn network_allow_is_exact_and_a_prefix_needs_two_elements() {
let c = parse("[sandbox]\nnetwork_allow = [[\"cargo\", \"fetch\"]]\nnetwork_allow_prefix = [[\"npm\", \"ci\"]]\n").unwrap();
let s = Sandboxed {
profile: Profile::new(Path::new("/w"), &c, None),
backend: Backend::Seatbelt,
network_allow: c.network_allow.clone(),
network_allow_prefix: c.network_allow_prefix.clone(),
};
let argv = |s: &str| s.split(' ').map(str::to_owned).collect::<Vec<_>>();
assert_eq!(s.network_for(&argv("cargo fetch")), Network::On);
assert_ne!(s.network_for(&argv("cargo fetch --x")), Network::On);
assert_eq!(s.network_for(&argv("npm ci --silent")), Network::On);
assert!(parse("[sandbox]\nnetwork_allow_prefix = [[\"npm\"]]\n").is_err());
}
}