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
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
13 changed files with 640 additions and 100 deletions

View File

@ -4,12 +4,14 @@
* index and search this repo. OPTIONAL: with nothing selected, gstack works
* fine and callers use grep / the file-only decision store.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* Usage:
* gstack-code-intelligence suggest [repo] [--json] # should the one-time indexing offer be made here?
* gstack-code-intelligence options # list providers (GBrain first) + availability
* gstack-code-intelligence status # current selection + availability
* gstack-code-intelligence consent [repo-path] <yes|no> # record per-repo indexing consent (value REQUIRED)
* gstack-code-intelligence select <gbrain|sourcebot|graphify|none>
* gstack-code-intelligence consent [repo-path] # allow indexing this repo (per-repo)
* gstack-code-intelligence index [repo-path] # index the repo with the selected provider
* gstack-code-intelligence search <query...> # search via the selected provider
*
@ -19,11 +21,15 @@
* auto-installed.
*/
import { createHash } from "crypto";
import { realpathSync } from "fs";
import { hostname } from "os";
import { basename, resolve } from "path";
import {
CodeProviderError,
RECOMMENDED_ORDER,
detectAvailable,
getRoot,
hasConsent,
providerById,
readSelection,
@ -125,22 +131,73 @@ function cmdSelect(arg: string | undefined): void {
if (!provider.local) out(`${LABEL[id]} sends repo content off this machine — run \`consent\` in a repo before indexing it.`);
}
function cmdConsent(pathArg: string | undefined): void {
const repoPath = resolve(pathArg ?? process.cwd());
setConsent(repoPath, true);
out(`indexing consent recorded for ${repoPath}`);
/**
* Record per-repo indexing consent: `consent [repo-path] <yes|no>`.
*
* The yes|no value is REQUIRED (true/false also accepted). It is never
* defaulted: an agent recording a user's "no" must persist consent DENIED,
* and a missing/unknown value must record NOTHING — a consent gate that
* assumes "yes" is a consent gate that lies.
*/
function cmdConsent(rest: string[]): void {
const positional = rest.filter((a) => !a.startsWith("--"));
const CONSENT_USAGE = "Usage: consent [repo-path] <yes|no> — the yes/no value is required; consent is never assumed";
if (positional.length < 1 || positional.length > 2) fail(CONSENT_USAGE);
const value = positional[positional.length - 1].toLowerCase();
let consented: boolean;
if (value === "yes" || value === "true") consented = true;
else if (value === "no" || value === "false") consented = false;
else fail(CONSENT_USAGE);
const repoPath = resolve(positional.length === 2 ? positional[0] : process.cwd());
setConsent(repoPath, consented);
out(consented ? `indexing consent recorded for ${repoPath}` : `indexing consent DENIED for ${repoPath} (recorded)`);
}
/**
* Host+path-hashed source id for GBrain/Sourcebot — the same approach as
* deriveCodeSourceId in bin/gstack-gbrain-sync.ts. A bare basename collides:
* two repos both named "api" (or the same repo on two machines against a
* federated brain) would silently share one source. Suffix = first 8 hex of
* sha1(`${hostname}::${realpath}`); base sanitized to gbrain's source-id
* charset (lowercase alnum + interior hyphens) and capped so the whole id
* stays within gbrain's 32-char limit.
*/
function hashedSourceId(repoPath: string): string {
let real = repoPath;
try {
real = realpathSync(repoPath);
} catch {
// path may not exist yet at id-derivation time — hash the resolved form
}
const host = process.env.GSTACK_HOSTNAME || hostname();
const suffix = createHash("sha1").update(`${host}::${real}`).digest("hex").slice(0, 8);
const base =
basename(real)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 23)
.replace(/-+$/, "") || "repo";
return `${base}-${suffix}`;
}
async function cmdIndex(pathArg: string | undefined): Promise<void> {
const provider = resolveSelectedProvider();
if (!provider) fail("no provider selected; run `select <provider>` first");
const repoPath = resolve(pathArg ?? process.cwd());
// Indexing is write-class: hasConsent's default op class applies, so a
// `deny` OR `read-only` repo trust policy vetoes it (code indexing writes
// pages — same semantics as gstack-gbrain-sync's runCodeImport).
const consented = hasConsent(repoPath);
if (!provider!.local && !consented) {
fail(`${provider!.label} would send this repo's content off the machine. Run \`gstack-code-intelligence consent ${repoPath}\` first.`);
const recorded = readSelection().consents[repoPath] === true;
fail(recorded
? `${provider!.label} indexing is blocked by the repo trust policy (deny or read-only — code indexing writes pages). Change with: gstack-gbrain-repo-policy set <origin-url> read-write`
: `${provider!.label} would send this repo's content off the machine. Run \`gstack-code-intelligence consent ${repoPath} yes\` first.`);
}
// Graphify keys sources on the repo path; GBrain/Sourcebot on a short id.
const sourceId = provider!.id === "graphify" ? repoPath : basename(repoPath);
// Graphify keys sources on the repo path; GBrain/Sourcebot on a short
// host+path-hashed id (bare basenames collide across same-named repos).
const sourceId = provider!.id === "graphify" ? repoPath : hashedSourceId(repoPath);
const repo = { id: sourceId, path: repoPath };
try {
const registered = await provider!.registerSource(repo, { consented });
@ -159,8 +216,16 @@ async function cmdSearch(terms: string[]): Promise<void> {
if (!query) fail("Usage: search <query...>");
const provider = resolveSelectedProvider();
if (!provider) fail("no provider selected; run `select <provider>` first (or use grep)");
// Search is read-class: a read-only repo trust policy still allows it
// (mirrors gstack-gbrain-sync: search allowed, page writes never), but a
// deny tier — or no recorded consent at all — still refuses for non-local
// providers, because the query text itself is repo-derived content. The
// consent repo is the one this provider indexed (search reads that graph);
// loopback providers need no consent, so their path is unchanged.
const searchRoot = getRoot(provider!.id) ?? resolve(process.cwd());
const consented = hasConsent(searchRoot, undefined, "read");
try {
const hits = await provider!.search(query, { limit: 10 });
const hits = await provider!.search(query, { limit: 10, consented });
if (!hits.length) {
out("(no results)");
return;
@ -176,6 +241,9 @@ function handleProviderError(err: unknown, label: string): never {
if (err.code === "PROVIDER_UNAVAILABLE") {
fail(`${label} is unavailable (${err.message}). gstack still works — fall back to grep / file-only.`);
}
if (err.code === "PROVIDER_NOT_CONSENTED") {
fail(`${label} ${err.code}: ${err.message} Run \`gstack-code-intelligence consent <repo-path> yes\` first (a deny repo trust policy overrides recorded consent).`);
}
fail(`${label} ${err.code}: ${err.message}`);
}
fail(err instanceof Error ? err.message : String(err));
@ -193,13 +261,13 @@ async function main(): Promise<void> {
case "select":
return cmdSelect(rest[0]);
case "consent":
return cmdConsent(rest[0]);
return cmdConsent(rest);
case "index":
return cmdIndex(rest[0]);
case "search":
return cmdSearch(rest);
default:
fail("Usage: suggest [path] [--json] | options | status | select <provider> | consent [path] | index [path] | search <query...>");
fail("Usage: suggest [path] [--json] | options | status | select <provider> | consent [path] <yes|no> | index [path] | search <query...>");
}
}

View File

@ -42,6 +42,7 @@ import { detectAutopilot, decideSourceRemove, decideCodeSync } from "../lib/gbra
import { writeReceipt } from "../lib/egress-receipt";
import { localEngineStatus, type LocalEngineStatus } from "../lib/gbrain-local-status";
import { buildGbrainEnv, spawnGbrain, execGbrainJson, NEEDS_SHELL_ON_WINDOWS } from "../lib/gbrain-exec";
import { repoPolicyTier as sharedRepoPolicyTier } from "../lib/gbrain-repo-policy-client";
import { checkOwnedStagingDir } from "../lib/staging-guard";
// ── Types ──────────────────────────────────────────────────────────────────
@ -793,21 +794,23 @@ function warnProbeTimeout(stage: "code" | "memory" | "dream"): void {
* behavior as before for every non-policy user, and skips the subprocess).
* Fail-closed ("error") when a store exists but can't be read: a policy the
* user set must not be silently bypassed by a broken store or missing jq.
*
* Reads through the shared lib/gbrain-repo-policy-client.ts (same client as
* the code-intelligence consent veto the two gates can never drift, and
* win32 gets the invoke-via-bash path). A spawn failure is still fail-closed
* but says so, instead of the misleading "store could not be read".
*/
export function repoPolicyTier(url: string | null): "read-write" | "read-only" | "deny" | "unset" | "error" {
const policyFile = join(process.env.GSTACK_HOME || join(homedir(), ".gstack"), "gbrain-repo-policy.json");
if (!existsSync(policyFile)) return "unset";
if (!url) return "unset"; // policy is keyed by origin remote; no remote → nothing set for this repo
const res = spawnSync(join(import.meta.dir, "gstack-gbrain-repo-policy"), ["get", url], {
encoding: "utf-8",
timeout: 10_000,
// Explicit env: Bun's spawnSync default env snapshot misses runtime
// process.env mutations (e.g. tests redirecting GSTACK_HOME).
env: { ...process.env },
});
if (res.error || res.status !== 0) return "error";
const tier = (res.stdout || "").trim();
return tier === "deny" || tier === "read-only" || tier === "read-write" || tier === "unset" ? tier : "error";
const res = sharedRepoPolicyTier(url, process.env);
if (res.error === "spawn-failed") {
process.stderr.write(
"[gstack-gbrain-sync] the repo-policy helper could not be spawned (bash missing from PATH?) — " +
"refusing ingest rather than bypassing a possibly-set policy\n",
);
return "error";
}
if (res.error) return "error";
return res.tier === "none" ? "unset" : res.tier;
}
async function runCodeImport(args: CliArgs): Promise<StageResult> {

View File

@ -1,6 +1,8 @@
/**
* code-intelligence/contract the OPTIONAL, repo-oriented provider contract.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* gstack does not maintain a home-grown indexer. It defines this small contract
* and external providers (GBrain, Sourcebot, Graphify) implement it. The whole
* contract is OPTIONAL: when no provider is available/consented,
@ -17,6 +19,16 @@
export type CodeProviderId = "gbrain" | "sourcebot" | "graphify";
/**
* Policy op classification for the per-remote trust-tier veto (selection.ts).
* Write-class ops (register_source / index / refresh / add / delete) cause
* pages to be written, so BOTH `deny` and `read-only` tiers veto them the
* same semantics as runCodeImport in bin/gstack-gbrain-sync.ts ("code ingest
* writes pages"). Read-class ops (search / export / status) write nothing, so
* only `deny` vetoes them. Callers that don't say get "write" fail-closed.
*/
export type OpClass = "read" | "write";
export type CodeProviderCapability =
| "register_source"
| "refresh"
@ -72,16 +84,18 @@ export interface CodeSearchHit {
export interface OpOptions {
/**
* Env override for spawned processes. Production callers leave this unset;
* tests inject a synthetic env (fake CLI on PATH). Matches the existing
* gbrain helpers.
* Env override for spawned processes and egress-receipt home resolution.
* Production callers leave this unset; tests inject a synthetic env (fake
* CLI on PATH, temp GSTACK_HOME). Matches the existing gbrain helpers.
*/
env?: NodeJS.ProcessEnv;
/** Timeout in ms for the underlying op. */
timeout?: number;
/**
* Explicit per-repo consent that repo content may leave the machine. Required
* for non-local providers on register_source / refresh / add.
* for non-local providers on register_source / refresh / add / search (the
* search query text is repo-derived content). The recorded value also feeds
* the egress receipt, which attests the ACTUAL consent state never assumed.
*/
consented?: boolean;
}

View File

@ -1,6 +1,8 @@
/**
* GBrain adapter full contract fit over the existing gbrain CLI chokepoint.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* Reuses lib/gbrain-exec.ts (spawnGbrain, seeded DATABASE_URL) and
* lib/gbrain-sources.ts (ensureSourceRegistered, probeSource, sourcePageCount)
* rather than re-issuing raw commands, so the DATABASE_URL / GBRAIN_HOME /
@ -39,6 +41,23 @@ const CAPABILITIES: CodeProviderCapability[] = [
];
const DEFAULT_TIMEOUT_MS = 30_000;
/**
* refresh() default. Full code indexing on the 1000+-tracked-file repos this
* feature targets routinely outruns the 30s op default; GraphifyProvider uses
* the same 120s ceiling for the same indexing work. Query/status stay at 30s.
*/
const REFRESH_TIMEOUT_MS = 120_000;
/**
* Environmental (engine / DB / config) failure shapes, shared by #assertOk and
* #wrap so the two paths can never drift. These degrade to
* PROVIDER_UNAVAILABLE (caller falls back to grep / file-only), not a hard
* PROVIDER_ERROR with a raw dump. Covers the real case where gbrain's pglite
* engine fails to init its WASM runtime (garrytan/gbrain#223) as well as
* unreachable/unconfigured databases and a missing CLI.
*/
const ENVIRONMENTAL_ERROR_RE =
/not on PATH|command not found|PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json|database (is )?un(reachable|available)/i;
/**
* Parse `gbrain search` text output (`[score] slug -- snippet`) into hits.
@ -110,7 +129,7 @@ export class GbrainProvider implements CodeProvider {
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
assertEgressConsent(this, opts);
this.#receipt("repo-code-index (sent by gbrain subprocess)", opts);
const timeout = opts.timeout ?? DEFAULT_TIMEOUT_MS;
const timeout = opts.timeout ?? REFRESH_TIMEOUT_MS;
// Two passes, verified end-to-end against real Postgres-backed gbrain 0.42.56:
// 1. default sync (markdown strategy) — indexes docs.
// 2. `sync --strategy code` — the ACTUAL code-indexing pass. Without it code
@ -219,10 +238,8 @@ export class GbrainProvider implements CodeProvider {
throw new CodeProviderError("PROVIDER_TIMEOUT", "gbrain timed out", this.id);
}
// Engine / DB / config problems are ENVIRONMENTAL — degrade to UNAVAILABLE
// (caller falls back to file-only), not a hard PROVIDER_ERROR with a raw dump.
// Covers the real case where gbrain's pglite engine fails to init its WASM
// runtime (garrytan/gbrain#223) as well as unreachable/unconfigured databases.
if (/PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json|database (is )?un(reachable|available)/i.test(stderr)) {
// (caller falls back to file-only). Shapes hoisted to ENVIRONMENTAL_ERROR_RE.
if (ENVIRONMENTAL_ERROR_RE.test(stderr)) {
throw new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(stderr) || "gbrain engine unavailable", this.id);
}
throw new CodeProviderError("PROVIDER_ERROR", firstLine(stderr) || `gbrain exited ${r.status}`, this.id);
@ -231,9 +248,9 @@ export class GbrainProvider implements CodeProvider {
#wrap(err: unknown): CodeProviderError {
if (err instanceof CodeProviderError) return err;
const message = err instanceof Error ? err.message : String(err);
// Same environmental-vs-real split as #assertOk: missing CLI, or engine/DB/
// config problems, degrade to UNAVAILABLE so callers fall back to file-only.
if (/not on PATH|command not found|PGLite|WASM|failed to initialize|Aborted|Cannot connect to database|not configured|config\.json/i.test(message)) {
// Same environmental-vs-real split as #assertOk — literally the same
// regex (ENVIRONMENTAL_ERROR_RE), so the two paths can never drift.
if (ENVIRONMENTAL_ERROR_RE.test(message)) {
return new CodeProviderError("PROVIDER_UNAVAILABLE", firstLine(message), this.id);
}
return new CodeProviderError("PROVIDER_ERROR", firstLine(message), this.id);

View File

@ -1,6 +1,8 @@
/**
* Graphify adapter real CLI integration (github.com/Graphify-Labs/graphify).
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* Graphify is a LOCAL tree-sitter knowledge graph. For CODE, `graphify <dir>`
* and `graphify update <dir>` produce the SAME AST graph with NO LLM and NO
* network (verified against graphify 0.9.23 both emit `AST extraction on N
@ -26,7 +28,7 @@
*/
import { spawnSync } from "child_process";
import { existsSync, readFileSync } from "fs";
import { existsSync, readFileSync, statSync } from "fs";
import { join } from "path";
import {
assertCapability,
@ -47,6 +49,13 @@ const OUT_DIR = "graphify-out";
const GRAPH_JSON = "graph.json";
const DEFAULT_TIMEOUT_MS = 120_000; // indexing a repo can take a while
const NEEDS_SHELL_ON_WINDOWS = process.platform === "win32"; // graphify is a shim on Windows
/**
* status() only parses graph.json for a node count when the file is at most
* this big. On the 1000+-file repos this feature targets, graph.json can run
* to hundreds of MB JSON.parsing that for a cosmetic count is a heap spike.
* Above the threshold the display reports the file size instead.
*/
const STATUS_PARSE_MAX_BYTES = 5 * 1024 * 1024;
export interface GraphifyOptions {
/** Directory whose `graphify-out/` search/status/export read. Defaults to cwd. */
@ -127,13 +136,21 @@ export class GraphifyProvider implements CodeProvider {
const graphPath = join(dir, OUT_DIR, GRAPH_JSON);
if (!existsSync(graphPath)) return { id: dir, state: "absent" };
let itemCount: number | undefined;
let detail = graphPath;
try {
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
// stat first: parse the whole graph only when it's small (the node count
// is display-only, never worth a hundreds-of-MB JSON.parse heap spike).
const size = statSync(graphPath).size;
if (size <= STATUS_PARSE_MAX_BYTES) {
const graph = JSON.parse(readFileSync(graphPath, "utf-8")) as { nodes?: unknown[] };
if (Array.isArray(graph.nodes)) itemCount = graph.nodes.length;
} else {
detail = `${graphPath} (${(size / (1024 * 1024)).toFixed(1)} MB graph; node count skipped)`;
}
} catch {
// graph.json present but unparseable — still ready, just no count.
// graph.json present but unstatable/unparseable — still ready, just no count.
}
return { id: dir, state: "ready", itemCount, detail: graphPath };
return { id: dir, state: "ready", itemCount, detail };
}
async export(source: SourceRef, _opts: OpOptions = {}): Promise<string> {

View File

@ -1,5 +1,8 @@
/**
* code-intelligence the OPTIONAL, repo-oriented provider contract.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* See docs/designs/CODE_INTELLIGENCE_PROVIDER_CONTRACT.md.
*/

View File

@ -2,6 +2,8 @@
* Picker constructs the code-intelligence provider the user selected, and
* offers the recommendation order (GBrain first) for the selection UX.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* `resolveSelectedProvider()` reads the persisted selection and constructs that
* provider, or returns null when nothing is selected the provider-OFF path,
* where callers degrade to grep / the file-only decision store. Availability is
@ -60,41 +62,49 @@ export interface Availability {
detail: string;
}
/**
* Availability probes are DISPLAY probes they must never stall the CLI. A
* dead non-loopback SOURCEBOT_URL at the adapters' 30s op default meant a 30s
* hang just to print the options table; 3s is plenty for a liveness check.
*/
const PROBE_TIMEOUT_MS = 3_000;
/**
* Probe which providers are usable right now, in recommendation order. Used by
* the `options`/`status` display. GBrain via the real localEngineStatus();
* Graphify via its CLI status; Sourcebot via an HTTP liveness probe.
* Graphify via its CLI status; Sourcebot via an HTTP liveness probe. The three
* probes are independent, so they run concurrently, each capped at
* PROBE_TIMEOUT_MS (localEngineStatus owns its own probe timeout + cache).
*/
export async function detectAvailable(opts: PickerOptions = {}): Promise<Availability[]> {
const gbrainStatus = localEngineStatus({ env: opts.env });
const gbrainOk = gbrainStatus === "ok" || gbrainStatus === "timeout";
// Available = the CLI is installed and selectable (NOT "a graph already exists
// here"). A freshly installed Graphify with no graph yet is still available.
const graphifyOk = graphifyInstalled(opts.env);
let graphifyDetail = "graphify CLI not installed (pip install graphifyy, Python >= 3.10)";
if (graphifyOk) {
try {
const s = await new GraphifyProvider({ env: opts.env, ...opts.graphify }).status();
graphifyDetail = s.state === "ready" ? "installed; graph built in this repo" : "installed; run `index` to build a graph";
} catch {
graphifyDetail = "installed";
}
}
let sourcebotOk = false;
let sourcebotDetail = "server unreachable";
try {
const s = await new SourcebotProvider({ env: opts.env, ...opts.sourcebot }).status();
sourcebotOk = s.state === "ready";
sourcebotDetail = s.detail ?? "";
} catch {
sourcebotOk = false;
}
return [
{ id: "gbrain", available: gbrainOk, detail: `gbrain engine: ${gbrainStatus}` },
{ id: "sourcebot", available: sourcebotOk, detail: sourcebotDetail },
{ id: "graphify", available: graphifyOk, detail: graphifyDetail },
];
const [gbrain, sourcebot, graphify] = await Promise.all([
(async (): Promise<Availability> => {
const status = localEngineStatus({ env: opts.env });
return { id: "gbrain", available: status === "ok" || status === "timeout", detail: `gbrain engine: ${status}` };
})(),
(async (): Promise<Availability> => {
try {
const s = await new SourcebotProvider({ env: opts.env, ...opts.sourcebot }).status(undefined, { timeout: PROBE_TIMEOUT_MS });
return { id: "sourcebot", available: s.state === "ready", detail: s.detail ?? "" };
} catch {
return { id: "sourcebot", available: false, detail: "server unreachable" };
}
})(),
(async (): Promise<Availability> => {
// Available = the CLI is installed and selectable (NOT "a graph already exists
// here"). A freshly installed Graphify with no graph yet is still available.
const installed = graphifyInstalled(opts.env);
let detail = "graphify CLI not installed (pip install graphifyy, Python >= 3.10)";
if (installed) {
try {
const s = await new GraphifyProvider({ env: opts.env, ...opts.graphify }).status(undefined, { timeout: PROBE_TIMEOUT_MS });
detail = s.state === "ready" ? "installed; graph built in this repo" : "installed; run `index` to build a graph";
} catch {
detail = "installed";
}
}
return { id: "graphify", available: installed, detail };
})(),
]);
return [gbrain, sourcebot, graphify];
}

View File

@ -3,6 +3,8 @@
* per-repo indexing consent. Stored at `$GSTACK_HOME/code-intelligence.json`
* (default `~/.gstack/`), the same home the rest of gstack uses.
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* Consent is per-repo (keyed by absolute repo path), because indexing consent
* is "may THIS repo's content be indexed by the selected provider" a decision
* a user makes per project, not once for the machine. No selection at all is the
@ -12,8 +14,9 @@
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join, resolve } from "path";
import { execFileSync, spawnSync } from "child_process";
import type { CodeProviderId } from "./contract";
import { execFileSync } from "child_process";
import { hasRepoPolicyStore, repoPolicyTier } from "../gbrain-repo-policy-client";
import type { CodeProviderId, OpClass } from "./contract";
export interface Selection {
provider: CodeProviderId | null;
@ -76,14 +79,20 @@ export function setConsent(repoPath: string, consented: boolean, env: NodeJS.Pro
* The per-remote trust store (gstack-gbrain-repo-policy) is the SINGLE
* authority for consent-to-send: a `deny` tier vetoes any recorded
* code-intelligence consent, so two stores can never disagree about whether
* code may leave this repo (R1, fork port wave 2 review). Mirrors the
* gbrain-sync chokepoint's polarity: no policy store no veto (nothing was
* ever set); unreadable store veto (fail-closed a policy the user set
* must not be bypassed by a broken store).
* code may leave this repo (R1, fork port wave 2 review). The veto is
* op-class-aware (R2): `read-only` means "search allowed, page writes never"
* (the exact semantics runCodeImport in bin/gstack-gbrain-sync.ts enforces
* code ingest writes pages), so it vetoes write-class ops (register / index /
* refresh / add / delete) while read-class ops (search / export / status)
* pass; `deny` vetoes both classes. Mirrors the gbrain-sync chokepoint's
* polarity: no policy store no veto (nothing was ever set); unreadable
* store OR unspawnable policy helper veto for every op class (fail-closed
* a policy the user set must not be bypassed by a broken store or a helper
* that can't run). Reads through the shared lib/gbrain-repo-policy-client.ts
* so this site and the gbrain-sync gate can never drift.
*/
function repoPolicyVeto(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
const home = env.GSTACK_HOME || join(homedir(), ".gstack");
if (!existsSync(join(home, "gbrain-repo-policy.json"))) return false;
function repoPolicyVeto(repoPath: string, opClass: OpClass, env: NodeJS.ProcessEnv = process.env): boolean {
if (!hasRepoPolicyStore(env)) return false; // fast path: nothing was ever set — skip the git spawn too
let url = "";
try {
url = execFileSync("git", ["-C", resolve(repoPath), "remote", "get-url", "origin"], {
@ -93,17 +102,21 @@ function repoPolicyVeto(repoPath: string, env: NodeJS.ProcessEnv = process.env):
return false; // no remote → policy (keyed by remote) has nothing set for this repo
}
if (!url) return false;
const res = spawnSync(join(import.meta.dir, "..", "..", "bin", "gstack-gbrain-repo-policy"), ["get", url], {
encoding: "utf-8", timeout: 10_000, env: { ...env } as NodeJS.ProcessEnv,
});
if (res.error || res.status !== 0) return true; // fail-closed
const tier = (res.stdout || "").trim();
return tier === "deny";
const res = repoPolicyTier(url, env);
if (res.error) return true; // fail-closed (unreadable store or spawn failure alike)
if (res.tier === "deny") return true; // deny beats consent for every op class
return res.tier === "read-only" && opClass === "write"; // read-only: writes never, reads pass
}
export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env): boolean {
/**
* Recorded per-repo consent, filtered through the repo-policy veto. `opClass`
* defaults to "write" so a caller that doesn't classify its op gets the
* fail-closed answer; pass "read" only for ops that write no pages (search /
* export / status).
*/
export function hasConsent(repoPath: string, env: NodeJS.ProcessEnv = process.env, opClass: OpClass = "write"): boolean {
if (readSelection(env).consents[resolve(repoPath)] !== true) return false;
return !repoPolicyVeto(repoPath, env);
return !repoPolicyVeto(repoPath, opClass, env);
}
/** Record the repo path a provider last indexed, so search reads the same graph. */

View File

@ -2,6 +2,8 @@
* Sourcebot adapter real HTTP + config integration
* (github.com/sourcebot-dev/sourcebot, YC F2025).
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* Sourcebot is a self-hosted server that indexes repos declared in its
* config.json and serves regex code search over `POST /api/search` (zoekt). So
* the runtime drives it with plain HTTP + a config-file edit no MCP:
@ -128,32 +130,46 @@ export class SourcebotProvider implements CodeProvider {
return { id: repo.id, state: "registered", detail: "Sourcebot re-indexes on config change" };
}
/** Sourcebot re-indexes automatically; report current liveness. */
/**
* Sourcebot re-indexes automatically; report current liveness. Refresh is a
* write-class op (it represents indexing repo content into the server), so a
* non-loopback server requires per-repo consent even though the HTTP call
* below is only the liveness probe.
*/
async refresh(source: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
assertEgressConsent(this, opts); // no-op when the server is loopback (local)
const live = await this.status(source, opts);
return { ...live, detail: "Sourcebot re-indexes automatically (config change + reindexIntervalMs)" };
}
async search(query: string, opts: SearchOptions = {}): Promise<CodeSearchHit[]> {
if (!query.trim()) return [];
// Query text is repo-derived content (sensitive class): a non-loopback
// server needs per-repo consent BEFORE the query is sent. Fail-closed —
// the throw happens before any bytes (or any receipt) exist.
assertEgressConsent(this, opts); // no-op when the server is loopback (local)
const body = {
query: opts.source ? `repo:${opts.source} ${query}` : query,
matches: opts.limit ?? 20,
isRegexEnabled: true,
isCaseSensitivityEnabled: false,
};
const payload = await this.#post("/api/search", body, opts.timeout ?? DEFAULT_TIMEOUT_MS);
const payload = await this.#post("/api/search", body, opts);
return parseSourcebotSearch(payload, opts.limit ?? 20);
}
async status(_source?: SourceRef, opts: OpOptions = {}): Promise<SourceStatus> {
// `redirect: manual` so an auth-gated server (307 -> /login) reads as
// not-usable instead of following to a 200 and falsely reporting "ready".
// The probe body is a FIXED literal (query "sourcebot") — no repo-derived
// content — so it is allowed without consent; the receipt records that
// consent was unchecked instead of pretending it was verified.
try {
const res = await this.#fetchWithTimeout(
`${this.#baseUrl}/api/search`,
{ method: "POST", headers: { "Content-Type": "application/json", ...this.#authHeaders() }, body: JSON.stringify({ query: "sourcebot", matches: 1, isRegexEnabled: false }), redirect: "manual" },
opts.timeout ?? DEFAULT_TIMEOUT_MS,
{ payloadClass: "liveness-probe (fixed query, no repo-derived content)", consented: opts.consented === true, env: opts.env },
);
if (res.status === 401 || res.status === 403) {
return { id: "*", state: "unknown", partial: true, detail: "reachable but login-gated (enable anonymous access with FORCE_ENABLE_ANONYMOUS_ACCESS=true for local use, or set SOURCEBOT_API_KEY)" };
@ -164,15 +180,20 @@ export class SourcebotProvider implements CodeProvider {
}
}
async #post(path: string, body: unknown, timeout: number): Promise<unknown> {
async #post(path: string, body: unknown, opts: OpOptions): Promise<unknown> {
let res: Response;
try {
res = await this.#fetchWithTimeout(`${this.#baseUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json", ...this.#authHeaders() },
body: JSON.stringify(body),
}, timeout);
}, opts.timeout ?? DEFAULT_TIMEOUT_MS, {
payloadClass: "code-search-request",
consented: opts.consented === true,
env: opts.env,
});
} catch (err) {
if (err instanceof CodeProviderError) throw err;
throw new CodeProviderError("PROVIDER_UNAVAILABLE", `Sourcebot unreachable at ${this.#baseUrl}: ${(err as Error).message}`, this.id);
}
if (res.status === 401 || res.status === 403) {
@ -186,19 +207,32 @@ export class SourcebotProvider implements CodeProvider {
}
}
async #fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise<Response> {
async #fetchWithTimeout(
url: string,
init: RequestInit,
timeout: number,
receipt: { payloadClass: string; consented: boolean; env?: NodeJS.ProcessEnv },
): Promise<Response> {
// Every Sourcebot HTTP call routes through here. A loopback server keeps
// content on this machine (no egress, no receipt); a non-loopback server
// is an off-machine send and gets a fail-closed receipt BEFORE the fetch.
// The receipt's consent field records the ACTUAL consent state passed by
// the op (opts.consented) — the tamper-evident ledger must never attest
// "consented=true" for a send where nothing checked consent. Content-
// bearing ops (search/refresh) assert consent before reaching here; the
// status liveness probe is allowed unconsented and its receipt says so.
if (!this.local) {
const body = typeof init.body === "string" ? init.body : "";
writeReceipt({
env: receipt.env,
sink: "sourcebot",
host: new URL(url).host,
payloadClass: "code-search-request",
payloadClass: receipt.payloadClass,
bytes: Buffer.byteLength(body),
sha256: sha256Hex(body),
consent: "code-intelligence provider=sourcebot (non-loopback SOURCEBOT_URL) + per-repo consented=true",
consent: receipt.consented
? "code-intelligence provider=sourcebot (non-loopback SOURCEBOT_URL) + per-repo consented=true"
: "code-intelligence provider=sourcebot (non-loopback SOURCEBOT_URL) + consent=unchecked (liveness probe only; content-bearing ops assert consent before sending)",
});
}
const controller = new AbortController();

View File

@ -1,6 +1,8 @@
/**
* suggest should the session-start indexing offer be made for this repo?
*
* Portions copyright (c) 2026 Sina Matian, time-attack/gstack (GStack 2), MIT.
*
* The offer fires at most once per machine: never when a provider is already
* selected, never after an explicit decline (`select none`), and never for
* small repos where grep is already fast. Detection is cheap and local
@ -11,7 +13,7 @@ import { spawnSync } from "child_process";
import { resolve } from "path";
import { readSelection } from "./selection";
// ponytail: single tracked-file-count knob for "large"; add a LOC signal if it misfires
// Single tracked-file-count knob for "large"; add a LOC signal if it misfires.
/** Tracked-file count at which indexing starts paying for itself. */
export const LARGE_REPO_FILE_THRESHOLD = 1000;

View File

@ -0,0 +1,84 @@
/**
* gbrain-repo-policy-client the ONE TypeScript client for the per-remote
* trust store (bin/gstack-gbrain-repo-policy, a bash CLI that owns URL
* normalization and schema migration do not reimplement either here).
*
* Extracted because two call sites (lib/code-intelligence/selection.ts consent
* veto; bin/gstack-gbrain-sync.ts code-import gate) each spawnSync'd the script
* themselves and had started to drift. On win32, spawning a
* `#!/usr/bin/env bash` script directly fails ENOENT, which both sites'
* fail-closed paths then reported as "store could not be read" for EVERY repo
* so this client invokes the script through `bash` there (an ENOENT then
* genuinely means "no bash on PATH") and reports a spawn failure distinctly
* from a policy-read failure, so callers can say what actually broke.
*
* POLARITY IS THE CALLER'S. This client only reads and classifies; each call
* site keeps its own fail-open / fail-closed decision on `error`.
*/
import { spawnSync } from "child_process";
import { existsSync } from "fs";
import { homedir } from "os";
import { join } from "path";
export type RepoPolicyTierValue = "deny" | "read-only" | "read-write" | "none";
export interface RepoPolicyResult {
/** `none` = no policy store, no remote URL, or no entry for this remote. */
tier: RepoPolicyTierValue;
/**
* Set when the tier could not be determined (tier is `none` then):
* - `spawn-failed`: the policy script could not be executed at all
* (script missing, or no bash on PATH on win32) the store itself may
* be perfectly fine.
* - `unreadable`: the script ran but could not read the store
* (permissions, corruption, unexpected output).
*/
error?: "unreadable" | "spawn-failed";
}
/** Absolute path of the policy store for this env (GSTACK_HOME-aware). */
export function repoPolicyStorePath(env: NodeJS.ProcessEnv = process.env): string {
const home = env.GSTACK_HOME || join(env.HOME || homedir(), ".gstack");
return join(home, "gbrain-repo-policy.json");
}
/** No store on disk = no policy was ever set (the fast path — no subprocess). */
export function hasRepoPolicyStore(env: NodeJS.ProcessEnv = process.env): boolean {
return existsSync(repoPolicyStorePath(env));
}
/** The bash script that owns the store — resolved relative to this file (lib/ → bin/), never cwd. */
const POLICY_SCRIPT = join(import.meta.dir, "..", "bin", "gstack-gbrain-repo-policy");
/**
* Trust tier for a remote URL, via `gstack-gbrain-repo-policy get <url>`.
*
* Fast paths (no subprocess): no store on disk `none`; no remote URL
* `none` (policy is keyed by origin remote, so nothing can be set for the
* repo). Everything else shells to the script, which owns normalization.
*/
export function repoPolicyTier(url: string | null, env: NodeJS.ProcessEnv = process.env): RepoPolicyResult {
if (!hasRepoPolicyStore(env)) return { tier: "none" };
if (!url) return { tier: "none" };
// The script is `#!/usr/bin/env bash`; win32 can't exec a shebang file, so
// invoke through bash there. An ENOENT then means bash is not on PATH.
const [cmd, args]: [string, string[]] =
process.platform === "win32" ? ["bash", [POLICY_SCRIPT, "get", url]] : [POLICY_SCRIPT, ["get", url]];
const res = spawnSync(cmd, args, {
encoding: "utf-8",
timeout: 10_000,
// Explicit env: Bun's spawnSync default env snapshot misses runtime
// process.env mutations (e.g. tests redirecting GSTACK_HOME).
env: { ...env } as NodeJS.ProcessEnv,
});
if (res.error) {
const code = (res.error as NodeJS.ErrnoException).code;
return { tier: "none", error: code === "ENOENT" ? "spawn-failed" : "unreadable" };
}
if (res.status !== 0) return { tier: "none", error: "unreadable" };
const tier = (res.stdout || "").trim();
if (tier === "deny" || tier === "read-only" || tier === "read-write") return { tier };
if (tier === "unset") return { tier: "none" };
return { tier: "none", error: "unreadable" }; // unexpected output — a read failure, not a tier
}

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);
});

View File

@ -344,6 +344,7 @@ describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path)
});
test('store exists but unreadable → fail-closed refusal, never bypassed', () => {
if (process.platform === 'win32' || process.getuid?.() === 0) return; // chmod semantics differ
makeRepo();
expect(run(['set', REPO_URL, 'deny']).status).toBe(0);
fs.chmodSync(policyFile(), 0o000);