feat(sealed): sealed-output guard over every published commit, in the pre-push hook and gstack-pregate sealed

A repo with a sealed key in .gstack/pregate.json (base branch union the
pushed commit, so a push can tighten but never relax) refuses publishing
sealed paths, transcripts, quoted answers, seeds or local home paths to a
public or unknown destination (SEALED_OUTPUT_REFUSED); private warns.
Path rules see every added, renamed or copied path in each published
commit (git rev-list <local> --not --remotes=<remote>), text rules each
commit's added lines; waivers only from the base branch allow file; a
malformed policy fails closed; visibility caches public 24 h, private 1 h.

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-13 00:53:03 +00:00
1 parent 1898142986
commit 67dd3449eb
11 files changed
+653 -3

No files matched your search

+1
View File
@@ -167,6 +167,7 @@ gstack_result_anchor() {
BRIEF_INPUT_MISSING) printf '%s' 'brief-input-missing' ;;
DRAFT_DIRECTION_UNRESOLVED) printf '%s' 'draft-direction-unresolved' ;;
PREGATE_GUARD_FAILED) printf '%s' 'pregate-guard-failed' ;;
SEALED_OUTPUT_REFUSED) printf '%s' 'sealed-output-refused' ;;
*) return 1 ;;
esac
}
+35
View File
@@ -9,6 +9,8 @@
* gstack-pregate clear --remote <workflow.yml/job> --run-url <url> | --bundle-lane <label> [--out <pregate.json>]
* gstack-pregate lanes [--explain] the lane list and platform tiers read from .github/workflows
* gstack-pregate validate | init [--explain] [--force] the working-tree .gstack/pregate.json
* gstack-pregate sealed [--range <a..b>] [--remote <name>] [--base <branch>] [--json]
* the sealed-output guard (lib/sealed-guard.ts) before any push or PR creation
*
* Runs at the entry of /ship Step 5 (before Step 6's paid evals and any
* remote gate) under `set -o pipefail`, aggregates exit codes itself, prints
@@ -28,6 +30,7 @@ import { CHECKS, CHECK_IDS, PREGATE_OUT_REL, PREGATE_TIER2, clearObligation, rea
import { describeLane, discoverLanes } from '../lib/pregate/workflows';
import { ensureTmpExcluded } from '../lib/restamp';
import { resultLine, type ResultCodeName } from '../lib/result-codes';
import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard';
import { policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
const HELP = `gstack-pregate — cheap checks before anything paid or remote (docs/pregate.md)
@@ -37,6 +40,9 @@ const HELP = `gstack-pregate — cheap checks before anything paid or remote (do
clear record the receipt that clears one requires-remote obligation
lanes print every CI lane with its platform tier (from .github/workflows/*.yml)
validate | init the working-tree ${PREGATE_CONFIG_PATH}
sealed [--range <a..b>] [--remote <name>] [--base <branch>]
the sealed-output guard (docs/pregate.md#sealed) over the commits <remote> lacks, or the range:
run it before any push or PR creation in a repo with a \`sealed\` config; exit 1 refuses (SEALED_OUTPUT_REFUSED)
--stage preflight|tests|all default all
--only <check,...> restrict to these checks (${CHECK_IDS.join(', ')}; tier 2 later: ${PREGATE_TIER2.join(', ')})
@@ -62,6 +68,7 @@ const VERB_FLAGS: Record<string, [string[], string[]]> = {
lanes: [[], ['--explain', '--json']],
validate: [[], ['--json']],
init: [[], ['--explain', '--force']],
sealed: [['--range', '--base', '--remote'], ['--json']],
};
const HINTS: Record<string, string> = { '--sealed': 'the sealed-output guard is the verb `gstack-pregate sealed`' };
@@ -152,6 +159,33 @@ function cmdClear(root: string, rest: string[]): number {
return EXIT.ok;
}
function gitOut(root: string, args: string[]): string | null {
const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 30_000 });
return r.status === 0 ? r.stdout.trim() : null;
}
/** The sealed-output guard before any push or PR creation: the commits `--remote` lacks (or `--range a..b`), under base ∪ pushed policy. */
function cmdSealed(root: string, rest: string[], json: boolean): number {
const base = resolveBaseBranch(root, flag(rest, '--base'));
const remote = flag(rest, '--remote') ?? 'origin';
const range = flag(rest, '--range');
const end = range ? (range.split(/\.\.\.?/)[1] || 'HEAD') : 'HEAD';
const pushedSha = gitOut(root, ['rev-parse', '--verify', '--quiet', `${end}^{commit}`]);
if (!pushedSha) { process.stderr.write(`gstack-pregate sealed: ${end} is not a commit\n`); return EXIT.usage; }
const listed = range ? rangeCommits(root, range) : publishedCommits(root, { localSha: pushedSha, remoteName: remote });
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 }
: 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`);
return blocks ? EXIT.fail : EXIT.ok;
}
function main(argv: string[]): number {
const cmd = argv[0] && !argv[0].startsWith('--') ? argv[0] : 'run';
const rest = argv[0] && !argv[0].startsWith('--') ? argv.slice(1) : argv;
@@ -164,6 +198,7 @@ function main(argv: string[]): number {
if (cmd === 'run') return cmdRun(root, rest, json);
if (cmd === 'verify') return cmdVerify(root, rest, json);
if (cmd === 'clear') return cmdClear(root, rest);
if (cmd === 'sealed') return cmdSealed(root, rest, json);
if (cmd === 'lanes') {
const lanes = discoverLanes(root);
if (json) { process.stdout.write(JSON.stringify(lanes) + '\n'); return EXIT.ok; }
+28 -1
View File
@@ -29,6 +29,10 @@
* line, `#` comments. Only a finding whose matched text equals an entry is
* suppressed; every push that carries entries lists what they suppressed.
* - GSTACK_REDACT_PREPUSH=skip → log + exit 0 (escape valve).
* - A repo with a `sealed` key in .gstack/pregate.json (base branch ∪ pushed
* commit): lib/sealed-guard.ts checks every commit the push publishes for
* sealed or eval output; a public (or unknown) destination refuses
* (SEALED_OUTPUT_REFUSED), a private one warns.
*
* Installed/uninstalled via `gstack-redact install-prepush-hook` (see the
* gstack-redact CLI), which chains any pre-existing hook.
@@ -39,6 +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";
const ZERO = /^0+$/;
let emptyTree: string | undefined;
@@ -594,6 +599,21 @@ function mediumLine(f: Finding, part: AddedFile): string {
return ` MEDIUM ${f.id} ${where} fix: ${fix}\n`;
}
/** The sealed-output guard over every commit this push publishes (lib/sealed-guard.ts); null when nothing is pushed. */
function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): SealedReport | null {
if (!refs.length) return null;
const target = pushTarget();
const remoteName = target.kind === "remote" ? target.name : target.kind === "unknown" && git(["remote"]).split("\n").includes("origin") ? "origin" : null;
const remoteUrl = process.argv[3] || (remoteName ? git(["remote", "get-url", remoteName]).trim() : "") || null;
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" };
targets.push({ commits: published.commits, pushedSha: ref.localSha });
}
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
}
function main() {
if ((process.env.GSTACK_REDACT_PREPUSH || "").toLowerCase() === "skip") {
logSkip(process.env.GSTACK_REDACT_PREPUSH_REASON || "env-skip");
@@ -612,6 +632,7 @@ function main() {
const allowEntries = new Set<string>();
const historyRevs = new Set<string>();
let trackingRemote: string | null = null;
const pushedRefs: Array<{ localSha: string; remoteSha: string }> = [];
for (const fields of refs) {
// Fail CLOSED on a ref line we cannot parse (#2498): git hands pre-push
@@ -630,6 +651,7 @@ function main() {
process.exit(1);
}
if (ZERO.test(localSha!)) continue; // branch delete → nothing pushed
pushedRefs.push({ localSha: localSha!, remoteSha: remoteSha! });
let added: AddedFile[];
try {
added = addedLinesFor(localSha, remoteSha || "0");
@@ -710,6 +732,11 @@ function main() {
}
}
const sealed = sealedForPush(pushedRefs);
const sealedText = sealed ? renderSealed(sealed, "gstack sealed-output guard") : "";
if (sealedText) process.stderr.write(sealedText);
const sealedBlocks = !!sealed && (sealed.status === "refused" || sealed.status === "error");
if (allHigh.length > 0) {
// A scan that could not RUN is not a scan that FOUND something. Reporting
// "credential(s) in the pushed diff — rotate the credential" for an
@@ -757,7 +784,7 @@ function main() {
process.exit(1);
}
process.exit(0);
process.exit(sealedBlocks ? 1 : 0);
}
main();
+47
View File
@@ -64,6 +64,7 @@ the byte-pinned `/ship` renders in `test/fixtures/golden/`).
| `strays.allow` | intentional root files or new top-level directories |
| `guards` | `[{ id, when: [globs], command, fix?, timeout_s? }]`: repo commands a lane must pass when a touched file matches `when` (manifest checks, subprocess goldens the import graph cannot see); see [guards](#guards) |
| `serial_patterns` | test-file globs the `lanes` check runs one process per file, apart from the unit files (default `**/*.serial.test.*`) |
| `sealed` | sealed or eval output that must never reach a public destination; read by the pre-push hook and `gstack-pregate sealed` (see [sealed output](#sealed)) |
| `price_file` | repo-relative path of the paid gate's call-plan data file (`GSTACK_EVAL_CALLS v1`); `gstack-eval-plan price-gate` prices from it and never executes anything ([docs/eval-calls.md](eval-calls.md)) |
Only the `origin/<base>` copy is honoured, so a downgrade is reviewed before it
@@ -163,6 +164,52 @@ guard is a *command* with its own fix line. Older gstack refuses unknown keys
that reads them ships. The worked gbrain configuration is in
[gbrain worked configs](#gbrain-worked-configs).
<a id="sealed"></a>
## Sealed output: the pre-push hook and `gstack-pregate sealed`
```json
{ "sealed": {
"paths": ["heldout/**"],
"transcripts": ["runs/**/transcript*.jsonl"],
"answer_markers": ["SEALED ANSWER:"],
"seed_patterns": ["shuffle_seed=\\d+"],
"local_paths": true } }
```
A repo with a `sealed` key gets a configured local check against accidental
publication whenever the managed pre-push hook (`gstack-redact
install-prepush-hook`, no reinstall needed) or the standalone preflight runs:
```
gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--json]
```
- **Boundary.** Every commit the push publishes: `git rev-list <local sha>
--not --remotes=<remote>` (and the remote tip when the ref exists); a
destination with no refs means everything reachable. `--range` names the
commits explicitly.
- **Rules.** `sealed.path` and `sealed.transcript` check every path a commit
adds, renames or copies, whatever its content (binary, empty, deleted again
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
(`/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\`) check each commit's added
lines. Findings print rule, `file[:line]` and commit, never the text.
- **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
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.
- **Visibility.** `sealed.visibility` from the base branch, else a cache under
the state root (`sealed-visibility.json`: a `public` answer for 24 hours, a
`private` one for 1 hour before it may turn a refusal into a warning), else
`gh repo view --json visibility` (5 s). Unknown, unreadable or newly created
counts as public. Public refuses (`SEALED_OUTPUT_REFUSED`, exit 1); private
warns.
Capy's pull-request tool may not run local git hooks, so an agent runs
`gstack-pregate sealed` before any push or PR creation in a repo with a
`sealed` config (the coordinator contract says so). Hosted CI on the public
repository only detects a bypass after the upload.
<a id="issue-links"></a>
## `gstack-issue-links`
+20
View File
@@ -2480,3 +2480,23 @@ downgraded to warn.
and do what its `fix` line says, commit, then rerun
`gstack-pregate run --allow-repo-commands`. A lane reports done only after this
passes and carries the `PREGATE:` line in its handoff.
<a id="sealed-output-refused"></a>
### `SEALED_OUTPUT_REFUSED`
**Meaning.** The repo declares a `sealed` policy in `.gstack/pregate.json`
(base branch ∪ the pushed commit) and a commit this push publishes adds a
sealed path or transcript (binary, empty and later-deleted files included), or
adds a line with a sealed-answer marker, a seed or a local home path, and the
destination is public or its visibility is unknown (a new or unreadable
repository counts as public). An unreadable or malformed sealed policy refuses
too. The managed pre-push hook and `gstack-pregate sealed` print one line per
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
`.gstack/sealed-allow.tsv`: `rule<TAB>path<TAB>sha<TAB>reason`. A private
destination sets `"visibility": "private"` under `sealed` in the base branch's
`.gstack/pregate.json`. Capy's pull-request tool may not run local hooks, so
run `gstack-pregate sealed` yourself before any push or PR creation.
+6 -1
View File
@@ -18,6 +18,8 @@
* (manifest checks, CLI goldens); never downgradable
* serial_patterns globs of test files the lanes check runs one process each
* (default: every `*.serial.test.*` file)
* sealed the sealed-output guard's rules (lib/sealed-guard.ts; read by
* the pre-push hook and `gstack-pregate sealed`, not by `run`)
* price_file the call-plan data file `gstack-eval-plan price-gate` prices
* a paid gate from (GSTACK_EVAL_CALLS v1; never executed)
*/
@@ -25,6 +27,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { spawnSync } from 'node:child_process';
import type { ResultCodeName } from '../result-codes';
import { sealedErrors } from '../sealed-guard';
import { containedRelative, globToRegExp } from '../ship-policy';
export const PREGATE_CONFIG_PATH = '.gstack/pregate.json';
@@ -49,7 +52,7 @@ export interface ConfigError { code: ResultCodeName; path: string; message: stri
const MAX_LIST = 256;
const MAX_COMMAND = 512;
const KEYS = ['runner', 'dependencies', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file'];
const KEYS = ['runner', 'dependencies', 'selection', 'warn', 'strays', 'guards', 'serial_patterns', 'price_file', 'sealed'];
const MAX_GUARDS = 32;
const GUARD_KEYS = ['id', 'when', 'command', 'fix', 'timeout_s'];
@@ -102,6 +105,7 @@ export function validateConfig(raw: unknown, knownChecks: readonly string[] = []
if (why) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'price_file', message: why });
}
if ('guards' in obj) guardErrors(obj.guards, errors);
if ('sealed' in obj) for (const e of sealedErrors(obj.sealed)) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: e.path, message: e.message });
if ('serial_patterns' in obj) {
const list = obj.serial_patterns;
if (!Array.isArray(list) || list.some(g => typeof g !== 'string')) errors.push({ code: 'PREGATE_CONFIG_INVALID', path: 'serial_patterns', message: 'must be an array of globs' });
@@ -208,6 +212,7 @@ export function explainConfig(): string {
'strays.allow: root files or new top-level directories that are intentional',
'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)',
'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)',
].join('\n') + '\n';
}
+5
View File
@@ -471,6 +471,11 @@ export const RESULT_CODES = {
summary: 'a repo guard from .gstack/pregate.json whose files the branch touched exited non-zero',
fix: 'run the guard command printed in the table, apply the guard\'s fix line, commit, then rerun `gstack-pregate run --allow-repo-commands`',
},
SEALED_OUTPUT_REFUSED: {
anchor: 'sealed-output-refused',
summary: 'a push or PR would publish sealed or eval output (a sealed path, a transcript, a quoted answer, a seed or a local home path) to a public or unknown destination, or the sealed policy is unreadable',
fix: 'remove it from every published commit, or add one reviewed `rule<TAB>path<TAB>sha<TAB>reason` line to the base branch\'s .gstack/sealed-allow.tsv; a private destination sets sealed.visibility "private" in the base branch\'s .gstack/pregate.json',
},
} as const satisfies Record<string, ResultCode>;
export type ResultCodeName = keyof typeof RESULT_CODES;
+276
View File
@@ -0,0 +1,276 @@
/**
* sealed-guard — the sealed-output guard (field wave CD4), shared by the
* managed pre-push hook (bin/gstack-redact-prepush) and the standalone
* preflight `gstack-pregate sealed [--range <a..b>]`.
*
* A repo opts in with a `sealed` key in `.gstack/pregate.json`:
* paths globs that may never be published (held-out sets, cells)
* transcripts globs of per-cell transcripts
* 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
* visibility "public" | "private": the destination override (reviewed on the base branch)
*
* The publication boundary is every commit the push publishes: the local sha
* minus everything the destination remote already has (`git rev-list <local>
* --not --remotes=<remote> ^<remote sha>`); a destination with no refs means
* everything reachable. Path rules see every path a commit adds, renames or
* 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
* 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.
* Findings name file, line, rule and commit, never the matched text.
*/
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 { 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 SealedFinding { rule: SealedRule; file: string; line?: number; commit: 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 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;
/** `<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 }' }];
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(', ')}` });
const list = (key: string, check: (s: string) => string | null) => {
if (!(key in o)) return;
const v = o[key];
if (!Array.isArray(v) || v.some(s => typeof s !== 'string')) { errors.push({ path: `sealed.${key}`, message: 'must be an array of strings' }); return; }
if (v.length > MAX_ENTRIES) errors.push({ path: `sealed.${key}`, message: `more than ${MAX_ENTRIES} entries` });
(v as string[]).forEach((s, i) => { const why = check(s); if (why) errors.push({ path: `sealed.${key}[${i}]`, message: `${JSON.stringify(s)} ${why}` }); });
};
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' });
if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' });
return errors;
}
function git(repoRoot: string, args: string[], input?: string): { ok: boolean; out: string; err: string } {
const r = spawnSync('git', args, { cwd: repoRoot, encoding: 'utf8', input, timeout: 120_000, maxBuffer: 256 * 1024 * 1024 });
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
}
export type PolicyRead = { policy: SealedPolicy | null; label: string } | { error: string; label: string };
/** The `sealed` key of `.gstack/pregate.json` at a commit; null when absent; an error when present but unreadable. */
export function sealedPolicyAt(repoRoot: string, treeish: string, label: string): PolicyRead {
const spec = `${treeish}:${SEALED_CONFIG_PATH}`;
if (!git(repoRoot, ['cat-file', '-e', spec]).ok) return { policy: null, label };
const show = git(repoRoot, ['cat-file', 'blob', spec]);
if (!show.ok) return { error: `cannot read ${SEALED_CONFIG_PATH} (${show.err})`, label };
let raw: any;
try { raw = JSON.parse(show.out); }
catch (e: any) { return /"sealed"/.test(show.out) ? { error: `${SEALED_CONFIG_PATH} is not valid JSON (${e.message})`, label } : { policy: null, label }; }
if (!raw || typeof raw !== 'object' || !('sealed' in raw)) return { policy: null, label };
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 };
}
/** Base ∪ pushed: lists concatenate, `local_paths` ORs; 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 };
}
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */
export function allowEntriesAt(repoRoot: string, treeish: string | null): AllowEntry[] {
if (!treeish) return [];
const show = git(repoRoot, ['cat-file', 'blob', `${treeish}:${SEALED_ALLOW_PATH}`]);
if (!show.ok) return [];
return show.out.split('\n').filter(l => l.trim() && !l.startsWith('#')).map(l => l.split('\t')).filter(f => f.length >= 4 && /^[0-9a-f]{7,64}$/.test(f[2]!) && f[3]!.trim())
.map(f => ({ rule: f[0]!.trim(), path: f[1]!.trim(), sha: f[2]!, reason: f.slice(3).join('\t').trim() }));
}
/** Commits a push publishes: local minus the remote's refs (and its tip for this ref); null remote and no tip → everything reachable. */
export function publishedCommits(repoRoot: string, o: { localSha: string; remoteSha?: string | null; remoteName?: string | null }): { commits: string[] } | { error: string } {
const not: string[] = [];
if (o.remoteName) not.push(`--remotes=${o.remoteName}/*`);
if (o.remoteSha && !/^0+$/.test(o.remoteSha) && git(repoRoot, ['cat-file', '-e', `${o.remoteSha}^{commit}`]).ok) not.push(o.remoteSha);
const r = git(repoRoot, ['rev-list', o.localSha, ...(not.length ? ['--not', ...not] : [])]);
if (!r.ok) return { error: `git rev-list failed: ${r.err.slice(0, 200)}` };
return { commits: r.out.split('\n').filter(Boolean) };
}
export function rangeCommits(repoRoot: string, range: string): { commits: string[] } | { error: string } {
const r = git(repoRoot, ['rev-list', range]);
if (!r.ok) return { error: `git rev-list ${range} failed: ${r.err.slice(0, 200)}` };
return { commits: r.out.split('\n').filter(Boolean) };
}
const HEX = /^[0-9a-f]{40,64}$/;
/** 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 tokens = r.out.split('\0');
let commit = '';
for (let i = 0; i < tokens.length; i++) {
const t = tokens[i]!;
if (!t) continue;
if (HEX.test(t)) { commit = t; continue; }
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 });
}
return out;
}
/** Text rules over each commit's added lines (`--cc` for merges: only the resolution's own lines). */
function textFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] {
const rules: Array<[SealedRule, RegExp]> = [
...p.answer_markers.map(r => ['sealed.answer', new RegExp(r)] as [SealedRule, RegExp]),
...p.seed_patterns.map(r => ['sealed.seed', new RegExp(r)] as [SealedRule, RegExp]),
...(p.local_paths ? [['sealed.local_path', LOCAL_PATH_RE] as [SealedRule, RegExp]] : []),
];
if (!rules.length) return [];
const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '-p', '--unified=0', '--no-color', '--no-ext-diff', '--no-textconv'], commits.join('\n') + '\n');
if (!r.ok) throw new Error(`git diff-tree -p failed: ${r.err.slice(0, 200)}`);
const out: SealedFinding[] = [];
let commit = ''; let file = ''; let inHunk = false; let parents = 1; let next = 0;
for (const line of r.out.split('\n')) {
if (HEX.test(line)) { commit = line; inHunk = false; continue; }
if (line.startsWith('diff --')) { inHunk = false; file = ''; continue; }
if (line.startsWith('@@')) { inHunk = true; parents = (/^@+/.exec(line)?.[0].length ?? 2) - 1; next = Number(/ \+(\d+)(?:,\d+)? @+/.exec(line)?.[1] ?? 0); continue; }
if (!inHunk) { if (line.startsWith('+++ ')) file = line.startsWith('+++ b/') ? line.slice(6) : ''; continue; }
const prefix = line.slice(0, parents);
if (line.startsWith('\\') || prefix.includes('-')) continue;
const n = next++;
if (!prefix.includes('+')) continue;
const text = line.slice(parents);
for (const [rule, re] of rules) if (re.test(text)) out.push({ rule, file, line: n, commit });
}
return out;
}
export function scanCommits(repoRoot: string, commits: string[], policy: SealedPolicy): SealedFinding[] {
if (!commits.length) return [];
const seen = new Set<string>();
return [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy)].filter(f => {
const key = `${f.rule}\0${f.file}\0${f.line ?? ''}\0${f.commit}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
/** `owner/repo` for a GitHub remote URL; null for anything else (a local path, another host). */
export function githubSlug(url: string | null | undefined): string | null {
const m = /^(?:https?:\/\/(?:[^@/]+@)?github\.com\/|ssh:\/\/git@github\.com\/|git@github\.com:)([A-Za-z0-9-]+)\/([A-Za-z0-9._-]+?)(?:\.git)?\/?$/.exec((url ?? '').trim());
return m ? `${m[1]}/${m[2]}` : null;
}
export interface VisibilityOptions { env?: NodeJS.ProcessEnv; gh?: (args: string[]) => { ok: boolean; out: string }; now?: number }
/** Destination visibility: cache (public 24 h, private 1 h), else `gh repo view` (5 s); unknown on any failure or a non-GitHub remote. */
export function resolveVisibility(url: string | null, o: VisibilityOptions = {}): { visibility: Visibility; source: string } {
const slug = githubSlug(url);
if (!slug) return { visibility: 'unknown', source: 'not a GitHub remote' };
const now = o.now ?? Date.now();
const file = path.join(resolveStateRoot(o.env ?? process.env), 'sealed-visibility.json');
let cache: Record<string, { visibility: Visibility; checked_at: number }> = {};
try { cache = JSON.parse(fs.readFileSync(file, 'utf8')).entries ?? {}; } catch { cache = {}; }
const hit = cache[slug];
if (hit && ((hit.visibility === 'public' && now - hit.checked_at < PUBLIC_TTL_MS) || (hit.visibility === 'private' && now - hit.checked_at < PRIVATE_TTL_MS))) return { visibility: hit.visibility, source: `cache ${new Date(hit.checked_at).toISOString()}` };
const gh = o.gh ?? ((args: string[]) => { const r = spawnSync('gh', args, { encoding: 'utf8', timeout: 5_000, env: o.env ?? process.env }); return { ok: r.status === 0, out: r.stdout ?? '' }; });
const r = gh(['repo', 'view', slug, '--json', 'visibility']);
let v: string | undefined;
try { v = r.ok ? String(JSON.parse(r.out).visibility ?? '').toLowerCase() : undefined; } catch { v = undefined; }
if (v !== 'public' && v !== 'private' && v !== 'internal') return { visibility: 'unknown', source: `gh repo view ${slug} failed (a new or unreadable repository counts as public)` };
const visibility: Visibility = v === 'public' ? 'public' : 'private';
cache[slug] = { visibility, checked_at: now };
try { fs.mkdirSync(path.dirname(file), { recursive: true }); fs.writeFileSync(file, JSON.stringify({ entries: cache }, null, 2) + '\n'); } catch { /* an unwritable cache only costs a lookup */ }
return { visibility, source: `gh repo view ${slug}` };
}
export interface SealedReport {
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
policyLabels: string[]; commits: number; findings: SealedFinding[]; waived: Array<SealedFinding & { reason: string }>;
visibility: Visibility; visibilitySource: string; target: string; error?: string;
}
export interface EvaluateOptions {
repoRoot: string; targets: Array<{ commits: string[]; pushedSha: string }>; baseRef: string | null; baseLabel?: string; remoteUrl: string | null; target: string;
env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh'];
}
/** 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 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;
const labels = base.policy ? [base.label] : [];
for (const t of o.targets) {
const pushed = sealedPolicyAt(o.repoRoot, t.pushedSha, `pushed ${t.pushedSha.slice(0, 12)}`);
if ('error' in pushed) return { ...blank, status: 'error', error: `the sealed policy in ${pushed.label} is unreadable: ${pushed.error}` };
if (pushed.policy) labels.push(pushed.label);
policy = unionPolicies(policy, pushed.policy);
}
if (!policy) return { ...blank, status: 'no-policy' };
const commits = [...new Set(o.targets.flatMap(t => t.commits))];
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 allow = allowEntriesAt(o.repoRoot, o.baseRef);
const waived: SealedReport['waived'] = [];
const findings = found.filter(f => {
const entry = allow.find(a => a.rule === f.rule && a.path === f.file && f.commit.startsWith(a.sha));
if (entry) waived.push({ ...f, reason: entry.reason });
return !entry;
});
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 };
}
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${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 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)}`);
if (r.status === 'error') {
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} — ${r.error}.`);
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)`);
} 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);
}
return lines.length ? lines.join('\n') + '\n' : '';
}
+2 -1
View File
@@ -105,7 +105,8 @@
{ "file": "lib/pregate/checks/literals.ts", "movedFrom": "(new: pre-gate check, C8)" },
{ "file": "lib/pregate/checks/lanes.ts", "movedFrom": "(new: pre-gate check with the pipefail runner and requires-remote obligations, C8)" },
{ "file": "lib/pregate/checks/guards.ts", "movedFrom": "(new: pre-gate repo guards, field wave CD1)" },
{ "file": "lib/pregate/runtime.ts", "movedFrom": "(new: the verified launch Bun for repo commands, field wave CD1)" }
{ "file": "lib/pregate/runtime.ts", "movedFrom": "(new: the verified launch Bun for repo commands, field wave CD1)" },
{ "file": "lib/sealed-guard.ts", "movedFrom": "(new: the sealed-output guard shared by the pre-push hook and gstack-pregate sealed, field wave CD4)" }
],
"residualFiles": [
{ "file": "scripts/test-free-shards.ts", "maxLines": 2361, "reason": "free lane policy after the shard engine took spawn, sandbox, logs, seeds and flags" },
+10
View File
@@ -127,6 +127,16 @@ describe('guards (CD1)', () => {
});
});
describe('pregate config: sealed (CD4)', () => {
test('`gstack-pregate validate` accepts a sealed policy and names each bad field', () => {
expect(codes({ sealed: { paths: ['heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], answer_markers: ['SEALED ANSWER:'], seed_patterns: ['seed=\\d+'], local_paths: true, visibility: 'private' } })).toEqual([]);
expect(codes({ sealed: { paths: 'heldout/**' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.paths']);
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']);
});
});
describe('gstack-pregate flags (CD1)', () => {
test('an unknown flag is usage (exit 2) on every verb instead of a silent normal run', () => {
const repo = makeFixtureRepo(); repos.push(repo);
+223
View File
@@ -0,0 +1,223 @@
/**
* Field wave CD4: the sealed-output guard. A repo that declares `sealed` in
* `.gstack/pregate.json` cannot push sealed or eval output to a public
* destination by accident: the managed pre-push hook and
* `gstack-pregate sealed` enumerate every commit the push publishes (local
* sha minus everything the destination remote already has), apply path
* rules to every added, renamed or copied path (binary and empty files,
* add-then-delete) and text rules to each commit's added lines. The policy
* is the union of the base branch's and the pushed one; waivers come only
* from the base branch's allow file; an unknown visibility counts as public.
* Field incident: a per-cell transcript, a quoted sealed answer, a seed and a
* /home/user path were pushed to a public eval repo with exit 0 and no output.
*/
import { afterEach, describe, expect, test } from 'bun:test';
import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { resolveVisibility } from '../lib/sealed-guard';
const ROOT = path.resolve(import.meta.dir, '..');
const PREPUSH = path.join(ROOT, 'bin', 'gstack-redact-prepush');
const PREGATE = path.join(ROOT, 'bin', 'gstack-pregate');
const ZERO = '0'.repeat(40);
const SEALED = {
paths: ['heldout/**'],
transcripts: ['runs/**/transcript*.jsonl'],
answer_markers: ['SEALED ANSWER:'],
seed_patterns: ['shuffle_seed=\\d+'],
local_paths: true,
};
interface Scratch { root: string; work: string; origin: string; state: string; env: Record<string, string> }
const scratches: Scratch[] = [];
afterEach(() => { while (scratches.length) fs.rmSync(scratches.pop()!.root, { recursive: true, force: true }); });
function sh(cwd: string, env: Record<string, string>, args: string[]): string {
const r = spawnSync('git', args, { cwd, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...env } });
if (r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`);
return (r.stdout ?? '').trim();
}
/** A bare origin with main carrying the given base files, and a clone on branch `results`. */
function scratch(baseFiles: Record<string, string | Buffer>): Scratch {
const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-guard-')));
const work = path.join(root, 'work'); const origin = path.join(root, 'origin.git'); const state = path.join(root, 'state');
fs.mkdirSync(work); fs.mkdirSync(state);
const env = { HOME: root, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' };
fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@example.com\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n');
spawnSync('git', ['init', '-q', '--bare', origin], { env: { ...process.env, ...env } });
sh(work, env, ['init', '-q', '-b', 'main']);
write(work, { 'README.md': '# evals\n', ...baseFiles });
sh(work, env, ['add', '-A']); sh(work, env, ['commit', '-q', '-m', 'base']);
sh(work, env, ['remote', 'add', 'origin', origin]);
sh(work, env, ['push', '-q', 'origin', 'main']);
sh(work, env, ['remote', 'set-head', 'origin', 'main']);
sh(work, env, ['checkout', '-q', '-b', 'results']);
const s = { root, work, origin, state, env };
scratches.push(s);
return s;
}
function write(work: string, files: Record<string, string | Buffer | null>): void {
for (const [rel, body] of Object.entries(files)) {
const abs = path.join(work, rel);
if (body === null) { fs.rmSync(abs, { force: true }); continue; }
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, body);
}
}
function commit(s: Scratch, files: Record<string, string | Buffer | null>, msg = 'results'): string {
write(s.work, files);
sh(s.work, s.env, ['add', '-A']); sh(s.work, s.env, ['commit', '-q', '-m', msg]);
return sh(s.work, s.env, ['rev-parse', 'HEAD']);
}
const policy = (sealed: unknown) => JSON.stringify({ sealed }, null, 2) + '\n';
/** Drive the hook exactly as git does for `git push origin results` (a new remote branch). */
function hook(s: Scratch, extraEnv: Record<string, string> = {}) {
const head = sh(s.work, s.env, ['rev-parse', 'HEAD']);
const r = spawnSync('bun', [PREPUSH, 'origin', s.origin], { cwd: s.work, input: `refs/heads/results ${head} refs/heads/results ${ZERO}\n`, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env, ...extraEnv } });
return { code: r.status, err: r.stderr ?? '' };
}
describe('pre-push hook: public destination refuses each kind of sealed output', () => {
test('a transcript, a sealed answer, a seed and a local home path each refuse, naming file, line and rule, never the matched text', () => {
const cases: Array<[Record<string, string | Buffer>, string, RegExp]> = [
[{ 'runs/cat40/cell-07/transcript.jsonl': '{"q":"x"}\n' }, 'sealed.transcript', /sealed\.transcript\s+runs\/cat40\/cell-07\/transcript\.jsonl/],
[{ 'runs/cat40/summary.md': 'intro\nSEALED ANSWER: fund-a led the round\n' }, 'sealed.answer', /sealed\.answer\s+runs\/cat40\/summary\.md:2/],
[{ 'runs/cat40/summary.md': 'run with shuffle_seed=42\n' }, 'sealed.seed', /sealed\.seed\s+runs\/cat40\/summary\.md:1/],
[{ 'runs/cat40/summary.md': 'raw at /home/user/.capy/work/evals/raw.jsonl\n' }, 'sealed.local_path', /sealed\.local_path\s+runs\/cat40\/summary\.md:1/],
];
for (const [files, rule, where] of cases) {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(s, files);
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toMatch(where);
expect(r.err).toContain(rule);
expect(r.err).toContain('(SEALED_OUTPUT_REFUSED)');
expect(r.err).toContain('sealed.visibility');
expect(r.err).not.toContain('fund-a led');
}
});
test('path rules ignore content: a binary file, an empty transcript, an add-then-delete and a rename into a sealed path all refuse', () => {
const binary = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(binary, { 'heldout/questions.bin': Buffer.from([0, 1, 2, 0, 255, 0]) });
expect(hook(binary).err).toMatch(/sealed\.path\s+heldout\/questions\.bin/);
const empty = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(empty, { 'runs/c1/transcript.jsonl': '' });
expect(hook(empty).code).toBe(1);
const gone = scratch({ '.gstack/pregate.json': policy(SEALED) });
const added = commit(gone, { 'runs/c1/transcript.jsonl': '{"q":1}\n' });
commit(gone, { 'runs/c1/transcript.jsonl': null }, 'remove it again');
const g = hook(gone);
expect(g.code).toBe(1);
expect(g.err).toContain(`sealed.transcript runs/c1/transcript.jsonl commit ${added.slice(0, 12)}`);
const moved = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(moved, { 'scratch/t.jsonl': '{"q":1}\n{"q":2}\n{"q":3}\n' });
fs.mkdirSync(path.join(moved.work, 'heldout'));
sh(moved.work, moved.env, ['mv', 'scratch/t.jsonl', 'heldout/t.jsonl']);
sh(moved.work, moved.env, ['commit', '-q', '-m', 'move']);
expect(hook(moved).err).toMatch(/sealed\.path\s+heldout\/t\.jsonl/);
});
test('commits the destination already has are not this push: a branch from main that only adds a doc passes', () => {
const s = scratch({ 'runs/old/transcript.jsonl': '{"q":"published before the policy"}\n', '.gstack/pregate.json': policy(SEALED) });
commit(s, { 'docs/notes.md': 'notes\n' });
const r = hook(s);
expect(r.code).toBe(0);
expect(r.err).not.toContain('sealed.');
});
});
describe('policy union, waivers, private destinations and fail-closed parsing', () => {
test('a push can tighten the policy but never relax it: dropping the base sealed config does not let a transcript through', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(s, { '.gstack/pregate.json': '{}\n', 'runs/c1/transcript.jsonl': '{}\n' });
expect(hook(s).code).toBe(1);
const tighten = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(tighten, { '.gstack/pregate.json': policy({ ...SEALED, paths: ['heldout/**', 'cells/**'] }), 'cells/c1.json': '{}\n' });
expect(hook(tighten).err).toMatch(/sealed\.path\s+cells\/c1\.json/);
});
test('a waiver counts only from the base branch allow file and is listed; the same line committed in the push is ignored', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
const sha = commit(s, { 'runs/c1/transcript.jsonl': '{}\n' });
const allow = `# rule\tpath\tsha\treason\nsealed.transcript\truns/c1/transcript.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture, no sealed content\n`;
commit(s, { '.gstack/sealed-allow.tsv': allow });
expect(hook(s).code).toBe(1);
sh(s.work, s.env, ['checkout', '-q', 'main']);
write(s.work, { '.gstack/sealed-allow.tsv': allow });
sh(s.work, s.env, ['add', '-A']); sh(s.work, s.env, ['commit', '-q', '-m', 'allow']); sh(s.work, s.env, ['push', '-q', 'origin', 'main']);
sh(s.work, s.env, ['checkout', '-q', 'results']);
const r = hook(s);
expect(r.code).toBe(0);
expect(r.err).toContain('waived sealed.transcript runs/c1/transcript.jsonl');
});
test('a private destination (base override) warns and passes; a repo without the key sees no change; a malformed sealed policy refuses', () => {
const priv = scratch({ '.gstack/pregate.json': policy({ ...SEALED, visibility: 'private' }) });
commit(priv, { 'runs/c1/transcript.jsonl': '{}\n' });
const p = hook(priv);
expect(p.code).toBe(0);
expect(p.err).toMatch(/warning.*sealed\.transcript\s+runs\/c1\/transcript\.jsonl/s);
const none = scratch({});
commit(none, { 'runs/c1/transcript.jsonl': '{}\n' });
const n = hook(none);
expect(n.code).toBe(0);
expect(n.err).not.toContain('sealed');
const bad = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(bad, { '.gstack/pregate.json': policy({ paths: 'heldout/**' }), 'docs/x.md': 'x\n' });
const b = hook(bad);
expect(b.code).toBe(1);
expect(b.err).toContain('(SEALED_OUTPUT_REFUSED)');
expect(b.err).toContain('sealed policy');
});
});
describe('gstack-pregate sealed (the standalone preflight before any push or PR creation)', () => {
test('--range refuses the same findings with exit 1; the default range is what origin does not have yet', () => {
const s = scratch({ '.gstack/pregate.json': policy(SEALED) });
commit(s, { 'runs/c1/transcript.jsonl': '{}\n' });
const run = (args: string[]) => spawnSync(PREGATE, ['sealed', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
const ranged = run(['--range', 'origin/main..HEAD', '--base', 'main']);
expect(ranged.status).toBe(1);
expect(ranged.stderr).toMatch(/sealed\.transcript\s+runs\/c1\/transcript\.jsonl/);
expect(ranged.stdout).toMatch(/^SEALED: refused findings=1 commits=1 visibility=unknown \(counted public\)/m);
const dflt = run(['--base', 'main']);
expect(dflt.status).toBe(1);
sh(s.work, s.env, ['push', '-q', 'origin', 'results']);
const after = run(['--base', 'main']);
expect(after.status).toBe(0);
expect(after.stdout).toMatch(/^SEALED: clear findings=0 commits=0/m);
});
});
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-'));
try {
let calls = 0;
let answer: string | null = 'PUBLIC';
const gh = () => { calls += 1; return answer === null ? { ok: false, out: '' } : { ok: true, out: JSON.stringify({ visibility: answer }) }; };
const env = { GSTACK_STATE_ROOT: state };
const t0 = Date.parse('2026-10-12T00:00:00Z');
expect(resolveVisibility('https://github.com/acme-example/evals.git', { env, gh, now: t0 }).visibility).toBe('public');
expect(resolveVisibility('https://github.com/acme-example/evals.git', { env, gh, now: t0 + 60_000 }).visibility).toBe('public');
expect(calls).toBe(1);
answer = 'PRIVATE';
expect(resolveVisibility('git@github.com:acme-example/private-evals.git', { env, gh, now: t0 }).visibility).toBe('private');
expect(resolveVisibility('git@github.com:acme-example/private-evals.git', { env, gh, now: t0 + 30 * 60_000 }).visibility).toBe('private');
expect(calls).toBe(2);
answer = null;
const stale = resolveVisibility('git@github.com:acme-example/private-evals.git', { env, gh, now: t0 + 2 * 3600_000 });
expect(calls).toBe(3);
expect(stale.visibility).toBe('unknown');
expect(resolveVisibility('/srv/git/evals.git', { env, gh, now: t0 }).visibility).toBe('unknown');
expect(calls).toBe(3);
expect(fs.existsSync(path.join(state, 'sealed-visibility.json'))).toBe(true);
} finally { fs.rmSync(state, { recursive: true, force: true }); }
});
});