{"record":{"id":"93f1821c4cc7952f","repo":"jackwener/OpenCLI","slug":"plugin-path-subpath-escapes-repo-root","errorCode":null,"errorMessage":"Plugin path \"${subPath}\" escapes repo root.","messagePattern":"Plugin path \"(.+?)\" escapes repo root\\.","errorType":"validation","errorClass":"PluginError","httpStatus":null,"severity":"error","filePath":"src/plugin.ts","lineNumber":274,"sourceCode":"  if (!source || source.kind === 'local') {\n    throw new Error(`Unable to determine remote source for plugin at ${dir}`);\n  }\n  return source.url;\n}\n\nfunction pathExistsSync(p: string): boolean {\n  try {\n    fs.lstatSync(p);\n    return true;\n  } catch {\n    return false;\n  }\n}\n\nfunction resolveRepoContainedPath(repoRoot: string, subPath: string): string {\n  const resolved = path.resolve(repoRoot, subPath);\n  if (!resolved.startsWith(repoRoot + path.sep) && resolved !== repoRoot) {\n    throw new PluginError(`Plugin path \"${subPath}\" escapes repo root.`);\n  }\n  return resolved;\n}\n\nfunction removePathSync(p: string): void {\n  try {\n    const stat = fs.lstatSync(p);\n    if (stat.isSymbolicLink()) {\n      fs.unlinkSync(p);\n      return;\n    }\n    fs.rmSync(p, { recursive: true, force: true });\n  } catch {}\n}\n\ninterface TransactionHandle {\n  finalize(): void;\n  rollback(): void;","sourceCodeStart":256,"sourceCodeEnd":292,"githubUrl":"https://github.com/jackwener/OpenCLI/blob/49907e53dc3ade5c223ff0c4c2c2785687cec4e6/src/plugin.ts#L256-L292","documentation":"resolveRepoContainedPath resolves a subPath against a repo root and refuses results that resolve outside the repo root (path traversal). opencli throws this as a security guard so a malicious or misconfigured monorepo manifest cannot point a plugin symlink at arbitrary filesystem locations.","triggerScenarios":"Called from installMonorepo, publishMonorepoPlugins, and updatePlugin with a manifest entry whose `path` is absolute, contains '../' segments that climb above the repo root, or resolves to the repo root itself via symlinks — e.g. plugins entry { path: \"../../etc\" } in opencli-plugin.json.","commonSituations":"A monorepo manifest with a mistyped plugin path (leading slash or extra ../); a malicious third-party plugin repo attempting path escape; Windows path separator mismatches; repo moved so relative paths no longer line up.","solutions":["Fix the plugin's `path` in the monorepo manifest to a relative path that stays inside the repository.","Remove leading '/' or '../' segments from the path.","Use forward slashes and repo-relative paths so resolution works cross-platform.","If a third-party repo triggers this intentionally, do not install it — this is a security rejection."],"exampleFix":"// before (opencli-plugin.json)\n{ \"plugins\": { \"evil\": { \"path\": \"../../outside\" } } }\n// after\n{ \"plugins\": { \"good\": { \"path\": \"packages/good-plugin\" } } }","handlingStrategy":"validation","validationCode":"const path = require('node:path');\nfunction pathStaysInside(repoRoot, subPath) {\n  const resolved = path.resolve(repoRoot, subPath);\n  return resolved === repoRoot || resolved.startsWith(repoRoot + path.sep);\n}","typeGuard":null,"tryCatchPattern":"try {\n  installPlugin('github:user/monorepo/sub');\n} catch (err) {\n  if (err instanceof PluginError && err.message.includes('escapes repo root')) {\n    // reject manifest / fix plugin path — do not bypass\n  } else throw err;\n}","preventionTips":["Use plain repo-relative paths (no leading '/', no '..') in monorepo manifests.","Treat this error from third-party repos as a security signal — don't work around it.","Test manifests with path.resolve in CI before publishing a plugin repo."],"tags":["security","path-traversal","monorepo-manifest"],"backgroundTag":"path-traversal-attempt","analyzedSha":"49907e53dc3ade5c223ff0c4c2c2785687cec4e6","analyzedAt":"2026-08-29T08:14:47.543Z","schemaVersion":2},"datasetVersion":"2026-08-29T12:17:43.993Z"}