diff --git a/client/src/config.rs b/client/src/config.rs index 747695c1e..5178f5c17 100644 --- a/client/src/config.rs +++ b/client/src/config.rs @@ -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" @@ -80,6 +81,10 @@ pub struct Sonny { /// The profile every other agent Sonny starts is told, in place of /// `task`. pub task_profile: Option, + /// 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, /// The MCP servers Sonny's agents reach, by name. pub mcp_servers: BTreeMap, /// Whether a copy of the agent guesses the person's next message after @@ -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, } @@ -312,6 +318,7 @@ fn parse(text: &str) -> Result { "lead_profile", "task_profile", "suggest", + "unsandboxed", ], _ => return Err(format!("unknown table [{name}]")), }; @@ -335,6 +342,18 @@ fn parse(text: &str) -> Result { .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::>()?; + 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()) { @@ -493,7 +512,7 @@ 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")); @@ -501,6 +520,7 @@ mod tests { 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(); @@ -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}"); } diff --git a/docs/CLI.md b/docs/CLI.md index 4b7069497..2ee84de46 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 diff --git a/docs/DAEMON_MEASUREMENTS.md b/docs/DAEMON_MEASUREMENTS.md index 288963573..48cf00556 100644 --- a/docs/DAEMON_MEASUREMENTS.md +++ b/docs/DAEMON_MEASUREMENTS.md @@ -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. diff --git a/docs/RUST_PROTOTYPE.md b/docs/RUST_PROTOTYPE.md index 2b747d446..183f748ea 100644 --- a/docs/RUST_PROTOTYPE.md +++ b/docs/RUST_PROTOTYPE.md @@ -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 @@ -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 @@ -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 diff --git a/sonny/README.md b/sonny/README.md index c58e4bb83..acaa34e1f 100644 --- a/sonny/README.md +++ b/sonny/README.md @@ -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 diff --git a/sonny/src/acp.rs b/sonny/src/acp.rs index f315a8597..7db75b483 100644 --- a/sonny/src/acp.rs +++ b/sonny/src/acp.rs @@ -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); } @@ -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) { diff --git a/sonny/src/approvals.rs b/sonny/src/approvals.rs index 19e3ae00d..b8b765b3e 100644 --- a/sonny/src/approvals.rs +++ b/sonny/src/approvals.rs @@ -71,14 +71,30 @@ pub fn gate(mode: &str, tools: &[String]) -> Map { /// 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 { +/// read-only), and the commands the person's config lets out of it +/// (`[sonny] unsandboxed`). In `full` mode an agent runs unconfined, as +/// under Codex's full access, where nothing would answer a call that asks +/// to leave the sandbox. +pub fn sandbox(mode: &str, workspace: &std::path::Path) -> Map { + sandbox_with(mode, workspace, &unsandboxed()) +} + +/// [`sandbox`]'s fields, with `unsandboxed` the commands let out of it. +fn sandbox_with( + mode: &str, + workspace: &std::path::Path, + unsandboxed: &[String], +) -> Map { let fields = match mode { - "full" if lead => json!({"sandbox": "danger-full-access"}), + "full" => json!({"sandbox": "danger-full-access"}), _ => { let writable: Vec = repository(workspace).into_iter().collect(); - json!({"sandbox": "workspace-write", "writable": writable, "network": true}) + let mut fields = + json!({"sandbox": "workspace-write", "writable": writable, "network": true}); + if !unsandboxed.is_empty() { + fields["unsandboxed"] = json!(unsandboxed); + } + fields } }; match fields { @@ -87,6 +103,14 @@ pub fn sandbox(mode: &str, lead: bool, workspace: &std::path::Path) -> Map Vec { + agent_client::config::load() + .map(|config| config.sonny.unsandboxed) + .unwrap_or_default() +} + /// 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 @@ -110,9 +134,10 @@ pub fn repository(dir: &std::path::Path) -> Option { 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(); + let list = |value: &Value, key: &str| value[key].as_array().cloned().unwrap_or_default(); settings["sandbox"] == fields["sandbox"] - && writable(settings) == writable(&fields) + && list(settings, "writable") == list(&fields, "writable") + && list(settings, "unsandboxed") == list(&fields, "unsandboxed") && settings["network"].as_bool().unwrap_or(false) == fields["network"].as_bool().unwrap_or(false) } @@ -158,8 +183,9 @@ pub fn made_by<'a>(bots: &'a [Value], lead: &str) -> Vec<&'a Value> { made } -/// The `configure` fields that give a bot `mode`, or `None` when its gate -/// is already the one `gate` makes: the same approver over the same tools, +/// The `configure` fields that give a bot `mode`, with the sandbox the +/// mode gives an agent in its folder, or `None` when its gate is already +/// the one `gate` makes and its sandbox the mode's: the same approver over the same tools, /// with no expiry. `record` is the bot as `inspect` gives it, with the /// tools its client declared. pub fn regate(record: &Value, mode: &str) -> Option> { @@ -193,7 +219,14 @@ pub fn regate(record: &Value, mode: &str) -> Option> { && sorted(&gate["tools"]) == sorted(&want["tools"]) && gate["expire_ms"].is_null() }); - (!has).then_some(fields) + let mut fields = fields; + let mut confined = true; + if let Some(workspace) = record["workspace"].as_str() { + let sandbox = sandbox(mode, std::path::Path::new(workspace)); + confined = sandboxed_as(record, &sandbox); + fields.extend(sandbox); + } + (!has || !confined).then_some(fields) } #[cfg(test)] @@ -201,7 +234,7 @@ mod tests { use super::*; #[test] - fn a_lead_is_confined_but_in_full_mode_and_a_task_always() { + fn an_agent_is_confined_but_in_full_mode() { 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(); @@ -209,30 +242,55 @@ 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 = sandbox(mode, false, &dir); + let fields = sandbox_with(mode, &dir, &[]); + if mode == "full" { + continue; + } 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(sandbox_with("auto", &dir, &[])), confined); + let full = sandbox_with("full", &dir, &[]); assert_eq!( Value::Object(full.clone()), json!({"sandbox": "danger-full-access"}) ); + // A task the person moves to `full` leaves its sandbox with the + // gate, and one already there is left alone. + let task = json!({"workspace": dir, "gates": [], + "settings": {"sandbox": "workspace-write", "writable": [git], "network": true}}); + assert_eq!( + regate(&task, "full").unwrap()["sandbox"], + "danger-full-access" + ); + let freed = json!({"workspace": dir, "gates": [], + "settings": {"sandbox": "danger-full-access", "writable": [], "network": false}}); + assert_eq!(regate(&freed, "full"), None); 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); + let auto = sandbox_with("auto", &dir, &[]); assert!(!sandboxed_as(&json!({"settings": {}}), &auto)); let moved = json!({"settings": {"sandbox": "workspace-write", "writable": ["/gone/.git"], "network": true}}); assert!(!sandboxed_as(&moved, &auto)); + // The commands the config lets out of the sandbox go with it, and + // a lead made before they were named takes them. + let mcpx = sandbox_with("auto", &dir, &["mcpx".to_owned()]); + assert_eq!(mcpx["unsandboxed"], json!(["mcpx"])); + assert!(!sandboxed_as(&json!({"settings": auto}), &mcpx)); + assert!(sandboxed_as(&json!({"settings": mcpx}), &mcpx)); + assert!( + sandbox_with("full", &dir, &["mcpx".to_owned()]) + .get("unsandboxed") + .is_none() + ); // 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!([])); + assert_eq!(sandbox_with("ask", &plain, &[])["writable"], json!([])); let apart = dir.join("apart"); let tree = dir.join("tree"); std::fs::create_dir_all(&tree).unwrap(); @@ -241,7 +299,7 @@ mod tests { &["init", "-q", "--separate-git-dir", apart.to_str().unwrap()], ) .unwrap(); - assert_eq!(sandbox("ask", true, &tree)["writable"], json!([])); + assert_eq!(sandbox_with("ask", &tree, &[])["writable"], json!([])); std::fs::remove_dir_all(&dir).unwrap(); } diff --git a/sonny/src/main.rs b/sonny/src/main.rs index 9eae6d2a7..aaf63efb0 100644 --- a/sonny/src/main.rs +++ b/sonny/src/main.rs @@ -909,7 +909,7 @@ async fn run() -> Result<(), String> { && let Some(mode) = app.mode.filter(|mode| *mode != "full") && !approvals::sandboxed_as( &bot, - &approvals::sandbox(mode, true, std::path::Path::new(&app.workspace)), + &approvals::sandbox(mode, std::path::Path::new(&app.workspace)), ) { app.gating = Some(mode); @@ -971,7 +971,7 @@ async fn create(client: &Client, options: &Options, bot: &str) -> Result<(), Str "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); + let mut settings = approvals::sandbox(approvals::MODES[0].0, &workspace); if let Some(ttl) = agent_client::policy::cache_ttl(&model) { settings.insert("cache_ttl".into(), json!(ttl)); } @@ -2548,7 +2548,7 @@ impl App { // 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)); + fields.extend(approvals::sandbox(mode, workspace)); } } match self.client.request("configure", asked).await { diff --git a/sonny/src/project.rs b/sonny/src/project.rs index 954ca5bfd..ea505e429 100644 --- a/sonny/src/project.rs +++ b/sonny/src/project.rs @@ -557,7 +557,7 @@ async fn make( }; let declare = json!(crate::declared::tools(true)); let gate = crate::approvals::gate(mode, &crate::approvals::gateable(&tools, &declare)); - let mut settings = crate::approvals::sandbox(mode, true, dir); + let mut settings = crate::approvals::sandbox(mode, dir); if agent_client::config::load()?.hide_senders { settings.insert("hide_senders".into(), json!(true)); } diff --git a/sonny/src/task.rs b/sonny/src/task.rs index d79f7b2f4..3d6034314 100644 --- a/sonny/src/task.rs +++ b/sonny/src/task.rs @@ -133,23 +133,35 @@ pub fn start(here: &Path, lead: &str, start: &Start) -> Result { )); } } - let lead = ( + let lead_picks = ( std::env::var("AGENT_MODEL").ok(), std::env::var("AGENT_EFFORT").ok(), ); let flags: Vec<&String> = flags.iter().collect(); - let flags = with_defaults(&info, &flags, lead, config.sonny.task_effort); + let flags = with_defaults(&info, &flags, lead_picks, config.sonny.task_effort); let flags: Vec<&String> = flags.iter().collect(); let mut flags = match told(&place.dir, &info, &flags) { Ok(flags) => flags, Err(error) => return failed(&place, false, error), }; with_cache(&mut flags); - // 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 task works in its lead's mode's sandbox: confined to its folder, + // committing to its repository and running the commands the config + // lets out of it, or, in `full`, unconfined, as nothing would answer a + // call that asks to leave. + let mode = lead_mode(&agent, lead).unwrap_or(crate::approvals::MODES[0].0); + for (key, value) in crate::approvals::sandbox(mode, &place.dir) { + let flag = format!("--{key}"); + match value { + Value::String(value) => flags.extend([flag, value]), + Value::Bool(true) => flags.push(flag), + Value::Array(values) => { + for value in values.iter().filter_map(Value::as_str) { + flags.extend([flag.clone(), value.to_owned()]); + } + } + _ => {} + } } // 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. @@ -509,6 +521,18 @@ fn ends_name(after: &str, part_of_name: impl Fn(char) -> bool) -> bool { } } +/// The approvals mode of the lead `name`, from its gates. +fn lead_mode(agent: &Path, name: &str) -> Option<&'static str> { + let output = Command::new(agent) + .args(["inspect", "--bot", name]) + .stdin(Stdio::null()) + .stderr(Stdio::null()) + .output() + .ok()?; + let record: Value = serde_json::from_slice(&output.stdout).ok()?; + crate::approvals::mode(&record) +} + /// Whether the daemon has a task `name`: one whose start failed after it /// was made keeps its folder, since a retry continues it there. fn listed(agent: &Path, name: &str) -> bool { diff --git a/sonny/src/trigger.rs b/sonny/src/trigger.rs index 35ed6cacb..b6385e408 100644 --- a/sonny/src/trigger.rs +++ b/sonny/src/trigger.rs @@ -2374,15 +2374,25 @@ pub fn restamp(places: &Places, identity: &str, lineage: &str, launchd: Loader) return; }; // Read again under the lock: an `rm`, `add` or fire may have come first. - let path = places.plist(&trigger.name); + let path = places.job(&trigger.name); let Ok(text) = std::fs::read_to_string(&path) else { continue; }; - let stamped = text.replacen( - &format!("{}", escape(identity)), - &format!("{}", escape(lineage)), - 1, - ); + let stamped = match is_plist(&text) { + true => text.replacen( + &format!("{}", escape(identity)), + &format!("{}", escape(lineage)), + 1, + ), + false => { + let flag = quoted("--store-id", true); + text.replacen( + &format!("{flag} {}", quoted(identity, true)), + &format!("{flag} {}", quoted(lineage, true)), + 1, + ) + } + }; if stamped == text { continue; } @@ -2398,14 +2408,11 @@ pub fn restamp(places: &Places, identity: &str, lineage: &str, launchd: Loader) /// Once Sonny knows its daemon: [`restamp`] the triggers made on its store. pub fn restamp_at_start(identity: Option<&str>, lineage: Option<&str>) { - if !cfg!(target_os = "macos") { - return; - } let (Ok(places), Some(identity), Some(lineage)) = (Places::home(), identity, lineage) else { return; }; let (identity, lineage) = (identity.to_owned(), lineage.to_owned()); - std::thread::spawn(move || restamp(&places, &identity, &lineage, &launchctl)); + std::thread::spawn(move || restamp(&places, &identity, &lineage, &schedule)); } /// What `trigger add` was asked. @@ -4288,7 +4295,12 @@ mod tests { #[test] fn a_trigger_made_by_its_store_file_s_identity_names_its_lineage_instead() { - let w = World::new("restamp"); + for w in [World::new("restamp"), World::systemd("restamp-unit")] { + restamps(w); + } + } + + fn restamps(w: World) { let elsewhere = Trigger { name: "q.other".into(), store_id: "another-store".into(), diff --git a/src/cli.rs b/src/cli.rs index 02e32108a..9cc122c59 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 --sandbox --writable --network --hide-senders", + 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 --hide-senders --unsandboxed", startup: true, }, Command { @@ -388,6 +388,10 @@ fn print_flags(flags: &str) { "", "A new bot's model reads what other bots and clients send it with no line naming the sender", ), + "--unsandboxed" => ( + "COMMAND", + "A command, by its first words, a sandboxed bot runs outside its sandbox without asking; repeat for more", + ), "--fallbacks" => ( "", "A new bot's declined Anthropic requests rerun on the recommended model", @@ -564,7 +568,10 @@ pub fn prepare(args: Vec) -> Result>> { return fail_with("usage", format!("{} does not accept {flag}", c.name)); } if flags.iter().any(|f| f == flag) - && !matches!(flag, "--provider" | "--image" | "--writable") + && !matches!( + flag, + "--provider" | "--image" | "--writable" | "--unsandboxed" + ) { return fail_with("usage", format!("{flag} may only be specified once")); } @@ -715,6 +722,7 @@ pub fn prepare(args: Vec) -> Result>> { "--writable", "--network", "--hide-senders", + "--unsandboxed", "--approval", "--approve", ] diff --git a/src/client.rs b/src/client.rs index 19201101a..ccd9152d1 100644 --- a/src/client.rs +++ b/src/client.rs @@ -406,6 +406,18 @@ fn parse(args: &[String]) -> Result { options.settings_flags.push(flag.to_owned()); } } + "--unsandboxed" => { + let unsandboxed = options + .settings + .entry("unsandboxed".to_owned()) + .or_insert_with(|| json!([])); + if let Value::Array(commands) = unsandboxed { + commands.push(json!(value)); + } + 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/sandbox.rs b/src/sandbox.rs index 3e8c5ccc2..6fd704c67 100644 --- a/src/sandbox.rs +++ b/src/sandbox.rs @@ -287,6 +287,96 @@ pub fn escapes( } } +/// Whether `command` runs nothing but commands `unsandboxed` lets out of +/// the sandbox, as a Codex rule that allows a command prefix lets it out +/// without asking: each entry is a prefix of words, such as `mcpx` or +/// `docker compose`. Such a command, as a tool with a daemon of its own +/// needs, runs where the bot's client trusted it to. Only a plain script +/// is read, as Codex reads one: words, quoted or not, in commands joined by +/// `&&`, `||`, `;`, `|` or a newline, every one of them a match. Anything +/// that could run something else (an expansion, a substitution, a +/// redirection, a subshell, a command in the background) and anything +/// ill-formed is no match, and the command stays in the sandbox. +pub fn unsandboxed(unsandboxed: &[String], command: &str) -> bool { + let Some(commands) = commands(command) else { + return false; + }; + let prefixes: Vec> = unsandboxed + .iter() + .map(|prefix| prefix.split_whitespace().collect()) + .filter(|words: &Vec<&str>| !words.is_empty()) + .collect(); + !commands.is_empty() + && commands.iter().all(|words| { + prefixes.iter().any(|prefix| { + words.len() >= prefix.len() && words.iter().zip(prefix).all(|(a, b)| a == b) + }) + }) +} + +/// The words of each command in a plain script, as bash reads them; `None` +/// for anything else. +fn commands(script: &str) -> Option>> { + let mut commands = Vec::new(); + let mut words = Vec::new(); + let mut word: Option = None; + let mut chars = script.chars().peekable(); + while let Some(c) = chars.next() { + match c { + ' ' | '\t' => words.extend(word.take()), + '\n' | ';' | '&' | '|' => { + match c { + // Alone, `&` puts a command in the background. + '&' if chars.next_if_eq(&'&').is_none() => return None, + '|' => drop(chars.next_if_eq(&'|')), + _ => {} + } + words.extend(word.take()); + if !words.is_empty() { + commands.push(std::mem::take(&mut words)); + } + } + '\'' => { + let word = word.get_or_insert_default(); + loop { + match chars.next()? { + '\'' => break, + c => word.push(c), + } + } + } + '"' => { + let word = word.get_or_insert_default(); + loop { + match chars.next()? { + '"' => break, + '$' | '`' => return None, + '\\' => match chars.next()? { + '\n' => {} + c @ ('"' | '\\' | '$' | '`') => word.push(c), + c => word.extend(['\\', c]), + }, + c => word.push(c), + } + } + } + '\\' => match chars.next()? { + '\n' => {} + c => word.get_or_insert_default().push(c), + }, + // A comment, which could hide what a reader of the call misses. + '#' if word.is_none() => return None, + '$' | '`' | '<' | '>' | '(' | ')' => return None, + c => word.get_or_insert_default().push(c), + } + } + words.extend(word); + if !words.is_empty() { + commands.push(words); + } + Some(commands) +} + /// Whether `path`, read against `folder`, lies outside every folder a bot /// in `mode` may write. fn outside( @@ -1071,6 +1161,51 @@ pub fn seatbelt(policy: &Policy) -> (String, Vec<(String, PathBuf)>) { mod tests { use super::*; + #[test] + fn only_a_plain_script_of_allowed_commands_leaves_the_sandbox() { + let allowed = ["mcpx".to_owned(), "docker compose".to_owned()]; + for command in [ + "mcpx", + "mcpx github search_issues --query 'is:open label:\"bug\"'", + "mcpx a && mcpx b || mcpx c; mcpx d | mcpx e\nmcpx f", + "'mcpx' \"x y\" a\\ b \\\n --flag=1 2>x#y", + "docker compose up", + "mcpx a;;", + ] { + let command = command.replace("2>x#y", "x#y"); + assert!(unsandboxed(&allowed, &command), "{command}"); + } + for command in [ + "", + " ;", + "mcpx a | jq .", + "docker ps", + "docker", + "mcpxx a", + "/usr/bin/mcpx a", + "PATH=. mcpx a", + "mcpx a & evil", + "mcpx a |& evil", + "mcpx $(evil)", + "mcpx `evil`", + "mcpx \"$(evil)\"", + "mcpx \"`evil`\"", + "mcpx $HOME", + "mcpx > ~/.zshrc", + "mcpx < input", + "mcpx <(evil)", + "(mcpx a)", + "mcpx a # ; evil", + "mcpx 'unclosed", + "mcpx \"unclosed", + "mcpx a\\", + ] { + assert!(!unsandboxed(&allowed, command), "{command}"); + } + assert!(!unsandboxed(&[], "mcpx a")); + assert!(!unsandboxed(&[" ".to_owned()], "mcpx a")); + } + #[test] fn a_call_escapes_by_asking_or_by_writing_outside_its_roots() { let root = Path::new("/work/project"); diff --git a/src/server/mod.rs b/src/server/mod.rs index 98fd8df41..012b6bea2 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -266,13 +266,14 @@ enum Command { /// 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. + /// settings name it: with `writable`, `network` and `unsandboxed`, + /// which otherwise go back to none, off and none. sandbox: Option, writable: Option>, network: Option, /// Its prompt-cache lifetime, as `create`'s settings name it. cache_ttl: Option, + unsandboxed: Option>, }, /// Summarize a bot's context with its compaction instructions before /// its next model call: a running turn's next round boundary, or an @@ -2730,6 +2731,7 @@ impl Service { writable, network, cache_ttl, + unsandboxed, } => { if effort.is_some() && model.is_none() { return fail_with("invalid_effort", "effort goes with model"); @@ -2740,6 +2742,7 @@ impl Service { sandbox: Some(mode), writable: writable.unwrap_or_default(), network: network.unwrap_or(false), + unsandboxed: unsandboxed.unwrap_or_default(), ..Settings::default() }; settings.validate()?; @@ -2750,12 +2753,12 @@ impl Service { }) .await .map_err(|_| Error::new("sandbox_unavailable"))??; - Some((settings.sandbox, settings.writable, settings.network)) + Some(settings) } - None if writable.is_some() || network.is_some() => { + None if writable.is_some() || network.is_some() || unsandboxed.is_some() => { return fail_with( "invalid_setting", - "writable and network go with sandbox", + "writable, network and unsandboxed go with sandbox", ); } None => None, diff --git a/src/server/turn.rs b/src/server/turn.rs index 61ef9477b..b65c0285d 100644 --- a/src/server/turn.rs +++ b/src/server/turn.rs @@ -3431,9 +3431,13 @@ impl Turn { // 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. + // A command its client lets out of the sandbox runs unconfined, + // as a Codex rule that allows it does. let mut sandbox = None; - let confined = - record.sandboxed() && agent_runtime::sandbox::confinable(&call.name) && !approved; + let confined = record.sandboxed() + && agent_runtime::sandbox::confinable(&call.name) + && !approved + && !record.unsandboxed(&call); let prepared = match prepared { Ok(Prepared::Shell { .. } | Prepared::Detach(_) | Prepared::Terminal { .. }) if confined && !record.confines(&call, workspace) => @@ -3759,15 +3763,17 @@ impl Turn { .await?; // A sandboxed bot's commands all run in its one sandbox; when it // can't be had, each command fails with why. - let shells = batch - .iter() - .any(|(_, prepared)| matches!(prepared, Prepared::Shell { .. })); + // A command its client lets out of it runs unconfined. + let shells = batch.iter().any(|(call, prepared)| { + matches!(prepared, Prepared::Shell { .. }) && !record.unsandboxed(call) + }); let sandbox = match shells && record.sandboxed() { true => self.sandbox(record, workspace).await, false => Ok(None), }; let runs = batch.into_iter().map(|(call, prepared)| { let sandbox = match (&prepared, &sandbox) { + _ if record.unsandboxed(&call) => Ok(None), (Prepared::Shell { .. }, Err(error)) => Err(error.clone()), (_, sandbox) => Ok(sandbox.clone().unwrap_or_default()), }; diff --git a/src/store/db.rs b/src/store/db.rs index d6d8f87c6..2f0bb8f87 100644 --- a/src/store/db.rs +++ b/src/store/db.rs @@ -227,6 +227,11 @@ pub struct Settings { /// model without the line saying so after its words. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub hide_senders: bool, + /// Command prefixes, each of words, that run outside the sandbox + /// without asking, as a Codex rule that allows a prefix runs it + /// ([`crate::sandbox::unsandboxed`]). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub unsandboxed: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Deserialize, Serialize)] pub enum CacheTtl { @@ -291,7 +296,8 @@ impl 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. + /// sandbox, and reaches the network only if both do; it runs outside + /// the sandbox only commands its creator does. pub fn under(mut self, creator: &Settings) -> Settings { use crate::sandbox::Mode; let sandboxed = |settings: &Settings| { @@ -306,7 +312,10 @@ impl Settings { if !sandboxed(&self) { self.writable = creator.writable.clone(); self.network = creator.network; + self.unsandboxed = creator.unsandboxed.clone(); } + self.unsandboxed + .retain(|prefix| creator.unsandboxed.contains(prefix)); self.sandbox = Mode::stricter(self.sandbox, creator.sandbox); if self.sandbox != Some(Mode::WorkspaceWrite) { self.writable.clear(); @@ -362,6 +371,21 @@ impl Settings { if !confined && (!self.writable.is_empty() || self.network) { return refuse("writable and network go with sandbox workspace-write"); } + let sandboxed = matches!( + self.sandbox, + Some(crate::sandbox::Mode::ReadOnly | crate::sandbox::Mode::WorkspaceWrite) + ); + if !sandboxed && !self.unsandboxed.is_empty() { + return refuse("unsandboxed goes with sandbox read-only or workspace-write"); + } + if self.unsandboxed.len() > 64 + || self + .unsandboxed + .iter() + .any(|prefix| prefix.split_whitespace().next().is_none() || prefix.len() > 1024) + { + return refuse("unsandboxed names at most 64 commands, each words of at most 1 KiB"); + } if self.writable.len() > 64 || crate::output::encoded_len(&self.writable).unwrap_or(usize::MAX) > FOLDERS_JSON { @@ -386,7 +410,7 @@ impl Settings { "keep_warm":self.keep_warm().map_or(0, |after| after.as_secs()), "cache_ttl":self.cache_ttl.unwrap_or(CacheTtl::Minutes), "sandbox":self.sandbox,"writable":self.writable,"network":self.network, - "hide_senders":self.hide_senders}) + "hide_senders":self.hide_senders,"unsandboxed":self.unsandboxed}) } fn stored(&self) -> Result> { Ok((*self != Self::default()) @@ -454,6 +478,21 @@ impl Bot { Some(Mode::ReadOnly | Mode::WorkspaceWrite) ) } + /// Whether this call is a command the bot's client lets run outside its + /// sandbox without asking: one the bot doesn't ask to escalate, which + /// runs nothing but its `unsandboxed` commands. + pub fn unsandboxed(&self, call: &ToolCall) -> bool { + if call.name != "shell" || self.settings.unsandboxed.is_empty() || !self.sandboxed() { + return false; + } + let Ok(arguments) = serde_json::from_str::(&call.arguments) else { + return false; + }; + arguments["sandbox_permissions"] != "require_escalated" + && arguments["command"].as_str().is_some_and(|command| { + crate::sandbox::unsandboxed(&self.settings.unsandboxed, command) + }) + } /// 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 { @@ -2299,20 +2338,26 @@ 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 + /// Replace a bot's sandbox with `sandbox`'s: its mode, the folders it + /// may write besides its own, whether it reaches the network and the + /// commands that run outside it. 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), + sandbox: Settings, ) -> 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 settings = Settings { + sandbox: sandbox.sandbox, + writable: sandbox.writable, + network: sandbox.network, + unsandboxed: sandbox.unsandboxed, + ..bot.settings + }; let creator = match bot.created_by_id { Some(creator) => self .conn @@ -2327,9 +2372,10 @@ impl Database { .transpose()?, None => None, }; - if let Some(creator) = &creator { - settings = settings.under(creator); - } + let settings = match &creator { + Some(creator) => settings.under(creator), + None => settings, + }; settings.validate()?; let stored = settings.stored()?; let changed = self.conn.execute( diff --git a/tests/store_contract.rs b/tests/store_contract.rs index bc88b618a..9d8164bc6 100644 --- a/tests/store_contract.rs +++ b/tests/store_contract.rs @@ -3196,7 +3196,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","sandbox":null,"writable":[],"network":false,"hide_senders":false}) + "keep_warm":0,"cache_ttl":"1h","sandbox":null,"writable":[],"network":false,"hide_senders":false,"unsandboxed":[]}) ); // Retention follows the bot's own setting. for n in 1..=3 { diff --git a/tests/test_approvals.py b/tests/test_approvals.py index bc6504cff..13271e292 100644 --- a/tests/test_approvals.py +++ b/tests/test_approvals.py @@ -323,6 +323,38 @@ def test_a_sandboxed_bots_commands_run_together_in_its_sandbox(self): self.assertEqual(self.tool_output(client, 'shell-3')[1]['error'], 'escalation_refused') self.assertEqual(list(outside.iterdir()), []) + @unittest.skipUnless(shutil.which('bwrap') or sys.platform == 'darwin', 'sandboxes need bubblewrap') + def test_a_command_its_client_trusts_runs_outside_the_sandbox_without_asking(self): + (ROOT / '.local').mkdir(exist_ok=True) + outside = Path(tempfile.mkdtemp(dir=ROOT / '.local')) + self.addCleanup(shutil.rmtree, outside) + client = self.gated(settings={'sandbox': 'workspace-write', 'unsandboxed': ['touch']}) + self.assertEqual(client.request('inspect', bot='Bob')['result']['settings']['unsandboxed'], ['touch']) + # Alone, and beside a command that runs anything else, which stays + # in the sandbox; neither asks. + for request_id, calls in [('alone', [['shell', {'command': f'touch {outside}/alone'}]]), + ('together', [['shell', {'command': f"touch '{outside}/one' && touch {outside}/two"}], + ['shell', {'command': f'touch {outside}/three && true'}]])]: + turn = client.request('submit', bot='Bob', request_id=request_id, + prompt='calls:' + json.dumps(calls))['result']['turn'] + self.assertEqual(client.finished(turn)['data']['status'], 'completed') + self.assertEqual(self.events(client, turn, 'approval_requested'), []) + self.assertEqual(sorted(path.name for path in outside.iterdir()), ['alone', 'one', 'two']) + self.assertNotEqual(self.tool_output(client, 'shell-2')[1]['exit_code'], 0) + # Only with a sandbox; configure replaces the list, and a bot the + # bot makes runs outside only what its creator does. + self.assertEqual(client.request('configure', bot='Bob', unsandboxed=['touch'])['error'], 'invalid_setting') + self.assertEqual(client.request('configure', bot='Bob', sandbox='danger-full-access', + unsandboxed=['touch'])['error'], 'invalid_setting') + configured = client.request('configure', bot='Bob', sandbox='workspace-write', + unsandboxed=['touch', 'mcpx'])['result'] + self.assertEqual(configured['changed'], ['sandbox']) + bob = client.request('inspect', bot='Bob')['result'] + kid = client.request('create', bot='Kid', workspace=str(self.path), created_by='Bob', + created_by_id=bob['bot_id'], + settings={'sandbox': 'read-only', 'unsandboxed': ['mcpx', 'rm']})['result'] + self.assertEqual((kid['settings']['sandbox'], kid['settings']['unsandboxed']), ('read-only', ['mcpx'])) + 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_served.py b/tests/test_served.py index ca5b5e7ea..d81d21dda 100644 --- a/tests/test_served.py +++ b/tests/test_served.py @@ -74,9 +74,11 @@ def test_a_lead_starts_a_task_in_a_worktree_of_its_own(self): (repo / 'calc.py').write_text('one\n') self.git(repo, 'add', '.') self.git(repo, 'commit', '-qm', 'one') + # A lead in `auto`. self.daemon.request('create', bot='calc.lead', workspace=str(repo), model='openai/synthetic-model', instructions='Lead.', declare=[declared('start_task', inherit=False), - declared('show_agent')]) + declared('show_agent')], + approve=['show_agent'], approver='auto') self.lookout() output = self.call('calc.lead', 'start_task', {'name': 'fix', 'brief': f'Fix {repo}/calc.py.'}) self.assertTrue(output.startswith('Started calc.fix: '), output) @@ -101,6 +103,13 @@ def test_a_lead_starts_a_task_in_a_worktree_of_its_own(self): refused = json.loads(self.call('calc.lead', 'start_task', {'name': 'fix', 'brief': 'Again.'})) self.assertEqual(refused['error'], 'task_not_started') self.assertTrue(worktree.is_dir()) + # In `full`, with no gate to ask, a task runs unconfined, as its + # lead does. + self.daemon.request('configure', bot='calc.lead', approve=[]) + output = self.call('calc.lead', 'start_task', {'name': 'free', 'brief': 'Go.'}) + self.assertTrue(output.startswith('Started calc.free: '), output) + free = self.daemon.request('inspect', bot='calc.free')['result']['settings'] + self.assertEqual((free['sandbox'], free['writable'], free['network']), ('danger-full-access', [], False)) def test_a_turn_interrupted_mid_setup_stops_it_and_starts_no_task(self): repo = self.path / 'calc'