{"record":{"id":"3ce9ac2888c89999","repo":"zeroclaw-labs/zeroclaw","slug":"skill-skill-in-url-is-a-symlink-catalog-s","errorCode":null,"errorMessage":"skill '{$skill}' in {$url} is a symlink; catalog skills must be real directories inside the repository","messagePattern":"skill '(.+?)' in (.+?) is a symlink; catalog skills must be real directories inside the repository","errorType":"validation","errorClass":"anyhow::Error","httpStatus":null,"severity":"error","filePath":"crates/zeroclaw-runtime/src/skills/mod.rs","lineNumber":2467,"sourceCode":"                    \"cli-skills-install-skill-not-in-catalog\",\n                    &[\n                        (\"skill\", skill_name),\n                        (\"url\", url),\n                        (\"available\", &available.join(\", \")),\n                    ]\n                ));\n            }\n            Err(err) => {\n                return Err(err).with_context(|| {\n                    format!(\n                        \"failed to read metadata for selected catalog skill {}\",\n                        skill_dir.display()\n                    )\n                });\n            }\n        };\n        if entry_meta.file_type().is_symlink() {\n            anyhow::bail!(crate::i18n::get_required_cli_string_with_args(\n                \"cli-skills-install-catalog-skill-symlink\",\n                &[(\"skill\", skill_name), (\"url\", url)]\n            ));\n        }\n        let selected = skill_dir.canonicalize().with_context(|| {\n            format!(\n                \"failed to canonicalize selected skill {}\",\n                skill_dir.display()\n            )\n        })?;\n        if !selected.starts_with(&skills_root) {\n            anyhow::bail!(crate::i18n::get_required_cli_string_with_args(\n                \"cli-skills-install-catalog-skill-escapes\",\n                &[(\"skill\", skill_name), (\"url\", url)]\n            ));\n        }\n        if !selected.is_dir() {\n            let available = list_contained_catalog_skill_names(&skills_root);","sourceCodeStart":2449,"sourceCodeEnd":2485,"githubUrl":"https://github.com/zeroclaw-labs/zeroclaw/blob/88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc/crates/zeroclaw-runtime/src/skills/mod.rs#L2449-L2485","documentation":"Thrown by the skills catalog installer in crates/zeroclaw-runtime/src/skills/mod.rs. After cloning a catalog repo, it stats skills/<skill> and refuses to install when the entry is a symbolic link. Catalog skills must be real directories inside the clone so that what gets installed is exactly what the repo versioned; a symlink could point outside the clone or at content that changes later.","triggerScenarios":"Calling the catalog skill install path (skills install from a catalog URL, e.g. `zeroclaw skills install my-skill --catalog <url>`) where the cloned catalog repository has `skills/<skill-name>` as a symlink. The check is `entry_meta.file_type().is_symlink()` on the stat'd skill dir, so any symlink (even one pointing to a sibling dir in the same repo) trips it.","commonSituations":"Catalog repos that deduplicate shared skills via symlinks; repos prepared on OSes/tools that materialize shared folders as symlinks; a malicious or compromised catalog attempting a symlink-based install attack; nested catalogs where one skill links to another.","solutions":["In the catalog repo, replace the symlink with a real directory: copy the target contents (cp -rL) and commit them so skills/<name> is a plain directory.","If you do not maintain the catalog, report the symlink to its maintainer and install the skill from its real source location instead.","Verify before installing: `git ls-files -s skills/` in the clone — mode 120000 entries are symlinks; `ls -la skills/` shows the arrows.","If content sharing is the goal, use a build/release step that materializes real directories rather than symlinks."],"exampleFix":"# before (in the catalog repo)\nln -s ../shared/my-skill skills/my-skill\n\n# after\ncp -rL ../shared/my-skill skills/my-skill\ngit add skills/my-skill\ngit commit -m \"skills: materialize my-skill as a real directory\"","handlingStrategy":"validation","validationCode":"fn is_real_dir(p: &std::path::Path) -> bool {\n    std::fs::symlink_metadata(p)\n        .map(|m| m.is_dir() && !m.file_type().is_symlink())\n        .unwrap_or(false)\n}\n// before install: assert is_real_dir(catalog_clone.join(\"skills\").join(skill))","typeGuard":null,"tryCatchPattern":"match install_catalog_skill(url, skill) {\n    Ok(_) => {},\n    Err(e) if e.to_string().contains(\"is a symlink\") => {\n        eprintln!(\"catalog bug: {skill} is a symlink; report to catalog maintainer\");\n    }\n    Err(e) => return Err(e),\n}","preventionTips":["In catalog repos, lint CI on `git ls-files -s skills/` and reject mode-120000 (symlink) entries.","Never author catalog skills as symlinks; materialize shared content at release time.","Pin catalog repos to reviewed commits rather than floating branches."],"tags":["skills","catalog","install","symlink","security","path-traversal"],"backgroundTag":"symlink-path-traversal","analyzedSha":"88bb9c8533fc57ed7a03e36ca7c9ed2bf8336dcc","analyzedAt":"2026-08-23T01:07:41.857Z","schemaVersion":2},"datasetVersion":"2026-08-23T08:06:27.607Z"}