@pipobscure/bundle 0.0.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 (98) hide show
  1. package/HISTORY.md +1924 -0
  2. package/README.md +623 -0
  3. package/bundle.run +0 -0
  4. package/dist/api.d.ts +147 -0
  5. package/dist/api.d.ts.map +1 -0
  6. package/dist/api.js +174 -0
  7. package/dist/api.js.map +1 -0
  8. package/dist/archive.d.ts +115 -0
  9. package/dist/archive.d.ts.map +1 -0
  10. package/dist/archive.js +188 -0
  11. package/dist/archive.js.map +1 -0
  12. package/dist/audit.d.ts +78 -0
  13. package/dist/audit.d.ts.map +1 -0
  14. package/dist/audit.js +119 -0
  15. package/dist/audit.js.map +1 -0
  16. package/dist/cli.d.ts +23 -0
  17. package/dist/cli.d.ts.map +1 -0
  18. package/dist/cli.js +555 -0
  19. package/dist/cli.js.map +1 -0
  20. package/dist/files.d.ts +53 -0
  21. package/dist/files.d.ts.map +1 -0
  22. package/dist/files.js +118 -0
  23. package/dist/files.js.map +1 -0
  24. package/dist/index.d.ts +10 -0
  25. package/dist/index.d.ts.map +1 -0
  26. package/dist/index.js +35 -0
  27. package/dist/index.js.map +1 -0
  28. package/dist/launch.d.ts +97 -0
  29. package/dist/launch.d.ts.map +1 -0
  30. package/dist/launch.js +267 -0
  31. package/dist/launch.js.map +1 -0
  32. package/dist/main.d.ts +3 -0
  33. package/dist/main.d.ts.map +1 -0
  34. package/dist/main.js +19 -0
  35. package/dist/main.js.map +1 -0
  36. package/dist/manifest.d.ts +139 -0
  37. package/dist/manifest.d.ts.map +1 -0
  38. package/dist/manifest.js +504 -0
  39. package/dist/manifest.js.map +1 -0
  40. package/dist/oidc.d.ts +40 -0
  41. package/dist/oidc.d.ts.map +1 -0
  42. package/dist/oidc.js +320 -0
  43. package/dist/oidc.js.map +1 -0
  44. package/dist/preload.d.ts +14 -0
  45. package/dist/preload.d.ts.map +1 -0
  46. package/dist/preload.js +38 -0
  47. package/dist/preload.js.map +1 -0
  48. package/dist/provider.d.ts +83 -0
  49. package/dist/provider.d.ts.map +1 -0
  50. package/dist/provider.js +206 -0
  51. package/dist/provider.js.map +1 -0
  52. package/dist/record.d.ts +2 -0
  53. package/dist/record.d.ts.map +1 -0
  54. package/dist/record.js +23 -0
  55. package/dist/record.js.map +1 -0
  56. package/dist/recorder.d.ts +64 -0
  57. package/dist/recorder.d.ts.map +1 -0
  58. package/dist/recorder.js +111 -0
  59. package/dist/recorder.js.map +1 -0
  60. package/dist/register.d.ts +2 -0
  61. package/dist/register.d.ts.map +1 -0
  62. package/dist/register.js +28 -0
  63. package/dist/register.js.map +1 -0
  64. package/dist/sea.d.ts +97 -0
  65. package/dist/sea.d.ts.map +1 -0
  66. package/dist/sea.js +220 -0
  67. package/dist/sea.js.map +1 -0
  68. package/dist/sigstore.d.ts +112 -0
  69. package/dist/sigstore.d.ts.map +1 -0
  70. package/dist/sigstore.js +385 -0
  71. package/dist/sigstore.js.map +1 -0
  72. package/dist/skill.d.ts +36 -0
  73. package/dist/skill.d.ts.map +1 -0
  74. package/dist/skill.js +108 -0
  75. package/dist/skill.js.map +1 -0
  76. package/package.json +84 -0
  77. package/shell-base +2 -0
  78. package/skills/audit-bundle/SKILL.md +271 -0
  79. package/src/api.ts +293 -0
  80. package/src/archive.ts +312 -0
  81. package/src/audit.ts +206 -0
  82. package/src/cli.ts +575 -0
  83. package/src/files.ts +156 -0
  84. package/src/index.ts +114 -0
  85. package/src/launch.ts +336 -0
  86. package/src/main.ts +20 -0
  87. package/src/manifest.ts +615 -0
  88. package/src/oidc.ts +372 -0
  89. package/src/preload.ts +40 -0
  90. package/src/provider.ts +270 -0
  91. package/src/record.ts +25 -0
  92. package/src/recorder.ts +166 -0
  93. package/src/register.ts +30 -0
  94. package/src/sea.ts +341 -0
  95. package/src/sigstore.ts +492 -0
  96. package/src/skill.ts +132 -0
  97. package/src/types/node-vfs.d.ts +90 -0
  98. package/src/types/node-zip.d.ts +85 -0
package/src/archive.ts ADDED
@@ -0,0 +1,312 @@
1
+ import * as ZLIB from 'node:zlib';
2
+ import * as PATH from 'node:path';
3
+ import * as FS from 'node:fs';
4
+ import * as CRYPTO from 'node:crypto';
5
+ import { Transform, type Writable } from 'node:stream';
6
+ import { buildManifest, formatSignature, AUTHORITY } from './manifest.ts';
7
+
8
+ // Building an archive, in two steps that are deliberately separable.
9
+ //
10
+ // `bundle()` collects files off disk into an unsigned archive. `rebundle()`
11
+ // takes an existing archive and re-emits it — with a different prefix, a
12
+ // different certificate, or both. Signing is only ever `rebundle()`'s job.
13
+ //
14
+ // That split is what makes one build serve every shape. The offsets inside a
15
+ // ZIP central directory are absolute, so an archive that will sit behind a
16
+ // 155 MB node binary is not byte-identical to the same archive behind a 79-byte
17
+ // shebang: the prefix has to be chosen before the offsets are fixed, and
18
+ // therefore before the hash exists. Re-emitting from the members rather than
19
+ // copying bytes is what lets one `app.bundle` become a `#!` launcher, a
20
+ // self-contained executable and a plain mountable archive, each correctly
21
+ // offset and each signed over its own finished bytes:
22
+ //
23
+ // bundle → app.bundle (unsigned, the source of truth)
24
+ // rebundle → app.run (prefix: shell-base) signed
25
+ // rebundle → app.sea (prefix: node-base) signed
26
+ // rebundle → app.signed.bundle (no prefix) signed
27
+ //
28
+ // A signer is `{ chain, signAlg, sign(digest) }`: the chain goes into
29
+ // `AUTHORITY.PEM` *before* hashing, and `sign()` is called *after*, with the
30
+ // finished hash. `keySigner()` below is the offline-CA implementation;
31
+ // `sigstore.ts` provides the other one. Nothing here knows which it has.
32
+
33
+ /** One file about to become an archive member. */
34
+ export interface Member {
35
+ /** The member's name inside the archive; a `/`-separated relative path. */
36
+ name: string;
37
+ data: Buffer;
38
+ mode?: number | undefined;
39
+ }
40
+
41
+ /** What a signer hands back once the finished hash exists. */
42
+ export interface Signature {
43
+ signature: Buffer | Uint8Array;
44
+ /**
45
+ * Unsigned attributes to record beside the signature in the EOCD comment —
46
+ * anything obtained *after* signing, which therefore cannot be inside what
47
+ * the signature covers.
48
+ */
49
+ fields?: Record<string, string | undefined> | undefined;
50
+ }
51
+
52
+ /**
53
+ * The two-phase signing interface. `chain` is known up front (it goes into
54
+ * `AUTHORITY.PEM`, inside the hashed region); `sign()` is called afterwards
55
+ * with the hash of the finished bytes.
56
+ */
57
+ export interface Signer {
58
+ kind?: string | undefined;
59
+ /** Full PEM certificate chain, leaf first. */
60
+ chain: string;
61
+ /** Digest the signature over the whole-file hash uses. */
62
+ signAlg: string;
63
+ sign(digest: Buffer): Promise<Signature>;
64
+ }
65
+
66
+ /** What `emit()` reports once the file has been fully written. */
67
+ export interface EmitResult {
68
+ /** The whole-file hash, hex — null for an unsigned archive. */
69
+ hash: string | null;
70
+ signed: boolean;
71
+ }
72
+
73
+ export interface BundleOptions {
74
+ /** Base directory the file list is relative to. */
75
+ base: string;
76
+ /** Member names, relative to `base`. */
77
+ files: string[];
78
+ /** A launcher or binary to prepend, making the result self-running. */
79
+ prefix?: string | undefined;
80
+ /** Digest for the whole-file hash and member digests (default: 'sha256'). */
81
+ hashAlg?: string | undefined;
82
+ /** Digest the signature uses (default: 'sha256'). */
83
+ signAlg?: string | undefined;
84
+ /** Shorthand for `signer: keySigner({ key, chain, signAlg })`. */
85
+ key?: Buffer | string | CRYPTO.KeyObject | undefined;
86
+ chain?: string | undefined;
87
+ signer?: Signer | undefined;
88
+ out: Writable;
89
+ }
90
+
91
+ export interface RebundleOptions {
92
+ /** Path to the archive whose members are re-emitted. */
93
+ source: string;
94
+ prefix?: string | undefined;
95
+ hashAlg?: string | undefined;
96
+ signAlg?: string | undefined;
97
+ key?: Buffer | string | CRYPTO.KeyObject | undefined;
98
+ chain?: string | undefined;
99
+ signer?: Signer | undefined;
100
+ out: Writable;
101
+ }
102
+
103
+ // Members, as `{ name, data, mode }`. Two sources: a directory plus a file
104
+ // list, or an existing archive.
105
+ //
106
+ // `AUTHORITY.PEM` is never carried across from a source archive — it describes
107
+ // the signing of the archive it came from, and a re-emitted archive gets a
108
+ // fresh one.
109
+ export async function *fromDirectory(base: string, files: string[]): AsyncGenerator<Member> {
110
+ for (const name of files) {
111
+ yield { name, data: FS.readFileSync(PATH.resolve(base, name)), mode: 0o444 };
112
+ }
113
+ }
114
+
115
+ export async function *fromArchive(zip: ZLIB.ZipFile): AsyncGenerator<Member> {
116
+ for (const [name, entry] of zip.entriesSync()) {
117
+ if (name === AUTHORITY || entry.isDirectory) continue;
118
+ yield { name, data: entry.contentSync(), mode: entry.mode || 0o444 };
119
+ }
120
+ }
121
+
122
+ // Yields a ZipEntry per member — each stamped, in its entry comment, with the
123
+ // hex digest of its own content — then a final `AUTHORITY.PEM` manifest entry
124
+ // declaring the algorithms and (when signing) carrying the certificate chain.
125
+ // Members are small (an application's own files; the heavy runtime is the
126
+ // prepended prefix, not an archive member), so each is held whole to hash it
127
+ // before the entry, whose comment must be fixed at creation time, is built.
128
+ async function *entries(
129
+ members: AsyncIterable<Member> | Iterable<Member>,
130
+ { hashAlg, signAlg, chain }: { hashAlg: string; signAlg?: string | undefined; chain?: string | undefined },
131
+ ): AsyncGenerator<ZLIB.ZipEntry> {
132
+ for await (const { name, data, mode } of members) {
133
+ const digest = CRYPTO.createHash(hashAlg).update(data).digest('hex');
134
+ yield await ZLIB.ZipEntry.create(name, data, { mode: mode ?? 0o444, comment: digest });
135
+ }
136
+ yield await ZLIB.ZipEntry.create(AUTHORITY, buildManifest({ hashAlg, signAlg, chain }), { mode: 0o444 });
137
+ }
138
+
139
+ /**
140
+ * A Readable of the ZIP archive over `members`. Its members carry per-file
141
+ * digests and its AUTHORITY.PEM manifest declares the algorithms and chain, but
142
+ * the archive itself is left with an empty EOCD comment: the whole-file hash
143
+ * and its signature are a property of the finished file and are applied by
144
+ * `emit()`. `baseOffset` seeds the archive's internal offsets for when it is
145
+ * appended after a prefix.
146
+ */
147
+ export function createArchive({ members, base, files, hashAlg = 'sha256', signAlg, chain, baseOffset = 0 }: {
148
+ members?: AsyncIterable<Member> | Iterable<Member> | undefined;
149
+ base?: string | undefined;
150
+ files?: string[] | undefined;
151
+ hashAlg?: string | undefined;
152
+ signAlg?: string | undefined;
153
+ chain?: string | undefined;
154
+ baseOffset?: number | undefined;
155
+ }) {
156
+ const source = members ?? fromDirectory(base ?? '.', files ?? []);
157
+ return ZLIB.createZipArchive(entries(source, { hashAlg, signAlg, chain }), { baseOffset });
158
+ }
159
+
160
+ /**
161
+ * A signer backed by a private key and a certificate chain already on disk —
162
+ * the offline-CA path, and the shape `sigstore.ts` implements too.
163
+ */
164
+ export function keySigner({ key, chain, signAlg = 'sha256' }: {
165
+ key: Buffer | string | CRYPTO.KeyObject;
166
+ chain: Buffer | string;
167
+ signAlg?: string | undefined;
168
+ }): Signer {
169
+ return {
170
+ kind: 'key',
171
+ chain: String(chain),
172
+ signAlg,
173
+ async sign(digest: Buffer): Promise<Signature> {
174
+ return { signature: Buffer.from(CRYPTO.sign(signAlg, digest, key as CRYPTO.KeyLike)) };
175
+ },
176
+ };
177
+ }
178
+
179
+ // Writes `prefix` (when given) then the archive to `out`, without closing
180
+ // `out`. With no prefix the result is a plain archive — a `.bundle` meant to be
181
+ // run through `--vfs-load`; with one it is a self-running container (a shebang
182
+ // launcher or a SEA binary) that carries the same archive in its tail. When a
183
+ // `signer` is given, the whole file is signed. The hash runs over the prefix
184
+ // and then over the archive up to (but not including) the EOCD comment; that
185
+ // hash is what the signer signs. The EOCD comment records both, so a verifier
186
+ // can validate the hash on its own (a cheap pre-mount integrity gate) and only
187
+ // then check the signature over that hash against the certificate:
188
+ //
189
+ // SIGNED:<hash-of-region-hex>:<signature-hex>[:<NAME>=<value>]*
190
+ async function emit({ members, prefix, hashAlg = 'sha256', signer, out }: {
191
+ members: AsyncIterable<Member> | Iterable<Member>;
192
+ prefix?: string | undefined;
193
+ hashAlg?: string | undefined;
194
+ signer?: Signer | undefined;
195
+ out: Writable;
196
+ }): Promise<EmitResult> {
197
+ const hasher = signer ? CRYPTO.createHash(hashAlg) : null;
198
+
199
+ // 1. Stream the prefix straight to `out`, feeding the whole-file hash.
200
+ if (prefix) await prepend(prefix, out, hasher);
201
+
202
+ // 2. Build the archive (small) with an empty EOCD comment, in memory. The
203
+ // chain has to be embedded here, before anything is hashed — which is
204
+ // why a signer hands over its certificate up front and signs later.
205
+ const archive = await collect(createArchive({
206
+ members, hashAlg,
207
+ signAlg: signer?.signAlg,
208
+ chain: signer?.chain,
209
+ baseOffset: prefix ? FS.statSync(prefix).size : 0,
210
+ }));
211
+
212
+ if (!signer || !hasher) {
213
+ await write(out, archive);
214
+ return { hash: null, signed: false };
215
+ }
216
+
217
+ // 3. The signed region is the prefix plus the archive minus its trailing
218
+ // 2-byte (empty) comment-length field. Hash it, sign the hash, and
219
+ // re-emit the archive with the marker as the EOCD comment. Anything the
220
+ // signer produced *after* signing — a transparency-log entry, a
221
+ // timestamp — comes back as fields and rides in the same comment, since
222
+ // it could not have been inside the hash it postdates.
223
+ const region = archive.subarray(0, archive.length - 2);
224
+ hasher.update(region);
225
+ const digest = hasher.digest();
226
+ const { signature, fields } = await signer.sign(digest);
227
+
228
+ const marker = formatSignature({
229
+ hash: digest.toString('hex'),
230
+ sig: Buffer.from(signature).toString('hex'),
231
+ fields,
232
+ });
233
+ const comment = Buffer.from(marker, 'ascii');
234
+ if (comment.length > 0xffff) {
235
+ throw new Error(`signature marker is ${comment.length} bytes; a ZIP comment holds at most 65535`);
236
+ }
237
+ const length = Buffer.alloc(2);
238
+ length.writeUInt16LE(comment.length, 0);
239
+ await write(out, Buffer.concat([region, length, comment]));
240
+ return { hash: digest.toString('hex'), signed: true };
241
+ }
242
+
243
+ /**
244
+ * Build an archive from files on disk. `key`/`chain` are accepted as a
245
+ * shorthand for `signer: keySigner({ key, chain, signAlg })`, so the
246
+ * create-and-sign-in-one-step path stays available.
247
+ */
248
+ export async function bundle({ base, files, prefix, hashAlg = 'sha256', signAlg = 'sha256', key, chain, signer, out }: BundleOptions): Promise<EmitResult> {
249
+ const active = signer ?? (key && chain ? keySigner({ key, chain, signAlg }) : undefined);
250
+ return emit({ members: fromDirectory(base, files), prefix, hashAlg, signer: active, out });
251
+ }
252
+
253
+ /**
254
+ * Re-emit an existing archive: same members, new prefix, new signature. This is
255
+ * what `bundle sign` runs. `source` is a path to an archive (signed or not,
256
+ * prefixed or not) — its members are read out, its old AUTHORITY.PEM is
257
+ * dropped, and everything is laid down again at the offsets the new prefix
258
+ * implies before the result is hashed and signed as a whole.
259
+ */
260
+ export async function rebundle({ source, prefix, hashAlg = 'sha256', signAlg = 'sha256', key, chain, signer, out }: RebundleOptions): Promise<EmitResult> {
261
+ const active = signer ?? (key && chain ? keySigner({ key, chain, signAlg }) : undefined);
262
+ const zip = ZLIB.ZipFile.openSync(PATH.resolve(source));
263
+ try {
264
+ // The member list is drained into memory before writing starts: the
265
+ // source archive may be the file being overwritten, and in any case the
266
+ // entries have to outlive the ZipFile handle closed below.
267
+ const members: Member[] = [];
268
+ for await (const member of fromArchive(zip)) members.push(member);
269
+ if (!members.length) throw new Error(`'${source}' contains no members to sign`);
270
+ return await emit({ members, prefix, hashAlg, signer: active, out });
271
+ } finally {
272
+ zip.closeSync();
273
+ }
274
+ }
275
+
276
+ /**
277
+ * The member names an archive holds, in order, excluding AUTHORITY.PEM — for
278
+ * reporting what is about to be re-signed without reading every member's bytes.
279
+ */
280
+ export function members(source: string): string[] {
281
+ const zip = ZLIB.ZipFile.openSync(PATH.resolve(source));
282
+ try {
283
+ return [...zip.entriesSync()]
284
+ .filter(([name, entry]) => name !== AUTHORITY && !entry.isDirectory)
285
+ .map(([name]) => name);
286
+ } finally {
287
+ zip.closeSync();
288
+ }
289
+ }
290
+
291
+ function prepend(file: string, out: Writable, sink: CRYPTO.Hash | null): Promise<void> {
292
+ return new Promise((resolve, reject) => {
293
+ const tap = new Transform({
294
+ transform(chunk: Buffer, _enc, cb) { if (sink) sink.update(chunk); cb(null, chunk); },
295
+ });
296
+ FS.createReadStream(file).on('error', reject)
297
+ .pipe(tap).on('error', reject).on('end', () => resolve())
298
+ .pipe(out, { end: false });
299
+ });
300
+ }
301
+
302
+ async function collect(readable: AsyncIterable<Buffer>): Promise<Buffer> {
303
+ const chunks: Buffer[] = [];
304
+ for await (const chunk of readable) chunks.push(chunk);
305
+ return Buffer.concat(chunks);
306
+ }
307
+
308
+ function write(out: Writable, buffer: Buffer): Promise<void> {
309
+ return new Promise((resolve, reject) => {
310
+ out.write(buffer, (err) => (err ? reject(err) : resolve()));
311
+ });
312
+ }
package/src/audit.ts ADDED
@@ -0,0 +1,206 @@
1
+ import * as FS from 'node:fs';
2
+ import * as PATH from 'node:path';
3
+ import * as CRYPTO from 'node:crypto';
4
+ import { verifyBundleSync, inspectBundle } from './api.ts';
5
+ import { STATES, message } from './manifest.ts';
6
+
7
+ // Step 3 of building a bundle: the audit gate.
8
+ //
9
+ // observe → create → AUDIT → sign
10
+ //
11
+ // The review itself needs judgement — it is `skills/audit-bundle`, and a person
12
+ // or an agent performs it. What code can do is the two mechanical halves around
13
+ // it: say what is about to be reviewed and how, and then refuse to let signing
14
+ // proceed on anything but a clean verdict over exactly these bytes.
15
+ //
16
+ // The verdict is a JSON file the skill writes (see its "A verdict something else
17
+ // can gate on" section), and the load-bearing field is the **sha256 of the
18
+ // archive**. Without that pin the gate is theatre: it would approve any later
19
+ // build on the strength of one earlier approval, which is the failure mode of
20
+ // every "security review completed" checkbox. Rebuilding invalidates the
21
+ // approval, and it should.
22
+ //
23
+ // This is a gate you choose to put in your own pipeline, not something the
24
+ // format imposes — the same position `--identity` takes.
25
+
26
+ /** What an audit concluded, as the gate reads it. */
27
+ export interface Verdict {
28
+ /** The archive reviewed, as the auditor referred to it. */
29
+ bundle?: string | undefined;
30
+ /** sha256 of the archive file — what pins the verdict to the bytes. */
31
+ sha256?: string | undefined;
32
+ /** The archive it was reviewed against, when the review was a diff. */
33
+ baseline?: string | undefined;
34
+ baselineSha256?: string | undefined;
35
+ /** Which question was answered: whether to sign it, or whether to run it. */
36
+ mode?: 'sign' | 'run' | undefined;
37
+ state?: string | undefined;
38
+ members?: number | undefined;
39
+ /** How many members were actually read — the changed set, in diff mode. */
40
+ reviewed?: number | undefined;
41
+ verdict?: 'pass' | 'fail' | undefined;
42
+ summary?: string | undefined;
43
+ findings?: Finding[] | undefined;
44
+ /** 'human' when a person recorded it rather than the skill. */
45
+ by?: string | undefined;
46
+ at?: string | undefined;
47
+ }
48
+
49
+ export interface Finding {
50
+ severity: string;
51
+ file: string;
52
+ line?: number | undefined;
53
+ what: string;
54
+ why?: string | undefined;
55
+ }
56
+
57
+ export interface AuditOptions {
58
+ /** The archive under review. */
59
+ bundle: string;
60
+ /** Where the verdict is written and read (default: `<bundle>.audit.json`). */
61
+ verdict?: string | undefined;
62
+ /** A previously approved archive to review against, when there is one. */
63
+ baseline?: string | undefined;
64
+ }
65
+
66
+ /** What `prepare()` found, for a caller that wants to report it itself. */
67
+ export interface Preparation {
68
+ bundle: string;
69
+ sha256: string;
70
+ state: string;
71
+ members: string[];
72
+ verdict: string;
73
+ /** Present when a baseline was supplied and exists. */
74
+ baseline?: {
75
+ path: string;
76
+ sha256: string;
77
+ added: string[];
78
+ removed: string[];
79
+ carried: number;
80
+ } | undefined;
81
+ }
82
+
83
+ /** Where the verdict for `bundle` lives, unless the caller says otherwise. */
84
+ export function verdictPath(bundle: string, override?: string | undefined): string {
85
+ return override ? PATH.resolve(override) : `${PATH.resolve(bundle)}.audit.json`;
86
+ }
87
+
88
+ function sha256Of(path: string): string {
89
+ return CRYPTO.createHash('sha256').update(FS.readFileSync(path)).digest('hex');
90
+ }
91
+
92
+ /**
93
+ * Describe what is about to be audited: its hash, its members, and — when a
94
+ * baseline is given — what changed since. Throws when the archive does not hold
95
+ * together, because reviewing an archive whose contents are not what it says
96
+ * they are is worse than not reviewing it.
97
+ */
98
+ export function prepare({ bundle, verdict, baseline }: AuditOptions): Preparation {
99
+ if (!FS.existsSync(bundle)) throw new Error(`there is no archive at ${bundle}`);
100
+
101
+ // Damaged badly enough not to parse as a ZIP throws from inside the reader
102
+ // rather than answering; that is still a refusal and should read as one.
103
+ let state, members;
104
+ try {
105
+ state = verifyBundleSync(bundle).state;
106
+ ({ members } = inspectBundle(bundle));
107
+ } catch (err) {
108
+ throw new Error(`${bundle} could not be read as an archive: ${message(err)}`);
109
+ }
110
+ if (state === 'invalid') {
111
+ throw new Error(`${bundle} is ${STATES.invalid.label} — rebuild it rather than reviewing it; ` +
112
+ 'what you would review is not what it holds');
113
+ }
114
+
115
+ const found: Preparation = {
116
+ bundle, sha256: sha256Of(bundle), state, members,
117
+ verdict: verdictPath(bundle, verdict),
118
+ };
119
+
120
+ if (baseline && FS.existsSync(baseline)) {
121
+ const previous = inspectBundle(baseline).members;
122
+ const added = members.filter((name) => !previous.includes(name));
123
+ const removed = previous.filter((name) => !members.includes(name));
124
+ found.baseline = {
125
+ path: baseline, sha256: sha256Of(baseline),
126
+ added, removed, carried: members.length - added.length,
127
+ };
128
+ }
129
+ return found;
130
+ }
131
+
132
+ /**
133
+ * The gate. Returns the verdict when it passes; throws with the reason when it
134
+ * does not — no verdict, a verdict over different bytes, a verdict against a
135
+ * different baseline, or a verdict that failed.
136
+ */
137
+ export function check({ bundle, verdict, baseline }: AuditOptions): Verdict {
138
+ const path = verdictPath(bundle, verdict);
139
+ if (!FS.existsSync(path)) {
140
+ throw new Error(`${bundle} has not been audited — there is no verdict at ${path}`);
141
+ }
142
+
143
+ let found: Verdict;
144
+ try {
145
+ found = JSON.parse(FS.readFileSync(path, 'utf-8')) as Verdict;
146
+ } catch (err) {
147
+ throw new Error(`${path} is not readable JSON: ${message(err)}`);
148
+ }
149
+
150
+ const sha256 = sha256Of(bundle);
151
+ if (found.sha256?.toLowerCase() !== sha256) {
152
+ throw new Error(`${path} approves different bytes — it is stale.\n` +
153
+ ` approved: ${found.sha256 ?? '(none)'}\n` +
154
+ ` on disk: ${sha256}\n` +
155
+ ' re-audit the archive this build actually produced');
156
+ }
157
+
158
+ // A baseline that was available but reviewed against something else is the
159
+ // one way this gate can be fooled honestly: a verdict claiming a diff it did
160
+ // not do. A verdict recording *no* baseline is a full review, which is fine.
161
+ if (baseline && FS.existsSync(baseline) && found.baselineSha256) {
162
+ const expected = sha256Of(baseline);
163
+ if (found.baselineSha256.toLowerCase() !== expected) {
164
+ throw new Error(`${path} was reached against a different baseline.\n` +
165
+ ` audited against: ${found.baselineSha256}\n` +
166
+ ` on disk: ${expected}\n` +
167
+ ' re-audit against the baseline this build is comparing to');
168
+ }
169
+ }
170
+
171
+ if (found.verdict !== 'pass') {
172
+ const serious = (found.findings ?? []).filter((f) => f.severity !== 'note');
173
+ throw new Error(`the audit of ${bundle} did not pass: ${found.summary ?? '(no summary)'}` +
174
+ serious.map((f) => `\n [${f.severity}] ${f.file}${f.line ? `:${f.line}` : ''} — ${f.what}`).join(''));
175
+ }
176
+ return found;
177
+ }
178
+
179
+ /**
180
+ * Record a clean verdict reached by reading the archive yourself. It writes the
181
+ * same file the skill would, marked `by: 'human'`, so the gate treats both
182
+ * identically while the record still says which happened.
183
+ */
184
+ export function approve({ bundle, verdict, baseline, note }: AuditOptions & {
185
+ /** What you checked — it becomes the verdict's summary. */
186
+ note?: string | undefined;
187
+ }): Verdict {
188
+ const found = prepare({ bundle, verdict, baseline });
189
+ const recorded: Verdict = {
190
+ bundle,
191
+ sha256: found.sha256,
192
+ ...(found.baseline ? { baseline: found.baseline.path, baselineSha256: found.baseline.sha256 } : {}),
193
+ mode: 'sign',
194
+ state: found.state,
195
+ members: found.members.length,
196
+ reviewed: found.members.length,
197
+ verdict: 'pass',
198
+ summary: note ?? 'approved by hand; no note given',
199
+ findings: [],
200
+ by: 'human',
201
+ at: new Date().toISOString(),
202
+ };
203
+ FS.mkdirSync(PATH.dirname(found.verdict), { recursive: true });
204
+ FS.writeFileSync(found.verdict, `${JSON.stringify(recorded, null, 2)}\n`);
205
+ return recorded;
206
+ }