Skip to content

Commit 217e9ea

Browse files
Sandbox the agent's shell on macOS
On macOS the sandbox launcher starts the shell through the system's sandbox-exec, with a profile that allows writes only beneath the same directories as on Linux (plus the macOS cache folders); reads stay unrestricted. Directories are also given under their real paths, since /tmp and the temp folders are links into /private. A gitagent started inside the sandbox stays in it instead of failing to enter another. The sandbox crate's apply() becomes prepare(), which returns how to start the program: directly on Linux, where the launcher restricts itself as before, or through sandbox-exec on macOS. No new dependency. CI gains a macOS job that runs the sandbox tests and checks that the launcher refuses a write outside the allowed folders. The docs no longer list macOS as running without a sandbox.
1 parent fea11db commit 217e9ea

8 files changed

Lines changed: 248 additions & 40 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,27 @@ jobs:
1919
- run: cargo clippy --all-targets --locked -- -D warnings
2020
- run: cargo test --workspace --locked
2121

22+
macos:
23+
runs-on: macos-latest
24+
needs: check
25+
steps:
26+
- uses: actions/checkout@v4
27+
- name: Install toolchain from rust-toolchain.toml
28+
run: rustup show active-toolchain || rustup toolchain install
29+
- uses: Swatinem/rust-cache@v2
30+
- run: cargo clippy --all-targets --locked -- -D warnings
31+
- run: cargo test --locked -p gitagent-sandbox
32+
- name: The launcher refuses writes outside the allowed folders
33+
run: |
34+
cargo build --locked -p gitagent-cli
35+
mkdir -p allowed
36+
target/debug/gitagent __sandbox --write "$PWD/allowed" --write /dev -- \
37+
sh -c 'echo ok > allowed/in.txt && ! (echo x > outside.txt) 2>/dev/null' 2> launcher.err
38+
cat launcher.err
39+
test -f allowed/in.txt
40+
test ! -e outside.txt
41+
! grep -q 'sandbox limited' launcher.err
42+
2243
musl:
2344
runs-on: ubuntu-latest
2445
needs: check

‎BLUEPRINT.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Harbor also hosts other benchmarks with the same task shape, SWE-bench Verified
7979
│ hooks · approvals · audit │
8080
├──────────────────────────────────────────────────────────┤
8181
│ record: trajectory + counters every turn, events │
82-
│ sandbox: Landlock + seccomp around the agent's shell │
82+
│ sandbox: Landlock + seccomp; macOS: sandbox-exec │
8383
└──────────────────────────────────────────────────────────┘
8484
```
8585

@@ -125,7 +125,7 @@ Steps 4 to 9 repeat until the stop check accepts, the agent replies in conversat
125125
| D19 | Parallel work as a `plan` tool over sub-agents, with a reconciling step, opt-in per agent | Large jobs finish faster and more consistently; bounded phases and tasks keep the cost predictable |
126126
| D20 | Schedules run in-process while `gitagent schedule start` runs | The same behaviour as before; anything that must not be missed calls `gitagent schedule run` from an external scheduler |
127127
| D21 | The terminal is the interface | One console with tool lines, approvals, a task board and command suggestions; no web server to secure |
128-
| D22 | Landlock and seccomp around the agent's shell, not around gitagent | The agent cannot write outside its folders, while gitagent can still record |
128+
| D22 | Landlock and seccomp (on macOS, a `sandbox-exec` profile) around the agent's shell, not around gitagent | The agent cannot write outside its folders, while gitagent can still record |
129129
| D23 | `--bench` reads only committed files, and a test pins its prompt and tools | Product features cannot change a scored run by accident |
130130
| D24 | Everyday defaults keep the benchmark's guards, except the snapshot; the console has no time limit unless one is set | The loop detector, stop check and context clearing help in any repository; a person at the console stops a turn with Ctrl-C, while runs nobody watches keep a 30-minute ceiling; the snapshot costs up to 15 seconds per start in a project the user already knows |
131131
| D25 | Sub-agents are warned as their turns run low, may be given their own turn limit (up to 100), and come back marked partial when they run out | A review cut off mid-step wastes its whole cost; a warned one hands back what it found, and the lead re-runs only what is marked partial |
@@ -180,7 +180,7 @@ gitagent/
180180
│ ├─ mcp/ the MCP client
181181
│ ├─ record/ trajectory, counters, events
182182
│ ├─ repo/ clone, branch, commit, push
183-
│ ├─ sandbox/ Landlock and seccomp
183+
│ ├─ sandbox/ Landlock and seccomp; sandbox-exec on macOS
184184
│ ├─ learn/ offline learning of lessons
185185
│ └─ sdk/ the in-process Rust library
186186
├─ clients/ python/ and typescript/: thin clients over --ipc
@@ -201,7 +201,7 @@ pub trait Provider: Send + Sync {
201201
}
202202
```
203203

204-
Dependencies are kept few: `tokio`, `reqwest` with rustls, `serde`, `serde_json`, a YAML parser, `clap`, `regex`, `portable-pty`, `anyhow`, `thiserror`, `async-trait`, and `landlock` with a seccomp filter for the sandbox. Token counts are estimated at four bytes per token instead of linking a tokenizer. Release builds are static musl binaries for x86_64 and aarch64 Linux, plus macOS.
204+
Dependencies are kept few: `tokio`, `reqwest` with rustls, `serde`, `serde_json`, a YAML parser, `clap`, `regex`, `portable-pty`, `anyhow`, `thiserror`, `async-trait`, and `landlock` with a seccomp filter for the sandbox (on macOS it uses the system's `sandbox-exec`, so no dependency). Token counts are estimated at four bytes per token instead of linking a tokenizer. Release builds are static musl binaries for x86_64 and aarch64 Linux, plus macOS.
205205

206206
---
207207

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ The workspace is split into crates under `crates/`:
5656
| `mcp` | The Model Context Protocol client |
5757
| `record` | Trajectories, counters and events |
5858
| `repo` | Repo mode: clones, branches, commits, pushes |
59-
| `sandbox` | Landlock and seccomp for the agent's shell |
59+
| `sandbox` | Landlock and seccomp for the agent's shell; `sandbox-exec` on macOS |
6060
| `learn` | Offline learning of lessons from finished runs |
6161
| `sdk` | The Rust library interface |
6262

‎README.md‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,6 @@ gitagent 3 replaces the TypeScript packages (1.x and 2.x) with one binary. What
545545
- **Not carried over.** `--voice`; the remote `--sandbox` (the binary prints a one-line note); a plugin's `entry` JavaScript (plugins contribute tools, skills, hooks and prompt text through files); the 1.x telemetry adapter (OTLP export replaces it).
546546
- **Task list.** 1.x's `task_tracker` kept a searchable list of tasks. gitagent 3's `todo` is a plain checklist the agent rewrites whole; the name `task_tracker` still maps to it in tool lists and hooks.
547547
- **Learning.** 1.x's online skill learning, which adjusted a skill's confidence after each use, is gone. Lessons are learned from finished runs instead (`learning: online` or `gitagent learn`, below) and go into the system prompt; skills are not changed.
548-
- **Sandbox on macOS.** The sandbox is Linux-only (Landlock and seccomp). On macOS the agent's shell runs without one, where 1.x's remote sandbox worked on every system.
549548
- **Scripts without a runtime.** 1.x ran every declared tool with `sh` unless `runtime` was set. gitagent 3 picks the interpreter by extension (`.py` with `python3`, `.js` with `node`, `.rb` with `ruby`, …), so such tools now run instead of failing.
550549

551550
## Configuration
@@ -582,7 +581,7 @@ With `learning: online`, gitagent learns from each task it completes. The model
582581

583582
### Sandbox
584583

585-
On Linux, outside benchmark runs, the agent's shell runs under Landlock: it may write only to the working folder, the output folder, the temp folders and `~/.cache`. A seccomp filter blocks system calls such as `mount`, loading kernel modules and `reboot`. Set `sandbox: off` in `gitagent.yaml` to disable it, or `sandbox: on` to require it. In benchmark runs, writing under `/tests` is refused; elsewhere `/tests` is an ordinary path.
584+
Outside benchmark runs, the agent's shell runs in a sandbox: it may write only to the working folder, the output folder, the temp folders and `~/.cache` (on macOS also `~/Library/Caches`). Reading is not restricted. On Linux this is Landlock, plus a seccomp filter that blocks system calls such as `mount`, loading kernel modules and `reboot`. On macOS it is the system's `sandbox-exec` with a profile that refuses other writes. Other systems run the shell without a sandbox. Set `sandbox: off` in `gitagent.yaml` to disable it, or `sandbox: on` to require it. In benchmark runs, writing under `/tests` is refused; elsewhere `/tests` is an ordinary path.
586585

587586
## Terminal-Bench with Harbor
588587

@@ -624,7 +623,7 @@ It learns only from tasks listed under `dev` in a split file (`--split`, default
624623

625624
**My files are not being used.** Run `gitagent validate <dir>`: it lists every problem with a file and line number. Warnings at startup (`gitagent: warning: …`) name files that were skipped.
626625

627-
**A command or file write is refused.** On Linux the agent's shell runs in a sandbox that allows writes only under the working folder, the output folder, the temp folders and `~/.cache`. Set `sandbox: off` in `gitagent.yaml` if the agent must write elsewhere. A hook may also have blocked the action; the `$` line says so in its result.
626+
**A command or file write is refused.** On Linux and macOS the agent's shell runs in a sandbox that allows writes only under the working folder, the output folder, the temp folders and `~/.cache`. Set `sandbox: off` in `gitagent.yaml` if the agent must write elsewhere. A hook may also have blocked the action; the `$` line says so in its result.
628627

629628
**The session pauses with `! approval needed`.** The agent's compliance settings ask a person for that action. Answer `y` or `n`. Without a terminal, for example in a pipe, such requests are denied.
630629

‎crates/cli/src/main.rs‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@ fn sandbox_dirs(
755755
"auto" => !bench && gitagent_sandbox::available(),
756756
"on" => {
757757
if !gitagent_sandbox::available() {
758-
bail!("sandbox: on needs Linux (Landlock and seccomp)");
758+
bail!("sandbox: on needs Linux (Landlock and seccomp) or macOS (sandbox-exec)");
759759
}
760760
true
761761
}
@@ -773,8 +773,19 @@ fn sandbox_dirs(
773773
PathBuf::from("/var/tmp"),
774774
PathBuf::from("/dev"),
775775
];
776-
if let Some(home) = std::env::var_os("HOME") {
777-
dirs.push(PathBuf::from(home).join(".cache"));
776+
if let Some(home) = std::env::var_os("HOME").map(PathBuf::from) {
777+
dirs.push(home.join(".cache"));
778+
if cfg!(target_os = "macos") {
779+
dirs.push(home.join("Library/Caches"));
780+
}
781+
}
782+
// macOS keeps the per-user cache folder (compilers' module caches) next
783+
// to the per-user temp folder `.../T`.
784+
if cfg!(target_os = "macos") {
785+
let tmp = std::env::temp_dir();
786+
if tmp.file_name().is_some_and(|n| n == "T") {
787+
dirs.extend(tmp.parent().map(|p| p.to_path_buf()));
788+
}
778789
}
779790
std::fs::create_dir_all(out_dir)?;
780791
Ok(Some(dirs))

‎crates/cli/src/sandbox_exec.rs‎

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
//! `gitagent __sandbox --write <dir>... -- <program> [args...]`: applies the
2-
//! sandbox to this process, then replaces it with the program (the agent's
3-
//! shell). Used only internally, when the sandbox is on.
1+
//! `gitagent __sandbox --write <dir>... -- <program> [args...]`: prepares the
2+
//! sandbox (on Linux by restricting this process), then replaces this process
3+
//! with the program (the agent's shell), started inside it. Used only
4+
//! internally, when the sandbox is on.
45
56
use std::ffi::OsString;
67
use std::os::unix::process::CommandExt;
78
use std::path::PathBuf;
8-
use std::process::{Command, ExitCode};
9+
use std::process::ExitCode;
910

1011
use gitagent_sandbox::{Policy, Status};
1112

@@ -34,17 +35,17 @@ pub(crate) fn run(args: &[OsString]) -> ExitCode {
3435
eprintln!("gitagent __sandbox: missing `-- <program>`");
3536
return ExitCode::from(2);
3637
};
37-
match gitagent_sandbox::apply(&Policy { writable }) {
38-
Ok(Status::Enforced) => {}
39-
Ok(Status::Partial(note) | Status::Unsupported(note)) => {
40-
eprintln!("gitagent: sandbox limited: {note}");
41-
}
38+
let sandbox = match gitagent_sandbox::prepare(&Policy { writable }) {
39+
Ok(s) => s,
4240
Err(e) => {
4341
eprintln!("gitagent: sandbox failed: {e:#}");
4442
return ExitCode::from(2);
4543
}
44+
};
45+
if let Status::Partial(note) | Status::Unsupported(note) = &sandbox.status {
46+
eprintln!("gitagent: sandbox limited: {note}");
4647
}
47-
let err = Command::new(&program).args(rest).exec();
48+
let err = sandbox.command(&program).args(rest).exec();
4849
eprintln!("gitagent __sandbox: cannot start {program:?}: {err}");
4950
ExitCode::from(127)
5051
}

0 commit comments

Comments
 (0)