fix(code-intelligence): consent that means what it says — polarity, receipts, read-only veto

Four review findings on the wave's own Phase 1 port, all red-first:
'consent <repo> no' recorded consent GRANTED (the CLI ignored the
argument and always wrote true) — yes|no is now required and garbage
records nothing; Sourcebot egress receipts claimed consented=true on
paths that never checked consent — the actual consent state is threaded
into every receipt, search is fail-closed on non-loopback, and the
liveness probe's receipt says truthfully that it sends no repo content;
repoPolicyVeto only honored the deny tier while gbrain refresh writes
pages — write-class ops now veto on read-only too, matching the sync
chokepoint, via one shared lib/gbrain-repo-policy-client.ts (win32
bash invocation, spawn-vs-unreadable error distinction) used by both
call sites. Also: source ids get a host+path hash (same-name repos no
longer collide), refresh timeout raised to 120s, availability probes
run concurrently at 3s, graphify status stops JSON.parsing 100MB graphs
for a count, and every ported file carries the fork MIT notice.
+15 tests across the two suites.
This commit is contained in:
Garry Tan
2026-08-14 17:13:22 -07:00
parent 531d9a6e1f
commit 9488173b0a
13 changed files with 640 additions and 100 deletions
+276 -2
View File
@@ -12,9 +12,10 @@ import { describe, test, expect, beforeEach, afterEach } from "bun:test";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { execFileSync } from "child_process";
import { execFileSync, spawnSync } from "child_process";
import {
REQUIRED_CAPABILITIES,
detectAvailable,
GbrainProvider,
GraphifyProvider,
SourcebotProvider,
@@ -260,6 +261,18 @@ exit 1
new GraphifyProvider({ root: repo, env: { PATH: os.tmpdir() } }).search("q"),
).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" });
});
test("status skips parsing a huge graph.json (heap guard): size in detail, no itemCount", async () => {
const outDir = path.join(repo, "graphify-out");
fs.mkdirSync(outDir, { recursive: true });
// 6MB of spaces — over the 5MB parse threshold, so the content is never
// parsed (on real target repos graph.json can run to hundreds of MB).
fs.writeFileSync(path.join(outDir, "graph.json"), Buffer.alloc(6 * 1024 * 1024, 0x20));
const s = await new GraphifyProvider({ root: repo, env: env() }).status();
expect(s.state).toBe("ready");
expect(s.itemCount).toBeUndefined();
expect(s.detail).toContain("node count skipped");
});
});
describe("Sourcebot adapter (injected fetch, real v5 auth + shape)", () => {
@@ -416,7 +429,8 @@ describe("consent unification — deny tier wins (R1)", () => {
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
test("unreadable policy store fails closed (consent vetoed)", () => {
test("unreadable policy store fails closed (consent vetoed) for BOTH op classes", () => {
if (process.platform === "win32" || process.getuid?.() === 0) return; // chmod semantics differ
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-veto-"));
try {
const env = { ...process.env, GSTACK_HOME: home };
@@ -426,9 +440,269 @@ describe("consent unification — deny tier wins (R1)", () => {
fs.chmodSync(path.join(home, "gbrain-repo-policy.json"), 0o000);
try {
expect(hasConsent(repo, env)).toBe(false);
expect(hasConsent(repo, env, "read")).toBe(false);
} finally {
fs.chmodSync(path.join(home, "gbrain-repo-policy.json"), 0o600);
}
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
// ── R2: read-only is a WRITE veto, not a total one ─────────────────────────
// gstack-gbrain-sync semantics: "search allowed, page writes never". The
// code-intelligence veto must match: index/register/refresh (write-class)
// are refused on read-only; search (read-class) still works.
test("read-only tier vetoes write-class consent but allows read-class (R2)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-veto-"));
try {
const env = { ...process.env, GSTACK_HOME: home };
const repo = makeRepo(home, URL);
setConsent(repo, true, env);
execFileSync(POLICY_BIN, ["set", URL, "read-only"], { env, encoding: "utf-8" });
// Default op class is write — a caller that doesn't say gets fail-closed.
expect(hasConsent(repo, env)).toBe(false);
expect(hasConsent(repo, env, "write")).toBe(false);
// Read-class (search/export/status) survives read-only.
expect(hasConsent(repo, env, "read")).toBe(true);
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
test("deny beats consent for BOTH op classes", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-veto-"));
try {
const env = { ...process.env, GSTACK_HOME: home };
const repo = makeRepo(home, URL);
setConsent(repo, true, env);
execFileSync(POLICY_BIN, ["set", URL, "deny"], { env, encoding: "utf-8" });
expect(hasConsent(repo, env, "write")).toBe(false);
expect(hasConsent(repo, env, "read")).toBe(false);
} finally { fs.rmSync(home, { recursive: true, force: true }); }
});
});
// ── R2 at the CLI: `index` is write-class, so read-only refuses it ──────────
describe("read-only repo policy blocks write-class CLI index (R2)", () => {
const CLI = path.join(import.meta.dir, "..", "bin", "gstack-code-intelligence");
const POLICY_BIN = path.join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
const URL = "https://github.com/acme/readonly-widget.git";
test("index refuses on read-only even with recorded consent (gbrain provider)", () => {
const home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-ro-"));
const shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-ro-bin-"));
try {
// Shim shadows any real gbrain on PATH: even if a regression lets the
// index proceed, this test can never touch a real brain.
fs.writeFileSync(path.join(shimDir, "gbrain"), "#!/usr/bin/env bash\nexit 1\n", { mode: 0o755 });
const env = { ...process.env, GSTACK_HOME: home, PATH: `${shimDir}:${process.env.PATH}` };
const repo = path.join(home, "repo");
fs.mkdirSync(repo, { recursive: true });
execFileSync("git", ["init", "-q", "."], { cwd: repo });
execFileSync("git", ["remote", "add", "origin", URL], { cwd: repo });
setProvider("gbrain", env);
setConsent(repo, true, env);
execFileSync(POLICY_BIN, ["set", URL, "read-only"], { env, encoding: "utf-8" });
const res = spawnSync("bun", [CLI, "index", repo], {
encoding: "utf-8",
timeout: 30_000,
env: env as Record<string, string>,
});
expect(res.status).not.toBe(0);
expect(res.stderr).toContain("repo trust policy");
} finally {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(shimDir, { recursive: true, force: true });
}
});
});
// ── receipt truthfulness: the tamper-evident ledger must never claim a consent
// that was never checked (red-team finding 1). Non-loopback Sourcebot only —
// loopback sends nothing off-machine and writes no receipt at all.
describe("Sourcebot egress receipts record the TRUE consent state", () => {
let home: string;
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-sb-egress-"));
});
afterEach(() => fs.rmSync(home, { recursive: true, force: true }));
function ledgerLines(): Array<Record<string, unknown>> {
const p = path.join(home, "security", "egress.jsonl");
if (!fs.existsSync(p)) return [];
return fs
.readFileSync(p, "utf-8")
.split("\n")
.filter(Boolean)
.map((l) => JSON.parse(l) as Record<string, unknown>);
}
const okFetch = (async () =>
new Response(JSON.stringify({ files: [] }), {
status: 200,
headers: { "Content-Type": "application/json" },
})) as unknown as typeof fetch;
test("status probe: allowed without consent, receipt says consent=unchecked (never consented=true)", async () => {
const sb = new SourcebotProvider({ baseUrl: "http://sb.example.com:3000", fetch: okFetch });
const s = await sb.status(undefined, { env: { GSTACK_HOME: home } });
expect(s.state).toBe("ready");
const lines = ledgerLines();
expect(lines.length).toBe(1);
expect(lines[0].sink).toBe("sourcebot");
expect(String(lines[0].consent)).toContain("consent=unchecked");
expect(String(lines[0].consent)).not.toContain("consented=true");
expect(String(lines[0].payload_class)).toContain("liveness-probe");
});
test("non-loopback search without consent is refused BEFORE any bytes leave (fail-closed)", async () => {
let calls = 0;
const spyFetch = (async () => {
calls++;
return new Response("{}", { status: 200 });
}) as unknown as typeof fetch;
const sb = new SourcebotProvider({ baseUrl: "http://sb.example.com:3000", fetch: spyFetch });
await expect(sb.search("internalSecretFn", { env: { GSTACK_HOME: home } })).rejects.toMatchObject({
code: "PROVIDER_NOT_CONSENTED",
});
expect(calls).toBe(0); // the query never left the machine
expect(ledgerLines()).toEqual([]); // nothing sent → nothing receipted
});
test("non-loopback refresh without consent → PROVIDER_NOT_CONSENTED (write-class)", async () => {
const sb = new SourcebotProvider({ baseUrl: "http://sb.example.com:3000", fetch: okFetch });
await expect(sb.refresh({ id: "r" }, { env: { GSTACK_HOME: home } })).rejects.toMatchObject({
code: "PROVIDER_NOT_CONSENTED",
});
expect(ledgerLines()).toEqual([]);
});
test("consented non-loopback search sends, and the receipt truthfully records consented=true", async () => {
const sb = new SourcebotProvider({ baseUrl: "http://sb.example.com:3000", fetch: okFetch });
const hits = await sb.search("foo", { consented: true, env: { GSTACK_HOME: home } });
expect(hits).toEqual([]);
const lines = ledgerLines();
expect(lines.length).toBe(1);
expect(String(lines[0].consent)).toContain("consented=true");
expect(lines[0].payload_class).toBe("code-search-request");
});
test("loopback search stays consent-free and writes no receipt (no egress)", async () => {
const sb = new SourcebotProvider({ baseUrl: "http://localhost:3000", fetch: okFetch });
await sb.search("foo", { env: { GSTACK_HOME: home } });
expect(ledgerLines()).toEqual([]);
});
});
// ── consent CLI polarity — a recorded "no" must persist DENIED, never granted ─
describe("consent CLI requires an explicit yes|no (never defaults to granted)", () => {
const CLI = path.join(import.meta.dir, "..", "bin", "gstack-code-intelligence");
let home: string;
let repo: string;
let env: NodeJS.ProcessEnv;
function runConsent(...args: string[]) {
const res = spawnSync("bun", [CLI, "consent", ...args], {
encoding: "utf-8",
timeout: 30_000,
env: env as Record<string, string>,
});
return { status: res.status ?? -1, stdout: res.stdout || "", stderr: res.stderr || "" };
}
beforeEach(() => {
home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-consent-home-"));
repo = fs.mkdtempSync(path.join(os.tmpdir(), "ci-consent-repo-"));
env = { ...process.env, GSTACK_HOME: home };
});
afterEach(() => {
fs.rmSync(home, { recursive: true, force: true });
fs.rmSync(repo, { recursive: true, force: true });
});
test("consent <repo> no records FALSE, and the deny-check honors it", () => {
const r = runConsent(repo, "no");
expect(r.status).toBe(0);
expect(readSelection(env).consents[path.resolve(repo)]).toBe(false);
expect(hasConsent(repo, env)).toBe(false);
});
test("consent <repo> yes records true; a later no overrides it", () => {
expect(runConsent(repo, "yes").status).toBe(0);
expect(hasConsent(repo, env)).toBe(true);
expect(runConsent(repo, "no").status).toBe(0);
expect(readSelection(env).consents[path.resolve(repo)]).toBe(false);
expect(hasConsent(repo, env)).toBe(false);
});
test("true/false are accepted as aliases", () => {
expect(runConsent(repo, "false").status).toBe(0);
expect(readSelection(env).consents[path.resolve(repo)]).toBe(false);
expect(runConsent(repo, "true").status).toBe(0);
expect(readSelection(env).consents[path.resolve(repo)]).toBe(true);
});
test("missing value exits nonzero and records NOTHING (never defaults to yes)", () => {
const r = runConsent(repo);
expect(r.status).not.toBe(0);
expect(r.stderr).toContain("yes|no");
expect(readSelection(env).consents).toEqual({});
expect(hasConsent(repo, env)).toBe(false);
});
test("garbage value exits nonzero and records NOTHING", () => {
const r = runConsent(repo, "maybe");
expect(r.status).not.toBe(0);
expect(r.stderr).toContain("yes|no");
expect(readSelection(env).consents).toEqual({});
});
});
// ── picker probes: concurrent, short-timeout, never a 30s CLI stall ─────────
describe("detectAvailable probes fast even when Sourcebot is a dead non-loopback host", () => {
test("rows come back in recommendation order, well under the old 30s stall", async () => {
const binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-pick-bin-"));
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-pick-home-"));
// localEngineStatus writes its probe cache via process.env.GSTACK_HOME —
// point it at the temp home for the duration so nothing touches ~/.gstack.
const prevGstackHome = process.env.GSTACK_HOME;
process.env.GSTACK_HOME = homeDir;
try {
// PATH-scoped fake gbrain; graphify deliberately absent from that PATH.
fs.writeFileSync(
path.join(binDir, "gbrain"),
`#!/usr/bin/env bash
if [ "$1" = "--version" ]; then echo "gbrain 0.42.0"; exit 0; fi
if [ "$1" = "sources" ]; then echo '{"sources":[]}'; exit 0; fi
exit 1
`,
{ mode: 0o755 },
);
const env: NodeJS.ProcessEnv = { PATH: binDir, HOME: homeDir, GSTACK_HOME: homeDir };
// A hanging fetch that only settles on abort — the 3s probe cap must cut
// it off; with the adapters' 30s default this test would blow its budget.
const hangingFetch = ((_url: string, init: RequestInit) =>
new Promise((_resolve, reject) => {
init.signal?.addEventListener("abort", () =>
reject(Object.assign(new Error("aborted"), { name: "AbortError" })),
);
})) as unknown as typeof fetch;
const t0 = Date.now();
const rows = await detectAvailable({
env,
sourcebot: { baseUrl: "http://sb.internal.example:3000", fetch: hangingFetch },
});
expect(Date.now() - t0).toBeLessThan(5_000);
expect(rows.map((r) => r.id)).toEqual(["gbrain", "sourcebot", "graphify"]);
const gbrain = rows.find((r) => r.id === "gbrain")!;
expect(gbrain.detail).toContain("gbrain engine:");
const sourcebot = rows.find((r) => r.id === "sourcebot")!;
expect(sourcebot.available).toBe(false);
const graphify = rows.find((r) => r.id === "graphify")!;
expect(graphify.available).toBe(false); // not on the scoped PATH
expect(graphify.detail).toContain("not installed");
} finally {
if (prevGstackHome === undefined) delete process.env.GSTACK_HOME;
else process.env.GSTACK_HOME = prevGstackHome;
fs.rmSync(binDir, { recursive: true, force: true });
fs.rmSync(homeDir, { recursive: true, force: true });
}
}, 20_000);
});