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
- Replace the symlink at `.codewhale` (or `.codewhale/skills`) with a real directory inside the anchor; move the data, not the link
- Keep both the anchor and skills root on real directories on the same filesystem, with no symlinked components in between
- 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
- 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
- Never symlink .codewhale or .codewhale/skills to outside the workspace/home; move content instead
- Beware dotfile managers (stow, chezmoi) that convert these paths into symlinks
- On macOS, invoke operations with canonical paths so /tmp-style symlinks do not skew the comparison
- Run readlink -f on the chain before automating installs
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
- audited skill path does not match owned package
- bundle path escapes the config directory via a symlink…
- external credential path contains reparse point
- Fleet task ' ' path ' ' cannot contain parent traversal
- sub-agent state path must not traverse symlinks
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)