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
- Replace symlinks inside the skill package with real files (`cp -rL` the package or dereference on extraction).
- Remove the symlinks if the assets they point to are not needed.
- 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
- Ship skill packages as regular files only.
- Extract archives with symlink dereferencing enabled.
- Scan packages for symlinks before publishing or importing them.
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
- audited skill path does not match owned package
- refusing to import unsafe external skill package
- refusing to mutate symlinked Codewhale skills path component
- audited skill root identity changed; refusing mutation
- built-in plugin path may not be a symbolic link or reparse…
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)