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/repair.js
CHANGED
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
2
3
|
import fs from "node:fs";
|
|
3
4
|
import net from "node:net";
|
|
4
5
|
import os from "node:os";
|
|
5
6
|
import path from "node:path";
|
|
6
7
|
import { parse, stringify } from "yaml";
|
|
7
|
-
import {
|
|
8
|
+
import { buildExecutionEnv, runToCompletion } from "./exec.js";
|
|
8
9
|
import { REPAIRED_GENERATED_COMPOSE_MARKER, repoComposeRepairFile } from "./plan.js";
|
|
9
|
-
import {
|
|
10
|
+
import { redactText } from "./redact.js";
|
|
11
|
+
import { attestationPath, buildAttestation, evaluateDetachedSignature, gitInfo, signDetached, TOOL_ID, verifyDetached, verifySignature, writeAttestation, } from "./proof.js";
|
|
10
12
|
import { up } from "./run.js";
|
|
11
13
|
import { inferRepo } from "./infer.js";
|
|
14
|
+
import { buildExternalHealthAttestation } from "./external-health.js";
|
|
15
|
+
import { buildRepairAction, buildRepairReceiptBase, createRepairCommand, validateRepairAction, } from "./repair-safety.js";
|
|
16
|
+
import { deterministicRepairCandidateFor, repairProgressed, } from "./repair-playbooks.js";
|
|
17
|
+
export * from "./repair-safety.js";
|
|
18
|
+
export * from "./repair-playbooks.js";
|
|
12
19
|
const LOCKFILE = /^(?:package-lock\.json|npm-shrinkwrap\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lockb?|Gemfile\.lock|go\.sum)$/;
|
|
13
20
|
const ENV_EXAMPLE = /^\.env(?:\.[^/]+)?\.example$/;
|
|
14
21
|
const BOOTPROOF_FILE = /(^|\/)[^/]*\.bootproof\.[^/]+$/;
|
|
@@ -45,6 +52,8 @@ export function assertRepairScope(changes) {
|
|
|
45
52
|
const file = normalizedRelative(change.path);
|
|
46
53
|
const base = path.posix.basename(file);
|
|
47
54
|
const allowed = file === "package.json" ||
|
|
55
|
+
file === "config/database.yml" ||
|
|
56
|
+
file === "config/gitlab.yml" ||
|
|
48
57
|
LOCKFILE.test(base) ||
|
|
49
58
|
BOOTPROOF_FILE.test(file) ||
|
|
50
59
|
ENV_EXAMPLE.test(base) ||
|
|
@@ -74,6 +83,9 @@ export function verifyRepairReceipt(receipt) {
|
|
|
74
83
|
return false;
|
|
75
84
|
return verifyDetached(canonicalReceipt(receipt), receipt.signature, receipt.signer.publicKey);
|
|
76
85
|
}
|
|
86
|
+
export function evaluateRepairReceiptSignature(receipt) {
|
|
87
|
+
return evaluateDetachedSignature(canonicalReceipt(receipt), receipt.signature, receipt.signer?.publicKey);
|
|
88
|
+
}
|
|
77
89
|
export function sha256Attestation(attestation) {
|
|
78
90
|
return crypto.createHash("sha256").update(JSON.stringify(attestation)).digest("hex");
|
|
79
91
|
}
|
|
@@ -237,6 +249,8 @@ async function remapConflictingServicePort(context) {
|
|
|
237
249
|
preconditions: [{ path: repoCompose, content: source }],
|
|
238
250
|
planDelta: `Create ${repairFile} as a complete repaired copy of ${repoCompose}. Use service step: ${command}`,
|
|
239
251
|
envDelta: null,
|
|
252
|
+
mutationScope: "repo_only",
|
|
253
|
+
riskLevel: "low",
|
|
240
254
|
};
|
|
241
255
|
}
|
|
242
256
|
const generatedPath = path.join(context.sandbox, composeFile);
|
|
@@ -260,6 +274,8 @@ async function remapConflictingServicePort(context) {
|
|
|
260
274
|
preconditions: [],
|
|
261
275
|
planDelta: null,
|
|
262
276
|
envDelta: null,
|
|
277
|
+
mutationScope: "repo_only",
|
|
278
|
+
riskLevel: "low",
|
|
263
279
|
};
|
|
264
280
|
}
|
|
265
281
|
export function packageManagerActivationCommand(packageManager, version) {
|
|
@@ -274,7 +290,7 @@ async function activatePackageManager(context) {
|
|
|
274
290
|
return null;
|
|
275
291
|
const corepackHome = path.join(context.sandbox, ".bootproof", "corepack");
|
|
276
292
|
const environment = { COREPACK_HOME: corepackHome };
|
|
277
|
-
const result = await runToCompletion(command, context.sandbox, 120_000,
|
|
293
|
+
const result = await runToCompletion(command, context.sandbox, 120_000, buildExecutionEnv(environment));
|
|
278
294
|
if (result.exitCode !== 0 || result.timedOut) {
|
|
279
295
|
throw new Error(`environment remediation failed: ${command}\n${result.stderr || result.stdout}`);
|
|
280
296
|
}
|
|
@@ -290,6 +306,9 @@ async function activatePackageManager(context) {
|
|
|
290
306
|
planDelta: null,
|
|
291
307
|
envDelta: command,
|
|
292
308
|
environment,
|
|
309
|
+
mutationScope: "project_cache",
|
|
310
|
+
riskLevel: "medium",
|
|
311
|
+
command: createRepairCommand("corepack", ["prepare", `${packageManager}@${packageManagerVersion}`, "--activate"]),
|
|
293
312
|
};
|
|
294
313
|
}
|
|
295
314
|
export function prismaRepairCommand(repo) {
|
|
@@ -308,6 +327,10 @@ function readPackageJson(repo) {
|
|
|
308
327
|
function hasAnyFile(repo, files) {
|
|
309
328
|
return files.find(file => fs.existsSync(path.join(repo, file))) ?? null;
|
|
310
329
|
}
|
|
330
|
+
function structuredPlaybookCommand(command) {
|
|
331
|
+
const [executable, ...args] = command.split(" ");
|
|
332
|
+
return createRepairCommand(executable, args);
|
|
333
|
+
}
|
|
311
334
|
export function migrationRepairFor(repo, evidence) {
|
|
312
335
|
const pkg = readPackageJson(repo);
|
|
313
336
|
const dependencies = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
@@ -400,6 +423,9 @@ async function deployFrameworkMigrations(context) {
|
|
|
400
423
|
planDelta: `Insert after dependency installation and before application start: ${migration.command}`,
|
|
401
424
|
envDelta: null,
|
|
402
425
|
additionalPreparationCommands: [preparation],
|
|
426
|
+
mutationScope: "database",
|
|
427
|
+
riskLevel: "high",
|
|
428
|
+
command: structuredPlaybookCommand(migration.command),
|
|
403
429
|
};
|
|
404
430
|
}
|
|
405
431
|
const REGISTRY = {
|
|
@@ -462,6 +488,43 @@ function persistFailureAttestation(repo, source, extraEvidence = null) {
|
|
|
462
488
|
writeAttestation(repo, attestation);
|
|
463
489
|
return attestation;
|
|
464
490
|
}
|
|
491
|
+
function proposedActionFor(applied) {
|
|
492
|
+
if (applied.fileChanges.length) {
|
|
493
|
+
const patch = applied.patch ?? applied.diff;
|
|
494
|
+
if (!patch)
|
|
495
|
+
throw new Error("repair file changes require an exact patch");
|
|
496
|
+
return buildRepairAction({
|
|
497
|
+
actionType: "patch",
|
|
498
|
+
mutationScope: "repo_only",
|
|
499
|
+
riskLevel: applied.riskLevel,
|
|
500
|
+
patch: {
|
|
501
|
+
format: "unified-diff",
|
|
502
|
+
content: patch,
|
|
503
|
+
files: applied.fileChanges.map(change => normalizedRelative(change.path)),
|
|
504
|
+
},
|
|
505
|
+
explanation: applied.description,
|
|
506
|
+
evidenceRefs: [".bootproof/attestation.json"],
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
if (applied.command) {
|
|
510
|
+
return buildRepairAction({
|
|
511
|
+
actionType: "command",
|
|
512
|
+
mutationScope: applied.mutationScope,
|
|
513
|
+
riskLevel: applied.riskLevel,
|
|
514
|
+
command: applied.command,
|
|
515
|
+
explanation: applied.description,
|
|
516
|
+
evidenceRefs: [".bootproof/attestation.json"],
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
return buildRepairAction({
|
|
520
|
+
actionType: "instruction",
|
|
521
|
+
mutationScope: "none",
|
|
522
|
+
riskLevel: applied.riskLevel,
|
|
523
|
+
instruction: applied.description,
|
|
524
|
+
explanation: applied.description,
|
|
525
|
+
evidenceRefs: [".bootproof/attestation.json"],
|
|
526
|
+
});
|
|
527
|
+
}
|
|
465
528
|
function buildRepairReceipt(repo, before, after, applied, startedAt) {
|
|
466
529
|
if (before.result.booted ||
|
|
467
530
|
before.result.healthVerified ||
|
|
@@ -478,8 +541,29 @@ function buildRepairReceipt(repo, before, after, applied, startedAt) {
|
|
|
478
541
|
const beforeHash = sha256Attestation(before);
|
|
479
542
|
const afterHash = sha256Attestation(after);
|
|
480
543
|
const info = gitInfo(repo);
|
|
544
|
+
const finishedAt = new Date().toISOString();
|
|
545
|
+
const proposedAction = proposedActionFor(applied);
|
|
546
|
+
const base = buildRepairReceiptBase({
|
|
547
|
+
repairId: applied.id,
|
|
548
|
+
createdAt: finishedAt,
|
|
549
|
+
bootproofVersion: TOOL_ID.replace(/^bootproof@/, ""),
|
|
550
|
+
beforeFailureClass: before.result.failureClass,
|
|
551
|
+
beforeEvidenceHash: sha256Text(before.result.failureEvidence ?? ""),
|
|
552
|
+
proposedAction,
|
|
553
|
+
appliedAt: finishedAt,
|
|
554
|
+
applyResult: {
|
|
555
|
+
status: "applied",
|
|
556
|
+
exitCode: 0,
|
|
557
|
+
filesChanged: applied.filesChanged.map(normalizedRelative),
|
|
558
|
+
evidence: null,
|
|
559
|
+
},
|
|
560
|
+
progressed: true,
|
|
561
|
+
verified: true,
|
|
562
|
+
explanation: applied.description,
|
|
563
|
+
redactionsApplied: [],
|
|
564
|
+
});
|
|
481
565
|
const receipt = {
|
|
482
|
-
|
|
566
|
+
...base,
|
|
483
567
|
tool: TOOL_ID,
|
|
484
568
|
repo: { remote: info.remote, commit: info.commit, dirty: info.dirty },
|
|
485
569
|
environment: { os: `${os.platform()} ${os.release()}`, arch: os.arch(), node: process.version },
|
|
@@ -512,12 +596,13 @@ function buildRepairReceipt(repo, before, after, applied, startedAt) {
|
|
|
512
596
|
},
|
|
513
597
|
after: {
|
|
514
598
|
booted: true,
|
|
599
|
+
bootproofOrchestrated: true,
|
|
515
600
|
healthObservation: after.result.healthObservation,
|
|
516
601
|
attestationSha256: afterHash,
|
|
517
602
|
},
|
|
518
603
|
},
|
|
519
604
|
startedAt,
|
|
520
|
-
finishedAt
|
|
605
|
+
finishedAt,
|
|
521
606
|
signer: null,
|
|
522
607
|
signature: null,
|
|
523
608
|
};
|
|
@@ -572,6 +657,605 @@ function signedFailedAttestation(repo, requestedProvider) {
|
|
|
572
657
|
return null;
|
|
573
658
|
}
|
|
574
659
|
}
|
|
660
|
+
export function latestDeterministicRepairCandidate(repoPath, requestedProvider) {
|
|
661
|
+
const repo = path.resolve(repoPath);
|
|
662
|
+
const attestation = signedFailedAttestation(repo, requestedProvider);
|
|
663
|
+
if (!attestation)
|
|
664
|
+
return null;
|
|
665
|
+
const candidate = deterministicRepairCandidateFor(attestation, { repoPath: repo });
|
|
666
|
+
return candidate ? { attestation, candidate } : null;
|
|
667
|
+
}
|
|
668
|
+
export function latestFailedAttestation(repoPath, requestedProvider) {
|
|
669
|
+
return signedFailedAttestation(path.resolve(repoPath), requestedProvider);
|
|
670
|
+
}
|
|
671
|
+
async function runStructuredRepairCommand(command, cwd, timeoutMs = 600_000) {
|
|
672
|
+
return await new Promise(resolve => {
|
|
673
|
+
const child = spawn(command.executable, command.args, {
|
|
674
|
+
cwd,
|
|
675
|
+
shell: false,
|
|
676
|
+
env: buildExecutionEnv(),
|
|
677
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
678
|
+
});
|
|
679
|
+
let output = "";
|
|
680
|
+
let settled = false;
|
|
681
|
+
const finish = (exitCode, extra = "") => {
|
|
682
|
+
if (settled)
|
|
683
|
+
return;
|
|
684
|
+
settled = true;
|
|
685
|
+
clearTimeout(timer);
|
|
686
|
+
const redacted = redactText(`${output}${extra}`.slice(-4000));
|
|
687
|
+
resolve({
|
|
688
|
+
exitCode,
|
|
689
|
+
evidence: redacted.text,
|
|
690
|
+
redactionsApplied: redacted.applied,
|
|
691
|
+
});
|
|
692
|
+
};
|
|
693
|
+
const capture = (chunk) => {
|
|
694
|
+
output = `${output}${String(chunk)}`.slice(-4000);
|
|
695
|
+
};
|
|
696
|
+
child.stdout?.on("data", capture);
|
|
697
|
+
child.stderr?.on("data", capture);
|
|
698
|
+
child.on("close", code => finish(code));
|
|
699
|
+
child.on("error", error => finish(null, `\n${error.message}`));
|
|
700
|
+
const timer = setTimeout(() => {
|
|
701
|
+
child.kill("SIGTERM");
|
|
702
|
+
finish(null, "\nrepair command timed out");
|
|
703
|
+
}, timeoutMs);
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
function buildLifecycleRepairReceipt(input) {
|
|
707
|
+
const after = input.after ?? null;
|
|
708
|
+
const verified = Boolean(after?.result.healthVerified &&
|
|
709
|
+
(after.result.booted || after.verificationMode === "external-health"));
|
|
710
|
+
const progressed = repairProgressed(input.candidate.failureClass, after);
|
|
711
|
+
const info = gitInfo(input.repo);
|
|
712
|
+
const redactedRemote = redactText(info.remote ?? "");
|
|
713
|
+
const redactionsApplied = [...new Set([
|
|
714
|
+
...(input.redactionsApplied ?? []),
|
|
715
|
+
...redactedRemote.applied,
|
|
716
|
+
])];
|
|
717
|
+
const base = buildRepairReceiptBase({
|
|
718
|
+
repairId: input.candidate.id,
|
|
719
|
+
createdAt: input.createdAt,
|
|
720
|
+
bootproofVersion: TOOL_ID.replace(/^bootproof@/, ""),
|
|
721
|
+
beforeFailureClass: input.candidate.failureClass,
|
|
722
|
+
beforeEvidenceHash: sha256Text(input.before.result.failureEvidence ?? ""),
|
|
723
|
+
proposedAction: input.candidate.action,
|
|
724
|
+
...(input.approvedAt ? { approvedAt: input.approvedAt } : {}),
|
|
725
|
+
...(input.appliedAt ? { appliedAt: input.appliedAt } : {}),
|
|
726
|
+
applyResult: input.applyResult,
|
|
727
|
+
...(after?.result.failureClass ? { afterFailureClass: after.result.failureClass } : {}),
|
|
728
|
+
progressed,
|
|
729
|
+
verified,
|
|
730
|
+
explanation: input.explanation,
|
|
731
|
+
redactionsApplied,
|
|
732
|
+
});
|
|
733
|
+
const receipt = {
|
|
734
|
+
...base,
|
|
735
|
+
tool: TOOL_ID,
|
|
736
|
+
repo: {
|
|
737
|
+
remote: info.remote === null ? null : redactedRemote.text,
|
|
738
|
+
commit: info.commit,
|
|
739
|
+
dirty: info.dirty,
|
|
740
|
+
},
|
|
741
|
+
environment: { os: `${os.platform()} ${os.release()}`, arch: os.arch(), node: process.version },
|
|
742
|
+
failure: {
|
|
743
|
+
class: input.candidate.failureClass,
|
|
744
|
+
beforeAttestationSha256: sha256Attestation(input.before),
|
|
745
|
+
},
|
|
746
|
+
startedAt: input.createdAt,
|
|
747
|
+
finishedAt: new Date().toISOString(),
|
|
748
|
+
...(verified && after?.result.healthObservation
|
|
749
|
+
? {
|
|
750
|
+
verification: {
|
|
751
|
+
before: {
|
|
752
|
+
booted: false,
|
|
753
|
+
failureClass: input.candidate.failureClass,
|
|
754
|
+
attestationSha256: sha256Attestation(input.before),
|
|
755
|
+
},
|
|
756
|
+
after: {
|
|
757
|
+
booted: after.result.booted,
|
|
758
|
+
bootproofOrchestrated: after.bootproofOrchestrated,
|
|
759
|
+
healthObservation: after.result.healthObservation,
|
|
760
|
+
attestationSha256: sha256Attestation(after),
|
|
761
|
+
},
|
|
762
|
+
},
|
|
763
|
+
}
|
|
764
|
+
: {}),
|
|
765
|
+
...(input.aiRepair
|
|
766
|
+
? {
|
|
767
|
+
aiEvidence: {
|
|
768
|
+
provider: input.aiRepair.provider,
|
|
769
|
+
model: input.aiRepair.model,
|
|
770
|
+
context: input.aiRepair.context,
|
|
771
|
+
suggestion: input.aiRepair.suggestion,
|
|
772
|
+
},
|
|
773
|
+
}
|
|
774
|
+
: {}),
|
|
775
|
+
signer: null,
|
|
776
|
+
signature: null,
|
|
777
|
+
};
|
|
778
|
+
const signed = signDetached(canonicalReceipt(receipt));
|
|
779
|
+
receipt.signature = signed.signature;
|
|
780
|
+
receipt.signer = { publicKey: signed.publicKeyPem, algorithm: "ed25519" };
|
|
781
|
+
return receipt;
|
|
782
|
+
}
|
|
783
|
+
function writeLifecycleRepairReceipt(repo, receipt) {
|
|
784
|
+
const file = receiptPath(repo);
|
|
785
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
786
|
+
fs.writeFileSync(file, JSON.stringify(receipt, null, 2) + "\n");
|
|
787
|
+
return file;
|
|
788
|
+
}
|
|
789
|
+
async function executeDeterministicRepairCandidate(repo, before, candidate, options) {
|
|
790
|
+
const startedAt = new Date().toISOString();
|
|
791
|
+
cleanPreviousRepairOutputs(repo);
|
|
792
|
+
const result = (receipt, explanation, afterFile = null, patchFile = null) => {
|
|
793
|
+
const receiptFile = writeLifecycleRepairReceipt(repo, receipt);
|
|
794
|
+
return {
|
|
795
|
+
schema: "bootproof/repair-result/v1",
|
|
796
|
+
repaired: receipt.verified,
|
|
797
|
+
failureClass: candidate.failureClass,
|
|
798
|
+
repairId: candidate.id,
|
|
799
|
+
receiptPath: relativeOutput(repo, receiptFile),
|
|
800
|
+
patchPath: relativeOutput(repo, patchFile),
|
|
801
|
+
afterAttestationPath: relativeOutput(repo, afterFile),
|
|
802
|
+
explanation,
|
|
803
|
+
};
|
|
804
|
+
};
|
|
805
|
+
const safetyErrors = [candidate.action, ...(candidate.followUpActions ?? [])]
|
|
806
|
+
.flatMap(action => validateRepairAction(action).errors);
|
|
807
|
+
if (safetyErrors.length) {
|
|
808
|
+
const explanation = `Deterministic repair candidate was blocked by the safety validator: ${[...new Set(safetyErrors)].join("; ")}`;
|
|
809
|
+
return result(buildLifecycleRepairReceipt({
|
|
810
|
+
repo,
|
|
811
|
+
before,
|
|
812
|
+
candidate,
|
|
813
|
+
createdAt: startedAt,
|
|
814
|
+
applyResult: { status: "failed", exitCode: null, filesChanged: [], evidence: explanation },
|
|
815
|
+
explanation,
|
|
816
|
+
}), explanation);
|
|
817
|
+
}
|
|
818
|
+
const provider = options.provider ?? before.plan.provider;
|
|
819
|
+
if (candidate.action.actionType === "instruction") {
|
|
820
|
+
const explanation = `Deterministic instruction recorded: ${candidate.action.instruction}`;
|
|
821
|
+
return result(buildLifecycleRepairReceipt({
|
|
822
|
+
repo,
|
|
823
|
+
before,
|
|
824
|
+
candidate,
|
|
825
|
+
createdAt: startedAt,
|
|
826
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
827
|
+
explanation,
|
|
828
|
+
}), explanation);
|
|
829
|
+
}
|
|
830
|
+
if (candidate.action.actionType === "patch") {
|
|
831
|
+
const patch = candidate.action.patch;
|
|
832
|
+
if (!patch || !candidate.fileChanges?.length) {
|
|
833
|
+
throw new Error("validated patch repair is missing its exact file changes");
|
|
834
|
+
}
|
|
835
|
+
const patchFile = path.join(repo, ".bootproof", `repair-${candidate.id}.patch`);
|
|
836
|
+
fs.mkdirSync(path.dirname(patchFile), { recursive: true });
|
|
837
|
+
fs.writeFileSync(patchFile, patch.content);
|
|
838
|
+
if (provider === "local" && !options.unsafeLocal) {
|
|
839
|
+
const explanation = "The repair patch was not tested because the required BootProof rerun would execute repository code locally; rerun with --unsafe-local after review.";
|
|
840
|
+
return result(buildLifecycleRepairReceipt({
|
|
841
|
+
repo,
|
|
842
|
+
before,
|
|
843
|
+
candidate,
|
|
844
|
+
createdAt: startedAt,
|
|
845
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
846
|
+
explanation,
|
|
847
|
+
}), explanation, null, patchFile);
|
|
848
|
+
}
|
|
849
|
+
if (options.actionApproved !== true) {
|
|
850
|
+
const explanation = "Repair patch was not approved. The working tree was not modified.";
|
|
851
|
+
return result(buildLifecycleRepairReceipt({
|
|
852
|
+
repo,
|
|
853
|
+
before,
|
|
854
|
+
candidate,
|
|
855
|
+
createdAt: startedAt,
|
|
856
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
857
|
+
explanation,
|
|
858
|
+
}), explanation, null, patchFile);
|
|
859
|
+
}
|
|
860
|
+
const approvedAt = new Date().toISOString();
|
|
861
|
+
const { root, sandbox, composeProjectName } = copyToSandbox(repo);
|
|
862
|
+
let patchApplied = false;
|
|
863
|
+
let afterOutcome = null;
|
|
864
|
+
let rerunError = "";
|
|
865
|
+
try {
|
|
866
|
+
for (const change of candidate.fileChanges) {
|
|
867
|
+
const target = path.join(sandbox, normalizedRelative(change.path));
|
|
868
|
+
const current = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : null;
|
|
869
|
+
if (current !== change.before) {
|
|
870
|
+
throw new Error(`repair patch preimage changed for ${change.path}`);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
writeChanges(sandbox, candidate.fileChanges);
|
|
874
|
+
patchApplied = true;
|
|
875
|
+
afterOutcome = await up(sandbox, {
|
|
876
|
+
provider,
|
|
877
|
+
unsafeLocal: options.unsafeLocal,
|
|
878
|
+
dryRun: false,
|
|
879
|
+
timeoutMs: options.timeoutMs,
|
|
880
|
+
install: true,
|
|
881
|
+
port: options.port,
|
|
882
|
+
remoteSource: options.remoteSource,
|
|
883
|
+
environment: { COMPOSE_PROJECT_NAME: composeProjectName },
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
rerunError = error instanceof Error ? error.message : String(error);
|
|
888
|
+
}
|
|
889
|
+
finally {
|
|
890
|
+
await cleanupServices(afterOutcome, buildExecutionEnv({ COMPOSE_PROJECT_NAME: composeProjectName }));
|
|
891
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
892
|
+
}
|
|
893
|
+
const after = afterOutcome?.attestation ?? null;
|
|
894
|
+
let afterFile = null;
|
|
895
|
+
if (after) {
|
|
896
|
+
afterFile = afterAttestationPath(repo);
|
|
897
|
+
fs.mkdirSync(path.dirname(afterFile), { recursive: true });
|
|
898
|
+
fs.writeFileSync(afterFile, JSON.stringify(after, null, 2) + "\n");
|
|
899
|
+
}
|
|
900
|
+
const verified = Boolean(after?.result.booted && after.result.healthVerified);
|
|
901
|
+
const progressed = repairProgressed(candidate.failureClass, after);
|
|
902
|
+
const redactedError = redactText(rerunError);
|
|
903
|
+
const explanation = !patchApplied
|
|
904
|
+
? `Repair patch could not be applied in the sandbox: ${redactedError.text || "unknown failure"}.`
|
|
905
|
+
: verified
|
|
906
|
+
? `Repair patch was tested in the sandbox and BootProof observed verified health: ${after.result.healthObservation}.`
|
|
907
|
+
: progressed
|
|
908
|
+
? `Repair patch was tested in the sandbox; BootProof progressed from ${candidate.failureClass} to ${after.result.failureClass}.`
|
|
909
|
+
: "Repair patch was tested in the sandbox, but the BootProof rerun did not verify boot or reach a different failure class.";
|
|
910
|
+
return result(buildLifecycleRepairReceipt({
|
|
911
|
+
repo,
|
|
912
|
+
before,
|
|
913
|
+
candidate,
|
|
914
|
+
createdAt: startedAt,
|
|
915
|
+
approvedAt,
|
|
916
|
+
...(patchApplied ? { appliedAt: new Date().toISOString() } : {}),
|
|
917
|
+
applyResult: {
|
|
918
|
+
status: patchApplied ? "applied" : "failed",
|
|
919
|
+
exitCode: patchApplied ? 0 : null,
|
|
920
|
+
filesChanged: patchApplied ? candidate.fileChanges.map(change => change.path) : [],
|
|
921
|
+
evidence: redactedError.text || null,
|
|
922
|
+
},
|
|
923
|
+
after,
|
|
924
|
+
explanation,
|
|
925
|
+
redactionsApplied: redactedError.applied,
|
|
926
|
+
}), explanation, afterFile, patchFile);
|
|
927
|
+
}
|
|
928
|
+
const command = candidate.action.command;
|
|
929
|
+
if (!command)
|
|
930
|
+
throw new Error("validated command repair is missing its exact command");
|
|
931
|
+
if (provider === "local" && !options.unsafeLocal) {
|
|
932
|
+
const explanation = "The repair command was not run because the required BootProof rerun would execute repository code locally; rerun with --unsafe-local after review.";
|
|
933
|
+
return result(buildLifecycleRepairReceipt({
|
|
934
|
+
repo,
|
|
935
|
+
before,
|
|
936
|
+
candidate,
|
|
937
|
+
createdAt: startedAt,
|
|
938
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
939
|
+
explanation,
|
|
940
|
+
}), explanation);
|
|
941
|
+
}
|
|
942
|
+
if (options.actionApproved !== true && options.commandApproved !== true) {
|
|
943
|
+
const explanation = "Repair command was not approved. No command was executed.";
|
|
944
|
+
return result(buildLifecycleRepairReceipt({
|
|
945
|
+
repo,
|
|
946
|
+
before,
|
|
947
|
+
candidate,
|
|
948
|
+
createdAt: startedAt,
|
|
949
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
950
|
+
explanation,
|
|
951
|
+
}), explanation);
|
|
952
|
+
}
|
|
953
|
+
const approvedAt = new Date().toISOString();
|
|
954
|
+
const commandResult = await runStructuredRepairCommand(command, repo);
|
|
955
|
+
const { root, sandbox, composeProjectName } = copyToSandbox(repo);
|
|
956
|
+
let afterOutcome = null;
|
|
957
|
+
let rerunError = "";
|
|
958
|
+
try {
|
|
959
|
+
afterOutcome = await up(sandbox, {
|
|
960
|
+
provider,
|
|
961
|
+
unsafeLocal: options.unsafeLocal,
|
|
962
|
+
dryRun: false,
|
|
963
|
+
timeoutMs: options.timeoutMs,
|
|
964
|
+
install: true,
|
|
965
|
+
port: options.port,
|
|
966
|
+
remoteSource: options.remoteSource,
|
|
967
|
+
environment: { COMPOSE_PROJECT_NAME: composeProjectName },
|
|
968
|
+
});
|
|
969
|
+
}
|
|
970
|
+
catch (error) {
|
|
971
|
+
rerunError = error instanceof Error ? error.message : String(error);
|
|
972
|
+
}
|
|
973
|
+
finally {
|
|
974
|
+
await cleanupServices(afterOutcome, buildExecutionEnv({ COMPOSE_PROJECT_NAME: composeProjectName }));
|
|
975
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
976
|
+
}
|
|
977
|
+
const after = afterOutcome?.attestation ?? null;
|
|
978
|
+
let afterFile = null;
|
|
979
|
+
if (after) {
|
|
980
|
+
afterFile = afterAttestationPath(repo);
|
|
981
|
+
fs.mkdirSync(path.dirname(afterFile), { recursive: true });
|
|
982
|
+
fs.writeFileSync(afterFile, JSON.stringify(after, null, 2) + "\n");
|
|
983
|
+
}
|
|
984
|
+
const commandApplied = commandResult.exitCode === 0;
|
|
985
|
+
const verified = Boolean(after?.result.booted && after.result.healthVerified);
|
|
986
|
+
const progressed = repairProgressed(candidate.failureClass, after);
|
|
987
|
+
const redactedEvidence = redactText([commandResult.evidence, rerunError].filter(Boolean).join("\n"));
|
|
988
|
+
const explanation = verified
|
|
989
|
+
? commandApplied
|
|
990
|
+
? `Repair command applied and BootProof observed verified health: ${after.result.healthObservation}.`
|
|
991
|
+
: `Repair command failed with exit ${commandResult.exitCode ?? "unknown"}, but the BootProof rerun observed verified health: ${after.result.healthObservation}.`
|
|
992
|
+
: progressed
|
|
993
|
+
? commandApplied
|
|
994
|
+
? `Repair command applied; BootProof progressed from ${candidate.failureClass} to ${after.result.failureClass}.`
|
|
995
|
+
: `Repair command failed with exit ${commandResult.exitCode ?? "unknown"}, but BootProof progressed from ${candidate.failureClass} to ${after.result.failureClass}.`
|
|
996
|
+
: commandApplied
|
|
997
|
+
? "Repair command completed, but the BootProof rerun did not verify boot or reach a different failure class."
|
|
998
|
+
: `Repair command failed with exit ${commandResult.exitCode ?? "unknown"}; the BootProof rerun did not verify boot or reach a different failure class.`;
|
|
999
|
+
const receipt = buildLifecycleRepairReceipt({
|
|
1000
|
+
repo,
|
|
1001
|
+
before,
|
|
1002
|
+
candidate,
|
|
1003
|
+
createdAt: startedAt,
|
|
1004
|
+
approvedAt,
|
|
1005
|
+
...(commandApplied ? { appliedAt: new Date().toISOString() } : {}),
|
|
1006
|
+
applyResult: {
|
|
1007
|
+
status: commandApplied ? "applied" : "failed",
|
|
1008
|
+
exitCode: commandResult.exitCode,
|
|
1009
|
+
filesChanged: [],
|
|
1010
|
+
evidence: redactedEvidence.text || null,
|
|
1011
|
+
},
|
|
1012
|
+
after,
|
|
1013
|
+
explanation,
|
|
1014
|
+
redactionsApplied: [...new Set([
|
|
1015
|
+
...commandResult.redactionsApplied,
|
|
1016
|
+
...redactedEvidence.applied,
|
|
1017
|
+
])],
|
|
1018
|
+
});
|
|
1019
|
+
return result(receipt, explanation, afterFile);
|
|
1020
|
+
}
|
|
1021
|
+
function verifiedAfterRepair(attestation) {
|
|
1022
|
+
return Boolean(attestation?.result.healthVerified &&
|
|
1023
|
+
(attestation.result.booted || attestation.verificationMode === "external-health"));
|
|
1024
|
+
}
|
|
1025
|
+
function writeRepairAfterAttestation(repo, attestation) {
|
|
1026
|
+
if (!attestation)
|
|
1027
|
+
return null;
|
|
1028
|
+
const file = afterAttestationPath(repo);
|
|
1029
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1030
|
+
fs.writeFileSync(file, JSON.stringify(attestation, null, 2) + "\n");
|
|
1031
|
+
return file;
|
|
1032
|
+
}
|
|
1033
|
+
async function rerunAfterAiRepair(sandbox, before, provider, options, composeProjectName) {
|
|
1034
|
+
try {
|
|
1035
|
+
if (before.verificationMode === "external-health" && before.externalHealthUrl) {
|
|
1036
|
+
const after = await buildExternalHealthAttestation(sandbox, before.externalHealthUrl, options.timeoutMs);
|
|
1037
|
+
return { after, outcome: null, error: "" };
|
|
1038
|
+
}
|
|
1039
|
+
const outcome = await up(sandbox, {
|
|
1040
|
+
provider,
|
|
1041
|
+
unsafeLocal: options.unsafeLocal,
|
|
1042
|
+
dryRun: false,
|
|
1043
|
+
timeoutMs: options.timeoutMs,
|
|
1044
|
+
install: true,
|
|
1045
|
+
port: options.port,
|
|
1046
|
+
remoteSource: options.remoteSource,
|
|
1047
|
+
environment: { COMPOSE_PROJECT_NAME: composeProjectName },
|
|
1048
|
+
});
|
|
1049
|
+
return { after: outcome.attestation, outcome, error: "" };
|
|
1050
|
+
}
|
|
1051
|
+
catch (error) {
|
|
1052
|
+
return {
|
|
1053
|
+
after: null,
|
|
1054
|
+
outcome: null,
|
|
1055
|
+
error: error instanceof Error ? error.message : String(error),
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
export async function executeAiSuggestedRepair(repoPath, before, action, options) {
|
|
1060
|
+
const repo = path.resolve(repoPath);
|
|
1061
|
+
const startedAt = new Date().toISOString();
|
|
1062
|
+
const failureClass = before.result.failureClass;
|
|
1063
|
+
if (!failureClass || before.result.healthVerified || before.result.booted || !verifySignature(before)) {
|
|
1064
|
+
throw new Error("AI repair requires a signature-valid classified failed attestation.");
|
|
1065
|
+
}
|
|
1066
|
+
if (action.source !== "ai_suggested" || action.deterministic) {
|
|
1067
|
+
throw new Error("AI repair execution requires an ai_suggested action.");
|
|
1068
|
+
}
|
|
1069
|
+
const validation = validateRepairAction(action);
|
|
1070
|
+
if (!validation.valid) {
|
|
1071
|
+
throw new Error(`AI suggestion was blocked by BootProof safety policy: ${validation.errors.join("; ")}`);
|
|
1072
|
+
}
|
|
1073
|
+
cleanPreviousRepairOutputs(repo);
|
|
1074
|
+
const candidate = {
|
|
1075
|
+
id: `ai-suggested-${failureClass}-${sha256Text(JSON.stringify(action)).slice(0, 12)}`,
|
|
1076
|
+
failureClass,
|
|
1077
|
+
action,
|
|
1078
|
+
};
|
|
1079
|
+
const result = (receipt, explanation, afterFile = null, patchFile = null) => {
|
|
1080
|
+
const receiptFile = writeLifecycleRepairReceipt(repo, receipt);
|
|
1081
|
+
return {
|
|
1082
|
+
schema: "bootproof/repair-result/v1",
|
|
1083
|
+
repaired: receipt.verified,
|
|
1084
|
+
failureClass,
|
|
1085
|
+
repairId: candidate.id,
|
|
1086
|
+
receiptPath: relativeOutput(repo, receiptFile),
|
|
1087
|
+
patchPath: relativeOutput(repo, patchFile),
|
|
1088
|
+
afterAttestationPath: relativeOutput(repo, afterFile),
|
|
1089
|
+
explanation,
|
|
1090
|
+
};
|
|
1091
|
+
};
|
|
1092
|
+
const receiptFor = (input) => buildLifecycleRepairReceipt({
|
|
1093
|
+
repo,
|
|
1094
|
+
before,
|
|
1095
|
+
candidate,
|
|
1096
|
+
createdAt: startedAt,
|
|
1097
|
+
aiRepair: options.aiRepair ?? null,
|
|
1098
|
+
...input,
|
|
1099
|
+
});
|
|
1100
|
+
const provider = options.provider ?? before.plan.provider;
|
|
1101
|
+
const localRerunRequiresAcknowledgement = before.verificationMode !== "external-health" &&
|
|
1102
|
+
provider === "local" &&
|
|
1103
|
+
!options.unsafeLocal;
|
|
1104
|
+
if (action.actionType === "instruction") {
|
|
1105
|
+
const approved = options.actionApproved === true;
|
|
1106
|
+
const explanation = approved
|
|
1107
|
+
? `AI-suggested instruction was approved for manual use but was not executed: ${action.instruction}`
|
|
1108
|
+
: "AI-suggested instruction was not approved. No command or patch was executed.";
|
|
1109
|
+
return result(receiptFor({
|
|
1110
|
+
...(approved ? { approvedAt: new Date().toISOString() } : {}),
|
|
1111
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
1112
|
+
explanation,
|
|
1113
|
+
redactionsApplied: [],
|
|
1114
|
+
}), explanation);
|
|
1115
|
+
}
|
|
1116
|
+
if (action.actionType === "patch") {
|
|
1117
|
+
const patch = action.patch;
|
|
1118
|
+
if (!patch)
|
|
1119
|
+
throw new Error("validated AI patch suggestion is missing its exact patch");
|
|
1120
|
+
const patchFile = path.join(repo, ".bootproof", `repair-${candidate.id}.patch`);
|
|
1121
|
+
if (localRerunRequiresAcknowledgement) {
|
|
1122
|
+
const explanation = "The AI-suggested patch was not tested because the required BootProof rerun would execute repository code locally; rerun with --unsafe-local after review.";
|
|
1123
|
+
return result(receiptFor({
|
|
1124
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
1125
|
+
explanation,
|
|
1126
|
+
redactionsApplied: [],
|
|
1127
|
+
}), explanation);
|
|
1128
|
+
}
|
|
1129
|
+
if (options.actionApproved !== true) {
|
|
1130
|
+
const explanation = "AI-suggested patch was not approved. The working tree was not modified.";
|
|
1131
|
+
return result(receiptFor({
|
|
1132
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
1133
|
+
explanation,
|
|
1134
|
+
redactionsApplied: [],
|
|
1135
|
+
}), explanation);
|
|
1136
|
+
}
|
|
1137
|
+
const approvedAt = new Date().toISOString();
|
|
1138
|
+
fs.mkdirSync(path.dirname(patchFile), { recursive: true });
|
|
1139
|
+
fs.writeFileSync(patchFile, patch.content);
|
|
1140
|
+
const { root, sandbox, composeProjectName } = copyToSandbox(repo);
|
|
1141
|
+
const sandboxPatch = path.join(root, "ai-suggested.patch");
|
|
1142
|
+
fs.writeFileSync(sandboxPatch, patch.content);
|
|
1143
|
+
let patchResult = {
|
|
1144
|
+
exitCode: null,
|
|
1145
|
+
evidence: "",
|
|
1146
|
+
redactionsApplied: [],
|
|
1147
|
+
};
|
|
1148
|
+
let after = null;
|
|
1149
|
+
let outcome = null;
|
|
1150
|
+
let rerunError = "";
|
|
1151
|
+
try {
|
|
1152
|
+
for (const file of patch.files)
|
|
1153
|
+
assertRepairTargetPath(sandbox, file);
|
|
1154
|
+
const check = await runStructuredRepairCommand(createRepairCommand("git", ["apply", "--check", sandboxPatch]), sandbox);
|
|
1155
|
+
patchResult = check.exitCode === 0
|
|
1156
|
+
? await runStructuredRepairCommand(createRepairCommand("git", ["apply", sandboxPatch]), sandbox)
|
|
1157
|
+
: check;
|
|
1158
|
+
if (patchResult.exitCode === 0) {
|
|
1159
|
+
const rerun = await rerunAfterAiRepair(sandbox, before, provider, options, composeProjectName);
|
|
1160
|
+
after = rerun.after;
|
|
1161
|
+
outcome = rerun.outcome;
|
|
1162
|
+
rerunError = rerun.error;
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
catch (error) {
|
|
1166
|
+
rerunError = error instanceof Error ? error.message : String(error);
|
|
1167
|
+
}
|
|
1168
|
+
finally {
|
|
1169
|
+
await cleanupServices(outcome, buildExecutionEnv({ COMPOSE_PROJECT_NAME: composeProjectName }));
|
|
1170
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1171
|
+
}
|
|
1172
|
+
const afterFile = writeRepairAfterAttestation(repo, after);
|
|
1173
|
+
const patchApplied = patchResult.exitCode === 0;
|
|
1174
|
+
const progressed = repairProgressed(failureClass, after);
|
|
1175
|
+
const redacted = redactText([patchResult.evidence, rerunError].filter(Boolean).join("\n"));
|
|
1176
|
+
const explanation = verifiedAfterRepair(after)
|
|
1177
|
+
? `AI-suggested patch was tested in a sandbox and BootProof independently verified health: ${after.result.healthObservation}.`
|
|
1178
|
+
: progressed
|
|
1179
|
+
? `AI-suggested patch was tested in a sandbox; BootProof progressed from ${failureClass} to ${after.result.failureClass}.`
|
|
1180
|
+
: patchApplied
|
|
1181
|
+
? "AI-suggested patch was tested in a sandbox, but BootProof did not verify boot or reach a different failure class."
|
|
1182
|
+
: `AI-suggested patch was blocked or failed in the sandbox: ${redacted.text || "git apply failed"}.`;
|
|
1183
|
+
return result(receiptFor({
|
|
1184
|
+
approvedAt,
|
|
1185
|
+
...(patchApplied ? { appliedAt: new Date().toISOString() } : {}),
|
|
1186
|
+
applyResult: {
|
|
1187
|
+
status: patchApplied ? "applied" : "failed",
|
|
1188
|
+
exitCode: patchResult.exitCode,
|
|
1189
|
+
filesChanged: patchApplied ? patch.files : [],
|
|
1190
|
+
evidence: redacted.text || null,
|
|
1191
|
+
},
|
|
1192
|
+
after,
|
|
1193
|
+
explanation,
|
|
1194
|
+
redactionsApplied: [...new Set([...patchResult.redactionsApplied, ...redacted.applied])],
|
|
1195
|
+
}), explanation, afterFile, patchFile);
|
|
1196
|
+
}
|
|
1197
|
+
const command = action.command;
|
|
1198
|
+
if (!command)
|
|
1199
|
+
throw new Error("validated AI command suggestion is missing its exact command");
|
|
1200
|
+
if (localRerunRequiresAcknowledgement) {
|
|
1201
|
+
const explanation = "The AI-suggested command was not run because the required BootProof rerun would execute repository code locally; rerun with --unsafe-local after review.";
|
|
1202
|
+
return result(receiptFor({
|
|
1203
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
1204
|
+
explanation,
|
|
1205
|
+
redactionsApplied: [],
|
|
1206
|
+
}), explanation);
|
|
1207
|
+
}
|
|
1208
|
+
if (options.actionApproved !== true) {
|
|
1209
|
+
const explanation = "AI-suggested command was not approved. No command was executed.";
|
|
1210
|
+
return result(receiptFor({
|
|
1211
|
+
applyResult: { status: "not_applied", exitCode: null, filesChanged: [], evidence: null },
|
|
1212
|
+
explanation,
|
|
1213
|
+
redactionsApplied: [],
|
|
1214
|
+
}), explanation);
|
|
1215
|
+
}
|
|
1216
|
+
const approvedAt = new Date().toISOString();
|
|
1217
|
+
const commandResult = await runStructuredRepairCommand(command, repo);
|
|
1218
|
+
const { root, sandbox, composeProjectName } = copyToSandbox(repo);
|
|
1219
|
+
let after = null;
|
|
1220
|
+
let outcome = null;
|
|
1221
|
+
let rerunError = "";
|
|
1222
|
+
try {
|
|
1223
|
+
const rerun = await rerunAfterAiRepair(sandbox, before, provider, options, composeProjectName);
|
|
1224
|
+
after = rerun.after;
|
|
1225
|
+
outcome = rerun.outcome;
|
|
1226
|
+
rerunError = rerun.error;
|
|
1227
|
+
}
|
|
1228
|
+
finally {
|
|
1229
|
+
await cleanupServices(outcome, buildExecutionEnv({ COMPOSE_PROJECT_NAME: composeProjectName }));
|
|
1230
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
1231
|
+
}
|
|
1232
|
+
const afterFile = writeRepairAfterAttestation(repo, after);
|
|
1233
|
+
const commandApplied = commandResult.exitCode === 0;
|
|
1234
|
+
const progressed = repairProgressed(failureClass, after);
|
|
1235
|
+
const redacted = redactText([commandResult.evidence, rerunError].filter(Boolean).join("\n"));
|
|
1236
|
+
const explanation = verifiedAfterRepair(after)
|
|
1237
|
+
? commandApplied
|
|
1238
|
+
? `AI-suggested command completed and BootProof independently verified health: ${after.result.healthObservation}.`
|
|
1239
|
+
: `AI-suggested command failed with exit ${commandResult.exitCode ?? "unknown"}, but BootProof independently verified health: ${after.result.healthObservation}.`
|
|
1240
|
+
: progressed
|
|
1241
|
+
? `AI-suggested command ${commandApplied ? "completed" : "failed"}; BootProof progressed from ${failureClass} to ${after.result.failureClass}.`
|
|
1242
|
+
: commandApplied
|
|
1243
|
+
? "AI-suggested command completed, but BootProof did not verify boot or reach a different failure class."
|
|
1244
|
+
: `AI-suggested command failed with exit ${commandResult.exitCode ?? "unknown"}; BootProof did not verify boot or reach a different failure class.`;
|
|
1245
|
+
return result(receiptFor({
|
|
1246
|
+
approvedAt,
|
|
1247
|
+
...(commandApplied ? { appliedAt: new Date().toISOString() } : {}),
|
|
1248
|
+
applyResult: {
|
|
1249
|
+
status: commandApplied ? "applied" : "failed",
|
|
1250
|
+
exitCode: commandResult.exitCode,
|
|
1251
|
+
filesChanged: [],
|
|
1252
|
+
evidence: redacted.text || null,
|
|
1253
|
+
},
|
|
1254
|
+
after,
|
|
1255
|
+
explanation,
|
|
1256
|
+
redactionsApplied: [...new Set([...commandResult.redactionsApplied, ...redacted.applied])],
|
|
1257
|
+
}), explanation, afterFile);
|
|
1258
|
+
}
|
|
575
1259
|
async function cleanupServices(outcome, env) {
|
|
576
1260
|
if (!outcome)
|
|
577
1261
|
return;
|
|
@@ -609,6 +1293,12 @@ export function applyVerifiedRepair(repoPath, receiptFile = path.join(repoPath,
|
|
|
609
1293
|
if (receipt.schema !== "bootproof/repair-receipt/v1" || !verifyRepairReceipt(receipt)) {
|
|
610
1294
|
return fail("repair receipt signature is invalid; no files were written");
|
|
611
1295
|
}
|
|
1296
|
+
if (!receipt.verified) {
|
|
1297
|
+
return fail("repair receipt is not verified; no repository files were written");
|
|
1298
|
+
}
|
|
1299
|
+
if (!receipt.repair) {
|
|
1300
|
+
return fail("repair receipt contains no verified repository file changes to apply");
|
|
1301
|
+
}
|
|
612
1302
|
const changes = receipt.repair.fileChanges;
|
|
613
1303
|
if (!Array.isArray(changes) || changes.length === 0) {
|
|
614
1304
|
return fail("verified repair has no repository file changes to apply");
|
|
@@ -708,6 +1398,25 @@ export async function repairRepo(repoPath, options) {
|
|
|
708
1398
|
const existingBefore = signedFailedAttestation(repo, options.provider);
|
|
709
1399
|
const freshBefore = freshFailedAttestation(repo, options.provider);
|
|
710
1400
|
const provider = options.provider ?? existingBefore?.plan.provider ?? "docker";
|
|
1401
|
+
if (existingBefore) {
|
|
1402
|
+
const candidate = deterministicRepairCandidateFor(existingBefore, { repoPath: repo });
|
|
1403
|
+
if (candidate) {
|
|
1404
|
+
return await executeDeterministicRepairCandidate(repo, existingBefore, candidate, options);
|
|
1405
|
+
}
|
|
1406
|
+
if (!(REGISTRY[existingBefore.result.failureClass] ?? []).length) {
|
|
1407
|
+
const failureClass = existingBefore.result.failureClass;
|
|
1408
|
+
return {
|
|
1409
|
+
schema: "bootproof/repair-result/v1",
|
|
1410
|
+
repaired: false,
|
|
1411
|
+
failureClass,
|
|
1412
|
+
repairId: null,
|
|
1413
|
+
receiptPath: null,
|
|
1414
|
+
patchPath: null,
|
|
1415
|
+
afterAttestationPath: null,
|
|
1416
|
+
explanation: `No verified deterministic remediation is known for ${failureClass} yet.`,
|
|
1417
|
+
};
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
711
1420
|
if (provider === "local" && !options.unsafeLocal) {
|
|
712
1421
|
return {
|
|
713
1422
|
schema: "bootproof/repair-result/v1",
|
|
@@ -773,7 +1482,7 @@ export async function repairRepo(repoPath, options) {
|
|
|
773
1482
|
receiptPath: null,
|
|
774
1483
|
patchPath: null,
|
|
775
1484
|
afterAttestationPath: null,
|
|
776
|
-
explanation: `
|
|
1485
|
+
explanation: `No verified deterministic remediation is known for ${failureClass} yet.`,
|
|
777
1486
|
};
|
|
778
1487
|
}
|
|
779
1488
|
let attemptedId = null;
|
|
@@ -851,7 +1560,7 @@ export async function repairRepo(repoPath, options) {
|
|
|
851
1560
|
};
|
|
852
1561
|
}
|
|
853
1562
|
finally {
|
|
854
|
-
await cleanupServices(lastOutcome,
|
|
1563
|
+
await cleanupServices(lastOutcome, buildExecutionEnv({ COMPOSE_PROJECT_NAME: composeProjectName }));
|
|
855
1564
|
fs.rmSync(root, { recursive: true, force: true });
|
|
856
1565
|
}
|
|
857
1566
|
}
|