{"record":{"id":"c4639621d076ce2c","repo":"paperclipai/paperclip","slug":"public-report-contains-a-broken-or-unsafe-link-in","errorCode":null,"errorMessage":"Public report contains a broken or unsafe link in ${file}: ${href}","messagePattern":"Public report contains a broken or unsafe link in (.+?): (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs","lineNumber":217,"sourceCode":"          payload.navigation?.previous?.href,\n          payload.navigation?.next?.href,\n        ].filter(Boolean)\n      : [];\n    for (const href of [...internalHtmlHrefs(content), ...navigation]) {\n      if (typeof href !== \"string\" || /[?:\\\\]|^\\/|^[a-z]+:/i.test(href))\n        throw new Error(`Unsafe report navigation in ${file}`);\n      const clean = href.split(\"#\", 1)[0].split(\"?\", 1)[0];\n      const target = resolve(\n        root,\n        ...file.split(\"/\").slice(0, -1),\n        ...clean.split(\"/\"),\n      );\n      const rel = relative(root, target).split(sep).join(\"/\");\n      if (\n        !isPublicProtocolEvalPath(rel) ||\n        !(await lstat(target).catch(() => null))?.isFile()\n      ) {\n        throw new Error(\n          `Public report contains a broken or unsafe link in ${file}: ${href}`,\n        );\n      }\n    }\n  }\n  if (viewer) {\n    for (const file of viewer.files.keys())\n      if (!files.includes(file))\n        throw new Error(`Missing public viewer asset: ${file}`);\n    if (files.some((file) => /^attempts\\/[^/]+\\.html$/.test(file)))\n      throw new Error(\n        \"Chat Evalbook must not mix in legacy plain attempt pages\",\n      );\n  }\n  const campaign = await loadObject(join(root, \"campaign.json\"));\n  if (\n    campaign.schema !== \"paperclip.runner-protocol-eval.campaign/v1\" ||\n    !SAFE_CAMPAIGN.test(String(campaign.campaignId ?? \"\"))","sourceCodeStart":199,"sourceCodeEnd":235,"githubUrl":"https://github.com/paperclipai/paperclip/blob/01ad8584922b5d85292b1723cae71fa0d9b07a19/packages/paperclip-runner/scripts/publish-runner-protocol-eval-history.mjs#L199-L235","documentation":"Thrown by validatePublicProtocolEvalReport when a link's href passed the safety check but its resolved target either falls outside the allowlisted public report paths (isPublicProtocolEvalPath fails) or does not exist as a file in the report directory. This guarantees every internal link in the published report resolves to a real, publishable file, preventing 404s and links smuggling paths outside the bundle.","triggerScenarios":"A report page href points to a file that was never generated (e.g. tests/missing.html), a file exists but violates SAFE_REPORT_PATHS (e.g. odd characters or disallowed extension), a relative path like ../../campaign.json resolves outside the report root, or the target file was deleted between generation and publication.","commonSituations":"Generator emits links conditionally on tests that failed to render; renaming an attempt directory invalidates cross-links from the index; a hand-edited page links to a file not included in the upload; case-sensitivity mismatch (Foo.html vs foo.html) on a case-insensitive dev machine.","solutions":["Open the named file, find the href shown in the error, and fix or remove the broken link.","Regenerate the report so the linked target file is actually produced.","Check the target path matches one of the SAFE_REPORT_PATHS patterns (index/latest/inventory/real-server.html, tests/*.html, attempts/*, viewer/assets/*, campaign.json) and rename if needed.","Verify file-name casing matches the href exactly before publishing."],"exampleFix":"// before: link references ungenerated page\n<a href=\"../tests/auth-refresh.html\">Auth refresh</a>\n// after: only link to generated targets, or generate the target\n{#if tests.includes('auth-refresh')}<a href=\"../tests/auth-refresh.html\">Auth refresh</a>{/if}","handlingStrategy":"validation","validationCode":"import { lstat } from \"node:fs/promises\";\nimport { relative, resolve, sep } from \"node:path\";\nexport async function assertLinksResolve(root, file, hrefs) {\n  for (const href of hrefs) {\n    const clean = href.split(\"#\", 1)[0].split(\"?\", 1)[0];\n    const target = resolve(root, ...file.split(\"/\").slice(0, -1), ...clean.split(\"/\"));\n    const rel = relative(root, target).split(sep).join(\"/\");\n    if (!(await lstat(target).catch(() => null))?.isFile())\n      throw new Error(`Broken link in ${file}: ${href}`);\n  }\n}","typeGuard":null,"tryCatchPattern":"try {\n  await validatePublicProtocolEvalReport(reportRoot, { viewerRoot });\n} catch (err) {\n  if (String(err.message).startsWith(\"Public report contains a broken or unsafe link\")) {\n    console.error(\"Fix or remove the broken href, or regenerate the missing target page.\");\n  }\n  throw err;\n}","preventionTips":["Generate links only for pages the generator actually produced (link list from the render manifest).","Check href casing against real file names, especially across case-insensitive filesystems.","Avoid hand-editing published report files; always regenerate from source.","Run the validator as a pre-publish CI step to catch link rot early."],"tags":["broken-link","publish-validation","html"],"backgroundTag":"file-not-found","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"}