mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 16:38:56 +02:00
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:
co-authored by
Sina Matian
Claude Fable 5
parent
c7addde547
commit
d2257abedd
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user