@openthink/stamp 2.0.0 → 2.0.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/dist/index.js CHANGED
@@ -1567,11 +1567,31 @@ function parseResponseJson(raw) {
1567
1567
  `review_server response: top-level verdict (${verdict}) and approval.verdict (${a.verdict}) disagree`
1568
1568
  );
1569
1569
  }
1570
+ let prAttestationV3 = null;
1571
+ const payloadB64 = obj["pr_attestation_v3_payload_b64"];
1572
+ const sigB64 = obj["pr_attestation_v3_signature_b64"];
1573
+ if (payloadB64 !== void 0 || sigB64 !== void 0) {
1574
+ if (typeof payloadB64 !== "string" || !payloadB64) {
1575
+ throw new Error(
1576
+ `review_server response.pr_attestation_v3_payload_b64 must be a non-empty string when present`
1577
+ );
1578
+ }
1579
+ if (typeof sigB64 !== "string" || !sigB64) {
1580
+ throw new Error(
1581
+ `review_server response.pr_attestation_v3_signature_b64 must be a non-empty string when present`
1582
+ );
1583
+ }
1584
+ prAttestationV3 = {
1585
+ payloadBytes: Buffer.from(payloadB64, "base64"),
1586
+ signatureB64: sigB64
1587
+ };
1588
+ }
1570
1589
  return {
1571
1590
  verdict,
1572
1591
  prose: obj.prose,
1573
1592
  approval,
1574
- signature: obj.signature
1593
+ signature: obj.signature,
1594
+ prAttestationV3
1575
1595
  };
1576
1596
  }
1577
1597
  function verifyServerSignature(opts) {
@@ -1709,12 +1729,26 @@ async function requestServerReview(input) {
1709
1729
  `review_server returned a signed approval for diff_sha256 ${parsed.approval.diff_sha256} but we sent diff_sha256 ${diffSha256} \u2014 refusing.`
1710
1730
  );
1711
1731
  }
1732
+ if (parsed.prAttestationV3) {
1733
+ const localCanonical = canonicalSerializeApproval(parsed.approval);
1734
+ if (!parsed.prAttestationV3.payloadBytes.equals(localCanonical)) {
1735
+ throw new Error(
1736
+ `review_server returned pr_attestation_v3_payload_b64 bytes that do not match canonicalSerializeApproval(approval) recomputed locally \u2014 refusing. This indicates a server/client canonicalizer drift (server stamp version mismatch?). Server bytes (first 80): ${JSON.stringify(parsed.prAttestationV3.payloadBytes.toString("utf8").slice(0, 80))}; client bytes (first 80): ${JSON.stringify(localCanonical.toString("utf8").slice(0, 80))}.`
1737
+ );
1738
+ }
1739
+ if (parsed.prAttestationV3.signatureB64 !== parsed.signature) {
1740
+ throw new Error(
1741
+ `review_server returned pr_attestation_v3_signature_b64 that disagrees with the top-level signature \u2014 refusing.`
1742
+ );
1743
+ }
1744
+ }
1712
1745
  return {
1713
1746
  verdict: parsed.verdict,
1714
1747
  prose: parsed.prose,
1715
1748
  approval: parsed.approval,
1716
1749
  signature: parsed.signature,
1717
- approvalJson: JSON.stringify(parsed.approval)
1750
+ approvalJson: JSON.stringify(parsed.approval),
1751
+ prAttestationV3: parsed.prAttestationV3
1718
1752
  };
1719
1753
  }
1720
1754
  function exitCodeHint(exitCode) {
@@ -10123,6 +10157,7 @@ function patchIdForSpan(base_sha, head_sha, repoRoot) {
10123
10157
 
10124
10158
  // src/lib/prAttestation.ts
10125
10159
  import { spawnSync as spawnSync13 } from "child_process";
10160
+ var PR_ATTESTATION_SCHEMA_VERSION = 3;
10126
10161
  var LEGACY_CLIENT_PR_ATTESTATION_SCHEMA_VERSION = 2;
10127
10162
  var MIN_ACCEPTED_PR_ATTESTATION_VERSION = 3;
10128
10163
  var MAX_PR_ATTESTATION_BYTES = 64 * 1024;
@@ -10236,22 +10271,256 @@ function runAttest(opts) {
10236
10271
  const branchRef = opts.branch ?? "HEAD";
10237
10272
  const revspec = `${opts.into}..${branchRef}`;
10238
10273
  const resolved = resolveDiff(revspec, repoRoot);
10239
- const db = openDb(stampStateDbPath(repoRoot));
10274
+ const patch_id = patchIdForSpan(resolved.base_sha, resolved.head_sha, repoRoot);
10275
+ const target_branch_tip_sha = runGit(
10276
+ ["rev-parse", `${opts.into}^{commit}`],
10277
+ repoRoot
10278
+ ).trim();
10279
+ const { keypair } = ensureUserKeypair();
10280
+ const result = rule.review_server ? buildV3Envelope({
10281
+ repoRoot,
10282
+ revspec,
10283
+ baseSha: resolved.base_sha,
10284
+ headSha: resolved.head_sha,
10285
+ diff: resolved.diff,
10286
+ targetBranch: opts.into,
10287
+ targetBranchTipSha: target_branch_tip_sha,
10288
+ patchId: patch_id,
10289
+ requiredReviewers: rule.required,
10290
+ operatorPrivateKeyPem: keypair.privateKeyPem,
10291
+ operatorFingerprint: keypair.fingerprint
10292
+ }) : buildV2Envelope({
10293
+ repoRoot,
10294
+ revspec,
10295
+ baseSha: resolved.base_sha,
10296
+ headSha: resolved.head_sha,
10297
+ targetBranch: opts.into,
10298
+ targetBranchTipSha: target_branch_tip_sha,
10299
+ patchId: patch_id,
10300
+ requiredReviewers: rule.required,
10301
+ operatorPrivateKeyPem: keypair.privateKeyPem,
10302
+ operatorFingerprint: keypair.fingerprint
10303
+ });
10304
+ const { ref, blob_sha } = writeAttestationRef(
10305
+ { payload: result.payload, signature: result.signature },
10306
+ repoRoot
10307
+ );
10308
+ const bar = "\u2500".repeat(72);
10309
+ console.log(bar);
10310
+ console.log(`attested ${branchRef} for merge into '${opts.into}'`);
10311
+ console.log(bar);
10312
+ console.log(` patch-id: ${patch_id}`);
10313
+ console.log(
10314
+ ` base\u2192head: ${resolved.base_sha.slice(0, 8)} \u2192 ${resolved.head_sha.slice(0, 8)}`
10315
+ );
10316
+ console.log(` signed by: ${keypair.fingerprint}`);
10317
+ console.log(` approvals: ${result.reviewerNames.join(", ")}`);
10318
+ console.log(
10319
+ ` schema: v${result.payload.schema_version}${result.payload.schema_version === PR_ATTESTATION_SCHEMA_VERSION ? " (server-attested)" : " (legacy)"}`
10320
+ );
10321
+ console.log(` ref: ${ref}`);
10322
+ console.log(` blob: ${blob_sha.slice(0, 12)}`);
10323
+ console.log(bar);
10324
+ if (opts.pushTo) {
10325
+ pushBranchAndAttestation(opts.pushTo, ref, repoRoot);
10326
+ console.log(
10327
+ `
10328
+ \u2713 pushed branch + attestation ref to ${opts.pushTo}. Open the PR; stamp/verify-attestation@v1 will look up refs/stamp/attestations/<patch-id> from your head SHA's diff against the base.`
10329
+ );
10330
+ } else {
10331
+ console.log(
10332
+ `
10333
+ Next: push the branch + attestation ref to your remote, open a PR, and let stamp/verify-attestation@v1 (the GH Action) confirm it. To do both pushes in one shot:
10334
+
10335
+ git push <remote> HEAD ${ref}
10336
+
10337
+ Or re-run with --push <remote> next time.`
10338
+ );
10339
+ }
10340
+ }
10341
+ function buildV3Envelope(input) {
10342
+ const diffBytes = Buffer.from(input.diff, "utf8");
10343
+ const diffSha256 = createHash12("sha256").update(diffBytes).digest("hex");
10344
+ let manifestYaml;
10345
+ try {
10346
+ manifestYaml = showAtRef(
10347
+ input.baseSha,
10348
+ ".stamp/trusted-keys/manifest.yml",
10349
+ input.repoRoot
10350
+ );
10351
+ } catch (err) {
10352
+ throw new Error(
10353
+ `review_server is configured but .stamp/trusted-keys/manifest.yml is missing at base ${input.baseSha.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}. Server-attested PR mode requires the manifest in the merge-base tree so each approval's server signature can be checked against the keys the repo trusted at attestation time. Commit a manifest with capabilities: [server] entries for the review server before attesting.`
10354
+ );
10355
+ }
10356
+ const manifest = parseManifest(manifestYaml);
10357
+ if (!manifest) {
10358
+ throw new Error(
10359
+ `.stamp/trusted-keys/manifest.yml at base ${input.baseSha.slice(0, 8)} failed to parse as a valid trusted-keys manifest. Fix the YAML (syntax error, duplicate fingerprint, unknown capability, etc.) and re-run \`stamp attest\`.`
10360
+ );
10361
+ }
10362
+ const pubFilenames = listFilesAtRef(
10363
+ input.baseSha,
10364
+ ".stamp/trusted-keys",
10365
+ input.repoRoot
10366
+ );
10367
+ const pubkeyByFingerprint = buildPubkeyMap(
10368
+ pubFilenames,
10369
+ (relPath) => showAtRef(input.baseSha, relPath, input.repoRoot)
10370
+ );
10371
+ const db = openDb(stampStateDbPath(input.repoRoot));
10372
+ let entries;
10373
+ try {
10374
+ const rows = serverApprovalsFor(db, input.baseSha, input.headSha);
10375
+ const byReviewer = new Map(rows.map((r) => [r.reviewer, r]));
10376
+ entries = input.requiredReviewers.map((reviewerName) => {
10377
+ const row = byReviewer.get(reviewerName);
10378
+ if (!row) {
10379
+ throw new Error(
10380
+ `missing server signature for reviewer "${reviewerName}" at base\u2192head ${input.baseSha.slice(0, 8)}\u2192${input.headSha.slice(0, 8)}. Server-attested PR mode requires every required reviewer to have a stamp-server-signed approval in the local DB. Possible causes:
10381
+ \u2022 the stamp-server is older than 2.0.1 and doesn't produce PR-attestation v3 payloads (upgrade the server)
10382
+ \u2022 \`stamp review --diff ${input.revspec}\` hasn't been run against this exact (base, head) pair yet
10383
+ \u2022 the review was run in local-mode (no \`review_server\` configured at review time)
10384
+ Run \`stamp review --diff ${input.revspec}\` to populate the signed row, then re-run \`stamp attest\`.`
10385
+ );
10386
+ }
10387
+ let parsedJson;
10388
+ try {
10389
+ parsedJson = JSON.parse(row.approval_json);
10390
+ } catch (err) {
10391
+ throw new Error(
10392
+ `server approval row for reviewer "${reviewerName}" has malformed JSON in server_approval_json \u2014 DB corruption or a writer-side bug. Re-run \`stamp review --diff ${input.revspec}\` to write a fresh row. (parse error: ${err instanceof Error ? err.message : String(err)})`
10393
+ );
10394
+ }
10395
+ if (!parsedJson || typeof parsedJson !== "object" || Array.isArray(parsedJson)) {
10396
+ throw new Error(
10397
+ `server approval row for reviewer "${reviewerName}" parsed to a non-object value \u2014 DB corruption or a writer-side bug. Re-run \`stamp review --diff ${input.revspec}\` to write a fresh row.`
10398
+ );
10399
+ }
10400
+ const obj = parsedJson;
10401
+ for (const field of [
10402
+ "reviewer",
10403
+ "verdict",
10404
+ "prompt_sha256",
10405
+ "diff_sha256",
10406
+ "base_sha",
10407
+ "head_sha",
10408
+ "trusted_keys_snapshot_sha256",
10409
+ "issued_at",
10410
+ "server_key_id"
10411
+ ]) {
10412
+ if (typeof obj[field] !== "string") {
10413
+ throw new Error(
10414
+ `server approval row for reviewer "${reviewerName}" is missing required field "${field}" (or it isn't a string) \u2014 DB corruption or a writer-side bug. Re-run \`stamp review --diff ${input.revspec}\` to write a fresh row.`
10415
+ );
10416
+ }
10417
+ }
10418
+ const approval = parsedJson;
10419
+ if (approval.reviewer !== reviewerName) {
10420
+ throw new Error(
10421
+ `server approval row for reviewer "${reviewerName}" carries approval.reviewer="${approval.reviewer}" \u2014 DB row drifted. Re-run \`stamp review --diff ${input.revspec}\`.`
10422
+ );
10423
+ }
10424
+ if (approval.base_sha !== input.baseSha) {
10425
+ throw new Error(
10426
+ `server approval for "${reviewerName}" was signed against base_sha ${approval.base_sha.slice(0, 8)} but we're attesting from ${input.baseSha.slice(0, 8)} \u2014 stale signature. Re-run \`stamp review --diff ${input.revspec}\` to refresh.`
10427
+ );
10428
+ }
10429
+ if (approval.head_sha !== input.headSha) {
10430
+ throw new Error(
10431
+ `server approval for "${reviewerName}" was signed against head_sha ${approval.head_sha.slice(0, 8)} but we're attesting head ${input.headSha.slice(0, 8)} \u2014 stale signature. Re-run \`stamp review --diff ${input.revspec}\` to refresh.`
10432
+ );
10433
+ }
10434
+ if (approval.diff_sha256 !== diffSha256) {
10435
+ throw new Error(
10436
+ `server approval for "${reviewerName}" was signed against diff_sha256 ${approval.diff_sha256.slice(0, 12)}\u2026 but the current diff hashes to ${diffSha256.slice(0, 12)}\u2026 \u2014 stale signature. The diff content drifted between review and attest (rebased base, modified head). Re-run \`stamp review --diff ${input.revspec}\`.`
10437
+ );
10438
+ }
10439
+ if (approval.verdict !== "approved") {
10440
+ throw new Error(
10441
+ `server approval for "${reviewerName}" carries verdict "${approval.verdict}", not "approved". Re-run \`stamp review --diff ${input.revspec}\` so the server signs the current approved verdict.`
10442
+ );
10443
+ }
10444
+ const caps = resolveCapability(manifest, approval.server_key_id);
10445
+ if (caps === null) {
10446
+ throw new Error(
10447
+ `server approval for "${reviewerName}" was signed by ${approval.server_key_id}, but that key isn't listed in .stamp/trusted-keys/manifest.yml at base ${input.baseSha.slice(0, 8)}. Either the server's signing key changed (commit the new fingerprint to the manifest with capabilities: [server]) or this row was written by a server the repo no longer trusts. Re-run \`stamp review --diff ${input.revspec}\` after fixing the manifest.`
10448
+ );
10449
+ }
10450
+ if (!caps.includes("server")) {
10451
+ throw new Error(
10452
+ `server approval for "${reviewerName}" was signed by ${approval.server_key_id}, but that key's capabilities in .stamp/trusted-keys/manifest.yml at base ${input.baseSha.slice(0, 8)} are [${caps.join(", ")}] \u2014 missing the required 'server' capability. Update the manifest entry and re-attest.`
10453
+ );
10454
+ }
10455
+ const serverPubPem = pubkeyByFingerprint.get(approval.server_key_id);
10456
+ if (!serverPubPem) {
10457
+ throw new Error(
10458
+ `server approval for "${reviewerName}" was signed by ${approval.server_key_id}, but no .pub file in .stamp/trusted-keys/ at base ${input.baseSha.slice(0, 8)} matches that fingerprint. Commit the server's public key alongside its manifest entry and re-attest.`
10459
+ );
10460
+ }
10461
+ const sigOk = verifyBytes(
10462
+ serverPubPem,
10463
+ canonicalSerializeApproval(approval),
10464
+ row.signature_b64
10465
+ );
10466
+ if (!sigOk) {
10467
+ throw new Error(
10468
+ `server signature for "${reviewerName}" failed Ed25519 verification against key ${approval.server_key_id}. The DB row's signature does not match the canonical bytes of its approval body \u2014 either the row was tampered with or the writer was buggy. Re-run \`stamp review --diff ${input.revspec}\` to refresh the signed row.`
10469
+ );
10470
+ }
10471
+ return {
10472
+ approval,
10473
+ server_attestation: {
10474
+ server_key_id: approval.server_key_id,
10475
+ signature: row.signature_b64
10476
+ }
10477
+ };
10478
+ });
10479
+ } finally {
10480
+ db.close();
10481
+ }
10482
+ const trustAnchorSignatures = [];
10483
+ const payload = {
10484
+ schema_version: PR_ATTESTATION_SCHEMA_VERSION,
10485
+ patch_id: input.patchId,
10486
+ base_sha: input.baseSha,
10487
+ head_sha: input.headSha,
10488
+ target_branch: input.targetBranch,
10489
+ target_branch_tip_sha: input.targetBranchTipSha,
10490
+ diff_sha256: diffSha256,
10491
+ approvals: entries,
10492
+ checks: [],
10493
+ // Phase-1 deliberate omission — see file-level comment.
10494
+ trust_anchor_signatures: trustAnchorSignatures,
10495
+ signer_key_id: input.operatorFingerprint
10496
+ };
10497
+ const signature = signBytes(
10498
+ input.operatorPrivateKeyPem,
10499
+ serializePayload2(payload)
10500
+ );
10501
+ return {
10502
+ payload,
10503
+ signature,
10504
+ reviewerNames: input.requiredReviewers
10505
+ };
10506
+ }
10507
+ function buildV2Envelope(input) {
10508
+ const db = openDb(stampStateDbPath(input.repoRoot));
10240
10509
  let approvals;
10241
10510
  try {
10242
- const reviews = latestReviews(db, resolved.base_sha, resolved.head_sha);
10511
+ const reviews = latestReviews(db, input.baseSha, input.headSha);
10243
10512
  const byReviewer = new Map(reviews.map((r) => [r.reviewer, r]));
10244
10513
  const missing = [];
10245
- for (const r of rule.required) {
10514
+ for (const r of input.requiredReviewers) {
10246
10515
  const rev = byReviewer.get(r);
10247
10516
  if (!rev || rev.verdict !== "approved") missing.push(r);
10248
10517
  }
10249
10518
  if (missing.length > 0) {
10250
10519
  throw new Error(
10251
- `gate CLOSED: missing approved verdicts for: ${missing.join(", ")}. Run \`stamp status --diff ${revspec}\` to inspect, then \`stamp review --diff ${revspec}\` to review.`
10520
+ `gate CLOSED: missing approved verdicts for: ${missing.join(", ")}. Run \`stamp status --diff ${input.revspec}\` to inspect, then \`stamp review --diff ${input.revspec}\` to review.`
10252
10521
  );
10253
10522
  }
10254
- approvals = rule.required.map((name) => {
10523
+ approvals = input.requiredReviewers.map((name) => {
10255
10524
  const rev = byReviewer.get(name);
10256
10525
  const toolCalls = redactToolCallsForAttestation(
10257
10526
  parseToolCalls(rev.tool_calls)
@@ -10267,20 +10536,20 @@ function runAttest(opts) {
10267
10536
  db.close();
10268
10537
  }
10269
10538
  const baseConfigYaml = showAtRef(
10270
- resolved.base_sha,
10539
+ input.baseSha,
10271
10540
  ".stamp/config.yml",
10272
- repoRoot
10541
+ input.repoRoot
10273
10542
  );
10274
10543
  const baseReviewers = readReviewersFromYaml(baseConfigYaml);
10275
10544
  approvals = approvals.map((a) => {
10276
10545
  const def = baseReviewers[a.reviewer];
10277
10546
  if (!def) {
10278
10547
  throw new Error(
10279
- `reviewer "${a.reviewer}" approved the diff but is not defined in .stamp/config.yml at base ${resolved.base_sha.slice(0, 8)}. This shouldn't happen \u2014 runReview reads from the same base. File a bug at https://github.com/OpenThinkAi/stamp-cli/issues.`
10548
+ `reviewer "${a.reviewer}" approved the diff but is not defined in .stamp/config.yml at base ${input.baseSha.slice(0, 8)}. This shouldn't happen \u2014 runReview reads from the same base. File a bug at https://github.com/OpenThinkAi/stamp-cli/issues.`
10280
10549
  );
10281
10550
  }
10282
- const promptText = showAtRef(resolved.base_sha, def.prompt, repoRoot);
10283
- const source = readReviewerSource2(a.reviewer, repoRoot);
10551
+ const promptText = showAtRef(input.baseSha, def.prompt, input.repoRoot);
10552
+ const source = readReviewerSource2(a.reviewer, input.repoRoot);
10284
10553
  return {
10285
10554
  ...a,
10286
10555
  prompt_sha256: hashPromptBytes(Buffer.from(promptText, "utf8")),
@@ -10289,58 +10558,27 @@ function runAttest(opts) {
10289
10558
  ...source ? { reviewer_source: source } : {}
10290
10559
  };
10291
10560
  });
10292
- const patch_id = patchIdForSpan(resolved.base_sha, resolved.head_sha, repoRoot);
10293
- const target_branch_tip_sha = runGit(
10294
- ["rev-parse", `${opts.into}^{commit}`],
10295
- repoRoot
10296
- ).trim();
10297
- const { keypair } = ensureUserKeypair();
10298
10561
  const payload = {
10299
10562
  schema_version: LEGACY_CLIENT_PR_ATTESTATION_SCHEMA_VERSION,
10300
- patch_id,
10301
- base_sha: resolved.base_sha,
10302
- head_sha: resolved.head_sha,
10303
- target_branch: opts.into,
10304
- target_branch_tip_sha,
10563
+ patch_id: input.patchId,
10564
+ base_sha: input.baseSha,
10565
+ head_sha: input.headSha,
10566
+ target_branch: input.targetBranch,
10567
+ target_branch_tip_sha: input.targetBranchTipSha,
10305
10568
  approvals,
10306
10569
  checks: [],
10307
10570
  // Phase-1 deliberate omission — see file-level comment.
10308
- signer_key_id: keypair.fingerprint
10571
+ signer_key_id: input.operatorFingerprint
10309
10572
  };
10310
- const signature = signBytes(keypair.privateKeyPem, serializePayload2(payload));
10311
- const { ref, blob_sha } = writeAttestationRef(
10312
- { payload, signature },
10313
- repoRoot
10573
+ const signature = signBytes(
10574
+ input.operatorPrivateKeyPem,
10575
+ serializePayload2(payload)
10314
10576
  );
10315
- const bar = "\u2500".repeat(72);
10316
- console.log(bar);
10317
- console.log(`attested ${branchRef} for merge into '${opts.into}'`);
10318
- console.log(bar);
10319
- console.log(` patch-id: ${patch_id}`);
10320
- console.log(
10321
- ` base\u2192head: ${resolved.base_sha.slice(0, 8)} \u2192 ${resolved.head_sha.slice(0, 8)}`
10322
- );
10323
- console.log(` signed by: ${keypair.fingerprint}`);
10324
- console.log(` approvals: ${approvals.map((a) => a.reviewer).join(", ")}`);
10325
- console.log(` ref: ${ref}`);
10326
- console.log(` blob: ${blob_sha.slice(0, 12)}`);
10327
- console.log(bar);
10328
- if (opts.pushTo) {
10329
- pushBranchAndAttestation(opts.pushTo, ref, repoRoot);
10330
- console.log(
10331
- `
10332
- \u2713 pushed branch + attestation ref to ${opts.pushTo}. Open the PR; stamp/verify-attestation@v1 will look up refs/stamp/attestations/<patch-id> from your head SHA's diff against the base.`
10333
- );
10334
- } else {
10335
- console.log(
10336
- `
10337
- Next: push the branch + attestation ref to your remote, open a PR, and let stamp/verify-attestation@v1 (the GH Action) confirm it. To do both pushes in one shot:
10338
-
10339
- git push <remote> HEAD ${ref}
10340
-
10341
- Or re-run with --push <remote> next time.`
10342
- );
10343
- }
10577
+ return {
10578
+ payload,
10579
+ signature,
10580
+ reviewerNames: approvals.map((a) => a.reviewer)
10581
+ };
10344
10582
  }
10345
10583
  function pushBranchAndAttestation(remote, attestationRef, repoRoot) {
10346
10584
  const result = spawnSync14(