Hmbown/CodeWhale · error

owned skill root escapes anchor

Error message

owned skill root {} escapes anchor {}

What it means

`validate_owned_target_chain` canonicalizes both the anchor and the skills root with `fs::canonicalize` (resolving all symlinks) and requires the canonical skills path to be inside the canonical anchor. If the resolved skills path lies outside the anchor — typically because a symlink redirected a component — the mutation is refused as a path-escape. This is a security guard against tricking Codewhale into writing skills outside its owned root via a planted symlink.

Solutions

  1. Replace the symlink at `.codewhale` (or `.codewhale/skills`) with a real directory inside the anchor; move the data, not the link
  2. Keep both the anchor and skills root on real directories on the same filesystem, with no symlinked components in between
  3. If the anchor itself lives behind a symlinked path (e.g. `/tmp` on macOS), invoke the operation with the canonical path so anchor and skills canonicalize consistently
  4. Audit the chain with `ls -la` / `readlink -f <anchor>/.codewhale/skills` and confirm the resolved path starts with the resolved anchor

Example fix

// before: symlinked skills root escaping the anchor
ln -s /srv/shared-skills ~/.codewhale/skills
// -> owned skill root /srv/shared-skills escapes anchor /home/me
// after: real directory inside the anchor; move the content instead
rm ~/.codewhale/skills
mv /srv/shared-skills ~/.codewhale/skills
Defensive patterns

Strategy: validation

Validate before calling

let canon_anchor = std::fs::canonicalize(anchor)?;
let canon_skills = std::fs::canonicalize(anchor.join(".codewhale").join("skills"))?;
if !canon_skills.starts_with(&canon_anchor) {
    return Err("skills root would escape anchor; fix symlinks first");
}

Type guard

fn stays_within(anchor: &Path, target: &Path) -> bool {
    match (std::fs::canonicalize(anchor), std::fs::canonicalize(target)) {
        (Ok(a), Ok(t)) => t.starts_with(a),
        _ => false,
    }
}

Try / catch

match install_remote(workspace, home, scope) {
    Err(e) if e.to_string().contains("escapes anchor") => {
        eprintln!("Fix symlinked .codewhale components under {}; see {}", workspace.display(), e);
        Err(e)
    }
    other => other,
}

Prevention

When it happens

Trigger: `<workspace>/.codewhale/skills` or an ancestor is a symlink pointing outside the workspace/home anchor; a bind mount or alternative path (e.g. macOS `/tmp` -> `/private/tmp`, or a home dir behind a symlink) makes the canonicalized anchor and skills diverge so the prefix check fails; calling `resolve_owned_target`/`validate_owned_root_descriptor` against a root assembled with `..` components that resolve outside the anchor.

Common situations: Users who symlink `~/.codewhale` into a dotfiles repo or another volume and then install a global skill; dotfile managers (stow, chezmoi) replacing `.codewhale` with symlinks; workspaces located on macOS where `/tmp`/`/var` symlink to `/private/...` so the anchor itself canonicalizes to a different prefix than an un-resolved comparison would suggest.

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/a99ceaf92d0c3f97. Report an issue: GitHub.

Appendix: source

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

    let skills_exists = checked_real_directory(skills_dir)?;
    if !skills_exists {
        if require_existing {
            bail!("owned skill root {} does not exist", skills_dir.display());
        }
        return Ok(());
    }

    let canonical_anchor = fs::canonicalize(anchor)
        .with_context(|| format!("failed to resolve owned anchor {}", anchor.display()))?;
    let canonical_skills = fs::canonicalize(skills_dir).with_context(|| {
        format!(
            "failed to resolve owned skill root {}",
            skills_dir.display()
        )
    })?;
    if !canonical_skills.starts_with(&canonical_anchor) {
        bail!(
            "owned skill root {} escapes anchor {}",
            skills_dir.display(),
            anchor.display()
        );
    }
    Ok(())
}

fn create_owned_directory(path: &Path) -> Result<()> {
    match fs::create_dir(path) {
        Ok(()) => {}
        Err(err) if err.kind() == ErrorKind::AlreadyExists => {}
        Err(err) => {
            return Err(err).with_context(|| format!("failed to create {}", path.display()));
        }
    }
    if !checked_real_directory(path)? {
        bail!("failed to create owned skill directory {}", path.display());

View on GitHub (pinned to 73e0f67d83)