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.
Files changed (71) hide show
  1. package/README.md +844 -152
  2. package/dist/agent-plan.d.ts +44 -0
  3. package/dist/agent-plan.js +826 -0
  4. package/dist/agent-run.d.ts +117 -0
  5. package/dist/agent-run.js +459 -0
  6. package/dist/ai-repair.d.ts +58 -0
  7. package/dist/ai-repair.js +380 -0
  8. package/dist/cli.js +730 -46
  9. package/dist/diagnosis.js +101 -16
  10. package/dist/diff.d.ts +29 -0
  11. package/dist/diff.js +569 -0
  12. package/dist/exec.d.ts +30 -2
  13. package/dist/exec.js +329 -51
  14. package/dist/external-health.d.ts +16 -0
  15. package/dist/external-health.js +214 -0
  16. package/dist/infer.js +238 -39
  17. package/dist/plan.js +2 -0
  18. package/dist/proof.d.ts +78 -2
  19. package/dist/proof.js +265 -12
  20. package/dist/receipt.d.ts +52 -0
  21. package/dist/receipt.js +356 -0
  22. package/dist/redact.d.ts +4 -0
  23. package/dist/redact.js +86 -2
  24. package/dist/registry.d.ts +82 -30
  25. package/dist/registry.js +355 -53
  26. package/dist/remote.js +3 -3
  27. package/dist/repair-playbooks.d.ts +24 -0
  28. package/dist/repair-playbooks.js +593 -0
  29. package/dist/repair-safety.d.ts +130 -0
  30. package/dist/repair-safety.js +766 -0
  31. package/dist/repair.d.ts +43 -11
  32. package/dist/repair.js +716 -7
  33. package/dist/run.d.ts +3 -0
  34. package/dist/run.js +218 -41
  35. package/dist/sbom.d.ts +22 -0
  36. package/dist/sbom.js +99 -0
  37. package/dist/taxonomy.d.ts +8 -3
  38. package/dist/taxonomy.js +404 -8
  39. package/dist/types.d.ts +40 -1
  40. package/docs/AGENT_IN_THE_LOOP.md +171 -0
  41. package/docs/AGENT_RUN_RECEIPTS.md +38 -0
  42. package/docs/CI_ACTION.md +67 -2
  43. package/docs/DETERMINISTIC_REPAIR_SAFETY_MODEL.md +705 -0
  44. package/docs/DISTRIBUTION.md +83 -0
  45. package/docs/FAILURE_TAXONOMY.md +28 -1
  46. package/docs/HONESTY_CONTRACT.md +34 -12
  47. package/docs/LAUNCH_PLAYBOOK.md +232 -0
  48. package/docs/REAL_WORLD_FIXTURES.md +105 -0
  49. package/docs/REGISTRY.md +48 -28
  50. package/docs/REPAIR_RECEIPT.md +54 -8
  51. package/docs/agent-loop-gap-analysis.md +188 -0
  52. package/docs/examples/registry-seeds/advertised-port-mismatch.json +28 -0
  53. package/docs/examples/registry-seeds/airbyte-abctl-external-orchestrator.json +36 -0
  54. package/docs/examples/registry-seeds/go-ollama-service.json +36 -0
  55. package/docs/examples/registry-seeds/laravel-vite-sqlite.json +36 -0
  56. package/docs/examples/registry-seeds/monorepo-ambiguous-health.json +29 -0
  57. package/docs/examples/registry-seeds/php-composer.json +33 -0
  58. package/docs/examples/registry-seeds/rails-bundler.json +32 -0
  59. package/docs/examples/registry-seeds/sentry-devenv-direnv.json +41 -0
  60. package/docs/schemas/action-verdict-v1.schema.json +64 -0
  61. package/docs/schemas/agent-plan-v1.schema.json +148 -0
  62. package/docs/schemas/agent-run-receipts-v1.schema.json +192 -0
  63. package/docs/schemas/ai-repair-suggestion-v1.schema.json +70 -0
  64. package/docs/schemas/ci-context-v1.schema.json +63 -0
  65. package/docs/schemas/diff-result-v1.schema.json +66 -0
  66. package/docs/schemas/federated-receipt-v1.schema.json +51 -0
  67. package/docs/schemas/registry-entry-v1.schema.json +95 -0
  68. package/docs/schemas/registry-seed-example-v1.schema.json +102 -0
  69. package/docs/schemas/repair-action-v1.schema.json +136 -0
  70. package/docs/schemas/repair-receipt-v1.schema.json +221 -0
  71. package/package.json +21 -11
package/dist/proof.js CHANGED
@@ -3,11 +3,13 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { execFileSync } from "node:child_process";
6
- export const TOOL_ID = "bootproof@0.3.0";
6
+ import { buildExecutionEnv } from "./exec.js";
7
+ import { redactJsonValue } from "./redact.js";
8
+ export const TOOL_ID = "bootproof@0.4.1";
7
9
  export function gitInfo(repo) {
8
10
  const git = (...args) => {
9
11
  try {
10
- return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8" }).trim();
12
+ return execFileSync("git", ["-C", repo, ...args], { encoding: "utf8", env: buildExecutionEnv() }).trim();
11
13
  }
12
14
  catch {
13
15
  return null;
@@ -26,6 +28,9 @@ export function gitInfo(repo) {
26
28
  function signerKeyPath() {
27
29
  return path.join(os.homedir(), ".bootproof", "signer.json");
28
30
  }
31
+ export function knownSignersPath() {
32
+ return path.join(os.homedir(), ".bootproof", "known_signers.json");
33
+ }
29
34
  function loadOrCreateSigner() {
30
35
  const p = signerKeyPath();
31
36
  if (fs.existsSync(p)) {
@@ -39,27 +44,204 @@ function loadOrCreateSigner() {
39
44
  fs.writeFileSync(p, JSON.stringify({ privateKeyPem, publicKeyPem }), { mode: 0o600 });
40
45
  return { privateKey: crypto.createPrivateKey(privateKeyPem), publicKeyPem };
41
46
  }
47
+ /**
48
+ * Rotate the local ed25519 signing key. The old key's public key is archived
49
+ * (so existing attestations remain independently verifiable), a new keypair is
50
+ * generated, and the latest attestation in the given repo is optionally
51
+ * re-signed with the new key.
52
+ *
53
+ * Rotation does NOT invalidate existing attestations — they still carry the
54
+ * old public key inline and verify with it. Rotation only affects what key
55
+ * future attestations will be signed with.
56
+ */
57
+ export function rotateSigner(opts = {}) {
58
+ const p = signerKeyPath();
59
+ const oldKey = fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, "utf8")) : null;
60
+ const oldPublicKeyPem = oldKey?.publicKeyPem ?? null;
61
+ // Generate the new keypair.
62
+ const { privateKey, publicKey } = crypto.generateKeyPairSync("ed25519");
63
+ const newPrivateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }).toString();
64
+ const newPublicKeyPem = publicKey.export({ type: "spki", format: "pem" }).toString();
65
+ // Back up the old key before overwriting (unless explicitly skipped).
66
+ let backedUpTo = null;
67
+ if (opts.backup !== false && oldKey) {
68
+ const backupDir = path.join(os.homedir(), ".bootproof", "archived-keys");
69
+ fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
70
+ const backupName = `signer-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
71
+ backedUpTo = path.join(backupDir, backupName);
72
+ fs.writeFileSync(backedUpTo, JSON.stringify(oldKey, null, 2), { mode: 0o600 });
73
+ }
74
+ // Write the new key.
75
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
76
+ fs.writeFileSync(p, JSON.stringify({ privateKeyPem: newPrivateKeyPem, publicKeyPem: newPublicKeyPem }), { mode: 0o600 });
77
+ // Optionally re-sign the latest attestation with the new key.
78
+ let reSigned = false;
79
+ if (opts.resignAttestation && opts.repo) {
80
+ const attPath = attestationPath(opts.repo);
81
+ if (fs.existsSync(attPath)) {
82
+ const att = JSON.parse(fs.readFileSync(attPath, "utf8"));
83
+ // Re-sign: the canonical body excludes signature and signer fields.
84
+ const body = canonicalBody(att);
85
+ const newPrivateKey = crypto.createPrivateKey(newPrivateKeyPem);
86
+ att.signature = crypto.sign(null, body, newPrivateKey).toString("base64");
87
+ att.signer = { publicKey: newPublicKeyPem, algorithm: "ed25519" };
88
+ fs.writeFileSync(attPath, JSON.stringify(att, null, 2) + "\n");
89
+ reSigned = true;
90
+ }
91
+ }
92
+ return {
93
+ schema: "bootproof/key-rotation/v1",
94
+ rotatedAt: new Date().toISOString(),
95
+ oldPublicKey: oldPublicKeyPem ?? "(no prior key existed)",
96
+ newPublicKey: newPublicKeyPem,
97
+ backedUpTo,
98
+ reSignedAttestation: reSigned,
99
+ };
100
+ }
101
+ function localSignerPublicKey() {
102
+ const p = signerKeyPath();
103
+ if (!fs.existsSync(p))
104
+ return null;
105
+ try {
106
+ const saved = JSON.parse(fs.readFileSync(p, "utf8"));
107
+ return typeof saved.publicKeyPem === "string" ? saved.publicKeyPem : null;
108
+ }
109
+ catch {
110
+ return null;
111
+ }
112
+ }
113
+ function emptyKnownSignerStore() {
114
+ return { schema: "bootproof/known-signers/v1", signers: {} };
115
+ }
116
+ function readKnownSignerStore() {
117
+ const p = knownSignersPath();
118
+ if (!fs.existsSync(p))
119
+ return emptyKnownSignerStore();
120
+ try {
121
+ const value = JSON.parse(fs.readFileSync(p, "utf8"));
122
+ if (value.schema !== "bootproof/known-signers/v1" || !value.signers || typeof value.signers !== "object") {
123
+ return emptyKnownSignerStore();
124
+ }
125
+ return { schema: value.schema, signers: value.signers };
126
+ }
127
+ catch {
128
+ return emptyKnownSignerStore();
129
+ }
130
+ }
131
+ export function signerFingerprint(publicKeyPem) {
132
+ const publicKey = crypto.createPublicKey(publicKeyPem);
133
+ const spki = publicKey.export({ type: "spki", format: "der" });
134
+ return `sha256:${crypto.createHash("sha256").update(spki).digest("hex")}`;
135
+ }
136
+ export function trustSigner(publicKeyPem, label) {
137
+ const fingerprint = signerFingerprint(publicKeyPem);
138
+ const store = readKnownSignerStore();
139
+ const existing = store.signers[fingerprint];
140
+ const record = existing ?? {
141
+ firstSeenAt: new Date().toISOString(),
142
+ ...(label ? { label } : {}),
143
+ };
144
+ if (label)
145
+ record.label = label;
146
+ store.signers[fingerprint] = record;
147
+ const p = knownSignersPath();
148
+ fs.mkdirSync(path.dirname(p), { recursive: true, mode: 0o700 });
149
+ fs.writeFileSync(p, JSON.stringify(store, null, 2) + "\n", { mode: 0o600 });
150
+ return {
151
+ fingerprint,
152
+ firstSeenAt: record.firstSeenAt,
153
+ label: record.label ?? null,
154
+ };
155
+ }
156
+ function signerTrust(publicKeyPem) {
157
+ let fingerprint;
158
+ try {
159
+ fingerprint = signerFingerprint(publicKeyPem);
160
+ }
161
+ catch {
162
+ return { tier: "invalid", fingerprint: null, label: null };
163
+ }
164
+ const localPublicKey = localSignerPublicKey();
165
+ if (localPublicKey) {
166
+ try {
167
+ if (signerFingerprint(localPublicKey) === fingerprint) {
168
+ return { tier: "self", fingerprint, label: null };
169
+ }
170
+ }
171
+ catch {
172
+ // A malformed local signer cannot establish trust in a foreign artifact.
173
+ }
174
+ }
175
+ const known = readKnownSignerStore().signers[fingerprint];
176
+ if (known)
177
+ return { tier: "known", fingerprint, label: known.label ?? null };
178
+ return { tier: "unknown-foreign", fingerprint, label: null };
179
+ }
180
+ export function evaluateDetachedSignature(body, signature, publicKeyPem) {
181
+ if (!signature || !publicKeyPem || !verifyDetached(body, signature, publicKeyPem)) {
182
+ return { integrityValid: false, tier: "invalid", fingerprint: null, label: null };
183
+ }
184
+ return { integrityValid: true, ...signerTrust(publicKeyPem) };
185
+ }
42
186
  function canonicalBody(att) {
43
187
  const { signature: _s, signer: _k, ...body } = att;
44
188
  return Buffer.from(JSON.stringify(body));
45
189
  }
46
190
  export function buildAttestation(input) {
191
+ const verificationMode = input.verificationMode ?? "bootproof-orchestrated";
192
+ const bootproofOrchestrated = verificationMode === "external-health"
193
+ ? false
194
+ : input.bootproofOrchestrated ?? true;
195
+ const redactionsApplied = new Set();
196
+ const redact = (value) => {
197
+ const redacted = redactJsonValue(value);
198
+ for (const rule of redacted.applied)
199
+ redactionsApplied.add(rule);
200
+ return redacted.value;
201
+ };
202
+ const repo = gitInfo(input.repo);
203
+ const persistedRepo = {
204
+ ...repo,
205
+ remote: redact(repo.remote),
206
+ };
207
+ const persistedPlan = redact(input.plan);
208
+ const persistedObserved = redact(input.observed);
209
+ const persistedHealthObservation = redact(input.healthObservation);
210
+ const persistedHealthEvidence = redact(input.healthEvidence ?? null);
211
+ const persistedObservedHealthCandidates = redact(input.observedHealthCandidates ?? []);
212
+ const persistedFailureEvidence = redact(input.failureEvidence);
213
+ const persistedExplanation = redact(input.explanation);
214
+ const persistedExternalHealthUrl = redact(input.externalHealthUrl ?? null);
215
+ const persistedObservedFinalUrl = redact(input.observedFinalUrl ?? null);
216
+ const persistedResponseSnippet = redact(input.responseSnippet ?? "");
47
217
  const att = {
48
218
  schema: "bootproof/attestation/v1",
49
219
  tool: TOOL_ID,
50
- repo: gitInfo(input.repo),
220
+ verificationMode,
221
+ bootproofOrchestrated,
222
+ externalHealthUrl: persistedExternalHealthUrl,
223
+ observedStatus: input.observedStatus ?? null,
224
+ observedFinalUrl: persistedObservedFinalUrl,
225
+ observedAt: input.observedAt ?? null,
226
+ responseSnippet: persistedResponseSnippet,
227
+ classification: input.classification ?? null,
228
+ redactionsApplied: [...redactionsApplied].sort(),
229
+ repo: persistedRepo,
51
230
  environment: { os: `${os.platform()} ${os.release()}`, arch: os.arch(), node: process.version },
52
- trust: { level: "local_developer_signed", signer: "local_ed25519", oidc: null },
53
- plan: input.plan,
54
- observed: input.observed,
231
+ trust: input.trust ?? { level: "local_developer_signed", signer: "local_ed25519", oidc: null },
232
+ plan: persistedPlan,
233
+ observed: persistedObserved,
55
234
  result: {
56
235
  booted: input.booted,
57
236
  healthVerified: input.healthVerified,
58
- healthObservation: input.healthObservation,
59
- observedHealthCandidates: input.observedHealthCandidates ?? [],
237
+ healthObservation: persistedHealthObservation,
238
+ healthEvidence: persistedHealthEvidence,
239
+ observedHealthCandidates: persistedObservedHealthCandidates,
240
+ observedPort: persistedPlan.observedPort ?? null,
241
+ healthCandidateSource: persistedPlan.healthCandidateSource ?? "inferred",
60
242
  failureClass: input.failureClass,
61
- failureEvidence: input.failureEvidence,
62
- explanation: input.explanation,
243
+ failureEvidence: persistedFailureEvidence,
244
+ explanation: persistedExplanation,
63
245
  },
64
246
  startedAt: input.startedAt,
65
247
  finishedAt: new Date().toISOString(),
@@ -71,6 +253,67 @@ export function buildAttestation(input) {
71
253
  att.signer = { publicKey: publicKeyPem, algorithm: "ed25519" };
72
254
  return att;
73
255
  }
256
+ /**
257
+ * Detect GitHub Actions OIDC environment. Present only when the workflow has
258
+ * `permissions: id-token: write`. The presence of these env vars IS the consent —
259
+ * the workflow author explicitly granted the OIDC scope.
260
+ */
261
+ export function detectOidcEnv(env = process.env) {
262
+ const url = env.ACTIONS_ID_TOKEN_REQUEST_URL?.trim();
263
+ const token = env.ACTIONS_ID_TOKEN_REQUEST_TOKEN?.trim();
264
+ if (!url || !token)
265
+ return null;
266
+ return { requestUrl: url, requestToken: token };
267
+ }
268
+ /**
269
+ * Fetch the OIDC JWT from GitHub Actions and decode its claims (without verification —
270
+ * verification is the receiver's job, not the signer's). Returns a compact record of
271
+ * claims suitable for embedding in the attestation trust block.
272
+ */
273
+ export async function fetchOidcClaims(requestUrl, requestToken, fetchImpl = fetch) {
274
+ const url = new URL(requestUrl);
275
+ url.searchParams.set("audience", "bootproof.dev");
276
+ const response = await fetchImpl(url.toString(), {
277
+ headers: { authorization: `bearer ${requestToken}` },
278
+ });
279
+ if (!response.ok) {
280
+ throw new Error(`OIDC token request failed with HTTP ${response.status}`);
281
+ }
282
+ const body = await response.json();
283
+ if (!body.value || typeof body.value !== "string") {
284
+ throw new Error("OIDC token response did not contain a 'value' field");
285
+ }
286
+ // Decode the JWT payload (middle segment). No verification — the receiver verifies.
287
+ const parts = body.value.split(".");
288
+ if (parts.length !== 3)
289
+ throw new Error("OIDC token is not a valid JWT");
290
+ const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
291
+ // Extract the claims that matter for provenance. Stringify everything for the schema.
292
+ const claims = {};
293
+ for (const key of ["iss", "sub", "aud", "ref", "repository", "repository_owner", "run_id", "run_attempt", "event_name", "workflow", "job_workflow_ref"]) {
294
+ if (payload[key] !== undefined && payload[key] !== null) {
295
+ claims[key] = String(payload[key]);
296
+ }
297
+ }
298
+ return claims;
299
+ }
300
+ /**
301
+ * Resolve the trust block for an attestation. When `--ci-oidc` is requested and
302
+ * the GitHub Actions OIDC environment is present, fetch the OIDC token and return
303
+ * a ci_oidc_signed trust block. Otherwise return local_developer_signed.
304
+ */
305
+ export async function resolveTrust(opts = {}) {
306
+ if (!opts.ciOidc) {
307
+ return { level: "local_developer_signed", signer: "local_ed25519", oidc: null };
308
+ }
309
+ const oidcEnv = detectOidcEnv(opts.env);
310
+ if (!oidcEnv) {
311
+ throw new Error("--ci-oidc was requested but ACTIONS_ID_TOKEN_REQUEST_URL/ACTIONS_ID_TOKEN_REQUEST_TOKEN are not set. "
312
+ + "Ensure the workflow has `permissions: id-token: write`.");
313
+ }
314
+ const claims = await fetchOidcClaims(oidcEnv.requestUrl, oidcEnv.requestToken, opts.fetchImpl);
315
+ return { level: "ci_oidc_signed", signer: "local_ed25519", oidc: claims };
316
+ }
74
317
  export function signDetached(body) {
75
318
  const { privateKey, publicKeyPem } = loadOrCreateSigner();
76
319
  return { signature: crypto.sign(null, body, privateKey).toString("base64"), publicKeyPem };
@@ -86,11 +329,21 @@ export function verifyDetached(body, signature, publicKeyPem) {
86
329
  export function verifySignature(att) {
87
330
  if (!att.signature || !att.signer)
88
331
  return false;
332
+ return verifyDetached(canonicalBody(att), att.signature, att.signer.publicKey);
333
+ }
334
+ export function evaluateAttestationSignature(att) {
335
+ return evaluateDetachedSignature(canonicalBody(att), att.signature, att.signer?.publicKey);
336
+ }
337
+ export function currentGitHead(repo) {
89
338
  try {
90
- return crypto.verify(null, canonicalBody(att), crypto.createPublicKey(att.signer.publicKey), Buffer.from(att.signature, "base64"));
339
+ return execFileSync("git", ["-C", repo, "rev-parse", "HEAD"], {
340
+ encoding: "utf8",
341
+ env: buildExecutionEnv(),
342
+ stdio: ["ignore", "pipe", "ignore"],
343
+ }).trim();
91
344
  }
92
345
  catch {
93
- return false;
346
+ return null;
94
347
  }
95
348
  }
96
349
  export function attestationPath(repo) {
@@ -0,0 +1,52 @@
1
+ import type { Attestation } from "./types.js";
2
+ interface ReceiptRecord {
3
+ schema: string;
4
+ id: string;
5
+ capturedAt: string;
6
+ capturedBy: string;
7
+ trust: {
8
+ level: string;
9
+ signer: string;
10
+ oidc: string | null;
11
+ upgradePath: string[];
12
+ };
13
+ repo: {
14
+ url: string;
15
+ commit: string;
16
+ branch: string;
17
+ label: string;
18
+ };
19
+ inference: {
20
+ language: string;
21
+ packageManager: string;
22
+ startCommand: string;
23
+ } | null;
24
+ plan: Array<{
25
+ step: string;
26
+ command: string;
27
+ exitCode: number;
28
+ durationMs: number;
29
+ }>;
30
+ log: Array<{
31
+ t: number;
32
+ level: string;
33
+ line: string;
34
+ }>;
35
+ observed: {
36
+ kind: string;
37
+ url?: string;
38
+ status?: number;
39
+ latencyMs?: number;
40
+ body?: string;
41
+ signal?: string | null;
42
+ code?: number;
43
+ windowMs?: number;
44
+ exitCode?: number;
45
+ };
46
+ booted: boolean;
47
+ healthVerified: boolean;
48
+ failureClass: string | null;
49
+ }
50
+ declare function attestationToRecord(att: Attestation): ReceiptRecord;
51
+ export declare function emitLivingReceipt(att: Attestation, outPath: string): string;
52
+ export { attestationToRecord };