bootproof 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +840 -152
- 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 +730 -46
- package/dist/diagnosis.js +101 -16
- package/dist/diff.d.ts +29 -0
- package/dist/diff.js +569 -0
- package/dist/exec.d.ts +30 -2
- package/dist/exec.js +329 -51
- package/dist/external-health.d.ts +16 -0
- package/dist/external-health.js +214 -0
- package/dist/infer.js +238 -39
- package/dist/plan.js +2 -0
- package/dist/proof.d.ts +78 -2
- package/dist/proof.js +265 -12
- 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.js +3 -3
- 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 +43 -11
- package/dist/repair.js +716 -7
- package/dist/run.d.ts +3 -0
- package/dist/run.js +218 -41
- package/dist/sbom.d.ts +22 -0
- package/dist/sbom.js +99 -0
- package/dist/taxonomy.d.ts +8 -3
- package/dist/taxonomy.js +404 -8
- package/dist/types.d.ts +40 -1
- package/docs/AGENT_IN_THE_LOOP.md +171 -0
- package/docs/AGENT_RUN_RECEIPTS.md +38 -0
- package/docs/CI_ACTION.md +67 -2
- package/docs/DETERMINISTIC_REPAIR_SAFETY_MODEL.md +705 -0
- package/docs/FAILURE_TAXONOMY.md +28 -1
- package/docs/HONESTY_CONTRACT.md +34 -12
- package/docs/LAUNCH_PLAYBOOK.md +232 -0
- package/docs/REAL_WORLD_FIXTURES.md +105 -0
- package/docs/REGISTRY.md +48 -28
- package/docs/REPAIR_RECEIPT.md +54 -8
- 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 +10 -6
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,
|
|
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
|
-
|
|
16
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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("
|
|
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("
|
|
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(", ")}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
267
|
-
|
|
268
|
-
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);
|
|
269
430
|
await app.stop();
|
|
270
|
-
|
|
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.
|
|
275
|
-
|
|
276
|
-
|
|
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:
|
|
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
|
|
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,
|
|
454
|
+
observed.push(step("health", "health", undefined, ht, null, false, healthFailureMessage, processEvidence));
|
|
287
455
|
const c = classifyFailure(`${healthFailureMessage}\n${evidence}`);
|
|
288
|
-
const healthClass =
|
|
289
|
-
? "
|
|
290
|
-
:
|
|
291
|
-
?
|
|
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.
|
|
321
|
-
|
|
322
|
-
|
|
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:
|
|
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 ${
|
|
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("
|
|
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
|
+
}
|
package/dist/taxonomy.d.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { FailureClass } from "./types.js";
|
|
2
|
-
export
|
|
3
|
-
export
|
|
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[];
|