diff --git a/bin/gstack-code-intelligence b/bin/gstack-code-intelligence index 799ba671a..b62f67f69 100755 --- a/bin/gstack-code-intelligence +++ b/bin/gstack-code-intelligence @@ -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] # record per-repo indexing consent (value REQUIRED) * gstack-code-intelligence select - * 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 # 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] `. + * + * 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] — 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 { const provider = resolveSelectedProvider(); if (!provider) fail("no provider selected; run `select ` 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 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 { if (!query) fail("Usage: search "); const provider = resolveSelectedProvider(); if (!provider) fail("no provider selected; run `select ` 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 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 { 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 | consent [path] | index [path] | search "); + fail("Usage: suggest [path] [--json] | options | status | select | consent [path] | index [path] | search "); } } diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 772e00598..4cf6709df 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -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 { diff --git a/lib/code-intelligence/contract.ts b/lib/code-intelligence/contract.ts index 8b56dacf4..24619d714 100644 --- a/lib/code-intelligence/contract.ts +++ b/lib/code-intelligence/contract.ts @@ -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; } diff --git a/lib/code-intelligence/gbrain-adapter.ts b/lib/code-intelligence/gbrain-adapter.ts index a3253a559..594d9ecbb 100644 --- a/lib/code-intelligence/gbrain-adapter.ts +++ b/lib/code-intelligence/gbrain-adapter.ts @@ -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 { 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); diff --git a/lib/code-intelligence/graphify-adapter.ts b/lib/code-intelligence/graphify-adapter.ts index e331984bc..b0cd85ca6 100644 --- a/lib/code-intelligence/graphify-adapter.ts +++ b/lib/code-intelligence/graphify-adapter.ts @@ -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 ` * and `graphify update ` 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 { diff --git a/lib/code-intelligence/index.ts b/lib/code-intelligence/index.ts index 82a7ab884..7db360259 100644 --- a/lib/code-intelligence/index.ts +++ b/lib/code-intelligence/index.ts @@ -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. */ diff --git a/lib/code-intelligence/picker.ts b/lib/code-intelligence/picker.ts index 56573bc91..9c21adaca 100644 --- a/lib/code-intelligence/picker.ts +++ b/lib/code-intelligence/picker.ts @@ -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 { - 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 => { + const status = localEngineStatus({ env: opts.env }); + return { id: "gbrain", available: status === "ok" || status === "timeout", detail: `gbrain engine: ${status}` }; + })(), + (async (): Promise => { + 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 => { + // 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]; } diff --git a/lib/code-intelligence/selection.ts b/lib/code-intelligence/selection.ts index 24a045bfb..b4fe58baf 100644 --- a/lib/code-intelligence/selection.ts +++ b/lib/code-intelligence/selection.ts @@ -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. */ diff --git a/lib/code-intelligence/sourcebot-adapter.ts b/lib/code-intelligence/sourcebot-adapter.ts index 76d25e8fa..f274cb09e 100644 --- a/lib/code-intelligence/sourcebot-adapter.ts +++ b/lib/code-intelligence/sourcebot-adapter.ts @@ -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 { + 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 { 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 { // `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 { + async #post(path: string, body: unknown, opts: OpOptions): Promise { 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 { + async #fetchWithTimeout( + url: string, + init: RequestInit, + timeout: number, + receipt: { payloadClass: string; consented: boolean; env?: NodeJS.ProcessEnv }, + ): Promise { // 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(); diff --git a/lib/code-intelligence/suggest.ts b/lib/code-intelligence/suggest.ts index 48bb6b736..283d5e076 100644 --- a/lib/code-intelligence/suggest.ts +++ b/lib/code-intelligence/suggest.ts @@ -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; diff --git a/lib/gbrain-repo-policy-client.ts b/lib/gbrain-repo-policy-client.ts new file mode 100644 index 000000000..dc5a368ad --- /dev/null +++ b/lib/gbrain-repo-policy-client.ts @@ -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 `. + * + * 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 +} diff --git a/test/code-intelligence.test.ts b/test/code-intelligence.test.ts index f992d45dd..a92a1e142 100644 --- a/test/code-intelligence.test.ts +++ b/test/code-intelligence.test.ts @@ -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, + }); + 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> { + 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); + } + + 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, + }); + 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 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 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); }); diff --git a/test/gbrain-repo-policy.test.ts b/test/gbrain-repo-policy.test.ts index 951117400..c0e87693f 100644 --- a/test/gbrain-repo-policy.test.ts +++ b/test/gbrain-repo-policy.test.ts @@ -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);