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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-31 12:48:44 +00:00
1 parent a305302963
commit 0a68d03217
8 files changed
+325 -15

No files matched your search

+7 -6
View File
@@ -9,7 +9,7 @@
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --bundle-lane <label> | --receipt <file> [--out <pregate.json>]
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
* gstack-pregate validate | init [--explain] [--force] the working-tree .gstack/pregate.json
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--json]
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip] [--json]
* gstack-pregate regate --against <gated-head> --bundle <evidence-bundle.json> [--require <lanes>] [--pr <n>]
* [--from <gate-ahead.json>] [--predecessor-pr <n> | --predecessor-ref <ref>] [--base <branch>] [--json]
* does the gated head's evidence still cover HEAD (lib/pregate/regate.ts)
@@ -55,8 +55,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
test_backend/<backend> run URL is recorded as attested, not validated
lanes print every CI lane with its platform tier (from .github/workflows/*.yml)
validate | init the working-tree ${PREGATE_CONFIG_PATH}
sealed [--range <a..b>] [--remote <name>] [--base <branch>]
the sealed-output guard (docs/pregate.md#sealed) over the commits <remote> lacks, or the range:
sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--no-skip]
the sealed-output guard (docs/pregate.md#sealed) over the commits <remote> lacks, or the range,
minus commits <remote> already holds as a squashed PR's refs/pull/<n>/head or whose patch landed (--no-skip scans all):
run it before any push or PR creation in a repo with a \`sealed\` config; exit 1 refuses (SEALED_OUTPUT_REFUSED);
it never pushes: policy=<base> + commit <sha> names where the policy was read
regate --against <gated-head> --bundle <file> [--require <lanes>] [--pr <n>] [--predecessor-pr <n> | --predecessor-ref <ref>]
@@ -107,7 +108,7 @@ const VERB_FLAGS: Record<string, [string[], string[]]> = {
lanes: [[], ['--explain', '--json']],
validate: [[], ['--json']],
init: [[], ['--explain', '--force']],
sealed: [['--range', '--base', '--remote'], ['--json']],
sealed: [['--range', '--base', '--remote'], ['--json', '--no-skip']],
regate: [['--against', '--bundle', '--require', '--pr', '--from', '--predecessor', '--predecessor-pr', '--predecessor-ref', '--base', '--current'], ['--json']],
};
const HINTS: Record<string, string> = { '--sealed': 'the sealed-output guard is the verb `gstack-pregate sealed`' };
@@ -249,12 +250,12 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
const report = 'error' in listed
? sealedErrorReport(listed.error, remoteUrl ?? remote)
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote });
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote, remoteName: remoteUrl ? remote : null, skip: !rest.includes('--no-skip') });
const blocks = report.status === 'refused' || report.status === 'error';
if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; }
process.stderr.write(renderSealed(report, 'gstack-pregate sealed'));
const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`;
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`);
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} skipped=${report.skipped.length} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`);
return blocks ? EXIT.fail : EXIT.ok;
}
+1 -1
View File
@@ -611,7 +611,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se
if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target");
targets.push({ commits: published.commits, pushedSha: ref.localSha });
}
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target", remoteName });
}
function main() {
+8
View File
@@ -0,0 +1,8 @@
**The sealed guard no longer rescans commits the destination already holds, so a stacked branch retargeted after its parent squash-landed stops being refused for the parent's history.** gstack-evals#22 listed its parent #6's seven commits after #6 landed as one squash. One of them had added and later deleted a held-out file, so the guard refused a push whose every byte GitHub already had as `refs/pull/6/head`, and the queue sat blocked for about 50 minutes until a waiver landed.
### Changed
- **`lib/sealed-guard.ts`** (`evaluateSealed`, shared by the pre-push hook and `gstack-pregate sealed`): a listed commit is skipped when it is an ancestor of the destination's own `refs/pull/<n>/head` for a squash `(#n)` on the base since the merge-base (at most 50 refs, a 20-second budget, an egress receipt per remote call, fetched refs deleted afterwards), or when its own `git patch-id --verbatim` and diff equal a base commit's (off above 2,000 base or 400 listed commits). Nothing is matched against a combined diff, and any failed lookup scans everything with one line saying why. docs/pregate.md#sealed-skip.
- **`gstack-pregate sealed`**: the `SEALED:` line gains `skipped=<n>` (and `commits=` counts the commits scanned), stderr names each skipped group, and `--no-skip` scans every listed commit.
### Tests
- `test/sealed-skip.test.ts`: the #22 shape is clear with seven commits skipped as `refs/pull/6/head`, the hook and the preflight agree, `--no-skip` refuses, no fetched ref is left behind and the egress ledger records the lookup. The same parent never published refuses although its squash equals the combined diff. An unreachable remote scans everything and says so. A cherry-pick that landed on the base is skipped and named. A whitespace near-miss (`/home/alice/` against `/home/ alice/`) is scanned and refuses.
+19 -1
View File
@@ -818,13 +818,31 @@ publication whenever the managed pre-push hook (`gstack-redact
install-prepush-hook`, no reinstall needed) or the standalone preflight runs:
```
gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--json]
gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--no-skip] [--json]
```
- **Boundary.** Every commit the push publishes: `git rev-list <local sha>
--not --remotes=<remote>` (and the remote tip when the ref exists); a
destination with no refs means everything reachable. `--range` names the
commits explicitly.
- <a id="sealed-skip"></a>**Skipped commits.** When the base branch resolves, a
listed commit is not rescanned when the destination already holds it or its
patch already landed. Held: an ancestor of the destination's own
`refs/pull/<n>/head` for a squash commit `(#n)` on the base since the
merge-base; the subject is only a hint, the fetched ref is the proof. That
lookup lists and fetches at most 50 refs within 20 seconds (receipted, sink
`sealed-guard`) and deletes the fetched refs afterwards. Landed: the commit's
own `git patch-id --verbatim` equals a base commit's since the merge-base and
so does its diff, apart from blob ids and line numbers; a merge never matches,
and above 2,000 base or 400 listed commits this check is off. Nothing
matches a combined diff, so a parent whose commits were never published is
scanned whole even when its squash landed. A lookup that fails, runs out of
time or exceeds a limit prints one line and scans everything. Each PR group
and landed commit is listed (`skipped <n> commit(s) the destination already
has as refs/pull/<n>/head`, `skipped commit <sha> (its patch landed on the
base as <sha>)`), the `SEALED:` line counts them as `skipped=<n>` (its
`commits=` counts the commits scanned), and `--no-skip` scans every listed
commit. The pre-push hook and `gstack-pregate sealed` skip the same way.
- **Rules.** `sealed.path` and `sealed.transcript` check every path a commit
adds, renames or copies, whatever its content (binary, empty, deleted again
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
+126 -5
View File
@@ -27,10 +27,19 @@
* (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). 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 { resolveStateRoot } from './state-root';
@@ -227,20 +236,124 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
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; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>; ignoredWaivers: Array<{ rule: string; path: string; sha: string; commit: string }>;
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, findings: [], exempt: [], waived: [], ignoredWaivers: [], visibility: 'unknown', visibilitySource: '', target };
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. */
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');
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);
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]); 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. */
@@ -257,10 +370,13 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
policy = unionPolicies(policy, pushed.policy);
}
if (!policy) return { ...blank, status: 'no-policy' };
const commits = [...new Set(o.targets.flatMap(t => t.commits))];
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));
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}` }; }
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 => {
@@ -284,7 +400,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
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, findings, exempt, waived, ignoredWaivers, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
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}` : ''}`;
@@ -292,6 +408,11 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${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) {
+4
View File
@@ -102,6 +102,10 @@ const MODULE_SINKS = [
// vendor CLI. hosts/ has no curl/fetch for the scanner to see, so the
// receipt wiring is pinned here explicitly.
'hosts/claude/hooks/memorable-user-prompt-hook.ts',
// GSTA-156 P1: the sealed guard lists and fetches the destination's own
// refs/pull/<n>/head for squashes on its base, to skip commits it already
// holds; PR numbers leave the machine, sink 'sealed-guard' (fail-open).
'lib/sealed-guard.ts',
];
/** Shell sinks: must source the shared lib; every network op receipted. */
+2 -2
View File
@@ -215,7 +215,7 @@ describe('gstack-pregate sealed (the standalone preflight before any push or PR
const ranged = run(['--range', 'origin/main..HEAD', '--base', 'main']);
expect(ranged.status).toBe(1);
expect(ranged.stderr).toMatch(/sealed\.transcript\s+runs\/c1\/transcript\.jsonl/);
expect(ranged.stdout).toMatch(/^SEALED: refused findings=1 commits=1 visibility=unknown \(counted public\)/m);
expect(ranged.stdout).toMatch(/^SEALED: refused findings=1 commits=1 skipped=0 visibility=unknown \(counted public\)/m);
const dflt = run(['--base', 'main']);
expect(dflt.status).toBe(1);
sh(s.work, s.env, ['push', '-q', 'origin', 'results']);
@@ -252,7 +252,7 @@ describe('sealed.allow_paths: base-branch exceptions applied after the deny glob
expect(hook(only).code).toBe(0);
const pre = preflight(only);
expect(pre.code).toBe(0);
expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m);
expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 skipped=0 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m);
const json = JSON.parse(preflight(only, ['--json']).out);
expect(json.exempt).toHaveLength(1);
expect(json.exempt[0]).toMatchObject({ rule: 'sealed.path', file: 'sealed/s1/MANIFEST.json', glob: '**/sealed/*/MANIFEST.json' });
+158
View File
@@ -0,0 +1,158 @@
/**
* GSTA-156 P1: the sealed guard rescans only what the destination does not
* already hold. gstack-evals#22 was retargeted onto main after its parent #6
* squash-landed; its range still listed #6's seven commits (one added and
* later deleted a held-out file), so the guard refused a push whose every
* byte the destination already had as refs/pull/6/head. A commit is skipped
* only when it is an ancestor of the destination's own refs/pull/<n>/head for
* a squash (#n) on the base since the merge-base, or when its own
* --verbatim patch-id and diff equal a base commit's. No combined-diff
* matching; any lookup failure scans everything.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
const ROOT = path.resolve(import.meta.dir, '..');
const PREPUSH = path.join(ROOT, 'bin', 'gstack-redact-prepush');
const PREGATE = path.join(ROOT, 'bin', 'gstack-pregate');
const ZERO = '0'.repeat(40);
const POLICY = JSON.stringify({ sealed: { paths: ['heldout/**'], answer_markers: ['SEALED ANSWER:'], local_paths: true } }, null, 2) + '\n';
interface Scratch { root: string; work: string; origin: string; state: string; env: Record<string, string> }
const scratches: Scratch[] = [];
afterEach(() => { while (scratches.length) fs.rmSync(scratches.pop()!.root, { recursive: true, force: true }); });
function sh(s: Scratch, args: string[]): string {
const r = spawnSync('git', args, { cwd: s.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...s.env } });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return (r.stdout ?? '').trim();
}
function scratch(): Scratch {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-skip-')));
const work = path.join(root, 'work'); const origin = path.join(root, 'origin.git'); const state = path.join(root, 'state');
fs.mkdirSync(work); fs.mkdirSync(state);
const env = { HOME: root, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@example.com\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
const s = { root, work, origin, state, env };
scratches.push(s);
spawnSync('git', ['init', '-q', '--bare', origin], { env: { ...process.env, ...env }, timeout: 30_000 });
sh(s, ['init', '-q', '-b', 'main']);
commit(s, { 'README.md': '# evals\n', '.gstack/pregate.json': POLICY }, 'base');
sh(s, ['remote', 'add', 'origin', origin]);
sh(s, ['push', '-q', 'origin', 'main']);
sh(s, ['remote', 'set-head', 'origin', 'main']);
return s;
}
function commit(s: Scratch, files: Record<string, string | null>, msg: string): string {
for (const [rel, body] of Object.entries(files)) {
const abs = path.join(s.work, rel);
if (body === null) { fs.rmSync(abs, { force: true }); continue; }
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
}
sh(s, ['add', '-A']); sh(s, ['commit', '-q', '--allow-empty', '-m', msg]);
return sh(s, ['rev-parse', 'HEAD']);
}
/** The #22 shape: parent #6 has seven commits (the second adds heldout/answers.json, the fifth deletes it); main lands it as one squash "(#6)"; results stacks one commit on the parent. */
function retargeted(s: Scratch, opts: { publishPull: boolean }): void {
sh(s, ['checkout', '-q', '-b', 'parent']);
commit(s, { 'cells/a.md': 'a\n' }, 'cell a');
commit(s, { 'heldout/answers.json': '{"q1":"b"}\n' }, 'stage answers');
commit(s, { 'cells/b.md': 'b\n' }, 'cell b');
commit(s, { 'cells/c.md': 'c\n' }, 'cell c');
commit(s, { 'heldout/answers.json': null }, 'drop answers');
commit(s, { 'cells/d.md': 'd\n' }, 'cell d');
commit(s, { 'cells/e.md': 'e\n' }, 'cell e');
if (opts.publishPull) sh(s, ['push', '-q', 'origin', 'parent:refs/pull/6/head']);
sh(s, ['checkout', '-q', 'main']);
sh(s, ['merge', '-q', '--squash', 'parent']);
sh(s, ['commit', '-q', '-m', 'feat: cells a-e (#6)']);
sh(s, ['push', '-q', 'origin', 'main']);
sh(s, ['checkout', '-q', '-b', 'results', 'parent']);
commit(s, { 'cells/f.md': 'f\n' }, 'cell f');
}
function hook(s: Scratch, url = s.origin) {
const head = sh(s, ['rev-parse', 'HEAD']);
const r = spawnSync('bun', [PREPUSH, 'origin', url], { cwd: s.work, input: `refs/heads/results ${head} refs/heads/results ${ZERO}\n`, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
return { code: r.status, err: r.stderr ?? '' };
}
function preflight(s: Scratch, args: string[] = []) {
const r = spawnSync('bun', [PREGATE, 'sealed', '--base', 'main', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
}
describe('sealed skips what the destination already holds', () => {
test('the #22 squash-retarget: seven commits published as refs/pull/6/head are skipped, the push is clear, the hook and the preflight agree, and --no-skip still refuses', () => {
const s = scratch();
retargeted(s, { publishPull: true });
const h = hook(s);
expect(h.err).toContain('skipped 7 commit(s) the destination already has as refs/pull/6/head');
expect(h.code).toBe(0);
const p = preflight(s);
expect(p.code).toBe(0);
expect(p.out).toMatch(/^SEALED: clear findings=0 commits=1 skipped=7 /m);
const full = preflight(s, ['--no-skip']);
expect(full.code).toBe(1);
expect(full.out).toMatch(/^SEALED: refused findings=1 commits=8 skipped=0 /m);
expect(full.err).toMatch(/sealed\.path\s+heldout\/answers\.json/);
expect(sh(s, ['for-each-ref', 'refs/gstack/'])).toBe('');
const ledger = fs.readFileSync(path.join(s.state, 'security', 'egress.jsonl'), 'utf8');
expect(ledger).toContain('"sink":"sealed-guard"');
});
test('an add-then-delete parent whose combined diff equals the squash but was never published still refuses', () => {
const s = scratch();
retargeted(s, { publishPull: false });
const h = hook(s);
expect(h.code).toBe(1);
expect(h.err).toMatch(/sealed\.path\s+heldout\/answers\.json/);
const p = preflight(s);
expect(p.code).toBe(1);
expect(p.out).toMatch(/^SEALED: refused findings=1 commits=8 skipped=0 /m);
});
test('a ref lookup failure scans everything and says so', () => {
const s = scratch();
retargeted(s, { publishPull: true });
sh(s, ['remote', 'set-url', 'origin', path.join(s.root, 'gone.git')]);
const h = hook(s, path.join(s.root, 'gone.git'));
expect(h.code).toBe(1);
expect(h.err).toContain('could not list refs/pull/<n>/head on origin');
expect(h.err).toMatch(/sealed\.path\s+heldout\/answers\.json/);
});
test('a cherry-picked commit whose patch landed on the base is skipped, naming the base commit', () => {
const s = scratch();
sh(s, ['checkout', '-q', '-b', 'results']);
const mine = commit(s, { 'notes.md': 'intro\nraw at /home/alice/evals/raw.jsonl\n' }, 'notes');
sh(s, ['checkout', '-q', 'main']);
commit(s, { 'other.md': 'x\n' }, 'unrelated');
sh(s, ['cherry-pick', mine]);
sh(s, ['push', '-q', 'origin', 'main']);
const landed = sh(s, ['rev-parse', 'HEAD']);
expect(landed).not.toBe(mine);
sh(s, ['checkout', '-q', 'results']);
const h = hook(s);
expect(h.code).toBe(0);
expect(h.err).toContain(`skipped commit ${mine.slice(0, 12)} (its patch landed on the base as ${landed.slice(0, 12)})`);
expect(preflight(s).out).toMatch(/^SEALED: clear findings=0 commits=0 skipped=1 /m);
});
test('a whitespace near-miss of a landed commit is scanned and refuses', () => {
const s = scratch();
sh(s, ['checkout', '-q', '-b', 'results']);
commit(s, { 'notes.md': 'intro\nraw at /home/alice/evals/raw.jsonl\n' }, 'notes');
sh(s, ['checkout', '-q', 'main']);
commit(s, { 'notes.md': 'intro\nraw at /home/ alice/evals/raw.jsonl\n' }, 'notes');
sh(s, ['push', '-q', 'origin', 'main']);
sh(s, ['checkout', '-q', 'results']);
const h = hook(s);
expect(h.code).toBe(1);
expect(h.err).toMatch(/sealed\.local_path\s+notes\.md:2/);
expect(preflight(s).out).toMatch(/^SEALED: refused findings=1 commits=1 skipped=0 /m);
});
});