{"record":{"id":"5069082bcae3ea5b","repo":"Hmbown/CodeWhale","slug":"refusing-to-copy-symlink","errorCode":null,"errorMessage":"refusing to copy symlink {}","messagePattern":"refusing to copy symlink (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/tui/src/skills/mutation.rs","lineNumber":831,"sourceCode":"        outcome: SkillMutationOutcome::Imported,\n    })\n}\n\nfn copy_skill_package(src: &Path, dest: &Path) -> Result<()> {\n    // Fail closed: source must already pass package digest (no symlinks).\n    package_digest::compute_package_digest(src).context(\"source package is not safe to copy\")?;\n    copy_dir_regular_files(src, dest)?;\n    Ok(())\n}\n\nfn copy_dir_regular_files(src: &Path, dest: &Path) -> Result<()> {\n    fs::create_dir_all(dest)?;\n    for entry in fs::read_dir(src)? {\n        let entry = entry?;\n        let path = entry.path();\n        let meta = fs::symlink_metadata(&path)?;\n        if meta.file_type().is_symlink() {\n            bail!(\"refusing to copy symlink {}\", path.display());\n        }\n        let name = entry.file_name();\n        let name_str = name.to_string_lossy();\n        if name_str == install::INSTALLED_FROM_MARKER\n            || name_str == install::TRUSTED_MARKER\n            || name_str == \".system-installed-version\"\n        {\n            continue;\n        }\n        let target = dest.join(&name);\n        if meta.is_dir() {\n            if name_str.starts_with('.') {\n                continue;\n            }\n            copy_dir_regular_files(&path, &target)?;\n        } else if meta.is_file() {\n            if name_str.starts_with('.') {\n                continue;","sourceCodeStart":813,"sourceCodeEnd":849,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/73e0f67d83c59909b571efdfc88c4bc28c309cb1/crates/tui/src/skills/mutation.rs#L813-L849","documentation":"`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.","triggerScenarios":"`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.","commonSituations":"A skill repo checked out with symlinked assets; an archive extracted with symlinks preserved; a vendored layout using symlinks to shared modules.","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."],"exampleFix":"// before\nmy-skill/assets -> ../../shared/assets\n// after\ncp -rL my-skill my-skill-real && mv my-skill-real my-skill  # symlinks materialized","handlingStrategy":"validation","validationCode":"fn package_has_symlinks(root: &Path) -> std::io::Result<bool> {\n    for entry in walkdir::WalkDir::new(root) {\n        if entry?.path_is_symlink() { return Ok(true); }\n    }\n    Ok(false)\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Ship skill packages as regular files only.","Extract archives with symlink dereferencing enabled.","Scan packages for symlinks before publishing or importing them."],"tags":["skills","security","symlink"],"backgroundTag":"path-traversal-blocked","analyzedSha":"73e0f67d83c59909b571efdfc88c4bc28c309cb1","analyzedAt":"2026-09-22T01:30:00.501Z","contentChangedAt":"2026-09-22T01:30:00.501Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}