mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-05 00:03:15 +01:00
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:
1 parent
1898142986
commit
67dd3449eb
11 files changed
+653
-3
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -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();
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }); }
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user