@junghanacs/entwurf 0.25.0 → 0.30.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/AGENTS.md +1 -1
  2. package/BASELINE.md +1 -1
  3. package/CHANGELOG.md +182 -0
  4. package/CONTRIBUTING.md +1 -1
  5. package/DELIVERY.md +1 -1
  6. package/README.md +178 -674
  7. package/VERIFY.md +17 -16
  8. package/demo/README.md +1 -1
  9. package/docs/acp-backend-rail.md +6 -2
  10. package/docs/external-mcp-host.md +1 -1
  11. package/docs/setup-clean-host.md +346 -10
  12. package/mcp/entwurf-bridge/dist/mcp/entwurf-bridge/src/index.js +27 -4
  13. package/mcp/entwurf-bridge/dist/pi-extensions/lib/acp/backend-adapter.js +12 -1
  14. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-peers-render.js +1 -1
  15. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-decider.js +2 -2
  16. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-mailbox.js +2 -2
  17. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-production.js +2 -2
  18. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-surface.js +3 -5
  19. package/mcp/entwurf-bridge/dist/pi-extensions/lib/entwurf-v2-visible-resume.js +3 -3
  20. package/mcp/entwurf-bridge/dist/pi-extensions/lib/fresh-call-composition.js +13 -6
  21. package/mcp/entwurf-bridge/dist/pi-extensions/lib/meta-mailbox-body.js +1 -1
  22. package/mcp/entwurf-bridge/dist/pi-extensions/lib/mux-fresh-call.js +3 -3
  23. package/mcp/entwurf-bridge/src/index.ts +30 -3
  24. package/package.json +11 -11
  25. package/pi-extensions/entwurf-control.ts +50 -637
  26. package/pi-extensions/lib/acp/backend-adapter.ts +13 -1
  27. package/pi-extensions/lib/entwurf-fact-provider.ts +2 -2
  28. package/pi-extensions/lib/entwurf-mcp-server.ts +74 -0
  29. package/pi-extensions/lib/entwurf-peers-render.ts +1 -1
  30. package/pi-extensions/lib/entwurf-v2-decider.ts +2 -2
  31. package/pi-extensions/lib/entwurf-v2-mailbox.ts +2 -2
  32. package/pi-extensions/lib/entwurf-v2-production.ts +3 -3
  33. package/pi-extensions/lib/entwurf-v2-surface.ts +3 -5
  34. package/pi-extensions/lib/entwurf-v2-visible-resume.ts +3 -3
  35. package/pi-extensions/lib/fresh-call-composition.ts +13 -6
  36. package/pi-extensions/lib/meta-mailbox-body.ts +1 -1
  37. package/pi-extensions/lib/mux-fresh-call.ts +3 -3
  38. package/run.sh +168 -54
  39. package/scripts/check-entwurf-peers-surface.ts +8 -15
  40. package/scripts/check-entwurf-self-address.ts +7 -11
  41. package/scripts/check-entwurf-v2-surface.ts +20 -112
  42. package/scripts/check-fresh-call-dispatch.ts +12 -13
  43. package/scripts/check-gate-qualification.ts +2741 -157
  44. package/scripts/check-install-surface.ts +3 -2
  45. package/scripts/check-mux-resume-call.ts +24 -35
  46. package/scripts/check-pi-bridge-sender.ts +130 -0
  47. package/scripts/check-pi-mcp-bridge.ts +251 -0
  48. package/scripts/lib/codex-fresh-live-protocol.ts +17 -5
  49. package/scripts/lib/codex-fresh-source-receipts.ts +21 -2
  50. package/scripts/lib/mutation-qualify.ts +269 -44
  51. package/scripts/lib/pi-mcp-bridge-probe.mjs +170 -0
  52. package/scripts/lib/qualification-acceptance.ts +146 -0
  53. package/scripts/lib/qualification-artifact.ts +359 -0
  54. package/scripts/lib/qualification-collection.ts +384 -0
  55. package/scripts/lib/qualification-gh.ts +231 -0
  56. package/scripts/lib/qualification-ledger.ts +669 -0
  57. package/scripts/lib/qualification-oracle-core.ts +279 -0
  58. package/scripts/lib/qualification-receipt.ts +561 -0
  59. package/scripts/lib/qualification-release.ts +271 -0
  60. package/scripts/meta-bridge-doctor.sh +16 -5
  61. package/scripts/mutants/acp-foreground-only.json +17 -0
  62. package/scripts/mutants/codex-native.json +11 -11
  63. package/scripts/mutants/control-send-receipt.json +6 -4
  64. package/scripts/mutants/copilot-birth.json +2 -1
  65. package/scripts/mutants/fresh-call-dispatch.json +3 -14
  66. package/scripts/mutants/gate-qualification.json +59 -0
  67. package/scripts/mutants/mux-fresh-call.json +43 -45
  68. package/scripts/mutants/mux-resume-call.json +11 -35
  69. package/scripts/mutants/pack-install.json +15 -4
  70. package/scripts/mutants/pi-bridge-sender.json +54 -0
  71. package/scripts/mutants/pi-floor.json +6 -2
  72. package/scripts/mutants/pi-mcp-bridge.json +36 -0
  73. package/scripts/mutants/pi-mcp-register.json +51 -0
  74. package/scripts/mutants/qualification-ledger.json +489 -0
  75. package/scripts/mutants/v2-surface.json +6 -6
  76. package/scripts/qualification-oracle.ts +124 -0
  77. package/scripts/resident-rpc-drive.ts +5 -1
  78. package/scripts/smoke-acp-bundled-mcp-live.ts +3 -1
  79. package/scripts/smoke-acp-cortex-live.ts +3 -1
  80. package/scripts/smoke-acp-session-reuse-live.ts +29 -12
  81. package/scripts/smoke-acp-socket-citizen-live.ts +3 -1
  82. package/scripts/smoke-acp-v2-send-live.ts +3 -1
  83. package/scripts/smoke-codex-fresh-live.ts +20 -1
  84. package/scripts/smoke-entwurf-chain-live.ts +6 -2
  85. package/scripts/smoke-entwurf-v2-matrix-live.ts +3 -1
  86. package/scripts/smoke-herdr-raw-install-live.sh +8 -3
  87. package/scripts/smoke-mux-lifecycle-live.ts +1 -1
  88. package/scripts/smoke-resident-garden-guard.sh +3 -1
@@ -0,0 +1,146 @@
1
+ /**
2
+ * qualification-acceptance — check that a written risk-acceptance RECORD is consistent with
3
+ * the RISK-PENDING release ledger it names (#124 P2). PURE: no network, no fs, no git.
4
+ *
5
+ * What it checks: the record's shape, that the ledger is RISK-PENDING and carries no
6
+ * refusal state, that the record's digest is the ledger's canonical digest recomputed
7
+ * HERE, that final SHA / required groups / the exact risk list (order and duplicates
8
+ * included) match, and that the ledger's risk list is what its own groups imply
9
+ * (re-derived here, independently of the composer).
10
+ *
11
+ * What it does NOT check, and never claims:
12
+ * - that the utterance is genuine, or who said it. `transcribedBy` names who wrote the
13
+ * record down; it is not an authenticator, and a file is not a person.
14
+ * - that the ledger is TRUE. A matching digest says the record names these ledger bytes;
15
+ * it says nothing about whether the evidence behind them is what it claims.
16
+ *
17
+ * Hence the only non-refusal outcome is RECORD-CONSISTENT (statement not authenticated).
18
+ * A release-class acceptance is not produced here: this module has no such output.
19
+ */
20
+
21
+ import type { GroupLedger } from "./qualification-ledger.ts";
22
+ import { canonicalJson, sha256 } from "./qualification-receipt.ts";
23
+ import type { ReleaseLedger } from "./qualification-release.ts";
24
+
25
+ export const ACCEPTANCE_SCHEMA = "entwurf.qualification-risk-acceptance/v0";
26
+ export const RECORD_CONSISTENT = "RECORD-CONSISTENT (statement not authenticated)";
27
+
28
+ export interface AcceptanceRecord {
29
+ schema: typeof ACCEPTANCE_SCHEMA;
30
+ finalSha: string;
31
+ ledgerSha256: string;
32
+ risks: string[];
33
+ required: string[][];
34
+ /** GLG's words, verbatim. Not normalized, not verified. */
35
+ utterance: string;
36
+ /** YYYY-MM-DD (KST), a real calendar date. */
37
+ acceptedOn: string;
38
+ /** Who wrote the record down. Not an authenticator. */
39
+ transcribedBy: string;
40
+ }
41
+
42
+ export interface AcceptanceCheck {
43
+ outcome: typeof RECORD_CONSISTENT | "REFUSED";
44
+ errors: string[];
45
+ }
46
+
47
+ const KEYS = ["acceptedOn", "finalSha", "ledgerSha256", "required", "risks", "schema", "transcribedBy", "utterance"];
48
+ const isStr = (v: unknown): v is string => typeof v === "string";
49
+ const isStrArr = (v: unknown): v is string[] => Array.isArray(v) && v.every(isStr);
50
+
51
+ /** A real calendar date, not only a date-shaped string (2026-02-30 is refused). */
52
+ export function isRealDate(s: string): boolean {
53
+ const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})$/.exec(s);
54
+ if (!m) return false;
55
+ const d = new Date(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])));
56
+ return d.toISOString().slice(0, 10) === s;
57
+ }
58
+
59
+ /** The risks a ledger's own groups imply — derived here, not read from `ledger.risks`. */
60
+ export function rederiveRisks(groups: GroupLedger[]): string[] {
61
+ const out: string[] = [];
62
+ for (const g of groups) {
63
+ const gate = g.gate.join(" ");
64
+ if (g.state === "INHERITED-UNVERIFIED" && g.inheritKind === "c-core") out.push(`c-core: ${gate} (${g.reasons[0]})`);
65
+ if (g.state === "UNRUN") out.push(`unrun: ${gate} (${g.claims.length} claims)`);
66
+ if (g.flags.length > 0) out.push(`flag: ${gate}: ${g.flags.join("; ")}`);
67
+ }
68
+ return out;
69
+ }
70
+
71
+ const multiset = (xs: string[]): string => canonicalJson([...xs].sort());
72
+
73
+ /**
74
+ * The refusal states a RISK-PENDING release ledger cannot carry, derived here from its
75
+ * groups, candidate and required list — not read from its own `blockers`.
76
+ */
77
+ export function rederiveRefusals(ledger: ReleaseLedger): string[] {
78
+ const out: string[] = [];
79
+ const exact = new Set(
80
+ ledger.groups.filter((g) => g.state === "MEASURED" && g.provenance === "exact").map((g) => JSON.stringify(g.gate)),
81
+ );
82
+ for (const g of ledger.groups) {
83
+ if (g.state === "UNRUN" || g.state === "FAIL") out.push(`${g.state}: ${g.gate.join(" ")}`);
84
+ if (g.state === "INHERITED-UNVERIFIED" && g.inheritKind === "c-input") out.push(`c-input: ${g.gate.join(" ")}`);
85
+ }
86
+ for (const argv of ledger.release.required) {
87
+ if (!exact.has(JSON.stringify(argv))) out.push(`required-not-exact: ${argv.join(" ")}`);
88
+ }
89
+ if (!ledger.candidate.porcelainClean) out.push("candidate dirty");
90
+ return out;
91
+ }
92
+
93
+ export function checkAcceptanceRecord(ledger: ReleaseLedger, record: unknown): AcceptanceCheck {
94
+ const errors: string[] = [];
95
+ const refuse = (): AcceptanceCheck => ({ outcome: "REFUSED", errors });
96
+ if (record === null || typeof record !== "object" || Array.isArray(record)) {
97
+ errors.push("record: not an object");
98
+ return refuse();
99
+ }
100
+ const r = record as Record<string, unknown>;
101
+ const keys = Object.keys(r).sort();
102
+ if (canonicalJson(keys) !== canonicalJson(KEYS)) {
103
+ errors.push(`record: keys must be exactly ${KEYS.join(", ")}; got ${keys.join(", ")}`);
104
+ return refuse();
105
+ }
106
+ if (r.schema !== ACCEPTANCE_SCHEMA) errors.push(`record: schema must be ${ACCEPTANCE_SCHEMA}`);
107
+ if (!isStr(r.finalSha) || !/^[0-9a-f]{40}$/.test(r.finalSha)) errors.push("record: finalSha must be 40-hex");
108
+ if (!isStr(r.ledgerSha256) || !/^[0-9a-f]{64}$/.test(r.ledgerSha256))
109
+ errors.push("record: ledgerSha256 must be 64-hex");
110
+ if (!isStrArr(r.risks)) errors.push("record: risks must be a string array");
111
+ if (!Array.isArray(r.required) || !r.required.every(isStrArr)) errors.push("record: required must be argv arrays");
112
+ if (!isStr(r.utterance) || r.utterance.trim() === "") errors.push("record: utterance must be non-empty");
113
+ if (!isStr(r.transcribedBy) || r.transcribedBy.trim() === "") errors.push("record: transcribedBy must be non-empty");
114
+ if (!isStr(r.acceptedOn) || !/^[0-9]{4}-[0-9]{2}-[0-9]{2}$/.test(r.acceptedOn)) {
115
+ errors.push("record: acceptedOn must be YYYY-MM-DD");
116
+ } else if (!isRealDate(r.acceptedOn)) {
117
+ errors.push(`record: acceptedOn ${r.acceptedOn} is not a real date`);
118
+ }
119
+ if (errors.length > 0) return refuse();
120
+ const rec = r as unknown as AcceptanceRecord;
121
+
122
+ // The ledger: only a RISK-PENDING one is something a human can be asked about.
123
+ if (ledger.verdict !== "RISK-PENDING") errors.push(`ledger: verdict is ${ledger.verdict}, not RISK-PENDING`);
124
+ if (ledger.risks.length === 0) errors.push("ledger: RISK-PENDING with no named risk");
125
+ if (ledger.blockers.length > 0) errors.push(`ledger: blockers present (${ledger.blockers.length})`);
126
+ if (ledger.invalid.length > 0 || ledger.inputErrors.length > 0) errors.push("ledger: invalid or unexpected inputs");
127
+ if (ledger.counts.groups.FAIL > 0) errors.push("ledger: a FAIL group is present");
128
+ if (ledger.release.contractErrors.length > 0 || ledger.release.crossCheck.length > 0)
129
+ errors.push("ledger: release contract or cross-check errors present");
130
+ const refusals = rederiveRefusals(ledger);
131
+ if (refusals.length > 0) errors.push(`ledger: refusal state in its own groups/candidate: ${refusals.join("; ")}`);
132
+ if (multiset(rederiveRisks(ledger.groups)) !== multiset(ledger.risks))
133
+ errors.push("ledger: its risk list is not what its own groups imply");
134
+
135
+ // The record names exactly these ledger bytes and this decision.
136
+ if (sha256(canonicalJson(ledger)) !== rec.ledgerSha256)
137
+ errors.push("record: ledgerSha256 is not the recomputed digest of this ledger");
138
+ if (rec.finalSha !== ledger.release.finalSha || rec.finalSha !== ledger.candidate.head)
139
+ errors.push("record: finalSha is not this ledger's final SHA");
140
+ if (canonicalJson(rec.risks) !== canonicalJson(ledger.risks))
141
+ errors.push("record: risks are not exactly the ledger's risks (order and duplicates included)");
142
+ if (canonicalJson(rec.required) !== canonicalJson(ledger.release.required))
143
+ errors.push("record: required groups are not exactly the ledger's");
144
+
145
+ return errors.length > 0 ? refuse() : { outcome: RECORD_CONSISTENT, errors };
146
+ }
@@ -0,0 +1,359 @@
1
+ /**
2
+ * qualification-artifact — admit ONE CI collection artifact without the network
3
+ * (#124 stage 3a). The caller injects the artifact metadata GitHub returned and the raw
4
+ * zip bytes it downloaded; this module never fetches, never extracts to disk, and never
5
+ * uses `gh run download` output (an extracted tree carries no artifact digest).
6
+ *
7
+ * selectArtifact exactly one artifact named for (runId, runAttempt), else RED.
8
+ * admitArtifact metadata (name, run, head SHA, not expired) + digest = sha256 of
9
+ * the zip bytes + zip containment + collection completeness + the
10
+ * collection's own run/attempt/head + core self-consistency.
11
+ * readZipEntries a minimal central-directory reader over Node's built-in zlib:
12
+ * stored or deflated entries only, no zip64, no encryption; every
13
+ * entry name must be a declared collection basename; symlinks,
14
+ * directories, duplicates and oversize are refused BY NAME.
15
+ * checkCoreConsistency each receipt's core.sha256 is recomputed from its own
16
+ * {files, node, nodeModules}; all receipts and the collection agree;
17
+ * optionally core.files must equal an independent CORE_FILES
18
+ * recomputation. node/node_modules stay a CI WITNESS — nothing here
19
+ * hashes the operator host's node_modules as if it were the runner's.
20
+ *
21
+ * Every error is a named string; an empty list is the only admission. None of this is a
22
+ * verdict — composeRelease decides that, and only over admitted collections.
23
+ */
24
+
25
+ import { createHash } from "node:crypto";
26
+ import * as zlib from "node:zlib";
27
+ import { type Collection, checkCollectionData, mapReader } from "./qualification-collection.ts";
28
+ import { canonicalJson, coreSha256 } from "./qualification-receipt.ts";
29
+
30
+ export const ENTRY_NAME = /^(collection\.json|g[0-9]{3}\.json)$/;
31
+ export const MAX_ENTRY_BYTES = 8 * 1024 * 1024;
32
+ export const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
33
+ /** Whole-archive cap, checked before a single byte is parsed or inflated. */
34
+ export const MAX_ZIP_BYTES = 80 * 1024 * 1024;
35
+
36
+ /** The fields of a GitHub `actions/runs/{id}/artifacts` item this module reads. */
37
+ export interface ApiArtifact {
38
+ id: number;
39
+ name: string;
40
+ digest: string | null;
41
+ expired: boolean;
42
+ workflow_run: { id: number; head_sha: string } | null;
43
+ }
44
+
45
+ export function artifactName(runId: string, runAttempt: string): string {
46
+ return `qualification-collection-${runId}-${runAttempt}`;
47
+ }
48
+
49
+ export function selectArtifact(
50
+ list: ApiArtifact[],
51
+ runId: string,
52
+ runAttempt: string,
53
+ ): { ok: true; artifact: ApiArtifact } | { ok: false; errors: string[] } {
54
+ const name = artifactName(runId, runAttempt);
55
+ const hits = list.filter((a) => a.name === name);
56
+ if (hits.length !== 1)
57
+ return { ok: false, errors: [`artifact-select: ${hits.length} artifacts named ${name}, need exactly 1`] };
58
+ return { ok: true, artifact: hits[0] };
59
+ }
60
+
61
+ // ── zip ─────────────────────────────────────────────────────────────────────
62
+
63
+ const EOCD = 0x06054b50;
64
+ const CEN = 0x02014b50;
65
+ const LOC = 0x04034b50;
66
+
67
+ const DESC = 0x08074b50;
68
+ const ZIP64_LOCATOR = 0x07064b50;
69
+
70
+ interface CentralEntry {
71
+ name: string;
72
+ madeBy: number;
73
+ flags: number;
74
+ method: number;
75
+ crc: number;
76
+ csize: number;
77
+ usize: number;
78
+ extAttr: number;
79
+ locOff: number;
80
+ }
81
+
82
+ /**
83
+ * Read every entry of a zip held in memory, refusing anything a collection may not
84
+ * contain. Structure first, content second — nothing is inflated until the whole archive
85
+ * is proven to be exactly its listed entries:
86
+ * 1. the archive is at most MAX_ZIP_BYTES (checked before any parsing);
87
+ * 2. the EOCD record ends the archive exactly (its comment length included), is single
88
+ * disk, and is not zip64;
89
+ * 3. the central directory ends at the EOCD and is consumed EXACTLY by its entry count
90
+ * (a lowered count cannot hide a listed entry);
91
+ * 4. the local records tile [0, cdOff) with no gap and no overlap (an unlisted local
92
+ * entry or smuggled bytes cannot hide either). A data descriptor (flag bit 3) is
93
+ * accepted in its two standard forms only and must agree with the central record;
94
+ * any other layout is refused by name rather than loosened to pass.
95
+ */
96
+ export function readZipEntries(zip: Buffer): { files: Record<string, Buffer>; errors: string[] } {
97
+ const files: Record<string, Buffer> = {};
98
+ const fail = (e: string) => ({ files, errors: [e] });
99
+ if (zip.length > MAX_ZIP_BYTES) return fail(`zip-oversize: archive (${zip.length} bytes)`);
100
+ if (zip.length < 22) return fail("zip-format: too short for an end-of-central-directory record");
101
+
102
+ let eocd = -1;
103
+ let sawSignature = false;
104
+ for (let i = zip.length - 22; i >= Math.max(0, zip.length - 22 - 0xffff); i--) {
105
+ if (zip.readUInt32LE(i) !== EOCD) continue;
106
+ sawSignature = true;
107
+ if (i + 22 + zip.readUInt16LE(i + 20) === zip.length) {
108
+ eocd = i;
109
+ break;
110
+ }
111
+ }
112
+ if (eocd < 0) {
113
+ return fail(
114
+ sawSignature
115
+ ? "zip-format: the end-of-central-directory record does not end the archive (trailing bytes or comment length)"
116
+ : "zip-format: no end-of-central-directory record",
117
+ );
118
+ }
119
+ const diskNo = zip.readUInt16LE(eocd + 4);
120
+ const cdDisk = zip.readUInt16LE(eocd + 6);
121
+ const onDisk = zip.readUInt16LE(eocd + 8);
122
+ const count = zip.readUInt16LE(eocd + 10);
123
+ const cdSize = zip.readUInt32LE(eocd + 12);
124
+ const cdOff = zip.readUInt32LE(eocd + 16);
125
+ if (count === 0xffff || cdSize === 0xffffffff || cdOff === 0xffffffff)
126
+ return fail("zip-format: zip64 is not accepted");
127
+ if (eocd >= 20 && zip.readUInt32LE(eocd - 20) === ZIP64_LOCATOR) return fail("zip-format: zip64 is not accepted");
128
+ if (diskNo !== 0 || cdDisk !== 0 || onDisk !== count) return fail("zip-format: multi-disk archives are not accepted");
129
+ if (cdOff + cdSize !== eocd)
130
+ return fail("zip-format: the central directory does not end at the end-of-central-directory record");
131
+
132
+ const entries: CentralEntry[] = [];
133
+ let p = cdOff;
134
+ for (let n = 0; n < count; n++) {
135
+ if (p + 46 > eocd || zip.readUInt32LE(p) !== CEN)
136
+ return fail(`zip-format: central directory entry ${n} is malformed`);
137
+ const nameLen = zip.readUInt16LE(p + 28);
138
+ const extraLen = zip.readUInt16LE(p + 30);
139
+ const commentLen = zip.readUInt16LE(p + 32);
140
+ if (p + 46 + nameLen + extraLen + commentLen > eocd)
141
+ return fail(`zip-format: central directory entry ${n} overruns`);
142
+ entries.push({
143
+ name: zip.subarray(p + 46, p + 46 + nameLen).toString("utf8"),
144
+ madeBy: zip.readUInt16LE(p + 4) >> 8,
145
+ flags: zip.readUInt16LE(p + 8),
146
+ method: zip.readUInt16LE(p + 10),
147
+ crc: zip.readUInt32LE(p + 16),
148
+ csize: zip.readUInt32LE(p + 20),
149
+ usize: zip.readUInt32LE(p + 24),
150
+ extAttr: zip.readUInt32LE(p + 38),
151
+ locOff: zip.readUInt32LE(p + 42),
152
+ });
153
+ p += 46 + nameLen + extraLen + commentLen;
154
+ }
155
+ if (p !== cdOff + cdSize) {
156
+ return fail(`zip-format: the central directory has ${cdOff + cdSize - p} bytes not covered by its entry count`);
157
+ }
158
+
159
+ // Local records must tile [0, cdOff) exactly.
160
+ const spans: { start: number; end: number; dataStart: number; e: CentralEntry }[] = [];
161
+ for (const e of entries) {
162
+ const label = JSON.stringify(e.name);
163
+ if (e.locOff + 30 > cdOff || zip.readUInt32LE(e.locOff) !== LOC)
164
+ return fail(`zip-format: ${label} local header missing`);
165
+ const lNameLen = zip.readUInt16LE(e.locOff + 26);
166
+ const lExtraLen = zip.readUInt16LE(e.locOff + 28);
167
+ if (zip.subarray(e.locOff + 30, e.locOff + 30 + lNameLen).toString("utf8") !== e.name) {
168
+ return fail(`zip-format: ${label} local name differs from the central directory`);
169
+ }
170
+ const dataStart = e.locOff + 30 + lNameLen + lExtraLen;
171
+ let end = dataStart + e.csize;
172
+ if (e.flags & 8) {
173
+ const signed = end + 16 <= cdOff && zip.readUInt32LE(end) === DESC;
174
+ const d = signed ? end + 4 : end;
175
+ if (d + 12 > cdOff) return fail(`zip-format: ${label} data descriptor out of bounds`);
176
+ if (zip.readUInt32LE(d) !== e.crc || zip.readUInt32LE(d + 4) !== e.csize || zip.readUInt32LE(d + 8) !== e.usize) {
177
+ return fail(`zip-format: ${label} data descriptor disagrees with the central directory`);
178
+ }
179
+ end = d + 12;
180
+ }
181
+ if (end > cdOff) return fail(`zip-format: ${label} data out of bounds`);
182
+ spans.push({ start: e.locOff, end, dataStart, e });
183
+ }
184
+ spans.sort((x, y) => x.start - y.start);
185
+ let cursor = 0;
186
+ for (const s of spans) {
187
+ if (s.start !== cursor) return fail("zip-format: unlisted bytes between local entries (a gap or an overlap)");
188
+ cursor = s.end;
189
+ }
190
+ if (cursor !== cdOff) return fail("zip-format: unlisted bytes before the central directory");
191
+
192
+ // Content, entry by entry.
193
+ const errors: string[] = [];
194
+ let total = 0;
195
+ for (const { e, dataStart } of spans) {
196
+ const label = JSON.stringify(e.name);
197
+ const unixMode = e.madeBy === 3 ? e.extAttr >>> 16 : 0;
198
+ if ((unixMode & 0o170000) === 0o120000) {
199
+ errors.push(`zip-symlink: ${label}`);
200
+ continue;
201
+ }
202
+ if (e.name.endsWith("/") || (unixMode & 0o170000) === 0o040000) {
203
+ errors.push(`zip-directory: ${label}`);
204
+ continue;
205
+ }
206
+ if (!ENTRY_NAME.test(e.name)) {
207
+ errors.push(`zip-name: ${label} is not collection.json or gNNN.json`);
208
+ continue;
209
+ }
210
+ if (Object.hasOwn(files, e.name)) {
211
+ errors.push(`zip-duplicate: ${label}`);
212
+ continue;
213
+ }
214
+ if (e.flags & 1) {
215
+ errors.push(`zip-encrypted: ${label}`);
216
+ continue;
217
+ }
218
+ total += e.usize;
219
+ if (e.usize > MAX_ENTRY_BYTES || e.csize > MAX_ENTRY_BYTES || total > MAX_TOTAL_BYTES) {
220
+ errors.push(`zip-oversize: ${label} (${e.usize} bytes)`);
221
+ continue;
222
+ }
223
+ const raw = zip.subarray(dataStart, dataStart + e.csize);
224
+ let data: Buffer;
225
+ try {
226
+ if (e.method === 0) data = Buffer.from(raw);
227
+ else if (e.method === 8) data = zlib.inflateRawSync(raw, { maxOutputLength: Math.max(1, e.usize) });
228
+ else {
229
+ errors.push(`zip-method: ${label} uses method ${e.method}`);
230
+ continue;
231
+ }
232
+ } catch (err) {
233
+ errors.push(`zip-inflate: ${label} ${err instanceof Error ? err.message : String(err)}`);
234
+ continue;
235
+ }
236
+ if (data.length !== e.usize || zlib.crc32(data) !== e.crc) {
237
+ errors.push(`zip-crc: ${label} size or CRC does not match its header`);
238
+ continue;
239
+ }
240
+ files[e.name] = data;
241
+ }
242
+ return { files, errors };
243
+ }
244
+
245
+ // ── core self-consistency ───────────────────────────────────────────────────
246
+
247
+ const isObj = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
248
+
249
+ /** The C0 fields a receipt must carry before its core can be recomputed or witnessed. */
250
+ export function coreShapeError(core: unknown): string | null {
251
+ if (!isObj(core)) return "core is not an object";
252
+ if (!isObj(core.files) || !Object.values(core.files).every((v) => typeof v === "string")) return "core.files";
253
+ const node = core.node;
254
+ if (
255
+ !isObj(node) ||
256
+ typeof node.version !== "string" ||
257
+ typeof node.execPath !== "string" ||
258
+ typeof node.execSha256 !== "string"
259
+ ) {
260
+ return "core.node";
261
+ }
262
+ const nm = core.nodeModules;
263
+ if (
264
+ !isObj(nm) ||
265
+ typeof nm.present !== "boolean" ||
266
+ !(nm.sha256 === null || typeof nm.sha256 === "string") ||
267
+ !Array.isArray(nm.excluded) ||
268
+ !nm.excluded.every((x) => typeof x === "string") ||
269
+ typeof nm.files !== "number" ||
270
+ typeof nm.links !== "number"
271
+ ) {
272
+ return "core.nodeModules";
273
+ }
274
+ if (typeof core.sha256 !== "string") return "core.sha256";
275
+ return null;
276
+ }
277
+
278
+ export function checkCoreConsistency(
279
+ files: Record<string, Buffer>,
280
+ collection: Collection,
281
+ independentCoreFiles?: Record<string, string>,
282
+ ): string[] {
283
+ const errors: string[] = [];
284
+ const shas = new Set<string>();
285
+ for (const a of collection.attempted) {
286
+ if (a.outcome !== "receipt-written" || !a.file || !files[a.file]) continue;
287
+ let core: { files: Record<string, string>; node: unknown; nodeModules: unknown; sha256: string };
288
+ try {
289
+ core = JSON.parse(files[a.file].toString("utf8")).core;
290
+ } catch {
291
+ errors.push(`core-unreadable: ${a.file}`);
292
+ continue;
293
+ }
294
+ const shapeErr = coreShapeError(core);
295
+ if (shapeErr) {
296
+ errors.push(`core-shape: ${a.file} ${shapeErr}`);
297
+ continue;
298
+ }
299
+ const recomputed = coreSha256({ files: core.files, node: core.node, nodeModules: core.nodeModules });
300
+ if (recomputed !== core.sha256)
301
+ errors.push(`core-inconsistent: ${a.file} core.sha256 is not the hash of its own parts`);
302
+ shas.add(recomputed);
303
+ if (independentCoreFiles && canonicalJson(core.files) !== canonicalJson(independentCoreFiles)) {
304
+ errors.push(`core-files-mismatch: ${a.file} CORE_FILES differ from the independent recomputation`);
305
+ }
306
+ }
307
+ if (shas.size > 1) errors.push("core-split: receipts of one collection disagree on C0");
308
+ const [only] = [...shas];
309
+ if (only && only !== collection.candidate.coreSha256) {
310
+ errors.push("core-collection-mismatch: the collection's candidate C0 is not its receipts' C0");
311
+ }
312
+ return errors;
313
+ }
314
+
315
+ // ── admission ───────────────────────────────────────────────────────────────
316
+
317
+ export interface ArtifactInput {
318
+ runId: string;
319
+ runAttempt: string;
320
+ headSha: string;
321
+ artifact: ApiArtifact;
322
+ zip: Buffer;
323
+ /** Present for the FINAL SHA only: CORE_FILES recomputed from the final tree. */
324
+ independentCoreFiles?: Record<string, string>;
325
+ }
326
+
327
+ export function admitArtifact(input: ArtifactInput): {
328
+ errors: string[];
329
+ files: Record<string, Buffer> | null;
330
+ collection: Collection | null;
331
+ /** How node/node_modules are known: only as the run's own witness. */
332
+ witness: string;
333
+ } {
334
+ const errors: string[] = [];
335
+ const a = input.artifact;
336
+ const tag = `run ${input.runId}#${input.runAttempt}`;
337
+ const witness = `node/node_modules: CI-witnessed by ${tag}, not recomputed here`;
338
+ if (a.name !== artifactName(input.runId, input.runAttempt)) errors.push(`artifact-name: ${a.name}`);
339
+ if (!a.workflow_run || String(a.workflow_run.id) !== input.runId) errors.push(`artifact-run: not produced by ${tag}`);
340
+ if (!a.workflow_run || a.workflow_run.head_sha !== input.headSha)
341
+ errors.push("artifact-head: not produced at this SHA");
342
+ if (a.expired) errors.push("artifact-expired: the evidence is gone");
343
+ const zipSha = createHash("sha256").update(input.zip).digest("hex");
344
+ if (a.digest !== `sha256:${zipSha}`)
345
+ errors.push(`artifact-digest: zip sha256 ${zipSha} vs recorded ${String(a.digest)}`);
346
+ if (errors.length > 0) return { errors, files: null, collection: null, witness };
347
+
348
+ const z = readZipEntries(input.zip);
349
+ if (z.errors.length > 0) return { errors: z.errors, files: null, collection: null, witness };
350
+ const { errors: cErrors, collection } = checkCollectionData(mapReader(z.files), Object.keys(z.files).sort());
351
+ errors.push(...cErrors);
352
+ if (!collection) return { errors, files: null, collection: null, witness };
353
+ if (collection.runId !== input.runId || collection.runAttempt !== input.runAttempt) {
354
+ errors.push(`collection-run: records ${collection.runId}#${collection.runAttempt}, artifact is ${tag}`);
355
+ }
356
+ if (collection.candidate.head !== input.headSha) errors.push("collection-head: measured another SHA");
357
+ errors.push(...checkCoreConsistency(z.files, collection, input.independentCoreFiles));
358
+ return { errors, files: errors.length === 0 ? z.files : null, collection, witness };
359
+ }