diff --git a/bin/gstack-pregate b/bin/gstack-pregate index 10da90e78..28bd83087 100755 --- a/bin/gstack-pregate +++ b/bin/gstack-pregate @@ -292,8 +292,9 @@ function cmdSealed(root: string, rest: string[], json: boolean): number { 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 unchecked = report.findings.filter(f => f.rule === 'sealed.label_join_unchecked').length; 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} exempt=${report.exempt.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}${unchecked ? ` unchecked=${unchecked}` : ''} policy=${policy}\n`); return blocks ? EXIT.fail : EXIT.ok; } diff --git a/changelog.d/capy-gst620-sealed-identifiers.md b/changelog.d/capy-gst620-sealed-identifiers.md new file mode 100644 index 000000000..7490348f7 --- /dev/null +++ b/changelog.d/capy-gst620-sealed-identifiers.md @@ -0,0 +1,13 @@ +**The sealed guard can now refuse a published record that puts a label next to an identifier.** gstack-evals hides which test units are decoys. Its /ship on garrytan/gstack-evals#99 published a panel where each unit's `stratum`, `tags` and `decoy_set` sat next to its `head`, `tree_sha256`, `plan_bytes` and `files`, values a model under test can compute from its own copy of the work. A model could look itself up and learn it was the decoy. No answer leaked, so `gstack-pregate sealed` said `clear` (GSTA-2, GST-620). The new rule is opt-in. A repo that sets `sealed.label_keys` gets it, and every other repo sees the same output as before, byte for byte. + +### Added +- **`sealed.label_keys` and `sealed.identifier_keys` in `.gstack/pregate.json`.** With `label_keys` set, `sealed.label_join` checks every .json, .jsonl, .ndjson, .csv, .tsv and Markdown pipe table a published commit adds, modifies, renames or copies. A record that holds a label field and an identifier (a field matching an `identifier_keys` glob, by default `head`, `*_sha256`, `*_bytes`, `files` and similar names, or any 7 to 64 character hex string) is a join. The guard fingerprints each join in memory and compares it with the parent's version of the file, so unchanged rows and reformatting stay clear while a replaced, added or widened row refuses even when the row count stays the same. Findings name the file, the commit and the field names (`keys=stratum,tags,head,plan_bytes`), never the values, on stderr, in `--json` and in the pre-push hook. +- **`sealed.label_join_unchecked`.** A supported file over 4 MiB, one that does not parse, one nested deeper than 64 levels, or a blob git cannot read refuses instead of passing unread. The `SEALED:` line counts these as `unchecked=`. Like every sealed finding, it can be waived through the base branch's `.gstack/sealed-allow.tsv`; `allow_paths` never exempt either rule. +- **The policy only tightens.** Both lists are the union of the base branch's and the pushed policy's, each resolved to its default first, so a pushed config can turn the rule on or widen it but cannot turn it off or narrow it. + +### Changed +- **docs/pregate.md#sealed** documents the rule, its normalization (`Tree SHA-256` matches as `tree_sha_256`) and its known limits: joins across files, joins by reference, columnar JSON, labels in file names and prose. Its version-skew note now names the new keys. A gstack older than this release refuses them as unknown, so raise `min_gstack_version` before adopting them. The unknown-key error now lists `label_keys` and `identifier_keys` among the valid keys. + +### Tests +- `test/sealed-guard.test.ts`: the GSTA-2 panel refuses through `gstack-pregate sealed`, `--json` and the pre-push hook, and warns on a private destination, with no value in any output. It failed on main. A golden captured from main 2d1b55d76 covers seven pushes (refused, clear, exempt, waived, warned, error, no policy) without `label_keys`; stdout, stderr, `--json`, the hook's stderr and every exit code match it byte for byte. +- `test/sealed-join.test.ts`: each format, human table headers, hex values under unlisted keys, decimal strings that stay clear, replacement at the same count, delete plus add, an added identifier field, labels then identifiers a commit later, renames, merges, five unchecked cases, waivers, `allow_paths`, pushed attempts to narrow or disable the rule, and `label_keys: []` matching an absent key. diff --git a/docs/pregate.md b/docs/pregate.md index 98a1caa0b..9d338d945 100644 --- a/docs/pregate.md +++ b/docs/pregate.md @@ -946,7 +946,8 @@ with a timing flake: never raise the bound. "answer_markers": ["SEALED ANSWER:"], "seed_patterns": ["shuffle_seed=\\d+"], "local_paths": true, - "case_insensitive": true } } + "case_insensitive": true, + "label_keys": ["stratum", "decoy_set"] } } ``` A repo with a `sealed` key gets a configured local check against accidental @@ -992,11 +993,48 @@ gstack-pregate sealed [--range ] [--remote origin] [--base ] [--js sealed data. Each exempt path is listed on every push (`exempt commit (sealed.allow_paths )`) and counted as `exempt=` 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. +- **Label joins.** Off unless `label_keys` names at least one field. Then + `sealed.label_join` refuses a published record that puts a label field next + to an identifier, the way a panel row with `stratum` beside `head` and + `plan_bytes` tells a model under test which unit it is. A record is a JSON + object at any depth (its own fields plus one level of nested objects, named + like `meta.head`), a JSONL or NDJSON line, a CSV or TSV row, or a row of a + Markdown pipe table. Field names and table headers are compared after + trimming, lowercasing and turning spaces and hyphens into underscores + (`Tree SHA-256` is `tree_sha_256`); this is the rule's own normalization, + separate from `case_insensitive`. An identifier is a field whose name + matches an `identifier_keys` glob (`*` is the only wildcard; the default + is `head`, `head_sha`, `sha`, `commit`, `tree`, `tree_sha*`, `*_sha`, + `*_sha256`, `sha256`, `digest`, `*_digest`, `hash`, `*_hash`, + `*_bytes`, `bytes`, `size`, `files`, `lines`), or any string value of 7 + to 64 hex characters with at least one digit and one letter a to f. Numbers + count only through the field name. For each supported file a commit adds, + modifies, renames or copies, the guard fingerprints every joined record + (sha256 over its label and identifier fields and their values, kept in + memory) and compares the set with the parent's version of the file (the + rename or copy source; every parent for a merge). A fingerprint no parent + has is a finding, so unchanged records and formatting-only edits stay clear, + and a replaced, added or widened record refuses even when the count stays + the same. A supported file over 4 MiB, one that does not parse (one bad + JSONL line is enough), one nested deeper than 64 levels, or a blob git + cannot read is `sealed.label_join_unchecked`. It refuses too, and the + `SEALED:` line counts it as `unchecked=`. Findings print field names + (`keys=`, label fields first), never values; `--json` carries them as + `keys`. `allow_paths` never exempt either rule; a base-branch waiver does. + Both lists are the union of the base branch's and the pushed policy's, each + resolved to its default first, so a push can turn the rule on or widen it + but never turn it off or narrow it. Without `label_keys` the guard reads no + file contents and its output is unchanged. Known limits: joins across files, + joins by reference inside a file (a `decoy_set` list naming units described + elsewhere), columnar JSON (`columns` plus `rows`), labels encoded in file + names, and prose. +- **Version skew.** A gstack older than the release that adds a `sealed` key + refuses it as unknown, so every push and `gstack-pregate run` in the + repository fail on that machine. `allow_paths` and `case_insensitive` came + with the release whose CHANGELOG adds `sealed.allow_paths`; `label_keys` + and `identifier_keys` came with the release whose CHANGELOG adds + `sealed.label_join`. Raise `min_gstack_version` in + `.gstack/ship-policy.json` to that release before landing the key. - **Policy.** The union of the destination base branch's `sealed` and the 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). The diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 61886e363..e9968ddeb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -3303,7 +3303,7 @@ 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 reached through a symlink into it, is ignored rather than read (`--explain` 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 +`allow_paths`, `case_insensitive`, `label_keys` or `identifier_keys`) 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. @@ -3892,7 +3892,10 @@ migration is a code change, so the gate reruns. sealed path or transcript (binary, empty and later-deleted files included), or adds a line with a sealed-answer marker, a seed or a local home path, and the destination is public or its visibility is unknown (a new or unreadable -repository counts as public). An unreadable or malformed sealed policy refuses +repository counts as public). With `sealed.label_keys` set, a new record that +puts a label field next to an identifier (`sealed.label_join`), or a supported +file the guard could not read (`sealed.label_join_unchecked`), refuses the +same way. An unreadable or malformed sealed policy refuses too. The managed pre-push hook and `gstack-pregate sealed` print one line per finding: rule, `file[:line]`, commit. They never print the matched text. @@ -3901,7 +3904,9 @@ reaches the public repository (a later commit that deletes it still publishes 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 +([docs/pregate.md#sealed](pregate.md#sealed)). For `sealed.label_join`, drop +the identifier fields (or the labels) from the published file, or move it under +a sealed path; `allow_paths` never exempt it. A reviewed false positive gets one line in the base branch's `.gstack/sealed-allow.tsv`: `rulepathshareason`, where `sha` is the full commit sha (a shortened one is ignored and named on stderr). A private diff --git a/lib/pregate/config.ts b/lib/pregate/config.ts index b30ad4c83..0b9f3caa1 100644 --- a/lib/pregate/config.ts +++ b/lib/pregate/config.ts @@ -338,7 +338,7 @@ export function explainConfig(): string { 'local: / 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, 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)', + 'sealed: { paths, transcripts, answer_markers, seed_patterns, local_paths, allow_paths, case_insensitive, label_keys, identifier_keys, 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; label_keys turns on sealed.label_join, which refuses a published record that puts one of those fields next to an identifier (identifier_keys globs, or a hex value) (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)', 'timeout_s: seconds each runner process may take (the unit-file group, each serial file, each guard without its own timeout_s, and the remote gate\'s deadline) when --timeout is not passed; 1 to 3600, default 600', diff --git a/lib/sealed-guard.ts b/lib/sealed-guard.ts index 7ba8b261d..d8ebeb9b2 100644 --- a/lib/sealed-guard.ts +++ b/lib/sealed-guard.ts @@ -11,6 +11,8 @@ * local_paths true: a local home path (/home//, /Users//) 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 + * label_keys field names whose records may not also carry an identifier (lib/sealed-join.ts; off when empty) + * identifier_keys field-name globs that count as identifiers (default DEFAULT_IDENTIFIER_KEYS) * visibility "public" | "private": the destination override (reviewed on the base branch) * * The publication boundary is every commit the push publishes: the local sha @@ -32,19 +34,20 @@ import { spawnSync } from 'node:child_process'; import * as fs from 'node:fs'; import * as path from 'node:path'; import { containedRelative, globToRegExp } from './ship-policy'; +import { DEFAULT_IDENTIFIER_KEYS, identifierKeyError, joinFindings, labelKeyError } from './sealed-join'; 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; allow_paths: string[]; case_insensitive: boolean; visibility?: 'public' | 'private' } -export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string } +export type SealedRule = 'sealed.path' | 'sealed.transcript' | 'sealed.answer' | 'sealed.seed' | 'sealed.local_path' | 'sealed.label_join' | 'sealed.label_join_unchecked'; +export interface SealedPolicy { paths: string[]; transcripts: string[]; answer_markers: string[]; seed_patterns: string[]; local_paths: boolean; allow_paths: string[]; case_insensitive: boolean; label_keys: string[]; identifier_keys: string[]; visibility?: 'public' | 'private' } +export interface SealedFinding { rule: SealedRule; file: string; line?: number; commit: string; keys?: 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', 'allow_paths', 'case_insensitive', 'visibility']; +const KEYS = ['paths', 'transcripts', 'answer_markers', 'seed_patterns', 'local_paths', 'allow_paths', 'case_insensitive', 'label_keys', 'identifier_keys', 'visibility']; const MAX_ENTRIES = 64; const MAX_REGEX = 256; const LOCAL_PATH_RE = /(?:^|[^A-Za-z0-9_.~-]|\$\{!?(?:[A-Za-z_][A-Za-z0-9_]*(?:\[[^\]\s]{0,64}\])?|[0-9]+|[@*#?$!-]):?-)(?:\/home\/[^/\s'"`]+\/|\/Users\/[^/\s'"`]+\/|[A-Za-z]:\\+Users\\+[^\\\s'"`]+\\)/; @@ -68,6 +71,7 @@ export function sealedErrors(raw: unknown): Array<{ path: string; message: strin 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 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); }; list('paths', glob); list('transcripts', glob); list('answer_markers', regex); list('seed_patterns', regex); list('allow_paths', exception); + list('label_keys', labelKeyError); list('identifier_keys', identifierKeyError); 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; @@ -93,15 +97,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, allow_paths: s.allow_paths ?? [], case_insensitive: s.case_insensitive === 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, label_keys: s.label_keys ?? [], identifier_keys: s.identifier_keys ?? DEFAULT_IDENTIFIER_KEYS, visibility: s.visibility }, label }; } -/** 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. */ +/** Base ∪ pushed: deny lists, `label_keys` and `identifier_keys` (each resolved to its default per policy first) 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 u = (k: 'paths' | 'transcripts' | 'answer_markers' | 'seed_patterns' | 'label_keys' | 'identifier_keys') => [...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), allow_paths: base?.allow_paths ?? [], case_insensitive: !!(base?.case_insensitive || pushed?.case_insensitive), 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), label_keys: u('label_keys'), identifier_keys: u('identifier_keys'), visibility }; } /** Reviewed waivers: `rulepathshareason` lines in the base branch's allow file; a waiver counts only when its sha equals the finding's full commit sha. */ @@ -190,7 +194,7 @@ function textFindings(repoRoot: string, commits: string[], p: SealedPolicy): Sea export function scanCommits(repoRoot: string, commits: string[], policy: SealedPolicy): SealedFinding[] { if (!commits.length) return []; const seen = new Set(); - return [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy)].filter(f => { + return [...pathFindings(repoRoot, commits, policy), ...textFindings(repoRoot, commits, policy), ...joinFindings(repoRoot, commits, policy)].filter(f => { const key = `${f.rule}\0${f.file}\0${f.line ?? ''}\0${f.commit}`; if (seen.has(key)) return false; seen.add(key); @@ -299,15 +303,16 @@ export function renderSealed(r: SealedReport, context: string): string { lines.push(`${context}: ignored waiver ${i.rule} ${i.path} sha ${i.sha} (${why}; this finding is in commit ${i.commit}, review it before waiving)`); } 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)}${f.keys ? ` keys=${f.keys.join(',')}` : ''}`); + const join = r.findings.some(f => f.rule.startsWith('sealed.label_join')) ? [`sealed.label_join: a record puts a sealed.label_keys field next to an identifier (keys= lists the label fields, then the identifier fields); drop one side from the published file or move it under a sealed path. sealed.label_join_unchecked: a supported file the guard could not read (over 4 MiB, unparseable, or nested deeper than 64); allow_paths never exempt either rule`] : []; if (r.status === 'error') { lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} — ${r.error}.`); lines.push(`Fix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)`); } else if (r.status === 'refused') { - lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body); + lines.push(`\n⛔ ${context} BLOCKED the publication of ${r.target} (${vis}) — sealed or eval output in ${r.commits} published commit(s):`, ...body, ...join); 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 \`rulepathfull-commit-shareason\` 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); + 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, ...join); } return lines.length ? lines.join('\n') + '\n' : ''; } diff --git a/lib/sealed-join.ts b/lib/sealed-join.ts new file mode 100644 index 000000000..2bc5595b9 --- /dev/null +++ b/lib/sealed-join.ts @@ -0,0 +1,232 @@ +/** + * sealed-join — the `sealed.label_join` rule of the sealed-output guard + * (lib/sealed-guard.ts), on only when the policy names `sealed.label_keys`. + * + * A record that puts a label field (a decoy stratum, a flaw tag) next to an + * identifier the model under test can compute from its own copy of the work + * (a head sha, a tree hash, a byte count) tells that model which label it + * carries. For every .json, .jsonl, .ndjson, .csv, .tsv or .md (pipe table) + * file a published commit adds, modifies, renames or copies, each joined + * record is fingerprinted (sha256 over its label and identifier key/value + * pairs, in memory only) at the commit and at every parent's version (the + * rename or copy source); a fingerprint no parent has is a finding. A file + * over 4 MiB, one that does not parse, nests deeper than 64 or cannot be read + * is `sealed.label_join_unchecked`. Findings carry field names, never values. + */ +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import type { SealedFinding } from './sealed-guard'; + +export const DEFAULT_IDENTIFIER_KEYS = ['head', 'head_sha', 'sha', 'commit', 'tree', 'tree_sha*', '*_sha', '*_sha256', 'sha256', 'digest', '*_digest', 'hash', '*_hash', '*_bytes', 'bytes', 'size', 'files', 'lines']; +export const JOIN_MAX_BYTES = 4 * 1024 * 1024; +const MAX_DEPTH = 64; +const MAX_KEY = 128; +const SUPPORTED = /\.(json|jsonl|ndjson|csv|tsv|md)$/i; +const HEX_ID = /^(?=[0-9a-f]*[0-9])(?=[0-9a-f]*[a-f])[0-9a-f]{7,64}$/i; +const ZERO = /^0+$/; +const TABLE_DELIMITER = /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/; + +export interface JoinPolicy { label_keys: string[]; identifier_keys: string[] } +type Field = { name: string; key: string; value: unknown }; +type Joins = Map; + +/** The rule's own key normalization: trimmed, lowercased, spaces and hyphens as underscores. */ +export const normalizeKey = (k: string) => k.trim().toLowerCase().replace(/[ -]/g, '_'); + +export function labelKeyError(s: string): string | null { + return s.trim() && s.length <= MAX_KEY && !/[*?[\]{}]/.test(s) ? null : `must be a field name of 1-${MAX_KEY} characters with no wildcard`; +} +export function identifierKeyError(s: string): string | null { + return s.trim() && s.length <= MAX_KEY && !/[?[\]{}]/.test(s) ? null : `must be a field name of 1-${MAX_KEY} characters; * is the only wildcard`; +} + +const present = (v: unknown) => typeof v === 'number' || (typeof v === 'string' && v.trim() !== '') || (Array.isArray(v) && v.length > 0); +const hexValue = (v: unknown): boolean => typeof v === 'string' ? HEX_ID.test(v.trim()) : Array.isArray(v) && v.some(e => typeof e === 'string' && HEX_ID.test(e.trim())); +const shown = (name: string) => name.split('.').map(s => HEX_ID.test(s) ? '(hex)' : s).join('.').slice(0, 64); + +function classifier(p: JoinPolicy): (fields: Field[]) => { fp: string; labels: string[]; ids: string[] } | null { + const labels = new Set(p.label_keys.map(normalizeKey)); + const ids = p.identifier_keys.map(g => new RegExp(`^${normalizeKey(g).replace(/[.+^${}()|[\]\\?]/g, '\\$&').replace(/\*/g, '.*')}$`)); + return fields => { + const l: Field[] = []; const i: Field[] = []; + for (const f of fields) { + if (labels.has(f.key)) l.push(f); + else if ((present(f.value) && ids.some(re => re.test(f.key))) || hexValue(f.value)) i.push(f); + } + if (!l.length || !i.length) return null; + const fp = createHash('sha256').update([...l, ...i].map(f => `${f.name}=${JSON.stringify(f.value)}`).sort().join('\0')).digest('hex'); + return { fp, labels: l.map(f => shown(f.name)), ids: i.map(f => shown(f.name)) }; + }; +} + +/** Every JSON object at any depth as one record: its own fields plus one level of nested object fields (`meta.head`); false past the depth cap. */ +function jsonRecords(root: unknown, out: Field[][]): boolean { + const field = (name: string, value: unknown, key: string): Field => ({ name, key: normalizeKey(key), value }); + const stack: Array<[unknown, number]> = [[root, 0]]; + while (stack.length) { + const [v, depth] = stack.pop()!; + if (!v || typeof v !== 'object') continue; + if (depth > MAX_DEPTH) return false; + if (Array.isArray(v)) { for (const e of v) stack.push([e, depth + 1]); continue; } + const fields: Field[] = []; + for (const [k, val] of Object.entries(v)) { + const name = normalizeKey(k); + fields.push(field(name, val, k)); + if (val && typeof val === 'object' && !Array.isArray(val)) for (const [k2, v2] of Object.entries(val)) fields.push(field(`${name}.${normalizeKey(k2)}`, v2, k2)); + stack.push([val, depth + 1]); + } + out.push(fields); + } + return true; +} + +function csvRows(text: string): string[][] | null { + const rows: string[][] = []; let row: string[] = []; let cell = ''; let quoted = false; + for (let i = 0; i < text.length; i++) { + const c = text[i]!; + if (quoted) { + if (c !== '"') cell += c; + else if (text[i + 1] === '"') { cell += '"'; i++; } + else quoted = false; + } else if (c === '"' && cell.trim() === '') { quoted = true; cell = ''; } + else if (c === ',') { row.push(cell); cell = ''; } + else if (c === '\n' || c === '\r') { if (c === '\r' && text[i + 1] === '\n') i++; row.push(cell); rows.push(row); row = []; cell = ''; } + else cell += c; + } + if (quoted) return null; + if (cell !== '' || row.length) { row.push(cell); rows.push(row); } + return rows; +} + +function markdownTables(text: string): string[][][] { + const cells = (line: string) => line.trim().replace(/^\|/, '').replace(/(? c.trim().replace(/^[`*]+|[`*]+$/g, '')); + const lines = text.split(/\r?\n/); + const tables: string[][][] = []; + for (let i = 0; i + 1 < lines.length; i++) { + if (!lines[i]!.includes('|') || !lines[i + 1]!.includes('|') || !TABLE_DELIMITER.test(lines[i + 1]!)) continue; + const table = [cells(lines[i]!)]; + let j = i + 2; + for (; j < lines.length && lines[j]!.includes('|'); j++) table.push(cells(lines[j]!)); + tables.push(table); + i = j - 1; + } + return tables; +} + +function tableRecords(rows: string[][], out: Field[][]): void { + const header = (rows[0] ?? []).map(normalizeKey); + for (const row of rows.slice(1)) if (row.some(c => c.trim())) out.push(header.map((h, i) => ({ name: h, key: h, value: (row[i] ?? '').trim() }))); +} + +/** The records of one supported file's text, or null when it cannot be checked (parse failure or depth cap). */ +export function fileRecords(file: string, text: string): Field[][] | null { + const body = text.replace(/^\uFEFF/, ''); + const ext = (/\.([a-z]+)$/i.exec(file)?.[1] ?? '').toLowerCase(); + const out: Field[][] = []; + try { + if (ext === 'json') return !body.trim() || jsonRecords(JSON.parse(body), out) ? out : null; + if (ext === 'jsonl' || ext === 'ndjson') { + for (const line of body.split(/\r?\n/)) if (line.trim() && !jsonRecords(JSON.parse(line), out)) return null; + return out; + } + } catch { return null; } + if (ext === 'md') { for (const t of markdownTables(body)) tableRecords(t, out); return out; } + const rows = ext === 'tsv' ? body.split(/\r?\n/).map(l => l.split('\t')) : csvRows(body); + if (!rows) return null; + tableRecords(rows.filter(r => r.some(c => c.trim())), out); + return out; +} + +/** Joined-record fingerprints of one file's text; null when it cannot be checked. */ +export function fileJoins(file: string, text: string, p: JoinPolicy): Joins | null { + const records = fileRecords(file, text); + if (!records) return null; + const classify = classifier(p); + const joins: Joins = new Map(); + for (const r of records) { const j = classify(r); if (j) joins.set(j.fp, { labels: j.labels, ids: j.ids }); } + return joins; +} + +function gitBuffer(repoRoot: string, args: string[], input: string, label: string): Buffer { + const r = spawnSync('git', args, { cwd: repoRoot, input, timeout: 120_000, maxBuffer: 512 * 1024 * 1024 }); + if (r.status !== 0) throw new Error(`git ${label} failed: ${(r.stderr ?? '').toString().trim().slice(0, 200)}`); + return r.stdout; +} + +interface Change { commit: string; file: string; blob: string; parents: string[] } + +/** Supported regular files a commit adds, modifies, renames or copies against every parent (`-m`: a merge's file must differ from each parent). */ +function changedFiles(repoRoot: string, commits: string[]): Change[] { + const parentCount = new Map(gitBuffer(repoRoot, ['rev-list', '--no-walk', '--parents', '--stdin'], commits.join('\n') + '\n', 'rev-list --parents').toString('utf8').split('\n').filter(Boolean).map(l => { const s = l.split(' '); return [s[0]!, Math.max(1, s.length - 1)] as const; })); + const tokens = gitBuffer(repoRoot, ['-c', 'core.quotePath=false', 'diff-tree', '--stdin', '-r', '--root', '-m', '-M', '-C', '--raw', '-z', '--no-abbrev'], commits.join('\n') + '\n', 'diff-tree -m').toString('utf8').split('\0'); + const sections = new Map>>(); + let current: Map | null = null; + for (let i = 0; i < tokens.length; i++) { + const t = tokens[i]!; + if (!t) continue; + if (!t.startsWith(':')) { current = new Map(); sections.set(t, [...(sections.get(t) ?? []), current]); continue; } + const [, dstMode, src, dst, status] = t.slice(1).split(' ') as [string, string, string, string, string]; + const file = /^[RC]/.test(status) ? tokens[i += 2]! : tokens[++i]!; + if (status.startsWith('D') || !/^100(644|755)$/.test(dstMode) || !SUPPORTED.test(file)) continue; + current?.set(file, { src, dst }); + } + const out: Change[] = []; + for (const [commit, parts] of sections) { + if (parts.length < (parentCount.get(commit) ?? 1)) continue; + for (const [file, { dst }] of parts[0]!) { + if (parts.every(p => p.has(file))) out.push({ commit, file, blob: dst, parents: parts.map(p => p.get(file)!.src).filter(s => !ZERO.test(s)) }); + } + } + return out; +} + +/** Blob text by sha through one `cat-file --batch-check` and chunked `--batch` reads; `big` over the cap, `missing` when unreadable. */ +function readBlobs(repoRoot: string, shas: string[]): Map { + const out = new Map(); + if (!shas.length) return out; + const small: Array<[string, number]> = []; + for (const line of gitBuffer(repoRoot, ['cat-file', '--batch-check'], shas.join('\n') + '\n', 'cat-file --batch-check').toString('utf8').split('\n').filter(Boolean)) { + const [sha, type, size] = line.split(' '); + if (type !== 'blob') out.set(sha!, { unread: 'missing' }); + else if (Number(size) > JOIN_MAX_BYTES) out.set(sha!, { unread: 'big' }); + else small.push([sha!, Number(size)]); + } + const decoder = new TextDecoder(); + for (let start = 0; start < small.length;) { + let end = start; let bytes = 0; + while (end < small.length && (end === start || bytes + small[end]![1] <= 64 * 1024 * 1024)) bytes += small[end++]![1]; + const buf = gitBuffer(repoRoot, ['cat-file', '--batch'], small.slice(start, end).map(s => s[0]).join('\n') + '\n', 'cat-file --batch'); + let pos = 0; + for (let k = start; k < end; k++) { + const nl = buf.indexOf(0x0a, pos); + const size = Number(buf.subarray(pos, nl).toString('utf8').split(' ')[2]); + out.set(small[k]![0], decoder.decode(buf.subarray(nl + 1, nl + 1 + size))); + pos = nl + 1 + size + 1; + } + start = end; + } + return out; +} + +/** `sealed.label_join` and `sealed.label_join_unchecked` findings for the published commits; [] without label_keys (no git reads). */ +export function joinFindings(repoRoot: string, commits: string[], p: JoinPolicy): SealedFinding[] { + if (!p.label_keys.length || !commits.length) return []; + const changes = changedFiles(repoRoot, commits); + const blobs = readBlobs(repoRoot, [...new Set(changes.flatMap(c => [c.blob, ...c.parents]))]); + const cache = new Map(); + const joinsOf = (sha: string, file: string) => { + const key = `${sha}\0${(/\.[a-z]+$/i.exec(file)?.[0] ?? '').toLowerCase()}`; + if (!cache.has(key)) { const text = blobs.get(sha); cache.set(key, typeof text === 'string' ? fileJoins(file, text, p) : null); } + return cache.get(key)!; + }; + const out: SealedFinding[] = []; + for (const c of changes) { + const now = joinsOf(c.blob, c.file); + if (!now) { out.push({ rule: 'sealed.label_join_unchecked', file: c.file, commit: c.commit }); continue; } + const before = new Set(c.parents.flatMap(s => [...(joinsOf(s, c.file)?.keys() ?? [])])); + const labels = new Set(); const ids = new Set(); + for (const [fp, j] of now) if (!before.has(fp)) { j.labels.forEach(k => labels.add(k)); j.ids.forEach(k => ids.add(k)); } + if (labels.size) out.push({ rule: 'sealed.label_join', file: c.file, commit: c.commit, keys: [...[...labels].sort(), ...[...ids].sort()] }); + } + return out; +} diff --git a/test/fixtures/module-size-ratchet.json b/test/fixtures/module-size-ratchet.json index d56450722..465a2dd03 100644 --- a/test/fixtures/module-size-ratchet.json +++ b/test/fixtures/module-size-ratchet.json @@ -155,6 +155,7 @@ { "file": "lib/pregate/checks/timing.ts", "movedFrom": "(new: pre-gate timing-assertion check, field list 2 W4)" }, { "file": "lib/pregate/runtime.ts", "movedFrom": "(new: the verified launch Bun for repo commands, field wave CD1)" }, { "file": "lib/sealed-guard.ts", "movedFrom": "(new: the sealed-output guard shared by the pre-push hook and gstack-pregate sealed, field wave CD4)" }, + { "file": "lib/sealed-join.ts", "movedFrom": "(new: the sealed.label_join rule, label fields published beside identifiers, GST-620)" }, { "file": "lib/pregate/regate.ts", "movedFrom": "(new: the regate verdict from a fresh tree receipt plus migration collisions, field wave CD3)" }, { "file": "lib/autoplan-scope.ts", "movedFrom": "(new: UI evidence from rendered surfaces and the plan scope marker, shared by the runner and the snapshot tool, field wave B2c)" }, { "file": "lib/source-root.ts", "movedFrom": "(new: the source checkout behind an install root, from its recorded .source-path, field wave B2e)" }, diff --git a/test/fixtures/sealed-guard-off-golden.json b/test/fixtures/sealed-guard-off-golden.json new file mode 100644 index 000000000..ebe386370 --- /dev/null +++ b/test/fixtures/sealed-guard-off-golden.json @@ -0,0 +1,65 @@ +{ + "refused": { + "code": 1, + "stdout": "SEALED: refused findings=2 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit \n", + "stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of /origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit \n sealed.answer runs/c1/summary.md:1 commit \nRemove 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 .gstack/pregate.json, or waive one reviewed finding with a `rulepathfull-commit-shareason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n", + "jsonCode": 1, + "json": "{\"status\":\"refused\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"\"},{\"rule\":\"sealed.answer\",\"file\":\"runs/c1/summary.md\",\"line\":1,\"commit\":\"\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":false}\n", + "hookCode": 1, + "hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of /origin.git (visibility: unknown, counted public; not a GitHub remote) — sealed or eval output in 1 published commit(s):\n sealed.transcript runs/c1/transcript.jsonl commit \n sealed.answer runs/c1/summary.md:1 commit \nRemove 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 .gstack/pregate.json, or waive one reviewed finding with a `rulepathfull-commit-shareason` line in the base branch's .gstack/sealed-allow.tsv. A private destination: set sealed.visibility \"private\" in the base branch's .gstack/pregate.json (SEALED_OUTPUT_REFUSED)\n" + }, + "clear": { + "code": 0, + "stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=0 policy=origin/main + commit \n", + "stderr": "", + "jsonCode": 0, + "json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "" + }, + "exempt": { + "code": 0, + "stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=0 exempt=1 policy=origin/main + commit \n", + "stderr": "gstack-pregate sealed: exempt sealed.path sealed/s1/MANIFEST.json commit (sealed.allow_paths **/sealed/*/MANIFEST.json)\n", + "jsonCode": 0, + "json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[],\"exempt\":[{\"rule\":\"sealed.path\",\"file\":\"sealed/s1/MANIFEST.json\",\"commit\":\"\",\"glob\":\"**/sealed/*/MANIFEST.json\"}],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "gstack sealed-output guard: exempt sealed.path sealed/s1/MANIFEST.json commit (sealed.allow_paths **/sealed/*/MANIFEST.json)\n" + }, + "waived": { + "code": 0, + "stdout": "SEALED: clear findings=0 commits=1 visibility=unknown (counted public) waived=1 exempt=0 policy=origin/main + commit \n", + "stderr": "gstack-pregate sealed: waived sealed.transcript runs/c1/transcript.jsonl commit (.gstack/sealed-allow.tsv: synthetic fixture)\n", + "jsonCode": 0, + "json": "{\"status\":\"clear\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[],\"exempt\":[],\"waived\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"\",\"reason\":\"synthetic fixture\"}],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"not a GitHub remote\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "gstack sealed-output guard: waived sealed.transcript runs/c1/transcript.jsonl commit (.gstack/sealed-allow.tsv: synthetic fixture)\n" + }, + "warned": { + "code": 0, + "stdout": "SEALED: warned findings=1 commits=1 visibility=private waived=0 exempt=0 policy=origin/main + commit \n", + "stderr": "gstack-pregate sealed: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit \n", + "jsonCode": 0, + "json": "{\"status\":\"warned\",\"policyLabels\":[\"origin/main\",\"commit \"],\"commits\":1,\"findings\":[{\"rule\":\"sealed.transcript\",\"file\":\"runs/c1/transcript.jsonl\",\"commit\":\"\"}],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"private\",\"visibilitySource\":\"sealed.visibility override\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "gstack sealed-output guard: warning — sealed or eval output in a push to a private destination (visibility: private; sealed.visibility override); it would be refused for a public one:\n sealed.transcript runs/c1/transcript.jsonl commit \n" + }, + "error": { + "code": 1, + "stdout": "SEALED: error findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n", + "stderr": "\n⛔ gstack-pregate sealed BLOCKED the publication of /origin.git — the sealed policy in commit is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n", + "jsonCode": 1, + "json": "{\"status\":\"error\",\"error\":\"the sealed policy in commit is unreadable: sealed.answer_markers[0]: \\\"(\\\" is not a valid regex (Invalid regular expression: missing ))\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"/origin.git\",\"ok\":false}\n", + "hookCode": 1, + "hookStderr": "\n⛔ gstack sealed-output guard BLOCKED the publication of /origin.git — the sealed policy in commit is unreadable: sealed.answer_markers[0]: \"(\" is not a valid regex (Invalid regular expression: missing )).\nFix the sealed policy (gstack-pregate validate), then retry; an unreadable policy never passes (SEALED_OUTPUT_REFUSED)\n" + }, + "no-policy": { + "code": 0, + "stdout": "SEALED: no-policy findings=0 commits=0 visibility=unknown (counted public) waived=0 exempt=0 policy=no sealed key on refs/remotes/origin/main or HEAD\n", + "stderr": "", + "jsonCode": 0, + "json": "{\"status\":\"no-policy\",\"policyLabels\":[],\"commits\":0,\"findings\":[],\"exempt\":[],\"waived\":[],\"ignoredWaivers\":[],\"visibility\":\"unknown\",\"visibilitySource\":\"\",\"target\":\"/origin.git\",\"ok\":true}\n", + "hookCode": 0, + "hookStderr": "" + } +} diff --git a/test/sealed-guard.test.ts b/test/sealed-guard.test.ts index 878f36dc0..f93e1eaac 100644 --- a/test/sealed-guard.test.ts +++ b/test/sealed-guard.test.ts @@ -506,3 +506,64 @@ describe('waivers name the full commit sha (GST-268)', () => { expect(JSON.parse(preflight(s, ['--range', 'nosuch..HEAD', '--json']).out).ignoredWaivers).toEqual([]); }); }); + +const PANEL = JSON.stringify({ units: [ + { stratum: 'decoy', tags: ['W:T1'], plan_bytes: 3760, files: 5, head: '5b7db3aa' + '1'.repeat(32), tree_sha256: '15762a' + '0'.repeat(56) + 'aa' }, + { stratum: 'seeded', tags: ['B:T2'], plan_bytes: 4112, files: 7, head: '9c1e0bbb' + '2'.repeat(32), tree_sha256: '88aa11' + '0'.repeat(56) + 'bb' }, +] }, null, 2) + '\n'; +const PANEL_VALUES = [/5b7db3aa/, /15762a0/, /9c1e0bbb/, /88aa110/, /\b3760\b/, /\b4112\b/, /W:T1/, /B:T2/, /\bdecoy\b/, /\bseeded\b/]; + +describe('sealed.label_join: labels published beside self-computable identifiers (GST-620)', () => { + test('the GSTA-2 panel (stratum and tags beside head, tree_sha256, plan_bytes, files) refuses on a public destination and warns on a private one, naming fields, never values', () => { + const labeled = { paths: ['**/heldout*/**'], label_keys: ['stratum', 'tags', 'decoy_set'] }; + const s = scratch({ '.gstack/pregate.json': policy(labeled) }); + const sha = commit(s, { 'wiki/board/panel/panel.json': PANEL }); + const keys = ['stratum', 'tags', 'files', 'head', 'plan_bytes', 'tree_sha256']; + const text = preflight(s); + expect(text.code).toBe(1); + expect(text.err).toContain(`sealed.label_join wiki/board/panel/panel.json commit ${sha.slice(0, 12)} keys=${keys.join(',')}`); + expect(text.out).toMatch(/^SEALED: refused findings=1 commits=1 /m); + const json = preflight(s, ['--json']); + expect(JSON.parse(json.out).findings).toEqual([{ rule: 'sealed.label_join', file: 'wiki/board/panel/panel.json', commit: sha, keys }]); + const h = hook(s); + expect(h.code).toBe(1); + expect(h.err).toContain(`sealed.label_join wiki/board/panel/panel.json commit ${sha.slice(0, 12)} keys=${keys.join(',')}`); + for (const out of [text.out, text.err, json.out, h.err]) for (const value of PANEL_VALUES) expect(out).not.toMatch(value); + const priv = scratch({ '.gstack/pregate.json': policy({ ...labeled, visibility: 'private' }) }); + commit(priv, { 'wiki/board/panel/panel.json': PANEL }); + const p = preflight(priv); + expect(p.code).toBe(0); + expect(p.err).toMatch(/warning.*sealed\.label_join\s+wiki\/board\/panel\/panel\.json/s); + }); +}); + +const OFF_GOLDEN = path.join(ROOT, 'test', 'fixtures', 'sealed-guard-off-golden.json'); +/** Representative pushes under policies without label_keys, each read three ways: the preflight's text and --json, and the pre-push hook. */ +function offRuns(): Record { + const table = '| unit | stratum | head |\n|---|---|---|\n| u1 | decoy | 5b7db3a |\n'; + const fixtures: Record Scratch> = { + refused: () => { const s = scratch({ '.gstack/pregate.json': policy(SEALED) }); commit(s, { 'wiki/board/panel/panel.json': PANEL, 'runs/c1/transcript.jsonl': '{}\n', 'runs/c1/summary.md': 'SEALED ANSWER: x\n' + table }); return s; }, + clear: () => { const s = scratch({ '.gstack/pregate.json': policy({ paths: ['heldout/**'] }) }); commit(s, { 'wiki/board/panel/panel.json': PANEL, 'wiki/board.md': table, 'wiki/units.csv': 'stratum,head\ndecoy,5b7db3a\n' }); return s; }, + exempt: () => { const s = scratch({ '.gstack/pregate.json': policy(MANIFEST) }); commit(s, { 'sealed/s1/MANIFEST.json': PANEL, 'wiki/board/panel/panel.json': PANEL }); return s; }, + waived: () => { const s = scratch({ '.gstack/pregate.json': policy(SEALED) }); const sha = commit(s, { 'runs/c1/transcript.jsonl': '{}\n', 'wiki/panel.json': PANEL }); landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.transcript\truns/c1/transcript.jsonl\t${sha}\tsynthetic fixture\n` }); return s; }, + warned: () => { const s = scratch({ '.gstack/pregate.json': policy({ ...SEALED, visibility: 'private' }) }); commit(s, { 'runs/c1/transcript.jsonl': '{}\n', 'wiki/panel.json': PANEL }); return s; }, + error: () => { const s = scratch({ '.gstack/pregate.json': policy(SEALED) }); commit(s, { '.gstack/pregate.json': policy({ ...SEALED, answer_markers: ['('] }), 'wiki/panel.json': PANEL }); return s; }, + 'no-policy': () => { const s = scratch({}); commit(s, { 'wiki/panel.json': PANEL }); return s; }, + }; + const runs: Record = {}; + for (const [name, make] of Object.entries(fixtures)) { + const s = make(); + const norm = (t: string) => t.split(s.root).join('').replace(/[0-9a-f]{12,64}/g, ''); + const text = preflight(s); const json = preflight(s, ['--json']); const h = hook(s); + runs[name] = { code: text.code, stdout: norm(text.out), stderr: norm(text.err), jsonCode: json.code, json: norm(json.out), hookCode: h.code, hookStderr: norm(h.err) }; + } + return runs; +} + +describe('off unless configured (GST-620 item 13)', () => { + test('without sealed.label_keys, stdout, stderr, --json, the hook and every exit code match the capture from main 2d1b55d76 byte for byte', () => { + const runs = offRuns(); + if (process.env.SEALED_GUARD_GOLDEN_CAPTURE) { fs.writeFileSync(process.env.SEALED_GUARD_GOLDEN_CAPTURE, JSON.stringify(runs, null, 2) + '\n'); return; } + expect(runs).toEqual(JSON.parse(fs.readFileSync(OFF_GOLDEN, 'utf8'))); + }, 120_000); +}); diff --git a/test/sealed-join.test.ts b/test/sealed-join.test.ts new file mode 100644 index 000000000..bc0bc068e --- /dev/null +++ b/test/sealed-join.test.ts @@ -0,0 +1,225 @@ +/** + * GST-620: `sealed.label_join`, the sealed guard's rule for a published + * record that puts a label field (`sealed.label_keys`) next to an identifier + * the model under test can compute from its own copy of the work. The repro + * and the byte-for-byte "off" golden live in test/sealed-guard.test.ts; this + * file covers formats, the per-record content diff against every parent, + * fail-closed unchecked files, waivers, allow_paths and the monotonic policy. + */ +import { afterEach, describe, expect, test } from 'bun:test'; +import { spawnSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { sealedErrors } from '../lib/sealed-guard'; +import { DEFAULT_IDENTIFIER_KEYS, joinFindings } from '../lib/sealed-join'; + +const ROOT = path.resolve(import.meta.dir, '..'); +const PREGATE = path.join(ROOT, 'bin', 'gstack-pregate'); +const HEAD_A = '5b7db3aa' + '1'.repeat(32); +const HEAD_B = '9c1e0bbb' + '2'.repeat(32); +const LABELED = { label_keys: ['stratum', 'tags', 'decoy_set'] }; +const P = { label_keys: ['stratum', 'tags'], identifier_keys: DEFAULT_IDENTIFIER_KEYS }; + +interface Scratch { root: string; work: string; env: Record } +const scratches: Scratch[] = []; +afterEach(() => { while (scratches.length) fs.rmSync(scratches.pop()!.root, { recursive: true, force: true }); }); + +function git(s: Scratch, args: string[], ok = true): string { + const r = spawnSync('git', args, { cwd: s.work, encoding: 'utf8', timeout: 30_000, env: { ...process.env, ...s.env } }); + if (ok && r.status !== 0) throw new Error(`git ${args.join(' ')}: ${r.stderr}`); + return (r.stdout ?? '').trim(); +} +function write(s: Scratch, files: Record): void { + for (const [rel, body] of Object.entries(files)) { + const abs = path.join(s.work, rel); + if (body === null) { fs.rmSync(abs, { force: true }); continue; } + fs.mkdirSync(path.dirname(abs), { recursive: true }); + fs.writeFileSync(abs, body); + } +} +function commit(s: Scratch, files: Record, msg = 'results'): string { + write(s, files); + git(s, ['add', '-A']); git(s, ['commit', '-q', '-m', msg]); + return git(s, ['rev-parse', 'HEAD']); +} +/** A bare origin whose main carries the base files, and a clone on branch `results`. */ +function scratch(baseFiles: Record): Scratch { + const root = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'sealed-join-'))); + const work = path.join(root, 'work'); const origin = path.join(root, 'origin.git'); const state = path.join(root, 'state'); + fs.mkdirSync(work); fs.mkdirSync(state); + const env = { HOME: root, GSTACK_HOME: state, GSTACK_STATE_ROOT: state, GIT_CONFIG_GLOBAL: path.join(root, 'gitconfig'), GIT_CONFIG_NOSYSTEM: '1' }; + fs.writeFileSync(env.GIT_CONFIG_GLOBAL, '[user]\n\tname = t\n\temail = t@example.com\n[commit]\n\tgpgsign = false\n[init]\n\tdefaultBranch = main\n'); + const s = { root, work, env }; + scratches.push(s); + spawnSync('git', ['init', '-q', '--bare', origin], { env: { ...process.env, ...env }, timeout: 30_000 }); + git(s, ['init', '-q', '-b', 'main']); + commit(s, { 'README.md': '# evals\n', ...baseFiles }, 'base'); + git(s, ['remote', 'add', 'origin', origin]); git(s, ['push', '-q', 'origin', 'main']); + git(s, ['checkout', '-q', '-b', 'results']); + return s; +} +function landOnMain(s: Scratch, files: Record): void { + git(s, ['checkout', '-q', 'main']); commit(s, files, 'base change'); git(s, ['push', '-q', 'origin', 'main']); git(s, ['checkout', '-q', 'results']); +} +const policy = (sealed: unknown) => JSON.stringify({ sealed }, null, 2) + '\n'; +const panel = (units: object[]) => JSON.stringify({ units }, null, 2) + '\n'; +const unit = (name: string, head: string, extra: object = {}) => ({ name, stratum: name === 'u1' ? 'decoy' : 'seeded', tags: ['W:T1'], head, plan_bytes: 3760, ...extra }); +const found = (s: Scratch) => joinFindings(s.work, git(s, ['rev-list', 'origin/main..HEAD']).split('\n').filter(Boolean), P); +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 ?? '' }; +} + +describe('formats and matching', () => { + test('a JSONL line, a nested meta.head, CSV, TSV, a markdown table with human headers and a hex value under an unlisted key each join; decimal-only strings, labels alone and identifiers alone do not', () => { + const s = scratch({}); + const sha = commit(s, { + 'a.jsonl': '{"x":1}\n{"stratum":"decoy","meta":{"head":"5b7db3a"}}\n', + 'b.csv': 'unit,Stratum,Plan Bytes\nu1,decoy,"3,760"\n', + 'c.tsv': 'stratum\ttree_sha256\ndecoy\tabc\n', + 'd.md': '# board\n\n| Unit | Stratum | `head` | Tree SHA-256 |\n|---|:---:|---|---|\n| u1 | decoy | x | y |\n', + 'e.json': '[{"tags":["B:T2"],"id":"5b7db3a"}]\n', + 'f.json': '[{"stratum":"decoy","ts":"202610311200","name":"unit-1","count":12}]\n', + 'g.json': '[{"stratum":"decoy"},{"head":"5b7db3aa"}]\n', + 'h.md': '| head | size |\n|---|---|\n| 5b7db3a | 3 |\n', + 'i.txt': '{"stratum":"decoy","head":"5b7db3a"}\n', + }); + expect(found(s).sort((a, b) => a.file.localeCompare(b.file))).toEqual([ + { rule: 'sealed.label_join', file: 'a.jsonl', commit: sha, keys: ['stratum', 'meta.head'] }, + { rule: 'sealed.label_join', file: 'b.csv', commit: sha, keys: ['stratum', 'plan_bytes'] }, + { rule: 'sealed.label_join', file: 'c.tsv', commit: sha, keys: ['stratum', 'tree_sha256'] }, + { rule: 'sealed.label_join', file: 'd.md', commit: sha, keys: ['stratum', 'head', 'tree_sha_256'] }, + { rule: 'sealed.label_join', file: 'e.json', commit: sha, keys: ['tags', 'id'] }, + ]); + }); + + test('without label_keys the rule reads nothing: no git call, even outside a repository', () => { + expect(joinFindings(os.tmpdir(), ['0'.repeat(40)], { label_keys: [], identifier_keys: DEFAULT_IDENTIFIER_KEYS })).toEqual([]); + }); + + test('label_keys must be plain field names and identifier_keys allow only *; both must be string arrays', () => { + expect(sealedErrors({ label_keys: ['stratum'], identifier_keys: ['*_sha', 'head'] })).toEqual([]); + expect(sealedErrors({ label_keys: ['strat*'] }).map(e => e.path)).toEqual(['sealed.label_keys[0]']); + expect(sealedErrors({ identifier_keys: ['he?d', ''] }).map(e => e.path)).toEqual(['sealed.identifier_keys[0]', 'sealed.identifier_keys[1]']); + expect(sealedErrors({ label_keys: 'stratum' })).toEqual([{ path: 'sealed.label_keys', message: 'must be an array of strings' }]); + }); +}); + +describe('content diff against every parent', () => { + test('an unchanged record and a formatting-only edit stay clear; a replacement at the same count, a delete plus add and an added identifier field refuse', () => { + const s = scratch({ 'wiki/panel.json': panel([unit('u1', HEAD_A), unit('u2', HEAD_B)]) }); + const reformat = commit(s, { 'wiki/panel.json': JSON.stringify({ units: [unit('u1', HEAD_A), unit('u2', HEAD_B), { note: 'no labels here' }] }) + '\n' }); + const replaced = commit(s, { 'wiki/panel.json': panel([unit('u1', HEAD_A), unit('u2', 'c'.repeat(39) + '3')]) }); + const swapped = commit(s, { 'wiki/panel.json': panel([unit('u2', 'c'.repeat(39) + '3'), unit('u3', 'd'.repeat(39) + '4')]) }); + const widened = commit(s, { 'wiki/panel.json': panel([unit('u2', 'c'.repeat(39) + '3'), unit('u3', 'd'.repeat(39) + '4', { tree_sha256: 'e'.repeat(63) + '5' })]) }); + const commits = found(s).map(f => f.commit); + expect(commits).not.toContain(reformat); + expect(commits.sort()).toEqual([replaced, swapped, widened].sort()); + }); + + test('labels in one commit and identifiers added to the same records in the next: the second commit refuses', () => { + const s = scratch({}); + const labels = commit(s, { 'units.jsonl': '{"name":"u1","stratum":"decoy"}\n' }); + const ids = commit(s, { 'units.jsonl': '{"name":"u1","stratum":"decoy","head":"5b7db3a"}\n' }); + expect(found(s).map(f => f.commit)).toEqual([ids]); + expect(found(s).map(f => f.commit)).not.toContain(labels); + }); + + test('a pure rename is clear and a rename that adds a unit refuses; a clean merge of two published sides is clear and a resolution that adds a joined row refuses', () => { + const units = ['u1', 'u2', 'u3', 'u4', 'u5', 'u6'].map((n, i) => unit(n, String(i + 1).repeat(4) + 'ab' + 'f'.repeat(34))); + const s = scratch({ 'wiki/panel.json': panel(units) }); + git(s, ['mv', 'wiki/panel.json', 'wiki/board.json']); git(s, ['commit', '-q', '-m', 'rename']); + write(s, { 'wiki/board.json': panel([...units, unit('u7', HEAD_A)]) }); + git(s, ['mv', 'wiki/board.json', 'wiki/board2.json']); git(s, ['add', '-A']); git(s, ['commit', '-q', '-m', 'rename and add']); + const renamedAndAdded = git(s, ['rev-parse', 'HEAD']); + expect(found(s).map(f => f.commit)).toEqual([renamedAndAdded]); + + const m = scratch({ 'wiki/panel.json': panel([unit('u1', HEAD_A)]) }); + git(m, ['checkout', '-q', '-b', 'side']); + const side = commit(m, { 'wiki/panel.json': panel([unit('u1', HEAD_A), unit('u2', HEAD_B)]) }); + git(m, ['checkout', '-q', 'results']); + const ours = commit(m, { 'wiki/panel.json': panel([unit('u3', 'c'.repeat(39) + '3'), unit('u1', HEAD_A)]) }); + const merge = (resolution: object[]) => { + git(m, ['merge', '-q', '--no-ff', '--no-commit', 'side'], false); + write(m, { 'wiki/panel.json': panel(resolution) }); + git(m, ['add', '-A']); git(m, ['commit', '-q', '--no-edit', '-m', 'merge']); + return git(m, ['rev-parse', 'HEAD']); + }; + const clean = merge([unit('u3', 'c'.repeat(39) + '3'), unit('u1', HEAD_A), unit('u2', HEAD_B)]); + expect(found(m).map(f => f.commit).sort()).toEqual([side, ours].sort()); + expect(found(m).map(f => f.commit)).not.toContain(clean); + git(m, ['reset', '-q', '--hard', ours]); + const added = merge([unit('u3', 'c'.repeat(39) + '3'), unit('u1', HEAD_A), unit('u2', HEAD_B), unit('u4', 'd'.repeat(39) + '4')]); + const atMerge = found(m).filter(f => f.commit === added); + expect(atMerge).toEqual([{ rule: 'sealed.label_join', file: 'wiki/panel.json', commit: added, keys: ['stratum', 'tags', 'head', 'plan_bytes'] }]); + }); +}); + +describe('fail closed, waivers, allow_paths and the policy union (gstack-pregate sealed)', () => { + test('an oversized valid JSON, malformed JSON, a malformed JSONL line, nesting deeper than 64 and an unreadable blob are each sealed.label_join_unchecked; an empty file is clear', () => { + const s = scratch({ '.gstack/pregate.json': policy(LABELED) }); + let deep: unknown = { stratum: 'decoy' }; + for (let i = 0; i < 70; i++) deep = { next: deep }; + const sha = commit(s, { + 'big.json': JSON.stringify({ pad: 'x'.repeat(4 * 1024 * 1024 + 10), units: [] }), + 'bad.json': '{"stratum": "decoy",', + 'bad.jsonl': '{"stratum":"decoy"}\nnot json\n', + 'deep.json': JSON.stringify(deep), + 'gone.json': '{"note":"this blob is removed after the commit"}\n', + 'empty.json': '', + }); + const blob = git(s, ['rev-parse', 'HEAD:gone.json']); + fs.rmSync(path.join(s.work, '.git', 'objects', blob.slice(0, 2), blob.slice(2))); + const r = preflight(s); + expect(r.code).toBe(1); + for (const f of ['big.json', 'bad.json', 'bad.jsonl', 'deep.json', 'gone.json']) expect(r.err).toContain(`sealed.label_join_unchecked ${f} commit ${sha.slice(0, 12)}`); + expect(r.err).not.toContain('empty.json'); + expect(r.out).toMatch(/^SEALED: refused findings=5 commits=1 .* exempt=0 unchecked=5 policy=/m); + const json = JSON.parse(preflight(s, ['--json']).out); + expect(json.findings.map((f: { rule: string; file: string }) => `${f.rule} ${f.file}`).sort()).toEqual(['bad.json', 'bad.jsonl', 'big.json', 'deep.json', 'gone.json'].map(f => `sealed.label_join_unchecked ${f}`)); + }); + + test('allow_paths never exempt the rule; a short-sha waiver is ignored and the full sha waives', () => { + const s = scratch({ '.gstack/pregate.json': policy({ ...LABELED, paths: ['heldout/**'], allow_paths: ['wiki/board/panel.json'] }) }); + const sha = commit(s, { 'wiki/board/panel.json': panel([unit('u1', HEAD_A)]) }); + const r = preflight(s); + expect(r.code).toBe(1); + expect(r.out).toMatch(/findings=1 .* exempt=0 policy=/); + landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.label_join\twiki/board/panel.json\t${sha.slice(0, 12)}\tsynthetic fixture\n` }); + const short = preflight(s); + expect(short.code).toBe(1); + expect(short.err).toContain('ignored waiver sealed.label_join wiki/board/panel.json'); + landOnMain(s, { '.gstack/sealed-allow.tsv': `sealed.label_join\twiki/board/panel.json\t${sha}\tsynthetic fixture\n` }); + const full = preflight(s); + expect(full.code).toBe(0); + expect(full.err).toContain('waived sealed.label_join wiki/board/panel.json'); + }); + + test('a push can enable or widen the rule but never disable or narrow it', () => { + const leak = { 'wiki/panel.json': panel([unit('u1', HEAD_A)]) }; + for (const pushed of [{ paths: ['heldout/**'] }, { ...LABELED, identifier_keys: ['zzz'] }, { label_keys: [] as string[], identifier_keys: [] as string[] }]) { + const s = scratch({ '.gstack/pregate.json': policy(LABELED) }); + commit(s, { '.gstack/pregate.json': policy(pushed), ...leak }); + const r = preflight(s); + expect(r.code).toBe(1); + expect(r.err).toContain('sealed.label_join wiki/panel.json'); + expect(r.err).toContain('head,plan_bytes'); + } + const enable = scratch({ '.gstack/pregate.json': policy({ paths: ['heldout/**'] }) }); + commit(enable, { '.gstack/pregate.json': policy(LABELED), ...leak }); + expect(preflight(enable).err).toContain('sealed.label_join wiki/panel.json'); + }); + + test('label_keys: [] reports exactly what an absent label_keys reports', () => { + const run = (sealed: object) => { + const s = scratch({ '.gstack/pregate.json': policy(sealed) }); + commit(s, { 'wiki/panel.json': panel([unit('u1', HEAD_A)]), 'runs/c1/transcript.jsonl': '{}\n' }); + const norm = (t: string) => t.split(s.root).join('').replace(/[0-9a-f]{12,64}/g, ''); + const text = preflight(s); const json = preflight(s, ['--json']); + return [text.code, norm(text.out), norm(text.err), json.code, norm(json.out)]; + }; + const base = { transcripts: ['runs/**/transcript*.jsonl'] }; + expect(run({ ...base, label_keys: [] })).toEqual(run(base)); + }); +});