{"record":{"id":"18e95d95a7f759f8","repo":"santifer/career-ops","slug":"refusing-to-write-the-cover-letter-outside-output-18e95d","errorCode":null,"errorMessage":"Refusing to write the cover letter outside output/: ${raw}","messagePattern":"Refusing to write the cover letter outside output/: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"generate-cover-letter.mjs","lineNumber":50,"sourceCode":" * would escape `output/` — `..` traversal or an absolute path outside it —\n * are rejected instead of being silently flattened to `output/<basename>`.\n *\n * @param {string} raw - Caller-supplied --out / payload.output_path value.\n * @returns {string} Absolute path inside OUTPUT_ROOT.\n */\nexport function safeOutputPath(raw) {\n  if (raw == null || String(raw).trim() === \"\") {\n    throw new Error(\"Refusing to write the cover letter outside output/: (empty path)\");\n  }\n  const trimmed = String(raw).trim();\n\n  const asWritten = resolve(trimmed);\n  if (containedInOutput(asWritten)) return asWritten;\n\n  // Absolute paths and any `..` segment already chose a location; if that\n  // location is not inside output/, refuse instead of rewriting to a basename.\n  if (isAbsolute(trimmed) || /(^|[\\\\/])\\.\\.([\\\\/]|$)/.test(trimmed)) {\n    throw new Error(`Refusing to write the cover letter outside output/: ${raw}`);\n  }\n\n  // Bare filename or a relative path that is not already under output/\n  // (e.g. --out cover.pdf, or --out output/foo/bar.pdf from another cwd).\n  const posix = trimmed.replace(/\\\\/g, \"/\").replace(/^\\.\\//, \"\");\n  const relativeToRoot = posix === \"output\" || posix === \"output/\"\n    ? \"\"\n    : posix.startsWith(\"output/\")\n      ? posix.slice(\"output/\".length)\n      : posix;\n  const candidate = resolve(OUTPUT_ROOT, relativeToRoot);\n  if (containedInOutput(candidate)) return candidate;\n\n  throw new Error(`Refusing to write the cover letter outside output/: ${raw}`);\n}\n\n/** True when absPath is a file (not output/ itself) still inside OUTPUT_ROOT. */\nfunction containedInOutput(absPath) {","sourceCodeStart":32,"sourceCodeEnd":68,"githubUrl":"https://github.com/santifer/career-ops/blob/60398d6549a46f5266929538af21cfab94badc75/generate-cover-letter.mjs#L32-L68","documentation":"safeOutputPath() refuses a path that is absolute but outside output/, or that contains any `..` path segment. The design decision is explicit in the source: a path with `..` or an absolute prefix has already chosen a location, so the script refuses rather than silently flattening it to output/<basename> — the user might believe the file landed where they pointed it.","triggerScenarios":"`--out /tmp/cover.pdf` (absolute, outside output/); `--out ../cover.pdf` or `--out ../../etc/tmp/x.pdf` (traversal segments); `--out output/../../cover.pdf` (the `..` regex fires even though it starts with output/).","commonSituations":"Users used to pointing PDF tools at /tmp for a quick look; CI scripts passing an artifacts directory outside the repo; habit from other tools that silently rewrite stray paths into their output dir.","solutions":["Use a bare filename (`--out cover.pdf`) or a path under output/ (`--out output/acme/cover.pdf`) — subdirectories under output/ are preserved.","If you genuinely need the file elsewhere, render into output/ then copy it: `node ... --out cover.pdf && cp output/cover.pdf /tmp/`."],"exampleFix":"# before\nnode generate-cover-letter.mjs --payload p.json --out /tmp/acme.pdf\n\n# after\nnode generate-cover-letter.mjs --payload p.json --out acme.pdf\ncp output/acme.pdf /tmp/  # if needed elsewhere","handlingStrategy":"validation","validationCode":"import { isAbsolute } from 'node:path';\n\nfunction isSafeOutCandidate(raw) {\n  const t = String(raw ?? '').trim();\n  if (t === '') return false;\n  if (isAbsolute(t)) return t.includes('/output/') || t.includes('\\\\output\\\\'); // at most heuristic; prefer relative\n  return !/(^|[\\\\/])\\.\\.([\\\\/]|$)/.test(t);\n}\nif (!isSafeOutCandidate(args.out)) {\n  console.error('Write inside output/ (e.g. --out cover.pdf), then copy the file where you need it.');\n  process.exit(1);\n}","typeGuard":null,"tryCatchPattern":"try {\n  const abs = safeOutputPath(userPath);\n} catch (err) {\n  if (err.message.startsWith('Refusing to write the cover letter outside output/')) {\n    // deliberate refusal, not a bug: tell the user the sandbox rule\n    console.error('Cover letters are written under output/. Re-run with --out <filename>.');\n    process.exit(1);\n  }\n  throw err;\n}","preventionTips":["Adopt the habit of always passing a bare filename or output/-prefixed relative path to --out.","Render into output/, then copy/move in a separate step — never fight the sandbox.","Reject any user-supplied path containing '..' upstream in your own tooling."],"tags":["path-traversal","security","cli","sandbox-escape"],"backgroundTag":"path-traversal-guard","analyzedSha":"60398d6549a46f5266929538af21cfab94badc75","analyzedAt":"2026-08-20T23:00:06.764Z","contentChangedAt":null,"schemaVersion":2},"datasetVersion":"2026-09-14T00:17:10.932Z"}