mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(windows): compare reviewed plugin paths canonical-to-canonical under LPAC
Hosted Windows (LPAC) refuses lstat on the drive root, so Node's JS
realpathSync — which walks every ancestor — failed the reviewed-closure
checks with EPERM lstat 'C:\' (13 failures on #6815, mostly
extension_host::native_mcp and raw_agent_presets). The earlier attempt
(4e1905b66, reverted) used realpathSync.native but compared its spelling
against raw input.
New src/dsh/canonical-path.ts is the one comparison rule: canonicalize
with realpathSync.native on win32 (GetFinalPathNameByHandle, no ancestor
walk) and realpathSync elsewhere, strip \\?\ and \\?\UNC\, compare
case-insensitively on win32, and express containment as
relative(canonical root, canonical target). A canonical path is never
compared to a raw one. resolve-hooks keys closures by canonical root and
remembers the raw root; the Node load hook now also refuses symlinked
closure modules (under --preserve-symlinks an in-closure symlink pointing
outside kept its in-closure URL and loaded — reproduced on HEAD).
Evidence (macOS): npm --prefix crates/tui/extension-host test: 406 tests,
399 pass, 0 fail, 7 skipped (new canonical-path test passes);
cargo test -p codewhale-tui --lib -- extension_host dsh: 264-265 pass,
2-3 plugins::install::dsh failures that differ per run and pass in
isolation (parallel-load flake under investigation). Windows LPAC itself
cannot run here; hosted Windows CI is the proof.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
1 parent
e8036038db
commit
f0080cd2e5
8 files changed
+470
-102
No files matched your search
+85
-26
@@ -6102,6 +6102,52 @@ var init_skill_filesystem = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/canonical-path.ts
|
||||
import { realpathSync } from "node:fs";
|
||||
import { posix, win32 } from "node:path";
|
||||
function canonicalPath(path, platform = process.platform) {
|
||||
return stripVerbatim(platform === "win32" ? realpathSync.native(path) : realpathSync(path), platform);
|
||||
}
|
||||
function stripVerbatim(path, platform = process.platform) {
|
||||
if (platform !== "win32") return path;
|
||||
if (/^[\\/]{2}\?[\\/]UNC[\\/]/i.test(path)) return `\\\\${path.slice(8)}`;
|
||||
if (/^[\\/]{2}\?[\\/]/.test(path)) return path.slice(4);
|
||||
return path;
|
||||
}
|
||||
function pathKey(path, platform = process.platform) {
|
||||
if (platform !== "win32") return posix.normalize(path);
|
||||
return win32.normalize(stripVerbatim(path, platform)).toLowerCase();
|
||||
}
|
||||
function samePath(a, b, platform = process.platform) {
|
||||
return pathKey(a, platform) === pathKey(b, platform);
|
||||
}
|
||||
function insideKey(root, target, platform = process.platform) {
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
const inside = path.relative(stripVerbatim(root, platform), stripVerbatim(target, platform));
|
||||
if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return void 0;
|
||||
return platform === "win32" ? inside.split(win32.sep).join("/") : inside;
|
||||
}
|
||||
function isUnlinkedInside(root, key, target, platform = process.platform) {
|
||||
let canonicalRoot, canonicalTarget;
|
||||
try {
|
||||
canonicalRoot = canonicalPath(root, platform);
|
||||
canonicalTarget = canonicalPath(target, platform);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform);
|
||||
}
|
||||
function unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform = process.platform) {
|
||||
if (!key || key.split("/").some((part) => !part || part === "." || part === "..")) return false;
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
return samePath(path.join(stripVerbatim(canonicalRoot, platform), ...key.split("/")), canonicalTarget, platform);
|
||||
}
|
||||
var init_canonical_path = __esm({
|
||||
"src/dsh/canonical-path.ts"() {
|
||||
"use strict";
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/upstream/hooks/hook-protocol/src/matcher.ts
|
||||
function isMatchAll(matcher) {
|
||||
return matcher === void 0 || matcher === "" || matcher === "*";
|
||||
@@ -6250,14 +6296,14 @@ var init_config2 = __esm({
|
||||
|
||||
// src/dsh/shell-hooks.ts
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, realpathSync, lstatSync } from "node:fs";
|
||||
import { readFileSync, lstatSync } from "node:fs";
|
||||
import { resolve, relative, isAbsolute, sep } from "node:path";
|
||||
function reviewedHookModule(dialect, root, files) {
|
||||
return { name: `hooks-${dialect}`, inject: ["shellHooks"], apply(ctx, config) {
|
||||
if (!config || typeof config.configPath !== "string") throw new Error("hook bridge needs its reviewed configPath");
|
||||
const path = resolve(root, config.configPath);
|
||||
const inside = relative(root, path).split(sep).join("/");
|
||||
if (!inside || inside.startsWith("../") || isAbsolute(inside) || !files[inside] || realpathSync(path) !== path || !lstatSync(path).isFile()) throw new Error("hook config is absent from the reviewed regular-file closure");
|
||||
if (!inside || inside.startsWith("../") || isAbsolute(inside) || !files[inside] || !isUnlinkedInside(root, inside, path) || !lstatSync(path).isFile()) throw new Error("hook config is absent from the reviewed regular-file closure");
|
||||
const bytes = readFileSync(path);
|
||||
if (bytes.length > 1024 * 1024 || createHash("sha256").update(bytes).digest("hex") !== files[inside]) throw new Error("hook config changed after review or exceeds 1 MiB");
|
||||
if (config.projectDir !== void 0) throw new Error("explicit projectDir is unsupported; each process uses its current core workspace");
|
||||
@@ -6282,6 +6328,8 @@ var EVENTS;
|
||||
var init_shell_hooks = __esm({
|
||||
"src/dsh/shell-hooks.ts"() {
|
||||
"use strict";
|
||||
init_canonical_path();
|
||||
init_canonical_path();
|
||||
init_config();
|
||||
init_config2();
|
||||
EVENTS = { SessionStart: "session_start", UserPromptSubmit: "message_submit", PreToolUse: "tool_call_before", PostToolUse: "tool_call_after", Stop: "turn_end", SubagentStart: "subagent_spawn", SubagentStop: "subagent_complete" };
|
||||
@@ -12005,10 +12053,19 @@ var init_bun_closure = __esm({
|
||||
|
||||
// src/dsh/resolve-hooks.ts
|
||||
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "node:url";
|
||||
import { relative as relative2, resolve as resolve2, sep as sep2, isAbsolute as isAbsolute2, dirname } from "node:path";
|
||||
import { readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
|
||||
import { resolve as resolve2, isAbsolute as isAbsolute2, dirname } from "node:path";
|
||||
function keyIn(closure, path) {
|
||||
const canonical = insideKey(closure.canonicalRoot, path);
|
||||
if (canonical !== void 0) return canonical;
|
||||
for (const root of closure.rawRoots) {
|
||||
const raw = insideKey(root, path);
|
||||
if (raw !== void 0) return raw;
|
||||
}
|
||||
}
|
||||
function admitReviewedClosure(baseUrl, files) {
|
||||
const root = realpathSync2(resolve2(fileURLToPath2(baseUrl)));
|
||||
const rawRoot = resolve2(fileURLToPath2(baseUrl));
|
||||
const canonicalRoot = canonicalPath(rawRoot);
|
||||
const id = pathKey(canonicalRoot);
|
||||
const accepted = /* @__PURE__ */ Object.create(null);
|
||||
const keys = Object.keys(files);
|
||||
if (keys.length > 4096) throw new Error("reviewed composition closure exceeds its file limit");
|
||||
@@ -12016,41 +12073,43 @@ function admitReviewedClosure(baseUrl, files) {
|
||||
if (!key || key.split("/").some((part) => !part || part === "." || part === "..") || key.includes("\\") || key.includes(":") || isAbsolute2(key) || !/^[a-f0-9]{64}$/.test(files[key])) throw new Error("invalid reviewed composition closure file");
|
||||
accepted[key] = files[key];
|
||||
}
|
||||
const existing = reviewedClosures.get(root);
|
||||
const existing = reviewedClosures.get(id);
|
||||
if (existing) {
|
||||
if (Object.keys(existing.files).length !== keys.length || keys.some((key) => existing.files[key] !== accepted[key])) throw new Error("the same composition root carries different file receipts");
|
||||
existing.refs++;
|
||||
} else reviewedClosures.set(root, { files: Object.freeze(accepted), refs: 1 });
|
||||
existing.rawRoots.add(rawRoot);
|
||||
} else reviewedClosures.set(id, { files: Object.freeze(accepted), refs: 1, canonicalRoot, rawRoots: /* @__PURE__ */ new Set([rawRoot]) });
|
||||
let disposed = false;
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
const current2 = reviewedClosures.get(root);
|
||||
if (current2 && --current2.refs === 0) reviewedClosures.delete(root);
|
||||
const current2 = reviewedClosures.get(id);
|
||||
if (current2 && --current2.refs === 0) reviewedClosures.delete(id);
|
||||
};
|
||||
}
|
||||
async function importReviewedModule(baseUrl, path) {
|
||||
const root = realpathSync2(resolve2(fileURLToPath2(baseUrl)));
|
||||
const receipt = reviewedClosures.get(root);
|
||||
const rawRoot = resolve2(fileURLToPath2(baseUrl));
|
||||
const id = pathKey(canonicalPath(rawRoot));
|
||||
const receipt = reviewedClosures.get(id);
|
||||
if (!receipt) throw new Error("composition module closure is no longer admitted");
|
||||
const entry = resolve2(root, path);
|
||||
const entry = resolve2(rawRoot, path);
|
||||
const admitted = closureAt(pathToFileURL2(entry).href);
|
||||
if (!admitted || admitted.root !== root || !(admitted.path in receipt.files)) throw new Error("composition module is absent from reviewed closure");
|
||||
if (!admitted || admitted.root !== id || !(admitted.path in receipt.files)) throw new Error("composition module is absent from reviewed closure");
|
||||
return import(pathToFileURL2(entry).href);
|
||||
}
|
||||
function closureAt(url) {
|
||||
if (!url?.startsWith("file:")) return;
|
||||
const path = resolve2(fileURLToPath2(url));
|
||||
for (const [root, receipt] of reviewedClosures) {
|
||||
const inside = relative2(root, path);
|
||||
if (inside === ".." || inside.startsWith(`..${sep2}`) || isAbsolute2(inside)) continue;
|
||||
return { root, receipt, path: inside.split(sep2).join("/") };
|
||||
const inside = keyIn(receipt, path);
|
||||
if (inside !== void 0) return { root, receipt, path: inside };
|
||||
}
|
||||
}
|
||||
var reviewedClosures;
|
||||
var init_resolve_hooks = __esm({
|
||||
"src/dsh/resolve-hooks.ts"() {
|
||||
"use strict";
|
||||
init_canonical_path();
|
||||
init_runtime();
|
||||
init_bun_closure();
|
||||
reviewedClosures = /* @__PURE__ */ new Map();
|
||||
@@ -12071,7 +12130,7 @@ __export(composition_exports, {
|
||||
});
|
||||
import { createHash as createHash2 } from "node:crypto";
|
||||
import { readFile as readFile2 } from "node:fs/promises";
|
||||
import { isAbsolute as isAbsolute3, relative as relative3, resolve as resolve3, sep as sep3 } from "node:path";
|
||||
import { isAbsolute as isAbsolute3, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
|
||||
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
||||
function digest(bytes) {
|
||||
return createHash2("sha256").update(bytes).digest("hex");
|
||||
@@ -12346,8 +12405,8 @@ var init_composition = __esm({
|
||||
async importReviewed(module) {
|
||||
const root = fileURLToPath3(this.base);
|
||||
const path = resolve3(root, module.path);
|
||||
const inside = relative3(root, path);
|
||||
if (inside.startsWith(`..${sep3}`) || inside === ".." || isAbsolute3(inside)) throw new Error("module escapes reviewed bundle");
|
||||
const inside = relative2(root, path);
|
||||
if (inside.startsWith(`..${sep2}`) || inside === ".." || isAbsolute3(inside)) throw new Error("module escapes reviewed bundle");
|
||||
if (this.spec.files[module.path] !== module.sha256 || digest(await readFile2(path)) !== module.sha256) throw new Error("module changed after review");
|
||||
return importReviewedModule(this.base, module.path);
|
||||
}
|
||||
@@ -12827,7 +12886,7 @@ var init_composition_inventory = __esm({
|
||||
|
||||
// src/dsh/agent-presets.ts
|
||||
import { createHash as createHash3 } from "node:crypto";
|
||||
import { resolve as resolve5, relative as relative4, dirname as dirname3, sep as sep4 } from "node:path";
|
||||
import { resolve as resolve5, relative as relative3, dirname as dirname3, sep as sep3 } from "node:path";
|
||||
import { fileURLToPath as fileURLToPath5, pathToFileURL as pathToFileURL5 } from "node:url";
|
||||
function plain(path) {
|
||||
return typeof path === "string" && path.length > 0 && path.length <= 4096 && !/[\\:\u0000-\u001f\u007f]/u.test(path) && !path.startsWith("/") && path.split("/").every((p) => p !== "" && p !== "." && p !== "..");
|
||||
@@ -12879,9 +12938,9 @@ async function reviewAgentPresets(input) {
|
||||
const files = composition.files;
|
||||
if (!files || Object.keys(files).length > 4096 || !Array.isArray(input.directories) || input.directories.length > 4096) fail("invalid source inventory");
|
||||
const base = resolve5("codewhale-reviewed-agent-presets");
|
||||
const baseUrl = pathToFileURL5(`${base}${sep4}`).href;
|
||||
const baseUrl = pathToFileURL5(`${base}${sep3}`).href;
|
||||
function key(path) {
|
||||
const inside = relative4(base, path).split(sep4).join("/");
|
||||
const inside = relative3(base, path).split(sep3).join("/");
|
||||
if (!plain(inside)) fail("path escapes the admitted source root");
|
||||
return inside;
|
||||
}
|
||||
@@ -12900,9 +12959,9 @@ async function reviewAgentPresets(input) {
|
||||
const parsed = /* @__PURE__ */ new Map();
|
||||
const io = {
|
||||
async readFile(path) {
|
||||
const relative5 = key(path);
|
||||
if (invalid.has(relative5)) throw new Error("composition exceeds its data/shape bound");
|
||||
const text2 = documents.get(relative5);
|
||||
const relative4 = key(path);
|
||||
if (invalid.has(relative4)) throw new Error("composition exceeds its data/shape bound");
|
||||
const text2 = documents.get(relative4);
|
||||
if (text2 === void 0) throw new Error("document not admitted");
|
||||
return text2;
|
||||
},
|
||||
@@ -12953,7 +13012,7 @@ async function reviewAgentPresets(input) {
|
||||
}
|
||||
});
|
||||
if (candidates.length !== 1) fail("shipped root requires exactly one admitted agent-presets package");
|
||||
const folder = dirname3(candidates[0][0]).split(sep4).join("/");
|
||||
const folder = dirname3(candidates[0][0]).split(sep3).join("/");
|
||||
const root = folder === "." ? "presets" : `${folder}/presets`;
|
||||
if (!directories.has(root)) fail("shipped preset root was not admitted");
|
||||
roots.push({ path: resolve5(base, root), trust: "system" });
|
||||
|
||||
+96
-25
@@ -6478,6 +6478,53 @@ var init_skill_filesystem = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/canonical-path.ts
|
||||
import { realpathSync } from "node:fs";
|
||||
import { posix, win32 } from "node:path";
|
||||
function canonicalPath(path, platform = process.platform) {
|
||||
return stripVerbatim(platform === "win32" ? realpathSync.native(path) : realpathSync(path), platform);
|
||||
}
|
||||
function stripVerbatim(path, platform = process.platform) {
|
||||
if (platform !== "win32") return path;
|
||||
if (/^[\\/]{2}\?[\\/]UNC[\\/]/i.test(path)) return `\\\\${path.slice(8)}`;
|
||||
if (/^[\\/]{2}\?[\\/]/.test(path)) return path.slice(4);
|
||||
return path;
|
||||
}
|
||||
function pathKey(path, platform = process.platform) {
|
||||
if (platform !== "win32") return posix.normalize(path);
|
||||
return win32.normalize(stripVerbatim(path, platform)).toLowerCase();
|
||||
}
|
||||
function samePath(a, b, platform = process.platform) {
|
||||
return pathKey(a, platform) === pathKey(b, platform);
|
||||
}
|
||||
function insideKey(root, target, platform = process.platform) {
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
const inside = path.relative(stripVerbatim(root, platform), stripVerbatim(target, platform));
|
||||
if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return void 0;
|
||||
return platform === "win32" ? inside.split(win32.sep).join("/") : inside;
|
||||
}
|
||||
function isUnlinkedInside(root, key, target, platform = process.platform) {
|
||||
let canonicalRoot, canonicalTarget;
|
||||
try {
|
||||
canonicalRoot = canonicalPath(root, platform);
|
||||
canonicalTarget = canonicalPath(target, platform);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform);
|
||||
}
|
||||
function unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform = process.platform) {
|
||||
if (!key || key.split("/").some((part) => !part || part === "." || part === "..")) return false;
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
return samePath(path.join(stripVerbatim(canonicalRoot, platform), ...key.split("/")), canonicalTarget, platform);
|
||||
}
|
||||
var init_canonical_path = __esm({
|
||||
"src/dsh/canonical-path.ts"() {
|
||||
"use strict";
|
||||
init_define_BUILTIN_MODULE_DIGESTS();
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/upstream/hooks/hook-protocol/src/matcher.ts
|
||||
function isMatchAll(matcher) {
|
||||
return matcher === void 0 || matcher === "" || matcher === "*";
|
||||
@@ -6629,14 +6676,14 @@ var init_config2 = __esm({
|
||||
|
||||
// src/dsh/shell-hooks.ts
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, realpathSync, lstatSync } from "node:fs";
|
||||
import { readFileSync, lstatSync } from "node:fs";
|
||||
import { resolve as resolve2, relative, isAbsolute, sep } from "node:path";
|
||||
function reviewedHookModule(dialect, root, files) {
|
||||
return { name: `hooks-${dialect}`, inject: ["shellHooks"], apply(ctx, config) {
|
||||
if (!config || typeof config.configPath !== "string") throw new Error("hook bridge needs its reviewed configPath");
|
||||
const path = resolve2(root, config.configPath);
|
||||
const inside = relative(root, path).split(sep).join("/");
|
||||
if (!inside || inside.startsWith("../") || isAbsolute(inside) || !files[inside] || realpathSync(path) !== path || !lstatSync(path).isFile()) throw new Error("hook config is absent from the reviewed regular-file closure");
|
||||
if (!inside || inside.startsWith("../") || isAbsolute(inside) || !files[inside] || !isUnlinkedInside(root, inside, path) || !lstatSync(path).isFile()) throw new Error("hook config is absent from the reviewed regular-file closure");
|
||||
const bytes = readFileSync(path);
|
||||
if (bytes.length > 1024 * 1024 || createHash("sha256").update(bytes).digest("hex") !== files[inside]) throw new Error("hook config changed after review or exceeds 1 MiB");
|
||||
if (config.projectDir !== void 0) throw new Error("explicit projectDir is unsupported; each process uses its current core workspace");
|
||||
@@ -6662,6 +6709,8 @@ var init_shell_hooks = __esm({
|
||||
"src/dsh/shell-hooks.ts"() {
|
||||
"use strict";
|
||||
init_define_BUILTIN_MODULE_DIGESTS();
|
||||
init_canonical_path();
|
||||
init_canonical_path();
|
||||
init_config();
|
||||
init_config2();
|
||||
EVENTS = { SessionStart: "session_start", UserPromptSubmit: "message_submit", PreToolUse: "tool_call_before", PostToolUse: "tool_call_after", Stop: "turn_end", SubagentStart: "subagent_spawn", SubagentStop: "subagent_complete" };
|
||||
@@ -12631,8 +12680,8 @@ var init_bun_closure = __esm({
|
||||
import * as nodeModule2 from "node:module";
|
||||
import { createHash as createHash2 } from "node:crypto";
|
||||
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL3 } from "node:url";
|
||||
import { relative as relative2, resolve as resolve3, sep as sep2, isAbsolute as isAbsolute2, dirname } from "node:path";
|
||||
import { readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
|
||||
import { resolve as resolve3, isAbsolute as isAbsolute2, dirname } from "node:path";
|
||||
import { readFileSync as readFileSync2 } from "node:fs";
|
||||
function packageName(specifier) {
|
||||
const parts = specifier.split("/");
|
||||
return specifier.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0];
|
||||
@@ -12677,7 +12726,7 @@ function installBunResolver(modules) {
|
||||
const name = String(specifier);
|
||||
const closure = closureAt(caller);
|
||||
if (!closure) throw new Error("composition module closure is no longer admitted");
|
||||
const target = checkedBunSpecifier(name, closure.root, closure.receipt.files, caller, false);
|
||||
const target = checkedBunSpecifier(name, closure.receipt, caller, false);
|
||||
const singleton = classifySpecifier(target);
|
||||
if (singleton !== null) return modules[singleton];
|
||||
return import(target, options);
|
||||
@@ -12703,13 +12752,13 @@ function installBunResolver(modules) {
|
||||
const loader2 = extension === "tsx" ? "tsx" : extension === "ts" || extension === "mts" || extension === "cts" ? "ts" : extension === "jsx" || extension === "js" ? "jsx" : "js";
|
||||
return { contents: readFileSync2(args.path, "utf8"), loader: loader2 };
|
||||
}
|
||||
if (!(closure.path in closure.receipt.files) || realpathSync2(args.path) !== args.path) throw new Error("module was absent from reviewed composition closure or contains a symbolic link");
|
||||
if (!(closure.path in closure.receipt.files) || !unlinkedInside(closure.receipt, closure.path, args.path)) throw new Error("module was absent from reviewed composition closure or contains a symbolic link");
|
||||
const bytes = readFileSync2(args.path);
|
||||
if (bytes.length > 64 * 1024 * 1024 || createHash2("sha256").update(bytes).digest("hex") !== closure.receipt.files[closure.path]) throw new Error("composition module bytes changed after review");
|
||||
if (closure.path.endsWith(".json")) return { contents: `export default JSON.parse(${JSON.stringify(bytes.toString("utf8"))});`, loader: "js" };
|
||||
let source;
|
||||
try {
|
||||
source = prepareBunSource(bytes.toString("utf8"), args.path, (specifier, require3) => checkedBunSpecifier(specifier, closure.root, closure.receipt.files, pathToFileURL3(args.path).href, require3));
|
||||
source = prepareBunSource(bytes.toString("utf8"), args.path, (specifier, require3) => checkedBunSpecifier(specifier, closure.receipt, pathToFileURL3(args.path).href, require3));
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) throw new Error("reviewed composition module has unsupported JavaScript syntax");
|
||||
throw error;
|
||||
@@ -12719,7 +12768,7 @@ function installBunResolver(modules) {
|
||||
}
|
||||
});
|
||||
}
|
||||
function checkedBunSpecifier(specifier, root, files, caller, require3) {
|
||||
function checkedBunSpecifier(specifier, closure, caller, require3) {
|
||||
const key = classifySpecifier(specifier);
|
||||
if (key !== null) {
|
||||
if (!(key in globalThis[REGISTRY_KEY])) throw new UnsupportedPeerError(specifier);
|
||||
@@ -12730,12 +12779,31 @@ function checkedBunSpecifier(specifier, root, files, caller, require3) {
|
||||
const path = file ? require3 && !specifier.startsWith("file:") ? resolve3(dirname(fileURLToPath2(caller)), specifier) : resolve3(fileURLToPath2(file)) : void 0;
|
||||
if (!path) throw new Error("bare dependency is absent from this reviewed composition; package its reviewed relative source");
|
||||
if (file?.search || file?.hash) throw new Error('Bun does not preserve reviewed module query or fragment identity; select [extension_host] runtime = "node" for this composition');
|
||||
const inside = relative2(root, path).split(sep2).join("/");
|
||||
if (inside === ".." || inside.startsWith("../") || isAbsolute2(inside) || !(inside in files)) throw new Error("composition import escapes the reviewed file closure");
|
||||
const inside = keyIn(closure, path);
|
||||
if (inside === void 0 || !(inside in closure.files)) throw new Error("composition import escapes the reviewed file closure");
|
||||
return require3 ? path : file.href;
|
||||
}
|
||||
function keyIn(closure, path) {
|
||||
const canonical = insideKey(closure.canonicalRoot, path);
|
||||
if (canonical !== void 0) return canonical;
|
||||
for (const root of closure.rawRoots) {
|
||||
const raw = insideKey(root, path);
|
||||
if (raw !== void 0) return raw;
|
||||
}
|
||||
}
|
||||
function unlinkedInside(closure, key, path) {
|
||||
let canonical;
|
||||
try {
|
||||
canonical = canonicalPath(path);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return unlinkedKeyMatches(closure.canonicalRoot, key, canonical);
|
||||
}
|
||||
function admitReviewedClosure(baseUrl, files) {
|
||||
const root = realpathSync2(resolve3(fileURLToPath2(baseUrl)));
|
||||
const rawRoot = resolve3(fileURLToPath2(baseUrl));
|
||||
const canonicalRoot = canonicalPath(rawRoot);
|
||||
const id = pathKey(canonicalRoot);
|
||||
const accepted = /* @__PURE__ */ Object.create(null);
|
||||
const keys = Object.keys(files);
|
||||
if (keys.length > 4096) throw new Error("reviewed composition closure exceeds its file limit");
|
||||
@@ -12743,35 +12811,36 @@ function admitReviewedClosure(baseUrl, files) {
|
||||
if (!key || key.split("/").some((part) => !part || part === "." || part === "..") || key.includes("\\") || key.includes(":") || isAbsolute2(key) || !/^[a-f0-9]{64}$/.test(files[key])) throw new Error("invalid reviewed composition closure file");
|
||||
accepted[key] = files[key];
|
||||
}
|
||||
const existing = reviewedClosures.get(root);
|
||||
const existing = reviewedClosures.get(id);
|
||||
if (existing) {
|
||||
if (Object.keys(existing.files).length !== keys.length || keys.some((key) => existing.files[key] !== accepted[key])) throw new Error("the same composition root carries different file receipts");
|
||||
existing.refs++;
|
||||
} else reviewedClosures.set(root, { files: Object.freeze(accepted), refs: 1 });
|
||||
existing.rawRoots.add(rawRoot);
|
||||
} else reviewedClosures.set(id, { files: Object.freeze(accepted), refs: 1, canonicalRoot, rawRoots: /* @__PURE__ */ new Set([rawRoot]) });
|
||||
let disposed = false;
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
const current2 = reviewedClosures.get(root);
|
||||
if (current2 && --current2.refs === 0) reviewedClosures.delete(root);
|
||||
const current2 = reviewedClosures.get(id);
|
||||
if (current2 && --current2.refs === 0) reviewedClosures.delete(id);
|
||||
};
|
||||
}
|
||||
async function importReviewedModule(baseUrl, path) {
|
||||
const root = realpathSync2(resolve3(fileURLToPath2(baseUrl)));
|
||||
const receipt = reviewedClosures.get(root);
|
||||
const rawRoot = resolve3(fileURLToPath2(baseUrl));
|
||||
const id = pathKey(canonicalPath(rawRoot));
|
||||
const receipt = reviewedClosures.get(id);
|
||||
if (!receipt) throw new Error("composition module closure is no longer admitted");
|
||||
const entry = resolve3(root, path);
|
||||
const entry = resolve3(rawRoot, path);
|
||||
const admitted = closureAt(pathToFileURL3(entry).href);
|
||||
if (!admitted || admitted.root !== root || !(admitted.path in receipt.files)) throw new Error("composition module is absent from reviewed closure");
|
||||
if (!admitted || admitted.root !== id || !(admitted.path in receipt.files)) throw new Error("composition module is absent from reviewed closure");
|
||||
return import(pathToFileURL3(entry).href);
|
||||
}
|
||||
function closureAt(url) {
|
||||
if (!url?.startsWith("file:")) return;
|
||||
const path = resolve3(fileURLToPath2(url));
|
||||
for (const [root, receipt] of reviewedClosures) {
|
||||
const inside = relative2(root, path);
|
||||
if (inside === ".." || inside.startsWith(`..${sep2}`) || isAbsolute2(inside)) continue;
|
||||
return { root, receipt, path: inside.split(sep2).join("/") };
|
||||
const inside = keyIn(receipt, path);
|
||||
if (inside !== void 0) return { root, receipt, path: inside };
|
||||
}
|
||||
}
|
||||
function installResolveHooks(modules) {
|
||||
@@ -12807,6 +12876,7 @@ function installResolveHooks(modules) {
|
||||
const closure = closureAt(url);
|
||||
if (closure) {
|
||||
if (!(closure.path in closure.receipt.files) || result.source === void 0 || result.source === null) throw new Error("module was absent from reviewed composition closure");
|
||||
if (!unlinkedInside(closure.receipt, closure.path, fileURLToPath2(url))) throw new Error("module was absent from reviewed composition closure or contains a symbolic link");
|
||||
const source = typeof result.source === "string" ? Buffer.from(result.source) : Buffer.from(result.source);
|
||||
if (source.length > 64 * 1024 * 1024 || createHash2("sha256").update(source).digest("hex") !== closure.receipt.files[closure.path]) throw new Error("composition module bytes changed after review");
|
||||
}
|
||||
@@ -12819,6 +12889,7 @@ var init_resolve_hooks = __esm({
|
||||
"src/dsh/resolve-hooks.ts"() {
|
||||
"use strict";
|
||||
init_define_BUILTIN_MODULE_DIGESTS();
|
||||
init_canonical_path();
|
||||
init_runtime();
|
||||
init_bun_closure();
|
||||
SCHEME = "codewhale-host:";
|
||||
@@ -14789,7 +14860,7 @@ __export(composition_exports, {
|
||||
});
|
||||
import { createHash as createHash5 } from "node:crypto";
|
||||
import { readFile as readFile3 } from "node:fs/promises";
|
||||
import { isAbsolute as isAbsolute4, relative as relative3, resolve as resolve4, sep as sep3 } from "node:path";
|
||||
import { isAbsolute as isAbsolute4, relative as relative2, resolve as resolve4, sep as sep2 } from "node:path";
|
||||
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
||||
function digest(bytes) {
|
||||
return createHash5("sha256").update(bytes).digest("hex");
|
||||
@@ -15065,8 +15136,8 @@ var init_composition = __esm({
|
||||
async importReviewed(module) {
|
||||
const root = fileURLToPath5(this.base);
|
||||
const path = resolve4(root, module.path);
|
||||
const inside = relative3(root, path);
|
||||
if (inside.startsWith(`..${sep3}`) || inside === ".." || isAbsolute4(inside)) throw new Error("module escapes reviewed bundle");
|
||||
const inside = relative2(root, path);
|
||||
if (inside.startsWith(`..${sep2}`) || inside === ".." || isAbsolute4(inside)) throw new Error("module escapes reviewed bundle");
|
||||
if (this.spec.files[module.path] !== module.sha256 || digest(await readFile3(path)) !== module.sha256) throw new Error("module changed after review");
|
||||
return importReviewedModule(this.base, module.path);
|
||||
}
|
||||
|
||||
+85
-26
@@ -6053,6 +6053,52 @@ var init_skill_filesystem = __esm({
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/canonical-path.ts
|
||||
import { realpathSync } from "node:fs";
|
||||
import { posix, win32 } from "node:path";
|
||||
function canonicalPath(path, platform = process.platform) {
|
||||
return stripVerbatim(platform === "win32" ? realpathSync.native(path) : realpathSync(path), platform);
|
||||
}
|
||||
function stripVerbatim(path, platform = process.platform) {
|
||||
if (platform !== "win32") return path;
|
||||
if (/^[\\/]{2}\?[\\/]UNC[\\/]/i.test(path)) return `\\\\${path.slice(8)}`;
|
||||
if (/^[\\/]{2}\?[\\/]/.test(path)) return path.slice(4);
|
||||
return path;
|
||||
}
|
||||
function pathKey(path, platform = process.platform) {
|
||||
if (platform !== "win32") return posix.normalize(path);
|
||||
return win32.normalize(stripVerbatim(path, platform)).toLowerCase();
|
||||
}
|
||||
function samePath(a, b, platform = process.platform) {
|
||||
return pathKey(a, platform) === pathKey(b, platform);
|
||||
}
|
||||
function insideKey(root, target, platform = process.platform) {
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
const inside = path.relative(stripVerbatim(root, platform), stripVerbatim(target, platform));
|
||||
if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return void 0;
|
||||
return platform === "win32" ? inside.split(win32.sep).join("/") : inside;
|
||||
}
|
||||
function isUnlinkedInside(root, key, target, platform = process.platform) {
|
||||
let canonicalRoot, canonicalTarget;
|
||||
try {
|
||||
canonicalRoot = canonicalPath(root, platform);
|
||||
canonicalTarget = canonicalPath(target, platform);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform);
|
||||
}
|
||||
function unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform = process.platform) {
|
||||
if (!key || key.split("/").some((part) => !part || part === "." || part === "..")) return false;
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
return samePath(path.join(stripVerbatim(canonicalRoot, platform), ...key.split("/")), canonicalTarget, platform);
|
||||
}
|
||||
var init_canonical_path = __esm({
|
||||
"src/dsh/canonical-path.ts"() {
|
||||
"use strict";
|
||||
}
|
||||
});
|
||||
|
||||
// src/dsh/upstream/hooks/hook-protocol/src/matcher.ts
|
||||
function isMatchAll(matcher) {
|
||||
return matcher === void 0 || matcher === "" || matcher === "*";
|
||||
@@ -6201,14 +6247,14 @@ var init_config2 = __esm({
|
||||
|
||||
// src/dsh/shell-hooks.ts
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, realpathSync, lstatSync } from "node:fs";
|
||||
import { readFileSync, lstatSync } from "node:fs";
|
||||
import { resolve, relative, isAbsolute, sep } from "node:path";
|
||||
function reviewedHookModule(dialect, root, files) {
|
||||
return { name: `hooks-${dialect}`, inject: ["shellHooks"], apply(ctx, config) {
|
||||
if (!config || typeof config.configPath !== "string") throw new Error("hook bridge needs its reviewed configPath");
|
||||
const path = resolve(root, config.configPath);
|
||||
const inside = relative(root, path).split(sep).join("/");
|
||||
if (!inside || inside.startsWith("../") || isAbsolute(inside) || !files[inside] || realpathSync(path) !== path || !lstatSync(path).isFile()) throw new Error("hook config is absent from the reviewed regular-file closure");
|
||||
if (!inside || inside.startsWith("../") || isAbsolute(inside) || !files[inside] || !isUnlinkedInside(root, inside, path) || !lstatSync(path).isFile()) throw new Error("hook config is absent from the reviewed regular-file closure");
|
||||
const bytes = readFileSync(path);
|
||||
if (bytes.length > 1024 * 1024 || createHash("sha256").update(bytes).digest("hex") !== files[inside]) throw new Error("hook config changed after review or exceeds 1 MiB");
|
||||
if (config.projectDir !== void 0) throw new Error("explicit projectDir is unsupported; each process uses its current core workspace");
|
||||
@@ -6233,6 +6279,8 @@ var EVENTS;
|
||||
var init_shell_hooks = __esm({
|
||||
"src/dsh/shell-hooks.ts"() {
|
||||
"use strict";
|
||||
init_canonical_path();
|
||||
init_canonical_path();
|
||||
init_config();
|
||||
init_config2();
|
||||
EVENTS = { SessionStart: "session_start", UserPromptSubmit: "message_submit", PreToolUse: "tool_call_before", PostToolUse: "tool_call_after", Stop: "turn_end", SubagentStart: "subagent_spawn", SubagentStop: "subagent_complete" };
|
||||
@@ -11956,10 +12004,19 @@ var init_bun_closure = __esm({
|
||||
|
||||
// src/dsh/resolve-hooks.ts
|
||||
import { fileURLToPath as fileURLToPath2, pathToFileURL as pathToFileURL2 } from "node:url";
|
||||
import { relative as relative2, resolve as resolve2, sep as sep2, isAbsolute as isAbsolute2, dirname } from "node:path";
|
||||
import { readFileSync as readFileSync2, realpathSync as realpathSync2 } from "node:fs";
|
||||
import { resolve as resolve2, isAbsolute as isAbsolute2, dirname } from "node:path";
|
||||
function keyIn(closure, path) {
|
||||
const canonical = insideKey(closure.canonicalRoot, path);
|
||||
if (canonical !== void 0) return canonical;
|
||||
for (const root of closure.rawRoots) {
|
||||
const raw = insideKey(root, path);
|
||||
if (raw !== void 0) return raw;
|
||||
}
|
||||
}
|
||||
function admitReviewedClosure(baseUrl, files) {
|
||||
const root = realpathSync2(resolve2(fileURLToPath2(baseUrl)));
|
||||
const rawRoot = resolve2(fileURLToPath2(baseUrl));
|
||||
const canonicalRoot = canonicalPath(rawRoot);
|
||||
const id = pathKey(canonicalRoot);
|
||||
const accepted = /* @__PURE__ */ Object.create(null);
|
||||
const keys = Object.keys(files);
|
||||
if (keys.length > 4096) throw new Error("reviewed composition closure exceeds its file limit");
|
||||
@@ -11967,41 +12024,43 @@ function admitReviewedClosure(baseUrl, files) {
|
||||
if (!key || key.split("/").some((part) => !part || part === "." || part === "..") || key.includes("\\") || key.includes(":") || isAbsolute2(key) || !/^[a-f0-9]{64}$/.test(files[key])) throw new Error("invalid reviewed composition closure file");
|
||||
accepted[key] = files[key];
|
||||
}
|
||||
const existing = reviewedClosures.get(root);
|
||||
const existing = reviewedClosures.get(id);
|
||||
if (existing) {
|
||||
if (Object.keys(existing.files).length !== keys.length || keys.some((key) => existing.files[key] !== accepted[key])) throw new Error("the same composition root carries different file receipts");
|
||||
existing.refs++;
|
||||
} else reviewedClosures.set(root, { files: Object.freeze(accepted), refs: 1 });
|
||||
existing.rawRoots.add(rawRoot);
|
||||
} else reviewedClosures.set(id, { files: Object.freeze(accepted), refs: 1, canonicalRoot, rawRoots: /* @__PURE__ */ new Set([rawRoot]) });
|
||||
let disposed = false;
|
||||
return () => {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
const current2 = reviewedClosures.get(root);
|
||||
if (current2 && --current2.refs === 0) reviewedClosures.delete(root);
|
||||
const current2 = reviewedClosures.get(id);
|
||||
if (current2 && --current2.refs === 0) reviewedClosures.delete(id);
|
||||
};
|
||||
}
|
||||
async function importReviewedModule(baseUrl, path) {
|
||||
const root = realpathSync2(resolve2(fileURLToPath2(baseUrl)));
|
||||
const receipt = reviewedClosures.get(root);
|
||||
const rawRoot = resolve2(fileURLToPath2(baseUrl));
|
||||
const id = pathKey(canonicalPath(rawRoot));
|
||||
const receipt = reviewedClosures.get(id);
|
||||
if (!receipt) throw new Error("composition module closure is no longer admitted");
|
||||
const entry = resolve2(root, path);
|
||||
const entry = resolve2(rawRoot, path);
|
||||
const admitted = closureAt(pathToFileURL2(entry).href);
|
||||
if (!admitted || admitted.root !== root || !(admitted.path in receipt.files)) throw new Error("composition module is absent from reviewed closure");
|
||||
if (!admitted || admitted.root !== id || !(admitted.path in receipt.files)) throw new Error("composition module is absent from reviewed closure");
|
||||
return import(pathToFileURL2(entry).href);
|
||||
}
|
||||
function closureAt(url) {
|
||||
if (!url?.startsWith("file:")) return;
|
||||
const path = resolve2(fileURLToPath2(url));
|
||||
for (const [root, receipt] of reviewedClosures) {
|
||||
const inside = relative2(root, path);
|
||||
if (inside === ".." || inside.startsWith(`..${sep2}`) || isAbsolute2(inside)) continue;
|
||||
return { root, receipt, path: inside.split(sep2).join("/") };
|
||||
const inside = keyIn(receipt, path);
|
||||
if (inside !== void 0) return { root, receipt, path: inside };
|
||||
}
|
||||
}
|
||||
var reviewedClosures;
|
||||
var init_resolve_hooks = __esm({
|
||||
"src/dsh/resolve-hooks.ts"() {
|
||||
"use strict";
|
||||
init_canonical_path();
|
||||
init_runtime();
|
||||
init_bun_closure();
|
||||
reviewedClosures = /* @__PURE__ */ new Map();
|
||||
@@ -12477,7 +12536,7 @@ var init_composition_inventory = __esm({
|
||||
|
||||
// src/dsh/agent-presets.ts
|
||||
import { createHash as createHash2 } from "node:crypto";
|
||||
import { resolve as resolve4, relative as relative3, dirname as dirname3, sep as sep3 } from "node:path";
|
||||
import { resolve as resolve4, relative as relative2, dirname as dirname3, sep as sep2 } from "node:path";
|
||||
import { fileURLToPath as fileURLToPath4, pathToFileURL as pathToFileURL5 } from "node:url";
|
||||
function plain(path) {
|
||||
return typeof path === "string" && path.length > 0 && path.length <= 4096 && !/[\\:\u0000-\u001f\u007f]/u.test(path) && !path.startsWith("/") && path.split("/").every((p) => p !== "" && p !== "." && p !== "..");
|
||||
@@ -12529,9 +12588,9 @@ async function reviewAgentPresets(input) {
|
||||
const files = composition.files;
|
||||
if (!files || Object.keys(files).length > 4096 || !Array.isArray(input.directories) || input.directories.length > 4096) fail("invalid source inventory");
|
||||
const base = resolve4("codewhale-reviewed-agent-presets");
|
||||
const baseUrl = pathToFileURL5(`${base}${sep3}`).href;
|
||||
const baseUrl = pathToFileURL5(`${base}${sep2}`).href;
|
||||
function key(path) {
|
||||
const inside = relative3(base, path).split(sep3).join("/");
|
||||
const inside = relative2(base, path).split(sep2).join("/");
|
||||
if (!plain(inside)) fail("path escapes the admitted source root");
|
||||
return inside;
|
||||
}
|
||||
@@ -12550,9 +12609,9 @@ async function reviewAgentPresets(input) {
|
||||
const parsed = /* @__PURE__ */ new Map();
|
||||
const io = {
|
||||
async readFile(path) {
|
||||
const relative5 = key(path);
|
||||
if (invalid.has(relative5)) throw new Error("composition exceeds its data/shape bound");
|
||||
const text2 = documents.get(relative5);
|
||||
const relative4 = key(path);
|
||||
if (invalid.has(relative4)) throw new Error("composition exceeds its data/shape bound");
|
||||
const text2 = documents.get(relative4);
|
||||
if (text2 === void 0) throw new Error("document not admitted");
|
||||
return text2;
|
||||
},
|
||||
@@ -12603,7 +12662,7 @@ async function reviewAgentPresets(input) {
|
||||
}
|
||||
});
|
||||
if (candidates.length !== 1) fail("shipped root requires exactly one admitted agent-presets package");
|
||||
const folder = dirname3(candidates[0][0]).split(sep3).join("/");
|
||||
const folder = dirname3(candidates[0][0]).split(sep2).join("/");
|
||||
const root = folder === "." ? "presets" : `${folder}/presets`;
|
||||
if (!directories.has(root)) fail("shipped preset root was not admitted");
|
||||
roots.push({ path: resolve4(base, root), trust: "system" });
|
||||
@@ -12786,7 +12845,7 @@ __export(composition_exports, {
|
||||
});
|
||||
import { createHash as createHash3 } from "node:crypto";
|
||||
import { readFile as readFile2 } from "node:fs/promises";
|
||||
import { isAbsolute as isAbsolute4, relative as relative4, resolve as resolve5, sep as sep4 } from "node:path";
|
||||
import { isAbsolute as isAbsolute4, relative as relative3, resolve as resolve5, sep as sep3 } from "node:path";
|
||||
import { fileURLToPath as fileURLToPath5 } from "node:url";
|
||||
function digest(bytes) {
|
||||
return createHash3("sha256").update(bytes).digest("hex");
|
||||
@@ -13061,8 +13120,8 @@ var init_composition = __esm({
|
||||
async importReviewed(module) {
|
||||
const root = fileURLToPath5(this.base);
|
||||
const path = resolve5(root, module.path);
|
||||
const inside = relative4(root, path);
|
||||
if (inside.startsWith(`..${sep4}`) || inside === ".." || isAbsolute4(inside)) throw new Error("module escapes reviewed bundle");
|
||||
const inside = relative3(root, path);
|
||||
if (inside.startsWith(`..${sep3}`) || inside === ".." || isAbsolute4(inside)) throw new Error("module escapes reviewed bundle");
|
||||
if (this.spec.files[module.path] !== module.sha256 || digest(await readFile2(path)) !== module.sha256) throw new Error("module changed after review");
|
||||
return importReviewedModule(this.base, module.path);
|
||||
}
|
||||
|
||||
+49
-3
@@ -1,8 +1,49 @@
|
||||
// src/dsh/shell-hooks.ts
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync, realpathSync, lstatSync } from "node:fs";
|
||||
import { readFileSync, lstatSync } from "node:fs";
|
||||
import { resolve, relative, isAbsolute, sep } from "node:path";
|
||||
|
||||
// src/dsh/canonical-path.ts
|
||||
import { realpathSync } from "node:fs";
|
||||
import { posix, win32 } from "node:path";
|
||||
function canonicalPath(path, platform = process.platform) {
|
||||
return stripVerbatim(platform === "win32" ? realpathSync.native(path) : realpathSync(path), platform);
|
||||
}
|
||||
function stripVerbatim(path, platform = process.platform) {
|
||||
if (platform !== "win32") return path;
|
||||
if (/^[\\/]{2}\?[\\/]UNC[\\/]/i.test(path)) return `\\\\${path.slice(8)}`;
|
||||
if (/^[\\/]{2}\?[\\/]/.test(path)) return path.slice(4);
|
||||
return path;
|
||||
}
|
||||
function pathKey(path, platform = process.platform) {
|
||||
if (platform !== "win32") return posix.normalize(path);
|
||||
return win32.normalize(stripVerbatim(path, platform)).toLowerCase();
|
||||
}
|
||||
function samePath(a, b, platform = process.platform) {
|
||||
return pathKey(a, platform) === pathKey(b, platform);
|
||||
}
|
||||
function insideKey(root, target, platform = process.platform) {
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
const inside = path.relative(stripVerbatim(root, platform), stripVerbatim(target, platform));
|
||||
if (inside === ".." || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return void 0;
|
||||
return platform === "win32" ? inside.split(win32.sep).join("/") : inside;
|
||||
}
|
||||
function isUnlinkedInside(root, key, target, platform = process.platform) {
|
||||
let canonicalRoot, canonicalTarget;
|
||||
try {
|
||||
canonicalRoot = canonicalPath(root, platform);
|
||||
canonicalTarget = canonicalPath(target, platform);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform);
|
||||
}
|
||||
function unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform = process.platform) {
|
||||
if (!key || key.split("/").some((part) => !part || part === "." || part === "..")) return false;
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
return samePath(path.join(stripVerbatim(canonicalRoot, platform), ...key.split("/")), canonicalTarget, platform);
|
||||
}
|
||||
|
||||
// src/dsh/upstream/hooks/hook-protocol/src/matcher.ts
|
||||
function isMatchAll(matcher) {
|
||||
return matcher === void 0 || matcher === "" || matcher === "*";
|
||||
@@ -136,7 +177,7 @@ function reviewedHookModule(dialect, root, files) {
|
||||
if (!config || typeof config.configPath !== "string") throw new Error("hook bridge needs its reviewed configPath");
|
||||
const path = resolve(root, config.configPath);
|
||||
const inside = relative(root, path).split(sep).join("/");
|
||||
if (!inside || inside.startsWith("../") || isAbsolute(inside) || !files[inside] || realpathSync(path) !== path || !lstatSync(path).isFile()) throw new Error("hook config is absent from the reviewed regular-file closure");
|
||||
if (!inside || inside.startsWith("../") || isAbsolute(inside) || !files[inside] || !isUnlinkedInside(root, inside, path) || !lstatSync(path).isFile()) throw new Error("hook config is absent from the reviewed regular-file closure");
|
||||
const bytes = readFileSync(path);
|
||||
if (bytes.length > 1024 * 1024 || createHash("sha256").update(bytes).digest("hex") !== files[inside]) throw new Error("hook config changed after review or exceeds 1 MiB");
|
||||
if (config.projectDir !== void 0) throw new Error("explicit projectDir is unsupported; each process uses its current core workspace");
|
||||
@@ -158,5 +199,10 @@ function reviewedHookModule(dialect, root, files) {
|
||||
} };
|
||||
}
|
||||
export {
|
||||
reviewedHookModule
|
||||
insideKey,
|
||||
pathKey,
|
||||
reviewedHookModule,
|
||||
samePath,
|
||||
stripVerbatim,
|
||||
unlinkedKeyMatches
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* One canonical-path rule for the reviewed-closure checks.
|
||||
*
|
||||
* Windows: JS `realpathSync` lstats every ancestor starting at the drive root,
|
||||
* and a Windows LPAC (AppContainer) host cannot read `C:\` (EPERM).
|
||||
* `realpathSync.native` asks the OS for the opened file's final path
|
||||
* (GetFinalPathNameByHandle) and needs access to that file alone. Its spelling
|
||||
* differs from the caller's: a `\\?\` prefix, long names in place of 8.3 short
|
||||
* names (`RUNNER~1`), drive-letter and on-disk case. So a canonical path is
|
||||
* never compared with a raw one. Both sides go through `canonicalPath`, then
|
||||
* `pathKey`, and containment is `relative(canonical root, canonical target)`.
|
||||
*
|
||||
* Elsewhere `realpathSync` is unchanged.
|
||||
*/
|
||||
import { realpathSync } from 'node:fs'
|
||||
import { posix, win32 } from 'node:path'
|
||||
|
||||
type Platform = NodeJS.Platform
|
||||
|
||||
export function canonicalPath(path: string, platform: Platform = process.platform): string {
|
||||
return stripVerbatim(platform === 'win32' ? realpathSync.native(path) : realpathSync(path), platform)
|
||||
}
|
||||
|
||||
/** Drop the Win32 verbatim prefix: `\\?\C:\x` → `C:\x`, `\\?\UNC\h\s` → `\\h\s`. Case is kept. */
|
||||
export function stripVerbatim(path: string, platform: Platform = process.platform): string {
|
||||
if (platform !== 'win32') return path
|
||||
if (/^[\\/]{2}\?[\\/]UNC[\\/]/i.test(path)) return `\\\\${path.slice(8)}`
|
||||
if (/^[\\/]{2}\?[\\/]/.test(path)) return path.slice(4)
|
||||
return path
|
||||
}
|
||||
|
||||
/** The identity used for equality: normalized and, on Windows, case-folded. Never joined or displayed. */
|
||||
export function pathKey(path: string, platform: Platform = process.platform): string {
|
||||
if (platform !== 'win32') return posix.normalize(path)
|
||||
return win32.normalize(stripVerbatim(path, platform)).toLowerCase()
|
||||
}
|
||||
|
||||
export function samePath(a: string, b: string, platform: Platform = process.platform): boolean {
|
||||
return pathKey(a, platform) === pathKey(b, platform)
|
||||
}
|
||||
|
||||
/**
|
||||
* The `/`-separated path of `target` inside `root`, or `undefined` when it is
|
||||
* outside. Both arguments must be spelled the same way (both raw, or both
|
||||
* from `canonicalPath`). `''` is the root itself, which is never a file key.
|
||||
* Win32 `relative` compares case-insensitively and keeps `target`'s case.
|
||||
*/
|
||||
export function insideKey(root: string, target: string, platform: Platform = process.platform): string | undefined {
|
||||
const path = platform === 'win32' ? win32 : posix
|
||||
const inside = path.relative(stripVerbatim(root, platform), stripVerbatim(target, platform))
|
||||
if (inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside)) return undefined
|
||||
return platform === 'win32' ? inside.split(win32.sep).join('/') : inside
|
||||
}
|
||||
|
||||
/**
|
||||
* True when `target` names `root/key` with no symbolic link (or junction)
|
||||
* inside the root: its canonical path is the canonical root joined with the
|
||||
* reviewed key. Links above the root are the root's own location and are
|
||||
* absorbed by canonicalizing it. A missing or unreadable target is false.
|
||||
*/
|
||||
export function isUnlinkedInside(root: string, key: string, target: string, platform: Platform = process.platform): boolean {
|
||||
let canonicalRoot: string, canonicalTarget: string
|
||||
try {
|
||||
canonicalRoot = canonicalPath(root, platform)
|
||||
canonicalTarget = canonicalPath(target, platform)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return unlinkedKeyMatches(canonicalRoot, key, canonicalTarget, platform)
|
||||
}
|
||||
|
||||
/** Pure half of `isUnlinkedInside`, over already-canonical paths. */
|
||||
export function unlinkedKeyMatches(canonicalRoot: string, key: string, canonicalTarget: string, platform: Platform = process.platform): boolean {
|
||||
if (!key || key.split('/').some((part) => !part || part === '.' || part === '..')) return false
|
||||
const path = platform === 'win32' ? win32 : posix
|
||||
return samePath(path.join(stripVerbatim(canonicalRoot, platform), ...key.split('/')), canonicalTarget, platform)
|
||||
}
|
||||
@@ -19,8 +19,9 @@
|
||||
import * as nodeModule from 'node:module'
|
||||
import {createHash} from 'node:crypto'
|
||||
import {fileURLToPath,pathToFileURL} from 'node:url'
|
||||
import {relative,resolve,sep,isAbsolute,dirname} from 'node:path'
|
||||
import {readFileSync,realpathSync} from 'node:fs'
|
||||
import {resolve,isAbsolute,dirname} from 'node:path'
|
||||
import {readFileSync} from 'node:fs'
|
||||
import { canonicalPath, insideKey, pathKey, unlinkedKeyMatches } from './canonical-path.ts'
|
||||
import { RUNTIME } from '../runtime.ts'
|
||||
import { prepareBunSource, REVIEWED_IMPORT } from './bun-closure.ts'
|
||||
|
||||
@@ -124,7 +125,7 @@ function installBunResolver(modules: Record<string, Record<string, unknown>>) {
|
||||
const name=String(specifier)
|
||||
const closure=closureAt(caller)
|
||||
if(!closure)throw new Error('composition module closure is no longer admitted')
|
||||
const target=checkedBunSpecifier(name,closure.root,closure.receipt.files,caller,false)
|
||||
const target=checkedBunSpecifier(name,closure.receipt,caller,false)
|
||||
const singleton=classifySpecifier(target)
|
||||
if(singleton!==null)return modules[singleton]
|
||||
return import(target,options as any)
|
||||
@@ -152,14 +153,14 @@ function installBunResolver(modules: Record<string, Record<string, unknown>>) {
|
||||
:extension==='jsx' || extension==='js'?'jsx':'js'
|
||||
return {contents:readFileSync(args.path,'utf8'),loader}
|
||||
}
|
||||
if(!(closure.path in closure.receipt.files) || realpathSync(args.path)!==args.path)throw new Error('module was absent from reviewed composition closure or contains a symbolic link')
|
||||
if(!(closure.path in closure.receipt.files) || !unlinkedInside(closure.receipt,closure.path,args.path))throw new Error('module was absent from reviewed composition closure or contains a symbolic link')
|
||||
const bytes=readFileSync(args.path)
|
||||
if(bytes.length>64*1024*1024 || createHash('sha256').update(bytes).digest('hex')!==closure.receipt.files[closure.path])throw new Error('composition module bytes changed after review')
|
||||
// Bun 1.4 runtime onLoad treats its JSON loader as JS. Preserve exact
|
||||
// JSON semantics (including __proto__ data keys) via a JS data module.
|
||||
if(closure.path.endsWith('.json'))return {contents:`export default JSON.parse(${JSON.stringify(bytes.toString('utf8'))});`,loader:'js'}
|
||||
let source:string
|
||||
try {source=prepareBunSource(bytes.toString('utf8'),args.path,(specifier,require)=>checkedBunSpecifier(specifier,closure.root,closure.receipt.files,pathToFileURL(args.path).href,require))}
|
||||
try {source=prepareBunSource(bytes.toString('utf8'),args.path,(specifier,require)=>checkedBunSpecifier(specifier,closure.receipt,pathToFileURL(args.path).href,require))}
|
||||
catch(error){if(error instanceof SyntaxError)throw new Error('reviewed composition module has unsupported JavaScript syntax');throw error}
|
||||
return {contents:source,loader:'js'}
|
||||
})
|
||||
@@ -167,7 +168,7 @@ function installBunResolver(modules: Record<string, Record<string, unknown>>) {
|
||||
})
|
||||
}
|
||||
|
||||
function checkedBunSpecifier(specifier:string,root:string,files:Readonly<Record<string,string>>,caller:string,require:boolean):string {
|
||||
function checkedBunSpecifier(specifier:string,closure:ReviewedClosure,caller:string,require:boolean):string {
|
||||
const key=classifySpecifier(specifier)
|
||||
if(key!==null) {
|
||||
if(!(key in (globalThis as any)[REGISTRY_KEY]))throw new UnsupportedPeerError(specifier)
|
||||
@@ -179,17 +180,39 @@ function checkedBunSpecifier(specifier:string,root:string,files:Readonly<Record<
|
||||
const path=file?(require && !specifier.startsWith('file:')?resolve(dirname(fileURLToPath(caller)),specifier):resolve(fileURLToPath(file))):undefined
|
||||
if(!path)throw new Error('bare dependency is absent from this reviewed composition; package its reviewed relative source')
|
||||
if(file?.search || file?.hash)throw new Error('Bun does not preserve reviewed module query or fragment identity; select [extension_host] runtime = \"node\" for this composition')
|
||||
const inside=relative(root,path).split(sep).join('/')
|
||||
if(inside==='..' || inside.startsWith('../') || isAbsolute(inside) || !(inside in files))throw new Error('composition import escapes the reviewed file closure')
|
||||
const inside=keyIn(closure,path)
|
||||
if(inside===undefined || !(inside in closure.files))throw new Error('composition import escapes the reviewed file closure')
|
||||
return require?path:file!.href
|
||||
}
|
||||
|
||||
// Module graph admission for an already-reviewed composition. This is an
|
||||
// ephemeral resolver index over the existing tree's file receipt, not an owner,
|
||||
// session or plugin state store. Native JS remains arbitrary co-resident code.
|
||||
const reviewedClosures=new Map<string,{files:Readonly<Record<string,string>>,refs:number}>()
|
||||
//
|
||||
// Keyed by the canonical root's `pathKey`. A module path is matched lexically
|
||||
// against the canonical root or a raw root it was admitted under — never a raw
|
||||
// spelling against a canonical one (see canonical-path.ts): on Windows the two
|
||||
// differ in prefix, 8.3 names and case for the very same directory.
|
||||
interface ReviewedClosure {files:Readonly<Record<string,string>>,refs:number,canonicalRoot:string,rawRoots:Set<string>}
|
||||
const reviewedClosures=new Map<string,ReviewedClosure>()
|
||||
function keyIn(closure:ReviewedClosure,path:string):string|undefined {
|
||||
const canonical=insideKey(closure.canonicalRoot,path)
|
||||
if(canonical!==undefined)return canonical
|
||||
for(const root of closure.rawRoots) {
|
||||
const raw=insideKey(root,path)
|
||||
if(raw!==undefined)return raw
|
||||
}
|
||||
}
|
||||
/** `path` is `root/key` with no link inside the root (both sides canonicalized). */
|
||||
function unlinkedInside(closure:ReviewedClosure,key:string,path:string):boolean {
|
||||
let canonical:string
|
||||
try {canonical=canonicalPath(path)} catch {return false}
|
||||
return unlinkedKeyMatches(closure.canonicalRoot,key,canonical)
|
||||
}
|
||||
export function admitReviewedClosure(baseUrl:string,files:Readonly<Record<string,string>>):()=>void {
|
||||
const root=realpathSync(resolve(fileURLToPath(baseUrl)))
|
||||
const rawRoot=resolve(fileURLToPath(baseUrl))
|
||||
const canonicalRoot=canonicalPath(rawRoot)
|
||||
const id=pathKey(canonicalRoot)
|
||||
const accepted:Record<string,string>=Object.create(null)
|
||||
const keys=Object.keys(files)
|
||||
if (keys.length>4096) throw new Error('reviewed composition closure exceeds its file limit')
|
||||
@@ -197,31 +220,34 @@ export function admitReviewedClosure(baseUrl:string,files:Readonly<Record<string
|
||||
if (!key || key.split('/').some(part=>!part || part==='.' || part==='..') || key.includes('\\') || key.includes(':') || isAbsolute(key) || !/^[a-f0-9]{64}$/.test(files[key])) throw new Error('invalid reviewed composition closure file')
|
||||
accepted[key]=files[key]
|
||||
}
|
||||
const existing=reviewedClosures.get(root)
|
||||
const existing=reviewedClosures.get(id)
|
||||
if(existing) {
|
||||
if(Object.keys(existing.files).length!==keys.length || keys.some(key=>existing.files[key]!==accepted[key])) throw new Error('the same composition root carries different file receipts')
|
||||
existing.refs++
|
||||
} else reviewedClosures.set(root,{files:Object.freeze(accepted),refs:1})
|
||||
existing.rawRoots.add(rawRoot)
|
||||
} else reviewedClosures.set(id,{files:Object.freeze(accepted),refs:1,canonicalRoot,rawRoots:new Set([rawRoot])})
|
||||
let disposed=false
|
||||
return ()=>{if(disposed)return;disposed=true;const current=reviewedClosures.get(root);if(current && --current.refs===0)reviewedClosures.delete(root)}
|
||||
return ()=>{if(disposed)return;disposed=true;const current=reviewedClosures.get(id);if(current && --current.refs===0)reviewedClosures.delete(id)}
|
||||
}
|
||||
/** Both runtimes consume the same exact admitted source-file receipt. */
|
||||
export async function importReviewedModule(baseUrl:string,path:string):Promise<unknown> {
|
||||
const root=realpathSync(resolve(fileURLToPath(baseUrl)))
|
||||
const receipt=reviewedClosures.get(root)
|
||||
const rawRoot=resolve(fileURLToPath(baseUrl))
|
||||
const id=pathKey(canonicalPath(rawRoot))
|
||||
const receipt=reviewedClosures.get(id)
|
||||
if(!receipt)throw new Error('composition module closure is no longer admitted')
|
||||
const entry=resolve(root,path)
|
||||
// The entry keeps the caller's spelling of the root: the one the composition
|
||||
// already read and hashed the module through.
|
||||
const entry=resolve(rawRoot,path)
|
||||
const admitted=closureAt(pathToFileURL(entry).href)
|
||||
if(!admitted || admitted.root!==root || !(admitted.path in receipt.files))throw new Error('composition module is absent from reviewed closure')
|
||||
if(!admitted || admitted.root!==id || !(admitted.path in receipt.files))throw new Error('composition module is absent from reviewed closure')
|
||||
return import(pathToFileURL(entry).href)
|
||||
}
|
||||
function closureAt(url:string|undefined) {
|
||||
if(!url?.startsWith('file:'))return
|
||||
const path=resolve(fileURLToPath(url))
|
||||
for(const [root,receipt] of reviewedClosures) {
|
||||
const inside=relative(root,path)
|
||||
if(inside==='..' || inside.startsWith(`..${sep}`) || isAbsolute(inside))continue
|
||||
return {root,receipt,path:inside.split(sep).join('/')}
|
||||
const inside=keyIn(receipt,path)
|
||||
if(inside!==undefined)return {root,receipt,path:inside}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +290,9 @@ export function installResolveHooks(modules: Record<string, Record<string, unkno
|
||||
const closure=closureAt(url)
|
||||
if(closure) {
|
||||
if(!(closure.path in closure.receipt.files) || result.source===undefined || result.source===null) throw new Error('module was absent from reviewed composition closure')
|
||||
// Windows runs Node with --preserve-symlinks, so a linked module keeps
|
||||
// its in-closure URL; refuse it here as the Bun loader does.
|
||||
if(!unlinkedInside(closure.receipt,closure.path,fileURLToPath(url))) throw new Error('module was absent from reviewed composition closure or contains a symbolic link')
|
||||
const source=typeof result.source==='string'?Buffer.from(result.source):Buffer.from(result.source as Uint8Array)
|
||||
if(source.length>64*1024*1024 || createHash('sha256').update(source).digest('hex')!==closure.receipt.files[closure.path]) throw new Error('composition module bytes changed after review')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
/** Pinned configuration/matcher semantics adapted onto the existing core hook catalog. No agent/session runtime. */
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync,realpathSync,lstatSync } from 'node:fs'
|
||||
import { readFileSync,lstatSync } from 'node:fs'
|
||||
import { resolve,relative,isAbsolute,sep } from 'node:path'
|
||||
import { isUnlinkedInside } from './canonical-path.ts'
|
||||
// Re-exported for the pure path-normalization unit test (test/canonical-path.test.mjs).
|
||||
export { insideKey, pathKey, samePath, stripVerbatim, unlinkedKeyMatches } from './canonical-path.ts'
|
||||
import { parseClaudeCodeConfig } from './upstream/hooks/hooks-claude-code/src/config.ts'
|
||||
import { parseCodexConfig } from './upstream/hooks/hooks-codex/src/config.ts'
|
||||
const EVENTS:Record<string,string>={SessionStart:'session_start',UserPromptSubmit:'message_submit',PreToolUse:'tool_call_before',PostToolUse:'tool_call_after',Stop:'turn_end',SubagentStart:'subagent_spawn',SubagentStop:'subagent_complete'}
|
||||
@@ -9,7 +12,7 @@ export function reviewedHookModule(dialect:'claude-code'|'codex',root:string,fil
|
||||
return {name:`hooks-${dialect}`,inject:['shellHooks'],apply(ctx:any,config:any) {
|
||||
if(!config || typeof config.configPath!=='string')throw new Error('hook bridge needs its reviewed configPath')
|
||||
const path=resolve(root,config.configPath);const inside=relative(root,path).split(sep).join('/')
|
||||
if(!inside || inside.startsWith('../') || isAbsolute(inside) || !files[inside] || realpathSync(path)!==path || !lstatSync(path).isFile())throw new Error('hook config is absent from the reviewed regular-file closure')
|
||||
if(!inside || inside.startsWith('../') || isAbsolute(inside) || !files[inside] || !isUnlinkedInside(root,inside,path) || !lstatSync(path).isFile())throw new Error('hook config is absent from the reviewed regular-file closure')
|
||||
const bytes=readFileSync(path)
|
||||
if(bytes.length>1024*1024 || createHash('sha256').update(bytes).digest('hex')!==files[inside])throw new Error('hook config changed after review or exceeds 1 MiB')
|
||||
// The only substitutions are the sealed bundle and current per-call core workspace.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {insideKey,pathKey,samePath,stripVerbatim,unlinkedKeyMatches} from '../dist/shell-hooks.mjs'
|
||||
|
||||
// Pure string normalization: runs on every OS by naming the platform.
|
||||
test('win32 canonical compare ignores the verbatim prefix and case, never the path itself',()=>{
|
||||
assert.equal(stripVerbatim('\\\\?\\C:\\Users\\runneradmin\\x','win32'),'C:\\Users\\runneradmin\\x')
|
||||
assert.equal(stripVerbatim('\\\\?\\UNC\\host\\share\\x','win32'),'\\\\host\\share\\x')
|
||||
assert.ok(samePath('\\\\?\\C:\\Users\\RunnerAdmin\\Bundle\\hooks.json','c:\\users\\runneradmin\\bundle\\hooks.json','win32'))
|
||||
assert.equal(pathKey('\\\\?\\C:\\A\\B','win32'),pathKey('c:\\a\\b','win32'))
|
||||
assert.ok(!samePath('C:\\a\\b','C:\\a\\c','win32'))
|
||||
// Case folds only on win32.
|
||||
assert.ok(!samePath('/a/B','/a/b','linux'))
|
||||
// Containment against a differently spelled canonical root keeps the target's case.
|
||||
assert.equal(insideKey('\\\\?\\C:\\Users\\RUNNERADMIN\\bundle','c:\\users\\runneradmin\\bundle\\Mod\\a.js','win32'),'Mod/a.js')
|
||||
assert.equal(insideKey('C:\\bundle','C:\\bundle-evil\\a.js','win32'),undefined)
|
||||
assert.equal(insideKey('C:\\bundle','D:\\bundle\\a.js','win32'),undefined)
|
||||
assert.equal(insideKey('/r','/r/../x','linux'),undefined)
|
||||
// A file is unlinked when its canonical path is the canonical root joined with its reviewed key.
|
||||
assert.ok(unlinkedKeyMatches('\\\\?\\C:\\Users\\runneradmin\\bundle','hooks.json','C:\\USERS\\RunnerAdmin\\Bundle\\HOOKS.JSON','win32'))
|
||||
assert.ok(!unlinkedKeyMatches('C:\\bundle','link.json','C:\\bundle\\hooks.json','win32'))
|
||||
assert.ok(!unlinkedKeyMatches('C:\\bundle','../x.json','C:\\x.json','win32'))
|
||||
assert.ok(!unlinkedKeyMatches('/r','link.json','/r/hooks.json','linux'))
|
||||
})
|
||||
Reference in new issue
Block a user