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
- Replace the symlink with a real directory (copy the content) in the owned skills root.
- Ensure the canonicalized child resolves inside the canonicalized root — check with `readlink -f` on both paths before mutating.
- 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
- Do not symlink skill folders into the owned skills root; copy them instead.
- Audit the skills root for symlinks (find . -type l) before bulk mutations.
- Configure skills root paths to their canonical real locations so canonicalization does not shift the parent.
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
- refusing to mutate path
- audited owned root does not match mutation target
- refusing to mutate symlinked Codewhale skills path component
- audited skill path does not match owned package
- external credential path contains reparse point
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)