diff --git a/bin/gstack-code-intelligence b/bin/gstack-code-intelligence index b62f67f69..dbe765e13 100755 --- a/bin/gstack-code-intelligence +++ b/bin/gstack-code-intelligence @@ -224,6 +224,16 @@ async function cmdSearch(terms: string[]): Promise { // loopback providers need no consent, so their path is unchanged. const searchRoot = getRoot(provider!.id) ?? resolve(process.cwd()); const consented = hasConsent(searchRoot, undefined, "read"); + // Honest pre-flight (mirrors cmdIndex): the adapter enforces the same gate + // (assertEgressConsent throws PROVIDER_NOT_CONSENTED before any bytes or + // receipt exist), but the CLI names WHY — missing consent vs a deny repo + // trust policy — instead of surfacing a generic provider error. + if (!provider!.local && !consented) { + const recorded = readSelection().consents[searchRoot] === true; + fail(recorded + ? `${provider!.label} search is blocked by the repo trust policy (deny — the query text is repo-derived content). Change with: gstack-gbrain-repo-policy set read-only (search allowed) or read-write` + : `${provider!.label} would send the query text (repo-derived content) off this machine. Run \`gstack-code-intelligence consent ${searchRoot} yes\` first.`); + } try { const hits = await provider!.search(query, { limit: 10, consented }); if (!hits.length) { diff --git a/lib/code-intelligence/gbrain-adapter.ts b/lib/code-intelligence/gbrain-adapter.ts index 594d9ecbb..4515ab296 100644 --- a/lib/code-intelligence/gbrain-adapter.ts +++ b/lib/code-intelligence/gbrain-adapter.ts @@ -92,9 +92,14 @@ export class GbrainProvider implements CodeProvider { } /** - * Fail-closed egress receipt for the write ops (register/refresh/add). The - * gbrain subprocess owns the wire bytes, so the receipt records destination - * + payload class; sha256 is only known for `add` (the exact document body). + * Fail-closed egress receipt, written BEFORE every content-bearing send + * (register/refresh/add AND search/export). The gbrain subprocess owns the + * wire bytes, so the receipt records destination + payload class; sha256 is + * known when the exact payload text is (the `add` document body, the + * `search` query). The consent field records the ACTUAL consent state from + * opts — the tamper-evident ledger must never attest consented=true for a + * send where nothing checked consent (every current caller asserts consent + * first, so the unchecked branch is defense-in-depth, not a live path). */ #receipt(payloadClass: string, opts: OpOptions, body?: string): void { writeReceipt({ @@ -104,7 +109,9 @@ export class GbrainProvider implements CodeProvider { payloadClass, bytes: body == null ? 0 : Buffer.byteLength(body), sha256: body == null ? null : sha256Hex(body), - consent: "code-intelligence provider=gbrain + per-repo consented=true", + consent: opts.consented === true + ? "code-intelligence provider=gbrain + per-repo consented=true" + : "code-intelligence provider=gbrain + consent=unchecked (content-bearing ops assert consent before sending)", }); } @@ -143,6 +150,14 @@ export class GbrainProvider implements CodeProvider { async search(query: string, opts: SearchOptions = {}): Promise { if (!query.trim()) return []; + // The query text is repo-derived content and DATABASE_URL may point at a + // remote DB. Unlike Sourcebot there is no cheap loopback check here — the + // URL is resolved inside the gbrain CLI's own config, not by this adapter + // — so EVERY send is treated as consent-requiring (fail closed, matching + // the contract's OpOptions doc). The throw happens before any bytes (or + // any receipt) exist; the receipt lands before the subprocess spawns. + assertEgressConsent(this, opts); + this.#receipt("code-search-query (sent by gbrain subprocess)", opts, query); // `gbrain search` is global and has no `--source` flag; `--limit` is real // (verified against gbrain 0.42.x --help). const args = ["search", query]; @@ -198,6 +213,11 @@ export class GbrainProvider implements CodeProvider { async export(_source: SourceRef, opts: OpOptions = {}): Promise { assertCapability(this, "export"); + // The export request federates into the same possibly-remote DB as + // search — same fail-closed consent gate + receipt (no loopback + // exemption exists for gbrain; see search()). + assertEgressConsent(this, opts); + this.#receipt("brain-export-request (sent by gbrain subprocess)", opts); // `gbrain export` is brain-wide (no per-source flag); returns whatever it prints. const r = spawnGbrain(["export"], { baseEnv: opts.env, diff --git a/test/code-intelligence.test.ts b/test/code-intelligence.test.ts index a92a1e142..255c2d712 100644 --- a/test/code-intelligence.test.ts +++ b/test/code-intelligence.test.ts @@ -13,6 +13,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { execFileSync, spawnSync } from "child_process"; +import { sha256Hex } from "../lib/egress-receipt.js"; import { REQUIRED_CAPABILITIES, detectAvailable, @@ -351,7 +352,7 @@ if [ "$1" = "search" ]; then fi exit 1 `); - const hits = await new GbrainProvider().search("where", { env: env() }); + const hits = await new GbrainProvider().search("where", { env: env(), consented: true }); expect(hits).toEqual([{ ref: "src/x.ts", score: 0.88, snippet: "match", kind: "document" }]); }); @@ -377,14 +378,14 @@ echo "PGLite failed to initialize its WASM runtime." >&2 echo " Original error: Aborted()." >&2 exit 1 `); - await expect(new GbrainProvider().search("q", { env: env() })).rejects.toMatchObject({ + await expect(new GbrainProvider().search("q", { env: env(), consented: true })).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE", }); }); test("missing CLI degrades to PROVIDER_UNAVAILABLE", async () => { await expect( - new GbrainProvider().search("q", { env: { PATH: binDir, HOME: homeDir } }), + new GbrainProvider().search("q", { env: { PATH: binDir, HOME: homeDir }, consented: true }), ).rejects.toMatchObject({ code: "PROVIDER_UNAVAILABLE" }); }); }); @@ -591,6 +592,157 @@ describe("Sourcebot egress receipts record the TRUE consent state", () => { }); }); +// ── GBrain consent + receipts: gbrain federates into a possibly-remote +// DATABASE_URL and (unlike Sourcebot) has no cheap loopback check — the URL is +// resolved inside the gbrain CLI's own config — so EVERY content-bearing send +// is consent-gated and receipted: search's query text and the export request +// included, not just register/refresh/add. +describe("GBrain search/export consent gate + egress receipts", () => { + let binDir: string; + let home: string; + let marker: string; + function env(): NodeJS.ProcessEnv { + return { PATH: `${binDir}:${process.env.PATH}`, HOME: home, GSTACK_HOME: home }; + } + function ledgerLines(): Array> { + 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); + } + beforeEach(() => { + binDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-egress-bin-")); + home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-gb-egress-home-")); + marker = path.join(home, "gbrain-invocations.log"); + // Shim logs every invocation, so "refused BEFORE the subprocess runs" is + // provable: an unconsented op must leave the marker file nonexistent. + fs.writeFileSync( + path.join(binDir, "gbrain"), + `#!/usr/bin/env bash +printf '%s\\n' "$*" >> "${marker}" +if [ "$1" = "search" ]; then echo "[0.88] src/x.ts -- match"; exit 0; fi +if [ "$1" = "export" ]; then echo "exported-brain-body"; exit 0; fi +exit 1 +`, + { mode: 0o755 }, + ); + }); + afterEach(() => { + fs.rmSync(binDir, { recursive: true, force: true }); + fs.rmSync(home, { recursive: true, force: true }); + }); + + test("unconsented search is refused BEFORE the subprocess runs (fail-closed) and writes no receipt", async () => { + await expect(new GbrainProvider().search("internalSecretFn", { env: env() })).rejects.toMatchObject({ + code: "PROVIDER_NOT_CONSENTED", + }); + expect(fs.existsSync(marker)).toBe(false); // the query never left the machine + expect(ledgerLines()).toEqual([]); // nothing sent → nothing receipted + }); + + test("consented search sends, and the receipt truthfully records consented=true + the query hash", async () => { + const hits = await new GbrainProvider().search("where is auth", { env: env(), consented: true }); + expect(hits).toEqual([{ ref: "src/x.ts", score: 0.88, snippet: "match", kind: "document" }]); + const lines = ledgerLines(); + expect(lines.length).toBe(1); + expect(lines[0].sink).toBe("gbrain"); + expect(String(lines[0].consent)).toContain("consented=true"); + expect(String(lines[0].payload_class)).toContain("code-search-query"); + expect(lines[0].sha256).toBe(sha256Hex("where is auth")); + expect(lines[0].bytes).toBe(Buffer.byteLength("where is auth")); + }); + + test("unconsented export is refused (no loopback exemption exists for gbrain) and writes no receipt", async () => { + await expect(new GbrainProvider().export({ id: "code" }, { env: env() })).rejects.toMatchObject({ + code: "PROVIDER_NOT_CONSENTED", + }); + expect(fs.existsSync(marker)).toBe(false); + expect(ledgerLines()).toEqual([]); + }); + + test("consented export sends and writes a truthful receipt", async () => { + const body = await new GbrainProvider().export({ id: "code" }, { env: env(), consented: true }); + expect(body).toContain("exported-brain-body"); + const lines = ledgerLines(); + expect(lines.length).toBe(1); + expect(lines[0].sink).toBe("gbrain"); + expect(String(lines[0].consent)).toContain("consented=true"); + expect(String(lines[0].payload_class)).toContain("brain-export-request"); + }); +}); + +// ── the CLI names WHY a gbrain search is refused (missing consent vs a deny +// repo trust policy) instead of surfacing a generic provider error, and the +// refusal happens before any subprocess spawn or receipt. +describe("CLI search consent gate (gbrain provider, honest refusal message)", () => { + 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/search-widget.git"; + let home: string; + let shimDir: string; + let marker: string; + let repo: string; + let env: NodeJS.ProcessEnv; + + beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cli-search-")); + shimDir = fs.mkdtempSync(path.join(os.tmpdir(), "ci-cli-search-bin-")); + marker = path.join(home, "gbrain-invocations.log"); + fs.writeFileSync( + path.join(shimDir, "gbrain"), + `#!/usr/bin/env bash\nprintf '%s\\n' "$*" >> "${marker}"\necho "[0.90] slug -- hit"; exit 0\n`, + { mode: 0o755 }, + ); + repo = path.join(home, "repo"); + fs.mkdirSync(repo, { recursive: true }); + execFileSync("git", ["init", "-q", "."], { cwd: repo }); + execFileSync("git", ["remote", "add", "origin", URL], { cwd: repo }); + env = { ...process.env, GSTACK_HOME: home, PATH: `${shimDir}:${process.env.PATH}` }; + setProvider("gbrain", env); + setRoot("gbrain", repo, env); + }); + afterEach(() => { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(shimDir, { recursive: true, force: true }); + }); + + function runSearch() { + return spawnSync("bun", [CLI, "search", "internalSecretFn"], { + encoding: "utf-8", + timeout: 30_000, + env: env as Record, + }); + } + + test("no recorded consent → refused with the consent instruction; nothing spawned, nothing receipted", () => { + const res = runSearch(); + expect(res.status).not.toBe(0); + expect(res.stderr).toContain("consent"); + expect(fs.existsSync(marker)).toBe(false); + expect(fs.existsSync(path.join(home, "security", "egress.jsonl"))).toBe(false); + }); + + test("deny repo trust policy → refused with the trust-policy explanation even with recorded consent", () => { + setConsent(repo, true, env); + execFileSync(POLICY_BIN, ["set", URL, "deny"], { env, encoding: "utf-8" }); + const res = runSearch(); + expect(res.status).not.toBe(0); + expect(res.stderr).toContain("repo trust policy"); + expect(fs.existsSync(marker)).toBe(false); + }); + + test("recorded consent + no deny → search runs against the provider", () => { + setConsent(repo, true, env); + const res = runSearch(); + expect(res.status).toBe(0); + expect(res.stdout).toContain("slug"); + expect(fs.existsSync(marker)).toBe(true); + }); +}); + // ── 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");