Hmbown/CodeWhale · error

refusing to mutate symlinked Codewhale skills path component

Error message

refusing to mutate symlinked Codewhale skills path component {}

What it means

checked_real_directory walks skills path components using symlink_metadata (deliberately not following links) and refuses to mutate if any component is a symlink. Following the link first could redirect a CodeWhale-owned root to an attacker-chosen write target, so symlinked components are rejected outright.

Solutions

  1. Replace the symlink with a real directory (copy contents back in)
  2. Symlink the individual skills inside the directory instead of any component of the owned chain
  3. Point configuration at the real location and let CodeWhale create its own directory there

Example fix

// before
ln -s ~/dotfiles/skills ~/.codewhale/skills
// after
rm ~/.codewhale/skills
mkdir ~/.codewhale/skills
cp -r ~/dotfiles/skills/* ~/.codewhale/skills/
Defensive patterns

Strategy: validation

Validate before calling

use std::fs::symlink_metadata;
for comp in [anchor.join(".codewhale"), anchor.join(".codewhale").join("skills")] {
    if let Ok(meta) = symlink_metadata(&comp) {
        assert!(!meta.file_type().is_symlink(), "{} is a symlink", comp.display());
    }
}

Type guard

fn is_real_dir(p: &Path) -> bool {
    std::fs::symlink_metadata(p).map(|m| !m.file_type().is_symlink() && m.is_dir()).unwrap_or(false)
}

Try / catch

match install_remote(anchor, name) {
    Err(e) if e.to_string().contains("symlinked") => eprintln!("replace the symlink in the skills path with a real directory"),
    other => other?,
}

Prevention

When it happens

Trigger: Installing, updating, or removing a skill where the anchor, .codewhale dir, skills dir, or any intermediate path component is a symlink (raised via validate_owned_target_chain, create_owned_directory, prepare_owned_target, or validate_owned_child).

Common situations: Users symlinking ~/.codewhale/skills to a dotfiles-managed or Dropbox-synced location; setups where the whole .codewhale directory is a symlink.

Related errors


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

Appendix: source

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

fn owned_anchor<'a>(
    workspace: &'a Path,
    home: Option<&'a Path>,
    target: SkillTargetScope,
) -> Result<&'a Path> {
    match target {
        SkillTargetScope::Project => Ok(workspace),
        SkillTargetScope::Global => home.context("global skill mutations require a home directory"),
    }
}

/// Return whether `path` is an existing real directory, rejecting links and
/// non-directory components. `symlink_metadata` is intentional: following a
/// link before checking it would turn a lexical CodeWhale-owned root into an
/// attacker-selected write target.
fn checked_real_directory(path: &Path) -> Result<bool> {
    match fs::symlink_metadata(path) {
        Ok(meta) if meta.file_type().is_symlink() => {
            bail!(
                "refusing to mutate symlinked Codewhale skills path component {}",
                path.display()
            )
        }
        Ok(meta) if !meta.is_dir() => bail!(
            "refusing to mutate through non-directory Codewhale skills path component {}",
            path.display()
        ),
        Ok(_) => Ok(true),
        Err(err) if err.kind() == ErrorKind::NotFound => Ok(false),
        Err(err) => Err(err).with_context(|| format!("failed to inspect {}", path.display())),
    }
}

/// Validate the complete owned-root chain without following a symlink in the
/// workspace/home anchor, `.codewhale`, or `skills` component.
fn validate_owned_target_chain(
    anchor: &Path,

View on GitHub (pinned to 73e0f67d83)