bootproof 0.3.0 → 0.4.0

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 (70) hide show
  1. package/README.md +840 -152
  2. package/dist/agent-plan.d.ts +44 -0
  3. package/dist/agent-plan.js +826 -0
  4. package/dist/agent-run.d.ts +117 -0
  5. package/dist/agent-run.js +459 -0
  6. package/dist/ai-repair.d.ts +58 -0
  7. package/dist/ai-repair.js +380 -0
  8. package/dist/cli.js +730 -46
  9. package/dist/diagnosis.js +101 -16
  10. package/dist/diff.d.ts +29 -0
  11. package/dist/diff.js +569 -0
  12. package/dist/exec.d.ts +30 -2
  13. package/dist/exec.js +329 -51
  14. package/dist/external-health.d.ts +16 -0
  15. package/dist/external-health.js +214 -0
  16. package/dist/infer.js +238 -39
  17. package/dist/plan.js +2 -0
  18. package/dist/proof.d.ts +78 -2
  19. package/dist/proof.js +265 -12
  20. package/dist/receipt.d.ts +52 -0
  21. package/dist/receipt.js +356 -0
  22. package/dist/redact.d.ts +4 -0
  23. package/dist/redact.js +86 -2
  24. package/dist/registry.d.ts +82 -30
  25. package/dist/registry.js +355 -53
  26. package/dist/remote.js +3 -3
  27. package/dist/repair-playbooks.d.ts +24 -0
  28. package/dist/repair-playbooks.js +593 -0
  29. package/dist/repair-safety.d.ts +130 -0
  30. package/dist/repair-safety.js +766 -0
  31. package/dist/repair.d.ts +43 -11
  32. package/dist/repair.js +716 -7
  33. package/dist/run.d.ts +3 -0
  34. package/dist/run.js +218 -41
  35. package/dist/sbom.d.ts +22 -0
  36. package/dist/sbom.js +99 -0
  37. package/dist/taxonomy.d.ts +8 -3
  38. package/dist/taxonomy.js +404 -8
  39. package/dist/types.d.ts +40 -1
  40. package/docs/AGENT_IN_THE_LOOP.md +171 -0
  41. package/docs/AGENT_RUN_RECEIPTS.md +38 -0
  42. package/docs/CI_ACTION.md +67 -2
  43. package/docs/DETERMINISTIC_REPAIR_SAFETY_MODEL.md +705 -0
  44. package/docs/FAILURE_TAXONOMY.md +28 -1
  45. package/docs/HONESTY_CONTRACT.md +34 -12
  46. package/docs/LAUNCH_PLAYBOOK.md +232 -0
  47. package/docs/REAL_WORLD_FIXTURES.md +105 -0
  48. package/docs/REGISTRY.md +48 -28
  49. package/docs/REPAIR_RECEIPT.md +54 -8
  50. package/docs/agent-loop-gap-analysis.md +188 -0
  51. package/docs/examples/registry-seeds/advertised-port-mismatch.json +28 -0
  52. package/docs/examples/registry-seeds/airbyte-abctl-external-orchestrator.json +36 -0
  53. package/docs/examples/registry-seeds/go-ollama-service.json +36 -0
  54. package/docs/examples/registry-seeds/laravel-vite-sqlite.json +36 -0
  55. package/docs/examples/registry-seeds/monorepo-ambiguous-health.json +29 -0
  56. package/docs/examples/registry-seeds/php-composer.json +33 -0
  57. package/docs/examples/registry-seeds/rails-bundler.json +32 -0
  58. package/docs/examples/registry-seeds/sentry-devenv-direnv.json +41 -0
  59. package/docs/schemas/action-verdict-v1.schema.json +64 -0
  60. package/docs/schemas/agent-plan-v1.schema.json +148 -0
  61. package/docs/schemas/agent-run-receipts-v1.schema.json +192 -0
  62. package/docs/schemas/ai-repair-suggestion-v1.schema.json +70 -0
  63. package/docs/schemas/ci-context-v1.schema.json +63 -0
  64. package/docs/schemas/diff-result-v1.schema.json +66 -0
  65. package/docs/schemas/federated-receipt-v1.schema.json +51 -0
  66. package/docs/schemas/registry-entry-v1.schema.json +95 -0
  67. package/docs/schemas/registry-seed-example-v1.schema.json +102 -0
  68. package/docs/schemas/repair-action-v1.schema.json +136 -0
  69. package/docs/schemas/repair-receipt-v1.schema.json +221 -0
  70. package/package.json +10 -6
package/dist/cli.js CHANGED
@@ -1,16 +1,24 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs";
3
3
  import path from "node:path";
4
+ import * as readline from "node:readline/promises";
4
5
  import { inferRepo } from "./infer.js";
5
6
  import { buildPlan, composeFileFor, envExampleFor } from "./plan.js";
6
7
  import { up } from "./run.js";
7
- import { verifySignature, attestationPath, TOOL_ID } from "./proof.js";
8
+ import { attestationPath, currentGitHead, evaluateAttestationSignature, trustSigner, rotateSigner, writeAttestation, TOOL_ID, } from "./proof.js";
9
+ import { emitLivingReceipt } from "./receipt.js";
8
10
  import { pollHealth } from "./exec.js";
9
- import { buildRegistryEntry, verifyRegistryEntry, writeRegistryEntry, registryEntryPath } from "./registry.js";
11
+ import { buildExternalHealthAttestation } from "./external-health.js";
12
+ import { agentPlanPath, buildAgentPlan, writeAgentPlan, } from "./agent-plan.js";
13
+ import { agentRunDirectory, appendAgentVerification, createAgentRun, explainAgentRun, latestAgentRunId, readAgentRun, } from "./agent-run.js";
14
+ import { buildFederatedReceipt, buildRegistryEntry, currentGitBranch, evaluateRegistryEntrySignature, writeFederatedReceipt, writeRegistryEntry, registryEntryPath, } from "./registry.js";
10
15
  import { normalizeDockerBindPath, detectHostPlatform } from "./platform.js";
11
16
  import { diagnoseFailure } from "./diagnosis.js";
12
17
  import { cloneRemoteTarget, isRemoteTarget, managedRemoteSource } from "./remote.js";
13
- import { applyVerifiedRepair, repairRepo, verifyRepairReceipt, } from "./repair.js";
18
+ import { applyVerifiedRepair, executeAiSuggestedRepair, evaluateRepairReceiptSignature, latestDeterministicRepairCandidate, latestFailedAttestation, repairRepo, registeredRemediationsFor, verifyRepairReceipt, } from "./repair.js";
19
+ import { requestAiRepairSuggestion, resolveAiProvider, } from "./ai-repair.js";
20
+ import { exportSbom } from "./sbom.js";
21
+ import { diffRefs } from "./diff.js";
14
22
  let GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RED = "\x1b[31m", DIM = "\x1b[2m", BOLD = "\x1b[1m", RESET = "\x1b[0m";
15
23
  const ok = (s) => console.log(`${GREEN}\u2713 ${s}${RESET}`);
16
24
  const would = (s) => console.log(`${DIM}\u25cb would: ${s}${RESET}`);
@@ -18,7 +26,23 @@ const warn = (s) => console.log(`${YELLOW}! ${s}${RESET}`);
18
26
  const bad = (s) => console.log(`${RED}\u2717 ${s}${RESET}`);
19
27
  const disableColor = () => { GREEN = ""; YELLOW = ""; RED = ""; DIM = ""; BOLD = ""; RESET = ""; };
20
28
  const portableRelative = (from, to) => path.relative(from, to).replace(/\\/g, "/");
21
- const COMMANDS = ["up", "fix", "apply-repair", "analyze", "plan", "verify", "explain", "attest", "help", "version", "--help", "-h", "--version"];
29
+ const COMMANDS = ["up", "verify-url", "plan-agent", "explain-run", "fix", "apply-repair", "diff", "analyze", "plan", "verify", "explain", "attest", "registry", "export-sbom", "rotate-keys", "help", "version", "--help", "-h", "--version"];
30
+ const SUPPORTED_FLAGS = {
31
+ diff: new Set(["base", "head", "json", "ci"]),
32
+ analyze: new Set(["workspace", "json", "ci"]),
33
+ plan: new Set(["workspace", "provider", "ci"]),
34
+ "plan-agent": new Set(["json", "ci"]),
35
+ "explain-run": new Set(["ci"]),
36
+ "apply-repair": new Set(["receipt", "dry-run", "json", "ci"]),
37
+ fix: new Set(["provider", "unsafe-local", "port", "timeout", "dry-run", "json", "ci", "ai"]),
38
+ up: new Set(["provider", "unsafe-local", "install", "workspace", "port", "timeout", "dry-run", "json", "ci", "command", "external-health", "receipt", "health-path", "ci-oidc"]),
39
+ "verify-url": new Set(["timeout", "json", "ci"]),
40
+ verify: new Set(["ci", "trust-signer", "require-known-signer", "strict"]),
41
+ attest: new Set(["ci", "trust-signer", "require-known-signer", "strict"]),
42
+ registry: new Set(["mode", "federated", "ci"]),
43
+ "export-sbom": new Set(["format", "json", "ci"]),
44
+ "rotate-keys": new Set(["repo", "resign", "no-backup", "json", "ci"]),
45
+ };
22
46
  void normalizeDockerBindPath;
23
47
  void detectHostPlatform; // exported surface, used by docker provider work in progress
24
48
  if (process.env.NO_COLOR !== undefined)
@@ -50,34 +74,66 @@ Usage:
50
74
  bootproof analyze <path|git-url> [--workspace dir] [--json]
51
75
  inspect a repo, show evidence-based inference
52
76
  bootproof plan <path|git-url> [--workspace dir] show the run plan and files that WOULD be generated
77
+ bootproof plan-agent <path|git-url> [--json] write a risk-classified plan; execute nothing
78
+ bootproof explain-run <run-id> verify and explain a local agent receipt chain
53
79
  bootproof up <path|git-url> [options] execute the plan, verify localhost, write signed proof
80
+ bootproof verify-url <url> [--timeout ms] verify an externally managed HTTP service
54
81
  bootproof fix <path|git-url> [options] test a deterministic repair in a sandbox
82
+ bootproof fix <path|git-url> --ai optionally request a BYOK AI suggestion after deterministic refusal
55
83
  bootproof apply-repair <path> [--receipt proof.json] explicitly apply a signature-valid verified file change
84
+ bootproof diff [--base ref] [--head ref] [--json] statically detect infrastructure drift between Git refs
56
85
  bootproof verify <path|proof.json> validate an attestation or repair-receipt signature
57
86
  bootproof explain <proof.json> explain an attestation or repair receipt
58
- bootproof attest export <path> redacted, re-signed shareable registry entry (never uploads)
59
- bootproof attest check <path> verify a registry entry signature
87
+ bootproof registry export <path> explicitly write a redacted local registry export
88
+ bootproof registry export <path> --federated explicitly write a public-candidate receipt
89
+ bootproof attest export <path> compatibility alias for local registry export
90
+ bootproof attest check <path> verify a registry entry signature
91
+ bootproof export-sbom <path> [--format cyclonedx-json] export a CycloneDX SBOM from package-lock.json
92
+ bootproof rotate-keys [--repo <path>] [--resign] rotate the local ed25519 signing key (back up old, generate new)
60
93
  bootproof version
61
94
 
62
95
  Options for up:
63
96
  --provider docker|local execution provider (default docker)
64
- --unsafe-local required acknowledgement for --provider local
97
+ docker: only works for source-built Compose applications; refuses host commands for plain repos
98
+ local: runs install/start commands directly on your host machine (requires --unsafe-local)
99
+ --unsafe-local required consent for --provider local; confirms you accept host execution
65
100
  --install run the dependency install step (off by default)
66
101
  --workspace <dir> pick a monorepo workspace
102
+ --command <command> override the inferred application start command
103
+ --external-health <url> verify an externally managed service; do not start the app
67
104
  --port <n> override inferred port
105
+ --health-path <path> override inferred health endpoint path (e.g. /health, /healthz)
68
106
  --timeout <ms> health verification timeout (default 60000)
69
107
  --dry-run show what would happen; executes nothing, writes nothing
70
108
  --json one bootproof/result/v1 JSON object on stdout
71
109
  --ci no prompts, colours, or interactive UI; fail closed
110
+ --receipt write a self-verifying Living Receipt HTML to .bootproof/living-receipt.html
111
+ --ci-oidc fetch GitHub Actions OIDC token and sign at ci_oidc_signed trust level (requires permissions: id-token: write)
72
112
 
73
113
  Options for fix:
74
- --provider docker|local execution provider (default docker)
75
- --unsafe-local required acknowledgement for local sandbox execution
114
+ --provider docker|local execution provider (default docker; docker only works for Compose apps)
115
+ --unsafe-local required consent for --provider local; confirms you accept host execution
76
116
  --port <n> override inferred application port
77
117
  --timeout <ms> before/after health timeout (default 60000)
118
+ --ai optional BYOK suggestion after no deterministic repair is known
78
119
  --dry-run execute nothing, write nothing, produce no repair proof
79
120
  --json one bootproof/repair-result/v1 object on stdout
80
121
 
122
+ Command repairs show the exact command and require the literal response Y before execution.
123
+ JSON and CI modes never prompt and never approve a command.
124
+ AI uses OPENAI_API_KEY or ANTHROPIC_API_KEY directly with native fetch. It sends only
125
+ redacted structured failure evidence after explicit consent and never runs inside bootproof up.
126
+
127
+ Options for diff:
128
+ --base <ref> base Git commit (default HEAD^)
129
+ --head <ref> head Git commit (default HEAD)
130
+ --json one bootproof/diff-result/v1 JSON object on stdout
131
+
132
+ Options for verify and attest check:
133
+ --trust-signer explicitly pin an intact foreign signer in ~/.bootproof/known_signers.json
134
+ --require-known-signer fail when the signer is not this machine or explicitly pinned
135
+ --strict require a known signer and fail directory verification on commit mismatch
136
+
81
137
  Honesty contract: no green check without an observed event; dry runs say "would";
82
138
  .env/.env.local are never written; secrets are never invented.
83
139
  Remote execution requires --provider local --unsafe-local. docs/HONESTY_CONTRACT.md`);
@@ -105,6 +161,8 @@ function printInference(inf) {
105
161
  console.log(` backend command: ${inf.backendCommand}`);
106
162
  if (inf.frontendCommand)
107
163
  console.log(` frontend command: ${inf.frontendCommand}`);
164
+ if (inf.asset_dev_server_command)
165
+ console.log(` asset dev server command: ${inf.asset_dev_server_command}`);
108
166
  if (inf.workerCommand)
109
167
  console.log(` worker command: ${inf.workerCommand}`);
110
168
  if (inf.appCommand)
@@ -113,6 +171,9 @@ function printInference(inf) {
113
171
  console.log(` preparation: ${inf.preparationCommands.map(command => command.command).join("; ")}`);
114
172
  console.log(` command scope: ${inf.commandScope}`);
115
173
  console.log(` port: ${inf.port} ${DIM}(${inf.portEvidence})${RESET}`);
174
+ if (inf.observedPort !== null)
175
+ console.log(` observed port: ${inf.observedPort}`);
176
+ console.log(` health candidate source: ${inf.healthCandidateSource}`);
116
177
  if (inf.healthCandidates.length)
117
178
  console.log(` health candidates: ${inf.healthCandidates.join(", ")}`);
118
179
  if (inf.services.length)
@@ -157,6 +218,84 @@ function machineFailure(explanation) {
157
218
  writtenFiles: [],
158
219
  };
159
220
  }
221
+ function externalMachineResult(attestation, evidencePath) {
222
+ return {
223
+ schema: "bootproof/result/v1",
224
+ booted: false,
225
+ healthVerified: attestation.result.healthVerified,
226
+ failureClass: attestation.result.failureClass,
227
+ classification: attestation.classification,
228
+ verificationMode: attestation.verificationMode,
229
+ bootproofOrchestrated: attestation.bootproofOrchestrated,
230
+ externalHealthUrl: attestation.externalHealthUrl,
231
+ observedStatus: attestation.observedStatus,
232
+ observedFinalUrl: attestation.observedFinalUrl,
233
+ observedAt: attestation.observedAt,
234
+ responseSnippet: attestation.responseSnippet,
235
+ attestationPath: evidencePath,
236
+ plan: attestation.plan,
237
+ observed: attestation.observed,
238
+ explanation: attestation.result.explanation,
239
+ trust: attestation.trust,
240
+ writtenFiles: evidencePath ? [evidencePath] : [],
241
+ };
242
+ }
243
+ function printExternalHealthResult(attestation, evidencePath) {
244
+ const result = attestation.result;
245
+ if (result.healthVerified) {
246
+ ok(`${BOLD}EXTERNAL SERVICE VERIFIED${RESET}${GREEN} — ${result.healthObservation} (observed, signed)`);
247
+ }
248
+ else {
249
+ bad(`${BOLD}NOT VERIFIED${RESET}${RED} — ${attestation.classification}`);
250
+ if (attestation.observedStatus !== null) {
251
+ console.log(`Observed: HTTP ${attestation.observedStatus} at ${attestation.observedFinalUrl}`);
252
+ }
253
+ const connectionError = result.healthEvidence?.connectionError;
254
+ if (connectionError)
255
+ console.log(`Connection error: ${connectionError}`);
256
+ }
257
+ console.log("Ownership: externally managed (bootproofOrchestrated=false).");
258
+ if (evidencePath)
259
+ console.log(`Evidence: ${evidencePath}`);
260
+ }
261
+ function printAgentPlan(plan, outputPath, runId, runPath) {
262
+ console.log(`${BOLD}Agent plan (planning only)${RESET}`);
263
+ if (plan.classifications.length)
264
+ console.log(`Classifications: ${plan.classifications.join(", ")}`);
265
+ console.log(`Current failure class: ${plan.currentFailureClass || "none established"}`);
266
+ console.log(`Suspected stack: ${plan.suspectedStack.length ? plan.suspectedStack.join(", ") : "none established"}`);
267
+ console.log(`BootProof direct orchestration: ${plan.canBootProofOrchestrateDirectly ? "available" : "not established"}`);
268
+ console.log(`External health verification: ${plan.canBootProofVerifyExternally ? "available" : "not established"}`);
269
+ if (plan.missingTools.length)
270
+ console.log(`Missing tools: ${plan.missingTools.join(", ")}`);
271
+ if (plan.observedEvidence.length) {
272
+ console.log("Observed evidence:");
273
+ for (const evidence of plan.observedEvidence)
274
+ console.log(` - ${evidence}`);
275
+ }
276
+ if (plan.candidateNextActions.length) {
277
+ console.log("Candidate next actions:");
278
+ for (const [index, candidate] of plan.candidateNextActions.entries()) {
279
+ console.log(` ${index + 1}. ${candidate.classification}`);
280
+ if (candidate.command)
281
+ console.log(` Command: ${candidate.command}`);
282
+ console.log(` Reason: ${candidate.reason}`);
283
+ console.log(` Risk: ${candidate.riskLevel}`);
284
+ console.log(` Mutation scope: ${candidate.mutationScope}`);
285
+ console.log(` Approval required: ${candidate.requiresApproval ? "yes" : "no"}`);
286
+ console.log(` Approval: ${candidate.approvalPrompt}`);
287
+ if (candidate.blockedReason)
288
+ console.log(` Blocked: ${candidate.blockedReason}`);
289
+ if (candidate.secretSensitive)
290
+ console.log(" Secret-sensitive: yes; command output must not be saved");
291
+ console.log(` Verify after action: ${candidate.verificationStep}`);
292
+ console.log(` Stop condition: ${candidate.stopCondition}`);
293
+ }
294
+ }
295
+ console.log(`Plan: ${outputPath}`);
296
+ console.log(`Agent run: ${runId} (${runPath})`);
297
+ console.log("No candidate action was executed. Verification remains pending.");
298
+ }
160
299
  function printFailure(failureClass, diagnosis, evidencePath) {
161
300
  bad(`${BOLD}NOT VERIFIED${RESET}${RED} — ${failureClass}`);
162
301
  console.log(`What happened: ${diagnosis.whatHappened}`);
@@ -167,6 +306,67 @@ function printFailure(failureClass, diagnosis, evidencePath) {
167
306
  function isRepairReceipt(value) {
168
307
  return Boolean(value && typeof value === "object" && value.schema === "bootproof/repair-receipt/v1");
169
308
  }
309
+ function signatureTrustText(result) {
310
+ if (!result.integrityValid || result.tier === "invalid")
311
+ return "signature INVALID";
312
+ if (result.tier === "self")
313
+ return "signature intact, signer: this machine";
314
+ if (result.tier === "known") {
315
+ return `signature intact, signer: known (${result.label ?? result.fingerprint})`;
316
+ }
317
+ return "signature intact, signer: UNKNOWN — integrity only, not a trusted signer";
318
+ }
319
+ function printSignatureTrust(result) {
320
+ const message = signatureTrustText(result);
321
+ if (!result.integrityValid)
322
+ bad(message);
323
+ else if (result.tier === "unknown-foreign")
324
+ warn(message);
325
+ else
326
+ ok(message);
327
+ }
328
+ function maybeTrustSigner(result, publicKey, requested, reevaluate) {
329
+ if (!requested || !result.integrityValid || result.tier !== "unknown-foreign" || !publicKey)
330
+ return result;
331
+ console.log(`Trusting signer: ${result.fingerprint}`);
332
+ trustSigner(publicKey);
333
+ return reevaluate();
334
+ }
335
+ function signerTrustFails(result, requireKnown) {
336
+ return !result.integrityValid || (requireKnown && result.tier === "unknown-foreign");
337
+ }
338
+ function commitMismatchMessage(attestation, repo) {
339
+ const head = currentGitHead(repo);
340
+ if (!head || attestation.repo.commit === head)
341
+ return null;
342
+ const attested = attestation.repo.commit?.slice(0, 8) ?? "none";
343
+ return `This attestation does not describe the repository's current commit (${attested} vs ${head.slice(0, 8)}); it is not proof of the current working tree.`;
344
+ }
345
+ function optionalRepairReceipt(repo) {
346
+ const receipt = path.join(repo, ".bootproof", "repair-receipt.json");
347
+ if (!fs.existsSync(receipt))
348
+ return null;
349
+ try {
350
+ const value = JSON.parse(fs.readFileSync(receipt, "utf8"));
351
+ return isRepairReceipt(value) && verifyRepairReceipt(value) ? value : null;
352
+ }
353
+ catch {
354
+ return null;
355
+ }
356
+ }
357
+ function registryEntryFor(repo, registryMode) {
358
+ const ap = attestationPath(repo);
359
+ if (!fs.existsSync(ap))
360
+ return null;
361
+ const att = JSON.parse(fs.readFileSync(ap, "utf8"));
362
+ return buildRegistryEntry(att, {
363
+ registryMode,
364
+ inference: inferRepo(repo),
365
+ repairReceipt: optionalRepairReceipt(repo),
366
+ branch: currentGitBranch(repo),
367
+ sign: true,
368
+ });
369
+ }
170
370
  function printRepairResult(result) {
171
371
  if (result.repaired) {
172
372
  ok(`${BOLD}VERIFIED REPAIR${RESET}${GREEN} — ${result.repairId}`);
@@ -179,6 +379,83 @@ function printRepairResult(result) {
179
379
  }
180
380
  bad(`${BOLD}NO VERIFIED REPAIR${RESET}${RED}${result.failureClass ? ` — ${result.failureClass}` : ""}`);
181
381
  console.log(result.explanation);
382
+ if (result.receiptPath)
383
+ console.log(`Receipt: ${result.receiptPath}`);
384
+ }
385
+ async function commandRepairApproval(action) {
386
+ console.log(action.approvalPrompt);
387
+ console.log(`Command: ${action.command.display}`);
388
+ console.log(`Mutation scope: ${action.mutationScope}`);
389
+ console.log(`Risk: ${action.riskLevel}`);
390
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
391
+ try {
392
+ return await prompt.question("Run this command? Type Y to approve: ") === "Y";
393
+ }
394
+ finally {
395
+ prompt.close();
396
+ }
397
+ }
398
+ async function patchRepairApproval(action) {
399
+ console.log(action.approvalPrompt);
400
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
401
+ try {
402
+ return await prompt.question("Test this patch in the repair sandbox? Type Y to approve: ") === "Y";
403
+ }
404
+ finally {
405
+ prompt.close();
406
+ }
407
+ }
408
+ function printAiRepairCandidate(requested) {
409
+ const action = requested.action;
410
+ console.log(`${BOLD}AI-suggested unverified repair${RESET}`);
411
+ console.log(`Provider: ${requested.provider} (${requested.model})`);
412
+ console.log(`Failure: ${requested.suggestion.failure_class}`);
413
+ console.log(`Confidence reported by provider: ${requested.suggestion.confidence}`);
414
+ console.log(`Explanation: ${requested.suggestion.explanation_for_user}`);
415
+ console.log(`Provider safety rationale (unverified): ${requested.suggestion.why_this_is_safe}`);
416
+ if (action.command)
417
+ console.log(`Command: ${action.command.display}`);
418
+ if (action.instruction)
419
+ console.log(`Instruction: ${action.instruction}`);
420
+ if (action.patch)
421
+ console.log(`Patch preview:\n${action.patch.content}`);
422
+ console.log(`Mutation scope: ${action.mutationScope}`);
423
+ console.log(`Risk: ${action.riskLevel}`);
424
+ console.log(`Approval: ${action.approvalPrompt}`);
425
+ console.log(`Verify after action: ${action.verificationStep}`);
426
+ console.log("Source: ai_suggested_unverified. AI has not proved this repair.");
427
+ }
428
+ async function aiRepairApproval(action, prompt) {
429
+ if (action.actionType === "command") {
430
+ return await prompt.question("Run this AI-suggested command? Type Y to approve: ") === "Y";
431
+ }
432
+ if (action.actionType === "patch") {
433
+ return await prompt.question("Test this AI-suggested patch in the repair sandbox? Type Y to approve: ") === "Y";
434
+ }
435
+ return await prompt.question("Approve recording this AI-suggested instruction? Type Y to approve: ") === "Y";
436
+ }
437
+ function printRepairCandidate(candidate) {
438
+ if (!candidate)
439
+ return;
440
+ const action = candidate.candidate.action;
441
+ console.log(`${BOLD}Deterministic repair candidate${RESET}`);
442
+ console.log(`Failure: ${candidate.candidate.failureClass}`);
443
+ if (action.command)
444
+ console.log(`Command: ${action.command.display}`);
445
+ if (action.instruction)
446
+ console.log(`Instruction: ${action.instruction}`);
447
+ if (action.patch)
448
+ console.log(`Patch preview:\n${action.patch.content}`);
449
+ console.log(`Mutation scope: ${action.mutationScope}`);
450
+ console.log(`Risk: ${action.riskLevel}`);
451
+ console.log(`Approval: ${action.approvalPrompt}`);
452
+ console.log(`Verify after action: ${action.verificationStep}`);
453
+ for (const followUp of candidate.candidate.followUpActions ?? []) {
454
+ if (followUp.command)
455
+ console.log(`Later separately approved command: ${followUp.command.display}`);
456
+ if (followUp.instruction)
457
+ console.log(`Follow-up instruction: ${followUp.instruction}`);
458
+ }
182
459
  }
183
460
  function printRepairApplyResult(result) {
184
461
  if (result.applied) {
@@ -190,6 +467,41 @@ function printRepairApplyResult(result) {
190
467
  bad(`${BOLD}REPAIR NOT APPLIED${RESET}`);
191
468
  console.log(result.explanation);
192
469
  }
470
+ function printDiffResult(result) {
471
+ console.log(`${BOLD}Static infrastructure diff${RESET}`);
472
+ console.log(`Base: ${result.base}`);
473
+ console.log(`Head: ${result.head}`);
474
+ console.log(`Risk: ${result.riskLevel}`);
475
+ console.log(`Fresh boot proof required: ${result.proofRequired ? "yes" : "no detected infrastructure drift"}`);
476
+ const sections = [
477
+ ["Changed files", result.changedFiles],
478
+ ["Added services", result.addedServices],
479
+ ["Removed services", result.removedServices],
480
+ ["Added ports", result.addedPorts],
481
+ ["Removed ports", result.removedPorts],
482
+ ["Added env vars", result.addedEnvVars],
483
+ ["Removed env vars", result.removedEnvVars],
484
+ [
485
+ "Changed commands",
486
+ result.changedCommands.map(change => `${change.source}: ${change.before ?? "(absent)"} -> ${change.after ?? "(absent)"}`),
487
+ ],
488
+ [
489
+ "Changed package managers",
490
+ result.changedPackageManagers.map(change => `${change.source}: ${change.before ?? "(absent)"} -> ${change.after ?? "(absent)"}`),
491
+ ],
492
+ ];
493
+ for (const [label, values] of sections) {
494
+ if (!values.length)
495
+ continue;
496
+ console.log(`${label}:`);
497
+ for (const value of values)
498
+ console.log(` - ${value}`);
499
+ }
500
+ console.log("Review notes:");
501
+ for (const note of result.suggestedReviewNotes)
502
+ console.log(` - ${note}`);
503
+ console.log("Static analysis only. No repository code was executed and no data was uploaded.");
504
+ }
193
505
  function rebaseRemoteRepairPaths(result, repo) {
194
506
  const rebase = (value) => value
195
507
  ? portableRelative(process.cwd(), path.join(repo, value))
@@ -216,11 +528,123 @@ async function main() {
216
528
  const { flags, positional } = parseFlags(rest);
217
529
  if (flags.ci || flags.json)
218
530
  disableColor();
531
+ const unsupportedFlag = Object.keys(flags).find(flag => !SUPPORTED_FLAGS[cmd]?.has(flag));
532
+ if (unsupportedFlag) {
533
+ const explanation = `unsupported flag for ${cmd}: --${unsupportedFlag}`;
534
+ if (cmd === "up" && flags.json)
535
+ console.log(JSON.stringify(machineFailure(explanation)));
536
+ else
537
+ bad(explanation);
538
+ process.exitCode = 1;
539
+ return;
540
+ }
219
541
  const targetInput = String(positional[0] ?? ".");
542
+ if (cmd === "diff") {
543
+ const optionError = positional.length
544
+ ? "diff does not accept a path; run it from the Git repository to compare"
545
+ : flags.base !== undefined && typeof flags.base !== "string"
546
+ ? "--base requires a Git ref"
547
+ : flags.head !== undefined && typeof flags.head !== "string"
548
+ ? "--head requires a Git ref"
549
+ : null;
550
+ if (optionError) {
551
+ bad(optionError);
552
+ process.exitCode = 1;
553
+ return;
554
+ }
555
+ const result = diffRefs(process.cwd(), {
556
+ base: flags.base,
557
+ head: flags.head,
558
+ });
559
+ if (flags.json)
560
+ console.log(JSON.stringify(result));
561
+ else
562
+ printDiffResult(result);
563
+ return;
564
+ }
565
+ if (cmd === "explain-run") {
566
+ if (!positional[0] || positional.length > 1) {
567
+ bad("explain-run requires exactly one run id");
568
+ process.exitCode = 1;
569
+ return;
570
+ }
571
+ const run = readAgentRun(process.cwd(), positional[0]);
572
+ for (const line of explainAgentRun(process.cwd(), positional[0]))
573
+ console.log(line);
574
+ process.exitCode = run.chainValid ? 0 : 1;
575
+ return;
576
+ }
577
+ if (cmd === "verify-url") {
578
+ if (!positional[0] || positional.length > 1) {
579
+ const explanation = "verify-url requires exactly one HTTP or HTTPS URL";
580
+ if (flags.json)
581
+ console.log(JSON.stringify(machineFailure(explanation)));
582
+ else
583
+ bad(explanation);
584
+ process.exitCode = 1;
585
+ return;
586
+ }
587
+ const timeoutMs = Number(flags.timeout ?? 5000);
588
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
589
+ const explanation = `invalid --timeout value: ${String(flags.timeout)} (expected a positive number)`;
590
+ if (flags.json)
591
+ console.log(JSON.stringify(machineFailure(explanation)));
592
+ else
593
+ bad(explanation);
594
+ process.exitCode = 1;
595
+ return;
596
+ }
597
+ const attestation = await buildExternalHealthAttestation(process.cwd(), targetInput, timeoutMs);
598
+ if (flags.json)
599
+ console.log(JSON.stringify(attestation));
600
+ else
601
+ printExternalHealthResult(attestation, null);
602
+ process.exitCode = attestation.result.healthVerified ? 0 : 1;
603
+ return;
604
+ }
605
+ if (cmd === "up" && flags["external-health"] !== undefined) {
606
+ const externalHealthUrl = flags["external-health"];
607
+ const incompatibleFlag = ["provider", "unsafe-local", "install", "workspace", "port", "dry-run", "command"]
608
+ .find(flag => flags[flag] !== undefined);
609
+ const timeoutMs = Number(flags.timeout ?? 5000);
610
+ const optionError = typeof externalHealthUrl !== "string" || externalHealthUrl.trim().length === 0
611
+ ? "--external-health requires an HTTP or HTTPS URL"
612
+ : incompatibleFlag
613
+ ? `--external-health cannot be combined with --${incompatibleFlag}`
614
+ : !Number.isFinite(timeoutMs) || timeoutMs <= 0
615
+ ? `invalid --timeout value: ${String(flags.timeout)} (expected a positive number)`
616
+ : isRemoteTarget(targetInput)
617
+ ? "--external-health requires a local evidence directory, not a remote repository URL"
618
+ : null;
619
+ if (optionError) {
620
+ if (flags.json)
621
+ console.log(JSON.stringify(machineFailure(optionError)));
622
+ else
623
+ bad(optionError);
624
+ process.exitCode = 1;
625
+ return;
626
+ }
627
+ const target = path.resolve(targetInput);
628
+ const evidencePath = ".bootproof/attestation.json";
629
+ const attestation = await buildExternalHealthAttestation(target, externalHealthUrl, timeoutMs);
630
+ writeAttestation(target, attestation);
631
+ const runId = latestAgentRunId(target);
632
+ if (runId)
633
+ appendAgentVerification(target, runId, attestation);
634
+ if (flags.json)
635
+ console.log(JSON.stringify(externalMachineResult(attestation, evidencePath)));
636
+ else {
637
+ printExternalHealthResult(attestation, evidencePath);
638
+ if (runId)
639
+ console.log(`Agent run verification: ${runId}`);
640
+ }
641
+ process.exitCode = attestation.result.healthVerified ? 0 : 1;
642
+ return;
643
+ }
220
644
  let target = path.resolve(targetInput);
221
645
  let remote = null;
222
646
  let remoteSource = null;
223
- if (["analyze", "plan", "up", "fix"].includes(cmd) && isRemoteTarget(targetInput)) {
647
+ if (["analyze", "plan", "plan-agent", "up", "fix"].includes(cmd) && isRemoteTarget(targetInput)) {
224
648
  if (flags["dry-run"]) {
225
649
  const explanation = "Remote dry runs are refused because cloning would write files, while BootProof dry runs promise to write nothing.";
226
650
  if (flags.json)
@@ -249,7 +673,7 @@ async function main() {
249
673
  return;
250
674
  }
251
675
  }
252
- if (!remote && ["analyze", "plan", "up", "fix"].includes(cmd)) {
676
+ if (!remote && ["analyze", "plan", "plan-agent", "up", "fix"].includes(cmd)) {
253
677
  remoteSource = managedRemoteSource(target);
254
678
  if (remoteSource && !flags.json) {
255
679
  console.log(`${DIM}Managed remote source: ${remoteSource}${RESET}`);
@@ -279,6 +703,20 @@ async function main() {
279
703
  console.log(`${DIM}--- .env.bootproof.example (preview) ---\n${envExampleFor(inf)}${RESET}`);
280
704
  return;
281
705
  }
706
+ if (cmd === "plan-agent") {
707
+ const plan = buildAgentPlan(target);
708
+ const output = writeAgentPlan(target, plan);
709
+ const summary = createAgentRun(target, plan);
710
+ const displayedOutput = remote
711
+ ? portableRelative(process.cwd(), output)
712
+ : portableRelative(process.cwd(), agentPlanPath(target));
713
+ const displayedRun = portableRelative(process.cwd(), agentRunDirectory(target, summary.runId));
714
+ if (flags.json)
715
+ console.log(JSON.stringify(plan));
716
+ else
717
+ printAgentPlan(plan, displayedOutput, summary.runId, displayedRun);
718
+ return;
719
+ }
282
720
  if (cmd === "apply-repair") {
283
721
  if (isRemoteTarget(targetInput)) {
284
722
  const result = {
@@ -368,12 +806,80 @@ async function main() {
368
806
  process.exitCode = 1;
369
807
  return;
370
808
  }
809
+ const latestCandidate = latestDeterministicRepairCandidate(target, provider);
810
+ const latestFailure = latestFailedAttestation(target, provider);
811
+ const hasRegisteredDeterministicRepair = Boolean(latestFailure?.result.failureClass &&
812
+ registeredRemediationsFor(latestFailure.result.failureClass).length);
813
+ if (!latestCandidate && !hasRegisteredDeterministicRepair && flags.ai) {
814
+ resolveAiProvider();
815
+ const before = latestFailure;
816
+ if (!before) {
817
+ throw new Error("AI-assisted repair requires a signature-valid failed attestation. Run bootproof up first.");
818
+ }
819
+ if (flags.json || flags.ci) {
820
+ throw new Error("AI-assisted repair requires interactive consent and approval; --json and --ci never prompt or approve actions.");
821
+ }
822
+ const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
823
+ try {
824
+ const requestApproved = await prompt.question("No verified deterministic remediation is known. Request an AI suggestion using your BYOK provider? Type Y to approve: ") === "Y";
825
+ if (!requestApproved) {
826
+ const result = {
827
+ schema: "bootproof/repair-result/v1",
828
+ repaired: false,
829
+ failureClass: before.result.failureClass,
830
+ repairId: null,
831
+ receiptPath: null,
832
+ patchPath: null,
833
+ afterAttestationPath: null,
834
+ explanation: "AI suggestion request was declined. No AI provider request, command, or patch occurred.",
835
+ };
836
+ printRepairResult(result);
837
+ process.exitCode = 1;
838
+ return;
839
+ }
840
+ const requested = await requestAiRepairSuggestion(before, { timeoutMs });
841
+ printAiRepairCandidate(requested);
842
+ const actionApproved = await aiRepairApproval(requested.action, prompt);
843
+ const repairResult = await executeAiSuggestedRepair(target, before, requested.action, {
844
+ provider: provider,
845
+ unsafeLocal: Boolean(flags["unsafe-local"]),
846
+ timeoutMs,
847
+ port,
848
+ remoteSource: remoteSource ?? undefined,
849
+ actionApproved,
850
+ aiRepair: requested,
851
+ });
852
+ const result = remote ? rebaseRemoteRepairPaths(repairResult, target) : repairResult;
853
+ printRepairResult(result);
854
+ process.exitCode = result.repaired ? 0 : 1;
855
+ return;
856
+ }
857
+ finally {
858
+ prompt.close();
859
+ }
860
+ }
861
+ let actionApproved = false;
862
+ if (latestCandidate && !flags.json) {
863
+ printRepairCandidate(latestCandidate);
864
+ }
865
+ if (latestCandidate &&
866
+ latestCandidate.candidate.action.actionType !== "instruction" &&
867
+ !flags.json &&
868
+ !flags.ci) {
869
+ const effectiveProvider = provider ?? latestCandidate.attestation.plan.provider;
870
+ if (effectiveProvider !== "local" || flags["unsafe-local"]) {
871
+ actionApproved = latestCandidate.candidate.action.actionType === "command"
872
+ ? await commandRepairApproval(latestCandidate.candidate.action)
873
+ : await patchRepairApproval(latestCandidate.candidate.action);
874
+ }
875
+ }
371
876
  const repairResult = await repairRepo(target, {
372
877
  provider: provider,
373
878
  unsafeLocal: Boolean(flags["unsafe-local"]),
374
879
  timeoutMs,
375
880
  port,
376
881
  remoteSource: remoteSource ?? undefined,
882
+ actionApproved,
377
883
  });
378
884
  const result = remote ? rebaseRemoteRepairPaths(repairResult, target) : repairResult;
379
885
  if (flags.json)
@@ -387,13 +893,19 @@ async function main() {
387
893
  const provider = flags.provider ?? "docker";
388
894
  const timeoutMs = Number(flags.timeout ?? 60_000);
389
895
  const port = flags.port === undefined ? undefined : Number(flags.port);
896
+ const command = flags.command;
897
+ const healthPath = typeof flags["health-path"] === "string" ? flags["health-path"] : undefined;
390
898
  const optionError = provider !== "docker" && provider !== "local"
391
899
  ? `invalid --provider value: ${String(provider)} (expected docker or local)`
392
900
  : !Number.isFinite(timeoutMs) || timeoutMs <= 0
393
901
  ? `invalid --timeout value: ${String(flags.timeout)} (expected a positive number)`
394
902
  : port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)
395
903
  ? `invalid --port value: ${String(flags.port)} (expected an integer from 1 to 65535)`
396
- : null;
904
+ : command !== undefined && (typeof command !== "string" || command.trim().length === 0)
905
+ ? "--command requires a non-empty command string"
906
+ : healthPath !== undefined && (typeof healthPath !== "string" || !healthPath.startsWith("/"))
907
+ ? "--health-path requires a path starting with / (e.g. /health)"
908
+ : null;
397
909
  if (optionError) {
398
910
  if (flags.json)
399
911
  console.log(JSON.stringify(machineFailure(optionError)));
@@ -411,9 +923,18 @@ async function main() {
411
923
  timeoutMs,
412
924
  install: Boolean(flags.install),
413
925
  port,
926
+ command: typeof command === "string" ? command : undefined,
927
+ healthPath,
928
+ ciOidc: Boolean(flags["ci-oidc"]),
414
929
  };
415
930
  const outcome = await up(target, opts);
416
931
  const verified = outcome.attestation?.result.booted === true && outcome.attestation.result.healthVerified === true;
932
+ if (flags.receipt && outcome.attestation) {
933
+ const receiptPath = path.join(target, ".bootproof", "living-receipt.html");
934
+ emitLivingReceipt(outcome.attestation, receiptPath);
935
+ if (!flags.json)
936
+ console.log(`${GREEN}\u2713${RESET} Living Receipt: ${portableRelative(process.cwd(), receiptPath)}`);
937
+ }
417
938
  if (flags.json) {
418
939
  console.log(JSON.stringify(machineResult(outcome, evidencePath)));
419
940
  if (flags.ci || !opts.dryRun)
@@ -456,25 +977,43 @@ async function main() {
456
977
  return;
457
978
  }
458
979
  if (cmd === "verify") {
459
- const p = path.extname(target) === ".json" ? target : attestationPath(target);
980
+ const directoryTarget = fs.existsSync(target) && fs.statSync(target).isDirectory();
981
+ const p = directoryTarget ? attestationPath(target) : target;
460
982
  if (!fs.existsSync(p)) {
461
983
  bad(`no proof at ${p} — run bootproof up or bootproof fix first`);
462
984
  process.exitCode = 1;
463
985
  return;
464
986
  }
465
987
  const proof = JSON.parse(fs.readFileSync(p, "utf8"));
988
+ const requireKnownSigner = Boolean(flags["require-known-signer"] || flags.strict);
989
+ const trustRequested = Boolean(flags["trust-signer"]);
466
990
  if (isRepairReceipt(proof)) {
467
- const valid = verifyRepairReceipt(proof);
468
- (valid ? ok : bad)(`repair receipt signature ${valid ? "valid" : "INVALID"} (ed25519, trust-on-first-use)`);
469
- console.log(`${DIM}failure=${proof.verification.before.failureClass} repair=${proof.repair.id} after=${proof.verification.after.healthObservation}${RESET}`);
470
- if (!valid)
991
+ let trust = evaluateRepairReceiptSignature(proof);
992
+ trust = maybeTrustSigner(trust, proof.signer?.publicKey, trustRequested, () => evaluateRepairReceiptSignature(proof));
993
+ printSignatureTrust(trust);
994
+ const summary = proof.verification && proof.repair
995
+ ? `failure=${proof.verification.before.failureClass} repair=${proof.repair.id} after=${proof.verification.after.healthObservation}`
996
+ : `failure=${proof.beforeFailureClass} repair=${proof.repairId} applied=${proof.applyResult.status} progressed=${proof.progressed} verified=${proof.verified}`;
997
+ console.log(`${DIM}${summary}${RESET}`);
998
+ if (signerTrustFails(trust, requireKnownSigner))
471
999
  process.exitCode = 1;
472
1000
  return;
473
1001
  }
474
1002
  const att = proof;
475
- const sig = verifySignature(att);
476
- (sig ? ok : bad)(`signature ${sig ? "valid" : "INVALID"} (ed25519, trust-on-first-use)`);
477
- console.log(`Trust level: ${att.trust?.level ?? "legacy_unspecified"}`);
1003
+ let trust = evaluateAttestationSignature(att);
1004
+ trust = maybeTrustSigner(trust, att.signer?.publicKey, trustRequested, () => evaluateAttestationSignature(att));
1005
+ printSignatureTrust(trust);
1006
+ console.log(`Attested trust claim: ${att.trust?.level ?? "legacy_unspecified"} (signed content; signer tier shown above)`);
1007
+ const commitMismatch = directoryTarget ? commitMismatchMessage(att, target) : null;
1008
+ if (commitMismatch)
1009
+ warn(commitMismatch);
1010
+ if (att.verificationMode === "external-health") {
1011
+ console.log(`${DIM}attested: classification=${att.classification} bootproofOrchestrated=false at ${att.observedAt ?? att.finishedAt}${RESET}`);
1012
+ console.log("This attestation observes an externally managed service; it does not claim BootProof started the application.");
1013
+ if (signerTrustFails(trust, requireKnownSigner) || (commitMismatch && requireKnownSigner))
1014
+ process.exitCode = 1;
1015
+ return;
1016
+ }
478
1017
  console.log(`${DIM}attested: booted=${att.result.booted} at commit ${att.repo.commit ?? "unknown"} on ${att.environment.os} node ${att.environment.node}${RESET}`);
479
1018
  const retainedRemote = managedRemoteSource(att.repo.path);
480
1019
  if (retainedRemote) {
@@ -484,29 +1023,85 @@ async function main() {
484
1023
  else {
485
1024
  console.log(`Replaying attested plan with bootproof up --provider ${att.plan.provider} would re-verify it on this machine.`);
486
1025
  }
487
- if (att.result.booted) {
1026
+ if (att.result.booted && !commitMismatch && (trust.tier === "self" || trust.tier === "known")) {
488
1027
  const live = await pollHealth(att.plan.healthUrl, 3000);
489
1028
  if (live.responded)
490
1029
  ok(`bonus observation: ${att.plan.healthUrl} is responding right now (HTTP ${live.status})`);
491
1030
  else
492
1031
  console.log(`${DIM}(app not currently running — attestation describes a past verified run)${RESET}`);
493
1032
  }
494
- if (!sig)
1033
+ else if (att.result.booted && commitMismatch) {
1034
+ console.log(`${DIM}(live health bonus skipped because the attestation does not match the current commit)${RESET}`);
1035
+ }
1036
+ else if (att.result.booted && trust.tier === "unknown-foreign") {
1037
+ console.log(`${DIM}(live health bonus skipped because the signer is unknown)${RESET}`);
1038
+ }
1039
+ if (signerTrustFails(trust, requireKnownSigner) || (commitMismatch && requireKnownSigner))
495
1040
  process.exitCode = 1;
496
1041
  return;
497
1042
  }
1043
+ if (cmd === "registry") {
1044
+ const sub = positional[0];
1045
+ const repo = path.resolve(String(positional[1] ?? "."));
1046
+ if (sub !== "export") {
1047
+ bad(`unknown registry subcommand: ${sub ?? "(none)"} — use export`);
1048
+ process.exitCode = 1;
1049
+ return;
1050
+ }
1051
+ const requestedMode = String(flags.mode ?? (flags.federated ? "federated_public_candidate" : "local_export"));
1052
+ const registryModes = ["local_export", "federated_public_candidate", "cloud_upload_candidate"];
1053
+ if (!registryModes.includes(requestedMode)) {
1054
+ bad(`invalid --mode value: ${requestedMode}`);
1055
+ process.exitCode = 1;
1056
+ return;
1057
+ }
1058
+ if (flags.federated && requestedMode !== "federated_public_candidate") {
1059
+ bad("--federated requires --mode federated_public_candidate when --mode is provided");
1060
+ process.exitCode = 1;
1061
+ return;
1062
+ }
1063
+ const entry = registryEntryFor(repo, requestedMode);
1064
+ if (!entry) {
1065
+ bad(`no attestation at ${attestationPath(repo)} — run bootproof up first`);
1066
+ process.exitCode = 1;
1067
+ return;
1068
+ }
1069
+ if (flags.federated) {
1070
+ const receipt = buildFederatedReceipt(entry, { sign: true });
1071
+ const output = writeFederatedReceipt(repo, receipt);
1072
+ ok(`wrote redacted federated public candidate: ${output}`);
1073
+ }
1074
+ else {
1075
+ const output = writeRegistryEntry(repo, entry);
1076
+ ok(`wrote redacted registry entry: ${output}`);
1077
+ }
1078
+ console.log(`${DIM}redactions applied: ${entry.redactionsApplied.join(", ")}${RESET}`);
1079
+ console.log("Nothing has been uploaded. This export is local and opt-in.");
1080
+ if (flags.federated) {
1081
+ console.log("Review the receipt before deliberately committing it to the public repository.");
1082
+ }
1083
+ else if (requestedMode === "cloud_upload_candidate") {
1084
+ console.log("This is only a Cloud upload candidate. BootProof Cloud upload is not implemented here.");
1085
+ }
1086
+ return;
1087
+ }
498
1088
  if (cmd === "attest") {
499
1089
  const sub = positional[0];
500
1090
  const repo = path.resolve(String(positional[1] ?? "."));
501
1091
  if (sub === "export") {
502
- const ap = attestationPath(repo);
503
- if (!fs.existsSync(ap)) {
504
- bad(`no attestation at ${ap} — run bootproof up first`);
1092
+ const verificationFlag = ["trust-signer", "require-known-signer", "strict"]
1093
+ .find(flag => flags[flag] !== undefined);
1094
+ if (verificationFlag) {
1095
+ bad(`--${verificationFlag} is supported only by attest check`);
1096
+ process.exitCode = 1;
1097
+ return;
1098
+ }
1099
+ const entry = registryEntryFor(repo, "local_export");
1100
+ if (!entry) {
1101
+ bad(`no attestation at ${attestationPath(repo)} — run bootproof up first`);
505
1102
  process.exitCode = 1;
506
1103
  return;
507
1104
  }
508
- const att = JSON.parse(fs.readFileSync(ap, "utf8"));
509
- const entry = buildRegistryEntry(att);
510
1105
  const out = writeRegistryEntry(repo, entry);
511
1106
  ok(`wrote redacted registry entry: ${out}`);
512
1107
  console.log(`${DIM}redactions applied: ${entry.redactionsApplied.length ? entry.redactionsApplied.join(", ") : "none needed"}${RESET}`);
@@ -523,10 +1118,12 @@ async function main() {
523
1118
  return;
524
1119
  }
525
1120
  const entry = JSON.parse(fs.readFileSync(ep, "utf8"));
526
- const valid = verifyRegistryEntry(entry);
527
- (valid ? ok : bad)(`registry entry signature ${valid ? "valid" : "INVALID"}`);
528
- console.log(`${DIM}booted=${entry.result.booted} class=${entry.result.failureClass ?? "none"} commit=${entry.repo.commit?.slice(0, 8) ?? "?"}${RESET}`);
529
- if (!valid)
1121
+ const requireKnownSigner = Boolean(flags["require-known-signer"] || flags.strict);
1122
+ let trust = evaluateRegistryEntrySignature(entry);
1123
+ trust = maybeTrustSigner(trust, entry.signature?.publicKey, Boolean(flags["trust-signer"]), () => evaluateRegistryEntrySignature(entry));
1124
+ printSignatureTrust(trust);
1125
+ console.log(`${DIM}verified=${entry.verified} class=${entry.failureClass ?? "none"} commit=${entry.commitHash?.slice(0, 8) ?? "?"}${RESET}`);
1126
+ if (signerTrustFails(trust, requireKnownSigner))
530
1127
  process.exitCode = 1;
531
1128
  return;
532
1129
  }
@@ -534,34 +1131,121 @@ async function main() {
534
1131
  process.exitCode = 1;
535
1132
  return;
536
1133
  }
1134
+ if (cmd === "export-sbom") {
1135
+ const format = flags.format ?? "cyclonedx-json";
1136
+ if (format !== "cyclonedx-json") {
1137
+ if (flags.json)
1138
+ console.log(JSON.stringify(machineFailure(`Unsupported SBOM format: ${format}. Currently supported: cyclonedx-json.`)));
1139
+ else
1140
+ bad(`Unsupported SBOM format: ${format}. Currently supported: cyclonedx-json.`);
1141
+ process.exitCode = 1;
1142
+ return;
1143
+ }
1144
+ try {
1145
+ const result = exportSbom(target, format);
1146
+ if (flags.json) {
1147
+ console.log(JSON.stringify(result));
1148
+ return;
1149
+ }
1150
+ ok(`SBOM exported: ${portableRelative(process.cwd(), result.path)}`);
1151
+ console.log(` format: ${result.format}`);
1152
+ console.log(` components: ${result.componentCount}`);
1153
+ console.log(`${DIM}CycloneDX 1.5 JSON. Read from package-lock.json. No transitive resolution beyond the lockfile.${RESET}`);
1154
+ }
1155
+ catch (e) {
1156
+ const msg = e instanceof Error ? e.message : String(e);
1157
+ if (flags.json)
1158
+ console.log(JSON.stringify(machineFailure(msg)));
1159
+ else
1160
+ bad(msg);
1161
+ process.exitCode = 1;
1162
+ }
1163
+ return;
1164
+ }
1165
+ if (cmd === "rotate-keys") {
1166
+ const repo = flags.repo ? path.resolve(String(flags.repo)) : undefined;
1167
+ const resign = Boolean(flags.resign);
1168
+ const backup = !flags["no-backup"];
1169
+ try {
1170
+ const result = rotateSigner({ repo, resignAttestation: resign, backup });
1171
+ if (flags.json) {
1172
+ console.log(JSON.stringify(result));
1173
+ return;
1174
+ }
1175
+ ok("Signing key rotated.");
1176
+ console.log(` old public key: ${result.oldPublicKey.split("\n")[0]}...`);
1177
+ console.log(` new public key: ${result.newPublicKey.split("\n")[0]}...`);
1178
+ if (result.backedUpTo)
1179
+ console.log(` old key backed up to: ${result.backedUpTo}`);
1180
+ if (result.reSignedAttestation)
1181
+ console.log(` re-signed latest attestation at ${repo}`);
1182
+ console.log(`${DIM}Existing attestations still verify with their embedded public key. Rotation only affects future signatures.${RESET}`);
1183
+ }
1184
+ catch (e) {
1185
+ const msg = e instanceof Error ? e.message : String(e);
1186
+ if (flags.json)
1187
+ console.log(JSON.stringify(machineFailure(msg)));
1188
+ else
1189
+ bad(msg);
1190
+ process.exitCode = 1;
1191
+ }
1192
+ return;
1193
+ }
537
1194
  if (cmd === "explain") {
538
1195
  const p = positional[0] ? path.resolve(positional[0]) : attestationPath(target);
539
1196
  const proof = JSON.parse(fs.readFileSync(p, "utf8"));
540
1197
  if (isRepairReceipt(proof)) {
541
- const valid = verifyRepairReceipt(proof);
1198
+ const trust = evaluateRepairReceiptSignature(proof);
542
1199
  console.log(`${BOLD}Repair receipt explained${RESET}`);
543
- console.log(`Signature: ${valid ? "valid" : "INVALID"}`);
544
- if (!valid) {
1200
+ console.log(`Signature: ${signatureTrustText(trust).replace(/^signature /, "")}`);
1201
+ if (!trust.integrityValid) {
545
1202
  console.log("The receipt has been tampered with or is malformed. Its repair claims are not trusted.");
546
1203
  process.exitCode = 1;
547
1204
  return;
548
1205
  }
549
- console.log(`Before: NOT VERIFIED — ${proof.verification.before.failureClass}.`);
550
- console.log(`Repair: ${proof.repair.id} (${proof.repair.kind}).`);
551
- console.log(`After: BOOTED — ${proof.verification.after.healthObservation}.`);
552
- console.log(`Description: ${proof.repair.description}`);
553
- if (proof.repair.filesChanged.length)
554
- console.log(`Files changed in sandbox: ${proof.repair.filesChanged.join(", ")}`);
555
- if (proof.repair.planDelta)
556
- console.log(`Plan delta: ${proof.repair.planDelta}`);
557
- if (proof.repair.envDelta)
558
- console.log(`Environment delta: ${proof.repair.envDelta}`);
1206
+ console.log(`Before: NOT VERIFIED — ${proof.beforeFailureClass}.`);
1207
+ console.log(`Repair: ${proof.repairId} (${proof.actionType}; scope=${proof.mutationScope}; risk=${proof.riskLevel}).`);
1208
+ if (proof.proposedAction.command)
1209
+ console.log(`Command: ${proof.proposedAction.command.display}`);
1210
+ if (proof.proposedAction.instruction)
1211
+ console.log(`Instruction: ${proof.proposedAction.instruction}`);
1212
+ console.log(`Applied: ${proof.applyResult.status}. Progressed: ${proof.progressed}. Verified: ${proof.verified}.`);
1213
+ if (proof.afterFailureClass)
1214
+ console.log(`After failure: ${proof.afterFailureClass}.`);
1215
+ console.log(`Description: ${proof.explanation}`);
1216
+ if (proof.repair) {
1217
+ if (proof.repair.filesChanged.length)
1218
+ console.log(`Files changed in sandbox: ${proof.repair.filesChanged.join(", ")}`);
1219
+ if (proof.repair.planDelta)
1220
+ console.log(`Plan delta: ${proof.repair.planDelta}`);
1221
+ if (proof.repair.envDelta)
1222
+ console.log(`Environment delta: ${proof.repair.envDelta}`);
1223
+ }
559
1224
  return;
560
1225
  }
561
1226
  const att = proof;
1227
+ const trust = evaluateAttestationSignature(att);
562
1228
  console.log(`${BOLD}Attestation explained${RESET}`);
1229
+ console.log(`Signature: ${signatureTrustText(trust).replace(/^signature /, "")}`);
1230
+ if (!trust.integrityValid) {
1231
+ console.log("The attestation has been tampered with or is malformed. Its boot claims are not trusted.");
1232
+ process.exitCode = 1;
1233
+ return;
1234
+ }
1235
+ if (att.verificationMode === "external-health") {
1236
+ console.log(att.result.healthVerified
1237
+ ? `This externally managed service was VERIFIED: ${att.result.healthObservation}.`
1238
+ : `This external health check did NOT verify. Classification: ${att.classification}.`);
1239
+ console.log("BootProof did not start or orchestrate this service.");
1240
+ console.log(att.result.explanation);
1241
+ if (att.plan.healthCandidates?.length)
1242
+ console.log(`Health candidates: ${att.plan.healthCandidates.join(", ")}`);
1243
+ for (const o of att.observed)
1244
+ console.log(` ${o.ok ? "\u2713" : "\u2717"} ${o.id}: ${o.observation}`);
1245
+ return;
1246
+ }
563
1247
  console.log(att.result.booted ? `This run BOOTED: ${att.result.healthObservation}.` : `This run did NOT verify. Failure class: ${att.result.failureClass}.`);
564
- console.log(`Trust level: ${att.trust?.level ?? "legacy_unspecified"}`);
1248
+ console.log(`Attested trust claim: ${att.trust?.level ?? "legacy_unspecified"} (signed content; signer tier shown above)`);
565
1249
  if (!att.result.booted && att.result.failureClass) {
566
1250
  const diagnosis = diagnoseFailure(att.result.failureClass, att.result.failureEvidence, att.result.explanation);
567
1251
  console.log(`What happened: ${diagnosis.whatHappened}`);
@@ -583,7 +1267,7 @@ async function main() {
583
1267
  }
584
1268
  main().catch(err => {
585
1269
  const argv = process.argv.slice(2);
586
- if (argv.includes("--json") && argv[0] === "up") {
1270
+ if (argv.includes("--json") && (argv[0] === "up" || argv[0] === "verify-url")) {
587
1271
  console.log(JSON.stringify(machineFailure(String(err?.message ?? err))));
588
1272
  }
589
1273
  else if (argv.includes("--json") && argv[0] === "fix") {