{"record":{"id":"6d5805978b639c94","repo":"paperclipai/paperclip","slug":"trusted-viewer-must-not-use-symlinks","errorCode":null,"errorMessage":"Trusted viewer must not use symlinks","messagePattern":"Trusted viewer must not use symlinks","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/paperclip-runner/scripts/public-eval-viewer.mjs","lineNumber":38,"sourceCode":"      `<script type=\"application/json\" id=\"paperclip-eval-report\">${encodedPayload}</script>\\n    <script type=\"module\"`,\n    );\n}\n\nexport async function trustedViewerFiles(viewerRoot) {\n  const rootStat = viewerRoot ? await lstat(viewerRoot) : null;\n  if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory())\n    throw new Error(\n      \"A trusted viewer build is required for public chat reports\",\n    );\n  const indexStat = await lstat(join(viewerRoot, \"index.html\"));\n  const assetsStat = await lstat(join(viewerRoot, \"assets\"));\n  if (\n    indexStat.isSymbolicLink() ||\n    !indexStat.isFile() ||\n    assetsStat.isSymbolicLink() ||\n    !assetsStat.isDirectory()\n  )\n    throw new Error(\"Trusted viewer must not use symlinks\");\n  const index = await readFile(join(viewerRoot, \"index.html\"), \"utf8\");\n  const files = new Map();\n  for (const entry of await readdir(join(viewerRoot, \"assets\"), {\n    withFileTypes: true,\n  })) {\n    if (!entry.isFile() || entry.isSymbolicLink() || !ASSET.test(entry.name))\n      throw new Error(\"Unexpected trusted viewer asset\");\n    files.set(\n      `viewer/assets/${entry.name}`,\n      await readFile(join(viewerRoot, \"assets\", entry.name)),\n    );\n  }\n  if (\n    ![...files.keys()].some((name) => name.endsWith(\".js\")) ||\n    !index.includes('<script type=\"module\"')\n  )\n    throw new Error(\"Incomplete trusted viewer build\");\n  return { index, files };","sourceCodeStart":20,"sourceCodeEnd":56,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/packages/paperclip-runner/scripts/public-eval-viewer.mjs#L20-L56","documentation":"Within the trusted viewer root, index.html must be a regular non-symlink file and assets must be a real non-symlink directory. Symlinks are rejected because a public report must only serve content from the trusted build; a symlink could redirect reads outside the trusted tree. Any violation throws this error.","triggerScenarios":"trustedViewerFiles finds index.html is a symlink or not a file, or assets/ is a symlink or not a directory, when scanning the viewer build for a public report.","commonSituations":"Deployment pipelines that symlink dist files to save space; an artifact sync replacing real files with symlinks; a partially built viewer where assets/ was never emitted; a viewer build layout change.","solutions":["Replace symlinks in the viewer build with real files: use 'cp -rL' (dereference) or 'rsync -L' when staging the viewer artifacts.","Re-run the viewer build to regenerate a complete dist with a real index.html and assets/ directory.","Fix the deploy step so it does not create symlinks inside the served viewer root.","Verify the viewer build output still contains index.html and assets/ at the expected locations and update viewerRoot if the layout changed."],"exampleFix":"# before (symlinked deployment)\nln -s /shared/ui/dist/assets viewer/assets\n# after\nrsync -rL /shared/ui/dist/ viewer/  # real files, no symlinks","handlingStrategy":"validation","validationCode":"import { lstat } from 'node:fs/promises';\nconst idx = await lstat(join(root, 'index.html'));\nconst assets = await lstat(join(root, 'assets'));\nif (idx.isSymbolicLink() || !idx.isFile() || assets.isSymbolicLink() || !assets.isDirectory()) {\n  throw new Error('viewer build must contain real index.html and assets/ (no symlinks)');\n}","typeGuard":"async function isRealViewerLayout(root) {\n  try {\n    const idx = await lstat(join(root, 'index.html'));\n    const assets = await lstat(join(root, 'assets'));\n    return idx.isFile() && !idx.isSymbolicLink() && assets.isDirectory() && !assets.isSymbolicLink();\n  } catch { return false; }\n}","tryCatchPattern":"try {\n  const files = await trustedViewerFiles(viewerRoot);\n} catch (err) {\n  if (err.message === 'Trusted viewer must not use symlinks') {\n    throw new Error(`Viewer build at ${viewerRoot} contains symlinks; restage with cp -rL / rsync -L`);\n  }\n  throw err;\n}","preventionTips":["Stage viewer artifacts with dereferencing copies (rsync -L, cp -rL), never symlinks.","Add a CI check that lstat on index.html and assets/ shows no symlinks.","Keep the viewer build output layout stable (index.html + assets/ at root).","Serve only from inside the trusted root; reject any entry with symlink members."],"tags":["security","symlink","path-traversal","filesystem"],"backgroundTag":"path-traversal-blocked","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T05:17:10.506Z"}