{"record":{"id":"86351e8c0e8baeec","repo":"paperclipai/paperclip","slug":"unexpected-trusted-viewer-asset","errorCode":null,"errorMessage":"Unexpected trusted viewer asset","messagePattern":"Unexpected trusted viewer asset","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/paperclip-runner/scripts/public-eval-viewer.mjs","lineNumber":45,"sourceCode":"    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 };\n}\n\nexport function validatePublicChatPayload(payload) {\n  if (\n    payload?.publication?.schema !== PUBLIC_CHAT_SCHEMA ||\n    payload.view?.sessionId !== \"public-report\" ||\n    payload.view?.composer?.state !== \"disabled\" ||","sourceCodeStart":27,"sourceCodeEnd":63,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/packages/paperclip-runner/scripts/public-eval-viewer.mjs#L27-L63","documentation":"trustedViewerFiles() reads a pre-built trusted viewer bundle (index.html plus an assets/ directory) that will be published as a public eval report. Because that bundle is served publicly under a strict CSP, every asset entry must be a regular, non-symlink file whose name matches a strict allowlist regex (ASCII-alphanumeric start, then [A-Za-z0-9._-], ending in .js, .css, or .woff2). If any directory entry fails that check, the function throws \"Unexpected trusted viewer asset\" to stop potentially unsafe or unexpected files from being shipped.","triggerScenarios":"Calling trustedViewerFiles(viewerRoot) when the assets/ directory contains: a subdirectory, a symlink, a file with a disallowed extension (e.g. .map, .png, .txt), a file starting with a dot or non-ASCII/odd character, or a name with characters outside [A-Za-z0-9._-].","commonSituations":"Vite build outputs sourcemaps (.js.map) or license files into assets/; a developer drops a README or screenshot into assets/; a misconfigured build with assetsInclude emits images/fonts outside the allowlist; symlinks left by a copied node_modules-based build.","solutions":["Remove or move out any files in viewerRoot/assets that do not match ^[A-Za-z0-9][A-Za-z0-9._-]*\\.(js|css|woff2)$ (especially .map sourcemaps and dotfiles).","Rebuild the viewer with sourcemaps disabled (e.g. build.sourcemap: false in the Vite config) so only .js/.css/.woff2 are emitted.","Replace any symlinks or subdirectories inside assets/ with real regular files flattened into assets/.","If a new asset type is genuinely required, update the ASSET regex in public-eval-viewer.mjs and get the change reviewed as a CSP/publication surface change."],"exampleFix":"// before: assets/ contains app.js, app.js.map, .DS_Store\n// after: clean the build output so only allowlisted assets remain\nrm viewer/assets/app.js.map viewer/assets/.DS_Store\npnpm --filter @paperclipai/paperclip-runner build:viewer","handlingStrategy":"validation","validationCode":"import { readdir } from \"node:fs/promises\";\nconst ASSET = /^[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:js|css|woff2)$/;\nconst bad = (await readdir(join(root, \"assets\"))).filter((n) => !ASSET.test(n));\nif (bad.length) throw new Error(`Non-publishable assets: ${bad.join(\", \")}`);","typeGuard":"const isPublishableAsset = (entry) => entry.isFile() && !entry.isSymbolicLink() && /^[A-Za-z0-9][A-Za-z0-9._-]*\\.(?:js|css|woff2)$/.test(entry.name);","tryCatchPattern":null,"preventionTips":["Disable sourcemaps and other extra artifacts in the viewer build config","Never hand-place files into the viewer assets/ directory; treat it as build output only","Add a CI step that lists assets/ and fails on non-.js/.css/.woff2 entries before publishing","Copy build output with dereferencing (real files, no symlinks)"],"tags":["validation","security","build-artifacts","filesystem"],"backgroundTag":"unexpected-api-response-shape","analyzedSha":"01ad8584922b5d85292b1723cae71fa0d9b07a19","analyzedAt":"2026-09-10T03:14:50.855Z","contentChangedAt":"2026-09-10T03:14:50.855Z","schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}