From 19c74ffff36073c2a544f965f19a092e92e31875 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 19:25:26 +0000 Subject: [PATCH 1/5] feat(pregate): sealed.allow_paths exceptions and opt-in case_insensitive deny globs (GSTA-145) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- bin/gstack-pregate | 6 +- bin/gstack-redact-prepush | 4 +- docs/pregate.md | 36 +++++++++- lib/pregate/config.ts | 2 +- lib/sealed-guard.ts | 64 ++++++++++++----- test/pregate-guards.test.ts | 6 ++ test/sealed-guard.test.ts | 139 ++++++++++++++++++++++++++++++++++++ 7 files changed, 230 insertions(+), 27 deletions(-) diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 8613150ed..446e4be7a 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -40,7 +40,7 @@ import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context'; import { cancelActive } from '../lib/pregate/supervise'; import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate'; import { GATE_AHEAD_REL } from '../lib/restamp'; -import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard'; +import { evaluateSealed, publishedCommits, rangeCommits, renderSealed, sealedErrorReport } from '../lib/sealed-guard'; import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy'; import { fromGateAhead } from '../lib/tree-receipt'; @@ -244,13 +244,13 @@ function cmdSealed(root: string, rest: string[], json: boolean): number { const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null; const remoteUrl = gitOut(root, ['remote', 'get-url', remote]); const report = 'error' in listed - ? { status: 'error' as const, error: listed.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: 'unknown' as const, visibilitySource: '', target: remoteUrl ?? remote } + ? sealedErrorReport(listed.error, remoteUrl ?? remote) : evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote }); const blocks = report.status === 'refused' || report.status === 'error'; if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; } process.stderr.write(renderSealed(report, 'gstack-pregate sealed')); const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`; - process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} policy=${policy}\n`); + process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`); return blocks ? EXIT.fail : EXIT.ok; } diff --git a/bin/gstack-redact-prepush b/bin/gstack-redact-prepush index 252dc4389..80ebddeed 100755 --- a/bin/gstack-redact-prepush +++ b/bin/gstack-redact-prepush @@ -43,7 +43,7 @@ import * as path from "path"; import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine"; import { mkdirpSync } from "../lib/fs-utils"; import { resolveStateRoot } from "../lib/state-root"; -import { evaluateSealed, publishedCommits, renderSealed, type SealedReport } from "../lib/sealed-guard"; +import { evaluateSealed, publishedCommits, renderSealed, sealedErrorReport, type SealedReport } from "../lib/sealed-guard"; const ZERO = /^0+$/; let emptyTree: string | undefined; @@ -608,7 +608,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se const targets: Array<{ commits: string[]; pushedSha: string }> = []; for (const ref of refs) { const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName }); - if ("error" in published) return { status: "error", error: published.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: "unknown", visibilitySource: "", target: remoteUrl ?? "the push target" }; + 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" }); diff --git a/docs/pregate.md b/docs/pregate.md index a42b37831..79c671854 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -763,11 +763,13 @@ with a timing flake: never raise the bound. ```json { "sealed": { - "paths": ["heldout/**"], + "paths": ["heldout/**", "**/sealed/**"], "transcripts": ["runs/**/transcript*.jsonl"], + "allow_paths": ["**/sealed/*/MANIFEST.json", "**/protected/MANIFEST.json"], "answer_markers": ["SEALED ANSWER:"], "seed_patterns": ["shuffle_seed=\\d+"], - "local_paths": true } } + "local_paths": true, + "case_insensitive": true } } ``` A repo with a `sealed` key gets a configured local check against accidental @@ -787,8 +789,36 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path` (`/home//`, `/Users//`, `C:\Users\\`) check each commit's added lines. Findings print rule, `file[:line]` and commit, never the text. +- **Globs.** `paths`, `transcripts` and `allow_paths` match the whole + repo-relative path from the repository root. `*` and `?` stay inside one + path segment; `**/` spans zero or more directories; a `**` not followed by + `/` matches across directories (`heldout/**` covers every depth under + `heldout/`); `[` and `{` are literal. So `sealed/**` misses `tasks/x/sealed/a.json`: + to guard a `sealed/` directory wherever it sits, write `**/sealed/**`. +- **Case.** Matching is case-sensitive. `case_insensitive: true` folds case + for `paths` and `transcripts` only, so `**/heldout/**` also catches + `HELDOUT/` and `HeldOut/`. Separator and spelling variants (`hold-out/`, + `held_out/`, `heldout-v2/`) are not folded: list each as its own glob. + The effective value is the base branch's OR the pushed one (it only tightens). +- **Exceptions.** `allow_paths` exempts publishable files that sit next to + sealed data (a `MANIFEST.json` of hashes and counts) from `sealed.path` and + `sealed.transcript`, applied after the deny globs and before waivers. They + are read from the destination base branch only: an exception a push adds + takes effect once it lands on the base branch, so a branch can never widen + its own. They never fold case, and an entry whose last segment is only + wildcards (`**`, `sealed/*`) is refused. An exempt file is checked by the + text rules only, so configure `answer_markers` when you exempt files next to + sealed data. Each exempt path is listed on every push (`exempt + commit (sealed.allow_paths )`) and counted as + `exempt=` on the `SEALED:` line. +- **Version skew.** A gstack older than the release whose CHANGELOG adds + `sealed.allow_paths` refuses `allow_paths` and `case_insensitive` as + unknown keys, so every push and `gstack-pregate run` in the repository + fail on that machine. Raise `min_gstack_version` in + `.gstack/ship-policy.json` to that release before landing either key. - **Policy.** The union of the destination base branch's `sealed` and the - pushed commit's: a push can tighten it, never relax it. A malformed or + pushed commit's: a push can tighten it, never relax it (`allow_paths` come + from the base branch alone, and a pushed deny glob cannot override one). 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. diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index 8e20540fa..bbd409ee7 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -321,7 +321,7 @@ export function explainConfig(): string { 'local: / lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote', 'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable', `serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`, - 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)', + 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)', 'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)', 'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)', 'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree= tests= failed=) (docs/pregate.md#remote-gate)', diff --git a/lib/sealed-guard.ts b/lib/sealed-guard.ts index e10c08a6a..f7bdbe526 100644 --- a/lib/sealed-guard.ts +++ b/lib/sealed-guard.ts @@ -9,6 +9,8 @@ * answer_markers regexes for a quoted sealed answer in added text * seed_patterns regexes for a sampling seed in added text * local_paths true: a local home path (/home//, /Users//) in added text + * allow_paths globs exempt from the path rules (read from the base branch only; never folds case) + * case_insensitive true: `paths` and `transcripts` match regardless of case * visibility "public" | "private": the destination override (reviewed on the base branch) * * The publication boundary is every commit the push publishes: the local sha @@ -18,8 +20,9 @@ * copies, independent of content (binary, empty, add-then-delete); text rules * see each commit's added lines. The effective policy is the union of the * destination base branch's `sealed` and the pushed one (a push can tighten, - * never relax); waivers come only from the base branch's - * `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail + * 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 * 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. @@ -28,31 +31,33 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; -import { containedRelative, globToRegExp, matchesAny } from './ship-policy'; +import { containedRelative, globToRegExp } from './ship-policy'; import { resolveStateRoot } from './state-root'; export const SEALED_CONFIG_PATH = '.gstack/pregate.json'; export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv'; export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility'; export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path'; -export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; visibility?: 'public' | 'private' } +export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; visibility?: 'public' | 'private' } export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string } +export type ExemptFinding = SealedFinding & { glob: string }; export interface AllowEntry { rule: string; path: string; sha: string; reason: string } export type Visibility = 'public' | 'private' | 'unknown'; -const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'visibility']; +const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility']; const MAX_ENTRIES = 64; const MAX_REGEX = 256; const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/; const PUBLIC_TTL_MS = 24 * 3600_000; const PRIVATE_TTL_MS = 3600_000; +const MAX_EXEMPT_LINES = 20; /** `: ` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */ export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> { const errors: Array<{ path: string; message: string }> = []; - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: 'must be an object { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }' }]; + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: `must be an object { ${KEYS.join(', ')} }` }]; const o = raw as Record; - for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')}` }); + for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (a key from a newer gstack: upgrade gstack on this machine)` }); const list = (key: string, check: (s: string) => string | null) => { if (!(key in o)) return; const v = o[key]; @@ -62,8 +67,9 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin }; const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } }; const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } }; - list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); - if ('local_paths' in o && typeof o.local_paths !== 'boolean') errors.push({ path: 'sealed.local_paths', message: 'must be true or false' }); + const exception = (g: string) => glob(g) ?? (/^[*?]+$/.test(g.split('/').pop()!) ? 'ends in a wildcard-only segment, which would exempt a whole directory; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); + list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception); + for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, message: 'must be true or false' }); if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' }); return errors; } @@ -88,15 +94,15 @@ export function sealedPolicyAt(repoRoot: string, treeish: string, label: string) const errors = sealedErrors(raw.sealed); if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label }; const s = raw.sealed; - return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, visibility: s.visibility }, label }; + return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, visibility: s.visibility }, label }; } -/** Base ∪ pushed: lists concatenate, `local_paths` ORs; visibility stays private only when the base says so and the push does not say public. */ +/** Base ∪ pushed: deny lists concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; visibility stays private only when the base says so and the push does not say public. */ export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null { if (!base && !pushed) return null; const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])]; const visibility = pushed?.visibility === 'public' || base?.visibility === 'public' ? 'public' : base?.visibility === 'private' ? 'private' : undefined; - return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), visibility }; + 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. */ @@ -126,12 +132,20 @@ export function rangeCommits(repoRoot: string, range: string): { commits: string const HEX = /^[0-9a-f]{40,64}$/; +/** The first glob matching a repo-relative path, compiled once per list (`matchesAny` normalization); null when none does. */ +function globMatcher(globs: string[], ignoreCase: boolean): (file: string) => string | null { + const compiled = globs.map(g => { const re = globToRegExp(g); return { g, re: ignoreCase ? new RegExp(re.source, 'i') : re }; }); + return file => { const norm = file.replace(/\\/g, '/').replace(/^\.\//, ''); return compiled.find(c => c.re.test(norm))?.g ?? null; }; +} + /** Path rules over every added, renamed or copied path, per commit (merges: paths new against every parent). */ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] { if (!p.paths.length && !p.transcripts.length) return []; const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '--name-status', '-z'], commits.join('\n') + '\n'); if (!r.ok) throw new Error(`git diff-tree failed: ${r.err.slice(0, 200)}`); const out: SealedFinding[] = []; + const transcript = globMatcher(p.transcripts, p.case_insensitive); + const sealedPath = globMatcher(p.paths, p.case_insensitive); const tokens = r.out.split('\0'); let commit = ''; for (let i = 0; i < tokens.length; i++) { @@ -141,8 +155,8 @@ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea const renamed = /^[RC]\d*$/.test(t); const file = renamed ? tokens[i += 2]! : tokens[++i]!; if (!renamed && !t.includes('A')) continue; - if (matchesAny(p.transcripts, file)) out.push({ rule: 'sealed.transcript', file, commit }); - else if (matchesAny(p.paths, file)) out.push({ rule: 'sealed.path', file, commit }); + if (transcript(file)) out.push({ rule: 'sealed.transcript', file, commit }); + else if (sealedPath(file)) out.push({ rule: 'sealed.path', file, commit }); } return out; } @@ -216,7 +230,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[]; waived: Array; + policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array; visibility: Visibility; visibilitySource: string; target: string; error?: string; } @@ -225,9 +239,14 @@ export interface EvaluateOptions { 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: [], visibility: 'unknown', visibilitySource: '', target }; +} + /** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */ export function evaluateSealed(o: EvaluateOptions): SealedReport { - const blank = { policyLabels: [] as string[], commits: 0, findings: [] as SealedFinding[], waived: [] as Array, visibility: 'unknown' as Visibility, visibilitySource: '', target: o.target }; + const blank = { ...sealedErrorReport('', o.target), error: undefined }; const base = o.baseRef ? sealedPolicyAt(o.repoRoot, o.baseRef, o.baseLabel ?? o.baseRef) : { policy: null, label: '(no destination base branch)' }; if ('error' in base) return { ...blank, status: 'error', error: `the sealed policy on ${base.label} is unreadable: ${base.error}` }; let policy: SealedPolicy | null = base.policy; @@ -243,6 +262,13 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport { 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}` }; } + const exception = globMatcher(policy.allow_paths, false); + const exempt: ExemptFinding[] = []; + found = found.filter(f => { + const glob = f.rule === 'sealed.path' || f.rule === 'sealed.transcript' ? exception(f.file) : null; + if (glob) exempt.push({ ...f, glob }); + return !glob; + }); const allow = allowEntriesAt(o.repoRoot, o.baseRef); const waived: SealedReport['waived'] = []; const findings = found.filter(f => { @@ -252,7 +278,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport { }); 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, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target }; + return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target }; } const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`; @@ -260,6 +286,8 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:$ /** stderr text for a report: findings one per line (` commit `), never the matched text. */ export function renderSealed(r: SealedReport, context: string): string { const lines: string[] = []; + for (const e of r.exempt.slice(0, MAX_EXEMPT_LINES)) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`); + if (r.exempt.length > MAX_EXEMPT_LINES) lines.push(`${context}: and ${r.exempt.length - MAX_EXEMPT_LINES} more exempt path(s) (sealed.allow_paths)`); 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})`); 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)}`); @@ -268,7 +296,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, 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 \`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)`); } 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/test/pregate-guards.test.ts b/test/pregate-guards.test.ts index 7719b9ec9..33f1a6fb8 100644 --- a/test/pregate-guards.test.ts +++ b/test/pregate-guards.test.ts @@ -193,6 +193,12 @@ describe('pregate config: sealed (CD4)', () => { expect(codes({ sealed: { seed_patterns: ['('] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.seed_patterns[0]']); expect(codes({ sealed: { visibility: 'internal' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.visibility']); expect(codes({ sealed: { block: true } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.block']); + expect(codes({ sealed: { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json', '**/protected/MANIFEST.json'], case_insensitive: true } })).toEqual([]); + expect(codes({ sealed: { allow_paths: '**/sealed/*/MANIFEST.json' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']); + expect(codes({ sealed: { allow_paths: ['../x/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]']); + expect(codes({ sealed: { allow_paths: Array.from({ length: 65 }, (_, i) => `s${i}/MANIFEST.json`) } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']); + expect(codes({ sealed: { allow_paths: ['**', 'sealed/**', 'sealed/*', '**/sealed/*/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]', 'PREGATE_CONFIG_INVALID:sealed.allow_paths[1]', 'PREGATE_CONFIG_INVALID:sealed.allow_paths[2]']); + expect(codes({ sealed: { case_insensitive: 'yes' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.case_insensitive']); }); }); diff --git a/test/sealed-guard.test.ts b/test/sealed-guard.test.ts index 1fff7c24f..982c63b31 100644 --- a/test/sealed-guard.test.ts +++ b/test/sealed-guard.test.ts @@ -17,6 +17,7 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { resolveVisibility } from '../lib/sealed-guard'; +import { matchesAny } from '../lib/ship-policy'; const ROOT = path.resolve(import.meta.dir, '..'); const PREPUSH = path.join(ROOT, 'bin', 'gstack-redact-prepush'); @@ -195,6 +196,144 @@ describe('gstack-pregate sealed (the standalone preflight before any push or PR }); }); +const MANIFEST = { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json'], answer_markers: ['SEALED ANSWER:'] }; +function preflight(s: Scratch, args: string[] = []) { + const r = spawnSync(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 ?? '' }; +} +function landOnMain(s: Scratch, files: Record): void { + sh(s.work, s.env, ['checkout', '-q', 'main']); + write(s.work, files); + sh(s.work, s.env, ['add', '-A']); sh(s.work, s.env, ['commit', '-q', '-m', 'base change']); sh(s.work, s.env, ['push', '-q', 'origin', 'main']); + sh(s.work, s.env, ['checkout', '-q', 'results']); +} + +describe('sealed.allow_paths: base-branch exceptions applied after the deny globs', () => { + test('a deny with an allowed exception refuses the sealed file, lists the manifest as exempt, and a manifest-only push passes', () => { + const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(s, { 'sealed/s1/tasks.json': '{}\n', 'sealed/s1/MANIFEST.json': '{"n":1}\n', 'tasks/x/sealed/s2/MANIFEST.json': '{"n":2}\n' }); + const r = hook(s); + expect(r.code).toBe(1); + expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/tasks\.json/m); + expect(r.err).not.toMatch(/^ sealed\.path\s+\S*MANIFEST\.json/m); + expect(r.err).toMatch(/exempt sealed\.path sealed\/s1\/MANIFEST\.json commit [0-9a-f]{12} \(sealed\.allow_paths \*\*\/sealed\/\*\/MANIFEST\.json\)/); + expect(r.err).toContain('exempt sealed.path tasks/x/sealed/s2/MANIFEST.json'); + const only = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(only, { 'sealed/s1/MANIFEST.json': '{"n":1}\n' }); + 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); + 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' }); + }); + + test('an exception that does not match still refuses: another case and another depth', () => { + for (const file of ['sealed/s1/manifest.json', 'sealed/s1/x/MANIFEST.json']) { + const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(s, { [file]: '{}\n' }); + const r = hook(s); + expect(r.code).toBe(1); + expect(r.err).toContain(`sealed.path ${file}`); + expect(r.err).not.toContain('exempt sealed.'); + } + }); + + test('an exception added on the pushed head is ignored, on top of a base list too; a malformed pushed allow_paths refuses', () => { + const fresh = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) }); + commit(fresh, { '.gstack/pregate.json': policy(MANIFEST), 'sealed/s1/MANIFEST.json': '{}\n' }); + const f = hook(fresh); + expect(f.code).toBe(1); + expect(f.err).toContain('sealed.path sealed/s1/MANIFEST.json'); + const extra = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(extra, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: [...MANIFEST.allow_paths, '**/sealed/*/notes.md'] }), 'sealed/s1/notes.md': 'n\n' }); + expect(hook(extra).err).toContain('sealed.path sealed/s1/notes.md'); + const bad = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(bad, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: '**/sealed/*/MANIFEST.json' }), 'docs/x.md': 'x\n' }); + const b = hook(bad); + expect(b.code).toBe(1); + expect(b.err).toContain('sealed.allow_paths'); + }); + + test('an allowed manifest that quotes a sealed answer still refuses on the text rule', () => { + const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(s, { 'sealed/s1/MANIFEST.json': '{"note":"SEALED ANSWER: b"}\n' }); + const r = hook(s); + expect(r.code).toBe(1); + expect(r.err).toMatch(/sealed\.answer\s+sealed\/s1\/MANIFEST\.json:1/); + expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json'); + }); + + 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` }); + const pre = preflight(s); + expect(pre.code).toBe(0); + expect(pre.err).toContain('exempt sealed.transcript sealed/s1/transcript-public.jsonl'); + expect(pre.out).toMatch(/findings=0 .* waived=0 exempt=1 /); + }); + + test('a preflight error carries the same report shape: exit 1, SEALED: error with exempt=0, no stack trace', () => { + const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); + commit(s, { 'docs/x.md': 'x\n' }); + const r = preflight(s, ['--range', 'nosuch..HEAD']); + expect(r.code).toBe(1); + expect(r.out).toMatch(/^SEALED: error findings=0 commits=0 .* waived=0 exempt=0 /m); + expect(r.err).not.toMatch(/\n\s+at /); + expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).exempt).toEqual([]); + }); +}); + +describe('sealed.case_insensitive: deny globs fold case, exceptions never do', () => { + test('off (the default), HELDOUT/ passes a lowercase deny glob; on, HELDOUT/, HeldOut/ and a Transcript refuse, and hold-out/ still passes', () => { + const off = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) }); + commit(off, { 'HELDOUT/q.json': '{}\n' }); + expect(hook(off).code).toBe(0); + const on = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], case_insensitive: true }) }); + commit(on, { 'HELDOUT/q.json': '{}\n', 'HeldOut/q.json': '{}\n', 'Runs/c1/Transcript.jsonl': '{}\n', 'hold-out/q.json': '{}\n' }); + const r = hook(on); + expect(r.code).toBe(1); + expect(r.err).toContain('sealed.path HELDOUT/q.json'); + expect(r.err).toContain('sealed.path HeldOut/q.json'); + expect(r.err).toContain('sealed.transcript Runs/c1/Transcript.jsonl'); + expect(r.err).not.toContain('hold-out/q.json'); + }); + + test('on, allow_paths stay case-sensitive: MANIFEST.json is exempt and manifest.json refuses', () => { + const s = scratch({ '.gstack/pregate.json': policy({ ...MANIFEST, case_insensitive: true }) }); + commit(s, { 'sealed/s1/MANIFEST.json': '{}\n', 'sealed/s1/manifest.json': '{}\n' }); + const r = hook(s); + expect(r.code).toBe(1); + expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json'); + expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/manifest\.json/m); + }); + + test('set only on the pushed head, it still tightens (base OR pushed)', () => { + const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) }); + commit(s, { '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], case_insensitive: true }), 'HELDOUT/q.json': '{}\n' }); + expect(hook(s).err).toContain('sealed.path HELDOUT/q.json'); + }); +}); + +describe('root-anchoring (the docs/pregate.md example)', () => { + test('globs match from the repo root: sealed/** misses a nested sealed/ directory and **/sealed/** catches it at any depth', () => { + const docs = fs.readFileSync(path.join(ROOT, 'docs', 'pregate.md'), 'utf8'); + expect(docs).toContain('`sealed/**` misses `tasks/x/sealed/a.json`'); + expect(docs).toContain('write `**/sealed/**`'); + expect(matchesAny(['sealed/**'], 'tasks/x/sealed/a.json')).toBe(false); + expect(matchesAny(['**/sealed/**'], 'tasks/x/sealed/a.json')).toBe(true); + expect(matchesAny(['**/sealed/**'], 'sealed/a.json')).toBe(true); + const anchored = scratch({ '.gstack/pregate.json': policy({ paths: ['sealed/**'] }) }); + commit(anchored, { 'tasks/x/sealed/a.json': '{}\n' }); + expect(hook(anchored).code).toBe(0); + const any = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) }); + commit(any, { 'tasks/x/sealed/a.json': '{}\n' }); + expect(hook(any).err).toContain('sealed.path tasks/x/sealed/a.json'); + }); +}); + describe('visibility (cache under the state root; unknown counts as public)', () => { test('a public answer is cached; a cached private answer older than an hour is re-queried before it can downgrade; a failed lookup is unknown', () => { const state = fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-vis-')); From 95d9c6d06ef1d4f42b43c51e893d46c92e7b5d20 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 19:27:00 +0000 Subject: [PATCH 2/5] docs(changelog): sealed allow_paths and case_insensitive (Unreleased) Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d8357e7f..f63370d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,21 @@ # Changelog +## [Unreleased] + +**A sealed policy can now refuse `sealed//tasks.json` while allowing the `MANIFEST.json` beside it, and can catch `HELDOUT/` with a `heldout` glob.** The sealed guard only had deny globs and one-commit waivers, so the gstack-evals layout, which keeps a public manifest of hashes and counts next to its sealed tasks, needed a fresh `.gstack/sealed-allow.tsv` line for every manifest commit (gstack-evals wiki/board/2026-10-28-gsta-9-sealed-manifest-glob-gap.md). Their first draft also wrote `sealed/**`, which never matched their nested `tasks//sealed/` directories, and garrytan/gstack-evals#3 (dc54796) hand-listed `Sealed/`, `HELDOUT/` and other case variants. + +### Added +- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry whose last segment is only wildcards (`**`, `sealed/*`) is refused. Each exempt path prints `exempt commit (sealed.allow_paths )` (at most 20 lines, then a count). +- **`sealed.case_insensitive: true`**: `paths` and `transcripts` match regardless of case, so `**/heldout/**` also refuses `HELDOUT/` and `HeldOut/`. The value is the base branch's OR the pushed one, because it only tightens. `allow_paths` never fold case. Separator and spelling variants such as `hold-out/` still need their own globs. + +### Changed +- `gstack-pregate sealed` prints `exempt=` after `waived=` on its `SEALED:` line (`policy=` stays last), and `--json` carries an `exempt` array. Both bins build their error report through one `sealedErrorReport`. +- An unknown `sealed` key now says it may come from a newer gstack. A gstack older than this release refuses `allow_paths` and `case_insensitive` this way on every push and `gstack-pregate run`, so a repository raises `min_gstack_version` before landing either key. +- `docs/pregate.md` § Sealed output explains glob anchoring: patterns match from the repository root, so `sealed/**` misses `tasks/x/sealed/a.json` and the docs say to write `**/sealed/**`. It also documents case handling, exceptions and the version skew. + +### Gate integrity +- This is a gate edit: `allow_paths` lets the sealed guard pass files it refused before, but only from the reviewed base branch. No existing test, threshold or allowlist changed. New cases in `test/sealed-guard.test.ts` (10) and `test/pregate-guards.test.ts` cover an allowed exception, a non-matching one, an exception added on the pushed head, a text rule inside an exempt file, transcripts, the waiver order, the error report, case folding in both directions and the root-anchoring example. + ## [1.91.297.0] - 2026-10-28 **The terminal agent on Windows no longer waits on PowerShell to learn its own start time, so it boots in about a second and a slow PowerShell can no longer leave it without an identity.** Every agent boot ran `powershell.exe Get-CimInstance` with a 2 s timeout, twice when started standalone. On a loaded `windows-latest` runner one launch took 0.94 s at the median and up to 10.8 s, so agent boot took 2.4 to 2.8 s at the median and 5.1 s on a runner's first boot, past the owner-watchdog test's 5 s window. Measured again after the change under the same load: 0.86 to 0.91 s at the median, 1.18 s at most, 120 of 120 boots inside the window. From 5e63f0f8ea3137a88c9ee0526ef6a31b1bbf1000 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 19:40:07 +0000 Subject: [PATCH 3/5] fix: pre-landing review fixes Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 4 ++-- docs/pregate.md | 4 ++-- lib/sealed-guard.ts | 8 +++----- test/pregate-guards.test.ts | 2 +- test/sealed-guard.test.ts | 10 ++++------ 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f63370d85..a1615bc83 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,12 @@ **A sealed policy can now refuse `sealed//tasks.json` while allowing the `MANIFEST.json` beside it, and can catch `HELDOUT/` with a `heldout` glob.** The sealed guard only had deny globs and one-commit waivers, so the gstack-evals layout, which keeps a public manifest of hashes and counts next to its sealed tasks, needed a fresh `.gstack/sealed-allow.tsv` line for every manifest commit (gstack-evals wiki/board/2026-10-28-gsta-9-sealed-manifest-glob-gap.md). Their first draft also wrote `sealed/**`, which never matched their nested `tasks//sealed/` directories, and garrytan/gstack-evals#3 (dc54796) hand-listed `Sealed/`, `HELDOUT/` and other case variants. ### Added -- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry whose last segment is only wildcards (`**`, `sealed/*`) is refused. Each exempt path prints `exempt commit (sealed.allow_paths )` (at most 20 lines, then a count). +- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry with a wildcard file name (`**`, `sealed/*`, `**/*.json`) is refused. Each exempt path prints `exempt commit (sealed.allow_paths )` on every push. - **`sealed.case_insensitive: true`**: `paths` and `transcripts` match regardless of case, so `**/heldout/**` also refuses `HELDOUT/` and `HeldOut/`. The value is the base branch's OR the pushed one, because it only tightens. `allow_paths` never fold case. Separator and spelling variants such as `hold-out/` still need their own globs. ### Changed - `gstack-pregate sealed` prints `exempt=` after `waived=` on its `SEALED:` line (`policy=` stays last), and `--json` carries an `exempt` array. Both bins build their error report through one `sealedErrorReport`. -- An unknown `sealed` key now says it may come from a newer gstack. A gstack older than this release refuses `allow_paths` and `case_insensitive` this way on every push and `gstack-pregate run`, so a repository raises `min_gstack_version` before landing either key. +- An unknown `sealed` key now says to upgrade gstack if the key comes from a newer one. A gstack older than this release refuses `allow_paths` and `case_insensitive` this way on every push and `gstack-pregate run`, so a repository raises `min_gstack_version` before landing either key. - `docs/pregate.md` § Sealed output explains glob anchoring: patterns match from the repository root, so `sealed/**` misses `tasks/x/sealed/a.json` and the docs say to write `**/sealed/**`. It also documents case handling, exceptions and the version skew. ### Gate integrity diff --git a/docs/pregate.md b/docs/pregate.md index 79c671854..337cbe623 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -805,8 +805,8 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js `sealed.transcript`, applied after the deny globs and before waivers. They are read from the destination base branch only: an exception a push adds takes effect once it lands on the base branch, so a branch can never widen - its own. They never fold case, and an entry whose last segment is only - wildcards (`**`, `sealed/*`) is refused. An exempt file is checked by the + its own. They never fold case, and an entry with a wildcard file name + (`**`, `sealed/*`, `**/*.json`, `sealed/**.json`) is refused: name the file. An exempt file is checked by the text rules only, so configure `answer_markers` when you exempt files next to sealed data. Each exempt path is listed on every push (`exempt commit (sealed.allow_paths )`) and counted as diff --git a/lib/sealed-guard.ts b/lib/sealed-guard.ts index f7bdbe526..6dd1572fa 100644 --- a/lib/sealed-guard.ts +++ b/lib/sealed-guard.ts @@ -50,14 +50,13 @@ const MAX_REGEX = 256; const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/; const PUBLIC_TTL_MS = 24 * 3600_000; const PRIVATE_TTL_MS = 3600_000; -const MAX_EXEMPT_LINES = 20; /** `: ` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */ export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> { const errors: Array<{ path: string; message: string }> = []; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: `must be an object { ${KEYS.join(', ')} }` }]; const o = raw as Record; - for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (a key from a newer gstack: upgrade gstack on this machine)` }); + for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (if the key comes from a newer gstack, upgrade gstack on this machine)` }); const list = (key: string, check: (s: string) => string | null) => { if (!(key in o)) return; const v = o[key]; @@ -67,7 +66,7 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin }; const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } }; const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } }; - const exception = (g: string) => glob(g) ?? (/^[*?]+$/.test(g.split('/').pop()!) ? 'ends in a wildcard-only segment, which would exempt a whole directory; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); + const exception = (g: string) => { const name = g.split('/').pop()!; return glob(g) ?? (name.includes('**') || /^[*?]+(\.|$)/.test(name) ? 'has a wildcard file name, which would exempt whole directories or file types; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); }; list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception); for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, message: 'must be true or false' }); if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' }); @@ -286,8 +285,7 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:$ /** stderr text for a report: findings one per line (` commit `), never the matched text. */ export function renderSealed(r: SealedReport, context: string): string { const lines: string[] = []; - for (const e of r.exempt.slice(0, MAX_EXEMPT_LINES)) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`); - if (r.exempt.length > MAX_EXEMPT_LINES) lines.push(`${context}: and ${r.exempt.length - MAX_EXEMPT_LINES} more exempt path(s) (sealed.allow_paths)`); + 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})`); 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)}`); diff --git a/test/pregate-guards.test.ts b/test/pregate-guards.test.ts index 33f1a6fb8..bf0e65bf4 100644 --- a/test/pregate-guards.test.ts +++ b/test/pregate-guards.test.ts @@ -197,7 +197,7 @@ describe('pregate config: sealed (CD4)', () => { expect(codes({ sealed: { allow_paths: '**/sealed/*/MANIFEST.json' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']); expect(codes({ sealed: { allow_paths: ['../x/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]']); expect(codes({ sealed: { allow_paths: Array.from({ length: 65 }, (_, i) => `s${i}/MANIFEST.json`) } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']); - expect(codes({ sealed: { allow_paths: ['**', 'sealed/**', 'sealed/*', '**/sealed/*/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]', 'PREGATE_CONFIG_INVALID:sealed.allow_paths[1]', 'PREGATE_CONFIG_INVALID:sealed.allow_paths[2]']); + expect(codes({ sealed: { allow_paths: ['**', 'sealed/**', 'sealed/*', '**/*.json', 'sealed/*.*', '**/sealed/**.json', '**/sealed/*/MANIFEST.json', '**/sealed/*/transcript-*.jsonl', '.hidden'] } })).toEqual([0, 1, 2, 3, 4, 5].map(i => `PREGATE_CONFIG_INVALID:sealed.allow_paths[${i}]`)); expect(codes({ sealed: { case_insensitive: 'yes' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.case_insensitive']); }); }); diff --git a/test/sealed-guard.test.ts b/test/sealed-guard.test.ts index 982c63b31..a9f044f6d 100644 --- a/test/sealed-guard.test.ts +++ b/test/sealed-guard.test.ts @@ -17,7 +17,6 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; import { resolveVisibility } from '../lib/sealed-guard'; -import { matchesAny } from '../lib/ship-policy'; const ROOT = path.resolve(import.meta.dir, '..'); const PREPUSH = path.join(ROOT, 'bin', 'gstack-redact-prepush'); @@ -322,15 +321,14 @@ describe('root-anchoring (the docs/pregate.md example)', () => { const docs = fs.readFileSync(path.join(ROOT, 'docs', 'pregate.md'), 'utf8'); expect(docs).toContain('`sealed/**` misses `tasks/x/sealed/a.json`'); expect(docs).toContain('write `**/sealed/**`'); - expect(matchesAny(['sealed/**'], 'tasks/x/sealed/a.json')).toBe(false); - expect(matchesAny(['**/sealed/**'], 'tasks/x/sealed/a.json')).toBe(true); - expect(matchesAny(['**/sealed/**'], 'sealed/a.json')).toBe(true); const anchored = scratch({ '.gstack/pregate.json': policy({ paths: ['sealed/**'] }) }); commit(anchored, { 'tasks/x/sealed/a.json': '{}\n' }); expect(hook(anchored).code).toBe(0); const any = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) }); - commit(any, { 'tasks/x/sealed/a.json': '{}\n' }); - expect(hook(any).err).toContain('sealed.path tasks/x/sealed/a.json'); + commit(any, { 'tasks/x/sealed/a.json': '{}\n', 'sealed/b.json': '{}\n' }); + const a = hook(any); + expect(a.err).toContain('sealed.path tasks/x/sealed/a.json'); + expect(a.err).toContain('sealed.path sealed/b.json'); }); }); From 6643b152cada9a0978893074916a7107ddd89c43 Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 19:46:40 +0000 Subject: [PATCH 4/5] docs(pregate): state exactly which allow_paths file names are refused Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- CHANGELOG.md | 2 +- docs/pregate.md | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1615bc83..af81a6063 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ **A sealed policy can now refuse `sealed//tasks.json` while allowing the `MANIFEST.json` beside it, and can catch `HELDOUT/` with a `heldout` glob.** The sealed guard only had deny globs and one-commit waivers, so the gstack-evals layout, which keeps a public manifest of hashes and counts next to its sealed tasks, needed a fresh `.gstack/sealed-allow.tsv` line for every manifest commit (gstack-evals wiki/board/2026-10-28-gsta-9-sealed-manifest-glob-gap.md). Their first draft also wrote `sealed/**`, which never matched their nested `tasks//sealed/` directories, and garrytan/gstack-evals#3 (dc54796) hand-listed `Sealed/`, `HELDOUT/` and other case variants. ### Added -- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry with a wildcard file name (`**`, `sealed/*`, `**/*.json`) is refused. Each exempt path prints `exempt commit (sealed.allow_paths )` on every push. +- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry whose file name contains `**` or is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`) is refused. Each exempt path prints `exempt commit (sealed.allow_paths )` on every push. - **`sealed.case_insensitive: true`**: `paths` and `transcripts` match regardless of case, so `**/heldout/**` also refuses `HELDOUT/` and `HeldOut/`. The value is the base branch's OR the pushed one, because it only tightens. `allow_paths` never fold case. Separator and spelling variants such as `hold-out/` still need their own globs. ### Changed diff --git a/docs/pregate.md b/docs/pregate.md index 337cbe623..82d3d5eb7 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -805,8 +805,10 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js `sealed.transcript`, applied after the deny globs and before waivers. They are read from the destination base branch only: an exception a push adds takes effect once it lands on the base branch, so a branch can never widen - its own. They never fold case, and an entry with a wildcard file name - (`**`, `sealed/*`, `**/*.json`, `sealed/**.json`) is refused: name the file. An exempt file is checked by the + its own. They never fold case. An entry whose file name contains `**`, or + is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`, + `sealed/**.json`), is refused; a partial wildcard such as `MANIFEST*.json` + is accepted and matches every file it spells, so name the file. An exempt file is checked by the text rules only, so configure `answer_markers` when you exempt files next to sealed data. Each exempt path is listed on every push (`exempt commit (sealed.allow_paths )`) and counted as From 1b942fd6265c15637236c33f6f40d6fed8cdc15d Mon Sep 17 00:00:00 2001 From: garrytan Date: Wed, 28 Oct 2026 19:53:14 +0000 Subject: [PATCH 5/5] docs(troubleshooting): sealed allow_paths exemption and newer-gstack sealed keys Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com> --- docs/troubleshooting.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 1ffa23542..c4ef4b88b 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3150,7 +3150,10 @@ cannot be downgraded; every other check id in `warn` is honoured only from the base branch's copy or the user-owned file. For the user-owned file, edit it directly at the path the message names. A user file inside the repository, or reached through a symlink into it, is ignored rather than read (`--explain` -names it). +names it). An unknown `sealed` key that a newer gstack defines (such as +`allow_paths` or `case_insensitive`) means this machine's gstack is older than +the policy: upgrade gstack here, and raise `min_gstack_version` in the +repository's `.gstack/ship-policy.json` before landing such a key. ### `PREGATE_SECRETS` @@ -3656,7 +3659,11 @@ finding: rule, `file[:line]`, commit. They never print the matched text. **Fix.** Remove the output from every published commit before the branch reaches the public repository (a later commit that deletes it still publishes -it). A reviewed false positive gets one line in the base branch's +it). A publishable file kept next to sealed data (a `MANIFEST.json` of hashes +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 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