From aab25a5a007a2083971ea3eb816497fef2a44196 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 1 Nov 2026 19:11:48 +0100 Subject: [PATCH] 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 --- crates/mind-body/src/self_run.rs | 17 +++++++++++------ crates/mind-body/src/self_run/tests.rs | 6 ++++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/crates/mind-body/src/self_run.rs b/crates/mind-body/src/self_run.rs index 78242f3..da6eb8d 100644 --- a/crates/mind-body/src/self_run.rs +++ b/crates/mind-body/src/self_run.rs @@ -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 { 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 { "--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, diff --git a/crates/mind-body/src/self_run/tests.rs b/crates/mind-body/src/self_run/tests.rs index 916d889..ea9cd91 100644 --- a/crates/mind-body/src/self_run/tests.rs +++ b/crates/mind-body/src/self_run/tests.rs @@ -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 = [ - "--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")); }