affaan-m/ECC · critical · Error

Refusing to ${action} through symlinked Claude skill path: '

Error message

Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.

What it means

Thrown by assertSafeSkillPath in scripts/lib/install/claude-skill-migration.js after the containment check passes. The guard walks every segment from targetRoot to targetPath, calling fs.lstatSync on each. If any segment is a symbolic link, the operation is refused. This is the Claude-skill equivalent of apply.js's symlink guard (error 206) and covers the same TOCTOU/symlink-escape class of attack but specifically for managed skill files.

Source

Thrown at scripts/lib/install/claude-skill-migration.js:74

    throw new Error(
      `Refusing to ${action} outside the install root: '${targetPath}' is not within '${targetRoot}'.`
    );
  }

  let currentPath = resolvedRoot;
  for (const segment of relativePath.split(path.sep)) {
    currentPath = path.join(currentPath, segment);
    let stats;
    try {
      stats = fs.lstatSync(currentPath);
    } catch (error) {
      if (error && error.code === 'ENOENT') {
        break;
      }
      throw error;
    }
    if (stats.isSymbolicLink()) {
      throw new Error(
        `Refusing to ${action} through symlinked Claude skill path: '${currentPath}'.`
      );
    }
  }

  if (pathExists(targetRoot)) {
    assertWithinTrustedRoot(targetPath, targetRoot, action);
  }
}

function describeClaudeSkillOperation(targetRoot, operation) {
  if (!operation || operation.kind !== 'copy-file') {
    return null;
  }

  const sourceRelativePath = normalizeSourceRelativePath(operation.sourceRelativePath);
  if (!sourceRelativePath) {
    return null;

View on GitHub (pinned to 01e15490f0)

Solutions

  1. Run ls -la <targetRoot>/skills (and parents) to identify symlinks.
  2. Replace each symlinked skill directory with a real directory (copy contents, remove symlink).
  3. Configure your dotfile manager to copy rather than link .claude/skills.
  4. Re-run the install.

Example fix

# before
~/.claude/skills/my-skill -> ~/dotfiles/.claude/skills/my-skill

# after
rm ~/.claude/skills/my-skill
cp -r ~/dotfiles/.claude/skills/my-skill ~/.claude/skills/my-skill
Defensive patterns

Strategy: validation

Validate before calling

function assertRealDirsOnly(rootDir, childPath) {
  const rel = path.relative(rootDir, childPath);
  let cur = rootDir;
  for (const seg of rel.split(path.sep)) {
    cur = path.join(cur, seg);
    let s;
    try { s = fs.lstatSync(cur); } catch (e) { if (e.code === 'ENOENT') break; throw e; }
    if (s.isSymbolicLink()) throw new Error(`symlink in path: ${cur}`);
  }
}
for (const op of plan.operations) assertRealDirsOnly(plan.targetRoot, op.destinationPath);

Try / catch

try {
  applyInstallPlan(plan);
} catch (err) {
  if (/through symlinked Claude skill path/.test(err.message)) {
    console.error('Replace skill symlinks with real directories under', plan.targetRoot);
  }
  throw err;
}

Prevention

When it happens

Trigger: Any segment between targetRoot and the skill file is a symlink — e.g. ~/.claude/skills/<name> is a symlink installed by a dotfile manager, or a parent directory in the chain is symlinked.

Common situations: Dotfile-managed .claude (stow, yadm, chezmoi); Dropbox/iCloud-synced skills folder; symlinked skill installed by another tool; a malicious repo that symlinks .claude/skills.

Related errors


AI-assisted analysis of affaan-m/ECC@01e15490f0 (2026-08-13). Data as JSON: /api/errors/eb9afd39b0ff8d7b. Report an issue: GitHub.