Merge origin/main 5385842 into gsta154-train-stamps; stamp v1.91.339.0 (gstack-restamp --next --pr 3595, train gate.train-9678d27cec77-53858424d03a-a0cea8 slot 1)

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-30 19:08:05 +00:00
commit c2b4f0d292
16 files changed
+193 -30

No files matched your search

+17 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## [Unreleased]
## [1.91.339.0] - 2026-10-30
**Merge trains can stamp every slot themselves under `changelog_fragments`: `train build` builds a stamped prefix for each slot and `train land` publishes it, so no lane stamps a PR by hand.** gstack's own policy still leaves the key off (docs/ship-policy.md#changelog-fragments).
@@ -18,6 +18,22 @@
- `test/train-backstop.test.ts`: main equal to S1 is covered; the same tree with another VERSION is gated; a chain mixing stamped and plain prefixes is refused.
- `test/version-gate-fragments.test.ts`: a stacked child's stamp counts only its own fragment once its parent has landed.
## [1.91.338.0] - 2026-10-30
**A sealed-guard waiver now counts only when it names the full commit sha.** A `.gstack/sealed-allow.tsv` line used to accept a sha as short as 7 hex characters and waive any commit whose sha started with it. A PR author could generate a commit with the same first 7 characters in minutes and publish different content at the same path under an old, reviewed waiver. A waiver now matches only the finding's full commit sha (40 hex characters, 64 in a SHA-256 repository). A shortened, uppercase or mistyped sha is ignored, so the finding still refuses, and stderr says which line was ignored and which commit the finding is in, to review before anyone waives it.
### Changed
- `lib/sealed-guard.ts`: waivers compare the whole commit sha; `gstack-pregate sealed --json` adds `ignoredWaivers` (rule, path, the sha as written, the finding's commit) for waiver lines that name a finding's rule and path but not its commit, once per line and commit. Lines that match no finding, and waivers that applied to another commit in the push, stay silent.
- The refusal hint, the `SEALED_OUTPUT_REFUSED` fix text, docs/pregate.md and docs/troubleshooting.md say the sha must be the full commit sha.
### Tests
- `test/sealed-guard.test.ts`: 7-, 12- and 39-character prefixes, an uppercase sha and one extra character do not waive and are named; the full sha waives, trailing whitespace included; another commit's full sha does not waive; a short line with no matching finding prints nothing; a waiver applied to one commit is not reported for a later one; a text rule with two matching lines names a short waiver once; a SHA-256 repository accepts 64 characters and rejects 40, 63 and 65; error reports carry an empty list. The two existing waiver tests now write the full sha.
## [1.91.337.0] - 2026-10-30
### Changed
- **Plan first has two narrow fix exemptions.** An S1 emergency (a broken install or upgrade, a red main, a security bug) and a fix from a problem list that was already diagnosed and audited can be built without an `/autoplan` run. Every other fix, a one-line bug fix included, gets a one-round, eng-only plan. `docs/captain.md` § Plan before build says how, and `captain/SKILL.md`, `captain/templates/CREW.md` rule 24 and the Capy skill's SKILL.md list the same exemptions.
## [1.91.336.0] - 2026-10-30
**A repository can now assign its version when a PR merges instead of inside the PR: PRs carry unversioned CHANGELOG fragments and `gstack-ship-queue land` stamps, pushes and merges.** Today every PR picks a version before it merges, so each merge makes the next PR's version stale and its lane redoes the stamp (a median 14.5 minutes from stamp commit to merge over 30 slots, Oct 27-29; the queue froze on hand-made stamps in #3422 and #3487). With `changelog_fragments` on, two PRs cut from one base cannot collide, and nobody stamps by hand. gstack's own policy does not turn it on in this release (docs/ship-policy.md#changelog-fragments).
+1 -1
View File
@@ -1 +1 @@
1.91.336.0
1.91.339.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.336.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.339.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,
+3 -1
View File
@@ -355,7 +355,9 @@ a plan; relay it whole. Template: `captain/templates/plan-ask.md`.
its first act after the session-start check and sends the controller the plan ask above; the controller relays it, and the
lane builds only after the owner's word is answered into the run and it reports `APPROVED`.
New scope taken on part-way gets its own plan. Read-only lanes, the flaky pass's repairs,
slot stamps, CHANGELOG resolves and cap re-pins under a grant are exempt. The brief a
slot stamps, CHANGELOG resolves, cap re-pins under a grant, S1 emergencies (broken install
or upgrade, red main, a security bug) and fixes from an already-diagnosed, audited problem
list are exempt; every other fix gets a one-round, eng-only plan. The brief a
controller hands a lane, with its `Plan: required | approved | exempt` line, is
`captain/templates/brief.md`; the rule is `docs/captain.md` (Plan before build).
+3 -1
View File
@@ -283,7 +283,9 @@ a plan; relay it whole. Template: `captain/templates/plan-ask.md`.
its first act after the session-start check and sends the controller the plan ask above; the controller relays it, and the
lane builds only after the owner's word is answered into the run and it reports `APPROVED`.
New scope taken on part-way gets its own plan. Read-only lanes, the flaky pass's repairs,
slot stamps, CHANGELOG resolves and cap re-pins under a grant are exempt. The brief a
slot stamps, CHANGELOG resolves, cap re-pins under a grant, S1 emergencies (broken install
or upgrade, red main, a security bug) and fixes from an already-diagnosed, audited problem
list are exempt; every other fix gets a one-round, eng-only plan. The brief a
controller hands a lane, with its `Plan: required | approved | exempt` line, is
`captain/templates/brief.md`; the rule is `docs/captain.md` (Plan before build).
+3 -1
View File
@@ -133,7 +133,9 @@ Homes are paths in the gstack checkout the lane checked at session start.
answered into the run and it reports `APPROVED`; record it with `gstack-approvals
import --autoplan`. New scope taken on part-way gets the same. Exempt: read-only
lanes, the daily flaky pass's repairs, slot stamps, CHANGELOG-only resolves, cap
re-pins under a written grant. Home: `docs/captain.md` (Plan before build),
re-pins under a written grant, S1 emergencies (broken install or upgrade, red main,
a security bug) and fixes from an already-diagnosed, audited problem list; every
other fix gets a one-round, eng-only plan. Home: `docs/captain.md` (Plan before build),
`captain/templates/brief.md`.
## Crew-specific rules
+5 -2
View File
@@ -17,8 +17,11 @@ Spend cap: <focused paid runs per PR, from SPEND>
set, run dir under ~/.capy/work). Write the plan with its ## ELI5, ## Before → after,
## Why it matters, ## What caused this work, ## Big decisions and ## Implementation plan
sections, then run gstack-autoplan next --out <run dir> --plan <plan.md> <scope flags>
sized to this brief (a one-file fix: --no-ui --no-dx --max-rounds 1 --estimate-usd <n>
--spend-cap <small>). At the gate send me the output of
sized to this brief (a fix: --no-ui --no-dx --max-rounds 1 --estimate-usd <n>
--spend-cap <small>, then close the CEO phase before dispatching its voices with
gstack-autoplan close --out <run dir> --phase ceo --deferred "eng-only fix plan"
--by agent:<your thread>).
At the gate send me the output of
gstack-owner-brief render --captain --out <run dir> whole, as a needs-you report.
Build nothing yet.
2. Build after the word. I bring the owner's exact words back. Answer them into the run
+1 -1
View File
@@ -53,7 +53,7 @@ Every Capy thread that uses gstack follows these for new work, under a Captain o
- **/ship before ready.** Run /ship at the final pushed head before a PR is called ready; `SHIP_RECEIPT: pr=<n> head=<head>` must name that head, and a later push needs a new /ship. The review inside /ship, like the autoplan, is sized to the change.
- **No merge without the owner's word.** A thread never merges on its own; under a Captain the controller merges on a recorded standing approval, and a thread with no Captain asks the person.
Exempt from the plan step: read-only work, the daily flaky pass's repairs, slot stamps, CHANGELOG-only resolves and cap re-pins under a written grant.
Exempt from the plan step: read-only work, the daily flaky pass's repairs, slot stamps, CHANGELOG-only resolves, cap re-pins under a written grant, S1 emergencies (a broken install or upgrade, a red main, a security bug) and fixes from an already-diagnosed, audited problem list. Every other fix gets a one-round, eng-only plan.
## Working under a Captain
+8 -2
View File
@@ -134,7 +134,7 @@ alone is not an ask.
A lane that builds something plans first. Its first act after the session-start install
check, before any product code, is a `/autoplan` run on its brief, sized to the work. The
phases run in order, CEO, then design, then DX, then eng, with design and DX only when the
scope has them, and a one-file fix gets a short run (`--no-ui --no-dx --max-rounds 1`, with
scope has them, and a fix the exemptions below don't cover gets a short run (`--no-ui --no-dx --max-rounds 1`, with
`--estimate-usd` set so a small `--spend-cap` still seats its attempts), not no run. On Capy the
unattended loop is `autoplan/DRIVER.md` in the gstack checkout. New functionality a lane
takes on part-way, scope its brief did not name, gets its own plan and its own ask before
@@ -178,7 +178,13 @@ The brief's done line names the row. A plan approval is not a READY field; the
**Exemptions.** Read-only lanes (triage, research, measurement, a review voice), the daily
flaky pass's repairs, slot stamps, CHANGELOG-only conflict resolves and a size or budget
cap re-pinned under the controller's written grant need no plan. A crew may add exemptions
cap re-pinned under the controller's written grant need no plan. Two kinds of fix are exempt
too: an S1 emergency (a broken install or upgrade, a red main, a security bug), and a fix
from a problem list that was already diagnosed and audited, where that list is the plan.
Every other fix, a one-line bug fix included, gets a one-round, eng-only plan: the short
run above, with the CEO phase closed before its voices are dispatched
(`gstack-autoplan close --out <run dir> --phase ceo --deferred "eng-only fix plan" --by agent:<thread>`).
A crew may add exemptions
in its rules file with the owner's words and date; it does not remove these. The brief
says which applies, `Plan: required | approved: <run> | exempt: <reason>`, so an
already-approved plan is not planned twice and exempt work does not wait on an ask.
+6 -1
View File
@@ -843,7 +843,12 @@ gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--js
`git ls-remote <remote> <ref>`. A malformed or
unreadable policy refuses. Waivers come only from the base branch's
`.gstack/sealed-allow.tsv` (`rule<TAB>path<TAB>sha<TAB>reason`) and are
listed on every push.
listed on every push. A waiver counts only when `sha` is the finding's full
commit sha (40 hex characters, 64 in a SHA-256 repository); a shortened or
mistyped one is ignored, so the finding still refuses, and stderr names it
(`ignored waiver <rule> <path> sha <value> (...)`) with the finding's full
commit, which needs a review before anyone waives it.
`--json` lists them as `ignoredWaivers`.
- **Visibility.** `sealed.visibility` from the base branch, else a cache under
the state root (`sealed-visibility.json`: a `public` answer for 24 hours, a
`private` one for 1 hour before it may turn a refusal into a warning), else
+2 -1
View File
@@ -3813,7 +3813,8 @@ and counts) can be exempted from the path rules with a `sealed.allow_paths`
glob in the base branch's `.gstack/pregate.json`; the text rules still read it
([docs/pregate.md#sealed](pregate.md#sealed)). A reviewed false positive gets
one line in the base branch's
`.gstack/sealed-allow.tsv`: `rule<TAB>path<TAB>sha<TAB>reason`. A private
`.gstack/sealed-allow.tsv`: `rule<TAB>path<TAB>sha<TAB>reason`, where `sha` is
the full commit sha (a shortened one is ignored and named on stderr). A private
destination sets `"visibility": "private"` under `sealed` in the base branch's
`.gstack/pregate.json`. Capy's pull-request tool may not run local hooks, so
run `gstack-pregate sealed` yourself before any push or PR creation.
+1 -1
View File
@@ -513,7 +513,7 @@ export const RESULT_CODES = {
SEALED_OUTPUT_REFUSED: {
anchor: 'sealed-output-refused',
summary: 'a push or PR would publish sealed or eval output (a sealed path, a transcript, a quoted answer, a seed or a local home path) to a public or unknown destination, or the sealed policy is unreadable',
fix: 'remove it from every published commit, or add one reviewed `rule<TAB>path<TAB>sha<TAB>reason` line to the base branch\'s .gstack/sealed-allow.tsv; a private destination sets sealed.visibility "private" in the base branch\'s .gstack/pregate.json',
fix: 'remove it from every published commit, or add one reviewed `rule<TAB>path<TAB>full-commit-sha<TAB>reason` line to the base branch\'s .gstack/sealed-allow.tsv (a shortened sha is ignored); a private destination sets sealed.visibility "private" in the base branch\'s .gstack/pregate.json',
},
EVAL_GATE_UNPRICED: {
anchor: 'eval-gate-unpriced',
+20 -9
View File
@@ -22,7 +22,7 @@
* 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`. A malformed or unreadable policy refuses (fail
* 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.
@@ -104,13 +104,13 @@ export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy |
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), visibility };
}
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */
/** 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 && /^[0-9a-f]{7,64}$/.test(f[2]!) && f[3]!.trim())
.map(f => ({ rule: f[0]!.trim(), path: f[1]!.trim(), sha: f[2]!, reason: f.slice(3).join('\t').trim() }));
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. */
@@ -229,7 +229,7 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
export interface SealedReport {
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>;
policyLabels: string[]; commits: number; 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;
}
@@ -240,7 +240,7 @@ export interface EvaluateOptions {
/** 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: [], visibility: 'unknown', visibilitySource: '', target };
return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], ignoredWaivers: [], visibility: 'unknown', visibilitySource: '', target };
}
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
@@ -271,13 +271,20 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
const allow = allowEntriesAt(o.repoRoot, o.baseRef);
const waived: SealedReport['waived'] = [];
const findings = found.filter(f => {
const entry = allow.find(a => a.rule === f.rule && a.path === f.file && f.commit.startsWith(a.sha));
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, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
return { status, policyLabels: labels, commits: commits.length, 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}` : ''}`;
@@ -287,6 +294,10 @@ export function renderSealed(r: SealedReport, context: string): string {
const lines: string[] = [];
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)}`);
if (r.status === 'error') {
@@ -294,7 +305,7 @@ export function renderSealed(r: SealedReport, context: string): string {
lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'refused') {
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body);
lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
lines.push(`Remove 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 ${SEALED_CONFIG_PATH}, 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);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.336",
"version": "1.91.339",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+11
View File
@@ -81,6 +81,17 @@ describe('/captain skill', () => {
expect(read('captain/templates/automation-flaky-pass.md')).toContain('never merge');
});
test('every copy of the plan-first exemptions names the two fix exemptions and the eng-only plan for every other fix', () => {
for (const rel of ['docs/captain.md', 'captain/SKILL.md', 'captain/templates/CREW.md', 'capy/skill/gstack/SKILL.md']) {
const text = read(rel).replace(/\s+/g, ' ');
expect(text, rel).toMatch(/S1 emergenc(?:y|ies) \(a? ?broken install or upgrade, a? ?red main, a security bug\)/);
expect(text, rel).toMatch(/already[- ]diagnosed,? (?:and )?audited problem list|problem list that was already diagnosed and audited/);
expect(text, rel).toContain('one-round, eng-only plan');
}
expect(read('docs/captain.md')).toContain('gstack-autoplan close --out <run dir> --phase ceo --deferred');
expect(read('captain/templates/brief.md').replace(/\s+/g, ' ')).toContain('gstack-autoplan close --out <run dir> --phase ceo --deferred');
});
test('is generic: parameters, not one crew\'s codes, names or repository', () => {
const files = ['captain/SKILL.md.tmpl', 'docs/captain.md', ...TEMPLATES.map(t => 'captain/templates/' + t)];
for (const f of files) {
+110 -6
View File
@@ -41,14 +41,14 @@ function sh(cwd: string, env: Record<string, string>, args: string[]): string {
}
/** A bare origin with main carrying the given base files, and a clone on branch `results`. */
function scratch(baseFiles: Record<string, string | Buffer>): Scratch {
function scratch(baseFiles: Record<string, string | Buffer>, objectFormat: 'sha1' | 'sha256' = 'sha1'): Scratch {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-guard-')));
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');
spawnSync('git', ['init', '-q', '--bare', origin], { env: { ...process.env, ...env }, timeout: 30_000 });
sh(work, env, ['init', '-q', '-b', 'main']);
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`);
spawnSync('git', ['init', '-q', '--bare', `--object-format=${objectFormat}`, origin], { env: { ...process.env, ...env }, timeout: 30_000 });
sh(work, env, ['init', '-q', '-b', 'main', `--object-format=${objectFormat}`]);
write(work, { 'README.md': '# evals\n', ...baseFiles });
sh(work, env, ['add', '-A']); sh(work, env, ['commit', '-q', '-m', 'base']);
sh(work, env, ['remote', 'add', 'origin', origin]);
@@ -145,7 +145,7 @@ describe('policy union, waivers, private destinations and fail-closed parsing',
test('a waiver counts only from the base branch allow file and is listed; the same line committed in the push is ignored', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const sha = commit(s, { 'runs/c1/transcript.jsonl': '{}\n' });
const allow = `# rule\tpath\tsha\treason\nsealed.transcript\truns/c1/transcript.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture, no sealed content\n`;
const allow = `# rule\tpath\tsha\treason\nsealed.transcript\truns/c1/transcript.jsonl\t${sha}\tsynthetic fixture, no sealed content\n`;
commit(s, { '.gstack/sealed-allow.tsv': allow });
expect(hook(s).code).toBe(1);
sh(s.work, s.env, ['checkout', '-q', 'main']);
@@ -267,7 +267,7 @@ describe('sealed.allow_paths: base-branch exceptions applied after the deny glob
test('an exception covers sealed.transcript too and is counted before a base sha waiver, never both', () => {
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'], transcripts: ['**/transcript*.jsonl'], allow_paths: ['**/sealed/*/transcript-public.jsonl'] }) });
const sha = commit(s, { 'sealed/s1/transcript-public.jsonl': '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture\n` });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha}\tsynthetic fixture\n` });
const pre = preflight(s);
expect(pre.code).toBe(0);
expect(pre.err).toContain('exempt sealed.transcript sealed/s1/transcript-public.jsonl');
@@ -372,3 +372,107 @@ describe('gstack-pregate sealed is a check, not a push (#3565)', () => {
expect(before).not.toContain('refs/heads/results');
});
});
describe('waivers name the full commit sha (GST-268)', () => {
const TRANSCRIPT = 'runs/c1/transcript.jsonl';
const line = (sha: string) => `sealed.transcript\t${TRANSCRIPT}\t${sha}\tsynthetic fixture\n`;
const PREFIX = 'must name the full lowercase commit sha';
const OTHER = 'it names another commit';
const SHORT: Array<[string, (x: string) => string, string]> = [['a 7-character prefix', x => x.slice(0, 7), PREFIX], ['a 12-character prefix', x => x.slice(0, 12), PREFIX], ['a 39-character prefix', x => x.slice(0, 39), PREFIX], ['an uppercase sha', x => x.toUpperCase(), PREFIX], ['one extra character', x => `${x}a`, OTHER]];
test.each(SHORT)('%s does not waive and is named as ignored with the full commit', (_label, cut, why) => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const sha = commit(s, { [TRANSCRIPT]: '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': line(cut(sha)) });
const h = hook(s);
expect(h.code).toBe(1);
expect(h.err).toContain(`ignored waiver sealed.transcript ${TRANSCRIPT} sha ${cut(sha)}`);
expect(h.err).toContain(`${why}; this finding is in commit ${sha}, review it before waiving)`);
const pre = preflight(s);
expect(pre.code).toBe(1);
expect(pre.out).toMatch(/^SEALED: refused findings=1 .* waived=0 /m);
expect(JSON.parse(preflight(s, ['--json']).out).ignoredWaivers).toEqual([{ rule: 'sealed.transcript', path: TRANSCRIPT, sha: cut(sha), commit: sha }]);
});
test('the full 40-character sha waives, trailing whitespace included, with no ignored note', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const sha = commit(s, { [TRANSCRIPT]: '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': line(`${sha} `) });
const r = hook(s);
expect(r.code).toBe(0);
expect(r.err).toContain(`waived sealed.transcript ${TRANSCRIPT}`);
expect(r.err).not.toContain('ignored waiver');
expect(JSON.parse(preflight(s, ['--json']).out).ignoredWaivers).toEqual([]);
});
test("the full sha of a different commit does not waive this commit's file at the same path", () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const first = commit(s, { [TRANSCRIPT]: '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': line(first) });
sh(s.work, s.env, ['reset', '-q', '--hard', 'origin/main']);
const second = commit(s, { [TRANSCRIPT]: '{"other":1}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toContain(`ignored waiver sealed.transcript ${TRANSCRIPT} sha ${first}`);
expect(r.err).toContain(`it names another commit; this finding is in commit ${second}`);
});
test("a short waiver for an older commit is named as another commit, never as this commit's shortened sha", () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const first = commit(s, { [TRANSCRIPT]: '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': line(first.slice(0, 7)) });
sh(s.work, s.env, ['reset', '-q', '--hard', 'origin/main']);
const second = commit(s, { [TRANSCRIPT]: '{"other":1}\n' });
expect(second.startsWith(first.slice(0, 7))).toBe(false);
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toContain(`ignored waiver sealed.transcript ${TRANSCRIPT} sha ${first.slice(0, 7)} (${OTHER}; this finding is in commit ${second}, review it before waiving)`);
expect(r.err).not.toContain(PREFIX);
});
test('a short waiver line on the base branch prints nothing on a push with no matching finding', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED), '.gstack/sealed-allow.tsv': line('abc1234') });
commit(s, { 'notes.md': 'ok\n' });
const r = hook(s);
expect(r.code).toBe(0);
expect(r.err).not.toContain('ignored waiver');
});
const SHA256: Array<[string, (x: string) => string, boolean]> = [['the full 64 characters waive', x => x, true], ['a 40-character prefix does not', x => x.slice(0, 40), false], ['a 63-character prefix does not', x => x.slice(0, 63), false], ['65 characters do not', x => `${x}a`, false]];
test.each(SHA256)('a SHA-256 repository: %s', (_label, cut, waives) => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) }, 'sha256');
const sha = commit(s, { [TRANSCRIPT]: '{}\n' });
expect(sha).toMatch(/^[0-9a-f]{64}$/);
landOnMain(s, { '.gstack/sealed-allow.tsv': line(cut(sha)) });
const pre = preflight(s);
expect(pre.code).toBe(waives ? 0 : 1);
expect(pre.err.includes('ignored waiver')).toBe(!waives);
if (!waives) expect(pre.err).toContain(`${cut(sha).length < 64 ? PREFIX : OTHER}; this finding is in commit ${sha}, review it before waiving)`);
});
test('a waiver that applied in one commit is not reported as ignored for a later commit at the same path', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const first = commit(s, { 'notes/a.md': 'SEALED ANSWER: b\n' });
const second = commit(s, { 'notes/a.md': 'SEALED ANSWER: b\nSEALED ANSWER: c\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.answer\tnotes/a.md\t${first}\tsynthetic fixture\n` });
const j = JSON.parse(preflight(s, ['--json']).out);
expect(j.waived.map((w: { commit: string }) => w.commit)).toEqual([first]);
expect(j.findings.map((f: { commit: string }) => f.commit)).toEqual([second]);
expect(j.ignoredWaivers).toEqual([]);
});
test('a short waiver for a text rule with several matching lines is named once', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const sha = commit(s, { 'notes/a.md': 'SEALED ANSWER: b\nSEALED ANSWER: c\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.answer\tnotes/a.md\t${sha.slice(0, 7)}\tsynthetic fixture\n` });
const j = JSON.parse(preflight(s, ['--json']).out);
expect(j.findings).toHaveLength(2);
expect(j.ignoredWaivers).toEqual([{ rule: 'sealed.answer', path: 'notes/a.md', sha: sha.slice(0, 7), commit: sha }]);
});
test('an error report carries an empty ignoredWaivers list', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(s, { 'notes.md': 'ok\n' });
expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).ignoredWaivers).toEqual([]);
});
});