{"record":{"id":"a99ceaf92d0c3f97","repo":"Hmbown/CodeWhale","slug":"owned-skill-root-escapes-anchor","errorCode":null,"errorMessage":"owned skill root {} escapes anchor {}","messagePattern":"owned skill root (.+?) escapes anchor (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"crates/tui/src/skills/mutation.rs","lineNumber":216,"sourceCode":"\n    let skills_exists = checked_real_directory(skills_dir)?;\n    if !skills_exists {\n        if require_existing {\n            bail!(\"owned skill root {} does not exist\", skills_dir.display());\n        }\n        return Ok(());\n    }\n\n    let canonical_anchor = fs::canonicalize(anchor)\n        .with_context(|| format!(\"failed to resolve owned anchor {}\", anchor.display()))?;\n    let canonical_skills = fs::canonicalize(skills_dir).with_context(|| {\n        format!(\n            \"failed to resolve owned skill root {}\",\n            skills_dir.display()\n        )\n    })?;\n    if !canonical_skills.starts_with(&canonical_anchor) {\n        bail!(\n            \"owned skill root {} escapes anchor {}\",\n            skills_dir.display(),\n            anchor.display()\n        );\n    }\n    Ok(())\n}\n\nfn create_owned_directory(path: &Path) -> Result<()> {\n    match fs::create_dir(path) {\n        Ok(()) => {}\n        Err(err) if err.kind() == ErrorKind::AlreadyExists => {}\n        Err(err) => {\n            return Err(err).with_context(|| format!(\"failed to create {}\", path.display()));\n        }\n    }\n    if !checked_real_directory(path)? {\n        bail!(\"failed to create owned skill directory {}\", path.display());","sourceCodeStart":198,"sourceCodeEnd":234,"githubUrl":"https://github.com/Hmbown/CodeWhale/blob/73e0f67d83c59909b571efdfc88c4bc28c309cb1/crates/tui/src/skills/mutation.rs#L198-L234","documentation":"`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.","triggerScenarios":"`<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.","commonSituations":"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.","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"],"exampleFix":"// before: symlinked skills root escaping the anchor\nln -s /srv/shared-skills ~/.codewhale/skills\n// -> owned skill root /srv/shared-skills escapes anchor /home/me\n// after: real directory inside the anchor; move the content instead\nrm ~/.codewhale/skills\nmv /srv/shared-skills ~/.codewhale/skills","handlingStrategy":"validation","validationCode":"let canon_anchor = std::fs::canonicalize(anchor)?;\nlet canon_skills = std::fs::canonicalize(anchor.join(\".codewhale\").join(\"skills\"))?;\nif !canon_skills.starts_with(&canon_anchor) {\n    return Err(\"skills root would escape anchor; fix symlinks first\");\n}","typeGuard":"fn stays_within(anchor: &Path, target: &Path) -> bool {\n    match (std::fs::canonicalize(anchor), std::fs::canonicalize(target)) {\n        (Ok(a), Ok(t)) => t.starts_with(a),\n        _ => false,\n    }\n}","tryCatchPattern":"match install_remote(workspace, home, scope) {\n    Err(e) if e.to_string().contains(\"escapes anchor\") => {\n        eprintln!(\"Fix symlinked .codewhale components under {}; see {}\", workspace.display(), e);\n        Err(e)\n    }\n    other => other,\n}","preventionTips":["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"],"tags":["security","symlink","path-traversal","path-validation"],"backgroundTag":"path-traversal-blocked","analyzedSha":"73e0f67d83c59909b571efdfc88c4bc28c309cb1","analyzedAt":"2026-09-22T01:30:00.501Z","contentChangedAt":"2026-09-22T01:30:00.501Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}