@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.
- package/README.md +547 -0
- package/bin/holosystem.mjs +427 -7
- package/docs/native-build-threat-model.md +150 -0
- package/docs/vm-launch-threat-model.md +91 -0
- package/package.json +3 -2
- package/src/index.mjs +37 -0
- package/src/native-build.mjs +970 -0
- package/src/substrate-debian-release.mjs +852 -0
- package/src/substrate-import-debian.mjs +979 -0
- package/src/substrate-import.mjs +578 -0
- package/src/substrate.mjs +789 -0
- package/src/vm-launch.mjs +927 -0
package/bin/holosystem.mjs
CHANGED
|
@@ -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
|
|
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)
|
|
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)
|
|
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)
|
|
218
|
-
|
|
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(
|
|
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)
|
|
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.
|