Files
codecraft/server/src/sandbox.rs
T
Richard ZinkandClaude Opus 5.5 0f4bfa0ab0 Git identity from the settings for all agent shells, terminals and the git panel
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>
2026-11-09 12:22:25 +01:00

181 lines
6.7 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"])
.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");
}
}