Hmbown/CodeWhale · error

refusing to copy symlink

Error message

refusing to copy symlink {}

What it means

`copy_dir_regular_files` copies only regular files when duplicating a skill package. Any entry that is a symlink aborts the copy, because symlinks could escape the package boundary and point at arbitrary paths outside the owned skills root.

Solutions

  1. Replace symlinks inside the skill package with real files (`cp -rL` the package or dereference on extraction).
  2. Remove the symlinks if the assets they point to are not needed.
  3. Repackage the skill (zip/tar without symlink support) and re-import.

Example fix

// before
my-skill/assets -> ../../shared/assets
// after
cp -rL my-skill my-skill-real && mv my-skill-real my-skill  # symlinks materialized
Defensive patterns

Strategy: validation

Validate before calling

fn package_has_symlinks(root: &Path) -> std::io::Result<bool> {
    for entry in walkdir::WalkDir::new(root) {
        if entry?.path_is_symlink() { return Ok(true); }
    }
    Ok(false)
}

Prevention

When it happens

Trigger: `copy_skill_package` → `copy_dir_regular_files` encounters an entry where `fs::symlink_metadata` reports a symlink — any file or subdirectory inside the source package at any depth.

Common situations: A skill repo checked out with symlinked assets; an archive extracted with symlinks preserved; a vendored layout using symlinks to shared modules.

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 Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/5069082bcae3ea5b. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/skills/mutation.rs:831

        outcome: SkillMutationOutcome::Imported,
    })
}

fn copy_skill_package(src: &Path, dest: &Path) -> Result<()> {
    // Fail closed: source must already pass package digest (no symlinks).
    package_digest::compute_package_digest(src).context("source package is not safe to copy")?;
    copy_dir_regular_files(src, dest)?;
    Ok(())
}

fn copy_dir_regular_files(src: &Path, dest: &Path) -> Result<()> {
    fs::create_dir_all(dest)?;
    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let path = entry.path();
        let meta = fs::symlink_metadata(&path)?;
        if meta.file_type().is_symlink() {
            bail!("refusing to copy symlink {}", path.display());
        }
        let name = entry.file_name();
        let name_str = name.to_string_lossy();
        if name_str == install::INSTALLED_FROM_MARKER
            || name_str == install::TRUSTED_MARKER
            || name_str == ".system-installed-version"
        {
            continue;
        }
        let target = dest.join(&name);
        if meta.is_dir() {
            if name_str.starts_with('.') {
                continue;
            }
            copy_dir_regular_files(&path, &target)?;
        } else if meta.is_file() {
            if name_str.starts_with('.') {
                continue;

View on GitHub (pinned to 73e0f67d83)