{"version":3,"file":"boundary.d.ts","sourceRoot":"","sources":["../../../src/core/safety/boundary.ts"],"names":[],"mappings":"AAKA,qBAAa,sBAAuB,SAAQ,KAAK;IAChD,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,YAAY,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAIxC;CACD;AAED,QAAA,MAAM,GAAG,YAAW,CAAC;AACrB,QAAA,MAAM,SAAS,SAA+B,CAAC;AAE/C;;;GAGG;AACH,wBAAgB,YAAY,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAS9C;AASD,MAAM,WAAW,oBAAoB;IACpC,oDAAoD;IACpD,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,gDAAgD;IAChD,SAAS,CAAC,EAAE,OAAO,CAAC;IACpB,2FAA2F;IAC3F,SAAS,CAAC,EAAE,OAAO,CAAC;CACpB;AAED;;;;;;;GAOG;AACH,qBAAa,iBAAiB;IAC7B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAElC,OAAO,eAGN;IAED,OAAa,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,iBAAiB,CAAC,CAQ5D;IAED,IAAI,aAAa,IAAI,MAAM,CAE1B;IAED,OAAO,CAAC,WAAW;IAanB;;;;OAIG;IACG,aAAa,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAQlD;IAED;;;;OAIG;IACG,kBAAkB,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAMzD;YAGa,kBAAkB;IA6BhC,2EAA2E;IACrE,SAAS,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO7C;CACD;AAED,uEAAuE;AACvE,wBAAgB,iBAAiB,CAAC,CAAC,EAAE,MAAM,EAAE,SAAS,UAAO,GAAG,MAAM,CAKrE;AAED,OAAO,EAAE,SAAS,EAAE,GAAG,EAAE,CAAC;AAE1B,mFAAmF;AACnF,wBAAgB,WAAW,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAK7C;AAED,wBAAsB,UAAU,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAO9D","sourcesContent":["import { constants } from \"node:fs\";\nimport { access, lstat, realpath } from \"node:fs/promises\";\nimport os from \"node:os\";\nimport path from \"node:path\";\n\nexport class WorkspaceBoundaryError extends Error {\n\treadonly code: string;\n\tconstructor(code: string, message: string) {\n\t\tsuper(message);\n\t\tthis.code = code;\n\t\tthis.name = \"WorkspaceBoundaryError\";\n\t}\n}\n\nconst SEP = path.sep;\nconst isWindows = process.platform === \"win32\";\n\n/**\n * A canonical platform key used for membership comparisons. On Windows this is\n * case-insensitive and drive-letter-normalized; on POSIX it is byte-identical.\n */\nexport function canonicalKey(p: string): string {\n\tlet normalized = p.replace(/[\\\\/]+/g, \"/\");\n\tif (normalized !== \"/\" && normalized.endsWith(\"/\")) normalized = normalized.slice(0, -1);\n\tif (isWindows) {\n\t\tnormalized = normalized.toLowerCase();\n\t\t// normalize drive letter, keep a single leading slash\n\t\tnormalized = normalized.replace(/^([a-z]):/, \"$1:\");\n\t}\n\treturn normalized;\n}\n\nfunction isWithin(realTarget: string, realRoot: string): boolean {\n\tconst t = canonicalKey(realTarget);\n\tconst r = canonicalKey(realRoot);\n\tif (t === r) return true;\n\treturn t.startsWith(`${r}/`);\n}\n\nexport interface BoundaryCheckOptions {\n\t/** Reject NUL and malformed input. Default true. */\n\tvalidateInput?: boolean;\n\t/** Reject UNC paths (Windows). Default true. */\n\trejectUnc?: boolean;\n\t/** Reject absolute paths outside root entirely (single-root containment). Default true. */\n\tcontained?: boolean;\n}\n\n/**\n * Canonical workspace-root enforcement for every path-bearing mutation.\n *\n * Normalizes absolute/relative/`..` paths, resolves symlinks (and Windows\n * junctions/reparse points via realpath), and rejects anything that escapes the\n * authorized root. Callers must re-run `assertParentWithin` immediately before\n * the physical write to narrow the TOCTOU window.\n */\nexport class WorkspaceBoundary {\n\treadonly root: string;\n\tprivate readonly rootReal: string;\n\n\tprivate constructor(root: string, rootReal: string) {\n\t\tthis.root = path.resolve(root);\n\t\tthis.rootReal = rootReal;\n\t}\n\n\tstatic async create(root: string): Promise<WorkspaceBoundary> {\n\t\tlet rootReal = path.resolve(root);\n\t\ttry {\n\t\t\trootReal = await realpath(rootReal);\n\t\t} catch {\n\t\t\t// root does not exist yet; use the resolved form.\n\t\t}\n\t\treturn new WorkspaceBoundary(root, rootReal);\n\t}\n\n\tget effectiveRoot(): string {\n\t\treturn this.rootReal;\n\t}\n\n\tprivate assertInput(p: string): void {\n\t\tif (p === \"\") throw new WorkspaceBoundaryError(\"empty_path\", \"empty path\");\n\t\tif (p.includes(\"\\u0000\")) {\n\t\t\tthrow new WorkspaceBoundaryError(\"nul_byte\", \"path contains NUL byte\");\n\t\t}\n\t\tif (isWindows && p.startsWith(\"\\\\\\\\\")) {\n\t\t\tthrow new WorkspaceBoundaryError(\"unc_path\", \"UNC paths are not allowed\");\n\t\t}\n\t\tif (isWindows && /^[a-z]:(?![\\\\/])/i.test(p)) {\n\t\t\t// drive-relative like `C:foo` — normalise on the current drive's root.\n\t\t}\n\t}\n\n\t/**\n\t * Resolve an input path (absolute or relative to the workspace root) to an\n\t * absolute path that is guaranteed to be within the real workspace root.\n\t * Throws WorkspaceBoundaryError on escape.\n\t */\n\tasync resolveWithin(input: string): Promise<string> {\n\t\tthis.assertInput(input);\n\t\tconst abs = path.isAbsolute(input) ? input : path.resolve(this.root, input);\n\t\tconst realTarget = await this.resolvedRealtarget(abs);\n\t\tif (!isWithin(realTarget, this.rootReal)) {\n\t\t\tthrow new WorkspaceBoundaryError(\"escape\", `path escapes the authorized workspace: ${input}`);\n\t\t}\n\t\treturn realTarget;\n\t}\n\n\t/**\n\t * Revalidate the resolved parent directory of a target immediately before a\n\t * write, protecting against the parent directory being swapped for a symlink\n\t * to an external directory after the initial validation.\n\t */\n\tasync assertParentWithin(targetAbs: string): Promise<void> {\n\t\tconst parent = path.dirname(targetAbs);\n\t\tconst realParent = await this.resolvedRealtarget(parent);\n\t\tif (!isWithin(realParent, this.rootReal)) {\n\t\t\tthrow new WorkspaceBoundaryError(\"escape\", `parent directory escapes the authorized workspace: ${targetAbs}`);\n\t\t}\n\t}\n\n\t/** Resolve the real path of a target, using the nearest existing ancestor. */\n\tprivate async resolvedRealtarget(abs: string): Promise<string> {\n\t\ttry {\n\t\t\treturn await realpath(abs);\n\t\t} catch {\n\t\t\t// The leaf does not exist yet. Resolve the nearest existing ancestor\n\t\t\t// to catch a symlinked ancestor escaping the workspace.\n\t\t\tlet candidate = abs;\n\t\t\tconst missing: string[] = [];\n\t\t\tfor (;;) {\n\t\t\t\tlet real: string | null = null;\n\t\t\t\ttry {\n\t\t\t\t\treal = await realpath(candidate);\n\t\t\t\t} catch {\n\t\t\t\t\treal = null;\n\t\t\t\t}\n\t\t\t\tif (real !== null) {\n\t\t\t\t\tconst joined = path.join(real, ...missing);\n\t\t\t\t\treturn joined;\n\t\t\t\t}\n\t\t\t\tconst parent = path.dirname(candidate);\n\t\t\t\tif (parent === candidate) {\n\t\t\t\t\treturn abs;\n\t\t\t\t}\n\t\t\t\tmissing.unshift(path.basename(candidate));\n\t\t\t\tcandidate = parent;\n\t\t\t}\n\t\t}\n\t}\n\n\t/** True when a path already exists as a symlink (for rollback/restore). */\n\tasync isSymlink(abs: string): Promise<boolean> {\n\t\ttry {\n\t\t\tconst st = await lstat(abs);\n\t\t\treturn st.isSymbolicLink();\n\t\t} catch {\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\n/** Reject NUL and throw for secret-bearing paths when so requested. */\nexport function validatePathInput(p: string, rejectNul = true): string {\n\tif (rejectNul && p.includes(\"\\u0000\")) {\n\t\tthrow new WorkspaceBoundaryError(\"nul_byte\", \"path contains NUL byte\");\n\t}\n\treturn p;\n}\n\nexport { isWindows, SEP };\n\n/** Strip a leading home dir from display purposes (never used for containment). */\nexport function displayPath(p: string): string {\n\tconst home = os.homedir();\n\tif (p === home) return \"~\";\n\tif (p.startsWith(home + path.sep)) return `~${p.slice(home.length)}`;\n\treturn p;\n}\n\nexport async function pathExists(abs: string): Promise<boolean> {\n\ttry {\n\t\tawait access(abs, constants.F_OK);\n\t\treturn true;\n\t} catch {\n\t\treturn false;\n\t}\n}\n"]}