{"record":{"id":"69f852d3c3e51ea9","repo":"can1357/oh-my-pi","slug":"skill-path-resolves-outside-the-plugin-root","errorCode":null,"errorMessage":"skill:// path resolves outside the plugin root: ${url.href}","messagePattern":"skill:// path resolves outside the plugin root: (.+?)","errorType":"validation","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/internal-urls/skill-protocol.ts","lineNumber":86,"sourceCode":"\t\tconst hasRelativePath = urlPath && urlPath !== \"/\" && urlPath !== \"\";\n\n\t\tif (hasRelativePath) {\n\t\t\tconst relativePath = decodeURIComponent(urlPath.slice(1));\n\t\t\tvalidateRelativePath(relativePath);\n\t\t\ttargetPath = path.join(skill.baseDir, relativePath);\n\n\t\t\tconst resolvedPath = path.resolve(targetPath);\n\t\t\tconst resolvedBaseDir = path.resolve(skill.baseDir);\n\t\t\tif (!resolvedPath.startsWith(resolvedBaseDir + path.sep) && resolvedPath !== resolvedBaseDir) {\n\t\t\t\tthrow new Error(\"Path traversal is not allowed\");\n\t\t\t}\n\t\t\t// Agent Plugin skills (§4.1): the resource must canonically resolve\n\t\t\t// within the plugin root; a dangling or unresolvable path fails closed.\n\t\t\t// Symlinks may target other files inside the same package.\n\t\t\tif (skill.containRoot) {\n\t\t\t\tconst contained = await resolveContainedPath(skill.containRoot, resolvedPath);\n\t\t\t\tif (contained.status === \"outside\") {\n\t\t\t\t\tthrow new Error(`skill:// path resolves outside the plugin root: ${url.href}`);\n\t\t\t\t}\n\t\t\t\tif (contained.status === \"missing\") {\n\t\t\t\t\tthrow new Error(`File not found: ${resolvedPath}`);\n\t\t\t\t}\n\t\t\t\ttargetPath = contained.realPath;\n\t\t\t}\n\t\t} else {\n\t\t\ttargetPath = context?.pathOnly === true ? skill.baseDir : skill.filePath;\n\t\t}\n\n\t\tlet stats: fsTypes.Stats;\n\t\ttry {\n\t\t\tstats = await fs.stat(targetPath);\n\t\t} catch (error) {\n\t\t\tif (isEnoent(error)) {\n\t\t\t\tthrow new Error(`File not found: ${targetPath}`);\n\t\t\t}\n\t\t\tthrow error;","sourceCodeStart":68,"sourceCodeEnd":104,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/internal-urls/skill-protocol.ts#L68-L104","documentation":"For Agent Plugin skills (spec §4.1) that declare a containRoot, resolve() additionally verifies the requested path canonically resolves (via resolveContainedPath, which follows symlinks) inside the plugin's contain root. If the real path lands outside that root, resolve() fails closed with this error that includes the offending URL. Symlinks may only target files within the same plugin package.","triggerScenarios":"Resolving skill://<name>/<path> on a plugin skill where the path (or a symlink along it) resolves via fs.realpath to a location outside skill.containRoot — e.g. a symlink to a shared directory elsewhere on disk, or a path that escapes the plugin package after canonicalization.","commonSituations":"Plugin authors symlinking shared assets from outside the package (e.g. ../../shared/theme.md) into their skill; linking to node_modules outside the contain root; a moved or re-rooted plugin install where symlinks now point outside; build artifacts generated outside the plugin root and linked in.","solutions":["Restructure the plugin so all skill-referenced files (including symlink targets) live inside the plugin contain root","Replace out-of-package symlinks with copies of the files inside the plugin package","If the resource belongs to another plugin/skill, reference it via that skill's own skill:// URL","Check the plugin layout on disk (fs.realpath on the target) to confirm where the canonical path lands"],"exampleFix":"// before\n// plugin/skills/my-skill/shared.md -> ../../../shared/docs.md (outside plugin root)\n// after\ncp ../../../shared/docs.md plugin/skills/my-skill/shared.md  # physical copy inside contain root","handlingStrategy":"validation","validationCode":"import { resolveContainedPath } from '../discovery/contained-path';\nconst target = path.resolve(skill.baseDir, rel);\nconst contained = await resolveContainedPath(skill.containRoot, target);\nif (contained.status !== 'inside') {\n  throw new Error(`target resolves outside plugin root (${contained.status})`);\n}","typeGuard":null,"tryCatchPattern":"try {\n  return await handler.resolve(url, ctx);\n} catch (err) {\n  if (err instanceof Error && err.message.includes('resolves outside the plugin root')) {\n    // flag the plugin for repackaging; skip resolving this path\n  }\n  throw err;\n}","preventionTips":["Package plugin assets physically inside the plugin root — no external symlinks","Run resolveContainedPath checks in CI for every file a skill references","After moving/reinstalling a plugin, re-verify that symlink targets still resolve inside the contain root"],"tags":["security","symlink","plugin","containment"],"backgroundTag":"symlink-escape-blocked","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}