zeroclaw-labs/zeroclaw · error · anyhow::Error

skill '{$skill}' in {$url} is a symlink; catalog skills must

Error message

skill '{$skill}' in {$url} is a symlink; catalog skills must be real directories inside the repository

What it means

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.

Source

Thrown at crates/zeroclaw-runtime/src/skills/mod.rs:2467

                    "cli-skills-install-skill-not-in-catalog",
                    &[
                        ("skill", skill_name),
                        ("url", url),
                        ("available", &available.join(", ")),
                    ]
                ));
            }
            Err(err) => {
                return Err(err).with_context(|| {
                    format!(
                        "failed to read metadata for selected catalog skill {}",
                        skill_dir.display()
                    )
                });
            }
        };
        if entry_meta.file_type().is_symlink() {
            anyhow::bail!(crate::i18n::get_required_cli_string_with_args(
                "cli-skills-install-catalog-skill-symlink",
                &[("skill", skill_name), ("url", url)]
            ));
        }
        let selected = skill_dir.canonicalize().with_context(|| {
            format!(
                "failed to canonicalize selected skill {}",
                skill_dir.display()
            )
        })?;
        if !selected.starts_with(&skills_root) {
            anyhow::bail!(crate::i18n::get_required_cli_string_with_args(
                "cli-skills-install-catalog-skill-escapes",
                &[("skill", skill_name), ("url", url)]
            ));
        }
        if !selected.is_dir() {
            let available = list_contained_catalog_skill_names(&skills_root);

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. 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.
  2. If you do not maintain the catalog, report the symlink to its maintainer and install the skill from its real source location instead.
  3. Verify before installing: `git ls-files -s skills/` in the clone — mode 120000 entries are symlinks; `ls -la skills/` shows the arrows.
  4. If content sharing is the goal, use a build/release step that materializes real directories rather than symlinks.

Example fix

# before (in the catalog repo)
ln -s ../shared/my-skill skills/my-skill

# after
cp -rL ../shared/my-skill skills/my-skill
git add skills/my-skill
git commit -m "skills: materialize my-skill as a real directory"
Defensive patterns

Strategy: validation

Validate before calling

fn is_real_dir(p: &std::path::Path) -> bool {
    std::fs::symlink_metadata(p)
        .map(|m| m.is_dir() && !m.file_type().is_symlink())
        .unwrap_or(false)
}
// before install: assert is_real_dir(catalog_clone.join("skills").join(skill))

Try / catch

match install_catalog_skill(url, skill) {
    Ok(_) => {},
    Err(e) if e.to_string().contains("is a symlink") => {
        eprintln!("catalog bug: {skill} is a symlink; report to catalog maintainer");
    }
    Err(e) => return Err(e),
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Related errors


AI-assisted analysis of zeroclaw-labs/zeroclaw@88bb9c8533 (2026-08-23). Data as JSON: /api/errors/3ce9ac2888c89999. Report an issue: GitHub.