@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/cli.ts ADDED
@@ -0,0 +1,575 @@
1
+ import * as FS from 'node:fs';
2
+ import * as PATH from 'node:path';
3
+ import { parseArgs } from 'node:util';
4
+ import { createBundle, signBundle, verifyBundle, runBundle, fileSigner } from './api.ts';
5
+ import { members } from './archive.ts';
6
+ import { launcherPath } from './files.ts';
7
+ import * as AUDIT from './audit.ts';
8
+ import { message, STATES, type VerificationResult, type VerificationState } from './manifest.ts';
9
+
10
+ // Re-exported because this is where a CLI consumer looks for it; it is defined
11
+ // in the format layer so the `bundle` launcher can report an exit code without
12
+ // loading the whole CLI.
13
+ export { STATES };
14
+ import * as SKILLS from './skill.ts';
15
+
16
+ // Argument parsing and reporting, and nothing else. Every command below is a
17
+ // `parseArgs` call, a message or two, and one call into `api.ts` — which is
18
+ // deliberate: what the CLI can do is exactly what an embedder can do, because
19
+ // they are the same functions.
20
+
21
+ export const USAGE = `usage: bundle <command> [options]
22
+
23
+ commands:
24
+ create build an archive from a list of files
25
+ sign sign an archive into a new file, optionally behind a prefix
26
+ audit report what is about to be reviewed, and gate signing on the verdict
27
+ verify verify an archive and report its trust state
28
+ run mount a signed archive and run it
29
+ sea build a node runtime that verifies an archive before running it
30
+ trust refresh the sigstore trust root used to check sigstore signatures
31
+ skill install this package's bundle-auditing skill into a project
32
+
33
+ create options:
34
+ -b, --base <dir> base directory the file list is relative to (default: .)
35
+ -p, --prefix <file> prefix prepended before the archive (launcher or binary);
36
+ omit it for a plain archive meant to be run from a mount
37
+ -f, --files <file> read the newline-separated file list from here (default: stdin)
38
+ -o, --output <file> write the archive here (default: stdout)
39
+ -k, --key <file> leaf private key (PEM); signs at build time with --chain
40
+ -c, --chain <file> full certificate chain (PEM, leaf first)
41
+ --hash <alg> digest for the whole-file hash and member digests (default: sha256)
42
+ --sign <alg> digest the signature over that hash uses (default: sha256)
43
+
44
+ sign options: usage: sign [options] <archive>
45
+ -o, --output <file> write the signed archive here (default: stdout)
46
+ -l, --launcher prepend this package's shell launcher, so the result runs
47
+ by name — the usual way to make a self-executing archive
48
+ -p, --prefix <file> prepend some other prefix: a launcher of your own, or a
49
+ node binary; omit both for a plain mountable archive
50
+ -x, --executable make the output executable (implied by --launcher/--prefix)
51
+ --hash <alg> digest for the whole-file hash and member digests (default: sha256)
52
+ --sign <alg> digest the signature over that hash uses (default: sha256)
53
+
54
+ by default this signs through sigstore, taking the identity from CI when
55
+ there is one and otherwise opening a GitHub sign-in:
56
+ --flow <how> auto | ci | browser | device (default: auto)
57
+ --token <jwt> use this OIDC token instead of signing in
58
+ --oidc-issuer <url> OIDC issuer (default: sigstore's dex)
59
+ --connector <name> identity provider to jump to (default: github)
60
+ --fulcio <url> certificate authority (default: fulcio.sigstore.dev)
61
+ --rekor <url> transparency log; empty string to skip it
62
+ --tsa <url> timestamp authority; empty string to skip it
63
+
64
+ or, to sign against a certificate authority of your own:
65
+ -k, --key <file> leaf private key (PEM)
66
+ -c, --chain <file> full certificate chain (PEM, leaf first)
67
+
68
+ audit options: usage: audit [options] <archive>
69
+ -b, --baseline <file> a previously approved archive to review against, so the
70
+ review is of what changed rather than of everything
71
+ -v, --verdict <file> where the verdict is (default: <archive>.audit.json)
72
+ --check exit non-zero unless a clean verdict pins these bytes
73
+ --approve record a clean verdict you reached by reading it yourself
74
+ -n, --note <text> what you checked, recorded with --approve
75
+
76
+ with none of those it reports what is about to be reviewed and how. The review
77
+ itself needs judgement, so no command performs it: install the skill with
78
+ 'bundle skill' and run /audit-bundle, or read the archive yourself and
79
+ --approve. Signing is not gated unless you run --check before it.
80
+
81
+ verify options: usage: verify [options] <archive>
82
+ -a, --archive <file> archive to verify (or pass it as a positional argument)
83
+ -r, --root <file> extra trusted root certificate (PEM); repeatable
84
+ --identity <san> require this sigstore signing identity
85
+ --issuer <url> require this sigstore OIDC issuer
86
+ --sigstore-root <file> sigstore trust root (default: the cache 'trust' fills)
87
+ --json print the result as JSON
88
+
89
+ run options: usage: run [options] <archive> [-- <app args>]
90
+ -r, --root <file> extra trusted root certificate (PEM); repeatable
91
+ --identity <san> require this sigstore signing identity
92
+ --issuer <url> require this sigstore OIDC issuer
93
+ --untrusted run an archive whose signature is good but untrusted
94
+
95
+ sea options: usage: sea [options] [archive]
96
+ -o, --output <file> write the executable here (required)
97
+ --node <file> node binary to embed (default: the running one)
98
+ --base <file> reuse a SEA base built earlier instead of building one
99
+ --no-sigstore leave the sigstore libraries out of the embedded verifier
100
+ --untrusted let the finished executable run an archive whose
101
+ signature is good but unanchored
102
+ -r, --root <file> trusted root the executable checks against; repeatable
103
+ --identity <san> identity the executable requires of a signature
104
+ --issuer <url> issuer the executable requires of a signature
105
+
106
+ with an archive, the result is that application: one file that verifies
107
+ itself and runs what is inside it. without one, the result is a verifying
108
+ node — a runtime that takes an archive on its own command line:
109
+
110
+ bundle sea -o node-verifying
111
+ ./node-verifying ./my-app.zip --args --for --the --app
112
+
113
+ a runtime built with a policy (-r, --identity, --issuer) is sealed: it
114
+ accepts no policy from its command line, because a binary that demands a
115
+ signing identity is not one whose user can ask it to stop.
116
+
117
+ the signing options are the same as 'sign': sigstore by default, or --key
118
+ with --chain against a certificate authority of your own
119
+
120
+ trust options:
121
+ --mirror <url> TUF repository to refresh from (default: sigstore's)
122
+
123
+ skill options: usage: skill [options] [name]
124
+ -d, --dir <dir> where to install (default: .claude/skills)
125
+ -f, --force overwrite files that are already there
126
+ -l, --list list the skills this package carries and stop
127
+
128
+ -h, --help show this help`;
129
+
130
+ /** Where a command's output goes. Swappable so tests need no subprocess. */
131
+ export interface Console {
132
+ out(line: string): void;
133
+ err(line: string): void;
134
+ }
135
+
136
+ const CONSOLE: Console = {
137
+ out: (line) => { process.stdout.write(`${line}\n`); },
138
+ err: (line) => { process.stderr.write(`${line}\n`); },
139
+ };
140
+
141
+ /**
142
+ * The commands, in the order the usage text lists them. Keeping the dispatch
143
+ * table and the help in one place is what stops the two drifting apart.
144
+ */
145
+ export const COMMANDS: Record<string, (args: string[], io: Console) => number | Promise<number>> = {
146
+ create, sign, audit, verify: check, run, sea, trust, skill,
147
+ };
148
+
149
+ /**
150
+ * Run one CLI invocation. `argv` is the user's arguments — everything after the
151
+ * runtime and the entry point — and the return value is the process exit code,
152
+ * so a caller decides what to do with it rather than being exited out from
153
+ * under.
154
+ */
155
+ export async function main(argv: string[], io: Console = CONSOLE): Promise<number> {
156
+ const [cmd, ...rest] = argv;
157
+ try {
158
+ if (cmd === undefined) {
159
+ io.out(USAGE);
160
+ return 64;
161
+ }
162
+ if (cmd === '-h' || cmd === '--help' || cmd === 'help') {
163
+ io.out(USAGE);
164
+ return 0;
165
+ }
166
+ const command = Object.hasOwn(COMMANDS, cmd) ? COMMANDS[cmd] : undefined;
167
+ if (!command) throw new Error(`unknown command: ${cmd}`);
168
+ return await command(rest, io);
169
+ } catch (err) {
170
+ io.err(`error: ${message(err)}`);
171
+ return 70;
172
+ }
173
+ }
174
+
175
+ async function create(args: string[], io: Console): Promise<number> {
176
+ const { values } = parseArgs({
177
+ args,
178
+ options: {
179
+ base: { type: 'string', short: 'b', default: '.' },
180
+ prefix: { type: 'string', short: 'p' },
181
+ files: { type: 'string', short: 'f' },
182
+ output: { type: 'string', short: 'o' },
183
+ key: { type: 'string', short: 'k' },
184
+ chain: { type: 'string', short: 'c' },
185
+ hash: { type: 'string', default: 'sha256' },
186
+ sign: { type: 'string', default: 'sha256' },
187
+ },
188
+ });
189
+ if (Boolean(values.key) !== Boolean(values.chain)) throw new Error('create: --key and --chain must be given together');
190
+
191
+ const listing = values.files ? FS.readFileSync(values.files, 'utf-8') : await readStdin();
192
+ const files = [...new Set(listing.split(/\r?\n/).filter(Boolean))].sort();
193
+ if (!files.length) throw new Error('create: the file list is empty');
194
+
195
+ for (const file of files) io.err(`+ ${file}`);
196
+ io.err(values.key
197
+ ? `* signed archive (${files.length} members, ${values.hash} digests, ${values.sign} signature)`
198
+ : `* unsigned archive (${files.length} members, ${values.hash} digests)`);
199
+
200
+ await createBundle({
201
+ base: values.base, files, prefix: values.prefix, output: values.output,
202
+ hashAlg: values.hash, signAlg: values.sign,
203
+ key: values.key ? FS.readFileSync(values.key) : undefined,
204
+ chain: values.chain ? FS.readFileSync(values.chain, 'utf-8') : undefined,
205
+ });
206
+ return 0;
207
+ }
208
+
209
+ async function sign(args: string[], io: Console): Promise<number> {
210
+ const { values, positionals } = parseArgs({
211
+ args,
212
+ allowPositionals: true,
213
+ options: {
214
+ output: { type: 'string', short: 'o' },
215
+ launcher: { type: 'boolean', short: 'l' },
216
+ prefix: { type: 'string', short: 'p' },
217
+ executable: { type: 'boolean', short: 'x' },
218
+ key: { type: 'string', short: 'k' },
219
+ chain: { type: 'string', short: 'c' },
220
+ hash: { type: 'string', default: 'sha256' },
221
+ sign: { type: 'string', default: 'sha256' },
222
+ flow: { type: 'string', default: 'auto' },
223
+ token: { type: 'string' },
224
+ 'oidc-issuer': { type: 'string' },
225
+ connector: { type: 'string' },
226
+ fulcio: { type: 'string' },
227
+ rekor: { type: 'string' },
228
+ tsa: { type: 'string' },
229
+ },
230
+ });
231
+ const source = positionals[0];
232
+ if (!source) throw new Error('sign: an archive path is required');
233
+ if (Boolean(values.key) !== Boolean(values.chain)) throw new Error('sign: --key and --chain must be given together');
234
+ if (values.launcher && values.prefix) throw new Error('sign: --launcher and --prefix are alternatives');
235
+
236
+ // `--launcher` is `--prefix <this package's shell-base>`, spelled so that
237
+ // nobody has to know the prefix ships inside node_modules.
238
+ const prefix = values.launcher ? launcherPath() : values.prefix;
239
+
240
+ for (const name of members(source)) io.err(`+ ${name}`);
241
+ if (prefix) io.err(`* prefix ${prefix} (${FS.statSync(prefix).size} bytes)`);
242
+
243
+ const signer = await chooseSigner(values, io);
244
+
245
+ const res = await signBundle({
246
+ source, output: values.output, prefix, executable: values.executable,
247
+ hashAlg: values.hash, signAlg: values.sign, signer,
248
+ });
249
+ io.err(`* signed: ${res.hash}`);
250
+ if (res.output) io.err(`* wrote ${res.output} (${res.size} bytes)`);
251
+ return 0;
252
+ }
253
+
254
+ async function check(args: string[], io: Console): Promise<number> {
255
+ const { values, positionals } = parseArgs({
256
+ args,
257
+ allowPositionals: true,
258
+ options: {
259
+ archive: { type: 'string', short: 'a' },
260
+ root: { type: 'string', short: 'r', multiple: true },
261
+ identity: { type: 'string' },
262
+ issuer: { type: 'string' },
263
+ 'sigstore-root': { type: 'string' },
264
+ json: { type: 'boolean' },
265
+ },
266
+ });
267
+ const archive = values.archive ?? positionals[0];
268
+ if (!archive) throw new Error('verify: an archive path is required');
269
+
270
+ const res = await verifyBundle(archive, {
271
+ roots: values.root ?? [],
272
+ identity: values.identity,
273
+ issuer: values.issuer,
274
+ trustedRoot: values['sigstore-root'],
275
+ });
276
+ report(res, Boolean(values.json), io);
277
+ return STATES[res.state].code;
278
+ }
279
+
280
+ /** Print a verification result, as text or as the JSON `--json` produces. */
281
+ export function report(res: VerificationResult, json: boolean, io: Console): void {
282
+ const state = STATES[res.state];
283
+ if (json) {
284
+ io.out(JSON.stringify({
285
+ state: res.state, reason: res.reason, subject: res.subject,
286
+ signed: res.signed, trusted: res.trusted, sigstore: Boolean(res.sigstore),
287
+ identity: res.identity, issuer: res.issuer,
288
+ signedAt: res.signedAt ? res.signedAt.toISOString() : undefined,
289
+ members: res.digests ? [...res.digests.keys()] : undefined,
290
+ code: state.code,
291
+ }, null, 2));
292
+ return;
293
+ }
294
+ io.out(`${state.label} — ${res.reason ?? state.note}`);
295
+ // For a sigstore signature the identity is the answer to "who signed this";
296
+ // the certificate subject is an ephemeral Fulcio artifact and says nothing
297
+ // useful.
298
+ if (res.identity) io.out(` identity: ${res.identity}`);
299
+ if (res.issuer) io.out(` issuer: ${res.issuer}`);
300
+ if (res.signedAt) io.out(` signed: ${res.signedAt.toISOString()}`);
301
+ if (res.subject && !res.identity) io.out(` certificate: ${res.subject.replace(/\n/g, ', ')}`);
302
+ }
303
+
304
+ // Run an archive the way a mount does. Everything after `--` is the
305
+ // application's own argv.
306
+ function run(args: string[], io: Console): number {
307
+ const split = args.indexOf('--');
308
+ const mine = split < 0 ? args : args.slice(0, split);
309
+ const theirs = split < 0 ? [] : args.slice(split + 1);
310
+ const { values, positionals } = parseArgs({
311
+ args: mine,
312
+ allowPositionals: true,
313
+ options: {
314
+ root: { type: 'string', short: 'r', multiple: true },
315
+ identity: { type: 'string' },
316
+ issuer: { type: 'string' },
317
+ untrusted: { type: 'boolean' },
318
+ },
319
+ });
320
+ const archive = positionals[0];
321
+ if (!archive) throw new Error('run: an archive path is required');
322
+
323
+ let res;
324
+ try {
325
+ res = runBundle(archive, {
326
+ roots: values.root ?? [], identity: values.identity, issuer: values.issuer,
327
+ allowUntrusted: values.untrusted, args: theirs,
328
+ });
329
+ } catch (err) {
330
+ if ((err as { code?: string }).code !== 'ERR_BUNDLE_UNTRUSTED') throw err;
331
+ const state = (err as { state?: VerificationState }).state;
332
+ io.err(`error: ${message(err)}`);
333
+ return state ? STATES[state].code : 2;
334
+ }
335
+ if (res.signal) process.kill(process.pid, res.signal);
336
+ return res.status ?? 70;
337
+ }
338
+
339
+ // The audit gate. The review itself needs judgement, so this command does the
340
+ // two mechanical halves around it: say what is about to be reviewed, and refuse
341
+ // to let signing proceed without a clean verdict over exactly these bytes.
342
+ function audit(args: string[], io: Console): number {
343
+ const { values, positionals } = parseArgs({
344
+ args,
345
+ allowPositionals: true,
346
+ options: {
347
+ baseline: { type: 'string', short: 'b' },
348
+ verdict: { type: 'string', short: 'v' },
349
+ note: { type: 'string', short: 'n' },
350
+ check: { type: 'boolean' },
351
+ approve: { type: 'boolean' },
352
+ },
353
+ });
354
+ const bundle = positionals[0];
355
+ if (!bundle) throw new Error('audit: an archive path is required');
356
+ if (values.check && values.approve) throw new Error('audit: --check and --approve are alternatives');
357
+ const options = { bundle, verdict: values.verdict, baseline: values.baseline };
358
+
359
+ if (values.check) {
360
+ const verdict = AUDIT.check(options);
361
+ const notes = (verdict.findings ?? []).length;
362
+ io.err(`* audited: ${verdict.summary ?? 'pass'}`);
363
+ io.err(` ${verdict.reviewed ?? '?'} of ${verdict.members ?? '?'} members reviewed, ` +
364
+ `${notes} finding${notes === 1 ? '' : 's'}`);
365
+ if (verdict.baselineSha256) io.err(` as a diff against ${verdict.baselineSha256.slice(0, 16)}…`);
366
+ return 0;
367
+ }
368
+
369
+ if (values.approve) {
370
+ const verdict = AUDIT.approve({ ...options, note: values.note });
371
+ io.err(`* recorded a pass over ${verdict.sha256!.slice(0, 16)}… in ${AUDIT.verdictPath(bundle, values.verdict)}`);
372
+ return 0;
373
+ }
374
+
375
+ const found = AUDIT.prepare(options);
376
+ io.out(`${found.bundle}: ${STATES[found.state as VerificationState]?.label ?? found.state}, ${found.members.length} members`);
377
+ io.out(` sha256: ${found.sha256}`);
378
+ if (found.state === 'unsigned') {
379
+ io.out(' unsigned, as an archive that has not been signed yet should be');
380
+ }
381
+ if (found.baseline) {
382
+ io.out(` against ${found.baseline.path} (${found.baseline.sha256.slice(0, 16)}…)`);
383
+ io.out(` ${found.baseline.added.length} added, ${found.baseline.removed.length} removed, ` +
384
+ `${found.baseline.carried} carried over`);
385
+ for (const name of found.baseline.added.slice(0, 10)) io.out(` + ${name}`);
386
+ for (const name of found.baseline.removed.slice(0, 10)) io.out(` - ${name}`);
387
+ } else {
388
+ io.out(' no baseline — the review is of everything, not a diff');
389
+ }
390
+
391
+ io.err('');
392
+ io.err('* review it, then record the verdict:');
393
+ io.err(` BUNDLE_AUDIT_VERDICT=${found.verdict} \\`);
394
+ io.err(found.baseline
395
+ ? ` claude "/audit-bundle ${bundle} against ${found.baseline.path}"`
396
+ : ` claude "/audit-bundle ${bundle}"`);
397
+ io.err(' or, having read it yourself:');
398
+ io.err(` bundle audit --approve --note '<what you checked>' ${bundle}`);
399
+ io.err("* then gate signing on it: bundle audit --check ... && bundle sign ...");
400
+ return 0;
401
+ }
402
+
403
+ // Wrap an archive in a node runtime that verifies itself before running it.
404
+ // The signing half is the same as `sign` — the finished executable is one
405
+ // signed file whose hash covers the runtime, the verifier and the application
406
+ // alike, which is what lets it check itself with something inside itself.
407
+ async function sea(args: string[], io: Console): Promise<number> {
408
+ const { values, positionals } = parseArgs({
409
+ args,
410
+ allowPositionals: true,
411
+ options: {
412
+ output: { type: 'string', short: 'o' },
413
+ node: { type: 'string' },
414
+ base: { type: 'string' },
415
+ sigstore: { type: 'boolean', default: true },
416
+ untrusted: { type: 'boolean' },
417
+ root: { type: 'string', short: 'r', multiple: true },
418
+ identity: { type: 'string' },
419
+ issuer: { type: 'string' },
420
+ key: { type: 'string', short: 'k' },
421
+ chain: { type: 'string', short: 'c' },
422
+ hash: { type: 'string', default: 'sha256' },
423
+ sign: { type: 'string', default: 'sha256' },
424
+ flow: { type: 'string', default: 'auto' },
425
+ token: { type: 'string' },
426
+ 'oidc-issuer': { type: 'string' },
427
+ connector: { type: 'string' },
428
+ fulcio: { type: 'string' },
429
+ rekor: { type: 'string' },
430
+ tsa: { type: 'string' },
431
+ },
432
+ });
433
+ const app = positionals[0];
434
+ if (!values.output) throw new Error('sea: --output is required');
435
+ if (Boolean(values.key) !== Boolean(values.chain)) throw new Error('sea: --key and --chain must be given together');
436
+
437
+ const SEA = await import('./sea.ts');
438
+ // A policy baked into a runtime is the last word: it would be no policy at
439
+ // all if the command line could drop it. Nothing baked, nothing to seal —
440
+ // that runtime takes its policy from flags and the environment, the way
441
+ // `bundle run` does.
442
+ const bootstrap = {
443
+ roots: values.root,
444
+ identity: values.identity,
445
+ issuer: values.issuer,
446
+ allowUntrusted: values.untrusted,
447
+ sealed: Boolean(values.root?.length || values.identity || values.issuer),
448
+ };
449
+
450
+ if (!app) {
451
+ if (values.key || values.chain) {
452
+ throw new Error('sea: signing options need an archive to sign — a verifying node carries none');
453
+ }
454
+ if (values.base) throw new Error('sea: --base reuses a runtime; without an archive there is nothing to add to it');
455
+ io.err('* building a verifying node (node runtime + verifier, no application)');
456
+ const built = await SEA.createSeaBase({
457
+ output: values.output,
458
+ node: values.node,
459
+ sigstore: values.sigstore,
460
+ bootstrap,
461
+ });
462
+ io.err(`* wrote ${built.output} (${built.size} bytes, ${built.verifier.length} verifier members)`);
463
+ io.err(`* run an archive with it: ${values.output} <archive> [args...]`);
464
+ return 0;
465
+ }
466
+
467
+ const signer = await chooseSigner(values, io);
468
+ const res = await SEA.buildSea({
469
+ app,
470
+ output: values.output,
471
+ node: values.node,
472
+ base: values.base,
473
+ sigstore: values.sigstore,
474
+ signer,
475
+ hashAlg: values.hash,
476
+ signAlg: values.sign,
477
+ bootstrap,
478
+ log: io.err,
479
+ });
480
+ if (res.output) io.err(`* wrote ${res.output} (${res.size} bytes)`);
481
+ return 0;
482
+ }
483
+
484
+ // Refresh the sigstore trust root. Verification is deliberately offline — it
485
+ // will not reach for the network to decide whether to mount something — so the
486
+ // trust material has to be fetched by an explicit step like this one. It comes
487
+ // over TUF, which is signed metadata with its own root of trust rather than a
488
+ // plain download.
489
+ async function trust(args: string[], io: Console): Promise<number> {
490
+ const { values } = parseArgs({ args, options: { mirror: { type: 'string' } } });
491
+ const SIGSTORE = await import('./sigstore.ts');
492
+ const path = await SIGSTORE.refreshTrustedRoot(values.mirror ? { mirror: values.mirror } : {});
493
+ io.out(`sigstore trust root refreshed: ${path}`);
494
+ return 0;
495
+ }
496
+
497
+ // Install the auditing skill into a project, so whoever is about to run an
498
+ // archive has the review procedure to hand rather than having to find it here.
499
+ function skill(args: string[], io: Console): number {
500
+ const { values, positionals } = parseArgs({
501
+ args,
502
+ allowPositionals: true,
503
+ options: {
504
+ dir: { type: 'string', short: 'd' },
505
+ force: { type: 'boolean', short: 'f' },
506
+ list: { type: 'boolean', short: 'l' },
507
+ },
508
+ });
509
+
510
+ const available = SKILLS.skills();
511
+ if (values.list) {
512
+ if (!available.length) io.out('this package carries no skills');
513
+ for (const entry of available) io.out(`${entry.name}\n ${entry.description}`);
514
+ return 0;
515
+ }
516
+
517
+ const names = positionals.length ? positionals : available.map((entry) => entry.name);
518
+ if (!names.length) throw new Error('skill: this package carries no skills to install');
519
+
520
+ for (const name of names) {
521
+ const res = SKILLS.install(name, { dir: values.dir, force: values.force });
522
+ for (const file of res.written) io.err(`+ ${PATH.relative(process.cwd(), file)}`);
523
+ for (const file of res.skipped) io.err(`= ${PATH.relative(process.cwd(), file)} (already there; --force to overwrite)`);
524
+ io.out(`installed skill '${res.name}' into ${PATH.relative(process.cwd(), res.path) || res.path}`);
525
+ }
526
+ return 0;
527
+ }
528
+
529
+ /** What `sign` and `sea` both accept to decide how the signature is made. */
530
+ interface SignerChoice {
531
+ key?: string | undefined;
532
+ chain?: string | undefined;
533
+ sign?: string | undefined;
534
+ flow?: string | undefined;
535
+ token?: string | undefined;
536
+ 'oidc-issuer'?: string | undefined;
537
+ connector?: string | undefined;
538
+ fulcio?: string | undefined;
539
+ rekor?: string | undefined;
540
+ tsa?: string | undefined;
541
+ }
542
+
543
+ // Two signers, one interface. Either way the certificate has to be in hand
544
+ // before the archive is built, because AUTHORITY.PEM carries it and
545
+ // AUTHORITY.PEM is inside the region the hash covers — so this runs first and
546
+ // the signature itself is made later, over the finished bytes.
547
+ async function chooseSigner(values: SignerChoice, io: Console) {
548
+ if (values.key && values.chain) {
549
+ io.err('* signing against the supplied certificate chain');
550
+ return fileSigner({ key: values.key, chain: values.chain, signAlg: values.sign });
551
+ }
552
+ const SIGSTORE = await import('./sigstore.ts');
553
+ io.err('* signing through sigstore');
554
+ return await SIGSTORE.signer({
555
+ signAlg: values.sign,
556
+ flow: values.flow as 'auto' | 'ci' | 'browser' | 'device' | undefined,
557
+ token: values.token,
558
+ issuer: values['oidc-issuer'],
559
+ connector: values.connector,
560
+ fulcioURL: values.fulcio,
561
+ rekorURL: values.rekor,
562
+ tsaURL: values.tsa,
563
+ log: io.err,
564
+ });
565
+ }
566
+
567
+ function readStdin(): Promise<string> {
568
+ return new Promise((resolve, reject) => {
569
+ let data = '';
570
+ process.stdin.setEncoding('utf-8');
571
+ process.stdin.on('data', (chunk: string) => { data += chunk; })
572
+ .on('end', () => resolve(data))
573
+ .on('error', reject);
574
+ });
575
+ }