mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-06 17:04:27 +01:00
* feat(memorable): per-remote recall opt-in for read-only repos; recall veto fails closed on a corrupt policy store (#3359) Closes #3359 Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(repo-policy): reserve only the two metadata keys, so the _unattributed sentinel stays a valid policy key Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(memorable): recall lookup is a pure snapshot read that fails closed on malformed, dangling or unsearchable stores, and keys on the configured origin URL (security review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(memorable): check every configured origin URL, and confirm a missing store through its ancestors (security review, second pass) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(memorable): read origin URLs NUL-delimited, and keep a dangling ancestor symlink off the no-store fast path (security review, third pass) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(memorable): use each NUL-delimited origin URL verbatim (security review, fourth pass) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --------- Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
235 lines
9.9 KiB
TypeScript
235 lines
9.9 KiB
TypeScript
/**
|
|
* 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 { lstatSync, statSync } from "fs";
|
|
import { dirname, join } from "path";
|
|
import { mergedStateRoots, resolveStateRoot } from "./state-root";
|
|
|
|
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 = resolveStateRoot(env);
|
|
return join(home, "gbrain-repo-policy.json");
|
|
}
|
|
|
|
/**
|
|
* No store on disk = no policy was ever set (the fast path — no subprocess).
|
|
* The ~/.gstack store counts too: its deny tiers merge into every state root.
|
|
* Only confirmed absence is "no store": ENOENT whose nearest existing
|
|
* ancestor resolves (so no dangling symlink cut the walk short). A dangling
|
|
* symlink at the store or above it, a non-directory component, or a path that
|
|
* cannot be checked (an unsearchable directory) counts as a store, so the
|
|
* caller spawns the script and its own fail-closed rules apply.
|
|
*/
|
|
export function hasRepoPolicyStore(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
return mergedStateRoots(env).some((root) => {
|
|
let path = join(root, "gbrain-repo-policy.json");
|
|
try {
|
|
lstatSync(path);
|
|
return true;
|
|
} catch (err) {
|
|
if ((err as NodeJS.ErrnoException).code !== "ENOENT") return true;
|
|
}
|
|
for (let up = dirname(path); up !== path; path = up, up = dirname(up)) {
|
|
try {
|
|
lstatSync(up);
|
|
} catch {
|
|
continue;
|
|
}
|
|
try {
|
|
statSync(up);
|
|
return false;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
});
|
|
}
|
|
|
|
/** 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.
|
|
*
|
|
* `timeoutMs` bounds that spawn (default 10 s). A caller on its own deadline
|
|
* (a Claude Code hook) passes what it can afford; a timeout reads as
|
|
* `unreadable`, and polarity stays the caller's.
|
|
*/
|
|
export function repoPolicyTier(
|
|
url: string | null,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
timeoutMs: number = 10_000,
|
|
): 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: Math.max(1, timeoutMs),
|
|
// 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
|
|
}
|
|
|
|
export interface MemorableRecallPolicy extends RepoPolicyResult {
|
|
/**
|
|
* True only when the tier is `read-only` and the store opts this remote in
|
|
* to Memorable recall. Never true alongside `error` or any other tier.
|
|
*/
|
|
memorableOptIn: boolean;
|
|
}
|
|
|
|
/**
|
|
* Tier plus the per-remote Memorable recall opt-in, via
|
|
* `gstack-gbrain-repo-policy memorable get <url>` (one spawn). Unlike
|
|
* repoPolicyTier, the script never quarantines a corrupt store on this path,
|
|
* so a corrupt store reads as `unreadable` instead of as "no policy".
|
|
* Fast paths and failure classification match repoPolicyTier; anything but
|
|
* exactly "<tier>\t<allow|off>" is `unreadable`. POLARITY IS THE CALLER'S.
|
|
*/
|
|
export function memorableRecallPolicy(
|
|
url: string | null,
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
timeoutMs: number = 10_000,
|
|
): MemorableRecallPolicy {
|
|
if (!hasRepoPolicyStore(env)) return { tier: "none", memorableOptIn: false };
|
|
if (!url) return { tier: "none", memorableOptIn: false };
|
|
const [cmd, args]: [string, string[]] =
|
|
process.platform === "win32"
|
|
? ["bash", [POLICY_SCRIPT, "memorable", "get", url]]
|
|
: [POLICY_SCRIPT, ["memorable", "get", url]];
|
|
const res = spawnSync(cmd, args, {
|
|
encoding: "utf-8",
|
|
timeout: Math.max(1, timeoutMs),
|
|
env: { ...env } as NodeJS.ProcessEnv,
|
|
});
|
|
if (res.error) {
|
|
const code = (res.error as NodeJS.ErrnoException).code;
|
|
return { tier: "none", memorableOptIn: false, error: code === "ENOENT" ? "spawn-failed" : "unreadable" };
|
|
}
|
|
const unreadable: MemorableRecallPolicy = { tier: "none", memorableOptIn: false, error: "unreadable" };
|
|
if (res.status !== 0) return unreadable;
|
|
const match = /^(deny|read-only|read-write|unset)\t(allow|off)\n?$/.exec(res.stdout || "");
|
|
if (!match) return unreadable;
|
|
const tier = match[1] === "unset" ? "none" : (match[1] as RepoPolicyTierValue);
|
|
return { tier, memorableOptIn: tier === "read-only" && match[2] === "allow" };
|
|
}
|
|
|
|
/**
|
|
* Bulk trust-tier lookup via `gstack-gbrain-repo-policy get --batch` — ONE
|
|
* spawn total for the whole url list (memory-ingest checks every distinct
|
|
* transcript remote in a run; per-url spawns would fork N bash+jq processes).
|
|
*
|
|
* The deduped url list goes to the script's stdin, one per line; the script
|
|
* answers one tier per line in input order (`none` where single `get` says
|
|
* `unset`). Fast paths mirror repoPolicyTier: no store on disk → every url
|
|
* is `{ tier: "none" }` with no subprocess.
|
|
*
|
|
* Failure classification matches repoPolicyTier (spawn ENOENT →
|
|
* `spawn-failed`, everything else → `unreadable`), applied to EVERY url: a
|
|
* malformed, incomplete, or timed-out batch (wrong line count, unknown tier
|
|
* token, non-zero exit) maps every url to `{ tier: "none", error:
|
|
* "unreadable" }`. POLARITY IS STILL THE CALLER'S — this client only reads
|
|
* and classifies.
|
|
*/
|
|
export function repoPolicyTierBatch(
|
|
urls: string[],
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
): Map<string, RepoPolicyResult> {
|
|
const out = new Map<string, RepoPolicyResult>();
|
|
const distinct = [...new Set(urls)];
|
|
if (distinct.length === 0) return out;
|
|
if (!hasRepoPolicyStore(env)) {
|
|
for (const u of distinct) out.set(u, { tier: "none" });
|
|
return out;
|
|
}
|
|
const allWithError = (error: "unreadable" | "spawn-failed"): Map<string, RepoPolicyResult> => {
|
|
for (const u of distinct) out.set(u, { tier: "none", error });
|
|
return out;
|
|
};
|
|
// Same win32 bash-wrapping as repoPolicyTier: the script is
|
|
// `#!/usr/bin/env bash`, which win32 can't exec directly.
|
|
const [cmd, args]: [string, string[]] =
|
|
process.platform === "win32"
|
|
? ["bash", [POLICY_SCRIPT, "get", "--batch"]]
|
|
: [POLICY_SCRIPT, ["get", "--batch"]];
|
|
const res = spawnSync(cmd, args, {
|
|
encoding: "utf-8",
|
|
timeout: 10_000,
|
|
input: distinct.join("\n") + "\n",
|
|
env: { ...env } as NodeJS.ProcessEnv,
|
|
});
|
|
if (res.error) {
|
|
const code = (res.error as NodeJS.ErrnoException).code;
|
|
return allWithError(code === "ENOENT" ? "spawn-failed" : "unreadable");
|
|
}
|
|
if (res.status !== 0) return allWithError("unreadable");
|
|
const lines = (res.stdout || "").replace(/\n$/, "").split("\n");
|
|
if (lines.length !== distinct.length) return allWithError("unreadable");
|
|
const parsed: RepoPolicyResult[] = [];
|
|
for (const raw of lines) {
|
|
const tier = raw.trim();
|
|
if (tier === "deny" || tier === "read-only" || tier === "read-write") {
|
|
parsed.push({ tier });
|
|
} else if (tier === "none") {
|
|
parsed.push({ tier: "none" });
|
|
} else {
|
|
// Unknown token anywhere poisons the whole batch — a partially-garbled
|
|
// response can't be trusted line-by-line (the ordering itself may be off).
|
|
return allWithError("unreadable");
|
|
}
|
|
}
|
|
for (let i = 0; i < distinct.length; i++) out.set(distinct[i], parsed[i]);
|
|
return out;
|
|
}
|