mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-03 07:18:03 +01:00
* feat(sealed): skip commits the destination already holds (refs/pull/<n>/head) or whose patch landed (GSTA-156 P1) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(sealed): compare landed patches byte for byte (latin1), so bytes that are not UTF-8 cannot collapse into a false match Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(sealed): a landed-patch skip still runs sealed.label_join, which judges the whole file the base may never have held 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>
445 lines
33 KiB
TypeScript
445 lines
33 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
|
||
* 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 * as fs from 'node:fs';
|
||
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;
|
||
}
|
||
|
||
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: 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, 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). */
|
||
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, 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');
|
||
}
|
||
}
|
||
|
||
/** Diff text compared after a patch-id match: no commit line, blob ids or hunk line numbers. */
|
||
const patchBody = (diff: string) => diff.split('\n').filter(l => !HEX.test(l) && !l.startsWith('index ')).map(l => l.replace(/^@@ -\d+(?:,\d+)? \+\d+(?:,\d+)? @@/, '@@')).join('\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. */
|
||
function landedOnBase(o: EvaluateOptions, rest: string[], mbs: string[], notes: string[]): 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[][] => {
|
||
const d = git(o.repoRoot, [...diffArgs, '--stdin'], list.join('\n') + '\n', 'latin1');
|
||
if (!d.ok) throw new Error(`git diff-tree -p failed: ${d.err.slice(0, 200)}`);
|
||
const p = git(o.repoRoot, ['patch-id', '--verbatim'], d.out, 'latin1');
|
||
if (!p.ok) throw new Error(`git patch-id failed: ${p.err.slice(0, 200)}`);
|
||
return p.out.split('\n').map(l => l.split(' ')).filter(f => f.length === 2);
|
||
};
|
||
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) => { const r = git(o.repoRoot, [...diffArgs, c], undefined, 'latin1'); return r.ok && r.out.trim() ? patchBody(r.out) : null; };
|
||
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' : '';
|
||
}
|