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/run.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Inference, RunPlan, FailureClass, Attestation } from "./types.js";
1
+ import type { Inference, RunPlan, FailureClass, Attestation, PreparationCommand } from "./types.js";
2
2
  export interface UpOptions {
3
3
  provider: "docker" | "local";
4
4
  unsafeLocal: boolean;
@@ -8,6 +8,11 @@ export interface UpOptions {
8
8
  timeoutMs: number;
9
9
  install: boolean;
10
10
  port?: number;
11
+ environment?: Record<string, string>;
12
+ additionalPreparationCommands?: PreparationCommand[];
13
+ command?: string;
14
+ healthPath?: string;
15
+ ciOidc?: boolean;
11
16
  }
12
17
  export interface UpOutcome {
13
18
  inference: Inference;
package/dist/run.js CHANGED
@@ -1,17 +1,91 @@
1
1
  import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
2
4
  import { inferRepo } from "./infer.js";
3
5
  import { buildPlan, writePlanFiles } from "./plan.js";
4
- import { runToCompletion, superviseApp, pollHealthCandidates, minimalEnv } from "./exec.js";
5
- import { classifyFailure } from "./taxonomy.js";
6
- 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";
7
10
  function classifyHealthFailure(evidence) {
8
11
  if (/(only HTTP 5\d\d observed|HTTP 5\d\d|status\s*5\d\d|returned 5\d\d)/i.test(evidence)) {
9
12
  return "health_http_error";
10
13
  }
11
14
  return "health_check_timeout";
12
15
  }
13
- function step(id, kind, command, startedAt, exitCode, ok, observation, evidenceTail) {
14
- 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
+ };
15
89
  }
16
90
  export function packageManagerVersionMatches(expected, actual) {
17
91
  const expectedMatch = expected.trim().match(/^v?(\d+)(?:\.(\d+))?(?:\.(\d+))?$/);
@@ -22,11 +96,21 @@ export function packageManagerVersionMatches(expected, actual) {
22
96
  const actualParts = actualMatch.slice(1, 1 + expectedParts.length);
23
97
  return expectedParts.every((part, index) => part === actualParts[index]);
24
98
  }
25
- function packageManagerVersionEvidence(inference) {
99
+ function commandUsesExecutable(command, executable) {
100
+ if (!command)
101
+ return false;
102
+ const escaped = executable.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
103
+ return new RegExp(`(?:^|&&|\\|\\||;)\\s*${escaped}(?:\\s|$)`).test(command);
104
+ }
105
+ function packageManagerVersionEvidence(inference, plan, env) {
26
106
  if (inference.packageManager === "unknown" || !inference.packageManagerVersion)
27
107
  return null;
108
+ if (!plan.steps.some(planned => commandUsesExecutable(planned.command, inference.packageManager)))
109
+ return null;
28
110
  try {
29
- const actual = execFileSync(inference.packageManager, ["--version"], { cwd: inference.repoPath, encoding: "utf8" }).trim();
111
+ const actual = process.platform === "win32"
112
+ ? execFileSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", `${inference.packageManager} --version`], { cwd: inference.repoPath, encoding: "utf8", env }).trim()
113
+ : execFileSync(inference.packageManager, ["--version"], { cwd: inference.repoPath, encoding: "utf8", env }).trim();
30
114
  if (packageManagerVersionMatches(inference.packageManagerVersion, actual))
31
115
  return null;
32
116
  return `packageManager field or engines.${inference.packageManager} expected version: ${inference.packageManagerVersion}\nGot: ${actual}`;
@@ -35,17 +119,101 @@ function packageManagerVersionEvidence(inference) {
35
119
  return null;
36
120
  }
37
121
  }
122
+ function commandWithPort(command, port) {
123
+ return command
124
+ .replace(/((?:--port(?:=|\s+)|-p\s+))\d{2,5}\b/, `$1${port}`)
125
+ .replace(/(\bmanage\.py\s+runserver\s+(?:127\.0\.0\.1|localhost):)\d{2,5}\b/, `$1${port}`);
126
+ }
127
+ function unsupportedOrchestrationExplanation(inference) {
128
+ if (inference.appCommand)
129
+ return null;
130
+ const sourceComposeServices = inference.composeApplicationServices.filter(service => service.source === "build");
131
+ if (sourceComposeServices.length > 1) {
132
+ return `Detected multiple source-built HTTP services in ${inference.repoComposeFile}: ${sourceComposeServices.map(service => service.name).join(", ")}. BootProof will not treat one responding service as proof that the repository booted. Diagnosis only — no localhost claim.`;
133
+ }
134
+ if (sourceComposeServices.length === 1 && inference.composeHealthCandidates.length === 0) {
135
+ return `Detected source-built service ${sourceComposeServices[0].name} in ${inference.repoComposeFile}, but no unambiguous published HTTP candidate. Diagnosis only — no localhost claim.`;
136
+ }
137
+ const backend = inference.stack.includes("go-backend")
138
+ ? { stack: "go-backend", markers: inference.backendMarkers.filter(marker => marker === "go.mod" || marker === "go.work") }
139
+ : inference.stack.includes("ruby-backend")
140
+ ? { stack: "ruby-backend", markers: inference.backendMarkers.filter(marker => marker === "Gemfile" || marker === "config/database.yml") }
141
+ : inference.stack.includes("make-driven")
142
+ ? { stack: "make-driven", markers: inference.backendMarkers.filter(marker => marker === "Makefile") }
143
+ : null;
144
+ if (!backend)
145
+ return null;
146
+ const frontendStack = inference.stack.includes("react-frontend")
147
+ ? "react-frontend"
148
+ : inference.stack.includes("node-frontend")
149
+ ? "node-frontend"
150
+ : null;
151
+ const frontendMarker = inference.frontendMarkers.find(marker => marker.endsWith("/package.json"))
152
+ ?? inference.frontendMarkers.find(marker => marker === "package.json");
153
+ const frontend = frontendStack && frontendMarker ? ` with ${frontendStack} (${frontendMarker})` : "";
154
+ return `Detected ${backend.stack} (${backend.markers.join(", ")})${frontend}. BootProof can diagnose this stack but does not yet orchestrate its boot. Diagnosis only — no localhost claim.`;
155
+ }
38
156
  export async function up(repoPath, opts) {
39
157
  const startedAt = new Date().toISOString();
40
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
+ }
170
+ if (opts.additionalPreparationCommands?.length) {
171
+ inference.preparationCommands.push(...opts.additionalPreparationCommands);
172
+ inference.dependencyInstallRequired = true;
173
+ }
41
174
  if (opts.port) {
42
- inference.port = opts.port;
43
- inference.portEvidence = "set by --port flag";
44
- inference.healthCandidates = inference.healthCandidates.map(candidate => candidate.replace(/:\d{2,5}(?=\/)/, `:${opts.port}`));
175
+ if (inference.appCommand) {
176
+ inference.port = opts.port;
177
+ inference.portEvidence = "set by --port flag";
178
+ inference.appCommand = commandWithPort(inference.appCommand, opts.port);
179
+ if (inference.backendCommand)
180
+ inference.backendCommand = commandWithPort(inference.backendCommand, opts.port);
181
+ inference.healthCandidates = inference.healthCandidates.map(candidate => candidate.replace(/:\d{2,5}(?=\/)/, `:${opts.port}`));
182
+ }
183
+ else if (inference.composeHealthCandidates.length) {
184
+ inference.portEvidence = `repository Compose published port retained; --port ${opts.port} was not applied`;
185
+ }
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`;
45
208
  }
46
209
  const plan = buildPlan(inference, opts.provider);
210
+ const trust = await resolveTrust({ ciOidc: opts.ciOidc });
211
+ const env = buildExecutionEnv({ PORT: String(inference.port), ...opts.environment });
212
+ const runsSourceComposeApplication = opts.provider === "docker" &&
213
+ Boolean(inference.repoComposeFile) &&
214
+ inference.composeHealthCandidates.length > 0;
47
215
  const base = { inference, plan, writtenFiles: [] };
48
- const refuse = (failureClass, explanation, observed = [], failureEvidence = explanation) => {
216
+ const refuse = (failureClass, explanation, observed = [], failureEvidence = explanation, healthEvidence = null, observedHealthCandidates = []) => {
49
217
  const refusal = { failureClass, explanation };
50
218
  if (opts.dryRun)
51
219
  return { ...base, attestation: null, refusal };
@@ -63,10 +231,12 @@ export async function up(repoPath, opts) {
63
231
  booted: false,
64
232
  healthVerified: false,
65
233
  healthObservation: null,
66
- observedHealthCandidates: [],
234
+ healthEvidence,
235
+ observedHealthCandidates,
67
236
  failureClass,
68
237
  failureEvidence: failureEvidence.slice(-2000),
69
238
  explanation,
239
+ trust,
70
240
  });
71
241
  writeAttestation(inference.repoPath, attestation);
72
242
  return { inference, plan: refusalPlan, writtenFiles: [], attestation, refusal };
@@ -77,62 +247,137 @@ export async function up(repoPath, opts) {
77
247
  if (inference.stack.includes("python-backend") && inference.stack.includes("flask") && inference.setupSteps.length > 0) {
78
248
  return refuse("python_flask_setup_required", "BootProof detected a Python/Flask + React application with setup steps. This repository requires database migration/init and service orchestration before it can be verified.");
79
249
  }
250
+ const orchestrationExplanation = unsupportedOrchestrationExplanation(inference);
251
+ if (orchestrationExplanation && !runsSourceComposeApplication) {
252
+ const failureClass = inference.stack.includes("go-backend")
253
+ ? "go_service_orchestration_not_supported"
254
+ : "orchestration_not_supported";
255
+ return refuse(failureClass, orchestrationExplanation);
256
+ }
257
+ if (!inference.appCommand && inference.composeHealthCandidates.length > 0 && opts.provider !== "docker") {
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.`);
259
+ }
80
260
  if (!opts.workspace && inference.workspaces.length > 1 && !inference.appCommand) {
81
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.`);
82
262
  }
83
263
  if (opts.remoteSource && !opts.dryRun && (opts.provider !== "local" || !opts.unsafeLocal)) {
84
- 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.`);
85
265
  }
86
266
  if (opts.provider === "local" && !opts.unsafeLocal) {
87
- 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.");
88
268
  }
89
269
  if (opts.dryRun)
90
270
  return { ...base, attestation: null, refusal: null };
91
- if (inference.dependencyInstallRequired && !opts.install) {
92
- const skipped = step("install", "install", inference.installCommand ?? undefined, new Date().toISOString(), null, false, "skipped by default dependency-backed application was not started; pass --install to run dependency installation");
271
+ if (inference.multiAppCommand) {
272
+ return refuse("workspace_ambiguous", "BootProof detected a root command that starts multiple workspaces in parallel. Choose a specific application with --workspace <dir>; one responding workspace is not proof that the whole repository booted.");
273
+ }
274
+ const preparationSteps = plan.steps.filter(planned => planned.kind === "install" || planned.kind === "build");
275
+ if (preparationSteps.length > 0 && !opts.install) {
276
+ const skipped = step(preparationSteps[0].id, preparationSteps[0].kind, preparationSteps[0].command, new Date().toISOString(), null, false, "skipped by default — dependency-backed application was not started; pass --install to run dependency installation");
93
277
  return refuse("dependency_install_skipped", "The inferred application command depends on project packages, but dependency installation was not requested. BootProof did not start the partial application pipeline.", [skipped]);
94
278
  }
279
+ const hostExecutionSteps = plan.steps.filter(planned => planned.kind === "install" ||
280
+ planned.kind === "build" ||
281
+ planned.kind === "start-app");
282
+ if (opts.provider === "docker" && !runsSourceComposeApplication && hostExecutionSteps.length > 0) {
283
+ return refuse("orchestration_not_supported", `Docker provider selected, but the inferred plan contains host commands (${hostExecutionSteps.map(planned => planned.command).filter(Boolean).join("; ")}). BootProof will not silently run them on the host. Use a source-built repository Compose application, or explicitly choose --provider local --unsafe-local after review.`);
284
+ }
95
285
  if (opts.install) {
96
- const versionEvidence = packageManagerVersionEvidence(inference);
286
+ const versionEvidence = packageManagerVersionEvidence(inference, plan, env);
97
287
  if (versionEvidence) {
98
288
  const observed = step("package-manager-version", "install", `${inference.packageManager} --version`, new Date().toISOString(), 0, false, `declared ${inference.packageManager} ${inference.packageManagerVersion}, but found ${versionEvidence.split("Got: ")[1]}`, versionEvidence);
99
289
  return refuse("package_manager_version_mismatch", "The repository declares a package manager version that does not match the version available in the current environment. Enable Corepack or install the required package manager version before rerunning BootProof.", [observed], versionEvidence);
100
290
  }
101
291
  }
102
- if (inference.multiAppCommand) {
103
- return refuse("workspace_ambiguous", "BootProof detected a root command that starts multiple workspaces in parallel. Choose a specific application with --workspace <dir>; one responding workspace is not proof that the whole repository booted.");
104
- }
105
292
  if (inference.incompleteAppCommand) {
106
- 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
+ }
107
307
  }
108
308
  const writtenFiles = writePlanFiles(inference, inference.repoPath);
309
+ if (inference.appCommand?.includes(".bootproof/runtime/")) {
310
+ fs.mkdirSync(path.join(inference.repoPath, ".bootproof", "runtime"), { recursive: true });
311
+ }
109
312
  const observed = [];
110
- const env = minimalEnv({ PORT: String(inference.port) });
111
- const fail = (failureClass, evidence, explanation) => {
112
- const att = buildAttestation({ repo: inference.repoPath, plan, observed, startedAt, booted: false, healthVerified: false, healthObservation: null, observedHealthCandidates: [], failureClass, failureEvidence: evidence.slice(-2000), explanation });
313
+ const explanationWithMissingEnv = (failureClass, evidence, explanation) => {
314
+ if (failureClass !== "missing_env_var")
315
+ return explanation;
316
+ const names = extractMissingEnvNames(evidence);
317
+ if (!names.length)
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
+ }
323
+ const generatedExample = path.join(inference.repoPath, ".env.bootproof.example");
324
+ const suffix = fs.existsSync(generatedExample)
325
+ ? `Missing: ${names.join(", ")} — see .env.bootproof.example; bootproof will not invent values.`
326
+ : `Missing variable${names.length === 1 ? "" : "s"}: ${names.join(", ")}. BootProof will not invent values.`;
327
+ return `${explanation} ${suffix}`;
328
+ };
329
+ const fail = (failureClass, evidence, explanation, healthEvidence = null, observedHealthCandidates = []) => {
330
+ const preciseExplanation = explanationWithMissingEnv(failureClass, evidence, explanation);
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 });
113
332
  writeAttestation(inference.repoPath, att);
114
333
  return { inference, plan, writtenFiles, attestation: att, refusal: null };
115
334
  };
335
+ const composeDiagnostics = async () => {
336
+ if (!inference.repoComposeFile)
337
+ return "";
338
+ const commands = [
339
+ {
340
+ id: "compose-ps",
341
+ command: `docker compose -f ${inference.repoComposeFile} ps --all`,
342
+ observation: "captured repository Compose service state",
343
+ },
344
+ {
345
+ id: "compose-logs",
346
+ command: `docker compose -f ${inference.repoComposeFile} logs --no-color --tail 200`,
347
+ observation: "captured repository Compose logs",
348
+ },
349
+ ];
350
+ const evidence = [];
351
+ for (const diagnostic of commands) {
352
+ const t = new Date().toISOString();
353
+ const result = await runToCompletion(diagnostic.command, inference.repoPath, 30_000, env);
354
+ const text = [result.stdout, result.stderr].filter(Boolean).join("\n");
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)));
356
+ if (text)
357
+ evidence.push(`${diagnostic.command}\n${text}`);
358
+ }
359
+ return evidence.join("\n");
360
+ };
116
361
  for (const planned of plan.steps) {
117
362
  if (planned.kind === "service" && planned.command) {
118
363
  const t = new Date().toISOString();
119
364
  const r = await runToCompletion(planned.command, inference.repoPath, 120_000, env);
120
365
  const ok = r.exitCode === 0;
121
- observed.push(step(planned.id, "service", planned.command, t, r.exitCode, ok, ok ? "services started (docker compose exit 0)" : "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)));
122
367
  if (!ok) {
123
368
  const c = classifyFailure(r.stderr + r.stdout);
124
369
  return fail(c.class, r.stderr + r.stdout, c.explanation);
125
370
  }
126
371
  }
127
- if (planned.kind === "install" && planned.command) {
372
+ if ((planned.kind === "install" || planned.kind === "build") && planned.command) {
128
373
  if (!opts.install) {
129
- observed.push(step(planned.id, "install", planned.command, new Date().toISOString(), null, false, "skipped by default — optional install was not needed for the observed boot"));
374
+ observed.push(step(planned.id, planned.kind, planned.command, new Date().toISOString(), null, false, "skipped by default — preparation was not authorized"));
130
375
  continue;
131
376
  }
132
377
  const t = new Date().toISOString();
133
378
  const r = await runToCompletion(planned.command, inference.repoPath, 600_000, env);
134
379
  const ok = r.exitCode === 0 && !r.timedOut;
135
- observed.push(step(planned.id, "install", planned.command, t, r.exitCode, ok, ok ? "dependencies installed (exit 0)" : r.timedOut ? "install timed out" : `install 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)));
136
381
  if (!ok) {
137
382
  const c = classifyFailure(r.stderr + r.stdout);
138
383
  return fail(c.class === "unknown_failure" ? "install_failed" : c.class, r.stderr + r.stdout, c.explanation);
@@ -141,41 +386,84 @@ export async function up(repoPath, opts) {
141
386
  if (planned.kind === "start-app" && planned.command) {
142
387
  const t = new Date().toISOString();
143
388
  const app = superviseApp(planned.command, inference.repoPath, env);
144
- 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
+ }
145
403
  plan.healthCandidates = health.candidates;
404
+ const processOutputHealthUrl = advertisedHealthUrls[0] ?? health.discoveredCandidates[0];
405
+ if (processOutputHealthUrl) {
406
+ recordHealthPort(inference, plan, processOutputHealthUrl, "process_output");
407
+ }
146
408
  if (health.url)
147
409
  plan.healthUrl = health.url;
410
+ const portMismatch = detectHealthCandidatePortMismatch(inferredHealthUrl, health.discoveredCandidates, planned.command);
411
+ const portMismatchEvidence = healthCandidatePortMismatchEvidence(portMismatch);
412
+ const packageScriptContext = packageScriptFailureContext(inference);
148
413
  const exit = app.exited();
149
- if (exit && !health.responded) {
150
- observed.push(step(planned.id, "start-app", planned.command, t, exit.code, false, `app process exited (code ${exit.code}) before responding`, app.output()));
151
- 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);
152
430
  await app.stop();
153
- 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);
154
435
  }
155
436
  observed.push(step(planned.id, "start-app", planned.command, t, null, true, "app process started and was supervised"));
156
437
  const ht = new Date().toISOString();
157
- if (health.responded && health.status !== null && health.status < 500) {
158
- const observedUrl = health.url ?? plan.healthUrl;
159
- 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));
160
444
  await app.stop();
161
- 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 });
162
446
  writeAttestation(inference.repoPath, att);
163
447
  return { inference, plan, writtenFiles, attestation: att, refusal: null };
164
448
  }
165
- const evidence = app.output();
449
+ const processEvidence = app.evidence();
450
+ const evidence = [processEvidenceText(processEvidence), portMismatchEvidence, packageScriptContext].filter(Boolean).join("\n");
166
451
  const healthFailureMessage = health.responded
167
452
  ? `only HTTP ${health.status} observed at ${health.url ?? plan.healthUrl}`
168
453
  : `no HTTP response at candidates ${health.candidates.join(", ")} within ${opts.timeoutMs}ms`;
169
- observed.push(step("health", "health", undefined, ht, null, false, healthFailureMessage, evidence));
454
+ observed.push(step("health", "health", undefined, ht, null, false, healthFailureMessage, processEvidence));
170
455
  const c = classifyFailure(`${healthFailureMessage}\n${evidence}`);
171
- const healthClass = health.responded && health.status !== null && health.status >= 500
172
- ? "health_http_error"
173
- : c.class === "unknown_failure"
174
- ? classifyHealthFailure(healthFailureMessage)
175
- : 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;
176
463
  const healthExplanation = healthClass === "health_http_error"
177
464
  ? "The app responded on the configured health URL, but returned HTTP 5xx. BootProof observed a running server, but not a verified healthy boot."
178
465
  : c.explanation;
466
+ const preciseHealthExplanation = explanationWithMissingEnv(healthClass, `${healthFailureMessage}\n${evidence}`, healthExplanation);
179
467
  await app.stop();
180
468
  const att = buildAttestation({
181
469
  repo: inference.repoPath,
@@ -185,14 +473,65 @@ export async function up(repoPath, opts) {
185
473
  booted: false,
186
474
  healthVerified: false,
187
475
  healthObservation: null,
476
+ healthEvidence: health.evidence,
188
477
  observedHealthCandidates: health.discoveredCandidates,
189
478
  failureClass: healthClass,
190
479
  failureEvidence: `${healthFailureMessage}\n${evidence}`.slice(-2000),
191
- explanation: healthExplanation,
480
+ explanation: preciseHealthExplanation,
481
+ trust,
192
482
  });
193
483
  writeAttestation(inference.repoPath, att);
194
484
  return { inference, plan, writtenFiles, attestation: att, refusal: null };
195
485
  }
486
+ if (planned.kind === "health" && runsSourceComposeApplication) {
487
+ const ht = new Date().toISOString();
488
+ const health = await pollHealthCandidates(plan.healthCandidates, opts.timeoutMs);
489
+ plan.healthCandidates = health.candidates;
490
+ if (health.url)
491
+ plan.healthUrl = health.url;
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));
498
+ const att = buildAttestation({
499
+ repo: inference.repoPath,
500
+ plan,
501
+ observed,
502
+ startedAt,
503
+ booted: true,
504
+ healthVerified: true,
505
+ healthObservation: healthObservationSummary(health.evidence),
506
+ healthEvidence: health.evidence,
507
+ observedHealthCandidates: health.discoveredCandidates,
508
+ failureClass: null,
509
+ failureEvidence: null,
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,
512
+ });
513
+ writeAttestation(inference.repoPath, att);
514
+ return { inference, plan, writtenFiles, attestation: att, refusal: null };
515
+ }
516
+ const healthFailureMessage = health.responded
517
+ ? `only HTTP ${health.status} observed at ${health.url ?? plan.healthUrl}`
518
+ : `no HTTP response at candidates ${health.candidates.join(", ")} within ${opts.timeoutMs}ms`;
519
+ observed.push(step("health", "health", undefined, ht, null, false, healthFailureMessage));
520
+ const diagnostics = await composeDiagnostics();
521
+ const evidence = [healthFailureMessage, diagnostics].filter(Boolean).join("\n");
522
+ const classified = classifyFailure(evidence);
523
+ const failureClass = health.responded && health.status !== null && health.status >= 500
524
+ ? "health_http_error"
525
+ : classified.class === "unknown_failure"
526
+ ? classifyHealthFailure(healthFailureMessage)
527
+ : classified.class;
528
+ const explanation = failureClass === "health_http_error"
529
+ ? "The Compose application responded on the configured health URL, but returned HTTP 5xx. BootProof observed a server, but not a verified healthy boot."
530
+ : failureClass === "health_check_timeout"
531
+ ? "Repository Compose accepted the start request, but no HTTP response was observed. Compose service state and logs are preserved in the attestation."
532
+ : classified.explanation;
533
+ return fail(failureClass, evidence, explanation, health.evidence, health.discoveredCandidates);
534
+ }
196
535
  }
197
- return fail("not_an_application", "", "Plan contained no runnable app 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.");
198
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;