{"record":{"id":"12e1d2e8c801788d","repo":"can1357/oh-my-pi","slug":"vault-url-escapes-vault-root","errorCode":null,"errorMessage":"vault:// URL escapes vault root","messagePattern":"vault:// URL escapes vault root","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/coding-agent/src/internal-urls/vault-protocol.ts","lineNumber":126,"sourceCode":"let cachedVaultDirectory: Map<string, string> | undefined;\nlet cachedActiveVaultPath: string | undefined;\nconst cachedVaultInfo = new Map<string, string>();\n\nfunction toVaultValidationError(error: unknown): Error {\n\tconst message = error instanceof Error ? error.message : String(error);\n\treturn new Error(message.replace(\"skill://\", \"vault://\"));\n}\n\nfunction getContentType(filePath: string): ContentType {\n\tif (isMarkdownPath(filePath)) return \"text/markdown\";\n\tconst ext = path.extname(filePath).toLowerCase();\n\tif (ext === \".json\") return \"application/json\";\n\treturn \"text/plain\";\n}\n\nfunction ensureWithinRoot(targetPath: string, rootPath: string): void {\n\tif (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) {\n\t\tthrow new Error(\"vault:// URL escapes vault root\");\n\t}\n}\n\nfunction encodePathComponent(component: string): string {\n\treturn encodeURIComponent(component).replaceAll(\"%2F\", \"/\");\n}\n\nfunction encodeRelativePath(relativePath: string): string {\n\treturn relativePath\n\t\t.split(\"/\")\n\t\t.filter(segment => segment.length > 0)\n\t\t.map(encodeURIComponent)\n\t\t.join(\"/\");\n}\n\nfunction decodeVaultPath(url: InternalUrl): {\n\trawPathname: string;\n\trelativePath: string;","sourceCodeStart":108,"sourceCodeEnd":144,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/coding-agent/src/internal-urls/vault-protocol.ts#L108-L144","documentation":"The vault:// protocol handler throws this when a resolved or canonicalized filesystem path is not the vault root itself and does not sit under it (prefix `root + path.sep`). It is a path-traversal containment check: `ensureWithinRoot` runs after `path.resolve` and after `fs.realpathSync`, so symlinked or `..`-laden targets that land outside the vault are rejected. It guards both file reads/directory listings and the ancestor-walk used to canonicalize not-yet-existing paths.","triggerScenarios":"resolveVaultUrlToPath, #readFile, #listDir, #resolveFsTarget, findExistingAncestor(Sync) are called with a vault:// URL whose decoded relativePath contains `..` segments surviving validation, or whose target is a symlink pointing outside the vault root, or whose cached vault root resolves to a different real path than the target's real ancestor (e.g. vault root itself is a symlink and realpathSync of the target escapes it).","commonSituations":"Constructing URLs by hand with `..` segments; note-taking setups where a note inside the vault symlinks to a folder outside (common for dotfiles or shared asset dirs); moving/renaming the vault so the cached root realpath no longer contains the target's realpath.","solutions":["Remove `..` segments and symlinks-to-outside from the vault path referenced by the URL, or reference the real path inside the vault.","Resolve the symlink into the vault (copy or move the target under the vault root) instead of linking out.","Re-read `vault://` (e.g. `vault://_/`) to refresh the cached vault root/active path so it matches the on-disk vault location, then retry.","Verify the vault root itself is a real directory (not a symlink chain) if the error occurs on every URL."],"exampleFix":"// before\nconst url = \"vault://_/../secrets/api-keys.md\";\nawait handler.resolve(parseInternalUrl(url)); // throws: escapes vault root\n// after\nconst url = \"vault://_/notes/api-keys.md\"; // keep target inside the vault\nawait handler.resolve(parseInternalUrl(url));","handlingStrategy":"validation","validationCode":"import * as path from \"node:path\";\nfunction staysInsideVault(relativePath: string, vaultRoot: string): boolean {\n  const abs = path.resolve(vaultRoot, relativePath);\n  return abs === vaultRoot || abs.startsWith(vaultRoot + path.sep);\n}\n// call before resolving: staysInsideVault(\"notes/a.md\", vaultRoot)","typeGuard":"function isWithinRoot(target: string, root: string): boolean {\n  return target === root || target.startsWith(`${root}${path.sep}`);\n}","tryCatchPattern":"try {\n  const p = resolveVaultUrlToPath(url);\n} catch (err) {\n  if (err instanceof Error && err.message === \"vault:// URL escapes vault root\") {\n    // reject the link or re-anchor it inside the vault; do not retry as-is\n  }\n  throw err;\n}","preventionTips":["Never build vault:// paths with `..` segments; normalize and strip them first","Avoid symlinking files outside the vault into notes you will resolve via vault://","Prefer URL-building helpers (encodeRelativePath-style) over string concatenation","Refresh the cached vault root after moving the vault on disk"],"tags":["path-traversal","security","filesystem","url"],"backgroundTag":"path-escapes-root","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}