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

  1. Pass the real directory the symlink resolves to: use fs.realpath(source) before calling, or resolve the link manually.
  2. Reconfigure the skills catalog to expose real directories instead of symlinks at the root.
  3. 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

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


AI-assisted analysis of paperclipai/paperclip@67001ec6eb (2026-08-12). Data as JSON: /api/errors/0f6c7f7c663ac644. Report an issue: GitHub.