{"record":{"id":"4cd0acc6bf6c6136","repo":"zeroclaw-labs/zeroclaw","slug":"refusing-to-copy-symlink-within-skill-source","errorCode":null,"errorMessage":"Refusing to copy symlink within skill source: {}","messagePattern":"Refusing to copy symlink within skill source: (.+?)","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/skills/mod.rs","lineNumber":2057,"sourceCode":"    std::fs::create_dir_all(dest).with_context(|| {\n        format!(\n            \"failed to create destination {}\",\n            dest.display().to_string()\n        )\n    })?;\n    for entry in std::fs::read_dir(src)? {\n        let entry = entry?;\n        let src_path = entry.path();\n        let dest_path = dest.join(entry.file_name());\n        let metadata = std::fs::symlink_metadata(&src_path).with_context(|| {\n            format!(\n                \"failed to read metadata for {}\",\n                src_path.display().to_string()\n            )\n        })?;\n\n        if metadata.file_type().is_symlink() {\n            anyhow::bail!(\n                \"Refusing to copy symlink within skill source: {}\",\n                src_path.display()\n            );\n        }\n\n        if metadata.is_dir() {\n            copy_dir_recursive_secure(&src_path, &dest_path)?;\n        } else if metadata.is_file() {\n            std::fs::copy(&src_path, &dest_path).with_context(|| {\n                format!(\n                    \"failed to copy skill file from {} to {}\",\n                    src_path.display().to_string(),\n                    dest_path.display()\n                )\n            })?;\n        }\n    }\n","sourceCodeStart":2039,"sourceCodeEnd":2075,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/skills/mod.rs#L2039-L2075","documentation":"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.","triggerScenarios":"A skill source tree containing any symlinked file or subdirectory, e.g. 'ln -s ../../shared/assets assets' inside the skill folder.","commonSituations":"Monorepo skills sharing files via symlinks; setup scripts using ln -s on macOS/Linux; npm-style linked dependencies vendored into a skill.","solutions":["Find the links: find <skill-dir> -type l","Replace each symlink with a real copy (cp -L) and retry the install","Restructure the skill to be fully self-contained instead of linking out"],"exampleFix":"# before: skill contains a symlink\nfind my-skill -type l   # my-skill/assets -> ../../shared/assets\n\n# after: materialize real copies\nrm my-skill/assets && cp -L ../../shared/assets my-skill/assets\nzeroclaw skills install ./my-skill","handlingStrategy":"validation","validationCode":"fn contains_symlink(dir: &std::path::Path) -> std::io::Result<bool> {\n    for entry in std::fs::read_dir(dir)? {\n        let entry = entry?;\n        let meta = std::fs::symlink_metadata(entry.path())?;\n        if meta.file_type().is_symlink() {\n            return Ok(true);\n        }\n        if meta.is_dir() && contains_symlink(&entry.path())? {\n            return Ok(true);\n        }\n    }\n    Ok(false)\n}\n\nif contains_symlink(std::path::Path::new(source))? {\n    anyhow::bail!(\"skill source contains symlinks; materialize them first\");\n}","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Run 'find <skill-dir> -type l' before installing","Materialize shared files with cp -L instead of linking","Keep skill folders self-contained — no links out to repo-shared assets"],"tags":["skills","install","symlink","security"],"backgroundTag":"symlink-not-allowed","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}