{"record":{"id":"87444413b91a3a81","repo":"santifer/career-ops","slug":"output-escapes-the-tracker-workspace-entryoutpu","errorCode":null,"errorMessage":"output escapes the tracker workspace: ${entryOutput}","messagePattern":"output escapes the tracker workspace: (.+?)","errorType":"validation","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"generate-pdf.mjs","lineNumber":1287,"sourceCode":"      const entryReport = (spec.reportNum ?? '').toString().trim();\n      if (entryReport && !/^\\d+$/.test(entryReport)) {\n        throw new Error(`invalid reportNum \"${entryReport}\" (use the numeric report number)`);\n      }\n\n      // Resolve manifest-supplied input/output relative to the manifest's own\n      // directory, not process.cwd(), so a manifest renders identically wherever\n      // the batch is launched from. Absolute paths in the manifest still win\n      // (resolve() ignores the base when the tail is absolute).\n      const entryInput = resolve(manifestDir, spec.input);\n      const entryOutput = resolve(manifestDir, spec.output);\n\n      // Path-containment guards (realpath-based): keep the read and write inside\n      // the tracker workspace even through a symlinked ancestor. A batch\n      // manifest that escapes the workspace is malformed/tampered and is\n      // recorded as a per-entry failure rather than read or written.\n      assertInsideWorkspace(entryInput, 'input');\n      if (!isWorkspaceOutputPath(entryOutput, workspaceRoot)) {\n        throw new Error(`output escapes the tracker workspace: ${entryOutput}`);\n      }\n\n      let html = await readFile(entryInput, 'utf-8');\n      // Same order as the single render: reorder first so the guard judges the\n      // document that will actually be printed. Without this the batch path\n      // rendered N CVs with cv.sections silently inert.\n      html = reorderCvSections(html, cvSectionOrder);\n      validateCvSectionOrder(html, cvMarkdown, { allowReorder: globals.allowReorder });\n      html = normalizeTextForATS(html).html;\n\n      entries.push({\n        _idx: i,\n        html,\n        outputPath: entryOutput,\n        format: entryFormat,\n        baseDir: dirname(entryInput),\n        reportNum: entryReport,\n        inputPath: entryInput,","sourceCodeStart":1269,"sourceCodeEnd":1305,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/generate-pdf.mjs#L1269-L1305","documentation":"In the batch loop, each entry's output is resolved relative to the manifest's own directory (not cwd) and must satisfy isWorkspaceOutputPath() — inside the tracker workspace's output area even through symlinked ancestors. A manifest whose output escapes is treated as malformed or tampered and fails for that entry only: nothing is read or written for it, and the batch continues.","triggerScenarios":"A manifest at batch/manifest.json with \"output\": \"../reports/x.pdf\" — resolving above the output area; an absolute output like \"/srv/pdfs/x.pdf\" outside the workspace; the workspace reached through a symlink so a lexically-inside path canonicalizes outside.","commonSituations":"Manifests written when the repo lived at a different location; shared manifests reused across projects with different layouts; attempts to write deliverables straight into a reports/ or uploads/ directory.","solutions":["Point outputs inside the workspace output area, relative to the manifest: \"output\": \"../output/x.pdf\" for a manifest in batch/.","If deliverables must live elsewhere, render into output/ and copy afterwards.","For symlinked checkouts, run from the real path (see the assertInsideWorkspace errors) so containment resolves correctly."],"exampleFix":"// before (batch/manifest.json, workspace root is ..)\n{ \"input\": \"a.html\", \"output\": \"../reports/a.pdf\" }\n\n// after\n{ \"input\": \"a.html\", \"output\": \"../output/a.pdf\" }","handlingStrategy":"validation","validationCode":"import { resolve, relative, isAbsolute } from 'node:path';\n\nfunction outputStaysInWorkspaceOutput(spec, manifestDir, workspaceRoot) {\n  const out = resolve(manifestDir, spec.output);\n  const rel = relative(resolve(workspaceRoot, 'output'), out);\n  return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);\n}\nconst offenders = manifest.filter(e => !outputStaysInWorkspaceOutput(e, manifestDir, workspaceRoot));\nif (offenders.length) { console.error(`Outputs must stay under <workspace>/output/: ${offenders.map(o => o.output).join(', ')}`); process.exit(1); }","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Author manifest outputs relative to the manifest's own directory and aim them under output/.","Render into output/ and copy deliverables elsewhere afterwards — don't bend the manifest to reach external dirs.","When moving or renaming the repo, regenerate manifests instead of reusing absolute paths."],"tags":["batch","path-containment","security","manifest"],"backgroundTag":"path-traversal-guard","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","schemaVersion":2},"datasetVersion":"2026-08-21T13:17:26.733Z"}