{"record":{"id":"a495ff0a4565e155","repo":"moeru-ai/airi","slug":"extension-entrypoint-resolves-outside-the-package-folder","errorCode":null,"errorMessage":"Extension entrypoint resolves outside the package folder: ${entrypoint}","messagePattern":"Extension entrypoint resolves outside the package folder: (.+?)","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"apps/stage-tamagotchi/src/main/services/airi/plugins/host/directory-import.ts","lineNumber":248,"sourceCode":"  if (!parsedManifest.success) {\n    throw new Error(`Extension manifest is invalid: ${formatManifestDiagnostics(parsedManifest.diagnostics)}`)\n  }\n\n  for (const entrypoint of Object.values(parsedManifest.manifest.entrypoints)) {\n    if (!entrypoint) {\n      continue\n    }\n    if (isAbsolute(entrypoint)) {\n      throw new Error(`Imported Extension entrypoints must be relative paths: ${entrypoint}`)\n    }\n    const resolvedEntrypoint = resolve(sourceRealPath, entrypoint)\n    if (!isContainedPath(sourceRealPath, resolvedEntrypoint)) {\n      throw new Error(`Extension entrypoint escapes the package folder: ${entrypoint}`)\n    }\n    await assertRegularFile(resolvedEntrypoint, 'Extension entrypoint')\n    const entrypointRealPath = await realpath(resolvedEntrypoint)\n    if (!isContainedPath(sourceRealPath, entrypointRealPath)) {\n      throw new Error(`Extension entrypoint resolves outside the package folder: ${entrypoint}`)\n    }\n  }\n\n  const fingerprint = createHash('sha256')\n  for (const directory of directories) {\n    fingerprint.update(`directory\\0${directory}\\0`)\n  }\n  for (const file of files) {\n    fingerprint.update(`file\\0${file.relativePath}\\0${file.size}\\0`)\n    if (file.relativePath === manifestRelativePath) {\n      fingerprint.update(manifestContents)\n    }\n    else {\n      let bytesRead = 0\n      for await (const chunk of createReadStream(file.path)) {\n        const contents = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)\n        bytesRead += contents.byteLength\n        if (bytesRead > file.size) {","sourceCodeStart":230,"sourceCodeEnd":266,"githubUrl":"https://github.com/moeru-ai/airi/blob/438a067dde47aa0bdb46c2323d1fe293dc805218/apps/stage-tamagotchi/src/main/services/airi/plugins/host/directory-import.ts#L230-L266","documentation":"After the entrypoint path passes the lexical containment check, AIRI resolves the file's `realpath` (following symlinks) and verifies it again stays within the package folder. This error is thrown when the entrypoint file is a symlink (or hardlink chain) pointing outside the imported directory — a defense against symlink-based escapes from the package sandbox.","triggerScenarios":"`inspectExtensionDirectory` calls `realpath(resolvedEntrypoint)` and the resulting real path is not contained by `sourceRealPath`, e.g. the entrypoint is `./main.js` but `main.js` is a symlink to `/home/user/lib/main.js` outside the package.","commonSituations":"Developers symlinking source files during development into the extension folder and then importing the folder as-is; package managers creating symlinks (e.g. pnpm/node_modules links) inside the extension; zips extracted with symlink entries preserved.","solutions":["Replace the symlinked entrypoint with a real copy of the target file inside the package folder.","Re-pack the extension ensuring no symlink entries escape the root (dereference symlinks when zipping, e.g. `zip -r --symlinks` alternatives or copy with `-L`).","If the symlink target is legitimate, move that dependency into the package and update the manifest entrypoint."],"exampleFix":"// before: package/main.js -> /home/user/lib/main.js (symlink)\n// after\ncp -L /home/user/lib/main.js package/main.js  # real file inside the package","handlingStrategy":"validation","validationCode":"import { realpath } from 'node:fs/promises'\nasync function assertNoSymlinkEscape(root, entrypoint) {\n  const real = await realpath(resolve(root, entrypoint))\n  if (real !== root && !real.startsWith(root + '/'))\n    throw new Error(`entrypoint symlink escapes package: ${entrypoint} -> ${real}`)\n}","typeGuard":"import { lstat } from 'node:fs/promises'\nconst isRealFileNotSymlink = async (p) => (await lstat(p)).isFile() && !(await lstat(p)).isSymbolicLink()","tryCatchPattern":"try {\n  await importExtension(folder)\n} catch (err) {\n  if (String(err.message).includes('resolves outside the package folder')) {\n    showHint('The entrypoint is a symlink pointing outside the package. Ship real files instead.')\n  } else throw err\n}","preventionTips":["Do not symlink source files into the extension folder you plan to import; copy them instead.","Repackage archives with symlinks dereferenced so no link targets escape the root.","Run `find package -type l` before distribution to detect stray symlinks."],"tags":["extensions","symlink","path-traversal","security"],"backgroundTag":"path-traversal-blocked","analyzedSha":"438a067dde47aa0bdb46c2323d1fe293dc805218","analyzedAt":"2026-09-17T01:14:42.644Z","contentChangedAt":"2026-09-17T01:14:42.644Z","schemaVersion":2},"datasetVersion":"2026-09-23T08:17:48.524Z"}