sandbox: the scratch is read-only to her, only claude-config is writable

Soul s1 scratch-symlink-deputy-write: she could plant a link at the
instructions file in the unit's RUNTIME_DIRECTORY and the organ's next write
followed it onto any path the unit's user can write, the hidden trace included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-11-01 19:11:48 +01:00
commit aab25a5a00
2 changed files with 15 additions and 8 deletions

View file

@ -695,13 +695,17 @@ impl Spawner for ClaudeSpawner {
}
/// The bubblewrap arguments up to the program: the whole filesystem read-only, a private `/tmp`
/// and `/run`, the unit's scratch directory and her sandbox writable, her unit's credentials
/// (`/etc/gamecult`, the other `/var/lib` directories, the lease socket under `/run`) hidden, a
/// private process table, and the child dead when the unit's process is. The network is shared: the
/// web, `huginn` and the API are reachable. The boundary is the operating system's, not a pattern list
/// (ruling `eureka-body:ruling:persona-git-personal-sandbox`).
/// and `/run`, the unit's scratch directory read-only with only its `claude-config` writable (she
/// cannot create, replace or link a file where the organ writes, such as the instructions file),
/// her sandbox writable, her unit's credentials (`/etc/gamecult`, the other `/var/lib`
/// directories, the lease socket under `/run`) hidden, a private process table, and the child dead
/// when the unit's process is. The network is shared: the web, `huginn` and the API are reachable,
/// and what she reaches on the host is the uid fence's (gamecult-ops `mind-fence.service`), which
/// binds the organ and this sandbox alike. The boundary is the operating system's, not a pattern
/// list (ruling `eureka-body:ruling:persona-git-personal-sandbox`).
pub(crate) fn sandbox_args(sandbox: &Path, scratch: &Path) -> Vec<String> {
let (sandbox, scratch) = (sandbox.to_string_lossy(), scratch.to_string_lossy());
let config = scratch.to_string() + "/" + CONFIG_DIR_NAME;
[
"--ro-bind", "/", "/",
"--dev", "/dev",
@ -709,7 +713,8 @@ pub(crate) fn sandbox_args(sandbox: &Path, scratch: &Path) -> Vec<String> {
"--tmpfs", "/tmp",
"--tmpfs", "/run",
"--ro-bind-try", "/run/systemd/resolve", "/run/systemd/resolve",
"--bind", &scratch, &scratch,
"--ro-bind", &scratch, &scratch,
"--bind", &config, &config,
"--tmpfs", "/etc/gamecult",
"--tmpfs", "/var/lib",
"--bind", &sandbox, &sandbox,

View file

@ -1697,8 +1697,8 @@ mod sandbox {
fn the_sandbox_arguments_are_pinned() {
let args = sandbox_args(Path::new("/var/lib/mind-x/sandbox"), Path::new("/run/mind-x"));
let expected: Vec<String> = [
"--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--tmpfs", "/run", "--ro-bind-try", "/run/systemd/resolve", "/run/systemd/resolve", "--bind", "/run/mind-x", "/run/mind-x", "--tmpfs",
"/etc/gamecult", "--tmpfs", "/var/lib", "--bind", "/var/lib/mind-x/sandbox", "/var/lib/mind-x/sandbox", "--unshare-pid", "--die-with-parent", "--new-session", "--chdir", "/var/lib/mind-x/sandbox", "--",
"--ro-bind", "/", "/", "--dev", "/dev", "--proc", "/proc", "--tmpfs", "/tmp", "--tmpfs", "/run", "--ro-bind-try", "/run/systemd/resolve", "/run/systemd/resolve", "--ro-bind", "/run/mind-x", "/run/mind-x", "--bind",
"/run/mind-x/claude-config", "/run/mind-x/claude-config", "--tmpfs", "/etc/gamecult", "--tmpfs", "/var/lib", "--bind", "/var/lib/mind-x/sandbox", "/var/lib/mind-x/sandbox", "--unshare-pid", "--die-with-parent", "--new-session", "--chdir", "/var/lib/mind-x/sandbox", "--",
]
.map(String::from)
.to_vec();
@ -1706,6 +1706,8 @@ mod sandbox {
// The credentials are hidden before the sandbox and the scratch are bound back, and the network is not unshared.
let at = |needle: &str| args.iter().position(|arg| arg == needle).unwrap();
assert!(at("/run") < at("/run/mind-x") && at("/var/lib") < at("/var/lib/mind-x/sandbox"));
// The scratch is read-only to her: no plain bind of it, so she cannot plant a link where the organ writes.
assert!(!args.windows(3).any(|w| w[0] == "--bind" && w[1] == "/run/mind-x"), "{args:?}");
assert!(!args.iter().any(|arg| arg == "--unshare-net" || arg == "--unshare-all"));
}