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>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-28 19:25:26 +00:00
1 parent 98dd72ffc3
commit 19c74ffff3
7 files changed
+230 -27

No files matched your search

+3 -3
View File
@@ -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;
}
+2 -2
View File
@@ -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" });
+33 -3
View File
@@ -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 <a..b>] [--remote origin] [--base <branch>] [--js
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
(`/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\`) 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 <rule>
<file> commit <sha> (sealed.allow_paths <glob>)`) and counted as
`exempt=<n>` 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` (`rule<TAB>path<TAB>sha<TAB>reason`) and are
listed on every push.
+1 -1
View File
@@ -321,7 +321,7 @@ export function explainConfig(): string {
'local: <workflow.yml>/<job> 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=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
+46 -18
View File
@@ -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/<u>/, /Users/<u>/) 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;
/** `<key>: <message>` 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<string, unknown>;
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: `rule<TAB>path<TAB>sha<TAB>reason` 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<SealedFinding & { reason: string }>;
policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>;
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<SealedFinding & { reason: string }>, 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 (`<rule> <file[:line]> commit <sha12>`), 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 \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'warned') {
lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body);
}
+6
View File
@@ -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']);
});
});
+139
View File
@@ -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<string, string>): 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-'));