{"record":{"id":"141b02fd846565eb","repo":"can1357/oh-my-pi","slug":"local-url-escapes-local-root","errorCode":null,"errorMessage":"local:// URL escapes local root","messagePattern":"local:// URL escapes local root","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/internal-urls/local-protocol.ts","lineNumber":23,"sourceCode":"import { AgentRegistry } from \"../registry/agent-registry\";\nimport { isMarkdownPath } from \"../utils/lang-from-path\";\nimport { buildDirectoryResource } from \"./filesystem-resource\";\nimport { parseInternalUrl } from \"./parse\";\nimport { validateRelativePath } from \"./skill-protocol\";\nimport type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from \"./types\";\n\nexport interface LocalProtocolOptions {\n\tgetArtifactsDir?: () => string | null;\n\tgetSessionId?: () => string | null;\n}\n\nfunction parseLocalUrl(input: string): InternalUrl {\n\treturn parseInternalUrl(input);\n}\n\nfunction ensureWithinRoot(targetPath: string, rootPath: string): void {\n\tif (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) {\n\t\tthrow new Error(\"local:// URL escapes local root\");\n\t}\n}\n\nfunction toLocalValidationError(error: unknown): Error {\n\tconst message = error instanceof Error ? error.message : String(error);\n\treturn new Error(message.replace(\"skill://\", \"local://\"));\n}\nconst WINDOWS_LOCAL_ROOT_MAX_CHARS = 180;\n\nfunction safeSessionId(options: LocalProtocolOptions): string {\n\tconst raw = options.getSessionId?.() ?? \"session\";\n\tconst safe = raw.replace(/[^a-zA-Z0-9_.-]/g, \"_\");\n\treturn safe.length > 0 ? safe : \"session\";\n}\n\nfunction shortLocalRoot(options: LocalProtocolOptions): string {\n\t// Derive the short root from the stable session id, never the artifact path,\n\t// so `SessionManager.moveTo()` and the resume-after-move flow keep finding","sourceCodeStart":5,"sourceCodeEnd":41,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/internal-urls/local-protocol.ts#L5-L41","documentation":"ensureWithinRoot enforces that any local:// target — after path resolution and realpath (symlink) expansion — still lies inside the session's local root directory. It throws when the resolved path is neither the root itself nor a descendant of it. This is a path-traversal / symlink-escape security guard.","triggerScenarios":"Resolving local://../../etc/passwd (validateRelativePath usually catches ../ first, but realpath-expansion can still expose escapes); a symlink inside the local root pointing at a file outside it, e.g. local://link where link -> /etc/passwd; resolveLocalUrlToPath or resolveLocalTarget with a crafted InternalUrl.","commonSituations":"A user or LLM writes a symlink into the session's local artifacts dir; sessions whose artifacts dir was relocated without copying contents; hostile input embedded in model-generated local:// URLs.","solutions":["Remove or replace the symlink inside the local root that points outside it.","Only reference paths genuinely under the session local root (visible via local:// listing).","If you legitimately need external files, read them with the normal file tools, not local://."],"exampleFix":null,"handlingStrategy":"validation","validationCode":"const rel = url.replace(/^local:\\/\\//, '');\nif (rel.includes('..') || path.isAbsolute(rel)) throw new Error('local:// path must be relative and stay inside the session root');","typeGuard":null,"tryCatchPattern":"try { resource = await handler.resolve(url, ctx); } catch (e) { if (e.message === 'local:// URL escapes local root') { /* treat as security rejection; log and refuse */ } else throw e; }","preventionTips":["Only reference paths shown in the local:// root listing.","Never create symlinks inside the session local artifacts directory.","Treat this error as a security signal — investigate the URL source rather than working around it."],"tags":["security","path-traversal","symlink"],"backgroundTag":"path-traversal-blocked","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}