Files
gstack/lib/sealed-guard.ts
T
Garry Tanandcapy-ai[bot] 842fef1f95 Let the sealed-output guard check pull requests larger than 256 MiB (GST-2494) (#4300)
* Let the sealed-output guard read pull requests larger than 256 MiB (GST-2494)

The guard's text check and landed-patch check buffered git diff-tree's whole output under spawnSync's 256 MiB maxBuffer, so a PR with millions of generated lines always refused with an empty-stderr error. Both now spool git's stdout to a private temp file and read it a line at a time after git exits 0; failures name the exit code, signal or timeout.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Accept the platform spawn error text in the missing-git test (Windows says Executable not found)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* Reword the sealed guard changelog entry to pass the prose check

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>
2026-11-11 22:48:18 +00:00

503 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
* allow_paths globs exempt from the path rules (read from the base branch only; never folds case)
* case_insensitive true: `paths` and `transcripts` match regardless of case
* label_keys field names whose records may not also carry an identifier (lib/sealed-join.ts; off when empty)
* identifier_keys field-name globs that count as identifiers (default DEFAULT_IDENTIFIER_KEYS)
* 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); `allow_paths` exceptions come only from the base branch's
* policy and remove path findings before waivers, which come only from the
* base branch's `.gstack/sealed-allow.tsv` and must name the full commit sha. 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.
*
* Skipping (on when a base resolves; `--no-skip` turns it off): a listed
* commit the destination already holds as an ancestor of `refs/pull/<n>/head`,
* for a squash `(#n)` on the base since the merge-base (at most 50 refs, a
* 20 s budget, fetched from the destination remote), is not rescanned, nor is
* one whose own `git patch-id --verbatim` and diff equal a base commit's
* since the merge-base (off above 2,000 base or 400 listed commits). A
* landed patch skips only the path and text rules: `sealed.label_join` judges
* the whole file at the commit, which the base may never have held. No
* combined-diff matching; any lookup failure scans everything.
*/
import { spawnSync } from 'node:child_process';
import { createHash } from 'node:crypto';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { sha256Hex, writeReceipt } from './egress-receipt';
import { containedRelative, globToRegExp } from './ship-policy';
import { DEFAULT_IDENTIFIER_KEYS, identifierKeyError, joinFindings, labelKeyError } from './sealed-join';
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' | 'sealed.label_join' | 'sealed.label_join_unchecked';
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; label_keys: string[]; identifier_keys: string[]; visibility?: 'public' | 'private' }
export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string; keys?: string[] }
export type ExemptFinding = SealedFinding & { glob: 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', 'allow_paths', 'case_insensitive', 'label_keys', 'identifier_keys', 'visibility'];
const MAX_ENTRIES = 64;
const MAX_REGEX = 256;
const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-]|\$\{!?(?:[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]\s]{0,64}\])?|[0-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 { ${KEYS.join(', ')} }` }];
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(', ')} (if the key comes from a newer gstack, upgrade gstack on this machine)` });
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})`; } };
const exception = (g: string) => { const name = g.split('/').pop()!; return glob(g) ?? (name.includes('**') || /^[*?]+(\.|$)/.test(name) ? 'has a wildcard file name, which would exempt whole directories or file types; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); };
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception);
list('label_keys', labelKeyError); list('identifier_keys', identifierKeyError);
for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, 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;
}
const GIT_TIMEOUT_MS = 120_000;
function git(repoRoot: string, args: string[], input?: string, encoding: BufferEncoding = 'utf8'): { ok: boolean; out: string; err: string } {
const r = spawnSync('git', args, { cwd: repoRoot, encoding, input, timeout: GIT_TIMEOUT_MS, maxBuffer: 256 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
/** Test seams for the streamed reads: read size, git binary and timeout. */
export interface GitStreamOptions { chunkBytes?: number; bin?: string; timeoutMs?: number }
type GitStdin = { text: string } | { file: string } | null;
/** Runs git with stdout written to a private temp file, so no output size meets spawnSync's maxBuffer, and passes that file to `use` only after git exits 0. A spawn error, timeout, signal or non-zero exit throws `git <label> failed (<why>)`, so partial output never reaches a verdict. */
function spoolGit<T>(repoRoot: string, label: string, args: string[], stdin: GitStdin, o: GitStreamOptions, use: (file: string) => T): T {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-sealed-'));
try {
const file = path.join(dir, 'stdout');
const outFd = fs.openSync(file, 'wx', 0o600);
const inFd = stdin && 'file' in stdin ? fs.openSync(stdin.file, 'r') : null;
const timeout = o.timeoutMs ?? GIT_TIMEOUT_MS;
let r: ReturnType<typeof spawnSync>;
try { r = spawnSync(o.bin ?? 'git', args, { cwd: repoRoot, input: stdin && 'text' in stdin ? stdin.text : undefined, stdio: [inFd ?? (stdin ? 'pipe' : 'ignore'), outFd, 'pipe'], timeout, maxBuffer: 16 * 1024 * 1024 }); }
finally { fs.closeSync(outFd); if (inFd !== null) fs.closeSync(inFd); }
const code = (r.error as NodeJS.ErrnoException | undefined)?.code;
const why = code === 'ETIMEDOUT' ? `timed out after ${timeout / 1000} s, ${r.signal ?? 'killed'}` : r.error ? `could not run git: ${r.error.message}` : r.signal ? `killed by ${r.signal}` : r.status !== 0 ? `exit ${r.status}` : null;
if (why) { const err = (r.stderr ?? '').toString().trim().slice(0, 200); throw new Error(`git ${label} failed (${why})${err ? `: ${err}` : ''}`); }
return use(file);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
/** Each line of a file, split exactly as `text.split('\n')` would, decoding one line at a time; memory holds one chunk and the longest line. */
function readLines(file: string, encoding: BufferEncoding, onLine: (line: string) => void, chunkBytes = 1024 * 1024): void {
const fd = fs.openSync(file, 'r');
try {
const buf = Buffer.allocUnsafe(chunkBytes);
let carry: Buffer[] = [];
for (let n = fs.readSync(fd, buf, 0, chunkBytes, null); n > 0; n = fs.readSync(fd, buf, 0, chunkBytes, null)) {
const view = buf.subarray(0, n);
let start = 0;
for (let nl = view.indexOf(10); nl !== -1; nl = view.indexOf(10, start)) {
onLine(Buffer.concat([...carry, view.subarray(start, nl)]).toString(encoding));
carry = []; start = nl + 1;
}
if (start < n) carry.push(Buffer.from(view.subarray(start)));
}
onLine(Buffer.concat(carry).toString(encoding));
} finally {
fs.closeSync(fd);
}
}
/** git's stdout, streamed line by line once git has exited 0 (spoolGit, readLines). */
export function gitLines(repoRoot: string, label: string, args: string[], stdin: GitStdin, encoding: BufferEncoding, onLine: (line: string) => void, o: GitStreamOptions = {}): void {
spoolGit(repoRoot, label, args, stdin, o, file => readLines(file, encoding, onLine, o.chunkBytes));
}
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, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, label_keys: s.label_keys ?? [], identifier_keys: s.identifier_keys ?? DEFAULT_IDENTIFIER_KEYS, visibility: s.visibility }, label };
}
/** Base ∪ pushed: deny lists, `label_keys` and `identifier_keys` (each resolved to its default per policy first) concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; 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' | 'label_keys' | 'identifier_keys') => [...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), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), label_keys: u('label_keys'), identifier_keys: u('identifier_keys'), visibility };
}
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file; a waiver counts only when its sha equals the finding's full commit sha. */
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 && f[2]!.trim() && f[3]!.trim())
.map(f => ({ rule: f[0]!.trim(), path: f[1]!.trim(), sha: f[2]!.trim(), 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}$/;
/** The first glob matching a repo-relative path, compiled once per list (`matchesAny` normalization); null when none does. */
function globMatcher(globs: string[], ignoreCase: boolean): (file: string) => string | null {
const compiled = globs.map(g => { const re = globToRegExp(g); return { g, re: ignoreCase ? new RegExp(re.source, 'i') : re }; });
return file => { const norm = file.replace(/\\/g, '/').replace(/^\.\//, ''); return compiled.find(c => c.re.test(norm))?.g ?? null; };
}
/** 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 transcript = globMatcher(p.transcripts, p.case_insensitive);
const sealedPath = globMatcher(p.paths, p.case_insensitive);
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 (transcript(file)) out.push({ rule: 'sealed.transcript', file, commit });
else if (sealedPath(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), streamed so the patch size is unbounded. */
export function textFindings(repoRoot: string, commits: string[], p: SealedPolicy, stream: GitStreamOptions = {}): 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 out: SealedFinding[] = [];
let commit = ''; let file = ''; let inHunk = false; let parents = 1; let next = 0;
gitLines(repoRoot, 'diff-tree -p', ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '-p', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv'], { text: commits.join('\n') + '\n' }, 'utf8', line => {
if (HEX.test(line)) { commit = line; inHunk = false; return; }
if (line.startsWith('diff --')) { inHunk = false; file = ''; return; }
if (line.startsWith('@@')) { inHunk = true; parents = (/^@+/.exec(line)?.[0].length ?? 2) - 1; next = Number(/ \+(\d+)(?:,\d+)? @+/.exec(line)?.[1] ?? 0); return; }
if (!inHunk) { if (line.startsWith('+++ ')) file = line.startsWith('+++ b/') ? line.slice(6) : ''; return; }
const prefix = line.slice(0, parents);
if (line.startsWith('\\') || prefix.includes('-')) return;
const n = next++;
if (!prefix.includes('+')) return;
const text = line.slice(parents);
for (const [rule, re] of rules) if (re.test(text)) out.push({ rule, file, line: n, commit });
}, stream);
return out;
}
export function scanCommits(repoRoot: string, commits: string[], policy: SealedPolicy, joinCommits: string[] = commits): SealedFinding[] {
if (!commits.length && !joinCommits.length) return [];
const seen = new Set<string>();
const own = commits.length ? [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy)] : [];
return [...own, ...joinFindings(repoRoot, joinCommits, 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 SkippedCommit { commit: string; pr?: number; landed?: string }
export interface SealedReport {
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
policyLabels: string[]; commits: number; skipped: SkippedCommit[]; notes: string[]; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>; ignoredWaivers: Array<{ rule: string; path: string; sha: string; commit: 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;
/** The destination remote whose refs/pull/<n>/head prove a commit is already published; null skips that lookup. */
remoteName?: string | null; skip?: boolean;
env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh'];
}
/** A report for a failure before any policy is read (an unlisted range, an unreadable push); renders as a fail-closed refusal. */
export function sealedErrorReport(error: string, target: string): SealedReport {
return { status: 'error', error, policyLabels: [], commits: 0, skipped: [], notes: [], findings: [], exempt: [], waived: [], ignoredWaivers: [], visibility: 'unknown', visibilitySource: '', target };
}
const MAX_PULL_REFS = 50;
const PULL_BUDGET_MS = 20_000;
const MAX_LANDED_BASE = 2000;
const MAX_LANDED_LISTED = 400;
const PULL_NS = 'refs/gstack/sealed-pull';
function remoteHost(url: string | null): string {
const m = /^[a-z][a-z0-9+.-]*:\/\/(?:[^@/]+@)?([^/:]+)/i.exec(url ?? '') ?? /^[^@/:]+@([^:/]+):/.exec(url ?? '');
return m?.[1] ?? 'local';
}
/** Listed commits the destination already has: ancestors of refs/pull/<n>/head for the squashes `(#n)` on the base since the merge-base. */
function publishedViaPulls(o: EvaluateOptions, listed: Set<string>, mbs: string[], notes: string[]): Map<string, number> {
const out = new Map<string, number>();
if (!o.remoteName) return out;
const log = git(o.repoRoot, ['log', '--first-parent', '--format=%s', o.baseRef!, ...mbs.map(b => `^${b}`)]);
if (!log.ok) { notes.push(`could not read the base since the merge-base (${log.err.slice(0, 120)}): published commits are scanned`); return out; }
const prs = [...new Set(log.out.split('\n').map(l => /\(#(\d+)\)\s*$/.exec(l)?.[1]).filter((n): n is string => !!n))];
if (!prs.length) return out;
if (prs.length > MAX_PULL_REFS) { notes.push(`${prs.length} squashed pull requests on the base since the merge-base, over ${MAX_PULL_REFS}: published commits are scanned`); return out; }
const url = git(o.repoRoot, ['remote', 'get-url', o.remoteName]).out.trim() || null;
const deadline = Date.now() + PULL_BUDGET_MS;
const remote = (args: string[]) => {
const left = deadline - Date.now();
if (left <= 0) return { ok: false, out: '', err: `the ${PULL_BUDGET_MS / 1000} s budget ran out` };
try { writeReceipt({ sink: 'sealed-guard', host: remoteHost(url), payloadClass: 'pull-ref-read', bytes: 0, sha256: sha256Hex(args.join(' ')), consent: 'the sealed guard reads the destination\'s own refs/pull/<n>/head for the squashes on its base, to skip commits it already holds', env: o.env }); }
catch (err) { process.stderr.write(`[sealed-guard] egress receipt could not be written (${(err as Error).message}); proceeding (fail-open)\n`); }
const r = spawnSync('git', args, { cwd: o.repoRoot, encoding: 'utf8', timeout: left });
return { ok: r.status === 0 && !r.error, out: r.stdout ?? '', err: (r.stderr || String(r.error ?? '')).trim() };
};
const ls = remote(['ls-remote', o.remoteName, ...prs.map(n => `refs/pull/${n}/head`)]);
if (!ls.ok) { notes.push(`could not list refs/pull/<n>/head on ${o.remoteName} (${ls.err.slice(0, 120)}): published commits are scanned`); return out; }
const heads = ls.out.split('\n').map(l => /^([0-9a-f]{40,64})\trefs\/pull\/(\d+)\/head$/.exec(l)).filter((m): m is RegExpExecArray => !!m);
if (!heads.length) return out;
try {
const fetched = remote(['fetch', '--quiet', '--no-tags', '--no-write-fetch-head', o.remoteName, ...heads.map(m => `+refs/pull/${m[2]}/head:${PULL_NS}/${m[2]}`)]);
if (!fetched.ok) { notes.push(`could not fetch refs/pull/<n>/head from ${o.remoteName} (${fetched.err.slice(0, 120)}): published commits are scanned`); return out; }
for (const m of heads) {
const r = git(o.repoRoot, ['rev-list', m[1]!, ...mbs.map(b => `^${b}`)]);
if (!r.ok) continue;
for (const c of r.out.split('\n')) if (listed.has(c) && !out.has(c)) out.set(c, Number(m[2]));
}
return out;
} finally {
git(o.repoRoot, ['update-ref', '--stdin'], heads.map(m => `delete ${PULL_NS}/${m[2]}`).join('\n') + '\n');
}
}
/** Listed commits whose own patch (`--verbatim` patch-id, then equal diff text) is a base commit since the merge-base; merges never match. Diffs are read as latin1 so every byte counts: UTF-8 decoding folds invalid bytes into one replacement character. Streamed: the diff text is compared by the sha256 of its lines without the commit line, blob ids and hunk line numbers. */
export function landedOnBase(o: EvaluateOptions, rest: string[], mbs: string[], notes: string[], stream: GitStreamOptions = {}): Map<string, string> {
const out = new Map<string, string>();
if (!rest.length) return out;
const base = git(o.repoRoot, ['rev-list', '--no-merges', o.baseRef!, ...mbs.map(b => `^${b}`)]);
if (!base.ok) { notes.push(`could not list the base since the merge-base (${base.err.slice(0, 120)}): commits whose patch landed are scanned`); return out; }
const baseCommits = base.out.split('\n').filter(Boolean);
if (!baseCommits.length) return out;
if (baseCommits.length > MAX_LANDED_BASE || rest.length > MAX_LANDED_LISTED) { notes.push(`${baseCommits.length} base and ${rest.length} listed commits since the merge-base, over ${MAX_LANDED_BASE} or ${MAX_LANDED_LISTED}: commits whose patch landed are scanned`); return out; }
const diffArgs = ['diff-tree', '-r', '--root', '-p', '--no-color', '--no-ext-diff', '--no-textconv'];
const ids = (list: string[]): string[][] => spoolGit(o.repoRoot, 'diff-tree -p', [...diffArgs, '--stdin'], { text: list.join('\n') + '\n' }, stream, diff => {
const pairs: string[][] = [];
gitLines(o.repoRoot, 'patch-id', ['patch-id', '--verbatim'], { file: diff }, 'latin1', l => { const f = l.split(' '); if (f.length === 2) pairs.push(f); }, stream);
return pairs;
});
const byId = new Map<string, string>();
for (const [id, c] of ids(baseCommits)) if (!byId.has(id!)) byId.set(id!, c!);
const body = (c: string): string | null => {
const h = createHash('sha256'); let first = true; let blank = true;
try {
gitLines(o.repoRoot, 'diff-tree -p', [...diffArgs, c], null, 'latin1', l => {
if (blank && l.trim()) blank = false;
if (HEX.test(l) || l.startsWith('index ')) return;
h.update((first ? '' : '\n') + l.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/, '@@'), 'latin1');
first = false;
}, stream);
} catch { return null; }
return blank ? null : h.digest('hex');
};
for (const [id, c] of ids(rest)) {
const b = byId.get(id!);
if (!b) continue;
const mine = body(c!);
if (mine !== null && mine === body(b)) out.set(c!, b);
}
return out;
}
/** The listed commits the guard need not rescan, and why (docs/pregate.md#sealed-skip); any failure skips nothing. */
function alreadyPublished(o: EvaluateOptions, listed: string[]): { skipped: SkippedCommit[]; notes: string[] } {
const notes: string[] = [];
if (!o.baseRef || o.skip === false || !listed.length) return { skipped: [], notes };
try {
const mbs: string[] = [];
for (const t of o.targets) {
const r = git(o.repoRoot, ['merge-base', o.baseRef, t.pushedSha]);
if (!r.ok || !r.out.trim()) { notes.push(`no merge-base between ${o.baseRef} and ${t.pushedSha.slice(0, 12)}: every listed commit is scanned`); return { skipped: [], notes }; }
mbs.push(r.out.trim());
}
const pulls = publishedViaPulls(o, new Set(listed), mbs, notes);
const landed = landedOnBase(o, listed.filter(c => !pulls.has(c)), mbs, notes);
return { skipped: [...[...pulls].map(([commit, pr]) => ({ commit, pr })), ...[...landed].map(([commit, b]) => ({ commit, landed: b }))], notes };
} catch (e: any) {
notes.push(`skipping failed (${String(e?.message ?? e).slice(0, 160)}): every listed commit is scanned`);
return { skipped: [], notes };
}
}
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
export function evaluateSealed(o: EvaluateOptions): SealedReport {
const blank = { ...sealedErrorReport('', o.target), error: undefined };
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, `commit ${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 listed = [...new Set(o.targets.flatMap(t => t.commits))];
const { skipped, notes } = alreadyPublished(o, listed);
const skip = new Set(skipped.map(k => k.commit));
const commits = listed.filter(c => !skip.has(c));
const held = new Set(skipped.filter(k => k.pr !== undefined).map(k => k.commit));
let found: SealedFinding[];
try { found = scanCommits(o.repoRoot, commits, policy, listed.filter(c => !held.has(c))); }
catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, skipped, notes, status: 'error', error: `could not read the published commits: ${e.message}` }; }
const exception = globMatcher(policy.allow_paths, false);
const exempt: ExemptFinding[] = [];
found = found.filter(f => {
const glob = f.rule === 'sealed.path' || f.rule === 'sealed.transcript' ? exception(f.file) : null;
if (glob) exempt.push({ ...f, glob });
return !glob;
});
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 && a.sha === f.commit);
if (entry) waived.push({ ...f, reason: entry.reason });
return !entry;
});
const applied = new Set(waived.map(w => `${w.rule}\t${w.file}\t${w.commit}`));
const ignored = new Map<string, SealedReport['ignoredWaivers'][number]>();
for (const f of findings) for (const a of allow) {
if (a.rule !== f.rule || a.path !== f.file || applied.has(`${a.rule}\t${a.path}\t${a.sha}`)) continue;
ignored.set(`${a.rule}\t${a.path}\t${a.sha}\t${f.commit}`, { rule: a.rule, path: a.path, sha: a.sha, commit: f.commit });
}
const ignoredWaivers = [...ignored.values()];
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, skipped, notes, findings, exempt, waived, ignoredWaivers, 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[] = [];
const byPr = new Map<number, number>();
for (const k of r.skipped) if (k.pr !== undefined) byPr.set(k.pr, (byPr.get(k.pr) ?? 0) + 1);
for (const [pr, n] of byPr) lines.push(`${context}: skipped ${n} commit(s) the destination already has as refs/pull/${pr}/head`);
for (const k of r.skipped) if (k.landed) lines.push(`${context}: skipped commit ${k.commit.slice(0, 12)} (its patch landed on the base as ${k.landed.slice(0, 12)})`);
for (const n of r.notes) lines.push(`${context}: ${n}`);
for (const e of r.exempt) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`);
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})`);
for (const i of r.ignoredWaivers) {
const why = i.commit.startsWith(i.sha.toLowerCase()) ? `${SEALED_ALLOW_PATH} must name the full lowercase commit sha` : 'it names another commit';
lines.push(`${context}: ignored waiver ${i.rule} ${i.path} sha ${i.sha} (${why}; this finding is in commit ${i.commit}, review it before waiving)`);
}
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)}${f.keys ? ` keys=${f.keys.join(',')}` : ''}`);
const join = r.findings.some(f => f.rule.startsWith('sealed.label_join')) ? [`sealed.label_join: a record puts a sealed.label_keys field next to an identifier (keys= lists the label fields, then the identifier fields); drop one side from the published file or keep the file out of the public repository. sealed.label_join_unchecked: a supported file the guard could not read (over 4 MiB, unparseable, or nested deeper than 64); allow_paths never exempt either rule`] : [];
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, ...join);
const exemptable = r.findings.some(f => !f.rule.startsWith('sealed.label_join')) ? ` exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH},` : '';
lines.push(`Remove it from every published commit before this branch reaches a public repository,${exemptable} or waive one reviewed finding with a \`rule<TAB>path<TAB>full-commit-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, ...join);
}
return lines.length ? lines.join('\n') + '\n' : '';
}