# `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

This is not foolproof though - for example, on linux, it's not possible to protect `.git` if it doesn't already exist, and so 

## 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.

#### The subtle flaw: capturing is circular

The scheme above closes the swap-*after*-capture window, but the original
capture step had a hidden circularity. It opened a fresh `O_PATH` FD each run
(following symlinks), then *re-derived* the bind path from that same FD via
`readlink(/proc/self/fd/N)`. Both the pinned inode and the bind path therefore
came from one open of an attacker-influenced path.

That means a symlink swapped in **before** the capture is silently followed: the
open pins the attacker's inode and the readlink reports the attacker's path. The
in-sandbox validator then compares `fstat(fd)` against `lstat(mount)` — but both
sides now agree on the same wrong, attacker-chosen inode, so the check passes.
The validator can only prove the FD and the mount name the same object; it
cannot prove that object is the one the user approved.

#### The complete fix: persist the approval-time canonical path

The fix pins the identity at *approval* time and re-proves it at *enforcement*
time, rather than re-deriving it from the FD every run:

- At approval, we resolve the requested path to its canonical (symlink-free)
  target and persist that canonical path alongside the request.
- At enforcement, we open the persisted canonical path with `O_PATH | O_NOFOLLOW`,
  reject a symlink leaf (an `S_IFLNK` check, since `readlink` of an
  `O_NOFOLLOW` symlink FD returns the symlink's own path and would spuriously
  match), and require `readlink(/proc/self/fd/N) == persisted canonical`. If any
  component became a symlink after approval, the FD resolves elsewhere and this
  diverges, so we fail closed.

This host-side, **pre-mount** readlink check composes with the existing
in-sandbox, **post-mount** `fstat(fd) == lstat(mount)` check to cover every
window:
- a swap *before* the host open is caught by the readlink check;
- a swap *between* the host check and the mount is caught in-sandbox.

The readlink half must run host-side: once the FD is bound, its inode is
remapped to the mount destination inside the sandbox, so an in-sandbox readlink
would just report the mount path and prove nothing.

Grants are persisted as `(requested, resolved-canonical)` pairs. FDs cannot
survive a process restart, so even transient "allow for this thread" grants are
rebuilt from these strings and re-verified. The `requested` path is retained for
display and provenance only — it is never fed back into enforcement.

#### 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`. It wraps a
fully `cfg`-ed, per-OS inner struct so each platform carries exactly the captured
identity its enforcement layer needs:
- Linux: `{ O_PATH fd, canonical_path, untrusted_raw_path }`
- macOS: `{ canonical_path, untrusted_raw_path }`
- other platforms: `{ untrusted_raw_path }` (the real capture happens WSL-side)

The type is opaque: it does not `Deref`, and its paths are readable only through
a display view, never as a value that can be re-fed into a constructor by string.
Equality reflects the actual filesystem object (the inode behind the FD on Linux,
the canonical path on macOS), not the textual raw path.

There are two constructors:
- `capture(raw)` resolves `raw` to its canonical target, **following** symlinks
  (Linux: `O_PATH` open, then `readlink(/proc/self/fd/N)`; macOS: canonicalize).
  Use it for locations that are *not* re-established from persisted state — the
  project's own worktree roots and protected paths (whose parents a sandboxed
  command can't tamper with) — and to resolve a user-requested path at approval
  time so the true target can be shown and persisted.
- `reopen(raw, canonical)` rebuilds a persisted grant and **proves** the object
  now at `canonical` is the one that was approved. On Linux it opens `canonical`
  with `O_PATH | O_CLOEXEC | O_NOFOLLOW`, rejects a symlink leaf (`S_IFLNK`
  check), and requires `readlink(/proc/self/fd/N) == canonical`, failing closed
  otherwise. On macOS the persisted canonical is trusted verbatim, because
  Seatbelt matches the *resolved* access path: a post-approval component swap is
  denied at syscall time rather than redirecting, so no FD/readlink check is
  needed.

`display()` returns a `HostFilesystemLocationDisplay` exposing both the raw
request and the canonical target (plus whether the request was redirected
through a symlink) so the approval UI can render an informed "requested →
granted" disclosure. The raw path is display/provenance only.

### `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
