Hmbown/CodeWhale · error

owned skill path escapes direct root

Error message

owned skill path {} escapes direct root {}

What it means

After existence is confirmed, validate_owned_child canonicalizes both the root and the child and requires the canonicalized child to remain a direct child of the canonicalized root. This is the symlink/alias defense: if the child resolves (via symlinks) outside the owned root, the mutation is refused to prevent writing through a link into another location.

Solutions

  1. Replace the symlink with a real directory (copy the content) in the owned skills root.
  2. Ensure the canonicalized child resolves inside the canonicalized root — check with `readlink -f` on both paths before mutating.
  3. If the whole skills root is symlinked, mutate using the canonical path so both sides resolve identically, or reconfigure the root to its real location.

Example fix

// before
ln -s ~/shared/my-skill .codewhale/skills/my-skill  # mutation refused
// after
rm .codewhale/skills/my-skill
cp -r ~/shared/my-skill .codewhale/skills/my-skill
Defensive patterns

Strategy: validation

Validate before calling

fn resolves_inside_root(skills_dir: &std::path::Path, child: &std::path::Path) -> std::io::Result<bool> {
    let root = std::fs::canonicalize(skills_dir)?;
    let kid = std::fs::canonicalize(child)?;
    Ok(kid.parent() == Some(root.as_path()))
}

Try / catch

// detect symlinked skill dirs up front
if fs::symlink_metadata(child).map(|m| m.file_type().is_symlink()).unwrap_or(false) {
    eprintln!("{} is a symlink; copy it into the root as a real directory", child.display());
}

Prevention

When it happens

Trigger: Calling validate_owned_skill_path, install_remote, import_external, or update_skill where the child directory is (or contains) a symlink whose canonical target's parent is not the canonicalized skills root — e.g. the skill dir is a symlink to ~/elsewhere/skill, or the root itself is reached through a different symlinked path component.

Common situations: User symlinked a skill folder into the skills root to share it; the project skills directory is itself a symlink so canonicalization shifts the parent; dotfile managers (stow, chezmoi) replace skill dirs with links.

Understand the failure class

Background: Path traversal blocked: "path escapes the workspace" and "outside site root" errors when a path will not stay inside its allowed directory — this error's family across 26 libraries.

Related errors


AI-assisted analysis of Hmbown/CodeWhale@73e0f67d83 (2026-09-22). Data as JSON: /api/errors/5aa75bb6d8f40cfc. Report an issue: GitHub.

Appendix: source

Thrown at crates/tui/src/skills/mutation.rs:318

    }
    let exists = checked_real_directory(child)?;
    if !exists {
        if require_existing {
            bail!("owned skill path {} does not exist", child.display());
        }
        return Ok(false);
    }

    let canonical_root = fs::canonicalize(skills_dir).with_context(|| {
        format!(
            "failed to resolve owned skill root {}",
            skills_dir.display()
        )
    })?;
    let canonical_child = fs::canonicalize(child)
        .with_context(|| format!("failed to resolve owned skill path {}", child.display()))?;
    if canonical_child.parent() != Some(canonical_root.as_path()) {
        bail!(
            "owned skill path {} escapes direct root {}",
            child.display(),
            skills_dir.display()
        );
    }
    Ok(true)
}

fn validate_owned_skill_path(
    ctx: &MutationContext<'_>,
    skill: &AuditedSkill,
    path: &Path,
) -> Result<PathBuf> {
    if skill.id.root_id != skill.root.id {
        bail!("audited skill root identity changed; refusing mutation");
    }
    let skills_dir = validate_owned_root_descriptor(ctx, &skill.root)?;
    let package_name = on_disk_package_name(&skill.id)?;

View on GitHub (pinned to 73e0f67d83)