mirror of https://github.com/garrytan/gstack.git
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:
parent
531d9a6e1f
commit
9488173b0a
|
|
@ -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...>");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue