Skip to content

Wrap agent terminal commands in macOS Seatbelt sandbox - #57431

Merged
MartinYe1234 merged 4 commits into
mainfrom
sandboxing/3-tool
May 27, 2026
Merged

Wrap agent terminal commands in macOS Seatbelt sandbox#57431
MartinYe1234 merged 4 commits into
mainfrom
sandboxing/3-tool

Conversation

@rtfeldman

Copy link
Copy Markdown
Contributor

Stacked on top of #57430.

When the sandboxing feature flag is on (macOS only), agent-run terminal commands are launched under /usr/bin/sandbox-exec with a per-command Seatbelt policy:

  • Reads: any path on the filesystem.
  • Writes: each project worktree plus a per-command $TMPDIR.
  • Network: blocked.

The model can request relaxations on individual calls via three new input flags on the terminal tool:

  • allow_network: true
  • allow_fs_write: true
  • unsandboxed: true

Each one triggers a separate always-prompt user approval (bypassing any always_allow rules, since escalation is a stronger trust boundary than the baseline command approval). The flags are silently ignored when sandboxing is off, so the model can't surreptitiously change runtime behavior by setting them in the no-op case.

The per-command tempdir is provisioned regardless of sandbox state so the model can't infer whether the sandbox is in effect by looking at $TMPDIR.

The Seatbelt config file's lifetime is tied to the Terminal entity via an opaque RAII handle (SandboxConfigHandle = Box<dyn Any + Send>), so cancellation paths drop it automatically.

Notes for review

  • The writable scope passed to the sandbox is the project's worktree paths plus the per-command tempdir, not the resolved cd working directory — cd is model-controlled, and using it as the writable scope would let the model widen its own write permissions outside the project.
  • The new tool flags are always present in the JSON schema (with #[serde(default)]), even when the sandbox prompt section isn't rendered. The system prompt only documents them when the section is present, so the model shouldn't try to use them when it's not — but the schema doesn't actively reject them, just ignores them.
  • I opted not to add an end-to-end test for the escalation prompt path: the existing FakeThreadEnvironment ignores the new params and toggling the feature flag in tests is more wiring than felt worth it for a first cut. The pure-function pieces (sandbox_approval_title, schema, deserialization) are covered, and the sandbox crate itself has end-to-end tests that actually invoke sandbox-exec.

Release Notes:

  • N/A

@rtfeldman rtfeldman self-assigned this May 21, 2026
@cla-bot cla-bot Bot added the cla-signed The user has signed the Contributor License Agreement label May 21, 2026
@zed-community-bot zed-community-bot Bot added the staff Pull requests authored by a current member of Zed staff label May 21, 2026
rtfeldman added 3 commits May 21, 2026 14:51
Introduces a new crate that wraps shell invocations under macOS's
sandbox-exec(1) with a Seatbelt policy built from per-command
permissions. Reads are unrestricted; writes are restricted to a
caller-provided list of directories; network access and unrestricted
filesystem writes must be opted into per command.

No callers yet — the agent terminal tool wires up in a follow-up.
Adds a 'sandboxing' feature flag (off for staff by default) and, when
it's on, renders a 'Terminal sandbox' section in the agent's system
prompt that describes which paths are writable and which per-command
flags the model can request to relax the sandbox.

No behavior change to terminal execution yet — the next commit wires
the actual sandbox-exec invocation into the terminal tool.
When the 'sandboxing' feature flag is on (macOS only), agent-run
terminal commands are launched under /usr/bin/sandbox-exec with a
per-command Seatbelt policy:

- Reads: any path on the filesystem.
- Writes: each project worktree plus a per-command $TMPDIR.
- Network: blocked.

The model can request relaxations on individual calls via three new
input flags \u2014 allow_network, allow_fs_write, unsandboxed \u2014 which
trigger a separate always-prompt user approval before the command
runs. The per-command tempdir is provisioned regardless of sandbox
state so the model can't infer whether the sandbox is in effect.

The Seatbelt config file's lifetime is tied to the Terminal entity
via an opaque RAII handle, so cancellation paths drop it
automatically.
@rtfeldman
rtfeldman force-pushed the sandboxing/3-tool branch from 0b41bbd to ff16a04 Compare May 21, 2026 18:51
@rtfeldman
rtfeldman force-pushed the sandboxing/2-prompt branch from a016ec2 to 461142f Compare May 21, 2026 18:51
@rtfeldman
rtfeldman marked this pull request as ready for review May 27, 2026 20:30
// user's shared `/tmp`. Decoupling it from sandbox state also
// means the model can't infer the sandbox state by looking at
// `$TMPDIR`.
let temp_dir = tempfile::Builder::new()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Temp dir life cycle should be tied to the thread lifecycle.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non Trivial change will be done in a follow up PR

Base automatically changed from sandboxing/2-prompt to main May 27, 2026 21:14
@MartinYe1234
MartinYe1234 added this pull request to the merge queue May 27, 2026
Merged via the queue into main with commit f0341c9 May 27, 2026
32 checks passed
@MartinYe1234
MartinYe1234 deleted the sandboxing/3-tool branch May 27, 2026 21:41
TomPlanche pushed a commit to TomPlanche/zed that referenced this pull request Jun 2, 2026
…s#57431)

Stacked on top of zed-industries#57430.

When the `sandboxing` feature flag is on (macOS only), agent-run
terminal commands are launched under `/usr/bin/sandbox-exec` with a
per-command Seatbelt policy:

- **Reads:** any path on the filesystem.
- **Writes:** each project worktree plus a per-command `$TMPDIR`.
- **Network:** blocked.

The model can request relaxations on individual calls via three new
input flags on the `terminal` tool:

- `allow_network: true`
- `allow_fs_write: true`
- `unsandboxed: true`

Each one triggers a separate always-prompt user approval (bypassing any
`always_allow` rules, since escalation is a stronger trust boundary than
the baseline command approval). The flags are silently ignored when
sandboxing is off, so the model can't surreptitiously change runtime
behavior by setting them in the no-op case.

The per-command tempdir is provisioned regardless of sandbox state so
the model can't infer whether the sandbox is in effect by looking at
`$TMPDIR`.

The Seatbelt config file's lifetime is tied to the `Terminal` entity via
an opaque RAII handle (`SandboxConfigHandle = Box<dyn Any + Send>`), so
cancellation paths drop it automatically.

### Notes for review

- The writable scope passed to the sandbox is the project's worktree
paths plus the per-command tempdir, **not** the resolved `cd` working
directory — `cd` is model-controlled, and using it as the writable scope
would let the model widen its own write permissions outside the project.
- The new tool flags are always present in the JSON schema (with
`#[serde(default)]`), even when the sandbox prompt section isn't
rendered. The system prompt only documents them when the section is
present, so the model shouldn't try to use them when it's not — but the
schema doesn't actively reject them, just ignores them.
- I opted not to add an end-to-end test for the escalation prompt path:
the existing `FakeThreadEnvironment` ignores the new params and toggling
the feature flag in tests is more wiring than felt worth it for a first
cut. The pure-function pieces (`sandbox_approval_title`, schema,
deserialization) are covered, and the sandbox crate itself has
end-to-end tests that actually invoke `sandbox-exec`.

Release Notes:

- N/A

---------

Co-authored-by: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com>
jonx pushed a commit to jonx/zed-aros that referenced this pull request Jul 17, 2026
…s#57431)

Stacked on top of zed-industries#57430.

When the `sandboxing` feature flag is on (macOS only), agent-run
terminal commands are launched under `/usr/bin/sandbox-exec` with a
per-command Seatbelt policy:

- **Reads:** any path on the filesystem.
- **Writes:** each project worktree plus a per-command `$TMPDIR`.
- **Network:** blocked.

The model can request relaxations on individual calls via three new
input flags on the `terminal` tool:

- `allow_network: true`
- `allow_fs_write: true`
- `unsandboxed: true`

Each one triggers a separate always-prompt user approval (bypassing any
`always_allow` rules, since escalation is a stronger trust boundary than
the baseline command approval). The flags are silently ignored when
sandboxing is off, so the model can't surreptitiously change runtime
behavior by setting them in the no-op case.

The per-command tempdir is provisioned regardless of sandbox state so
the model can't infer whether the sandbox is in effect by looking at
`$TMPDIR`.

The Seatbelt config file's lifetime is tied to the `Terminal` entity via
an opaque RAII handle (`SandboxConfigHandle = Box<dyn Any + Send>`), so
cancellation paths drop it automatically.

### Notes for review

- The writable scope passed to the sandbox is the project's worktree
paths plus the per-command tempdir, **not** the resolved `cd` working
directory — `cd` is model-controlled, and using it as the writable scope
would let the model widen its own write permissions outside the project.
- The new tool flags are always present in the JSON schema (with
`#[serde(default)]`), even when the sandbox prompt section isn't
rendered. The system prompt only documents them when the section is
present, so the model shouldn't try to use them when it's not — but the
schema doesn't actively reject them, just ignores them.
- I opted not to add an end-to-end test for the escalation prompt path:
the existing `FakeThreadEnvironment` ignores the new params and toggling
the feature flag in tests is more wiring than felt worth it for a first
cut. The pure-function pieces (`sandbox_approval_title`, schema,
deserialization) are covered, and the sandbox crate itself has
end-to-end tests that actually invoke `sandbox-exec`.

Release Notes:

- N/A

---------

Co-authored-by: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com>
jolutz pushed a commit to jolutz/zed that referenced this pull request Aug 8, 2026
…s#57431)

Stacked on top of zed-industries#57430.

When the `sandboxing` feature flag is on (macOS only), agent-run
terminal commands are launched under `/usr/bin/sandbox-exec` with a
per-command Seatbelt policy:

- **Reads:** any path on the filesystem.
- **Writes:** each project worktree plus a per-command `$TMPDIR`.
- **Network:** blocked.

The model can request relaxations on individual calls via three new
input flags on the `terminal` tool:

- `allow_network: true`
- `allow_fs_write: true`
- `unsandboxed: true`

Each one triggers a separate always-prompt user approval (bypassing any
`always_allow` rules, since escalation is a stronger trust boundary than
the baseline command approval). The flags are silently ignored when
sandboxing is off, so the model can't surreptitiously change runtime
behavior by setting them in the no-op case.

The per-command tempdir is provisioned regardless of sandbox state so
the model can't infer whether the sandbox is in effect by looking at
`$TMPDIR`.

The Seatbelt config file's lifetime is tied to the `Terminal` entity via
an opaque RAII handle (`SandboxConfigHandle = Box<dyn Any + Send>`), so
cancellation paths drop it automatically.

### Notes for review

- The writable scope passed to the sandbox is the project's worktree
paths plus the per-command tempdir, **not** the resolved `cd` working
directory — `cd` is model-controlled, and using it as the writable scope
would let the model widen its own write permissions outside the project.
- The new tool flags are always present in the JSON schema (with
`#[serde(default)]`), even when the sandbox prompt section isn't
rendered. The system prompt only documents them when the section is
present, so the model shouldn't try to use them when it's not — but the
schema doesn't actively reject them, just ignores them.
- I opted not to add an end-to-end test for the escalation prompt path:
the existing `FakeThreadEnvironment` ignores the new params and toggling
the feature flag in tests is more wiring than felt worth it for a first
cut. The pure-function pieces (`sandbox_approval_title`, schema,
deserialization) are covered, and the sandbox crate itself has
end-to-end tests that actually invoke `sandbox-exec`.

Release Notes:

- N/A

---------

Co-authored-by: MartinYe1234 <52641447+MartinYe1234@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cla-signed The user has signed the Contributor License Agreement staff Pull requests authored by a current member of Zed staff

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants