mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-02 23:14:33 +01:00
v1.92.83.0 feat(sealed): skip commits the destination already holds or whose patch landed (GSTA-156 P1) (#3689)
* 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>
This commit is contained in:
1 parent
26539fb0d9
commit
1a7f2578b7
12 files changed
+395
-38
No files matched your search
@@ -1,5 +1,16 @@
|
||||
# Changelog
|
||||
|
||||
## [1.92.83.0] - 2026-11-02
|
||||
|
||||
**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; a landed commit still gets the `sealed.label_join` check, which reads the whole file). 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, and so is a landed commit that differs only in bytes that aren't valid UTF-8.
|
||||
|
||||
## [1.92.82.0] - 2026-11-02
|
||||
|
||||
**`gstack-review-dispatch verify` now catches a merged findings file edited after collect.** Verify used to compare only output hashes and line counts. So a `merged.jsonl` row whose label was lowered, or whose text was replaced with another line's summary, passed with `VERIFY: clean` once the judge agreed with the edited row. Verify now re-parses each reader's unchanged output and checks every merged row against the finding at its index, label and raw text byte for byte. A mismatch prints `merged_line_differs` on the OUTPUT and JUDGE rows, and verify is dirty. A missing output file or an output event with no file recorded (even for a reply with no findings), a judgment run on a copy of the merged file, merged rows that collect never wrote, and an output whose finding count no longer matches its recorded ids are dirty too. A reply refused for naming the wrong checkout is still allowed to hold findings collect never merged, but only when verify re-checks its tree line and finds it wrong.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# gstack digest v1.92.82.0 — regenerate/re-copy after upgrading gstack
|
||||
# gstack digest v1.92.83.0 — regenerate/re-copy after upgrading gstack
|
||||
|
||||
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
|
||||
for agent hosts without a full skill install. The full skills add workflows,
|
||||
|
||||
+7
-6
@@ -9,7 +9,7 @@
|
||||
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --run <id|url> [--run …] [--allow-repo-commands] | --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)
|
||||
@@ -60,8 +60,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
|
||||
profile, --run-url is verified the same way
|
||||
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>]
|
||||
@@ -115,7 +116,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`' };
|
||||
@@ -270,13 +271,13 @@ 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 unchecked = report.findings.filter(f => f.rule === 'sealed.label_join_unchecked').length;
|
||||
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}${unchecked ? ` unchecked=${unchecked}` : ''} 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}${unchecked ? ` unchecked=${unchecked}` : ''} policy=${policy}\n`);
|
||||
return blocks ? EXIT.fail : EXIT.ok;
|
||||
}
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
+21
-1
@@ -1114,13 +1114,33 @@ 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. A landed commit
|
||||
skips only the path and text rules: `sealed.label_join` judges the whole file
|
||||
at that commit, which the base may never have held, so it still runs. 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`
|
||||
|
||||
+136
-11
@@ -29,10 +29,21 @@
|
||||
* (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';
|
||||
@@ -77,8 +88,8 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin
|
||||
return errors;
|
||||
}
|
||||
|
||||
function git(repoRoot: string, args: string[], input?: string): { ok: boolean; out: string; err: string } {
|
||||
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', input, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
|
||||
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() };
|
||||
}
|
||||
|
||||
@@ -191,10 +202,11 @@ function textFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea
|
||||
return out;
|
||||
}
|
||||
|
||||
export function scanCommits(repoRoot: string, commits: string[], policy: SealedPolicy): SealedFinding[] {
|
||||
if (!commits.length) return [];
|
||||
export function scanCommits(repoRoot: string, commits: string[], policy: SealedPolicy, joinCommits: string[] = commits): SealedFinding[] {
|
||||
if (!commits.length && !joinCommits.length) return [];
|
||||
const seen = new Set<string>();
|
||||
return [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy), ...joinFindings(repoRoot, commits, policy)].filter(f => {
|
||||
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);
|
||||
@@ -231,20 +243,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. 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. */
|
||||
@@ -261,10 +377,14 @@ 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));
|
||||
const held = new Set(skipped.filter(k => k.pr !== undefined).map(k => k.commit));
|
||||
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}` }; }
|
||||
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 => {
|
||||
@@ -288,7 +408,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}` : ''}`;
|
||||
@@ -296,6 +416,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) {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "gstack",
|
||||
"version": "1.92.82",
|
||||
"version": "1.92.83",
|
||||
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -107,6 +107,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. */
|
||||
|
||||
+14
-14
@@ -1,64 +1,64 @@
|
||||
{
|
||||
"refused": {
|
||||
"code": 1,
|
||||
"stdout": "SEALED: refused findings=2 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit <sha>\n",
|
||||
"stdout": "SEALED: refused findings=2 commits=1 skipped=0 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit <sha>\n",
|
||||
"stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of <root>/origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit <sha>\n sealed.answer runs/c1/summary.md:1 commit <sha>\nRemove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's .gstack/pregate.json, or waive one reviewed finding with a `rule<TAB>path<TAB>full-commit-sha<TAB>reason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n",
|
||||
"jsonCode": 1,
|
||||
"json": "{\"status\":\"refused\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\"},{\"rule\":\"sealed.answer\",\"file\":\"runs/c1/summary.md\",\"line\":1,\"commit\":\"<sha>\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":false}\n",
|
||||
"json": "{\"status\":\"refused\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\"},{\"rule\":\"sealed.answer\",\"file\":\"runs/c1/summary.md\",\"line\":1,\"commit\":\"<sha>\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":false}\n",
|
||||
"hookCode": 1,
|
||||
"hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of <root>/origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit <sha>\n sealed.answer runs/c1/summary.md:1 commit <sha>\nRemove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's .gstack/pregate.json, or waive one reviewed finding with a `rule<TAB>path<TAB>full-commit-sha<TAB>reason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n"
|
||||
},
|
||||
"clear": {
|
||||
"code": 0,
|
||||
"stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit <sha>\n",
|
||||
"stdout": "SEALED: clear findings=0 commits=1 skipped=0 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit <sha>\n",
|
||||
"stderr": "",
|
||||
"jsonCode": 0,
|
||||
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"hookCode": 0,
|
||||
"hookStderr": ""
|
||||
},
|
||||
"exempt": {
|
||||
"code": 0,
|
||||
"stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=1 policy=origin/main + commit <sha>\n",
|
||||
"stdout": "SEALED: clear findings=0 commits=1 skipped=0 visibility=unknown (counted public) waived=0 exempt=1 policy=origin/main + commit <sha>\n",
|
||||
"stderr": "gstack-pregate sealed: exempt sealed.path sealed/s1/MANIFEST.json commit <sha> (sealed.allow_paths **/sealed/*/MANIFEST.json)\n",
|
||||
"jsonCode": 0,
|
||||
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[],\"exempt\":[{\"rule\":\"sealed.path\",\"file\":\"sealed/s1/MANIFEST.json\",\"commit\":\"<sha>\",\"glob\":\"**/sealed/*/MANIFEST.json\"}],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[{\"rule\":\"sealed.path\",\"file\":\"sealed/s1/MANIFEST.json\",\"commit\":\"<sha>\",\"glob\":\"**/sealed/*/MANIFEST.json\"}],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"hookCode": 0,
|
||||
"hookStderr": "gstack sealed-output guard: exempt sealed.path sealed/s1/MANIFEST.json commit <sha> (sealed.allow_paths **/sealed/*/MANIFEST.json)\n"
|
||||
},
|
||||
"waived": {
|
||||
"code": 0,
|
||||
"stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=1 exempt=0 policy=origin/main + commit <sha>\n",
|
||||
"stdout": "SEALED: clear findings=0 commits=1 skipped=0 visibility=unknown (counted public) waived=1 exempt=0 policy=origin/main + commit <sha>\n",
|
||||
"stderr": "gstack-pregate sealed: waived sealed.transcript runs/c1/transcript.jsonl commit <sha> (.gstack/sealed-allow.tsv: synthetic fixture)\n",
|
||||
"jsonCode": 0,
|
||||
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\",\"reason\":\"synthetic fixture\"}],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[],\"waived\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\",\"reason\":\"synthetic fixture\"}],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"hookCode": 0,
|
||||
"hookStderr": "gstack sealed-output guard: waived sealed.transcript runs/c1/transcript.jsonl commit <sha> (.gstack/sealed-allow.tsv: synthetic fixture)\n"
|
||||
},
|
||||
"warned": {
|
||||
"code": 0,
|
||||
"stdout": "SEALED: warned findings=1 commits=1 visibility=private waived=0 exempt=0 policy=origin/main + commit <sha>\n",
|
||||
"stdout": "SEALED: warned findings=1 commits=1 skipped=0 visibility=private waived=0 exempt=0 policy=origin/main + commit <sha>\n",
|
||||
"stderr": "gstack-pregate sealed: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit <sha>\n",
|
||||
"jsonCode": 0,
|
||||
"json": "{\"status\":\"warned\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"private\",\"visibilitySource\":\"sealed.visibility override\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"json": "{\"status\":\"warned\",\"policyLabels\":[\"origin/main\",\"commit <sha>\"],\"commits\":1,\"skipped\":[],\"notes\":[],\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"<sha>\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"private\",\"visibilitySource\":\"sealed.visibility override\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"hookCode": 0,
|
||||
"hookStderr": "gstack sealed-output guard: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit <sha>\n"
|
||||
},
|
||||
"error": {
|
||||
"code": 1,
|
||||
"stdout": "SEALED: error findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n",
|
||||
"stdout": "SEALED: error findings=0 commits=0 skipped=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n",
|
||||
"stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of <root>/origin.git — the sealed policy in commit <sha> is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n",
|
||||
"jsonCode": 1,
|
||||
"json": "{\"status\":\"error\",\"error\":\"the sealed policy in commit <sha> is unreadable: sealed.answer_markers[0]: \\\"(\\\" is not a valid regex (Invalid regular expression: missing ))\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"<root>/origin.git\",\"ok\":false}\n",
|
||||
"json": "{\"status\":\"error\",\"error\":\"the sealed policy in commit <sha> is unreadable: sealed.answer_markers[0]: \\\"(\\\" is not a valid regex (Invalid regular expression: missing ))\",\"policyLabels\":[],\"commits\":0,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"<root>/origin.git\",\"ok\":false}\n",
|
||||
"hookCode": 1,
|
||||
"hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of <root>/origin.git — the sealed policy in commit <sha> is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n"
|
||||
},
|
||||
"no-policy": {
|
||||
"code": 0,
|
||||
"stdout": "SEALED: no-policy findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n",
|
||||
"stdout": "SEALED: no-policy findings=0 commits=0 skipped=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n",
|
||||
"stderr": "",
|
||||
"jsonCode": 0,
|
||||
"json": "{\"status\":\"no-policy\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"json": "{\"status\":\"no-policy\",\"policyLabels\":[],\"commits\":0,\"skipped\":[],\"notes\":[],\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"<root>/origin.git\",\"ok\":true}\n",
|
||||
"hookCode": 0,
|
||||
"hookStderr": ""
|
||||
}
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* 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);
|
||||
});
|
||||
|
||||
test('a landed commit still gets the label_join check, because the join can form only on the branch', () => {
|
||||
const s = scratch();
|
||||
const rec = (extra: Record<number, string>) => ['{', ' "id": "r1",', ' "k1": 1,', ' "k2": 2,', ' "k3": 3,', ' "k4": 4,', ' "k5": 5,', ' "k6": 6,', ' "k7": 7', '}'].flatMap((l, i) => extra[i] ? [l, extra[i]!] : [l]).join('\n') + '\n';
|
||||
const labeled = JSON.stringify({ sealed: { paths: ['heldout/**'], label_keys: ['stratum'] } }, null, 2) + '\n';
|
||||
commit(s, { '.gstack/pregate.json': labeled, 'rec.json': rec({}) }, 'labeled policy');
|
||||
sh(s, ['push', '-q', 'origin', 'main']);
|
||||
sh(s, ['checkout', '-q', '-b', 'results']);
|
||||
commit(s, { 'rec.json': rec({ 1: ' "stratum": "decoy",' }) }, 'stratum');
|
||||
const head = commit(s, { 'rec.json': rec({ 1: ' "stratum": "decoy",', 7: ' "head": "abc1234def5678",' }) }, 'head');
|
||||
sh(s, ['checkout', '-q', 'main']);
|
||||
sh(s, ['cherry-pick', head]);
|
||||
sh(s, ['push', '-q', 'origin', 'main']);
|
||||
expect(sh(s, ['show', 'HEAD:rec.json'])).not.toContain('stratum');
|
||||
sh(s, ['checkout', '-q', 'results']);
|
||||
const h = hook(s);
|
||||
expect(h.code).toBe(1);
|
||||
expect(h.err).toMatch(/sealed\.label_join\s+rec\.json/);
|
||||
const p = preflight(s);
|
||||
expect(p.code).toBe(1);
|
||||
expect(p.out).toMatch(/^SEALED: refused findings=1 commits=1 skipped=1 /m);
|
||||
});
|
||||
|
||||
test('a landed commit that differs only in bytes that are not UTF-8 is scanned and refuses', () => {
|
||||
const s = scratch();
|
||||
sh(s, ['checkout', '-q', '-b', 'results']);
|
||||
fs.writeFileSync(path.join(s.work, 'notes.md'), Buffer.from('intro\nraw at /home/alice/evals/raw\xe8.jsonl\n', 'latin1'));
|
||||
sh(s, ['add', 'notes.md']); sh(s, ['commit', '-q', '-m', 'notes']);
|
||||
sh(s, ['checkout', '-q', 'main']);
|
||||
fs.writeFileSync(path.join(s.work, 'notes.md'), Buffer.from('intro\nraw at /home/alice/evals/raw\xe9.jsonl\n', 'latin1'));
|
||||
sh(s, ['add', 'notes.md']); sh(s, ['commit', '-q', '-m', '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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user