fix(gbrain-sync): enforce the per-repo policy at the code-import chokepoint (#2140 sync path)

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 <sina@time-attack.dev>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan 2026-08-14 12:33:02 -07:00
parent c7addde547
commit d2257abedd
No known key found for this signature in database
GPG Key ID: C1F69E85C74EFE1D
2 changed files with 163 additions and 0 deletions

View File

@ -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<StageResult> {
const t0 = Date.now();
const root = repoRoot();
@ -787,6 +819,36 @@ async function runCodeImport(args: CliArgs): Promise<StageResult> {
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.

View File

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