bootproof 0.1.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 (74) hide show
  1. package/README.md +873 -109
  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 +936 -38
  9. package/dist/diagnosis.js +114 -17
  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 +332 -37
  14. package/dist/external-health.d.ts +16 -0
  15. package/dist/external-health.js +214 -0
  16. package/dist/infer.js +489 -41
  17. package/dist/plan.d.ts +2 -0
  18. package/dist/plan.js +49 -7
  19. package/dist/proof.d.ts +78 -2
  20. package/dist/proof.js +266 -13
  21. package/dist/receipt.d.ts +52 -0
  22. package/dist/receipt.js +356 -0
  23. package/dist/redact.d.ts +4 -0
  24. package/dist/redact.js +86 -2
  25. package/dist/registry.d.ts +82 -30
  26. package/dist/registry.js +355 -53
  27. package/dist/remote.d.ts +12 -1
  28. package/dist/remote.js +62 -18
  29. package/dist/repair-playbooks.d.ts +24 -0
  30. package/dist/repair-playbooks.js +593 -0
  31. package/dist/repair-safety.d.ts +130 -0
  32. package/dist/repair-safety.js +766 -0
  33. package/dist/repair.d.ts +142 -0
  34. package/dist/repair.js +1566 -0
  35. package/dist/run.d.ts +6 -1
  36. package/dist/run.js +385 -46
  37. package/dist/sbom.d.ts +22 -0
  38. package/dist/sbom.js +99 -0
  39. package/dist/taxonomy.d.ts +8 -2
  40. package/dist/taxonomy.js +428 -8
  41. package/dist/types.d.ts +57 -2
  42. package/docs/AGENT_IN_THE_LOOP.md +171 -0
  43. package/docs/AGENT_RUN_RECEIPTS.md +38 -0
  44. package/docs/CI_ACTION.md +71 -5
  45. package/docs/DETERMINISTIC_REPAIR_SAFETY_MODEL.md +705 -0
  46. package/docs/FAILURE_TAXONOMY.md +30 -1
  47. package/docs/HONESTY_CONTRACT.md +55 -4
  48. package/docs/LAUNCH_PLAYBOOK.md +232 -0
  49. package/docs/REAL_REPO_EVIDENCE.md +77 -0
  50. package/docs/REAL_WORLD_FIXTURES.md +105 -0
  51. package/docs/REGISTRY.md +48 -28
  52. package/docs/RELEASE_CHECKLIST.md +9 -1
  53. package/docs/REPAIR_RECEIPT.md +224 -0
  54. package/docs/agent-loop-gap-analysis.md +188 -0
  55. package/docs/examples/registry-seeds/advertised-port-mismatch.json +28 -0
  56. package/docs/examples/registry-seeds/airbyte-abctl-external-orchestrator.json +36 -0
  57. package/docs/examples/registry-seeds/go-ollama-service.json +36 -0
  58. package/docs/examples/registry-seeds/laravel-vite-sqlite.json +36 -0
  59. package/docs/examples/registry-seeds/monorepo-ambiguous-health.json +29 -0
  60. package/docs/examples/registry-seeds/php-composer.json +33 -0
  61. package/docs/examples/registry-seeds/rails-bundler.json +32 -0
  62. package/docs/examples/registry-seeds/sentry-devenv-direnv.json +41 -0
  63. package/docs/schemas/action-verdict-v1.schema.json +64 -0
  64. package/docs/schemas/agent-plan-v1.schema.json +148 -0
  65. package/docs/schemas/agent-run-receipts-v1.schema.json +192 -0
  66. package/docs/schemas/ai-repair-suggestion-v1.schema.json +70 -0
  67. package/docs/schemas/ci-context-v1.schema.json +63 -0
  68. package/docs/schemas/diff-result-v1.schema.json +66 -0
  69. package/docs/schemas/federated-receipt-v1.schema.json +51 -0
  70. package/docs/schemas/registry-entry-v1.schema.json +95 -0
  71. package/docs/schemas/registry-seed-example-v1.schema.json +102 -0
  72. package/docs/schemas/repair-action-v1.schema.json +136 -0
  73. package/docs/schemas/repair-receipt-v1.schema.json +221 -0
  74. package/package.json +13 -6
package/dist/cli.js CHANGED
@@ -1,22 +1,48 @@
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
- import { cloneGithubRemote, isRemoteTarget, managedRemoteSource } from "./remote.js";
17
+ import { cloneRemoteTarget, isRemoteTarget, managedRemoteSource } from "./remote.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";
13
22
  let GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RED = "\x1b[31m", DIM = "\x1b[2m", BOLD = "\x1b[1m", RESET = "\x1b[0m";
14
23
  const ok = (s) => console.log(`${GREEN}\u2713 ${s}${RESET}`);
15
24
  const would = (s) => console.log(`${DIM}\u25cb would: ${s}${RESET}`);
16
25
  const warn = (s) => console.log(`${YELLOW}! ${s}${RESET}`);
17
26
  const bad = (s) => console.log(`${RED}\u2717 ${s}${RESET}`);
18
27
  const disableColor = () => { GREEN = ""; YELLOW = ""; RED = ""; DIM = ""; BOLD = ""; RESET = ""; };
19
- const COMMANDS = ["up", "analyze", "plan", "verify", "explain", "attest", "help", "version", "--help", "-h", "--version"];
28
+ const portableRelative = (from, to) => path.relative(from, to).replace(/\\/g, "/");
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
+ };
20
46
  void normalizeDockerBindPath;
21
47
  void detectHostPlatform; // exported surface, used by docker provider work in progress
22
48
  if (process.env.NO_COLOR !== undefined)
@@ -45,26 +71,68 @@ function help() {
45
71
  console.log(`${BOLD}bootproof${RESET} — Human diagnosis. Machine proof. One engine.
46
72
 
47
73
  Usage:
48
- bootproof analyze <path|github-url> [--workspace dir] [--json]
74
+ bootproof analyze <path|git-url> [--workspace dir] [--json]
49
75
  inspect a repo, show evidence-based inference
50
- bootproof plan <path|github-url> [--workspace dir] show the run plan and files that WOULD be generated
51
- bootproof up <path|github-url> [options] execute the plan, verify localhost, write signed proof
52
- bootproof verify <path|attestation.json> validate an attestation signature and inspect its claim
53
- bootproof explain <attestation.json> human explanation of an attestation
54
- bootproof attest export <path> redacted, re-signed shareable registry entry (never uploads)
55
- bootproof attest check <path> verify a registry entry signature
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
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
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
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
85
+ bootproof verify <path|proof.json> validate an attestation or repair-receipt signature
86
+ bootproof explain <proof.json> explain an attestation or repair receipt
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)
56
93
  bootproof version
57
94
 
58
95
  Options for up:
59
96
  --provider docker|local execution provider (default docker)
60
- --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
61
100
  --install run the dependency install step (off by default)
62
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
63
104
  --port <n> override inferred port
105
+ --health-path <path> override inferred health endpoint path (e.g. /health, /healthz)
64
106
  --timeout <ms> health verification timeout (default 60000)
65
107
  --dry-run show what would happen; executes nothing, writes nothing
66
108
  --json one bootproof/result/v1 JSON object on stdout
67
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)
112
+
113
+ Options for fix:
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
116
+ --port <n> override inferred application port
117
+ --timeout <ms> before/after health timeout (default 60000)
118
+ --ai optional BYOK suggestion after no deterministic repair is known
119
+ --dry-run execute nothing, write nothing, produce no repair proof
120
+ --json one bootproof/repair-result/v1 object on stdout
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
68
136
 
69
137
  Honesty contract: no green check without an observed event; dry runs say "would";
70
138
  .env/.env.local are never written; secrets are never invented.
@@ -81,6 +149,11 @@ function printInference(inf) {
81
149
  console.log(` frontend markers: ${inf.frontendMarkers.join(", ")}`);
82
150
  if (inf.serviceMarkers.length)
83
151
  console.log(` service markers: ${inf.serviceMarkers.join(", ")}`);
152
+ if (inf.repoComposeFile)
153
+ console.log(` repo compose: ${inf.repoComposeFile} (bootproof defers to it)`);
154
+ if (inf.composeApplicationServices.length) {
155
+ console.log(` compose HTTP services: ${inf.composeApplicationServices.map(service => `${service.name} (${service.source === "build" ? "builds checked-out source" : "image only"})`).join("; ")}`);
156
+ }
84
157
  console.log(` package manager: ${inf.packageManager} ${DIM}(${inf.packageManagerEvidence})${RESET}`);
85
158
  if (inf.setupSteps.length)
86
159
  console.log(` setup steps: ${inf.setupSteps.join("; ")}`);
@@ -88,12 +161,19 @@ function printInference(inf) {
88
161
  console.log(` backend command: ${inf.backendCommand}`);
89
162
  if (inf.frontendCommand)
90
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}`);
91
166
  if (inf.workerCommand)
92
167
  console.log(` worker command: ${inf.workerCommand}`);
93
168
  if (inf.appCommand)
94
169
  console.log(` selected command: ${inf.appCommand} ${DIM}(${inf.appCommandSource})${RESET}`);
170
+ if (inf.preparationCommands.length)
171
+ console.log(` preparation: ${inf.preparationCommands.map(command => command.command).join("; ")}`);
95
172
  console.log(` command scope: ${inf.commandScope}`);
96
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}`);
97
177
  if (inf.healthCandidates.length)
98
178
  console.log(` health candidates: ${inf.healthCandidates.join(", ")}`);
99
179
  if (inf.services.length)
@@ -138,6 +218,84 @@ function machineFailure(explanation) {
138
218
  writtenFiles: [],
139
219
  };
140
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
+ }
141
299
  function printFailure(failureClass, diagnosis, evidencePath) {
142
300
  bad(`${BOLD}NOT VERIFIED${RESET}${RED} — ${failureClass}`);
143
301
  console.log(`What happened: ${diagnosis.whatHappened}`);
@@ -145,6 +303,216 @@ function printFailure(failureClass, diagnosis, evidencePath) {
145
303
  console.log(`Safe next step: ${diagnosis.safeNextStep}`);
146
304
  console.log(`Evidence: ${evidencePath}`);
147
305
  }
306
+ function isRepairReceipt(value) {
307
+ return Boolean(value && typeof value === "object" && value.schema === "bootproof/repair-receipt/v1");
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
+ }
370
+ function printRepairResult(result) {
371
+ if (result.repaired) {
372
+ ok(`${BOLD}VERIFIED REPAIR${RESET}${GREEN} — ${result.repairId}`);
373
+ console.log(result.explanation);
374
+ if (result.patchPath)
375
+ console.log(`Patch: ${result.patchPath}`);
376
+ console.log(`Receipt: ${result.receiptPath}`);
377
+ console.log(`After attestation: ${result.afterAttestationPath}`);
378
+ return;
379
+ }
380
+ bad(`${BOLD}NO VERIFIED REPAIR${RESET}${RED}${result.failureClass ? ` — ${result.failureClass}` : ""}`);
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
+ }
459
+ }
460
+ function printRepairApplyResult(result) {
461
+ if (result.applied) {
462
+ ok(`${BOLD}APPLIED VERIFIED REPAIR${RESET}`);
463
+ console.log(result.explanation);
464
+ console.log(`Receipt: ${result.receiptPath}`);
465
+ return;
466
+ }
467
+ bad(`${BOLD}REPAIR NOT APPLIED${RESET}`);
468
+ console.log(result.explanation);
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
+ }
505
+ function rebaseRemoteRepairPaths(result, repo) {
506
+ const rebase = (value) => value
507
+ ? portableRelative(process.cwd(), path.join(repo, value))
508
+ : null;
509
+ return {
510
+ ...result,
511
+ receiptPath: rebase(result.receiptPath),
512
+ patchPath: rebase(result.patchPath),
513
+ afterAttestationPath: rebase(result.afterAttestationPath),
514
+ };
515
+ }
148
516
  async function main() {
149
517
  const [cmd, ...rest] = process.argv.slice(2);
150
518
  if (!cmd || cmd === "help" || cmd === "--help" || cmd === "-h")
@@ -160,11 +528,123 @@ async function main() {
160
528
  const { flags, positional } = parseFlags(rest);
161
529
  if (flags.ci || flags.json)
162
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
+ }
163
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
+ }
164
644
  let target = path.resolve(targetInput);
165
645
  let remote = null;
166
646
  let remoteSource = null;
167
- if (["analyze", "plan", "up"].includes(cmd) && isRemoteTarget(targetInput)) {
647
+ if (["analyze", "plan", "plan-agent", "up", "fix"].includes(cmd) && isRemoteTarget(targetInput)) {
168
648
  if (flags["dry-run"]) {
169
649
  const explanation = "Remote dry runs are refused because cloning would write files, while BootProof dry runs promise to write nothing.";
170
650
  if (flags.json)
@@ -175,12 +655,12 @@ async function main() {
175
655
  return;
176
656
  }
177
657
  try {
178
- remote = cloneGithubRemote(targetInput, process.cwd());
658
+ remote = cloneRemoteTarget(targetInput, process.cwd());
179
659
  target = remote.repoPath;
180
660
  remoteSource = remote.canonicalUrl;
181
661
  if (!flags.json) {
182
662
  console.log(`${DIM}Remote source: ${remote.canonicalUrl}${RESET}`);
183
- console.log(`${DIM}Clone retained at: ${path.relative(process.cwd(), remote.repoPath)}${RESET}`);
663
+ console.log(`${DIM}Clone retained at: ${portableRelative(process.cwd(), remote.repoPath)}${RESET}`);
184
664
  }
185
665
  }
186
666
  catch (error) {
@@ -193,14 +673,14 @@ async function main() {
193
673
  return;
194
674
  }
195
675
  }
196
- if (!remote && ["analyze", "plan", "up"].includes(cmd)) {
676
+ if (!remote && ["analyze", "plan", "plan-agent", "up", "fix"].includes(cmd)) {
197
677
  remoteSource = managedRemoteSource(target);
198
678
  if (remoteSource && !flags.json) {
199
679
  console.log(`${DIM}Managed remote source: ${remoteSource}${RESET}`);
200
680
  }
201
681
  }
202
682
  const evidencePath = remote
203
- ? path.relative(process.cwd(), attestationPath(target))
683
+ ? portableRelative(process.cwd(), attestationPath(target))
204
684
  : ".bootproof/attestation.json";
205
685
  if (cmd === "analyze") {
206
686
  const inf = inferRepo(target, { workspace: flags.workspace });
@@ -223,17 +703,209 @@ async function main() {
223
703
  console.log(`${DIM}--- .env.bootproof.example (preview) ---\n${envExampleFor(inf)}${RESET}`);
224
704
  return;
225
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
+ }
720
+ if (cmd === "apply-repair") {
721
+ if (isRemoteTarget(targetInput)) {
722
+ const result = {
723
+ schema: "bootproof/repair-apply-result/v1",
724
+ applied: false,
725
+ receiptPath: String(flags.receipt ?? ".bootproof/repair-receipt.json"),
726
+ filesChanged: [],
727
+ explanation: "apply-repair requires a local working tree; use the retained managed clone path for a remote repair",
728
+ };
729
+ if (flags.json)
730
+ console.log(JSON.stringify(result));
731
+ else
732
+ printRepairApplyResult(result);
733
+ process.exitCode = 1;
734
+ return;
735
+ }
736
+ if (flags["dry-run"]) {
737
+ const result = {
738
+ schema: "bootproof/repair-apply-result/v1",
739
+ applied: false,
740
+ receiptPath: String(flags.receipt ?? ".bootproof/repair-receipt.json"),
741
+ filesChanged: [],
742
+ explanation: "Dry run — no repair files were applied.",
743
+ };
744
+ if (flags.json)
745
+ console.log(JSON.stringify(result));
746
+ else
747
+ printRepairApplyResult(result);
748
+ process.exitCode = 1;
749
+ return;
750
+ }
751
+ const receipt = flags.receipt
752
+ ? path.resolve(String(flags.receipt))
753
+ : path.join(target, ".bootproof", "repair-receipt.json");
754
+ const result = applyVerifiedRepair(target, receipt);
755
+ if (flags.json)
756
+ console.log(JSON.stringify(result));
757
+ else
758
+ printRepairApplyResult(result);
759
+ process.exitCode = result.applied ? 0 : 1;
760
+ return;
761
+ }
762
+ if (cmd === "fix") {
763
+ if (flags["dry-run"]) {
764
+ const result = {
765
+ schema: "bootproof/repair-result/v1",
766
+ repaired: false,
767
+ failureClass: null,
768
+ repairId: null,
769
+ receiptPath: null,
770
+ patchPath: null,
771
+ afterAttestationPath: null,
772
+ explanation: "Dry run — nothing was executed, nothing was written, and no repair proof exists.",
773
+ };
774
+ if (flags.json)
775
+ console.log(JSON.stringify(result));
776
+ else
777
+ printRepairResult(result);
778
+ process.exitCode = 1;
779
+ return;
780
+ }
781
+ const provider = flags.provider;
782
+ const timeoutMs = Number(flags.timeout ?? 60_000);
783
+ const port = flags.port === undefined ? undefined : Number(flags.port);
784
+ const optionError = provider !== undefined && provider !== "docker" && provider !== "local"
785
+ ? `invalid --provider value: ${String(provider)} (expected docker or local)`
786
+ : !Number.isFinite(timeoutMs) || timeoutMs <= 0
787
+ ? `invalid --timeout value: ${String(flags.timeout)} (expected a positive number)`
788
+ : port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)
789
+ ? `invalid --port value: ${String(flags.port)} (expected an integer from 1 to 65535)`
790
+ : null;
791
+ if (optionError) {
792
+ const result = {
793
+ schema: "bootproof/repair-result/v1",
794
+ repaired: false,
795
+ failureClass: null,
796
+ repairId: null,
797
+ receiptPath: null,
798
+ patchPath: null,
799
+ afterAttestationPath: null,
800
+ explanation: optionError,
801
+ };
802
+ if (flags.json)
803
+ console.log(JSON.stringify(result));
804
+ else
805
+ printRepairResult(result);
806
+ process.exitCode = 1;
807
+ return;
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
+ }
876
+ const repairResult = await repairRepo(target, {
877
+ provider: provider,
878
+ unsafeLocal: Boolean(flags["unsafe-local"]),
879
+ timeoutMs,
880
+ port,
881
+ remoteSource: remoteSource ?? undefined,
882
+ actionApproved,
883
+ });
884
+ const result = remote ? rebaseRemoteRepairPaths(repairResult, target) : repairResult;
885
+ if (flags.json)
886
+ console.log(JSON.stringify(result));
887
+ else
888
+ printRepairResult(result);
889
+ process.exitCode = result.repaired ? 0 : 1;
890
+ return;
891
+ }
226
892
  if (cmd === "up") {
227
893
  const provider = flags.provider ?? "docker";
228
894
  const timeoutMs = Number(flags.timeout ?? 60_000);
229
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;
230
898
  const optionError = provider !== "docker" && provider !== "local"
231
899
  ? `invalid --provider value: ${String(provider)} (expected docker or local)`
232
900
  : !Number.isFinite(timeoutMs) || timeoutMs <= 0
233
901
  ? `invalid --timeout value: ${String(flags.timeout)} (expected a positive number)`
234
902
  : port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)
235
903
  ? `invalid --port value: ${String(flags.port)} (expected an integer from 1 to 65535)`
236
- : 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;
237
909
  if (optionError) {
238
910
  if (flags.json)
239
911
  console.log(JSON.stringify(machineFailure(optionError)));
@@ -251,9 +923,18 @@ async function main() {
251
923
  timeoutMs,
252
924
  install: Boolean(flags.install),
253
925
  port,
926
+ command: typeof command === "string" ? command : undefined,
927
+ healthPath,
928
+ ciOidc: Boolean(flags["ci-oidc"]),
254
929
  };
255
930
  const outcome = await up(target, opts);
256
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
+ }
257
938
  if (flags.json) {
258
939
  console.log(JSON.stringify(machineResult(outcome, evidencePath)));
259
940
  if (flags.ci || !opts.dryRun)
@@ -296,16 +977,43 @@ async function main() {
296
977
  return;
297
978
  }
298
979
  if (cmd === "verify") {
299
- 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;
300
982
  if (!fs.existsSync(p)) {
301
- bad(`no attestation at ${p} — run bootproof up first, or this repo has no committed proof yet`);
983
+ bad(`no proof at ${p} — run bootproof up or bootproof fix first`);
302
984
  process.exitCode = 1;
303
985
  return;
304
986
  }
305
- const att = JSON.parse(fs.readFileSync(p, "utf8"));
306
- const sig = verifySignature(att);
307
- (sig ? ok : bad)(`signature ${sig ? "valid" : "INVALID"} (ed25519, trust-on-first-use)`);
308
- console.log(`Trust level: ${att.trust?.level ?? "legacy_unspecified"}`);
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"]);
990
+ if (isRepairReceipt(proof)) {
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))
999
+ process.exitCode = 1;
1000
+ return;
1001
+ }
1002
+ const att = proof;
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
+ }
309
1017
  console.log(`${DIM}attested: booted=${att.result.booted} at commit ${att.repo.commit ?? "unknown"} on ${att.environment.os} node ${att.environment.node}${RESET}`);
310
1018
  const retainedRemote = managedRemoteSource(att.repo.path);
311
1019
  if (retainedRemote) {
@@ -315,29 +1023,85 @@ async function main() {
315
1023
  else {
316
1024
  console.log(`Replaying attested plan with bootproof up --provider ${att.plan.provider} would re-verify it on this machine.`);
317
1025
  }
318
- if (att.result.booted) {
1026
+ if (att.result.booted && !commitMismatch && (trust.tier === "self" || trust.tier === "known")) {
319
1027
  const live = await pollHealth(att.plan.healthUrl, 3000);
320
1028
  if (live.responded)
321
1029
  ok(`bonus observation: ${att.plan.healthUrl} is responding right now (HTTP ${live.status})`);
322
1030
  else
323
1031
  console.log(`${DIM}(app not currently running — attestation describes a past verified run)${RESET}`);
324
1032
  }
325
- 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))
326
1040
  process.exitCode = 1;
327
1041
  return;
328
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
+ }
329
1088
  if (cmd === "attest") {
330
1089
  const sub = positional[0];
331
1090
  const repo = path.resolve(String(positional[1] ?? "."));
332
1091
  if (sub === "export") {
333
- const ap = attestationPath(repo);
334
- if (!fs.existsSync(ap)) {
335
- 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`);
336
1102
  process.exitCode = 1;
337
1103
  return;
338
1104
  }
339
- const att = JSON.parse(fs.readFileSync(ap, "utf8"));
340
- const entry = buildRegistryEntry(att);
341
1105
  const out = writeRegistryEntry(repo, entry);
342
1106
  ok(`wrote redacted registry entry: ${out}`);
343
1107
  console.log(`${DIM}redactions applied: ${entry.redactionsApplied.length ? entry.redactionsApplied.join(", ") : "none needed"}${RESET}`);
@@ -354,10 +1118,12 @@ async function main() {
354
1118
  return;
355
1119
  }
356
1120
  const entry = JSON.parse(fs.readFileSync(ep, "utf8"));
357
- const valid = verifyRegistryEntry(entry);
358
- (valid ? ok : bad)(`registry entry signature ${valid ? "valid" : "INVALID"}`);
359
- console.log(`${DIM}booted=${entry.result.booted} class=${entry.result.failureClass ?? "none"} commit=${entry.repo.commit?.slice(0, 8) ?? "?"}${RESET}`);
360
- 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))
361
1127
  process.exitCode = 1;
362
1128
  return;
363
1129
  }
@@ -365,12 +1131,121 @@ async function main() {
365
1131
  process.exitCode = 1;
366
1132
  return;
367
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
+ }
368
1194
  if (cmd === "explain") {
369
1195
  const p = positional[0] ? path.resolve(positional[0]) : attestationPath(target);
370
- const att = JSON.parse(fs.readFileSync(p, "utf8"));
1196
+ const proof = JSON.parse(fs.readFileSync(p, "utf8"));
1197
+ if (isRepairReceipt(proof)) {
1198
+ const trust = evaluateRepairReceiptSignature(proof);
1199
+ console.log(`${BOLD}Repair receipt explained${RESET}`);
1200
+ console.log(`Signature: ${signatureTrustText(trust).replace(/^signature /, "")}`);
1201
+ if (!trust.integrityValid) {
1202
+ console.log("The receipt has been tampered with or is malformed. Its repair claims are not trusted.");
1203
+ process.exitCode = 1;
1204
+ return;
1205
+ }
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
+ }
1224
+ return;
1225
+ }
1226
+ const att = proof;
1227
+ const trust = evaluateAttestationSignature(att);
371
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
+ }
372
1247
  console.log(att.result.booted ? `This run BOOTED: ${att.result.healthObservation}.` : `This run did NOT verify. Failure class: ${att.result.failureClass}.`);
373
- 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)`);
374
1249
  if (!att.result.booted && att.result.failureClass) {
375
1250
  const diagnosis = diagnoseFailure(att.result.failureClass, att.result.failureEvidence, att.result.explanation);
376
1251
  console.log(`What happened: ${diagnosis.whatHappened}`);
@@ -392,9 +1267,32 @@ async function main() {
392
1267
  }
393
1268
  main().catch(err => {
394
1269
  const argv = process.argv.slice(2);
395
- if (argv[0] === "up" && argv.includes("--json")) {
1270
+ if (argv.includes("--json") && (argv[0] === "up" || argv[0] === "verify-url")) {
396
1271
  console.log(JSON.stringify(machineFailure(String(err?.message ?? err))));
397
1272
  }
1273
+ else if (argv.includes("--json") && argv[0] === "fix") {
1274
+ const result = {
1275
+ schema: "bootproof/repair-result/v1",
1276
+ repaired: false,
1277
+ failureClass: null,
1278
+ repairId: null,
1279
+ receiptPath: null,
1280
+ patchPath: null,
1281
+ afterAttestationPath: null,
1282
+ explanation: String(err?.message ?? err),
1283
+ };
1284
+ console.log(JSON.stringify(result));
1285
+ }
1286
+ else if (argv.includes("--json") && argv[0] === "apply-repair") {
1287
+ const result = {
1288
+ schema: "bootproof/repair-apply-result/v1",
1289
+ applied: false,
1290
+ receiptPath: ".bootproof/repair-receipt.json",
1291
+ filesChanged: [],
1292
+ explanation: String(err?.message ?? err),
1293
+ };
1294
+ console.log(JSON.stringify(result));
1295
+ }
398
1296
  else {
399
1297
  bad(String(err?.message ?? err));
400
1298
  }