{"record":{"id":"d450f41207394513","repo":"nanocoai/nanoclaw","slug":"inbox-safety-rejecting-unsafe-inbox-path","errorCode":null,"errorMessage":"inbox-safety: rejecting unsafe inbox path","messagePattern":"inbox-safety: rejecting unsafe inbox path","errorType":"console","errorClass":null,"httpStatus":null,"severity":"warning","filePath":"src/inbox-safety.ts","lineNumber":61,"sourceCode":" *\n * Returns the resolved, contained subdir path (write into it with an exclusive\n * flag — `COPYFILE_EXCL` / `wx` — so a pre-existing symlinked *file* can't be\n * followed either), or `null` if any guard tripped. On `null` the caller logs\n * its own context and skips; `context` is merged into the warn logs here so\n * each call site stays diagnosable.\n */\nexport function ensureContainedInboxDir(\n  inboxRoot: string,\n  messageId: string,\n  context: Record<string, unknown>,\n): string | null {\n  const inboxDir = path.join(inboxRoot, messageId);\n\n  for (const dir of [inboxRoot, inboxDir]) {\n    try {\n      const st = fs.lstatSync(dir);\n      if (st.isSymbolicLink() || !st.isDirectory()) {\n        log.warn('inbox-safety: rejecting unsafe inbox path', { ...context, dir });\n        return null;\n      }\n    } catch {\n      // Does not exist yet — fine, mkdir below creates it.\n    }\n  }\n\n  fs.mkdirSync(inboxDir, { recursive: true });\n\n  try {\n    const realInboxDir = fs.realpathSync(inboxDir);\n    const realInboxRoot = fs.realpathSync(inboxRoot);\n    if (!isPathInside(realInboxRoot, realInboxDir)) {\n      log.warn('inbox-safety: inbox dir escaped inbox root', { ...context, inboxDir });\n      return null;\n    }\n    return realInboxDir;\n  } catch (err) {","sourceCodeStart":43,"sourceCodeEnd":79,"githubUrl":"https://github.com/nanocoai/nanoclaw/blob/294ef2aee85218b23ad30eda9dfe10e590b54a8c/src/inbox-safety.ts#L43-L79","documentation":"inbox-safety rejected an inbox path because lstat found the inbox root or per-message inbox dir is a symlink or not a directory. This is path-traversal/symlink hardening for attachment extraction: a null return means the caller refuses to write attachments.","triggerScenarios":"ensureContainedInboxDir lstats inboxRoot or inboxRoot/<messageId> and finds S_ISLNK or non-directory; returns null so targetInboxDir/extractAttachmentFiles skip the files.","commonSituations":"Someone symlinked data/.../inbox to another location (or /tmp) for debugging; a compromised or buggy writer replaced the dir; restoring from archive converted the dir to a symlink.","solutions":["ls -l the reported dir path and remove the offending symlink","Recreate it as a real directory with correct ownership","Audit what created the symlink (migration script, backup restore, manual tweak)","Never point the inbox at another volume via symlink — mount the volume at the inbox root instead"],"exampleFix":"# before\ninbox -> /mnt/bigdisk/inbox (symlink)\n\n# after\nrm inbox\nmkdir inbox\n# or mount /mnt/bigdisk at the inbox root in the container config","handlingStrategy":"validation","validationCode":"import fs from 'node:fs';\nfunction isInboxDirSafe(p: string): boolean {\n  try {\n    const st = fs.lstatSync(p);\n    return st.isDirectory(); // lstat: false for symlinks\n  } catch { return true; } // absent is fine\n}","typeGuard":"function isRealDirectory(p: string): boolean {\n  try { return fs.lstatSync(p).isDirectory() && !fs.lstatSync(p).isSymbolicLink(); }\n  catch { return false; }\n}","tryCatchPattern":null,"preventionTips":["Never symlink inbox dirs; use mounts","Audit session data trees for symlinks after restores/migrations","Run the inbox-safety checks in a startup self-test for the data dir"],"tags":["security","symlink","path-traversal","attachments","inbox"],"backgroundTag":"symlink-path-traversal-rejected","analyzedSha":"294ef2aee85218b23ad30eda9dfe10e590b54a8c","analyzedAt":"2026-08-28T13:59:10.357Z","schemaVersion":2},"datasetVersion":"2026-08-28T16:17:29.566Z"}