{"record":{"id":"631b30d00cff029e","repo":"paperclipai/paperclip","slug":"createos-archive-destination-cannot-be-a-symlink","errorCode":null,"errorMessage":"CreateOS archive destination cannot be a symlink.","messagePattern":"CreateOS archive destination cannot be a symlink\\.","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/plugins/sandbox-providers/createos/src/file-sync.ts","lineNumber":150,"sourceCode":"          filesTransferred += mapping.kind === \"file\" ? 1 : await countArchiveFiles(source);\n          bytesTransferred += (await fs.stat(source)).size;\n        } else {\n          const excludeArgs = (mapping.exclude ?? []).map((pattern) => `--exclude=${shellQuote(pattern)}`).join(\" \");\n          await run(mapping.kind === \"file\"\n            ? `${remoteGuard(remote)} && test -f \"$resolved\" && cp -- \"$resolved\" ${shellQuote(scratch)}`\n            : `${remoteGuard(remote)} && test -d \"$resolved\" && tar ${mapping.followSymlinks ? \"-h \" : \"\"}${excludeArgs} -cf ${shellQuote(scratch)} -C \"$resolved\" .`);\n          await download(scratch, transferFile, mapping.mode ?? 0o600);\n          bytesTransferred += (await fs.stat(transferFile)).size;\n          if (mapping.kind === \"file\") {\n            // Apply exact requested mode before promotion, including under a\n            // restrictive umask. Never expose secret bytes at the final path first.\n            await fs.chmod(transferFile, mapping.mode ?? 0o600);\n            await fs.rename(transferFile, local);\n            filesTransferred++;\n          } else {\n            filesTransferred += await validateArchive(transferFile);\n            await fs.mkdir(local, { recursive: true, mode: mapping.mode ?? 0o700 });\n            if ((await fs.lstat(local)).isSymbolicLink()) throw new Error(\"CreateOS archive destination cannot be a symlink.\");\n            // tar rejects traversal through existing symlink parents. Validate\n            // all archive entries first so an unsafe archive never partly lands.\n            await tar.x({ file: transferFile, cwd: local, strict: true, preservePaths: false });\n            if (mapping.mode != null) await fs.chmod(local, mapping.mode);\n          }\n        }\n      } finally {\n        await fs.rm(temp, { recursive: true, force: true });\n        await client.json(`/sandboxes/${id}/exec`, \"POST\", { cmd: \"/bin/rm\", args: [\"-f\", \"--\", scratch] }).catch(() => undefined);\n      }\n    }\n    for (const command of operation.postUploadCommands ?? []) {\n      await run(remoteGuard(command.cwd ?? ROOT));\n      await run(command.command, command.cwd ?? ROOT, command.timeoutMs);\n    }\n    operations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred });\n  }\n  return { operations };","sourceCodeStart":132,"sourceCodeEnd":168,"githubUrl":"https://github.com/paperclipai/paperclip/blob/3f1d897a7c018d76563a21c6e39c3c9b03933622/packages/plugins/sandbox-providers/createos/src/file-sync.ts#L132-L168","documentation":"When extracting an outbound archive into a local directory, the plugin checks that the destination directory itself is not a symlink before untarring. This blocks archive-extraction attacks where an attacker-controlled sandbox replaces the target path with a symlink pointing elsewhere on the host. It is a security guard for host-side path integrity.","triggerScenarios":"download() creates/copies a path at the mapping's local target, and a prior state (or a malicious archive/remote content) leaves `local` as a symlink when lstat runs, i.e. `(await fs.lstat(local)).isSymbolicLink()` is true after mkdir.","commonSituations":"A previous sync or an attacker planted a symlink at the extraction directory; the target path deliberately points at a symlinked directory the user expected to follow; repeated runs on shared temp directories with stale state.","solutions":["Remove the symlink at the target path and let the plugin create a real directory, then retry the transfer.","Audit the host directory for unexpected symlinks — this can indicate a compromised sandbox or archive (tar.x runs with strict:true and preservePaths:false, but the destination check is separate).","Point the mapping's local target at a fresh, plugin-owned directory rather than a shared or user-controlled path."],"exampleFix":"// before (host state)\n/work/out -> /etc   // symlink\n// after: clear it so a real directory is created\nrm /work/out && mkdir /work/out   // or delete the symlink and re-run syncFiles","handlingStrategy":"validation","validationCode":"import fs from \"node:fs/promises\";\nconst st = await fs.lstat(localTarget).catch(() => null);\nif (st?.isSymbolicLink()) throw new Error(`target is a symlink: ${localTarget}`);","typeGuard":"const isRealDir = async (p: string) => {\n  try { return (await fs.lstat(p)).isDirectory() && !(await fs.lstat(p)).isSymbolicLink(); }\n  catch { return false; }\n};","tryCatchPattern":"try {\n  await syncFiles({ direction: \"out\", operations });\n} catch (err) {\n  if (err.message.includes(\"symlink\")) {\n    // inspect for tampering; remove symlink with a real dir and retry once\n  } else throw err;\n}","preventionTips":["Extract archives only into fresh plugin-owned directories","Audit shared temp dirs for stale symlinks before runs","Treat this error as a possible compromise signal and investigate"],"tags":["security","symlink","archive-extraction","path-traversal"],"backgroundTag":"path-traversal-blocked","analyzedSha":"3f1d897a7c018d76563a21c6e39c3c9b03933622","analyzedAt":"2026-09-18T08:03:59.046Z","contentChangedAt":"2026-09-18T08:03:59.046Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}