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
- Run ls -la <targetRoot>/skills (and parents) to identify symlinks.
- Replace each symlinked skill directory with a real directory (copy contents, remove symlink).
- Configure your dotfile manager to copy rather than link .claude/skills.
- 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
- Use copy-based dotfile management for .claude/skills (avoid stow/yadm linking).
- Periodically run find ~/.claude -type l to catch stray symlinks.
- Never install skills into a sync-managed (Dropbox/iCloud) directory.
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
- Refusing to install ECC file through symlinked path: '${curr
- Refusing to ${action} outside the install root: '${targetPat
- Refusing to ${action}: destination parent is not a trusted d
- Refusing to read non-file path: ${filePath}
- Refusing to apply install operation: missing destination pat
AI-assisted analysis of affaan-m/ECC@01e15490f0 (2026-08-13).
Data as JSON: /api/errors/eb9afd39b0ff8d7b.
Report an issue: GitHub.