Files
codecraft/server/src/sandbox.rs
T

190 lines
7.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Per-user sandbox for shell commands (and git) using bubblewrap.
//!
//! A sandboxed command sees the system read-only, only the user's folders
//! writable, its own home directory, a private /tmp, its own process
//! namespace and an empty environment (no server secrets). The network is
//! shared so package managers keep working.
use crate::app::App;
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use tokio::process::Command;
/// What a sandboxed process may access.
#[derive(Debug, Clone)]
pub struct SandboxSpec {
pub username: String,
/// Writable directories (the user's roots).
pub roots: Vec<PathBuf>,
/// Host directory mounted as the user's home.
pub home: PathBuf,
/// Extra environment (the user's git identity).
pub env: Vec<(String, String)>,
}
impl SandboxSpec {
pub fn home_inside(&self) -> String {
format!("/home/{}", self.username)
}
}
static AVAILABLE: OnceLock<Result<(), String>> = OnceLock::new();
/// Check once whether bubblewrap works in this environment.
pub async fn probe() -> Result<(), String> {
if let Some(r) = AVAILABLE.get() {
return r.clone();
}
let res = match Command::new("bwrap")
.args(["--ro-bind", "/usr", "/usr", "--ro-bind", "/bin", "/bin", "--ro-bind", "/lib", "/lib", "--dev", "/dev", "--proc", "/proc"])
// The dynamic loader lives in /lib64 on x86_64 (/lib on arm64).
.args(["--ro-bind-try", "/lib64", "/lib64", "--ro-bind-try", "/lib32", "/lib32"])
.args(["--unshare-all", "--die-with-parent", "--clearenv", "/bin/true"])
.env_clear()
.output()
.await
{
Ok(o) if o.status.success() => Ok(()),
Ok(o) => Err(String::from_utf8_lossy(&o.stderr).trim().to_string()),
Err(e) => Err(format!("bubblewrap is not installed: {e}")),
};
match &res {
Ok(()) => tracing::info!("sandbox: bubblewrap available"),
Err(e) => tracing::warn!("sandbox: bubblewrap unavailable – shell commands of non-admin users are disabled ({e})"),
}
let _ = AVAILABLE.set(res.clone());
res
}
pub fn available() -> bool {
matches!(AVAILABLE.get(), Some(Ok(())))
}
/// The sandbox for a session owner, or None if commands run unsandboxed
/// (admins in "auto" mode, or sandbox mode "off").
pub fn spec_for(app: &App, owner_id: Option<&str>) -> Option<SandboxSpec> {
let mode = app.config().sandbox.mode.clone();
if mode == "off" {
return None;
}
let user = owner_id.and_then(|id| app.store.user(id).ok().flatten())?;
if user.is_admin() && mode != "always" {
return None;
}
let home = app.data_dir().join("users").join(&user.username).join("home");
let _ = std::fs::create_dir_all(&home);
Some(SandboxSpec { username: user.username.clone(), roots: app.user_roots(Some(&user.id)), home, env: crate::gitcreds::identity_env(app, &user.id) })
}
/// Build a command that runs `program args` inside the sandbox in `cwd`.
pub fn command(spec: &SandboxSpec, cwd: &Path, program: &str, args: &[&str]) -> Command {
command_with(spec, cwd, &[], program, args)
}
/// Like `command`, with extra host directories mounted read-only (e.g. tools).
pub fn command_with(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], program: &str, args: &[&str]) -> Command {
let mut c = Command::new("bwrap");
// Empty environment: bubblewrap's own helper process must not carry secrets.
c.env_clear();
c.args(bwrap_args(spec, cwd, extra_ro, true, program, args));
c
}
/// Arguments for `bwrap` running `program args` in the sandbox. `new_session`
/// detaches from the controlling terminal (protects the caller's tty); a
/// terminal with its own PTY leaves it off to keep job control.
pub fn bwrap_args(spec: &SandboxSpec, cwd: &Path, extra_ro: &[&Path], new_session: bool, program: &str, args: &[&str]) -> Vec<std::ffi::OsString> {
let mut c: Vec<std::ffi::OsString> = Vec::new();
let mut push = |items: &[&dyn AsRef<std::ffi::OsStr>]| {
for i in items {
c.push(i.as_ref().to_os_string());
}
};
for dir in ["/usr", "/bin", "/lib", "/etc", "/sbin", "/opt"] {
if Path::new(dir).is_dir() && !Path::new(dir).is_symlink() {
push(&[&"--ro-bind", &dir, &dir]);
} else if let Ok(target) = std::fs::read_link(dir) {
push(&[&"--symlink", &target, &dir]);
}
}
for dir in ["/lib64", "/lib32"] {
let p = Path::new(dir);
if let Ok(target) = std::fs::read_link(p) {
push(&[&"--symlink", &target, &dir]);
} else if p.is_dir() {
push(&[&"--ro-bind", &dir, &dir]);
}
}
for root in &spec.roots {
push(&[&"--bind", root, root]);
}
// Built-in skills and templates are readable (not writable) in the sandbox.
let builtin = crate::prompts::builtin_resources();
for dir in extra_ro.iter().copied().chain(std::iter::once(builtin.as_path())) {
if dir.exists() {
push(&[&"--ro-bind", &dir, &dir]);
}
}
let home = spec.home_inside();
push(&[&"--bind", &spec.home, &home]);
push(&[&"--tmpfs", &"/tmp", &"--dev", &"/dev", &"--proc", &"/proc"]);
push(&[&"--unshare-all", &"--share-net", &"--die-with-parent", &"--clearenv"]);
if new_session {
push(&[&"--new-session"]);
}
// SSH wrappers (user's keys); only when keys are set up.
let ssh_wrapper = spec.home.join(crate::sshkeys::SANDBOX_BIN).join("ssh");
let ssh_bin = format!("{home}/{}", crate::sshkeys::SANDBOX_BIN);
let path = format!("{}{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin", if ssh_wrapper.exists() { format!("{ssh_bin}:") } else { String::new() });
let npm = format!("{home}/.npm-global");
let pipx = format!("{home}/.local/pipx");
let pipx_bin = format!("{home}/.local/bin");
for (k, v) in [
("HOME", home.as_str()),
("USER", spec.username.as_str()),
("PATH", path.as_str()),
("LANG", "C.UTF-8"),
("TERM", "xterm-256color"),
("CODECRAFT", "1"),
("GIT_TERMINAL_PROMPT", "0"),
("PAGER", "cat"),
("PIP_BREAK_SYSTEM_PACKAGES", "1"),
("NPM_CONFIG_PREFIX", npm.as_str()),
("PIPX_HOME", pipx.as_str()),
("PIPX_BIN_DIR", pipx_bin.as_str()),
] {
push(&[&"--setenv", &k, &v]);
}
if ssh_wrapper.exists() {
let cmd = format!("{ssh_bin}/ssh");
push(&[&"--setenv", &"GIT_SSH_COMMAND", &cmd]);
}
for (k, v) in &spec.env {
push(&[&"--setenv", k, v]);
}
push(&[&"--chdir", &cwd, &program]);
for a in args {
push(&[a]);
}
c
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn git_identity_reaches_the_sandbox() {
let spec = SandboxSpec {
username: "ann".into(),
roots: vec![],
home: PathBuf::from("/tmp/ann"),
env: vec![("GIT_AUTHOR_NAME".into(), "Ann Example".into())],
};
let args: Vec<String> = bwrap_args(&spec, Path::new("/tmp"), &[], true, "bash", &[]).into_iter().map(|a| a.to_string_lossy().into_owned()).collect();
let i = args.iter().position(|a| a == "GIT_AUTHOR_NAME").expect("identity set");
assert_eq!(args[i - 1], "--setenv");
assert_eq!(args[i + 1], "Ann Example");
}
}