{"record":{"id":"30fe7f08cc95dcba","repo":"zeroclaw-labs/zeroclaw","slug":"refusing-to-copy-symlinked-skill-source-path","errorCode":null,"errorMessage":"Refusing to copy symlinked skill source path: {}","messagePattern":"Refusing to copy symlinked skill source path: (.+?)","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/skills/mod.rs","lineNumber":2027,"sourceCode":"    }\n\n    anyhow::bail!(\"Skill security audit failed: {}\", report.summary());\n}\n\nfn remove_git_metadata(skill_path: &Path) -> Result<()> {\n    let git_dir = skill_path.join(\".git\");\n    if git_dir.exists() {\n        std::fs::remove_dir_all(&git_dir)\n            .with_context(|| format!(\"failed to remove {}\", git_dir.display().to_string()))?;\n    }\n    Ok(())\n}\n\nfn copy_dir_recursive_secure(src: &Path, dest: &Path) -> Result<()> {\n    let src_meta = std::fs::symlink_metadata(src)\n        .with_context(|| format!(\"failed to read metadata for {}\", src.display().to_string()))?;\n    if src_meta.file_type().is_symlink() {\n        anyhow::bail!(\n            \"Refusing to copy symlinked skill source path: {}\",\n            src.display()\n        );\n    }\n    if !src_meta.is_dir() {\n        anyhow::bail!(\n            \"Skill source must be a directory: {}\",\n            src.display().to_string()\n        );\n    }\n\n    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)? {","sourceCodeStart":2009,"sourceCodeEnd":2045,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/skills/mod.rs#L2009-L2045","documentation":"copy_dir_recursive_secure stats the source with symlink_metadata (which does not follow links) and refuses to copy when the top-level source path itself is a symlink. The installer only copies a real directory the user actually named, never whatever a link resolves to.","triggerScenarios":"skills install <path> where path is a symlink to the actual skill directory — common with dotfiles-style management, stow, or a monorepo checkout where the skills folder is linked into the workspace.","commonSituations":"Skills managed via symlink farms; workspaces assembled from linked checkouts; shortcuts created for convenience pointing at the real skill repo.","solutions":["Resolve the link and pass the real directory: run 'realpath <path>' and install that path","In code, std::fs::canonicalize the path before calling install_local_skill_source","Restructure so the skill directory is a real directory in (or copied into) the workspace"],"exampleFix":"# before\nzeroclaw skills install ~/links/my-skill   # ~/links/my-skill is a symlink\n\n# after\nzeroclaw skills install \"$(realpath ~/links/my-skill)\"","handlingStrategy":"validation","validationCode":"// Resolve links before installing:\nlet real = std::fs::canonicalize(&source)\n    .with_context(|| format!(\"resolve skill source {source}\"))?;\ninstall_local_skill_source(real.to_str().unwrap(), &skills_path, allow_scripts)?;","typeGuard":"fn is_real_directory(p: &std::path::Path) -> bool {\n    std::fs::symlink_metadata(p)\n        .map(|m| !m.file_type().is_symlink() && m.is_dir())\n        .unwrap_or(false)\n}","tryCatchPattern":null,"preventionTips":["Run realpath on user-supplied skill paths before install","Prefer real directories over symlink farms for skill management","Remember symlink_metadata semantics: the check is on the link itself, not its target"],"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"}