paperclipai/paperclip · error · Error
Refusing to materialize a skill root that is itself a symlin
Error message
Refusing to materialize a skill root that is itself a symlink.
What it means
Thrown by materializePaperclipSkillCopy when fs.lstat(sourceRoot) reports the source root is itself a symbolic link. The copy walker descends into directories but explicitly skips symlinked entries, so a symlinked root would either copy nothing meaningful or follow outside the intended tree. Refusing a symlink root keeps the materialized copy self-contained and reproducible.
Source
Thrown at packages/adapter-utils/src/server-utils.ts:3068
source: string,
target: string,
): Promise<MaterializedPaperclipSkillCopyResult> {
const sourceRoot = path.resolve(source);
const targetRoot = path.resolve(target);
const relativeTarget = path.relative(sourceRoot, targetRoot);
const relativeSource = path.relative(targetRoot, sourceRoot);
if (
!relativeTarget ||
(!relativeTarget.startsWith("..") && !path.isAbsolute(relativeTarget)) ||
!relativeSource ||
(!relativeSource.startsWith("..") && !path.isAbsolute(relativeSource))
) {
throw new Error("Refusing to materialize a skill into itself, an ancestor, or one of its descendants.");
}
const rootStat = await fs.lstat(sourceRoot);
if (rootStat.isSymbolicLink()) {
throw new Error("Refusing to materialize a skill root that is itself a symlink.");
}
if (!rootStat.isDirectory()) {
throw new Error("Paperclip skills must be directories.");
}
const result: MaterializedPaperclipSkillCopyResult = {
copiedFiles: 0,
skippedSymlinks: [],
};
const lockDir = `${targetRoot}.lock`;
const releaseLock = await acquireMaterializeLock(lockDir);
const tempRoot = `${targetRoot}.tmp-${process.pid}-${randomUUID()}`;
async function copyEntry(sourcePath: string, targetPath: string, relativePath: string): Promise<void> {
const stat = await fs.lstat(sourcePath);
if (stat.isSymbolicLink()) {
result.skippedSymlinks.push(relativePath || ".");View on GitHub (pinned to 67001ec6eb)
Solutions
- Pass the real directory the symlink resolves to: use fs.realpath(source) before calling, or resolve the link manually.
- Reconfigure the skills catalog to expose real directories instead of symlinks at the root.
- If the link is intentional for development, point materializePaperclipSkillCopy at the link target path directly.
Example fix
// before await materializePaperclipSkillCopy(source, target); // after const realSource = await fs.realpath(source); await materializePaperclipSkillCopy(realSource, target);
Defensive patterns
Strategy: validation
Validate before calling
const stat = await fs.lstat(source);
if (stat.isSymbolicLink()) {
// resolve to the real target or refuse
source = await fs.realpath(source);
}
await materializePaperclipSkillCopy(source, target); Prevention
- Run fs.realpath on skill source paths before materialization to defeat symlinks.
- Document that skill catalog roots must be real directories, not symlinks.
- Add a catalog-load check that lstat's each skill root and rejects symlinks at ingest time.
When it happens
Trigger: Calling materializePaperclipSkillCopy with a source that is a symlink (ln -s real_skill link_skill and passing link_skill as source). fs.lstat follows the link metadata, not the target, so isSymbolicLink() is true.
Common situations: A skills home populated with symlinks into a maintainer-only catalog; a deploy that symlinks the skill dir into a shared volume; dev setups that symlink a repo subdirectory as the skill root.
Related errors
- Refusing to materialize a skill into itself, an ancestor, or
- Paperclip skills must be directories.
- Could not locate local Paperclip skills directory. Expected
- Export output path ${root} exists and is not a directory.
- Export output directory ${root} already contains files. Re-r
AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12).
Data as JSON: /api/errors/0f6c7f7c663ac644.
Report an issue: GitHub.