great-cto 3.70.0 → 3.71.1

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 (37) hide show
  1. package/README.md +9 -1
  2. package/board/.claude-plugin/plugin.json +93 -1
  3. package/board/packages/board/lib/routes.mjs +14 -1
  4. package/board/packages/board/lib/work.mjs +34 -3
  5. package/board/packages/board/public/assets/admin-shell.css +63 -0
  6. package/board/packages/board/public/assets/admin-shell.js +39 -5
  7. package/board/packages/board/public/assets/work-view.js +320 -37
  8. package/board/packages/board/public/index.html +43 -1
  9. package/board/scripts/codex-pipeline.mjs +7 -1
  10. package/board/scripts/lib/claude-exec.mjs +49 -6
  11. package/board/scripts/lib/codex-exec.mjs +42 -18
  12. package/board/scripts/lib/codex-host-state.mjs +18 -2
  13. package/board/scripts/lib/codex-pipeline.mjs +44 -7
  14. package/board/scripts/lib/host-graph-bindings.mjs +135 -0
  15. package/board/scripts/lib/host-graph-events.mjs +157 -0
  16. package/board/scripts/lib/host-graph-journal.mjs +210 -0
  17. package/board/scripts/lib/host-graph-observer.mjs +61 -0
  18. package/board/scripts/lib/host-graph-runtime.mjs +38 -0
  19. package/board/scripts/work-task.mjs +17 -4
  20. package/dist/adapt.js +10 -21
  21. package/dist/codex.js +4 -15
  22. package/dist/companion.js +6 -122
  23. package/dist/installer.js +4 -72
  24. package/dist/main.js +51 -118
  25. package/dist/release-activation.mjs +561 -0
  26. package/dist/release-archive.mjs +165 -0
  27. package/dist/release-trust.mjs +514 -0
  28. package/dist/release-update.mjs +303 -0
  29. package/dist/self-upgrade.js +10 -39
  30. package/dist/update-check.js +1 -0
  31. package/dist/upgrade.js +5 -120
  32. package/package.json +1 -1
  33. package/board/packages/board/fixtures/beads-read-owner.mjs +0 -3
  34. package/board/packages/board/fixtures/fake-bd-controlled.mjs +0 -55
  35. package/board/packages/board/fixtures/fake-bd-fail.sh +0 -9
  36. package/board/packages/board/fixtures/fake-bd-slow.sh +0 -12
  37. package/board/packages/board/fixtures/held-receipt-git.mjs +0 -21
@@ -0,0 +1,514 @@
1
+ /** Publisher admission is an in-memory capability, never a serialized approval. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
5
+ import { execFileSync } from 'node:child_process';
6
+ import { inspectReleaseArchive } from './release-archive.mjs';
7
+ export const RELEASE_TRUST_POLICY = Object.freeze({ repository: 'avelikiy/great_cto', workflow: '.github/workflows/publish.yml', issuer: 'https://token.actions.githubusercontent.com', cosignVersion: 'v3.1.4' });
8
+ // Rotation is a reviewed source change, not a value accepted from release metadata.
9
+ export const RELEASE_TRUST_ROOT_SHA256 = '6494e21ea73fa7ee769f85f57d5a3e6a08725eae1e38c755fc3517c9e6bc0b66';
10
+ export const COSIGN_PLATFORM_SHA256 = Object.freeze({
11
+ 'darwin-x64': '9d1e4abe2b6be279ed0d2ff706fde1ccd738747c834e3e6d44039c238867334f',
12
+ 'darwin-arm64': '9f9b8cd557cf2e5b3df3373a00baa005a86b02391e3e06dd0ce37b3a1533d3a4',
13
+ 'linux-x64': '424e2c97d0b45fd7a6b608a87f090f35633f4c23ece5f092d48bba11aaf4bbfa',
14
+ 'linux-arm64': 'ade83b7aac62e131cacfe96d958948b2150cc9efec46a570a2649b8e27dedad2',
15
+ });
16
+ export const RELEASE_TRUST_LIMITS = Object.freeze({ index: 256 * 1024, bundle: 2 * 1024 * 1024, archive: 128 * 1024 * 1024, inventory: 8 * 1024 * 1024, sbom: 8 * 1024 * 1024 });
17
+ const admissions = new WeakMap();
18
+ const sha = (bytes, algorithm = 'sha256') => createHash(algorithm).update(bytes).digest('hex');
19
+ const stable = value => typeof value === 'string' && /^(?:0|[1-9][0-9]{0,5})\.(?:0|[1-9][0-9]{0,5})\.(?:0|[1-9][0-9]{0,5})$/.test(value);
20
+ const hex = (value, n) => typeof value === 'string' && new RegExp(`^[a-f0-9]{${n}}$`).test(value);
21
+ function exact(value, keys, label) {
22
+ if (!value || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).length !== keys.length || keys.some(k => !Object.hasOwn(value, k)))
23
+ throw Error(`${label} schema fields refused`);
24
+ }
25
+ function compare(a, b) { const x = a.split('.').map(Number), y = b.split('.').map(Number); for (let i = 0; i < 3; i++)
26
+ if (x[i] !== y[i])
27
+ return x[i] - y[i]; return 0; }
28
+ /** Reject duplicate decoded keys before JSON.parse can erase their ambiguity. */
29
+ export function parseStrictReleaseIndex(text, { maxBytes = RELEASE_TRUST_LIMITS.index } = {}) {
30
+ if (typeof text !== 'string' || Buffer.byteLength(text) > maxBytes)
31
+ throw Error('JSON input size refused');
32
+ let at = 0, nodes = 0;
33
+ const white = () => { while (/[ \n\r\t]/.test(text[at] || '#'))
34
+ at++; };
35
+ const string = () => {
36
+ const start = at++;
37
+ while (at < text.length) {
38
+ const c = text[at++];
39
+ if (c === '\\') {
40
+ at++;
41
+ continue;
42
+ }
43
+ if (c === '"') {
44
+ try {
45
+ return JSON.parse(text.slice(start, at));
46
+ }
47
+ catch {
48
+ throw Error('invalid JSON string');
49
+ }
50
+ }
51
+ }
52
+ throw Error('unterminated JSON string');
53
+ };
54
+ const value = depth => {
55
+ if (depth > 32 || ++nodes > 100_000)
56
+ throw Error('JSON depth or node limit exceeded');
57
+ white();
58
+ const c = text[at];
59
+ if (c === '"') {
60
+ string();
61
+ return;
62
+ }
63
+ if (c === '{') {
64
+ at++;
65
+ white();
66
+ const seen = new Set();
67
+ if (text[at] === '}') {
68
+ at++;
69
+ return;
70
+ }
71
+ while (true) {
72
+ white();
73
+ if (text[at] !== '"')
74
+ throw Error('invalid JSON object key');
75
+ const k = string();
76
+ if (seen.has(k) || ['__proto__', 'constructor', 'prototype'].includes(k))
77
+ throw Error('duplicate or unsafe JSON key');
78
+ seen.add(k);
79
+ white();
80
+ if (text[at++] !== ':')
81
+ throw Error('invalid JSON object separator');
82
+ value(depth + 1);
83
+ white();
84
+ const next = text[at++];
85
+ if (next === '}')
86
+ return;
87
+ if (next !== ',')
88
+ throw Error('invalid JSON object delimiter');
89
+ }
90
+ }
91
+ if (c === '[') {
92
+ at++;
93
+ white();
94
+ if (text[at] === ']') {
95
+ at++;
96
+ return;
97
+ }
98
+ while (true) {
99
+ value(depth + 1);
100
+ white();
101
+ const next = text[at++];
102
+ if (next === ']')
103
+ return;
104
+ if (next !== ',')
105
+ throw Error('invalid JSON array delimiter');
106
+ }
107
+ }
108
+ const match = /^(?:true|false|null|-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?)/.exec(text.slice(at));
109
+ if (!match)
110
+ throw Error('invalid JSON value');
111
+ at += match[0].length;
112
+ };
113
+ value(0);
114
+ white();
115
+ if (at !== text.length)
116
+ throw Error('trailing JSON data');
117
+ let parsed;
118
+ try {
119
+ parsed = JSON.parse(text);
120
+ }
121
+ catch {
122
+ throw Error('invalid JSON');
123
+ }
124
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
125
+ throw Error('JSON object required');
126
+ return parsed;
127
+ }
128
+ export function validateReleaseIndex(input, { version, expectedCommit, currentVersion, currentIndexDigest, profile = 'github-oidc', localKeyFingerprint } = {}) {
129
+ exact(input, ['schema', 'package', 'version', 'source', 'publisher', 'artifacts'], 'release index');
130
+ if (!['github-oidc', 'local-ed25519'].includes(profile))
131
+ throw Error('release trust profile refused');
132
+ if (input.schema !== (profile === 'local-ed25519' ? 2 : 1) || input.package !== 'great-cto' || !stable(input.version) || (version !== undefined && version !== input.version))
133
+ throw Error('release index version or package refused');
134
+ if (currentVersion !== undefined) {
135
+ if (!stable(currentVersion) || compare(input.version, currentVersion) < 0)
136
+ throw Error('release version downgrade refused');
137
+ if (input.version === currentVersion && !hex(currentIndexDigest, 64))
138
+ throw Error('same version requires retained index digest');
139
+ }
140
+ exact(input.source, ['repository', 'commit', 'tree', 'tag'], 'source');
141
+ if (input.source.repository !== RELEASE_TRUST_POLICY.repository || !hex(input.source.commit, 40) || !hex(input.source.tree, 40) || input.source.tag !== `v${input.version}`)
142
+ throw Error('release source identity or tag refused');
143
+ if (expectedCommit !== undefined && input.source.commit !== expectedCommit)
144
+ throw Error('release source commit mismatch');
145
+ if (profile === 'local-ed25519') {
146
+ exact(input.publisher, ['profile', 'keyFingerprint'], 'publisher');
147
+ if (input.publisher.profile !== profile || !hex(input.publisher.keyFingerprint, 64) || (localKeyFingerprint !== undefined && input.publisher.keyFingerprint !== localKeyFingerprint))
148
+ throw Error('local publisher key fingerprint or profile refused');
149
+ }
150
+ else {
151
+ exact(input.publisher, ['workflow'], 'publisher');
152
+ if (input.publisher.workflow !== RELEASE_TRUST_POLICY.workflow)
153
+ throw Error('publisher workflow refused');
154
+ }
155
+ if (!Array.isArray(input.artifacts) || input.artifacts.length !== 4)
156
+ throw Error('release artifact kinds incomplete');
157
+ const names = { npm: `great-cto-${input.version}.tgz`, plugin: `great-cto-plugin-${input.version}.tgz`, inventory: 'plugin-inventory.json', sbom: 'sbom.cdx.json' }, seen = new Set();
158
+ for (const a of input.artifacts) {
159
+ exact(a, ['kind', 'filename', 'size', 'sha256', 'sha512'], 'artifact');
160
+ if (!Object.hasOwn(names, a.kind) || seen.has(a.kind) || a.filename !== names[a.kind])
161
+ throw Error('artifact kind or filename refused');
162
+ seen.add(a.kind);
163
+ const cap = ['npm', 'plugin'].includes(a.kind) ? RELEASE_TRUST_LIMITS.archive : RELEASE_TRUST_LIMITS[a.kind];
164
+ if (!Number.isSafeInteger(a.size) || a.size <= 0 || a.size > cap || !hex(a.sha256, 64) || !hex(a.sha512, 128))
165
+ throw Error('artifact size or digest refused');
166
+ }
167
+ return structuredClone(input);
168
+ }
169
+ function canonicalPrivateDirectory(value) {
170
+ if (typeof value !== 'string' || !path.isAbsolute(value) || fs.realpathSync(value) !== path.resolve(value))
171
+ throw Error('stage must be an absolute canonical private directory');
172
+ const stat = fs.lstatSync(value);
173
+ if (!stat.isDirectory() || stat.isSymbolicLink() || (stat.mode & 0o777) !== 0o700 || (process.getuid && stat.uid !== process.getuid()))
174
+ throw Error('stage directory requires private owner-only 0700 permissions');
175
+ return { path: value, dev: stat.dev, ino: stat.ino };
176
+ }
177
+ function sameDirectory(expected) {
178
+ const actual = canonicalPrivateDirectory(expected.path);
179
+ if (actual.dev !== expected.dev || actual.ino !== expected.ino)
180
+ throw Error('stage directory identity changed');
181
+ }
182
+ function readRegular(file, cap, { privateFile = true, executable = false } = {}) {
183
+ if (!path.isAbsolute(file) || fs.realpathSync(file) !== path.resolve(file))
184
+ throw Error('file must be canonical, not a symlink');
185
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
186
+ try {
187
+ const stat = fs.fstatSync(fd, { bigint: true });
188
+ const uid = process.getuid ? BigInt(process.getuid()) : stat.uid;
189
+ const wrongOwner = privateFile ? stat.uid !== uid : stat.uid !== uid && stat.uid !== 0n;
190
+ if (!stat.isFile() || stat.nlink !== 1n || stat.size <= 0n || stat.size > BigInt(cap) || wrongOwner)
191
+ throw Error('unsafe regular file ownership, size or link count');
192
+ if (privateFile ? (Number(stat.mode) & 0o777) !== 0o600 : (Number(stat.mode) & 0o022) !== 0)
193
+ throw Error('unsafe file permissions');
194
+ if (executable && !(Number(stat.mode) & 0o100))
195
+ throw Error('verifier is not executable');
196
+ const bytes = fs.readFileSync(fd), after = fs.fstatSync(fd, { bigint: true });
197
+ if (['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].some(k => stat[k] !== after[k]) || bytes.length !== Number(stat.size))
198
+ throw Error('file changed while reading');
199
+ const identity = ['dev', 'ino', 'size', 'mtimeNs', 'ctimeNs'].map(k => stat[k].toString()).join(':');
200
+ return { bytes, identity };
201
+ }
202
+ finally {
203
+ fs.closeSync(fd);
204
+ }
205
+ }
206
+ function verifyPin(file, digest, cap, executable = false) {
207
+ if (!hex(digest, 64))
208
+ throw Error('trusted tool/root digest pin required');
209
+ const snapshot = readRegular(file, cap, { privateFile: false, executable });
210
+ if (sha(snapshot.bytes) !== digest)
211
+ throw Error(executable ? 'cosign verifier digest mismatch' : 'trusted root digest mismatch');
212
+ return snapshot;
213
+ }
214
+ function ticketState(ticket) {
215
+ const state = admissions.get(ticket);
216
+ if (!state)
217
+ throw Error('unknown or forged release admission ticket');
218
+ sameDirectory(state.directory);
219
+ state.checkTrust?.();
220
+ const current = readRegular(path.join(state.directory.path, 'release-index.json'), RELEASE_TRUST_LIMITS.index);
221
+ if (current.identity !== state.indexSnapshot.identity || sha(current.bytes) !== state.info.indexDigest)
222
+ throw Error('admitted release index changed');
223
+ return state;
224
+ }
225
+ export function admitRelease(opts, { run = execFileSync } = {}) {
226
+ if (opts.profile === 'local-ed25519')
227
+ return admitLocalRelease(opts, { isProduction: run === execFileSync });
228
+ if (opts.profile !== undefined && opts.profile !== 'github-oidc')
229
+ throw Error('release trust profile refused');
230
+ const isProduction = run === execFileSync;
231
+ if (isProduction) {
232
+ const expectedTool = COSIGN_PLATFORM_SHA256[`${process.platform}-${process.arch}`];
233
+ if (!expectedTool)
234
+ throw Error('unsupported verifier platform');
235
+ if (opts.cosignSha256 !== expectedTool || opts.trustedRootSha256 !== RELEASE_TRUST_ROOT_SHA256)
236
+ throw Error('pinned production verifier and trust root required');
237
+ }
238
+ const directory = canonicalPrivateDirectory(opts.directory);
239
+ const tool = path.resolve(opts.cosignPath || ''), root = path.resolve(opts.trustedRootPath || '');
240
+ for (const name of [tool, root])
241
+ if (name === directory.path || name.startsWith(directory.path + path.sep))
242
+ throw Error('candidate cannot supply verifier or trusted root');
243
+ if (tool !== opts.cosignPath || root !== opts.trustedRootPath)
244
+ throw Error('verifier and trust root require absolute canonical paths');
245
+ const checkTools = () => { verifyPin(tool, opts.cosignSha256, 256 * 1024 * 1024, true); verifyPin(root, opts.trustedRootSha256, RELEASE_TRUST_LIMITS.bundle); };
246
+ checkTools();
247
+ const indexFile = path.join(directory.path, 'release-index.json');
248
+ const indexSnapshot = readRegular(indexFile, RELEASE_TRUST_LIMITS.index);
249
+ const index = validateReleaseIndex(parseStrictReleaseIndex(indexSnapshot.bytes.toString('utf8')), opts);
250
+ if (opts.currentVersion === index.version && opts.currentIndexDigest !== sha(indexSnapshot.bytes))
251
+ throw Error('same-version release index digest changed');
252
+ const verifierHome = fs.mkdtempSync(path.join(directory.path, '.verifier-'));
253
+ fs.chmodSync(verifierHome, 0o700);
254
+ const snapshots = new Map();
255
+ const options = { cwd: verifierHome, encoding: 'utf8', timeout: 30_000, killSignal: 'SIGKILL', maxBuffer: 128 * 1024, stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: '/usr/bin:/bin', HOME: verifierHome, TMPDIR: verifierHome, LANG: 'C', LC_ALL: 'C' } };
256
+ const verify = (file, snapshot, cap) => {
257
+ checkTools();
258
+ sameDirectory(directory);
259
+ const bundle = file + '.sigstore.json', proof = readRegular(bundle, RELEASE_TRUST_LIMITS.bundle);
260
+ const before = readRegular(file, cap);
261
+ if (before.identity !== snapshot.identity || sha(before.bytes) !== sha(snapshot.bytes))
262
+ throw Error('staged file changed before verification');
263
+ run(tool, ['verify-blob', '--bundle', bundle, '--trusted-root', root, '--certificate-identity', `https://github.com/${RELEASE_TRUST_POLICY.repository}/${RELEASE_TRUST_POLICY.workflow}@refs/tags/v${index.version}`, '--certificate-oidc-issuer', RELEASE_TRUST_POLICY.issuer, '--certificate-github-workflow-repository', RELEASE_TRUST_POLICY.repository, '--certificate-github-workflow-ref', `refs/tags/v${index.version}`, '--certificate-github-workflow-sha', index.source.commit, '--certificate-github-workflow-trigger', 'push', '--timeout', '30s', file], options);
264
+ const after = readRegular(file, cap), afterProof = readRegular(bundle, RELEASE_TRUST_LIMITS.bundle);
265
+ if (after.identity !== snapshot.identity || sha(after.bytes) !== sha(snapshot.bytes) || afterProof.identity !== proof.identity || sha(afterProof.bytes) !== sha(proof.bytes))
266
+ throw Error('staged file or signature bundle changed during verification');
267
+ return after;
268
+ };
269
+ try {
270
+ const reported = JSON.parse(String(run(tool, ['version', '--json'], options)));
271
+ if (reported.gitVersion !== RELEASE_TRUST_POLICY.cosignVersion)
272
+ throw Error('unsupported cosign verifier version');
273
+ verify(indexFile, indexSnapshot, RELEASE_TRUST_LIMITS.index);
274
+ for (const artifact of index.artifacts) {
275
+ const file = path.join(directory.path, artifact.filename), snapshot = readRegular(file, artifact.size);
276
+ if (snapshot.bytes.length !== artifact.size || sha(snapshot.bytes) !== artifact.sha256 || sha(snapshot.bytes, 'sha512') !== artifact.sha512)
277
+ throw Error('artifact size or digest mismatch');
278
+ verify(file, snapshot, artifact.size);
279
+ snapshots.set(artifact.kind, { ...artifact, path: file, identity: snapshot.identity });
280
+ }
281
+ checkTools();
282
+ sameDirectory(directory);
283
+ }
284
+ finally {
285
+ fs.rmSync(verifierHome, { recursive: true, force: true });
286
+ }
287
+ const info = Object.freeze({ version: index.version, commit: index.source.commit, tree: index.source.tree, source: Object.freeze({ ...index.source }), indexDigest: sha(indexSnapshot.bytes), isProduction, authority: isProduction ? 'ARTIFACT_ADMISSION' : 'NONE', evidence: isProduction ? 'COSIGN_VERIFIED' : 'SYNTHETIC_ONLY', releaseEligibility: 'NOT_CHECKED' });
288
+ const ticket = Object.freeze({ type: 'release-admission' });
289
+ admissions.set(ticket, { directory, info, index, snapshots, indexSnapshot });
290
+ try {
291
+ inspectNpmPayload(ticket);
292
+ inspectPluginPayload(ticket);
293
+ }
294
+ catch (error) {
295
+ admissions.delete(ticket);
296
+ throw error;
297
+ }
298
+ return ticket;
299
+ }
300
+ /** A separately enrolled public key is operator authority, never candidate input. */
301
+ export function readTrustedLocalPublicKey(file, fingerprint) {
302
+ if (!hex(fingerprint, 64))
303
+ throw Error('pinned local release key fingerprint required');
304
+ const snapshot = readRegular(file, 8192, { privateFile: false });
305
+ const text = snapshot.bytes.toString('utf8');
306
+ if (!text.startsWith('-----BEGIN PUBLIC KEY-----\n'))
307
+ throw Error('Ed25519 SPKI public key PEM required');
308
+ let key;
309
+ try {
310
+ key = createPublicKey({ key: snapshot.bytes, format: 'pem', type: 'spki' });
311
+ }
312
+ catch {
313
+ throw Error('invalid local release public key');
314
+ }
315
+ if (key.asymmetricKeyType !== 'ed25519')
316
+ throw Error('Ed25519 release key required');
317
+ if (text !== key.export({ type: 'spki', format: 'pem' }))
318
+ throw Error('canonical SPKI public key PEM required');
319
+ if (sha(key.export({ type: 'spki', format: 'der' })) !== fingerprint)
320
+ throw Error('local release public key fingerprint mismatch');
321
+ return { key, fingerprint, identity: snapshot.identity };
322
+ }
323
+ /** Filename and exact index digest separate signatures from other protocols. */
324
+ export function localReleaseSignatureMessage(filename, indexDigest, bytes) {
325
+ if (typeof filename !== 'string' || !filename || path.basename(filename) !== filename || /[\\\0\r\n]/.test(filename) || !hex(indexDigest, 64) || !Buffer.isBuffer(bytes))
326
+ throw Error('invalid local signature message');
327
+ return Buffer.concat([Buffer.from('great-cto-release-v1\n' + JSON.stringify({ filename, indexDigest }) + '\n'), bytes]);
328
+ }
329
+ function admitLocalRelease(opts, { isProduction }) {
330
+ const directory = canonicalPrivateDirectory(opts.directory);
331
+ const keyPath = opts.localPublicKeyPath;
332
+ if (typeof keyPath !== 'string' || keyPath === directory.path || keyPath.startsWith(directory.path + path.sep))
333
+ throw Error('candidate cannot supply trusted release key');
334
+ const enrolled = readTrustedLocalPublicKey(keyPath, opts.localKeyFingerprint);
335
+ const checkTrust = () => {
336
+ const current = readTrustedLocalPublicKey(keyPath, opts.localKeyFingerprint);
337
+ if (current.identity !== enrolled.identity)
338
+ throw Error('trusted local release key identity changed');
339
+ };
340
+ const indexFile = path.join(directory.path, 'release-index.json');
341
+ const indexSnapshot = readRegular(indexFile, RELEASE_TRUST_LIMITS.index);
342
+ const indexDigest = sha(indexSnapshot.bytes);
343
+ const index = validateReleaseIndex(parseStrictReleaseIndex(indexSnapshot.bytes.toString('utf8')), opts);
344
+ if (opts.currentVersion === index.version && opts.currentIndexDigest !== indexDigest)
345
+ throw Error('same-version release index digest changed');
346
+ const names = ['release-index.json', ...index.artifacts.map(a => a.filename)];
347
+ const expected = [...names, ...names.map(name => name + '.signature.json')].sort();
348
+ if (JSON.stringify(fs.readdirSync(directory.path).sort()) !== JSON.stringify(expected))
349
+ throw Error('unexpected local signature directory membership or profile sidecars');
350
+ const snapshots = new Map();
351
+ const verify = (filename, cap) => {
352
+ sameDirectory(directory);
353
+ checkTrust();
354
+ const file = path.join(directory.path, filename), snapshot = readRegular(file, cap);
355
+ const proofFile = file + '.signature.json', proof = readRegular(proofFile, 8192);
356
+ const envelope = parseStrictReleaseIndex(proof.bytes.toString('utf8'), { maxBytes: 8192 });
357
+ exact(envelope, ['schema', 'profile', 'keyFingerprint', 'filename', 'indexDigest', 'signature'], 'local signature envelope');
358
+ if (envelope.schema !== 1 || envelope.profile !== 'local-ed25519' || envelope.keyFingerprint !== opts.localKeyFingerprint || envelope.filename !== filename || envelope.indexDigest !== indexDigest)
359
+ throw Error('local signature envelope binding refused');
360
+ if (typeof envelope.signature !== 'string' || !/^[A-Za-z0-9+/]{86}==$/.test(envelope.signature))
361
+ throw Error('canonical Ed25519 signature required');
362
+ const signature = Buffer.from(envelope.signature, 'base64');
363
+ if (signature.length !== 64 || signature.toString('base64') !== envelope.signature || !verifySignature(null, localReleaseSignatureMessage(filename, indexDigest, snapshot.bytes), enrolled.key, signature))
364
+ throw Error('local release signature verification failed');
365
+ const after = readRegular(file, cap), afterProof = readRegular(proofFile, 8192);
366
+ if (after.identity !== snapshot.identity || sha(after.bytes) !== sha(snapshot.bytes) || afterProof.identity !== proof.identity || sha(afterProof.bytes) !== sha(proof.bytes))
367
+ throw Error('local artifact or signature changed during verification');
368
+ sameDirectory(directory);
369
+ checkTrust();
370
+ return snapshot;
371
+ };
372
+ const verifiedIndex = verify('release-index.json', RELEASE_TRUST_LIMITS.index);
373
+ if (verifiedIndex.identity !== indexSnapshot.identity || sha(verifiedIndex.bytes) !== indexDigest)
374
+ throw Error('release index changed during verification');
375
+ for (const artifact of index.artifacts) {
376
+ const snapshot = verify(artifact.filename, artifact.size);
377
+ if (snapshot.bytes.length !== artifact.size || sha(snapshot.bytes) !== artifact.sha256 || sha(snapshot.bytes, 'sha512') !== artifact.sha512)
378
+ throw Error('artifact size or digest mismatch');
379
+ snapshots.set(artifact.kind, { ...artifact, path: path.join(directory.path, artifact.filename), identity: snapshot.identity });
380
+ }
381
+ const info = Object.freeze({ version: index.version, commit: index.source.commit, tree: index.source.tree, source: Object.freeze({ ...index.source }), indexDigest, isProduction, authority: isProduction ? 'ARTIFACT_ADMISSION' : 'NONE', evidence: isProduction ? 'LOCAL_ED25519_VERIFIED' : 'SYNTHETIC_ONLY', releaseEligibility: 'NOT_CHECKED' });
382
+ const ticket = Object.freeze({ type: 'release-admission' });
383
+ admissions.set(ticket, { directory, info, index, snapshots, indexSnapshot, checkTrust });
384
+ try {
385
+ inspectNpmPayload(ticket);
386
+ inspectPluginPayload(ticket);
387
+ }
388
+ catch (error) {
389
+ admissions.delete(ticket);
390
+ throw error;
391
+ }
392
+ return ticket;
393
+ }
394
+ export function getAdmissionInfo(ticket) { return ticketState(ticket).info; }
395
+ export function getAdmittedArtifact(ticket, kind) {
396
+ const state = ticketState(ticket), artifact = state.snapshots.get(kind);
397
+ if (!artifact)
398
+ throw Error('unknown admitted artifact kind');
399
+ const snapshot = readRegular(artifact.path, artifact.size);
400
+ if (snapshot.identity !== artifact.identity || snapshot.bytes.length !== artifact.size || sha(snapshot.bytes) !== artifact.sha256 || sha(snapshot.bytes, 'sha512') !== artifact.sha512)
401
+ throw Error('admitted artifact identity or digest changed');
402
+ return Object.freeze({ path: artifact.path, sha256: artifact.sha256, size: artifact.size });
403
+ }
404
+ function artifactBytes(ticket, kind) { const a = getAdmittedArtifact(ticket, kind); const bytes = readRegular(a.path, a.size).bytes; if (sha(bytes) !== a.sha256)
405
+ throw Error('artifact digest changed'); return bytes; }
406
+ function required(files, names) { const paths = new Set(files.map(f => f.path)); for (const name of names)
407
+ if (!paths.has(name))
408
+ throw Error(`runtime archive missing ${name}`); }
409
+ export function inspectRuntimeImportReferences(files) {
410
+ const paths = new Set(files.map(f => f.path));
411
+ for (const file of files) {
412
+ if (!/\.(?:mjs|js|cjs)$/.test(file.path))
413
+ continue;
414
+ const source = file.bytes.toString('utf8');
415
+ // Match the publisher's literal-import closure. Computed imports still need runtime smoke.
416
+ const pattern = /(?:\bfrom\s*|\bimport\s*(?:\(\s*)?)["'](\.{1,2}\/[^"'\n\r]+)["']/g;
417
+ for (const match of source.matchAll(pattern)) {
418
+ const specifier = match[1];
419
+ if (/[?#\\]/.test(specifier))
420
+ throw Error('unsupported relative runtime import');
421
+ const target = path.posix.normalize(path.posix.join(path.posix.dirname(file.path), specifier));
422
+ if (target === '..' || target.startsWith('../') || path.posix.isAbsolute(target) || !paths.has(target))
423
+ throw Error(`runtime import missing from archive: ${file.path}`);
424
+ }
425
+ }
426
+ }
427
+ export function inspectNpmPayload(ticket) {
428
+ const { version } = getAdmissionInfo(ticket), files = inspectReleaseArchive(artifactBytes(ticket, 'npm'));
429
+ required(files, ['package.json', 'index.mjs', 'dist/main.js', 'board/packages/board/server.mjs']);
430
+ const pkg = parseStrictReleaseIndex(files.find(f => f.path === 'package.json').bytes.toString('utf8'));
431
+ if (pkg.name !== 'great-cto' || pkg.version !== version)
432
+ throw Error('npm payload package identity mismatch');
433
+ exact(pkg.bin, ['great-cto'], 'npm bin');
434
+ if (pkg.bin['great-cto'] !== 'index.mjs')
435
+ throw Error('npm bin target is unsupported');
436
+ for (const k of ['dependencies', 'optionalDependencies', 'peerDependencies', 'bundledDependencies', 'bundleDependencies']) {
437
+ if (Object.hasOwn(pkg, k) && (!pkg[k] || typeof pkg[k] !== 'object' || Object.keys(pkg[k]).length !== 0))
438
+ throw Error('npm runtime dependency closure is unsupported');
439
+ }
440
+ if (files.some(f => /^(?:npm-shrinkwrap\.json|package-lock\.json)$/.test(f.path)))
441
+ throw Error('npm dependency lockfile closure is unsupported');
442
+ for (const key of ['man', 'workspaces'])
443
+ if (Object.hasOwn(pkg, key) && (!Array.isArray(pkg[key]) || pkg[key].length))
444
+ throw Error('additional npm install targets are unsupported');
445
+ inspectRuntimeImportReferences(files);
446
+ return { version, files };
447
+ }
448
+ /** Validate declarative local assets as well as literal JavaScript imports. */
449
+ export function inspectPluginManifestReferences(files) {
450
+ const actual = new Map(files.map(f => [f.path, f]));
451
+ const reference = (value, directoryAllowed = false) => {
452
+ if (typeof value !== 'string' || !value.startsWith('./') || /[\\?#\0]/.test(value) || (!directoryAllowed && value.endsWith('/')))
453
+ throw Error('plugin manifest path refused');
454
+ const member = value.slice(2).replace(/\/$/, '');
455
+ if (!member || path.posix.normalize(member) !== member || member === '..' || member.startsWith('../'))
456
+ throw Error('plugin manifest path refused');
457
+ const fileMatch = !value.endsWith('/') && actual.has(member);
458
+ const directoryMatch = directoryAllowed && files.some(f => f.path.startsWith(member + '/'));
459
+ if (!fileMatch && !directoryMatch)
460
+ throw Error('plugin manifest reference missing from runtime archive');
461
+ };
462
+ for (const name of ['.claude-plugin/plugin.json', '.codex-plugin/plugin.json']) {
463
+ if (!actual.has(name))
464
+ throw Error('plugin manifest reference missing from runtime archive');
465
+ const manifest = parseStrictReleaseIndex(actual.get(name).bytes.toString('utf8'));
466
+ for (const key of ['agents', 'commands', 'skills', 'outputStyles']) {
467
+ if (!Object.hasOwn(manifest, key))
468
+ continue;
469
+ const values = Array.isArray(manifest[key]) ? manifest[key] : [manifest[key]];
470
+ for (const value of values)
471
+ reference(value, true);
472
+ }
473
+ if (Object.hasOwn(manifest, 'types'))
474
+ reference(manifest.types);
475
+ for (const key of ['hooks', 'mcpServers']) {
476
+ if (!Object.hasOwn(manifest, key))
477
+ continue;
478
+ if (typeof manifest[key] === 'string')
479
+ reference(manifest[key]);
480
+ else if (!manifest[key] || typeof manifest[key] !== 'object' || Array.isArray(manifest[key]))
481
+ throw Error('plugin manifest reference shape refused');
482
+ }
483
+ if (manifest.interface && Object.hasOwn(manifest.interface, 'composerIcon'))
484
+ reference(manifest.interface.composerIcon);
485
+ }
486
+ return true;
487
+ }
488
+ export function inspectPluginPayload(ticket) {
489
+ const { version } = getAdmissionInfo(ticket), files = inspectReleaseArchive(artifactBytes(ticket, 'plugin'), { prefix: 'plugin' });
490
+ required(files, ['.claude-plugin/plugin.json', '.codex-plugin/plugin.json', 'packages/cli/dist/main.js', 'packages/cli/index.mjs', 'packages/board/server.mjs']);
491
+ const inventory = parseStrictReleaseIndex(artifactBytes(ticket, 'inventory').toString('utf8'), { maxBytes: RELEASE_TRUST_LIMITS.inventory });
492
+ exact(inventory, ['schema', 'version', 'files'], 'plugin inventory');
493
+ if (inventory.schema !== 1 || inventory.version !== version || !Array.isArray(inventory.files) || inventory.files.length !== files.length)
494
+ throw Error('plugin inventory membership mismatch');
495
+ const actual = new Map(files.map(f => [f.path, f]));
496
+ let previous = '';
497
+ for (const entry of inventory.files) {
498
+ exact(entry, ['path', 'size', 'sha256', 'mode'], 'plugin inventory entry');
499
+ if (typeof entry.path !== 'string' || entry.path <= previous)
500
+ throw Error('plugin inventory must be sorted and unique');
501
+ previous = entry.path;
502
+ const file = actual.get(entry.path);
503
+ if (!file || entry.size !== file.bytes.length || entry.sha256 !== sha(file.bytes) || entry.mode !== file.mode)
504
+ throw Error('plugin inventory bytes, size or mode mismatch');
505
+ }
506
+ for (const [name, expected] of [['.claude-plugin/plugin.json', 'great-cto'], ['.codex-plugin/plugin.json', 'great-cto']]) {
507
+ const manifest = parseStrictReleaseIndex(actual.get(name).bytes.toString('utf8'));
508
+ if (manifest.name !== expected || manifest.version !== version)
509
+ throw Error('plugin manifest identity mismatch');
510
+ }
511
+ inspectPluginManifestReferences(files);
512
+ inspectRuntimeImportReferences(files);
513
+ return { version, files };
514
+ }