bootproof 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +844 -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/DISTRIBUTION.md +83 -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 +21 -11
package/dist/cli.js
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import * as readline from "node:readline/promises";
|
|
4
5
|
import { inferRepo } from "./infer.js";
|
|
5
6
|
import { buildPlan, composeFileFor, envExampleFor } from "./plan.js";
|
|
6
7
|
import { up } from "./run.js";
|
|
7
|
-
import {
|
|
8
|
+
import { attestationPath, currentGitHead, evaluateAttestationSignature, trustSigner, rotateSigner, writeAttestation, TOOL_ID, } from "./proof.js";
|
|
9
|
+
import { emitLivingReceipt } from "./receipt.js";
|
|
8
10
|
import { pollHealth } from "./exec.js";
|
|
9
|
-
import {
|
|
11
|
+
import { buildExternalHealthAttestation } from "./external-health.js";
|
|
12
|
+
import { agentPlanPath, buildAgentPlan, writeAgentPlan, } from "./agent-plan.js";
|
|
13
|
+
import { agentRunDirectory, appendAgentVerification, createAgentRun, explainAgentRun, latestAgentRunId, readAgentRun, } from "./agent-run.js";
|
|
14
|
+
import { buildFederatedReceipt, buildRegistryEntry, currentGitBranch, evaluateRegistryEntrySignature, writeFederatedReceipt, writeRegistryEntry, registryEntryPath, } from "./registry.js";
|
|
10
15
|
import { normalizeDockerBindPath, detectHostPlatform } from "./platform.js";
|
|
11
16
|
import { diagnoseFailure } from "./diagnosis.js";
|
|
12
17
|
import { cloneRemoteTarget, isRemoteTarget, managedRemoteSource } from "./remote.js";
|
|
13
|
-
import { applyVerifiedRepair, repairRepo, verifyRepairReceipt, } from "./repair.js";
|
|
18
|
+
import { applyVerifiedRepair, executeAiSuggestedRepair, evaluateRepairReceiptSignature, latestDeterministicRepairCandidate, latestFailedAttestation, repairRepo, registeredRemediationsFor, verifyRepairReceipt, } from "./repair.js";
|
|
19
|
+
import { requestAiRepairSuggestion, resolveAiProvider, } from "./ai-repair.js";
|
|
20
|
+
import { exportSbom } from "./sbom.js";
|
|
21
|
+
import { diffRefs } from "./diff.js";
|
|
14
22
|
let GREEN = "\x1b[32m", YELLOW = "\x1b[33m", RED = "\x1b[31m", DIM = "\x1b[2m", BOLD = "\x1b[1m", RESET = "\x1b[0m";
|
|
15
23
|
const ok = (s) => console.log(`${GREEN}\u2713 ${s}${RESET}`);
|
|
16
24
|
const would = (s) => console.log(`${DIM}\u25cb would: ${s}${RESET}`);
|
|
@@ -18,7 +26,23 @@ const warn = (s) => console.log(`${YELLOW}! ${s}${RESET}`);
|
|
|
18
26
|
const bad = (s) => console.log(`${RED}\u2717 ${s}${RESET}`);
|
|
19
27
|
const disableColor = () => { GREEN = ""; YELLOW = ""; RED = ""; DIM = ""; BOLD = ""; RESET = ""; };
|
|
20
28
|
const portableRelative = (from, to) => path.relative(from, to).replace(/\\/g, "/");
|
|
21
|
-
const COMMANDS = ["up", "fix", "apply-repair", "analyze", "plan", "verify", "explain", "attest", "help", "version", "--help", "-h", "--version"];
|
|
29
|
+
const COMMANDS = ["up", "verify-url", "plan-agent", "explain-run", "fix", "apply-repair", "diff", "analyze", "plan", "verify", "explain", "attest", "registry", "export-sbom", "rotate-keys", "help", "version", "--help", "-h", "--version"];
|
|
30
|
+
const SUPPORTED_FLAGS = {
|
|
31
|
+
diff: new Set(["base", "head", "json", "ci"]),
|
|
32
|
+
analyze: new Set(["workspace", "json", "ci"]),
|
|
33
|
+
plan: new Set(["workspace", "provider", "ci"]),
|
|
34
|
+
"plan-agent": new Set(["json", "ci"]),
|
|
35
|
+
"explain-run": new Set(["ci"]),
|
|
36
|
+
"apply-repair": new Set(["receipt", "dry-run", "json", "ci"]),
|
|
37
|
+
fix: new Set(["provider", "unsafe-local", "port", "timeout", "dry-run", "json", "ci", "ai"]),
|
|
38
|
+
up: new Set(["provider", "unsafe-local", "install", "workspace", "port", "timeout", "dry-run", "json", "ci", "command", "external-health", "receipt", "health-path", "ci-oidc"]),
|
|
39
|
+
"verify-url": new Set(["timeout", "json", "ci"]),
|
|
40
|
+
verify: new Set(["ci", "trust-signer", "require-known-signer", "strict"]),
|
|
41
|
+
attest: new Set(["ci", "trust-signer", "require-known-signer", "strict"]),
|
|
42
|
+
registry: new Set(["mode", "federated", "ci"]),
|
|
43
|
+
"export-sbom": new Set(["format", "json", "ci"]),
|
|
44
|
+
"rotate-keys": new Set(["repo", "resign", "no-backup", "json", "ci"]),
|
|
45
|
+
};
|
|
22
46
|
void normalizeDockerBindPath;
|
|
23
47
|
void detectHostPlatform; // exported surface, used by docker provider work in progress
|
|
24
48
|
if (process.env.NO_COLOR !== undefined)
|
|
@@ -50,34 +74,66 @@ Usage:
|
|
|
50
74
|
bootproof analyze <path|git-url> [--workspace dir] [--json]
|
|
51
75
|
inspect a repo, show evidence-based inference
|
|
52
76
|
bootproof plan <path|git-url> [--workspace dir] show the run plan and files that WOULD be generated
|
|
77
|
+
bootproof plan-agent <path|git-url> [--json] write a risk-classified plan; execute nothing
|
|
78
|
+
bootproof explain-run <run-id> verify and explain a local agent receipt chain
|
|
53
79
|
bootproof up <path|git-url> [options] execute the plan, verify localhost, write signed proof
|
|
80
|
+
bootproof verify-url <url> [--timeout ms] verify an externally managed HTTP service
|
|
54
81
|
bootproof fix <path|git-url> [options] test a deterministic repair in a sandbox
|
|
82
|
+
bootproof fix <path|git-url> --ai optionally request a BYOK AI suggestion after deterministic refusal
|
|
55
83
|
bootproof apply-repair <path> [--receipt proof.json] explicitly apply a signature-valid verified file change
|
|
84
|
+
bootproof diff [--base ref] [--head ref] [--json] statically detect infrastructure drift between Git refs
|
|
56
85
|
bootproof verify <path|proof.json> validate an attestation or repair-receipt signature
|
|
57
86
|
bootproof explain <proof.json> explain an attestation or repair receipt
|
|
58
|
-
bootproof
|
|
59
|
-
bootproof
|
|
87
|
+
bootproof registry export <path> explicitly write a redacted local registry export
|
|
88
|
+
bootproof registry export <path> --federated explicitly write a public-candidate receipt
|
|
89
|
+
bootproof attest export <path> compatibility alias for local registry export
|
|
90
|
+
bootproof attest check <path> verify a registry entry signature
|
|
91
|
+
bootproof export-sbom <path> [--format cyclonedx-json] export a CycloneDX SBOM from package-lock.json
|
|
92
|
+
bootproof rotate-keys [--repo <path>] [--resign] rotate the local ed25519 signing key (back up old, generate new)
|
|
60
93
|
bootproof version
|
|
61
94
|
|
|
62
95
|
Options for up:
|
|
63
96
|
--provider docker|local execution provider (default docker)
|
|
64
|
-
|
|
97
|
+
docker: only works for source-built Compose applications; refuses host commands for plain repos
|
|
98
|
+
local: runs install/start commands directly on your host machine (requires --unsafe-local)
|
|
99
|
+
--unsafe-local required consent for --provider local; confirms you accept host execution
|
|
65
100
|
--install run the dependency install step (off by default)
|
|
66
101
|
--workspace <dir> pick a monorepo workspace
|
|
102
|
+
--command <command> override the inferred application start command
|
|
103
|
+
--external-health <url> verify an externally managed service; do not start the app
|
|
67
104
|
--port <n> override inferred port
|
|
105
|
+
--health-path <path> override inferred health endpoint path (e.g. /health, /healthz)
|
|
68
106
|
--timeout <ms> health verification timeout (default 60000)
|
|
69
107
|
--dry-run show what would happen; executes nothing, writes nothing
|
|
70
108
|
--json one bootproof/result/v1 JSON object on stdout
|
|
71
109
|
--ci no prompts, colours, or interactive UI; fail closed
|
|
110
|
+
--receipt write a self-verifying Living Receipt HTML to .bootproof/living-receipt.html
|
|
111
|
+
--ci-oidc fetch GitHub Actions OIDC token and sign at ci_oidc_signed trust level (requires permissions: id-token: write)
|
|
72
112
|
|
|
73
113
|
Options for fix:
|
|
74
|
-
--provider docker|local execution provider (default docker)
|
|
75
|
-
--unsafe-local required
|
|
114
|
+
--provider docker|local execution provider (default docker; docker only works for Compose apps)
|
|
115
|
+
--unsafe-local required consent for --provider local; confirms you accept host execution
|
|
76
116
|
--port <n> override inferred application port
|
|
77
117
|
--timeout <ms> before/after health timeout (default 60000)
|
|
118
|
+
--ai optional BYOK suggestion after no deterministic repair is known
|
|
78
119
|
--dry-run execute nothing, write nothing, produce no repair proof
|
|
79
120
|
--json one bootproof/repair-result/v1 object on stdout
|
|
80
121
|
|
|
122
|
+
Command repairs show the exact command and require the literal response Y before execution.
|
|
123
|
+
JSON and CI modes never prompt and never approve a command.
|
|
124
|
+
AI uses OPENAI_API_KEY or ANTHROPIC_API_KEY directly with native fetch. It sends only
|
|
125
|
+
redacted structured failure evidence after explicit consent and never runs inside bootproof up.
|
|
126
|
+
|
|
127
|
+
Options for diff:
|
|
128
|
+
--base <ref> base Git commit (default HEAD^)
|
|
129
|
+
--head <ref> head Git commit (default HEAD)
|
|
130
|
+
--json one bootproof/diff-result/v1 JSON object on stdout
|
|
131
|
+
|
|
132
|
+
Options for verify and attest check:
|
|
133
|
+
--trust-signer explicitly pin an intact foreign signer in ~/.bootproof/known_signers.json
|
|
134
|
+
--require-known-signer fail when the signer is not this machine or explicitly pinned
|
|
135
|
+
--strict require a known signer and fail directory verification on commit mismatch
|
|
136
|
+
|
|
81
137
|
Honesty contract: no green check without an observed event; dry runs say "would";
|
|
82
138
|
.env/.env.local are never written; secrets are never invented.
|
|
83
139
|
Remote execution requires --provider local --unsafe-local. docs/HONESTY_CONTRACT.md`);
|
|
@@ -105,6 +161,8 @@ function printInference(inf) {
|
|
|
105
161
|
console.log(` backend command: ${inf.backendCommand}`);
|
|
106
162
|
if (inf.frontendCommand)
|
|
107
163
|
console.log(` frontend command: ${inf.frontendCommand}`);
|
|
164
|
+
if (inf.asset_dev_server_command)
|
|
165
|
+
console.log(` asset dev server command: ${inf.asset_dev_server_command}`);
|
|
108
166
|
if (inf.workerCommand)
|
|
109
167
|
console.log(` worker command: ${inf.workerCommand}`);
|
|
110
168
|
if (inf.appCommand)
|
|
@@ -113,6 +171,9 @@ function printInference(inf) {
|
|
|
113
171
|
console.log(` preparation: ${inf.preparationCommands.map(command => command.command).join("; ")}`);
|
|
114
172
|
console.log(` command scope: ${inf.commandScope}`);
|
|
115
173
|
console.log(` port: ${inf.port} ${DIM}(${inf.portEvidence})${RESET}`);
|
|
174
|
+
if (inf.observedPort !== null)
|
|
175
|
+
console.log(` observed port: ${inf.observedPort}`);
|
|
176
|
+
console.log(` health candidate source: ${inf.healthCandidateSource}`);
|
|
116
177
|
if (inf.healthCandidates.length)
|
|
117
178
|
console.log(` health candidates: ${inf.healthCandidates.join(", ")}`);
|
|
118
179
|
if (inf.services.length)
|
|
@@ -157,6 +218,84 @@ function machineFailure(explanation) {
|
|
|
157
218
|
writtenFiles: [],
|
|
158
219
|
};
|
|
159
220
|
}
|
|
221
|
+
function externalMachineResult(attestation, evidencePath) {
|
|
222
|
+
return {
|
|
223
|
+
schema: "bootproof/result/v1",
|
|
224
|
+
booted: false,
|
|
225
|
+
healthVerified: attestation.result.healthVerified,
|
|
226
|
+
failureClass: attestation.result.failureClass,
|
|
227
|
+
classification: attestation.classification,
|
|
228
|
+
verificationMode: attestation.verificationMode,
|
|
229
|
+
bootproofOrchestrated: attestation.bootproofOrchestrated,
|
|
230
|
+
externalHealthUrl: attestation.externalHealthUrl,
|
|
231
|
+
observedStatus: attestation.observedStatus,
|
|
232
|
+
observedFinalUrl: attestation.observedFinalUrl,
|
|
233
|
+
observedAt: attestation.observedAt,
|
|
234
|
+
responseSnippet: attestation.responseSnippet,
|
|
235
|
+
attestationPath: evidencePath,
|
|
236
|
+
plan: attestation.plan,
|
|
237
|
+
observed: attestation.observed,
|
|
238
|
+
explanation: attestation.result.explanation,
|
|
239
|
+
trust: attestation.trust,
|
|
240
|
+
writtenFiles: evidencePath ? [evidencePath] : [],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
function printExternalHealthResult(attestation, evidencePath) {
|
|
244
|
+
const result = attestation.result;
|
|
245
|
+
if (result.healthVerified) {
|
|
246
|
+
ok(`${BOLD}EXTERNAL SERVICE VERIFIED${RESET}${GREEN} — ${result.healthObservation} (observed, signed)`);
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
bad(`${BOLD}NOT VERIFIED${RESET}${RED} — ${attestation.classification}`);
|
|
250
|
+
if (attestation.observedStatus !== null) {
|
|
251
|
+
console.log(`Observed: HTTP ${attestation.observedStatus} at ${attestation.observedFinalUrl}`);
|
|
252
|
+
}
|
|
253
|
+
const connectionError = result.healthEvidence?.connectionError;
|
|
254
|
+
if (connectionError)
|
|
255
|
+
console.log(`Connection error: ${connectionError}`);
|
|
256
|
+
}
|
|
257
|
+
console.log("Ownership: externally managed (bootproofOrchestrated=false).");
|
|
258
|
+
if (evidencePath)
|
|
259
|
+
console.log(`Evidence: ${evidencePath}`);
|
|
260
|
+
}
|
|
261
|
+
function printAgentPlan(plan, outputPath, runId, runPath) {
|
|
262
|
+
console.log(`${BOLD}Agent plan (planning only)${RESET}`);
|
|
263
|
+
if (plan.classifications.length)
|
|
264
|
+
console.log(`Classifications: ${plan.classifications.join(", ")}`);
|
|
265
|
+
console.log(`Current failure class: ${plan.currentFailureClass || "none established"}`);
|
|
266
|
+
console.log(`Suspected stack: ${plan.suspectedStack.length ? plan.suspectedStack.join(", ") : "none established"}`);
|
|
267
|
+
console.log(`BootProof direct orchestration: ${plan.canBootProofOrchestrateDirectly ? "available" : "not established"}`);
|
|
268
|
+
console.log(`External health verification: ${plan.canBootProofVerifyExternally ? "available" : "not established"}`);
|
|
269
|
+
if (plan.missingTools.length)
|
|
270
|
+
console.log(`Missing tools: ${plan.missingTools.join(", ")}`);
|
|
271
|
+
if (plan.observedEvidence.length) {
|
|
272
|
+
console.log("Observed evidence:");
|
|
273
|
+
for (const evidence of plan.observedEvidence)
|
|
274
|
+
console.log(` - ${evidence}`);
|
|
275
|
+
}
|
|
276
|
+
if (plan.candidateNextActions.length) {
|
|
277
|
+
console.log("Candidate next actions:");
|
|
278
|
+
for (const [index, candidate] of plan.candidateNextActions.entries()) {
|
|
279
|
+
console.log(` ${index + 1}. ${candidate.classification}`);
|
|
280
|
+
if (candidate.command)
|
|
281
|
+
console.log(` Command: ${candidate.command}`);
|
|
282
|
+
console.log(` Reason: ${candidate.reason}`);
|
|
283
|
+
console.log(` Risk: ${candidate.riskLevel}`);
|
|
284
|
+
console.log(` Mutation scope: ${candidate.mutationScope}`);
|
|
285
|
+
console.log(` Approval required: ${candidate.requiresApproval ? "yes" : "no"}`);
|
|
286
|
+
console.log(` Approval: ${candidate.approvalPrompt}`);
|
|
287
|
+
if (candidate.blockedReason)
|
|
288
|
+
console.log(` Blocked: ${candidate.blockedReason}`);
|
|
289
|
+
if (candidate.secretSensitive)
|
|
290
|
+
console.log(" Secret-sensitive: yes; command output must not be saved");
|
|
291
|
+
console.log(` Verify after action: ${candidate.verificationStep}`);
|
|
292
|
+
console.log(` Stop condition: ${candidate.stopCondition}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
console.log(`Plan: ${outputPath}`);
|
|
296
|
+
console.log(`Agent run: ${runId} (${runPath})`);
|
|
297
|
+
console.log("No candidate action was executed. Verification remains pending.");
|
|
298
|
+
}
|
|
160
299
|
function printFailure(failureClass, diagnosis, evidencePath) {
|
|
161
300
|
bad(`${BOLD}NOT VERIFIED${RESET}${RED} — ${failureClass}`);
|
|
162
301
|
console.log(`What happened: ${diagnosis.whatHappened}`);
|
|
@@ -167,6 +306,67 @@ function printFailure(failureClass, diagnosis, evidencePath) {
|
|
|
167
306
|
function isRepairReceipt(value) {
|
|
168
307
|
return Boolean(value && typeof value === "object" && value.schema === "bootproof/repair-receipt/v1");
|
|
169
308
|
}
|
|
309
|
+
function signatureTrustText(result) {
|
|
310
|
+
if (!result.integrityValid || result.tier === "invalid")
|
|
311
|
+
return "signature INVALID";
|
|
312
|
+
if (result.tier === "self")
|
|
313
|
+
return "signature intact, signer: this machine";
|
|
314
|
+
if (result.tier === "known") {
|
|
315
|
+
return `signature intact, signer: known (${result.label ?? result.fingerprint})`;
|
|
316
|
+
}
|
|
317
|
+
return "signature intact, signer: UNKNOWN — integrity only, not a trusted signer";
|
|
318
|
+
}
|
|
319
|
+
function printSignatureTrust(result) {
|
|
320
|
+
const message = signatureTrustText(result);
|
|
321
|
+
if (!result.integrityValid)
|
|
322
|
+
bad(message);
|
|
323
|
+
else if (result.tier === "unknown-foreign")
|
|
324
|
+
warn(message);
|
|
325
|
+
else
|
|
326
|
+
ok(message);
|
|
327
|
+
}
|
|
328
|
+
function maybeTrustSigner(result, publicKey, requested, reevaluate) {
|
|
329
|
+
if (!requested || !result.integrityValid || result.tier !== "unknown-foreign" || !publicKey)
|
|
330
|
+
return result;
|
|
331
|
+
console.log(`Trusting signer: ${result.fingerprint}`);
|
|
332
|
+
trustSigner(publicKey);
|
|
333
|
+
return reevaluate();
|
|
334
|
+
}
|
|
335
|
+
function signerTrustFails(result, requireKnown) {
|
|
336
|
+
return !result.integrityValid || (requireKnown && result.tier === "unknown-foreign");
|
|
337
|
+
}
|
|
338
|
+
function commitMismatchMessage(attestation, repo) {
|
|
339
|
+
const head = currentGitHead(repo);
|
|
340
|
+
if (!head || attestation.repo.commit === head)
|
|
341
|
+
return null;
|
|
342
|
+
const attested = attestation.repo.commit?.slice(0, 8) ?? "none";
|
|
343
|
+
return `This attestation does not describe the repository's current commit (${attested} vs ${head.slice(0, 8)}); it is not proof of the current working tree.`;
|
|
344
|
+
}
|
|
345
|
+
function optionalRepairReceipt(repo) {
|
|
346
|
+
const receipt = path.join(repo, ".bootproof", "repair-receipt.json");
|
|
347
|
+
if (!fs.existsSync(receipt))
|
|
348
|
+
return null;
|
|
349
|
+
try {
|
|
350
|
+
const value = JSON.parse(fs.readFileSync(receipt, "utf8"));
|
|
351
|
+
return isRepairReceipt(value) && verifyRepairReceipt(value) ? value : null;
|
|
352
|
+
}
|
|
353
|
+
catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
function registryEntryFor(repo, registryMode) {
|
|
358
|
+
const ap = attestationPath(repo);
|
|
359
|
+
if (!fs.existsSync(ap))
|
|
360
|
+
return null;
|
|
361
|
+
const att = JSON.parse(fs.readFileSync(ap, "utf8"));
|
|
362
|
+
return buildRegistryEntry(att, {
|
|
363
|
+
registryMode,
|
|
364
|
+
inference: inferRepo(repo),
|
|
365
|
+
repairReceipt: optionalRepairReceipt(repo),
|
|
366
|
+
branch: currentGitBranch(repo),
|
|
367
|
+
sign: true,
|
|
368
|
+
});
|
|
369
|
+
}
|
|
170
370
|
function printRepairResult(result) {
|
|
171
371
|
if (result.repaired) {
|
|
172
372
|
ok(`${BOLD}VERIFIED REPAIR${RESET}${GREEN} — ${result.repairId}`);
|
|
@@ -179,6 +379,83 @@ function printRepairResult(result) {
|
|
|
179
379
|
}
|
|
180
380
|
bad(`${BOLD}NO VERIFIED REPAIR${RESET}${RED}${result.failureClass ? ` — ${result.failureClass}` : ""}`);
|
|
181
381
|
console.log(result.explanation);
|
|
382
|
+
if (result.receiptPath)
|
|
383
|
+
console.log(`Receipt: ${result.receiptPath}`);
|
|
384
|
+
}
|
|
385
|
+
async function commandRepairApproval(action) {
|
|
386
|
+
console.log(action.approvalPrompt);
|
|
387
|
+
console.log(`Command: ${action.command.display}`);
|
|
388
|
+
console.log(`Mutation scope: ${action.mutationScope}`);
|
|
389
|
+
console.log(`Risk: ${action.riskLevel}`);
|
|
390
|
+
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
391
|
+
try {
|
|
392
|
+
return await prompt.question("Run this command? Type Y to approve: ") === "Y";
|
|
393
|
+
}
|
|
394
|
+
finally {
|
|
395
|
+
prompt.close();
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
async function patchRepairApproval(action) {
|
|
399
|
+
console.log(action.approvalPrompt);
|
|
400
|
+
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
401
|
+
try {
|
|
402
|
+
return await prompt.question("Test this patch in the repair sandbox? Type Y to approve: ") === "Y";
|
|
403
|
+
}
|
|
404
|
+
finally {
|
|
405
|
+
prompt.close();
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
function printAiRepairCandidate(requested) {
|
|
409
|
+
const action = requested.action;
|
|
410
|
+
console.log(`${BOLD}AI-suggested unverified repair${RESET}`);
|
|
411
|
+
console.log(`Provider: ${requested.provider} (${requested.model})`);
|
|
412
|
+
console.log(`Failure: ${requested.suggestion.failure_class}`);
|
|
413
|
+
console.log(`Confidence reported by provider: ${requested.suggestion.confidence}`);
|
|
414
|
+
console.log(`Explanation: ${requested.suggestion.explanation_for_user}`);
|
|
415
|
+
console.log(`Provider safety rationale (unverified): ${requested.suggestion.why_this_is_safe}`);
|
|
416
|
+
if (action.command)
|
|
417
|
+
console.log(`Command: ${action.command.display}`);
|
|
418
|
+
if (action.instruction)
|
|
419
|
+
console.log(`Instruction: ${action.instruction}`);
|
|
420
|
+
if (action.patch)
|
|
421
|
+
console.log(`Patch preview:\n${action.patch.content}`);
|
|
422
|
+
console.log(`Mutation scope: ${action.mutationScope}`);
|
|
423
|
+
console.log(`Risk: ${action.riskLevel}`);
|
|
424
|
+
console.log(`Approval: ${action.approvalPrompt}`);
|
|
425
|
+
console.log(`Verify after action: ${action.verificationStep}`);
|
|
426
|
+
console.log("Source: ai_suggested_unverified. AI has not proved this repair.");
|
|
427
|
+
}
|
|
428
|
+
async function aiRepairApproval(action, prompt) {
|
|
429
|
+
if (action.actionType === "command") {
|
|
430
|
+
return await prompt.question("Run this AI-suggested command? Type Y to approve: ") === "Y";
|
|
431
|
+
}
|
|
432
|
+
if (action.actionType === "patch") {
|
|
433
|
+
return await prompt.question("Test this AI-suggested patch in the repair sandbox? Type Y to approve: ") === "Y";
|
|
434
|
+
}
|
|
435
|
+
return await prompt.question("Approve recording this AI-suggested instruction? Type Y to approve: ") === "Y";
|
|
436
|
+
}
|
|
437
|
+
function printRepairCandidate(candidate) {
|
|
438
|
+
if (!candidate)
|
|
439
|
+
return;
|
|
440
|
+
const action = candidate.candidate.action;
|
|
441
|
+
console.log(`${BOLD}Deterministic repair candidate${RESET}`);
|
|
442
|
+
console.log(`Failure: ${candidate.candidate.failureClass}`);
|
|
443
|
+
if (action.command)
|
|
444
|
+
console.log(`Command: ${action.command.display}`);
|
|
445
|
+
if (action.instruction)
|
|
446
|
+
console.log(`Instruction: ${action.instruction}`);
|
|
447
|
+
if (action.patch)
|
|
448
|
+
console.log(`Patch preview:\n${action.patch.content}`);
|
|
449
|
+
console.log(`Mutation scope: ${action.mutationScope}`);
|
|
450
|
+
console.log(`Risk: ${action.riskLevel}`);
|
|
451
|
+
console.log(`Approval: ${action.approvalPrompt}`);
|
|
452
|
+
console.log(`Verify after action: ${action.verificationStep}`);
|
|
453
|
+
for (const followUp of candidate.candidate.followUpActions ?? []) {
|
|
454
|
+
if (followUp.command)
|
|
455
|
+
console.log(`Later separately approved command: ${followUp.command.display}`);
|
|
456
|
+
if (followUp.instruction)
|
|
457
|
+
console.log(`Follow-up instruction: ${followUp.instruction}`);
|
|
458
|
+
}
|
|
182
459
|
}
|
|
183
460
|
function printRepairApplyResult(result) {
|
|
184
461
|
if (result.applied) {
|
|
@@ -190,6 +467,41 @@ function printRepairApplyResult(result) {
|
|
|
190
467
|
bad(`${BOLD}REPAIR NOT APPLIED${RESET}`);
|
|
191
468
|
console.log(result.explanation);
|
|
192
469
|
}
|
|
470
|
+
function printDiffResult(result) {
|
|
471
|
+
console.log(`${BOLD}Static infrastructure diff${RESET}`);
|
|
472
|
+
console.log(`Base: ${result.base}`);
|
|
473
|
+
console.log(`Head: ${result.head}`);
|
|
474
|
+
console.log(`Risk: ${result.riskLevel}`);
|
|
475
|
+
console.log(`Fresh boot proof required: ${result.proofRequired ? "yes" : "no detected infrastructure drift"}`);
|
|
476
|
+
const sections = [
|
|
477
|
+
["Changed files", result.changedFiles],
|
|
478
|
+
["Added services", result.addedServices],
|
|
479
|
+
["Removed services", result.removedServices],
|
|
480
|
+
["Added ports", result.addedPorts],
|
|
481
|
+
["Removed ports", result.removedPorts],
|
|
482
|
+
["Added env vars", result.addedEnvVars],
|
|
483
|
+
["Removed env vars", result.removedEnvVars],
|
|
484
|
+
[
|
|
485
|
+
"Changed commands",
|
|
486
|
+
result.changedCommands.map(change => `${change.source}: ${change.before ?? "(absent)"} -> ${change.after ?? "(absent)"}`),
|
|
487
|
+
],
|
|
488
|
+
[
|
|
489
|
+
"Changed package managers",
|
|
490
|
+
result.changedPackageManagers.map(change => `${change.source}: ${change.before ?? "(absent)"} -> ${change.after ?? "(absent)"}`),
|
|
491
|
+
],
|
|
492
|
+
];
|
|
493
|
+
for (const [label, values] of sections) {
|
|
494
|
+
if (!values.length)
|
|
495
|
+
continue;
|
|
496
|
+
console.log(`${label}:`);
|
|
497
|
+
for (const value of values)
|
|
498
|
+
console.log(` - ${value}`);
|
|
499
|
+
}
|
|
500
|
+
console.log("Review notes:");
|
|
501
|
+
for (const note of result.suggestedReviewNotes)
|
|
502
|
+
console.log(` - ${note}`);
|
|
503
|
+
console.log("Static analysis only. No repository code was executed and no data was uploaded.");
|
|
504
|
+
}
|
|
193
505
|
function rebaseRemoteRepairPaths(result, repo) {
|
|
194
506
|
const rebase = (value) => value
|
|
195
507
|
? portableRelative(process.cwd(), path.join(repo, value))
|
|
@@ -216,11 +528,123 @@ async function main() {
|
|
|
216
528
|
const { flags, positional } = parseFlags(rest);
|
|
217
529
|
if (flags.ci || flags.json)
|
|
218
530
|
disableColor();
|
|
531
|
+
const unsupportedFlag = Object.keys(flags).find(flag => !SUPPORTED_FLAGS[cmd]?.has(flag));
|
|
532
|
+
if (unsupportedFlag) {
|
|
533
|
+
const explanation = `unsupported flag for ${cmd}: --${unsupportedFlag}`;
|
|
534
|
+
if (cmd === "up" && flags.json)
|
|
535
|
+
console.log(JSON.stringify(machineFailure(explanation)));
|
|
536
|
+
else
|
|
537
|
+
bad(explanation);
|
|
538
|
+
process.exitCode = 1;
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
219
541
|
const targetInput = String(positional[0] ?? ".");
|
|
542
|
+
if (cmd === "diff") {
|
|
543
|
+
const optionError = positional.length
|
|
544
|
+
? "diff does not accept a path; run it from the Git repository to compare"
|
|
545
|
+
: flags.base !== undefined && typeof flags.base !== "string"
|
|
546
|
+
? "--base requires a Git ref"
|
|
547
|
+
: flags.head !== undefined && typeof flags.head !== "string"
|
|
548
|
+
? "--head requires a Git ref"
|
|
549
|
+
: null;
|
|
550
|
+
if (optionError) {
|
|
551
|
+
bad(optionError);
|
|
552
|
+
process.exitCode = 1;
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const result = diffRefs(process.cwd(), {
|
|
556
|
+
base: flags.base,
|
|
557
|
+
head: flags.head,
|
|
558
|
+
});
|
|
559
|
+
if (flags.json)
|
|
560
|
+
console.log(JSON.stringify(result));
|
|
561
|
+
else
|
|
562
|
+
printDiffResult(result);
|
|
563
|
+
return;
|
|
564
|
+
}
|
|
565
|
+
if (cmd === "explain-run") {
|
|
566
|
+
if (!positional[0] || positional.length > 1) {
|
|
567
|
+
bad("explain-run requires exactly one run id");
|
|
568
|
+
process.exitCode = 1;
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
const run = readAgentRun(process.cwd(), positional[0]);
|
|
572
|
+
for (const line of explainAgentRun(process.cwd(), positional[0]))
|
|
573
|
+
console.log(line);
|
|
574
|
+
process.exitCode = run.chainValid ? 0 : 1;
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
if (cmd === "verify-url") {
|
|
578
|
+
if (!positional[0] || positional.length > 1) {
|
|
579
|
+
const explanation = "verify-url requires exactly one HTTP or HTTPS URL";
|
|
580
|
+
if (flags.json)
|
|
581
|
+
console.log(JSON.stringify(machineFailure(explanation)));
|
|
582
|
+
else
|
|
583
|
+
bad(explanation);
|
|
584
|
+
process.exitCode = 1;
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
const timeoutMs = Number(flags.timeout ?? 5000);
|
|
588
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
589
|
+
const explanation = `invalid --timeout value: ${String(flags.timeout)} (expected a positive number)`;
|
|
590
|
+
if (flags.json)
|
|
591
|
+
console.log(JSON.stringify(machineFailure(explanation)));
|
|
592
|
+
else
|
|
593
|
+
bad(explanation);
|
|
594
|
+
process.exitCode = 1;
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const attestation = await buildExternalHealthAttestation(process.cwd(), targetInput, timeoutMs);
|
|
598
|
+
if (flags.json)
|
|
599
|
+
console.log(JSON.stringify(attestation));
|
|
600
|
+
else
|
|
601
|
+
printExternalHealthResult(attestation, null);
|
|
602
|
+
process.exitCode = attestation.result.healthVerified ? 0 : 1;
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
if (cmd === "up" && flags["external-health"] !== undefined) {
|
|
606
|
+
const externalHealthUrl = flags["external-health"];
|
|
607
|
+
const incompatibleFlag = ["provider", "unsafe-local", "install", "workspace", "port", "dry-run", "command"]
|
|
608
|
+
.find(flag => flags[flag] !== undefined);
|
|
609
|
+
const timeoutMs = Number(flags.timeout ?? 5000);
|
|
610
|
+
const optionError = typeof externalHealthUrl !== "string" || externalHealthUrl.trim().length === 0
|
|
611
|
+
? "--external-health requires an HTTP or HTTPS URL"
|
|
612
|
+
: incompatibleFlag
|
|
613
|
+
? `--external-health cannot be combined with --${incompatibleFlag}`
|
|
614
|
+
: !Number.isFinite(timeoutMs) || timeoutMs <= 0
|
|
615
|
+
? `invalid --timeout value: ${String(flags.timeout)} (expected a positive number)`
|
|
616
|
+
: isRemoteTarget(targetInput)
|
|
617
|
+
? "--external-health requires a local evidence directory, not a remote repository URL"
|
|
618
|
+
: null;
|
|
619
|
+
if (optionError) {
|
|
620
|
+
if (flags.json)
|
|
621
|
+
console.log(JSON.stringify(machineFailure(optionError)));
|
|
622
|
+
else
|
|
623
|
+
bad(optionError);
|
|
624
|
+
process.exitCode = 1;
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
const target = path.resolve(targetInput);
|
|
628
|
+
const evidencePath = ".bootproof/attestation.json";
|
|
629
|
+
const attestation = await buildExternalHealthAttestation(target, externalHealthUrl, timeoutMs);
|
|
630
|
+
writeAttestation(target, attestation);
|
|
631
|
+
const runId = latestAgentRunId(target);
|
|
632
|
+
if (runId)
|
|
633
|
+
appendAgentVerification(target, runId, attestation);
|
|
634
|
+
if (flags.json)
|
|
635
|
+
console.log(JSON.stringify(externalMachineResult(attestation, evidencePath)));
|
|
636
|
+
else {
|
|
637
|
+
printExternalHealthResult(attestation, evidencePath);
|
|
638
|
+
if (runId)
|
|
639
|
+
console.log(`Agent run verification: ${runId}`);
|
|
640
|
+
}
|
|
641
|
+
process.exitCode = attestation.result.healthVerified ? 0 : 1;
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
220
644
|
let target = path.resolve(targetInput);
|
|
221
645
|
let remote = null;
|
|
222
646
|
let remoteSource = null;
|
|
223
|
-
if (["analyze", "plan", "up", "fix"].includes(cmd) && isRemoteTarget(targetInput)) {
|
|
647
|
+
if (["analyze", "plan", "plan-agent", "up", "fix"].includes(cmd) && isRemoteTarget(targetInput)) {
|
|
224
648
|
if (flags["dry-run"]) {
|
|
225
649
|
const explanation = "Remote dry runs are refused because cloning would write files, while BootProof dry runs promise to write nothing.";
|
|
226
650
|
if (flags.json)
|
|
@@ -249,7 +673,7 @@ async function main() {
|
|
|
249
673
|
return;
|
|
250
674
|
}
|
|
251
675
|
}
|
|
252
|
-
if (!remote && ["analyze", "plan", "up", "fix"].includes(cmd)) {
|
|
676
|
+
if (!remote && ["analyze", "plan", "plan-agent", "up", "fix"].includes(cmd)) {
|
|
253
677
|
remoteSource = managedRemoteSource(target);
|
|
254
678
|
if (remoteSource && !flags.json) {
|
|
255
679
|
console.log(`${DIM}Managed remote source: ${remoteSource}${RESET}`);
|
|
@@ -279,6 +703,20 @@ async function main() {
|
|
|
279
703
|
console.log(`${DIM}--- .env.bootproof.example (preview) ---\n${envExampleFor(inf)}${RESET}`);
|
|
280
704
|
return;
|
|
281
705
|
}
|
|
706
|
+
if (cmd === "plan-agent") {
|
|
707
|
+
const plan = buildAgentPlan(target);
|
|
708
|
+
const output = writeAgentPlan(target, plan);
|
|
709
|
+
const summary = createAgentRun(target, plan);
|
|
710
|
+
const displayedOutput = remote
|
|
711
|
+
? portableRelative(process.cwd(), output)
|
|
712
|
+
: portableRelative(process.cwd(), agentPlanPath(target));
|
|
713
|
+
const displayedRun = portableRelative(process.cwd(), agentRunDirectory(target, summary.runId));
|
|
714
|
+
if (flags.json)
|
|
715
|
+
console.log(JSON.stringify(plan));
|
|
716
|
+
else
|
|
717
|
+
printAgentPlan(plan, displayedOutput, summary.runId, displayedRun);
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
282
720
|
if (cmd === "apply-repair") {
|
|
283
721
|
if (isRemoteTarget(targetInput)) {
|
|
284
722
|
const result = {
|
|
@@ -368,12 +806,80 @@ async function main() {
|
|
|
368
806
|
process.exitCode = 1;
|
|
369
807
|
return;
|
|
370
808
|
}
|
|
809
|
+
const latestCandidate = latestDeterministicRepairCandidate(target, provider);
|
|
810
|
+
const latestFailure = latestFailedAttestation(target, provider);
|
|
811
|
+
const hasRegisteredDeterministicRepair = Boolean(latestFailure?.result.failureClass &&
|
|
812
|
+
registeredRemediationsFor(latestFailure.result.failureClass).length);
|
|
813
|
+
if (!latestCandidate && !hasRegisteredDeterministicRepair && flags.ai) {
|
|
814
|
+
resolveAiProvider();
|
|
815
|
+
const before = latestFailure;
|
|
816
|
+
if (!before) {
|
|
817
|
+
throw new Error("AI-assisted repair requires a signature-valid failed attestation. Run bootproof up first.");
|
|
818
|
+
}
|
|
819
|
+
if (flags.json || flags.ci) {
|
|
820
|
+
throw new Error("AI-assisted repair requires interactive consent and approval; --json and --ci never prompt or approve actions.");
|
|
821
|
+
}
|
|
822
|
+
const prompt = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
823
|
+
try {
|
|
824
|
+
const requestApproved = await prompt.question("No verified deterministic remediation is known. Request an AI suggestion using your BYOK provider? Type Y to approve: ") === "Y";
|
|
825
|
+
if (!requestApproved) {
|
|
826
|
+
const result = {
|
|
827
|
+
schema: "bootproof/repair-result/v1",
|
|
828
|
+
repaired: false,
|
|
829
|
+
failureClass: before.result.failureClass,
|
|
830
|
+
repairId: null,
|
|
831
|
+
receiptPath: null,
|
|
832
|
+
patchPath: null,
|
|
833
|
+
afterAttestationPath: null,
|
|
834
|
+
explanation: "AI suggestion request was declined. No AI provider request, command, or patch occurred.",
|
|
835
|
+
};
|
|
836
|
+
printRepairResult(result);
|
|
837
|
+
process.exitCode = 1;
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
const requested = await requestAiRepairSuggestion(before, { timeoutMs });
|
|
841
|
+
printAiRepairCandidate(requested);
|
|
842
|
+
const actionApproved = await aiRepairApproval(requested.action, prompt);
|
|
843
|
+
const repairResult = await executeAiSuggestedRepair(target, before, requested.action, {
|
|
844
|
+
provider: provider,
|
|
845
|
+
unsafeLocal: Boolean(flags["unsafe-local"]),
|
|
846
|
+
timeoutMs,
|
|
847
|
+
port,
|
|
848
|
+
remoteSource: remoteSource ?? undefined,
|
|
849
|
+
actionApproved,
|
|
850
|
+
aiRepair: requested,
|
|
851
|
+
});
|
|
852
|
+
const result = remote ? rebaseRemoteRepairPaths(repairResult, target) : repairResult;
|
|
853
|
+
printRepairResult(result);
|
|
854
|
+
process.exitCode = result.repaired ? 0 : 1;
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
finally {
|
|
858
|
+
prompt.close();
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
let actionApproved = false;
|
|
862
|
+
if (latestCandidate && !flags.json) {
|
|
863
|
+
printRepairCandidate(latestCandidate);
|
|
864
|
+
}
|
|
865
|
+
if (latestCandidate &&
|
|
866
|
+
latestCandidate.candidate.action.actionType !== "instruction" &&
|
|
867
|
+
!flags.json &&
|
|
868
|
+
!flags.ci) {
|
|
869
|
+
const effectiveProvider = provider ?? latestCandidate.attestation.plan.provider;
|
|
870
|
+
if (effectiveProvider !== "local" || flags["unsafe-local"]) {
|
|
871
|
+
actionApproved = latestCandidate.candidate.action.actionType === "command"
|
|
872
|
+
? await commandRepairApproval(latestCandidate.candidate.action)
|
|
873
|
+
: await patchRepairApproval(latestCandidate.candidate.action);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
371
876
|
const repairResult = await repairRepo(target, {
|
|
372
877
|
provider: provider,
|
|
373
878
|
unsafeLocal: Boolean(flags["unsafe-local"]),
|
|
374
879
|
timeoutMs,
|
|
375
880
|
port,
|
|
376
881
|
remoteSource: remoteSource ?? undefined,
|
|
882
|
+
actionApproved,
|
|
377
883
|
});
|
|
378
884
|
const result = remote ? rebaseRemoteRepairPaths(repairResult, target) : repairResult;
|
|
379
885
|
if (flags.json)
|
|
@@ -387,13 +893,19 @@ async function main() {
|
|
|
387
893
|
const provider = flags.provider ?? "docker";
|
|
388
894
|
const timeoutMs = Number(flags.timeout ?? 60_000);
|
|
389
895
|
const port = flags.port === undefined ? undefined : Number(flags.port);
|
|
896
|
+
const command = flags.command;
|
|
897
|
+
const healthPath = typeof flags["health-path"] === "string" ? flags["health-path"] : undefined;
|
|
390
898
|
const optionError = provider !== "docker" && provider !== "local"
|
|
391
899
|
? `invalid --provider value: ${String(provider)} (expected docker or local)`
|
|
392
900
|
: !Number.isFinite(timeoutMs) || timeoutMs <= 0
|
|
393
901
|
? `invalid --timeout value: ${String(flags.timeout)} (expected a positive number)`
|
|
394
902
|
: port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)
|
|
395
903
|
? `invalid --port value: ${String(flags.port)} (expected an integer from 1 to 65535)`
|
|
396
|
-
:
|
|
904
|
+
: command !== undefined && (typeof command !== "string" || command.trim().length === 0)
|
|
905
|
+
? "--command requires a non-empty command string"
|
|
906
|
+
: healthPath !== undefined && (typeof healthPath !== "string" || !healthPath.startsWith("/"))
|
|
907
|
+
? "--health-path requires a path starting with / (e.g. /health)"
|
|
908
|
+
: null;
|
|
397
909
|
if (optionError) {
|
|
398
910
|
if (flags.json)
|
|
399
911
|
console.log(JSON.stringify(machineFailure(optionError)));
|
|
@@ -411,9 +923,18 @@ async function main() {
|
|
|
411
923
|
timeoutMs,
|
|
412
924
|
install: Boolean(flags.install),
|
|
413
925
|
port,
|
|
926
|
+
command: typeof command === "string" ? command : undefined,
|
|
927
|
+
healthPath,
|
|
928
|
+
ciOidc: Boolean(flags["ci-oidc"]),
|
|
414
929
|
};
|
|
415
930
|
const outcome = await up(target, opts);
|
|
416
931
|
const verified = outcome.attestation?.result.booted === true && outcome.attestation.result.healthVerified === true;
|
|
932
|
+
if (flags.receipt && outcome.attestation) {
|
|
933
|
+
const receiptPath = path.join(target, ".bootproof", "living-receipt.html");
|
|
934
|
+
emitLivingReceipt(outcome.attestation, receiptPath);
|
|
935
|
+
if (!flags.json)
|
|
936
|
+
console.log(`${GREEN}\u2713${RESET} Living Receipt: ${portableRelative(process.cwd(), receiptPath)}`);
|
|
937
|
+
}
|
|
417
938
|
if (flags.json) {
|
|
418
939
|
console.log(JSON.stringify(machineResult(outcome, evidencePath)));
|
|
419
940
|
if (flags.ci || !opts.dryRun)
|
|
@@ -456,25 +977,43 @@ async function main() {
|
|
|
456
977
|
return;
|
|
457
978
|
}
|
|
458
979
|
if (cmd === "verify") {
|
|
459
|
-
const
|
|
980
|
+
const directoryTarget = fs.existsSync(target) && fs.statSync(target).isDirectory();
|
|
981
|
+
const p = directoryTarget ? attestationPath(target) : target;
|
|
460
982
|
if (!fs.existsSync(p)) {
|
|
461
983
|
bad(`no proof at ${p} — run bootproof up or bootproof fix first`);
|
|
462
984
|
process.exitCode = 1;
|
|
463
985
|
return;
|
|
464
986
|
}
|
|
465
987
|
const proof = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
988
|
+
const requireKnownSigner = Boolean(flags["require-known-signer"] || flags.strict);
|
|
989
|
+
const trustRequested = Boolean(flags["trust-signer"]);
|
|
466
990
|
if (isRepairReceipt(proof)) {
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
991
|
+
let trust = evaluateRepairReceiptSignature(proof);
|
|
992
|
+
trust = maybeTrustSigner(trust, proof.signer?.publicKey, trustRequested, () => evaluateRepairReceiptSignature(proof));
|
|
993
|
+
printSignatureTrust(trust);
|
|
994
|
+
const summary = proof.verification && proof.repair
|
|
995
|
+
? `failure=${proof.verification.before.failureClass} repair=${proof.repair.id} after=${proof.verification.after.healthObservation}`
|
|
996
|
+
: `failure=${proof.beforeFailureClass} repair=${proof.repairId} applied=${proof.applyResult.status} progressed=${proof.progressed} verified=${proof.verified}`;
|
|
997
|
+
console.log(`${DIM}${summary}${RESET}`);
|
|
998
|
+
if (signerTrustFails(trust, requireKnownSigner))
|
|
471
999
|
process.exitCode = 1;
|
|
472
1000
|
return;
|
|
473
1001
|
}
|
|
474
1002
|
const att = proof;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
1003
|
+
let trust = evaluateAttestationSignature(att);
|
|
1004
|
+
trust = maybeTrustSigner(trust, att.signer?.publicKey, trustRequested, () => evaluateAttestationSignature(att));
|
|
1005
|
+
printSignatureTrust(trust);
|
|
1006
|
+
console.log(`Attested trust claim: ${att.trust?.level ?? "legacy_unspecified"} (signed content; signer tier shown above)`);
|
|
1007
|
+
const commitMismatch = directoryTarget ? commitMismatchMessage(att, target) : null;
|
|
1008
|
+
if (commitMismatch)
|
|
1009
|
+
warn(commitMismatch);
|
|
1010
|
+
if (att.verificationMode === "external-health") {
|
|
1011
|
+
console.log(`${DIM}attested: classification=${att.classification} bootproofOrchestrated=false at ${att.observedAt ?? att.finishedAt}${RESET}`);
|
|
1012
|
+
console.log("This attestation observes an externally managed service; it does not claim BootProof started the application.");
|
|
1013
|
+
if (signerTrustFails(trust, requireKnownSigner) || (commitMismatch && requireKnownSigner))
|
|
1014
|
+
process.exitCode = 1;
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
478
1017
|
console.log(`${DIM}attested: booted=${att.result.booted} at commit ${att.repo.commit ?? "unknown"} on ${att.environment.os} node ${att.environment.node}${RESET}`);
|
|
479
1018
|
const retainedRemote = managedRemoteSource(att.repo.path);
|
|
480
1019
|
if (retainedRemote) {
|
|
@@ -484,29 +1023,85 @@ async function main() {
|
|
|
484
1023
|
else {
|
|
485
1024
|
console.log(`Replaying attested plan with bootproof up --provider ${att.plan.provider} would re-verify it on this machine.`);
|
|
486
1025
|
}
|
|
487
|
-
if (att.result.booted) {
|
|
1026
|
+
if (att.result.booted && !commitMismatch && (trust.tier === "self" || trust.tier === "known")) {
|
|
488
1027
|
const live = await pollHealth(att.plan.healthUrl, 3000);
|
|
489
1028
|
if (live.responded)
|
|
490
1029
|
ok(`bonus observation: ${att.plan.healthUrl} is responding right now (HTTP ${live.status})`);
|
|
491
1030
|
else
|
|
492
1031
|
console.log(`${DIM}(app not currently running — attestation describes a past verified run)${RESET}`);
|
|
493
1032
|
}
|
|
494
|
-
if (
|
|
1033
|
+
else if (att.result.booted && commitMismatch) {
|
|
1034
|
+
console.log(`${DIM}(live health bonus skipped because the attestation does not match the current commit)${RESET}`);
|
|
1035
|
+
}
|
|
1036
|
+
else if (att.result.booted && trust.tier === "unknown-foreign") {
|
|
1037
|
+
console.log(`${DIM}(live health bonus skipped because the signer is unknown)${RESET}`);
|
|
1038
|
+
}
|
|
1039
|
+
if (signerTrustFails(trust, requireKnownSigner) || (commitMismatch && requireKnownSigner))
|
|
495
1040
|
process.exitCode = 1;
|
|
496
1041
|
return;
|
|
497
1042
|
}
|
|
1043
|
+
if (cmd === "registry") {
|
|
1044
|
+
const sub = positional[0];
|
|
1045
|
+
const repo = path.resolve(String(positional[1] ?? "."));
|
|
1046
|
+
if (sub !== "export") {
|
|
1047
|
+
bad(`unknown registry subcommand: ${sub ?? "(none)"} — use export`);
|
|
1048
|
+
process.exitCode = 1;
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
const requestedMode = String(flags.mode ?? (flags.federated ? "federated_public_candidate" : "local_export"));
|
|
1052
|
+
const registryModes = ["local_export", "federated_public_candidate", "cloud_upload_candidate"];
|
|
1053
|
+
if (!registryModes.includes(requestedMode)) {
|
|
1054
|
+
bad(`invalid --mode value: ${requestedMode}`);
|
|
1055
|
+
process.exitCode = 1;
|
|
1056
|
+
return;
|
|
1057
|
+
}
|
|
1058
|
+
if (flags.federated && requestedMode !== "federated_public_candidate") {
|
|
1059
|
+
bad("--federated requires --mode federated_public_candidate when --mode is provided");
|
|
1060
|
+
process.exitCode = 1;
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const entry = registryEntryFor(repo, requestedMode);
|
|
1064
|
+
if (!entry) {
|
|
1065
|
+
bad(`no attestation at ${attestationPath(repo)} — run bootproof up first`);
|
|
1066
|
+
process.exitCode = 1;
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
if (flags.federated) {
|
|
1070
|
+
const receipt = buildFederatedReceipt(entry, { sign: true });
|
|
1071
|
+
const output = writeFederatedReceipt(repo, receipt);
|
|
1072
|
+
ok(`wrote redacted federated public candidate: ${output}`);
|
|
1073
|
+
}
|
|
1074
|
+
else {
|
|
1075
|
+
const output = writeRegistryEntry(repo, entry);
|
|
1076
|
+
ok(`wrote redacted registry entry: ${output}`);
|
|
1077
|
+
}
|
|
1078
|
+
console.log(`${DIM}redactions applied: ${entry.redactionsApplied.join(", ")}${RESET}`);
|
|
1079
|
+
console.log("Nothing has been uploaded. This export is local and opt-in.");
|
|
1080
|
+
if (flags.federated) {
|
|
1081
|
+
console.log("Review the receipt before deliberately committing it to the public repository.");
|
|
1082
|
+
}
|
|
1083
|
+
else if (requestedMode === "cloud_upload_candidate") {
|
|
1084
|
+
console.log("This is only a Cloud upload candidate. BootProof Cloud upload is not implemented here.");
|
|
1085
|
+
}
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
498
1088
|
if (cmd === "attest") {
|
|
499
1089
|
const sub = positional[0];
|
|
500
1090
|
const repo = path.resolve(String(positional[1] ?? "."));
|
|
501
1091
|
if (sub === "export") {
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
1092
|
+
const verificationFlag = ["trust-signer", "require-known-signer", "strict"]
|
|
1093
|
+
.find(flag => flags[flag] !== undefined);
|
|
1094
|
+
if (verificationFlag) {
|
|
1095
|
+
bad(`--${verificationFlag} is supported only by attest check`);
|
|
1096
|
+
process.exitCode = 1;
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
const entry = registryEntryFor(repo, "local_export");
|
|
1100
|
+
if (!entry) {
|
|
1101
|
+
bad(`no attestation at ${attestationPath(repo)} — run bootproof up first`);
|
|
505
1102
|
process.exitCode = 1;
|
|
506
1103
|
return;
|
|
507
1104
|
}
|
|
508
|
-
const att = JSON.parse(fs.readFileSync(ap, "utf8"));
|
|
509
|
-
const entry = buildRegistryEntry(att);
|
|
510
1105
|
const out = writeRegistryEntry(repo, entry);
|
|
511
1106
|
ok(`wrote redacted registry entry: ${out}`);
|
|
512
1107
|
console.log(`${DIM}redactions applied: ${entry.redactionsApplied.length ? entry.redactionsApplied.join(", ") : "none needed"}${RESET}`);
|
|
@@ -523,10 +1118,12 @@ async function main() {
|
|
|
523
1118
|
return;
|
|
524
1119
|
}
|
|
525
1120
|
const entry = JSON.parse(fs.readFileSync(ep, "utf8"));
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
1121
|
+
const requireKnownSigner = Boolean(flags["require-known-signer"] || flags.strict);
|
|
1122
|
+
let trust = evaluateRegistryEntrySignature(entry);
|
|
1123
|
+
trust = maybeTrustSigner(trust, entry.signature?.publicKey, Boolean(flags["trust-signer"]), () => evaluateRegistryEntrySignature(entry));
|
|
1124
|
+
printSignatureTrust(trust);
|
|
1125
|
+
console.log(`${DIM}verified=${entry.verified} class=${entry.failureClass ?? "none"} commit=${entry.commitHash?.slice(0, 8) ?? "?"}${RESET}`);
|
|
1126
|
+
if (signerTrustFails(trust, requireKnownSigner))
|
|
530
1127
|
process.exitCode = 1;
|
|
531
1128
|
return;
|
|
532
1129
|
}
|
|
@@ -534,34 +1131,121 @@ async function main() {
|
|
|
534
1131
|
process.exitCode = 1;
|
|
535
1132
|
return;
|
|
536
1133
|
}
|
|
1134
|
+
if (cmd === "export-sbom") {
|
|
1135
|
+
const format = flags.format ?? "cyclonedx-json";
|
|
1136
|
+
if (format !== "cyclonedx-json") {
|
|
1137
|
+
if (flags.json)
|
|
1138
|
+
console.log(JSON.stringify(machineFailure(`Unsupported SBOM format: ${format}. Currently supported: cyclonedx-json.`)));
|
|
1139
|
+
else
|
|
1140
|
+
bad(`Unsupported SBOM format: ${format}. Currently supported: cyclonedx-json.`);
|
|
1141
|
+
process.exitCode = 1;
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
const result = exportSbom(target, format);
|
|
1146
|
+
if (flags.json) {
|
|
1147
|
+
console.log(JSON.stringify(result));
|
|
1148
|
+
return;
|
|
1149
|
+
}
|
|
1150
|
+
ok(`SBOM exported: ${portableRelative(process.cwd(), result.path)}`);
|
|
1151
|
+
console.log(` format: ${result.format}`);
|
|
1152
|
+
console.log(` components: ${result.componentCount}`);
|
|
1153
|
+
console.log(`${DIM}CycloneDX 1.5 JSON. Read from package-lock.json. No transitive resolution beyond the lockfile.${RESET}`);
|
|
1154
|
+
}
|
|
1155
|
+
catch (e) {
|
|
1156
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1157
|
+
if (flags.json)
|
|
1158
|
+
console.log(JSON.stringify(machineFailure(msg)));
|
|
1159
|
+
else
|
|
1160
|
+
bad(msg);
|
|
1161
|
+
process.exitCode = 1;
|
|
1162
|
+
}
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
if (cmd === "rotate-keys") {
|
|
1166
|
+
const repo = flags.repo ? path.resolve(String(flags.repo)) : undefined;
|
|
1167
|
+
const resign = Boolean(flags.resign);
|
|
1168
|
+
const backup = !flags["no-backup"];
|
|
1169
|
+
try {
|
|
1170
|
+
const result = rotateSigner({ repo, resignAttestation: resign, backup });
|
|
1171
|
+
if (flags.json) {
|
|
1172
|
+
console.log(JSON.stringify(result));
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
ok("Signing key rotated.");
|
|
1176
|
+
console.log(` old public key: ${result.oldPublicKey.split("\n")[0]}...`);
|
|
1177
|
+
console.log(` new public key: ${result.newPublicKey.split("\n")[0]}...`);
|
|
1178
|
+
if (result.backedUpTo)
|
|
1179
|
+
console.log(` old key backed up to: ${result.backedUpTo}`);
|
|
1180
|
+
if (result.reSignedAttestation)
|
|
1181
|
+
console.log(` re-signed latest attestation at ${repo}`);
|
|
1182
|
+
console.log(`${DIM}Existing attestations still verify with their embedded public key. Rotation only affects future signatures.${RESET}`);
|
|
1183
|
+
}
|
|
1184
|
+
catch (e) {
|
|
1185
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
1186
|
+
if (flags.json)
|
|
1187
|
+
console.log(JSON.stringify(machineFailure(msg)));
|
|
1188
|
+
else
|
|
1189
|
+
bad(msg);
|
|
1190
|
+
process.exitCode = 1;
|
|
1191
|
+
}
|
|
1192
|
+
return;
|
|
1193
|
+
}
|
|
537
1194
|
if (cmd === "explain") {
|
|
538
1195
|
const p = positional[0] ? path.resolve(positional[0]) : attestationPath(target);
|
|
539
1196
|
const proof = JSON.parse(fs.readFileSync(p, "utf8"));
|
|
540
1197
|
if (isRepairReceipt(proof)) {
|
|
541
|
-
const
|
|
1198
|
+
const trust = evaluateRepairReceiptSignature(proof);
|
|
542
1199
|
console.log(`${BOLD}Repair receipt explained${RESET}`);
|
|
543
|
-
console.log(`Signature: ${
|
|
544
|
-
if (!
|
|
1200
|
+
console.log(`Signature: ${signatureTrustText(trust).replace(/^signature /, "")}`);
|
|
1201
|
+
if (!trust.integrityValid) {
|
|
545
1202
|
console.log("The receipt has been tampered with or is malformed. Its repair claims are not trusted.");
|
|
546
1203
|
process.exitCode = 1;
|
|
547
1204
|
return;
|
|
548
1205
|
}
|
|
549
|
-
console.log(`Before: NOT VERIFIED — ${proof.
|
|
550
|
-
console.log(`Repair: ${proof.
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
if (proof.
|
|
554
|
-
console.log(`
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
1206
|
+
console.log(`Before: NOT VERIFIED — ${proof.beforeFailureClass}.`);
|
|
1207
|
+
console.log(`Repair: ${proof.repairId} (${proof.actionType}; scope=${proof.mutationScope}; risk=${proof.riskLevel}).`);
|
|
1208
|
+
if (proof.proposedAction.command)
|
|
1209
|
+
console.log(`Command: ${proof.proposedAction.command.display}`);
|
|
1210
|
+
if (proof.proposedAction.instruction)
|
|
1211
|
+
console.log(`Instruction: ${proof.proposedAction.instruction}`);
|
|
1212
|
+
console.log(`Applied: ${proof.applyResult.status}. Progressed: ${proof.progressed}. Verified: ${proof.verified}.`);
|
|
1213
|
+
if (proof.afterFailureClass)
|
|
1214
|
+
console.log(`After failure: ${proof.afterFailureClass}.`);
|
|
1215
|
+
console.log(`Description: ${proof.explanation}`);
|
|
1216
|
+
if (proof.repair) {
|
|
1217
|
+
if (proof.repair.filesChanged.length)
|
|
1218
|
+
console.log(`Files changed in sandbox: ${proof.repair.filesChanged.join(", ")}`);
|
|
1219
|
+
if (proof.repair.planDelta)
|
|
1220
|
+
console.log(`Plan delta: ${proof.repair.planDelta}`);
|
|
1221
|
+
if (proof.repair.envDelta)
|
|
1222
|
+
console.log(`Environment delta: ${proof.repair.envDelta}`);
|
|
1223
|
+
}
|
|
559
1224
|
return;
|
|
560
1225
|
}
|
|
561
1226
|
const att = proof;
|
|
1227
|
+
const trust = evaluateAttestationSignature(att);
|
|
562
1228
|
console.log(`${BOLD}Attestation explained${RESET}`);
|
|
1229
|
+
console.log(`Signature: ${signatureTrustText(trust).replace(/^signature /, "")}`);
|
|
1230
|
+
if (!trust.integrityValid) {
|
|
1231
|
+
console.log("The attestation has been tampered with or is malformed. Its boot claims are not trusted.");
|
|
1232
|
+
process.exitCode = 1;
|
|
1233
|
+
return;
|
|
1234
|
+
}
|
|
1235
|
+
if (att.verificationMode === "external-health") {
|
|
1236
|
+
console.log(att.result.healthVerified
|
|
1237
|
+
? `This externally managed service was VERIFIED: ${att.result.healthObservation}.`
|
|
1238
|
+
: `This external health check did NOT verify. Classification: ${att.classification}.`);
|
|
1239
|
+
console.log("BootProof did not start or orchestrate this service.");
|
|
1240
|
+
console.log(att.result.explanation);
|
|
1241
|
+
if (att.plan.healthCandidates?.length)
|
|
1242
|
+
console.log(`Health candidates: ${att.plan.healthCandidates.join(", ")}`);
|
|
1243
|
+
for (const o of att.observed)
|
|
1244
|
+
console.log(` ${o.ok ? "\u2713" : "\u2717"} ${o.id}: ${o.observation}`);
|
|
1245
|
+
return;
|
|
1246
|
+
}
|
|
563
1247
|
console.log(att.result.booted ? `This run BOOTED: ${att.result.healthObservation}.` : `This run did NOT verify. Failure class: ${att.result.failureClass}.`);
|
|
564
|
-
console.log(`
|
|
1248
|
+
console.log(`Attested trust claim: ${att.trust?.level ?? "legacy_unspecified"} (signed content; signer tier shown above)`);
|
|
565
1249
|
if (!att.result.booted && att.result.failureClass) {
|
|
566
1250
|
const diagnosis = diagnoseFailure(att.result.failureClass, att.result.failureEvidence, att.result.explanation);
|
|
567
1251
|
console.log(`What happened: ${diagnosis.whatHappened}`);
|
|
@@ -583,7 +1267,7 @@ async function main() {
|
|
|
583
1267
|
}
|
|
584
1268
|
main().catch(err => {
|
|
585
1269
|
const argv = process.argv.slice(2);
|
|
586
|
-
if (argv.includes("--json") && argv[0] === "up") {
|
|
1270
|
+
if (argv.includes("--json") && (argv[0] === "up" || argv[0] === "verify-url")) {
|
|
587
1271
|
console.log(JSON.stringify(machineFailure(String(err?.message ?? err))));
|
|
588
1272
|
}
|
|
589
1273
|
else if (argv.includes("--json") && argv[0] === "fix") {
|