Files
zed/crates/sandbox/README.md
T
Cameron Mcloughlinandzed-zippy[bot] 8f92822cbf agent: Sandbox security review and docs update (#60291)
Closes security loopholes and updates docs:
- installs seccomp filter for blocking naughty syscalls
- tightens macos seatbelt profile
- fetch tool responses that redirect are now constrained by allowed
domains list

Also adds a few "Learn More" buttons that link to the new docs.

Also fixes a bug where the agent would try to create a
`~/.config/zed/AGENTS.md` directory

Also adds unicode confusable detection to URL/path privilege escalation
prompts.

---

Release Notes:

- N/A or Added/Fixed/Improved ...

---------

Co-authored-by: zed-zippy[bot] <234243425+zed-zippy[bot]@users.noreply.github.com>
2026-07-10 22:51:41 +00:00

331 lines
15 KiB
Markdown

# `sandbox`
Cross-platform sandboxing for shell commands.
## Overview
This crate allows creating a `Sandbox` according to some `SandboxPolicy`. A
`SandboxPolicy` expresses:
- what filesystem operations are allowed
- which kinds of networking operations are allowed
- which paths stay protected even under writable subtrees
Once you have a `Sandbox`, you can use it to run commands that are constrained
by that policy.
## Security model
The sandbox itself assumes all untrusted code is maximally hostile. It does
*not* assume that the untrusted code is written by a
well-meaning-but-perhaps-marginally-unaligned AI agent.
However, practical limitations make the default profile in Zed not secure
against attacks. An attacker with read/write access to the current directory can:
- create a new Rust project in the current dir
- create a proc macro library containing malicious code
- use that macro in the project somewhere
- rust-analyzer will run that proc macro outside the sandbox
This can be mitigated by:
- disabling any language servers with the capability to run untrusted code
- keeping sensitive project metadata such as `.git` protected, since write
access to those paths can be escalated to unsandboxed code execution via hooks,
`$EDITOR`, and other mechanisms
## Implementation
The implementations are highly platform-specific:
- Mac support comes from Seatbelt
- Linux support comes from [bubblewrap], implemented via Linux [namespaces].
- Windows:
- WSL: same as Linux
- non-WSL: not supported
Note that WSL shells can be used on all Windows projects, regardless of whether
the files are stored in the Linux filesystem or not.
Though not defined in this crate, the default grants provided by the Zed agent is:
- read-only access to all files
- read/write access to current project directories
- read-only access to any Git metadata, including those in project directories
- read/write access to an isolated tempdir, cleared between terminals
- on MacOS, this is set via the `$TMPDIR` variable and is not at `/tmp`
## Architecture
Filesystem restrictions are different on all platforms. Network restrictions
however largely follow a similar approach (details omitted):
- Disable networking in the sandbox, except for one localhost port
- Within the sandbox, set `HTTP_PROXY` and friends to tell programs to
communicate with that socket
- On the Zed host side, there is a proxy that listens to that port that enforces
domain filtering
On Linux specifically, there is an intermediate socket that allows data to flow
out of the sandbox. This is required because, unlike seatbelt, bubblewrap runs
sandboxed programs in an entirely separate network stack (i.e. it has a
different `localhost`).
### Linux
A naive implementation on Linux would work roughly like:
- Figure out which paths are read-only and which are read/write
- Run the sandboxed program through `bwrap` with `--ro-bind` for read-only and
`--bind` for read/write
However, this fails because of a nasty TOCTOU.
#### The nasty TOCTOU
Consider the following case:
- an attacker has convinced the user to open `project`, which contains an evil
`AGENTS.md`
- They have also convinced the user to grant a writable path inside `project`
- This means that the user will have given the following permissions to the sandbox:
- read/write access to `project`
- read/write access to `project/cache`
- read/write access to an isolated `/tmp`
- read-only access to `/`
- The `AGENTS.md` instructs the LLM to do the following:
- spawn two subagents
- the first subagent tries to swap `project/cache` with a symlink to
`/home/alice` using [`renameat2(2)`][renameat2] with the `RENAME_EXCHANGE`
flag set
- the second subagent tries to run `echo 'export PATH="proj/obfuscated.../evil_eavesdropping_sudo/bin:$PATH"' >> proj/cache/.bashrc`
- The user sends a prompt, we pick up the evil `AGENTS.md` instructions, and the
agent does them
- Zed checks whether paths are symlinks outside the allowable paths before
passing them to bubblewrap, but there is a **time delay** between this check
and when bubblewrap mounts them.
- In this delay, the `renameat2` may succeed, which means that:
- At check time, `proj/cache` is a subdirectory of `proj`
- At bind time, `proj/cache` is a symlink to `/home/alice`
- The attacker is now running code in a sandbox which has **read/write** access
to `/home/alice`, and so the second command to inject the malicious
credential-stealing sudo succeeds.
```mermaid
sequenceDiagram
participant Agent as Zed Agent
participant S1 as Subagent 1 swapper
participant S2 as Subagent 2 writer
participant Zed as Zed path validation
participant BW as bubblewrap
Note over Agent: Evil AGENTS.md picked up, writable path granted
Note over Agent: Grants rw project, rw project/cache, rw /tmp, ro /
Agent->>S1: spawn swap project/cache for a symlink to /home/alice
Agent->>S2: spawn append PATH hijack to project/cache/.bashrc
Zed->>Zed: check project/cache is not an out-of-bounds symlink
Note over Zed: at check time it is a real subdirectory, so OK
Note over Zed,BW: time delay, time-of-check to time-of-use
S1->>S1: renameat2 RENAME_EXCHANGE wins the race
Note over S1: project/cache is now a symlink to /home/alice
Zed->>BW: bind project/cache into the sandbox
BW->>BW: re-resolve project/cache, following symlink to /home/alice
S2->>BW: write project/cache/.bashrc
BW-->>S2: write lands in /home/alice/.bashrc
Note over S2: escalation, project-scoped grant becomes arbitrary write
```
Note that this attack requires *two nested directories*, each with read/write
grants. A single grant is insufficient, because you must mutate *a path which is
used as a `--bind` argument*. If you cannot mutate a parent (because we are
assuming no nested directories), then the only part you can mutate is the the
read/write grant path itself (i.e. `/home/alice/project`). But, in bubblewrap's
model, doing this requires write access to the *parent* (i.e. `/home/alice`),
which we have assumed is not present.
`./bind_source_toctou_test.sh` is a small bash script demonstrating this
behaviour. It tries to replace the current dir with a symlink, and it fails due
to invalid permissions.
#### The naive (and incorrect) fix
It is tempting to read the previous paragraph and think "that's simple, just
disallow nested directories". In theory, this would work. A read/write grant to `/foo` and `/foo/bar` is logically equivalent to a read/write grant to just `/foo`. And the following is *true*:
> If there is no pair of read/write grants such that one is an ancestor of the
> other, this TOCTOU attack is impossible.
However, this is not a viable countermeasure for two reasons:
1. It requires that no two grants of this kind ever exist at the same time
*globally across the whole system*. For example, opening `/foo` in one zed
window and `/foo/bar` in another would re-open this exploit. Even if we did
mitigate this by widening `/foo/bar` to have access to `/foo` (which in
itself is an unacceptable privilege escalation), we still wouldn't be able to
control non-Zed processes.
2. It prevents the potentially useful pattern of:
- read/write access to `/foo`
- read-only access to `/foo/bar`
- read/write access to `/foo/bar/baz`
Because of this, we need something more robust.
#### The correct fix
The correct fix involves using file descriptors as the source of truth, rather
than paths. This is important because file descriptors are stable once opened,
regardless of what happens to the path. The symlink swap attack will not change
which inode the FD points to.
This leads to a different question: how do we tell `bwrap` to use FDs instead of paths?
`bwrap` does support `--bind-fd`, but this has another issue: "how do you get
FDs into the `bwrap` process?
There are two options:
1. open the FDs in zed, clear `CLOEXEC`, then fork/exec into bwrap with the FD arguments
2. send them into a helper process inside the sandbox using an `SCM_RIGHTS`
socket, and validate from the inside of the sandbox.
We chose option 2 because we already have a helper process inside the sandbox
(to set up the HTTP proxy).
The flow for this approach in detail is:
- open each *writable* path we `--bind` and get an `O_PATH` FD (which pins the
inode without granting read/write on its contents)
- create an `SCM_RIGHTS` socket over which we can send the FDs
- run `bwrap --bind /path1 /path1 ... -- zed --zed-linux-sandbox-launcher <untrusted program args>`
- note: we use (potentially swapped) paths
- we also mount the socket in the sandbox
- the sandbox bridge reads the FDs from the socket, does the following for each
read/write bind:
- `fstat` the FD to get the `(device, inode)`
- `lstat` the corresponding mount path to get its `(device, inode)`
- check that they match
Note that this is essentially the check that `bwrap --bind-fd` does internally.
- if all binds match, run the untrusted command, otherwise refuse to execute
```mermaid
sequenceDiagram
participant Zed as Zed host
participant BW as bubblewrap
participant Bridge as sandbox-bridge in sandbox
participant Prog as untrusted program
Zed->>Zed: open O_PATH FD per writable path, pinning the inode
Zed->>Zed: create SCM_RIGHTS socket
Zed->>BW: exec bwrap, binding paths, then zed --zed-linux-sandbox-launcher
Note over Zed,BW: binds use possibly-swapped paths, socket mounted in sandbox
Zed->>Bridge: send FDs over the SCM_RIGHTS socket
loop each writable bind
Bridge->>Bridge: fstat the FD to get device and inode
Bridge->>Bridge: lstat the mount path to get device and inode
Bridge->>Bridge: compare the two pairs
end
alt all binds match
Bridge->>Prog: exec the untrusted command
else any mismatch, a path was swapped
Bridge-->>Zed: refuse to execute
end
```
If the attacker managed to change a path to point to a different inode to when
the FD was captured, the check will fail, and we don't run the untrusted
command.
#### Blocking IPC-socket escapes (seccomp)
A read-only bind mount does **not** stop a process from `connect()`-ing to a
Unix-domain socket: the kernel deliberately exempts sockets (and FIFOs, and
device nodes) from the read-only-filesystem write check, because connecting
modifies no filesystem data. So even with `--ro-bind / /`, a sandboxed command
could reach a session IPC socket in `$XDG_RUNTIME_DIR` (a Wayland compositor,
the D-Bus session bus, ...) or a system socket like the Docker daemon, and use
it to run a process *outside* the sandbox — defeating both the filesystem and
network restrictions, regardless of the read/write grant. `--unshare-net` does
not help: it isolates abstract sockets and TCP/IP, but these are pathname
sockets on the bound filesystem.
The fix is a seccomp-BPF filter (built with `seccompiler`) installed on the
untrusted command just before it runs. Rather than trying to hide every socket,
it stops the command from *obtaining* one it could escape through:
- `socket()` is allowed only for `AF_INET`/`AF_INET6`/`AF_NETLINK`; every other
family — notably `AF_UNIX` (session IPC) and `AF_VSOCK` (the VM host) — is
denied with `EPERM`.
- `socketpair()` is allowed only for `AF_UNIX` (a process-local pair that can't
reach anything outside the sandbox).
- `io_uring_*` is denied, so its ring operations can't create/connect a socket
without going through the filtered syscalls; `ptrace`/`process_vm_*` are denied.
- `connect`/`recvmsg`/`sendmsg`/`bind`/`listen`/`accept` stay allowed. With no
way to create a forbidden socket — and, by fd hygiene, none inherited — there
is nothing dangerous for them to act on, and blocking `connect` would break
legitimate loopback/proxy use. `seccompiler`'s architecture check kills
foreign-arch syscalls, closing the 32-bit (`socketcall`) bypass.
The filter must apply to the command but **not** to the launcher/bridge process,
which keeps using `AF_UNIX` to reach the host proxy for every request. So it is
installed inline right before `exec` in the direct case, and via the child's
`pre_exec` in the restricted-network bridge case. Because the filter lives in the
in-sandbox launcher, the launcher is now **always** run (even when there are no
writable binds to validate and no bridge), so the filter is always installed.
This is Linux/WSL-specific. On macOS, Seatbelt gates Unix-socket `connect` as a
separate `network-outbound` capability that is denied by default, so the same
escape is already closed there without a seccomp filter.
### Windows
> [!NOTE] The Windows implementation depends heavily on the details of the Linux
implementation.
The Linux approach works perfectly on WSL in theory (WSL uses a "regular linux
kernel"), but there is one practical thorn: the zed host code that creates the
FD is now running on Windows, but we need Linux file descriptors.
To work around this, we launch `zed --wsl-sandbox-helper` in WSL, which is a
shim that captures the FDs and sets up the socket. We download this to
`~/.local/libexec/zed`, so that it does not conflict with the Windows `zed.exe`
binary that WSL will inject into the Linux `$PATH` (yes the `.exe` is stripped).
### MacOS
MacOS uses seatbelt, which enforces a rules file. This generally makes
enforcement more straightforward. Unlike Linux, paths are resolved and checked
at *syscall time*, meaning the symlink swap attack will not succeed.
However, care has to be taken with various parts of the rules file, specifically
when it comes to `mach-lookup`. This controls access to, among other things,
Launch Services, which allows unsandboxed code execution.
The exact policy is defined in `src/macos_seatbelt.rs`, and is inspired by a
mixture of Codex and Chromium's rules.
Some of the denied services are somewhat questionable (i.e.
`com.apple.FontObjectsServer`) - there are legitimate uses for an application to
use this, but on the other hand, fonts can contain executable code, and have
historically been exploited to achieve RCE. Given that, in the Zed agent, it is
easy to opt-out of the sandbox, denying seems like a good choice. But we may
want to revisit this.
## Code design
### `HostFilesystemLocation`
As mentioned above, TOCTOUs are a real issue. MacOS is not vulnerable to the
TOCTOU that affected Linux, but there is still a risk if we canonicalize paths
twice with a time delay between.
To mitigate this, sensitive APIs take a `HostFilesystemLocation`. This is:
- an `Arc<OwnedFd>` on Linux
- a `PathBuf` on MacOS
This type does not expose its inner value, and so this encourages the developer
to capture and validate the path once, before passing it into this type.
### `SandboxFilesystemLocation`
A thin wrapper around a `PathBuf` representing a location *inside* the sandbox.
No hardening is required - the worst a tampered in-sandbox path can do is expose
already-granted host files at a different in-sandbox path.
[bubblewrap]: https://github.com/containers/bubblewrap
[namespaces]: https://en.wikipedia.org/wiki/Linux_namespaces
[renameat2]: https://man.archlinux.org/man/renameat2.2.en