//! 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, /// 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> = 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"]) .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 { 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 { let mut c: Vec = Vec::new(); let mut push = |items: &[&dyn AsRef]| { 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"]); } let path = format!("{home}/.local/bin:{home}/.cargo/bin:{home}/.npm-global/bin:/usr/local/bin:/usr/bin:/bin"); 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]); } 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 = 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"); } }