v1.91.311.0 feat(pregate): sealed.allow_paths exceptions and opt-in case_insensitive deny globs (GSTA-145) (#3556)

* feat(pregate): sealed.allow_paths exceptions and opt-in case_insensitive deny globs (GSTA-145)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): sealed allow_paths and case_insensitive (Unreleased)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix: pre-landing review fixes

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(pregate): state exactly which allow_paths file names are refused

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(troubleshooting): sealed allow_paths exemption and newer-gstack sealed keys

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
Garry Tanandcapy-ai[bot] authored and GitHub committed 2026-10-29 08:26:30 +00:00
1 parent ff2f191b47
commit 94165fff39
12 files changed
+256 -32

No files matched your search

+16
View File
@@ -1,5 +1,21 @@
# Changelog # Changelog
## [1.91.311.0] - 2026-10-29
**A sealed policy can now refuse `sealed/<set>/tasks.json` while allowing the `MANIFEST.json` beside it, and can catch `HELDOUT/` with a `heldout` glob.** The sealed guard only had deny globs and one-commit waivers, so the gstack-evals layout, which keeps a public manifest of hashes and counts next to its sealed tasks, needed a fresh `.gstack/sealed-allow.tsv` line for every manifest commit (gstack-evals wiki/board/2026-10-28-gsta-9-sealed-manifest-glob-gap.md). Their first draft also wrote `sealed/**`, which never matched their nested `tasks/<id>/sealed/` directories, and garrytan/gstack-evals#3 (dc54796) hand-listed `Sealed/`, `HELDOUT/` and other case variants.
### Added
- **`sealed.allow_paths`** (`lib/sealed-guard.ts`): globs that exempt publishable files from `sealed.path` and `sealed.transcript`, applied after the deny globs and before sha waivers. They are read from the destination base branch only, so a push cannot widen its own exceptions; a pushed copy is still validated and refuses when malformed. Text rules still read an exempt file, so a manifest that quotes a sealed answer still refuses. An entry whose file name contains `**` or is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`) is refused. Each exempt path prints `exempt <rule> <file> commit <sha> (sealed.allow_paths <glob>)` on every push.
- **`sealed.case_insensitive: true`**: `paths` and `transcripts` match regardless of case, so `**/heldout/**` also refuses `HELDOUT/` and `HeldOut/`. The value is the base branch's OR the pushed one, because it only tightens. `allow_paths` never fold case. Separator and spelling variants such as `hold-out/` still need their own globs.
### Changed
- `gstack-pregate sealed` prints `exempt=<n>` after `waived=<n>` on its `SEALED:` line (`policy=` stays last), and `--json` carries an `exempt` array. Both bins build their error report through one `sealedErrorReport`.
- An unknown `sealed` key now says to upgrade gstack if the key comes from a newer one. A gstack older than this release refuses `allow_paths` and `case_insensitive` this way on every push and `gstack-pregate run`, so a repository raises `min_gstack_version` before landing either key.
- `docs/pregate.md` § Sealed output explains glob anchoring: patterns match from the repository root, so `sealed/**` misses `tasks/x/sealed/a.json` and the docs say to write `**/sealed/**`. It also documents case handling, exceptions and the version skew.
### Gate integrity
- This is a gate edit: `allow_paths` lets the sealed guard pass files it refused before, but only from the reviewed base branch. No existing test, threshold or allowlist changed. New cases in `test/sealed-guard.test.ts` (10) and `test/pregate-guards.test.ts` cover an allowed exception, a non-matching one, an exception added on the pushed head, a text rule inside an exempt file, transcripts, the waiver order, the error report, case folding in both directions and the root-anchoring example.
## [1.91.310.0] - 2026-10-29 ## [1.91.310.0] - 2026-10-29
**The cookie workflow judge now asks for structured output, so a quote the model forgets to escape can no longer fail the gate.** Train 42 went red because both of its `setup-browser-cookies/SKILL.md workflow` samples came back as free text with a bare quote inside `reasoning` ("one --domain per run"), and neither parsed. The GSTA-91 comparison had measured that failure: 2 of 144 free-text samples failed to parse, against 0 of 72 with the schema. **The cookie workflow judge now asks for structured output, so a quote the model forgets to escape can no longer fail the gate.** Train 42 went red because both of its `setup-browser-cookies/SKILL.md workflow` samples came back as free text with a bare quote inside `reasoning` ("one --domain per run"), and neither parsed. The GSTA-91 comparison had measured that failure: 2 of 144 free-text samples failed to parse, against 0 of 72 with the schema.
+1 -1
View File
@@ -1 +1 @@
1.91.310.0 1.91.311.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.310.0 — regenerate/re-copy after upgrading gstack # gstack digest v1.91.311.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows, for agent hosts without a full skill install. The full skills add workflows,
+3 -3
View File
@@ -40,7 +40,7 @@ import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
import { cancelActive } from '../lib/pregate/supervise'; import { cancelActive } from '../lib/pregate/supervise';
import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate'; import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate';
import { GATE_AHEAD_REL } from '../lib/restamp'; import { GATE_AHEAD_REL } from '../lib/restamp';
import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard'; import { evaluateSealed, publishedCommits, rangeCommits, renderSealed, sealedErrorReport } from '../lib/sealed-guard';
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy'; import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
import { fromGateAhead } from '../lib/tree-receipt'; import { fromGateAhead } from '../lib/tree-receipt';
@@ -244,13 +244,13 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null; const 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 remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
const report = 'error' in listed const report = 'error' in listed
? { status: 'error' as const, error: listed.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: 'unknown' as const, visibilitySource: '', target: remoteUrl ?? remote } ? sealedErrorReport(listed.error, remoteUrl ?? remote)
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote }); : evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote });
const blocks = report.status === 'refused' || report.status === 'error'; 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; } 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')); 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}`; const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`;
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} policy=${policy}\n`); process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`);
return blocks ? EXIT.fail : EXIT.ok; return blocks ? EXIT.fail : EXIT.ok;
} }
+2 -2
View File
@@ -43,7 +43,7 @@ import * as path from "path";
import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine"; import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine";
import { mkdirpSync } from "../lib/fs-utils"; import { mkdirpSync } from "../lib/fs-utils";
import { resolveStateRoot } from "../lib/state-root"; import { resolveStateRoot } from "../lib/state-root";
import { evaluateSealed, publishedCommits, renderSealed, type SealedReport } from "../lib/sealed-guard"; import { evaluateSealed, publishedCommits, renderSealed, sealedErrorReport, type SealedReport } from "../lib/sealed-guard";
const ZERO = /^0+$/; const ZERO = /^0+$/;
let emptyTree: string | undefined; let emptyTree: string | undefined;
@@ -608,7 +608,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se
const targets: Array<{ commits: string[]; pushedSha: string }> = []; const targets: Array<{ commits: string[]; pushedSha: string }> = [];
for (const ref of refs) { for (const ref of refs) {
const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName }); const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName });
if ("error" in published) return { status: "error", error: published.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: "unknown", visibilitySource: "", target: remoteUrl ?? "the push target" }; if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target");
targets.push({ commits: published.commits, pushedSha: ref.localSha }); targets.push({ commits: published.commits, pushedSha: ref.localSha });
} }
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" }); return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
+35 -3
View File
@@ -768,11 +768,13 @@ with a timing flake: never raise the bound.
```json ```json
{ "sealed": { { "sealed": {
"paths": ["heldout/**"], "paths": ["heldout/**", "**/sealed/**"],
"transcripts": ["runs/**/transcript*.jsonl"], "transcripts": ["runs/**/transcript*.jsonl"],
"allow_paths": ["**/sealed/*/MANIFEST.json", "**/protected/MANIFEST.json"],
"answer_markers": ["SEALED ANSWER:"], "answer_markers": ["SEALED ANSWER:"],
"seed_patterns": ["shuffle_seed=\\d+"], "seed_patterns": ["shuffle_seed=\\d+"],
"local_paths": true } } "local_paths": true,
"case_insensitive": true } }
``` ```
A repo with a `sealed` key gets a configured local check against accidental A repo with a `sealed` key gets a configured local check against accidental
@@ -792,8 +794,38 @@ gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--js
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path` in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
(`/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\`) check each commit's added (`/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\`) check each commit's added
lines. Findings print rule, `file[:line]` and commit, never the text. lines. Findings print rule, `file[:line]` and commit, never the text.
- **Globs.** `paths`, `transcripts` and `allow_paths` match the whole
repo-relative path from the repository root. `*` and `?` stay inside one
path segment; `**/` spans zero or more directories; a `**` not followed by
`/` matches across directories (`heldout/**` covers every depth under
`heldout/`); `[` and `{` are literal. So `sealed/**` misses `tasks/x/sealed/a.json`:
to guard a `sealed/` directory wherever it sits, write `**/sealed/**`.
- **Case.** Matching is case-sensitive. `case_insensitive: true` folds case
for `paths` and `transcripts` only, so `**/heldout/**` also catches
`HELDOUT/` and `HeldOut/`. Separator and spelling variants (`hold-out/`,
`held_out/`, `heldout-v2/`) are not folded: list each as its own glob.
The effective value is the base branch's OR the pushed one (it only tightens).
- **Exceptions.** `allow_paths` exempts publishable files that sit next to
sealed data (a `MANIFEST.json` of hashes and counts) from `sealed.path` and
`sealed.transcript`, applied after the deny globs and before waivers. They
are read from the destination base branch only: an exception a push adds
takes effect once it lands on the base branch, so a branch can never widen
its own. They never fold case. An entry whose file name contains `**`, or
is only wildcards before its extension (`**`, `sealed/*`, `**/*.json`,
`sealed/**.json`), is refused; a partial wildcard such as `MANIFEST*.json`
is accepted and matches every file it spells, so name the file. An exempt file is checked by the
text rules only, so configure `answer_markers` when you exempt files next to
sealed data. Each exempt path is listed on every push (`exempt <rule>
<file> commit <sha> (sealed.allow_paths <glob>)`) and counted as
`exempt=<n>` on the `SEALED:` line.
- **Version skew.** A gstack older than the release whose CHANGELOG adds
`sealed.allow_paths` refuses `allow_paths` and `case_insensitive` as
unknown keys, so every push and `gstack-pregate run` in the repository
fail on that machine. Raise `min_gstack_version` in
`.gstack/ship-policy.json` to that release before landing either key.
- **Policy.** The union of the destination base branch's `sealed` and the - **Policy.** The union of the destination base branch's `sealed` and the
pushed commit's: a push can tighten it, never relax it. A malformed or pushed commit's: a push can tighten it, never relax it (`allow_paths` come
from the base branch alone, and a pushed deny glob cannot override one). A malformed or
unreadable policy refuses. Waivers come only from the base branch's unreadable policy refuses. Waivers come only from the base branch's
`.gstack/sealed-allow.tsv` (`rule<TAB>path<TAB>sha<TAB>reason`) and are `.gstack/sealed-allow.tsv` (`rule<TAB>path<TAB>sha<TAB>reason`) and are
listed on every push. listed on every push.
+9 -2
View File
@@ -3152,7 +3152,10 @@ cannot be downgraded; every other check id in `warn` is honoured only from the
base branch's copy or the user-owned file. For the user-owned file, edit it base branch's copy or the user-owned file. For the user-owned file, edit it
directly at the path the message names. A user file inside the repository, or directly at the path the message names. A user file inside the repository, or
reached through a symlink into it, is ignored rather than read (`--explain` reached through a symlink into it, is ignored rather than read (`--explain`
names it). names it). An unknown `sealed` key that a newer gstack defines (such as
`allow_paths` or `case_insensitive`) means this machine's gstack is older than
the policy: upgrade gstack here, and raise `min_gstack_version` in the
repository's `.gstack/ship-policy.json` before landing such a key.
<a id="pregate-secrets"></a> <a id="pregate-secrets"></a>
### `PREGATE_SECRETS` ### `PREGATE_SECRETS`
@@ -3658,7 +3661,11 @@ finding: rule, `file[:line]`, commit. They never print the matched text.
**Fix.** Remove the output from every published commit before the branch **Fix.** Remove the output from every published commit before the branch
reaches the public repository (a later commit that deletes it still publishes reaches the public repository (a later commit that deletes it still publishes
it). A reviewed false positive gets one line in the base branch's it). A publishable file kept next to sealed data (a `MANIFEST.json` of hashes
and counts) can be exempted from the path rules with a `sealed.allow_paths`
glob in the base branch's `.gstack/pregate.json`; the text rules still read it
([docs/pregate.md#sealed](pregate.md#sealed)). A reviewed false positive gets
one line in the base branch's
`.gstack/sealed-allow.tsv`: `rule<TAB>path<TAB>sha<TAB>reason`. A private `.gstack/sealed-allow.tsv`: `rule<TAB>path<TAB>sha<TAB>reason`. A private
destination sets `"visibility": "private"` under `sealed` in the base branch's 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 `.gstack/pregate.json`. Capy's pull-request tool may not run local hooks, so
+1 -1
View File
@@ -321,7 +321,7 @@ export function explainConfig(): string {
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote', 'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable', '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(', ')})`, `serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)', 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)',
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)', 'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)', 'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)', 'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
+44 -18
View File
@@ -9,6 +9,8 @@
* answer_markers regexes for a quoted sealed answer in added text * answer_markers regexes for a quoted sealed answer in added text
* seed_patterns regexes for a sampling seed 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 * local_paths true: a local home path (/home/<u>/, /Users/<u>/) in added text
* allow_paths globs exempt from the path rules (read from the base branch only; never folds case)
* case_insensitive true: `paths` and `transcripts` match regardless of case
* visibility "public" | "private": the destination override (reviewed on the base branch) * visibility "public" | "private": the destination override (reviewed on the base branch)
* *
* The publication boundary is every commit the push publishes: the local sha * The publication boundary is every commit the push publishes: the local sha
@@ -18,8 +20,9 @@
* copies, independent of content (binary, empty, add-then-delete); text rules * 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 * 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, * destination base branch's `sealed` and the pushed one (a push can tighten,
* never relax); waivers come only from the base branch's * never relax); `allow_paths` exceptions come only from the base branch's
* `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail * policy and remove path findings before waivers, which come only from the
* base branch's `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
* closed). Visibility: the base override, else a cache under the state root * 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 * (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. * downgrade a refusal), else `gh repo view` (5 s); unknown counts as public.
@@ -28,19 +31,20 @@
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { containedRelative, globToRegExp, matchesAny } from './ship-policy'; import { containedRelative, globToRegExp } from './ship-policy';
import { resolveStateRoot } from './state-root'; import { resolveStateRoot } from './state-root';
export const SEALED_CONFIG_PATH = '.gstack/pregate.json'; export const SEALED_CONFIG_PATH = '.gstack/pregate.json';
export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv'; export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv';
export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility'; export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility';
export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path'; export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path';
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; visibility?: 'public' | 'private' } export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; visibility?: 'public' | 'private' }
export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string } export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string }
export type ExemptFinding = SealedFinding & { glob: string };
export interface AllowEntry { rule: string; path: string; sha: string; reason: string } export interface AllowEntry { rule: string; path: string; sha: string; reason: string }
export type Visibility = 'public' | 'private' | 'unknown'; export type Visibility = 'public' | 'private' | 'unknown';
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'visibility']; const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility'];
const MAX_ENTRIES = 64; const MAX_ENTRIES = 64;
const MAX_REGEX = 256; const MAX_REGEX = 256;
const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/; const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/;
@@ -50,9 +54,9 @@ const PRIVATE_TTL_MS = 3600_000;
/** `<key>: <message>` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */ /** `<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 }> { export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> {
const errors: Array<{ path: string; message: string }> = []; const errors: Array<{ path: string; message: string }> = [];
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: 'must be an object { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }' }]; if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: `must be an object { ${KEYS.join(', ')} }` }];
const o = raw as Record<string, unknown>; const o = raw as Record<string, unknown>;
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')}` }); for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (if the key comes from a newer gstack, upgrade gstack on this machine)` });
const list = (key: string, check: (s: string) => string | null) => { const list = (key: string, check: (s: string) => string | null) => {
if (!(key in o)) return; if (!(key in o)) return;
const v = o[key]; const v = o[key];
@@ -62,8 +66,9 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin
}; };
const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } }; const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } };
const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } }; const 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); const exception = (g: string) => { const name = g.split('/').pop()!; return glob(g) ?? (name.includes('**') || /^[*?]+(\.|$)/.test(name) ? 'has a wildcard file name, which would exempt whole directories or file types; name the file (e.g. **/sealed/*/MANIFEST.json)' : null); };
if ('local_paths' in o && typeof o.local_paths !== 'boolean') errors.push({ path: 'sealed.local_paths', message: 'must be true or false' }); list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception);
for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, message: 'must be true or false' });
if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' }); if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' });
return errors; return errors;
} }
@@ -88,15 +93,15 @@ export function sealedPolicyAt(repoRoot: string, treeish: string, label: string)
const errors = sealedErrors(raw.sealed); const errors = sealedErrors(raw.sealed);
if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label }; if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label };
const s = raw.sealed; const s = raw.sealed;
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, visibility: s.visibility }, label }; return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, visibility: s.visibility }, label };
} }
/** Base ∪ pushed: lists concatenate, `local_paths` ORs; visibility stays private only when the base says so and the push does not say public. */ /** Base ∪ pushed: deny lists concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; visibility stays private only when the base says so and the push does not say public. */
export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null { export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null {
if (!base && !pushed) return null; if (!base && !pushed) return null;
const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])]; 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; const visibility = pushed?.visibility === 'public' || base?.visibility === 'public' ? 'public' : base?.visibility === 'private' ? 'private' : undefined;
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), visibility }; return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), visibility };
} }
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */ /** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */
@@ -126,12 +131,20 @@ export function rangeCommits(repoRoot: string, range: string): { commits: string
const HEX = /^[0-9a-f]{40,64}$/; const HEX = /^[0-9a-f]{40,64}$/;
/** The first glob matching a repo-relative path, compiled once per list (`matchesAny` normalization); null when none does. */
function globMatcher(globs: string[], ignoreCase: boolean): (file: string) => string | null {
const compiled = globs.map(g => { const re = globToRegExp(g); return { g, re: ignoreCase ? new RegExp(re.source, 'i') : re }; });
return file => { const norm = file.replace(/\\/g, '/').replace(/^\.\//, ''); return compiled.find(c => c.re.test(norm))?.g ?? null; };
}
/** Path rules over every added, renamed or copied path, per commit (merges: paths new against every parent). */ /** 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[] { function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] {
if (!p.paths.length && !p.transcripts.length) return []; 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'); 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)}`); if (!r.ok) throw new Error(`git diff-tree failed: ${r.err.slice(0, 200)}`);
const out: SealedFinding[] = []; const out: SealedFinding[] = [];
const transcript = globMatcher(p.transcripts, p.case_insensitive);
const sealedPath = globMatcher(p.paths, p.case_insensitive);
const tokens = r.out.split('\0'); const tokens = r.out.split('\0');
let commit = ''; let commit = '';
for (let i = 0; i < tokens.length; i++) { for (let i = 0; i < tokens.length; i++) {
@@ -141,8 +154,8 @@ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea
const renamed = /^[RC]\d*$/.test(t); const renamed = /^[RC]\d*$/.test(t);
const file = renamed ? tokens[i += 2]! : tokens[++i]!; const file = renamed ? tokens[i += 2]! : tokens[++i]!;
if (!renamed && !t.includes('A')) continue; if (!renamed && !t.includes('A')) continue;
if (matchesAny(p.transcripts, file)) out.push({ rule: 'sealed.transcript', file, commit }); if (transcript(file)) out.push({ rule: 'sealed.transcript', file, commit });
else if (matchesAny(p.paths, file)) out.push({ rule: 'sealed.path', file, commit }); else if (sealedPath(file)) out.push({ rule: 'sealed.path', file, commit });
} }
return out; return out;
} }
@@ -216,7 +229,7 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
export interface SealedReport { export interface SealedReport {
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error'; status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
policyLabels: string[]; commits: number; findings: SealedFinding[]; waived: Array<SealedFinding & { reason: string }>; policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>;
visibility: Visibility; visibilitySource: string; target: string; error?: string; visibility: Visibility; visibilitySource: string; target: string; error?: string;
} }
@@ -225,9 +238,14 @@ export interface EvaluateOptions {
env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh']; env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh'];
} }
/** A report for a failure before any policy is read (an unlisted range, an unreadable push); renders as a fail-closed refusal. */
export function sealedErrorReport(error: string, target: string): SealedReport {
return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], visibility: 'unknown', visibilitySource: '', target };
}
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */ /** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
export function evaluateSealed(o: EvaluateOptions): SealedReport { export function evaluateSealed(o: EvaluateOptions): SealedReport {
const blank = { policyLabels: [] as string[], commits: 0, findings: [] as SealedFinding[], waived: [] as Array<SealedFinding & { reason: string }>, visibility: 'unknown' as Visibility, visibilitySource: '', target: o.target }; const blank = { ...sealedErrorReport('', o.target), error: undefined };
const base = o.baseRef ? sealedPolicyAt(o.repoRoot, o.baseRef, o.baseLabel ?? o.baseRef) : { policy: null, label: '(no destination base branch)' }; 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}` }; 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; let policy: SealedPolicy | null = base.policy;
@@ -243,6 +261,13 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
let found: SealedFinding[]; let found: SealedFinding[];
try { found = scanCommits(o.repoRoot, commits, policy); } 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}` }; } catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, status: 'error', error: `could not read the published commits: ${e.message}` }; }
const exception = globMatcher(policy.allow_paths, false);
const exempt: ExemptFinding[] = [];
found = found.filter(f => {
const glob = f.rule === 'sealed.path' || f.rule === 'sealed.transcript' ? exception(f.file) : null;
if (glob) exempt.push({ ...f, glob });
return !glob;
});
const allow = allowEntriesAt(o.repoRoot, o.baseRef); const allow = allowEntriesAt(o.repoRoot, o.baseRef);
const waived: SealedReport['waived'] = []; const waived: SealedReport['waived'] = [];
const findings = found.filter(f => { const findings = found.filter(f => {
@@ -252,7 +277,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
}); });
const vis = policy.visibility ? { visibility: policy.visibility as Visibility, source: `${VISIBILITY_OVERRIDE_KEY} override` } : resolveVisibility(o.remoteUrl, { env: o.env, gh: o.gh }); const 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'; const status = findings.length === 0 ? 'clear' : vis.visibility === 'private' ? 'warned' : 'refused';
return { status, policyLabels: labels, commits: commits.length, findings, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target }; return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
} }
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`; const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`;
@@ -260,6 +285,7 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:$
/** stderr text for a report: findings one per line (`<rule> <file[:line]> commit <sha12>`), never the matched text. */ /** 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 { export function renderSealed(r: SealedReport, context: string): string {
const lines: string[] = []; const lines: string[] = [];
for (const e of r.exempt) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`);
for (const w of r.waived) lines.push(`${context}: waived ${w.rule} ${where(w)} commit ${w.commit.slice(0, 12)} (${SEALED_ALLOW_PATH}: ${w.reason})`); for (const 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 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)}`); const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}`);
@@ -268,7 +294,7 @@ export function renderSealed(r: SealedReport, context: string): string {
lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`); lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === '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(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body);
lines.push(`Remove it from every published commit before this branch reaches a public repository, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`); lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
} else if (r.status === 'warned') { } 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); 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);
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "gstack", "name": "gstack",
"version": "1.91.310", "version": "1.91.311",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.", "description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
+6
View File
@@ -193,6 +193,12 @@ describe('pregate config: sealed (CD4)', () => {
expect(codes({ sealed: { seed_patterns: ['('] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.seed_patterns[0]']); expect(codes({ sealed: { seed_patterns: ['('] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.seed_patterns[0]']);
expect(codes({ sealed: { visibility: 'internal' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.visibility']); expect(codes({ sealed: { visibility: 'internal' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.visibility']);
expect(codes({ sealed: { block: true } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.block']); expect(codes({ sealed: { block: true } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.block']);
expect(codes({ sealed: { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json', '**/protected/MANIFEST.json'], case_insensitive: true } })).toEqual([]);
expect(codes({ sealed: { allow_paths: '**/sealed/*/MANIFEST.json' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
expect(codes({ sealed: { allow_paths: ['../x/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]']);
expect(codes({ sealed: { allow_paths: Array.from({ length: 65 }, (_, i) => `s${i}/MANIFEST.json`) } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
expect(codes({ sealed: { allow_paths: ['**', 'sealed/**', 'sealed/*', '**/*.json', 'sealed/*.*', '**/sealed/**.json', '**/sealed/*/MANIFEST.json', '**/sealed/*/transcript-*.jsonl', '.hidden'] } })).toEqual([0, 1, 2, 3, 4, 5].map(i => `PREGATE_CONFIG_INVALID:sealed.allow_paths[${i}]`));
expect(codes({ sealed: { case_insensitive: 'yes' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.case_insensitive']);
}); });
}); });
+137
View File
@@ -195,6 +195,143 @@ describe('gstack-pregate sealed (the standalone preflight before any push or PR
}); });
}); });
const MANIFEST = { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json'], answer_markers: ['SEALED ANSWER:'] };
function preflight(s: Scratch, args: string[] = []) {
const r = spawnSync(PREGATE, ['sealed', '--base', 'main', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
}
function landOnMain(s: Scratch, files: Record<string, string>): void {
sh(s.work, s.env, ['checkout', '-q', 'main']);
write(s.work, files);
sh(s.work, s.env, ['add', '-A']); sh(s.work, s.env, ['commit', '-q', '-m', 'base change']); sh(s.work, s.env, ['push', '-q', 'origin', 'main']);
sh(s.work, s.env, ['checkout', '-q', 'results']);
}
describe('sealed.allow_paths: base-branch exceptions applied after the deny globs', () => {
test('a deny with an allowed exception refuses the sealed file, lists the manifest as exempt, and a manifest-only push passes', () => {
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(s, { 'sealed/s1/tasks.json': '{}\n', 'sealed/s1/MANIFEST.json': '{"n":1}\n', 'tasks/x/sealed/s2/MANIFEST.json': '{"n":2}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/tasks\.json/m);
expect(r.err).not.toMatch(/^ sealed\.path\s+\S*MANIFEST\.json/m);
expect(r.err).toMatch(/exempt sealed\.path sealed\/s1\/MANIFEST\.json commit [0-9a-f]{12} \(sealed\.allow_paths \*\*\/sealed\/\*\/MANIFEST\.json\)/);
expect(r.err).toContain('exempt sealed.path tasks/x/sealed/s2/MANIFEST.json');
const only = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(only, { 'sealed/s1/MANIFEST.json': '{"n":1}\n' });
expect(hook(only).code).toBe(0);
const pre = preflight(only);
expect(pre.code).toBe(0);
expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m);
const json = JSON.parse(preflight(only, ['--json']).out);
expect(json.exempt).toHaveLength(1);
expect(json.exempt[0]).toMatchObject({ rule: 'sealed.path', file: 'sealed/s1/MANIFEST.json', glob: '**/sealed/*/MANIFEST.json' });
});
test('an exception that does not match still refuses: another case and another depth', () => {
for (const file of ['sealed/s1/manifest.json', 'sealed/s1/x/MANIFEST.json']) {
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(s, { [file]: '{}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toContain(`sealed.path ${file}`);
expect(r.err).not.toContain('exempt sealed.');
}
});
test('an exception added on the pushed head is ignored, on top of a base list too; a malformed pushed allow_paths refuses', () => {
const fresh = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
commit(fresh, { '.gstack/pregate.json': policy(MANIFEST), 'sealed/s1/MANIFEST.json': '{}\n' });
const f = hook(fresh);
expect(f.code).toBe(1);
expect(f.err).toContain('sealed.path sealed/s1/MANIFEST.json');
const extra = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(extra, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: [...MANIFEST.allow_paths, '**/sealed/*/notes.md'] }), 'sealed/s1/notes.md': 'n\n' });
expect(hook(extra).err).toContain('sealed.path sealed/s1/notes.md');
const bad = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(bad, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: '**/sealed/*/MANIFEST.json' }), 'docs/x.md': 'x\n' });
const b = hook(bad);
expect(b.code).toBe(1);
expect(b.err).toContain('sealed.allow_paths');
});
test('an allowed manifest that quotes a sealed answer still refuses on the text rule', () => {
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(s, { 'sealed/s1/MANIFEST.json': '{"note":"SEALED ANSWER: b"}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toMatch(/sealed\.answer\s+sealed\/s1\/MANIFEST\.json:1/);
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
});
test('an exception covers sealed.transcript too and is counted before a base sha waiver, never both', () => {
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'], transcripts: ['**/transcript*.jsonl'], allow_paths: ['**/sealed/*/transcript-public.jsonl'] }) });
const sha = commit(s, { 'sealed/s1/transcript-public.jsonl': '{}\n' });
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture\n` });
const pre = preflight(s);
expect(pre.code).toBe(0);
expect(pre.err).toContain('exempt sealed.transcript sealed/s1/transcript-public.jsonl');
expect(pre.out).toMatch(/findings=0 .* waived=0 exempt=1 /);
});
test('a preflight error carries the same report shape: exit 1, SEALED: error with exempt=0, no stack trace', () => {
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
commit(s, { 'docs/x.md': 'x\n' });
const r = preflight(s, ['--range', 'nosuch..HEAD']);
expect(r.code).toBe(1);
expect(r.out).toMatch(/^SEALED: error findings=0 commits=0 .* waived=0 exempt=0 /m);
expect(r.err).not.toMatch(/\n\s+at /);
expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).exempt).toEqual([]);
});
});
describe('sealed.case_insensitive: deny globs fold case, exceptions never do', () => {
test('off (the default), HELDOUT/ passes a lowercase deny glob; on, HELDOUT/, HeldOut/ and a Transcript refuse, and hold-out/ still passes', () => {
const off = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
commit(off, { 'HELDOUT/q.json': '{}\n' });
expect(hook(off).code).toBe(0);
const on = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], case_insensitive: true }) });
commit(on, { 'HELDOUT/q.json': '{}\n', 'HeldOut/q.json': '{}\n', 'Runs/c1/Transcript.jsonl': '{}\n', 'hold-out/q.json': '{}\n' });
const r = hook(on);
expect(r.code).toBe(1);
expect(r.err).toContain('sealed.path HELDOUT/q.json');
expect(r.err).toContain('sealed.path HeldOut/q.json');
expect(r.err).toContain('sealed.transcript Runs/c1/Transcript.jsonl');
expect(r.err).not.toContain('hold-out/q.json');
});
test('on, allow_paths stay case-sensitive: MANIFEST.json is exempt and manifest.json refuses', () => {
const s = scratch({ '.gstack/pregate.json': policy({ ...MANIFEST, case_insensitive: true }) });
commit(s, { 'sealed/s1/MANIFEST.json': '{}\n', 'sealed/s1/manifest.json': '{}\n' });
const r = hook(s);
expect(r.code).toBe(1);
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/manifest\.json/m);
});
test('set only on the pushed head, it still tightens (base OR pushed)', () => {
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
commit(s, { '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], case_insensitive: true }), 'HELDOUT/q.json': '{}\n' });
expect(hook(s).err).toContain('sealed.path HELDOUT/q.json');
});
});
describe('root-anchoring (the docs/pregate.md example)', () => {
test('globs match from the repo root: sealed/** misses a nested sealed/ directory and **/sealed/** catches it at any depth', () => {
const docs = fs.readFileSync(path.join(ROOT, 'docs', 'pregate.md'), 'utf8');
expect(docs).toContain('`sealed/**` misses `tasks/x/sealed/a.json`');
expect(docs).toContain('write `**/sealed/**`');
const anchored = scratch({ '.gstack/pregate.json': policy({ paths: ['sealed/**'] }) });
commit(anchored, { 'tasks/x/sealed/a.json': '{}\n' });
expect(hook(anchored).code).toBe(0);
const any = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
commit(any, { 'tasks/x/sealed/a.json': '{}\n', 'sealed/b.json': '{}\n' });
const a = hook(any);
expect(a.err).toContain('sealed.path tasks/x/sealed/a.json');
expect(a.err).toContain('sealed.path sealed/b.json');
});
});
describe('visibility (cache under the state root; unknown counts as public)', () => { 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', () => { 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-')); const state = fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-vis-'));