@openthink/stamp 1.10.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.
@@ -9,6 +9,10 @@ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
9
  var __commonJS = (cb, mod) => function __require() {
10
10
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
11
11
  };
12
+ var __export = (target, all) => {
13
+ for (var name in all)
14
+ __defProp(target, name, { get: all[name], enumerable: true });
15
+ };
12
16
  var __copyProps = (to, from, except, desc) => {
13
17
  if (from && typeof from === "object" || typeof from === "function") {
14
18
  for (let key of __getOwnPropNames(from))
@@ -25,6 +29,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
25
29
  isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
30
  mod
27
31
  ));
32
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
28
33
 
29
34
  // node_modules/yaml/dist/nodes/identity.js
30
35
  var require_identity = __commonJS({
@@ -7329,8 +7334,24 @@ var require_dist = __commonJS({
7329
7334
  });
7330
7335
 
7331
7336
  // src/hooks/pre-receive.ts
7332
- var import_node_child_process = require("child_process");
7333
- var import_yaml4 = __toESM(require_dist(), 1);
7337
+ var pre_receive_exports = {};
7338
+ __export(pre_receive_exports, {
7339
+ parsePathRules: () => parsePathRules,
7340
+ verifyV4ApprovalSignatures: () => verifyV4ApprovalSignatures,
7341
+ verifyV4Approvals: () => verifyV4Approvals,
7342
+ verifyV4Checks: () => verifyV4Checks,
7343
+ verifyV4DiffHash: () => verifyV4DiffHash,
7344
+ verifyV4MergeStructure: () => verifyV4MergeStructure,
7345
+ verifyV4OuterSignature: () => verifyV4OuterSignature,
7346
+ verifyV4SignerTrust: () => verifyV4SignerTrust,
7347
+ verifyV4StampPathsGuard: () => verifyV4StampPathsGuard,
7348
+ verifyV4TargetBranch: () => verifyV4TargetBranch,
7349
+ verifyV4TrustAnchorSignatures: () => verifyV4TrustAnchorSignatures
7350
+ });
7351
+ module.exports = __toCommonJS(pre_receive_exports);
7352
+ var import_node_child_process3 = require("child_process");
7353
+ var import_node_url = require("url");
7354
+ var import_yaml5 = __toESM(require_dist(), 1);
7334
7355
 
7335
7356
  // src/lib/attestation.ts
7336
7357
  var MIN_ACCEPTED_PAYLOAD_VERSION = 3;
@@ -7358,6 +7379,26 @@ function parseCommitAttestation(commitMessage) {
7358
7379
  return { payload, payloadBytes, signatureBase64: b64Sig };
7359
7380
  }
7360
7381
 
7382
+ // src/lib/attestationV4.ts
7383
+ var MIN_ACCEPTED_V4_SCHEMA_VERSION = 4;
7384
+ var MAX_V4_ENVELOPE_BYTES = 64 * 1024;
7385
+ function sortKeysDeep(value) {
7386
+ if (value === null || typeof value !== "object") return value;
7387
+ if (Array.isArray(value)) return value.map(sortKeysDeep);
7388
+ const obj = value;
7389
+ const out = {};
7390
+ for (const key of Object.keys(obj).sort()) {
7391
+ out[key] = sortKeysDeep(obj[key]);
7392
+ }
7393
+ return out;
7394
+ }
7395
+ function canonicalSerializeApproval(a) {
7396
+ return Buffer.from(JSON.stringify(sortKeysDeep(a)), "utf8");
7397
+ }
7398
+ function canonicalSerializePayload(p) {
7399
+ return Buffer.from(JSON.stringify(sortKeysDeep(p)), "utf8");
7400
+ }
7401
+
7361
7402
  // src/lib/keys.ts
7362
7403
  var import_node_crypto = require("crypto");
7363
7404
  var import_node_fs2 = require("fs");
@@ -7488,7 +7529,622 @@ function verifyBytes(publicKeyPem, data, signatureBase64) {
7488
7529
  return (0, import_node_crypto3.verify)(null, data, key, sig);
7489
7530
  }
7490
7531
 
7532
+ // src/lib/trustedKeysManifest.ts
7533
+ var import_node_crypto4 = require("crypto");
7534
+ var import_yaml4 = __toESM(require_dist(), 1);
7535
+ var KNOWN_CAPABILITIES = ["admin", "operator", "server"];
7536
+ var MAX_MANIFEST_BYTES = 256 * 1024;
7537
+ var MAX_MANIFEST_ENTRIES = 1e4;
7538
+ var FINGERPRINT_PATTERN = /^sha256:[0-9a-f]{64}$/;
7539
+ var NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
7540
+ function parseManifest(yamlText) {
7541
+ if (typeof yamlText !== "string") return null;
7542
+ if (Buffer.byteLength(yamlText, "utf8") > MAX_MANIFEST_BYTES) return null;
7543
+ let parsed;
7544
+ try {
7545
+ parsed = (0, import_yaml4.parse)(yamlText);
7546
+ } catch {
7547
+ return null;
7548
+ }
7549
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
7550
+ return null;
7551
+ }
7552
+ const top = parsed;
7553
+ const rawKeys = top.keys;
7554
+ if (!rawKeys || typeof rawKeys !== "object" || Array.isArray(rawKeys)) {
7555
+ return null;
7556
+ }
7557
+ const names = Object.keys(rawKeys);
7558
+ if (names.length === 0) return null;
7559
+ if (names.length > MAX_MANIFEST_ENTRIES) return null;
7560
+ const fingerprintsSeen = /* @__PURE__ */ new Set();
7561
+ const entries = [];
7562
+ for (const name of names) {
7563
+ if (!NAME_PATTERN.test(name)) return null;
7564
+ const def = rawKeys[name];
7565
+ if (!def || typeof def !== "object" || Array.isArray(def)) return null;
7566
+ const d = def;
7567
+ if (typeof d.fingerprint !== "string") return null;
7568
+ if (!FINGERPRINT_PATTERN.test(d.fingerprint)) return null;
7569
+ if (fingerprintsSeen.has(d.fingerprint)) return null;
7570
+ fingerprintsSeen.add(d.fingerprint);
7571
+ if (!Array.isArray(d.capabilities)) return null;
7572
+ if (d.capabilities.length === 0) return null;
7573
+ const capSet = /* @__PURE__ */ new Set();
7574
+ for (const cap of d.capabilities) {
7575
+ if (typeof cap !== "string") return null;
7576
+ if (!isKnownCapability(cap)) return null;
7577
+ capSet.add(cap);
7578
+ }
7579
+ const capabilities = [...capSet].sort();
7580
+ let role_source;
7581
+ if (d.role_source !== void 0) {
7582
+ if (typeof d.role_source !== "string" || d.role_source.length === 0) {
7583
+ return null;
7584
+ }
7585
+ role_source = d.role_source;
7586
+ }
7587
+ entries.push({
7588
+ name,
7589
+ fingerprint: d.fingerprint,
7590
+ capabilities,
7591
+ ...role_source !== void 0 ? { role_source } : {}
7592
+ });
7593
+ }
7594
+ entries.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
7595
+ return { entries };
7596
+ }
7597
+ function isKnownCapability(s) {
7598
+ return KNOWN_CAPABILITIES.includes(s);
7599
+ }
7600
+ function serializeManifestCanonical(manifest) {
7601
+ const sortedEntries = [...manifest.entries].sort(
7602
+ (a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0
7603
+ );
7604
+ const keys = {};
7605
+ for (const e of sortedEntries) {
7606
+ const entry = {
7607
+ capabilities: [...e.capabilities].sort(),
7608
+ fingerprint: e.fingerprint
7609
+ };
7610
+ if (e.role_source !== void 0) {
7611
+ entry.role_source = e.role_source;
7612
+ }
7613
+ keys[e.name] = entry;
7614
+ }
7615
+ const canonical = { keys };
7616
+ return Buffer.from(JSON.stringify(canonical), "utf8");
7617
+ }
7618
+ function snapshotSha256(manifest) {
7619
+ const bytes = serializeManifestCanonical(manifest);
7620
+ const hex = (0, import_node_crypto4.createHash)("sha256").update(bytes).digest("hex");
7621
+ return `sha256:${hex}`;
7622
+ }
7623
+ function resolveCapability(manifest, fingerprint) {
7624
+ for (const entry of manifest.entries) {
7625
+ if (entry.fingerprint === fingerprint) {
7626
+ return [...entry.capabilities];
7627
+ }
7628
+ }
7629
+ return null;
7630
+ }
7631
+
7632
+ // src/lib/v4Trust.ts
7633
+ var import_node_child_process2 = require("child_process");
7634
+ var import_node_crypto6 = require("crypto");
7635
+
7636
+ // src/lib/sshReviewClient.ts
7637
+ var import_node_crypto5 = require("crypto");
7638
+ var import_node_child_process = require("child_process");
7639
+ function buildPubkeyMap(pubFilenames, readAtBase) {
7640
+ const out = /* @__PURE__ */ new Map();
7641
+ for (const name of pubFilenames) {
7642
+ if (!name.endsWith(".pub")) continue;
7643
+ let pem;
7644
+ try {
7645
+ pem = readAtBase(`.stamp/trusted-keys/${name}`);
7646
+ } catch {
7647
+ continue;
7648
+ }
7649
+ let fp;
7650
+ try {
7651
+ fp = fingerprintFromPem(pem);
7652
+ } catch {
7653
+ continue;
7654
+ }
7655
+ out.set(fp, pem);
7656
+ }
7657
+ return out;
7658
+ }
7659
+
7660
+ // src/lib/v4Trust.ts
7661
+ var COMMIT_PHASES_V4 = [
7662
+ { name: "verifyV4MergeStructure", fn: verifyV4MergeStructure },
7663
+ { name: "verifyV4TargetBranch", fn: verifyV4TargetBranch },
7664
+ { name: "verifyV4SignerTrust", fn: verifyV4SignerTrust },
7665
+ { name: "verifyV4OuterSignature", fn: verifyV4OuterSignature },
7666
+ { name: "verifyV4Approvals", fn: verifyV4Approvals },
7667
+ { name: "verifyV4DiffHash", fn: verifyV4DiffHash },
7668
+ { name: "verifyV4ApprovalSignatures", fn: verifyV4ApprovalSignatures },
7669
+ { name: "verifyV4Checks", fn: verifyV4Checks },
7670
+ // Runs before verifyV4StampPathsGuard for UX (clearer error message
7671
+ // on forged sigs); the guard is correct out-of-order too.
7672
+ { name: "verifyV4TrustAnchorSignatures", fn: verifyV4TrustAnchorSignatures },
7673
+ // Independently re-verifies trust-anchor signatures — see the
7674
+ // ORDERING note above. Phase ordering is not security-load-bearing.
7675
+ { name: "verifyV4StampPathsGuard", fn: verifyV4StampPathsGuard }
7676
+ ];
7677
+ var PR_MODE_PHASES_V4 = COMMIT_PHASES_V4.filter((p) => p.name !== "verifyV4MergeStructure");
7678
+ function verifyV4MergeStructure(input) {
7679
+ const { sha, branch, payload } = input;
7680
+ const parents = run(["rev-list", "--parents", "-n", "1", sha]).trim().split(/\s+/).slice(1);
7681
+ if (parents.length !== 2) {
7682
+ return {
7683
+ ok: false,
7684
+ reason: `commit ${sha.slice(0, 8)} is not a merge commit (has ${parents.length} parent(s)). Every commit to '${branch}' must be a --no-ff merge.`
7685
+ };
7686
+ }
7687
+ const [parent0, parent1] = parents;
7688
+ if (parent1 !== payload.head_sha) {
7689
+ return {
7690
+ ok: false,
7691
+ reason: `commit ${sha.slice(0, 8)}: v4 second parent (${parent1.slice(0, 8)}) != payload.head_sha (${payload.head_sha.slice(0, 8)})`
7692
+ };
7693
+ }
7694
+ const mergeBase = run(["merge-base", parent0, parent1]).trim();
7695
+ if (mergeBase !== payload.base_sha) {
7696
+ return {
7697
+ ok: false,
7698
+ reason: `commit ${sha.slice(0, 8)}: v4 merge-base(${parent0.slice(0, 8)}, ${parent1.slice(0, 8)}) = ${mergeBase.slice(0, 8)} != payload.base_sha (${payload.base_sha.slice(0, 8)})`
7699
+ };
7700
+ }
7701
+ return { ok: true };
7702
+ }
7703
+ function verifyV4TargetBranch(input) {
7704
+ const { sha, branch, payload } = input;
7705
+ if (payload.target_branch !== branch) {
7706
+ return {
7707
+ ok: false,
7708
+ reason: `commit ${sha.slice(0, 8)}: v4 payload.target_branch ("${payload.target_branch}") does not match the branch being pushed ("${branch}")`
7709
+ };
7710
+ }
7711
+ return { ok: true };
7712
+ }
7713
+ function verifyV4SignerTrust(input) {
7714
+ const { sha, payload, manifest, pubkeyByFingerprint } = input;
7715
+ const caps = resolveCapability(manifest, payload.signer_key_id);
7716
+ if (caps === null) {
7717
+ return {
7718
+ ok: false,
7719
+ reason: `commit ${sha.slice(0, 8)}: v4 signer key ${payload.signer_key_id} is not listed in .stamp/trusted-keys/manifest.yml at base ${payload.base_sha.slice(0, 8)}`
7720
+ };
7721
+ }
7722
+ if (!caps.includes("admin") && !caps.includes("operator")) {
7723
+ return {
7724
+ ok: false,
7725
+ reason: `commit ${sha.slice(0, 8)}: v4 signer key ${payload.signer_key_id} has capabilities [${caps.join(", ")}] in the manifest at base ${payload.base_sha.slice(0, 8)} \u2014 needs 'admin' or 'operator' to sign a v4 envelope. Update the manifest entry and re-merge.`
7726
+ };
7727
+ }
7728
+ if (!pubkeyByFingerprint.has(payload.signer_key_id)) {
7729
+ return {
7730
+ ok: false,
7731
+ reason: `commit ${sha.slice(0, 8)}: v4 signer key ${payload.signer_key_id} is in the manifest but no matching .pub file exists in .stamp/trusted-keys/ at base ${payload.base_sha.slice(0, 8)}. Commit the public key alongside the manifest entry and re-merge.`
7732
+ };
7733
+ }
7734
+ return { ok: true };
7735
+ }
7736
+ function verifyV4OuterSignature(input) {
7737
+ const { sha, payload, payloadBytes, signatureBase64, pubkeyByFingerprint } = input;
7738
+ const pem = pubkeyByFingerprint.get(payload.signer_key_id);
7739
+ let sigValid = false;
7740
+ try {
7741
+ sigValid = verifyBytes(pem, payloadBytes, signatureBase64);
7742
+ } catch (err) {
7743
+ return {
7744
+ ok: false,
7745
+ reason: `commit ${sha.slice(0, 8)}: v4 outer signature verification threw \u2014 ${err instanceof Error ? err.message : String(err)}`
7746
+ };
7747
+ }
7748
+ if (!sigValid) {
7749
+ return {
7750
+ ok: false,
7751
+ reason: `commit ${sha.slice(0, 8)}: v4 outer Ed25519 signature does not verify against the operator's trusted key ${payload.signer_key_id}`
7752
+ };
7753
+ }
7754
+ return { ok: true };
7755
+ }
7756
+ function verifyV4Approvals(input) {
7757
+ const { sha, payload, rule } = input;
7758
+ const approvedReviewers = new Set(
7759
+ payload.approvals.filter((a) => a.approval.verdict === "approved").map((a) => a.approval.reviewer)
7760
+ );
7761
+ const missing = rule.required.filter((r) => !approvedReviewers.has(r));
7762
+ if (missing.length > 0) {
7763
+ return {
7764
+ ok: false,
7765
+ reason: `commit ${sha.slice(0, 8)}: v4 missing required approvals \u2014 ${missing.join(", ")}`
7766
+ };
7767
+ }
7768
+ return { ok: true };
7769
+ }
7770
+ function verifyV4DiffHash(input) {
7771
+ const { sha, payload } = input;
7772
+ let diffText;
7773
+ try {
7774
+ diffText = run(["diff", `${payload.base_sha}...${payload.head_sha}`]);
7775
+ } catch (err) {
7776
+ return {
7777
+ ok: false,
7778
+ reason: `commit ${sha.slice(0, 8)}: v4 unable to compute base...head diff \u2014 ${err instanceof Error ? err.message : String(err)}`
7779
+ };
7780
+ }
7781
+ const actualDiffSha256 = (0, import_node_crypto6.createHash)("sha256").update(Buffer.from(diffText, "utf8")).digest("hex");
7782
+ if (actualDiffSha256 !== payload.diff_sha256) {
7783
+ return {
7784
+ ok: false,
7785
+ reason: `commit ${sha.slice(0, 8)}: v4 diff_sha256 mismatch \u2014 payload claims ${payload.diff_sha256.slice(0, 12)}\u2026 but base...head hashes to ${actualDiffSha256.slice(0, 12)}\u2026. The operator signed against a different diff than what the commit actually merges.`
7786
+ };
7787
+ }
7788
+ for (const entry of payload.approvals) {
7789
+ if (entry.approval.diff_sha256 !== actualDiffSha256) {
7790
+ return {
7791
+ ok: false,
7792
+ reason: `commit ${sha.slice(0, 8)}: v4 approval for "${entry.approval.reviewer}" was server-signed against diff_sha256 ${entry.approval.diff_sha256.slice(0, 12)}\u2026 but base...head hashes to ${actualDiffSha256.slice(0, 12)}\u2026. The server's verdict is for a different diff.`
7793
+ };
7794
+ }
7795
+ }
7796
+ return { ok: true };
7797
+ }
7798
+ function verifyV4ApprovalSignatures(input) {
7799
+ const { sha, payload, manifest, pubkeyByFingerprint } = input;
7800
+ const manifestSnapshot = snapshotSha256(manifest);
7801
+ const reviewerDefs = readReviewerDefsAtRef(payload.base_sha);
7802
+ for (const entry of payload.approvals) {
7803
+ const a = entry.approval;
7804
+ const reviewerLabel = `"${a.reviewer}"`;
7805
+ if (a.base_sha !== payload.base_sha) {
7806
+ return {
7807
+ ok: false,
7808
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel} was signed against base_sha ${a.base_sha.slice(0, 8)} but envelope's base_sha is ${payload.base_sha.slice(0, 8)}`
7809
+ };
7810
+ }
7811
+ if (a.head_sha !== payload.head_sha) {
7812
+ return {
7813
+ ok: false,
7814
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel} was signed against head_sha ${a.head_sha.slice(0, 8)} but envelope's head_sha is ${payload.head_sha.slice(0, 8)}`
7815
+ };
7816
+ }
7817
+ if (entry.server_attestation.server_key_id !== a.server_key_id) {
7818
+ return {
7819
+ ok: false,
7820
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: server_attestation.server_key_id (${entry.server_attestation.server_key_id}) does not match inner approval.server_key_id (${a.server_key_id}). The inner signed payload is authoritative; one of the two was tampered with after signing.`
7821
+ };
7822
+ }
7823
+ if (a.trusted_keys_snapshot_sha256 !== manifestSnapshot) {
7824
+ return {
7825
+ ok: false,
7826
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: trusted_keys_snapshot_sha256 (${a.trusted_keys_snapshot_sha256.slice(0, 16)}\u2026) does not match the manifest at base ${payload.base_sha.slice(0, 8)} (${manifestSnapshot.slice(0, 16)}\u2026). The server signed against a different snapshot of the trust set than the one committed at the merge base.`
7827
+ };
7828
+ }
7829
+ const caps = resolveCapability(manifest, a.server_key_id);
7830
+ if (caps === null) {
7831
+ return {
7832
+ ok: false,
7833
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel} was signed by ${a.server_key_id}, but that key is not in .stamp/trusted-keys/manifest.yml at base ${payload.base_sha.slice(0, 8)}`
7834
+ };
7835
+ }
7836
+ if (!caps.includes("server")) {
7837
+ return {
7838
+ ok: false,
7839
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel} was signed by ${a.server_key_id}, but that key's capabilities [${caps.join(", ")}] don't include 'server' at base ${payload.base_sha.slice(0, 8)}`
7840
+ };
7841
+ }
7842
+ const serverPem = pubkeyByFingerprint.get(a.server_key_id);
7843
+ if (!serverPem) {
7844
+ return {
7845
+ ok: false,
7846
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: no .pub file in .stamp/trusted-keys/ at base ${payload.base_sha.slice(0, 8)} matches fingerprint ${a.server_key_id}`
7847
+ };
7848
+ }
7849
+ let sigOk = false;
7850
+ try {
7851
+ sigOk = verifyBytes(
7852
+ serverPem,
7853
+ canonicalSerializeApproval(a),
7854
+ entry.server_attestation.signature
7855
+ );
7856
+ } catch (err) {
7857
+ return {
7858
+ ok: false,
7859
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: server signature verification threw \u2014 ${err instanceof Error ? err.message : String(err)}`
7860
+ };
7861
+ }
7862
+ if (!sigOk) {
7863
+ return {
7864
+ ok: false,
7865
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: server signature does not verify against ${a.server_key_id} over canonical approval bytes`
7866
+ };
7867
+ }
7868
+ const def = reviewerDefs[a.reviewer];
7869
+ if (!def) {
7870
+ return {
7871
+ ok: false,
7872
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: reviewer is not defined in .stamp/config.yml at base ${payload.base_sha.slice(0, 8)}`
7873
+ };
7874
+ }
7875
+ let promptText;
7876
+ try {
7877
+ promptText = run(["show", `${payload.base_sha}:${def.prompt}`]);
7878
+ } catch {
7879
+ return {
7880
+ ok: false,
7881
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: prompt "${def.prompt}" is unreadable at base ${payload.base_sha.slice(0, 8)}`
7882
+ };
7883
+ }
7884
+ const recomputedPromptSha = hashPromptBytes(Buffer.from(promptText, "utf8"));
7885
+ if (recomputedPromptSha !== a.prompt_sha256) {
7886
+ return {
7887
+ ok: false,
7888
+ reason: `commit ${sha.slice(0, 8)}: v4 approval ${reviewerLabel}: prompt_sha256 mismatch \u2014 server signed ${a.prompt_sha256.slice(0, 12)}\u2026 but prompt file at base hashes to ${recomputedPromptSha.slice(0, 12)}\u2026. The reviewer prompt the server reviewed differs from the one in the merge-base tree.`
7889
+ };
7890
+ }
7891
+ }
7892
+ return { ok: true };
7893
+ }
7894
+ function verifyV4Checks(input) {
7895
+ const { sha, payload, rule } = input;
7896
+ const requiredChecks = rule.required_checks ?? [];
7897
+ const attestedByName = new Map(payload.checks.map((c) => [c.name, c]));
7898
+ const missingChecks = [];
7899
+ const failingChecks = [];
7900
+ for (const req of requiredChecks) {
7901
+ const attested = attestedByName.get(req.name);
7902
+ if (!attested) {
7903
+ missingChecks.push(req.name);
7904
+ continue;
7905
+ }
7906
+ if (attested.exit_code !== 0) {
7907
+ failingChecks.push(`${req.name} (exit ${attested.exit_code})`);
7908
+ }
7909
+ }
7910
+ if (missingChecks.length > 0) {
7911
+ return {
7912
+ ok: false,
7913
+ reason: `commit ${sha.slice(0, 8)}: v4 attestation is missing required check(s) \u2014 ${missingChecks.join(", ")}`
7914
+ };
7915
+ }
7916
+ if (failingChecks.length > 0) {
7917
+ return {
7918
+ ok: false,
7919
+ reason: `commit ${sha.slice(0, 8)}: v4 attestation records failing check(s) \u2014 ${failingChecks.join(", ")}`
7920
+ };
7921
+ }
7922
+ return { ok: true };
7923
+ }
7924
+ function verifyV4TrustAnchorSignatures(input) {
7925
+ const { sha, payload, manifest, pubkeyByFingerprint } = input;
7926
+ if (payload.trust_anchor_signatures.length === 0) return { ok: true };
7927
+ const payloadForAdmins = {
7928
+ ...payload,
7929
+ trust_anchor_signatures: []
7930
+ };
7931
+ const adminSigningBytes = canonicalSerializePayload(payloadForAdmins);
7932
+ const seen = /* @__PURE__ */ new Set();
7933
+ for (const ts of payload.trust_anchor_signatures) {
7934
+ if (seen.has(ts.signer_key_id)) {
7935
+ return {
7936
+ ok: false,
7937
+ reason: `commit ${sha.slice(0, 8)}: v4 trust_anchor_signatures contains a duplicate entry for ${ts.signer_key_id}`
7938
+ };
7939
+ }
7940
+ seen.add(ts.signer_key_id);
7941
+ const caps = resolveCapability(manifest, ts.signer_key_id);
7942
+ if (caps === null) {
7943
+ return {
7944
+ ok: false,
7945
+ reason: `commit ${sha.slice(0, 8)}: v4 trust_anchor_signatures includes ${ts.signer_key_id}, which is not in the manifest at base ${payload.base_sha.slice(0, 8)}`
7946
+ };
7947
+ }
7948
+ if (!caps.includes("admin")) {
7949
+ return {
7950
+ ok: false,
7951
+ reason: `commit ${sha.slice(0, 8)}: v4 trust_anchor_signatures includes ${ts.signer_key_id} with capabilities [${caps.join(", ")}] \u2014 needs 'admin' to counter-sign at base ${payload.base_sha.slice(0, 8)}`
7952
+ };
7953
+ }
7954
+ const pem = pubkeyByFingerprint.get(ts.signer_key_id);
7955
+ if (!pem) {
7956
+ return {
7957
+ ok: false,
7958
+ reason: `commit ${sha.slice(0, 8)}: v4 trust_anchor_signatures includes ${ts.signer_key_id} but no matching .pub file is committed at base ${payload.base_sha.slice(0, 8)}`
7959
+ };
7960
+ }
7961
+ let ok = false;
7962
+ try {
7963
+ ok = verifyBytes(pem, adminSigningBytes, ts.signature);
7964
+ } catch (err) {
7965
+ return {
7966
+ ok: false,
7967
+ reason: `commit ${sha.slice(0, 8)}: v4 trust-anchor signature by ${ts.signer_key_id} threw on verify \u2014 ${err instanceof Error ? err.message : String(err)}`
7968
+ };
7969
+ }
7970
+ if (!ok) {
7971
+ return {
7972
+ ok: false,
7973
+ reason: `commit ${sha.slice(0, 8)}: v4 trust-anchor signature by ${ts.signer_key_id} does not verify`
7974
+ };
7975
+ }
7976
+ }
7977
+ return { ok: true };
7978
+ }
7979
+ function verifyV4StampPathsGuard(input) {
7980
+ const { sha, payload, manifest, pubkeyByFingerprint, pathRules, changedFiles } = input;
7981
+ if (pathRules.length === 0) return { ok: true };
7982
+ const payloadForAdmins = {
7983
+ ...payload,
7984
+ trust_anchor_signatures: []
7985
+ };
7986
+ const adminSigningBytes = canonicalSerializePayload(payloadForAdmins);
7987
+ const validSigners = /* @__PURE__ */ new Set();
7988
+ for (const ts of payload.trust_anchor_signatures) {
7989
+ if (validSigners.has(ts.signer_key_id)) continue;
7990
+ const pem = pubkeyByFingerprint.get(ts.signer_key_id);
7991
+ if (!pem) continue;
7992
+ let ok = false;
7993
+ try {
7994
+ ok = verifyBytes(pem, adminSigningBytes, ts.signature);
7995
+ } catch {
7996
+ ok = false;
7997
+ }
7998
+ if (ok) validSigners.add(ts.signer_key_id);
7999
+ }
8000
+ for (const rule of pathRules) {
8001
+ const matched = changedFiles.filter((f) => pathMatchesAny(f, [rule.pattern]));
8002
+ if (matched.length === 0) continue;
8003
+ let qualifying = 0;
8004
+ for (const keyId of validSigners) {
8005
+ const caps = resolveCapability(manifest, keyId);
8006
+ if (caps !== null && caps.includes(rule.require_capability)) {
8007
+ qualifying++;
8008
+ }
8009
+ }
8010
+ if (qualifying < rule.minimum_signatures) {
8011
+ const sample = matched.slice(0, 3).join(", ");
8012
+ const moreSuffix = matched.length > 3 ? `, +${matched.length - 3} more` : "";
8013
+ return {
8014
+ ok: false,
8015
+ reason: `commit ${sha.slice(0, 8)}: v4 path_rules gate for pattern "${rule.pattern}" requires ${rule.minimum_signatures} signature(s) from keys with capability '${rule.require_capability}' (diff touches ${matched.length} matched path(s): ${sample}${moreSuffix}), but only ${qualifying} qualifying trust_anchor_signature(s) are present at base ${payload.base_sha.slice(0, 8)}. Re-run the merge after collecting the required admin counter-signatures.`
8016
+ };
8017
+ }
8018
+ if (!rule.bypass_review_cycle && payload.approvals.length === 0) {
8019
+ return {
8020
+ ok: false,
8021
+ reason: `commit ${sha.slice(0, 8)}: v4 path_rules gate for pattern "${rule.pattern}" has bypass_review_cycle=false (admin signatures + reviewer cycle required), but the envelope carries no approvals \u2014 the reviewer cycle did not run for this merge.`
8022
+ };
8023
+ }
8024
+ }
8025
+ return { ok: true };
8026
+ }
8027
+ function run(args) {
8028
+ try {
8029
+ return (0, import_node_child_process2.execFileSync)("git", args, {
8030
+ encoding: "utf8",
8031
+ maxBuffer: 16 * 1024 * 1024,
8032
+ stdio: ["ignore", "pipe", "pipe"]
8033
+ });
8034
+ } catch (err) {
8035
+ throw new Error(
8036
+ `git ${args.join(" ")} failed: ${err instanceof Error ? err.message : String(err)}`
8037
+ );
8038
+ }
8039
+ }
8040
+ function readPubkeyMapAt(ref) {
8041
+ let lsOut;
8042
+ try {
8043
+ lsOut = run(["ls-tree", "--name-only", ref, ".stamp/trusted-keys/"]);
8044
+ } catch {
8045
+ return /* @__PURE__ */ new Map();
8046
+ }
8047
+ const names = [];
8048
+ for (const line of lsOut.split("\n")) {
8049
+ if (!line) continue;
8050
+ const prefix = ".stamp/trusted-keys/";
8051
+ const basename = line.startsWith(prefix) ? line.slice(prefix.length) : line;
8052
+ if (basename.endsWith(".pub")) names.push(basename);
8053
+ }
8054
+ return buildPubkeyMap(names, (relPath) => run(["show", `${ref}:${relPath}`]));
8055
+ }
8056
+ function readReviewerDefsAtRef(ref) {
8057
+ let yaml;
8058
+ try {
8059
+ yaml = run(["show", `${ref}:.stamp/config.yml`]);
8060
+ } catch {
8061
+ return {};
8062
+ }
8063
+ const defs = readReviewersFromYaml(yaml);
8064
+ const out = {};
8065
+ for (const [name, def] of Object.entries(defs)) {
8066
+ if (def && typeof def.prompt === "string") {
8067
+ out[name] = { prompt: def.prompt };
8068
+ }
8069
+ }
8070
+ return out;
8071
+ }
8072
+ function readChangedFilesAtRef(baseSha, headSha) {
8073
+ let out;
8074
+ try {
8075
+ out = run(["diff", "-z", "--name-only", `${baseSha}...${headSha}`]);
8076
+ } catch {
8077
+ return null;
8078
+ }
8079
+ return out.split("\0").filter((l) => l.length > 0);
8080
+ }
8081
+ function pathGlobToRegex(pattern) {
8082
+ const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
8083
+ const DOUBLE = "\0DOUBLESTAR\0";
8084
+ const translated = escaped.replace(/\*\*/g, DOUBLE).replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]").split(DOUBLE).join(".*");
8085
+ return new RegExp(`^${translated}$`);
8086
+ }
8087
+ function pathMatchesAny(filePath, patterns) {
8088
+ for (const p of patterns) {
8089
+ if (pathGlobToRegex(p).test(filePath)) return true;
8090
+ }
8091
+ return false;
8092
+ }
8093
+ function parsePathRules(raw) {
8094
+ if (raw === void 0 || raw === null) return { rules: [], warnings: [] };
8095
+ if (typeof raw !== "object" || Array.isArray(raw)) {
8096
+ return {
8097
+ rules: [],
8098
+ warnings: [
8099
+ `path_rules: top-level value must be a YAML map (e.g. \`".stamp/**": { ... }\`). Got ${Array.isArray(raw) ? "an array" : typeof raw}; entire path_rules section ignored.`
8100
+ ]
8101
+ };
8102
+ }
8103
+ const out = [];
8104
+ const warnings = [];
8105
+ for (const [pattern, rule] of Object.entries(raw)) {
8106
+ if (typeof pattern !== "string" || pattern.length === 0) {
8107
+ warnings.push(`path_rules: empty or non-string pattern key skipped.`);
8108
+ continue;
8109
+ }
8110
+ if (!rule || typeof rule !== "object" || Array.isArray(rule)) {
8111
+ warnings.push(
8112
+ `path_rules["${pattern}"]: rule body must be a YAML map with require_capability/minimum_signatures/bypass_review_cycle fields. Rule ignored \u2014 the path is NOT gated.`
8113
+ );
8114
+ continue;
8115
+ }
8116
+ const r = rule;
8117
+ if (typeof r.require_capability !== "string" || r.require_capability.length === 0) {
8118
+ warnings.push(
8119
+ `path_rules["${pattern}"]: require_capability must be a non-empty string (e.g. \`admin\`). Got ${typeof r.require_capability}; rule ignored \u2014 the path is NOT gated.`
8120
+ );
8121
+ continue;
8122
+ }
8123
+ if (typeof r.minimum_signatures !== "number" || !Number.isInteger(r.minimum_signatures) || r.minimum_signatures < 1) {
8124
+ warnings.push(
8125
+ `path_rules["${pattern}"]: minimum_signatures must be a positive integer (got ${JSON.stringify(r.minimum_signatures)}). Rule ignored \u2014 the path is NOT gated.`
8126
+ );
8127
+ continue;
8128
+ }
8129
+ if (typeof r.bypass_review_cycle !== "boolean") {
8130
+ warnings.push(
8131
+ `path_rules["${pattern}"]: bypass_review_cycle must be a YAML boolean (true or false; YAML's \`yes\`/\`no\`/\`on\`/\`off\` are NOT parsed as booleans here). Got ${JSON.stringify(r.bypass_review_cycle)}; rule ignored \u2014 the path is NOT gated.`
8132
+ );
8133
+ continue;
8134
+ }
8135
+ out.push({
8136
+ pattern,
8137
+ require_capability: r.require_capability,
8138
+ minimum_signatures: r.minimum_signatures,
8139
+ bypass_review_cycle: r.bypass_review_cycle
8140
+ });
8141
+ }
8142
+ out.sort((a, b) => a.pattern < b.pattern ? -1 : a.pattern > b.pattern ? 1 : 0);
8143
+ return { rules: out, warnings };
8144
+ }
8145
+
7491
8146
  // src/hooks/pre-receive.ts
8147
+ var import_meta = {};
7492
8148
  var ZERO_SHA = "0000000000000000000000000000000000000000";
7493
8149
  function main() {
7494
8150
  const stdin = readAllStdin();
@@ -7550,7 +8206,7 @@ function verifyRef(oldSha, newSha, refname) {
7550
8206
  function verifyTagPush(newSha, refname) {
7551
8207
  let pointedCommit;
7552
8208
  try {
7553
- pointedCommit = run(["rev-parse", `${newSha}^{commit}`]).trim();
8209
+ pointedCommit = run2(["rev-parse", `${newSha}^{commit}`]).trim();
7554
8210
  } catch (err) {
7555
8211
  reject(
7556
8212
  refname,
@@ -7559,7 +8215,7 @@ function verifyTagPush(newSha, refname) {
7559
8215
  }
7560
8216
  let headRef;
7561
8217
  try {
7562
- headRef = run(["symbolic-ref", "HEAD"]).trim();
8218
+ headRef = run2(["symbolic-ref", "HEAD"]).trim();
7563
8219
  } catch {
7564
8220
  reject(
7565
8221
  refname,
@@ -7568,7 +8224,7 @@ function verifyTagPush(newSha, refname) {
7568
8224
  }
7569
8225
  let defaultBranchTip;
7570
8226
  try {
7571
- defaultBranchTip = run(["rev-parse", headRef]).trim();
8227
+ defaultBranchTip = run2(["rev-parse", headRef]).trim();
7572
8228
  } catch {
7573
8229
  reject(
7574
8230
  refname,
@@ -7582,7 +8238,7 @@ function verifyTagPush(newSha, refname) {
7582
8238
  `no readable .stamp/config.yml at ${defaultBranchTip.slice(0, 8)}; tag pushes require a bootstrapped repo`
7583
8239
  );
7584
8240
  }
7585
- const branchListing = run([
8241
+ const branchListing = run2([
7586
8242
  "for-each-ref",
7587
8243
  "--format=%(refname:short)",
7588
8244
  "refs/heads/"
@@ -7599,7 +8255,7 @@ function verifyTagPush(newSha, refname) {
7599
8255
  );
7600
8256
  }
7601
8257
  for (const b of protectedBranches) {
7602
- const tip = run(["rev-parse", `refs/heads/${b}`]).trim();
8258
+ const tip = run2(["rev-parse", `refs/heads/${b}`]).trim();
7603
8259
  if (isAncestor(pointedCommit, tip)) {
7604
8260
  return;
7605
8261
  }
@@ -7609,8 +8265,18 @@ function verifyTagPush(newSha, refname) {
7609
8265
  `tag points at commit ${pointedCommit.slice(0, 8)} which is not reachable from any protected branch (${protectedBranches.join(", ")}). Tags can only point at commits that have already passed branch verification \u2014 merge to a protected branch first via the stamp flow, then create the tag from that commit.`
7610
8266
  );
7611
8267
  }
8268
+ var COMMIT_PHASES = [
8269
+ { name: "verifyMergeStructure", fn: verifyMergeStructure },
8270
+ { name: "verifyTargetBranch", fn: verifyTargetBranch },
8271
+ { name: "verifySignerTrust", fn: verifySignerTrust },
8272
+ { name: "verifyTrailerSignature", fn: verifyTrailerSignature },
8273
+ { name: "verifyApprovals", fn: verifyApprovals },
8274
+ { name: "verifyChecks", fn: verifyChecks },
8275
+ { name: "verifySchemaVersion", fn: verifySchemaVersion },
8276
+ { name: "verifyReviewerHashesAtMergeBase", fn: verifyReviewerHashesAtMergeBase }
8277
+ ];
7612
8278
  function verifyCommit(sha, branch, rule, trustedKeys, refname) {
7613
- const commitMessage = run(["cat-file", "-p", sha]).split(/\n\n/s).slice(1).join("\n\n");
8279
+ const commitMessage = run2(["cat-file", "-p", sha]).split(/\n\n/s).slice(1).join("\n\n");
7614
8280
  const parsed = parseCommitAttestation(commitMessage);
7615
8281
  if (!parsed) {
7616
8282
  reject(
@@ -7618,66 +8284,106 @@ function verifyCommit(sha, branch, rule, trustedKeys, refname) {
7618
8284
  `commit ${sha.slice(0, 8)} has no Stamp-Payload / Stamp-Verified trailers. Every commit to '${branch}' must be a stamped merge.`
7619
8285
  );
7620
8286
  }
7621
- const { payload, payloadBytes, signatureBase64 } = parsed;
7622
- const parents = run(["rev-list", "--parents", "-n", "1", sha]).trim().split(/\s+/).slice(1);
8287
+ const rawSchemaVersion = parsed.payload.schema_version;
8288
+ if (typeof rawSchemaVersion === "number" && rawSchemaVersion >= MIN_ACCEPTED_V4_SCHEMA_VERSION) {
8289
+ verifyCommitV4(sha, branch, rule, parsed.payloadBytes, parsed.signatureBase64, refname);
8290
+ return;
8291
+ }
8292
+ const input = {
8293
+ sha,
8294
+ branch,
8295
+ rule,
8296
+ trustedKeys,
8297
+ payload: parsed.payload,
8298
+ payloadBytes: parsed.payloadBytes,
8299
+ signatureBase64: parsed.signatureBase64
8300
+ };
8301
+ for (const phase of COMMIT_PHASES) {
8302
+ const result = phase.fn(input);
8303
+ if (!result.ok) reject(refname, result.reason);
8304
+ }
8305
+ }
8306
+ function verifyMergeStructure(input) {
8307
+ const { sha, branch, payload } = input;
8308
+ const parents = run2(["rev-list", "--parents", "-n", "1", sha]).trim().split(/\s+/).slice(1);
7623
8309
  if (parents.length !== 2) {
7624
- reject(
7625
- refname,
7626
- `commit ${sha.slice(0, 8)} is not a merge commit (has ${parents.length} parent(s)). Every commit to '${branch}' must be a --no-ff merge.`
7627
- );
8310
+ return {
8311
+ ok: false,
8312
+ reason: `commit ${sha.slice(0, 8)} is not a merge commit (has ${parents.length} parent(s)). Every commit to '${branch}' must be a --no-ff merge.`
8313
+ };
7628
8314
  }
7629
8315
  const [parent0, parent1] = parents;
7630
8316
  if (parent1 !== payload.head_sha) {
7631
- reject(
7632
- refname,
7633
- `commit ${sha.slice(0, 8)}: second parent (${parent1.slice(0, 8)}) != payload.head_sha (${payload.head_sha.slice(0, 8)})`
7634
- );
8317
+ return {
8318
+ ok: false,
8319
+ reason: `commit ${sha.slice(0, 8)}: second parent (${parent1.slice(0, 8)}) != payload.head_sha (${payload.head_sha.slice(0, 8)})`
8320
+ };
7635
8321
  }
7636
- const mergeBase = run(["merge-base", parent0, parent1]).trim();
8322
+ const mergeBase = run2(["merge-base", parent0, parent1]).trim();
7637
8323
  if (mergeBase !== payload.base_sha) {
7638
- reject(
7639
- refname,
7640
- `commit ${sha.slice(0, 8)}: merge-base(${parent0.slice(0, 8)}, ${parent1.slice(0, 8)}) = ${mergeBase.slice(0, 8)} != payload.base_sha (${payload.base_sha.slice(0, 8)})`
7641
- );
8324
+ return {
8325
+ ok: false,
8326
+ reason: `commit ${sha.slice(0, 8)}: merge-base(${parent0.slice(0, 8)}, ${parent1.slice(0, 8)}) = ${mergeBase.slice(0, 8)} != payload.base_sha (${payload.base_sha.slice(0, 8)})`
8327
+ };
7642
8328
  }
8329
+ return { ok: true };
8330
+ }
8331
+ function verifyTargetBranch(input) {
8332
+ const { sha, branch, payload } = input;
7643
8333
  if (payload.target_branch !== branch) {
7644
- reject(
7645
- refname,
7646
- `commit ${sha.slice(0, 8)}: payload.target_branch ("${payload.target_branch}") does not match the branch being pushed ("${branch}")`
7647
- );
8334
+ return {
8335
+ ok: false,
8336
+ reason: `commit ${sha.slice(0, 8)}: payload.target_branch ("${payload.target_branch}") does not match the branch being pushed ("${branch}")`
8337
+ };
7648
8338
  }
7649
- const trustedPem = trustedKeys.get(payload.signer_key_id);
7650
- if (!trustedPem) {
7651
- reject(
7652
- refname,
7653
- `commit ${sha.slice(0, 8)}: signer key ${payload.signer_key_id} is not in .stamp/trusted-keys/`
7654
- );
8339
+ return { ok: true };
8340
+ }
8341
+ function verifySignerTrust(input) {
8342
+ const { sha, payload, trustedKeys } = input;
8343
+ if (!trustedKeys.has(payload.signer_key_id)) {
8344
+ return {
8345
+ ok: false,
8346
+ reason: `commit ${sha.slice(0, 8)}: signer key ${payload.signer_key_id} is not in .stamp/trusted-keys/`
8347
+ };
7655
8348
  }
8349
+ return { ok: true };
8350
+ }
8351
+ function verifyTrailerSignature(input) {
8352
+ const { sha, payload, payloadBytes, signatureBase64, trustedKeys } = input;
8353
+ const trustedPem = trustedKeys.get(payload.signer_key_id);
7656
8354
  let sigValid = false;
7657
8355
  try {
7658
8356
  sigValid = verifyBytes(trustedPem, payloadBytes, signatureBase64);
7659
8357
  } catch (err) {
7660
- reject(
7661
- refname,
7662
- `commit ${sha.slice(0, 8)}: signature verification threw \u2014 ${err instanceof Error ? err.message : String(err)}`
7663
- );
8358
+ return {
8359
+ ok: false,
8360
+ reason: `commit ${sha.slice(0, 8)}: signature verification threw \u2014 ${err instanceof Error ? err.message : String(err)}`
8361
+ };
7664
8362
  }
7665
8363
  if (!sigValid) {
7666
- reject(
7667
- refname,
7668
- `commit ${sha.slice(0, 8)}: Ed25519 signature does not verify against the signer's trusted key`
7669
- );
8364
+ return {
8365
+ ok: false,
8366
+ reason: `commit ${sha.slice(0, 8)}: Ed25519 signature does not verify against the signer's trusted key`
8367
+ };
7670
8368
  }
8369
+ return { ok: true };
8370
+ }
8371
+ function verifyApprovals(input) {
8372
+ const { sha, payload, rule } = input;
7671
8373
  const approvedReviewers = new Set(
7672
8374
  payload.approvals.filter((a) => a.verdict === "approved").map((a) => a.reviewer)
7673
8375
  );
7674
8376
  const missing = rule.required.filter((r) => !approvedReviewers.has(r));
7675
8377
  if (missing.length > 0) {
7676
- reject(
7677
- refname,
7678
- `commit ${sha.slice(0, 8)}: missing required approvals \u2014 ${missing.join(", ")}`
7679
- );
8378
+ return {
8379
+ ok: false,
8380
+ reason: `commit ${sha.slice(0, 8)}: missing required approvals \u2014 ${missing.join(", ")}`
8381
+ };
7680
8382
  }
8383
+ return { ok: true };
8384
+ }
8385
+ function verifyChecks(input) {
8386
+ const { sha, payload, rule } = input;
7681
8387
  const requiredChecks = rule.required_checks ?? [];
7682
8388
  const attestedByName = new Map(
7683
8389
  (payload.checks ?? []).map((c) => [c.name, c])
@@ -7695,36 +8401,42 @@ function verifyCommit(sha, branch, rule, trustedKeys, refname) {
7695
8401
  }
7696
8402
  }
7697
8403
  if (missingChecks.length > 0) {
7698
- reject(
7699
- refname,
7700
- `commit ${sha.slice(0, 8)}: attestation is missing required check(s) \u2014 ${missingChecks.join(", ")}`
7701
- );
8404
+ return {
8405
+ ok: false,
8406
+ reason: `commit ${sha.slice(0, 8)}: attestation is missing required check(s) \u2014 ${missingChecks.join(", ")}`
8407
+ };
7702
8408
  }
7703
8409
  if (failingChecks.length > 0) {
7704
- reject(
7705
- refname,
7706
- `commit ${sha.slice(0, 8)}: attestation records failing check(s) \u2014 ${failingChecks.join(", ")}`
7707
- );
8410
+ return {
8411
+ ok: false,
8412
+ reason: `commit ${sha.slice(0, 8)}: attestation records failing check(s) \u2014 ${failingChecks.join(", ")}`
8413
+ };
7708
8414
  }
8415
+ return { ok: true };
8416
+ }
8417
+ function verifySchemaVersion(input) {
8418
+ const { sha, payload } = input;
7709
8419
  const version = payload.schema_version ?? 1;
7710
8420
  if (version < MIN_ACCEPTED_PAYLOAD_VERSION) {
7711
- reject(
7712
- refname,
7713
- `commit ${sha.slice(0, 8)}: attestation schema_version ${version} is no longer accepted (minimum supported is ${MIN_ACCEPTED_PAYLOAD_VERSION} \u2014 earlier versions are known-broken under the feature-branch self-review attack). Re-create the merge with a current stamp-cli build which produces v${MIN_ACCEPTED_PAYLOAD_VERSION} attestations bound to the merge-base tree.`
7714
- );
8421
+ return {
8422
+ ok: false,
8423
+ reason: `commit ${sha.slice(0, 8)}: attestation schema_version ${version} is no longer accepted (minimum supported is ${MIN_ACCEPTED_PAYLOAD_VERSION} \u2014 earlier versions are known-broken under the feature-branch self-review attack). Re-create the merge with a current stamp-cli build which produces v${MIN_ACCEPTED_PAYLOAD_VERSION} attestations bound to the merge-base tree.`
8424
+ };
7715
8425
  }
7716
- verifyReviewerHashesAtMergeBase(sha, payload.base_sha, payload, refname);
8426
+ return { ok: true };
7717
8427
  }
7718
- function verifyReviewerHashesAtMergeBase(sha, baseSha, payload, refname) {
8428
+ function verifyReviewerHashesAtMergeBase(input) {
8429
+ const { sha, payload } = input;
8430
+ const baseSha = payload.base_sha;
7719
8431
  const prefix = `commit ${sha.slice(0, 8)}: v3 attestation:`;
7720
8432
  let configYaml;
7721
8433
  try {
7722
- configYaml = run(["show", `${baseSha}:.stamp/config.yml`]);
8434
+ configYaml = run2(["show", `${baseSha}:.stamp/config.yml`]);
7723
8435
  } catch {
7724
- reject(
7725
- refname,
7726
- `${prefix} .stamp/config.yml unreadable at merge-base ${baseSha.slice(0, 8)}`
7727
- );
8436
+ return {
8437
+ ok: false,
8438
+ reason: `${prefix} .stamp/config.yml unreadable at merge-base ${baseSha.slice(0, 8)}`
8439
+ };
7728
8440
  }
7729
8441
  const reviewers = readReviewersFromYaml(configYaml);
7730
8442
  for (const approval of payload.approvals) {
@@ -7733,42 +8445,111 @@ function verifyReviewerHashesAtMergeBase(sha, baseSha, payload, refname) {
7733
8445
  if (!approval.tools_sha256) missing.push("tools_sha256");
7734
8446
  if (!approval.mcp_sha256) missing.push("mcp_sha256");
7735
8447
  if (missing.length > 0) {
7736
- reject(
7737
- refname,
7738
- `${prefix} approval for "${approval.reviewer}" is missing ${missing.join(", ")}`
7739
- );
8448
+ return {
8449
+ ok: false,
8450
+ reason: `${prefix} approval for "${approval.reviewer}" is missing ${missing.join(", ")}`
8451
+ };
7740
8452
  }
7741
8453
  const def = reviewers[approval.reviewer];
7742
8454
  if (!def) {
7743
- reject(
7744
- refname,
7745
- `${prefix} reviewer "${approval.reviewer}" not defined in .stamp/config.yml at merge-base`
7746
- );
8455
+ return {
8456
+ ok: false,
8457
+ reason: `${prefix} reviewer "${approval.reviewer}" not defined in .stamp/config.yml at merge-base`
8458
+ };
7747
8459
  }
7748
8460
  let promptBytes;
7749
8461
  try {
7750
- promptBytes = run(["show", `${baseSha}:${def.prompt}`]);
8462
+ promptBytes = run2(["show", `${baseSha}:${def.prompt}`]);
7751
8463
  } catch {
7752
- reject(
7753
- refname,
7754
- `${prefix} reviewer "${approval.reviewer}" prompt "${def.prompt}" unreadable at merge-base`
7755
- );
8464
+ return {
8465
+ ok: false,
8466
+ reason: `${prefix} reviewer "${approval.reviewer}" prompt "${def.prompt}" unreadable at merge-base`
8467
+ };
8468
+ }
8469
+ const fields = [
8470
+ { field: "prompt", computed: hashPromptBytes(Buffer.from(promptBytes, "utf8")), expected: approval.prompt_sha256 },
8471
+ { field: "tools", computed: hashTools(def.tools), expected: approval.tools_sha256 },
8472
+ { field: "mcp_servers", computed: hashMcpServers(def.mcp_servers), expected: approval.mcp_sha256 }
8473
+ ];
8474
+ for (const f of fields) {
8475
+ if (f.computed === f.expected) continue;
8476
+ return {
8477
+ ok: false,
8478
+ reason: `${prefix} reviewer "${approval.reviewer}" ${f.field} hash mismatch (expected ${f.expected.slice(0, 16)}..., committed tree has ${f.computed.slice(0, 16)}...). The committed config differs from what the attestation claims; re-run stamp merge or revert the change.`
8479
+ };
7756
8480
  }
7757
- checkHashOrReject(prefix, refname, approval.reviewer, "prompt", hashPromptBytes(Buffer.from(promptBytes, "utf8")), approval.prompt_sha256);
7758
- checkHashOrReject(prefix, refname, approval.reviewer, "tools", hashTools(def.tools), approval.tools_sha256);
7759
- checkHashOrReject(prefix, refname, approval.reviewer, "mcp_servers", hashMcpServers(def.mcp_servers), approval.mcp_sha256);
7760
8481
  }
8482
+ return { ok: true };
7761
8483
  }
7762
- function checkHashOrReject(prefix, refname, reviewer, field, computed, expected) {
7763
- if (computed === expected) return;
7764
- reject(
7765
- refname,
7766
- `${prefix} reviewer "${reviewer}" ${field} hash mismatch (expected ${expected.slice(0, 16)}..., committed tree has ${computed.slice(0, 16)}...). The committed config differs from what the attestation claims; re-run stamp merge or revert the change.`
7767
- );
8484
+ function verifyCommitV4(sha, branch, rule, payloadBytes, signatureBase64, refname) {
8485
+ let payload;
8486
+ try {
8487
+ payload = JSON.parse(payloadBytes.toString("utf8"));
8488
+ } catch (err) {
8489
+ reject(
8490
+ refname,
8491
+ `commit ${sha.slice(0, 8)}: v4 payload is not valid JSON \u2014 ${err instanceof Error ? err.message : String(err)}`
8492
+ );
8493
+ }
8494
+ if (!payload || typeof payload !== "object" || typeof payload.schema_version !== "number" || typeof payload.base_sha !== "string" || typeof payload.head_sha !== "string" || typeof payload.target_branch !== "string" || typeof payload.diff_sha256 !== "string" || typeof payload.signer_key_id !== "string" || !Array.isArray(payload.approvals) || !Array.isArray(payload.checks) || !Array.isArray(payload.trust_anchor_signatures)) {
8495
+ reject(
8496
+ refname,
8497
+ `commit ${sha.slice(0, 8)}: v4 payload has invalid structure (missing or wrong-typed fields)`
8498
+ );
8499
+ }
8500
+ if (payload.schema_version < MIN_ACCEPTED_V4_SCHEMA_VERSION) {
8501
+ reject(
8502
+ refname,
8503
+ `commit ${sha.slice(0, 8)}: v4 attestation schema_version ${payload.schema_version} is below minimum ${MIN_ACCEPTED_V4_SCHEMA_VERSION}. Re-create the merge with a current stamp-cli build.`
8504
+ );
8505
+ }
8506
+ let manifestYaml;
8507
+ try {
8508
+ manifestYaml = run2(["show", `${payload.base_sha}:.stamp/trusted-keys/manifest.yml`]);
8509
+ } catch (err) {
8510
+ reject(
8511
+ refname,
8512
+ `commit ${sha.slice(0, 8)}: .stamp/trusted-keys/manifest.yml is missing at base ${payload.base_sha.slice(0, 8)} \u2014 v4 attestations require the manifest in the merge-base tree. (${err instanceof Error ? err.message : String(err)})`
8513
+ );
8514
+ }
8515
+ const manifest = parseManifest(manifestYaml);
8516
+ if (!manifest) {
8517
+ reject(
8518
+ refname,
8519
+ `commit ${sha.slice(0, 8)}: .stamp/trusted-keys/manifest.yml at base ${payload.base_sha.slice(0, 8)} failed to parse (bad YAML, duplicate fingerprint, unknown capability, etc.)`
8520
+ );
8521
+ }
8522
+ const pubkeyByFingerprint = readPubkeyMapAt(payload.base_sha);
8523
+ const configAtBase = readConfigAt(payload.base_sha);
8524
+ const pathRules = configAtBase?.path_rules ?? [];
8525
+ const changedFiles = readChangedFilesAtRef(payload.base_sha, payload.head_sha);
8526
+ if (changedFiles === null) {
8527
+ reject(
8528
+ refname,
8529
+ `commit ${sha.slice(0, 8)}: unable to enumerate changed files between base ${payload.base_sha.slice(0, 8)} and head ${payload.head_sha.slice(0, 8)} for path_rules evaluation. Run \`stamp review --diff <base>..<head>\` and re-attempt the merge.`
8530
+ );
8531
+ return;
8532
+ }
8533
+ const input = {
8534
+ sha,
8535
+ branch,
8536
+ rule,
8537
+ payload,
8538
+ payloadBytes,
8539
+ signatureBase64,
8540
+ manifest,
8541
+ pubkeyByFingerprint,
8542
+ pathRules,
8543
+ changedFiles
8544
+ };
8545
+ for (const phase of COMMIT_PHASES_V4) {
8546
+ const result = phase.fn(input);
8547
+ if (!result.ok) reject(refname, result.reason);
8548
+ }
7768
8549
  }
7769
- function run(args) {
8550
+ function run2(args) {
7770
8551
  try {
7771
- return (0, import_node_child_process.execFileSync)("git", args, {
8552
+ return (0, import_node_child_process3.execFileSync)("git", args, {
7772
8553
  encoding: "utf8",
7773
8554
  maxBuffer: 16 * 1024 * 1024,
7774
8555
  stdio: ["ignore", "pipe", "pipe"]
@@ -7797,8 +8578,8 @@ function resolveBranchRule(branches, branchName) {
7797
8578
  }
7798
8579
  function readConfigAt(sha) {
7799
8580
  try {
7800
- const raw = run(["show", `${sha}:.stamp/config.yml`]);
7801
- const parsed = (0, import_yaml4.parse)(raw);
8581
+ const raw = run2(["show", `${sha}:.stamp/config.yml`]);
8582
+ const parsed = (0, import_yaml5.parse)(raw);
7802
8583
  if (!parsed || typeof parsed !== "object") return null;
7803
8584
  const obj = parsed;
7804
8585
  const branches = {};
@@ -7824,7 +8605,15 @@ function readConfigAt(sha) {
7824
8605
  };
7825
8606
  }
7826
8607
  }
7827
- return { branches };
8608
+ const parsedPathRules = parsePathRules(obj.path_rules);
8609
+ for (const warning of parsedPathRules.warnings) {
8610
+ process.stderr.write(`stamp-verify: ${warning}
8611
+ `);
8612
+ }
8613
+ return {
8614
+ branches,
8615
+ ...parsedPathRules.rules.length > 0 ? { path_rules: parsedPathRules.rules } : {}
8616
+ };
7828
8617
  } catch {
7829
8618
  return null;
7830
8619
  }
@@ -7833,14 +8622,14 @@ function readTrustedKeysAt(sha) {
7833
8622
  const map = /* @__PURE__ */ new Map();
7834
8623
  let lsOut;
7835
8624
  try {
7836
- lsOut = run(["ls-tree", "-r", "--name-only", sha, ".stamp/trusted-keys/"]);
8625
+ lsOut = run2(["ls-tree", "-r", "--name-only", sha, ".stamp/trusted-keys/"]);
7837
8626
  } catch {
7838
8627
  return map;
7839
8628
  }
7840
8629
  const files = lsOut.split("\n").filter((f) => f.endsWith(".pub"));
7841
8630
  for (const path of files) {
7842
8631
  try {
7843
- const pem = run(["show", `${sha}:${path}`]);
8632
+ const pem = run2(["show", `${sha}:${path}`]);
7844
8633
  const fp = fingerprintFromPem(pem);
7845
8634
  map.set(fp, pem);
7846
8635
  } catch {
@@ -7850,7 +8639,7 @@ function readTrustedKeysAt(sha) {
7850
8639
  }
7851
8640
  function readLiveRef(refname) {
7852
8641
  try {
7853
- return (0, import_node_child_process.execFileSync)("git", ["rev-parse", "--verify", refname], {
8642
+ return (0, import_node_child_process3.execFileSync)("git", ["rev-parse", "--verify", refname], {
7854
8643
  encoding: "utf8",
7855
8644
  stdio: ["ignore", "pipe", "pipe"]
7856
8645
  }).trim();
@@ -7860,7 +8649,7 @@ function readLiveRef(refname) {
7860
8649
  }
7861
8650
  function isAncestor(ancestor, descendant) {
7862
8651
  try {
7863
- (0, import_node_child_process.execFileSync)(
8652
+ (0, import_node_child_process3.execFileSync)(
7864
8653
  "git",
7865
8654
  ["merge-base", "--is-ancestor", ancestor, descendant],
7866
8655
  { stdio: "ignore" }
@@ -7871,7 +8660,7 @@ function isAncestor(ancestor, descendant) {
7871
8660
  }
7872
8661
  }
7873
8662
  function listNewCommits(oldSha, newSha) {
7874
- const out = run([
8663
+ const out = run2([
7875
8664
  "rev-list",
7876
8665
  "--first-parent",
7877
8666
  `${oldSha}..${newSha}`
@@ -7896,14 +8685,41 @@ function reject(refname, reason) {
7896
8685
  `);
7897
8686
  process.exit(1);
7898
8687
  }
7899
- try {
7900
- main();
7901
- process.exit(0);
7902
- } catch (err) {
7903
- process.stderr.write(
7904
- `stamp-verify: internal error \u2014 ${err instanceof Error ? err.stack ?? err.message : String(err)}
8688
+ function isMainModule() {
8689
+ const argv1 = typeof process !== "undefined" ? process.argv?.[1] : void 0;
8690
+ if (!argv1) {
8691
+ return false;
8692
+ }
8693
+ try {
8694
+ return (0, import_node_url.fileURLToPath)(import_meta.url) === argv1;
8695
+ } catch {
8696
+ return true;
8697
+ }
8698
+ }
8699
+ if (isMainModule()) {
8700
+ try {
8701
+ main();
8702
+ process.exit(0);
8703
+ } catch (err) {
8704
+ process.stderr.write(
8705
+ `stamp-verify: internal error \u2014 ${err instanceof Error ? err.stack ?? err.message : String(err)}
7905
8706
  `
7906
- );
7907
- process.exit(1);
8707
+ );
8708
+ process.exit(1);
8709
+ }
7908
8710
  }
8711
+ // Annotate the CommonJS export names for ESM import in node:
8712
+ 0 && (module.exports = {
8713
+ parsePathRules,
8714
+ verifyV4ApprovalSignatures,
8715
+ verifyV4Approvals,
8716
+ verifyV4Checks,
8717
+ verifyV4DiffHash,
8718
+ verifyV4MergeStructure,
8719
+ verifyV4OuterSignature,
8720
+ verifyV4SignerTrust,
8721
+ verifyV4StampPathsGuard,
8722
+ verifyV4TargetBranch,
8723
+ verifyV4TrustAnchorSignatures
8724
+ });
7909
8725
  //# sourceMappingURL=pre-receive.cjs.map