From d2257abedd3684019ae915972938334d7c6055e8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 14 Aug 2026 12:33:02 -0700 Subject: [PATCH] fix(gbrain-sync): enforce the per-repo policy at the code-import chokepoint (#2140 sync path) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deny/read-only tiers in ~/.gstack/gbrain-repo-policy.json were stored by gstack-gbrain-repo-policy but enforced only in /sync-gbrain skill prose — a direct or cron invocation of gstack-gbrain-sync ingested repo code regardless. Worse: the code stage's egress receipt has cited 'per-repo policy chokepoint (repoPolicyTier)' as its consent since v1.63 while no such function existed. repoPolicyTier() now gates the stage before the dry-run branch: deny → refused-policy-deny (exit 1, loud), read-only → clean skipped-policy-read-only (code ingest writes pages), unreadable store → fail-closed refused-policy-unreadable, no store → unchanged fail-open. Subprocess tests pin all four paths against real git repos and a permission-blocked store (verified RED against the ungated binary). The receipt's consent string is truthful from this commit. #2140's ingest-path source-isolation ask remains open — partial-progress comment at ship. Ported from time-attack/gstack (GStack 2). Co-authored-by: Sina Matian Co-Authored-By: Claude Fable 5 --- bin/gstack-gbrain-sync.ts | 62 ++++++++++++++++++++ test/gbrain-repo-policy.test.ts | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 163 insertions(+) diff --git a/bin/gstack-gbrain-sync.ts b/bin/gstack-gbrain-sync.ts index 8aa703e4b..772e00598 100644 --- a/bin/gstack-gbrain-sync.ts +++ b/bin/gstack-gbrain-sync.ts @@ -778,6 +778,38 @@ function warnProbeTimeout(stage: "code" | "memory" | "dream"): void { } +/** + * Per-repo trust tier from ~/.gstack/gbrain-repo-policy.json, read through + * the bin/gstack-gbrain-repo-policy CLI (which owns URL normalization and + * schema migration — do not reimplement either here). + * + * The tier was previously enforced only in /sync-gbrain skill prose, so a + * direct or cron invocation of this script ingested repo code regardless of + * a `deny`/`read-only` setting — and the egress receipt below cited this + * chokepoint as consent before it existed (#2140 sync path). This check + * closes both gaps. + * + * Fail-open ONLY when no policy store exists (nothing was ever set — same + * 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. + */ +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"; +} + async function runCodeImport(args: CliArgs): Promise { const t0 = Date.now(); const root = repoRoot(); @@ -787,6 +819,36 @@ async function runCodeImport(args: CliArgs): Promise { const sourceId = deriveCodeSourceId(root); + // Per-repo trust tier — checked BEFORE the dry-run branch so previews report + // the refusal honestly instead of claiming they would sync. + const policyUrl = originUrl(); + const tier = repoPolicyTier(policyUrl); + if (tier === "read-only") { + // Honoring an explicit user setting (search allowed, page writes never) is + // a clean skip, not a stage failure — code ingest writes pages. + return { + name: "code", + ran: false, + ok: true, + duration_ms: Date.now() - t0, + summary: `skipped — repo policy is read-only for ${policyUrl} (code ingest writes pages). Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write`, + detail: { source_id: sourceId, source_path: root, status: "skipped-policy-read-only" }, + }; + } + if (tier === "deny" || tier === "error") { + const why = tier === "deny" + ? `repo policy is deny for ${policyUrl} — no gbrain ingest for this repo. Change with: gstack-gbrain-repo-policy set ${policyUrl} read-write` + : "repo policy store exists but could not be read (gstack-gbrain-repo-policy get failed) — refusing ingest rather than bypassing a set policy"; + return { + name: "code", + ran: true, + ok: false, + duration_ms: Date.now() - t0, + summary: `refused: ${why}`, + detail: { source_id: sourceId, source_path: root, status: tier === "deny" ? "refused-policy-deny" : "refused-policy-unreadable" }, + }; + } + // dry-run preview always shows the would-do steps, regardless of local // engine state. Useful for "what would /sync-gbrain do" without probing // the engine. diff --git a/test/gbrain-repo-policy.test.ts b/test/gbrain-repo-policy.test.ts index d922c8313..951117400 100644 --- a/test/gbrain-repo-policy.test.ts +++ b/test/gbrain-repo-policy.test.ts @@ -269,3 +269,104 @@ describe('get without arg (auto-detect from current dir)', () => { } }); }); + +// ── #2140 sync-path chokepoint ────────────────────────────────────────────── +// The tier above is a STORE. This block pins the ENFORCEMENT: a direct +// gstack-gbrain-sync invocation (skill prose bypassed — cron, curiosity, +// automation) must honor deny/read-only at the code-import stage, and the +// egress receipt's "per-repo policy chokepoint (repoPolicyTier)" consent +// string must describe code that exists. Wave-1 shipped the receipt string +// without the function; these tests make that impossible to repeat. + +describe('gstack-gbrain-sync code stage honors the repo policy (#2140 sync path)', () => { + const SYNC = path.join(ROOT, 'bin', 'gstack-gbrain-sync.ts'); + const REPO_URL = 'https://github.com/acme/widget.git'; + + let repoDir: string; + + function makeRepo(): void { + repoDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gbrain-policy-repo-')); + const git = (...args: string[]) => + spawnSync('git', args, { cwd: repoDir, encoding: 'utf-8' }); + git('init', '-q', '.'); + git('remote', 'add', 'origin', REPO_URL); + fs.writeFileSync(path.join(repoDir, 'README.md'), 'fixture\n'); + git('add', '-A'); + git('-c', 'user.email=t@t', '-c', 'user.name=t', 'commit', '-qm', 'fixture'); + } + + function runSync(): { status: number; text: string; stages: any[] } { + const res = spawnSync('bun', [SYNC, '--code-only', '--incremental'], { + cwd: repoDir, + encoding: 'utf-8', + timeout: 60_000, + // HOME also redirected so engine detection can't find a real ~/.gbrain. + env: { ...process.env, GSTACK_HOME: tmpHome, HOME: tmpHome }, + }); + let stages: any[] = []; + try { + stages = JSON.parse( + fs.readFileSync(path.join(tmpHome, '.gbrain-sync-state.json'), 'utf-8'), + ).last_stages || []; + } catch { + // state file may be absent on early refusal paths — text asserts cover it + } + return { + status: res.status ?? -1, + text: `${res.stdout || ''}\n${res.stderr || ''}`, + stages, + }; + } + + afterEach(() => { + if (repoDir) fs.rmSync(repoDir, { recursive: true, force: true }); + }); + + test('deny → code stage refuses loudly, exit 1, status refused-policy-deny', () => { + makeRepo(); + expect(run(['set', REPO_URL, 'deny']).status).toBe(0); + const r = runSync(); + expect(r.status).toBe(1); + expect(r.text).toContain('refused'); + expect(r.text).toContain('deny'); + const code = r.stages.find((s: any) => s.name === 'code'); + expect(code?.detail?.status).toBe('refused-policy-deny'); + }); + + test('read-only → clean skip (exit 0), status skipped-policy-read-only', () => { + makeRepo(); + expect(run(['set', REPO_URL, 'read-only']).status).toBe(0); + const r = runSync(); + expect(r.status).toBe(0); + expect(r.text).toContain('read-only'); + const code = r.stages.find((s: any) => s.name === 'code'); + expect(code?.detail?.status).toBe('skipped-policy-read-only'); + }); + + test('store exists but unreadable → fail-closed refusal, never bypassed', () => { + makeRepo(); + expect(run(['set', REPO_URL, 'deny']).status).toBe(0); + fs.chmodSync(policyFile(), 0o000); + try { + const r = runSync(); + expect(r.status).toBe(1); + expect(r.text).toContain('refus'); + const code = r.stages.find((s: any) => s.name === 'code'); + expect(code?.detail?.status).toBe('refused-policy-unreadable'); + } finally { + fs.chmodSync(policyFile(), 0o600); + } + }); + + test('no policy store → fail-open, stage proceeds past the gate (no policy status)', () => { + makeRepo(); + const r = runSync(); + const code = r.stages.find((s: any) => s.name === 'code'); + // With no engine in the redirected HOME the stage skips for ENGINE + // reasons — what matters is that no policy refusal fired and the exit + // is clean, preserving pre-policy behavior for every non-policy user. + expect(r.status).toBe(0); + expect(String(code?.detail?.status || '')).not.toContain('policy'); + expect(r.text).not.toContain('refused'); + }); +});