{"record":{"id":"8889688730e4f80a","repo":"astrid-runtime/astrid","slug":"symlink-resolves-outside-the-capsule-source-roo","errorCode":null,"errorMessage":"symlink {} resolves outside the capsule source root ({}); refusing to copy (sandbox-escape vector)","messagePattern":"symlink (.+?) resolves outside the capsule source root \\((.+?)\\); refusing to copy \\(sandbox-escape vector\\)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/astrid-capsule-install/src/copy.rs","lineNumber":138,"sourceCode":"fn handle_symlink(src_path: &Path, dst_path: &Path, canonical_root: &Path) -> anyhow::Result<()> {\n    // Canonicalize first — resolves the symlink chain to a real path\n    // we can reason about. A dangling symlink errors here; we treat\n    // that as a hard install failure because a capsule tree with\n    // broken symlinks isn't trustworthy.\n    let resolved: PathBuf = std::fs::canonicalize(src_path).with_context(|| {\n        format!(\n            \"symlink {} could not be canonicalized (dangling or denied)\",\n            src_path.display()\n        )\n    })?;\n\n    // Hard-refuse anything that resolves outside the source tree.\n    // `Path::starts_with` on canonical paths is sound: canonicalize\n    // has already collapsed every `..` and resolved every symlink in\n    // both the resolved target and the root, so there's no path-\n    // traversal escape hatch left.\n    if !resolved.starts_with(canonical_root) {\n        bail!(\n            \"symlink {} resolves outside the capsule source root ({}); \\\n             refusing to copy (sandbox-escape vector)\",\n            src_path.display(),\n            resolved.display()\n        );\n    }\n\n    let resolved_meta = std::fs::metadata(&resolved)\n        .with_context(|| format!(\"stat resolved symlink target {}\", resolved.display()))?;\n\n    if resolved_meta.is_dir() {\n        // Directory symlinks open the door to (a) infinite recursion\n        // when the link points to an ancestor and (b) ballooning\n        // copies of legitimately-shared trees. `npm install` only\n        // produces FILE symlinks for `.bin/` entries; we don't need\n        // directory symlinks for any current capsule layout. Refuse\n        // them outright.\n        bail!(","sourceCodeStart":120,"sourceCodeEnd":156,"githubUrl":"https://github.com/astrid-runtime/astrid/blob/affd8760f44190dbdfbec23403f4c4b642c33112/crates/astrid-capsule-install/src/copy.rs#L120-L156","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","solutions":["Remove or rewrite the offending symlink so it resolves inside the capsule source tree (relative link within the root).","Replace the symlink with a real copy of the target file inside the source tree.","If the target is genuinely external, vendor its content into the capsule rather than linking."],"exampleFix":"// before (inside capsule source)\nbin/tool -> /usr/local/bin/tool\n// after\ncp /usr/local/bin/tool bin/tool   # real file, or relative link: bin/tool -> ../shared/tool","handlingStrategy":"validation","validationCode":"// pre-screen a source tree before installing:\nfn has_escaping_symlinks(root: &Path) -> anyhow::Result<bool> {\n    let canon_root = root.canonicalize()?;\n    for entry in walkdir::WalkDir::new(root).follow_links(false) {\n        let p = entry?.path().to_path_buf();\n        if fs::symlink_metadata(&p)?.file_type().is_symlink() {\n            let target = fs::read_link(&p)?;\n            let resolved = p.parent().unwrap().join(target).canonicalize()?;\n            if !resolved.starts_with(&canon_root) { return Ok(true); }\n        }\n    }\n    Ok(false)\n}","typeGuard":null,"tryCatchPattern":"match copy_capsule_dir_inner(...) {\n    Err(e) if e.to_string().contains(\"sandbox-escape\") => {\n        eprintln!(\"source contains an escaping symlink; vendor the target file instead\");\n    }\n    other => other?,\n}","preventionTips":["Use relative symlinks that stay within the source tree.","Replace external links with real copies before packaging.","Never vendor trees containing absolute symlinks from other machines."],"tags":["rust","security","symlink","path-traversal"],"backgroundTag":"path-traversal-blocked","analyzedSha":"affd8760f44190dbdfbec23403f4c4b642c33112","analyzedAt":"2026-09-09T21:28:12.402Z","contentChangedAt":"2026-09-09T21:28:12.402Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}