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.
- package/README.md +873 -109
- package/dist/agent-plan.d.ts +44 -0
- package/dist/agent-plan.js +826 -0
- package/dist/agent-run.d.ts +117 -0
- package/dist/agent-run.js +459 -0
- package/dist/ai-repair.d.ts +58 -0
- package/dist/ai-repair.js +380 -0
- package/dist/cli.js +936 -38
- package/dist/diagnosis.js +114 -17
- package/dist/diff.d.ts +29 -0
- package/dist/diff.js +569 -0
- package/dist/exec.d.ts +30 -2
- package/dist/exec.js +332 -37
- package/dist/external-health.d.ts +16 -0
- package/dist/external-health.js +214 -0
- package/dist/infer.js +489 -41
- package/dist/plan.d.ts +2 -0
- package/dist/plan.js +49 -7
- package/dist/proof.d.ts +78 -2
- package/dist/proof.js +266 -13
- package/dist/receipt.d.ts +52 -0
- package/dist/receipt.js +356 -0
- package/dist/redact.d.ts +4 -0
- package/dist/redact.js +86 -2
- package/dist/registry.d.ts +82 -30
- package/dist/registry.js +355 -53
- package/dist/remote.d.ts +12 -1
- package/dist/remote.js +62 -18
- package/dist/repair-playbooks.d.ts +24 -0
- package/dist/repair-playbooks.js +593 -0
- package/dist/repair-safety.d.ts +130 -0
- package/dist/repair-safety.js +766 -0
- package/dist/repair.d.ts +142 -0
- package/dist/repair.js +1566 -0
- package/dist/run.d.ts +6 -1
- package/dist/run.js +385 -46
- package/dist/sbom.d.ts +22 -0
- package/dist/sbom.js +99 -0
- package/dist/taxonomy.d.ts +8 -2
- package/dist/taxonomy.js +428 -8
- package/dist/types.d.ts +57 -2
- package/docs/AGENT_IN_THE_LOOP.md +171 -0
- package/docs/AGENT_RUN_RECEIPTS.md +38 -0
- package/docs/CI_ACTION.md +71 -5
- package/docs/DETERMINISTIC_REPAIR_SAFETY_MODEL.md +705 -0
- package/docs/FAILURE_TAXONOMY.md +30 -1
- package/docs/HONESTY_CONTRACT.md +55 -4
- package/docs/LAUNCH_PLAYBOOK.md +232 -0
- package/docs/REAL_REPO_EVIDENCE.md +77 -0
- package/docs/REAL_WORLD_FIXTURES.md +105 -0
- package/docs/REGISTRY.md +48 -28
- package/docs/RELEASE_CHECKLIST.md +9 -1
- package/docs/REPAIR_RECEIPT.md +224 -0
- package/docs/agent-loop-gap-analysis.md +188 -0
- package/docs/examples/registry-seeds/advertised-port-mismatch.json +28 -0
- package/docs/examples/registry-seeds/airbyte-abctl-external-orchestrator.json +36 -0
- package/docs/examples/registry-seeds/go-ollama-service.json +36 -0
- package/docs/examples/registry-seeds/laravel-vite-sqlite.json +36 -0
- package/docs/examples/registry-seeds/monorepo-ambiguous-health.json +29 -0
- package/docs/examples/registry-seeds/php-composer.json +33 -0
- package/docs/examples/registry-seeds/rails-bundler.json +32 -0
- package/docs/examples/registry-seeds/sentry-devenv-direnv.json +41 -0
- package/docs/schemas/action-verdict-v1.schema.json +64 -0
- package/docs/schemas/agent-plan-v1.schema.json +148 -0
- package/docs/schemas/agent-run-receipts-v1.schema.json +192 -0
- package/docs/schemas/ai-repair-suggestion-v1.schema.json +70 -0
- package/docs/schemas/ci-context-v1.schema.json +63 -0
- package/docs/schemas/diff-result-v1.schema.json +66 -0
- package/docs/schemas/federated-receipt-v1.schema.json +51 -0
- package/docs/schemas/registry-entry-v1.schema.json +95 -0
- package/docs/schemas/registry-seed-example-v1.schema.json +102 -0
- package/docs/schemas/repair-action-v1.schema.json +136 -0
- package/docs/schemas/repair-receipt-v1.schema.json +221 -0
- 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,
|
|
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
|
-
|
|
14
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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.
|
|
92
|
-
|
|
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("
|
|
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
|
|
111
|
-
|
|
112
|
-
|
|
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 ? "
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
150
|
-
|
|
151
|
-
const
|
|
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
|
-
|
|
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.
|
|
158
|
-
|
|
159
|
-
|
|
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:
|
|
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
|
|
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,
|
|
454
|
+
observed.push(step("health", "health", undefined, ht, null, false, healthFailureMessage, processEvidence));
|
|
170
455
|
const c = classifyFailure(`${healthFailureMessage}\n${evidence}`);
|
|
171
|
-
const healthClass =
|
|
172
|
-
? "
|
|
173
|
-
:
|
|
174
|
-
?
|
|
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:
|
|
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("
|
|
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;
|