@holoscript/holosystem 0.2.2 → 0.2.3

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.
@@ -3,10 +3,19 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
 
5
5
  import {
6
+ buildSubstrateClosure,
6
7
  createHoloSystemConfig,
7
8
  discoverConsumptionSurfaceCatalog,
8
9
  discoverSourceLineage,
10
+ importDebianPackageSnapshot,
11
+ importNpmPackageLock,
12
+ inspectNativeBuildSource,
9
13
  inspectHoloSystemConfig,
14
+ inspectVmExecutor,
15
+ inspectVmLaunchAsset,
16
+ runNativeBuild,
17
+ runVmLaunch,
18
+ runWhpxVmLaunch,
10
19
  } from '../src/index.mjs';
11
20
 
12
21
  const CLI_RECEIPT_SCHEMA = 'holoscript.holosystem.cli-receipt.v1';
@@ -18,13 +27,22 @@ Usage:
18
27
  holosystem inspect [file|-] [--json]
19
28
  holosystem catalog --seeds <file> --portfolio <file> --manifest <file> [--lineage <file>] [--active-batches <file>] [--promotions <file>] [--output <file>] [--json]
20
29
  holosystem lineage --portfolio <file> [--concurrency <1-12>] [--output <file>] [--json]
30
+ holosystem substrate-import --lock <package-lock.json> --config <file> [--output <file>] [--force] [--json]
31
+ holosystem substrate-import-debian --status <status> (--packages <Packages> | --sources <json>) --maintainer-scripts <json> --config <file> [--output <file>] [--force] [--json]
32
+ holosystem native-build-source --source <directory> [--json]
33
+ holosystem native-build --plan <file> --source <directory> --executor <file> --artifact-dir <directory> [--output <receipt>] [--force] [--json]
34
+ holosystem vm-executor --runtime <directory> [--json]
35
+ holosystem vm-asset --kind <kernel|initrd> --file <file> [--json]
36
+ holosystem vm-launch --plan <file> --runtime <directory> --kernel <file> --initrd <file> [--output <receipt>] [--force] [--json]
37
+ holosystem vm-launch-whpx --plan <file> --runtime <directory> --kernel <file> --initrd <file> [--output <receipt>] [--force] [--json]
38
+ holosystem substrate --input <file> [--output <file>] [--force] [--json]
21
39
  holosystem --help
22
40
  holosystem --version
23
41
 
24
42
  Defaults:
25
43
  create writes holosystem.config.json and never overwrites it without --force.
26
44
  inspect reads holosystem.config.json. Use - to read JSON from stdin.
27
- catalog and lineage read caller-owned evidence and never read credentials.
45
+ catalog, lineage, substrate imports, and substrate read caller-owned evidence and never read credentials.
28
46
  `;
29
47
 
30
48
  function die(message, { json = false, code = 1 } = {}) {
@@ -143,9 +161,42 @@ function readOptionalJsonFile(path, fallback) {
143
161
  return JSON.parse(readFileSync(resolve(process.cwd(), path), 'utf8').replace(/^\uFEFF/u, ''));
144
162
  }
145
163
 
164
+ function readTextFile(path, label) {
165
+ if (!path) throw new Error(`--${label} is required.`);
166
+ return readFileSync(resolve(process.cwd(), path), 'utf8').replace(/^\uFEFF/u, '');
167
+ }
168
+
169
+ function readDebianSources(path) {
170
+ const manifest = readJsonFile(path, 'sources');
171
+ const entries = Array.isArray(manifest) ? manifest : manifest?.sources;
172
+ if (!Array.isArray(entries) || entries.length === 0) {
173
+ throw new Error('--sources must contain a non-empty JSON array.');
174
+ }
175
+ return entries.map((entry, index) => {
176
+ if (!entry?.packages) {
177
+ throw new Error(`sources[${index}].packages is required.`);
178
+ }
179
+ return {
180
+ packagesIndex: readTextFile(entry.packages, `sources[${index}].packages`),
181
+ repository: entry.repository
182
+ ? {
183
+ ...entry.repository,
184
+ authentication: entry.authentication || entry.repository.authentication,
185
+ }
186
+ : {
187
+ uri: entry.uri,
188
+ packagesIndexDigest: entry.packagesIndexDigest,
189
+ authentication: entry.authentication,
190
+ },
191
+ custody: entry.custody,
192
+ };
193
+ });
194
+ }
195
+
146
196
  function writeJsonOutput(path, value, { force = false } = {}) {
147
197
  const absolute = resolve(process.cwd(), path);
148
- if (existsSync(absolute) && !force) throw new Error(`${path} already exists; use --force to replace it.`);
198
+ if (existsSync(absolute) && !force)
199
+ throw new Error(`${path} already exists; use --force to replace it.`);
149
200
  writeFileSync(absolute, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
150
201
  }
151
202
 
@@ -200,7 +251,8 @@ async function runCatalog(args) {
200
251
  die(error.message, { json: args.includes('--json') });
201
252
  }
202
253
  const { options, positionals } = parsed;
203
- if (positionals.length > 0) die('catalog does not accept positional arguments.', { json: options.json });
254
+ if (positionals.length > 0)
255
+ die('catalog does not accept positional arguments.', { json: options.json });
204
256
  let catalog;
205
257
  try {
206
258
  const seeds = readJsonFile(options.seeds, 'seeds');
@@ -214,8 +266,12 @@ async function runCatalog(args) {
214
266
  portfolio,
215
267
  manifest,
216
268
  lineage,
217
- activeProofBatches: Array.isArray(activeProofBatches) ? activeProofBatches : activeProofBatches.batches,
218
- promotionHistory: Array.isArray(promotionHistory) ? promotionHistory : promotionHistory.promotions,
269
+ activeProofBatches: Array.isArray(activeProofBatches)
270
+ ? activeProofBatches
271
+ : activeProofBatches.batches,
272
+ promotionHistory: Array.isArray(promotionHistory)
273
+ ? promotionHistory
274
+ : promotionHistory.promotions,
219
275
  evidence: {
220
276
  operatingSet: options.manifest,
221
277
  packageAdmission: options.portfolio,
@@ -230,7 +286,9 @@ async function runCatalog(args) {
230
286
  else {
231
287
  process.stdout.write(`Catalog: ${catalog.status}\n`);
232
288
  for (const [id, rail] of Object.entries(catalog.rails)) {
233
- process.stdout.write(`${id}: published=${rail.published} ready=${rail.consumerReady} gaps=${rail.gaps}\n`);
289
+ process.stdout.write(
290
+ `${id}: published=${rail.published} ready=${rail.consumerReady} gaps=${rail.gaps}\n`
291
+ );
234
292
  }
235
293
  if (options.output) process.stdout.write(`Wrote ${options.output}\n`);
236
294
  }
@@ -251,7 +309,8 @@ async function runLineage(args) {
251
309
  die(error.message, { json: args.includes('--json') });
252
310
  }
253
311
  const { options, positionals } = parsed;
254
- if (positionals.length > 0) die('lineage does not accept positional arguments.', { json: options.json });
312
+ if (positionals.length > 0)
313
+ die('lineage does not accept positional arguments.', { json: options.json });
255
314
  let lineage;
256
315
  try {
257
316
  const portfolio = readJsonFile(options.portfolio, 'portfolio');
@@ -273,6 +332,346 @@ async function runLineage(args) {
273
332
  if (lineage.status !== 'complete') process.exitCode = 2;
274
333
  }
275
334
 
335
+ function runSubstrateImport(args) {
336
+ let parsed;
337
+ try {
338
+ parsed = parseArguments(args, {
339
+ lock: 'value',
340
+ config: 'value',
341
+ output: 'value',
342
+ force: 'boolean',
343
+ json: 'boolean',
344
+ });
345
+ } catch (error) {
346
+ die(error.message, { json: args.includes('--json') });
347
+ }
348
+ const { options, positionals } = parsed;
349
+ if (positionals.length > 0) {
350
+ die('substrate-import does not accept positional arguments.', { json: options.json });
351
+ }
352
+
353
+ let imported;
354
+ try {
355
+ const lockfile = readJsonFile(options.lock, 'lock');
356
+ const config = readJsonFile(options.config, 'config');
357
+ imported = importNpmPackageLock({
358
+ lockfile,
359
+ root: config.root,
360
+ verificationPolicy: config.verificationPolicy,
361
+ includeDev: config.includeDev,
362
+ externalCustody: config.externalCustody,
363
+ });
364
+ if (options.output) writeJsonOutput(options.output, imported.input, { force: options.force });
365
+ } catch (error) {
366
+ die(`Cannot import npm substrate: ${error.message}`, {
367
+ json: options.json,
368
+ code: 2,
369
+ });
370
+ }
371
+
372
+ if (options.json) outputJson(imported);
373
+ else {
374
+ process.stdout.write(
375
+ `Substrate import: ${imported.status} components=${imported.summary.components} dependencies=${imported.summary.dependencies} missing-attestations=${imported.summary.missingAttestations}\n`
376
+ );
377
+ for (const item of imported.issues) {
378
+ process.stdout.write(`BLOCK ${item.code} ${item.path}: ${item.message}\n`);
379
+ }
380
+ if (options.output) process.stdout.write(`Wrote ${options.output}\n`);
381
+ }
382
+ if (!imported.importable) process.exitCode = 2;
383
+ }
384
+
385
+ function runDebianSubstrateImport(args) {
386
+ let parsed;
387
+ try {
388
+ parsed = parseArguments(args, {
389
+ status: 'value',
390
+ packages: 'value',
391
+ sources: 'value',
392
+ 'maintainer-scripts': 'value',
393
+ config: 'value',
394
+ output: 'value',
395
+ force: 'boolean',
396
+ json: 'boolean',
397
+ });
398
+ } catch (error) {
399
+ die(error.message, { json: args.includes('--json') });
400
+ }
401
+ const { options, positionals } = parsed;
402
+ if (positionals.length > 0) {
403
+ die('substrate-import-debian does not accept positional arguments.', { json: options.json });
404
+ }
405
+ if (Boolean(options.packages) === Boolean(options.sources)) {
406
+ die('Provide exactly one of --packages or --sources.', { json: options.json });
407
+ }
408
+
409
+ let imported;
410
+ try {
411
+ const status = readTextFile(options.status, 'status');
412
+ const packagesIndex = options.packages ? readTextFile(options.packages, 'packages') : null;
413
+ const sources = options.sources ? readDebianSources(options.sources) : null;
414
+ const maintainerScripts = readJsonFile(options['maintainer-scripts'], 'maintainer-scripts');
415
+ const config = readJsonFile(options.config, 'config');
416
+ imported = importDebianPackageSnapshot({
417
+ status,
418
+ sources,
419
+ packagesIndex,
420
+ maintainerScripts,
421
+ repository: sources ? null : config.repository,
422
+ root: config.root,
423
+ verificationPolicy: config.verificationPolicy,
424
+ externalCustody: config.externalCustody,
425
+ });
426
+ if (options.output) writeJsonOutput(options.output, imported.input, { force: options.force });
427
+ } catch (error) {
428
+ die(`Cannot import Debian substrate: ${error.message}`, {
429
+ json: options.json,
430
+ code: 2,
431
+ });
432
+ }
433
+
434
+ if (options.json) outputJson(imported);
435
+ else {
436
+ process.stdout.write(
437
+ `Debian substrate import: ${imported.status} packages=${imported.summary.installedPackages} dependencies=${imported.summary.dependencies} maintainer-script-packages=${imported.summary.maintainerScriptPackages}\n`
438
+ );
439
+ for (const item of imported.issues) {
440
+ process.stdout.write(`BLOCK ${item.code} ${item.path}: ${item.message}\n`);
441
+ }
442
+ if (options.output) process.stdout.write(`Wrote ${options.output}\n`);
443
+ }
444
+ if (!imported.importable) process.exitCode = 2;
445
+ }
446
+
447
+ function runSubstrate(args) {
448
+ let parsed;
449
+ try {
450
+ parsed = parseArguments(args, {
451
+ input: 'value',
452
+ output: 'value',
453
+ force: 'boolean',
454
+ json: 'boolean',
455
+ });
456
+ } catch (error) {
457
+ die(error.message, { json: args.includes('--json') });
458
+ }
459
+ const { options, positionals } = parsed;
460
+ if (positionals.length > 0) {
461
+ die('substrate does not accept positional arguments.', { json: options.json });
462
+ }
463
+
464
+ let substrate;
465
+ try {
466
+ const input = readJsonFile(options.input, 'input');
467
+ substrate = buildSubstrateClosure(input);
468
+ if (options.output) writeJsonOutput(options.output, substrate, { force: options.force });
469
+ } catch (error) {
470
+ die(`Cannot build substrate closure: ${error.message}`, {
471
+ json: options.json,
472
+ code: 2,
473
+ });
474
+ }
475
+
476
+ if (options.json) outputJson(substrate);
477
+ else {
478
+ process.stdout.write(
479
+ `Substrate: ${substrate.status} components=${substrate.summary.components} dependencies=${substrate.summary.dependencies} external=${substrate.summary.external}\n`
480
+ );
481
+ for (const boundary of substrate.sovereignty.externalBoundaries) {
482
+ process.stdout.write(
483
+ `EXTERNAL ${boundary.id} kind=${boundary.kind} owner=${boundary.owner}\n`
484
+ );
485
+ }
486
+ for (const item of substrate.issues) {
487
+ process.stdout.write(`BLOCK ${item.code} ${item.path}: ${item.message}\n`);
488
+ }
489
+ if (options.output) process.stdout.write(`Wrote ${options.output}\n`);
490
+ }
491
+ if (!substrate.ready) process.exitCode = 2;
492
+ }
493
+
494
+ function runNativeBuildSource(args) {
495
+ let parsed;
496
+ try {
497
+ parsed = parseArguments(args, { source: 'value', json: 'boolean' });
498
+ } catch (error) {
499
+ die(error.message, { json: args.includes('--json') });
500
+ }
501
+ const { options, positionals } = parsed;
502
+ if (positionals.length > 0) {
503
+ die('native-build-source does not accept positional arguments.', { json: options.json });
504
+ }
505
+ const report = inspectNativeBuildSource({ sourceDirectory: options.source });
506
+ if (options.json) outputJson(report);
507
+ else {
508
+ process.stdout.write(
509
+ `Native source: ${report.ready ? 'ready' : 'blocked'} files=${report.summary.files} bytes=${report.summary.bytes}\n`
510
+ );
511
+ if (report.digest) process.stdout.write(`Digest: ${report.digest}\n`);
512
+ for (const item of report.issues) {
513
+ process.stdout.write(`BLOCK ${item.code} ${item.path}: ${item.message}\n`);
514
+ }
515
+ }
516
+ if (!report.ready) process.exitCode = 2;
517
+ }
518
+
519
+ function runNativeBuildCommand(args) {
520
+ let parsed;
521
+ try {
522
+ parsed = parseArguments(args, {
523
+ plan: 'value',
524
+ source: 'value',
525
+ executor: 'value',
526
+ 'artifact-dir': 'value',
527
+ output: 'value',
528
+ force: 'boolean',
529
+ json: 'boolean',
530
+ });
531
+ } catch (error) {
532
+ die(error.message, { json: args.includes('--json') });
533
+ }
534
+ const { options, positionals } = parsed;
535
+ if (positionals.length > 0) {
536
+ die('native-build does not accept positional arguments.', { json: options.json });
537
+ }
538
+
539
+ let nativeBuild;
540
+ try {
541
+ const plan = readJsonFile(options.plan, 'plan');
542
+ if (!options.source) throw new Error('--source is required.');
543
+ if (!options.executor) throw new Error('--executor is required.');
544
+ if (!options['artifact-dir']) throw new Error('--artifact-dir is required.');
545
+ nativeBuild = runNativeBuild({
546
+ plan,
547
+ sourceDirectory: options.source,
548
+ outputDirectory: options['artifact-dir'],
549
+ executorPath: options.executor,
550
+ });
551
+ if (options.output) writeJsonOutput(options.output, nativeBuild, { force: options.force });
552
+ } catch (error) {
553
+ die(`Cannot run native build: ${error.message}`, { json: options.json, code: 2 });
554
+ }
555
+
556
+ if (options.json) outputJson(nativeBuild);
557
+ else {
558
+ process.stdout.write(
559
+ `Native build: ${nativeBuild.status} reproducible=${nativeBuild.reproducible ? 'yes' : 'no'} verified=${nativeBuild.verified ? 'yes' : 'no'}\n`
560
+ );
561
+ if (nativeBuild.output.digest) {
562
+ process.stdout.write(`Artifact: ${nativeBuild.output.path} ${nativeBuild.output.digest}\n`);
563
+ }
564
+ for (const item of nativeBuild.issues) {
565
+ process.stdout.write(`BLOCK ${item.code} ${item.path}: ${item.message}\n`);
566
+ }
567
+ if (options.output) process.stdout.write(`Wrote ${options.output}\n`);
568
+ }
569
+ if (!nativeBuild.verified) process.exitCode = 2;
570
+ }
571
+
572
+ function runVmExecutor(args) {
573
+ let parsed;
574
+ try {
575
+ parsed = parseArguments(args, { runtime: 'value', json: 'boolean' });
576
+ } catch (error) {
577
+ die(error.message, { json: args.includes('--json') });
578
+ }
579
+ const { options, positionals } = parsed;
580
+ if (positionals.length > 0) {
581
+ die('vm-executor does not accept positional arguments.', { json: options.json });
582
+ }
583
+ const report = inspectVmExecutor({ executorDirectory: options.runtime });
584
+ if (options.json) outputJson(report);
585
+ else {
586
+ process.stdout.write(
587
+ `VM executor: ${report.ready ? 'ready' : 'blocked'} files=${report.summary.files} bytes=${report.summary.bytes}\n`
588
+ );
589
+ if (report.digest) process.stdout.write(`Digest: ${report.digest}\n`);
590
+ for (const item of report.issues) {
591
+ process.stdout.write(`BLOCK ${item.code} ${item.path}: ${item.message}\n`);
592
+ }
593
+ }
594
+ if (!report.ready) process.exitCode = 2;
595
+ }
596
+
597
+ function runVmAsset(args) {
598
+ let parsed;
599
+ try {
600
+ parsed = parseArguments(args, { kind: 'value', file: 'value', json: 'boolean' });
601
+ } catch (error) {
602
+ die(error.message, { json: args.includes('--json') });
603
+ }
604
+ const { options, positionals } = parsed;
605
+ if (positionals.length > 0) {
606
+ die('vm-asset does not accept positional arguments.', { json: options.json });
607
+ }
608
+ const report = inspectVmLaunchAsset({ assetPath: options.file, kind: options.kind });
609
+ if (options.json) outputJson(report);
610
+ else {
611
+ process.stdout.write(
612
+ `VM asset: ${report.ready ? 'ready' : 'blocked'} kind=${report.kind || 'invalid'} bytes=${report.bytes ?? 0}\n`
613
+ );
614
+ if (report.digest) process.stdout.write(`Digest: ${report.digest}\n`);
615
+ for (const item of report.issues) {
616
+ process.stdout.write(`BLOCK ${item.code} ${item.path}: ${item.message}\n`);
617
+ }
618
+ }
619
+ if (!report.ready) process.exitCode = 2;
620
+ }
621
+
622
+ function runVmLaunchCommand(args, { launcher = runVmLaunch, commandName = 'vm-launch' } = {}) {
623
+ let parsed;
624
+ try {
625
+ parsed = parseArguments(args, {
626
+ plan: 'value',
627
+ runtime: 'value',
628
+ kernel: 'value',
629
+ initrd: 'value',
630
+ output: 'value',
631
+ force: 'boolean',
632
+ json: 'boolean',
633
+ });
634
+ } catch (error) {
635
+ die(error.message, { json: args.includes('--json') });
636
+ }
637
+ const { options, positionals } = parsed;
638
+ if (positionals.length > 0) {
639
+ die(`${commandName} does not accept positional arguments.`, { json: options.json });
640
+ }
641
+
642
+ let launch;
643
+ try {
644
+ const plan = readJsonFile(options.plan, 'plan');
645
+ if (!options.runtime) throw new Error('--runtime is required.');
646
+ if (!options.kernel) throw new Error('--kernel is required.');
647
+ if (!options.initrd) throw new Error('--initrd is required.');
648
+ launch = launcher({
649
+ plan,
650
+ executorDirectory: options.runtime,
651
+ kernelPath: options.kernel,
652
+ initrdPath: options.initrd,
653
+ });
654
+ if (options.output) writeJsonOutput(options.output, launch, { force: options.force });
655
+ } catch (error) {
656
+ die(`Cannot run VM launch: ${error.message}`, { json: options.json, code: 2 });
657
+ }
658
+
659
+ if (options.json) outputJson(launch);
660
+ else {
661
+ process.stdout.write(
662
+ `VM launch: ${launch.status} deterministic=${launch.deterministic ? 'yes' : 'no'} hardware-backed=${launch.hardwareBacked ? 'yes' : 'no'} host-isolated=${launch.isolation?.verified ? 'yes' : 'no'}\n`
663
+ );
664
+ if (launch.measurementDigest) {
665
+ process.stdout.write(`Measurement: ${launch.measurementDigest}\n`);
666
+ }
667
+ for (const item of launch.issues) {
668
+ process.stdout.write(`BLOCK ${item.code} ${item.path}: ${item.message}\n`);
669
+ }
670
+ if (options.output) process.stdout.write(`Wrote ${options.output}\n`);
671
+ }
672
+ if (!launch.verified) process.exitCode = 2;
673
+ }
674
+
276
675
  const argv = process.argv.slice(2);
277
676
  const command = argv[0];
278
677
  if (!command || command === '--help' || command === '-h' || command === 'help') {
@@ -288,6 +687,27 @@ if (!command || command === '--help' || command === '-h' || command === 'help')
288
687
  await runCatalog(argv.slice(1));
289
688
  } else if (command === 'lineage') {
290
689
  await runLineage(argv.slice(1));
690
+ } else if (command === 'substrate-import') {
691
+ runSubstrateImport(argv.slice(1));
692
+ } else if (command === 'substrate-import-debian') {
693
+ runDebianSubstrateImport(argv.slice(1));
694
+ } else if (command === 'native-build-source') {
695
+ runNativeBuildSource(argv.slice(1));
696
+ } else if (command === 'native-build') {
697
+ runNativeBuildCommand(argv.slice(1));
698
+ } else if (command === 'vm-executor') {
699
+ runVmExecutor(argv.slice(1));
700
+ } else if (command === 'vm-asset') {
701
+ runVmAsset(argv.slice(1));
702
+ } else if (command === 'vm-launch') {
703
+ runVmLaunchCommand(argv.slice(1));
704
+ } else if (command === 'vm-launch-whpx') {
705
+ runVmLaunchCommand(argv.slice(1), {
706
+ launcher: runWhpxVmLaunch,
707
+ commandName: 'vm-launch-whpx',
708
+ });
709
+ } else if (command === 'substrate') {
710
+ runSubstrate(argv.slice(1));
291
711
  } else {
292
712
  die(`Unknown command ${command}. Run holosystem --help.`);
293
713
  }
@@ -0,0 +1,150 @@
1
+ # Native-build threat model
2
+
3
+ This threat model covers `holoscript.holosystem.native-build-plan.v1` and the
4
+ Docker executor in `@holoscript/holosystem`. Its security objective is narrow:
5
+ compile a caller-pinned source snapshot into one reproducible Linux AMD64 ELF
6
+ artifact without giving the plan a general process-execution language.
7
+
8
+ It does not establish that a compiler image is benevolent, that Docker or the
9
+ host kernel is uncompromised, or that two runs under one image are independent
10
+ rebuilds. Those claims require separately governed trust roots and the existing
11
+ signed rebuild-attestation gate.
12
+
13
+ ## Trust assumptions
14
+
15
+ - The caller establishes the expected source, Docker executable, compiler-image,
16
+ and final artifact digests through a control plane outside the build plan.
17
+ - The host filesystem, Docker daemon, kernel, CPU, and cryptographic primitives
18
+ are trusted to execute their documented behavior.
19
+ - An immutable OCI manifest digest identifies stable image bytes; it does not
20
+ prove publisher identity or compiler correctness.
21
+ - The independent builder reviews the native receipt before signing the existing
22
+ rebuild-attestation payload. A receipt hash is content integrity, not a
23
+ signature or organizational-independence proof.
24
+
25
+ ## Adversary classes
26
+
27
+ | Adversary | Capability | Goal | Measurable success |
28
+ | -------------------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |
29
+ | Plan-language attacker | Controls every JSON plan field | Reach a shell, arbitrary executable, network, privilege, host mount, or free-form compiler flag | A supplied field changes process identity or weakens a fixed Docker policy |
30
+ | Source-race attacker | Mutates caller source while a build starts | Compile bytes different from the pinned source digest | Mounted snapshot digest differs from the plan while the receipt is `verified` |
31
+ | Executor-substitution attacker | Replaces or redirects the Docker executable | Run a different host program under an accepted plan | Process launch occurs after executable type or digest mismatch |
32
+ | Output-smuggling attacker | Controls compiler behavior inside the container | Export undeclared files, links, oversized data, or the wrong machine format | A non-declared or non-AMD64-ELF artifact reaches the caller output directory |
33
+ | Reproducibility attacker | Makes clean builds diverge | Obtain coverage for unstable or environment-dependent output | The receipt includes `native-build` when two run digests differ |
34
+ | Diagnostic-exfiltration attacker | Places secrets or host paths in compiler output | Copy sensitive diagnostics into a portable receipt | Receipt JSON contains raw stdout, stderr, source contents, or operational paths |
35
+ | Compiler-image attacker | Publishes a malicious but digest-pinned image | Exploit Docker/kernel or emit reproducible malware | Outside the runner's prevention boundary; detected only by independent rebuild and image-governance controls |
36
+ | Host-control attacker | Controls Docker daemon, host kernel, or same-user filesystem namespace | Forge all observations or race executable/output paths | Explicitly out of scope for this receipt; requires measured host/boot custody |
37
+
38
+ ## Attack specifications and defenses
39
+
40
+ ### A1: shell and option injection
41
+
42
+ Precondition: the attacker can submit a plan. They add `command`, `args`, an
43
+ image tag, a traversal path, a compiler plugin flag, or a weaker network/user
44
+ policy. Success means any attacker text is forwarded as executable or shell
45
+ input.
46
+
47
+ Defense: the plan rejects unknown fields, exposes only GCC/C11 plus a small
48
+ optimization enum, and generates the complete Docker and compiler argv. The
49
+ production API binds directly to `spawnSync`; its test process adapter is not
50
+ exported from the package root. Docker receives `shell: false`, an overridden
51
+ `/usr/local/bin/gcc` entrypoint, no network, no IPC namespace sharing, a
52
+ read-only root and source, no capabilities, no-new-privileges, and a non-root
53
+ uid/gid.
54
+
55
+ Regression evidence: `inspects a closed declarative build vocabulary`, `does
56
+ not publish process-runner injection through the package API`.
57
+
58
+ ### A2: source time-of-check/time-of-use swap
59
+
60
+ Precondition: the attacker can mutate the source directory during launch. They
61
+ replace a translation unit or introduce a link after the initial digest scan.
62
+ Success means different bytes reach GCC while the plan's source digest remains
63
+ accepted.
64
+
65
+ Defense: the runner rejects links and special files, copies only the inspected
66
+ manifest into a private temporary snapshot, re-hashes that snapshot, and mounts
67
+ the snapshot read-only for both builds. A mismatch blocks before Docker starts.
68
+
69
+ Regression evidence: source digest and snapshot validation are exercised by
70
+ every successful and source-mismatch build test.
71
+
72
+ ### A3: executor replacement
73
+
74
+ Precondition: the attacker changes the host executable or points at a link.
75
+ Success means the supplied program starts before its identity is rejected.
76
+
77
+ Defense: the operational executor must be a regular non-link file and match the
78
+ plan's SHA-256 digest. The runner then copies it into its private custody
79
+ directory, re-hashes that copy, and launches only the copy. A later replacement
80
+ of the caller's executable path cannot change either rebuild. Operational paths
81
+ never enter the receipt.
82
+
83
+ Regression evidence: `blocks an executor substitution before process launch`,
84
+ `launches a private executor snapshot immune to later path substitution`.
85
+
86
+ ### A4: artifact smuggling
87
+
88
+ Precondition: the compiler image is malicious or the translation unit triggers
89
+ unexpected compiler behavior. It creates extra files, a symbolic link, a large
90
+ artifact, or a binary for another architecture. Success means any such output
91
+ is retained as verified.
92
+
93
+ Defense: each run gets a fresh output directory. The runner accepts exactly the
94
+ declared relative path, rejects links and special files, caps artifact size,
95
+ parses the ELF class/endian/machine header, and copies output into a newly
96
+ created caller directory only after all checks pass.
97
+
98
+ Regression evidence: `blocks non-reproducible and unexpected build outputs`,
99
+ `blocks a reproducible artifact for the wrong machine target`, `requires a new
100
+ output directory and redacts compiler diagnostics`.
101
+
102
+ ### A5: reproducible but unpinned output
103
+
104
+ Precondition: a compromised compiler emits the same malicious artifact twice.
105
+ Success means same-toolchain reproducibility is treated as published-artifact or
106
+ independent-rebuild trust.
107
+
108
+ Defense: the first reproducible build is only `artifact-pin-required` and does
109
+ not include the `native-build` layer. A second plan must carry an externally
110
+ selected `expectedArtifactDigest`. Even a verified native receipt is only input
111
+ to the existing signed rebuild payload; the substrate gate still demands
112
+ caller-owned independent trust roots.
113
+
114
+ Regression evidence: `requires an artifact pin before claiming native-build
115
+ coverage`, `turns a verified native result into the existing signed rebuild
116
+ payload`.
117
+
118
+ ### A6: diagnostic exfiltration
119
+
120
+ Precondition: source or compiler emits secrets and absolute paths on stdout or
121
+ stderr. Success means those bytes appear in portable JSON.
122
+
123
+ Defense: logs are bounded and represented only by byte counts and SHA-256
124
+ digests. Errors use fixed messages and relative schema paths. Source content,
125
+ executor path, source path, artifact directory, stdout, and stderr are absent.
126
+
127
+ Regression evidence: `requires a new output directory and redacts compiler
128
+ diagnostics` plus the receipt path-redaction assertions in the successful build
129
+ test.
130
+
131
+ ## Resource and isolation contract
132
+
133
+ The runner fixes `--pull never`, `--platform linux/amd64`, `--network none`,
134
+ `--ipc none`, `--read-only`, `--cap-drop ALL`, `no-new-privileges`, uid/gid
135
+ 65534, read-only source, bounded tmpfs, PIDs, memory plus swap, CPU quota, log
136
+ buffer, and wall-clock timeout. These settings reduce container reach; they are
137
+ not a proof against a Docker, kernel, CPU, or compiler exploit.
138
+
139
+ ## Residual work
140
+
141
+ The next trust layers are intentionally separate contracts:
142
+
143
+ 1. independently governed compiler-image and builder signing roots;
144
+ 2. VM/hypervisor isolation and measured launch receipts;
145
+ 3. UEFI, Secure Boot, TPM, bootloader, firmware, and microcode measurements;
146
+ 4. ISA-specific code generation and bare-metal runtime admission;
147
+ 5. physical-device identity, hardware telemetry, and recovery receipts.
148
+
149
+ Combining those into a broader `command` field would erase the security model.
150
+ Each layer must add a typed target and a verifier that can fail closed.