Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
30 changes: 29 additions & 1 deletion client/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
//! lead_profile = "my-lead" # the role a new lead starts in, not lead
//! task_profile = "worker" # what every other agent is told, not task
//! suggest = false # no guess at the next message, on by default
//! unsandboxed = ["mcpx"] # commands run outside agents' sandboxes
//!
//! [sonny.mcp_servers.linear] # an MCP server Sonny's agents reach
//! url = "https://mcp.linear.app/mcp"
Expand Down Expand Up @@ -80,6 +81,10 @@ pub struct Sonny {
/// The profile every other agent Sonny starts is told, in place of
/// `task`.
pub task_profile: Option<String>,
/// Commands, by their first words, that its sandboxed agents run
/// outside their sandboxes without asking, as Codex's rules that allow
/// a command prefix do: a tool with a daemon of its own, such as mcpx.
pub unsandboxed: Vec<String>,
/// The MCP servers Sonny's agents reach, by name.
pub mcp_servers: BTreeMap<String, McpServer>,
/// Whether a copy of the agent guesses the person's next message after
Expand All @@ -94,6 +99,7 @@ impl Default for Sonny {
task_effort: None,
lead_profile: None,
task_profile: None,
unsandboxed: Vec::new(),
mcp_servers: BTreeMap::new(),
suggest: true,
}
Expand Down Expand Up @@ -312,6 +318,7 @@ fn parse(text: &str) -> Result<Config, String> {
"lead_profile",
"task_profile",
"suggest",
"unsandboxed",
],
_ => return Err(format!("unknown table [{name}]")),
};
Expand All @@ -335,6 +342,18 @@ fn parse(text: &str) -> Result<Config, String> {
.ok_or_else(|| format!("{name}.{key} must be true or false"))?;
continue;
}
if (name.as_str(), key.as_str()) == ("sonny", "unsandboxed") {
let invalid = || format!("{name}.{key} must be a list of commands");
let commands = value.as_array().ok_or_else(invalid)?;
config.sonny.unsandboxed = commands
.iter()
.map(|command| match command.as_str() {
Some(command) if !command.trim().is_empty() => Ok(command.to_owned()),
_ => Err(invalid()),
})
.collect::<Result<_, _>>()?;
continue;
}
let value = string(&format!("{name}.{key}"), value)?;
// The daemon judges models and levels when the agents are made.
*match (name.as_str(), key.as_str()) {
Expand Down Expand Up @@ -493,14 +512,15 @@ mod tests {
fn the_file_sets_what_it_names_and_refuses_the_rest() {
assert_eq!(parse("").unwrap(), Config::default());
let config = parse(
"# mine\n[judge]\nmodel = \"openai/luna\"\n\n[sonny]\nlead_effort = \"medium\"\ntask_effort = \"high\"\n",
"# mine\n[judge]\nmodel = \"openai/luna\"\n\n[sonny]\nlead_effort = \"medium\"\ntask_effort = \"high\"\nunsandboxed = [\"mcpx\", \"docker compose\"]\n",
)
.unwrap();
assert_eq!(config.judge.model.as_deref(), Some("openai/luna"));
assert_eq!(config.judge.effort, None);
assert_eq!(config.sonny.lead_effort.as_deref(), Some("medium"));
assert_eq!(config.sonny.task_effort.as_deref(), Some("high"));
assert_eq!(config.sonny.lead_profile, None);
assert_eq!(config.sonny.unsandboxed, ["mcpx", "docker compose"]);
assert_eq!(config.web_search, WebSearch::Live);
let roles =
parse("[sonny]\nlead_profile = \"my-lead\"\ntask_profile = \"worker\"").unwrap();
Expand Down Expand Up @@ -530,6 +550,14 @@ mod tests {
("[sonny]\neffort = \"low\"", "unknown key sonny.effort"),
("[judge]\neffort = 3", "judge.effort must be a string"),
("[judge]\nmodel = \"\"", "judge.model must not be empty"),
(
"[sonny]\nunsandboxed = \"mcpx\"",
"sonny.unsandboxed must be a list of commands",
),
(
"[sonny]\nunsandboxed = [\" \"]",
"sonny.unsandboxed must be a list of commands",
),
] {
assert_eq!(parse(text).unwrap_err(), error, "{text}");
}
Expand Down
3 changes: 2 additions & 1 deletion docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -168,7 +168,8 @@ identity is `bot_not_found`.
`--note-turns`, `--compact-at`, `--compact-keep`, `--compact-tokens`, `--keep-turns`,
`--approval-hold`, `--max-output-tokens`, `--keep-warm`, `--cache-ttl`,
`--sandbox`, `--writable` (repeatable, made absolute from the current
folder), `--network` and `--hide-senders`; they are not daemon options, and an existing bot
folder), `--network`, `--hide-senders` and `--unsandboxed` (repeatable: a
command's first words, such as `mcpx`); 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
Expand Down
25 changes: 25 additions & 0 deletions docs/DAEMON_MEASUREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5692,3 +5692,28 @@ Store jobs per turn did not change (7.5 on both). Every change is within the
gate's 15%. The benches' calls are short, so `tool_started` takes its old path:
the new parse runs only for a write or edit past the 2 KiB preview, once, on
arguments the daemon already holds, as a cut patch's parse already did.

## Commands a client trusts leave the sandbox (#309)

A sandboxed bot may now name commands, `unsandboxed`, that run outside its
sandbox without asking. Every shell call of a sandboxed bot looks at that
list; only a non-empty list parses the command. Bots without a sandbox, as
in every bench, skip the look. The Perf workflow's matched screen, base main
7cb4221 against the PR head 9d5c0fd, on the GitHub runner (2026-10-29):

| Bench | Measure | Base | Head | Change |
| --- | --- | ---: | ---: | ---: |
| admission | lone submit CPU, ms | 29.951 | 30.049 | +0.3% |
| admission | burst submit p99, ms | 4.027 | 3.99 | -0.9% |
| completion | release to terminal p99, ms | 9.28 | 8.892 | -4.2% |
| footprint | idle RSS, MiB | 18.949 | 18.824 | -0.7% |
| footprint | per idle bot, KiB | 1.454 | 1.45 | -0.3% |
| footprint | RSS after a turn each, MiB | 28.65 | 28.865 | +0.7% |
| footprint | CPU per turn, ms | 0.877 | 0.875 | -0.3% |
| lifecycle | CPU, s | 0.895 | 0.88 | -1.7% |
| lifecycle | p95 turn, ms | 698.223 | 629.455 | -9.8% |
| lifecycle | peak RSS, MiB | 25.547 | 25.078 | -1.8% |

Store jobs per turn did not change, and every change is within the gate's
15%. Sandboxed bots and the parse itself are not measured: it walks the
command's characters once, against a list of at most 64 prefixes.
26 changes: 22 additions & 4 deletions docs/RUST_PROTOTYPE.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,6 +413,7 @@ keep-warm when it sets any of them.
| `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 |
| `hide_senders` (`--hide-senders`) | Its model reads what other bots and clients send it without the line naming the sender (see [fleet controllers](#fleet-controllers)); `senders = "hidden"` in `~/.agent/config.toml` sets it on the bots the CLI and Sonny make. | off |
| `unsandboxed` (`--unsandboxed`, repeatable) | Commands a sandboxed bot runs outside its sandbox without asking, each by its first words, such as `mcpx` or `docker compose` (see [sandbox](#sandbox)). At most 64, each at most 1 KiB. | 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
Expand Down Expand Up @@ -1187,9 +1188,9 @@ 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. `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
anything is written. `sandbox`, with `writable`, `network` and `unsandboxed`
(as `create`'s settings name them), replaces the bot's sandbox, and those left
out go back to none, off and none; 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. `cache_ttl` (`5m` or `1h`, as `create`'s settings name it) sets the
bot's prompt-cache lifetime from its next model call. It answers `{bot, bot_id, changed}`, `changed` naming the fields
Expand Down Expand Up @@ -2248,10 +2249,27 @@ 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 command its client trusts runs outside the sandbox without asking, as a
Codex rule that allows a command prefix runs it: a shell call that doesn't
ask to escalate, whose script runs nothing but commands that begin with the
words of one of the bot's `unsandboxed` entries. So a tool with a daemon of
its own, such as mcpx, which keeps its lock and socket under
`~/.local/state` and starts its daemon on first use, works from a sandboxed
bot once it is named: started in a sandbox, the daemon would keep the
sandbox's limits, and on Linux die with the command. Only a plain script is
read, as Codex reads one: words, quoted or not, in commands joined by `&&`,
`||`, `;`, `|` or a newline. A script with anything that could run something
else (`$`, a backquote, a redirection, parentheses, `&` alone, a comment)
runs in the sandbox, and so does one with any other command in it, such as
`mcpx … | jq`; it can still ask to escalate. The command is found on the
daemon's `PATH`, so an `unsandboxed` command is only as trusted as the
folders there.

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.
parent's sandbox. It runs outside the sandbox only commands its creator
does.

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
Expand Down
14 changes: 12 additions & 2 deletions sonny/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -357,9 +357,19 @@ off again never shows.
`/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.
is sandboxed the same way in its own folder, but in `full`, where
nothing would answer a call that asks to leave the sandbox, tasks run
unconfined too, as under Codex's full access; a pick sets the tasks'
sandboxes along with their gates.
A lead made before Sonny sandboxed leads is sandboxed when Sonny opens
it. On Linux sandboxes need bubblewrap.
it. On Linux sandboxes need bubblewrap. A tool that keeps a daemon of its
own, such as mcpx, can't run inside a sandbox: name it in
`~/.agent/config.toml`, `[sonny] unsandboxed = ["mcpx"]`, and every agent
runs it outside without asking, as a Codex rule that allows a command
does. Each entry is a command's first words (`"docker compose"`), and a
call runs outside only when everything it runs is named, so
`mcpx … | jq` still asks. Sonny gives a lead the list again when it opens
it.
- `/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
7 changes: 2 additions & 5 deletions sonny/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1375,10 +1375,7 @@ async fn setup_lead(
let mut gating = lead.new.then_some(approvals::MODES[0].0);
if gating.is_none()
&& let Some(mode) = mode.filter(|mode| *mode != "full")
&& !approvals::sandboxed_as(
&record,
&approvals::sandbox(mode, true, Path::new(&workspace)),
)
&& !approvals::sandboxed_as(&record, &approvals::sandbox(mode, Path::new(&workspace)))
{
gating = Some(mode);
}
Expand All @@ -1396,7 +1393,7 @@ async fn setup_lead(
mode,
&approvals::gateable(&tools, declared),
));
fields.extend(approvals::sandbox(mode, true, Path::new(&workspace)));
fields.extend(approvals::sandbox(mode, Path::new(&workspace)));
}
// A lead at work keeps its setup until the next thread finds it idle.
if asked.as_object().is_some_and(|fields| fields.len() > 1) {
Expand Down
Loading
Loading