Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
f6de576
Daemon: sandbox a bot's commands, writes and edits as Codex does
claude Oct 28, 2026
8a219bc
Sandbox: placeholders removed with no lock held, confined writes take…
claude Oct 28, 2026
0760bd8
Sandbox: probe off the runtime thread for bots from before a restart;…
claude Oct 28, 2026
00ae9ae
Sandbox: deny the daemon's socket to sandboxed commands, as Codex doe…
claude Oct 28, 2026
1782065
Sandbox: keep a daemon socket inside a writable folder from being ren…
claude Oct 28, 2026
8349f5a
Sandbox: on macOS, anchor the folders above every kept path, not only…
claude Oct 28, 2026
e588ccb
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 28, 2026
dd51373
Sandbox: a sandboxed bot reaches the daemon through its own socket, c…
claude Oct 28, 2026
7c7bad8
Sandbox: hide the other bots' sockets; a bot made from a sandbox gets…
claude Oct 28, 2026
23972e6
Measurements: a matched screen for the sandboxed bots' own sockets
claude Oct 28, 2026
3ad1eca
Merge remote-tracking branch 'origin/claude/project-thread-6zwinw' in…
claude Oct 28, 2026
bfe1141
Merge main; lay out the tool contract's own socket as the daemon does
claude Oct 28, 2026
4b0e9bb
Bind each sandbox root by the folder opened for it
claude Oct 28, 2026
fbd8aaa
Cap forks' client-answered tools and every sandboxed turn's folder; c…
claude Oct 28, 2026
2b8dae0
Check sandbox roots by real path instead of pinning them; close bots'…
claude Oct 28, 2026
159f4d7
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 28, 2026
c58f65b
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 28, 2026
9e834d9
Pass on only the client-answered tools the caller may call; keep infe…
claude Oct 28, 2026
7c67935
Let a resent delete through the cap; a fork's callable tools follow i…
claude Oct 28, 2026
56ce5eb
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 28, 2026
b5cae03
Let a resent fork through the cap once its source is gone
claude Oct 28, 2026
4838d0b
Merge the merged #276 branch's history; main already holds its changes
claude Oct 28, 2026
e860f68
Sonny sandboxes leads and tasks by default; configure sets a bot's sa…
claude Oct 28, 2026
aa39c8b
Make a new project's lead sandboxed; refresh a moved lead's writable;…
claude Oct 28, 2026
a59e692
Know a git folder by its name, not by a HEAD the bot can move
claude Oct 28, 2026
c211593
Give a bot its repository's git folder only when it is named as one
claude Oct 28, 2026
a375dc9
Make a tool-made project's lead with its gate; keep included config a…
claude Oct 28, 2026
9d9f1db
Keep git's commondir read-only in a git folder a bot may write
claude Oct 28, 2026
2d4f109
Remove sandbox placeholders on a thread of their own
claude Oct 28, 2026
fd8bd93
Remove only the git folder's own commondir after a command
claude Oct 28, 2026
1c74670
Merge main into the sandbox-by-default branch
claude Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion client/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -402,13 +402,41 @@ 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() {
params["declare"] = policy["declare"].clone();
}
// The client's gate, `create`'s approval fields, for the tools the lead
// has: the client may not know which its model is given.
if let (Value::Object(params), Some(gate)) = (&mut params, policy["gate"].as_object()) {
let has = |name: &str| {
params["tools"]
.as_array()
.into_iter()
.flatten()
.any(|tool| tool == name)
|| (params.get("declare").and_then(Value::as_array))
.into_iter()
.flatten()
.any(|tool| tool["name"] == name)
};
let mut gate = gate.clone();
if let Some(Value::Array(approve)) = gate.get_mut("approve") {
approve.retain(|tool| tool.as_str().is_some_and(has));
if approve.is_empty() {
gate.remove("approver");
}
}
params.extend(gate);
}
let record = match daemon.request("create", params).await {
Ok(record) => record,
// Made since the look above: as when it was there before.
Expand Down Expand Up @@ -688,6 +716,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]
Expand Down
22 changes: 19 additions & 3 deletions docs/RUST_PROTOTYPE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1184,8 +1184,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`
Expand Down Expand Up @@ -2106,7 +2110,19 @@ 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`, `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 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
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.

Expand Down
8 changes: 8 additions & 0 deletions sonny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
95 changes: 95 additions & 0 deletions sonny/src/approvals.rs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,56 @@ pub fn gate(mode: &str, tools: &[String]) -> Map<String, Value> {
}
}

/// 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<String, Value> {
let fields = match mode {
"full" if lead => json!({"sandbox": "danger-full-access"}),
_ => {
let writable: Vec<String> = 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, by its real path as the daemon keeps it. Only one
/// named as the daemon knows a git folder, `.git` or `NAME.git`: in one
/// named otherwise, such as a separate git folder, its hooks and config
/// would not be kept, so a commit there asks instead.
pub fn repository(dir: &std::path::Path) -> Option<String> {
let found = crate::task::git(
dir,
&["rev-parse", "--path-format=absolute", "--git-common-dir"],
)
.ok()
.filter(|path| !path.is_empty())?;
let real = std::fs::canonicalize(found).ok()?;
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
/// writable folders, which a moved folder leaves stale, and its network.
pub fn sandboxed_as(record: &Value, fields: &Map<String, Value>) -> 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
/// client declared, those Sonny gates (`served::GATED`).
pub fn gateable(tools: &[String], declared: &Value) -> Vec<String> {
Expand Down Expand Up @@ -150,6 +200,51 @@ pub fn regate(record: &Value, mode: &str) -> Option<Map<String, Value>> {
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 = 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 = 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));
// 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]
fn modes_are_read_back_from_the_gates_they_make() {
let tools: Vec<String> = ["shell", "read", "wait", "history"]
Expand Down
23 changes: 22 additions & 1 deletion sonny/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,19 @@ 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 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.filter(|mode| *mode != "full")
&& !approvals::sandboxed_as(
&bot,
&approvals::sandbox(mode, true, std::path::Path::new(&app.workspace)),
)
{
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() {
Expand Down Expand Up @@ -921,9 +934,12 @@ async fn create(client: &Client, options: &Options, bot: &str) -> Result<(), Str
"instructions": instructions, "tools": 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: Vec<String> = tools.into_iter().map(String::from).collect();
let tools = approvals::gateable(&tools, &declare);
if let Value::Object(fields) = &mut asked {
Expand Down Expand Up @@ -2502,6 +2518,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));
Comment thread
lydakis marked this conversation as resolved.
}
}
match self.client.request("configure", asked).await {
Ok(_) => {
Expand Down
21 changes: 20 additions & 1 deletion sonny/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -495,12 +495,31 @@ async fn make(
picks: &agent_client::project::Picks,
) -> Result<Value, String> {
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<String> = match &role.tools {
Some(tools) => tools.clone(),
// The tools the client will give it; one the gate names that the
// lead doesn't get is left out there.
None => {
let model = picks.model.as_deref().or(role.model.as_deref());
agent_client::default_tools(client.providers(), model.unwrap_or(""), picks.web_search)
.into_iter()
.map(String::from)
.collect()
}
};
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),
"declare": declare,
"gate": gate,
"settings": crate::approvals::sandbox(mode, true, dir),
});
let copies = crate::settings::Settings::load().copies;
agent_client::project::create(
Expand Down
8 changes: 7 additions & 1 deletion sonny/src/task.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,10 +171,16 @@ pub fn start(here: &Path, lead: &str, start: &Start) -> Result<String, String> {
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()];
Expand Down
Loading
Loading