From f6de57650893580d58723c1d1563c9c7deaab39c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:09:52 +0000 Subject: [PATCH 01/23] Daemon: sandbox a bot's commands, writes and edits as Codex does Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- .github/workflows/test.yml | 18 +- NOTICE | 12 + docs/CLI.md | 5 +- docs/DAEMON_MEASUREMENTS.md | 32 + docs/RUST_PROTOTYPE.md | 65 ++ src/cli.rs | 21 +- src/client.rs | 33 + src/lib.rs | 1 + src/main.rs | 5 + src/provider.rs | 2 +- src/sandbox.rs | 883 +++++++++++++++++++ src/sandbox/seatbelt_base_policy.sbpl | 116 +++ src/sandbox/seatbelt_network_policy.sbpl | 31 + src/sandbox/seatbelt_preferences_policy.sbpl | 8 + src/server/mod.rs | 73 +- src/server/turn.rs | 93 +- src/store.rs | 8 +- src/store/db.rs | 207 ++++- src/tools.rs | 145 ++- src/tools/files.rs | 59 +- tests/store_contract.rs | 198 ++++- tests/test_approvals.py | 62 +- tests/test_runtime.py | 6 + tests/tool_contract.rs | 358 +++++++- 24 files changed, 2345 insertions(+), 96 deletions(-) create mode 100644 NOTICE create mode 100644 src/sandbox.rs create mode 100644 src/sandbox/seatbelt_base_policy.sbpl create mode 100644 src/sandbox/seatbelt_network_policy.sbpl create mode 100644 src/sandbox/seatbelt_preferences_policy.sbpl diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e304f53b..374a30250 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,6 +28,8 @@ jobs: shell: bash env: CARGO_TERM_COLOR: never + # Sandbox tests fail here, rather than skip, when no sandbox runs. + AGENT_TEST_SANDBOX: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -38,9 +40,13 @@ jobs: run: cargo clippy --locked --workspace --all-targets -- -D warnings - name: Rust tests run: | - # Sonny's SandFS task copies mount through FUSE. + # Sonny's SandFS task copies mount through FUSE, and sandboxed + # commands run in bubblewrap. sudo apt-get update - sudo apt-get install -y fuse3 + sudo apt-get install -y fuse3 bubblewrap + # Ubuntu 24.04 keeps the unprivileged user namespaces bubblewrap + # needs behind AppArmor. + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 cargo test --locked --workspace - name: Release script tests run: python3 -m unittest discover -s release @@ -53,6 +59,8 @@ jobs: shell: bash env: CARGO_TERM_COLOR: never + # Sandbox tests fail here, rather than skip, when no sandbox runs. + AGENT_TEST_SANDBOX: "1" steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -60,6 +68,12 @@ jobs: - name: Build the daemon and Sonny # The suite runs the release binaries in .local/target/release. run: cargo build --release --locked --workspace --bins + - name: Install bubblewrap + # Sandboxed commands run in it; see the rust job for the sysctl. + run: | + sudo apt-get update + sudo apt-get install -y bubblewrap + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - name: Runtime tests env: AGENT_TEST_RUNTIME: "1" diff --git a/NOTICE b/NOTICE new file mode 100644 index 000000000..625edfbc2 --- /dev/null +++ b/NOTICE @@ -0,0 +1,12 @@ +agent +Copyright (c) 2026 George Lydakis + +This product includes software from OpenAI Codex +(https://github.com/openai/codex), Copyright 2025 OpenAI, licensed under the +Apache License, Version 2.0: + +- src/sandbox/seatbelt_base_policy.sbpl, src/sandbox/seatbelt_network_policy.sbpl + and src/sandbox/seatbelt_preferences_policy.sbpl, copied from + codex-rs/sandboxing/src. +- The bubblewrap arguments and the Seatbelt profile that src/sandbox.rs builds, + ported from codex-rs/linux-sandbox and codex-rs/sandboxing. diff --git a/docs/CLI.md b/docs/CLI.md index 838d90e92..f6e71ea87 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -160,8 +160,9 @@ identity is `bot_not_found`. setting, applied after each of its turns. - `run` sets a new bot's own settings with `--context-bytes`, `--context-items`, `--note-turns`, `--compact-at`, `--compact-keep`, `--compact-tokens`, `--keep-turns`, - `--approval-hold`, `--max-output-tokens`, `--keep-warm`, and - `--cache-ttl`; they are not daemon options, and an existing bot + `--approval-hold`, `--max-output-tokens`, `--keep-warm`, `--cache-ttl`, + `--sandbox`, `--writable` (repeatable, made absolute from the current + folder) and `--network`; they are not daemon options, and an existing bot keeps its own (see [bot settings](RUST_PROTOTYPE.md#bot-settings)). The lightweight command registry in `src/cli.rs` supplies help and option scope diff --git a/docs/DAEMON_MEASUREMENTS.md b/docs/DAEMON_MEASUREMENTS.md index 754532ab1..0aa1852fb 100644 --- a/docs/DAEMON_MEASUREMENTS.md +++ b/docs/DAEMON_MEASUREMENTS.md @@ -5317,3 +5317,35 @@ pool closed on every refusal costs: at 5 % refused, both builds fall from about 285 to about 20 turns/s with p50 latency near 3 s. That is the pacer behaving as designed today; whether one refusal should close the pool is left to a pacer change. + +## Sandbox (#265) + +The sandbox adds work only to bots that set `sandbox`: their schema gains two +fields, a confined call builds a policy off the runtime thread, and a +confined command runs under bwrap or Seatbelt. An unsandboxed bot pays one +boolean check per call. This screen checks that the paths every bot takes +held. `cd58f3d` (main) against `fc13380` (this branch), release builds of +`-p agent-runtime --bin agent`, one Linux x86_64 container, synthetic +provider, two alternating rounds: + +| Measure | main | branch | +| --- | ---: | ---: | +| Completion burst p99 ms | 12.9 | 13.7 | +| 32 lone submits, daemon CPU ms | 44.6 | 46.6 | +| 32 lone submits, reply p50 ms | 1.63 | 1.64 | +| 32 burst submits, reply p99 ms | 6.42 | 6.24 | +| CPU per text turn (footprint), ms | 1.25 | 1.32 | +| Store jobs per text turn | 7.53 | 7.53 | +| Idle RSS (footprint), MiB | 17.3 | 17.7 | +| RSS after 1000 turns, MiB | 26.3 | 27.2 | +| 32-bot socket echo, CPU s | 1.19 | 1.12 | +| 32-bot socket echo, peak RSS MiB | 23.1 | 23.5 | +| 32-bot socket echo, turn p95 ms | 615 | 603 | + +Medians of 6 (completion, lifecycle), 10 (admission) or 4 (footprint) +measured runs. Latency, CPU and store work overlap between builds. Idle RSS +is about 0.4 MiB higher with ranges that do not overlap (17.09 to 17.34 +against 17.59 to 17.90). `/proc//smaps` of idle daemons puts all of it +in the executable's own mapped pages, 0.4 to 0.6 MiB more resident, with the +heap the same to the KiB: the binary is 200 KiB larger, and nothing is +allocated at startup. Sandboxed bots and macOS are not measured. diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index 43907d244..5426e0b97 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -404,6 +404,10 @@ keep-warm when it sets any of them. | `max_output_tokens` (`--max-output-tokens`) | Generated tokens per model call, including reasoning, summaries included. Anthropic calls use the model's full output limit (read inside Bedrock ids) unless this is set; set, it is sent as `max_tokens`, at least 2,048 so a legacy thinking budget of 1,024 or more fits beside the answer. Bedrock deducts input plus this bound from quota when a call starts, so a bound near real output buys throughput there. | none | | `keep_warm` (`--keep-warm`) | Seconds an Anthropic prompt cache may sit unread while a turn runs a tool before it is refreshed (see [keeping the cache warm](#keeping-the-anthropic-cache-warm)). Below 300; 0 never refreshes. | 240 | | `cache_ttl` (`--cache-ttl`) | Anthropic prompt-cache lifetime, `5m` or `1h`, on both cache markers. `1h` bills each write at twice the input rate instead of 1.25 times and sends no refreshes. Responses providers are unaffected. Bedrock takes `1h` for Claude 4.5 and later and may refuse it for older Claude (its prompt-caching guide, read 2026-10-28), so calls to older Claude there keep five minutes whatever this says, also after `configure` switches a bot to one. | `5m` | +| `sandbox` (`--sandbox`) | How the bot's commands, writes and edits are confined, by Codex's names: `read-only`, `workspace-write` or `danger-full-access` (see [sandbox](#sandbox)). | none | +| `writable` (`--writable`, repeatable) | Absolute folders a `workspace-write` bot may write besides its own; each must exist and is stored by its real path. At most 64, 32 KiB as JSON. | none | +| `network` (`--network`) | A `workspace-write` bot's commands may use the network. | off | +| `within` | Absolute folders a `workspace-write` bot's writable folders must lie in, by their real paths; one outside them all is not writable. Set from the creator for a bot a sandboxed bot makes (see [Sandbox](#sandbox)). Limits as `writable`. | none | Provider requests multiplex over HTTP/2, and one connection carries at most the 100 streams the provider advertises; the HTTP layer queues the rest, so a @@ -2084,6 +2088,67 @@ cancelled turn. Without a committed result the tool outcome is unknown, not a claim that all work stopped. The turn ends `interrupted` and the bot stays usable; history tells the model to inspect current state before retrying. +### Sandbox + +A bot created with `sandbox` runs its `shell` commands, `write`s and `edit`s +confined, as Codex confines its agent: the mode names, the writable folders, +the folders kept read-only inside them, the bubblewrap arguments on Linux and +the Seatbelt profile on macOS are Codex's, ported (see NOTICE). Reading stays +open. `read-only` writes nothing; `workspace-write` writes the bot's folder +(its stored workspace, else the turn's), each `writable` folder, `$TMPDIR` +and `/tmp`. Inside each writable folder, `.git` (and a linked worktree's own +git folder when the folder holds it), `.agents`, `.codex` and `.aws` stay +read-only, and a missing `.git`, `.agents` or `.codex` may not be made: git +runs hooks and config from there outside the sandbox, and agents trust what +is there. The daemon's store and the files beside it stay read-only too. A +protected folder that is a symlink refuses the call with +`sandbox_unavailable`, since the link could be swapped from inside. Network is +off unless `network` is set; the daemon's socket stays reachable, so a +sandboxed bot can still delegate. `danger-full-access`, or no `sandbox`, runs +unconfined. + +Creating a sandboxed bot checks that sandboxes run here and refuses with +`sandbox_unavailable` otherwise: on Linux, a system bubblewrap +(`/usr/bin/bwrap`, `/bin/bwrap`, `/usr/local/bin/bwrap` or +`/run/current-system/sw/bin/bwrap`, never one found on `PATH`, which could +lie in a folder a bot may write) must make the user, pid and network +namespaces; on +macOS, `/usr/bin/sandbox-exec` must exist. A sandboxed bot never runs +unconfined for want of one. A `writable` folder that doesn't exist is +`path_not_found`. + +A confined command that touches what it may not fails as it would on a +read-only filesystem, and the model sees that result. A confined `write` or +`edit` is checked against the same folders by the target's real path and +refused with `write_denied`; the write itself then runs inside the sandbox, +as Codex runs `apply_patch`, in the agent binary started as +`agent --agent-run-as-write PATH` with the content on stdin, so a link +swapped in after the check can't carry it out. To leave the sandbox, a shell call sets +`sandbox_permissions: "require_escalated"` with a `justification`, which a +sandboxed bot's `shell` schema shows with Codex's descriptions; a `write` or +`edit` leaves it by naming a path outside its writable folders. Only such an +escape is gated: a confined call runs without a verdict even under a gate, +and an escape waits on the bot's gate as any gated call does +(see [tool approval](#tool-approval)) and, allowed, runs unconfined. With no +gate on the tool there is no one to ask, so, as Codex refuses an escape under +a policy that never asks, a command asking to escalate is refused with +`escalation_refused` and a write or edit stays held to the sandbox +(`write_denied`). Whether a call escapes is read +from its arguments and the folders' names alone, so the round that announces +it and the run that starts it agree. + +A bot can't make its way out of its sandbox. A bot that a sandboxed bot +makes (its `created_by`), or forks, takes the creator's sandbox unless it +names a stricter one, reaches the network only if both do, and writes only +within the creator's own roots: its `within` is the creator's folder and +`writable` folders, as Codex's subagents inherit their parent's sandbox. +`danger-full-access` or no `sandbox` there gives the creator's sandbox, +`writable` and `network`. + +On Linux, bubblewrap makes an empty folder on the host to mount a missing +`.git` read-only over; the daemon removes it once no running sandbox holds +it, and a detached command holds it until it exits. + ### Declared tools Protocol version 19 lets a client declare tools of its own on a bot: diff --git a/src/cli.rs b/src/cli.rs index a64ea070f..5480db9ef 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -21,7 +21,7 @@ const COMMANDS: &[Command] = &[ about: "Send a prompt to a bot, or make one with --new; prints the turn as it runs", usage: "run [OPTIONS] [--] PROMPT...", flags: "--bot --new --fork --allow --expires --detach --delivery --turn --model --workspace --effort --turn-budget-tokens --image --agents --profile --request-id --bot-id --pretty --no-spawn", - settings: "--tools --instructions --instructions-file --budget-tokens --compaction-instructions --compaction-instructions-file --compaction-model --no-compaction --fallbacks --approval --approve --context-bytes --context-items --note-turns --compact-at --compact-keep --compact-tokens --keep-turns --approval-hold --max-output-tokens --keep-warm --cache-ttl", + settings: "--tools --instructions --instructions-file --budget-tokens --compaction-instructions --compaction-instructions-file --compaction-model --no-compaction --fallbacks --approval --approve --context-bytes --context-items --note-turns --compact-at --compact-keep --compact-tokens --keep-turns --approval-hold --max-output-tokens --keep-warm --cache-ttl --sandbox --writable --network", startup: true, }, Command { @@ -367,6 +367,15 @@ fn print_flags(flags: &str) { "A new bot's summarizer; default: its own model", ), "--no-compaction" => ("", "Create the bot without compaction"), + "--sandbox" => ( + "MODE", + "A new bot's command sandbox: read-only, workspace-write or danger-full-access; default none", + ), + "--writable" => ( + "PATH", + "Another folder a workspace-write bot may change; repeat for more", + ), + "--network" => ("", "A workspace-write bot's commands may use the network"), "--fallbacks" => ( "", "A new bot's declined Anthropic requests rerun on the recommended model", @@ -533,7 +542,9 @@ pub fn prepare(args: Vec) -> Result>> { if !allowed { return fail_with("usage", format!("{} does not accept {flag}", c.name)); } - if flags.iter().any(|f| f == flag) && !matches!(flag, "--provider" | "--image") { + if flags.iter().any(|f| f == flag) + && !matches!(flag, "--provider" | "--image" | "--writable") + { return fail_with("usage", format!("{flag} may only be specified once")); } flags.push(flag.to_owned()); @@ -552,6 +563,7 @@ pub fn prepare(args: Vec) -> Result>> { | "--no-compaction" | "--agents" | "--fallbacks" + | "--network" | "--discover" ) { if inline.is_some() { @@ -677,6 +689,9 @@ pub fn prepare(args: Vec) -> Result>> { "--profile", "--budget-tokens", "--fallbacks", + "--sandbox", + "--writable", + "--network", "--approval", "--approve", ] @@ -685,7 +700,7 @@ pub fn prepare(args: Vec) -> Result>> { { return fail_with( "usage", - "instructions, budget, and approval are creation options; use --new", + "instructions, budget, approval and sandbox are creation options; use --new", ); } Ok(Some(out)) diff --git a/src/client.rs b/src/client.rs index 27ea23b1f..3a89f1985 100644 --- a/src/client.rs +++ b/src/client.rs @@ -218,6 +218,10 @@ fn parse(args: &[String]) -> Result { "--full" => options.full = true, "--active" => options.active = true, "--newest" => options.newest = true, + "--network" => { + options.settings.insert("network".to_owned(), json!(true)); + options.settings_flags.push("--network".to_owned()); + } "--" => options.positional.extend(iter.by_ref().cloned()), flag if flag.starts_with("--") => { let value = iter @@ -361,6 +365,35 @@ fn parse(args: &[String]) -> Result { .insert("keep_warm".to_owned(), json!(seconds)); options.settings_flags.push(flag.to_owned()); } + "--sandbox" => { + if !matches!( + value.as_str(), + "read-only" | "workspace-write" | "danger-full-access" + ) { + return fail_with( + "usage", + "--sandbox needs read-only, workspace-write or danger-full-access", + ); + } + options.settings.insert("sandbox".to_owned(), json!(value)); + options.settings_flags.push(flag.to_owned()); + } + "--writable" => { + let path = std::path::absolute(&value)?; + let path = path + .to_str() + .ok_or(Error::with("usage", "--writable needs a UTF-8 path"))?; + let writable = options + .settings + .entry("writable".to_owned()) + .or_insert_with(|| json!([])); + if let Value::Array(paths) = writable { + paths.push(json!(path)); + } + if !options.settings_flags.iter().any(|f| f == flag) { + options.settings_flags.push(flag.to_owned()); + } + } "--cache-ttl" => { if !matches!(value.as_str(), "5m" | "1h") { return fail_with("usage", "--cache-ttl needs 5m or 1h"); diff --git a/src/lib.rs b/src/lib.rs index 6788192c4..5c07e6a55 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ pub mod codec; pub mod output; pub mod provider; +pub mod sandbox; pub mod sse; pub mod store; pub mod tools; diff --git a/src/main.rs b/src/main.rs index 8e84d1fca..e6474e28e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -95,6 +95,11 @@ fn run() -> Result { cli::help(args.get(1).map(String::as_str))?; return Ok(0); } + // A sandboxed bot's write, which the daemon runs in the sandbox. + Some(agent_runtime::sandbox::WRITE) if args.len() == 2 => { + agent_runtime::sandbox::write(std::path::Path::new(&args[1]))?; + return Ok(0); + } None => return fail_with("usage", "a command is required; use agent --help"), _ => {} } diff --git a/src/provider.rs b/src/provider.rs index ea7e6306a..764df5f9b 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -2079,7 +2079,7 @@ mod tests { Provider::new(transport.clone(), family, "http://example.test", None).unwrap(); let schemas = crate::tools::Registry::all() .unwrap() - .encoded(family, &["echo".into()], &[]) + .encoded(family, &["echo".into()], &[], false) .unwrap(); for allow in [true, false] { for tools in [&*schemas, &*none()] { diff --git a/src/sandbox.rs b/src/sandbox.rs new file mode 100644 index 000000000..e371678f0 --- /dev/null +++ b/src/sandbox.rs @@ -0,0 +1,883 @@ +//! Confining a bot's commands as Codex does: its three sandbox modes, its +//! writable roots with read-only folders inside them, bubblewrap on Linux +//! and Seatbelt on macOS. The bubblewrap arguments and the Seatbelt policy +//! are ported from Codex's `linux-sandbox` and `sandboxing` crates +//! (Apache-2.0; see NOTICE), and kept in step with them. +//! +//! A bot with a sandbox writes only its roots: its folder, the folders its +//! client named, and the temporary directories. Reading stays open. Inside +//! each root, what git, agent configuration and AWS profiles run or trust +//! stays read-only, and so does the daemon's own store. Network is off +//! unless the bot's client turned it on. + +use crate::{Result, fail_with}; +use serde::{Deserialize, Serialize}; +use std::path::{Component, Path, PathBuf}; + +/// How a bot's commands are confined, by Codex's `sandbox_mode` names. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Mode { + /// Nothing may be written. + ReadOnly, + /// The bot's roots may be written. + WorkspaceWrite, + /// No sandbox. + DangerFullAccess, +} + +/// Folders at the top of a writable root that stay read-only, as Codex keeps +/// them: what git runs (hooks, config), what agents trust, and AWS profiles, +/// which can name a program to run for credentials. A missing one is kept +/// from being made, as Codex does for its own metadata. +const PROTECTED: [&str; 4] = [".git", ".agents", ".codex", ".aws"]; +/// Of those, the ones kept from being made when missing: a new `.git` +/// would be run by git outside the sandbox, and a new `.agents` read as +/// the project's own settings. +const PROTECTED_MISSING: [&str; 3] = [".git", ".agents", ".codex"]; + +/// A folder the bot may write, and what inside it stays read-only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Root { + pub path: PathBuf, + /// Existing paths inside the root that stay read-only. + pub read_only: Vec, + /// Paths inside the root that don't exist and may not be made. + pub missing: Vec, +} + +/// What a sandboxed command may do: write its roots, and reach the network +/// when `network` is on. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Policy { + /// Shallowest first: a deeper root decides for what is inside it. + pub roots: Vec, + pub network: bool, + /// The daemon's socket, which a command may reach with network off. + pub socket: Option, + /// The agent binary a confined write runs in ([`WRITE`]). + pub helper: Option, +} + +impl Policy { + /// The policy for a bot in `mode`, whose folder is `workspace` and + /// whose client named `writable`; `None` when its commands run + /// unconfined. A folder outside every one of `within`, when it is + /// given, is not a root: a bot made by a sandboxed bot writes no more than + /// its creator. `daemon` holds what the daemon itself keeps: its store, + /// which no root may write, and its socket. + pub fn new( + mode: Option, + workspace: Option<&Path>, + writable: &[String], + within: Option<&[String]>, + network: bool, + daemon: &Daemon, + ) -> Result> { + let mode = match mode { + None | Some(Mode::DangerFullAccess) => return Ok(None), + Some(mode) => mode, + }; + let mut paths = writable_paths(mode, workspace, writable); + if let Some(within) = within { + let within: Vec = within + .iter() + .filter_map(|path| std::fs::canonicalize(path).ok()) + .collect(); + let temporary: Vec<&PathBuf> = temporary().iter().map(|(_, real)| real).collect(); + paths.retain(|path| { + let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()); + temporary.contains(&&path) || within.iter().any(|kept| path.starts_with(kept)) + }); + } + paths.sort_by_key(|path| (depth(path), path.clone())); + paths.dedup(); + let mut roots = Vec::new(); + for path in paths { + roots.push(root(&path, &daemon.store)?); + } + Ok(Some(Policy { + roots, + network: network && mode == Mode::WorkspaceWrite, + socket: daemon.socket.clone(), + helper: daemon.helper.clone(), + })) + } + + /// Whether a command in this sandbox may write `path`, an absolute + /// path: the deepest root that holds it decides, as the sandbox's + /// mounts and rules do. Symlinks in what exists of it are followed, as + /// a write would follow them. + pub fn can_write(&self, path: &Path) -> bool { + let Some(path) = real(path) else { + return false; + }; + self.roots + .iter() + .rev() + .find(|root| path.starts_with(&root.path)) + .is_some_and(|root| { + !root + .read_only + .iter() + .chain(&root.missing) + .any(|kept| path.starts_with(kept)) + }) + } +} + +/// The folders a bot in `mode` may write: none when read-only; when +/// workspace-write, its folder, the folders its client named, and the +/// temporary directories, as Codex adds them, each by its real path, which +/// is what the sandbox sees. +fn writable_paths(mode: Mode, workspace: Option<&Path>, writable: &[String]) -> Vec { + match mode { + Mode::ReadOnly | Mode::DangerFullAccess => Vec::new(), + Mode::WorkspaceWrite => workspace + .map(Path::to_owned) + .into_iter() + .chain(writable.iter().map(PathBuf::from)) + .chain(temporary().iter().map(|(_, real)| real.clone())) + .collect(), + } +} + +/// `$TMPDIR` and `/tmp`, each as named and by its real path, read once: +/// what decides whether a call escapes must not change while a round runs. +fn temporary() -> &'static [(PathBuf, PathBuf)] { + static TEMPORARY: std::sync::OnceLock> = std::sync::OnceLock::new(); + TEMPORARY.get_or_init(|| { + std::env::var_os("TMPDIR") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .into_iter() + .chain([PathBuf::from("/tmp")]) + .filter_map(|path| std::fs::canonicalize(&path).ok().map(|real| (path, real))) + .collect() + }) +} + +/// Read what the escape check reads once, at start, rather than on the +/// runtime thread when the first call comes. +pub fn warm() { + temporary(); +} + +impl Mode { + /// The stricter of two modes; no sandbox is the least strict. + pub fn stricter(this: Option, other: Option) -> Option { + let rank = |mode: Option| match mode { + Some(Mode::ReadOnly) => 0, + Some(Mode::WorkspaceWrite) => 1, + Some(Mode::DangerFullAccess) | None => 2, + }; + match rank(this) <= rank(other) { + true => this, + false => other, + } + } +} + +/// The argument that makes the agent binary a confined write: it reads +/// the content on stdin and writes it to the path that follows, inside the +/// bot's sandbox, as Codex runs `apply_patch` inside its own. The kernel +/// then decides what the write reaches, so a link swapped in while it runs +/// can't carry it out. +pub const WRITE: &str = "--agent-run-as-write"; + +/// The confined write [`WRITE`] asks for, run inside the sandbox: make the +/// folders above `path`, then replace its content with stdin's. Only a +/// regular file is written. +pub fn write(path: &Path) -> Result<()> { + use std::io::{Read, Write}; + let denied = |error: std::io::Error| match error.raw_os_error() { + Some(libc::EACCES | libc::EPERM | libc::EROFS) => { + crate::Error::with("write_denied", path.display().to_string()) + } + _ => crate::Error::with("io_error", error.to_string()), + }; + let mut content = Vec::new(); + std::io::stdin().read_to_end(&mut content)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(denied)?; + } + match std::fs::metadata(path) { + Ok(found) if !found.is_file() => return crate::fail("file_not_regular"), + Ok(_) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(denied(error)), + } + let mut options = std::fs::OpenOptions::new(); + options.write(true).create(true); + // As the daemon's own write: a FIFO swapped in must not block the open. + std::os::unix::fs::OpenOptionsExt::custom_flags( + &mut options, + libc::O_NONBLOCK | libc::O_NOCTTY, + ); + let mut file = options.open(path).map_err(denied)?; + if !file.metadata()?.is_file() { + return crate::fail("file_not_regular"); + } + file.set_len(0).map_err(denied)?; + file.write_all(&content).map_err(denied)?; + Ok(()) +} + +/// The tools a sandbox confines: what runs a command or writes a file. +pub fn confinable(tool: &str) -> bool { + matches!(tool, "shell" | "write" | "edit") +} + +/// Whether a call by a bot in `mode` asks to run outside its sandbox: a +/// command that asks to escalate, or a write or edit of a path outside +/// every folder the bot may write. `root` is the bot's folder and `folder` +/// the turn's, against which a relative path is read. Decided from the call +/// and the folders' names alone, never from the files as they are now, so +/// the round that announces a call and the run that starts it agree; a +/// confined write is still checked against the real path when it runs. +pub fn escapes( + mode: Mode, + root: Option<&Path>, + writable: &[String], + tool: &str, + arguments: &str, + folder: &Path, +) -> bool { + let Ok(arguments) = serde_json::from_str::(arguments) else { + return false; + }; + match tool { + "shell" => arguments["sandbox_permissions"] == "require_escalated", + "write" | "edit" => { + let Some(path) = arguments["path"].as_str() else { + return false; + }; + let target = lexical(&folder.join(path)); + let inside = |dir: &Path| target.starts_with(dir); + let temporary = temporary().iter().any(|(named, real)| { + mode == Mode::WorkspaceWrite && (inside(named) || inside(real)) + }); + !(temporary + || writable_paths(mode, root, writable) + .iter() + .any(|dir| inside(dir))) + } + _ => false, + } +} + +/// `path` with `.` dropped and `..` taken back, by name alone. +fn lexical(path: &Path) -> PathBuf { + let mut out = PathBuf::new(); + for part in path.components() { + match part { + Component::CurDir => {} + Component::ParentDir => { + out.pop(); + } + other => out.push(other), + } + } + out +} + +/// What the daemon keeps that its bots' sandboxes must leave alone or let +/// them reach. +#[derive(Debug, Clone, Default)] +pub struct Daemon { + /// The store's path; it and the files SQLite keeps beside it stay + /// read-only in every root. + pub store: Option, + pub socket: Option, + /// The agent binary, which a confined write runs in. + pub helper: Option, +} + +/// The root at `path`, with what stays read-only inside it: the protected +/// folders at its top, a worktree's own git folder when it is inside the +/// root, and the daemon's store. +fn root(path: &Path, store: &Option) -> Result { + let mut read_only = Vec::new(); + let mut missing = Vec::new(); + for name in PROTECTED { + let kept = path.join(name); + if held(&kept) { + missing.push(kept); + continue; + } + match std::fs::symlink_metadata(&kept) { + // A symlink the sandbox may replace can't be held read-only: + // whatever is mounted over its target, the link itself can be + // swapped for a folder of the command's own. Codex refuses it + // too. + Ok(found) if found.file_type().is_symlink() => { + return fail_with( + "sandbox_unavailable", + format!( + "{} is a symlink in a folder the bot may write, so it can't be kept \ + read-only", + kept.display() + ), + ); + } + Ok(found) => { + // A linked worktree's .git names its repository's folder for + // it, which stays read-only too when the root holds it. + if name == ".git" + && found.is_file() + && let Some(gitdir) = gitdir(&kept) + && gitdir.starts_with(path) + { + read_only.push(gitdir); + } + read_only.push(kept); + } + Err(_) if PROTECTED_MISSING.contains(&name) => missing.push(kept), + Err(_) => {} + } + } + if let Some(store) = store + && store.starts_with(path) + { + read_only.extend(store_files(store).filter(|file| file.exists())); + } + Ok(Root { + path: path.to_owned(), + read_only, + missing, + }) +} + +/// The store and the files SQLite and the daemon keep beside it. +fn store_files(store: &Path) -> impl Iterator + '_ { + ["", "-wal", "-shm", "-journal", ".owner-lock", ".sock"] + .into_iter() + .map(move |suffix| { + let mut file = store.as_os_str().to_owned(); + file.push(suffix); + PathBuf::from(file) + }) +} + +/// The folder a `.git` file names, by its real path. +fn gitdir(file: &Path) -> Option { + let text = std::fs::read_to_string(file).ok()?; + let named = text.trim().strip_prefix("gitdir:")?.trim(); + std::fs::canonicalize(file.parent()?.join(named)).ok() +} + +fn depth(path: &Path) -> usize { + path.components().count() +} + +/// `path` with what exists of it resolved to its real path and the rest +/// appended; `None` when the rest climbs out with `..`, which can't be +/// known without making it. A symlink whose target is missing is followed +/// to that target, as a write through it would be. +fn real(path: &Path) -> Option { + real_within(path, 40) +} + +fn real_within(path: &Path, links: u32) -> Option { + let mut existing = path.to_owned(); + let mut rest = Vec::new(); + loop { + let found = std::fs::canonicalize(&existing).ok().or_else(|| { + // canonicalize fails on a dangling link as on a missing name. + let link = std::fs::read_link(&existing).ok()?; + let mut target = existing.parent()?.join(link); + for part in rest.iter().rev() { + target.push(part); + } + rest.clear(); + real_within(&target, links.checked_sub(1)?) + }); + if let Some(mut resolved) = found { + for part in rest.iter().rev() { + resolved.push(part); + } + return Some(resolved); + } + if std::fs::symlink_metadata(&existing).is_ok_and(|found| found.file_type().is_symlink()) { + return None; + } + let name = existing.file_name()?.to_owned(); + if !matches!( + Path::new(&name).components().next(), + Some(Component::Normal(_)) + ) { + return None; + } + rest.push(name); + existing = existing.parent()?.to_owned(); + } +} + +/// Whether this machine can run sandboxed commands, or why not: refused when +/// a bot asks for a sandbox, rather than running it unconfined. +pub fn available() -> Result<()> { + #[cfg(target_os = "linux")] + { + linux::checked().map(|_| ()) + } + #[cfg(target_os = "macos")] + { + static FOUND: std::sync::OnceLock = std::sync::OnceLock::new(); + match FOUND.get_or_init(|| Path::new(macos::SANDBOX_EXEC).exists()) { + true => Ok(()), + false => fail_with( + "sandbox_unavailable", + format!("{} is missing", macos::SANDBOX_EXEC), + ), + } + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + fail_with( + "sandbox_unavailable", + "sandboxes run on Linux, with bubblewrap, and on macOS", + ) + } +} + +/// `program args...` confined by `policy`. A detached command outlives the +/// daemon, so its sandbox doesn't die with it. +pub fn command( + policy: &Policy, + program: &str, + args: &[&str], + detached: bool, +) -> Result { + #[cfg(target_os = "linux")] + { + linux::command(policy, program, args, detached) + } + #[cfg(target_os = "macos")] + { + let _ = detached; + macos::command(policy, program, args) + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + let _ = (policy, program, args, detached); + available().map(|()| unreachable!()) + } +} + +#[cfg(target_os = "linux")] +mod linux { + use super::{Policy, depth}; + use crate::{Error, Result}; + use std::path::{Path, PathBuf}; + + /// Where a system's bubblewrap is installed. Never looked up on PATH: + /// a PATH entry inside a folder a bot may write, such as an activated + /// virtualenv, would let a sandboxed command plant the next one's + /// `bwrap`, which runs before any sandbox exists. + const BWRAP: [&str; 4] = [ + "/usr/bin/bwrap", + "/bin/bwrap", + "/usr/local/bin/bwrap", + "/run/current-system/sw/bin/bwrap", + ]; + + fn bwrap() -> Option { + BWRAP + .iter() + .map(PathBuf::from) + .find(|found| found.is_file()) + } + + /// The bubblewrap that sandboxes run in, checked once: a daemon + /// doesn't look again for each command. + pub(super) fn checked() -> Result<&'static PathBuf> { + static CHECKED: std::sync::OnceLock> = + std::sync::OnceLock::new(); + CHECKED + .get_or_init(check) + .as_ref() + .map_err(|detail| Error::with("sandbox_unavailable", detail.clone())) + } + + /// Whether bubblewrap is here and may make the namespaces a sandbox + /// needs, or what to do about it. + fn check() -> std::result::Result { + let Some(bwrap) = bwrap() else { + return Err(format!( + "bubblewrap (bwrap) is not in {}; install it, for example with \ + `apt install bubblewrap` or `dnf install bubblewrap`", + BWRAP.join(", ") + )); + }; + let output = std::process::Command::new(&bwrap) + .args([ + "--ro-bind", + "/", + "/", + "--dev", + "/dev", + "--unshare-user", + "--unshare-pid", + "--unshare-net", + "--proc", + "/proc", + "--", + "true", + ]) + .stdin(std::process::Stdio::null()) + .output() + .map_err(|e| format!("{} could not run: {e}", bwrap.display()))?; + match output.status.success() { + true => Ok(bwrap), + false => Err(format!( + "{} could not make a sandbox ({}); user namespaces may be off, as \ + kernel.unprivileged_userns_clone=0 or an AppArmor rule makes them", + bwrap.display(), + String::from_utf8_lossy(&output.stderr).trim() + )), + } + } + + /// bubblewrap's arguments for `policy`, as Codex builds them: the whole + /// filesystem read-only, each root bound writable from the shallowest + /// down with its read-only folders bound back over it, and what may not + /// be made held by an empty read-only folder in its place. + pub(super) fn args(policy: &Policy, detached: bool) -> Vec { + let path = |path: &Path| path.to_string_lossy().into_owned(); + // A new session keeps the command from typing into the daemon's + // terminal (TIOCSTI). + let mut args: Vec = vec!["--new-session".into()]; + if !detached { + args.push("--die-with-parent".into()); + } + args.extend(["--ro-bind", "/", "/", "--dev", "/dev"].map(String::from)); + for root in &policy.roots { + args.extend(["--bind".into(), path(&root.path), path(&root.path)]); + let mut kept: Vec<&PathBuf> = root.read_only.iter().collect(); + kept.sort_by_key(|path| depth(path)); + for kept in kept { + args.extend(["--ro-bind".into(), path(kept), path(kept)]); + } + for missing in &root.missing { + args.extend([ + "--perms".into(), + "555".into(), + "--tmpfs".into(), + path(missing), + "--remount-ro".into(), + path(missing), + ]); + } + } + args.extend(["--unshare-user", "--unshare-pid", "--unshare-ipc"].map(String::from)); + if !policy.network { + args.push("--unshare-net".into()); + } + args.extend(["--proc", "/proc", "--cap-drop", "ALL"].map(String::from)); + args + } + + pub(super) fn command( + policy: &Policy, + program: &str, + rest: &[&str], + detached: bool, + ) -> Result { + let mut command = tokio::process::Command::new(checked()?); + command + .args(args(policy, detached)) + .arg("--") + .arg(program) + .args(rest); + Ok(command) + } +} + +/// The placeholders that running sandboxes hold for missing protected +/// folders, by how many hold each. bubblewrap makes a placeholder on the +/// host for its read-only mount; removing it while any sandbox still +/// mounts it would lift that mount there, so the last one out removes it. +static HELD: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); + +/// Whether a running sandbox holds `path` as a placeholder: then it is +/// still missing, whatever is there now. +fn held(path: &Path) -> bool { + HELD.lock().unwrap().iter().any(|(held, _)| held == path) +} + +/// What a running sandbox holds in place; dropping it lets the +/// placeholders go once no other sandbox holds them. +pub struct Hold(Vec); + +/// Hold `policy`'s placeholders while its command runs. +pub fn hold(policy: &Policy) -> Hold { + let paths: Vec = match cfg!(target_os = "linux") { + true => policy + .roots + .iter() + .flat_map(|root| root.missing.iter().cloned()) + .collect(), + false => Vec::new(), + }; + let mut held = HELD.lock().unwrap(); + for path in &paths { + match held.iter_mut().find(|(held, _)| held == path) { + Some((_, count)) => *count += 1, + None => held.push((path.clone(), 1)), + } + } + Hold(paths) +} + +impl Drop for Hold { + fn drop(&mut self) { + let mut released = Vec::new(); + let mut held = HELD.lock().unwrap(); + for path in self.0.drain(..) { + if let Some(index) = held.iter().position(|(held, _)| *held == path) { + held[index].1 -= 1; + if held[index].1 == 0 { + held.swap_remove(index); + released.push(path); + } + } + } + drop(held); + if released.is_empty() { + return; + } + // Off the runtime thread, and under the lock again, so a sandbox + // that took one up meanwhile keeps it. + let remove = move || { + let held = HELD.lock().unwrap(); + for path in released { + if !held.iter().any(|(held, _)| *held == path) { + // Only an empty one: a placeholder, never what a + // command outside the sandbox made there since. + let _ = std::fs::remove_dir(path); + } + } + }; + match tokio::runtime::Handle::try_current() { + Ok(runtime) => drop(runtime.spawn_blocking(remove)), + Err(_) => remove(), + } + } +} + +#[cfg(target_os = "macos")] +mod macos { + use super::Policy; + use crate::Result; + + /// By its absolute path, as Codex runs it, so PATH can't name another. + pub(super) const SANDBOX_EXEC: &str = "/usr/bin/sandbox-exec"; + + pub(super) fn command( + policy: &Policy, + program: &str, + rest: &[&str], + ) -> Result { + let (profile, params) = super::seatbelt(policy); + let mut command = tokio::process::Command::new(SANDBOX_EXEC); + command.arg("-p").arg(profile); + for (key, value) in params { + command.arg(format!("-D{key}={}", value.to_string_lossy())); + } + command.arg("--").arg(program).args(rest); + Ok(command) + } +} + +/// Codex's Seatbelt policy pieces, as Codex ships them. +const SEATBELT_BASE: &str = include_str!("sandbox/seatbelt_base_policy.sbpl"); +const SEATBELT_NETWORK: &str = include_str!("sandbox/seatbelt_network_policy.sbpl"); +const SEATBELT_PREFERENCES: &str = include_str!("sandbox/seatbelt_preferences_policy.sbpl"); +const SEATBELT_TLS_TRUST: &str = + "(allow mach-lookup (global-name \"com.apple.TrustEvaluationAgent\"))\n"; + +/// The Seatbelt profile for `policy` and the parameters it names, as Codex +/// writes it: everything denied but what its base allows, reading open, +/// writing each root but the paths it keeps, and network only when on. +/// Paths go in as parameters, never into the profile's text. +pub fn seatbelt(policy: &Policy) -> (String, Vec<(String, PathBuf)>) { + let mut params: Vec<(String, PathBuf)> = Vec::new(); + let mut allows = Vec::new(); + let mut anchors = Vec::new(); + for (index, root) in policy.roots.iter().enumerate() { + let key = format!("WRITABLE_ROOT_{index}"); + params.push((key.clone(), root.path.clone())); + // The root itself can't be renamed away from under its own rules. + anchors.push(format!( + "(deny file-write-unlink (require-all (literal (param \"{key}\")) (vnode-type DIRECTORY)))" + )); + // What this root keeps, and what each deeper root inside it keeps: + // the deepest root decides, as bubblewrap's mounts do. + let kept: Vec<&PathBuf> = policy.roots[index..] + .iter() + .filter(|inner| inner.path.starts_with(&root.path)) + .flat_map(|inner| inner.read_only.iter().chain(&inner.missing)) + .collect(); + let mut parts = vec![format!("(subpath (param \"{key}\"))")]; + for (kept_index, kept) in kept.into_iter().enumerate() { + let kept_key = format!("{key}_EXCLUDED_{kept_index}"); + params.push((kept_key.clone(), kept.clone())); + // Both the path and what is under it: `subpath` alone would let + // the folder itself be made. + parts.push(format!("(require-not (literal (param \"{kept_key}\")))")); + parts.push(format!("(require-not (subpath (param \"{kept_key}\")))")); + } + allows.push(match parts.len() { + 1 => parts.remove(0), + _ => format!("(require-all {} )", parts.join(" ")), + }); + } + let mut sections = vec![ + SEATBELT_BASE.to_owned(), + "; allow read-only file operations\n(allow file-read*)".to_owned(), + ]; + if !allows.is_empty() { + sections.push(format!("(allow file-write*\n{}\n)", allows.join(" "))); + sections.extend(anchors); + } + // Network as Codex allows it: everything when on, and with it off only + // the daemon's socket, which network-off would deny too (Claude Code's + // allowUnixSockets keeps one the same way). + let socket = policy.socket.as_ref().map(|socket| { + params.push(("DAEMON_SOCKET".to_owned(), socket.clone())); + "; allow unix domain sockets for local IPC\n\ + (allow system-socket (socket-domain AF_UNIX))\n\ + (allow network-outbound (remote unix-socket (subpath (param \"DAEMON_SOCKET\"))))\n" + }); + match (policy.network, socket) { + (true, socket) => sections.push(format!( + "(allow network-outbound)\n(allow network-inbound)\n{}{SEATBELT_NETWORK}{SEATBELT_TLS_TRUST}", + socket.unwrap_or_default() + )), + (false, Some(socket)) => sections.push(format!("{socket}{SEATBELT_NETWORK}")), + (false, None) => {} + } + sections.push(SEATBELT_PREFERENCES.to_owned()); + sections.push("(deny mach-lookup (xpc-service-name-prefix \"\"))".to_owned()); + // These fcntls change a file through a read-only descriptor, past + // file-write*: F_MAKECOMPRESSED and F_TRANSFEREXTENTS. + sections.push("(deny system-fcntl (fcntl-command 80 110))".to_owned()); + (sections.join("\n"), params) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_call_escapes_by_asking_or_by_writing_outside_its_roots() { + let root = Path::new("/work/project"); + let escapes = |tool: &str, arguments: serde_json::Value| { + escapes( + Mode::WorkspaceWrite, + Some(root), + &["/work/shared".to_owned()], + tool, + &arguments.to_string(), + Path::new("/work/project/sub"), + ) + }; + assert!(!escapes("shell", serde_json::json!({"command":"make"}))); + assert!(!escapes( + "shell", + serde_json::json!({"command":"make","sandbox_permissions":"use_default"}) + )); + assert!(escapes( + "shell", + serde_json::json!({"command":"git push","sandbox_permissions":"require_escalated"}) + )); + for inside in ["a.txt", "../b.txt", "/work/shared/c", "/tmp/d"] { + assert!( + !escapes("write", serde_json::json!({"path":inside,"content":""})), + "{inside}" + ); + } + for outside in ["../../elsewhere", "/etc/hosts", "/work/projectile/x"] { + assert!( + escapes( + "edit", + serde_json::json!({"path":outside,"old":"a","new":"b"}) + ), + "{outside}" + ); + } + // Read-only writes nowhere, so every write asks. + assert!(super::escapes( + Mode::ReadOnly, + Some(root), + &[], + "write", + r#"{"path":"a.txt","content":""}"#, + root + )); + } + + #[test] + fn the_deepest_root_decides_and_seatbelt_keeps_what_it_keeps() { + let base = std::env::temp_dir().join(format!("agent-sandbox-unit-{}", std::process::id())); + let inner = base.join("inner"); + std::fs::create_dir_all(inner.join(".git")).unwrap(); + std::fs::create_dir_all(base.join(".git")).unwrap(); + let base = std::fs::canonicalize(&base).unwrap(); + let inner = base.join("inner"); + let policy = Policy::new( + Some(Mode::WorkspaceWrite), + Some(&base), + &[inner.to_str().unwrap().to_owned()], + None, + false, + &Daemon::default(), + ) + .unwrap() + .unwrap(); + assert!(policy.can_write(&base.join("a"))); + assert!(policy.can_write(&inner.join("new/file"))); + assert!(!policy.can_write(&base.join(".git/config"))); + assert!(!policy.can_write(&inner.join(".git/HEAD"))); + assert!(!policy.can_write(Path::new("/etc/hosts"))); + assert!(!policy.can_write(&base.join("../../etc/x"))); + // A missing `.agents` may not be made. + assert!(!policy.can_write(&inner.join(".agents/skills"))); + let (profile, params) = seatbelt(&policy); + assert!(profile.contains("(allow file-read*)")); + assert!(!profile.contains("(allow network-outbound)")); + // The outer root keeps the inner root's `.git` too. + let outer = params + .iter() + .position(|(_, path)| path == &base) + .map(|index| params[index].0.clone()) + .unwrap(); + let kept: Vec<&PathBuf> = params + .iter() + .filter(|(key, _)| key.starts_with(&format!("{outer}_EXCLUDED_"))) + .map(|(_, path)| path) + .collect(); + assert!(kept.contains(&&inner.join(".git"))); + // Paths go in as parameters, never into the profile's text. + assert!(!profile.contains(base.to_str().unwrap())); + std::fs::remove_dir_all(base).unwrap(); + } + + #[test] + fn a_symlinked_git_folder_refuses_the_sandbox() { + let base = std::env::temp_dir().join(format!("agent-sandbox-link-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + std::os::unix::fs::symlink("/", base.join(".git")).unwrap(); + let error = Policy::new( + Some(Mode::WorkspaceWrite), + Some(&base), + &[], + None, + false, + &Daemon::default(), + ) + .unwrap_err(); + assert_eq!(error.code, "sandbox_unavailable"); + std::fs::remove_dir_all(base).unwrap(); + } +} diff --git a/src/sandbox/seatbelt_base_policy.sbpl b/src/sandbox/seatbelt_base_policy.sbpl new file mode 100644 index 000000000..2fca6a886 --- /dev/null +++ b/src/sandbox/seatbelt_base_policy.sbpl @@ -0,0 +1,116 @@ +(version 1) + +; inspired by Chrome's sandbox policy: +; https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd +; https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/renderer.sb;l=64;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd + +; start with closed-by-default +(deny default) + +; child processes inherit the policy of their parent +(allow process-exec) +(allow process-fork) +(allow signal (target same-sandbox)) + +; process-info +(allow process-info* (target same-sandbox)) + +(allow file-write-data + (require-all + (path "/dev/null") + (vnode-type CHARACTER-DEVICE))) + +; sysctls permitted. +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.model") + (sysctl-name "hw.memsize") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + ; Chrome locks these CPU feature detection down a bit more tightly, + ; but mostly for fingerprinting concerns which isn't an issue for codex. + (sysctl-name-prefix "hw.optional.arm.") + (sysctl-name-prefix "hw.optional.armv8_") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.pagesize") + (sysctl-name "hw.physicalcpu") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.logicalcpu") + (sysctl-name "hw.cpufrequency") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "machdep.cpu.brand_string") + (sysctl-name "kern.argmax") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.maxproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + ; Python's ProcessPoolExecutor queries this through sysconf(_SC_SEM_NSEMS_MAX). + (sysctl-name "kern.sysv.semmns") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name "vm.loadavg") + (sysctl-name-prefix "hw.perflevel") + (sysctl-name-prefix "kern.proc.pgrp.") + (sysctl-name-prefix "kern.proc.pid.") + (sysctl-name-prefix "net.routetable.") +) + +; Allow Java to read some CPU info. This is misclassified as a "write" because +; userspace passes a memory buffer to the sysctl, but conceptually it is a read. +(allow sysctl-write + (sysctl-name "kern.grade_cputype")) + +; IOKit +(allow iokit-open + (iokit-registry-entry-class "RootDomainUserClient") +) + +; needed to look up user info, see https://crbug.com/792228 +(allow mach-lookup + (global-name "com.apple.system.opendirectoryd.libinfo") +) + +; Needed for python multiprocessing on MacOS for the SemLock +(allow ipc-posix-sem) + +; Needed for PyTorch/libomp on macOS to register OpenMP runtimes. +(allow ipc-posix-shm-read-data + ipc-posix-shm-write-create + ipc-posix-shm-write-unlink + (ipc-posix-name-regex #"^/__KMP_REGISTERED_LIB_[0-9]+$")) + +(allow mach-lookup + (global-name "com.apple.PowerManagement.control") +) + +; allow openpty() +(allow pseudo-tty) +(allow file-read* file-write* file-ioctl (literal "/dev/ptmx")) +(allow file-read* file-write* + (require-all + (regex #"^/dev/ttys[0-9]+") + (extension "com.apple.sandbox.pty"))) +; PTYs created before entering seatbelt may lack the extension; allow ioctl +; on those slave ttys so interactive shells detect a TTY and remain functional. +(allow file-ioctl (regex #"^/dev/ttys[0-9]+")) diff --git a/src/sandbox/seatbelt_network_policy.sbpl b/src/sandbox/seatbelt_network_policy.sbpl new file mode 100644 index 000000000..1bb9eb2b8 --- /dev/null +++ b/src/sandbox/seatbelt_network_policy.sbpl @@ -0,0 +1,31 @@ +; when network access is enabled, these policies are added after those in seatbelt_base_policy.sbpl +; proxy-specific allow rules are injected by codex-core based on environment. +; Ref https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/network.sb;drc=f8f264d5e4e7509c913f4c60c2639d15905a07e4 + +; allow only safe AF_SYSTEM sockets used for local platform services. +(allow system-socket + (require-all + (socket-domain AF_SYSTEM) + (socket-protocol 2) + ) +) + +(allow mach-lookup + ; Used by platform helpers that resolve user directory locations. + (global-name "com.apple.bsd.dirhelper") + (global-name "com.apple.system.opendirectoryd.membership") + + ; Communicate with the security server for TLS certificate information. + (global-name "com.apple.SecurityServer") + (global-name "com.apple.networkd") + (global-name "com.apple.ocspd") + (global-name "com.apple.trustd.agent") + + ; Read network configuration. + (global-name "com.apple.SystemConfiguration.DNSConfiguration") + (global-name "com.apple.SystemConfiguration.configd") +) + +(allow sysctl-read + (sysctl-name-regex #"^net.routetable") +) diff --git a/src/sandbox/seatbelt_preferences_policy.sbpl b/src/sandbox/seatbelt_preferences_policy.sbpl new file mode 100644 index 000000000..45599d1b5 --- /dev/null +++ b/src/sandbox/seatbelt_preferences_policy.sbpl @@ -0,0 +1,8 @@ +; Preferences IPC can expose data outside the filesystem read roots. +; Include this policy only when filesystem reads are unrestricted. +(allow ipc-posix-shm-read* (ipc-posix-name-prefix "apple.cfprefs.")) +(allow mach-lookup + (global-name "com.apple.cfprefsd.daemon") + (global-name "com.apple.cfprefsd.agent") + (local-name "com.apple.cfprefsd.agent")) +(allow user-preference-read) diff --git a/src/server/mod.rs b/src/server/mod.rs index 45e3aab99..6ca375d2a 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -13,8 +13,8 @@ use agent_runtime::{ output::{self, Output}, provider::{Listed, Provider, STREAMS_PER_CONNECTION, Transport}, store::{ - Answer, Binding, Bot, Decision, Delivery, Fork, Gate, MAX_GATES, Publication, Settings, - Started, Store, TurnOptions, Waiting, Wake, + Answer, Binding, Bot, Decision, Delivery, FOLDERS_JSON, Fork, Gate, MAX_GATES, Publication, + Settings, Started, Store, TurnOptions, Waiting, Wake, }, tools::{ ANSWER_BYTES, DECLARED_BYTES, Declared, MAX_DECLARED, Outcome, Registry, validate_declared, @@ -112,6 +112,7 @@ fn admission_bound(command: &Command, id: &Value, deliveries: usize) -> Bound { compaction_instructions, compaction_model, declare, + settings, .. } => ( // A bot that takes its creator's declared tools shows them too, @@ -135,7 +136,20 @@ fn admission_bound(command: &Command, id: &Value, deliveries: usize) -> Bound { } }), output::encoded_len(&(bot, created_by)), - if workspace.is_some() { PATH_JSON } else { 0 }, + // The workspace and the writable and within folders, canonical, + // which the reply's settings repeat; a bot's creator's sandbox + // can add its own folders and its roots to them. + usize::from(workspace.is_some()) * PATH_JSON + + [ + &settings.writable, + settings.within.as_ref().unwrap_or(&Vec::new()), + ] + .iter() + .map(|paths| (paths.len() * PATH_JSON).min(FOLDERS_JSON)) + .sum::() + + created_by + .as_ref() + .map_or(0, |_| 2 * FOLDERS_JSON + PATH_JSON), // Both the bot and its event carry its gates, most of them // inherited from its creator, so the command does not show // them. Each gate keeps only the bot's tools, which it lists. @@ -776,6 +790,31 @@ fn workspace(path: &str) -> Result { .ok_or(Error::new("workspace_not_utf8"))? .into()) } +/// A new bot's sandbox, made ready to keep: refused where none can run, so +/// a bot that asked for one never runs unconfined, and each folder it may +/// write named by its real path, as the sandbox sees it. +fn confine(settings: &mut Settings) -> Result<()> { + use agent_runtime::sandbox::Mode; + if matches!( + settings.sandbox, + Some(Mode::ReadOnly | Mode::WorkspaceWrite) + ) { + agent_runtime::sandbox::available()?; + } + for paths in std::iter::once(&mut settings.writable).chain(&mut settings.within) { + for path in paths.iter_mut() { + let real = std::fs::canonicalize(&*path) + .map_err(|_| Error::with("path_not_found", path.clone()))?; + *path = real + .to_str() + .ok_or(Error::with("path_not_utf8", path.clone()))? + .to_owned(); + } + paths.sort(); + paths.dedup(); + } + Ok(()) +} /// A field given as `null` deserializes to `Some(None)`, apart from one /// left out, which `default` makes `None`. fn present<'de, D, T>(deserializer: D) -> std::result::Result, D::Error> @@ -1168,9 +1207,29 @@ pub async fn run(config: Configuration) -> Result<()> { std::path::absolute(path)?.to_string_lossy().into_owned(), )); } + agent_runtime::sandbox::warm(); + let daemon = agent_runtime::sandbox::Daemon { + store: Some(std::fs::canonicalize(&config.store)?), + // By its folder's real path, which is what Seatbelt matches. + socket: config + .socket + .as_deref() + .map(|socket| -> Result { + let socket = std::path::absolute(socket)?; + Ok(match (socket.parent(), socket.file_name()) { + (Some(folder), Some(name)) => std::fs::canonicalize(folder) + .map_or_else(|_| socket.clone(), |folder| folder.join(name)), + _ => socket, + }) + }) + .transpose()?, + // Read once at start: what runs a confined write is this binary. + helper: std::env::current_exe().ok(), + }; let registry = registry .with_credentials(credentials) .with_environment(environment) + .with_daemon(daemon) .with_process_budget(limits.processes) .with_detached_budget(limits.detached); let hub = Hub::default(); @@ -2172,7 +2231,7 @@ impl Service { approve, approver, approve_expire_ms, - settings, + mut settings, declare, request_id, } => { @@ -2206,6 +2265,12 @@ impl Service { // with no room for thinking, is refused before the bot exists. settings.validate()?; turn::shaped(served, &settings)?; + // Checking for a sandbox and resolving folders touch the + // filesystem: off the runtime thread. + let settings = + tokio::task::spawn_blocking(move || confine(&mut settings).map(|()| settings)) + .await + .map_err(|_| Error::new("sandbox_unavailable"))??; if let Some(level) = &effort { family.check_effort(level)?; } diff --git a/src/server/turn.rs b/src/server/turn.rs index 56af8720e..acb27cb2d 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -16,6 +16,7 @@ use agent_runtime::{ codec::split_model, fail, provider::{Chain, Delta, Items, Provider, Report, Request as ModelRequest, ToolCall}, + sandbox::Policy, store::{ Bot, CallAnswer, ContextPrefix, ContextUsage, Gated, Settings, Slot, Store, Strip, TURN_VIEW_ACCOUNTING, Waiting, Window, unserved, @@ -1467,7 +1468,9 @@ impl Turn { None => match summarizer.family() { agent_runtime::codec::Family::Anthropic => (Body::Span(&plan, stripped), tools), agent_runtime::codec::Family::Responses => { - empty = self.registry.encoded(summarizer.family(), &[], &[])?; + empty = self + .registry + .encoded(summarizer.family(), &[], &[], false)?; (Body::Span(&plan, stripped), &*empty) } }, @@ -1841,7 +1844,7 @@ impl Turn { + match family { agent_runtime::codec::Family::Anthropic => tools.get().len(), agent_runtime::codec::Family::Responses => { - self.registry.encoded(family, &[], &[])?.get().len() + self.registry.encoded(family, &[], &[], false)?.get().len() } }) } @@ -1915,7 +1918,7 @@ impl Turn { ) -> Result { let (bot, turn) = (self.bot.clone(), self.turn); let mut record = self.store.op("inspect", move |db| db.inspect(&bot)).await?; - self.settings.get_or_init(|| record.settings); + self.settings.get_or_init(|| record.settings.clone()); let context = self.store.op("context", move |db| db.context(turn)).await?; // A turn may run at its own effort; everything after reads the record. record.effort = context.effort.clone(); @@ -2067,9 +2070,12 @@ impl Turn { let mut model_rounds = context.model_rounds; let called = context.model.as_str(); // This bot's tools, encoded once per distinct selection and shared. - let tools = self - .registry - .encoded(provider.family(), &record.tools, &record.declared)?; + let tools = self.registry.encoded( + provider.family(), + &record.tools, + &record.declared, + record.sandboxed(), + )?; // A stub names the `read` call that returns its result, so only a // bot that has the tool elides. let elides = record.tools.iter().any(|tool| tool == "read"); @@ -2414,7 +2420,7 @@ impl Turn { }); record.sent = sent.or(record.sent); let name = self.bot.clone(); - let gated = calls.iter().any(|call| record.gated(&call.name)); + let gated = calls.iter().any(|call| record.gated(call, &workspace)); // Taken before the commit that stamps the gates' announcement, // so a gate's first lapse is never counted late. let planned = tokio::time::Instant::now(); @@ -3323,13 +3329,17 @@ impl Turn { let (turn, allowed) = (self.turn, record.callable()); let mut calls = calls.into_iter(); while let Some(call) = calls.next() { - if record.gated(&call.name) { + // An escape from a sandbox runs unconfined only once a gate + // allowed it. + let approved = record.gated(&call, workspace); + if approved { match self .approve( &call, &mut calls, announced.take(), record, + workspace, route, summary_retry_ms, ) @@ -3356,6 +3366,52 @@ impl Turn { } else { Err(Error::with("tool_not_available", call.name.as_str())) }; + // A call its sandbox confines runs in it; one that can't be + // confined fails rather than run unconfined. An escape no gate + // approved is refused, as Codex refuses one under a policy that + // never asks: a command, or a write or edit held to the sandbox. + let mut sandbox = None; + let confined = + record.sandboxed() && agent_runtime::sandbox::confinable(&call.name) && !approved; + let prepared = match prepared { + Ok(Prepared::Shell { .. } | Prepared::Detach(_)) + if confined && !record.confines(&call, workspace) => + { + Err(Error::with( + "escalation_refused", + "no approval lets this bot run a command outside its sandbox; \ + run it without sandbox_permissions", + )) + } + Ok(prepared) if confined => { + // Reading the roots touches the filesystem: off the + // runtime thread. + let (settings, daemon) = + (record.settings.clone(), self.registry.daemon().clone()); + let root = record + .workspace + .as_ref() + .map_or_else(|| workspace.to_owned(), PathBuf::from); + tokio::task::spawn_blocking(move || { + Policy::new( + settings.sandbox, + Some(&root), + &settings.writable, + settings.within.as_deref(), + settings.network, + &daemon, + ) + }) + .await + .map_err(|_| Error::new("sandbox_unavailable")) + .flatten() + .map(|policy| { + sandbox = policy.map(Arc::new); + prepared + }) + } + other => other, + }; let answered = record .declared(&call.name) .is_some_and(|tool| tool.answered_by.is_some()); @@ -3405,6 +3461,7 @@ impl Turn { workspace.to_path_buf(), timeout_ms, environment.to_vec(), + sandbox, ) .await { @@ -3494,6 +3551,7 @@ impl Turn { workspace.to_path_buf(), timeout_ms, environment.to_vec(), + sandbox, started, ); match self @@ -3510,7 +3568,8 @@ impl Turn { } Ok(prepared) => match self .run_tool( - self.registry.execute(prepared, workspace, environment), + self.registry + .execute(prepared, workspace, environment, sandbox), None, warm.as_deref_mut(), ) @@ -3536,12 +3595,14 @@ impl Turn { /// Wait for a gated call's verdict: live for the hold, then parked like /// `wait`, holding no task and no slot. Answers wake the task through /// the worker, so each check is one job and no commit. + #[allow(clippy::too_many_arguments)] async fn approve( &self, call: &ToolCall, calls: &mut std::vec::IntoIter, announced: Option<(Arc, tokio::time::Instant)>, record: &Bot, + workspace: &std::path::Path, route: Option<&str>, summary_retry_ms: Option, ) -> Result { @@ -3561,7 +3622,7 @@ impl Turn { .filter(|gate| { round .clone() - .any(|c| record.gated(&c.name) && gate.tools.contains(&c.name)) + .any(|c| record.gated(c, workspace) && gate.tools.contains(&c.name)) }) .filter_map(|gate| gate.expire_ms) .min(); @@ -3922,6 +3983,7 @@ impl Turn { workspace: PathBuf, timeout_ms: u64, environment: Vec<(String, String)>, + sandbox: Option>, ) -> Result { // The place in line is taken before anything durable is written. let queued = self.registry.queue()?; @@ -3931,8 +3993,15 @@ impl Turn { .op("process_start", move |db| db.process_start(turn, &started)) .await?; let (sender, receiver) = oneshot::channel(); - self.registry - .background(command, workspace, timeout_ms, environment, queued, sender); + self.registry.background( + command, + workspace, + timeout_ms, + environment, + sandbox, + queued, + sender, + ); self.collect(id, call_id, receiver); Ok(Outcome::text( json!({"handle":format!("proc:{id}"),"background":true}).to_string(), diff --git a/src/store.rs b/src/store.rs index aa20f3ade..80f630a3a 100644 --- a/src/store.rs +++ b/src/store.rs @@ -16,10 +16,10 @@ mod db; pub use context::{ContextPrefix, ContextUsage, pinned_item, thinking_bytes, without_thinking}; pub use db::{ Absorbed, Answered, Binding, Bot, CacheTtl, CallAnswer, CatchUp, CompactionPlan, - CompactionView, CopiedCall, DENIAL_WINDOW, Database, Decision, Delivery, ElisionPlan, Fork, - Gate, Gated, MAX_GATES, Planning, Publication, Served, Settings, Started, Strip, - TURN_VIEW_ACCOUNTING, TurnBudget, TurnContext, TurnOptions, Waiting, Wake, Window, cache_hit, - merge_gates, unserved, + CompactionView, CopiedCall, DENIAL_WINDOW, Database, Decision, Delivery, ElisionPlan, + FOLDERS_JSON, Fork, Gate, Gated, MAX_GATES, Planning, Publication, Served, Settings, Started, + Strip, TURN_VIEW_ACCOUNTING, TurnBudget, TurnContext, TurnOptions, Waiting, Wake, Window, + cache_hit, merge_gates, unserved, }; type ReadJob = Box; diff --git a/src/store/db.rs b/src/store/db.rs index 9aea637c8..e1889a522 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -168,7 +168,7 @@ impl Denials { /// its approvals. `None` takes the runtime's default; a fork keeps its /// source's. Stored as given, so a bot that chose nothing follows the /// defaults. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Deserialize, Serialize)] +#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Settings { /// Model context per request: the newest turns within these bounds, in @@ -212,6 +212,23 @@ pub struct Settings { /// Anthropic prompt-cache lifetime. #[serde(skip_serializing_if = "Option::is_none")] pub cache_ttl: Option, + /// How its shell, write and edit calls are confined, by Codex's + /// sandbox modes; none runs them unconfined. + #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox: Option, + /// Folders a workspace-write sandbox may write besides the bot's own + /// and the temporary directories, each by its real path. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub writable: Vec, + /// Whether a workspace-write sandbox reaches the network. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub network: bool, + /// Folders a workspace-write sandbox's roots must lie in, by their real + /// paths: a root outside them all is not writable. A bot made by a + /// sandboxed bot gets its creator's roots here, so it writes no more + /// than its creator; none sets no bound. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub within: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, Serialize)] pub enum CacheTtl { @@ -221,6 +238,9 @@ pub enum CacheTtl { Hour, } const MIN_CONTEXT_BYTES: usize = 1024; +/// The most a bot's `writable` or `within` folders take as JSON, by their +/// real paths, so a reply that repeats them has a bound. +pub const FOLDERS_JSON: usize = 32 << 10; const MIN_COMPACT_TOKENS: u64 = 1024; /// Where a model with a `window`-token context compacts unless the bot /// sets `compact_tokens`, as Codex and Claude Code compute it: the window @@ -269,6 +289,67 @@ impl Settings { None => Some(crate::provider::KEEP_WARM), } } + /// These settings for a bot that a bot with `creator`'s settings and + /// folder makes: no weaker a sandbox than its creator's. A sandboxed + /// creator's child takes the creator's sandbox unless it names a + /// stricter one, reaches the network only if both do, and writes only + /// within the creator's own roots, as Codex's subagents inherit their + /// parent's sandbox. + pub fn under(mut self, creator: &Settings, folder: Option<&str>) -> Settings { + use crate::sandbox::Mode; + let sandboxed = |settings: &Settings| { + matches!( + settings.sandbox, + Some(Mode::ReadOnly | Mode::WorkspaceWrite) + ) + }; + if !sandboxed(creator) { + return self; + } + if !sandboxed(&self) { + self.writable = creator.writable.clone(); + self.network = creator.network; + } + self.sandbox = Mode::stricter(self.sandbox, creator.sandbox); + if self.sandbox != Some(Mode::WorkspaceWrite) { + self.writable.clear(); + self.network = false; + self.within = None; + return self; + } + self.network &= creator.network; + let inside = |path: &str, folders: &[String]| { + folders + .iter() + .any(|folder| std::path::Path::new(path).starts_with(folder)) + }; + let roots: Vec = folder + .into_iter() + .map(str::to_owned) + .chain(creator.writable.iter().cloned()) + .filter(|root| { + creator + .within + .as_ref() + .is_none_or(|within| inside(root, within)) + }) + .collect(); + self.within = Some(match self.within.take() { + None => roots, + Some(own) => { + let mut both: Vec = own + .iter() + .filter(|path| inside(path, &roots)) + .chain(roots.iter().filter(|root| inside(root, &own))) + .cloned() + .collect(); + both.sort(); + both.dedup(); + both + } + }); + self + } pub fn cache_hour(&self) -> bool { self.cache_ttl == Some(CacheTtl::Hour) } @@ -312,6 +393,32 @@ impl Settings { { return refuse("keep_warm is below 300 seconds; 0 never refreshes"); } + let confined = self.sandbox == Some(crate::sandbox::Mode::WorkspaceWrite); + if !confined && (!self.writable.is_empty() || self.network) { + return refuse("writable and network go with sandbox workspace-write"); + } + if !confined && self.within.is_some() { + return refuse("within goes with sandbox workspace-write"); + } + for (name, paths) in [ + ("writable", Some(&self.writable)), + ("within", self.within.as_ref()), + ] { + let Some(paths) = paths else { + continue; + }; + if paths.len() > 64 + || crate::output::encoded_len(paths).unwrap_or(usize::MAX) > FOLDERS_JSON + { + return refuse(&format!("{name} names at most 64 folders, 32 KiB in all")); + } + if paths + .iter() + .any(|path| !std::path::Path::new(path).is_absolute() || path.len() > 4096) + { + return refuse(&format!("{name} folders are absolute paths")); + } + } Ok(()) } /// Every setting as it applies, defaults included, for clients. @@ -322,7 +429,8 @@ impl Settings { "keep_turns":self.keep_turns, "approval_hold_ms":self.approval_hold_ms(),"max_output_tokens":self.max_output_tokens, "keep_warm":self.keep_warm().map_or(0, |after| after.as_secs()), - "cache_ttl":self.cache_ttl.unwrap_or(CacheTtl::Minutes)}) + "cache_ttl":self.cache_ttl.unwrap_or(CacheTtl::Minutes), + "sandbox":self.sandbox,"writable":self.writable,"network":self.network}) } fn stored(&self) -> Result> { Ok((*self != Self::default()) @@ -373,11 +481,45 @@ impl Bot { .facts(json!({"turn_budget_tokens":cap.tokens,"turn_tokens_used":spent})), ) } - /// Whether a call of this tool, the daemon's or a declared one, waits - /// for a verdict. A call the bot may not make is refused, never - /// announced. - pub fn gated(&self, tool: &str) -> bool { - gated(&self.gates, tool) && self.may_call(tool) + /// Whether this call, of the daemon's tool or a declared one, waits + /// for a verdict, run in the turn's `workspace`. A call the bot may not + /// make is refused, never announced, and one its sandbox confines runs + /// without a verdict: only an escape is gated, as in Codex. + pub fn gated(&self, call: &ToolCall, workspace: &std::path::Path) -> bool { + gated(&self.gates, &call.name) + && self.may_call(&call.name) + && !self.confines(call, workspace) + } + /// Whether the bot's commands run in a sandbox. + pub fn sandboxed(&self) -> bool { + use crate::sandbox::Mode; + matches!( + self.settings.sandbox, + Some(Mode::ReadOnly | Mode::WorkspaceWrite) + ) + } + /// Whether this call runs inside the bot's sandbox: the bot has one, the + /// tool is one a sandbox holds, and the call doesn't ask to leave it. + pub fn confines(&self, call: &ToolCall, workspace: &std::path::Path) -> bool { + use crate::sandbox::{Mode, confinable, escapes}; + match self.settings.sandbox { + None | Some(Mode::DangerFullAccess) => false, + Some(mode) => { + confinable(&call.name) + && !escapes( + mode, + Some( + self.workspace + .as_deref() + .map_or(workspace, std::path::Path::new), + ), + &self.settings.writable, + &call.name, + &call.arguments, + workspace, + ) + } + } } /// The tools this bot may call: its allowed list, else its tools. pub fn callable(&self) -> &[String] { @@ -2010,6 +2152,26 @@ impl Database { None => Vec::new(), }) } + /// The settings and folder of the bot a client creates for, whose + /// sandbox the new bot may not weaken. + fn creator_settings( + conn: &Connection, + creator: Option, + ) -> Result)>> { + let Some(id) = creator else { + return Ok(None); + }; + let (settings, workspace): (Option, Option) = conn.query_row( + "SELECT settings,workspace FROM bots WHERE id=?", + [id], + |r| Ok((r.get(0)?, r.get(1)?)), + )?; + let settings = match settings { + Some(text) => serde_json::from_str(&text)?, + None => Settings::default(), + }; + Ok(Some((settings, workspace))) + } /// The tools the creating bot's client declared, which a bot it /// creates takes unless its own client declares others: all but those /// declared as the creator's own. @@ -2323,6 +2485,10 @@ impl Database { } let inherited = Self::creator_gates(&tx, created_by_id)?; let gates = stored_gates(&merge_gates(inherited.iter().chain(binding.gate), &tools))?; + let settings = match Self::creator_settings(&tx, created_by_id)? { + Some((creator, folder)) => binding.settings.clone().under(&creator, folder.as_deref()), + None => binding.settings.clone(), + }; tx.execute( "INSERT INTO bots(name,id,head,workspace,status,running_turn,provider,family,model,instructions,reasoning,budget_tokens,tokens_used,context_start,pruned_cursor,tools,created_by,created_by_id,compaction_instructions,compaction_model,fallbacks,gates,settings,creation,declared) VALUES (?,?,NULL,?,'idle',NULL,?,?,?,?,?,?,0,NULL,0,?,?,?,?,?,?,?,?,?,?)", params![ @@ -2342,7 +2508,7 @@ impl Database { binding.compaction_model, binding.fallbacks, gates, - binding.settings.stored()?, + settings.stored()?, creation(binding.request_id, request), declared, ], @@ -4867,11 +5033,23 @@ impl Database { usage: Option<&Usage>, ) -> Result> { let bot = self.active(turn)?; + // A gated round notes which item plans each call. Whether a + // sandboxed bot's call is gated can turn on the folder it runs in. + let workspace: Option = match calls.is_empty() || !bot.sandboxed() { + true => None, + false => self + .conn + .prepare_cached( + "SELECT COALESCE(t.workspace,b.workspace) FROM turns t + JOIN bots b ON b.name=t.bot WHERE t.id=?", + )? + .query_row([turn], |r| r.get(0))?, + }; + let workspace = std::path::Path::new(workspace.as_deref().unwrap_or("/")); let tx = self.conn.savepoint()?; let mut head = bot.head; let mut entries = Vec::new(); - // A gated round notes which item plans each call. - let gated = calls.iter().any(|call| bot.gated(&call.name)); + let gated = calls.iter().any(|call| bot.gated(call, workspace)); let mut planned = HashMap::new(); let last_reasoning = items.last().is_some_and(|item| { serde_json::from_slice::(item).is_ok_and(|k| k.kind == "reasoning") @@ -4910,7 +5088,7 @@ impl Database { let announced_ms = epoch_ms(); let mut announced = Vec::new(); let mut judged = Vec::new(); - for call in calls.iter().filter(|call| bot.gated(&call.name)) { + for call in calls.iter().filter(|call| bot.gated(call, workspace)) { let gates: Vec = bot .gates .iter() @@ -7197,6 +7375,11 @@ impl Database { parent.gates.iter().chain(&inherited).chain(gate), &tools, ))?; + // And the stricter of its source's sandbox and its creator's. + let settings = match Self::creator_settings(&tx, created_by_id)? { + Some((creator, folder)) => parent.settings.clone().under(&creator, folder.as_deref()), + None => parent.settings.clone(), + }; tx.execute( "INSERT INTO bots(name,id,head,workspace,status,running_turn,provider,family,model,instructions,reasoning,budget_tokens,tokens_used,context_start,pruned_cursor,tools,created_by,created_by_id,compaction_instructions,compaction_model,cache_bot,thinking_prefix,thinking_floor,fallbacks,thinking_from,thinking_to,thinking_elided,gates,allowed,settings,creation,expire_ms,expires_at,declared) VALUES (?,?,?,?,'idle',NULL,?,?,?,?,?,?,0,NULL,0,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", params![ @@ -7229,7 +7412,7 @@ impl Database { thinking.2, gates, allowed.as_ref().map(|allowed| allowed.join(",")), - parent.settings.stored()?, + settings.stored()?, creation(request_id, request), expire_ms.map(|ms| ms as i64), expire_ms.map(|ms| epoch_ms().saturating_add(ms as i64)), diff --git a/src/tools.rs b/src/tools.rs index d4691640a..a7d29140b 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1,10 +1,11 @@ //! Registered tools share an execution budget; no per-bot worker or shell exists. -//! Tools run with the caller's OS permissions inside the bot workspace path; -//! nothing here is a sandbox. +//! Tools run with the caller's OS permissions inside the bot workspace path, +//! confined by the bot's sandbox when it has one (see `sandbox`). use crate::{ Error, Result, codec::{Family, ToolSchema}, fail, fail_with, + sandbox::{Daemon, Policy}, }; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -339,6 +340,8 @@ pub struct Registry { detached_budget: usize, credentials: Credentials, environment: Arc>, + /// What the daemon keeps that a sandbox leaves alone or lets through. + daemon: Arc, /// Request encodings per (family, selection); see `encoded`. encodings: Encodings, } @@ -712,6 +715,17 @@ impl Outcome { } } +/// The shell's fields for leaving the sandbox, as Codex describes them, +/// shown only to a bot that has one. +fn escalation(parameters: &mut serde_json::Value) { + let properties = &mut parameters["properties"]; + properties["sandbox_permissions"] = json!({"type":"string", + "enum":["use_default","require_escalated"], + "description":"Sandbox permissions for the command. Set to \"require_escalated\" to request running without sandbox restrictions; defaults to \"use_default\"."}); + properties["justification"] = json!({"type":"string", + "description":"Only set if sandbox_permissions is \"require_escalated\". Request approval from the user to run this command outside the sandbox. Phrased as a simple question that summarizes the purpose of the command as it relates to the task at hand - e.g. 'Do you want to fetch and pull the latest version of this git branch?'"}); +} + impl Registry { /// Every tool this build knows: what a daemon registers at start. Which /// of them a bot may call is the bot's own, chosen at creation. @@ -743,6 +757,7 @@ impl Registry { detached_budget: DEFAULT_DETACHED_BUDGET, credentials: Credentials::default(), environment: Arc::new(Vec::new()), + daemon: Arc::default(), encodings: Arc::default(), }) } @@ -766,8 +781,12 @@ impl Registry { family: Family, names: &[String], declared: &[Declared], + sandboxed: bool, ) -> Result> { let mut key = (family, names.join(",")); + if sandboxed { + key.1.push_str("\0sandboxed"); + } if !declared.is_empty() { key.1.push('\0'); key.1.push_str(&serde_json::to_string(declared)?); @@ -780,6 +799,12 @@ impl Registry { .filter_map(|name| Tool::parse(name)) .filter(|tool| self.tools.contains(tool)) .map(Tool::schema) + .map(|mut schema| { + if sandboxed && schema.name == "shell" { + escalation(&mut schema.parameters); + } + schema + }) .chain(declared.iter().map(|tool| ToolSchema { name: tool.name.clone().into(), description: tool.description.clone().into(), @@ -856,6 +881,15 @@ impl Registry { self.credentials = credentials; self } + /// The daemon's store and socket, which its bots' sandboxes keep + /// read-only and reachable. + pub fn with_daemon(mut self, daemon: Daemon) -> Self { + self.daemon = Arc::new(daemon); + self + } + pub fn daemon(&self) -> &Daemon { + &self.daemon + } pub fn names(&self) -> Vec<&'static str> { self.tools.iter().map(|t| t.name()).collect() } @@ -888,6 +922,10 @@ impl Registry { background: bool, #[serde(default)] detach: bool, + /// Whether a sandboxed bot's command asks to leave its sandbox; + /// decided before the call runs, through its gate. + sandbox_permissions: Option, + justification: Option, } #[derive(Deserialize)] #[serde(deny_unknown_fields)] @@ -973,6 +1011,19 @@ impl Registry { )) } else if args.detach && args.background { Some("detach and background cannot both be set".to_owned()) + } else if args + .sandbox_permissions + .as_deref() + .is_some_and(|value| !matches!(value, "use_default" | "require_escalated")) + { + Some("sandbox_permissions is use_default or require_escalated".to_owned()) + } else if args.justification.is_some() + && args.sandbox_permissions.as_deref() != Some("require_escalated") + { + Some( + "justification is only for sandbox_permissions require_escalated" + .to_owned(), + ) } else { None }; @@ -1083,19 +1134,21 @@ impl Registry { } /// `environment` is what the running turn adds for its children, such - /// as its own model for peers it delegates to. + /// as its own model for peers it delegates to. `sandbox` confines a + /// command, a write or an edit; `None` runs it unconfined. pub async fn execute( &self, tool: Prepared, workspace: &Path, environment: &[(String, String)], + sandbox: Option>, ) -> Result { match tool { Prepared::Echo(text) => Ok(Outcome::text(self.redact(text))), Prepared::Shell { background: true, .. } => fail("background_requires_runtime"), - Prepared::Detach(command) => detach(&command, workspace, self, environment), + Prepared::Detach(command) => detach(&command, workspace, self, environment, sandbox), Prepared::Wait { .. } => fail("wait_requires_runtime"), Prepared::History { .. } => fail("history_requires_runtime"), Prepared::Note { .. } => fail("note_requires_runtime"), @@ -1110,6 +1163,7 @@ impl Registry { workspace.to_path_buf(), timeout_ms, environment.to_vec(), + sandbox, tokio::sync::oneshot::channel().0, ) .await @@ -1148,10 +1202,8 @@ impl Registry { } Prepared::Write { path, content } => { let target = resolve(workspace, &path); - if let Some(parent) = target.parent() { - tokio::fs::create_dir_all(parent).await?; - } - files::write(&target, content.as_bytes()).await?; + writable(sandbox.clone(), &target, &path).await?; + files::write(&target, content.as_bytes(), sandbox).await?; Ok(Outcome::text(format!( "wrote {} bytes to {}", content.len(), @@ -1165,6 +1217,7 @@ impl Registry { replace_all, } => { let target = resolve(workspace, &path); + writable(sandbox.clone(), &target, &path).await?; let bytes = read_bounded(&target).await?; let text = String::from_utf8(bytes).map_err(|_| Error::new("file_not_utf8"))?; let count = text.matches(&old).count(); @@ -1190,7 +1243,7 @@ impl Registry { text.replacen(&old, &new, 1) }; debug_assert_eq!(updated.len(), expanded); - files::write(&target, updated.as_bytes()).await?; + files::write(&target, updated.as_bytes(), sandbox).await?; Ok(Outcome::text(format!( "replaced {count} occurrence{} in {path}", if count == 1 { "" } else { "s" } @@ -1260,6 +1313,7 @@ impl Registry { workspace: PathBuf, timeout_ms: u64, environment: Vec<(String, String)>, + sandbox: Option>, started: tokio::sync::oneshot::Sender<()>, ) -> impl Future> + Send + 'static { let registry = self.clone(); @@ -1276,6 +1330,7 @@ impl Registry { Duration::from_millis(timeout_ms), ®istry, &environment, + sandbox.as_deref(), ) .await?; Ok(registry.shell_outcome(stdout, stderr, exit, elapsed)) @@ -1288,12 +1343,14 @@ impl Registry { /// command, or the error a foreground command would have returned. /// `timeout_ms` bounds the command's running time; the place in line is /// bounded by `queue`, not by the clock. + #[allow(clippy::too_many_arguments)] pub fn background( &self, command: String, workspace: PathBuf, timeout_ms: u64, environment: Vec<(String, String)>, + sandbox: Option>, queued: Queued, done: tokio::sync::oneshot::Sender>, ) { @@ -1313,6 +1370,7 @@ impl Registry { Duration::from_millis(timeout_ms), ®istry, &environment, + sandbox.as_deref(), ) .await?; Ok(registry.shell_outcome(stdout, stderr, exit, elapsed)) @@ -1435,6 +1493,29 @@ fn resolve(workspace: &Path, path: &str) -> PathBuf { workspace.join(path) } +/// Refuse a confined write or edit of what its sandbox keeps, as a +/// command in it would be refused. Resolving the target reads the +/// filesystem, so it runs off the runtime thread. +async fn writable(sandbox: Option>, target: &Path, path: &str) -> Result<()> { + let Some(policy) = sandbox else { + return Ok(()); + }; + let target = target.to_owned(); + let allowed = tokio::task::spawn_blocking(move || policy.can_write(&target)) + .await + .unwrap_or(false); + match allowed { + false => fail_with( + "write_denied", + format!( + "{path} is outside the folders this bot may write, or kept read-only in one; \ + ask to escalate to write it" + ), + ), + true => Ok(()), + } +} + fn boundary(text: &str, mut index: usize) -> usize { while index > 0 && !text.is_char_boundary(index) { index -= 1; @@ -1570,8 +1651,17 @@ fn sh( workspace: &Path, registry: &Registry, environment: &[(String, String)], -) -> tokio::process::Command { - let mut process = tokio::process::Command::new(shell_path()); + sandbox: Option<&Policy>, + detached: bool, +) -> Result { + let mut process = match sandbox { + Some(policy) => crate::sandbox::command(policy, shell_path(), &["-c", command], detached)?, + None => { + let mut process = tokio::process::Command::new(shell_path()); + process.arg("-c").arg(command); + process + } + }; for name in registry.credentials.names() { process.env_remove(name); } @@ -1612,8 +1702,8 @@ fn sh( for (name, value) in registry.environment.iter().chain(environment) { process.env(name, value); } - process.arg("-c").arg(command).current_dir(workspace); - process + process.current_dir(workspace); + Ok(process) } /// Start a command in a new session for a service that must outlive the @@ -1627,9 +1717,17 @@ fn detach( workspace: &Path, registry: &Registry, environment: &[(String, String)], + sandbox: Option>, ) -> Result { use std::process::Stdio; - let mut process = sh(command, workspace, registry, environment); + let mut process = sh( + command, + workspace, + registry, + environment, + sandbox.as_deref(), + true, + )?; process .stdin(Stdio::null()) .stdout(Stdio::null()) @@ -1659,11 +1757,14 @@ fn detach( ) })?) }; + // What the sandbox holds in place stays held while the command runs. + let held = sandbox.as_deref().map(crate::sandbox::hold); let mut child = process.spawn()?; let pid = child.id().ok_or(Error::new("shell_spawn_failed"))?; tokio::spawn(async move { let _permit = permit; let _ = child.wait().await; + drop(held); }); Ok(Outcome::text( json!({"detached":true,"pid":pid}).to_string(), @@ -1671,7 +1772,13 @@ fn detach( } #[cfg(not(unix))] -fn detach(_: &str, _: &Path, _: &Registry, _: &[(String, String)]) -> Result { +fn detach( + _: &str, + _: &Path, + _: &Registry, + _: &[(String, String)], + _: Option>, +) -> Result { fail("shell_platform_unsupported") } @@ -1682,6 +1789,7 @@ async fn shell( timeout: Duration, registry: &Registry, environment: &[(String, String)], + sandbox: Option<&Policy>, ) -> Result<(Captured, Captured, Exit, Duration)> { use std::process::Stdio; use tokio::process::Child; @@ -1701,7 +1809,9 @@ async fn shell( } } } - let child = sh(command, workspace, registry, environment) + // Held until the command and the sandbox around it are gone. + let _held = sandbox.map(crate::sandbox::hold); + let child = sh(command, workspace, registry, environment, sandbox, false)? .stdin(Stdio::null()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) @@ -1767,6 +1877,7 @@ async fn shell( _: Duration, _: &Registry, _: &[(String, String)], + _: Option<&Policy>, ) -> Result<(Captured, Captured, Exit, Duration)> { fail("shell_platform_unsupported") } @@ -1797,7 +1908,7 @@ mod tests { let prepared = registry .prepare("read", &json!({"path": path}).to_string()) .unwrap(); - registry.execute(prepared, &dir, &[]) + registry.execute(prepared, &dir, &[], None) }; let outcome = read("shot.png").await.unwrap(); assert_eq!( diff --git a/src/tools/files.rs b/src/tools/files.rs index 46f47b526..08535ffdf 100644 --- a/src/tools/files.rs +++ b/src/tools/files.rs @@ -1,8 +1,12 @@ //! Native file tools operate only on regular files. Special files need an I/O //! owner with cancellation semantics, not Tokio's blocking filesystem workers. use super::FILE_BYTES; -use crate::{Error, Result, fail}; -use std::{io::ErrorKind, path::Path}; +use crate::{ + Error, Result, fail, + sandbox::{Policy, WRITE}, +}; +use serde_json::Value; +use std::{io::ErrorKind, path::Path, process::Stdio, sync::Arc}; use tokio::io::{AsyncReadExt, AsyncWriteExt}; async fn open_regular(path: &Path, write: bool) -> Result { @@ -56,7 +60,18 @@ pub(super) async fn read_head(path: &Path, count: u64) -> Result> { Ok(bytes) } -pub(super) async fn write(path: &Path, content: &[u8]) -> Result<()> { +/// Write `content` to `path`. A confined write runs inside the bot's +/// sandbox, in the agent binary, as Codex runs `apply_patch`: the kernel +/// decides what it reaches, so a link swapped in after the path was +/// checked can't carry it out. An unconfined one makes the folders above +/// `path` itself first. +pub(super) async fn write(path: &Path, content: &[u8], sandbox: Option>) -> Result<()> { + if let Some(policy) = sandbox { + return confined(&policy, path, content).await; + } + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } let mut file = open_regular(path, true).await?; file.set_len(0).await?; file.write_all(content).await?; @@ -65,3 +80,41 @@ pub(super) async fn write(path: &Path, content: &[u8]) -> Result<()> { file.flush().await?; Ok(()) } + +async fn confined(policy: &Policy, path: &Path, content: &[u8]) -> Result<()> { + let Some(helper) = policy.helper.as_deref().and_then(Path::to_str) else { + return fail("sandbox_unavailable"); + }; + let Some(target) = path.to_str() else { + return fail("path_not_utf8"); + }; + let _held = crate::sandbox::hold(policy); + let mut command = crate::sandbox::command(policy, helper, &[WRITE, target], false)?; + command + .env_clear() + .stdin(Stdio::piped()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let mut child = command.spawn()?; + let mut stdin = child.stdin.take().ok_or(Error::new("io_error"))?; + // A helper that fails before reading closes stdin; its own error says why. + let _ = stdin.write_all(content).await; + drop(stdin); + let output = child.wait_with_output().await?; + if output.status.success() { + return Ok(()); + } + // The helper reports as the CLI does: one JSON object on stderr. + let reported: Option = serde_json::from_slice(&output.stderr).ok(); + let code = reported.as_ref().and_then(|r| r["error"].as_str()); + let detail = reported.as_ref().and_then(|r| r["detail"].as_str()); + Err(match (code, detail) { + (Some(code), Some(detail)) => Error::with(code, detail), + (Some(code), None) => Error::new(code), + _ => Error::with( + "io_error", + format!("the confined write exited with {}", output.status), + ), + }) +} diff --git a/tests/store_contract.rs b/tests/store_contract.rs index a3f1efdc3..3b7e81a20 100644 --- a/tests/store_contract.rs +++ b/tests/store_contract.rs @@ -741,6 +741,186 @@ fn gates_are_capped_before_a_bot_is_written() { gated_bot(&mut db, "g8", "t8", None, None).unwrap(); } +/// A sandboxed bot can't make its way out of its sandbox: a bot it makes +/// takes its sandbox unless it names a stricter one, and writes only +/// within the creator's own roots. +#[test] +fn a_bot_made_by_a_sandboxed_bot_is_no_less_sandboxed() { + use agent_runtime::sandbox::Mode; + let mut db = db(); + let ann = db + .create( + "Ann", + Some("/work"), + Binding { + settings: Settings { + sandbox: Some(Mode::WorkspaceWrite), + writable: vec!["/shared".into()], + ..Settings::default() + }, + ..binding() + }, + ) + .unwrap() + .0; + let made = |db: &mut Database, name: &str, settings: Settings| { + db.create( + name, + Some("/elsewhere"), + Binding { + settings, + created_by: Some(ann.name.as_str()), + created_by_id: Some(ann.bot_id), + ..binding() + }, + ) + .unwrap() + .0 + .settings + }; + let unconfined = made(&mut db, "Bob", Settings::default()); + assert_eq!(unconfined.sandbox, Some(Mode::WorkspaceWrite)); + assert_eq!(unconfined.writable, ["/shared"]); + assert_eq!( + unconfined.within, + Some(vec!["/work".into(), "/shared".into()]) + ); + let full = made( + &mut db, + "Cid", + Settings { + sandbox: Some(Mode::DangerFullAccess), + ..Settings::default() + }, + ); + assert_eq!(full.sandbox, Some(Mode::WorkspaceWrite)); + let networked = made( + &mut db, + "Dee", + Settings { + sandbox: Some(Mode::WorkspaceWrite), + writable: vec!["/shared/part".into()], + network: true, + within: Some(vec!["/shared".into(), "/other".into()]), + ..Settings::default() + }, + ); + assert!(!networked.network); + assert_eq!(networked.writable, ["/shared/part"]); + assert_eq!(networked.within, Some(vec!["/shared".into()])); + let read_only = made( + &mut db, + "Eve", + Settings { + sandbox: Some(Mode::ReadOnly), + ..Settings::default() + }, + ); + assert_eq!(read_only.sandbox, Some(Mode::ReadOnly)); + assert_eq!(read_only.within, None); + // A bot that bot makes is held within the same roots, or fewer. + let bob = db.inspect("Bob").unwrap(); + let grandchild = Settings::default().under(&bob.settings, Some("/work/sub")); + assert_eq!( + grandchild.within, + Some(vec!["/work/sub".into(), "/shared".into()]) + ); + let outside = Settings::default().under(&bob.settings, Some("/elsewhere")); + assert_eq!(outside.within, Some(vec!["/shared".into()])); + // An unconfined creator imposes nothing. + let free = Settings::default().under(&Settings::default(), Some("/work")); + assert_eq!(free, Settings::default()); +} + +#[test] +fn a_sandboxed_bot_waits_for_a_verdict_only_on_an_escape() { + let mut db = db(); + let tools = ["shell".to_owned(), "write".to_owned()]; + let gate = Gate { + tag: "manual".into(), + tools: tools.to_vec(), + expire_ms: None, + }; + let binding = Binding { + tools: &tools, + gate: Some(&gate), + settings: Settings { + sandbox: Some(agent_runtime::sandbox::Mode::WorkspaceWrite), + ..Settings::default() + }, + ..binding() + }; + db.create("Bob", Some("/synthetic"), binding).unwrap(); + let turn = db + .begin( + "Bob", + "request", + "work", + true, + &TurnOptions::default(), + allow_provider, + ) + .unwrap() + .turn; + let call = |id: &str, name: &str, arguments: Value| { + let arguments = arguments.to_string(); + let item = json!({"type":"function_call","id":format!("fc_{id}"),"call_id":id, + "name":name,"arguments":arguments}); + let call = ToolCall { + name: name.into(), + call_id: id.into(), + arguments, + }; + (Bytes::from(serde_json::to_vec(&item).unwrap()), call) + }; + let round = [ + call("plain", "shell", json!({"command":"make"})), + call( + "up", + "shell", + json!({"command":"git push","sandbox_permissions":"require_escalated", + "justification":"Do you want to push the branch?"}), + ), + call( + "inside", + "write", + json!({"path":"notes/a.md","content":"x"}), + ), + call( + "climbs", + "write", + json!({"path":"../elsewhere/a.md","content":"x"}), + ), + call( + "outside", + "write", + json!({"path":"/etc/hosts","content":"x"}), + ), + ]; + let (items, calls): (Vec<_>, Vec<_>) = round.into_iter().unzip(); + db.append(turn, items, &calls, None).unwrap(); + let bot = db.inspect("Bob").unwrap(); + let folder = std::path::Path::new("/synthetic"); + let gated: Vec<&str> = calls + .iter() + .filter(|call| bot.gated(call, folder)) + .map(|call| call.call_id.as_str()) + .collect(); + assert_eq!(gated, ["up", "climbs", "outside"]); + // What runs confined was never announced; each escape was. + for call in &calls { + let announced = db.approval_start(turn, call, 0); + match gated.contains(&call.call_id.as_str()) { + true => assert!( + matches!(announced, Ok(Gated::Pending { .. })), + "{}", + call.call_id + ), + false => assert_eq!(announced.err().unwrap().code, "invalid_tool_state"), + } + } +} + #[test] fn an_expiry_that_came_first_beats_a_later_denial() { let mut db = db(); @@ -2957,7 +3137,7 @@ fn a_bot_keeps_its_settings_and_a_fork_copies_them() { "Bob", Some("/synthetic"), Binding { - settings, + settings: settings.clone(), ..binding() }, ) @@ -2968,7 +3148,7 @@ fn a_bot_keeps_its_settings_and_a_fork_copies_them() { serde_json::to_value(&bob).unwrap()["settings"], json!({"context_bytes":4096,"context_items":4096,"note_turns":48,"compact_at":75, "compact_keep":25,"compact_tokens":null,"keep_turns":1,"approval_hold_ms":2000,"max_output_tokens":null, - "keep_warm":0,"cache_ttl":"1h"}) + "keep_warm":0,"cache_ttl":"1h","sandbox":null,"writable":[],"network":false}) ); // Retention follows the bot's own setting. for n in 1..=3 { @@ -3038,7 +3218,7 @@ fn a_bot_keeps_its_settings_and_a_fork_copies_them() { "Bad", Some("/synthetic"), Binding { - settings, + settings: settings.clone(), ..binding() }, ) @@ -7278,7 +7458,7 @@ fn schema_46_settings_keep_turns_as_prune_names_it() { ..Settings::default() }; let made = || Binding { - settings, + settings: settings.clone(), request_id: Some("once"), ..binding() }; @@ -11304,8 +11484,14 @@ fn a_fork_narrows_what_it_may_call_and_never_widens() { ); assert_eq!(forked.unwrap()["data"]["allowed"], json!(["read"])); // A call it may not make waits for no verdict: it is refused. - assert!(db.inspect("Bob").unwrap().gated("shell")); - assert!(!reader.gated("shell")); + let shell = ToolCall { + name: "shell".into(), + call_id: "c".into(), + arguments: r#"{"command":"true"}"#.into(), + }; + let folder = std::path::Path::new("/synthetic"); + assert!(db.inspect("Bob").unwrap().gated(&shell, folder)); + assert!(!reader.gated(&shell, folder)); let (plain, _) = db.fork("Reader", "Plain", Fork::default()).unwrap(); assert_eq!(plain.allowed.as_deref(), Some(&read[..])); assert_eq!( diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 947679f66..a10d50de6 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -5,10 +5,14 @@ import queue import re import shlex +import shutil import subprocess +import sys +import tempfile import threading import time import unittest +from pathlib import Path from bench.runtime_client import Client, node_item from bench.socket_client import Connection, SocketClient @@ -17,11 +21,13 @@ from tests.test_runtime import ODD_CALL_ID, ModelFixture, is_summary from tests.test_turn_compaction import all_events +ROOT = Path(__file__).resolve().parent.parent + @unittest.skipUnless(os.environ.get('AGENT_TEST_RUNTIME') == '1', 'set AGENT_TEST_RUNTIME=1 after a Rust release build') class ApprovalTests(ModelFixture): - def gated(self, extra=(), settings=None, bot='Bob', **gate): - client = self.client('echo,shell,wait', extra=extra, settings=settings) + def gated(self, extra=(), settings=None, bot='Bob', tools='echo,shell,wait', **gate): + client = self.client(tools, extra=extra, settings=settings) gate = {'approve': ['shell'], 'approver': 'manual', **gate} created = client.request('create', bot=bot, workspace=str(self.path), **gate)['result'] self.assertEqual(created['gates'], [{'tag': gate['approver'], 'tools': gate['approve'], @@ -92,6 +98,58 @@ def test_an_allowed_call_runs_and_records_who_allowed_it(self): # The live verdict took no park: the turn never waited. self.assertEqual(self.events(client, turn, 'turn_waiting'), []) + @unittest.skipUnless(shutil.which('bwrap') or sys.platform == 'darwin', 'sandboxes need bubblewrap') + def test_a_sandboxed_bot_asks_only_to_leave_its_sandbox(self): + # Outside the temporary directory, which a sandbox may write. + (ROOT / '.local').mkdir(exist_ok=True) + outside = Path(tempfile.mkdtemp(dir=ROOT / '.local')) + self.addCleanup(shutil.rmtree, outside) + client = self.gated(tools='shell,write', approve=['shell', 'write'], + settings={'sandbox': 'workspace-write'}) + created = client.request('inspect', bot='Bob')['result'] + self.assertEqual(created['settings']['sandbox'], 'workspace-write') + calls = [['shell', {'command': f'touch inside; touch {outside}/confined'}], + ['shell', {'command': f'touch {outside}/escaped', 'sandbox_permissions': 'require_escalated', + 'justification': 'Do you want to write outside the workspace?'}], + ['write', {'path': 'notes.md', 'content': 'x'}], + ['write', {'path': f'{outside}/written', 'content': 'x'}]] + turn = client.request('submit', bot='Bob', request_id='t', + prompt='calls:' + json.dumps(calls))['result']['turn'] + # Only the two that leave the sandbox wait for a verdict. + announced = self.announced(client, turn) + self.assertEqual([call['call_id'] for call in announced], ['shell-2', 'write-4']) + # The confined command's failure voids the round's verdicts, as any + # failure does; the escapes are asked again. + again = self.announced(client, turn, failed='shell-1') + self.assertEqual([call['call_id'] for call in again], ['shell-2', 'write-4']) + self.answer(client, turn, 'shell-2', request=2) + self.answer(client, turn, 'write-4', request=2) + self.assertEqual(client.finished(turn)['data']['status'], 'completed') + # The confined command wrote its folder and not past it; the + # allowed escapes ran unconfined. + self.assertTrue((self.path / 'inside').exists()) + self.assertNotEqual(self.tool_output(client, 'shell-1')[1]['exit_code'], 0) + self.assertEqual(sorted(p.name for p in outside.iterdir()), ['escaped', 'written']) + self.assertTrue((self.path / 'notes.md').exists()) + + @unittest.skipUnless(shutil.which('bwrap') or sys.platform == 'darwin', 'sandboxes need bubblewrap') + def test_a_sandboxed_bot_without_a_gate_cannot_leave_its_sandbox(self): + (ROOT / '.local').mkdir(exist_ok=True) + outside = Path(tempfile.mkdtemp(dir=ROOT / '.local')) + self.addCleanup(shutil.rmtree, outside) + client = self.client('shell,write', settings={'sandbox': 'workspace-write'}) + client.request('create', bot='Bob', workspace=str(self.path)) + calls = [['shell', {'command': f'touch {outside}/escaped', 'sandbox_permissions': 'require_escalated'}], + ['write', {'path': f'{outside}/written', 'content': 'x'}]] + turn = client.request('submit', bot='Bob', request_id='t', + prompt='calls:' + json.dumps(calls))['result']['turn'] + self.assertEqual(client.finished(turn)['data']['status'], 'completed') + self.assertEqual(self.events(client, turn, 'approval_requested'), []) + # As Codex refuses an escape under a policy that never asks. + self.assertEqual(self.tool_output(client, 'shell-1')[1]['error'], 'escalation_refused') + self.assertEqual(self.tool_output(client, 'write-2')[1]['error'], 'write_denied') + self.assertEqual(list(outside.iterdir()), []) + def test_a_denial_is_the_result_and_the_turn_goes_on(self): client = self.gated() turn = client.request('submit', bot='Bob', request_id='t', prompt='shell:touch ran')['result']['turn'] diff --git a/tests/test_runtime.py b/tests/test_runtime.py index 5b584ff51..319d29f26 100644 --- a/tests/test_runtime.py +++ b/tests/test_runtime.py @@ -409,6 +409,12 @@ def do_POST(self): text = '' output = [{'type': 'function_call', 'name': 'shell', 'call_id': 'shell-1', 'arguments': json.dumps({'command': user[6:], 'timeout_ms': 2000})}] + elif user.startswith('calls:'): + # Calls given whole, as [[name, arguments], ...], in one response. + text = '' + output = [{'type': 'function_call', 'name': name, 'call_id': f'{name}-{i + 1}', + 'arguments': json.dumps(arguments)} + for i, (name, arguments) in enumerate(json.loads(user[6:]))] elif user.startswith('shellecho:'): # A command, then an echo, in one response. text = '' diff --git a/tests/tool_contract.rs b/tests/tool_contract.rs index 9e1712e28..10bcce501 100644 --- a/tests/tool_contract.rs +++ b/tests/tool_contract.rs @@ -14,8 +14,14 @@ async fn shell_preserves_exit_status_and_separate_outputs() { &json!({"command":"printf out; printf err >&2; exit 7","timeout_ms":1000}).to_string(), ) .unwrap(); - let output: Value = - serde_json::from_str(&tools.execute(prepared, &cwd, &[]).await.unwrap().output).unwrap(); + let output: Value = serde_json::from_str( + &tools + .execute(prepared, &cwd, &[], None) + .await + .unwrap() + .output, + ) + .unwrap(); assert_eq!(output["stdout"], "out"); assert_eq!(output["stderr"], "err"); assert_eq!(output["exit_code"], 7); @@ -29,8 +35,14 @@ async fn shell_preserves_exit_status_and_separate_outputs() { ) .unwrap(); let env = [("PAGER".to_owned(), "less".to_owned())]; - let output: Value = - serde_json::from_str(&tools.execute(prepared, &cwd, &env).await.unwrap().output).unwrap(); + let output: Value = serde_json::from_str( + &tools + .execute(prepared, &cwd, &env, None) + .await + .unwrap() + .output, + ) + .unwrap(); assert_eq!(output["stdout"], "dumb 1 less cat cat"); assert!( Registry::new("echo") @@ -58,7 +70,7 @@ async fn shell_timeout_and_output_overflow_are_bounded() { .unwrap(); tokio::time::timeout( std::time::Duration::from_secs(2), - tools.execute(prepared, &cwd, &[]), + tools.execute(prepared, &cwd, &[], None), ) }; // A timed-out command still shows what it wrote before it was killed. @@ -109,7 +121,7 @@ async fn known_credential_text_is_redacted_before_tool_result_serialization() { .unwrap(); assert!( tools - .execute(echo, &std::env::temp_dir(), &[]) + .execute(echo, &std::env::temp_dir(), &[], None) .await .unwrap() .output @@ -117,7 +129,7 @@ async fn known_credential_text_is_redacted_before_tool_result_serialization() { ); let shell = tools.prepare("shell", &json!({"command": "printf '%s' 'synthetic-\"test-value'; printf '%s' 'synthetic-\"test-value' >&2"}).to_string()).unwrap(); let output = tools - .execute(shell, &std::env::temp_dir(), &[]) + .execute(shell, &std::env::temp_dir(), &[], None) .await .unwrap() .output; @@ -131,7 +143,7 @@ async fn known_credential_text_is_redacted_before_tool_result_serialization() { ) .unwrap(); let outcome = tools - .execute(binary, &std::env::temp_dir(), &[]) + .execute(binary, &std::env::temp_dir(), &[], None) .await .unwrap(); let result: Value = serde_json::from_str(&outcome.output).unwrap(); @@ -155,7 +167,7 @@ async fn rotated_overlapping_credentials_are_fully_redacted() { .unwrap(); assert_eq!( tools - .execute(echo, &std::env::temp_dir(), &[]) + .execute(echo, &std::env::temp_dir(), &[], None) .await .unwrap() .output, @@ -180,7 +192,7 @@ async fn file_tools_read_write_and_edit_within_the_workspace() { .unwrap(); assert!( tools - .execute(write, &dir, &[]) + .execute(write, &dir, &[], None) .await .unwrap() .output @@ -193,7 +205,7 @@ async fn file_tools_read_write_and_edit_within_the_workspace() { &json!({"path":"alias.rs","offset":2,"limit":1}).to_string(), ) .unwrap(); - let shown = tools.execute(read, &dir, &[]).await.unwrap().output; + let shown = tools.execute(read, &dir, &[], None).await.unwrap().output; assert!(shown.starts_with(" 2\ttwo\n"), "{shown}"); assert!(shown.contains("showing lines 2-2 of 3")); let ambiguous = tools @@ -203,7 +215,11 @@ async fn file_tools_read_write_and_edit_within_the_workspace() { ) .unwrap(); assert_eq!( - tools.execute(ambiguous, &dir, &[]).await.unwrap_err().code, + tools + .execute(ambiguous, &dir, &[], None) + .await + .unwrap_err() + .code, "edit_target_ambiguous" ); let edit = tools @@ -214,7 +230,7 @@ async fn file_tools_read_write_and_edit_within_the_workspace() { .unwrap(); assert!( tools - .execute(edit, &dir, &[]) + .execute(edit, &dir, &[], None) .await .unwrap() .output @@ -237,7 +253,11 @@ async fn file_tools_read_write_and_edit_within_the_workspace() { // Three replacements fit; five replacements exceed the file bound. std::fs::write(dir.join("src/lib.rs"), "a\nb\nc\nd\ne\n").unwrap(); assert_eq!( - tools.execute(oversized, &dir, &[]).await.unwrap_err().code, + tools + .execute(oversized, &dir, &[], None) + .await + .unwrap_err() + .code, "tool_output_limit" ); assert_eq!( @@ -250,7 +270,7 @@ async fn file_tools_read_write_and_edit_within_the_workspace() { &json!({"path":"alias.rs","content":"short"}).to_string(), ) .unwrap(); - tools.execute(rewrite, &dir, &[]).await.unwrap(); + tools.execute(rewrite, &dir, &[], None).await.unwrap(); assert_eq!( std::fs::read_to_string(dir.join("src/lib.rs")).unwrap(), "short" @@ -260,7 +280,11 @@ async fn file_tools_read_write_and_edit_within_the_workspace() { .prepare("read", &json!({"path":"nope.txt"}).to_string()) .unwrap(); assert_eq!( - tools.execute(missing, &dir, &[]).await.unwrap_err().code, + tools + .execute(missing, &dir, &[], None) + .await + .unwrap_err() + .code, "file_unreadable" ); // Oversized shell output keeps a preview and retains the full stream. @@ -270,7 +294,7 @@ async fn file_tools_read_write_and_edit_within_the_workspace() { &json!({"command":"head -c 200000 /dev/zero | tr '\\0' 'y'"}).to_string(), ) .unwrap(); - let outcome = tools.execute(big, &dir, &[]).await.unwrap(); + let outcome = tools.execute(big, &dir, &[], None).await.unwrap(); let result: Value = serde_json::from_str(&outcome.output).unwrap(); assert!(result["stdout"].as_str().unwrap().contains("bytes omitted")); assert_eq!(outcome.artifacts[0].0, "stdout"); @@ -289,12 +313,12 @@ async fn read_distinguishes_long_lines_from_eof_and_keeps_pages_bounded() { .unwrap() }; std::fs::write(dir.join("data"), "x".repeat(80_000)).unwrap(); - let error = tools.execute(read(1), &dir, &[]).await.unwrap_err(); + let error = tools.execute(read(1), &dir, &[], None).await.unwrap_err(); assert_eq!(error.code, "read_line_too_long"); assert!(error.detail.unwrap().contains("line 1")); assert!( tools - .execute(read(2), &dir, &[]) + .execute(read(2), &dir, &[], None) .await .unwrap() .output @@ -305,16 +329,24 @@ async fn read_distinguishes_long_lines_from_eof_and_keeps_pages_bounded() { format!("ok\n{}\ntail\n", "x".repeat(80_000)), ) .unwrap(); - let page = tools.execute(read(1), &dir, &[]).await.unwrap().output; + let page = tools + .execute(read(1), &dir, &[], None) + .await + .unwrap() + .output; assert!(page.starts_with(" 1\tok\n")); assert!(page.contains("offset=2")); assert_eq!( - tools.execute(read(2), &dir, &[]).await.unwrap_err().code, + tools + .execute(read(2), &dir, &[], None) + .await + .unwrap_err() + .code, "read_line_too_long" ); assert!( tools - .execute(read(3), &dir, &[]) + .execute(read(3), &dir, &[], None) .await .unwrap() .output @@ -324,7 +356,7 @@ async fn read_distinguishes_long_lines_from_eof_and_keeps_pages_bounded() { let page = tools .prepare("read", &json!({"path":"data","limit":5000}).to_string()) .unwrap(); - let output = tools.execute(page, &dir, &[]).await.unwrap().output; + let output = tools.execute(page, &dir, &[], None).await.unwrap().output; assert!(output.len() <= agent_runtime::tools::PREVIEW_BYTES); assert!(output.contains("continue with offset=")); std::fs::remove_dir_all(dir).unwrap(); @@ -350,8 +382,14 @@ async fn detached_commands_run_in_their_own_session_and_write_only_where_told() .to_string(), ) .unwrap(); - let output: Value = - serde_json::from_str(&tools.execute(prepared, &dir, &[]).await.unwrap().output).unwrap(); + let output: Value = serde_json::from_str( + &tools + .execute(prepared, &dir, &[], None) + .await + .unwrap() + .output, + ) + .unwrap(); assert_eq!(output.as_object().unwrap().len(), 2); let pid = output["pid"].as_i64().unwrap() as i32; // Its own session, so no group kill of the calling command reaches it. @@ -389,7 +427,7 @@ async fn detached_commands_are_bounded_and_reaped() { for _ in 0..2 { let output: Value = serde_json::from_str( &tools - .execute(detach("exec sleep 30"), &dir, &[]) + .execute(detach("exec sleep 30"), &dir, &[], None) .await .unwrap() .output, @@ -398,7 +436,10 @@ async fn detached_commands_are_bounded_and_reaped() { pids.push(output["pid"].as_i64().unwrap() as i32); } // Two running: the bound refuses a third, as a tool error the model sees. - let error = tools.execute(detach("true"), &dir, &[]).await.unwrap_err(); + let error = tools + .execute(detach("true"), &dir, &[], None) + .await + .unwrap_err(); assert_eq!(error.code, "detached_limit"); assert!( error @@ -426,7 +467,7 @@ async fn detached_commands_are_bounded_and_reaped() { -1 ); let output = tools - .execute(detach("true"), &dir, &[]) + .execute(detach("true"), &dir, &[], None) .await .unwrap() .output; @@ -442,6 +483,7 @@ async fn detached_commands_are_bounded_and_reaped() { .unwrap(), &dir, &[], + None, ) .await .unwrap(); @@ -451,3 +493,263 @@ async fn detached_commands_are_bounded_and_reaped() { } std::fs::remove_dir_all(dir).unwrap(); } + +/// Whether sandboxes run here. CI sets AGENT_TEST_SANDBOX, where one that +/// can't is a failure; elsewhere the test says why it skipped. +#[cfg(target_os = "linux")] +fn sandboxes() -> bool { + match agent_runtime::sandbox::available() { + Ok(()) => true, + Err(error) if std::env::var_os("AGENT_TEST_SANDBOX").is_some() => { + panic!("no sandbox: {error:?}") + } + Err(error) => { + eprintln!("skipped: {error:?}"); + false + } + } +} + +/// A sandboxed command writes its roots and nothing else, keeps `.git` +/// and the store read-only, can't make a missing `.git`, and has no +/// network; a confined write or edit is held to the same roots. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn a_sandboxed_command_writes_only_its_roots() { + use agent_runtime::sandbox::{Daemon, Mode, Policy}; + use std::sync::Arc; + if !sandboxes() { + return; + } + // Outside the temporary directory, which a sandbox may write. + let base = std::fs::canonicalize(env!("CARGO_TARGET_TMPDIR")) + .unwrap() + .join(format!("agent-sandbox-{}", std::process::id())); + let (dir, other, repo, fresh) = ( + base.join("work"), + base.join("other"), + base.join("repo"), + base.join("fresh"), + ); + for folder in [&dir, &other, &repo.join(".git"), &fresh] { + std::fs::create_dir_all(folder).unwrap(); + } + std::fs::write(dir.join("store"), "db").unwrap(); + let daemon = Daemon { + store: Some(dir.join("store")), + socket: None, + helper: Some(env!("CARGO_BIN_EXE_agent").into()), + }; + let writable = [ + repo.to_str().unwrap().to_owned(), + fresh.to_str().unwrap().to_owned(), + ]; + let policy = Arc::new( + Policy::new( + Some(Mode::WorkspaceWrite), + Some(&dir), + &writable, + None, + false, + &daemon, + ) + .unwrap() + .unwrap(), + ); + let tools = Registry::new("shell,write,edit").unwrap(); + let run = |command: String| { + let tools = tools.clone(); + let (dir, policy) = (dir.clone(), policy.clone()); + async move { + let prepared = tools + .prepare("shell", &json!({"command":command}).to_string()) + .unwrap(); + let outcome = tools + .execute(prepared, &dir, &[], Some(policy)) + .await + .unwrap(); + serde_json::from_str::(&outcome.output).unwrap() + } + }; + // Its folder, a named one and the temporary directory take writes, + // from where the command started. + let output = run(format!( + "echo a > made && echo b > {repo}/b && echo c > /tmp/agent-sandbox-tmp-{pid} && pwd", + repo = repo.display(), + pid = std::process::id() + )) + .await; + assert_eq!(output["exit_code"], 0, "{output}"); + assert_eq!( + output["stdout"].as_str().unwrap().trim(), + dir.to_str().unwrap() + ); + assert!(dir.join("made").exists() && repo.join("b").exists()); + std::fs::remove_file(format!("/tmp/agent-sandbox-tmp-{}", std::process::id())).unwrap(); + // Anything else, git's folder and the store stay as they were. + for target in [ + other.join("x"), + repo.join(".git/config"), + dir.join("store"), + fresh.join(".git"), + ] { + let output = run(format!("mkdir {0} || echo x > {0}", target.display())).await; + assert_ne!(output["exit_code"], 0, "{} {output}", target.display()); + } + assert!(!other.join("x").exists() && !repo.join(".git/config").exists()); + assert_eq!(std::fs::read_to_string(dir.join("store")).unwrap(), "db"); + // The placeholder that kept `.git` from being made goes with the + // command, off the runtime thread. + for _ in 0..100 { + if !fresh.join(".git").exists() && !dir.join(".git").exists() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert!(!fresh.join(".git").exists() && !dir.join(".git").exists()); + // Only loopback: no network. + let output = run("tail -n +3 /proc/net/dev | cut -d: -f1".into()).await; + assert_eq!(output["stdout"].as_str().unwrap().trim(), "lo"); + // A confined write or edit is held to the same roots. + let write = |path: &str| { + tools + .prepare("write", &json!({"path":path,"content":"x"}).to_string()) + .unwrap() + }; + tools + .execute(write("notes/a.md"), &dir, &[], Some(policy.clone())) + .await + .unwrap(); + for denied in [ + other.join("a").to_str().unwrap(), + repo.join(".git/HEAD").to_str().unwrap(), + "store", + ] { + let error = tools + .execute(write(denied), &dir, &[], Some(policy.clone())) + .await + .unwrap_err(); + assert_eq!(error.code, "write_denied", "{denied}"); + } + std::os::unix::fs::symlink(&other, dir.join("out")).unwrap(); + let edit = tools + .prepare( + "edit", + &json!({"path":"out/a","old":"x","new":"y"}).to_string(), + ) + .unwrap(); + let error = tools + .execute(edit, &dir, &[], Some(policy.clone())) + .await + .unwrap_err(); + assert_eq!(error.code, "write_denied"); + // A link to a file that doesn't exist yet is followed to where a write + // through it would land. + std::os::unix::fs::symlink(other.join("new"), dir.join("dangling")).unwrap(); + let error = tools + .execute(write("dangling"), &dir, &[], Some(policy.clone())) + .await + .unwrap_err(); + assert_eq!(error.code, "write_denied"); + assert!(!other.join("new").exists()); + std::fs::remove_dir_all(base).unwrap(); +} + +/// A root outside every folder in `within` is not one. +#[test] +fn a_root_outside_within_is_not_writable() { + use agent_runtime::sandbox::{Daemon, Mode, Policy}; + let base = std::fs::canonicalize(env!("CARGO_TARGET_TMPDIR")) + .unwrap() + .join(format!("agent-within-{}", std::process::id())); + let (mine, other) = (base.join("mine"), base.join("other")); + for folder in [&mine, &other] { + std::fs::create_dir_all(folder).unwrap(); + } + let named = [mine.to_str().unwrap().to_owned()]; + let policy = |within: &[String]| { + Policy::new( + Some(Mode::WorkspaceWrite), + Some(&other), + &named, + Some(within), + false, + &Daemon::default(), + ) + .unwrap() + .unwrap() + }; + let held = policy(&named); + assert!(held.can_write(&mine.join("a"))); + assert!(!held.can_write(&other.join("a"))); + let none = policy(&[]); + assert!(!none.can_write(&mine.join("a"))); + assert!(none.can_write(std::path::Path::new("/tmp/a"))); + std::fs::remove_dir_all(base).unwrap(); +} + +/// Read-only writes nothing, not even the temporary directory. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn a_read_only_sandbox_writes_nothing() { + use agent_runtime::sandbox::{Daemon, Mode, Policy}; + if !sandboxes() { + return; + } + let dir = std::fs::canonicalize(env!("CARGO_TARGET_TMPDIR")).unwrap(); + let policy = Policy::new( + Some(Mode::ReadOnly), + Some(&dir), + &[], + None, + true, + &Daemon::default(), + ) + .unwrap() + .unwrap(); + assert!(!policy.network); + let tools = Registry::new("shell").unwrap(); + let marker = format!("agent-read-only-{}", std::process::id()); + let prepared = tools + .prepare( + "shell", + &json!({"command":format!("cat /etc/hostname >/dev/null && echo x > {marker} || echo x > /tmp/{marker}")}) + .to_string(), + ) + .unwrap(); + let output: Value = serde_json::from_str( + &tools + .execute( + prepared, + &dir, + &[], + Some(std::sync::Arc::new(policy.clone())), + ) + .await + .unwrap() + .output, + ) + .unwrap(); + assert_ne!(output["exit_code"], 0, "{output}"); + assert!(!dir.join(&marker).exists()); + assert!(!std::path::Path::new("/tmp").join(&marker).exists()); + // The write a confined write tool runs is held by the kernel as well, + // whatever the daemon checked before it. + let target = dir.join(&marker); + let mut write = agent_runtime::sandbox::command( + &policy, + env!("CARGO_BIN_EXE_agent"), + &[agent_runtime::sandbox::WRITE, target.to_str().unwrap()], + false, + ) + .unwrap(); + let written = write + .stdin(std::process::Stdio::null()) + .output() + .await + .unwrap(); + assert!(!written.status.success()); + let reported: Value = serde_json::from_slice(&written.stderr).unwrap(); + assert_eq!(reported["error"], "write_denied", "{reported}"); + assert!(!target.exists()); +} From 8a219bc807e3ad5ef408852ea042abc99d9fa652 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:22:44 +0000 Subject: [PATCH 02/23] Sandbox: placeholders removed with no lock held, confined writes take a process slot, a child's roots keep what its creator's keep read-only Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 8 ++- src/sandbox.rs | 155 +++++++++++++++++++++++++++++++---------- src/tools.rs | 37 ++++++++-- src/tools/files.rs | 2 +- tests/tool_contract.rs | 32 +++++++++ 5 files changed, 188 insertions(+), 46 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index 5426e0b97..bbdb787d2 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2123,7 +2123,8 @@ read-only filesystem, and the model sees that result. A confined `write` or refused with `write_denied`; the write itself then runs inside the sandbox, as Codex runs `apply_patch`, in the agent binary started as `agent --agent-run-as-write PATH` with the content on stdin, so a link -swapped in after the check can't carry it out. To leave the sandbox, a shell call sets +swapped in after the check can't carry it out. Being a process, it takes a +slot of the daemon's process budget as a command does. To leave the sandbox, a shell call sets `sandbox_permissions: "require_escalated"` with a `justification`, which a sandboxed bot's `shell` schema shows with Codex's descriptions; a `write` or `edit` leaves it by naming a path outside its writable folders. Only such an @@ -2142,12 +2143,15 @@ makes (its `created_by`), or forks, takes the creator's sandbox unless it names a stricter one, reaches the network only if both do, and writes only within the creator's own roots: its `within` is the creator's folder and `writable` folders, as Codex's subagents inherit their parent's sandbox. +What the creator's roots keep read-only stays so: a root inside one of +those is none, and a root that holds one keeps it read-only. `danger-full-access` or no `sandbox` there gives the creator's sandbox, `writable` and `network`. On Linux, bubblewrap makes an empty folder on the host to mount a missing `.git` read-only over; the daemon removes it once no running sandbox holds -it, and a detached command holds it until it exits. +it, and a detached command holds it until it exits. A sandbox that needs +one being removed waits until it is gone. ### Declared tools diff --git a/src/sandbox.rs b/src/sandbox.rs index e371678f0..5d3c73e8e 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -79,22 +79,71 @@ impl Policy { Some(mode) => mode, }; let mut paths = writable_paths(mode, workspace, writable); + // What each folder of `within` keeps read-only stays so in a root + // inside it: a root inside one of those is none, and a root holding + // one keeps it, so the bot writes nothing its creator could not. + let mut kept: Vec<(PathBuf, Vec, Vec)> = Vec::new(); if let Some(within) = within { - let within: Vec = within - .iter() - .filter_map(|path| std::fs::canonicalize(path).ok()) - .collect(); + let mut ceilings = Vec::new(); + for path in within { + if let Ok(real) = std::fs::canonicalize(path) { + ceilings.push(root(&real, &daemon.store)?); + } + } let temporary: Vec<&PathBuf> = temporary().iter().map(|(_, real)| real).collect(); paths.retain(|path| { let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()); - temporary.contains(&&path) || within.iter().any(|kept| path.starts_with(kept)) + if temporary.contains(&&path) { + return true; + } + let Some(ceiling) = ceilings + .iter() + .filter(|ceiling| path.starts_with(&ceiling.path)) + .max_by_key(|ceiling| depth(&ceiling.path)) + else { + return false; + }; + let protected = |kept: &PathBuf| path.starts_with(kept); + if ceiling + .read_only + .iter() + .chain(&ceiling.missing) + .any(protected) + { + return false; + } + let inside = |found: &Vec| { + found + .iter() + .filter(|kept| kept.starts_with(&path)) + .cloned() + .collect::>() + }; + kept.push(( + path.clone(), + inside(&ceiling.read_only), + inside(&ceiling.missing), + )); + true }); } paths.sort_by_key(|path| (depth(path), path.clone())); paths.dedup(); let mut roots = Vec::new(); for path in paths { - roots.push(root(&path, &daemon.store)?); + let mut found = root(&path, &daemon.store)?; + let real = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); + if let Some((_, read_only, missing)) = kept.iter().find(|(kept, ..)| *kept == real) { + for (into, from) in [ + (&mut found.read_only, read_only), + (&mut found.missing, missing), + ] { + into.extend(from.iter().cloned()); + into.sort(); + into.dedup(); + } + } + roots.push(found); } Ok(Some(Policy { roots, @@ -594,23 +643,34 @@ mod linux { } /// The placeholders that running sandboxes hold for missing protected -/// folders, by how many hold each. bubblewrap makes a placeholder on the -/// host for its read-only mount; removing it while any sandbox still -/// mounts it would lift that mount there, so the last one out removes it. -static HELD: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); +/// folders: how many hold each, and whether it is being removed. bubblewrap +/// makes a placeholder on the host for its read-only mount; removing it +/// while any sandbox still mounts it would lift that mount there, so the +/// last one out removes it, and a sandbox that needs one being removed waits +/// until it is gone. The lock covers only this list, never the filesystem. +static HELD: std::sync::Mutex> = std::sync::Mutex::new(Vec::new()); +/// Woken when placeholders being removed are gone. +static REMOVED: tokio::sync::Notify = tokio::sync::Notify::const_new(); + +struct Placeholder { + path: PathBuf, + holders: usize, + removing: bool, +} -/// Whether a running sandbox holds `path` as a placeholder: then it is -/// still missing, whatever is there now. +/// Whether a running sandbox holds `path` as a placeholder, or it is being +/// removed: then it is still missing, whatever is there now. fn held(path: &Path) -> bool { - HELD.lock().unwrap().iter().any(|(held, _)| held == path) + HELD.lock().unwrap().iter().any(|held| held.path == path) } /// What a running sandbox holds in place; dropping it lets the /// placeholders go once no other sandbox holds them. pub struct Hold(Vec); -/// Hold `policy`'s placeholders while its command runs. -pub fn hold(policy: &Policy) -> Hold { +/// Hold `policy`'s placeholders while its command runs, once any of them +/// being removed is gone. +pub async fn hold(policy: &Policy) -> Hold { let paths: Vec = match cfg!(target_os = "linux") { true => policy .roots @@ -619,44 +679,63 @@ pub fn hold(policy: &Policy) -> Hold { .collect(), false => Vec::new(), }; - let mut held = HELD.lock().unwrap(); - for path in &paths { - match held.iter_mut().find(|(held, _)| held == path) { - Some((_, count)) => *count += 1, - None => held.push((path.clone(), 1)), + loop { + let removed = REMOVED.notified(); + tokio::pin!(removed); + removed.as_mut().enable(); + { + let mut held = HELD.lock().unwrap(); + let removing = held + .iter() + .any(|held| held.removing && paths.contains(&held.path)); + if !removing { + for path in &paths { + match held.iter_mut().find(|held| held.path == *path) { + Some(held) => held.holders += 1, + None => held.push(Placeholder { + path: path.clone(), + holders: 1, + removing: false, + }), + } + } + return Hold(paths); + } } + removed.await; } - Hold(paths) } impl Drop for Hold { fn drop(&mut self) { let mut released = Vec::new(); - let mut held = HELD.lock().unwrap(); - for path in self.0.drain(..) { - if let Some(index) = held.iter().position(|(held, _)| *held == path) { - held[index].1 -= 1; - if held[index].1 == 0 { - held.swap_remove(index); - released.push(path); + { + let mut held = HELD.lock().unwrap(); + for path in self.0.drain(..) { + if let Some(held) = held.iter_mut().find(|held| held.path == path) { + held.holders -= 1; + if held.holders == 0 { + held.removing = true; + released.push(path); + } } } } - drop(held); if released.is_empty() { return; } - // Off the runtime thread, and under the lock again, so a sandbox - // that took one up meanwhile keeps it. + // Off the runtime thread, and with no lock held: no sandbox takes + // one up while it is marked as being removed. let remove = move || { - let held = HELD.lock().unwrap(); - for path in released { - if !held.iter().any(|(held, _)| *held == path) { - // Only an empty one: a placeholder, never what a - // command outside the sandbox made there since. - let _ = std::fs::remove_dir(path); - } + for path in &released { + // Only an empty one: a placeholder, never what a command + // outside the sandbox made there since. + let _ = std::fs::remove_dir(path); } + HELD.lock() + .unwrap() + .retain(|held| !(held.removing && released.contains(&held.path))); + REMOVED.notify_waiters(); }; match tokio::runtime::Handle::try_current() { Ok(runtime) => drop(runtime.spawn_blocking(remove)), diff --git a/src/tools.rs b/src/tools.rs index a7d29140b..eaaff416e 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1148,7 +1148,9 @@ impl Registry { Prepared::Shell { background: true, .. } => fail("background_requires_runtime"), - Prepared::Detach(command) => detach(&command, workspace, self, environment, sandbox), + Prepared::Detach(command) => { + detach(&command, workspace, self, environment, sandbox).await + } Prepared::Wait { .. } => fail("wait_requires_runtime"), Prepared::History { .. } => fail("history_requires_runtime"), Prepared::Note { .. } => fail("note_requires_runtime"), @@ -1203,6 +1205,7 @@ impl Registry { Prepared::Write { path, content } => { let target = resolve(workspace, &path); writable(sandbox.clone(), &target, &path).await?; + let _slot = self.helper_slot(&sandbox).await?; files::write(&target, content.as_bytes(), sandbox).await?; Ok(Outcome::text(format!( "wrote {} bytes to {}", @@ -1243,6 +1246,7 @@ impl Registry { text.replacen(&old, &new, 1) }; debug_assert_eq!(updated.len(), expanded); + let _slot = self.helper_slot(&sandbox).await?; files::write(&target, updated.as_bytes(), sandbox).await?; Ok(Outcome::text(format!( "replaced {count} occurrence{} in {path}", @@ -1307,6 +1311,23 @@ impl Registry { /// can be moved to a task of its own. Dropping it kills the command. /// `started` fires once it holds a process slot: only then is it /// running, and only then may it move. + /// A process slot for a confined write, which runs as a process of + /// its own and so waits its turn as a command does. + async fn helper_slot( + &self, + sandbox: &Option>, + ) -> Result>> { + match sandbox { + Some(_) => Ok(Some( + self.slots + .acquire() + .await + .map_err(|_| Error::new("tool_scheduler_closed"))?, + )), + None => Ok(None), + } + } + pub fn foreground( &self, command: String, @@ -1712,7 +1733,7 @@ fn sh( /// No process slot, group kill, or handle applies to it. An async waiter /// reaps it on exit and releases its detached slot. #[cfg(unix)] -fn detach( +async fn detach( command: &str, workspace: &Path, registry: &Registry, @@ -1758,7 +1779,10 @@ fn detach( })?) }; // What the sandbox holds in place stays held while the command runs. - let held = sandbox.as_deref().map(crate::sandbox::hold); + let held = match sandbox.as_deref() { + Some(policy) => Some(crate::sandbox::hold(policy).await), + None => None, + }; let mut child = process.spawn()?; let pid = child.id().ok_or(Error::new("shell_spawn_failed"))?; tokio::spawn(async move { @@ -1772,7 +1796,7 @@ fn detach( } #[cfg(not(unix))] -fn detach( +async fn detach( _: &str, _: &Path, _: &Registry, @@ -1810,7 +1834,10 @@ async fn shell( } } // Held until the command and the sandbox around it are gone. - let _held = sandbox.map(crate::sandbox::hold); + let _held = match sandbox { + Some(policy) => Some(crate::sandbox::hold(policy).await), + None => None, + }; let child = sh(command, workspace, registry, environment, sandbox, false)? .stdin(Stdio::null()) .stdout(Stdio::piped()) diff --git a/src/tools/files.rs b/src/tools/files.rs index 08535ffdf..7ce25a662 100644 --- a/src/tools/files.rs +++ b/src/tools/files.rs @@ -88,7 +88,7 @@ async fn confined(policy: &Policy, path: &Path, content: &[u8]) -> Result<()> { let Some(target) = path.to_str() else { return fail("path_not_utf8"); }; - let _held = crate::sandbox::hold(policy); + let _held = crate::sandbox::hold(policy).await; let mut command = crate::sandbox::command(policy, helper, &[WRITE, target], false)?; command .env_clear() diff --git a/tests/tool_contract.rs b/tests/tool_contract.rs index 10bcce501..d1222a026 100644 --- a/tests/tool_contract.rs +++ b/tests/tool_contract.rs @@ -685,6 +685,38 @@ fn a_root_outside_within_is_not_writable() { let none = policy(&[]); assert!(!none.can_write(&mine.join("a"))); assert!(none.can_write(std::path::Path::new("/tmp/a"))); + // What `within` keeps read-only stays so for a root in it: a root that + // is one of those is none, and a root holding a worktree's git folder + // keeps it. + let checkout = base.join("checkout"); + let gitdir = checkout.join("bare/worktrees/x"); + std::fs::create_dir_all(&gitdir).unwrap(); + std::fs::create_dir_all(mine.join(".git")).unwrap(); + std::fs::write( + checkout.join(".git"), + format!("gitdir: {}\n", gitdir.display()), + ) + .unwrap(); + let ceilings = [ + mine.to_str().unwrap().to_owned(), + checkout.to_str().unwrap().to_owned(), + ]; + let inside = |workspace: &std::path::Path| { + Policy::new( + Some(Mode::WorkspaceWrite), + Some(workspace), + &[], + Some(&ceilings), + false, + &Daemon::default(), + ) + .unwrap() + .unwrap() + }; + assert!(!inside(&mine.join(".git")).can_write(&mine.join(".git/config"))); + let bare = inside(&checkout.join("bare")); + assert!(bare.can_write(&checkout.join("bare/objects"))); + assert!(!bare.can_write(&gitdir.join("HEAD"))); std::fs::remove_dir_all(base).unwrap(); } From 0760bd8626ba0162ddbc823dba796f43fd0cdf51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:29:38 +0000 Subject: [PATCH 03/23] Sandbox: probe off the runtime thread for bots from before a restart; pass on no root the creator keeps read-only Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- src/sandbox.rs | 6 ++++++ src/server/turn.rs | 3 +++ src/store/db.rs | 28 ++++++++++++++++++++++------ tests/store_contract.rs | 4 ++++ 4 files changed, 35 insertions(+), 6 deletions(-) diff --git a/src/sandbox.rs b/src/sandbox.rs index 5d3c73e8e..605510eb0 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -36,6 +36,12 @@ const PROTECTED: [&str; 4] = [".git", ".agents", ".codex", ".aws"]; /// the project's own settings. const PROTECTED_MISSING: [&str; 3] = [".git", ".agents", ".codex"]; +/// Whether `name`, at the top of a root, is a folder the root keeps +/// read-only. +pub fn protected(name: &std::ffi::OsStr) -> bool { + PROTECTED.iter().any(|kept| name == *kept) +} + /// A folder the bot may write, and what inside it stays read-only. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Root { diff --git a/src/server/turn.rs b/src/server/turn.rs index acb27cb2d..80d32bf2d 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -3393,6 +3393,9 @@ impl Turn { .as_ref() .map_or_else(|| workspace.to_owned(), PathBuf::from); tokio::task::spawn_blocking(move || { + // The probe for a sandbox runs once, here off the + // runtime thread, also for a bot from before a restart. + agent_runtime::sandbox::available()?; Policy::new( settings.sandbox, Some(&root), diff --git a/src/store/db.rs b/src/store/db.rs index e1889a522..cc7e03b74 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -323,16 +323,32 @@ impl Settings { .iter() .any(|folder| std::path::Path::new(path).starts_with(folder)) }; + // A root the creator's own `within` keeps read-only is none of its + // roots: one inside a protected folder at the top of the deepest + // folder that holds it. + let allowed = |root: &String| { + let Some(within) = &creator.within else { + return true; + }; + let root = std::path::Path::new(root); + within + .iter() + .map(std::path::Path::new) + .filter(|ceiling| root.starts_with(ceiling)) + .max_by_key(|ceiling| ceiling.components().count()) + .is_some_and(|ceiling| { + let first = root + .strip_prefix(ceiling) + .ok() + .and_then(|rest| rest.iter().next()); + !first.is_some_and(crate::sandbox::protected) + }) + }; let roots: Vec = folder .into_iter() .map(str::to_owned) .chain(creator.writable.iter().cloned()) - .filter(|root| { - creator - .within - .as_ref() - .is_none_or(|within| inside(root, within)) - }) + .filter(allowed) .collect(); self.within = Some(match self.within.take() { None => roots, diff --git a/tests/store_contract.rs b/tests/store_contract.rs index 3b7e81a20..ba08f007c 100644 --- a/tests/store_contract.rs +++ b/tests/store_contract.rs @@ -827,6 +827,10 @@ fn a_bot_made_by_a_sandboxed_bot_is_no_less_sandboxed() { ); let outside = Settings::default().under(&bob.settings, Some("/elsewhere")); assert_eq!(outside.within, Some(vec!["/shared".into()])); + // A bot in a folder its creator keeps read-only passes none of it on. + let git = Settings::default().under(&bob.settings, Some("/work/.git")); + let below = Settings::default().under(&git, Some("/work/.git")); + assert_eq!(below.within, Some(vec!["/shared".into()])); // An unconfined creator imposes nothing. let free = Settings::default().under(&Settings::default(), Some("/work")); assert_eq!(free, Settings::default()); From 00ae9aef115d9d6c90f72bf0e8a05d9221d54623 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:46:49 +0000 Subject: [PATCH 04/23] Sandbox: deny the daemon's socket to sandboxed commands, as Codex does; children inherit the sandbox without ceilings; take placeholders before a process slot Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 24 ++++--- src/sandbox.rs | 141 +++++++++++++++------------------------- src/server/mod.rs | 40 +++++------- src/server/turn.rs | 1 - src/store/db.rs | 126 ++++++++--------------------------- src/tools.rs | 16 ++--- src/tools/files.rs | 3 +- tests/store_contract.rs | 34 +++------- tests/tool_contract.rs | 100 +++++++++++++--------------- 9 files changed, 166 insertions(+), 319 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index bbdb787d2..da7b01d0e 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -407,7 +407,6 @@ keep-warm when it sets any of them. | `sandbox` (`--sandbox`) | How the bot's commands, writes and edits are confined, by Codex's names: `read-only`, `workspace-write` or `danger-full-access` (see [sandbox](#sandbox)). | none | | `writable` (`--writable`, repeatable) | Absolute folders a `workspace-write` bot may write besides its own; each must exist and is stored by its real path. At most 64, 32 KiB as JSON. | none | | `network` (`--network`) | A `workspace-write` bot's commands may use the network. | off | -| `within` | Absolute folders a `workspace-write` bot's writable folders must lie in, by their real paths; one outside them all is not writable. Set from the creator for a bot a sandboxed bot makes (see [Sandbox](#sandbox)). Limits as `writable`. | none | Provider requests multiplex over HTTP/2, and one connection carries at most the 100 streams the provider advertises; the HTTP layer queues the rest, so a @@ -2103,8 +2102,10 @@ runs hooks and config from there outside the sandbox, and agents trust what is there. The daemon's store and the files beside it stay read-only too. A protected folder that is a symlink refuses the call with `sandbox_unavailable`, since the link could be swapped from inside. Network is -off unless `network` is set; the daemon's socket stays reachable, so a -sandboxed bot can still delegate. `danger-full-access`, or no `sandbox`, runs +off unless `network` is set. The daemon's socket is out of reach either way, +as Codex keeps its own from a sandboxed command: nothing confined may drive +the daemon, so a sandboxed bot delegates through tools its client declares, +not through `agent` in its shell. `danger-full-access`, or no `sandbox`, runs unconfined. Creating a sandboxed bot checks that sandboxes run here and refuses with @@ -2124,7 +2125,9 @@ refused with `write_denied`; the write itself then runs inside the sandbox, as Codex runs `apply_patch`, in the agent binary started as `agent --agent-run-as-write PATH` with the content on stdin, so a link swapped in after the check can't carry it out. Being a process, it takes a -slot of the daemon's process budget as a command does. To leave the sandbox, a shell call sets +slot of the daemon's process budget as a command does. + +To leave the sandbox, a shell call sets `sandbox_permissions: "require_escalated"` with a `justification`, which a sandboxed bot's `shell` schema shows with Codex's descriptions; a `write` or `edit` leaves it by naming a path outside its writable folders. Only such an @@ -2138,15 +2141,10 @@ a policy that never asks, a command asking to escalate is refused with from its arguments and the folders' names alone, so the round that announces it and the run that starts it agree. -A bot can't make its way out of its sandbox. A bot that a sandboxed bot -makes (its `created_by`), or forks, takes the creator's sandbox unless it -names a stricter one, reaches the network only if both do, and writes only -within the creator's own roots: its `within` is the creator's folder and -`writable` folders, as Codex's subagents inherit their parent's sandbox. -What the creator's roots keep read-only stays so: a root inside one of -those is none, and a root that holds one keeps it read-only. -`danger-full-access` or no `sandbox` there gives the creator's sandbox, -`writable` and `network`. +A bot that a sandboxed bot makes (its `created_by`), or forks, takes the +creator's sandbox and `writable` folders unless it names a stricter sandbox, +and reaches the network only if both do, as Codex's subagents inherit their +parent's sandbox. On Linux, bubblewrap makes an empty folder on the host to mount a missing `.git` read-only over; the daemon removes it once no running sandbox holds diff --git a/src/sandbox.rs b/src/sandbox.rs index 605510eb0..fc2da0afa 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -36,12 +36,6 @@ const PROTECTED: [&str; 4] = [".git", ".agents", ".codex", ".aws"]; /// the project's own settings. const PROTECTED_MISSING: [&str; 3] = [".git", ".agents", ".codex"]; -/// Whether `name`, at the top of a root, is a folder the root keeps -/// read-only. -pub fn protected(name: &std::ffi::OsStr) -> bool { - PROTECTED.iter().any(|kept| name == *kept) -} - /// A folder the bot may write, and what inside it stays read-only. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Root { @@ -59,7 +53,7 @@ pub struct Policy { /// Shallowest first: a deeper root decides for what is inside it. pub roots: Vec, pub network: bool, - /// The daemon's socket, which a command may reach with network off. + /// The daemon's socket, which no command in the sandbox may reach. pub socket: Option, /// The agent binary a confined write runs in ([`WRITE`]). pub helper: Option, @@ -68,15 +62,12 @@ pub struct Policy { impl Policy { /// The policy for a bot in `mode`, whose folder is `workspace` and /// whose client named `writable`; `None` when its commands run - /// unconfined. A folder outside every one of `within`, when it is - /// given, is not a root: a bot made by a sandboxed bot writes no more than - /// its creator. `daemon` holds what the daemon itself keeps: its store, + /// unconfined. `daemon` holds what the daemon itself keeps: its store, /// which no root may write, and its socket. pub fn new( mode: Option, workspace: Option<&Path>, writable: &[String], - within: Option<&[String]>, network: bool, daemon: &Daemon, ) -> Result> { @@ -85,71 +76,11 @@ impl Policy { Some(mode) => mode, }; let mut paths = writable_paths(mode, workspace, writable); - // What each folder of `within` keeps read-only stays so in a root - // inside it: a root inside one of those is none, and a root holding - // one keeps it, so the bot writes nothing its creator could not. - let mut kept: Vec<(PathBuf, Vec, Vec)> = Vec::new(); - if let Some(within) = within { - let mut ceilings = Vec::new(); - for path in within { - if let Ok(real) = std::fs::canonicalize(path) { - ceilings.push(root(&real, &daemon.store)?); - } - } - let temporary: Vec<&PathBuf> = temporary().iter().map(|(_, real)| real).collect(); - paths.retain(|path| { - let path = std::fs::canonicalize(path).unwrap_or_else(|_| path.clone()); - if temporary.contains(&&path) { - return true; - } - let Some(ceiling) = ceilings - .iter() - .filter(|ceiling| path.starts_with(&ceiling.path)) - .max_by_key(|ceiling| depth(&ceiling.path)) - else { - return false; - }; - let protected = |kept: &PathBuf| path.starts_with(kept); - if ceiling - .read_only - .iter() - .chain(&ceiling.missing) - .any(protected) - { - return false; - } - let inside = |found: &Vec| { - found - .iter() - .filter(|kept| kept.starts_with(&path)) - .cloned() - .collect::>() - }; - kept.push(( - path.clone(), - inside(&ceiling.read_only), - inside(&ceiling.missing), - )); - true - }); - } paths.sort_by_key(|path| (depth(path), path.clone())); paths.dedup(); let mut roots = Vec::new(); for path in paths { - let mut found = root(&path, &daemon.store)?; - let real = std::fs::canonicalize(&path).unwrap_or_else(|_| path.clone()); - if let Some((_, read_only, missing)) = kept.iter().find(|(kept, ..)| *kept == real) { - for (into, from) in [ - (&mut found.read_only, read_only), - (&mut found.missing, missing), - ] { - into.extend(from.iter().cloned()); - into.sort(); - into.dedup(); - } - } - roots.push(found); + roots.push(root(&path, &daemon.store)?); } Ok(Some(Policy { roots, @@ -624,6 +555,11 @@ mod linux { ]); } } + // The daemon's socket is out of reach, as Codex keeps its own from + // a sandboxed command: nothing confined may drive the daemon. + if let Some(socket) = &policy.socket { + args.extend(["--ro-bind".into(), "/dev/null".into(), path(socket)]); + } args.extend(["--unshare-user", "--unshare-pid", "--unshare-ipc"].map(String::from)); if !policy.network { args.push("--unshare-net".into()); @@ -712,6 +648,14 @@ pub async fn hold(policy: &Policy) -> Hold { } } +/// [`hold`] for a call that may run unconfined. +pub async fn hold_for(policy: Option<&Policy>) -> Option { + match policy { + Some(policy) => Some(hold(policy).await), + None => None, + } +} + impl Drop for Hold { fn drop(&mut self) { let mut released = Vec::new(); @@ -825,22 +769,20 @@ pub fn seatbelt(policy: &Policy) -> (String, Vec<(String, PathBuf)>) { sections.push(format!("(allow file-write*\n{}\n)", allows.join(" "))); sections.extend(anchors); } - // Network as Codex allows it: everything when on, and with it off only - // the daemon's socket, which network-off would deny too (Claude Code's - // allowUnixSockets keeps one the same way). - let socket = policy.socket.as_ref().map(|socket| { + // Network as Codex allows it: everything when on, nothing when off. + if policy.network { + sections.push(format!( + "(allow network-outbound)\n(allow network-inbound)\n{SEATBELT_NETWORK}{SEATBELT_TLS_TRUST}" + )); + } + // Never the daemon's socket, as Codex keeps its own from a sandboxed + // command: nothing confined may drive the daemon. + if let Some(socket) = &policy.socket { params.push(("DAEMON_SOCKET".to_owned(), socket.clone())); - "; allow unix domain sockets for local IPC\n\ - (allow system-socket (socket-domain AF_UNIX))\n\ - (allow network-outbound (remote unix-socket (subpath (param \"DAEMON_SOCKET\"))))\n" - }); - match (policy.network, socket) { - (true, socket) => sections.push(format!( - "(allow network-outbound)\n(allow network-inbound)\n{}{SEATBELT_NETWORK}{SEATBELT_TLS_TRUST}", - socket.unwrap_or_default() - )), - (false, Some(socket)) => sections.push(format!("{socket}{SEATBELT_NETWORK}")), - (false, None) => {} + sections.push( + "(deny network-outbound (remote unix-socket (subpath (param \"DAEMON_SOCKET\"))))" + .to_owned(), + ); } sections.push(SEATBELT_PREFERENCES.to_owned()); sections.push("(deny mach-lookup (xpc-service-name-prefix \"\"))".to_owned()); @@ -914,7 +856,6 @@ mod tests { Some(Mode::WorkspaceWrite), Some(&base), &[inner.to_str().unwrap().to_owned()], - None, false, &Daemon::default(), ) @@ -945,6 +886,29 @@ mod tests { assert!(kept.contains(&&inner.join(".git"))); // Paths go in as parameters, never into the profile's text. assert!(!profile.contains(base.to_str().unwrap())); + // The daemon's socket is denied, network on or off. + for network in [false, true] { + let socket = Policy { + network, + socket: Some(base.join("agent.sock")), + ..policy.clone() + }; + let (profile, params) = seatbelt(&socket); + assert!(profile.contains( + "(deny network-outbound (remote unix-socket (subpath (param \"DAEMON_SOCKET\"))))" + )); + assert!( + params + .iter() + .any(|(key, path)| key == "DAEMON_SOCKET" && path == &base.join("agent.sock")) + ); + #[cfg(target_os = "linux")] + assert!( + linux::args(&socket, false) + .windows(3) + .any(|w| w[0] == "--ro-bind" && w[1] == "/dev/null") + ); + } std::fs::remove_dir_all(base).unwrap(); } @@ -957,7 +921,6 @@ mod tests { Some(Mode::WorkspaceWrite), Some(&base), &[], - None, false, &Daemon::default(), ) diff --git a/src/server/mod.rs b/src/server/mod.rs index 6ca375d2a..61b968468 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -136,20 +136,14 @@ fn admission_bound(command: &Command, id: &Value, deliveries: usize) -> Bound { } }), output::encoded_len(&(bot, created_by)), - // The workspace and the writable and within folders, canonical, - // which the reply's settings repeat; a bot's creator's sandbox - // can add its own folders and its roots to them. + // The workspace and the writable folders, canonical, which the + // reply's settings repeat; a bot a sandboxed bot makes can take + // its creator's. usize::from(workspace.is_some()) * PATH_JSON - + [ - &settings.writable, - settings.within.as_ref().unwrap_or(&Vec::new()), - ] - .iter() - .map(|paths| (paths.len() * PATH_JSON).min(FOLDERS_JSON)) - .sum::() - + created_by - .as_ref() - .map_or(0, |_| 2 * FOLDERS_JSON + PATH_JSON), + + match created_by { + Some(_) => FOLDERS_JSON, + None => (settings.writable.len() * PATH_JSON).min(FOLDERS_JSON), + }, // Both the bot and its event carry its gates, most of them // inherited from its creator, so the command does not show // them. Each gate keeps only the bot's tools, which it lists. @@ -801,18 +795,16 @@ fn confine(settings: &mut Settings) -> Result<()> { ) { agent_runtime::sandbox::available()?; } - for paths in std::iter::once(&mut settings.writable).chain(&mut settings.within) { - for path in paths.iter_mut() { - let real = std::fs::canonicalize(&*path) - .map_err(|_| Error::with("path_not_found", path.clone()))?; - *path = real - .to_str() - .ok_or(Error::with("path_not_utf8", path.clone()))? - .to_owned(); - } - paths.sort(); - paths.dedup(); + for path in &mut settings.writable { + let real = std::fs::canonicalize(&*path) + .map_err(|_| Error::with("path_not_found", path.clone()))?; + *path = real + .to_str() + .ok_or(Error::with("path_not_utf8", path.clone()))? + .to_owned(); } + settings.writable.sort(); + settings.writable.dedup(); Ok(()) } /// A field given as `null` deserializes to `Some(None)`, apart from one diff --git a/src/server/turn.rs b/src/server/turn.rs index 80d32bf2d..275a3d0ca 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -3400,7 +3400,6 @@ impl Turn { settings.sandbox, Some(&root), &settings.writable, - settings.within.as_deref(), settings.network, &daemon, ) diff --git a/src/store/db.rs b/src/store/db.rs index cc7e03b74..e4d4593f0 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -223,12 +223,6 @@ pub struct Settings { /// Whether a workspace-write sandbox reaches the network. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub network: bool, - /// Folders a workspace-write sandbox's roots must lie in, by their real - /// paths: a root outside them all is not writable. A bot made by a - /// sandboxed bot gets its creator's roots here, so it writes no more - /// than its creator; none sets no bound. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub within: Option>, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, Serialize)] pub enum CacheTtl { @@ -238,8 +232,8 @@ pub enum CacheTtl { Hour, } const MIN_CONTEXT_BYTES: usize = 1024; -/// The most a bot's `writable` or `within` folders take as JSON, by their -/// real paths, so a reply that repeats them has a bound. +/// The most a bot's `writable` folders take as JSON, by their real paths, +/// so a reply that repeats them has a bound. pub const FOLDERS_JSON: usize = 32 << 10; const MIN_COMPACT_TOKENS: u64 = 1024; /// Where a model with a `window`-token context compacts unless the bot @@ -289,13 +283,12 @@ impl Settings { None => Some(crate::provider::KEEP_WARM), } } - /// These settings for a bot that a bot with `creator`'s settings and - /// folder makes: no weaker a sandbox than its creator's. A sandboxed - /// creator's child takes the creator's sandbox unless it names a - /// stricter one, reaches the network only if both do, and writes only - /// within the creator's own roots, as Codex's subagents inherit their - /// parent's sandbox. - pub fn under(mut self, creator: &Settings, folder: Option<&str>) -> Settings { + /// These settings for a bot that a bot with `creator`'s settings + /// makes: no weaker a sandbox than its creator's, as Codex's subagents + /// inherit their parent's. A sandboxed creator's child takes the + /// creator's sandbox and writable folders unless it names a stricter + /// sandbox, and reaches the network only if both do. + pub fn under(mut self, creator: &Settings) -> Settings { use crate::sandbox::Mode; let sandboxed = |settings: &Settings| { matches!( @@ -314,56 +307,8 @@ impl Settings { if self.sandbox != Some(Mode::WorkspaceWrite) { self.writable.clear(); self.network = false; - self.within = None; - return self; } self.network &= creator.network; - let inside = |path: &str, folders: &[String]| { - folders - .iter() - .any(|folder| std::path::Path::new(path).starts_with(folder)) - }; - // A root the creator's own `within` keeps read-only is none of its - // roots: one inside a protected folder at the top of the deepest - // folder that holds it. - let allowed = |root: &String| { - let Some(within) = &creator.within else { - return true; - }; - let root = std::path::Path::new(root); - within - .iter() - .map(std::path::Path::new) - .filter(|ceiling| root.starts_with(ceiling)) - .max_by_key(|ceiling| ceiling.components().count()) - .is_some_and(|ceiling| { - let first = root - .strip_prefix(ceiling) - .ok() - .and_then(|rest| rest.iter().next()); - !first.is_some_and(crate::sandbox::protected) - }) - }; - let roots: Vec = folder - .into_iter() - .map(str::to_owned) - .chain(creator.writable.iter().cloned()) - .filter(allowed) - .collect(); - self.within = Some(match self.within.take() { - None => roots, - Some(own) => { - let mut both: Vec = own - .iter() - .filter(|path| inside(path, &roots)) - .chain(roots.iter().filter(|root| inside(root, &own))) - .cloned() - .collect(); - both.sort(); - both.dedup(); - both - } - }); self } pub fn cache_hour(&self) -> bool { @@ -413,27 +358,17 @@ impl Settings { if !confined && (!self.writable.is_empty() || self.network) { return refuse("writable and network go with sandbox workspace-write"); } - if !confined && self.within.is_some() { - return refuse("within goes with sandbox workspace-write"); + if self.writable.len() > 64 + || crate::output::encoded_len(&self.writable).unwrap_or(usize::MAX) > FOLDERS_JSON + { + return refuse("writable names at most 64 folders, 32 KiB in all"); } - for (name, paths) in [ - ("writable", Some(&self.writable)), - ("within", self.within.as_ref()), - ] { - let Some(paths) = paths else { - continue; - }; - if paths.len() > 64 - || crate::output::encoded_len(paths).unwrap_or(usize::MAX) > FOLDERS_JSON - { - return refuse(&format!("{name} names at most 64 folders, 32 KiB in all")); - } - if paths - .iter() - .any(|path| !std::path::Path::new(path).is_absolute() || path.len() > 4096) - { - return refuse(&format!("{name} folders are absolute paths")); - } + if self + .writable + .iter() + .any(|path| !std::path::Path::new(path).is_absolute() || path.len() > 4096) + { + return refuse("writable folders are absolute paths"); } Ok(()) } @@ -2168,25 +2103,18 @@ impl Database { None => Vec::new(), }) } - /// The settings and folder of the bot a client creates for, whose - /// sandbox the new bot may not weaken. - fn creator_settings( - conn: &Connection, - creator: Option, - ) -> Result)>> { + /// The settings of the bot a client creates for, whose sandbox the new + /// bot may not weaken. + fn creator_settings(conn: &Connection, creator: Option) -> Result> { let Some(id) = creator else { return Ok(None); }; - let (settings, workspace): (Option, Option) = conn.query_row( - "SELECT settings,workspace FROM bots WHERE id=?", - [id], - |r| Ok((r.get(0)?, r.get(1)?)), - )?; - let settings = match settings { + let settings: Option = + conn.query_row("SELECT settings FROM bots WHERE id=?", [id], |r| r.get(0))?; + Ok(Some(match settings { Some(text) => serde_json::from_str(&text)?, None => Settings::default(), - }; - Ok(Some((settings, workspace))) + })) } /// The tools the creating bot's client declared, which a bot it /// creates takes unless its own client declares others: all but those @@ -2502,7 +2430,7 @@ impl Database { let inherited = Self::creator_gates(&tx, created_by_id)?; let gates = stored_gates(&merge_gates(inherited.iter().chain(binding.gate), &tools))?; let settings = match Self::creator_settings(&tx, created_by_id)? { - Some((creator, folder)) => binding.settings.clone().under(&creator, folder.as_deref()), + Some(creator) => binding.settings.clone().under(&creator), None => binding.settings.clone(), }; tx.execute( @@ -7393,7 +7321,7 @@ impl Database { ))?; // And the stricter of its source's sandbox and its creator's. let settings = match Self::creator_settings(&tx, created_by_id)? { - Some((creator, folder)) => parent.settings.clone().under(&creator, folder.as_deref()), + Some(creator) => parent.settings.clone().under(&creator), None => parent.settings.clone(), }; tx.execute( diff --git a/src/tools.rs b/src/tools.rs index eaaff416e..cce3917e9 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1205,6 +1205,7 @@ impl Registry { Prepared::Write { path, content } => { let target = resolve(workspace, &path); writable(sandbox.clone(), &target, &path).await?; + let _held = crate::sandbox::hold_for(sandbox.as_deref()).await; let _slot = self.helper_slot(&sandbox).await?; files::write(&target, content.as_bytes(), sandbox).await?; Ok(Outcome::text(format!( @@ -1246,6 +1247,7 @@ impl Registry { text.replacen(&old, &new, 1) }; debug_assert_eq!(updated.len(), expanded); + let _held = crate::sandbox::hold_for(sandbox.as_deref()).await; let _slot = self.helper_slot(&sandbox).await?; files::write(&target, updated.as_bytes(), sandbox).await?; Ok(Outcome::text(format!( @@ -1339,6 +1341,9 @@ impl Registry { ) -> impl Future> + Send + 'static { let registry = self.clone(); async move { + // Held until the command and the sandbox around it are gone, + // and taken before the slot, so no slot waits on it. + let _held = crate::sandbox::hold_for(sandbox.as_deref()).await; let _slot = registry .slots .acquire() @@ -1385,6 +1390,7 @@ impl Registry { }; drop(queued); let _slot = slot.map_err(|_| Error::new("tool_scheduler_closed"))?; + let _held = crate::sandbox::hold_for(sandbox.as_deref()).await; let (stdout, stderr, exit, elapsed) = shell( &command, &workspace, @@ -1779,10 +1785,7 @@ async fn detach( })?) }; // What the sandbox holds in place stays held while the command runs. - let held = match sandbox.as_deref() { - Some(policy) => Some(crate::sandbox::hold(policy).await), - None => None, - }; + let held = crate::sandbox::hold_for(sandbox.as_deref()).await; let mut child = process.spawn()?; let pid = child.id().ok_or(Error::new("shell_spawn_failed"))?; tokio::spawn(async move { @@ -1833,11 +1836,6 @@ async fn shell( } } } - // Held until the command and the sandbox around it are gone. - let _held = match sandbox { - Some(policy) => Some(crate::sandbox::hold(policy).await), - None => None, - }; let child = sh(command, workspace, registry, environment, sandbox, false)? .stdin(Stdio::null()) .stdout(Stdio::piped()) diff --git a/src/tools/files.rs b/src/tools/files.rs index 7ce25a662..87c3b6fff 100644 --- a/src/tools/files.rs +++ b/src/tools/files.rs @@ -64,7 +64,7 @@ pub(super) async fn read_head(path: &Path, count: u64) -> Result> { /// sandbox, in the agent binary, as Codex runs `apply_patch`: the kernel /// decides what it reaches, so a link swapped in after the path was /// checked can't carry it out. An unconfined one makes the folders above -/// `path` itself first. +/// `path` itself first. The caller holds the sandbox's placeholders. pub(super) async fn write(path: &Path, content: &[u8], sandbox: Option>) -> Result<()> { if let Some(policy) = sandbox { return confined(&policy, path, content).await; @@ -88,7 +88,6 @@ async fn confined(policy: &Policy, path: &Path, content: &[u8]) -> Result<()> { let Some(target) = path.to_str() else { return fail("path_not_utf8"); }; - let _held = crate::sandbox::hold(policy).await; let mut command = crate::sandbox::command(policy, helper, &[WRITE, target], false)?; command .env_clear() diff --git a/tests/store_contract.rs b/tests/store_contract.rs index ba08f007c..a0972be2e 100644 --- a/tests/store_contract.rs +++ b/tests/store_contract.rs @@ -741,9 +741,8 @@ fn gates_are_capped_before_a_bot_is_written() { gated_bot(&mut db, "g8", "t8", None, None).unwrap(); } -/// A sandboxed bot can't make its way out of its sandbox: a bot it makes -/// takes its sandbox unless it names a stricter one, and writes only -/// within the creator's own roots. +/// A bot that a sandboxed bot makes takes its sandbox unless it names a +/// stricter one, as Codex's subagents inherit their parent's. #[test] fn a_bot_made_by_a_sandboxed_bot_is_no_less_sandboxed() { use agent_runtime::sandbox::Mode; @@ -781,10 +780,6 @@ fn a_bot_made_by_a_sandboxed_bot_is_no_less_sandboxed() { let unconfined = made(&mut db, "Bob", Settings::default()); assert_eq!(unconfined.sandbox, Some(Mode::WorkspaceWrite)); assert_eq!(unconfined.writable, ["/shared"]); - assert_eq!( - unconfined.within, - Some(vec!["/work".into(), "/shared".into()]) - ); let full = made( &mut db, "Cid", @@ -799,15 +794,13 @@ fn a_bot_made_by_a_sandboxed_bot_is_no_less_sandboxed() { "Dee", Settings { sandbox: Some(Mode::WorkspaceWrite), - writable: vec!["/shared/part".into()], + writable: vec!["/other".into()], network: true, - within: Some(vec!["/shared".into(), "/other".into()]), ..Settings::default() }, ); assert!(!networked.network); - assert_eq!(networked.writable, ["/shared/part"]); - assert_eq!(networked.within, Some(vec!["/shared".into()])); + assert_eq!(networked.writable, ["/other"]); let read_only = made( &mut db, "Eve", @@ -817,23 +810,12 @@ fn a_bot_made_by_a_sandboxed_bot_is_no_less_sandboxed() { }, ); assert_eq!(read_only.sandbox, Some(Mode::ReadOnly)); - assert_eq!(read_only.within, None); - // A bot that bot makes is held within the same roots, or fewer. - let bob = db.inspect("Bob").unwrap(); - let grandchild = Settings::default().under(&bob.settings, Some("/work/sub")); + assert!(read_only.writable.is_empty()); + // An unconfined creator imposes nothing. assert_eq!( - grandchild.within, - Some(vec!["/work/sub".into(), "/shared".into()]) + Settings::default().under(&Settings::default()), + Settings::default() ); - let outside = Settings::default().under(&bob.settings, Some("/elsewhere")); - assert_eq!(outside.within, Some(vec!["/shared".into()])); - // A bot in a folder its creator keeps read-only passes none of it on. - let git = Settings::default().under(&bob.settings, Some("/work/.git")); - let below = Settings::default().under(&git, Some("/work/.git")); - assert_eq!(below.within, Some(vec!["/shared".into()])); - // An unconfined creator imposes nothing. - let free = Settings::default().under(&Settings::default(), Some("/work")); - assert_eq!(free, Settings::default()); } #[test] diff --git a/tests/tool_contract.rs b/tests/tool_contract.rs index d1222a026..466284f9c 100644 --- a/tests/tool_contract.rs +++ b/tests/tool_contract.rs @@ -549,7 +549,6 @@ async fn a_sandboxed_command_writes_only_its_roots() { Some(Mode::WorkspaceWrite), Some(&dir), &writable, - None, false, &daemon, ) @@ -655,68 +654,58 @@ async fn a_sandboxed_command_writes_only_its_roots() { std::fs::remove_dir_all(base).unwrap(); } -/// A root outside every folder in `within` is not one. -#[test] -fn a_root_outside_within_is_not_writable() { +/// The daemon's socket is out of reach from inside a sandbox, network on +/// or off, as Codex keeps its own. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn a_sandboxed_command_cannot_reach_the_daemon_socket() { use agent_runtime::sandbox::{Daemon, Mode, Policy}; - let base = std::fs::canonicalize(env!("CARGO_TARGET_TMPDIR")) - .unwrap() - .join(format!("agent-within-{}", std::process::id())); - let (mine, other) = (base.join("mine"), base.join("other")); - for folder in [&mine, &other] { - std::fs::create_dir_all(folder).unwrap(); + if !sandboxes() { + return; } - let named = [mine.to_str().unwrap().to_owned()]; - let policy = |within: &[String]| { - Policy::new( - Some(Mode::WorkspaceWrite), - Some(&other), - &named, - Some(within), - false, - &Daemon::default(), - ) - .unwrap() + let base = std::fs::canonicalize(env!("CARGO_TARGET_TMPDIR")) .unwrap() + .join(format!("agent-socket-{}", std::process::id())); + std::fs::create_dir_all(&base).unwrap(); + let socket = base.join("agent.sock"); + let _listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let tools = Registry::new("shell").unwrap(); + let connect = format!( + "python3 -c 'import socket; socket.socket(socket.AF_UNIX).connect(\"{}\")'", + socket.display() + ); + let run = |sandbox: Option| { + let tools = tools.clone(); + let (base, connect) = (base.clone(), connect.clone()); + async move { + let prepared = tools + .prepare("shell", &json!({"command": connect}).to_string()) + .unwrap(); + let output = tools + .execute(prepared, &base, &[], sandbox.map(std::sync::Arc::new)) + .await + .unwrap() + .output; + serde_json::from_str::(&output).unwrap()["exit_code"].clone() + } }; - let held = policy(&named); - assert!(held.can_write(&mine.join("a"))); - assert!(!held.can_write(&other.join("a"))); - let none = policy(&[]); - assert!(!none.can_write(&mine.join("a"))); - assert!(none.can_write(std::path::Path::new("/tmp/a"))); - // What `within` keeps read-only stays so for a root in it: a root that - // is one of those is none, and a root holding a worktree's git folder - // keeps it. - let checkout = base.join("checkout"); - let gitdir = checkout.join("bare/worktrees/x"); - std::fs::create_dir_all(&gitdir).unwrap(); - std::fs::create_dir_all(mine.join(".git")).unwrap(); - std::fs::write( - checkout.join(".git"), - format!("gitdir: {}\n", gitdir.display()), - ) - .unwrap(); - let ceilings = [ - mine.to_str().unwrap().to_owned(), - checkout.to_str().unwrap().to_owned(), - ]; - let inside = |workspace: &std::path::Path| { - Policy::new( + assert_eq!(run(None).await, 0); + for network in [false, true] { + let daemon = Daemon { + socket: Some(socket.clone()), + ..Daemon::default() + }; + let policy = Policy::new( Some(Mode::WorkspaceWrite), - Some(workspace), + Some(&base), &[], - Some(&ceilings), - false, - &Daemon::default(), + network, + &daemon, ) .unwrap() - .unwrap() - }; - assert!(!inside(&mine.join(".git")).can_write(&mine.join(".git/config"))); - let bare = inside(&checkout.join("bare")); - assert!(bare.can_write(&checkout.join("bare/objects"))); - assert!(!bare.can_write(&gitdir.join("HEAD"))); + .unwrap(); + assert_ne!(run(Some(policy)).await, 0, "network {network}"); + } std::fs::remove_dir_all(base).unwrap(); } @@ -733,7 +722,6 @@ async fn a_read_only_sandbox_writes_nothing() { Some(Mode::ReadOnly), Some(&dir), &[], - None, true, &Daemon::default(), ) From 1782065bc1a0e9611c3de57f51a17fa980eddbaa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 03:59:01 +0000 Subject: [PATCH 05/23] Sandbox: keep a daemon socket inside a writable folder from being renamed or linked; each Seatbelt rule names only its own paths, once Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 4 +- src/sandbox.rs | 210 +++++++++++++++++++++++++++++++---------- 2 files changed, 163 insertions(+), 51 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index da7b01d0e..3602feb40 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2105,7 +2105,9 @@ protected folder that is a symlink refuses the call with off unless `network` is set. The daemon's socket is out of reach either way, as Codex keeps its own from a sandboxed command: nothing confined may drive the daemon, so a sandboxed bot delegates through tools its client declares, -not through `agent` in its shell. `danger-full-access`, or no `sandbox`, runs +not through `agent` in its shell. A socket inside a writable folder stays +read-only, and on macOS the folders between it and that folder can't be +renamed, so it can't be moved or linked to a name the rule doesn't cover. `danger-full-access`, or no `sandbox`, runs unconfined. Creating a sandboxed bot checks that sandboxes run here and refuses with diff --git a/src/sandbox.rs b/src/sandbox.rs index fc2da0afa..4e168388a 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -80,7 +80,7 @@ impl Policy { paths.dedup(); let mut roots = Vec::new(); for path in paths { - roots.push(root(&path, &daemon.store)?); + roots.push(root(&path, daemon)?); } Ok(Some(Policy { roots, @@ -281,8 +281,8 @@ pub struct Daemon { /// The root at `path`, with what stays read-only inside it: the protected /// folders at its top, a worktree's own git folder when it is inside the -/// root, and the daemon's store. -fn root(path: &Path, store: &Option) -> Result { +/// root, and the daemon's store and socket. +fn root(path: &Path, daemon: &Daemon) -> Result { let mut read_only = Vec::new(); let mut missing = Vec::new(); for name in PROTECTED { @@ -322,11 +322,19 @@ fn root(path: &Path, store: &Option) -> Result { Err(_) => {} } } - if let Some(store) = store + if let Some(store) = &daemon.store && store.starts_with(path) { read_only.extend(store_files(store).filter(|file| file.exists())); } + // A socket the bot could rename or link would be reachable by its new + // name, past a rule that names the old one. + if let Some(socket) = &daemon.socket + && socket.starts_with(path) + && socket.exists() + { + read_only.push(socket.clone()); + } Ok(Root { path: path.to_owned(), read_only, @@ -725,42 +733,76 @@ const SEATBELT_PREFERENCES: &str = include_str!("sandbox/seatbelt_preferences_po const SEATBELT_TLS_TRUST: &str = "(allow mach-lookup (global-name \"com.apple.TrustEvaluationAgent\"))\n"; +/// A rule that keeps the folder `key` names from being renamed or removed. +fn anchor(key: &str) -> String { + format!( + "(deny file-write-unlink (require-all (literal (param \"{key}\")) (vnode-type DIRECTORY)))" + ) +} + /// The Seatbelt profile for `policy` and the parameters it names, as Codex /// writes it: everything denied but what its base allows, reading open, /// writing each root but the paths it keeps, and network only when on. /// Paths go in as parameters, never into the profile's text. pub fn seatbelt(policy: &Policy) -> (String, Vec<(String, PathBuf)>) { let mut params: Vec<(String, PathBuf)> = Vec::new(); + // One parameter per path, however many rules name it. + let mut param = |path: &Path| -> String { + if let Some((key, _)) = params.iter().find(|(_, named)| named == path) { + return key.clone(); + } + let key = format!("PATH_{}", params.len()); + params.push((key.clone(), path.to_owned())); + key + }; let mut allows = Vec::new(); let mut anchors = Vec::new(); for (index, root) in policy.roots.iter().enumerate() { - let key = format!("WRITABLE_ROOT_{index}"); - params.push((key.clone(), root.path.clone())); + let key = param(&root.path); // The root itself can't be renamed away from under its own rules. - anchors.push(format!( - "(deny file-write-unlink (require-all (literal (param \"{key}\")) (vnode-type DIRECTORY)))" - )); - // What this root keeps, and what each deeper root inside it keeps: - // the deepest root decides, as bubblewrap's mounts do. - let kept: Vec<&PathBuf> = policy.roots[index..] - .iter() - .filter(|inner| inner.path.starts_with(&root.path)) - .flat_map(|inner| inner.read_only.iter().chain(&inner.missing)) - .collect(); + anchors.push(anchor(&key)); + // What this root keeps, and the deeper roots just inside it, which + // decide for themselves: the deepest root decides, as bubblewrap's + // mounts do, and each rule names only what is its own. + let inner = policy.roots[index + 1..].iter().filter(|inner| { + inner.path.starts_with(&root.path) + && !policy.roots[index + 1..].iter().any(|between| { + between.path != inner.path + && between.path.starts_with(&root.path) + && inner.path.starts_with(&between.path) + }) + }); let mut parts = vec![format!("(subpath (param \"{key}\"))")]; - for (kept_index, kept) in kept.into_iter().enumerate() { - let kept_key = format!("{key}_EXCLUDED_{kept_index}"); - params.push((kept_key.clone(), kept.clone())); + for kept in root.read_only.iter().chain(&root.missing) { + let kept_key = param(kept); // Both the path and what is under it: `subpath` alone would let // the folder itself be made. parts.push(format!("(require-not (literal (param \"{kept_key}\")))")); parts.push(format!("(require-not (subpath (param \"{kept_key}\")))")); } + for inner in inner { + let inner_key = param(&inner.path); + parts.push(format!("(require-not (subpath (param \"{inner_key}\")))")); + } allows.push(match parts.len() { 1 => parts.remove(0), _ => format!("(require-all {} )", parts.join(" ")), }); } + // Nor can the folders between a root and the daemon's socket, which + // would take the socket to a name its rule doesn't cover. + if let Some(socket) = &policy.socket { + for folder in socket.ancestors().skip(1) { + if policy + .roots + .iter() + .any(|root| folder.starts_with(&root.path)) + && !policy.roots.iter().any(|root| root.path == folder) + { + anchors.push(anchor(¶m(folder))); + } + } + } let mut sections = vec![ SEATBELT_BASE.to_owned(), "; allow read-only file operations\n(allow file-read*)".to_owned(), @@ -778,11 +820,10 @@ pub fn seatbelt(policy: &Policy) -> (String, Vec<(String, PathBuf)>) { // Never the daemon's socket, as Codex keeps its own from a sandboxed // command: nothing confined may drive the daemon. if let Some(socket) = &policy.socket { - params.push(("DAEMON_SOCKET".to_owned(), socket.clone())); - sections.push( - "(deny network-outbound (remote unix-socket (subpath (param \"DAEMON_SOCKET\"))))" - .to_owned(), - ); + let key = param(socket); + sections.push(format!( + "(deny network-outbound (remote unix-socket (subpath (param \"{key}\"))))" + )); } sections.push(SEATBELT_PREFERENCES.to_owned()); sections.push("(deny mach-lookup (xpc-service-name-prefix \"\"))".to_owned()); @@ -872,46 +913,115 @@ mod tests { let (profile, params) = seatbelt(&policy); assert!(profile.contains("(allow file-read*)")); assert!(!profile.contains("(allow network-outbound)")); - // The outer root keeps the inner root's `.git` too. - let outer = params - .iter() - .position(|(_, path)| path == &base) - .map(|index| params[index].0.clone()) - .unwrap(); - let kept: Vec<&PathBuf> = params - .iter() - .filter(|(key, _)| key.starts_with(&format!("{outer}_EXCLUDED_"))) - .map(|(_, path)| path) - .collect(); - assert!(kept.contains(&&inner.join(".git"))); + let key = |path: &Path| { + params + .iter() + .find(|(_, named)| named == path) + .map(|(key, _)| key.clone()) + .unwrap() + }; + // The outer root leaves the inner root to its own rule, which keeps + // the inner `.git`. + let (outer, inner_key, inner_git) = (key(&base), key(&inner), key(&inner.join(".git"))); + let rule = |root: &str| { + profile + .split(&format!("(require-all (subpath (param \"{root}\"))")) + .nth(1) + .unwrap() + .split(" )") + .next() + .unwrap() + .to_owned() + }; + assert!(rule(&outer).contains(&format!("(require-not (subpath (param \"{inner_key}\")))"))); + assert!(!rule(&outer).contains(&format!("\"{inner_git}\""))); + assert!( + rule(&inner_key).contains(&format!("(require-not (subpath (param \"{inner_git}\")))")) + ); // Paths go in as parameters, never into the profile's text. assert!(!profile.contains(base.to_str().unwrap())); - // The daemon's socket is denied, network on or off. + // The daemon's socket is denied, network on or off; inside a root + // it stays read-only and the folders above it stay put, so it can't + // be renamed or linked past its rule. + let socket = inner.join("run/agent.sock"); + std::fs::create_dir_all(socket.parent().unwrap()).unwrap(); + std::fs::write(&socket, "").unwrap(); + let daemon = Daemon { + socket: Some(socket.clone()), + ..Daemon::default() + }; for network in [false, true] { - let socket = Policy { + let policy = Policy::new( + Some(Mode::WorkspaceWrite), + Some(&base), + &[inner.to_str().unwrap().to_owned()], network, - socket: Some(base.join("agent.sock")), - ..policy.clone() - }; - let (profile, params) = seatbelt(&socket); - assert!(profile.contains( - "(deny network-outbound (remote unix-socket (subpath (param \"DAEMON_SOCKET\"))))" - )); - assert!( + &daemon, + ) + .unwrap() + .unwrap(); + assert!(!policy.can_write(&socket)); + let (profile, params) = seatbelt(&policy); + let key = |path: &Path| { params .iter() - .any(|(key, path)| key == "DAEMON_SOCKET" && path == &base.join("agent.sock")) - ); + .find(|(_, named)| named == path) + .map(|(key, _)| key.clone()) + .unwrap() + }; + assert!(profile.contains(&format!( + "(deny network-outbound (remote unix-socket (subpath (param \"{}\"))))", + key(&socket) + ))); + assert!(profile.contains(&format!( + "(require-not (literal (param \"{}\")))", + key(&socket) + ))); + assert!(profile.contains(&anchor(&key(&inner.join("run"))))); #[cfg(target_os = "linux")] assert!( - linux::args(&socket, false) + linux::args(&policy, false) .windows(3) - .any(|w| w[0] == "--ro-bind" && w[1] == "/dev/null") + .any(|w| w[0] == "--ro-bind" + && w[1] == "/dev/null" + && w[2] == socket.to_string_lossy()) ); } std::fs::remove_dir_all(base).unwrap(); } + #[test] + fn many_nested_roots_still_fit_one_invocation() { + // 64 nested roots with long names, about the settings' limit: each + // rule names only its own, so the profile and its parameters grow + // with the roots, not with their square. + let mut path = PathBuf::from("/"); + let roots: Vec = (0..64) + .map(|index| { + path.push(format!("{index:0>12}")); + Root { + read_only: PROTECTED.iter().map(|name| path.join(name)).collect(), + missing: Vec::new(), + path: path.clone(), + } + }) + .collect(); + let policy = Policy { + roots, + network: true, + socket: Some(path.join("agent.sock")), + helper: None, + }; + let (profile, params) = seatbelt(&policy); + let size = profile.len() + + params + .iter() + .map(|(key, path)| key.len() + path.as_os_str().len() + 4) + .sum::(); + assert!(size < 1 << 20, "{size}"); + assert_eq!(params.len(), 64 * 5 + 1); + } + #[test] fn a_symlinked_git_folder_refuses_the_sandbox() { let base = std::env::temp_dir().join(format!("agent-sandbox-link-{}", std::process::id())); From 8349f5a64b9c7d8c241694eea0a0384031edf951 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 04:09:23 +0000 Subject: [PATCH 06/23] Sandbox: on macOS, anchor the folders above every kept path, not only the socket Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 5 +++-- src/sandbox.rs | 36 +++++++++++++++++++++++++----------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index 3602feb40..c289acd2e 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2106,8 +2106,9 @@ off unless `network` is set. The daemon's socket is out of reach either way, as Codex keeps its own from a sandboxed command: nothing confined may drive the daemon, so a sandboxed bot delegates through tools its client declares, not through `agent` in its shell. A socket inside a writable folder stays -read-only, and on macOS the folders between it and that folder can't be -renamed, so it can't be moved or linked to a name the rule doesn't cover. `danger-full-access`, or no `sandbox`, runs +read-only. On macOS the folders between a writable folder and anything kept +inside it (the socket, the store, a worktree's git folder) can't be renamed, +so nothing kept can be moved to a name its rule doesn't cover. `danger-full-access`, or no `sandbox`, runs unconfined. Creating a sandboxed bot checks that sandboxes run here and refuses with diff --git a/src/sandbox.rs b/src/sandbox.rs index 4e168388a..99203b505 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -789,19 +789,27 @@ pub fn seatbelt(policy: &Policy) -> (String, Vec<(String, PathBuf)>) { _ => format!("(require-all {} )", parts.join(" ")), }); } - // Nor can the folders between a root and the daemon's socket, which - // would take the socket to a name its rule doesn't cover. - if let Some(socket) = &policy.socket { - for folder in socket.ancestors().skip(1) { - if policy + // Nor can the folders between a root and what stays out of reach (a + // kept path, the daemon's socket): renamed, they would take it to a + // name its rule doesn't cover. + let mut folders: Vec<&Path> = policy + .roots + .iter() + .flat_map(|root| root.read_only.iter().chain(&root.missing)) + .chain(&policy.socket) + .flat_map(|kept| kept.ancestors().skip(1)) + .filter(|folder| { + policy .roots .iter() .any(|root| folder.starts_with(&root.path)) - && !policy.roots.iter().any(|root| root.path == folder) - { - anchors.push(anchor(¶m(folder))); - } - } + && !policy.roots.iter().any(|root| root.path == *folder) + }) + .collect(); + folders.sort(); + folders.dedup(); + for folder in folders { + anchors.push(anchor(¶m(folder))); } let mut sections = vec![ SEATBELT_BASE.to_owned(), @@ -946,9 +954,13 @@ mod tests { let socket = inner.join("run/agent.sock"); std::fs::create_dir_all(socket.parent().unwrap()).unwrap(); std::fs::write(&socket, "").unwrap(); + let store = inner.join("state/state.sqlite"); + std::fs::create_dir_all(store.parent().unwrap()).unwrap(); + std::fs::write(&store, "").unwrap(); let daemon = Daemon { socket: Some(socket.clone()), - ..Daemon::default() + store: Some(store), + helper: None, }; for network in [false, true] { let policy = Policy::new( @@ -978,6 +990,8 @@ mod tests { key(&socket) ))); assert!(profile.contains(&anchor(&key(&inner.join("run"))))); + // So does the folder above a protected store. + assert!(profile.contains(&anchor(&key(&inner.join("state"))))); #[cfg(target_os = "linux")] assert!( linux::args(&policy, false) From dd5137345403f68438884b6526a07483d6b506c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 04:59:03 +0000 Subject: [PATCH 07/23] Sandbox: a sandboxed bot reaches the daemon through its own socket, capped to what it may do Each sandboxed bot gets a socket of its own beside the daemon's, bound over the daemon's path in bwrap and allowed by Seatbelt, and named by AGENT_SOCKET, so the agent CLI works in its shell. Requests on it answer as that bot: no configure, answers, tool results, serving approvals or shutdown; what it makes is its own and inherits its sandbox inside its folders; it removes only its own bots; what it sends reads as its own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 33 ++++-- src/sandbox.rs | 95 +++++++++++++++--- src/server/mod.rs | 217 ++++++++++++++++++++++++++++++++++------ src/server/session.rs | 25 ++++- src/server/socket.rs | 91 ++++++++++++++++- src/server/turn.rs | 19 +++- src/store/db.rs | 11 ++ src/tools.rs | 4 + tests/test_approvals.py | 68 +++++++++++++ tests/tool_contract.rs | 14 ++- 10 files changed, 520 insertions(+), 57 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index c289acd2e..d262dbc14 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2102,15 +2102,34 @@ runs hooks and config from there outside the sandbox, and agents trust what is there. The daemon's store and the files beside it stay read-only too. A protected folder that is a symlink refuses the call with `sandbox_unavailable`, since the link could be swapped from inside. Network is -off unless `network` is set. The daemon's socket is out of reach either way, -as Codex keeps its own from a sandboxed command: nothing confined may drive -the daemon, so a sandboxed bot delegates through tools its client declares, -not through `agent` in its shell. A socket inside a writable folder stays -read-only. On macOS the folders between a writable folder and anything kept -inside it (the socket, the store, a worktree's git folder) can't be renamed, -so nothing kept can be moved to a name its rule doesn't cover. `danger-full-access`, or no `sandbox`, runs +off unless `network` is set. `danger-full-access`, or no `sandbox`, runs unconfined. +A sandboxed bot's commands reach the daemon through the bot's own socket, +never the daemon's: so `agent` and the clients built on it work in its shell, +as that bot and only as far as a sandboxed bot may go. The daemon opens the +socket, `SOCKET.d/BOT_ID` beside its own, the first time one of the bot's +commands runs, and keeps it while it runs. On Linux it is bound over the +daemon's socket in the sandbox; on macOS Seatbelt denies the daemon's socket +and allows the bot's, network on or off. Either way the command's +`AGENT_SOCKET` names it. A request on it: + +| Request | On a bot's own socket | +|---|---| +| `configure`, `answer`, `tool_result`, `serve_approvals`, `renew_approvals`, `shutdown` | `not_permitted`: any of them would let a confined command undo its confinement | +| `create`, `fork` | Made by the caller, whatever `created_by` names (another is `not_permitted`), so the bot inherits its sandbox; its workspace and `writable` folders, and a fork's source's, must be ones the caller may write. A fork with `allow: []`, which only answers, may be of any bot | +| `submit` | Reads as the caller's: from its running turn, else with its name as `origin`; `from` naming another bot is `not_permitted`. A workspace for a sandboxed bot with none of its own must be one the caller may write | +| `delete`, `prune`, `compact`, `interrupt` | The caller itself, or a bot it made; another is `not_permitted` | +| Everything else | As on the daemon's socket | + +The folder of these sockets stays read-only in every root, so no bot can +put its own listener in another's place. A socket inside a writable folder +stays read-only too. On macOS the folders between a writable folder and +anything kept inside it (a socket, the store, a worktree's git folder) can't +be renamed, so nothing kept can be moved to a name its rule doesn't cover. A +daemon on stdio, or whose socket path leaves no room for the bots' sockets, +gives a sandboxed command no way to it. + Creating a sandboxed bot checks that sandboxes run here and refuses with `sandbox_unavailable` otherwise: on Linux, a system bubblewrap (`/usr/bin/bwrap`, `/bin/bwrap`, `/usr/local/bin/bwrap` or diff --git a/src/sandbox.rs b/src/sandbox.rs index 99203b505..009c7a4c3 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -53,8 +53,13 @@ pub struct Policy { /// Shallowest first: a deeper root decides for what is inside it. pub roots: Vec, pub network: bool, - /// The daemon's socket, which no command in the sandbox may reach. + /// The daemon's socket, which no command in the sandbox reaches + /// directly. pub socket: Option, + /// The bot's own socket into the daemon, which answers it as that bot + /// and only as far as a sandboxed bot may go; in the daemon socket's + /// place, so the `agent` CLI finds it there too. + pub capability: Option, /// The agent binary a confined write runs in ([`WRITE`]). pub helper: Option, } @@ -86,6 +91,7 @@ impl Policy { roots, network: network && mode == Mode::WorkspaceWrite, socket: daemon.socket.clone(), + capability: None, helper: daemon.helper.clone(), })) } @@ -277,6 +283,37 @@ pub struct Daemon { pub socket: Option, /// The agent binary, which a confined write runs in. pub helper: Option, + /// Where each sandboxed bot's own socket is opened, when the daemon + /// listens on one. + pub capabilities: Option, +} + +impl Daemon { + /// Bot `bot`'s own socket, opened on first use; `None` when the daemon + /// has no socket for a sandboxed command to use. + pub fn capability(&self, bot: i64) -> Result> { + self.capabilities + .as_ref() + .map(|capabilities| (capabilities.open)(bot)) + .transpose() + } +} + +/// The folder of the bots' own sockets, which stays read-only in every +/// root, and what opens one: the daemon's, which knows who is calling. +#[derive(Clone)] +pub struct Capabilities { + pub folder: PathBuf, + #[allow(clippy::type_complexity)] + pub open: std::sync::Arc Result + Send + Sync>, +} + +impl std::fmt::Debug for Capabilities { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Capabilities") + .field("folder", &self.folder) + .finish_non_exhaustive() + } } /// The root at `path`, with what stays read-only inside it: the protected @@ -328,12 +365,16 @@ fn root(path: &Path, daemon: &Daemon) -> Result { read_only.extend(store_files(store).filter(|file| file.exists())); } // A socket the bot could rename or link would be reachable by its new - // name, past a rule that names the old one. - if let Some(socket) = &daemon.socket - && socket.starts_with(path) - && socket.exists() + // name, past a rule that names the old one; and one bot could put its + // own listener in place of another's. + for kept in daemon + .socket + .iter() + .chain(daemon.capabilities.as_ref().map(|c| &c.folder)) { - read_only.push(socket.clone()); + if kept.starts_with(path) && kept.exists() { + read_only.push(kept.clone()); + } } Ok(Root { path: path.to_owned(), @@ -563,10 +604,14 @@ mod linux { ]); } } - // The daemon's socket is out of reach, as Codex keeps its own from - // a sandboxed command: nothing confined may drive the daemon. + // In the daemon's socket's place, the bot's own, which answers as + // that bot and only as far as a sandboxed bot may go; without one, + // nothing. if let Some(socket) = &policy.socket { - args.extend(["--ro-bind".into(), "/dev/null".into(), path(socket)]); + match &policy.capability { + Some(own) => args.extend(["--bind".into(), path(own), path(socket)]), + None => args.extend(["--ro-bind".into(), "/dev/null".into(), path(socket)]), + } } args.extend(["--unshare-user", "--unshare-pid", "--unshare-ipc"].map(String::from)); if !policy.network { @@ -825,14 +870,21 @@ pub fn seatbelt(policy: &Policy) -> (String, Vec<(String, PathBuf)>) { "(allow network-outbound)\n(allow network-inbound)\n{SEATBELT_NETWORK}{SEATBELT_TLS_TRUST}" )); } - // Never the daemon's socket, as Codex keeps its own from a sandboxed - // command: nothing confined may drive the daemon. + // Never the daemon's socket itself: the bot's own instead, which + // answers as that bot and only as far as a sandboxed bot may go. if let Some(socket) = &policy.socket { let key = param(socket); sections.push(format!( "(deny network-outbound (remote unix-socket (subpath (param \"{key}\"))))" )); } + if let Some(own) = &policy.capability { + let key = param(own); + sections.push(format!( + "(allow system-socket (socket-domain AF_UNIX))\n\ + (allow network-outbound (remote unix-socket (subpath (param \"{key}\"))))" + )); + } sections.push(SEATBELT_PREFERENCES.to_owned()); sections.push("(deny mach-lookup (xpc-service-name-prefix \"\"))".to_owned()); // These fcntls change a file through a read-only descriptor, past @@ -960,7 +1012,7 @@ mod tests { let daemon = Daemon { socket: Some(socket.clone()), store: Some(store), - helper: None, + ..Daemon::default() }; for network in [false, true] { let policy = Policy::new( @@ -1000,6 +1052,24 @@ mod tests { && w[1] == "/dev/null" && w[2] == socket.to_string_lossy()) ); + // With the bot's own socket, that one takes the daemon's place. + let own = Policy { + capability: Some(base.join("own.sock")), + ..policy.clone() + }; + let (profile, params) = seatbelt(&own); + let own_key = params + .iter() + .find(|(_, path)| path == &base.join("own.sock")) + .map(|(key, _)| key.clone()) + .unwrap(); + assert!(profile.contains(&format!( + "(allow network-outbound (remote unix-socket (subpath (param \"{own_key}\"))))" + ))); + #[cfg(target_os = "linux")] + assert!(linux::args(&own, false).windows(3).any(|w| w[0] == "--bind" + && w[1] == base.join("own.sock").to_string_lossy() + && w[2] == socket.to_string_lossy())); } std::fs::remove_dir_all(base).unwrap(); } @@ -1024,6 +1094,7 @@ mod tests { roots, network: true, socket: Some(path.join("agent.sock")), + capability: None, helper: None, }; let (profile, params) = seatbelt(&policy); diff --git a/src/server/mod.rs b/src/server/mod.rs index 61b968468..aa50986a4 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -24,7 +24,7 @@ use handles::{Completion, Handles, Waiter, now_ms}; use hub::{Hub, replay}; use serde::Deserialize; use serde_json::{Value, json}; -use session::{Inbound, socket_reader, stdio_reader}; +use session::{Inbound, stdio_reader}; use std::{ collections::HashMap, path::{Path, PathBuf}, @@ -32,7 +32,6 @@ use std::{ time::Duration, }; use tokio::{ - io::BufReader, sync::{mpsc, watch}, task::JoinSet, }; @@ -1200,7 +1199,9 @@ pub async fn run(config: Configuration) -> Result<()> { )); } agent_runtime::sandbox::warm(); - let daemon = agent_runtime::sandbox::Daemon { + let (sender, mut inbound) = mpsc::channel::(64); + let sessions_next = std::sync::Arc::new(std::sync::atomic::AtomicU64::new(1)); + let mut daemon = agent_runtime::sandbox::Daemon { store: Some(std::fs::canonicalize(&config.store)?), // By its folder's real path, which is what Seatbelt matches. socket: config @@ -1217,7 +1218,12 @@ pub async fn run(config: Configuration) -> Result<()> { .transpose()?, // Read once at start: what runs a confined write is this binary. helper: std::env::current_exe().ok(), + capabilities: None, }; + daemon.capabilities = daemon + .socket + .as_deref() + .and_then(|socket| socket::capabilities(socket, sender.clone(), sessions_next.clone())); let registry = registry .with_credentials(credentials) .with_environment(environment) @@ -1237,35 +1243,21 @@ pub async fn run(config: Configuration) -> Result<()> { "schema":agent_runtime::store::Database::SCHEMA, "tools":registry.names(),"providers":bindings, "durability":"sqlite_full","partial_text_durable":false}); - let (sender, mut inbound) = mpsc::channel::(64); let mut sessions: HashMap = HashMap::new(); + // The sandboxed bot each session on a bot's own socket answers as. + let mut callers: HashMap = HashMap::new(); let (stdout, stdout_writer) = Output::stdout(); let stdio_owner = config.socket.is_none(); let mut socket_owner = None; if let Some(socket) = &config.socket { let (owner, listener) = socket::Owner::bind(socket).await?; socket_owner = Some(owner); - let accept_sender = sender.clone(); - tokio::spawn(async move { - let mut next = 1u64; - while let Ok((stream, _)) = listener.accept().await { - let (read, write) = stream.into_split(); - let output = Output::writer(write); - if accept_sender - .send(Inbound::Open(next, output)) - .await - .is_err() - { - break; - } - tokio::spawn(socket_reader( - next, - BufReader::new(read), - accept_sender.clone(), - )); - next += 1; - } - }); + tokio::spawn(session::accept( + listener, + sender.clone(), + sessions_next.clone(), + None, + )); stdout.send(ready.clone()).await?; } else { sessions.insert(0, stdout.clone()); @@ -1452,13 +1444,15 @@ pub async fn run(config: Configuration) -> Result<()> { let Some(message) = message else { break }; last_activity = std::time::Instant::now(); match message { - Inbound::Open(id, output) => { + Inbound::Open(id, output, caller) => { let _ = output.try_send(ready.clone()); sessions.insert(id, output); + if let Some(caller) = caller { callers.insert(id, caller); } service.sessions = sessions.len(); } Inbound::Closed(id) => { sessions.remove(&id); + callers.remove(&id); service.sessions = sessions.len(); service.hub.close_session(id); service.handles.close_session(id); @@ -1476,6 +1470,13 @@ pub async fn run(config: Configuration) -> Result<()> { "id is a u64 or a string of at most 128 bytes", ), }); + // A bot's own socket answers as that bot, held to + // what it may do, before its reply is sized. + let mut request = request; + let refused = match (request.as_mut(), callers.get(&id)) { + (Ok(request), Some(&caller)) => service.capped(caller, &mut request.command).await.err(), + _ => None, + }; let bound = request.as_ref().map_or(Bound::default(), |r| service.admission_sends(&r.command, &r.id, id)); // Everything but an admission with room waits for the // admissions already queued, keeping request order. @@ -1496,17 +1497,21 @@ pub async fn run(config: Configuration) -> Result<()> { } let (request_id, result, shutdown, serving) = match request { Ok(request) => { + let allowed = refused.is_none(); let shutdown = match request.command { - Command::Shutdown { grace_ms } => Some(grace_ms), + Command::Shutdown { grace_ms } if allowed => Some(grace_ms), _ => None, }; let admission = matches!(request.command, Command::Create { .. } | Command::Submit { .. }); - let serves = matches!(request.command, Command::ServeApprovals { .. }); + let serves = allowed && matches!(request.command, Command::ServeApprovals { .. }); let answered = match &request.command { - Command::Answer { tag: Some(tag), lease: Some(lease), .. } => Some((tag.clone(), *lease)), + Command::Answer { tag: Some(tag), lease: Some(lease), .. } if allowed => Some((tag.clone(), *lease)), _ => None, }; - let result = service.dispatch(request.command, id, &output, request.id.clone(), bound).await; + let result = match refused { + Some(error) => Err(error), + None => service.dispatch(request.command, id, &output, request.id.clone(), bound).await, + }; if result.as_ref().is_err_and(|e| e.code == "deferred") { continue; } // A refused admission still answers after those queued before it. if admission && !service.admissions.is_empty() { @@ -1651,6 +1656,157 @@ fn validate_provider( } impl Service { + /// Hold a request that came in on sandboxed bot `caller`'s own socket to + /// what that bot may do: use and read bots, and make bots that inherit + /// its sandbox and stay inside the folders it may write. Never answer a + /// gate or a declared call, change a bot's gates or instructions, stop + /// the daemon, or remove what isn't its own: any of those would let a + /// confined command undo its confinement. A bot it makes is its own, + /// whatever the request names. + async fn capped(&self, caller: i64, command: &mut Command) -> Result<()> { + let refuse = |what: &str| -> Result<()> { + fail_with( + "not_permitted", + format!("a sandboxed bot's commands may not {what}"), + ) + }; + match command { + Command::Configure { .. } => return refuse("configure a bot"), + Command::Answer { .. } => return refuse("answer an approval"), + Command::ServeApprovals { .. } | Command::RenewApprovals { .. } => { + return refuse("serve approvals"); + } + Command::ToolResult { .. } => return refuse("answer a declared tool call"), + Command::Shutdown { .. } => return refuse("stop the daemon"), + _ => {} + } + let me = self + .store + .op("inspect", move |db| db.inspect_id(caller)) + .await?; + // A request may name only the caller as the creator of what it makes. + let claim = |created_by: &mut Option, created_by_id: &mut Option| { + if created_by.as_ref().is_some_and(|name| *name != me.name) + || created_by_id.is_some_and(|id| id != me.bot_id) + { + return refuse("make a bot on another bot's behalf"); + } + *created_by = Some(me.name.clone()); + *created_by_id = Some(me.bot_id); + Ok(()) + }; + // The folders a request would give a bot, each of which the caller + // must be able to write itself. + let mut folders: Vec = Vec::new(); + match command { + Command::Create { + workspace, + settings, + created_by, + created_by_id, + .. + } => { + claim(created_by, created_by_id)?; + folders.extend(workspace.clone()); + folders.extend(settings.writable.iter().cloned()); + } + Command::Fork { + source, + workspace, + created_by, + created_by_id, + allow, + .. + } => { + claim(created_by, created_by_id)?; + // A fork that may call nothing touches nothing: an ask. + if !matches!(allow, Some(Some(tools)) if tools.is_empty()) { + let source = source.clone(); + let source = self + .store + .op("inspect", move |db| db.inspect(&source)) + .await?; + folders.extend(workspace.clone().or(source.workspace)); + folders.extend(source.settings.writable); + } + } + Command::Submit { + bot, + workspace, + from, + origin, + .. + } => { + // What it sends reads as the caller's, never a person's or + // another bot's. + if from.as_ref().is_some_and(|author| author.bot != me.name) { + return refuse("send a message as another bot"); + } + match (from.is_some(), me.running_turn) { + (true, _) => *origin = None, + (false, Some(turn)) => { + *from = Some(Author { + bot: me.name.clone(), + turn, + }); + *origin = None; + } + (false, None) => *origin = Some(me.name.clone()), + } + // A sandboxed bot with no folder of its own works in each + // turn's: one the caller gives it must be the caller's. + if let Some(workspace) = workspace { + let bot = bot.clone(); + let target = self.store.op("inspect", move |db| db.inspect(&bot)).await?; + if target.sandboxed() && target.workspace.is_none() { + folders.push(workspace.clone()); + } + } + } + Command::Delete { bot, .. } + | Command::Prune { bot, .. } + | Command::Compact { bot, .. } + | Command::Interrupt { bot, .. } => { + let bot = bot.clone(); + let target = self.store.op("inspect", move |db| db.inspect(&bot)).await?; + if target.bot_id != me.bot_id && target.created_by_id != Some(me.bot_id) { + return refuse("change a bot it didn't make"); + } + } + _ => {} + } + if folders.is_empty() || !me.sandboxed() { + return Ok(()); + } + let daemon = agent_runtime::sandbox::Daemon { + capabilities: None, + ..self.registry.daemon().clone() + }; + let outside = tokio::task::spawn_blocking(move || -> Result> { + let Some(policy) = agent_runtime::sandbox::Policy::new( + me.settings.sandbox, + me.workspace.as_deref().map(Path::new), + &me.settings.writable, + me.settings.network, + &daemon, + )? + else { + return Ok(None); + }; + Ok(folders + .into_iter() + .find(|folder| !policy.can_write(Path::new(folder)))) + }) + .await + .map_err(|_| Error::new("sandbox_unavailable"))??; + match outside { + Some(folder) => fail_with( + "not_permitted", + format!("{folder} is outside the folders this sandboxed bot may write"), + ), + None => Ok(()), + } + } fn has_capacity(&self) -> bool { !self.draining && (self.limit_active == 0 || self.active.len() + self.reserved < self.limit_active) @@ -3568,6 +3724,7 @@ mod tests { } use super::*; + use tokio::io::BufReader; #[test] fn the_first_cancel_cause_wins() { diff --git a/src/server/session.rs b/src/server/session.rs index 83951faca..e0fe796fe 100644 --- a/src/server/session.rs +++ b/src/server/session.rs @@ -12,7 +12,8 @@ use tokio::{ // box for every request. Open/Closed messages are infrequent control traffic. #[allow(clippy::large_enum_variant)] pub enum Inbound { - Open(u64, Output), + /// A connection, and the sandboxed bot whose own socket it came in on. + Open(u64, Output, Option), Request(u64, Result), Closed(u64), } @@ -66,6 +67,28 @@ pub fn stdio_reader(sender: mpsc::Sender) { }); } +/// Accept connections on `listener` until the service stops, each a session +/// numbered from `next`, every one on a bot's own socket answered as `caller`. +pub async fn accept( + listener: tokio::net::UnixListener, + sender: mpsc::Sender, + next: std::sync::Arc, + caller: Option, +) { + while let Ok((stream, _)) = listener.accept().await { + let id = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let (read, write) = stream.into_split(); + if sender + .send(Inbound::Open(id, Output::writer(write), caller)) + .await + .is_err() + { + break; + } + tokio::spawn(socket_reader(id, BufReader::new(read), sender.clone())); + } +} + pub async fn socket_reader( id: u64, mut reader: BufReader, diff --git a/src/server/socket.rs b/src/server/socket.rs index 0a1a13b42..d968a33ae 100644 --- a/src/server/socket.rs +++ b/src/server/socket.rs @@ -1,5 +1,5 @@ //! Ownership of the rendezvous path is independent of database ownership. -use agent_runtime::{Error, Result, fail}; +use agent_runtime::{Error, Result, fail, fail_with}; use std::{ fs::{File, OpenOptions}, os::unix::fs::{FileTypeExt, MetadataExt, OpenOptionsExt}, @@ -8,6 +8,95 @@ use std::{ }; use tokio::net::{UnixListener, UnixStream}; +/// The longest socket path every system takes: macOS holds 104 bytes with +/// the terminating NUL, Linux 108. +const SOCKET_PATH: usize = 103; + +/// Each sandboxed bot's own socket, in a folder beside the daemon's: opened +/// the first time one of its commands runs, and kept while the daemon runs. +/// A connection on it is a session answered as that bot. `None` when the +/// folder's sockets wouldn't fit a socket path. +pub fn capabilities( + socket: &Path, + sender: tokio::sync::mpsc::Sender, + next: std::sync::Arc, +) -> Option { + let mut folder = socket.as_os_str().to_owned(); + folder.push(".d"); + let folder = PathBuf::from(folder); + if folder.as_os_str().len() + 1 + i64::MAX.to_string().len() > SOCKET_PATH { + return None; + } + let runtime = tokio::runtime::Handle::current(); + // The folder is made ready by the first bot to need it, once the + // daemon owns its socket. + let opened = std::sync::Mutex::new(None::>); + let (place, own) = (folder.clone(), socket.to_owned()); + Some(agent_runtime::sandbox::Capabilities { + folder, + open: std::sync::Arc::new(move |bot| { + let mut opened = opened.lock().unwrap_or_else(|e| e.into_inner()); + if opened.is_none() { + prepare(&place, &own)?; + } + let opened = opened.get_or_insert_default(); + if let Some(path) = opened.get(&bot) { + return Ok(path.clone()); + } + let path = place.join(bot.to_string()); + let listener = std::os::unix::net::UnixListener::bind(&path) + .map_err(|_| Error::new("socket_bind_failed"))?; + listener.set_nonblocking(true)?; + let listener = { + let _entered = runtime.enter(); + UnixListener::from_std(listener)? + }; + runtime.spawn(super::session::accept( + listener, + sender.clone(), + next.clone(), + Some(bot), + )); + opened.insert(bot, path.clone()); + Ok(path) + }), + }) +} + +/// The bots' socket folder, the daemon user's alone (whoever owns the +/// daemon's `socket`), with a previous daemon's sockets gone. Another user's +/// folder could hand one bot another's socket, so it is refused. +fn prepare(folder: &Path, socket: &Path) -> Result<()> { + use std::os::unix::fs::{DirBuilderExt, PermissionsExt}; + match std::fs::symlink_metadata(folder) { + Ok(found) => { + if !found.is_dir() + || found.uid() != std::fs::symlink_metadata(socket)?.uid() + || found.permissions().mode() & 0o077 != 0 + { + return fail_with( + "sandbox_unavailable", + format!( + "{} must be a folder only the daemon's user may use", + folder.display() + ), + ); + } + for entry in std::fs::read_dir(folder)? { + let entry = entry?; + if entry.file_type()?.is_socket() { + std::fs::remove_file(entry.path())?; + } + } + Ok(()) + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + Ok(std::fs::DirBuilder::new().mode(0o700).create(folder)?) + } + Err(error) => Err(error.into()), + } +} + pub struct Owner { path: PathBuf, identity: (u64, u64), diff --git a/src/server/turn.rs b/src/server/turn.rs index bf6bef3c5..8191d0268 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -3391,8 +3391,11 @@ impl Turn { Ok(prepared) if confined => { // Reading the roots touches the filesystem: off the // runtime thread. - let (settings, daemon) = - (record.settings.clone(), self.registry.daemon().clone()); + let (settings, daemon, bot) = ( + record.settings.clone(), + self.registry.daemon().clone(), + record.bot_id, + ); let root = record .workspace .as_ref() @@ -3401,13 +3404,21 @@ impl Turn { // The probe for a sandbox runs once, here off the // runtime thread, also for a bot from before a restart. agent_runtime::sandbox::available()?; - Policy::new( + // The bot's own socket, so its commands may use the + // daemon as far as a sandboxed bot may; opened first, + // so its folder is there to be kept read-only. + let capability = daemon.capability(bot)?; + let policy = Policy::new( settings.sandbox, Some(&root), &settings.writable, settings.network, &daemon, - ) + )?; + Ok(policy.map(|policy| Policy { + capability, + ..policy + })) }) .await .map_err(|_| Error::new("sandbox_unavailable")) diff --git a/src/store/db.rs b/src/store/db.rs index e4d4593f0..9738a0861 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -2132,6 +2132,17 @@ impl Database { declared.retain(|tool| tool.inherit); stored_declared(&declared) } + /// The bot holding identity `id`, whatever it is named. + pub fn inspect_id(&self, id: i64) -> Result { + self.conn + .query_row( + &format!("SELECT {} FROM bots WHERE id=?", Self::COLUMNS), + [id], + Self::row, + ) + .optional()? + .ok_or(Error::new("bot_not_found")) + } pub fn inspect(&self, name: &str) -> Result { self.conn .query_row( diff --git a/src/tools.rs b/src/tools.rs index cce3917e9..a8df356d3 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1729,6 +1729,10 @@ fn sh( for (name, value) in registry.environment.iter().chain(environment) { process.env(name, value); } + // A sandboxed command reaches the daemon through its bot's own socket. + if let Some(own) = sandbox.and_then(|policy| policy.capability.as_ref()) { + process.env("AGENT_SOCKET", own); + } process.current_dir(workspace); Ok(process) } diff --git a/tests/test_approvals.py b/tests/test_approvals.py index a10d50de6..60e4a5eca 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -150,6 +150,74 @@ def test_a_sandboxed_bot_without_a_gate_cannot_leave_its_sandbox(self): self.assertEqual(self.tool_output(client, 'write-2')[1]['error'], 'write_denied') self.assertEqual(list(outside.iterdir()), []) + @unittest.skipUnless(shutil.which('bwrap') or sys.platform == 'darwin', 'sandboxes need bubblewrap') + def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): + (ROOT / '.local').mkdir(exist_ok=True) + outside = Path(tempfile.mkdtemp(dir=ROOT / '.local')) + self.addCleanup(shutil.rmtree, outside) + daemon = SocketClient(self.binary, self.path / 'state.sqlite', self.url, 'echo,shell', + settings={'sandbox': 'workspace-write'}) + self.addCleanup(daemon.close) + daemon.request('create', bot='Bob', workspace=str(self.path)) + daemon.request('create', bot='Alice', workspace=str(outside), settings={}) + (self.path / 'kid').mkdir() + child = dict(op='create', model='openai/synthetic-model', instructions='Kid.', tools=['echo']) + requests = [ + dict(op='bots'), + dict(op='configure', bot='Bob', instructions='free'), + dict(op='answer', bot='Bob', turn=1, call_id='shell-1', request=1, decision='allow'), + dict(op='shutdown'), + dict(child, bot='Kid', workspace=str(self.path / 'kid')), + dict(child, bot='Out', workspace=str(outside)), + dict(child, bot='Liar', workspace=str(self.path / 'kid'), created_by='Alice'), + dict(op='delete', bot='Alice'), + dict(op='delete', bot='Kid'), + dict(op='fork', source='Alice', bot='Copy'), + dict(op='fork', source='Alice', bot='Ask', allow=[], expire_ms=60000), + ] + (self.path / 'requests.json').write_text(json.dumps(requests)) + (self.path / 'probe.py').write_text( + 'import json, os, socket, sys\n' + 'def ask(path, requests):\n' + ' s = socket.socket(socket.AF_UNIX); s.connect(path); f = s.makefile("rw")\n' + ' f.readline()\n' + ' out = []\n' + ' for i, request in enumerate(requests):\n' + ' f.write(json.dumps({"id": i, **request}) + "\\n"); f.flush()\n' + ' while (line := json.loads(f.readline())).get("id") != i: pass\n' + ' out.append(line)\n' + ' return out\n' + 'requests = json.load(open("requests.json"))\n' + 'try:\n' + ' open(os.path.join(os.path.dirname(os.environ["AGENT_SOCKET"]), "planted"), "w"); planted = True\n' + 'except OSError:\n' + ' planted = False\n' + 'print(json.dumps({"own": os.environ["AGENT_SOCKET"], "planted": planted, "answers": ask(os.environ["AGENT_SOCKET"], requests),\n' + ' "daemon": ask(sys.argv[1], requests[:1]) if sys.platform == "linux" else None}))\n') + turn = daemon.request('submit', bot='Bob', request_id='t', + prompt=f'shell:python3 probe.py {daemon.socket_path}')['result']['turn'] + self.assertEqual(daemon.finished(turn)['data']['status'], 'completed') + probed = json.loads(self.tool_output(daemon, 'shell-1')[1]['stdout']) + self.assertNotEqual(probed['own'], str(daemon.socket_path)) + # No bot can put its own listener in place of another's. + self.assertFalse(probed['planted']) + (bots, configure, answer, shutdown, kid, out, liar, delete_alice, delete_kid, copy, + ask) = probed['answers'] + self.assertIn('Bob', [bot['name'] for bot in bots['result']['bots']]) + for refused in (configure, answer, shutdown, out, liar, delete_alice, copy): + self.assertEqual(refused['error'], 'not_permitted', refused) + # What it makes is its own and no less sandboxed. + self.assertEqual((kid['result']['created_by'], kid['result']['settings']['sandbox']), + ('Bob', 'workspace-write')) + self.assertIn('result', delete_kid) + # A fork that may call nothing only answers. + self.assertEqual(ask['result']['created_by'], 'Bob') + if probed['daemon'] is not None: + # In the daemon's socket's place, its own. + self.assertIn('result', probed['daemon'][0]) + self.assertEqual(list(outside.iterdir()), []) + self.assertEqual(daemon.request('inspect', bot='Bob')['result']['instructions'], 'Test agent.') + def test_a_denial_is_the_result_and_the_turn_goes_on(self): client = self.gated() turn = client.request('submit', bot='Bob', request_id='t', prompt='shell:touch ran')['result']['turn'] diff --git a/tests/tool_contract.rs b/tests/tool_contract.rs index 466284f9c..fd2ef7068 100644 --- a/tests/tool_contract.rs +++ b/tests/tool_contract.rs @@ -537,8 +537,8 @@ async fn a_sandboxed_command_writes_only_its_roots() { std::fs::write(dir.join("store"), "db").unwrap(); let daemon = Daemon { store: Some(dir.join("store")), - socket: None, helper: Some(env!("CARGO_BIN_EXE_agent").into()), + ..Daemon::default() }; let writable = [ repo.to_str().unwrap().to_owned(), @@ -704,7 +704,17 @@ async fn a_sandboxed_command_cannot_reach_the_daemon_socket() { ) .unwrap() .unwrap(); - assert_ne!(run(Some(policy)).await, 0, "network {network}"); + assert_ne!(run(Some(policy.clone())).await, 0, "network {network}"); + // With the bot's own socket, the daemon's path reaches that one. + let own = base.join(format!("own-{network}.sock")); + let listener = std::os::unix::net::UnixListener::bind(&own).unwrap(); + listener.set_nonblocking(true).unwrap(); + let policy = Policy { + capability: Some(own), + ..policy + }; + assert_eq!(run(Some(policy)).await, 0, "network {network}"); + assert!(listener.accept().is_ok()); } std::fs::remove_dir_all(base).unwrap(); } From 7c7bad8350fd6a0a4cf7aab4f0f7b6d32d1de82d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:01:23 +0000 Subject: [PATCH 08/23] Sandbox: hide the other bots' sockets; a bot made from a sandbox gets only the answered tools its maker passes on Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 12 +++++--- src/sandbox.rs | 62 ++++++++++++++++++++++++++++++++++++++++- src/server/mod.rs | 15 ++++++++++ src/tools.rs | 2 +- tests/test_approvals.py | 32 ++++++++++++++------- 5 files changed, 107 insertions(+), 16 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index d262dbc14..12cf79481 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2110,9 +2110,11 @@ never the daemon's: so `agent` and the clients built on it work in its shell, as that bot and only as far as a sandboxed bot may go. The daemon opens the socket, `SOCKET.d/BOT_ID` beside its own, the first time one of the bot's commands runs, and keeps it while it runs. On Linux it is bound over the -daemon's socket in the sandbox; on macOS Seatbelt denies the daemon's socket -and allows the bot's, network on or off. Either way the command's -`AGENT_SOCKET` names it. A request on it: +daemon's socket in the sandbox, and the folder of the bots' sockets is +hidden; on macOS Seatbelt denies the daemon's socket and that folder and +allows the bot's own, network on or off. Either way the command's +`AGENT_SOCKET` names it, and no command reaches another bot's. A request on +it: | Request | On a bot's own socket | |---|---| @@ -2123,7 +2125,9 @@ and allows the bot's, network on or off. Either way the command's | Everything else | As on the daemon's socket | The folder of these sockets stays read-only in every root, so no bot can -put its own listener in another's place. A socket inside a writable folder +put its own listener in another's place. A tool a client answers, declared +for a bot the caller makes, must be one the caller has and passes on, +answered by the same client; another is `not_permitted`. A socket inside a writable folder stays read-only too. On macOS the folders between a writable folder and anything kept inside it (a socket, the store, a worktree's git folder) can't be renamed, so nothing kept can be moved to a name its rule doesn't cover. A diff --git a/src/sandbox.rs b/src/sandbox.rs index 009c7a4c3..502b116b8 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -65,6 +65,18 @@ pub struct Policy { } impl Policy { + /// The socket a command in this sandbox reaches the daemon by: its + /// bot's own, which bubblewrap puts in the daemon's socket's place, as + /// Seatbelt can't. + pub fn reach(&self) -> Option<&Path> { + self.capability.as_ref()?; + if cfg!(target_os = "linux") { + self.socket.as_deref() + } else { + self.capability.as_deref() + } + } + /// The policy for a bot in `mode`, whose folder is `workspace` and /// whose client named `writable`; `None` when its commands run /// unconfined. `daemon` holds what the daemon itself keeps: its store, @@ -609,7 +621,21 @@ mod linux { // nothing. if let Some(socket) = &policy.socket { match &policy.capability { - Some(own) => args.extend(["--bind".into(), path(own), path(socket)]), + Some(own) => { + args.extend(["--bind".into(), path(own), path(socket)]); + // The other bots' sockets are out of sight: each would + // answer as its own bot. + if let Some(folder) = own.parent() { + args.extend([ + "--perms".into(), + "555".into(), + "--tmpfs".into(), + path(folder), + "--remount-ro".into(), + path(folder), + ]); + } + } None => args.extend(["--ro-bind".into(), "/dev/null".into(), path(socket)]), } } @@ -879,6 +905,13 @@ pub fn seatbelt(policy: &Policy) -> (String, Vec<(String, PathBuf)>) { )); } if let Some(own) = &policy.capability { + // Not another bot's, which would answer as its own bot. + if let Some(folder) = own.parent() { + let key = param(folder); + sections.push(format!( + "(deny network-outbound (remote unix-socket (subpath (param \"{key}\"))))" + )); + } let key = param(own); sections.push(format!( "(allow system-socket (socket-domain AF_UNIX))\n\ @@ -1070,6 +1103,33 @@ mod tests { assert!(linux::args(&own, false).windows(3).any(|w| w[0] == "--bind" && w[1] == base.join("own.sock").to_string_lossy() && w[2] == socket.to_string_lossy())); + // Never another bot's, from the same folder. + let folder_key = params + .iter() + .find(|(_, path)| path == &base) + .map(|(key, _)| key.clone()) + .unwrap(); + let denied = format!( + "(deny network-outbound (remote unix-socket (subpath (param \"{folder_key}\"))))" + ); + let allowed = format!( + "(allow network-outbound (remote unix-socket (subpath (param \"{own_key}\"))))" + ); + assert!(profile.find(&denied).unwrap() < profile.find(&allowed).unwrap()); + #[cfg(target_os = "linux")] + assert!( + linux::args(&own, false) + .windows(2) + .any(|w| w[0] == "--tmpfs" && w[1] == base.to_string_lossy()) + ); + assert_eq!( + own.reach(), + Some(if cfg!(target_os = "linux") { + socket.as_path() + } else { + own.capability.as_deref().unwrap() + }) + ); } std::fs::remove_dir_all(base).unwrap(); } diff --git a/src/server/mod.rs b/src/server/mod.rs index aa50986a4..2f8dd4aef 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1704,9 +1704,24 @@ impl Service { settings, created_by, created_by_id, + declare, .. } => { claim(created_by, created_by_id)?; + // A tool a client answers is one the caller may pass on: one + // it has and gives the bots it makes, answered by the same + // client. Another could get a client to act for it. + let passes = |tool: &agent_runtime::tools::Declared| { + tool.answered_by.is_none() + || me.declared.iter().any(|own| { + own.inherit + && own.name == tool.name + && own.answered_by == tool.answered_by + }) + }; + if declare.iter().flatten().any(|tool| !passes(tool)) { + return refuse("declare a tool a client answers that it can't pass on"); + } folders.extend(workspace.clone()); folders.extend(settings.writable.iter().cloned()); } diff --git a/src/tools.rs b/src/tools.rs index a8df356d3..44c9f80d4 100644 --- a/src/tools.rs +++ b/src/tools.rs @@ -1730,7 +1730,7 @@ fn sh( process.env(name, value); } // A sandboxed command reaches the daemon through its bot's own socket. - if let Some(own) = sandbox.and_then(|policy| policy.capability.as_ref()) { + if let Some(own) = sandbox.and_then(Policy::reach) { process.env("AGENT_SOCKET", own); } process.current_dir(workspace); diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 60e4a5eca..e7ba69219 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -160,6 +160,11 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): self.addCleanup(daemon.close) daemon.request('create', bot='Bob', workspace=str(self.path)) daemon.request('create', bot='Alice', workspace=str(outside), settings={}) + # Another sandboxed bot, whose own socket is open once it has run a + # command. + carl = daemon.request('create', bot='Carl', workspace=str(self.path))['result']['bot_id'] + turn = daemon.request('submit', bot='Carl', request_id='c', prompt='shell:true')['result']['turn'] + self.assertEqual(daemon.finished(turn)['data']['status'], 'completed') (self.path / 'kid').mkdir() child = dict(op='create', model='openai/synthetic-model', instructions='Kid.', tools=['echo']) requests = [ @@ -174,6 +179,9 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): dict(op='delete', bot='Kid'), dict(op='fork', source='Alice', bot='Copy'), dict(op='fork', source='Alice', bot='Ask', allow=[], expire_ms=60000), + dict(child, bot='Hand', workspace=str(self.path / 'kid'), + declare=[{'name': 'act', 'description': 'Act.', 'parameters': {'type': 'object'}, + 'answered_by': 'client'}]), ] (self.path / 'requests.json').write_text(json.dumps(requests)) (self.path / 'probe.py').write_text( @@ -189,22 +197,29 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): ' return out\n' 'requests = json.load(open("requests.json"))\n' 'try:\n' - ' open(os.path.join(os.path.dirname(os.environ["AGENT_SOCKET"]), "planted"), "w"); planted = True\n' + ' ask(sys.argv[1] + ".d/" + sys.argv[2], requests[:1]); borrowed = True\n' + 'except OSError:\n' + ' borrowed = False\n' + 'try:\n' + ' open(sys.argv[1] + ".d/planted", "w"); planted = True\n' 'except OSError:\n' ' planted = False\n' - 'print(json.dumps({"own": os.environ["AGENT_SOCKET"], "planted": planted, "answers": ask(os.environ["AGENT_SOCKET"], requests),\n' - ' "daemon": ask(sys.argv[1], requests[:1]) if sys.platform == "linux" else None}))\n') + 'print(json.dumps({"own": os.environ["AGENT_SOCKET"], "planted": planted, "borrowed": borrowed, "answers": ask(os.environ["AGENT_SOCKET"], requests)}))\n') turn = daemon.request('submit', bot='Bob', request_id='t', - prompt=f'shell:python3 probe.py {daemon.socket_path}')['result']['turn'] + prompt=f'shell:python3 probe.py {daemon.socket_path} {carl}')['result']['turn'] self.assertEqual(daemon.finished(turn)['data']['status'], 'completed') probed = json.loads(self.tool_output(daemon, 'shell-1')[1]['stdout']) - self.assertNotEqual(probed['own'], str(daemon.socket_path)) + # On Linux its own socket is in the daemon's socket's place. + if sys.platform == 'linux': + self.assertEqual(probed['own'], str(daemon.socket_path)) # No bot can put its own listener in place of another's. self.assertFalse(probed['planted']) + # Nor reach another bot's, which would answer as that bot. + self.assertFalse(probed['borrowed']) (bots, configure, answer, shutdown, kid, out, liar, delete_alice, delete_kid, copy, - ask) = probed['answers'] + ask, hand) = probed['answers'] self.assertIn('Bob', [bot['name'] for bot in bots['result']['bots']]) - for refused in (configure, answer, shutdown, out, liar, delete_alice, copy): + for refused in (configure, answer, shutdown, out, liar, delete_alice, copy, hand): self.assertEqual(refused['error'], 'not_permitted', refused) # What it makes is its own and no less sandboxed. self.assertEqual((kid['result']['created_by'], kid['result']['settings']['sandbox']), @@ -212,9 +227,6 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): self.assertIn('result', delete_kid) # A fork that may call nothing only answers. self.assertEqual(ask['result']['created_by'], 'Bob') - if probed['daemon'] is not None: - # In the daemon's socket's place, its own. - self.assertIn('result', probed['daemon'][0]) self.assertEqual(list(outside.iterdir()), []) self.assertEqual(daemon.request('inspect', bot='Bob')['result']['instructions'], 'Test agent.') From 23972e60103f90d956f618b21176d0aef4c7579f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:22:39 +0000 Subject: [PATCH 09/23] Measurements: a matched screen for the sandboxed bots' own sockets Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/DAEMON_MEASUREMENTS.md | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/docs/DAEMON_MEASUREMENTS.md b/docs/DAEMON_MEASUREMENTS.md index 4c0378690..0e04262ec 100644 --- a/docs/DAEMON_MEASUREMENTS.md +++ b/docs/DAEMON_MEASUREMENTS.md @@ -5394,3 +5394,33 @@ five. Medians, ranges in brackets. Every difference is inside the run-to-run range: three integers more in a pool's state, updated under its existing lock, cost nothing measurable. + +## Sandboxed bots' own sockets + +A sandboxed bot's commands reach the daemon through a socket of the bot's +own, whose requests are held to what that bot may do. Every request now looks +its session up among those sockets' sessions, one hash lookup; the rest runs +only on a bot's own socket or for a sandboxed bot's confined call. This +screen checks that the paths every bot takes held. `6cbd621` (main) against +`7c7bad8` (this branch), release builds of `-p agent-runtime --bin agent` +(17,589,392 and 17,661,768 bytes), one Linux x86_64 container, synthetic +provider, two alternating rounds: + +| Measure | main | branch | +| --- | ---: | ---: | +| Completion burst p99 ms | 12.5 | 12.9 | +| 32 lone submits, daemon CPU ms | 51.3 | 49.1 | +| 32 lone submits, reply p50 ms | 1.83 | 1.75 | +| 32 burst submits, reply p99 ms | 6.65 | 6.73 | +| CPU per text turn (footprint), ms | 1.24 | 1.26 | +| Store jobs per text turn | 7.53 | 7.53 | +| Idle RSS (footprint), MiB | 18.7 | 18.9 | +| RSS after 1000 turns, MiB | 28.2 | 27.5 | +| 32-bot socket echo, CPU s | 1.15 | 1.12 | +| 32-bot socket echo, peak RSS MiB | 24.5 | 24.7 | +| 32-bot socket echo, turn p95 ms | 590 | 590 | + +Medians of 6 (completion, lifecycle), 10 (admission) or 4 (footprint) +measured runs. Every measure's ranges overlap between builds: idle RSS 18.45 +to 19.02 MiB on main against 17.59 to 19.05, and after 1000 turns 27.69 to +28.33 against 26.71 to 29.54. Sandboxed bots and macOS are not measured. From 4b0e9bbff0223748d598f1757564c5c23e4a3cbf Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:42:47 +0000 Subject: [PATCH 10/23] Bind each sandbox root by the folder opened for it A folder a bot was given can sit in one its creator may write, and the creator can swap a link in for it later; bound by its path, bubblewrap would follow that link. Each root is now opened folder by folder without following links, refused if one was swapped in, and bound from that descriptor. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- src/sandbox.rs | 92 +++++++++++++++++++++++++++++++++++++----- tests/tool_contract.rs | 43 ++++++++++++++++++++ 2 files changed, 125 insertions(+), 10 deletions(-) diff --git a/src/sandbox.rs b/src/sandbox.rs index 502b116b8..66ee80d8f 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -515,6 +515,7 @@ pub fn command( mod linux { use super::{Policy, depth}; use crate::{Error, Result}; + use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::path::{Path, PathBuf}; /// Where a system's bubblewrap is installed. Never looked up on PATH: @@ -588,8 +589,10 @@ mod linux { /// bubblewrap's arguments for `policy`, as Codex builds them: the whole /// filesystem read-only, each root bound writable from the shallowest /// down with its read-only folders bound back over it, and what may not - /// be made held by an empty read-only folder in its place. - pub(super) fn args(policy: &Policy, detached: bool) -> Vec { + /// be made held by an empty read-only folder in its place. Each root is + /// bound from `pinned`, the folder [`pin`] opened for it. + pub(super) fn args(policy: &Policy, detached: bool, pinned: &[RawFd]) -> Vec { + debug_assert_eq!(policy.roots.len(), pinned.len()); let path = |path: &Path| path.to_string_lossy().into_owned(); // A new session keeps the command from typing into the daemon's // terminal (TIOCSTI). @@ -598,8 +601,12 @@ mod linux { args.push("--die-with-parent".into()); } args.extend(["--ro-bind", "/", "/", "--dev", "/dev"].map(String::from)); - for root in &policy.roots { - args.extend(["--bind".into(), path(&root.path), path(&root.path)]); + for (root, folder) in policy.roots.iter().zip(pinned) { + args.extend([ + "--bind".into(), + format!("/proc/self/fd/{folder}"), + path(&root.path), + ]); let mut kept: Vec<&PathBuf> = root.read_only.iter().collect(); kept.sort_by_key(|path| depth(path)); for kept in kept { @@ -653,14 +660,75 @@ mod linux { rest: &[&str], detached: bool, ) -> Result { + let pinned = policy + .roots + .iter() + .map(|root| pin(&root.path)) + .collect::>>()?; + let folders: Vec = pinned.iter().map(AsRawFd::as_raw_fd).collect(); let mut command = tokio::process::Command::new(checked()?); command - .args(args(policy, detached)) + .args(args(policy, detached, &folders)) .arg("--") .arg(program) .args(rest); + // SAFETY: fcntl is async-signal-safe and changes only the child's + // descriptors: bubblewrap inherits the pinned folders, and the + // daemon's other children never see them. The closure owns them, so + // they stay open until the command is dropped, after it spawns. + unsafe { + command.pre_exec(move || { + for folder in &pinned { + if libc::fcntl(folder.as_raw_fd(), libc::F_SETFD, 0) < 0 { + return Err(std::io::Error::last_os_error()); + } + } + Ok(()) + }); + } Ok(command) } + + /// A root, a real path, opened as itself for bubblewrap to bind: each + /// folder on the way is opened without following a link. A folder a bot + /// was given can sit in one its creator may write, and the creator can + /// swap a link in for it later; bound by its path, that link would carry + /// the sandbox wherever it points. Opened this way the swap is refused, + /// and what is bound is the folder that was opened, wherever it has + /// moved since. + pub(super) fn pin(path: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + let refused = |detail: String| Error::with("sandbox_unavailable", detail); + let open = |at: RawFd, name: &std::ffi::CStr| -> std::io::Result { + let flags = libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC; + // SAFETY: `name` is NUL-terminated and outlives the call, and `at` + // is open. + match unsafe { libc::openat(at, name.as_ptr(), flags) } { + fd if fd < 0 => Err(std::io::Error::last_os_error()), + // SAFETY: a descriptor openat just returned belongs to + // nothing else. + fd => Ok(unsafe { OwnedFd::from_raw_fd(fd) }), + } + }; + let mut folder = open(libc::AT_FDCWD, c"/").map_err(|e| refused(format!("/: {e}")))?; + for component in path.components() { + let name = match component { + std::path::Component::RootDir => continue, + std::path::Component::Normal(name) => std::ffi::CString::new(name.as_bytes()) + .map_err(|_| refused(format!("{} is not a real path", path.display())))?, + _ => return Err(refused(format!("{} is not a real path", path.display()))), + }; + folder = + open(folder.as_raw_fd(), &name).map_err(|error| match error.raw_os_error() { + Some(libc::ELOOP | libc::ENOTDIR) => refused(format!( + "{} is reached through a link, so it is not the folder the bot was given", + path.display() + )), + _ => refused(format!("{}: {error}", path.display())), + })?; + } + Ok(folder) + } } /// The placeholders that running sandboxes hold for missing protected @@ -1079,7 +1147,7 @@ mod tests { assert!(profile.contains(&anchor(&key(&inner.join("state"))))); #[cfg(target_os = "linux")] assert!( - linux::args(&policy, false) + linux::args(&policy, false, &vec![3; policy.roots.len()]) .windows(3) .any(|w| w[0] == "--ro-bind" && w[1] == "/dev/null" @@ -1100,9 +1168,13 @@ mod tests { "(allow network-outbound (remote unix-socket (subpath (param \"{own_key}\"))))" ))); #[cfg(target_os = "linux")] - assert!(linux::args(&own, false).windows(3).any(|w| w[0] == "--bind" - && w[1] == base.join("own.sock").to_string_lossy() - && w[2] == socket.to_string_lossy())); + assert!( + linux::args(&own, false, &vec![3; own.roots.len()]) + .windows(3) + .any(|w| w[0] == "--bind" + && w[1] == base.join("own.sock").to_string_lossy() + && w[2] == socket.to_string_lossy()) + ); // Never another bot's, from the same folder. let folder_key = params .iter() @@ -1118,7 +1190,7 @@ mod tests { assert!(profile.find(&denied).unwrap() < profile.find(&allowed).unwrap()); #[cfg(target_os = "linux")] assert!( - linux::args(&own, false) + linux::args(&own, false, &vec![3; own.roots.len()]) .windows(2) .any(|w| w[0] == "--tmpfs" && w[1] == base.to_string_lossy()) ); diff --git a/tests/tool_contract.rs b/tests/tool_contract.rs index 34072e39d..a240b274a 100644 --- a/tests/tool_contract.rs +++ b/tests/tool_contract.rs @@ -722,6 +722,49 @@ async fn a_sandboxed_command_cannot_reach_the_daemon_socket() { std::fs::remove_dir_all(base).unwrap(); } +/// A folder a bot may write that something swapped for a link since is +/// refused, never bound where the link points: a bot's creator may write +/// the folder above it. +#[cfg(target_os = "linux")] +#[tokio::test] +async fn a_folder_swapped_for_a_link_is_not_followed() { + use agent_runtime::sandbox::{Daemon, Mode, Policy}; + if !sandboxes() { + return; + } + let base = std::fs::canonicalize(env!("CARGO_TARGET_TMPDIR")) + .unwrap() + .join(format!("agent-swap-{}", std::process::id())); + let (given, outside) = (base.join("creator/child"), base.join("outside")); + std::fs::create_dir_all(&given).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let policy = Policy::new( + Some(Mode::WorkspaceWrite), + Some(&given), + &[], + false, + &Daemon::default(), + ) + .unwrap() + .unwrap(); + std::fs::rename(&given, base.join("creator/moved")).unwrap(); + std::os::unix::fs::symlink(&outside, &given).unwrap(); + let tools = Registry::new("shell").unwrap(); + let prepared = tools + .prepare( + "shell", + &json!({"command": format!("touch {}/planted", given.display())}).to_string(), + ) + .unwrap(); + let ran = tools + .execute(prepared, &base, &[], Some(std::sync::Arc::new(policy))) + .await; + let error = ran.err().map(|error| error.code); + assert_eq!(error.as_deref(), Some("sandbox_unavailable")); + assert!(!outside.join("planted").exists()); + std::fs::remove_dir_all(base).unwrap(); +} + /// Read-only writes nothing, not even the temporary directory. #[cfg(target_os = "linux")] #[tokio::test] From fbd8aaada763cfcee18d423007127b4c4b28f196 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 05:48:36 +0000 Subject: [PATCH 11/23] Cap forks' client-answered tools and every sandboxed turn's folder; close a bot's socket with it A fork keeps its source's declared tools, so forking a bot whose tools a client answers is held to what the caller may pass on, as create is. A turn's folder becomes the bot's, so one given to any sandboxed bot must be the caller's to write. A deleted or expired bot's own socket is closed and removed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- src/sandbox.rs | 8 +++++++ src/server/mod.rs | 51 +++++++++++++++++++++++++++-------------- src/server/socket.rs | 21 +++++++++++++---- tests/test_approvals.py | 19 +++++++++++---- 4 files changed, 72 insertions(+), 27 deletions(-) diff --git a/src/sandbox.rs b/src/sandbox.rs index 66ee80d8f..52bcc5d5e 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -309,6 +309,13 @@ impl Daemon { .map(|capabilities| (capabilities.open)(bot)) .transpose() } + + /// Close bot `bot`'s own socket, once the bot is gone. + pub fn release(&self, bot: i64) { + if let Some(capabilities) = &self.capabilities { + (capabilities.close)(bot); + } + } } /// The folder of the bots' own sockets, which stays read-only in every @@ -318,6 +325,7 @@ pub struct Capabilities { pub folder: PathBuf, #[allow(clippy::type_complexity)] pub open: std::sync::Arc Result + Send + Sync>, + pub close: std::sync::Arc, } impl std::fmt::Debug for Capabilities { diff --git a/src/server/mod.rs b/src/server/mod.rs index 2f8dd4aef..529a9e070 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1695,6 +1695,15 @@ impl Service { *created_by_id = Some(me.bot_id); Ok(()) }; + // A tool a client answers is one the caller may pass on: one it has + // and gives the bots it makes, answered by the same client. Another + // could get a client to act for it. + let passes = |tool: &agent_runtime::tools::Declared| { + tool.answered_by.is_none() + || me.declared.iter().any(|own| { + own.inherit && own.name == tool.name && own.answered_by == tool.answered_by + }) + }; // The folders a request would give a bot, each of which the caller // must be able to write itself. let mut folders: Vec = Vec::new(); @@ -1708,17 +1717,6 @@ impl Service { .. } => { claim(created_by, created_by_id)?; - // A tool a client answers is one the caller may pass on: one - // it has and gives the bots it makes, answered by the same - // client. Another could get a client to act for it. - let passes = |tool: &agent_runtime::tools::Declared| { - tool.answered_by.is_none() - || me.declared.iter().any(|own| { - own.inherit - && own.name == tool.name - && own.answered_by == tool.answered_by - }) - }; if declare.iter().flatten().any(|tool| !passes(tool)) { return refuse("declare a tool a client answers that it can't pass on"); } @@ -1741,6 +1739,20 @@ impl Service { .store .op("inspect", move |db| db.inspect(&source)) .await?; + // A fork keeps its source's declared tools; those it may + // call are the caller's to pass on. + let callable = |tool: &&agent_runtime::tools::Declared| match allow { + Some(Some(names)) => names.contains(&tool.name), + _ => true, + }; + if source + .declared + .iter() + .filter(callable) + .any(|tool| !passes(tool)) + { + return refuse("fork a bot whose tools a client answers for it"); + } folders.extend(workspace.clone().or(source.workspace)); folders.extend(source.settings.writable); } @@ -1768,12 +1780,12 @@ impl Service { } (false, None) => *origin = Some(me.name.clone()), } - // A sandboxed bot with no folder of its own works in each - // turn's: one the caller gives it must be the caller's. + // A turn's folder is the sandboxed bot's to write, and stays + // its folder: one the caller gives it must be the caller's. if let Some(workspace) = workspace { let bot = bot.clone(); let target = self.store.op("inspect", move |db| db.inspect(&bot)).await?; - if target.sandboxed() && target.workspace.is_none() { + if target.sandboxed() { folders.push(workspace.clone()); } } @@ -2078,10 +2090,11 @@ impl Service { } let (store, hub, providers) = (self.store.clone(), self.hub.clone(), self.providers.clone()); + let daemon = self.registry.daemon().clone(); self.retention.spawn(async move { // A refusal (new work, a command still running) or a // deletion that raced it leaves the bot to a later look. - let _ = delete_pieces(store, hub, providers, bot, bot_id, true).await; + let _ = delete_pieces(store, hub, providers, daemon, bot, bot_id, true).await; }); } self.plan_expiry(overdue).await @@ -2561,8 +2574,10 @@ impl Service { }; let (store, hub, output) = (store.clone(), self.hub.clone(), output.clone()); let providers = self.providers.clone(); + let daemon = self.registry.daemon().clone(); self.retention.spawn(async move { - let result = delete_pieces(store, hub, providers, bot, bot_id, false).await; + let result = + delete_pieces(store, hub, providers, daemon, bot, bot_id, false).await; retention_reply(session, &output, id, result).await; }); Err(Error::new("deferred")) @@ -3594,6 +3609,7 @@ async fn delete_pieces( store: Store, hub: Hub, providers: Arc>, + daemon: agent_runtime::sandbox::Daemon, bot: String, bot_id: i64, expired: bool, @@ -3635,10 +3651,11 @@ async fn delete_pieces( } } // Its connections go with it, and a later bot of the - // same name starts on a fresh one. + // same name starts on a fresh one; so does its own socket. for provider in providers.values() { provider.forget(&bot); } + daemon.release(bot_id); // Followers learn the bot is gone; nothing durable remains to replay. let mut gone = json!({"event":"deleted","bot":bot,"durable":false}); if expired { diff --git a/src/server/socket.rs b/src/server/socket.rs index d968a33ae..2f3a62628 100644 --- a/src/server/socket.rs +++ b/src/server/socket.rs @@ -13,7 +13,8 @@ use tokio::net::{UnixListener, UnixStream}; const SOCKET_PATH: usize = 103; /// Each sandboxed bot's own socket, in a folder beside the daemon's: opened -/// the first time one of its commands runs, and kept while the daemon runs. +/// the first time one of its commands runs, and closed when the bot is +/// deleted. /// A connection on it is a session answered as that bot. `None` when the /// folder's sockets wouldn't fit a socket path. pub fn capabilities( @@ -30,17 +31,27 @@ pub fn capabilities( let runtime = tokio::runtime::Handle::current(); // The folder is made ready by the first bot to need it, once the // daemon owns its socket. - let opened = std::sync::Mutex::new(None::>); + type Opened = std::collections::HashMap; + let opened = std::sync::Arc::new(std::sync::Mutex::new(None::)); let (place, own) = (folder.clone(), socket.to_owned()); + let closing = opened.clone(); Some(agent_runtime::sandbox::Capabilities { folder, + close: std::sync::Arc::new(move |bot| { + let mut opened = closing.lock().unwrap_or_else(|e| e.into_inner()); + if let Some((path, accepting)) = opened.as_mut().and_then(|opened| opened.remove(&bot)) + { + accepting.abort(); + let _ = std::fs::remove_file(path); + } + }), open: std::sync::Arc::new(move |bot| { let mut opened = opened.lock().unwrap_or_else(|e| e.into_inner()); if opened.is_none() { prepare(&place, &own)?; } let opened = opened.get_or_insert_default(); - if let Some(path) = opened.get(&bot) { + if let Some((path, _)) = opened.get(&bot) { return Ok(path.clone()); } let path = place.join(bot.to_string()); @@ -51,13 +62,13 @@ pub fn capabilities( let _entered = runtime.enter(); UnixListener::from_std(listener)? }; - runtime.spawn(super::session::accept( + let accepting = runtime.spawn(super::session::accept( listener, sender.clone(), next.clone(), Some(bot), )); - opened.insert(bot, path.clone()); + opened.insert(bot, (path.clone(), accepting.abort_handle())); Ok(path) }), }) diff --git a/tests/test_approvals.py b/tests/test_approvals.py index e7ba69219..874d2b070 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -166,6 +166,9 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): turn = daemon.request('submit', bot='Carl', request_id='c', prompt='shell:true')['result']['turn'] self.assertEqual(daemon.finished(turn)['data']['status'], 'completed') (self.path / 'kid').mkdir() + act = {'name': 'act', 'description': 'Act.', 'parameters': {'type': 'object'}, 'answered_by': 'client'} + # A bot in its folder with a tool a client answers, which isn't Bob's. + daemon.request('create', bot='Dave', workspace=str(self.path), declare=[act]) child = dict(op='create', model='openai/synthetic-model', instructions='Kid.', tools=['echo']) requests = [ dict(op='bots'), @@ -179,9 +182,10 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): dict(op='delete', bot='Kid'), dict(op='fork', source='Alice', bot='Copy'), dict(op='fork', source='Alice', bot='Ask', allow=[], expire_ms=60000), - dict(child, bot='Hand', workspace=str(self.path / 'kid'), - declare=[{'name': 'act', 'description': 'Act.', 'parameters': {'type': 'object'}, - 'answered_by': 'client'}]), + dict(child, bot='Hand', workspace=str(self.path / 'kid'), declare=[act]), + dict(op='fork', source='Dave', bot='Tooled'), + dict(op='submit', bot='Bob', request_id='away', prompt='echo:hi', workspace=str(outside), + delivery='queue'), ] (self.path / 'requests.json').write_text(json.dumps(requests)) (self.path / 'probe.py').write_text( @@ -217,9 +221,9 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): # Nor reach another bot's, which would answer as that bot. self.assertFalse(probed['borrowed']) (bots, configure, answer, shutdown, kid, out, liar, delete_alice, delete_kid, copy, - ask, hand) = probed['answers'] + ask, hand, tooled, away) = probed['answers'] self.assertIn('Bob', [bot['name'] for bot in bots['result']['bots']]) - for refused in (configure, answer, shutdown, out, liar, delete_alice, copy, hand): + for refused in (configure, answer, shutdown, out, liar, delete_alice, copy, hand, tooled, away): self.assertEqual(refused['error'], 'not_permitted', refused) # What it makes is its own and no less sandboxed. self.assertEqual((kid['result']['created_by'], kid['result']['settings']['sandbox']), @@ -229,6 +233,11 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): self.assertEqual(ask['result']['created_by'], 'Bob') self.assertEqual(list(outside.iterdir()), []) self.assertEqual(daemon.request('inspect', bot='Bob')['result']['instructions'], 'Test agent.') + # A bot's own socket goes with it. + own = Path(f'{daemon.socket_path}.d') / str(carl) + self.assertTrue(own.is_socket()) + self.assertIn('result', daemon.request('delete', bot='Carl')) + self.assertFalse(own.exists()) def test_a_denial_is_the_result_and_the_turn_goes_on(self): client = self.gated() From 2b8dae0cee5a600a27dbe789405745c2685a5d09 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:01:03 +0000 Subject: [PATCH 12/23] Check sandbox roots by real path instead of pinning them; close bots' sockets off the runtime thread Pinned descriptors outlived bubblewrap into the command itself, which could reach the host's folders through them. Each confined call builds its policy anew, off the runtime thread, so a root that a link was swapped in for since is refused there instead. Closing a deleted bot's own socket takes a lock and unlinks a file, so it runs on a blocking worker. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- src/sandbox.rs | 104 +++++++++-------------------------------- src/server/mod.rs | 3 +- tests/tool_contract.rs | 36 ++++++-------- 3 files changed, 37 insertions(+), 106 deletions(-) diff --git a/src/sandbox.rs b/src/sandbox.rs index 52bcc5d5e..47f0f1b7a 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -340,6 +340,18 @@ impl std::fmt::Debug for Capabilities { /// folders at its top, a worktree's own git folder when it is inside the /// root, and the daemon's store and socket. fn root(path: &Path, daemon: &Daemon) -> Result { + // A root is kept by its real path. One reached through a link now had + // a link swapped in since, perhaps by a bot that may write the folder + // above it, and would carry the sandbox wherever the link points. + if real(path).is_some_and(|real| real != path) { + return fail_with( + "sandbox_unavailable", + format!( + "{} is reached through a link, so it is not the folder the bot was given", + path.display() + ), + ); + } let mut read_only = Vec::new(); let mut missing = Vec::new(); for name in PROTECTED { @@ -523,7 +535,6 @@ pub fn command( mod linux { use super::{Policy, depth}; use crate::{Error, Result}; - use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd}; use std::path::{Path, PathBuf}; /// Where a system's bubblewrap is installed. Never looked up on PATH: @@ -597,10 +608,8 @@ mod linux { /// bubblewrap's arguments for `policy`, as Codex builds them: the whole /// filesystem read-only, each root bound writable from the shallowest /// down with its read-only folders bound back over it, and what may not - /// be made held by an empty read-only folder in its place. Each root is - /// bound from `pinned`, the folder [`pin`] opened for it. - pub(super) fn args(policy: &Policy, detached: bool, pinned: &[RawFd]) -> Vec { - debug_assert_eq!(policy.roots.len(), pinned.len()); + /// be made held by an empty read-only folder in its place. + pub(super) fn args(policy: &Policy, detached: bool) -> Vec { let path = |path: &Path| path.to_string_lossy().into_owned(); // A new session keeps the command from typing into the daemon's // terminal (TIOCSTI). @@ -609,12 +618,8 @@ mod linux { args.push("--die-with-parent".into()); } args.extend(["--ro-bind", "/", "/", "--dev", "/dev"].map(String::from)); - for (root, folder) in policy.roots.iter().zip(pinned) { - args.extend([ - "--bind".into(), - format!("/proc/self/fd/{folder}"), - path(&root.path), - ]); + for root in &policy.roots { + args.extend(["--bind".into(), path(&root.path), path(&root.path)]); let mut kept: Vec<&PathBuf> = root.read_only.iter().collect(); kept.sort_by_key(|path| depth(path)); for kept in kept { @@ -668,75 +673,14 @@ mod linux { rest: &[&str], detached: bool, ) -> Result { - let pinned = policy - .roots - .iter() - .map(|root| pin(&root.path)) - .collect::>>()?; - let folders: Vec = pinned.iter().map(AsRawFd::as_raw_fd).collect(); let mut command = tokio::process::Command::new(checked()?); command - .args(args(policy, detached, &folders)) + .args(args(policy, detached)) .arg("--") .arg(program) .args(rest); - // SAFETY: fcntl is async-signal-safe and changes only the child's - // descriptors: bubblewrap inherits the pinned folders, and the - // daemon's other children never see them. The closure owns them, so - // they stay open until the command is dropped, after it spawns. - unsafe { - command.pre_exec(move || { - for folder in &pinned { - if libc::fcntl(folder.as_raw_fd(), libc::F_SETFD, 0) < 0 { - return Err(std::io::Error::last_os_error()); - } - } - Ok(()) - }); - } Ok(command) } - - /// A root, a real path, opened as itself for bubblewrap to bind: each - /// folder on the way is opened without following a link. A folder a bot - /// was given can sit in one its creator may write, and the creator can - /// swap a link in for it later; bound by its path, that link would carry - /// the sandbox wherever it points. Opened this way the swap is refused, - /// and what is bound is the folder that was opened, wherever it has - /// moved since. - pub(super) fn pin(path: &Path) -> Result { - use std::os::unix::ffi::OsStrExt; - let refused = |detail: String| Error::with("sandbox_unavailable", detail); - let open = |at: RawFd, name: &std::ffi::CStr| -> std::io::Result { - let flags = libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC; - // SAFETY: `name` is NUL-terminated and outlives the call, and `at` - // is open. - match unsafe { libc::openat(at, name.as_ptr(), flags) } { - fd if fd < 0 => Err(std::io::Error::last_os_error()), - // SAFETY: a descriptor openat just returned belongs to - // nothing else. - fd => Ok(unsafe { OwnedFd::from_raw_fd(fd) }), - } - }; - let mut folder = open(libc::AT_FDCWD, c"/").map_err(|e| refused(format!("/: {e}")))?; - for component in path.components() { - let name = match component { - std::path::Component::RootDir => continue, - std::path::Component::Normal(name) => std::ffi::CString::new(name.as_bytes()) - .map_err(|_| refused(format!("{} is not a real path", path.display())))?, - _ => return Err(refused(format!("{} is not a real path", path.display()))), - }; - folder = - open(folder.as_raw_fd(), &name).map_err(|error| match error.raw_os_error() { - Some(libc::ELOOP | libc::ENOTDIR) => refused(format!( - "{} is reached through a link, so it is not the folder the bot was given", - path.display() - )), - _ => refused(format!("{}: {error}", path.display())), - })?; - } - Ok(folder) - } } /// The placeholders that running sandboxes hold for missing protected @@ -1155,7 +1099,7 @@ mod tests { assert!(profile.contains(&anchor(&key(&inner.join("state"))))); #[cfg(target_os = "linux")] assert!( - linux::args(&policy, false, &vec![3; policy.roots.len()]) + linux::args(&policy, false) .windows(3) .any(|w| w[0] == "--ro-bind" && w[1] == "/dev/null" @@ -1176,13 +1120,9 @@ mod tests { "(allow network-outbound (remote unix-socket (subpath (param \"{own_key}\"))))" ))); #[cfg(target_os = "linux")] - assert!( - linux::args(&own, false, &vec![3; own.roots.len()]) - .windows(3) - .any(|w| w[0] == "--bind" - && w[1] == base.join("own.sock").to_string_lossy() - && w[2] == socket.to_string_lossy()) - ); + assert!(linux::args(&own, false).windows(3).any(|w| w[0] == "--bind" + && w[1] == base.join("own.sock").to_string_lossy() + && w[2] == socket.to_string_lossy())); // Never another bot's, from the same folder. let folder_key = params .iter() @@ -1198,7 +1138,7 @@ mod tests { assert!(profile.find(&denied).unwrap() < profile.find(&allowed).unwrap()); #[cfg(target_os = "linux")] assert!( - linux::args(&own, false, &vec![3; own.roots.len()]) + linux::args(&own, false) .windows(2) .any(|w| w[0] == "--tmpfs" && w[1] == base.to_string_lossy()) ); diff --git a/src/server/mod.rs b/src/server/mod.rs index 529a9e070..d1408f901 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -3655,7 +3655,8 @@ async fn delete_pieces( for provider in providers.values() { provider.forget(&bot); } - daemon.release(bot_id); + // Closing it takes a lock and unlinks a file: off the runtime thread. + let _ = tokio::task::spawn_blocking(move || daemon.release(bot_id)).await; // Followers learn the bot is gone; nothing durable remains to replay. let mut gone = json!({"event":"deleted","bot":bot,"durable":false}); if expired { diff --git a/tests/tool_contract.rs b/tests/tool_contract.rs index a240b274a..0a7dd18cd 100644 --- a/tests/tool_contract.rs +++ b/tests/tool_contract.rs @@ -724,7 +724,7 @@ async fn a_sandboxed_command_cannot_reach_the_daemon_socket() { /// A folder a bot may write that something swapped for a link since is /// refused, never bound where the link points: a bot's creator may write -/// the folder above it. +/// the folder above it. Each confined call builds its policy anew. #[cfg(target_os = "linux")] #[tokio::test] async fn a_folder_swapped_for_a_link_is_not_followed() { @@ -738,30 +738,20 @@ async fn a_folder_swapped_for_a_link_is_not_followed() { let (given, outside) = (base.join("creator/child"), base.join("outside")); std::fs::create_dir_all(&given).unwrap(); std::fs::create_dir_all(&outside).unwrap(); - let policy = Policy::new( - Some(Mode::WorkspaceWrite), - Some(&given), - &[], - false, - &Daemon::default(), - ) - .unwrap() - .unwrap(); + let policy = |workspace: &std::path::Path| { + Policy::new( + Some(Mode::WorkspaceWrite), + Some(workspace), + &[], + false, + &Daemon::default(), + ) + }; + assert!(policy(&given).is_ok()); std::fs::rename(&given, base.join("creator/moved")).unwrap(); std::os::unix::fs::symlink(&outside, &given).unwrap(); - let tools = Registry::new("shell").unwrap(); - let prepared = tools - .prepare( - "shell", - &json!({"command": format!("touch {}/planted", given.display())}).to_string(), - ) - .unwrap(); - let ran = tools - .execute(prepared, &base, &[], Some(std::sync::Arc::new(policy))) - .await; - let error = ran.err().map(|error| error.code); - assert_eq!(error.as_deref(), Some("sandbox_unavailable")); - assert!(!outside.join("planted").exists()); + let error = policy(&given).unwrap_err(); + assert_eq!(error.code, "sandbox_unavailable"); std::fs::remove_dir_all(base).unwrap(); } From 9e834d947be1c107eedc9191b47ca919c81bde2d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:23:10 +0000 Subject: [PATCH 13/23] Pass on only the client-answered tools the caller may call; keep inferred attribution stable across retries A caller whose allowed list leaves out a client-answered tool can't declare it for a bot it makes, nor have a new bot inherit it. A submit on a bot's own socket without from is named by the caller's name as origin, never by whatever turn it runs then, so a retry is the same request. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 11 +++++++---- src/server/mod.rs | 43 +++++++++++++++++++++++++++--------------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index 12cf79481..362e7667b 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2109,7 +2109,7 @@ A sandboxed bot's commands reach the daemon through the bot's own socket, never the daemon's: so `agent` and the clients built on it work in its shell, as that bot and only as far as a sandboxed bot may go. The daemon opens the socket, `SOCKET.d/BOT_ID` beside its own, the first time one of the bot's -commands runs, and keeps it while it runs. On Linux it is bound over the +commands runs, and closes it when the bot is deleted. On Linux it is bound over the daemon's socket in the sandbox, and the folder of the bots' sockets is hidden; on macOS Seatbelt denies the daemon's socket and that folder and allows the bot's own, network on or off. Either way the command's @@ -2120,14 +2120,17 @@ it: |---|---| | `configure`, `answer`, `tool_result`, `serve_approvals`, `renew_approvals`, `shutdown` | `not_permitted`: any of them would let a confined command undo its confinement | | `create`, `fork` | Made by the caller, whatever `created_by` names (another is `not_permitted`), so the bot inherits its sandbox; its workspace and `writable` folders, and a fork's source's, must be ones the caller may write. A fork with `allow: []`, which only answers, may be of any bot | -| `submit` | Reads as the caller's: from its running turn, else with its name as `origin`; `from` naming another bot is `not_permitted`. A workspace for a sandboxed bot with none of its own must be one the caller may write | +| `submit` | Reads as the caller's: `from` its turn when the request names it (the CLI does, from `AGENT_TURN`), else with its name as `origin`; `from` naming another bot is `not_permitted`. A workspace for a sandboxed bot must be one the caller may write | | `delete`, `prune`, `compact`, `interrupt` | The caller itself, or a bot it made; another is `not_permitted` | | Everything else | As on the daemon's socket | The folder of these sockets stays read-only in every root, so no bot can put its own listener in another's place. A tool a client answers, declared -for a bot the caller makes, must be one the caller has and passes on, -answered by the same client; another is `not_permitted`. A socket inside a writable folder +for a bot the caller makes or callable by a fork it makes, must be one the +caller has, may call and passes on, answered by the same client; another is +`not_permitted`, and one it may not call isn't inherited. A root reached +through a link, which something swapped in since, is `sandbox_unavailable`. +A socket inside a writable folder stays read-only too. On macOS the folders between a writable folder and anything kept inside it (a socket, the store, a worktree's git folder) can't be renamed, so nothing kept can be moved to a name its rule doesn't cover. A diff --git a/src/server/mod.rs b/src/server/mod.rs index d1408f901..42b7528a9 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1695,13 +1695,20 @@ impl Service { *created_by_id = Some(me.bot_id); Ok(()) }; - // A tool a client answers is one the caller may pass on: one it has - // and gives the bots it makes, answered by the same client. Another - // could get a client to act for it. + // A tool a client answers is one the caller may pass on: one it has, + // may call, and gives the bots it makes, answered by the same client. + // Another could get a client to act for it. + let callable = |own: &agent_runtime::tools::Declared| { + own.inherit + && me + .allowed + .as_ref() + .is_none_or(|allowed| allowed.contains(&own.name)) + }; let passes = |tool: &agent_runtime::tools::Declared| { tool.answered_by.is_none() || me.declared.iter().any(|own| { - own.inherit && own.name == tool.name && own.answered_by == tool.answered_by + callable(own) && own.name == tool.name && own.answered_by == tool.answered_by }) }; // The folders a request would give a bot, each of which the caller @@ -1720,6 +1727,17 @@ impl Service { if declare.iter().flatten().any(|tool| !passes(tool)) { return refuse("declare a tool a client answers that it can't pass on"); } + // Without its own list, a bot takes the tools its creator + // passes on: none a client answers that the caller may not call. + if declare.is_none() && me.declared.iter().any(|own| own.inherit && !passes(own)) { + *declare = Some( + me.declared + .iter() + .filter(|own| own.inherit && passes(own)) + .cloned() + .collect(), + ); + } folders.extend(workspace.clone()); folders.extend(settings.writable.iter().cloned()); } @@ -1769,17 +1787,12 @@ impl Service { if from.as_ref().is_some_and(|author| author.bot != me.name) { return refuse("send a message as another bot"); } - match (from.is_some(), me.running_turn) { - (true, _) => *origin = None, - (false, Some(turn)) => { - *from = Some(Author { - bot: me.name.clone(), - turn, - }); - *origin = None; - } - (false, None) => *origin = Some(me.name.clone()), - } + // Named once, as the request names it, so a retry of the + // same request is the same request. + *origin = match from { + Some(_) => None, + None => Some(me.name.clone()), + }; // A turn's folder is the sandboxed bot's to write, and stays // its folder: one the caller gives it must be the caller's. if let Some(workspace) = workspace { From 7c679356eddbf1e15c430956080895d9a088eefe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:32:28 +0000 Subject: [PATCH 14/23] Let a resent delete through the cap; a fork's callable tools follow its source's allowed list Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- src/server/mod.rs | 21 +++++++++++++++++---- tests/test_approvals.py | 5 ++++- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/src/server/mod.rs b/src/server/mod.rs index 42b7528a9..9825c9fb3 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1714,6 +1714,7 @@ impl Service { // The folders a request would give a bot, each of which the caller // must be able to write itself. let mut folders: Vec = Vec::new(); + let deleting = matches!(command, Command::Delete { .. }); match command { Command::Create { workspace, @@ -1761,7 +1762,10 @@ impl Service { // call are the caller's to pass on. let callable = |tool: &&agent_runtime::tools::Declared| match allow { Some(Some(names)) => names.contains(&tool.name), - _ => true, + _ => source + .allowed + .as_ref() + .is_none_or(|allowed| allowed.contains(&tool.name)), }; if source .declared @@ -1808,9 +1812,18 @@ impl Service { | Command::Compact { bot, .. } | Command::Interrupt { bot, .. } => { let bot = bot.clone(); - let target = self.store.op("inspect", move |db| db.inspect(&bot)).await?; - if target.bot_id != me.bot_id && target.created_by_id != Some(me.bot_id) { - return refuse("change a bot it didn't make"); + match self.store.op("inspect", move |db| db.inspect(&bot)).await { + Ok(target) + if target.bot_id != me.bot_id + && target.created_by_id != Some(me.bot_id) => + { + return refuse("change a bot it didn't make"); + } + Ok(_) => {} + // A resent delete of a bot already gone answers as a + // duplicate; nothing is there to change. + Err(error) if deleting && error.code == "bot_not_found" => {} + Err(error) => return Err(error), } } _ => {} diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 1cb45d088..f97d0c4d0 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -186,6 +186,7 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): dict(op='fork', source='Dave', bot='Tooled'), dict(op='submit', bot='Bob', request_id='away', prompt='echo:hi', workspace=str(outside), delivery='queue'), + dict(op='delete', bot='Kid'), ] (self.path / 'requests.json').write_text(json.dumps(requests)) (self.path / 'probe.py').write_text( @@ -221,7 +222,7 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): # Nor reach another bot's, which would answer as that bot. self.assertFalse(probed['borrowed']) (bots, configure, answer, shutdown, kid, out, liar, delete_alice, delete_kid, copy, - ask, hand, tooled, away) = probed['answers'] + ask, hand, tooled, away, again) = probed['answers'] self.assertIn('Bob', [bot['name'] for bot in bots['result']['bots']]) for refused in (configure, answer, shutdown, out, liar, delete_alice, copy, hand, tooled, away): self.assertEqual(refused['error'], 'not_permitted', refused) @@ -229,6 +230,8 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): self.assertEqual((kid['result']['created_by'], kid['result']['settings']['sandbox']), ('Bob', 'workspace-write')) self.assertIn('result', delete_kid) + # A delete resent for a bot already gone reaches the daemon's answer. + self.assertEqual(again['error'], 'bot_not_found', again) # A fork that may call nothing only answers. self.assertEqual(ask['result']['created_by'], 'Bob') self.assertEqual(list(outside.iterdir()), []) From b5cae0338ab44e8b49e1da68724606c3a099f301 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 06:47:29 +0000 Subject: [PATCH 15/23] Let a resent fork through the cap once its source is gone The store answers a resent fork before it looks up the source, so a client recovering a lost response still gets the fork it made. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- src/server/mod.rs | 47 ++++++++++++++++++++++++----------------- tests/test_approvals.py | 10 ++++++++- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/src/server/mod.rs b/src/server/mod.rs index ac10fcbcb..f86059538 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1754,29 +1754,38 @@ impl Service { // A fork that may call nothing touches nothing: an ask. if !matches!(allow, Some(Some(tools)) if tools.is_empty()) { let source = source.clone(); - let source = self + let source = match self .store .op("inspect", move |db| db.inspect(&source)) - .await?; - // A fork keeps its source's declared tools; those it may - // call are the caller's to pass on. - let callable = |tool: &&agent_runtime::tools::Declared| match allow { - Some(Some(names)) => names.contains(&tool.name), - _ => source - .allowed - .as_ref() - .is_none_or(|allowed| allowed.contains(&tool.name)), - }; - if source - .declared - .iter() - .filter(callable) - .any(|tool| !passes(tool)) + .await { - return refuse("fork a bot whose tools a client answers for it"); + Ok(source) => Some(source), + // A resent fork whose source is gone since answers + // as its duplicate; a new one finds no source. + Err(error) if error.code == "bot_not_found" => None, + Err(error) => return Err(error), + }; + if let Some(source) = source { + // A fork keeps its source's declared tools; those it may + // call are the caller's to pass on. + let callable = |tool: &&agent_runtime::tools::Declared| match allow { + Some(Some(names)) => names.contains(&tool.name), + _ => source + .allowed + .as_ref() + .is_none_or(|allowed| allowed.contains(&tool.name)), + }; + if source + .declared + .iter() + .filter(callable) + .any(|tool| !passes(tool)) + { + return refuse("fork a bot whose tools a client answers for it"); + } + folders.extend(workspace.clone().or(source.workspace)); + folders.extend(source.settings.writable); } - folders.extend(workspace.clone().or(source.workspace)); - folders.extend(source.settings.writable); } } Command::Submit { diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 2ce62d854..37f168fb5 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -187,6 +187,10 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): dict(op='submit', bot='Bob', request_id='away', prompt='echo:hi', workspace=str(outside), delivery='queue'), dict(op='delete', bot='Kid'), + dict(child, bot='Src', workspace=str(self.path / 'kid')), + dict(op='fork', source='Src', bot='Twin', request_id='twin'), + dict(op='delete', bot='Src'), + dict(op='fork', source='Src', bot='Twin', request_id='twin'), ] (self.path / 'requests.json').write_text(json.dumps(requests)) (self.path / 'probe.py').write_text( @@ -222,7 +226,7 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): # Nor reach another bot's, which would answer as that bot. self.assertFalse(probed['borrowed']) (bots, configure, answer, shutdown, kid, out, liar, delete_alice, delete_kid, copy, - ask, hand, tooled, away, again) = probed['answers'] + ask, hand, tooled, away, again, src, twin, delete_src, twin_again) = probed['answers'] self.assertIn('Bob', [bot['name'] for bot in bots['result']['bots']]) for refused in (configure, answer, shutdown, out, liar, delete_alice, copy, hand, tooled, away): self.assertEqual(refused['error'], 'not_permitted', refused) @@ -232,6 +236,10 @@ def test_a_sandboxed_bot_uses_the_daemon_as_itself_and_no_further(self): self.assertIn('result', delete_kid) # A delete resent for a bot already gone reaches the daemon's answer. self.assertEqual(again['error'], 'bot_not_found', again) + # So does a fork resent once its source is gone: the fork it made. + for answer in (src, twin, delete_src): + self.assertIn('result', answer) + self.assertEqual(twin_again['result']['bot_id'], twin['result']['bot_id'], twin_again) # A fork that may call nothing only answers. self.assertEqual(ask['result']['created_by'], 'Bob') self.assertEqual(list(outside.iterdir()), []) From e860f68ed909982c759cd6853be2ac097d4ddfb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 07:15:11 +0000 Subject: [PATCH 16/23] Sonny sandboxes leads and tasks by default; configure sets a bot's sandbox A lead in auto or ask mode is made, or set again when Sonny opens it, with Codex's workspace-write sandbox, the network on and its repository's git folder writable, so a commit doesn't ask. In full mode it runs unconfined. Every task is confined the same way in its own folder, whatever its lead's mode. The daemon's configure takes sandbox, writable and network, held no weaker than the creator's for a bot another bot made. A git folder given as a writable folder keeps its hooks and config read-only, as .git is kept, and its missing hooks may not be made. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 13 +++++-- sonny/README.md | 8 +++++ sonny/src/approvals.rs | 65 +++++++++++++++++++++++++++++++++++ sonny/src/main.rs | 19 ++++++++++- sonny/src/task.rs | 8 ++++- src/sandbox.rs | 76 +++++++++++++++++++++++++++++++++-------- src/server/mod.rs | 40 ++++++++++++++++++++++ src/store/db.rs | 39 +++++++++++++++++++++ tests/test_approvals.py | 34 ++++++++++++++++++ tests/test_served.py | 5 +++ 10 files changed, 288 insertions(+), 19 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index f55adc387..acbb8f473 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -1179,8 +1179,12 @@ since its history is kept in that family's encoding `approve: []` alone removes them, including the gates a bot took from the bot that made it: whoever configures a bot decides, as a person overriding one does, and bots it already made keep theirs. The gate is checked before -anything is written. It answers `{bot, bot_id, changed}`, `changed` naming the fields -(`model`, `instructions`, `gates`, `declared`) that differed; the same request again +anything is written. `sandbox`, with `writable` and `network` (as `create`'s +settings name them), replaces the bot's sandbox, and the two left out go back +to none and off; a bot another bot made keeps no weaker a sandbox than its +creator's, as when it was made, and a bot's own socket may not configure +at all. It answers `{bot, bot_id, changed}`, `changed` naming the fields +(`model`, `instructions`, `gates`, `declared`, `sandbox`) that differed; the same request again changes nothing, so a resend is harmless. A change is told to the bot's followers as a live `configured` event with the same fields, which is not kept, so a client that attaches later reads the bot as it is. It is refused with `bot_busy` @@ -2101,7 +2105,10 @@ read-only, and a missing `.git`, `.agents` or `.codex` may not be made: git runs hooks and config from there outside the sandbox, and agents trust what is there. The daemon's store and the files beside it stay read-only too. A protected folder that is a symlink refuses the call with -`sandbox_unavailable`, since the link could be swapped from inside. Network is +`sandbox_unavailable`, since the link could be swapped from inside. A git +folder given as a `writable` folder, so a bot may commit, keeps what git +runs read-only as `.git` does: its `hooks`, `config` and `config.worktree`, +and a repository's missing `hooks` may not be made. Network is off unless `network` is set. `danger-full-access`, or no `sandbox`, runs unconfined. diff --git a/sonny/README.md b/sonny/README.md index 5d06cfff1..9582f6d1c 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -237,6 +237,14 @@ off again never shows. now once it is idle. While the judge reviews a call the status row says `Reviewing approval request`, and a denied call is drawn as `✗ Request denied for …` with the judge's reason, in either view. + The mode sets the lead's sandbox too, as Codex's modes do: in `auto` + and `ask` its commands, writes and edits stay in its folder (and + `/tmp`), reach the network, and may commit to the folder's repository, + whose hooks and config stay read-only; a call that leaves the sandbox + waits for the verdict. In `full` the lead runs unconfined. Every task + is sandboxed the same way in its own folder, whatever the lead's mode. + A lead made before Sonny sandboxed leads is sandboxed when Sonny opens + it. On Linux sandboxes need bubblewrap. - `/approve` lists the calls the judge denied while Sonny watched, with the reason for each. Enter writes the call out whole, and a second Enter sends your approval of that exact call as your own message, so diff --git a/sonny/src/approvals.rs b/sonny/src/approvals.rs index 88492c80c..22b8285ad 100644 --- a/sonny/src/approvals.rs +++ b/sonny/src/approvals.rs @@ -67,6 +67,46 @@ pub fn gate(mode: &str, tools: &[String]) -> Map { } } +/// The sandbox fields, as `configure` takes them and `create`'s settings +/// hold them, for an agent working in `workspace`: Codex's +/// `workspace-write`, reaching the network, with the folder's repository +/// writable too so a commit runs without asking (its hooks and config stay +/// read-only). A lead in `full` mode runs unconfined, as Codex's full +/// access does; a task is always confined. +pub fn sandbox(mode: &str, lead: bool, workspace: &std::path::Path) -> Map { + let fields = match mode { + "full" if lead => json!({"sandbox": "danger-full-access"}), + _ => { + let writable: Vec = repository(workspace).into_iter().collect(); + json!({"sandbox": "workspace-write", "writable": writable, "network": true}) + } + }; + match fields { + Value::Object(fields) => fields, + _ => Map::new(), + } +} + +/// The git folder of the repository `dir` is in, which its linked +/// worktrees share. +pub fn repository(dir: &std::path::Path) -> Option { + crate::task::git( + dir, + &["rev-parse", "--path-format=absolute", "--git-common-dir"], + ) + .ok() + .filter(|path| !path.is_empty()) +} + +/// Whether `record`'s sandbox is the one `mode` gives it. +pub fn sandboxed_as(record: &Value, mode: &str, lead: bool) -> bool { + let wanted = match mode { + "full" if lead => "danger-full-access", + _ => "workspace-write", + }; + record["settings"]["sandbox"] == wanted +} + /// What a bot's gate may hold back: its own `tools`, and of the tools its /// client declared, those Sonny gates (`served::GATED`). pub fn gateable(tools: &[String], declared: &Value) -> Vec { @@ -150,6 +190,31 @@ pub fn regate(record: &Value, mode: &str) -> Option> { mod tests { use super::*; + #[test] + fn a_lead_is_confined_but_in_full_mode_and_a_task_always() { + let dir = std::env::temp_dir().join(format!("sonny-sandbox-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let dir = std::fs::canonicalize(&dir).unwrap(); + crate::task::git(&dir, &["init", "-q"]).unwrap(); + let git = dir.join(".git").display().to_string(); + let confined = json!({"sandbox": "workspace-write", "writable": [git], "network": true}); + for (mode, _) in MODES { + let fields = Value::Object(sandbox(mode, false, &dir)); + assert_eq!(fields, confined); + assert!(sandboxed_as(&json!({"settings": fields}), mode, false)); + } + assert_eq!(Value::Object(sandbox("auto", true, &dir)), confined); + let full = Value::Object(sandbox("full", true, &dir)); + assert_eq!(full, json!({"sandbox": "danger-full-access"})); + assert!(sandboxed_as(&json!({"settings": full}), "full", true)); + // A lead made before leads were confined is confined again. + assert!(!sandboxed_as(&json!({"settings": {}}), "auto", true)); + std::fs::remove_dir_all(&dir).unwrap(); + // Outside a repository, only its own folder. + let plain = std::env::temp_dir(); + assert_eq!(sandbox("ask", true, &plain)["writable"], json!([])); + } + #[test] fn modes_are_read_back_from_the_gates_they_make() { let tools: Vec = ["shell", "read", "wait", "history"] diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 2bfbe94b1..05f4ca369 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -870,6 +870,15 @@ async fn run() -> Result<(), String> { app.workspace = dir.display().to_string(); app.move_to = Some(app.workspace.clone()); } + // A lead made before Sonny confined leads, or whose sandbox is not its + // mode's, takes its mode's again. + if project.is_some() + && app.gating.is_none() + && let Some(mode) = app.mode + && !approvals::sandboxed_as(&bot, mode, true) + { + app.gating = Some(mode); + } app.project = project; // A new project's lead has no history yet: Sonny says hello over it. if fresh && options.side.is_none() { @@ -919,9 +928,12 @@ async fn create(client: &Client, options: &Options, bot: &str) -> Result<(), Str "instructions": instructions, "tools": DEFAULT_TOOLS, "declare": declare, "compaction_instructions": agent_client::policy::DEFAULT_COMPACTION_INSTRUCTIONS, "request_id": unique("tui-create")}); + // Confined from the start, as the mode it is made with confines it. + let mut settings = approvals::sandbox(approvals::MODES[0].0, true, &workspace); if let Some(ttl) = agent_client::policy::cache_ttl(&model) { - asked["settings"] = json!({"cache_ttl": ttl}); + settings.insert("cache_ttl".into(), json!(ttl)); } + asked["settings"] = Value::Object(settings); let tools = approvals::gateable(&DEFAULT_TOOLS.map(String::from), &declare); if let Value::Object(fields) = &mut asked { fields.extend(approvals::gate(approvals::MODES[0].0, &tools)); @@ -2455,6 +2467,11 @@ impl App { mode, &approvals::gateable(&self.tools, declared), )); + // A lead's sandbox goes with its mode. + if self.project.is_some() { + let workspace = std::path::Path::new(&self.workspace); + fields.extend(approvals::sandbox(mode, true, workspace)); + } } match self.client.request("configure", asked).await { Ok(_) => { diff --git a/sonny/src/task.rs b/sonny/src/task.rs index 4949f12d1..667e8863d 100644 --- a/sonny/src/task.rs +++ b/sonny/src/task.rs @@ -155,10 +155,16 @@ pub fn start(here: &Path, lead: &str, start: &Start) -> Result { let flags: Vec<&String> = flags.iter().collect(); let flags = with_defaults(&info, &flags, lead, config.sonny.task_effort); let flags: Vec<&String> = flags.iter().collect(); - let flags = match told(&place.dir, &flags) { + let mut flags = match told(&place.dir, &flags) { Ok(flags) => flags, Err(error) => return failed(&place, false, error), }; + // Every task works in a sandbox, whatever its lead's mode, and may + // commit to its repository. + flags.extend(["--sandbox", "workspace-write", "--network"].map(String::from)); + if let Some(git) = crate::approvals::repository(&place.dir) { + flags.extend(["--writable".to_owned(), git]); + } // A path in the lead's folder would have the task write there instead // of in its copy, where nothing keeps it apart from the lead's work. let brief = vec![start.brief.clone()]; diff --git a/src/sandbox.rs b/src/sandbox.rs index 47f0f1b7a..230c20646 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -35,6 +35,11 @@ const PROTECTED: [&str; 4] = [".git", ".agents", ".codex", ".aws"]; /// would be run by git outside the sandbox, and a new `.agents` read as /// the project's own settings. const PROTECTED_MISSING: [&str; 3] = [".git", ".agents", ".codex"]; +/// In a git folder given as a root, so a bot may commit, what git runs +/// stays read-only as it does under `.git`: its hooks, and the config that +/// can name other hooks or programs. Missing hooks are kept from being +/// made. +const GIT_PROTECTED: [&str; 3] = ["hooks", "config", "config.worktree"]; /// A folder the bot may write, and what inside it stays read-only. #[derive(Debug, Clone, PartialEq, Eq)] @@ -361,20 +366,7 @@ fn root(path: &Path, daemon: &Daemon) -> Result { continue; } match std::fs::symlink_metadata(&kept) { - // A symlink the sandbox may replace can't be held read-only: - // whatever is mounted over its target, the link itself can be - // swapped for a folder of the command's own. Codex refuses it - // too. - Ok(found) if found.file_type().is_symlink() => { - return fail_with( - "sandbox_unavailable", - format!( - "{} is a symlink in a folder the bot may write, so it can't be kept \ - read-only", - kept.display() - ), - ); - } + Ok(found) if found.file_type().is_symlink() => return linked(&kept), Ok(found) => { // A linked worktree's .git names its repository's folder for // it, which stays read-only too when the root holds it. @@ -391,6 +383,23 @@ fn root(path: &Path, daemon: &Daemon) -> Result { Err(_) => {} } } + if path.join("HEAD").is_file() { + for name in GIT_PROTECTED { + let kept = path.join(name); + if held(&kept) { + missing.push(kept); + continue; + } + match std::fs::symlink_metadata(&kept) { + Ok(found) if found.file_type().is_symlink() => return linked(&kept), + Ok(_) => read_only.push(kept), + // Only a repository's own folder runs hooks; a linked + // worktree's has none. + Err(_) if name == "hooks" && path.join("objects").is_dir() => missing.push(kept), + Err(_) => {} + } + } + } if let Some(store) = &daemon.store && store.starts_with(path) { @@ -415,6 +424,19 @@ fn root(path: &Path, daemon: &Daemon) -> Result { }) } +/// A symlink the sandbox may replace can't be held read-only: whatever is +/// mounted over its target, the link itself can be swapped for a folder of +/// the command's own. Codex refuses it too. +fn linked(kept: &Path) -> Result { + fail_with( + "sandbox_unavailable", + format!( + "{} is a symlink in a folder the bot may write, so it can't be kept read-only", + kept.display() + ), + ) +} + /// The store and the files SQLite and the daemon keep beside it. fn store_files(store: &Path) -> impl Iterator + '_ { ["", "-wal", "-shm", "-journal", ".owner-lock", ".sock"] @@ -998,6 +1020,32 @@ mod tests { )); } + #[test] + fn a_git_folder_given_as_a_root_keeps_what_git_runs() { + let base = std::env::temp_dir().join(format!("agent-sandbox-git-{}", std::process::id())); + let git = base.join(".git"); + std::fs::create_dir_all(git.join("objects")).unwrap(); + std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); + std::fs::write(git.join("config"), "").unwrap(); + let base = std::fs::canonicalize(&base).unwrap(); + let git = base.join(".git"); + let policy = Policy::new( + Some(Mode::WorkspaceWrite), + Some(&base), + &[git.to_str().unwrap().to_owned()], + false, + &Daemon::default(), + ) + .unwrap() + .unwrap(); + assert!(policy.can_write(&git.join("objects/ab/cdef"))); + assert!(policy.can_write(&git.join("index.lock"))); + assert!(!policy.can_write(&git.join("config"))); + // Missing hooks may not be made. + assert!(!policy.can_write(&git.join("hooks/pre-commit"))); + std::fs::remove_dir_all(&base).unwrap(); + } + #[test] fn the_deepest_root_decides_and_seatbelt_keeps_what_it_keeps() { let base = std::env::temp_dir().join(format!("agent-sandbox-unit-{}", std::process::id())); diff --git a/src/server/mod.rs b/src/server/mod.rs index f86059538..d6b4150d3 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -265,6 +265,12 @@ enum Command { /// The tools its client declares, replacing those it has; `[]` /// removes them. declare: Option>, + /// Its command sandbox, replacing the one it has, as `create`'s + /// settings name it: with `writable` and `network`, which + /// otherwise go back to none and off. + sandbox: Option, + writable: Option>, + network: Option, }, /// Summarize a bot's context with its compaction instructions before /// its next model call: a running turn's next round boundary, or an @@ -2667,10 +2673,39 @@ impl Service { approver, approve_expire_ms, declare, + sandbox, + writable, + network, } => { if effort.is_some() && model.is_none() { return fail_with("invalid_effort", "effort goes with model"); } + let sandbox = match sandbox { + Some(mode) => { + let mut settings = Settings { + sandbox: Some(mode), + writable: writable.unwrap_or_default(), + network: network.unwrap_or(false), + ..Settings::default() + }; + settings.validate()?; + // Checking for a sandbox and resolving folders touch + // the filesystem: off the runtime thread. + let settings = tokio::task::spawn_blocking(move || { + confine(&mut settings).map(|()| settings) + }) + .await + .map_err(|_| Error::new("sandbox_unavailable"))??; + Some((settings.sandbox, settings.writable, settings.network)) + } + None if writable.is_some() || network.is_some() => { + return fail_with( + "invalid_setting", + "writable and network go with sandbox", + ); + } + None => None, + }; let gate = match (&approve, &approver, approve_expire_ms) { (None, None, None) => None, (Some(tools), None, None) if tools.is_empty() => Some(None), @@ -2757,6 +2792,11 @@ impl Service { { changed.push("declared"); } + if let Some(sandbox) = sandbox + && db.set_sandbox(&bot, Some(id), sandbox)? + { + changed.push("sandbox"); + } Ok(json!({"bot":bot,"bot_id":id,"changed":changed})) }) diff --git a/src/store/db.rs b/src/store/db.rs index 62efbe728..6df3501a3 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -2244,6 +2244,45 @@ impl Database { )?; Ok(changed == 1) } + /// Replace a bot's sandbox: its mode, the folders it may write besides + /// its own and whether it reaches the network. A bot another bot made + /// keeps no weaker a sandbox than its creator's, as when it was made. + /// Whether anything changed. + pub fn set_sandbox( + &mut self, + name: &str, + bot_id: Option, + sandbox: (Option, Vec, bool), + ) -> Result { + let id = self.identity(name, bot_id)?; + let bot = self.inspect(name)?; + let mut settings = bot.settings; + (settings.sandbox, settings.writable, settings.network) = sandbox; + let creator = match bot.created_by_id { + Some(creator) => self + .conn + .query_row("SELECT settings FROM bots WHERE id=?", [creator], |r| { + r.get::<_, Option>(0) + }) + .optional()? + .map(|text| match text { + Some(text) => serde_json::from_str(&text), + None => Ok(Settings::default()), + }) + .transpose()?, + None => None, + }; + if let Some(creator) = &creator { + settings = settings.under(creator); + } + settings.validate()?; + let stored = settings.stored()?; + let changed = self.conn.execute( + "UPDATE bots SET settings=? WHERE id=? AND settings IS NOT ?", + params![stored, id, stored], + )?; + Ok(changed == 1) + } /// Bounded keyset pages. Full instructions remain available through inspect. /// A page of bots in name order after `after`. `name` keeps those whose /// name matches a GLOB pattern, which reads only its literal prefix's diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 37f168fb5..5a747e774 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -132,6 +132,40 @@ def test_a_sandboxed_bot_asks_only_to_leave_its_sandbox(self): self.assertEqual(sorted(p.name for p in outside.iterdir()), ['escaped', 'written']) self.assertTrue((self.path / 'notes.md').exists()) + @unittest.skipUnless(shutil.which('bwrap') or sys.platform == 'darwin', 'sandboxes need bubblewrap') + def test_a_bot_given_its_repository_commits_in_its_worktree_and_leaves_what_git_runs(self): + git = ['git', '-c', 'user.name=t', '-c', 'user.email=t@example.com'] + repo, tree = self.path / 'repo', self.path / 'tree' + repo.mkdir() + for args in (['init', '-q', '-b', 'main'], ['commit', '-q', '--allow-empty', '-m', 'one'], + ['worktree', 'add', '-q', '-b', 'task', str(tree)]): + subprocess.run(git + args, cwd=repo, check=True, capture_output=True) + config = (repo / '.git/config').read_text() + # An unsandboxed bot is confined once configured; one it makes keeps + # its sandbox whatever it is configured to. + client = self.client('shell') + bob = client.request('create', bot='Bob', workspace=str(tree))['result']['bot_id'] + configured = client.request('configure', bot='Bob', sandbox='workspace-write', + writable=[str(repo / '.git')], network=True)['result'] + self.assertEqual(configured['changed'], ['sandbox']) + client.request('create', bot='Kid', workspace=str(tree), created_by='Bob', created_by_id=bob) + client.request('configure', bot='Kid', sandbox='danger-full-access') + self.assertEqual(client.request('inspect', bot='Kid')['result']['settings']['sandbox'], 'workspace-write') + self.assertEqual(client.request('configure', bot='Bob', network=True)['error'], 'invalid_setting') + calls = [['shell', {'command': ' '.join(git) + ' commit -q --allow-empty -m two'}], + ['shell', {'command': f'echo exit 1 > {repo}/.git/hooks/pre-commit'}], + ['shell', {'command': f'echo "[core] hooksPath = /tmp" >> {repo}/.git/config'}]] + turn = client.request('submit', bot='Bob', request_id='t', + prompt='calls:' + json.dumps(calls))['result']['turn'] + self.assertEqual(client.finished(turn)['data']['status'], 'completed') + self.assertEqual(self.tool_output(client, 'shell-1')[1]['exit_code'], 0) + log = subprocess.run(['git', 'log', '--format=%s', 'task'], cwd=repo, capture_output=True, text=True) + self.assertEqual(log.stdout.split(), ['two', 'one']) + self.assertNotEqual(self.tool_output(client, 'shell-2')[1]['exit_code'], 0) + self.assertNotEqual(self.tool_output(client, 'shell-3')[1]['exit_code'], 0) + self.assertFalse((repo / '.git/hooks/pre-commit').exists()) + self.assertEqual((repo / '.git/config').read_text(), config) + @unittest.skipUnless(shutil.which('bwrap') or sys.platform == 'darwin', 'sandboxes need bubblewrap') def test_a_sandboxed_bot_without_a_gate_cannot_leave_its_sandbox(self): (ROOT / '.local').mkdir(exist_ok=True) diff --git a/tests/test_served.py b/tests/test_served.py index addfcf009..3c4ff6865 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -87,6 +87,11 @@ def test_a_lead_starts_a_task_in_a_worktree_of_its_own(self): # It has the tools every agent has, not its lead's own. record = self.daemon.request('inspect', bot='calc.fix')['result'] self.assertEqual([tool['name'] for tool in record['declared']], ['show_agent']) + # It works in a sandbox, reaching the network, and may commit to the + # repository its worktree shares. + self.assertEqual(record['settings']['sandbox'], 'workspace-write') + self.assertEqual(record['settings']['writable'], [str((repo / '.git').resolve())]) + self.assertTrue(record['settings']['network']) # The brief names the task's copy, not the lead's folder. turns = self.daemon.request('turns', bot='calc.fix')['result']['turns'] prompts = self.daemon.request('prompts', bot='calc.fix', turn=turns[0]['turn'])['result']['prompts'] From aa39c8b6b86a792ea5cf812b4f6d3dc5973ed4ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:52:36 +0000 Subject: [PATCH 17/23] Make a new project's lead sandboxed; refresh a moved lead's writable; keep per-worktree-config git folders read-only A project's lead is created with its sandbox, so a host that can't make one refuses the lead rather than running it unconfined until configure. A lead whose writable folders no longer match its folder's repository, as after the folder moves, takes the sandbox again. A git folder whose config turns on per-worktree config, or a repository's whose config is missing, stays read-only as a whole: git reads those files wherever they are made. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- client/src/project.rs | 7 +++++- sonny/src/approvals.rs | 50 +++++++++++++++++++++++++++--------------- sonny/src/main.rs | 7 ++++-- sonny/src/project.rs | 2 ++ src/sandbox.rs | 34 +++++++++++++++++++++++++++- 5 files changed, 78 insertions(+), 22 deletions(-) diff --git a/client/src/project.rs b/client/src/project.rs index fdf89559c..1c0d68852 100644 --- a/client/src/project.rs +++ b/client/src/project.rs @@ -396,8 +396,13 @@ pub async fn create( if let Some(effort) = effort { params["effort"] = json!(effort); } + // The client's settings, such as a sandbox, with the model's. + let mut settings = policy["settings"].as_object().cloned().unwrap_or_default(); if let Some(ttl) = crate::policy::cache_ttl(model) { - params["settings"] = json!({"cache_ttl": ttl}); + settings.insert("cache_ttl".into(), json!(ttl)); + } + if !settings.is_empty() { + params["settings"] = Value::Object(settings); } // The tools its client declares on the lead, from its first turn. if !policy["declare"].is_null() { diff --git a/sonny/src/approvals.rs b/sonny/src/approvals.rs index 22b8285ad..72f432a84 100644 --- a/sonny/src/approvals.rs +++ b/sonny/src/approvals.rs @@ -88,23 +88,28 @@ pub fn sandbox(mode: &str, lead: bool, workspace: &std::path::Path) -> Map Option { - crate::task::git( + let found = crate::task::git( dir, &["rev-parse", "--path-format=absolute", "--git-common-dir"], ) .ok() - .filter(|path| !path.is_empty()) + .filter(|path| !path.is_empty())?; + let real = std::fs::canonicalize(found).ok()?; + real.to_str().map(str::to_owned) } -/// Whether `record`'s sandbox is the one `mode` gives it. -pub fn sandboxed_as(record: &Value, mode: &str, lead: bool) -> bool { - let wanted = match mode { - "full" if lead => "danger-full-access", - _ => "workspace-write", - }; - record["settings"]["sandbox"] == wanted +/// Whether `record`'s sandbox is the one `fields` give it: its mode, its +/// writable folders, which a moved folder leaves stale, and its network. +pub fn sandboxed_as(record: &Value, fields: &Map) -> bool { + let fields = Value::Object(fields.clone()); + let settings = &record["settings"]; + let writable = |value: &Value| value["writable"].as_array().cloned().unwrap_or_default(); + settings["sandbox"] == fields["sandbox"] + && writable(settings) == writable(&fields) + && settings["network"].as_bool().unwrap_or(false) + == fields["network"].as_bool().unwrap_or(false) } /// What a bot's gate may hold back: its own `tools`, and of the tools its @@ -199,16 +204,25 @@ mod tests { let git = dir.join(".git").display().to_string(); let confined = json!({"sandbox": "workspace-write", "writable": [git], "network": true}); for (mode, _) in MODES { - let fields = Value::Object(sandbox(mode, false, &dir)); - assert_eq!(fields, confined); - assert!(sandboxed_as(&json!({"settings": fields}), mode, false)); + let fields = sandbox(mode, false, &dir); + assert_eq!(Value::Object(fields.clone()), confined); + assert!(sandboxed_as(&json!({"settings": fields}), &fields)); } assert_eq!(Value::Object(sandbox("auto", true, &dir)), confined); - let full = Value::Object(sandbox("full", true, &dir)); - assert_eq!(full, json!({"sandbox": "danger-full-access"})); - assert!(sandboxed_as(&json!({"settings": full}), "full", true)); - // A lead made before leads were confined is confined again. - assert!(!sandboxed_as(&json!({"settings": {}}), "auto", true)); + let full = sandbox("full", true, &dir); + assert_eq!( + Value::Object(full.clone()), + json!({"sandbox": "danger-full-access"}) + ); + let unconfined = json!({"settings": {"sandbox": "danger-full-access", "writable": [], "network": false}}); + assert!(sandboxed_as(&unconfined, &full)); + // A lead made before leads were confined is confined again, and + // one whose repository moved is given it where it is now. + let auto = sandbox("auto", true, &dir); + assert!(!sandboxed_as(&json!({"settings": {}}), &auto)); + let moved = json!({"settings": {"sandbox": "workspace-write", "writable": ["/gone/.git"], + "network": true}}); + assert!(!sandboxed_as(&moved, &auto)); std::fs::remove_dir_all(&dir).unwrap(); // Outside a repository, only its own folder. let plain = std::env::temp_dir(); diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 05f4ca369..274493697 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -871,11 +871,14 @@ async fn run() -> Result<(), String> { app.move_to = Some(app.workspace.clone()); } // A lead made before Sonny confined leads, or whose sandbox is not its - // mode's, takes its mode's again. + // mode's in the folder it now works in, takes its mode's again. if project.is_some() && app.gating.is_none() && let Some(mode) = app.mode - && !approvals::sandboxed_as(&bot, mode, true) + && !approvals::sandboxed_as( + &bot, + &approvals::sandbox(mode, true, std::path::Path::new(&app.workspace)), + ) { app.gating = Some(mode); } diff --git a/sonny/src/project.rs b/sonny/src/project.rs index 5de93d1a0..f29e2fab4 100644 --- a/sonny/src/project.rs +++ b/sonny/src/project.rs @@ -502,6 +502,8 @@ async fn make( "model": role.model, "tools": role.tools, "declare": crate::declared::tools(true), + // Confined from the start, as a new lead's mode confines it. + "settings": crate::approvals::sandbox(crate::approvals::MODES[0].0, true, dir), }); let copies = crate::settings::Settings::load().copies; agent_client::project::create(client, dir, &policy, &json!(TOOLS), picks, &copies, None).await diff --git a/src/sandbox.rs b/src/sandbox.rs index 230c20646..308ddfebd 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -383,7 +383,9 @@ fn root(path: &Path, daemon: &Daemon) -> Result { Err(_) => {} } } - if path.join("HEAD").is_file() { + if path.join("HEAD").is_file() && !git_folder_writable(path) { + read_only.push(path.to_owned()); + } else if path.join("HEAD").is_file() { for name in GIT_PROTECTED { let kept = path.join(name); if held(&kept) { @@ -424,6 +426,18 @@ fn root(path: &Path, daemon: &Daemon) -> Result { }) } +/// Whether a git folder may be written with its hooks and config kept: not +/// when its config is missing, which git would read once made, nor when it +/// turns on per-worktree config, whose files git reads wherever a worktree +/// keeps one, made or not. Any mention of that setting counts. +fn git_folder_writable(path: &Path) -> bool { + let repository = path.join("objects").is_dir(); + match std::fs::read_to_string(path.join("config")) { + Ok(config) => !config.to_ascii_lowercase().contains("worktreeconfig"), + Err(_) => !repository, + } +} + /// A symlink the sandbox may replace can't be held read-only: whatever is /// mounted over its target, the link itself can be swapped for a folder of /// the command's own. Codex refuses it too. @@ -1043,6 +1057,24 @@ mod tests { assert!(!policy.can_write(&git.join("config"))); // Missing hooks may not be made. assert!(!policy.can_write(&git.join("hooks/pre-commit"))); + // With per-worktree config on, whose files git reads wherever they + // are made, none of it is written. + std::fs::write( + git.join("config"), + "[extensions]\n\tworktreeConfig = true\n", + ) + .unwrap(); + let policy = Policy::new( + Some(Mode::WorkspaceWrite), + Some(&base), + &[git.to_str().unwrap().to_owned()], + false, + &Daemon::default(), + ) + .unwrap() + .unwrap(); + assert!(!policy.can_write(&git.join("objects/ab/cdef"))); + assert!(!policy.can_write(&git.join("worktrees/x/config.worktree"))); std::fs::remove_dir_all(&base).unwrap(); } From a59e69226bb97dbd5e06ef11b42302afb5f237c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 08:57:41 +0000 Subject: [PATCH 18/23] Know a git folder by its name, not by a HEAD the bot can move A bot that may write the folder could rename HEAD for one call and the next would build its sandbox without the hooks kept. The name, .git or NAME.git, is the settings' and the bot can't change it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 5 ++++- src/sandbox.rs | 22 +++++++++++----------- tests/test_approvals.py | 9 ++++++++- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index acbb8f473..41c41be6b 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2108,7 +2108,10 @@ protected folder that is a symlink refuses the call with `sandbox_unavailable`, since the link could be swapped from inside. A git folder given as a `writable` folder, so a bot may commit, keeps what git runs read-only as `.git` does: its `hooks`, `config` and `config.worktree`, -and a repository's missing `hooks` may not be made. Network is +and its missing `hooks` may not be made. It is known by its name, `.git` or +`NAME.git`, which the bot can't change, not by what is inside it. One whose +config is missing or turns on per-worktree config stays read-only as a +whole, since git reads those files wherever they are made. Network is off unless `network` is set. `danger-full-access`, or no `sandbox`, runs unconfined. diff --git a/src/sandbox.rs b/src/sandbox.rs index 308ddfebd..3614e91db 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -38,7 +38,8 @@ const PROTECTED_MISSING: [&str; 3] = [".git", ".agents", ".codex"]; /// In a git folder given as a root, so a bot may commit, what git runs /// stays read-only as it does under `.git`: its hooks, and the config that /// can name other hooks or programs. Missing hooks are kept from being -/// made. +/// made. A git folder is known by its name, `.git` or `NAME.git`, which the +/// bot can't change, never by what is inside it, which it can. const GIT_PROTECTED: [&str; 3] = ["hooks", "config", "config.worktree"]; /// A folder the bot may write, and what inside it stays read-only. @@ -383,9 +384,13 @@ fn root(path: &Path, daemon: &Daemon) -> Result { Err(_) => {} } } - if path.join("HEAD").is_file() && !git_folder_writable(path) { + let git = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.ends_with(".git")); + if git && !git_folder_writable(path) { read_only.push(path.to_owned()); - } else if path.join("HEAD").is_file() { + } else if git { for name in GIT_PROTECTED { let kept = path.join(name); if held(&kept) { @@ -395,9 +400,7 @@ fn root(path: &Path, daemon: &Daemon) -> Result { match std::fs::symlink_metadata(&kept) { Ok(found) if found.file_type().is_symlink() => return linked(&kept), Ok(_) => read_only.push(kept), - // Only a repository's own folder runs hooks; a linked - // worktree's has none. - Err(_) if name == "hooks" && path.join("objects").is_dir() => missing.push(kept), + Err(_) if name == "hooks" => missing.push(kept), Err(_) => {} } } @@ -431,11 +434,8 @@ fn root(path: &Path, daemon: &Daemon) -> Result { /// turns on per-worktree config, whose files git reads wherever a worktree /// keeps one, made or not. Any mention of that setting counts. fn git_folder_writable(path: &Path) -> bool { - let repository = path.join("objects").is_dir(); - match std::fs::read_to_string(path.join("config")) { - Ok(config) => !config.to_ascii_lowercase().contains("worktreeconfig"), - Err(_) => !repository, - } + std::fs::read_to_string(path.join("config")) + .is_ok_and(|config| !config.to_ascii_lowercase().contains("worktreeconfig")) } /// A symlink the sandbox may replace can't be held read-only: whatever is diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 5a747e774..8e829baf4 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -154,7 +154,12 @@ def test_a_bot_given_its_repository_commits_in_its_worktree_and_leaves_what_git_ self.assertEqual(client.request('configure', bot='Bob', network=True)['error'], 'invalid_setting') calls = [['shell', {'command': ' '.join(git) + ' commit -q --allow-empty -m two'}], ['shell', {'command': f'echo exit 1 > {repo}/.git/hooks/pre-commit'}], - ['shell', {'command': f'echo "[core] hooksPath = /tmp" >> {repo}/.git/config'}]] + ['shell', {'command': f'echo "[core] hooksPath = /tmp" >> {repo}/.git/config'}], + # What is inside the folder doesn't make it git's: one call + # moving HEAD leaves the next still kept from the hooks. + ['shell', {'command': f'mv {repo}/.git/HEAD {repo}/.git/HEAD.x'}], + ['shell', {'command': f'echo exit 1 > {repo}/.git/hooks/post-checkout; ' + f'mv {repo}/.git/HEAD.x {repo}/.git/HEAD'}]] turn = client.request('submit', bot='Bob', request_id='t', prompt='calls:' + json.dumps(calls))['result']['turn'] self.assertEqual(client.finished(turn)['data']['status'], 'completed') @@ -163,7 +168,9 @@ def test_a_bot_given_its_repository_commits_in_its_worktree_and_leaves_what_git_ self.assertEqual(log.stdout.split(), ['two', 'one']) self.assertNotEqual(self.tool_output(client, 'shell-2')[1]['exit_code'], 0) self.assertNotEqual(self.tool_output(client, 'shell-3')[1]['exit_code'], 0) + self.assertEqual(self.tool_output(client, 'shell-4')[1]['exit_code'], 0) self.assertFalse((repo / '.git/hooks/pre-commit').exists()) + self.assertFalse((repo / '.git/hooks/post-checkout').exists()) self.assertEqual((repo / '.git/config').read_text(), config) @unittest.skipUnless(shutil.which('bwrap') or sys.platform == 'darwin', 'sandboxes need bubblewrap') From c2115930af0e10d58aba47ef8993ffc27c9c778d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:02:06 +0000 Subject: [PATCH 19/23] Give a bot its repository's git folder only when it is named as one A separate git folder named otherwise isn't known to the daemon as git's, so its hooks and config wouldn't be kept; commits there ask instead. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- sonny/src/approvals.rs | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/sonny/src/approvals.rs b/sonny/src/approvals.rs index 72f432a84..19e3ae00d 100644 --- a/sonny/src/approvals.rs +++ b/sonny/src/approvals.rs @@ -88,7 +88,10 @@ pub fn sandbox(mode: &str, lead: bool, workspace: &std::path::Path) -> Map Option { let found = crate::task::git( dir, @@ -97,7 +100,9 @@ pub fn repository(dir: &std::path::Path) -> Option { .ok() .filter(|path| !path.is_empty())?; let real = std::fs::canonicalize(found).ok()?; - real.to_str().map(str::to_owned) + real.to_str() + .filter(|path| path.ends_with(".git")) + .map(str::to_owned) } /// Whether `record`'s sandbox is the one `fields` give it: its mode, its @@ -223,10 +228,21 @@ mod tests { let moved = json!({"settings": {"sandbox": "workspace-write", "writable": ["/gone/.git"], "network": true}}); assert!(!sandboxed_as(&moved, &auto)); - std::fs::remove_dir_all(&dir).unwrap(); - // Outside a repository, only its own folder. + // Outside a repository, only its own folder; with a git folder + // named otherwise, whose hooks the daemon wouldn't know to keep, + // commits ask. let plain = std::env::temp_dir(); assert_eq!(sandbox("ask", true, &plain)["writable"], json!([])); + let apart = dir.join("apart"); + let tree = dir.join("tree"); + std::fs::create_dir_all(&tree).unwrap(); + crate::task::git( + &tree, + &["init", "-q", "--separate-git-dir", apart.to_str().unwrap()], + ) + .unwrap(); + assert_eq!(sandbox("ask", true, &tree)["writable"], json!([])); + std::fs::remove_dir_all(&dir).unwrap(); } #[test] From a375dc932b2717f0d2ce48adf7d5360d0247e87e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:18:22 +0000 Subject: [PATCH 20/23] Make a tool-made project's lead with its gate; keep included config and submodules' git folders read-only A lead new_project makes is created with the auto gate as well as the sandbox, so opening it doesn't read it as full and unconfine it; and Sonny only ever confines a lead on opening it, never unconfines. A git folder whose config includes other files stays read-only as a whole, and its modules folder, which holds submodules' hooks and config, stays read-only within it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- client/src/project.rs | 27 +++++++++++++++++++++++++++ docs/RUST_PROTOTYPE.md | 11 ++++++----- sonny/src/main.rs | 5 +++-- sonny/src/project.rs | 15 ++++++++++++--- src/sandbox.rs | 35 +++++++++++++++++++++++++---------- 5 files changed, 73 insertions(+), 20 deletions(-) diff --git a/client/src/project.rs b/client/src/project.rs index 1c0d68852..3b3284a95 100644 --- a/client/src/project.rs +++ b/client/src/project.rs @@ -408,6 +408,10 @@ pub async fn create( if !policy["declare"].is_null() { params["declare"] = policy["declare"].clone(); } + // The client's gate, `create`'s approval fields. + if let (Value::Object(params), Some(gate)) = (&mut params, policy["gate"].as_object()) { + params.extend(gate.clone()); + } let record = match daemon.request("create", params).await { Ok(record) => record, // Made since the look above: as when it was there before. @@ -683,6 +687,29 @@ mod tests { ); assert!(create["instructions"].as_str().unwrap().ends_with("rules\n\nWhen this folder is a git repository, every task works in its own worktree, so tasks do not collide: only you work in this folder.")); std::fs::remove_dir_all(dir.parent().unwrap()).unwrap(); + // A client's gate and settings go with the lead it makes. + let dir = root("gated"); + let daemon = Fake::default(); + make( + &daemon, + &dir, + &json!({"instructions": "rules", "model": "alpha/role", + "gate": {"approve": ["shell"], "approver": "auto"}, + "settings": {"sandbox": "workspace-write", "network": true}}), + &Picks::default(), + ) + .unwrap(); + let calls = daemon.calls.lock().unwrap().clone(); + let create = &calls.iter().find(|(op, _)| op == "create").unwrap().1; + assert_eq!( + (&create["approve"], &create["approver"]), + (&json!(["shell"]), &json!("auto")) + ); + assert_eq!( + create["settings"], + json!({"sandbox": "workspace-write", "network": true}) + ); + std::fs::remove_dir_all(dir.parent().unwrap()).unwrap(); } #[test] diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index 41c41be6b..a9525f8fc 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2107,11 +2107,12 @@ is there. The daemon's store and the files beside it stay read-only too. A protected folder that is a symlink refuses the call with `sandbox_unavailable`, since the link could be swapped from inside. A git folder given as a `writable` folder, so a bot may commit, keeps what git -runs read-only as `.git` does: its `hooks`, `config` and `config.worktree`, -and its missing `hooks` may not be made. It is known by its name, `.git` or -`NAME.git`, which the bot can't change, not by what is inside it. One whose -config is missing or turns on per-worktree config stays read-only as a -whole, since git reads those files wherever they are made. Network is +runs read-only as `.git` does: its `hooks`, `config`, `config.worktree` and +submodules' `modules`, and missing `hooks` or `modules` may not be made. It is +known by its name, `.git` or `NAME.git`, which the bot can't change, not by +what is inside it. One whose config is missing, turns on per-worktree config +or includes other files stays read-only as a whole, since git reads those +files wherever they are made. Network is off unless `network` is set. `danger-full-access`, or no `sandbox`, runs unconfined. diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 274493697..764676e7c 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -871,10 +871,11 @@ async fn run() -> Result<(), String> { app.move_to = Some(app.workspace.clone()); } // A lead made before Sonny confined leads, or whose sandbox is not its - // mode's in the folder it now works in, takes its mode's again. + // mode's in the folder it now works in, takes its mode's again. Only + // ever confined this way: a lead is unconfined by the person's pick. if project.is_some() && app.gating.is_none() - && let Some(mode) = app.mode + && let Some(mode) = app.mode.filter(|mode| *mode != "full") && !approvals::sandboxed_as( &bot, &approvals::sandbox(mode, true, std::path::Path::new(&app.workspace)), diff --git a/sonny/src/project.rs b/sonny/src/project.rs index f29e2fab4..8952339a2 100644 --- a/sonny/src/project.rs +++ b/sonny/src/project.rs @@ -496,14 +496,23 @@ async fn make( picks: &agent_client::project::Picks, ) -> Result { let (instructions, _) = folder(dir, role)?; + // Gated and confined from the start, as a new lead's mode makes it, + // whoever opens it first. + let mode = crate::approvals::MODES[0].0; + let tools: Vec = match &role.tools { + Some(tools) => tools.clone(), + None => TOOLS.map(String::from).to_vec(), + }; + let declare = json!(crate::declared::tools(true)); + let gate = crate::approvals::gate(mode, &crate::approvals::gateable(&tools, &declare)); let policy = json!({ "instructions": instructions, "compaction_instructions": agent_client::policy::DEFAULT_COMPACTION_INSTRUCTIONS, "model": role.model, "tools": role.tools, - "declare": crate::declared::tools(true), - // Confined from the start, as a new lead's mode confines it. - "settings": crate::approvals::sandbox(crate::approvals::MODES[0].0, true, dir), + "declare": declare, + "gate": gate, + "settings": crate::approvals::sandbox(mode, true, dir), }); let copies = crate::settings::Settings::load().copies; agent_client::project::create(client, dir, &policy, &json!(TOOLS), picks, &copies, None).await diff --git a/src/sandbox.rs b/src/sandbox.rs index 3614e91db..c28ee4647 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -36,11 +36,11 @@ const PROTECTED: [&str; 4] = [".git", ".agents", ".codex", ".aws"]; /// the project's own settings. const PROTECTED_MISSING: [&str; 3] = [".git", ".agents", ".codex"]; /// In a git folder given as a root, so a bot may commit, what git runs -/// stays read-only as it does under `.git`: its hooks, and the config that -/// can name other hooks or programs. Missing hooks are kept from being -/// made. A git folder is known by its name, `.git` or `NAME.git`, which the +/// stays read-only as it does under `.git`: its hooks, the config that can +/// name other hooks or programs, and its submodules' git folders, which +/// hold their own. Missing hooks and submodules are kept from being made. A git folder is known by its name, `.git` or `NAME.git`, which the /// bot can't change, never by what is inside it, which it can. -const GIT_PROTECTED: [&str; 3] = ["hooks", "config", "config.worktree"]; +const GIT_PROTECTED: [&str; 4] = ["hooks", "config", "config.worktree", "modules"]; /// A folder the bot may write, and what inside it stays read-only. #[derive(Debug, Clone, PartialEq, Eq)] @@ -400,7 +400,7 @@ fn root(path: &Path, daemon: &Daemon) -> Result { match std::fs::symlink_metadata(&kept) { Ok(found) if found.file_type().is_symlink() => return linked(&kept), Ok(_) => read_only.push(kept), - Err(_) if name == "hooks" => missing.push(kept), + Err(_) if matches!(name, "hooks" | "modules") => missing.push(kept), Err(_) => {} } } @@ -431,11 +431,13 @@ fn root(path: &Path, daemon: &Daemon) -> Result { /// Whether a git folder may be written with its hooks and config kept: not /// when its config is missing, which git would read once made, nor when it -/// turns on per-worktree config, whose files git reads wherever a worktree -/// keeps one, made or not. Any mention of that setting counts. +/// turns on per-worktree config or includes other files, which git reads +/// wherever they are, made or not. Any mention of either counts. fn git_folder_writable(path: &Path) -> bool { - std::fs::read_to_string(path.join("config")) - .is_ok_and(|config| !config.to_ascii_lowercase().contains("worktreeconfig")) + std::fs::read_to_string(path.join("config")).is_ok_and(|config| { + let config = config.to_ascii_lowercase(); + !config.contains("worktreeconfig") && !config.contains("include") + }) } /// A symlink the sandbox may replace can't be held read-only: whatever is @@ -1055,8 +1057,9 @@ mod tests { assert!(policy.can_write(&git.join("objects/ab/cdef"))); assert!(policy.can_write(&git.join("index.lock"))); assert!(!policy.can_write(&git.join("config"))); - // Missing hooks may not be made. + // Missing hooks may not be made, nor a submodule's git folder. assert!(!policy.can_write(&git.join("hooks/pre-commit"))); + assert!(!policy.can_write(&git.join("modules/sub/hooks/pre-commit"))); // With per-worktree config on, whose files git reads wherever they // are made, none of it is written. std::fs::write( @@ -1075,6 +1078,18 @@ mod tests { .unwrap(); assert!(!policy.can_write(&git.join("objects/ab/cdef"))); assert!(!policy.can_write(&git.join("worktrees/x/config.worktree"))); + // As with config it includes, which may be anywhere. + std::fs::write(git.join("config"), "[include]\n\tpath = extra.conf\n").unwrap(); + let policy = Policy::new( + Some(Mode::WorkspaceWrite), + Some(&base), + &[git.to_str().unwrap().to_owned()], + false, + &Daemon::default(), + ) + .unwrap() + .unwrap(); + assert!(!policy.can_write(&git.join("extra.conf"))); std::fs::remove_dir_all(&base).unwrap(); } From 9d9f1db2af6dc29b6d189e9ba3e04aaf53b8b6ae Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:29:53 +0000 Subject: [PATCH 21/23] Keep git's commondir read-only in a git folder a bot may write A bot could make .git/commondir point at a folder of its own, so git outside the sandbox ran that folder's hooks and config. commondir is now kept like hooks, in the git folder and in each linked worktree's folder; a missing one may not be made on macOS, and on Linux, where a folder in its place would stop git, one the command makes is removed when it ends. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 9 ++++-- src/sandbox.rs | 69 ++++++++++++++++++++++++++++++++++++----- tests/test_approvals.py | 12 ++++++- 3 files changed, 79 insertions(+), 11 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index a9525f8fc..1c4906ea9 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2107,8 +2107,13 @@ is there. The daemon's store and the files beside it stay read-only too. A protected folder that is a symlink refuses the call with `sandbox_unavailable`, since the link could be swapped from inside. A git folder given as a `writable` folder, so a bot may commit, keeps what git -runs read-only as `.git` does: its `hooks`, `config`, `config.worktree` and -submodules' `modules`, and missing `hooks` or `modules` may not be made. It is +runs read-only as `.git` does: its `hooks`, `config`, `config.worktree`, +submodules' `modules`, and the `commondir` that would point git at another +folder's hooks and config, its own and each linked worktree's under +`worktrees`. Missing `hooks` or `modules` may not be made, nor a missing +`commondir` on macOS; on Linux, where a folder held in its place would stop +git, one the command makes is removed when it ends. So a sandboxed `git +worktree remove` of an existing worktree is refused. It is known by its name, `.git` or `NAME.git`, which the bot can't change, not by what is inside it. One whose config is missing, turns on per-worktree config or includes other files stays read-only as a whole, since git reads those diff --git a/src/sandbox.rs b/src/sandbox.rs index c28ee4647..5014a11ba 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -37,10 +37,13 @@ const PROTECTED: [&str; 4] = [".git", ".agents", ".codex", ".aws"]; const PROTECTED_MISSING: [&str; 3] = [".git", ".agents", ".codex"]; /// In a git folder given as a root, so a bot may commit, what git runs /// stays read-only as it does under `.git`: its hooks, the config that can -/// name other hooks or programs, and its submodules' git folders, which -/// hold their own. Missing hooks and submodules are kept from being made. A git folder is known by its name, `.git` or `NAME.git`, which the -/// bot can't change, never by what is inside it, which it can. -const GIT_PROTECTED: [&str; 4] = ["hooks", "config", "config.worktree", "modules"]; +/// name other hooks or programs, its submodules' git folders, which hold +/// their own, and `commondir`, which would have git take another folder's +/// hooks and config in its place. Missing hooks, submodules and `commondir` +/// are kept from being made. A git folder is known by its name, `.git` or +/// `NAME.git`, which the bot can't change, never by what is inside it, +/// which it can. +const GIT_PROTECTED: [&str; 5] = ["hooks", "config", "config.worktree", "modules", "commondir"]; /// A folder the bot may write, and what inside it stays read-only. #[derive(Debug, Clone, PartialEq, Eq)] @@ -50,6 +53,11 @@ pub struct Root { pub read_only: Vec, /// Paths inside the root that don't exist and may not be made. pub missing: Vec, + /// Paths inside the root that don't exist and may not be left made: + /// files git reads whenever they are there, so an empty folder held in + /// their place would stop git. On Linux, removed when the command ends; + /// elsewhere they are `missing`. + pub left: Vec, } /// What a sandboxed command may do: write its roots, and reach the network @@ -131,6 +139,7 @@ impl Policy { .read_only .iter() .chain(&root.missing) + .chain(&root.left) .any(|kept| path.starts_with(kept)) }) } @@ -360,6 +369,7 @@ fn root(path: &Path, daemon: &Daemon) -> Result { } let mut read_only = Vec::new(); let mut missing = Vec::new(); + let mut left = Vec::new(); for name in PROTECTED { let kept = path.join(name); if held(&kept) { @@ -401,9 +411,31 @@ fn root(path: &Path, daemon: &Daemon) -> Result { Ok(found) if found.file_type().is_symlink() => return linked(&kept), Ok(_) => read_only.push(kept), Err(_) if matches!(name, "hooks" | "modules") => missing.push(kept), + Err(_) if name == "commondir" && cfg!(target_os = "linux") => left.push(kept), + Err(_) if name == "commondir" => missing.push(kept), Err(_) => {} } } + // Each linked worktree's folder names the repository's folder for + // it the same way. + for entry in std::fs::read_dir(path.join("worktrees")) + .into_iter() + .flatten() + .flatten() + { + match entry.file_type() { + Ok(found) if found.is_symlink() => return linked(&entry.path()), + Ok(found) if found.is_dir() => {} + _ => continue, + } + let kept = entry.path().join("commondir"); + match std::fs::symlink_metadata(&kept) { + Ok(found) if found.file_type().is_symlink() => return linked(&kept), + Ok(_) => read_only.push(kept), + Err(_) if cfg!(target_os = "linux") => left.push(kept), + Err(_) => missing.push(kept), + } + } } if let Some(store) = &daemon.store && store.starts_with(path) @@ -426,6 +458,7 @@ fn root(path: &Path, daemon: &Daemon) -> Result { path: path.to_owned(), read_only, missing, + left, }) } @@ -744,8 +777,9 @@ fn held(path: &Path) -> bool { } /// What a running sandbox holds in place; dropping it lets the -/// placeholders go once no other sandbox holds them. -pub struct Hold(Vec); +/// placeholders go once no other sandbox holds them, and removes what the +/// command may not leave made. +pub struct Hold(Vec, Vec); /// Hold `policy`'s placeholders while its command runs, once any of them /// being removed is gone. @@ -778,7 +812,12 @@ pub async fn hold(policy: &Policy) -> Hold { }), } } - return Hold(paths); + let left = policy + .roots + .iter() + .flat_map(|root| root.left.iter().cloned()) + .collect(); + return Hold(paths, left); } } removed.await; @@ -808,12 +847,18 @@ impl Drop for Hold { } } } - if released.is_empty() { + let left = std::mem::take(&mut self.1); + if released.is_empty() && left.is_empty() { return; } // Off the runtime thread, and with no lock held: no sandbox takes // one up while it is marked as being removed. let remove = move || { + // Git never makes these in a repository's own folder: one there + // now is the command's. + for path in &left { + let _ = std::fs::remove_file(path); + } for path in &released { // Only an empty one: a placeholder, never what a command // outside the sandbox made there since. @@ -1043,6 +1088,8 @@ mod tests { std::fs::create_dir_all(git.join("objects")).unwrap(); std::fs::write(git.join("HEAD"), "ref: refs/heads/main\n").unwrap(); std::fs::write(git.join("config"), "").unwrap(); + std::fs::create_dir_all(git.join("worktrees/wt")).unwrap(); + std::fs::write(git.join("worktrees/wt/commondir"), "../..\n").unwrap(); let base = std::fs::canonicalize(&base).unwrap(); let git = base.join(".git"); let policy = Policy::new( @@ -1060,6 +1107,11 @@ mod tests { // Missing hooks may not be made, nor a submodule's git folder. assert!(!policy.can_write(&git.join("hooks/pre-commit"))); assert!(!policy.can_write(&git.join("modules/sub/hooks/pre-commit"))); + // Nor a `commondir`, which would have git take another folder's hooks + // and config, here or in a linked worktree's folder. + assert!(!policy.can_write(&git.join("commondir"))); + assert!(!policy.can_write(&git.join("worktrees/wt/commondir"))); + assert!(policy.can_write(&git.join("worktrees/wt/index.lock"))); // With per-worktree config on, whose files git reads wherever they // are made, none of it is written. std::fs::write( @@ -1261,6 +1313,7 @@ mod tests { Root { read_only: PROTECTED.iter().map(|name| path.join(name)).collect(), missing: Vec::new(), + left: Vec::new(), path: path.clone(), } }) diff --git a/tests/test_approvals.py b/tests/test_approvals.py index 8e829baf4..09d695c26 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -159,7 +159,10 @@ def test_a_bot_given_its_repository_commits_in_its_worktree_and_leaves_what_git_ # moving HEAD leaves the next still kept from the hooks. ['shell', {'command': f'mv {repo}/.git/HEAD {repo}/.git/HEAD.x'}], ['shell', {'command': f'echo exit 1 > {repo}/.git/hooks/post-checkout; ' - f'mv {repo}/.git/HEAD.x {repo}/.git/HEAD'}]] + f'mv {repo}/.git/HEAD.x {repo}/.git/HEAD'}], + # Nor may it point git at another folder's hooks and config. + ['shell', {'command': f'echo /tmp > {repo}/.git/commondir'}], + ['shell', {'command': f'echo /tmp > {repo}/.git/worktrees/tree/commondir'}]] turn = client.request('submit', bot='Bob', request_id='t', prompt='calls:' + json.dumps(calls))['result']['turn'] self.assertEqual(client.finished(turn)['data']['status'], 'completed') @@ -172,6 +175,13 @@ def test_a_bot_given_its_repository_commits_in_its_worktree_and_leaves_what_git_ self.assertFalse((repo / '.git/hooks/pre-commit').exists()) self.assertFalse((repo / '.git/hooks/post-checkout').exists()) self.assertEqual((repo / '.git/config').read_text(), config) + self.assertNotEqual(self.tool_output(client, 'shell-7')[1]['exit_code'], 0) + self.assertEqual((repo / '.git/worktrees/tree/commondir').read_text(), '../..\n') + # Linux can't keep it from being made, only removes it after. + deadline = time.monotonic() + 5 + while (repo / '.git/commondir').exists() and time.monotonic() < deadline: + time.sleep(0.05) + self.assertFalse((repo / '.git/commondir').exists()) @unittest.skipUnless(shutil.which('bwrap') or sys.platform == 'darwin', 'sandboxes need bubblewrap') def test_a_sandboxed_bot_without_a_gate_cannot_leave_its_sandbox(self): From 2d4f1090a4e5983aca978cfb544f71a286c28ff5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:38:36 +0000 Subject: [PATCH 22/23] Remove sandbox placeholders on a thread of their own A runtime shutting down drops blocking work not yet started, so a placeholder a sandboxed command held, such as /tmp/.git, could be left behind when its runtime ended, and Sonny then took folders under /tmp for a repository's. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- src/sandbox.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/sandbox.rs b/src/sandbox.rs index 5014a11ba..93dc50a64 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -852,7 +852,9 @@ impl Drop for Hold { return; } // Off the runtime thread, and with no lock held: no sandbox takes - // one up while it is marked as being removed. + // one up while it is marked as being removed. On a thread of its + // own, since a runtime shutting down drops blocking work not yet + // started, which would leave them in place. let remove = move || { // Git never makes these in a repository's own folder: one there // now is the command's. @@ -869,9 +871,21 @@ impl Drop for Hold { .retain(|held| !(held.removing && released.contains(&held.path))); REMOVED.notify_waiters(); }; - match tokio::runtime::Handle::try_current() { - Ok(runtime) => drop(runtime.spawn_blocking(remove)), - Err(_) => remove(), + type Job = std::sync::Mutex>>; + let run = |job: &Job| { + if let Some(remove) = job.lock().unwrap().take() { + remove(); + } + }; + let job: std::sync::Arc = + std::sync::Arc::new(std::sync::Mutex::new(Some(Box::new(remove)))); + let theirs = job.clone(); + if std::thread::Builder::new() + .name("sandbox-placeholders".into()) + .spawn(move || run(&theirs)) + .is_err() + { + run(&job); } } } From fd8bd938151be5547d1ff2002f99b3f45430ca1b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 28 Oct 2026 09:39:34 +0000 Subject: [PATCH 23/23] Remove only the git folder's own commondir after a command A linked worktree's folder could be swapped for a link to another repository's before the removal followed it. One without commondir was made since, and serves only a checkout the bot made. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013jkdGHSSeQctmSznfdNuRg --- docs/RUST_PROTOTYPE.md | 6 +++--- src/sandbox.rs | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index 1c4906ea9..6eca85c18 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -2110,9 +2110,9 @@ folder given as a `writable` folder, so a bot may commit, keeps what git runs read-only as `.git` does: its `hooks`, `config`, `config.worktree`, submodules' `modules`, and the `commondir` that would point git at another folder's hooks and config, its own and each linked worktree's under -`worktrees`. Missing `hooks` or `modules` may not be made, nor a missing -`commondir` on macOS; on Linux, where a folder held in its place would stop -git, one the command makes is removed when it ends. So a sandboxed `git +`worktrees`. Missing `hooks` or `modules` may not be made, nor the git +folder's own missing `commondir` on macOS; on Linux, where a folder held in +its place would stop git, one the command makes is removed when it ends. So a sandboxed `git worktree remove` of an existing worktree is refused. It is known by its name, `.git` or `NAME.git`, which the bot can't change, not by what is inside it. One whose config is missing, turns on per-worktree config diff --git a/src/sandbox.rs b/src/sandbox.rs index 93dc50a64..2e181c59a 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -53,7 +53,7 @@ pub struct Root { pub read_only: Vec, /// Paths inside the root that don't exist and may not be made. pub missing: Vec, - /// Paths inside the root that don't exist and may not be left made: + /// Paths right under the root that don't exist and may not be left made: /// files git reads whenever they are there, so an empty folder held in /// their place would stop git. On Linux, removed when the command ends; /// elsewhere they are `missing`. @@ -417,7 +417,10 @@ fn root(path: &Path, daemon: &Daemon) -> Result { } } // Each linked worktree's folder names the repository's folder for - // it the same way. + // it the same way. One without it was made since, and serves only a + // checkout the bot made to name it. Nothing this deep is removed + // after a command, as the root's own `commondir` is: its folder + // could be swapped for a link elsewhere before the removal follows. for entry in std::fs::read_dir(path.join("worktrees")) .into_iter() .flatten() @@ -432,8 +435,7 @@ fn root(path: &Path, daemon: &Daemon) -> Result { match std::fs::symlink_metadata(&kept) { Ok(found) if found.file_type().is_symlink() => return linked(&kept), Ok(_) => read_only.push(kept), - Err(_) if cfg!(target_os = "linux") => left.push(kept), - Err(_) => missing.push(kept), + Err(_) => {} } } }