mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-01 06:24:41 +01:00
feat(pregate): sealed.allow_paths exceptions and opt-in case_insensitive deny globs (GSTA-145)
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
98dd72ffc3
commit
19c74ffff3
7 files changed
+230
-27
No files matched your search
+3
-3
@@ -40,7 +40,7 @@ import { DEFAULT_STALL_TIMEOUT_S } from '../lib/pregate/context';
|
||||
import { cancelActive } from '../lib/pregate/supervise';
|
||||
import { defaultGh, evaluateRegate, renderRegate } from '../lib/pregate/regate';
|
||||
import { GATE_AHEAD_REL } from '../lib/restamp';
|
||||
import { evaluateSealed, publishedCommits, rangeCommits, renderSealed } from '../lib/sealed-guard';
|
||||
import { evaluateSealed, publishedCommits, rangeCommits, renderSealed, sealedErrorReport } from '../lib/sealed-guard';
|
||||
import { isUnavailable, loadPolicyFromBase, policyUnavailableLine, resolveBaseBranch } from '../lib/ship-policy';
|
||||
import { fromGateAhead } from '../lib/tree-receipt';
|
||||
|
||||
@@ -244,13 +244,13 @@ function cmdSealed(root: string, rest: string[], json: boolean): number {
|
||||
const baseRef = gitOut(root, ['rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${base}^{commit}`]) ? `refs/remotes/${remote}/${base}` : null;
|
||||
const remoteUrl = gitOut(root, ['remote', 'get-url', remote]);
|
||||
const report = 'error' in listed
|
||||
? { status: 'error' as const, error: listed.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: 'unknown' as const, visibilitySource: '', target: remoteUrl ?? remote }
|
||||
? sealedErrorReport(listed.error, remoteUrl ?? remote)
|
||||
: evaluateSealed({ repoRoot: root, targets: [{ commits: listed.commits, pushedSha }], baseRef, baseLabel: `${remote}/${base}`, remoteUrl, target: remoteUrl ?? remote });
|
||||
const blocks = report.status === 'refused' || report.status === 'error';
|
||||
if (json) { process.stdout.write(JSON.stringify({ ...report, ok: !blocks }) + '\n'); return blocks ? EXIT.fail : EXIT.ok; }
|
||||
process.stderr.write(renderSealed(report, 'gstack-pregate sealed'));
|
||||
const policy = report.policyLabels.length ? report.policyLabels.join(' + ') : `no sealed key on ${baseRef ?? `${remote}/${base}`} or ${end}`;
|
||||
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} policy=${policy}\n`);
|
||||
process.stdout.write(`SEALED: ${report.status} findings=${report.findings.length} commits=${report.commits} visibility=${report.visibility}${report.visibility === 'unknown' ? ' (counted public)' : ''} waived=${report.waived.length} exempt=${report.exempt.length} policy=${policy}\n`);
|
||||
return blocks ? EXIT.fail : EXIT.ok;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ import * as path from "path";
|
||||
import { normalizeWithMap, scan, type Finding, type ScanOptions } from "../lib/redact-engine";
|
||||
import { mkdirpSync } from "../lib/fs-utils";
|
||||
import { resolveStateRoot } from "../lib/state-root";
|
||||
import { evaluateSealed, publishedCommits, renderSealed, type SealedReport } from "../lib/sealed-guard";
|
||||
import { evaluateSealed, publishedCommits, renderSealed, sealedErrorReport, type SealedReport } from "../lib/sealed-guard";
|
||||
|
||||
const ZERO = /^0+$/;
|
||||
let emptyTree: string | undefined;
|
||||
@@ -608,7 +608,7 @@ function sealedForPush(refs: Array<{ localSha: string; remoteSha: string }>): Se
|
||||
const targets: Array<{ commits: string[]; pushedSha: string }> = [];
|
||||
for (const ref of refs) {
|
||||
const published = publishedCommits(".", { localSha: ref.localSha, remoteSha: ref.remoteSha, remoteName });
|
||||
if ("error" in published) return { status: "error", error: published.error, policyLabels: [], commits: 0, findings: [], waived: [], visibility: "unknown", visibilitySource: "", target: remoteUrl ?? "the push target" };
|
||||
if ("error" in published) return sealedErrorReport(published.error, remoteUrl ?? "the push target");
|
||||
targets.push({ commits: published.commits, pushedSha: ref.localSha });
|
||||
}
|
||||
return evaluateSealed({ repoRoot: ".", targets, baseRef: defaultRemoteBranch(), remoteUrl, target: remoteUrl ?? remoteName ?? "the push target" });
|
||||
|
||||
+33
-3
@@ -763,11 +763,13 @@ with a timing flake: never raise the bound.
|
||||
|
||||
```json
|
||||
{ "sealed": {
|
||||
"paths": ["heldout/**"],
|
||||
"paths": ["heldout/**", "**/sealed/**"],
|
||||
"transcripts": ["runs/**/transcript*.jsonl"],
|
||||
"allow_paths": ["**/sealed/*/MANIFEST.json", "**/protected/MANIFEST.json"],
|
||||
"answer_markers": ["SEALED ANSWER:"],
|
||||
"seed_patterns": ["shuffle_seed=\\d+"],
|
||||
"local_paths": true } }
|
||||
"local_paths": true,
|
||||
"case_insensitive": true } }
|
||||
```
|
||||
|
||||
A repo with a `sealed` key gets a configured local check against accidental
|
||||
@@ -787,8 +789,36 @@ gstack-pregate sealed [--range <a..b>] [--remote origin] [--base <branch>] [--js
|
||||
in a later commit); `sealed.answer`, `sealed.seed` and `sealed.local_path`
|
||||
(`/home/<user>/`, `/Users/<user>/`, `C:\Users\<user>\`) check each commit's added
|
||||
lines. Findings print rule, `file[:line]` and commit, never the text.
|
||||
- **Globs.** `paths`, `transcripts` and `allow_paths` match the whole
|
||||
repo-relative path from the repository root. `*` and `?` stay inside one
|
||||
path segment; `**/` spans zero or more directories; a `**` not followed by
|
||||
`/` matches across directories (`heldout/**` covers every depth under
|
||||
`heldout/`); `[` and `{` are literal. So `sealed/**` misses `tasks/x/sealed/a.json`:
|
||||
to guard a `sealed/` directory wherever it sits, write `**/sealed/**`.
|
||||
- **Case.** Matching is case-sensitive. `case_insensitive: true` folds case
|
||||
for `paths` and `transcripts` only, so `**/heldout/**` also catches
|
||||
`HELDOUT/` and `HeldOut/`. Separator and spelling variants (`hold-out/`,
|
||||
`held_out/`, `heldout-v2/`) are not folded: list each as its own glob.
|
||||
The effective value is the base branch's OR the pushed one (it only tightens).
|
||||
- **Exceptions.** `allow_paths` exempts publishable files that sit next to
|
||||
sealed data (a `MANIFEST.json` of hashes and counts) from `sealed.path` and
|
||||
`sealed.transcript`, applied after the deny globs and before waivers. They
|
||||
are read from the destination base branch only: an exception a push adds
|
||||
takes effect once it lands on the base branch, so a branch can never widen
|
||||
its own. They never fold case, and an entry whose last segment is only
|
||||
wildcards (`**`, `sealed/*`) is refused. An exempt file is checked by the
|
||||
text rules only, so configure `answer_markers` when you exempt files next to
|
||||
sealed data. Each exempt path is listed on every push (`exempt <rule>
|
||||
<file> commit <sha> (sealed.allow_paths <glob>)`) and counted as
|
||||
`exempt=<n>` on the `SEALED:` line.
|
||||
- **Version skew.** A gstack older than the release whose CHANGELOG adds
|
||||
`sealed.allow_paths` refuses `allow_paths` and `case_insensitive` as
|
||||
unknown keys, so every push and `gstack-pregate run` in the repository
|
||||
fail on that machine. Raise `min_gstack_version` in
|
||||
`.gstack/ship-policy.json` to that release before landing either key.
|
||||
- **Policy.** The union of the destination base branch's `sealed` and the
|
||||
pushed commit's: a push can tighten it, never relax it. A malformed or
|
||||
pushed commit's: a push can tighten it, never relax it (`allow_paths` come
|
||||
from the base branch alone, and a pushed deny glob cannot override one). A malformed or
|
||||
unreadable policy refuses. Waivers come only from the base branch's
|
||||
`.gstack/sealed-allow.tsv` (`rule<TAB>path<TAB>sha<TAB>reason`) and are
|
||||
listed on every push.
|
||||
|
||||
@@ -321,7 +321,7 @@ export function explainConfig(): string {
|
||||
'local: <workflow.yml>/<job> lanes a local run satisfies (platform-agnostic toolchain, or CI off); their tests run here, not requires-remote',
|
||||
'guards: [{ id, when: [globs], command, fix?, timeout_s? }]: each guard whose globs match a touched file runs in its own process (--allow-repo-commands); a failure fails pregate with the guard id and fix; never downgradable',
|
||||
`serial_patterns: test-file globs the lanes check runs one process per file, apart from the unit files (default ${DEFAULT_SERIAL_PATTERNS.join(', ')})`,
|
||||
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination (docs/pregate.md#sealed)',
|
||||
'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, visibility }: sealed or eval output the pre-push hook and `gstack-pregate sealed` refuse to publish to a public destination; allow_paths (base branch only) exempt publishable files from the path globs (docs/pregate.md#sealed)',
|
||||
'price_file: repo-relative path of the GSTACK_EVAL_CALLS v1 call-plan file `gstack-eval-plan price-gate` prices a paid gate from; never executed (docs/eval-calls.md)',
|
||||
'stall_timeout_s: seconds without test output before the lanes check kills the runner\'s process group and records it incomplete (default 300; 0 disables; --stall-timeout overrides)',
|
||||
'remote_gate: the repo\'s remote test gate command; under 8 cores the test tier becomes a requires-remote obligation naming it, and gstack-pregate --remote runs it (it must print PREGATE_REMOTE: tree=<tree> tests=<n> failed=<n>) (docs/pregate.md#remote-gate)',
|
||||
|
||||
+46
-18
@@ -9,6 +9,8 @@
|
||||
* answer_markers regexes for a quoted sealed answer in added text
|
||||
* seed_patterns regexes for a sampling seed in added text
|
||||
* local_paths true: a local home path (/home/<u>/, /Users/<u>/) in added text
|
||||
* allow_paths globs exempt from the path rules (read from the base branch only; never folds case)
|
||||
* case_insensitive true: `paths` and `transcripts` match regardless of case
|
||||
* visibility "public" | "private": the destination override (reviewed on the base branch)
|
||||
*
|
||||
* The publication boundary is every commit the push publishes: the local sha
|
||||
@@ -18,8 +20,9 @@
|
||||
* copies, independent of content (binary, empty, add-then-delete); text rules
|
||||
* see each commit's added lines. The effective policy is the union of the
|
||||
* destination base branch's `sealed` and the pushed one (a push can tighten,
|
||||
* never relax); waivers come only from the base branch's
|
||||
* `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
|
||||
* never relax); `allow_paths` exceptions come only from the base branch's
|
||||
* policy and remove path findings before waivers, which come only from the
|
||||
* base branch's `.gstack/sealed-allow.tsv`. A malformed or unreadable policy refuses (fail
|
||||
* closed). Visibility: the base override, else a cache under the state root
|
||||
* (a `public` answer for 24 h, a `private` one for 1 h before it may
|
||||
* downgrade a refusal), else `gh repo view` (5 s); unknown counts as public.
|
||||
@@ -28,31 +31,33 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { containedRelative, globToRegExp, matchesAny } from './ship-policy';
|
||||
import { containedRelative, globToRegExp } from './ship-policy';
|
||||
import { resolveStateRoot } from './state-root';
|
||||
|
||||
export const SEALED_CONFIG_PATH = '.gstack/pregate.json';
|
||||
export const SEALED_ALLOW_PATH = '.gstack/sealed-allow.tsv';
|
||||
export const VISIBILITY_OVERRIDE_KEY = 'sealed.visibility';
|
||||
export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path';
|
||||
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; visibility?: 'public' | 'private' }
|
||||
export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; visibility?: 'public' | 'private' }
|
||||
export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string }
|
||||
export type ExemptFinding = SealedFinding & { glob: string };
|
||||
export interface AllowEntry { rule: string; path: string; sha: string; reason: string }
|
||||
export type Visibility = 'public' | 'private' | 'unknown';
|
||||
|
||||
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'visibility'];
|
||||
const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'visibility'];
|
||||
const MAX_ENTRIES = 64;
|
||||
const MAX_REGEX = 256;
|
||||
const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-])(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/;
|
||||
const PUBLIC_TTL_MS = 24 * 3600_000;
|
||||
const PRIVATE_TTL_MS = 3600_000;
|
||||
const MAX_EXEMPT_LINES = 20;
|
||||
|
||||
/** `<key>: <message>` errors for a `sealed` value; empty when valid. Shared with lib/pregate/config.ts. */
|
||||
export function sealedErrors(raw: unknown): Array<{ path: string; message: string }> {
|
||||
const errors: Array<{ path: string; message: string }> = [];
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: 'must be an object { paths, transcripts, answer_markers, seed_patterns, local_paths, visibility }' }];
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return [{ path: 'sealed', message: `must be an object { ${KEYS.join(', ')} }` }];
|
||||
const o = raw as Record<string, unknown>;
|
||||
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')}` });
|
||||
for (const k of Object.keys(o)) if (!KEYS.includes(k)) errors.push({ path: `sealed.${k}`, message: `unknown key; valid keys: ${KEYS.join(', ')} (a key from a newer gstack: upgrade gstack on this machine)` });
|
||||
const list = (key: string, check: (s: string) => string | null) => {
|
||||
if (!(key in o)) return;
|
||||
const v = o[key];
|
||||
@@ -62,8 +67,9 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin
|
||||
};
|
||||
const glob = (g: string) => { const why = containedRelative(g); if (why) return why; try { globToRegExp(g); return null; } catch (e: any) { return e.message; } };
|
||||
const regex = (r: string) => { if (!r || r.length > MAX_REGEX) return `must be 1-${MAX_REGEX} characters`; try { new RegExp(r); return null; } catch (e: any) { return `is not a valid regex (${e.message})`; } };
|
||||
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex);
|
||||
if ('local_paths' in o && typeof o.local_paths !== 'boolean') errors.push({ path: 'sealed.local_paths', message: 'must be true or false' });
|
||||
const exception = (g: string) => glob(g) ?? (/^[*?]+$/.test(g.split('/').pop()!) ? 'ends in a wildcard-only segment, which would exempt a whole directory; name the file (e.g. **/sealed/*/MANIFEST.json)' : null);
|
||||
list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception);
|
||||
for (const k of ['local_paths', 'case_insensitive']) if (k in o && typeof o[k] !== 'boolean') errors.push({ path: `sealed.${k}`, message: 'must be true or false' });
|
||||
if ('visibility' in o && o.visibility !== 'public' && o.visibility !== 'private') errors.push({ path: 'sealed.visibility', message: 'must be "public" or "private"' });
|
||||
return errors;
|
||||
}
|
||||
@@ -88,15 +94,15 @@ export function sealedPolicyAt(repoRoot: string, treeish: string, label: string)
|
||||
const errors = sealedErrors(raw.sealed);
|
||||
if (errors.length) return { error: errors.map(e => `${e.path}: ${e.message}`).join('; '), label };
|
||||
const s = raw.sealed;
|
||||
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, visibility: s.visibility }, label };
|
||||
return { policy: { paths: s.paths ?? [], transcripts: s.transcripts ?? [], answer_markers: s.answer_markers ?? [], seed_patterns: s.seed_patterns ?? [], local_paths: s.local_paths === true, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === true, visibility: s.visibility }, label };
|
||||
}
|
||||
|
||||
/** Base ∪ pushed: lists concatenate, `local_paths` ORs; visibility stays private only when the base says so and the push does not say public. */
|
||||
/** Base ∪ pushed: deny lists concatenate, `local_paths` and `case_insensitive` OR (both only tighten); `allow_paths` relax, so they come from the base alone; visibility stays private only when the base says so and the push does not say public. */
|
||||
export function unionPolicies(base: SealedPolicy | null, pushed: SealedPolicy | null): SealedPolicy | null {
|
||||
if (!base && !pushed) return null;
|
||||
const u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns') => [...new Set([...(base?.[k] ?? []), ...(pushed?.[k] ?? [])])];
|
||||
const visibility = pushed?.visibility === 'public' || base?.visibility === 'public' ? 'public' : base?.visibility === 'private' ? 'private' : undefined;
|
||||
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), visibility };
|
||||
return { paths: u('paths'), transcripts: u('transcripts'), answer_markers: u('answer_markers'), seed_patterns: u('seed_patterns'), local_paths: !!(base?.local_paths || pushed?.local_paths), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), visibility };
|
||||
}
|
||||
|
||||
/** Reviewed waivers: `rule<TAB>path<TAB>sha<TAB>reason` lines in the base branch's allow file. */
|
||||
@@ -126,12 +132,20 @@ export function rangeCommits(repoRoot: string, range: string): { commits: string
|
||||
|
||||
const HEX = /^[0-9a-f]{40,64}$/;
|
||||
|
||||
/** The first glob matching a repo-relative path, compiled once per list (`matchesAny` normalization); null when none does. */
|
||||
function globMatcher(globs: string[], ignoreCase: boolean): (file: string) => string | null {
|
||||
const compiled = globs.map(g => { const re = globToRegExp(g); return { g, re: ignoreCase ? new RegExp(re.source, 'i') : re }; });
|
||||
return file => { const norm = file.replace(/\\/g, '/').replace(/^\.\//, ''); return compiled.find(c => c.re.test(norm))?.g ?? null; };
|
||||
}
|
||||
|
||||
/** Path rules over every added, renamed or copied path, per commit (merges: paths new against every parent). */
|
||||
function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): SealedFinding[] {
|
||||
if (!p.paths.length && !p.transcripts.length) return [];
|
||||
const r = git(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-M', '-C', '--cc', '--name-status', '-z'], commits.join('\n') + '\n');
|
||||
if (!r.ok) throw new Error(`git diff-tree failed: ${r.err.slice(0, 200)}`);
|
||||
const out: SealedFinding[] = [];
|
||||
const transcript = globMatcher(p.transcripts, p.case_insensitive);
|
||||
const sealedPath = globMatcher(p.paths, p.case_insensitive);
|
||||
const tokens = r.out.split('\0');
|
||||
let commit = '';
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
@@ -141,8 +155,8 @@ function pathFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea
|
||||
const renamed = /^[RC]\d*$/.test(t);
|
||||
const file = renamed ? tokens[i += 2]! : tokens[++i]!;
|
||||
if (!renamed && !t.includes('A')) continue;
|
||||
if (matchesAny(p.transcripts, file)) out.push({ rule: 'sealed.transcript', file, commit });
|
||||
else if (matchesAny(p.paths, file)) out.push({ rule: 'sealed.path', file, commit });
|
||||
if (transcript(file)) out.push({ rule: 'sealed.transcript', file, commit });
|
||||
else if (sealedPath(file)) out.push({ rule: 'sealed.path', file, commit });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -216,7 +230,7 @@ export function resolveVisibility(url: string | null, o: VisibilityOptions = {})
|
||||
|
||||
export interface SealedReport {
|
||||
status: 'no-policy' | 'clear' | 'refused' | 'warned' | 'error';
|
||||
policyLabels: string[]; commits: number; findings: SealedFinding[]; waived: Array<SealedFinding & { reason: string }>;
|
||||
policyLabels: string[]; commits: number; findings: SealedFinding[]; exempt: ExemptFinding[]; waived: Array<SealedFinding & { reason: string }>;
|
||||
visibility: Visibility; visibilitySource: string; target: string; error?: string;
|
||||
}
|
||||
|
||||
@@ -225,9 +239,14 @@ export interface EvaluateOptions {
|
||||
env?: NodeJS.ProcessEnv; gh?: VisibilityOptions['gh'];
|
||||
}
|
||||
|
||||
/** A report for a failure before any policy is read (an unlisted range, an unreadable push); renders as a fail-closed refusal. */
|
||||
export function sealedErrorReport(error: string, target: string): SealedReport {
|
||||
return { status: 'error', error, policyLabels: [], commits: 0, findings: [], exempt: [], waived: [], visibility: 'unknown', visibilitySource: '', target };
|
||||
}
|
||||
|
||||
/** The whole guard for one push (or range): policies, commits, findings, waivers, visibility, verdict. */
|
||||
export function evaluateSealed(o: EvaluateOptions): SealedReport {
|
||||
const blank = { policyLabels: [] as string[], commits: 0, findings: [] as SealedFinding[], waived: [] as Array<SealedFinding & { reason: string }>, visibility: 'unknown' as Visibility, visibilitySource: '', target: o.target };
|
||||
const blank = { ...sealedErrorReport('', o.target), error: undefined };
|
||||
const base = o.baseRef ? sealedPolicyAt(o.repoRoot, o.baseRef, o.baseLabel ?? o.baseRef) : { policy: null, label: '(no destination base branch)' };
|
||||
if ('error' in base) return { ...blank, status: 'error', error: `the sealed policy on ${base.label} is unreadable: ${base.error}` };
|
||||
let policy: SealedPolicy | null = base.policy;
|
||||
@@ -243,6 +262,13 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
|
||||
let found: SealedFinding[];
|
||||
try { found = scanCommits(o.repoRoot, commits, policy); }
|
||||
catch (e: any) { return { ...blank, policyLabels: labels, commits: commits.length, status: 'error', error: `could not read the published commits: ${e.message}` }; }
|
||||
const exception = globMatcher(policy.allow_paths, false);
|
||||
const exempt: ExemptFinding[] = [];
|
||||
found = found.filter(f => {
|
||||
const glob = f.rule === 'sealed.path' || f.rule === 'sealed.transcript' ? exception(f.file) : null;
|
||||
if (glob) exempt.push({ ...f, glob });
|
||||
return !glob;
|
||||
});
|
||||
const allow = allowEntriesAt(o.repoRoot, o.baseRef);
|
||||
const waived: SealedReport['waived'] = [];
|
||||
const findings = found.filter(f => {
|
||||
@@ -252,7 +278,7 @@ export function evaluateSealed(o: EvaluateOptions): SealedReport {
|
||||
});
|
||||
const vis = policy.visibility ? { visibility: policy.visibility as Visibility, source: `${VISIBILITY_OVERRIDE_KEY} override` } : resolveVisibility(o.remoteUrl, { env: o.env, gh: o.gh });
|
||||
const status = findings.length === 0 ? 'clear' : vis.visibility === 'private' ? 'warned' : 'refused';
|
||||
return { status, policyLabels: labels, commits: commits.length, findings, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
|
||||
return { status, policyLabels: labels, commits: commits.length, findings, exempt, waived, visibility: vis.visibility, visibilitySource: vis.source, target: o.target };
|
||||
}
|
||||
|
||||
const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:${f.line}` : ''}`;
|
||||
@@ -260,6 +286,8 @@ const where = (f: SealedFinding) => `${f.file || '(unknown file)'}${f.line ? `:$
|
||||
/** stderr text for a report: findings one per line (`<rule> <file[:line]> commit <sha12>`), never the matched text. */
|
||||
export function renderSealed(r: SealedReport, context: string): string {
|
||||
const lines: string[] = [];
|
||||
for (const e of r.exempt.slice(0, MAX_EXEMPT_LINES)) lines.push(`${context}: exempt ${e.rule} ${where(e)} commit ${e.commit.slice(0, 12)} (sealed.allow_paths ${e.glob})`);
|
||||
if (r.exempt.length > MAX_EXEMPT_LINES) lines.push(`${context}: and ${r.exempt.length - MAX_EXEMPT_LINES} more exempt path(s) (sealed.allow_paths)`);
|
||||
for (const w of r.waived) lines.push(`${context}: waived ${w.rule} ${where(w)} commit ${w.commit.slice(0, 12)} (${SEALED_ALLOW_PATH}: ${w.reason})`);
|
||||
const vis = `visibility: ${r.visibility}${r.visibility === 'unknown' ? ', counted public' : ''}; ${r.visibilitySource}`;
|
||||
const body = r.findings.map(f => ` ${f.rule} ${where(f)} commit ${f.commit.slice(0, 12)}`);
|
||||
@@ -268,7 +296,7 @@ export function renderSealed(r: SealedReport, context: string): string {
|
||||
lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`);
|
||||
} else if (r.status === 'refused') {
|
||||
lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body);
|
||||
lines.push(`Remove it from every published commit before this branch reaches a public repository, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
|
||||
lines.push(`Remove it from every published commit before this branch reaches a public repository, exempt a publishable path with a sealed.allow_paths glob in the base branch's ${SEALED_CONFIG_PATH}, or waive one reviewed finding with a \`rule<TAB>path<TAB>sha<TAB>reason\` line in the base branch's ${SEALED_ALLOW_PATH}. A private destination: set ${VISIBILITY_OVERRIDE_KEY} "private" in the base branch's ${SEALED_CONFIG_PATH} (SEALED_OUTPUT_REFUSED)`);
|
||||
} else if (r.status === 'warned') {
|
||||
lines.push(`${context}: warning — sealed or eval output in a push to a private destination (${vis}); it would be refused for a public one:`, ...body);
|
||||
}
|
||||
|
||||
@@ -193,6 +193,12 @@ describe('pregate config: sealed (CD4)', () => {
|
||||
expect(codes({ sealed: { seed_patterns: ['('] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.seed_patterns[0]']);
|
||||
expect(codes({ sealed: { visibility: 'internal' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.visibility']);
|
||||
expect(codes({ sealed: { block: true } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.block']);
|
||||
expect(codes({ sealed: { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json', '**/protected/MANIFEST.json'], case_insensitive: true } })).toEqual([]);
|
||||
expect(codes({ sealed: { allow_paths: '**/sealed/*/MANIFEST.json' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
|
||||
expect(codes({ sealed: { allow_paths: ['../x/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]']);
|
||||
expect(codes({ sealed: { allow_paths: Array.from({ length: 65 }, (_, i) => `s${i}/MANIFEST.json`) } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths']);
|
||||
expect(codes({ sealed: { allow_paths: ['**', 'sealed/**', 'sealed/*', '**/sealed/*/MANIFEST.json'] } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.allow_paths[0]', 'PREGATE_CONFIG_INVALID:sealed.allow_paths[1]', 'PREGATE_CONFIG_INVALID:sealed.allow_paths[2]']);
|
||||
expect(codes({ sealed: { case_insensitive: 'yes' } })).toEqual(['PREGATE_CONFIG_INVALID:sealed.case_insensitive']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import * as fs from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { resolveVisibility } from '../lib/sealed-guard';
|
||||
import { matchesAny } from '../lib/ship-policy';
|
||||
|
||||
const ROOT = path.resolve(import.meta.dir, '..');
|
||||
const PREPUSH = path.join(ROOT, 'bin', 'gstack-redact-prepush');
|
||||
@@ -195,6 +196,144 @@ describe('gstack-pregate sealed (the standalone preflight before any push or PR
|
||||
});
|
||||
});
|
||||
|
||||
const MANIFEST = { paths: ['**/sealed/**'], allow_paths: ['**/sealed/*/MANIFEST.json'], answer_markers: ['SEALED ANSWER:'] };
|
||||
function preflight(s: Scratch, args: string[] = []) {
|
||||
const r = spawnSync(PREGATE, ['sealed', '--base', 'main', ...args], { cwd: s.work, encoding: 'utf8', timeout: 60_000, env: { ...process.env, ...s.env } });
|
||||
return { code: r.status, out: r.stdout ?? '', err: r.stderr ?? '' };
|
||||
}
|
||||
function landOnMain(s: Scratch, files: Record<string, string>): void {
|
||||
sh(s.work, s.env, ['checkout', '-q', 'main']);
|
||||
write(s.work, files);
|
||||
sh(s.work, s.env, ['add', '-A']); sh(s.work, s.env, ['commit', '-q', '-m', 'base change']); sh(s.work, s.env, ['push', '-q', 'origin', 'main']);
|
||||
sh(s.work, s.env, ['checkout', '-q', 'results']);
|
||||
}
|
||||
|
||||
describe('sealed.allow_paths: base-branch exceptions applied after the deny globs', () => {
|
||||
test('a deny with an allowed exception refuses the sealed file, lists the manifest as exempt, and a manifest-only push passes', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { 'sealed/s1/tasks.json': '{}\n', 'sealed/s1/MANIFEST.json': '{"n":1}\n', 'tasks/x/sealed/s2/MANIFEST.json': '{"n":2}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/tasks\.json/m);
|
||||
expect(r.err).not.toMatch(/^ sealed\.path\s+\S*MANIFEST\.json/m);
|
||||
expect(r.err).toMatch(/exempt sealed\.path sealed\/s1\/MANIFEST\.json commit [0-9a-f]{12} \(sealed\.allow_paths \*\*\/sealed\/\*\/MANIFEST\.json\)/);
|
||||
expect(r.err).toContain('exempt sealed.path tasks/x/sealed/s2/MANIFEST.json');
|
||||
const only = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(only, { 'sealed/s1/MANIFEST.json': '{"n":1}\n' });
|
||||
expect(hook(only).code).toBe(0);
|
||||
const pre = preflight(only);
|
||||
expect(pre.code).toBe(0);
|
||||
expect(pre.out).toMatch(/^SEALED: clear findings=0 commits=1 visibility=unknown \(counted public\) waived=0 exempt=1 policy=/m);
|
||||
const json = JSON.parse(preflight(only, ['--json']).out);
|
||||
expect(json.exempt).toHaveLength(1);
|
||||
expect(json.exempt[0]).toMatchObject({ rule: 'sealed.path', file: 'sealed/s1/MANIFEST.json', glob: '**/sealed/*/MANIFEST.json' });
|
||||
});
|
||||
|
||||
test('an exception that does not match still refuses: another case and another depth', () => {
|
||||
for (const file of ['sealed/s1/manifest.json', 'sealed/s1/x/MANIFEST.json']) {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { [file]: '{}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toContain(`sealed.path ${file}`);
|
||||
expect(r.err).not.toContain('exempt sealed.');
|
||||
}
|
||||
});
|
||||
|
||||
test('an exception added on the pushed head is ignored, on top of a base list too; a malformed pushed allow_paths refuses', () => {
|
||||
const fresh = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
|
||||
commit(fresh, { '.gstack/pregate.json': policy(MANIFEST), 'sealed/s1/MANIFEST.json': '{}\n' });
|
||||
const f = hook(fresh);
|
||||
expect(f.code).toBe(1);
|
||||
expect(f.err).toContain('sealed.path sealed/s1/MANIFEST.json');
|
||||
const extra = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(extra, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: [...MANIFEST.allow_paths, '**/sealed/*/notes.md'] }), 'sealed/s1/notes.md': 'n\n' });
|
||||
expect(hook(extra).err).toContain('sealed.path sealed/s1/notes.md');
|
||||
const bad = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(bad, { '.gstack/pregate.json': policy({ ...MANIFEST, allow_paths: '**/sealed/*/MANIFEST.json' }), 'docs/x.md': 'x\n' });
|
||||
const b = hook(bad);
|
||||
expect(b.code).toBe(1);
|
||||
expect(b.err).toContain('sealed.allow_paths');
|
||||
});
|
||||
|
||||
test('an allowed manifest that quotes a sealed answer still refuses on the text rule', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { 'sealed/s1/MANIFEST.json': '{"note":"SEALED ANSWER: b"}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toMatch(/sealed\.answer\s+sealed\/s1\/MANIFEST\.json:1/);
|
||||
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
|
||||
});
|
||||
|
||||
test('an exception covers sealed.transcript too and is counted before a base sha waiver, never both', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'], transcripts: ['**/transcript*.jsonl'], allow_paths: ['**/sealed/*/transcript-public.jsonl'] }) });
|
||||
const sha = commit(s, { 'sealed/s1/transcript-public.jsonl': '{}\n' });
|
||||
landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\tsealed/s1/transcript-public.jsonl\t${sha.slice(0, 12)}\tsynthetic fixture\n` });
|
||||
const pre = preflight(s);
|
||||
expect(pre.code).toBe(0);
|
||||
expect(pre.err).toContain('exempt sealed.transcript sealed/s1/transcript-public.jsonl');
|
||||
expect(pre.out).toMatch(/findings=0 .* waived=0 exempt=1 /);
|
||||
});
|
||||
|
||||
test('a preflight error carries the same report shape: exit 1, SEALED: error with exempt=0, no stack trace', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) });
|
||||
commit(s, { 'docs/x.md': 'x\n' });
|
||||
const r = preflight(s, ['--range', 'nosuch..HEAD']);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.out).toMatch(/^SEALED: error findings=0 commits=0 .* waived=0 exempt=0 /m);
|
||||
expect(r.err).not.toMatch(/\n\s+at /);
|
||||
expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).exempt).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sealed.case_insensitive: deny globs fold case, exceptions never do', () => {
|
||||
test('off (the default), HELDOUT/ passes a lowercase deny glob; on, HELDOUT/, HeldOut/ and a Transcript refuse, and hold-out/ still passes', () => {
|
||||
const off = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
|
||||
commit(off, { 'HELDOUT/q.json': '{}\n' });
|
||||
expect(hook(off).code).toBe(0);
|
||||
const on = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], transcripts: ['runs/**/transcript*.jsonl'], case_insensitive: true }) });
|
||||
commit(on, { 'HELDOUT/q.json': '{}\n', 'HeldOut/q.json': '{}\n', 'Runs/c1/Transcript.jsonl': '{}\n', 'hold-out/q.json': '{}\n' });
|
||||
const r = hook(on);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toContain('sealed.path HELDOUT/q.json');
|
||||
expect(r.err).toContain('sealed.path HeldOut/q.json');
|
||||
expect(r.err).toContain('sealed.transcript Runs/c1/Transcript.jsonl');
|
||||
expect(r.err).not.toContain('hold-out/q.json');
|
||||
});
|
||||
|
||||
test('on, allow_paths stay case-sensitive: MANIFEST.json is exempt and manifest.json refuses', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy({ ...MANIFEST, case_insensitive: true }) });
|
||||
commit(s, { 'sealed/s1/MANIFEST.json': '{}\n', 'sealed/s1/manifest.json': '{}\n' });
|
||||
const r = hook(s);
|
||||
expect(r.code).toBe(1);
|
||||
expect(r.err).toContain('exempt sealed.path sealed/s1/MANIFEST.json');
|
||||
expect(r.err).toMatch(/^ sealed\.path\s+sealed\/s1\/manifest\.json/m);
|
||||
});
|
||||
|
||||
test('set only on the pushed head, it still tightens (base OR pushed)', () => {
|
||||
const s = scratch({ '.gstack/pregate.json': policy({ paths: ['**/heldout/**'] }) });
|
||||
commit(s, { '.gstack/pregate.json': policy({ paths: ['**/heldout/**'], case_insensitive: true }), 'HELDOUT/q.json': '{}\n' });
|
||||
expect(hook(s).err).toContain('sealed.path HELDOUT/q.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('root-anchoring (the docs/pregate.md example)', () => {
|
||||
test('globs match from the repo root: sealed/** misses a nested sealed/ directory and **/sealed/** catches it at any depth', () => {
|
||||
const docs = fs.readFileSync(path.join(ROOT, 'docs', 'pregate.md'), 'utf8');
|
||||
expect(docs).toContain('`sealed/**` misses `tasks/x/sealed/a.json`');
|
||||
expect(docs).toContain('write `**/sealed/**`');
|
||||
expect(matchesAny(['sealed/**'], 'tasks/x/sealed/a.json')).toBe(false);
|
||||
expect(matchesAny(['**/sealed/**'], 'tasks/x/sealed/a.json')).toBe(true);
|
||||
expect(matchesAny(['**/sealed/**'], 'sealed/a.json')).toBe(true);
|
||||
const anchored = scratch({ '.gstack/pregate.json': policy({ paths: ['sealed/**'] }) });
|
||||
commit(anchored, { 'tasks/x/sealed/a.json': '{}\n' });
|
||||
expect(hook(anchored).code).toBe(0);
|
||||
const any = scratch({ '.gstack/pregate.json': policy({ paths: ['**/sealed/**'] }) });
|
||||
commit(any, { 'tasks/x/sealed/a.json': '{}\n' });
|
||||
expect(hook(any).err).toContain('sealed.path tasks/x/sealed/a.json');
|
||||
});
|
||||
});
|
||||
|
||||
describe('visibility (cache under the state root; unknown counts as public)', () => {
|
||||
test('a public answer is cached; a cached private answer older than an hour is re-queried before it can downgrade; a failed lookup is unknown', () => {
|
||||
const state = fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-vis-'));
|
||||
|
||||
Reference in new issue
Block a user