bootproof 0.3.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +844 -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/DISTRIBUTION.md +83 -0
  45. package/docs/FAILURE_TAXONOMY.md +28 -1
  46. package/docs/HONESTY_CONTRACT.md +34 -12
  47. package/docs/LAUNCH_PLAYBOOK.md +232 -0
  48. package/docs/REAL_WORLD_FIXTURES.md +105 -0
  49. package/docs/REGISTRY.md +48 -28
  50. package/docs/REPAIR_RECEIPT.md +54 -8
  51. package/docs/agent-loop-gap-analysis.md +188 -0
  52. package/docs/examples/registry-seeds/advertised-port-mismatch.json +28 -0
  53. package/docs/examples/registry-seeds/airbyte-abctl-external-orchestrator.json +36 -0
  54. package/docs/examples/registry-seeds/go-ollama-service.json +36 -0
  55. package/docs/examples/registry-seeds/laravel-vite-sqlite.json +36 -0
  56. package/docs/examples/registry-seeds/monorepo-ambiguous-health.json +29 -0
  57. package/docs/examples/registry-seeds/php-composer.json +33 -0
  58. package/docs/examples/registry-seeds/rails-bundler.json +32 -0
  59. package/docs/examples/registry-seeds/sentry-devenv-direnv.json +41 -0
  60. package/docs/schemas/action-verdict-v1.schema.json +64 -0
  61. package/docs/schemas/agent-plan-v1.schema.json +148 -0
  62. package/docs/schemas/agent-run-receipts-v1.schema.json +192 -0
  63. package/docs/schemas/ai-repair-suggestion-v1.schema.json +70 -0
  64. package/docs/schemas/ci-context-v1.schema.json +63 -0
  65. package/docs/schemas/diff-result-v1.schema.json +66 -0
  66. package/docs/schemas/federated-receipt-v1.schema.json +51 -0
  67. package/docs/schemas/registry-entry-v1.schema.json +95 -0
  68. package/docs/schemas/registry-seed-example-v1.schema.json +102 -0
  69. package/docs/schemas/repair-action-v1.schema.json +136 -0
  70. package/docs/schemas/repair-receipt-v1.schema.json +221 -0
  71. package/package.json +21 -11
package/dist/run.d.ts CHANGED
@@ -10,6 +10,9 @@ export interface UpOptions {
10
10
  port?: number;
11
11
  environment?: Record<string, string>;
12
12
  additionalPreparationCommands?: PreparationCommand[];
13
+ command?: string;
14
+ healthPath?: string;
15
+ ciOidc?: boolean;
13
16
  }
14
17
  export interface UpOutcome {
15
18
  inference: Inference;
package/dist/run.js CHANGED
@@ -3,17 +3,89 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import { inferRepo } from "./infer.js";
5
5
  import { buildPlan, writePlanFiles } from "./plan.js";
6
- import { runToCompletion, superviseApp, pollHealthCandidates, minimalEnv } from "./exec.js";
7
- import { classifyFailure, extractMissingEnvNames } from "./taxonomy.js";
8
- import { buildAttestation, writeAttestation } from "./proof.js";
6
+ import { buildExecutionEnv, detectHealthCandidatePortMismatch, execResultEvidence, extractHealthCandidates, healthCandidatePortMismatchEvidence, processEvidenceText, runToCompletion, superviseApp, pollHealthCandidates, probeHealthCandidatesOnce, } from "./exec.js";
7
+ import { classifyFailure, extractMissingEnvNames, safeLocalEnvValue } from "./taxonomy.js";
8
+ import { buildAttestation, writeAttestation, resolveTrust } from "./proof.js";
9
+ import { redactText } from "./redact.js";
9
10
  function classifyHealthFailure(evidence) {
10
11
  if (/(only HTTP 5\d\d observed|HTTP 5\d\d|status\s*5\d\d|returned 5\d\d)/i.test(evidence)) {
11
12
  return "health_http_error";
12
13
  }
13
14
  return "health_check_timeout";
14
15
  }
15
- function step(id, kind, command, startedAt, exitCode, ok, observation, evidenceTail) {
16
- return { id, kind, command, startedAt, finishedAt: new Date().toISOString(), exitCode, ok, observation, evidenceTail };
16
+ /**
17
+ * Augment the app_exited_early explanation with the last ~10 lines of captured
18
+ * process output (stdout and stderr are combined at capture time). The evidence
19
+ * is already stored in the attestation; this only surfaces it in the explanation
20
+ * field. Redaction is applied by buildAttestation before persistence.
21
+ */
22
+ function appExitedEarlyExplanation(classExplanation, processEvidence) {
23
+ const tailLines = processEvidence.evidenceTail
24
+ .split(/\r?\n/)
25
+ .map(line => line.trim())
26
+ .filter(line => line.length > 0)
27
+ .slice(-10);
28
+ if (!tailLines.length)
29
+ return classExplanation;
30
+ return `${classExplanation}\n\nLast process output (stdout and stderr combined):\n${tailLines.join("\n")}`;
31
+ }
32
+ function healthStatusLabel(evidence) {
33
+ const status = `HTTP ${evidence.statusCode}${evidence.statusText ? ` ${evidence.statusText}` : ""}`;
34
+ return evidence.redirectLocation ? `${status} → ${evidence.redirectLocation}` : status;
35
+ }
36
+ function healthObservationSummary(evidence) {
37
+ if (evidence.redirectLocation)
38
+ return `${healthStatusLabel(evidence)} at ${evidence.requestedUrl}`;
39
+ return `HTTP ${evidence.statusCode} at ${evidence.requestedUrl}`;
40
+ }
41
+ function healthPort(url) {
42
+ try {
43
+ const parsed = new URL(url);
44
+ const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
45
+ return Number.isInteger(port) ? port : null;
46
+ }
47
+ catch {
48
+ return null;
49
+ }
50
+ }
51
+ function recordHealthPort(inference, plan, url, source) {
52
+ const port = healthPort(url);
53
+ if (port === null)
54
+ return;
55
+ inference.observedPort = port;
56
+ inference.healthCandidateSource = source;
57
+ plan.observedPort = port;
58
+ plan.healthCandidateSource = source;
59
+ if (source === "observed") {
60
+ inference.port = port;
61
+ inference.portEvidence = `observed healthy HTTP response at ${url}`;
62
+ }
63
+ }
64
+ function packageScriptFailureContext(inference) {
65
+ if (!inference.selectedPackageScriptName || !inference.selectedPackageScriptCommand)
66
+ return "";
67
+ return [
68
+ "Package script context",
69
+ `scriptName: ${inference.selectedPackageScriptName}`,
70
+ `scriptCommand: ${redactText(inference.selectedPackageScriptCommand).text}`,
71
+ `packageManager: ${inference.packageManager}`,
72
+ inference.stack.includes("python-backend") && inference.stack.includes("node-frontend")
73
+ ? "projectContext: python-node-hybrid"
74
+ : "",
75
+ ].filter(Boolean).join("\n");
76
+ }
77
+ function step(id, kind, command, startedAt, exitCode, ok, observation, evidence) {
78
+ return {
79
+ id,
80
+ kind,
81
+ command,
82
+ startedAt,
83
+ finishedAt: new Date().toISOString(),
84
+ exitCode,
85
+ ok,
86
+ observation,
87
+ ...(typeof evidence === "string" ? { evidenceTail: evidence } : evidence),
88
+ };
17
89
  }
18
90
  export function packageManagerVersionMatches(expected, actual) {
19
91
  const expectedMatch = expected.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
@@ -84,6 +156,17 @@ function unsupportedOrchestrationExplanation(inference) {
84
156
  export async function up(repoPath, opts) {
85
157
  const startedAt = new Date().toISOString();
86
158
  const inference = inferRepo(repoPath, { workspace: opts.workspace });
159
+ if (opts.command) {
160
+ inference.appCommand = opts.command;
161
+ inference.appCommandSource = "--command override";
162
+ inference.selectedPackageScriptName = null;
163
+ inference.selectedPackageScriptCommand = null;
164
+ inference.projectCliCommand = null;
165
+ inference.projectCliReady = null;
166
+ inference.commandScope = "explicit --command override";
167
+ inference.incompleteAppCommand = false;
168
+ inference.multiAppCommand = false;
169
+ }
87
170
  if (opts.additionalPreparationCommands?.length) {
88
171
  inference.preparationCommands.push(...opts.additionalPreparationCommands);
89
172
  inference.dependencyInstallRequired = true;
@@ -101,13 +184,36 @@ export async function up(repoPath, opts) {
101
184
  inference.portEvidence = `repository Compose published port retained; --port ${opts.port} was not applied`;
102
185
  }
103
186
  }
187
+ if (opts.healthPath) {
188
+ // Override the health endpoint path. Rewrites the path component of every
189
+ // health candidate URL. This resolves the case where an app answers on
190
+ // /health but returns 404 at / — the receipt then honestly signs a clean
191
+ // 200 instead of an honest-but-confusing 404 at the root.
192
+ const rewritePath = (url, newPath) => {
193
+ try {
194
+ const parsed = new URL(url);
195
+ parsed.pathname = newPath;
196
+ parsed.search = "";
197
+ return parsed.toString();
198
+ }
199
+ catch {
200
+ // Not a parseable URL; leave it alone rather than corrupting evidence.
201
+ return url;
202
+ }
203
+ };
204
+ inference.healthCandidates = inference.healthCandidates.map(candidate => rewritePath(candidate, opts.healthPath));
205
+ inference.composeHealthCandidates = inference.composeHealthCandidates.map(candidate => rewritePath(candidate, opts.healthPath));
206
+ inference.healthCandidateSource = "observed";
207
+ inference.portEvidence = `${inference.portEvidence}; health path set by --health-path flag`;
208
+ }
104
209
  const plan = buildPlan(inference, opts.provider);
105
- const env = minimalEnv({ PORT: String(inference.port), ...opts.environment });
210
+ const trust = await resolveTrust({ ciOidc: opts.ciOidc });
211
+ const env = buildExecutionEnv({ PORT: String(inference.port), ...opts.environment });
106
212
  const runsSourceComposeApplication = opts.provider === "docker" &&
107
213
  Boolean(inference.repoComposeFile) &&
108
214
  inference.composeHealthCandidates.length > 0;
109
215
  const base = { inference, plan, writtenFiles: [] };
110
- const refuse = (failureClass, explanation, observed = [], failureEvidence = explanation) => {
216
+ const refuse = (failureClass, explanation, observed = [], failureEvidence = explanation, healthEvidence = null, observedHealthCandidates = []) => {
111
217
  const refusal = { failureClass, explanation };
112
218
  if (opts.dryRun)
113
219
  return { ...base, attestation: null, refusal };
@@ -125,10 +231,12 @@ export async function up(repoPath, opts) {
125
231
  booted: false,
126
232
  healthVerified: false,
127
233
  healthObservation: null,
128
- observedHealthCandidates: [],
234
+ healthEvidence,
235
+ observedHealthCandidates,
129
236
  failureClass,
130
237
  failureEvidence: failureEvidence.slice(-2000),
131
238
  explanation,
239
+ trust,
132
240
  });
133
241
  writeAttestation(inference.repoPath, attestation);
134
242
  return { inference, plan: refusalPlan, writtenFiles: [], attestation, refusal };
@@ -141,7 +249,10 @@ export async function up(repoPath, opts) {
141
249
  }
142
250
  const orchestrationExplanation = unsupportedOrchestrationExplanation(inference);
143
251
  if (orchestrationExplanation && !runsSourceComposeApplication) {
144
- return refuse("orchestration_not_supported", orchestrationExplanation);
252
+ const failureClass = inference.stack.includes("go-backend")
253
+ ? "go_service_orchestration_not_supported"
254
+ : "orchestration_not_supported";
255
+ return refuse(failureClass, orchestrationExplanation);
145
256
  }
146
257
  if (!inference.appCommand && inference.composeHealthCandidates.length > 0 && opts.provider !== "docker") {
147
258
  return refuse("orchestration_not_supported", `Detected a source-built application in ${inference.repoComposeFile} with published HTTP candidates, but repository Compose requires --provider docker. Diagnosis only — no localhost claim.`);
@@ -150,10 +261,10 @@ export async function up(repoPath, opts) {
150
261
  return refuse("workspace_ambiguous", `This is a monorepo with ${inference.workspaces.length} workspace candidates. Choose one with --workspace <dir> instead of letting bootproof guess.`);
151
262
  }
152
263
  if (opts.remoteSource && !opts.dryRun && (opts.provider !== "local" || !opts.unsafeLocal)) {
153
- return refuse("unknown_failure", `BootProof cloned ${opts.remoteSource} for inspection but will not execute remote repository code without --provider local --unsafe-local.`);
264
+ return refuse("host_execution_not_acknowledged", `BootProof cloned ${opts.remoteSource} for inspection but will not execute remote repository code without --provider local --unsafe-local.`);
154
265
  }
155
266
  if (opts.provider === "local" && !opts.unsafeLocal) {
156
- return refuse("unknown_failure", "Local provider runs repository code directly on your machine. Re-run with --unsafe-local to acknowledge this, or use --provider docker.");
267
+ return refuse("host_execution_not_acknowledged", "Local provider runs repository code directly on your machine. Re-run with --unsafe-local to acknowledge this, or use --provider docker.");
157
268
  }
158
269
  if (opts.dryRun)
159
270
  return { ...base, attestation: null, refusal: null };
@@ -179,7 +290,20 @@ export async function up(repoPath, opts) {
179
290
  }
180
291
  }
181
292
  if (inference.incompleteAppCommand) {
182
- return refuse("unknown_failure", "BootProof detected a hybrid backend/frontend repository, but the inferred command starts only the frontend development pipeline. Complete application orchestration is not implemented, so no boot was attempted.");
293
+ return refuse("orchestration_not_supported", "BootProof detected a hybrid backend/frontend repository, but the inferred command starts only the frontend development pipeline. Complete application orchestration is not implemented, so no boot was attempted.");
294
+ }
295
+ const localStartStep = opts.provider === "local"
296
+ ? plan.steps.find(planned => planned.kind === "start-app" && planned.command)
297
+ : undefined;
298
+ if (localStartStep && plan.healthCandidates.length > 0) {
299
+ const preflightStartedAt = new Date().toISOString();
300
+ const preflight = await probeHealthCandidatesOnce(plan.healthCandidates);
301
+ if (preflight.responded && preflight.evidence) {
302
+ const url = preflight.evidence.requestedUrl;
303
+ const explanation = `A service was already responding at ${url} before BootProof started anything, so a health response cannot be attributed to this repository. If you intended to verify an already-running service, use: bootproof verify-url ${url} (or bootproof up . --external-health ${url}).`;
304
+ const preflightObservation = step("health-preflight", "health", undefined, preflightStartedAt, null, false, `pre-existing service responded ${healthStatusLabel(preflight.evidence)} at ${url}; application was not started`);
305
+ return refuse("health_preoccupied", explanation, [preflightObservation], explanation, preflight.evidence, [url]);
306
+ }
183
307
  }
184
308
  const writtenFiles = writePlanFiles(inference, inference.repoPath);
185
309
  if (inference.appCommand?.includes(".bootproof/runtime/")) {
@@ -192,15 +316,19 @@ export async function up(repoPath, opts) {
192
316
  const names = extractMissingEnvNames(evidence);
193
317
  if (!names.length)
194
318
  return explanation;
319
+ const safeValue = names.length === 1 ? safeLocalEnvValue(names[0]) : null;
320
+ if (safeValue) {
321
+ return `${explanation} Missing variable: ${names[0]}. Safe local value: ${safeValue}.`;
322
+ }
195
323
  const generatedExample = path.join(inference.repoPath, ".env.bootproof.example");
196
324
  const suffix = fs.existsSync(generatedExample)
197
325
  ? `Missing: ${names.join(", ")} — see .env.bootproof.example; bootproof will not invent values.`
198
- : `Missing: ${names.join(", ")}; bootproof will not invent values.`;
326
+ : `Missing variable${names.length === 1 ? "" : "s"}: ${names.join(", ")}. BootProof will not invent values.`;
199
327
  return `${explanation} ${suffix}`;
200
328
  };
201
- const fail = (failureClass, evidence, explanation) => {
329
+ const fail = (failureClass, evidence, explanation, healthEvidence = null, observedHealthCandidates = []) => {
202
330
  const preciseExplanation = explanationWithMissingEnv(failureClass, evidence, explanation);
203
- const att = buildAttestation({ repo: inference.repoPath, plan, observed, startedAt, booted: false, healthVerified: false, healthObservation: null, observedHealthCandidates: [], failureClass, failureEvidence: evidence.slice(-2000), explanation: preciseExplanation });
331
+ const att = buildAttestation({ repo: inference.repoPath, plan, observed, startedAt, booted: false, healthVerified: false, healthObservation: null, healthEvidence, observedHealthCandidates, failureClass, failureEvidence: evidence.slice(-2000), explanation: preciseExplanation, trust });
204
332
  writeAttestation(inference.repoPath, att);
205
333
  return { inference, plan, writtenFiles, attestation: att, refusal: null };
206
334
  };
@@ -224,7 +352,7 @@ export async function up(repoPath, opts) {
224
352
  const t = new Date().toISOString();
225
353
  const result = await runToCompletion(diagnostic.command, inference.repoPath, 30_000, env);
226
354
  const text = [result.stdout, result.stderr].filter(Boolean).join("\n");
227
- observed.push(step(diagnostic.id, "service", diagnostic.command, t, result.exitCode, result.exitCode === 0, result.exitCode === 0 ? diagnostic.observation : `${diagnostic.observation} failed`, text || undefined));
355
+ observed.push(step(diagnostic.id, "service", diagnostic.command, t, result.exitCode, result.exitCode === 0, result.exitCode === 0 ? diagnostic.observation : `${diagnostic.observation} failed`, result.exitCode === 0 ? text || undefined : execResultEvidence(result)));
228
356
  if (text)
229
357
  evidence.push(`${diagnostic.command}\n${text}`);
230
358
  }
@@ -235,7 +363,7 @@ export async function up(repoPath, opts) {
235
363
  const t = new Date().toISOString();
236
364
  const r = await runToCompletion(planned.command, inference.repoPath, 120_000, env);
237
365
  const ok = r.exitCode === 0;
238
- observed.push(step(planned.id, "service", planned.command, t, r.exitCode, ok, ok ? "docker compose accepted the start request (exit 0); HTTP health not yet verified" : "docker compose failed", r.stderr || r.stdout));
366
+ observed.push(step(planned.id, "service", planned.command, t, r.exitCode, ok, ok ? "docker compose accepted the start request (exit 0); HTTP health not yet verified" : "docker compose failed", ok ? r.stderr || r.stdout : execResultEvidence(r)));
239
367
  if (!ok) {
240
368
  const c = classifyFailure(r.stderr + r.stdout);
241
369
  return fail(c.class, r.stderr + r.stdout, c.explanation);
@@ -249,7 +377,7 @@ export async function up(repoPath, opts) {
249
377
  const t = new Date().toISOString();
250
378
  const r = await runToCompletion(planned.command, inference.repoPath, 600_000, env);
251
379
  const ok = r.exitCode === 0 && !r.timedOut;
252
- observed.push(step(planned.id, planned.kind, planned.command, t, r.exitCode, ok, ok ? `${planned.kind === "install" ? "dependency preparation" : "build"} completed (exit 0)` : r.timedOut ? `${planned.kind} timed out` : `${planned.kind} failed (exit ${r.exitCode})`, ok ? undefined : r.stderr || r.stdout));
380
+ observed.push(step(planned.id, planned.kind, planned.command, t, r.exitCode, ok, ok ? `${planned.kind === "install" ? "dependency preparation" : "build"} completed (exit 0)` : r.timedOut ? `${planned.kind} timed out` : `${planned.kind} failed (exit ${r.exitCode})`, ok ? undefined : execResultEvidence(r)));
253
381
  if (!ok) {
254
382
  const c = classifyFailure(r.stderr + r.stdout);
255
383
  return fail(c.class === "unknown_failure" ? "install_failed" : c.class, r.stderr + r.stdout, c.explanation);
@@ -258,38 +386,80 @@ export async function up(repoPath, opts) {
258
386
  if (planned.kind === "start-app" && planned.command) {
259
387
  const t = new Date().toISOString();
260
388
  const app = superviseApp(planned.command, inference.repoPath, env);
261
- const health = await pollHealthCandidates(plan.healthCandidates, opts.timeoutMs, app.output);
389
+ const inferredHealthUrl = plan.healthUrl;
390
+ const inferredHealthCandidates = new Set(plan.healthCandidates);
391
+ const health = await pollHealthCandidates(plan.healthCandidates, opts.timeoutMs, app.output, 1000, () => app.exited() !== null);
392
+ await new Promise(resolve => setImmediate(resolve));
393
+ const advertisedHealthUrls = extractHealthCandidates(app.output());
394
+ for (const advertisedHealthUrl of advertisedHealthUrls) {
395
+ if (!health.candidates.includes(advertisedHealthUrl)) {
396
+ health.candidates.push(advertisedHealthUrl);
397
+ }
398
+ if (!inferredHealthCandidates.has(advertisedHealthUrl)
399
+ && !health.discoveredCandidates.includes(advertisedHealthUrl)) {
400
+ health.discoveredCandidates.push(advertisedHealthUrl);
401
+ }
402
+ }
262
403
  plan.healthCandidates = health.candidates;
404
+ const processOutputHealthUrl = advertisedHealthUrls[0] ?? health.discoveredCandidates[0];
405
+ if (processOutputHealthUrl) {
406
+ recordHealthPort(inference, plan, processOutputHealthUrl, "process_output");
407
+ }
263
408
  if (health.url)
264
409
  plan.healthUrl = health.url;
410
+ const portMismatch = detectHealthCandidatePortMismatch(inferredHealthUrl, health.discoveredCandidates, planned.command);
411
+ const portMismatchEvidence = healthCandidatePortMismatchEvidence(portMismatch);
412
+ const packageScriptContext = packageScriptFailureContext(inference);
265
413
  const exit = app.exited();
266
- if (exit && !health.responded) {
267
- observed.push(step(planned.id, "start-app", planned.command, t, exit.code, false, `app process exited (code ${exit.code}) before responding`, app.output()));
268
- const c = classifyFailure(app.output());
414
+ if (exit) {
415
+ const processEvidence = app.evidence();
416
+ const unattributedHealthEvidence = health.evidence?.acceptedAsHealthy
417
+ ? `An accepted ${healthStatusLabel(health.evidence)} response was observed at ${health.evidence.requestedUrl}, but the supervised process had already exited, so BootProof did not attribute that response to this repository.`
418
+ : "";
419
+ const evidence = [
420
+ processEvidenceText(processEvidence),
421
+ unattributedHealthEvidence,
422
+ portMismatchEvidence,
423
+ packageScriptContext,
424
+ ].filter(Boolean).join("\n");
425
+ const observation = health.evidence?.acceptedAsHealthy
426
+ ? `app process exited (code ${exit.code}) before the accepted health response could be attributed to it`
427
+ : `app process exited (code ${exit.code}) before responding`;
428
+ observed.push(step(planned.id, "start-app", planned.command, t, exit.code, false, observation, processEvidence));
429
+ const c = classifyFailure(evidence);
269
430
  await app.stop();
270
- return fail(c.class === "unknown_failure" ? "app_exited_early" : c.class, app.output(), c.explanation);
431
+ const finalExplanation = c.class === "unknown_failure"
432
+ ? appExitedEarlyExplanation(c.explanation, processEvidence)
433
+ : c.explanation;
434
+ return fail(c.class === "unknown_failure" ? "app_exited_early" : c.class, evidence, finalExplanation, health.evidence, health.discoveredCandidates);
271
435
  }
272
436
  observed.push(step(planned.id, "start-app", planned.command, t, null, true, "app process started and was supervised"));
273
437
  const ht = new Date().toISOString();
274
- if (health.responded && health.status !== null && health.status < 500) {
275
- const observedUrl = health.url ?? plan.healthUrl;
276
- observed.push(step("health", "health", undefined, ht, null, true, `observed HTTP ${health.status} at ${observedUrl} after ${health.elapsedMs}ms (${health.attempts} attempts)`));
438
+ if (health.evidence?.acceptedAsHealthy) {
439
+ recordHealthPort(inference, plan, health.evidence.requestedUrl, "observed");
440
+ const healthStep = health.evidence.redirectLocation
441
+ ? healthStatusLabel(health.evidence)
442
+ : `observed HTTP ${health.evidence.statusCode} at ${health.evidence.requestedUrl} after ${health.elapsedMs}ms (${health.attempts} attempts)`;
443
+ observed.push(step("health", "health", undefined, ht, null, true, healthStep));
277
444
  await app.stop();
278
- const att = buildAttestation({ repo: inference.repoPath, plan, observed, startedAt, booted: true, healthVerified: true, healthObservation: `HTTP ${health.status} at ${observedUrl}`, observedHealthCandidates: health.discoveredCandidates, failureClass: null, failureEvidence: null, explanation: `Verified: ${observedUrl} responded HTTP ${health.status}. This attestation records what was observed, not a guarantee the app is fully functional.` });
445
+ const att = buildAttestation({ repo: inference.repoPath, plan, observed, startedAt, booted: true, healthVerified: true, healthObservation: healthObservationSummary(health.evidence), healthEvidence: health.evidence, observedHealthCandidates: health.discoveredCandidates, failureClass: null, failureEvidence: null, explanation: `Verified: ${health.evidence.requestedUrl} responded ${healthStatusLabel(health.evidence)}. This attestation records what was observed, not a guarantee the app is fully functional.`, trust });
279
446
  writeAttestation(inference.repoPath, att);
280
447
  return { inference, plan, writtenFiles, attestation: att, refusal: null };
281
448
  }
282
- const evidence = app.output();
449
+ const processEvidence = app.evidence();
450
+ const evidence = [processEvidenceText(processEvidence), portMismatchEvidence, packageScriptContext].filter(Boolean).join("\n");
283
451
  const healthFailureMessage = health.responded
284
452
  ? `only HTTP ${health.status} observed at ${health.url ?? plan.healthUrl}`
285
453
  : `no HTTP response at candidates ${health.candidates.join(", ")} within ${opts.timeoutMs}ms`;
286
- observed.push(step("health", "health", undefined, ht, null, false, healthFailureMessage, evidence));
454
+ observed.push(step("health", "health", undefined, ht, null, false, healthFailureMessage, processEvidence));
287
455
  const c = classifyFailure(`${healthFailureMessage}\n${evidence}`);
288
- const healthClass = health.responded && health.status !== null && health.status >= 500
289
- ? "health_http_error"
290
- : c.class === "unknown_failure"
291
- ? classifyHealthFailure(healthFailureMessage)
292
- : c.class;
456
+ const healthClass = portMismatch
457
+ ? "health_candidate_port_mismatch"
458
+ : health.responded && health.status !== null && health.status >= 500
459
+ ? "health_http_error"
460
+ : c.class === "unknown_failure"
461
+ ? classifyHealthFailure(healthFailureMessage)
462
+ : c.class;
293
463
  const healthExplanation = healthClass === "health_http_error"
294
464
  ? "The app responded on the configured health URL, but returned HTTP 5xx. BootProof observed a running server, but not a verified healthy boot."
295
465
  : c.explanation;
@@ -303,10 +473,12 @@ export async function up(repoPath, opts) {
303
473
  booted: false,
304
474
  healthVerified: false,
305
475
  healthObservation: null,
476
+ healthEvidence: health.evidence,
306
477
  observedHealthCandidates: health.discoveredCandidates,
307
478
  failureClass: healthClass,
308
479
  failureEvidence: `${healthFailureMessage}\n${evidence}`.slice(-2000),
309
480
  explanation: preciseHealthExplanation,
481
+ trust,
310
482
  });
311
483
  writeAttestation(inference.repoPath, att);
312
484
  return { inference, plan, writtenFiles, attestation: att, refusal: null };
@@ -317,9 +489,12 @@ export async function up(repoPath, opts) {
317
489
  plan.healthCandidates = health.candidates;
318
490
  if (health.url)
319
491
  plan.healthUrl = health.url;
320
- if (health.responded && health.status !== null && health.status < 500) {
321
- const observedUrl = health.url ?? plan.healthUrl;
322
- observed.push(step("health", "health", undefined, ht, null, true, `observed HTTP ${health.status} at ${observedUrl} after ${health.elapsedMs}ms (${health.attempts} attempts)`));
492
+ if (health.evidence?.acceptedAsHealthy) {
493
+ recordHealthPort(inference, plan, health.evidence.requestedUrl, "observed");
494
+ const healthStep = health.evidence.redirectLocation
495
+ ? healthStatusLabel(health.evidence)
496
+ : `observed HTTP ${health.evidence.statusCode} at ${health.evidence.requestedUrl} after ${health.elapsedMs}ms (${health.attempts} attempts)`;
497
+ observed.push(step("health", "health", undefined, ht, null, true, healthStep));
323
498
  const att = buildAttestation({
324
499
  repo: inference.repoPath,
325
500
  plan,
@@ -327,11 +502,13 @@ export async function up(repoPath, opts) {
327
502
  startedAt,
328
503
  booted: true,
329
504
  healthVerified: true,
330
- healthObservation: `HTTP ${health.status} at ${observedUrl}`,
505
+ healthObservation: healthObservationSummary(health.evidence),
506
+ healthEvidence: health.evidence,
331
507
  observedHealthCandidates: health.discoveredCandidates,
332
508
  failureClass: null,
333
509
  failureEvidence: null,
334
- explanation: `Verified: repository Compose started and ${observedUrl} responded HTTP ${health.status}. This proves the observed web boot, not every service or feature.`,
510
+ explanation: `Verified: repository Compose started and ${health.evidence.requestedUrl} responded ${healthStatusLabel(health.evidence)}. This proves the observed web boot, not every service or feature.`,
511
+ trust,
335
512
  });
336
513
  writeAttestation(inference.repoPath, att);
337
514
  return { inference, plan, writtenFiles, attestation: att, refusal: null };
@@ -353,8 +530,8 @@ export async function up(repoPath, opts) {
353
530
  : failureClass === "health_check_timeout"
354
531
  ? "Repository Compose accepted the start request, but no HTTP response was observed. Compose service state and logs are preserved in the attestation."
355
532
  : classified.explanation;
356
- return fail(failureClass, evidence, explanation);
533
+ return fail(failureClass, evidence, explanation, health.evidence, health.discoveredCandidates);
357
534
  }
358
535
  }
359
- return fail("unknown_failure", "", "Inference identified an application, but the plan contained no supported runnable app or source-built Compose health step.");
536
+ return fail("orchestration_not_supported", "", "Inference identified an application, but the plan contained no supported runnable app or source-built Compose health step.");
360
537
  }
package/dist/sbom.d.ts ADDED
@@ -0,0 +1,22 @@
1
+ export type SbomFormat = "cyclonedx-json";
2
+ export interface SbomResult {
3
+ schema: "bootproof/sbom-result/v1";
4
+ format: SbomFormat;
5
+ path: string;
6
+ componentCount: number;
7
+ components: Array<{
8
+ name: string;
9
+ version: string;
10
+ purl: string;
11
+ }>;
12
+ }
13
+ /**
14
+ * Export a Software Bill of Materials from a repository's dependency manifest.
15
+ * Currently supports npm (package-lock.json) and emits CycloneDX JSON.
16
+ *
17
+ * This is a minimal, honest SBOM: it reads the lockfile that already exists,
18
+ * classifies each dependency as a library component, and emits a CycloneDX
19
+ * document. It does not resolve transitive dependencies that aren't in the
20
+ * lockfile, and it does not claim to — the output states what it found.
21
+ */
22
+ export declare function exportSbom(repoPath: string, format?: SbomFormat): SbomResult;
package/dist/sbom.js ADDED
@@ -0,0 +1,99 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { TOOL_ID } from "./proof.js";
4
+ /**
5
+ * Export a Software Bill of Materials from a repository's dependency manifest.
6
+ * Currently supports npm (package-lock.json) and emits CycloneDX JSON.
7
+ *
8
+ * This is a minimal, honest SBOM: it reads the lockfile that already exists,
9
+ * classifies each dependency as a library component, and emits a CycloneDX
10
+ * document. It does not resolve transitive dependencies that aren't in the
11
+ * lockfile, and it does not claim to — the output states what it found.
12
+ */
13
+ export function exportSbom(repoPath, format = "cyclonedx-json") {
14
+ if (format !== "cyclonedx-json") {
15
+ throw new Error(`Unsupported SBOM format: ${format}. Currently supported: cyclonedx-json.`);
16
+ }
17
+ const lockfile = path.join(repoPath, "package-lock.json");
18
+ if (!fs.existsSync(lockfile)) {
19
+ throw new Error(`No package-lock.json found at ${repoPath}. SBOM export currently supports npm repositories with a committed lockfile.`);
20
+ }
21
+ const lock = JSON.parse(fs.readFileSync(lockfile, "utf8"));
22
+ // package-lock.json v2/v3 uses "packages" with keys like "node_modules/express".
23
+ // v1 uses "dependencies" with package names as keys.
24
+ const components = [];
25
+ const seen = new Set();
26
+ if (lock.packages) {
27
+ for (const [key, info] of Object.entries(lock.packages)) {
28
+ // Skip the root package (empty string key) and nested node_modules paths
29
+ // (we want the top-level resolved version, not nested dedupes).
30
+ if (key === "")
31
+ continue;
32
+ const name = key.startsWith("node_modules/") ? key.slice("node_modules/".length) : key;
33
+ // Skip scoped nested paths like node_modules/foo/node_modules/bar
34
+ if (name.includes("node_modules/"))
35
+ continue;
36
+ if (!info.version)
37
+ continue;
38
+ const id = `${name}@${info.version}`;
39
+ if (seen.has(id))
40
+ continue;
41
+ seen.add(id);
42
+ components.push({
43
+ name,
44
+ version: info.version,
45
+ purl: `pkg:npm/${name}@${info.version}`,
46
+ });
47
+ }
48
+ }
49
+ else if (lock.dependencies) {
50
+ for (const [name, info] of Object.entries(lock.dependencies)) {
51
+ if (!info.version)
52
+ continue;
53
+ const id = `${name}@${info.version}`;
54
+ if (seen.has(id))
55
+ continue;
56
+ seen.add(id);
57
+ components.push({
58
+ name,
59
+ version: info.version,
60
+ purl: `pkg:npm/${name}@${info.version}`,
61
+ });
62
+ }
63
+ }
64
+ const appName = lock.name ?? path.basename(path.resolve(repoPath));
65
+ const appVersion = lock.version ?? "unknown";
66
+ const bom = {
67
+ bomFormat: "CycloneDX",
68
+ specVersion: "1.5",
69
+ version: 1,
70
+ metadata: {
71
+ timestamp: new Date().toISOString(),
72
+ tools: [{ vendor: "bootproof", name: "bootproof", version: TOOL_ID.replace(/^bootproof@/, "") }],
73
+ component: {
74
+ type: "application",
75
+ "bom-ref": `pkg:npm/${appName}@${appVersion}`,
76
+ name: appName,
77
+ version: appVersion,
78
+ },
79
+ },
80
+ components: components.map(c => ({
81
+ type: "library",
82
+ "bom-ref": c.purl,
83
+ name: c.name,
84
+ version: c.version,
85
+ purl: c.purl,
86
+ })),
87
+ };
88
+ const outDir = path.join(repoPath, ".bootproof");
89
+ fs.mkdirSync(outDir, { recursive: true });
90
+ const outFile = path.join(outDir, "sbom.cdx.json");
91
+ fs.writeFileSync(outFile, JSON.stringify(bom, null, 2) + "\n");
92
+ return {
93
+ schema: "bootproof/sbom-result/v1",
94
+ format,
95
+ path: outFile,
96
+ componentCount: components.length,
97
+ components,
98
+ };
99
+ }
@@ -1,7 +1,12 @@
1
1
  import type { FailureClass } from "./types.js";
2
- export declare function extractMissingEnvNames(evidence: string): string[];
3
- export declare function classifyFailure(evidence: string): {
2
+ export type FailureMetadata = Record<string, string | string[]>;
3
+ export interface FailureClassification {
4
4
  class: FailureClass;
5
5
  explanation: string;
6
- };
6
+ metadata?: FailureMetadata;
7
+ safeNextStep?: string;
8
+ }
9
+ export declare function extractMissingEnvNames(evidence: string): string[];
10
+ export declare function safeLocalEnvValue(name: string): string | null;
11
+ export declare function classifyFailure(evidence: string): FailureClassification;
7
12
  export declare const TAXONOMY_DOC_CLASSES: FailureClass[];