bootproof 0.1.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +873 -109
  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 +936 -38
  9. package/dist/diagnosis.js +114 -17
  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 +332 -37
  14. package/dist/external-health.d.ts +16 -0
  15. package/dist/external-health.js +214 -0
  16. package/dist/infer.js +489 -41
  17. package/dist/plan.d.ts +2 -0
  18. package/dist/plan.js +49 -7
  19. package/dist/proof.d.ts +78 -2
  20. package/dist/proof.js +266 -13
  21. package/dist/receipt.d.ts +52 -0
  22. package/dist/receipt.js +356 -0
  23. package/dist/redact.d.ts +4 -0
  24. package/dist/redact.js +86 -2
  25. package/dist/registry.d.ts +82 -30
  26. package/dist/registry.js +355 -53
  27. package/dist/remote.d.ts +12 -1
  28. package/dist/remote.js +62 -18
  29. package/dist/repair-playbooks.d.ts +24 -0
  30. package/dist/repair-playbooks.js +593 -0
  31. package/dist/repair-safety.d.ts +130 -0
  32. package/dist/repair-safety.js +766 -0
  33. package/dist/repair.d.ts +142 -0
  34. package/dist/repair.js +1566 -0
  35. package/dist/run.d.ts +6 -1
  36. package/dist/run.js +385 -46
  37. package/dist/sbom.d.ts +22 -0
  38. package/dist/sbom.js +99 -0
  39. package/dist/taxonomy.d.ts +8 -2
  40. package/dist/taxonomy.js +428 -8
  41. package/dist/types.d.ts +57 -2
  42. package/docs/AGENT_IN_THE_LOOP.md +171 -0
  43. package/docs/AGENT_RUN_RECEIPTS.md +38 -0
  44. package/docs/CI_ACTION.md +71 -5
  45. package/docs/DETERMINISTIC_REPAIR_SAFETY_MODEL.md +705 -0
  46. package/docs/FAILURE_TAXONOMY.md +30 -1
  47. package/docs/HONESTY_CONTRACT.md +55 -4
  48. package/docs/LAUNCH_PLAYBOOK.md +232 -0
  49. package/docs/REAL_REPO_EVIDENCE.md +77 -0
  50. package/docs/REAL_WORLD_FIXTURES.md +105 -0
  51. package/docs/REGISTRY.md +48 -28
  52. package/docs/RELEASE_CHECKLIST.md +9 -1
  53. package/docs/REPAIR_RECEIPT.md +224 -0
  54. package/docs/agent-loop-gap-analysis.md +188 -0
  55. package/docs/examples/registry-seeds/advertised-port-mismatch.json +28 -0
  56. package/docs/examples/registry-seeds/airbyte-abctl-external-orchestrator.json +36 -0
  57. package/docs/examples/registry-seeds/go-ollama-service.json +36 -0
  58. package/docs/examples/registry-seeds/laravel-vite-sqlite.json +36 -0
  59. package/docs/examples/registry-seeds/monorepo-ambiguous-health.json +29 -0
  60. package/docs/examples/registry-seeds/php-composer.json +33 -0
  61. package/docs/examples/registry-seeds/rails-bundler.json +32 -0
  62. package/docs/examples/registry-seeds/sentry-devenv-direnv.json +41 -0
  63. package/docs/schemas/action-verdict-v1.schema.json +64 -0
  64. package/docs/schemas/agent-plan-v1.schema.json +148 -0
  65. package/docs/schemas/agent-run-receipts-v1.schema.json +192 -0
  66. package/docs/schemas/ai-repair-suggestion-v1.schema.json +70 -0
  67. package/docs/schemas/ci-context-v1.schema.json +63 -0
  68. package/docs/schemas/diff-result-v1.schema.json +66 -0
  69. package/docs/schemas/federated-receipt-v1.schema.json +51 -0
  70. package/docs/schemas/registry-entry-v1.schema.json +95 -0
  71. package/docs/schemas/registry-seed-example-v1.schema.json +102 -0
  72. package/docs/schemas/repair-action-v1.schema.json +136 -0
  73. package/docs/schemas/repair-receipt-v1.schema.json +221 -0
  74. package/package.json +13 -6
package/dist/plan.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { Inference, RunPlan } from "./types.js";
2
+ export declare const REPAIRED_GENERATED_COMPOSE_MARKER = "# BootProof verified repair: remap-conflicting-service-port";
3
+ export declare function repoComposeRepairFile(repoComposeFile: string): string;
2
4
  export declare function composeFileFor(inf: Inference): string | null;
3
5
  export declare function envExampleFor(inf: Inference): string | null;
4
6
  export declare function buildPlan(inf: Inference, provider: "docker" | "local"): RunPlan;
package/dist/plan.js CHANGED
@@ -6,9 +6,23 @@ const SERVICE_IMAGES = {
6
6
  redis: { image: "redis:7-alpine", port: 6379, env: {} },
7
7
  mongodb: { image: "mongo:7", port: 27017, env: {} },
8
8
  };
9
+ export const REPAIRED_GENERATED_COMPOSE_MARKER = "# BootProof verified repair: remap-conflicting-service-port";
10
+ export function repoComposeRepairFile(repoComposeFile) {
11
+ const directory = path.posix.dirname(repoComposeFile.replace(/\\/g, "/"));
12
+ const file = "docker-compose.bootproof.override.yml";
13
+ return directory === "." ? file : path.posix.join(directory, file);
14
+ }
9
15
  export function composeFileFor(inf) {
16
+ if (inf.repoComposeFile)
17
+ return null;
10
18
  if (!inf.services.length)
11
19
  return null;
20
+ const existingPath = path.join(inf.repoPath, "docker-compose.bootproof.yml");
21
+ if (fs.existsSync(existingPath)) {
22
+ const existing = fs.readFileSync(existingPath, "utf8");
23
+ if (existing.includes(REPAIRED_GENERATED_COMPOSE_MARKER))
24
+ return existing;
25
+ }
12
26
  const lines = ["# Generated by bootproof — review before use. Standard compose; no bootproof runtime required.", "services:"];
13
27
  for (const s of inf.services) {
14
28
  const spec = SERVICE_IMAGES[s.kind];
@@ -47,16 +61,42 @@ export function envExampleFor(inf) {
47
61
  }
48
62
  export function buildPlan(inf, provider) {
49
63
  const steps = [];
50
- if (inf.services.length && provider === "docker") {
51
- steps.push({ id: "services", kind: "service", command: "docker compose -f docker-compose.bootproof.yml up -d", description: `start ${inf.services.map(s => s.kind).join(", ")} in containers`, required: true });
64
+ const runsSourceComposeApplication = provider === "docker" &&
65
+ Boolean(inf.repoComposeFile) &&
66
+ inf.composeHealthCandidates.length > 0;
67
+ if (provider === "docker" && inf.repoComposeFile) {
68
+ const repairedCompose = repoComposeRepairFile(inf.repoComposeFile);
69
+ const usesRepairedCopy = fs.existsSync(path.join(inf.repoPath, repairedCompose));
70
+ steps.push({
71
+ id: "services",
72
+ kind: "service",
73
+ command: `docker compose -f ${usesRepairedCopy ? repairedCompose : inf.repoComposeFile} up -d`,
74
+ description: usesRepairedCopy
75
+ ? "use the BootProof repaired copy of the repository Compose file"
76
+ : "defer to the repository's own compose file",
77
+ required: true,
78
+ });
52
79
  }
53
- if (inf.installCommand) {
54
- steps.push({ id: "install", kind: "install", command: inf.installCommand, description: "install dependencies", required: inf.dependencyInstallRequired });
80
+ else if (inf.services.length && provider === "docker") {
81
+ steps.push({ id: "services", kind: "service", command: "docker compose -f docker-compose.bootproof.yml up -d", description: `start ${inf.services.map(s => s.kind).join(", ")} in containers`, required: true });
55
82
  }
56
- if (inf.appCommand) {
57
- steps.push({ id: "start-app", kind: "start-app", command: inf.appCommand, description: `start app (${inf.appCommandSource})`, required: true });
83
+ if (!runsSourceComposeApplication) {
84
+ for (const preparation of inf.preparationCommands) {
85
+ steps.push({
86
+ id: preparation.id,
87
+ kind: preparation.kind,
88
+ command: preparation.command,
89
+ description: `${preparation.description} (${preparation.source})`,
90
+ required: true,
91
+ });
92
+ }
93
+ if (inf.appCommand) {
94
+ steps.push({ id: "start-app", kind: "start-app", command: inf.appCommand, description: `start app (${inf.appCommandSource})`, required: true });
95
+ }
58
96
  }
59
- const healthCandidates = [...inf.healthCandidates];
97
+ const healthCandidates = runsSourceComposeApplication
98
+ ? [...inf.composeHealthCandidates]
99
+ : [...inf.healthCandidates];
60
100
  const healthUrl = healthCandidates[0] ?? "";
61
101
  if (inf.isApplication && healthUrl) {
62
102
  steps.push({ id: "health", kind: "health", description: `poll ${healthUrl} for an HTTP response`, required: true });
@@ -66,6 +106,8 @@ export function buildPlan(inf, provider) {
66
106
  steps,
67
107
  healthUrl,
68
108
  healthCandidates,
109
+ observedPort: inf.observedPort,
110
+ healthCandidateSource: inf.healthCandidateSource,
69
111
  generatedFiles: [
70
112
  ...(composeFileFor(inf) ? [{ path: "docker-compose.bootproof.yml", purpose: "service containers" }] : []),
71
113
  ...(envExampleFor(inf) ? [{ path: ".env.bootproof.example", purpose: "suggested local env values (never auto-applied)" }] : []),
package/dist/proof.d.ts CHANGED
@@ -1,6 +1,45 @@
1
- import type { Attestation, ObservedStep, RunPlan, FailureClass } from "./types.js";
2
- export declare const TOOL_ID = "bootproof@0.1.0";
1
+ import type { Attestation, AttestationTrust, ObservedStep, RunPlan, FailureClass, HealthEvidence, VerificationMode, ExternalVerificationClassification } from "./types.js";
2
+ export declare const TOOL_ID = "bootproof@0.4.0";
3
+ export type { AttestationTrust } from "./types.js";
4
+ export type SignerTrustTier = "invalid" | "self" | "known" | "unknown-foreign";
5
+ export interface SignatureTrustResult {
6
+ integrityValid: boolean;
7
+ tier: SignerTrustTier;
8
+ fingerprint: string | null;
9
+ label: string | null;
10
+ }
3
11
  export declare function gitInfo(repo: string): Attestation["repo"];
12
+ export declare function knownSignersPath(): string;
13
+ export interface RotationResult {
14
+ schema: "bootproof/key-rotation/v1";
15
+ rotatedAt: string;
16
+ oldPublicKey: string;
17
+ newPublicKey: string;
18
+ backedUpTo: string | null;
19
+ reSignedAttestation: boolean;
20
+ }
21
+ /**
22
+ * Rotate the local ed25519 signing key. The old key's public key is archived
23
+ * (so existing attestations remain independently verifiable), a new keypair is
24
+ * generated, and the latest attestation in the given repo is optionally
25
+ * re-signed with the new key.
26
+ *
27
+ * Rotation does NOT invalidate existing attestations — they still carry the
28
+ * old public key inline and verify with it. Rotation only affects what key
29
+ * future attestations will be signed with.
30
+ */
31
+ export declare function rotateSigner(opts?: {
32
+ repo?: string;
33
+ resignAttestation?: boolean;
34
+ backup?: boolean;
35
+ }): RotationResult;
36
+ export declare function signerFingerprint(publicKeyPem: string): string;
37
+ export declare function trustSigner(publicKeyPem: string, label?: string): {
38
+ fingerprint: string;
39
+ firstSeenAt: string;
40
+ label: string | null;
41
+ };
42
+ export declare function evaluateDetachedSignature(body: Buffer, signature: string | null | undefined, publicKeyPem: string | null | undefined): SignatureTrustResult;
4
43
  export declare function buildAttestation(input: {
5
44
  repo: string;
6
45
  plan: RunPlan;
@@ -9,16 +48,53 @@ export declare function buildAttestation(input: {
9
48
  booted: boolean;
10
49
  healthVerified: boolean;
11
50
  healthObservation: string | null;
51
+ healthEvidence?: HealthEvidence | null;
12
52
  observedHealthCandidates?: string[];
13
53
  failureClass: FailureClass | null;
14
54
  failureEvidence: string | null;
15
55
  explanation: string;
56
+ verificationMode?: VerificationMode;
57
+ bootproofOrchestrated?: boolean;
58
+ externalHealthUrl?: string | null;
59
+ observedStatus?: number | null;
60
+ observedFinalUrl?: string | null;
61
+ observedAt?: string | null;
62
+ responseSnippet?: string;
63
+ classification?: ExternalVerificationClassification | null;
64
+ trust?: AttestationTrust;
16
65
  }): Attestation;
66
+ /**
67
+ * Detect GitHub Actions OIDC environment. Present only when the workflow has
68
+ * `permissions: id-token: write`. The presence of these env vars IS the consent —
69
+ * the workflow author explicitly granted the OIDC scope.
70
+ */
71
+ export declare function detectOidcEnv(env?: NodeJS.ProcessEnv): {
72
+ requestUrl: string;
73
+ requestToken: string;
74
+ } | null;
75
+ /**
76
+ * Fetch the OIDC JWT from GitHub Actions and decode its claims (without verification —
77
+ * verification is the receiver's job, not the signer's). Returns a compact record of
78
+ * claims suitable for embedding in the attestation trust block.
79
+ */
80
+ export declare function fetchOidcClaims(requestUrl: string, requestToken: string, fetchImpl?: typeof fetch): Promise<Record<string, string>>;
81
+ /**
82
+ * Resolve the trust block for an attestation. When `--ci-oidc` is requested and
83
+ * the GitHub Actions OIDC environment is present, fetch the OIDC token and return
84
+ * a ci_oidc_signed trust block. Otherwise return local_developer_signed.
85
+ */
86
+ export declare function resolveTrust(opts?: {
87
+ ciOidc?: boolean;
88
+ env?: NodeJS.ProcessEnv;
89
+ fetchImpl?: typeof fetch;
90
+ }): Promise<AttestationTrust>;
17
91
  export declare function signDetached(body: Buffer): {
18
92
  signature: string;
19
93
  publicKeyPem: string;
20
94
  };
21
95
  export declare function verifyDetached(body: Buffer, signature: string, publicKeyPem: string): boolean;
22
96
  export declare function verifySignature(att: Attestation): boolean;
97
+ export declare function evaluateAttestationSignature(att: Attestation): SignatureTrustResult;
98
+ export declare function currentGitHead(repo: string): string | null;
23
99
  export declare function attestationPath(repo: string): string;
24
100
  export declare function writeAttestation(repo: string, att: Attestation): string;
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.1.0";
6
+ import { buildExecutionEnv } from "./exec.js";
7
+ import { redactJsonValue } from "./redact.js";
8
+ export const TOOL_ID = "bootproof@0.4.0";
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;
@@ -18,7 +20,7 @@ export function gitInfo(repo) {
18
20
  const status = git("status", "--porcelain");
19
21
  return {
20
22
  path: repo,
21
- remote: git("remote", "get-url", "origin"),
23
+ remote: git("config", "--get", "remote.origin.url"),
22
24
  commit: git("rev-parse", "HEAD"),
23
25
  dirty: status === null ? null : status.length > 0,
24
26
  };
@@ -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 };