zeroclaw-labs/zeroclaw · error · anyhow::Error

Refusing to copy symlink within skill source: {}

Error message

Refusing to copy symlink within skill source: {}

What it means

During the recursive secure copy, an entry inside the source tree is a symlink; the copy aborts and install_local_skill_source removes the partially created destination. The post-install audit would reject the symlink anyway — the secure copy refuses earlier so nothing untrustworthy lands on disk.

Source

Thrown at crates/zeroclaw-runtime/src/skills/mod.rs:2057

    std::fs::create_dir_all(dest).with_context(|| {
        format!(
            "failed to create destination {}",
            dest.display().to_string()
        )
    })?;
    for entry in std::fs::read_dir(src)? {
        let entry = entry?;
        let src_path = entry.path();
        let dest_path = dest.join(entry.file_name());
        let metadata = std::fs::symlink_metadata(&src_path).with_context(|| {
            format!(
                "failed to read metadata for {}",
                src_path.display().to_string()
            )
        })?;

        if metadata.file_type().is_symlink() {
            anyhow::bail!(
                "Refusing to copy symlink within skill source: {}",
                src_path.display()
            );
        }

        if metadata.is_dir() {
            copy_dir_recursive_secure(&src_path, &dest_path)?;
        } else if metadata.is_file() {
            std::fs::copy(&src_path, &dest_path).with_context(|| {
                format!(
                    "failed to copy skill file from {} to {}",
                    src_path.display().to_string(),
                    dest_path.display()
                )
            })?;
        }
    }

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Find the links: find <skill-dir> -type l
  2. Replace each symlink with a real copy (cp -L) and retry the install
  3. Restructure the skill to be fully self-contained instead of linking out

Example fix

# before: skill contains a symlink
find my-skill -type l   # my-skill/assets -> ../../shared/assets

# after: materialize real copies
rm my-skill/assets && cp -L ../../shared/assets my-skill/assets
zeroclaw skills install ./my-skill
Defensive patterns

Strategy: validation

Validate before calling

fn contains_symlink(dir: &std::path::Path) -> std::io::Result<bool> {
    for entry in std::fs::read_dir(dir)? {
        let entry = entry?;
        let meta = std::fs::symlink_metadata(entry.path())?;
        if meta.file_type().is_symlink() {
            return Ok(true);
        }
        if meta.is_dir() && contains_symlink(&entry.path())? {
            return Ok(true);
        }
    }
    Ok(false)
}

if contains_symlink(std::path::Path::new(source))? {
    anyhow::bail!("skill source contains symlinks; materialize them first");
}

Prevention

When it happens

Trigger: A skill source tree containing any symlinked file or subdirectory, e.g. 'ln -s ../../shared/assets assets' inside the skill folder.

Common situations: Monorepo skills sharing files via symlinks; setup scripts using ln -s on macOS/Linux; npm-style linked dependencies vendored into a skill.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/4cd0acc6bf6c6136. Report an issue: GitHub.