zed-industries/zed · error · io::Error

PermissionDenied

PermissionDenied

Error message

sandbox write grant {} is a symlink, not a directory

What it means

Zed's sandbox component validates each configured write grant by canonicalizing it with an `O_PATH|O_NOFOLLOW` file descriptor and checking its file type via `fstat`. If the grant path's leaf resolves to a symlink (or, more generally, is not a directory), `from_canonical` returns `io::ErrorKind::PermissionDenied` with the message "sandbox write grant {path} is a symlink, not a directory". The check exists because `readlink` on an O_NOFOLLOW fd returns the symlink's own path, so the earlier path-equality comparison cannot catch a symlinked leaf.

Source

Thrown at crates/sandbox/src/util/canonical_path.rs:150

        {
            use std::os::unix::fs::OpenOptionsExt as _;
            // `O_NOFOLLOW` makes a symlink *leaf* open the symlink itself
            // (harmless with `O_PATH`) rather than its target, so we can detect
            // and reject it below; intermediate components are still traversed
            // and caught by the canonical-path comparison.
            let file = std::fs::OpenOptions::new()
                .read(true)
                .custom_flags(libc::O_PATH | libc::O_CLOEXEC | libc::O_NOFOLLOW)
                .open(&path)?;
            let fd = OwnedFd::from(file);

            // Reject a symlink leaf outright: a grant must name a real directory,
            // and `readlink` of an `O_PATH|O_NOFOLLOW` fd on a symlink returns
            // the symlink's *own* path (equal to `path`), so the comparison
            // below wouldn't catch it.
            let stat = nix::sys::stat::fstat(&fd).map_err(io::Error::from)?;
            if stat.st_mode & libc::S_IFMT == libc::S_IFLNK {
                return Err(io::Error::new(
                    io::ErrorKind::PermissionDenied,
                    format!(
                        "sandbox write grant {} is a symlink, not a directory",
                        path.display()
                    ),
                ));
            }

            // Load-bearing: the pinned inode's real path must still be exactly
            // the approved canonical path. If any component became a symlink
            // after approval, the fd resolves elsewhere and this diverges.
            let current = std::fs::read_link(format!("/proc/self/fd/{}", fd.as_raw_fd()))?;
            if current != path {
                return Err(io::Error::new(
                    io::ErrorKind::PermissionDenied,
                    format!(
                        "sandbox write grant {} was redirected to {}",
                        path.display(),

View on GitHub (pinned to 5a9b9558db)

Solutions

  1. Resolve the symlink and pass the real directory: use the output of `readlink -f <path>` (or `realpath`) as the write grant instead of the symlink path.
  2. On macOS, replace `/tmp/...` grants with their `/private/tmp/...` real path.
  3. Restructure the environment so the sandboxed workspace is a real directory (move files rather than symlinking the whole project dir).
  4. If the symlink is intentional, grant the resolved parent (the symlink's target) and work inside it, not through the link.

Example fix

// before
zed --sandbox-write-grant /tmp/project

// after
zed --sandbox-write-grant "$(readlink -f /tmp/project)"
Defensive patterns

Strategy: validation

Validate before calling

use std::path::Path;

fn validate_write_grant(path: &Path) -> std::io::Result<()> {
    let meta = std::fs::metadata(path)?; // follows symlinks: stat the target
    if !meta.is_dir() {
        return Err(std::io::Error::new(
            std::io::ErrorKind::PermissionDenied,
            format!("{} is not a directory", path.display()),
        ));
    }
    if std::fs::symlink_metadata(path)?.file_type().is_symlink() {
        let real = std::fs::canonicalize(path)?;
        eprintln!("grant {} is a symlink; use {} instead", path.display(), real.display());
    }
    Ok(())
}
// call before handing the path to the sandbox: validate_write_grant(&grant)?;

Type guard

fn is_real_directory(path: &std::path::Path) -> bool {
    std::fs::symlink_metadata(path)
        .map(|m| m.is_dir() && !m.file_type().is_symlink())
        .unwrap_or(false)
}

Try / catch

match launch_sandboxed(grant_path) {
    Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied
        && err.to_string().contains("is a symlink") =>
    {
        let real = std::fs::canonicalize(grant_path)?;
        launch_sandboxed(&real)?
    }
    Err(err) => return Err(err.into()),
    Ok(handle) => handle,
}

Prevention

When it happens

Trigger: Launching Zed (or a sandboxed subprocess) with a write grant (`--sandbox-write-grant` / sandbox dir policy) whose path is a symlink — e.g. pointing a grant at `/tmp/xyz -> /private/tmp/xyz` on macOS, or a symlinked dotdir like `~/projects -> /data/projects` passed directly as the grant.

Common situations: macOS `/tmp` -> `/private/tmp` indirection; symlinked home subdirectories (dotfiles managers stow-style links); CI containers where mounted paths are symlinked; passing `~` expansions that resolve through symlinks.

Related errors


AI-assisted analysis of zed-industries/zed@5a9b9558db (2026-09-05). Data as JSON: /api/errors/00dac5a283bd8e90. Report an issue: GitHub.