Set GIT_AUTHOR_*/GIT_COMMITTER_* from Settings → Account → Git (also inside the sandbox), so a repository's own user.name/user.email no longer wins; tell agents not to change the git identity. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
181 lines
6.7 KiB
Rust
181 lines
6.7 KiB
Rust
//! 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"])
|
||
.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"]);
|
||
}
|
||
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<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");
|
||
}
|
||
}
|