astrid-runtime/astrid · error

symlink {} resolves outside the capsule source root ({}); re

Error message

symlink {} resolves outside the capsule source root ({}); refusing to copy (sandbox-escape vector)

What it means

While copying a capsule source tree, handle_symlink canonicalizes symlink targets and refuses any that resolve outside the canonicalized source root. This blocks a sandbox-escape vector: a malicious or misconfigured capsule source could otherwise plant a symlink that makes the copy routine write files outside the destination.

Source

Thrown at crates/astrid-capsule-install/src/copy.rs:138

fn handle_symlink(src_path: &Path, dst_path: &Path, canonical_root: &Path) -> anyhow::Result<()> {
    // Canonicalize first — resolves the symlink chain to a real path
    // we can reason about. A dangling symlink errors here; we treat
    // that as a hard install failure because a capsule tree with
    // broken symlinks isn't trustworthy.
    let resolved: PathBuf = std::fs::canonicalize(src_path).with_context(|| {
        format!(
            "symlink {} could not be canonicalized (dangling or denied)",
            src_path.display()
        )
    })?;

    // Hard-refuse anything that resolves outside the source tree.
    // `Path::starts_with` on canonical paths is sound: canonicalize
    // has already collapsed every `..` and resolved every symlink in
    // both the resolved target and the root, so there's no path-
    // traversal escape hatch left.
    if !resolved.starts_with(canonical_root) {
        bail!(
            "symlink {} resolves outside the capsule source root ({}); \
             refusing to copy (sandbox-escape vector)",
            src_path.display(),
            resolved.display()
        );
    }

    let resolved_meta = std::fs::metadata(&resolved)
        .with_context(|| format!("stat resolved symlink target {}", resolved.display()))?;

    if resolved_meta.is_dir() {
        // Directory symlinks open the door to (a) infinite recursion
        // when the link points to an ancestor and (b) ballooning
        // copies of legitimately-shared trees. `npm install` only
        // produces FILE symlinks for `.bin/` entries; we don't need
        // directory symlinks for any current capsule layout. Refuse
        // them outright.
        bail!(

View on GitHub (pinned to affd8760f4)

Solutions

  1. Remove or rewrite the offending symlink so it resolves inside the capsule source tree (relative link within the root).
  2. Replace the symlink with a real copy of the target file inside the source tree.
  3. If the target is genuinely external, vendor its content into the capsule rather than linking.

Example fix

// before (inside capsule source)
bin/tool -> /usr/local/bin/tool
// after
cp /usr/local/bin/tool bin/tool   # real file, or relative link: bin/tool -> ../shared/tool
Defensive patterns

Strategy: validation

Validate before calling

// pre-screen a source tree before installing:
fn has_escaping_symlinks(root: &Path) -> anyhow::Result<bool> {
    let canon_root = root.canonicalize()?;
    for entry in walkdir::WalkDir::new(root).follow_links(false) {
        let p = entry?.path().to_path_buf();
        if fs::symlink_metadata(&p)?.file_type().is_symlink() {
            let target = fs::read_link(&p)?;
            let resolved = p.parent().unwrap().join(target).canonicalize()?;
            if !resolved.starts_with(&canon_root) { return Ok(true); }
        }
    }
    Ok(false)
}

Try / catch

match copy_capsule_dir_inner(...) {
    Err(e) if e.to_string().contains("sandbox-escape") => {
        eprintln!("source contains an escaping symlink; vendor the target file instead");
    }
    other => other?,
}

Prevention

When it happens

Trigger: Calling copy_capsule_dir_inner (via the capsule install/copy flow) on a source tree containing a file symlink whose canonicalized target lies outside the source root — absolute links to /etc or $HOME, or relative links climbing past the root with ../ segments.

Common situations: A dev capsule source with convenience links into node_modules of a parent directory or to global tool paths; npm/pnpm layouts with absolute .bin links produced by unusual install setups; a vendored tree copied from another machine with dangling absolute links.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of astrid-runtime/astrid@affd8760f4 (2026-09-09). Data as JSON: /api/errors/8889688730e4f80a. Report an issue: GitHub.