mirror of
https://github.com/garrytan/gstack.git
synced 2026-10-31 22:14:25 +01:00
* docs(designs): gstack field wave plan (2026-10-12) and the shared simulated Capy host fixture
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(pregate): price_command config key for the non-executing call plan a paid gate is priced from
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(pregate): repo guards a lane must pass, verified launch Bun and per-file serial runs in lanes, unknown flags exit 2
The guards check runs each .gstack/pregate.json guard whose globs match a
touched file in its own process with its own timeout (PREGATE_GUARD_FAILED,
never downgradable, incomplete without --allow-repo-commands). The lanes
runner and guards get the first Bun in $BUN_INSTALL/bin or ~/.bun/bin that
satisfies gstack's and the project's engines.bun; serial_patterns files run
one process each. price_file replaces the unreleased price_command key: a
paid gate is priced from a call-plan data file, never by running a command.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(pricing): Sonnet 5.5, Gemini 3.x and gbrain embedding rows from fetched pages; Opus 4.7 at its current list price
Each new row carries its source URL and fetch date (2026-10-12). Embedding
models bill input only, so they live in EMBEDDING_PRICING beside PRICING
(eval arms never resolve to them); pricingRow also accepts gbrain's
provider:model ids (voyage:voyage-4). claude-opus-4-7 moves from 15/75 to the
5/25 the Anthropic pricing page lists; benchmark-runner's expected costs follow.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(eval-plan): price-gate prices a paid gate from a call-plan data file, never executing it
gstack-eval-plan price-gate --gate "<exact command>" reads a GSTACK_EVAL_CALLS
v1 file (--plan-file, else the base policy's price_file at HEAD), checks its
binding (gate command string equality, head with only the plan file changed
since, every input's sha256 at HEAD), prices each call purpose (aggregate
totals) and each one-call reservation, and writes a receipt binding the price
to the gate, head, inputs and plan bytes. Unpriced models print unknown, never
zero (EVAL_PRICE_MISSING); a missing plan or no call lines is
EVAL_GATE_UNPRICED unless --allow-unpriced records an owner override; over
--cap-usd / --smoke-cap-usd is EVAL_GATE_OVER_CAP; malformed or partial files
refuse EVAL_DRY_RUN_FAILED and stale ones EVAL_PLAN_STALE. dry-run --cap adds
the same reservation-fits-cap check. Wire format, receipt and the reference
emitter (formatEvalCalls): docs/eval-calls.md.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(eval-plan): the approval step prices every paid gate with price-gate
Step 4 runs gstack-eval-plan price-gate for each paid gate the plan names and
puts its GATE_PRICE line in the ask; the dry-run checklist documents
reservation-fits-cap under --cap.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(sealed): sealed-output guard over every published commit, in the pre-push hook and gstack-pregate sealed
A repo with a sealed key in .gstack/pregate.json (base branch union the
pushed commit, so a push can tighten but never relax) refuses publishing
sealed paths, transcripts, quoted answers, seeds or local home paths to a
public or unknown destination (SEALED_OUTPUT_REFUSED); private warns.
Path rules see every added, renamed or copied path in each published
commit (git rev-list <local> --not --remotes=<remote>), text rules each
commit's added lines; waivers only from the base branch allow file; a
malformed policy fails closed; visibility caches public 24 h, private 1 h.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix(eval-plan): only an undeclared plan is unpriced; a missing price_file is stale, a bad base policy refuses
price-gate's EVAL_GATE_UNPRICED (and --allow-unpriced) now covers only no
plan declared at all or a plan with zero call lines. A declared price_file
missing from the HEAD commit is EVAL_PLAN_STALE (exit 1, not overridable); an
unresolvable origin/<base> is POLICY_SOURCE_UNAVAILABLE and an invalid
.gstack/pregate.json is PREGATE_CONFIG_INVALID (both exit 3).
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(restamp): the repo's stamp rule (stamp_files, literal stamp_patterns) and restamp_command in the staging worktree
stamp_files is an allowlist applied before every stamp rule; stamp_patterns
are bounded literal {version} templates shared by the restamp writer and the
tree receipt. restamp_command runs first in the staging worktree with the
target version (argument and GSTACK_RESTAMP_VERSION), needs a clean caller
tree, skips gstack's version writer and keeps output confinement; a failure
or a wrong version writes nothing. migrations { dir, id } is the policy key
gstack-pregate regate reads.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(pregate): gstack-pregate regate --against <gated-head> with per-lane reuse and migration collisions
Reuse comes only from a tree receipt evaluated now, with a bundle produced
on the gated tree, the current execution identity and an unmoved, known
predecessor head. Each lane prints reusable/rerun/not-reusable; REGATE:
not-needed needs stamp-only, every bundle and --require lane reusable (a
stamp-sensitive lane reruns) and a finished migration lookup. Migration
numbers the branch adds are checked against the base tree and every other
open PR (gh pr list --limit 1000; 100-file lists refetched with gh api
--paginate); any lookup that cannot finish is unknown and blocks
not-needed (MIGRATION_COLLISION). Bare --against routes to the verb.
docs/pregate.md gains the regate section and the gbrain worked configs.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* feat(contract): lanes run gstack-pregate (and the sealed guard where configured) before done; handoff carries the gstack trailer and PREGATE: line
The coordinator contract gains a 'Before done' paragraph and the handoff
order GSTACK_RESULT, gstack: ok|fail <rev>, PREGATE:, gate summary; the
spec section is regenerated and docs/unattended.md states the same rule.
A new test pins the terms and their order; the byte-identity pins stay.
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* v1.91.98.0: pregate guards, priced paid gates, regate and the sealed-output guard
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* test: drop the helper-only Capy host fixture test (test-of-test ratchet); lane tests exercise the helper
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* fix: price-gate --gate-file keeps free text out of the /eval-plan fence; shortest dependency traces; bounded test spawns
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
* ci(windows): curate out the new pregate/restamp tests that spawn bin/ shebang scripts through runBin
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>
277 lines
19 KiB
TypeScript
277 lines
19 KiB
TypeScript
/**
|
||
* sealed-guard — the sealed-output guard (field wave CD4), shared by the
|
||
* managed pre-push hook (bin/gstack-redact-prepush) and the standalone
|
||
* preflight `gstack-pregate sealed [--range <a..b>]`.
|
||
*
|
||
* A repo opts in with a `sealed` key in `.gstack/pregate.json`:
|
||
* paths globs that may never be published (held-out sets, cells)
|
||
* transcripts globs of per-cell transcripts
|
||
* answer_markers regexes for a quoted sealed answer in added text
|
||
* seed_patterns regexes for a sampling seed in added text
|
||
* local_paths true: a local home path (/home/<u>/, /Users/<u>/) in added text
|
||
* visibility "public" | "private": the destination override (reviewed on the base branch)
|
||
*
|
||
* The publication boundary is every commit the push publishes: the local sha
|
||
* minus everything the destination remote already has (`git rev-list <local>
|
||
* --not --remotes=<remote> ^<remote sha>`); a destination with no refs means
|
||
* everything reachable. Path rules see every path a commit adds, renames or
|
||
* copies, independent of content (binary, empty, add-then-delete); text rules
|
||
* see each commit's added lines. The effective policy is the union of the
|
||
* destination base branch's `sealed` and the pushed one (a push can tighten,
|
||
* never relax); waivers come only from the base branch's
|
||
* `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
|
||
* closed). Visibility: the base override, else a cache under the state root
|
||
* (a `public` answer for 24 h, a `private` one for 1 h before it may
|
||
* downgrade a refusal), else `gh repo view` (5 s); unknown counts as public.
|
||
* Findings name file, line, rule and commit, never the matched text.
|
||
*/
|
||
import { spawnSync } from 'node:child_process';
|
||
import * as fs from 'node:fs';
|
||
import * as path from 'node:path';
|
||
import { containedRelative, globToRegExp, matchesAny } from './ship-policy';
|
||
import { resolveStateRoot } from './state-root';
|
||
|
||
export const SEALED_CONFIG_PATH = '.gstack/pregate.json';
|
||
export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv';
|
||
export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility';
|
||
export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path';
|
||
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; visibility?: 'public' | 'private' }
|
||
export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string }
|
||
export interface AllowEntry { rule: string; path: string; sha: string; reason: string }
|
||
export type Visibility = 'public' | 'private' | 'unknown';
|
||
|
||
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'visibility'];
|
||
const MAX_ENTRIES = 64;
|
||
const MAX_REGEX = 256;
|
||
const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/;
|
||
const PUBLIC_TTL_MS = 24 * 3600_000;
|
||
const PRIVATE_TTL_MS = 3600_000;
|
||
|
||
/** `<key>: <message>` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */
|
||
export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> {
|
||
const errors: Array<{ path: string; message: string }> = [];
|
||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: 'must be an object { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }' }];
|
||
const o = raw as Record<string, unknown>;
|
||
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')}` });
|
||
const list = (key: string, check: (s: string) => string | null) => {
|
||
if (!(key in o)) return;
|
||
const v = o[key];
|
||
if (!Array.isArray(v) || v.some(s => typeof s !== 'string')) { errors.push({ path: `sealed.${key}`, message: 'must be an array of strings' }); return; }
|
||
if (v.length > MAX_ENTRIES) errors.push({ path: `sealed.${key}`, message: `more than ${MAX_ENTRIES} entries` });
|
||
(v as string[]).forEach((s, i) => { const why = check(s); if (why) errors.push({ path: `sealed.${key}[${i}]`, message: `${JSON.stringify(s)} ${why}` }); });
|
||
};
|
||
const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } };
|
||
const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } };
|
||
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex);
|
||
if ('local_paths' in o && typeof o.local_paths !== 'boolean') errors.push({ path: 'sealed.local_paths', message: 'must be true or false' });
|
||
if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' });
|
||
return errors;
|
||
}
|
||
|
||
function git(repoRoot: string, args: string[], input?: string): { ok: boolean; out: string; err: string } {
|
||
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', input, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
|
||
}
|
||
|
||
export type PolicyRead = { policy: SealedPolicy | null; label: string } | { error: string; label: string };
|
||
|
||
/** The `sealed` key of `.gstack/pregate.json` at a commit; null when absent; an error when present but unreadable. */
|
||
export function sealedPolicyAt(repoRoot: string, treeish: string, label: string): PolicyRead {
|
||
const spec = `${treeish}:${SEALED_CONFIG_PATH}`;
|
||
if (!git(repoRoot, ['cat-file', '-e', spec]).ok) return { policy: null, label };
|
||
const show = git(repoRoot, ['cat-file', 'blob', spec]);
|
||
if (!show.ok) return { error: `cannot read ${SEALED_CONFIG_PATH} (${show.err})`, label };
|
||
let raw: any;
|
||
try { raw = JSON.parse(show.out); }
|
||
catch (e: any) { return /"sealed"/.test(show.out) ? { error: `${SEALED_CONFIG_PATH} is not valid JSON (${e.message})`, label } : { policy: null, label }; }
|
||
if (!raw || typeof raw !== 'object' || !('sealed' in raw)) return { policy: null, label };
|
||
const errors = sealedErrors(raw.sealed);
|
||
if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label };
|
||
const s = raw.sealed;
|
||
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, visibility: s.visibility }, label };
|
||
}
|
||
|
||
/** Base ∪ pushed: lists concatenate, `local_paths` ORs; visibility stays private only when the base says so and the push does not say public. */
|
||
export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null {
|
||
if (!base && !pushed) return null;
|
||
const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])];
|
||
const visibility = pushed?.visibility === 'public' || base?.visibility === 'public' ? 'public' : base?.visibility === 'private' ? 'private' : undefined;
|
||
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), visibility };
|
||
}
|
||
|
||
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */
|
||
export function allowEntriesAt(repoRoot: string, treeish: string | null): AllowEntry[] {
|
||
if (!treeish) return [];
|
||
const show = git(repoRoot, ['cat-file', 'blob', `${treeish}:${SEALED_ALLOW_PATH}`]);
|
||
if (!show.ok) return [];
|
||
return show.out.split('\n').filter(l => l.trim() && !l.startsWith('#')).map(l => l.split('\t')).filter(f => f.length >= 4 && /^[0-9a-f]{7,64}$/.test(f[2]!) && f[3]!.trim())
|
||
.map(f => ({ rule: f[0]!.trim(), path: f[1]!.trim(), sha: f[2]!, reason: f.slice(3).join('\t').trim() }));
|
||
}
|
||
|
||
/** Commits a push publishes: local minus the remote's refs (and its tip for this ref); null remote and no tip → everything reachable. */
|
||
export function publishedCommits(repoRoot: string, o: { localSha: string; remoteSha?: string | null; remoteName?: string | null }): { commits: string[] } | { error: string } {
|
||
const not: string[] = [];
|
||
if (o.remoteName) not.push(`--remotes=${o.remoteName}/*`);
|
||
if (o.remoteSha && !/^0+$/.test(o.remoteSha) && git(repoRoot, ['cat-file', '-e', `${o.remoteSha}^{commit}`]).ok) not.push(o.remoteSha);
|
||
const r = git(repoRoot, ['rev-list', o.localSha, ...(not.length ? ['--not', ...not] : [])]);
|
||
if (!r.ok) return { error: `git rev-list failed: ${r.err.slice(0, 200)}` };
|
||
return { commits: r.out.split('\n').filter(Boolean) };
|
||
}
|
||
|
||
export function rangeCommits(repoRoot: string, range: string): { commits: string[] } | { error: string } {
|
||
const r = git(repoRoot, ['rev-list', range]);
|
||
if (!r.ok) return { error: `git rev-list ${range} failed: ${r.err.slice(0, 200)}` };
|
||
return { commits: r.out.split('\n').filter(Boolean) };
|
||
}
|
||
|
||
const HEX = /^[0-9a-f]{40,64}$/;
|
||
|
||
/** Path rules over every added, renamed or copied path, per commit (merges: paths new against every parent). */
|
||
function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] {
|
||
if (!p.paths.length && !p.transcripts.length) return [];
|
||
const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '--name-status', '-z'], commits.join('\n') + '\n');
|
||
if (!r.ok) throw new Error(`git diff-tree failed: ${r.err.slice(0, 200)}`);
|
||
const out: SealedFinding[] = [];
|
||
const tokens = r.out.split('\0');
|
||
let commit = '';
|
||
for (let i = 0; i < tokens.length; i++) {
|
||
const t = tokens[i]!;
|
||
if (!t) continue;
|
||
if (HEX.test(t)) { commit = t; continue; }
|
||
const renamed = /^[RC]\d*$/.test(t);
|
||
const file = renamed ? tokens[i += 2]! : tokens[++i]!;
|
||
if (!renamed && !t.includes('A')) continue;
|
||
if (matchesAny(p.transcripts, file)) out.push({ rule: 'sealed.transcript', file, commit });
|
||
else if (matchesAny(p.paths, file)) out.push({ rule: 'sealed.path', file, commit });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
/** Text rules over each commit's added lines (`--cc` for merges: only the resolution's own lines). */
|
||
function textFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] {
|
||
const rules: Array<[SealedRule, RegExp]> = [
|
||
...p.answer_markers.map(r => ['sealed.answer', new RegExp(r)] as [SealedRule, RegExp]),
|
||
...p.seed_patterns.map(r => ['sealed.seed', new RegExp(r)] as [SealedRule, RegExp]),
|
||
...(p.local_paths ? [['sealed.local_path', LOCAL_PATH_RE] as [SealedRule, RegExp]] : []),
|
||
];
|
||
if (!rules.length) return [];
|
||
const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '-p', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv'], commits.join('\n') + '\n');
|
||
if (!r.ok) throw new Error(`git diff-tree -p failed: ${r.err.slice(0, 200)}`);
|
||
const out: SealedFinding[] = [];
|
||
let commit = ''; let file = ''; let inHunk = false; let parents = 1; let next = 0;
|
||
for (const line of r.out.split('\n')) {
|
||
if (HEX.test(line)) { commit = line; inHunk = false; continue; }
|
||
if (line.startsWith('diff --')) { inHunk = false; file = ''; continue; }
|
||
if (line.startsWith('@@')) { inHunk = true; parents = (/^@+/.exec(line)?.[0].length ?? 2) - 1; next = Number(/ \+(\d+)(?:,\d+)? @+/.exec(line)?.[1] ?? 0); continue; }
|
||
if (!inHunk) { if (line.startsWith('+++ ')) file = line.startsWith('+++ b/') ? line.slice(6) : ''; continue; }
|
||
const prefix = line.slice(0, parents);
|
||
if (line.startsWith('\\') || prefix.includes('-')) continue;
|
||
const n = next++;
|
||
if (!prefix.includes('+')) continue;
|
||
const text = line.slice(parents);
|
||
for (const [rule, re] of rules) if (re.test(text)) out.push({ rule, file, line: n, commit });
|
||
}
|
||
return out;
|
||
}
|
||
|
||
export function scanCommits(repoRoot: string, commits: string[], policy: SealedPolicy): SealedFinding[] {
|
||
if (!commits.length) return [];
|
||
const seen = new Set<string>();
|
||
return [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy)].filter(f => {
|
||
const key = `${f.rule}\0${f.file}\0${f.line ?? ''}\0${f.commit}`;
|
||
if (seen.has(key)) return false;
|
||
seen.add(key);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
/** `owner/repo` for a GitHub remote URL; null for anything else (a local path, another host). */
|
||
export function githubSlug(url: string | null | undefined): string | null {
|
||
const m = /^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec((url ?? '').trim());
|
||
return m ? `${m[1]}/${m[2]}` : null;
|
||
}
|
||
|
||
export interface VisibilityOptions { env?: NodeJS.ProcessEnv; gh?: (args: string[]) => { ok: boolean; out: string }; now?: number }
|
||
|
||
/** Destination visibility: cache (public 24 h, private 1 h), else `gh repo view` (5 s); unknown on any failure or a non-GitHub remote. */
|
||
export function resolveVisibility(url: string | null, o: VisibilityOptions = {}): { visibility: Visibility; source: string } {
|
||
const slug = githubSlug(url);
|
||
if (!slug) return { visibility: 'unknown', source: 'not a GitHub remote' };
|
||
const now = o.now ?? Date.now();
|
||
const file = path.join(resolveStateRoot(o.env ?? process.env), 'sealed-visibility.json');
|
||
let cache: Record<string, { visibility: Visibility; checked_at: number }> = {};
|
||
try { cache = JSON.parse(fs.readFileSync(file, 'utf8')).entries ?? {}; } catch { cache = {}; }
|
||
const hit = cache[slug];
|
||
if (hit && ((hit.visibility === 'public' && now - hit.checked_at < PUBLIC_TTL_MS) || (hit.visibility === 'private' && now - hit.checked_at < PRIVATE_TTL_MS))) return { visibility: hit.visibility, source: `cache ${new Date(hit.checked_at).toISOString()}` };
|
||
const gh = o.gh ?? ((args: string[]) => { const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 5_000, env: o.env ?? process.env }); return { ok: r.status === 0, out: r.stdout ?? '' }; });
|
||
const r = gh(['repo', 'view', slug, '--json', 'visibility']);
|
||
let v: string | undefined;
|
||
try { v = r.ok ? String(JSON.parse(r.out).visibility ?? '').toLowerCase() : undefined; } catch { v = undefined; }
|
||
if (v !== 'public' && v !== 'private' && v !== 'internal') return { visibility: 'unknown', source: `gh repo view ${slug} failed (a new or unreadable repository counts as public)` };
|
||
const visibility: Visibility = v === 'public' ? 'public' : 'private';
|
||
cache[slug] = { visibility, checked_at: now };
|
||
try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify({ entries: cache }, null, 2) + '\n'); } catch { /* an unwritable cache only costs a lookup */ }
|
||
return { visibility, source: `gh repo view ${slug}` };
|
||
}
|
||
|
||
export interface SealedReport {
|
||
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
|
||
policyLabels: string[]; commits: number; findings: SealedFinding[]; waived: Array<SealedFinding & { reason: string }>;
|
||
visibility: Visibility; visibilitySource: string; target: string; error?: string;
|
||
}
|
||
|
||
export interface EvaluateOptions {
|
||
repoRoot: string; targets: Array<{ commits: string[]; pushedSha: string }>; baseRef: string | null; baseLabel?: string; remoteUrl: string | null; target: string;
|
||
env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh'];
|
||
}
|
||
|
||
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
|
||
export function evaluateSealed(o: EvaluateOptions): SealedReport {
|
||
const blank = { policyLabels: [] as string[], commits: 0, findings: [] as SealedFinding[], waived: [] as Array<SealedFinding & { reason: string }>, visibility: 'unknown' as Visibility, visibilitySource: '', target: o.target };
|
||
const base = o.baseRef ? sealedPolicyAt(o.repoRoot, o.baseRef, o.baseLabel ?? o.baseRef) : { policy: null, label: '(no destination base branch)' };
|
||
if ('error' in base) return { ...blank, status: 'error', error: `the sealed policy on ${base.label} is unreadable: ${base.error}` };
|
||
let policy: SealedPolicy | null = base.policy;
|
||
const labels = base.policy ? [base.label] : [];
|
||
for (const t of o.targets) {
|
||
const pushed = sealedPolicyAt(o.repoRoot, t.pushedSha, `pushed ${t.pushedSha.slice(0, 12)}`);
|
||
if ('error' in pushed) return { ...blank, status: 'error', error: `the sealed policy in ${pushed.label} is unreadable: ${pushed.error}` };
|
||
if (pushed.policy) labels.push(pushed.label);
|
||
policy = unionPolicies(policy, pushed.policy);
|
||
}
|
||
if (!policy) return { ...blank, status: 'no-policy' };
|
||
const commits = [...new Set(o.targets.flatMap(t => t.commits))];
|
||
let found: SealedFinding[];
|
||
try { found = scanCommits(o.repoRoot, commits, policy); }
|
||
catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, status: 'error', error: `could not read the published commits: ${e.message}` }; }
|
||
const allow = allowEntriesAt(o.repoRoot, o.baseRef);
|
||
const waived: SealedReport['waived'] = [];
|
||
const findings = found.filter(f => {
|
||
const entry = allow.find(a => a.rule === f.rule && a.path === f.file && f.commit.startsWith(a.sha));
|
||
if (entry) waived.push({ ...f, reason: entry.reason });
|
||
return !entry;
|
||
});
|
||
const vis = policy.visibility ? { visibility: policy.visibility as Visibility, source: `${VISIBILITY_OVERRIDE_KEY} override` } : resolveVisibility(o.remoteUrl, { env: o.env, gh: o.gh });
|
||
const status = findings.length === 0 ? 'clear' : vis.visibility === 'private' ? 'warned' : 'refused';
|
||
return { status, policyLabels: labels, commits: commits.length, findings, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
|
||
}
|
||
|
||
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`;
|
||
|
||
/** stderr text for a report: findings one per line (`<rule> <file[:line]> commit <sha12>`), never the matched text. */
|
||
export function renderSealed(r: SealedReport, context: string): string {
|
||
const lines: string[] = [];
|
||
for (const w of r.waived) lines.push(`${context}: waived ${w.rule} ${where(w)} commit ${w.commit.slice(0, 12)} (${SEALED_ALLOW_PATH}: ${w.reason})`);
|
||
const vis = `visibility: ${r.visibility}${r.visibility === 'unknown' ? ', counted public' : ''}; ${r.visibilitySource}`;
|
||
const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}`);
|
||
if (r.status === 'error') {
|
||
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} — ${r.error}.`);
|
||
lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`);
|
||
} else if (r.status === 'refused') {
|
||
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body);
|
||
lines.push(`Remove it from every published commit before this branch reaches a public repository, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
|
||
} else if (r.status === 'warned') {
|
||
lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body);
|
||
}
|
||
return lines.length ? lines.join('\n') + '\n' : '';
|
||
}
|