mirror of https://github.com/garrytan/gstack.git
fix(code-intelligence): gbrain search/export are consent-gated and receipted
The Sourcebot side got this in the last round; gbrain had the same hole — search() and export() sent repo-derived query text into a possibly-remote DATABASE_URL with no consent check and no egress receipt, bypassing the deny-tier veto. Both now assert consent before any bytes move, receipts record the actual consent state (never a hardcoded true), and search receipts carry the query's sha256. gbrain stays fail-closed: the adapter cannot see where DATABASE_URL points, so every send requires consent. 7 new tests, red-first.
This commit is contained in:
parent
e0171f480c
commit
a64106535b
|
|
@ -224,6 +224,16 @@ async function cmdSearch(terms: string[]): Promise<void> {
|
||||||
// loopback providers need no consent, so their path is unchanged.
|
// loopback providers need no consent, so their path is unchanged.
|
||||||
const searchRoot = getRoot(provider!.id) ?? resolve(process.cwd());
|
const searchRoot = getRoot(provider!.id) ?? resolve(process.cwd());
|
||||||
const consented = hasConsent(searchRoot, undefined, "read");
|
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 <origin-url> 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 {
|
try {
|
||||||
const hits = await provider!.search(query, { limit: 10, consented });
|
const hits = await provider!.search(query, { limit: 10, consented });
|
||||||
if (!hits.length) {
|
if (!hits.length) {
|
||||||
|
|
|
||||||
|
|
@ -92,9 +92,14 @@ export class GbrainProvider implements CodeProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fail-closed egress receipt for the write ops (register/refresh/add). The
|
* Fail-closed egress receipt, written BEFORE every content-bearing send
|
||||||
* gbrain subprocess owns the wire bytes, so the receipt records destination
|
* (register/refresh/add AND search/export). The gbrain subprocess owns the
|
||||||
* + payload class; sha256 is only known for `add` (the exact document body).
|
* 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 {
|
#receipt(payloadClass: string, opts: OpOptions, body?: string): void {
|
||||||
writeReceipt({
|
writeReceipt({
|
||||||
|
|
@ -104,7 +109,9 @@ export class GbrainProvider implements CodeProvider {
|
||||||
payloadClass,
|
payloadClass,
|
||||||
bytes: body == null ? 0 : Buffer.byteLength(body),
|
bytes: body == null ? 0 : Buffer.byteLength(body),
|
||||||
sha256: body == null ? null : sha256Hex(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<CodeSearchHit[]> {
|
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
|
||||||
if (!query.trim()) return [];
|
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
|
// `gbrain search` is global and has no `--source` flag; `--limit` is real
|
||||||
// (verified against gbrain 0.42.x --help).
|
// (verified against gbrain 0.42.x --help).
|
||||||
const args = ["search", query];
|
const args = ["search", query];
|
||||||
|
|
@ -198,6 +213,11 @@ export class GbrainProvider implements CodeProvider {
|
||||||
|
|
||||||
async export(_source: SourceRef, opts: OpOptions = {}): Promise<string> {
|
async export(_source: SourceRef, opts: OpOptions = {}): Promise<string> {
|
||||||
assertCapability(this, "export");
|
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.
|
// `gbrain export` is brain-wide (no per-source flag); returns whatever it prints.
|
||||||
const r = spawnGbrain(["export"], {
|
const r = spawnGbrain(["export"], {
|
||||||
baseEnv: opts.env,
|
baseEnv: opts.env,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import * as fs from "fs";
|
||||||
import * as os from "os";
|
import * as os from "os";
|
||||||
import * as path from "path";
|
import * as path from "path";
|
||||||
import { execFileSync, spawnSync } from "child_process";
|
import { execFileSync, spawnSync } from "child_process";
|
||||||
|
import { sha256Hex } from "../lib/egress-receipt.js";
|
||||||
import {
|
import {
|
||||||
REQUIRED_CAPABILITIES,
|
REQUIRED_CAPABILITIES,
|
||||||
detectAvailable,
|
detectAvailable,
|
||||||
|
|
@ -351,7 +352,7 @@ if [ "$1" = "search" ]; then
|
||||||
fi
|
fi
|
||||||
exit 1
|
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" }]);
|
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
|
echo " Original error: Aborted()." >&2
|
||||||
exit 1
|
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",
|
code: "PROVIDER_UNAVAILABLE",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("missing CLI degrades to PROVIDER_UNAVAILABLE", async () => {
|
test("missing CLI degrades to PROVIDER_UNAVAILABLE", async () => {
|
||||||
await expect(
|
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" });
|
).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<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>);
|
||||||
|
}
|
||||||
|
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<string, string>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ─
|
// ── consent CLI polarity — a recorded "no" must persist DENIED, never granted ─
|
||||||
describe("consent CLI requires an explicit yes|no (never defaults to granted)", () => {
|
describe("consent CLI requires an explicit yes|no (never defaults to granted)", () => {
|
||||||
const CLI = path.join(import.meta.dir, "..", "bin", "gstack-code-intelligence");
|
const CLI = path.join(import.meta.dir, "..", "bin", "gstack-code-intelligence");
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue