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

skill catalog {$url} has a skills/ directory that resolves o

Error message

skill catalog {$url} has a skills/ directory that resolves outside the cloned catalog; refusing to inspect it

What it means

Defense-in-depth after the symlink check: the canonicalized skills/ root must remain inside the canonicalized clone root (Path::starts_with on canonical paths). If canonicalization resolves outside — e.g. the entry was swapped for a symlink between the metadata check and canonicalize (TOCTOU), or exotic mount/aliasing behavior — the install aborts rather than inspect foreign directories.

Source

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

                        skills_dir.display()
                    )
                });
            }
        };
        if skills_meta.file_type().is_symlink() {
            anyhow::bail!(crate::i18n::get_required_cli_string_with_args(
                "cli-skills-install-catalog-root-symlink",
                &[("url", url)]
            ));
        }
        let skills_root = skills_dir.canonicalize().with_context(|| {
            format!(
                "failed to canonicalize catalog skills root {}",
                skills_dir.display()
            )
        })?;
        if !skills_root.starts_with(&clone_root) {
            anyhow::bail!(crate::i18n::get_required_cli_string_with_args(
                "cli-skills-install-catalog-root-escapes",
                &[("url", url)]
            ));
        }
        if !skills_root.is_dir() {
            anyhow::bail!(crate::i18n::get_required_cli_string_with_args(
                "cli-skills-install-skill-not-in-catalog-empty",
                &[("skill", skill_name), ("url", url)]
            ));
        }

        let skill_dir = skills_root.join(skill_name);
        let entry_meta = match std::fs::symlink_metadata(&skill_dir) {
            Ok(metadata) => metadata,
            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
                let available = list_contained_catalog_skill_names(&skills_root);
                if available.is_empty() {
                    anyhow::bail!(crate::i18n::get_required_cli_string_with_args(

View on GitHub (pinned to 88bb9c8533)

Solutions

  1. Retry the install — transient races resolve on a second attempt
  2. If persistent, clone the catalog manually and inspect skills/ with ls -la and realpath
  3. Treat a persistent case as a hostile or broken catalog and stop using that URL
Defensive patterns

Strategy: retry

Try / catch

match install_git_catalog_skill_source(url, skill, &skills_path, false, &ws) {
    Err(e) if e.to_string().contains("resolves outside the cloned catalog") => {
        // likely a TOCTOU race: retry once; if it persists, audit the catalog
        // (clone manually, ls -la skills, realpath skills) and drop the URL
    }
    r => r,
}

Prevention

When it happens

Trigger: The skills entry replaced by an out-of-tree symlink between the two checks; unusual filesystem layouts where canonical paths diverge from the clone root.

Common situations: Rare in practice: races during install or adversarial catalogs. A plain retry almost always succeeds.

Related errors


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