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>
331 lines
15 KiB
Markdown
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
|