From 94a10cc9042776d2ea85b46eccaf3eba449a8b93 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 30 Oct 2026 10:10:37 -0700 Subject: [PATCH 1/2] v1.91.337.0 docs(captain): plan first exempts S1 emergencies and fixes from an audited problem list (GST-191) (#3647) * docs(captain): plan first exempts S1 emergencies and fixes from an audited problem list; every other fix gets an eng-only plan (GST-191) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(captain): the brief template sizes a fix as an eng-only plan too (GST-191 review) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(captain): brief template close command names --by Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * chore: stamp v1.91.337.0 (train slot 2) 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> --- CHANGELOG.md | 5 +++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- captain/SKILL.md | 4 +++- captain/SKILL.md.tmpl | 4 +++- captain/templates/CREW.md | 4 +++- captain/templates/brief.md | 7 +++++-- capy/skill/gstack/SKILL.md | 2 +- docs/captain.md | 10 ++++++++-- package.json | 2 +- test/captain-skill.test.ts | 11 +++++++++++ 11 files changed, 42 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a384bf8cb..75353b886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [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). diff --git a/VERSION b/VERSION index 6346b9d6a..5ee95b078 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.336.0 +1.91.337.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 51d51d4f1..156adc522 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.336.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.337.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, diff --git a/captain/SKILL.md b/captain/SKILL.md index 6a1f0f270..2655c6345 100644 --- a/captain/SKILL.md +++ b/captain/SKILL.md @@ -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). diff --git a/captain/SKILL.md.tmpl b/captain/SKILL.md.tmpl index 87ed80e88..2664b3d3c 100644 --- a/captain/SKILL.md.tmpl +++ b/captain/SKILL.md.tmpl @@ -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). diff --git a/captain/templates/CREW.md b/captain/templates/CREW.md index e6bdf2806..311750b23 100644 --- a/captain/templates/CREW.md +++ b/captain/templates/CREW.md @@ -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 diff --git a/captain/templates/brief.md b/captain/templates/brief.md index 1f6b0decb..efb283815 100644 --- a/captain/templates/brief.md +++ b/captain/templates/brief.md @@ -17,8 +17,11 @@ Spend cap: 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 --plan - sized to this brief (a one-file fix: --no-ui --no-dx --max-rounds 1 --estimate-usd - --spend-cap ). At the gate send me the output of + sized to this brief (a fix: --no-ui --no-dx --max-rounds 1 --estimate-usd + --spend-cap , then close the CEO phase before dispatching its voices with + gstack-autoplan close --out --phase ceo --deferred "eng-only fix plan" + --by agent:). + At the gate send me the output of gstack-owner-brief render --captain --out 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 diff --git a/capy/skill/gstack/SKILL.md b/capy/skill/gstack/SKILL.md index 2e74284b9..da5b50ef9 100644 --- a/capy/skill/gstack/SKILL.md +++ b/capy/skill/gstack/SKILL.md @@ -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= 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 diff --git a/docs/captain.md b/docs/captain.md index 9dc3697c5..d450b184c 100644 --- a/docs/captain.md +++ b/docs/captain.md @@ -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 --phase ceo --deferred "eng-only fix plan" --by agent:`). +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: | exempt: `, so an already-approved plan is not planned twice and exempt work does not wait on an ask. diff --git a/package.json b/package.json index d2b9bee45..f74d0b97e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.336", + "version": "1.91.337", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/captain-skill.test.ts b/test/captain-skill.test.ts index 895db459f..47309ab79 100644 --- a/test/captain-skill.test.ts +++ b/test/captain-skill.test.ts @@ -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 --phase ceo --deferred'); + expect(read('captain/templates/brief.md').replace(/\s+/g, ' ')).toContain('gstack-autoplan close --out --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) { From 53858424d03a65dafe13c080d96f7454e86265e8 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Fri, 30 Oct 2026 11:15:13 -0700 Subject: [PATCH 2/2] v1.91.338.0 fix(sealed): waivers must name the full commit sha (GST-268) (#3649) * fix(sealed): waivers must name the full commit sha (GST-268) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(sealed): one ignored-waiver note per line and commit; split the waiver loops into cases Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * docs(pregate): quote the ignored-waiver stderr line as printed Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * test(sealed): create SHA-256 scratch repos with --object-format, not the init config Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> * fix(sealed): ignored-waiver hint names the finding's commit to review, not a sha to write Choose the shortened-vs-another-commit wording by prefix against the finding's commit (fixes the SHA-256 40-hex mislabel and non-prefix short waivers), and check applied waivers with a Set instead of rescanning. 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> --- CHANGELOG.md | 11 ++++ VERSION | 2 +- agents-digest/gstack-AGENTS.md | 2 +- docs/pregate.md | 7 +- docs/troubleshooting.md | 3 +- lib/result-codes.ts | 2 +- lib/sealed-guard.ts | 29 ++++++--- package.json | 2 +- test/sealed-guard.test.ts | 116 +++++++++++++++++++++++++++++++-- 9 files changed, 153 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75353b886..6a9acfbf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## [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 diff --git a/VERSION b/VERSION index 5ee95b078..f7d51a4b5 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.91.337.0 +1.91.338.0 diff --git a/agents-digest/gstack-AGENTS.md b/agents-digest/gstack-AGENTS.md index 156adc522..4f01c7aae 100644 --- a/agents-digest/gstack-AGENTS.md +++ b/agents-digest/gstack-AGENTS.md @@ -1,4 +1,4 @@ -# gstack digest v1.91.337.0 — regenerate/re-copy after upgrading gstack +# gstack digest v1.91.338.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, diff --git a/docs/pregate.md b/docs/pregate.md index 7e677900d..4d526a85c 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -843,7 +843,12 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js `git ls-remote `. A malformed or unreadable policy refuses. Waivers come only from the base branch's `.gstack/sealed-allow.tsv` (`rulepathshareason`) 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 sha (...)`) 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 diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 02ecad44a..2b397f270 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -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`: `rulepathshareason`. A private +`.gstack/sealed-allow.tsv`: `rulepathshareason`, 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. diff --git a/lib/result-codes.ts b/lib/result-codes.ts index 80e6f16f1..04dfa6408 100644 --- a/lib/result-codes.ts +++ b/lib/result-codes.ts @@ -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 `rulepathshareason` 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 `rulepathfull-commit-shareason` 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', diff --git a/lib/sealed-guard.ts b/lib/sealed-guard.ts index cda351a97..976a44860 100644 --- a/lib/sealed-guard.ts +++ b/lib/sealed-guard.ts @@ -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: `rulepathshareason` lines in the base branch's allow file. */ +/** Reviewed waivers: `rulepathshareason` 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; + policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array; 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(); + 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 \`rulepathshareason\` 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 \`rulepathfull-commit-shareason\` 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); } diff --git a/package.json b/package.json index f74d0b97e..08361351e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "gstack", - "version": "1.91.337", + "version": "1.91.338", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "license": "MIT", "type": "module", diff --git a/test/sealed-guard.test.ts b/test/sealed-guard.test.ts index 00874c0a9..1d159e69e 100644 --- a/test/sealed-guard.test.ts +++ b/test/sealed-guard.test.ts @@ -41,14 +41,14 @@ function sh(cwd: string, env: Record, args: string[]): string { } /** A bare origin with main carrying the given base files, and a clone on branch `results`. */ -function scratch(baseFiles: Record): Scratch { +function scratch(baseFiles: Record, 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([]); + }); +});