From 0ffd97baed1296f4838d7ee6acf70c6e954e5493 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 12:26:37 +0000 Subject: [PATCH 1/3] Bridge and git gate: repo-root and excluded-dir containment by path.relative, not a hardcoded backslash github-draft-pr threw 'Target path escapes repo root' on Linux and the gate never excluded its own wrapper dir. One helper, tools/path-within.mjs, replaces the three copies; case-insensitivity now follows the platform's path flavour. Co-Authored-By: Claude Sonnet 5.5 --- tests/path-within.test.mjs | 50 ++++++++++++++++++++++++++++++++++++ tools/bifrost-bridge.mjs | 3 ++- tools/git-gate/gh-proxy.mjs | 7 +++-- tools/git-gate/git-proxy.mjs | 7 +++-- tools/path-within.mjs | 15 +++++++++++ 5 files changed, 73 insertions(+), 9 deletions(-) create mode 100644 tests/path-within.test.mjs create mode 100644 tools/path-within.mjs diff --git a/tests/path-within.test.mjs b/tests/path-within.test.mjs new file mode 100644 index 0000000..a99e137 --- /dev/null +++ b/tests/path-within.test.mjs @@ -0,0 +1,50 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import path from "node:path"; +import { isWithin } from "../tools/path-within.mjs"; + +const flavours = [ + { name: "posix", pathApi: path.posix, root: "/srv/repo", sep: "/", inside: "/srv/repo/src/a.txt", sibling: "/srv/repo-evil/a.txt", other: "/etc/passwd" }, + { name: "win32", pathApi: path.win32, root: "C:\\Work\\Repo", sep: "\\", inside: "C:\\Work\\Repo\\src\\a.txt", sibling: "C:\\Work\\Repo-evil\\a.txt", other: "D:\\Work\\Repo\\a.txt" }, +]; + +for (const f of flavours) { + const opts = { pathApi: f.pathApi }; + test(`${f.name}: a path inside the root, and the root itself, are within`, () => { + assert.equal(isWithin(f.root, f.inside, opts), true); + assert.equal(isWithin(f.root, f.root, opts), true); + assert.equal(isWithin(f.root, f.root + f.sep, opts), true); + }); + + test(`${f.name}: a sibling sharing the root as a name prefix is refused`, () => { + assert.equal(isWithin(f.root, f.sibling, opts), false); + }); + + test(`${f.name}: a dot-dot escape and an unrelated absolute path are refused`, () => { + assert.equal(isWithin(f.root, f.root + f.sep + ["..", "x"].join(f.sep), opts), false); + assert.equal(isWithin(f.root, f.other, opts), false); + assert.equal(isWithin(f.root, f.root + f.sep + "..", opts), false); + }); + + test(`${f.name}: a name that merely starts with two dots is inside`, () => { + assert.equal(isWithin(f.root, f.root + f.sep + "..cache" + f.sep + "x", opts), true); + }); + + test(`${f.name}: excluded dir, strictly beneath it vs not`, () => { + const excluded = f.root + f.sep + "tools" + f.sep + "git-gate"; + const strict = { ...opts, allowSelf: false }; + assert.equal(isWithin(excluded, excluded + f.sep + "git.cmd", strict), true); + assert.equal(isWithin(excluded, excluded, strict), false); + assert.equal(isWithin(excluded, excluded + "-other" + f.sep + "git", strict), false); + assert.equal(isWithin(excluded, f.root + f.sep + "bin" + f.sep + "git", strict), false); + }); +} + +test("win32: comparison ignores case, as Windows paths do", () => { + assert.equal(isWithin("C:\\Work\\Repo", "c:\\work\\repo\\SRC\\a.txt", { pathApi: path.win32 }), true); + assert.equal(isWithin("C:\\Work\\Repo", "c:\\work\\repo-evil\\a.txt", { pathApi: path.win32 }), false); +}); + +test("posix: comparison is case-sensitive", () => { + assert.equal(isWithin("/srv/repo", "/srv/Repo/a.txt", { pathApi: path.posix }), false); +}); diff --git a/tools/bifrost-bridge.mjs b/tools/bifrost-bridge.mjs index e51b2f0..c0122b9 100644 --- a/tools/bifrost-bridge.mjs +++ b/tools/bifrost-bridge.mjs @@ -4,6 +4,7 @@ import { mkdir, readFile, unlink, writeFile } from "node:fs/promises"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { isWithin } from "./path-within.mjs"; import { beginCrossingReceipt, crossingProvenanceFromOptions, @@ -182,7 +183,7 @@ async function createGitHubDraftPr(options) { } const targetPath = resolve(repoRoot, relativePath); - if (!targetPath.toLowerCase().startsWith(`${repoRoot.toLowerCase()}\\`) && targetPath.toLowerCase() !== repoRoot.toLowerCase()) { + if (!isWithin(repoRoot, targetPath)) { throw new Error(`Target path escapes repo root: ${relativePath}`); } diff --git a/tools/git-gate/gh-proxy.mjs b/tools/git-gate/gh-proxy.mjs index 94e05aa..896563d 100644 --- a/tools/git-gate/gh-proxy.mjs +++ b/tools/git-gate/gh-proxy.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { isWithin } from "../path-within.mjs"; const args = process.argv.slice(2); const wrapperDir = dirname(fileURLToPath(import.meta.url)); @@ -90,9 +91,7 @@ function resolveExecutable(command, excludedDir) { } function isWithinExcludedDir(candidate, excludedDir) { - const normalizedCandidate = resolve(candidate).toLowerCase(); - const normalizedExcludedDir = `${resolve(excludedDir).toLowerCase()}\\`; - return normalizedCandidate.startsWith(normalizedExcludedDir); + return isWithin(excludedDir, candidate, { allowSelf: false }); } function isWindowsCommandScript(command) { diff --git a/tools/git-gate/git-proxy.mjs b/tools/git-gate/git-proxy.mjs index 71b6ef2..902b787 100644 --- a/tools/git-gate/git-proxy.mjs +++ b/tools/git-gate/git-proxy.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import { spawnSync } from "node:child_process"; -import { dirname, resolve } from "node:path"; +import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; +import { isWithin } from "../path-within.mjs"; const args = process.argv.slice(2); const wrapperDir = dirname(fileURLToPath(import.meta.url)); @@ -59,9 +60,7 @@ function resolveExecutable(command, excludedDir) { } function isWithinExcludedDir(candidate, excludedDir) { - const normalizedCandidate = resolve(candidate).toLowerCase(); - const normalizedExcludedDir = `${resolve(excludedDir).toLowerCase()}\\`; - return normalizedCandidate.startsWith(normalizedExcludedDir); + return isWithin(excludedDir, candidate, { allowSelf: false }); } function findGitSubcommand(input) { diff --git a/tools/path-within.mjs b/tools/path-within.mjs new file mode 100644 index 0000000..31219a3 --- /dev/null +++ b/tools/path-within.mjs @@ -0,0 +1,15 @@ +import path from "node:path"; + +// True when `candidate` is `root` itself (only if allowSelf) or lies beneath it. +// Containment is path.relative-based, so it follows the host's separator and +// case rules; pass path.win32 or path.posix to pin a flavour. +export function isWithin(root, candidate, { pathApi = path, allowSelf = true } = {}) { + const relative = pathApi.relative(pathApi.resolve(root), pathApi.resolve(candidate)); + if (relative === "") { + return allowSelf; + } + if (pathApi.isAbsolute(relative) || relative === ".." || relative.startsWith(`..${pathApi.sep}`)) { + return false; + } + return true; +} From f0e62566eae9ad7a189c9bfc30b4ccd3fc791a29 Mon Sep 17 00:00:00 2001 From: Heimdall Date: Thu, 12 Nov 2026 12:26:37 +0000 Subject: [PATCH 2/3] tests: hostile 50k-char cursor is the longest the client Uri can carry, still a server-side 400 Co-Authored-By: Claude Sonnet 5.5 --- tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs b/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs index c6ebd74..77df76f 100644 --- a/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs +++ b/tests/Bifrost.Web.Tests/ConceptRankedExportTests.cs @@ -210,7 +210,8 @@ public sealed class ConceptRankedExportTests : IClassFixture Date: Thu, 12 Nov 2026 12:26:37 +0000 Subject: [PATCH 3/3] README: state the two suite prerequisites (real .NET 8, built CultLib) Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4994f86..8aaa457 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ For the full staged roadmap, including contribution points, revenue share, patro - set `Heimdall:PatronSupportIntakeSecret` before enabling Heimdall patron support intake in production - default connection string points at local PostgreSQL - build with `dotnet build Bifrost.slnx` -- test with `DOTNET_ROLL_FORWARD=Major dotnet test Bifrost.slnx` if the machine only has the .NET 10 runtime installed +- test with `dotnet test Bifrost.slnx`; the suite needs a real .NET 8 runtime (`DOTNET_ROOT`, with a runsettings `DotNetHostPath` if the SDK is newer; .NET 10 roll-forward makes Mvc.Testing 8.0.0 return 500 on every JSON response) and a built CultLib TypeScript runtime (`dist/` and `node_modules`) at `$VOIDBOT_CULTLIB_ROOT` - dry-run a Reddit organizing thread with `node tools/bifrost-bridge.mjs reddit-post --title "Thread title" --persona-name Bifrost --content "Thread body" --dry-run true` - print the Eve provider advertisement with `node tools/provider-advertisement.mjs print` - export the Eve provider store with `node tools/provider-advertisement.mjs export --out .bifrost/provider-store.cc`