@ai-sdlc/orchestrator 0.6.0 → 0.10.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 (209) hide show
  1. package/dist/__test-helpers/git-env.d.ts +40 -0
  2. package/dist/__test-helpers/git-env.js +73 -0
  3. package/dist/admission-composite.d.ts +89 -0
  4. package/dist/admission-composite.js +241 -0
  5. package/dist/admission-enrichment.d.ts +142 -0
  6. package/dist/admission-enrichment.js +341 -0
  7. package/dist/admission-hc.d.ts +81 -0
  8. package/dist/admission-hc.js +94 -0
  9. package/dist/admission-score.d.ts +109 -5
  10. package/dist/admission-score.js +90 -11
  11. package/dist/analysis/analyzer.js +3 -2
  12. package/dist/analysis/convention-detector.d.ts +85 -2
  13. package/dist/analysis/convention-detector.js +375 -70
  14. package/dist/analysis/diff-analyzer.d.ts +33 -0
  15. package/dist/analysis/diff-analyzer.js +122 -0
  16. package/dist/analysis/hotspot-analyzer.js +3 -1
  17. package/dist/analysis/index.d.ts +2 -1
  18. package/dist/analysis/index.js +2 -1
  19. package/dist/artifacts/index.d.ts +65 -0
  20. package/dist/artifacts/index.js +142 -0
  21. package/dist/backlog-adapter.d.ts +121 -0
  22. package/dist/backlog-adapter.js +438 -0
  23. package/dist/calibration.d.ts +43 -0
  24. package/dist/calibration.js +76 -0
  25. package/dist/cli/commands/agents.d.ts +24 -0
  26. package/dist/cli/commands/agents.js +66 -1
  27. package/dist/cli/commands/git-remote.d.ts +74 -0
  28. package/dist/cli/commands/git-remote.js +170 -0
  29. package/dist/cli/commands/health.d.ts +4 -0
  30. package/dist/cli/commands/health.js +13 -1
  31. package/dist/cli/commands/init-features.d.ts +208 -0
  32. package/dist/cli/commands/init-features.js +473 -0
  33. package/dist/cli/commands/init-templates.d.ts +104 -0
  34. package/dist/cli/commands/init-templates.js +399 -0
  35. package/dist/cli/commands/init.d.ts +48 -0
  36. package/dist/cli/commands/init.js +322 -23
  37. package/dist/cli/commands/mcp-setup.d.ts +31 -0
  38. package/dist/cli/commands/mcp-setup.js +78 -8
  39. package/dist/cli/formatters/table.js +15 -2
  40. package/dist/cli/index.d.ts +14 -1
  41. package/dist/cli/index.js +81 -20
  42. package/dist/cli/versions.d.ts +57 -0
  43. package/dist/cli/versions.js +128 -0
  44. package/dist/code-area-classifier.d.ts +21 -0
  45. package/dist/code-area-classifier.js +48 -0
  46. package/dist/config.d.ts +33 -1
  47. package/dist/config.js +78 -8
  48. package/dist/database/adapters/external.d.ts +24 -0
  49. package/dist/database/adapters/external.js +80 -0
  50. package/dist/database/adapters/neon.d.ts +41 -0
  51. package/dist/database/adapters/neon.js +98 -0
  52. package/dist/database/adapters/pg-snapshot-restore.d.ts +28 -0
  53. package/dist/database/adapters/pg-snapshot-restore.js +68 -0
  54. package/dist/database/adapters/sqlite-copy.d.ts +32 -0
  55. package/dist/database/adapters/sqlite-copy.js +145 -0
  56. package/dist/database/connection-injection.d.ts +35 -0
  57. package/dist/database/connection-injection.js +93 -0
  58. package/dist/database/index.d.ts +12 -0
  59. package/dist/database/index.js +23 -0
  60. package/dist/database/registry.d.ts +13 -0
  61. package/dist/database/registry.js +27 -0
  62. package/dist/database/topology.d.ts +52 -0
  63. package/dist/database/topology.js +44 -0
  64. package/dist/database/types.d.ts +89 -0
  65. package/dist/database/types.js +26 -0
  66. package/dist/design-authority.d.ts +53 -0
  67. package/dist/design-authority.js +84 -0
  68. package/dist/design-lookahead.d.ts +64 -0
  69. package/dist/design-lookahead.js +86 -0
  70. package/dist/design-quality-trend.d.ts +87 -0
  71. package/dist/design-quality-trend.js +190 -0
  72. package/dist/design-system-context.d.ts +46 -0
  73. package/dist/design-system-context.js +80 -0
  74. package/dist/design-system-correction-loop.d.ts +64 -0
  75. package/dist/design-system-correction-loop.js +128 -0
  76. package/dist/design-system-metrics.d.ts +61 -0
  77. package/dist/design-system-metrics.js +104 -0
  78. package/dist/design-system-stewardship.d.ts +22 -0
  79. package/dist/design-system-stewardship.js +85 -0
  80. package/dist/design-system-validation.d.ts +37 -0
  81. package/dist/design-system-validation.js +88 -0
  82. package/dist/dispatch/index.d.ts +4 -0
  83. package/dist/dispatch/index.js +4 -0
  84. package/dist/dispatch/merge-gate.d.ts +46 -0
  85. package/dist/dispatch/merge-gate.js +90 -0
  86. package/dist/dispatch/requeue.d.ts +57 -0
  87. package/dist/dispatch/requeue.js +131 -0
  88. package/dist/dispatch/worker-pool.d.ts +62 -0
  89. package/dist/dispatch/worker-pool.js +60 -0
  90. package/dist/execute.d.ts +40 -0
  91. package/dist/execute.js +188 -15
  92. package/dist/fix-ci.js +8 -1
  93. package/dist/fix-review.js +8 -1
  94. package/dist/harness/adapters/claude-code.d.ts +29 -0
  95. package/dist/harness/adapters/claude-code.js +191 -0
  96. package/dist/harness/adapters/codex.d.ts +25 -0
  97. package/dist/harness/adapters/codex.js +61 -0
  98. package/dist/harness/independence.d.ts +51 -0
  99. package/dist/harness/independence.js +67 -0
  100. package/dist/harness/index.d.ts +14 -0
  101. package/dist/harness/index.js +20 -0
  102. package/dist/harness/registry.d.ts +17 -0
  103. package/dist/harness/registry.js +31 -0
  104. package/dist/harness/types.d.ts +123 -0
  105. package/dist/harness/types.js +8 -0
  106. package/dist/harness/version-probe.d.ts +14 -0
  107. package/dist/harness/version-probe.js +123 -0
  108. package/dist/index.d.ts +30 -4
  109. package/dist/index.js +35 -2
  110. package/dist/models/classifier.d.ts +78 -0
  111. package/dist/models/classifier.js +277 -0
  112. package/dist/models/index.d.ts +3 -0
  113. package/dist/models/index.js +3 -0
  114. package/dist/models/registry.d.ts +97 -0
  115. package/dist/models/registry.js +173 -0
  116. package/dist/pillar-breakdown.d.ts +85 -0
  117. package/dist/pillar-breakdown.js +162 -0
  118. package/dist/priority.d.ts +5 -0
  119. package/dist/priority.js +18 -5
  120. package/dist/review-meta.d.ts +65 -0
  121. package/dist/review-meta.js +149 -0
  122. package/dist/review.d.ts +4 -0
  123. package/dist/review.js +12 -2
  124. package/dist/runners/claude-code-sdk.d.ts +31 -0
  125. package/dist/runners/claude-code-sdk.js +238 -0
  126. package/dist/runners/claude-code.d.ts +3 -0
  127. package/dist/runners/claude-code.js +30 -66
  128. package/dist/runners/codex.js +4 -1
  129. package/dist/runners/copilot.js +4 -1
  130. package/dist/runners/cursor.js +4 -1
  131. package/dist/runners/git-utils.d.ts +81 -0
  132. package/dist/runners/git-utils.js +201 -0
  133. package/dist/runners/index.d.ts +3 -0
  134. package/dist/runners/index.js +3 -0
  135. package/dist/runners/review-agent.d.ts +20 -0
  136. package/dist/runners/review-agent.js +171 -41
  137. package/dist/runners/runner-registry.js +10 -0
  138. package/dist/runners/sdk-review-runner.d.ts +65 -0
  139. package/dist/runners/sdk-review-runner.js +185 -0
  140. package/dist/runners/security-triage.d.ts +20 -4
  141. package/dist/runners/security-triage.js +39 -15
  142. package/dist/runners/types.d.ts +6 -0
  143. package/dist/runtime/attestations.d.ts +766 -0
  144. package/dist/runtime/attestations.js +1195 -0
  145. package/dist/runtime/git-env.d.ts +53 -0
  146. package/dist/runtime/git-env.js +60 -0
  147. package/dist/runtime/index.d.ts +7 -0
  148. package/dist/runtime/index.js +7 -0
  149. package/dist/runtime/parallelism-flag.d.ts +28 -0
  150. package/dist/runtime/parallelism-flag.js +39 -0
  151. package/dist/runtime/port-allocator.d.ts +32 -0
  152. package/dist/runtime/port-allocator.js +96 -0
  153. package/dist/runtime/worktree-pool.d.ts +86 -0
  154. package/dist/runtime/worktree-pool.js +204 -0
  155. package/dist/runtime/worktree.d.ts +25 -0
  156. package/dist/runtime/worktree.js +111 -0
  157. package/dist/sa-scoring/auto-calibrate.d.ts +69 -0
  158. package/dist/sa-scoring/auto-calibrate.js +107 -0
  159. package/dist/sa-scoring/c1-sa2-computable.d.ts +42 -0
  160. package/dist/sa-scoring/c1-sa2-computable.js +59 -0
  161. package/dist/sa-scoring/composite.d.ts +107 -0
  162. package/dist/sa-scoring/composite.js +139 -0
  163. package/dist/sa-scoring/depparse-client.d.ts +79 -0
  164. package/dist/sa-scoring/depparse-client.js +187 -0
  165. package/dist/sa-scoring/did-compiler.d.ts +122 -0
  166. package/dist/sa-scoring/did-compiler.js +286 -0
  167. package/dist/sa-scoring/drift-monitor.d.ts +84 -0
  168. package/dist/sa-scoring/drift-monitor.js +186 -0
  169. package/dist/sa-scoring/exemplar-bank.d.ts +78 -0
  170. package/dist/sa-scoring/exemplar-bank.js +154 -0
  171. package/dist/sa-scoring/feedback-store.d.ts +100 -0
  172. package/dist/sa-scoring/feedback-store.js +156 -0
  173. package/dist/sa-scoring/index.d.ts +71 -0
  174. package/dist/sa-scoring/index.js +158 -0
  175. package/dist/sa-scoring/layer1-deterministic.d.ts +115 -0
  176. package/dist/sa-scoring/layer1-deterministic.js +298 -0
  177. package/dist/sa-scoring/layer2-structural.d.ts +71 -0
  178. package/dist/sa-scoring/layer2-structural.js +151 -0
  179. package/dist/sa-scoring/layer3-llm.d.ts +86 -0
  180. package/dist/sa-scoring/layer3-llm.js +282 -0
  181. package/dist/sa-scoring/rescore-orchestrator.d.ts +52 -0
  182. package/dist/sa-scoring/rescore-orchestrator.js +47 -0
  183. package/dist/scheduling/burn-down.d.ts +27 -0
  184. package/dist/scheduling/burn-down.js +43 -0
  185. package/dist/scheduling/calibration.d.ts +42 -0
  186. package/dist/scheduling/calibration.js +150 -0
  187. package/dist/scheduling/index.d.ts +8 -0
  188. package/dist/scheduling/index.js +8 -0
  189. package/dist/scheduling/ledger.d.ts +59 -0
  190. package/dist/scheduling/ledger.js +216 -0
  191. package/dist/scheduling/off-peak.d.ts +27 -0
  192. package/dist/scheduling/off-peak.js +112 -0
  193. package/dist/scheduling/schedule-decision.d.ts +39 -0
  194. package/dist/scheduling/schedule-decision.js +89 -0
  195. package/dist/scheduling/tier-analysis.d.ts +47 -0
  196. package/dist/scheduling/tier-analysis.js +81 -0
  197. package/dist/scheduling/types.d.ts +140 -0
  198. package/dist/scheduling/types.js +11 -0
  199. package/dist/shared.d.ts +13 -0
  200. package/dist/shared.js +32 -0
  201. package/dist/state/schema.d.ts +5 -1
  202. package/dist/state/schema.js +223 -1
  203. package/dist/state/store.d.ts +55 -1
  204. package/dist/state/store.js +342 -7
  205. package/dist/state/types.d.ts +139 -0
  206. package/dist/types.d.ts +1 -1
  207. package/dist/validate-agent-output.js +4 -1
  208. package/dist/watch.js +6 -0
  209. package/package.json +3 -2
@@ -0,0 +1,1195 @@
1
+ /**
2
+ * Cryptographic review attestations (AISDLC-74).
3
+ *
4
+ * `/ai-sdlc execute` runs three reviewer subagents (code/test/security) locally
5
+ * before pushing. CI then re-ran the same reviewers via `Post Review Results` —
6
+ * burning tokens on duplicate work.
7
+ *
8
+ * This module provides the primitives `/ai-sdlc execute` and the
9
+ * `verify-attestation.yml` workflow share to skip CI review when a valid local
10
+ * attestation exists. The shape is a DSSE envelope (in-toto / SLSA pattern)
11
+ * carrying a versioned predicate that commits to the commit SHA, diff hash,
12
+ * policy hash, and reviewer agent file hashes — so CI can reject envelopes
13
+ * after force-push, after a policy edit, or after a reviewer agent change.
14
+ *
15
+ * ## Threat model (in-scope)
16
+ *
17
+ * - Lazy contributor faking attestation → signature mismatch
18
+ * - Copy-pasted attestation from another PR → subject digest mismatch
19
+ * - Replay after diff changed (force-push) → diffHash mismatch
20
+ * - Attestation issued before a policy edit → policyHash mismatch
21
+ * - Stale reviewer-agent attestation → agentFileHash mismatch
22
+ * - Schema drift / forward-compat smuggling → schemaVersion enforcement
23
+ *
24
+ * Out of scope: compromised dev machine, compromised CI runner, collusion.
25
+ *
26
+ * ## Why ed25519 + Node's built-in crypto (no Sigstore)
27
+ *
28
+ * The keys are project-controlled, committed in `.ai-sdlc/trusted-reviewers.yaml`,
29
+ * and small (32-byte). Sigstore would add Fulcio + Rekor + transparency log
30
+ * infrastructure for no benefit at this scale. ed25519 is what `ssh-keygen
31
+ * -t ed25519` and `git commit -S` already use; Node's `crypto.sign(null, ...)`
32
+ * supports it natively.
33
+ */
34
+ import { createHash, generateKeyPairSync, sign, verify } from 'node:crypto';
35
+ import { execFileSync } from 'node:child_process';
36
+ import { cleanGitEnv } from './git-env.js';
37
+ /**
38
+ * The currently-accepted predicate schema versions. CI rejects any envelope
39
+ * whose `payload.schemaVersion` is not in this allowlist — this is the
40
+ * forward-compatibility hatch.
41
+ *
42
+ * AISDLC-103 (Verifier Phase 3) narrowed this to `['v3']` only:
43
+ * - `v1` envelopes (pre-AISDLC-94, diffHash-only) are rejected.
44
+ * - `v2` was never landed as a distinct schemaVersion — the AISDLC-94
45
+ * `contentHash` and AISDLC-101 `contentHashV3` shipped under the v1
46
+ * schemaVersion as additive optional fields during the dual- and
47
+ * triple-hash soak windows.
48
+ * - `v3` envelopes carry `contentHashV3` as a required field and DO NOT
49
+ * carry `diffHash` or `contentHash` (the legacy hashes are forbidden;
50
+ * a v3 envelope smuggling either field is rejected by
51
+ * `validatePredicateShape`).
52
+ *
53
+ * Exported so the `verify-attestation` workflow can `import`/inline it.
54
+ */
55
+ export const ACCEPTED_SCHEMA_VERSIONS = ['v3'];
56
+ /**
57
+ * The DSSE PAE payload type for our predicate. DSSE spec mandates a payload
58
+ * type URI — we use a project-controlled vendor URI rather than the
59
+ * in-toto Statement format (which would force us to shape the predicate
60
+ * around `_type` + `subject` at the envelope layer instead of the predicate).
61
+ */
62
+ export const DSSE_PAYLOAD_TYPE = 'application/vnd.ai-sdlc.attestation+json';
63
+ // ─── Schema validation ────────────────────────────────────────────
64
+ //
65
+ // Defense-in-depth against GITHUB_OUTPUT injection (and any other
66
+ // downstream consumer that interpolates predicate fields into a
67
+ // structured format). Every field that the verifier ever interpolates
68
+ // into a `reason` string MUST be regex-validated to a known-safe
69
+ // charset BEFORE the rest of `verifyAttestation` runs. If validation
70
+ // fails, we return a FIXED reason string that does not embed the bad
71
+ // value — never give the attacker a way to smuggle their payload past
72
+ // us by burying it in our reason text.
73
+ //
74
+ // Mirror of `.ai-sdlc/schemas/attestation.v3.schema.json` — the v3 schema
75
+ // requires `contentHashV3` and forbids the legacy `diffHash` / `contentHash`
76
+ // fields. AISDLC-103 (Verifier Phase 3) narrowed the schemaVersion allowlist
77
+ // to `['v3']` only; envelopes carrying the legacy hashes (= v1/v2 envelopes
78
+ // smuggling themselves into the v3 window) are rejected with a fixed reason.
79
+ /** sha1 git commit (40 lowercase hex chars). */
80
+ const SHA1_HEX = /^[0-9a-f]{40}$/;
81
+ /** sha256 hex (64 lowercase hex chars). */
82
+ const SHA256_HEX = /^[0-9a-f]{64}$/;
83
+ /** ISO 8601 timestamp — permissive enough for `new Date().toISOString()`. */
84
+ const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
85
+ /** Free-form short identifier — letters, digits, dot, dash, underscore. */
86
+ const SHORT_ID = /^[A-Za-z0-9._-]+$/;
87
+ /**
88
+ * Semver-shape pattern for `pipelineVersion` (AISDLC-100.6). Accepts
89
+ * `MAJOR.MINOR.PATCH` and the optional `-prerelease` suffix used by npm
90
+ * tags (e.g. `0.1.0-rc.2`). Mirrors the schema's regex so JSON-Schema
91
+ * validators and the in-process shape validator agree.
92
+ */
93
+ const SEMVER = /^[0-9]+\.[0-9]+\.[0-9]+(-[a-z0-9.]+)?$/;
94
+ /**
95
+ * `harnessNote` is the only field the operator can put long-form text
96
+ * in. We allow letters/digits/punctuation/whitespace but reject CR/LF
97
+ * (which is what attackers need to inject newline-key=value pairs).
98
+ */
99
+ const SAFE_TEXT = /^[^\r\n]*$/;
100
+ /**
101
+ * Validate a parsed predicate against the v3 schema regex patterns.
102
+ *
103
+ * Returns `null` when the predicate is shape-valid; otherwise returns
104
+ * a static failure reason that does NOT embed any user-controlled
105
+ * value (just the field path). This is the load-bearing property:
106
+ * the malicious value never reaches the `reason` string, so it can't
107
+ * propagate to GITHUB_OUTPUT or commit-status descriptions.
108
+ *
109
+ * AISDLC-103 (Verifier Phase 3): `contentHashV3` is now required, and the
110
+ * legacy `diffHash` / `contentHash` fields are FORBIDDEN — a predicate
111
+ * carrying either is treated as a v1/v2 envelope smuggling itself into the
112
+ * v3 window and rejected with a static reason.
113
+ */
114
+ export function validatePredicateShape(parsed) {
115
+ if (parsed === null || typeof parsed !== 'object') {
116
+ return 'schema validation failed: predicate must be an object';
117
+ }
118
+ const p = parsed;
119
+ // schemaVersion — string from the accepted enum.
120
+ if (typeof p['schemaVersion'] !== 'string') {
121
+ return 'schema validation failed: schemaVersion must be a string';
122
+ }
123
+ if (!ACCEPTED_SCHEMA_VERSIONS.includes(p['schemaVersion'])) {
124
+ // Bounded set — safe to surface the version. We also re-check this
125
+ // in `verifyAttestation` after shape validation so the error
126
+ // surface is consistent between schema-rejection and allowlist.
127
+ return 'schema validation failed: schemaVersion not in accepted enum';
128
+ }
129
+ // subject.digest.sha1 — 40 hex chars.
130
+ const subject = p['subject'];
131
+ if (subject === null || typeof subject !== 'object') {
132
+ return 'schema validation failed: subject must be an object';
133
+ }
134
+ const digest = subject['digest'];
135
+ if (digest === null || typeof digest !== 'object') {
136
+ return 'schema validation failed: subject.digest must be an object';
137
+ }
138
+ const sha1 = digest['sha1'];
139
+ if (typeof sha1 !== 'string' || !SHA1_HEX.test(sha1)) {
140
+ return 'schema validation failed: subject.digest.sha1 does not match pattern';
141
+ }
142
+ // policyHash — 64 hex chars. Required.
143
+ {
144
+ const v = p['policyHash'];
145
+ if (typeof v !== 'string' || !SHA256_HEX.test(v)) {
146
+ return 'schema validation failed: policyHash does not match pattern';
147
+ }
148
+ }
149
+ // AISDLC-103 (Phase 3): legacy `diffHash` (v1) and `contentHash` (v2)
150
+ // are FORBIDDEN in v3 envelopes. A predicate that claims `schemaVersion:
151
+ // 'v3'` but carries either field is a v1/v2 envelope smuggling itself
152
+ // into the v3 window — reject with a fixed reason that doesn't embed
153
+ // the bad value.
154
+ if (p['diffHash'] !== undefined) {
155
+ return 'schema validation failed: diffHash is forbidden in v3 envelopes (legacy v1 field)';
156
+ }
157
+ if (p['contentHash'] !== undefined) {
158
+ return 'schema validation failed: contentHash is forbidden in v3 envelopes (legacy v2 field)';
159
+ }
160
+ // contentHashV3 (AISDLC-101) — REQUIRED in v3 envelopes. Must be a
161
+ // 64-char hex sha256.
162
+ {
163
+ const ch3 = p['contentHashV3'];
164
+ if (typeof ch3 !== 'string' || !SHA256_HEX.test(ch3)) {
165
+ return 'schema validation failed: contentHashV3 does not match pattern';
166
+ }
167
+ }
168
+ // contentHashV4 (AISDLC-193.1) — OPTIONAL during the v3+v4 dual-write
169
+ // transition window. When present, MUST be a 64-char hex sha256;
170
+ // when absent (legacy v3-only envelopes signed before this field
171
+ // landed), the verifier falls back to the v3 ancestor walk.
172
+ if (p['contentHashV4'] !== undefined) {
173
+ const ch4 = p['contentHashV4'];
174
+ if (typeof ch4 !== 'string' || !SHA256_HEX.test(ch4)) {
175
+ return 'schema validation failed: contentHashV4 does not match pattern';
176
+ }
177
+ }
178
+ // pluginVersion — short ID (no CR/LF, no `=`).
179
+ const pluginVersion = p['pluginVersion'];
180
+ if (typeof pluginVersion !== 'string' ||
181
+ pluginVersion.length === 0 ||
182
+ !SHORT_ID.test(pluginVersion)) {
183
+ return 'schema validation failed: pluginVersion does not match pattern';
184
+ }
185
+ // pipelineVersion (AISDLC-100.6) — optional. When present, must be a
186
+ // semver-shaped string (`MAJOR.MINOR.PATCH` with optional `-prerelease`).
187
+ // Absence is OK (legacy v1 envelopes signed before pipeline-cli existed
188
+ // / before Phase 6 landed). The verifier logs but does NOT enforce a
189
+ // specific version — see `scripts/verify-attestation.mjs`.
190
+ if (p['pipelineVersion'] !== undefined) {
191
+ const pv = p['pipelineVersion'];
192
+ if (typeof pv !== 'string' || pv.length === 0 || !SEMVER.test(pv)) {
193
+ return 'schema validation failed: pipelineVersion does not match pattern';
194
+ }
195
+ }
196
+ // iterationCount — positive integer.
197
+ const iterationCount = p['iterationCount'];
198
+ if (typeof iterationCount !== 'number' ||
199
+ !Number.isInteger(iterationCount) ||
200
+ iterationCount < 1) {
201
+ return 'schema validation failed: iterationCount must be a positive integer';
202
+ }
203
+ // harnessNote — free-form, but no CR/LF (else it can inject
204
+ // newlines into a downstream key=value writer).
205
+ const harnessNote = p['harnessNote'];
206
+ if (typeof harnessNote !== 'string' || !SAFE_TEXT.test(harnessNote)) {
207
+ return 'schema validation failed: harnessNote contains forbidden characters';
208
+ }
209
+ // harness (AISDLC-202.3) — optional envelope-level harness field.
210
+ // Absent on pre-202.3 envelopes — accepted for backward compatibility.
211
+ // When present, must be an object with a SHORT_ID `name` and an optional
212
+ // SEMVER `version`. Validated before interpolation to prevent injection.
213
+ const harness = p['harness'];
214
+ if (harness !== undefined) {
215
+ if (harness === null || typeof harness !== 'object') {
216
+ return 'schema validation failed: harness must be an object when present';
217
+ }
218
+ const h = harness;
219
+ const hName = h['name'];
220
+ if (typeof hName !== 'string' || hName.length === 0 || !SHORT_ID.test(hName)) {
221
+ return 'schema validation failed: harness.name does not match SHORT_ID pattern';
222
+ }
223
+ const hVersion = h['version'];
224
+ if (hVersion !== undefined) {
225
+ if (typeof hVersion !== 'string' || !SEMVER.test(hVersion)) {
226
+ return 'schema validation failed: harness.version does not match SEMVER pattern';
227
+ }
228
+ }
229
+ }
230
+ // signedAt — ISO 8601.
231
+ const signedAt = p['signedAt'];
232
+ if (typeof signedAt !== 'string' || !ISO_8601.test(signedAt)) {
233
+ return 'schema validation failed: signedAt does not match ISO 8601 pattern';
234
+ }
235
+ // reviewers — array of objects, each with regex-validated fields.
236
+ const reviewers = p['reviewers'];
237
+ if (!Array.isArray(reviewers) || reviewers.length === 0) {
238
+ return 'schema validation failed: reviewers must be a non-empty array';
239
+ }
240
+ for (let i = 0; i < reviewers.length; i++) {
241
+ const r = reviewers[i];
242
+ if (r === null || typeof r !== 'object') {
243
+ return 'schema validation failed: reviewer entry must be an object';
244
+ }
245
+ const rec = r;
246
+ const agentId = rec['agentId'];
247
+ if (typeof agentId !== 'string' || agentId.length === 0 || !SHORT_ID.test(agentId)) {
248
+ return 'schema validation failed: reviewer agentId does not match pattern';
249
+ }
250
+ const agentFileHash = rec['agentFileHash'];
251
+ if (typeof agentFileHash !== 'string' || !SHA256_HEX.test(agentFileHash)) {
252
+ return 'schema validation failed: reviewer agentFileHash does not match pattern';
253
+ }
254
+ const harness = rec['harness'];
255
+ if (typeof harness !== 'string' || harness.length === 0 || !SHORT_ID.test(harness)) {
256
+ return 'schema validation failed: reviewer harness does not match pattern';
257
+ }
258
+ if (typeof rec['approved'] !== 'boolean') {
259
+ return 'schema validation failed: reviewer approved must be a boolean';
260
+ }
261
+ const findings = rec['findings'];
262
+ if (findings === null || typeof findings !== 'object') {
263
+ return 'schema validation failed: reviewer findings must be an object';
264
+ }
265
+ for (const sev of ['critical', 'major', 'minor', 'suggestion']) {
266
+ const n = findings[sev];
267
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 0) {
268
+ return 'schema validation failed: reviewer findings count must be a non-negative integer';
269
+ }
270
+ }
271
+ }
272
+ return null;
273
+ }
274
+ /**
275
+ * The set of reviewer agent IDs the verifier expects to see in every
276
+ * attestation. Exported so callers (verify-attestation.mjs) can
277
+ * cross-check that all three reviewers are present + match.
278
+ *
279
+ * Frozen to discourage callers from mutating it.
280
+ */
281
+ export const REQUIRED_REVIEWER_AGENT_IDS = Object.freeze([
282
+ 'code-reviewer',
283
+ 'test-reviewer',
284
+ 'security-reviewer',
285
+ ]);
286
+ /**
287
+ * Name-equivalence map for the reviewer-set completeness check (AISDLC-252).
288
+ *
289
+ * A "role" is satisfied when any of the listed agentIds is present in the
290
+ * envelope's reviewer set. This lets codex-harness variants (`code-reviewer-codex`,
291
+ * `test-reviewer-codex`) satisfy the same role as their Claude counterparts,
292
+ * enabling the bidirectional cross-harness review goal without requiring a
293
+ * redundant Claude review on Codex-reviewed PRs.
294
+ *
295
+ * Security stays Claude-only: `security-reviewer` has no codex variant per
296
+ * `feedback_subagent_model_selection.md` (Claude Opus for security reasoning
297
+ * depth is not yet validated for Codex o4-mini).
298
+ *
299
+ * The map is keyed by role name (= the canonical agentId), each value is the
300
+ * set of ALL agentIds that satisfy the role (including the canonical one).
301
+ *
302
+ * Frozen to discourage callers from mutating it.
303
+ */
304
+ export const REVIEWER_ROLE_EQUIVALENCES = Object.freeze({
305
+ 'code-reviewer': Object.freeze(['code-reviewer', 'code-reviewer-codex']),
306
+ 'test-reviewer': Object.freeze(['test-reviewer', 'test-reviewer-codex']),
307
+ 'security-reviewer': Object.freeze(['security-reviewer']),
308
+ });
309
+ /**
310
+ * When the implementer ran in Codex (`predicate.harness.name === 'codex'`),
311
+ * these reviewer roles MUST be satisfied by a reviewer whose `harness` field
312
+ * differs from `codex`. Per RFC-0010 §13.10 `requiresIndependentHarnessFrom`:
313
+ * code and test reviewers must come from a different harness than the
314
+ * implementer to preserve cross-harness independence.
315
+ *
316
+ * Security is excluded — it is always Claude-only regardless.
317
+ *
318
+ * Frozen to discourage callers from mutating it.
319
+ */
320
+ export const INDEPENDENCE_REQUIRED_ROLES = Object.freeze([
321
+ 'code-reviewer',
322
+ 'test-reviewer',
323
+ ]);
324
+ /**
325
+ * Regex matching the envelope self-exclusion path pattern
326
+ * `.ai-sdlc/attestations/<sha>.dsse.json`. Used to filter out the
327
+ * envelope file itself from the file collector for AISDLC-193.1
328
+ * `contentHashV4` and AISDLC-101 `contentHashV3` purposes.
329
+ *
330
+ * The chore-commit pattern signs the predicate at the dev-commit (HEAD
331
+ * BEFORE the envelope file exists), then the chore commit on top adds
332
+ * the envelope file at `.ai-sdlc/attestations/<sha>.dsse.json`. If the
333
+ * collector includes the envelope file in the hashed file set, the
334
+ * verifier (which runs against PR HEAD = dev-commit + chore commit)
335
+ * will see an EXTRA entry for the envelope that the signer never saw
336
+ * → mismatch even on direct PR HEAD without any rebase.
337
+ *
338
+ * The exclusion applies to the file COLLECTOR for HASHING purposes
339
+ * only. The verifier's chore-commit allowlist (`scripts/verify-attestation.mjs`
340
+ * `CHORE_COMMIT_PATH_ALLOWLIST`) STILL allows the envelope file in the
341
+ * chore commit's diff — that's a separate concern from "what is in the
342
+ * file set we hash."
343
+ *
344
+ * Anchored with `^...$` against the forward-slash-normalized path so
345
+ * an attacker cannot bypass with `./.ai-sdlc/attestations/x.dsse.json`
346
+ * or `foo/.ai-sdlc/attestations/x.dsse.json`. Note that git's
347
+ * `--name-only` always emits paths relative to the repo root with
348
+ * forward slashes, so the match is straightforward in practice.
349
+ */
350
+ export const ATTESTATION_ENVELOPE_PATH_PATTERN = /^\.ai-sdlc\/attestations\/[^/]+\.dsse\.json$/;
351
+ /**
352
+ * Predicate to determine whether a file path identifies an attestation
353
+ * envelope and should therefore be excluded from `contentHashV3` /
354
+ * `contentHashV4` file enumeration. Defensive about backslash
355
+ * normalization (Windows callers).
356
+ */
357
+ export function isAttestationEnvelopePath(path) {
358
+ if (typeof path !== 'string')
359
+ return false;
360
+ const normalized = path.replace(/\\/g, '/');
361
+ return ATTESTATION_ENVELOPE_PATH_PATTERN.test(normalized);
362
+ }
363
+ /**
364
+ * The "shared churn" exclude list for `contentHashV4` (AISDLC-258).
365
+ *
366
+ * Files in this list are EXCLUDED from the v4 file collector in BOTH the
367
+ * signer (`collectChangedFileDeltaEntries`) and the verifier
368
+ * (`computeHeadContentHashV4` in `scripts/verify-attestation.mjs`). When
369
+ * a file appears in this list, changes to it after signing (e.g. from a
370
+ * merge-queue rebase that regenerated `pnpm-lock.yaml`) do NOT cause
371
+ * `contentHashV4` to mismatch, so the operator is never asked to re-sign
372
+ * just because a shared tooling file was regenerated automatically.
373
+ *
374
+ * **Security trade-off (operator-approved, 2026-05-10):** An attacker
375
+ * COULD slip malicious changes through these files undetected (the
376
+ * attestation would still pass even if the ignore-listed file was
377
+ * tampered). The operator accepted this risk because:
378
+ * - None of these files contain reviewable hand-written code.
379
+ * - `pnpm-lock.yaml` is generated from `package.json` (which IS hashed).
380
+ * - `CHANGELOG.md` variants are auto-generated by release-please from
381
+ * commit history (which IS hashed via the commit-level binding).
382
+ * - `generated-schemas.ts` is generated from spec schemas (reviewed
383
+ * separately in the spec/ PR that changed them).
384
+ *
385
+ * **DO NOT add to this list:** `package.json` (real dep changes are
386
+ * reviewable), source files, test files, configs, RFCs, or anything a
387
+ * human writes by hand. The list is intentionally narrow.
388
+ *
389
+ * Paths are exact matches against the forward-slash-normalized repo-relative
390
+ * path emitted by `git diff --name-only`. Patterns (globs/regex) are NOT
391
+ * supported to keep the list auditable — every entry must be exact.
392
+ *
393
+ * Exported so `scripts/verify-attestation.mjs` can import it from the
394
+ * orchestrator barrel and apply the same exclusions on the verifier side.
395
+ */
396
+ export const CONTENTHASHV4_IGNORE_FILES = Object.freeze([
397
+ 'pnpm-lock.yaml',
398
+ 'CHANGELOG.md',
399
+ 'pipeline-cli/CHANGELOG.md',
400
+ 'orchestrator/CHANGELOG.md',
401
+ // NOTE: `reference/src/core/generated-schemas.ts` was REMOVED from this
402
+ // list per AISDLC-258 code-review CRITICAL finding. Even though it's
403
+ // auto-generated from spec/schemas/, it remains a `.ts` source file
404
+ // shipped in reference/dist. An attacker who hand-edited it post-signing
405
+ // would bypass attestation. Keep it in the hash; PRs that change schemas
406
+ // pay the re-sign cost (which is correct — the schema change IS reviewable).
407
+ ]);
408
+ /**
409
+ * Predicate to determine whether a file path should be excluded from the
410
+ * `contentHashV4` computation because it is a "shared churn" file (see
411
+ * `CONTENTHASHV4_IGNORE_FILES`). Defensive about backslash normalization.
412
+ *
413
+ * Note: this predicate is intentionally separate from
414
+ * `isAttestationEnvelopePath` because the two exclusions serve different
415
+ * purposes and may diverge independently. Merge them only if the list
416
+ * becomes large enough to warrant a single unified predicate.
417
+ */
418
+ export function isIgnoredForContentHash(path) {
419
+ if (typeof path !== 'string')
420
+ return false;
421
+ const normalized = path.replace(/\\/g, '/');
422
+ return CONTENTHASHV4_IGNORE_FILES.includes(normalized);
423
+ }
424
+ /**
425
+ * Compute a sha256 hex digest. Single source of truth for the hashing
426
+ * algorithm — every predicate field that ends in `Hash` flows through here.
427
+ */
428
+ export function sha256Hex(input) {
429
+ return createHash('sha256').update(input).digest('hex');
430
+ }
431
+ /** Compute a sha1 hex digest (used for git commit SHAs in the subject). */
432
+ export function sha1Hex(input) {
433
+ return createHash('sha1').update(input).digest('hex');
434
+ }
435
+ /**
436
+ * Compute the rebase-tolerant `contentHash` (AISDLC-94) over a changed-file
437
+ * set. The canonical encoding is one line per entry, sorted ascending by
438
+ * path, with `<path>\t<blobSha>\n` per line. The whole string is sha256-ed.
439
+ *
440
+ * Why this beats `diffHash`:
441
+ * - Rebasing PR-X onto a new `main` that already touched the same files
442
+ * does NOT change the post-apply blob SHAs (assuming no conflict),
443
+ * so `contentHash` stays stable across the rebase.
444
+ * - A conflict resolution that picks different content WILL change the
445
+ * blob SHA → `contentHash` changes → attestation correctly invalidated.
446
+ * - Force-pushing a no-op edit (e.g. `git commit --amend --no-edit`) keeps
447
+ * blob SHAs identical → `contentHash` stays stable.
448
+ *
449
+ * The deduplication step makes the function idempotent if a caller
450
+ * accidentally passes the same path twice (last-write-wins per path).
451
+ *
452
+ * Pure function. The caller (sign-attestation script) is responsible for
453
+ * gathering the file set (via `git diff --name-only` + `git ls-tree`).
454
+ */
455
+ export function computeContentHash(entries) {
456
+ // Dedup by path (last entry wins) so callers passing the same file
457
+ // twice — e.g. an add+modify in two diff invocations — don't produce
458
+ // a different hash than a clean run.
459
+ const byPath = new Map();
460
+ for (const e of entries) {
461
+ if (typeof e?.path !== 'string' || e.path.length === 0) {
462
+ throw new Error(`computeContentHash: entry path must be a non-empty string`);
463
+ }
464
+ if (typeof e.blobSha !== 'string') {
465
+ throw new Error(`computeContentHash: entry blobSha must be a string for path ${e.path}`);
466
+ }
467
+ // Reject path entries containing the canonical-encoding delimiters
468
+ // (\t between path and sha, \n between lines). Without this, a
469
+ // single entry `{ path: 'a\tB1\nb', blobSha: 'B2' }` and the
470
+ // two-entry set `[{ a, B1 }, { b, B2 }]` produce the same canonical
471
+ // string and therefore the same hash — defeating the binding. Git's
472
+ // default config already disallows \n in tracked filenames on most
473
+ // platforms; we defend in depth here so the hash itself is injective
474
+ // regardless of what the caller hands us.
475
+ if (e.path.includes('\t') || e.path.includes('\n')) {
476
+ throw new Error(`computeContentHash: entry path must not contain tab or newline characters (got ${JSON.stringify(e.path)})`);
477
+ }
478
+ // Normalize: forward-slashes (git already emits forward-slashes
479
+ // regardless of platform but be defensive), lowercase blob SHA.
480
+ const normalizedPath = e.path.replace(/\\/g, '/');
481
+ byPath.set(normalizedPath, e.blobSha.toLowerCase());
482
+ }
483
+ const sorted = [...byPath.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
484
+ const canonical = sorted.map(([path, sha]) => `${path}\t${sha}\n`).join('');
485
+ return sha256Hex(canonical);
486
+ }
487
+ /**
488
+ * Collect the changed-file set used to compute `contentHash` (AISDLC-94).
489
+ *
490
+ * Returns one `{ path, blobSha }` entry per file in
491
+ * `git diff --name-only <baseRef>...<headRef>` with the blob SHA from
492
+ * `git ls-tree -r <headRef> -- <path>`. Deleted files get an empty
493
+ * `blobSha` (the path still appears so the canonical encoding distinguishes
494
+ * "deleted" from "kept").
495
+ *
496
+ * `--no-renames` so a rename shows up as add+delete (= two entries) — that
497
+ * way a rebase that resolved a conflict by renaming differently produces a
498
+ * different hash. `-c core.quotepath=false` mirrors the verifier's git
499
+ * helper so unicode paths come back as raw UTF-8.
500
+ *
501
+ * Path entries containing `\t` or `\n` are rejected to keep the canonical
502
+ * encoding injective (mirrors the rejection in `computeContentHash`). Such
503
+ * paths are exceedingly rare in practice — git's default config disallows
504
+ * `\n` in tracked filenames on most platforms — but we defend in depth so
505
+ * malicious or pathological inputs can't smuggle entries past the binding.
506
+ *
507
+ * Extracted from the previously-duplicated helpers in
508
+ * `ai-sdlc-plugin/scripts/sign-attestation.mjs` so a single source of truth
509
+ * applies the same parsing + validation at every signing site.
510
+ */
511
+ export function collectChangedFileEntries(baseRef, headRef, repoRoot, options = {}) {
512
+ const runGit = options.runGit ??
513
+ ((args, cwd) => execFileSync('git', args, {
514
+ cwd,
515
+ env: cleanGitEnv(),
516
+ encoding: 'utf-8',
517
+ maxBuffer: 64 * 1024 * 1024,
518
+ }));
519
+ let nameOnly;
520
+ try {
521
+ nameOnly = runGit([
522
+ '-c',
523
+ 'core.quotepath=false',
524
+ 'diff',
525
+ '--name-only',
526
+ '--no-renames',
527
+ `${baseRef}...${headRef}`,
528
+ ], repoRoot);
529
+ }
530
+ catch (err) {
531
+ const msg = err instanceof Error ? err.message : String(err);
532
+ throw new Error(`collectChangedFileEntries: git diff --name-only failed: ${msg}`);
533
+ }
534
+ const paths = nameOnly.split('\n').filter((p) => p.length > 0);
535
+ const entries = [];
536
+ for (const path of paths) {
537
+ // Reject delimiters here too so the error surfaces at the enumeration
538
+ // site (cleaner than failing later inside computeContentHash).
539
+ if (path.includes('\t') || path.includes('\n')) {
540
+ throw new Error(`collectChangedFileEntries: path must not contain tab or newline characters (got ${JSON.stringify(path)})`);
541
+ }
542
+ // `git ls-tree -r <ref> -- <path>` returns blank when the path doesn't
543
+ // exist at <ref> (= deleted file). Empty blobSha is then used as the
544
+ // marker — see computeContentHash for canonical encoding.
545
+ let blobSha = '';
546
+ try {
547
+ const lsOut = runGit(['-c', 'core.quotepath=false', 'ls-tree', '-r', headRef, '--', path], repoRoot);
548
+ // ls-tree output: `<mode> <type> <sha>\t<path>` (one line per file).
549
+ const line = lsOut.split('\n').find((l) => l.length > 0);
550
+ if (line) {
551
+ const m = line.match(/^[0-9]+\s+blob\s+([0-9a-f]{40})\t/);
552
+ if (m)
553
+ blobSha = m[1];
554
+ }
555
+ }
556
+ catch {
557
+ // ls-tree failed (path missing) → treat as deleted, leave blobSha=''.
558
+ }
559
+ entries.push({ path, blobSha });
560
+ }
561
+ return entries;
562
+ }
563
+ /**
564
+ * Compute the per-file-delta `contentHashV3` (AISDLC-101) over a set of
565
+ * `{path, baseBlobSha, headBlobSha}` triples. The canonical encoding is
566
+ * one line per entry, sorted ascending by path, with
567
+ * `<path>\t<fileDeltaHash>\n` per line, where
568
+ * `fileDeltaHash = sha256(baseBlobSha + ' -> ' + headBlobSha)`. The
569
+ * outer `contentHashV3` is the sha256 of the concatenated lines.
570
+ *
571
+ * Why per-file delta hashing — and what it adds vs. AISDLC-94's `contentHash`:
572
+ * - `contentHash` (AISDLC-94) hashes the post-apply blob SHA per file.
573
+ * If a sibling PR landed between OUR sign + OUR merge AND modified
574
+ * the SAME file, the rebased file's HEAD blob SHA contains both the
575
+ * sibling contribution AND ours → contentHash diverges (false reject).
576
+ * - `contentHashV3` (AISDLC-101) hashes the (base, head) blob-pair
577
+ * transition per file. Provides a stricter "we moved file F from blob
578
+ * A to blob B" binding than just "we ended up at blob B". Any genuine
579
+ * content change still flips the head blob SHA → fileDeltaHash flips
580
+ * → contentHashV3 flips → reject (threat model preserved).
581
+ *
582
+ * This is the SECOND line of defense in the 3-layer rebase-tolerance
583
+ * plan (AISDLC-94 = Phase 1 verifier-side dual-hash, AISDLC-102 = Phase 1.5
584
+ * producer-side pre-sign rebase, AISDLC-101 = Phase 2 per-file delta).
585
+ * The verifier OR's all three legs during the triple-hash window.
586
+ *
587
+ * Path-delimiter rejection (\t / \n) mirrors `computeContentHash` so the
588
+ * canonical encoding stays injective regardless of caller input.
589
+ *
590
+ * Pure function. Idempotent against double-enumeration via dedup-by-path
591
+ * (last-write-wins per path), same as `computeContentHash`.
592
+ */
593
+ export function computeContentHashV3(entries) {
594
+ // Dedup by path (last entry wins) so callers passing the same file
595
+ // twice — e.g. add+modify in two diff invocations — don't produce a
596
+ // different hash than a clean run.
597
+ const byPath = new Map();
598
+ for (const e of entries) {
599
+ if (typeof e?.path !== 'string' || e.path.length === 0) {
600
+ throw new Error(`computeContentHashV3: entry path must be a non-empty string`);
601
+ }
602
+ if (typeof e.baseBlobSha !== 'string') {
603
+ throw new Error(`computeContentHashV3: entry baseBlobSha must be a string for path ${e.path}`);
604
+ }
605
+ if (typeof e.headBlobSha !== 'string') {
606
+ throw new Error(`computeContentHashV3: entry headBlobSha must be a string for path ${e.path}`);
607
+ }
608
+ // Reject path entries containing the canonical-encoding delimiters
609
+ // (\t between path and delta hash, \n between lines). See the same
610
+ // rejection in `computeContentHash` for the injectivity rationale.
611
+ if (e.path.includes('\t') || e.path.includes('\n')) {
612
+ throw new Error(`computeContentHashV3: entry path must not contain tab or newline characters (got ${JSON.stringify(e.path)})`);
613
+ }
614
+ const normalizedPath = e.path.replace(/\\/g, '/');
615
+ byPath.set(normalizedPath, {
616
+ baseBlobSha: e.baseBlobSha.toLowerCase(),
617
+ headBlobSha: e.headBlobSha.toLowerCase(),
618
+ });
619
+ }
620
+ const sorted = [...byPath.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
621
+ const canonical = sorted
622
+ .map(([path, { baseBlobSha, headBlobSha }]) => {
623
+ const fileDeltaHash = sha256Hex(`${baseBlobSha} -> ${headBlobSha}`);
624
+ return `${path}\t${fileDeltaHash}\n`;
625
+ })
626
+ .join('');
627
+ return sha256Hex(canonical);
628
+ }
629
+ /**
630
+ * Compute the BASE-INDEPENDENT per-file head-blob `contentHashV4`
631
+ * (AISDLC-193.1) over a set of `{path, headBlobSha}` pairs. The
632
+ * canonical encoding is `JSON.stringify(sorted-by-path-array-of-{path,
633
+ * headBlobSha}-objects)`, hashed with sha256.
634
+ *
635
+ * Why JSON-of-sorted-array (and not the v3 `<path>\t<fileDeltaHash>\n`
636
+ * canonical) for v4:
637
+ * - JSON's quoting rules already cover delimiter injection
638
+ * (a malicious path containing tab/newline can't smuggle through
639
+ * because they round-trip as escape sequences). We still reject
640
+ * such paths defensively so the canonical stays injective and
641
+ * the on-the-wire representation is what readers expect.
642
+ * - JSON is unambiguous about field ordering (stringify of a
643
+ * `{path, headBlobSha}` literal always emits `path` first,
644
+ * `headBlobSha` second — V8's object-key ordering is insertion
645
+ * order, and we insert in this order in the .map() below).
646
+ * - Easier to extend: future hash versions can add fields
647
+ * (`mode`, `executable bit`, etc) to the entry objects without
648
+ * breaking the canonical encoding scheme.
649
+ *
650
+ * Why this is BASE-INDEPENDENT (= the whole point):
651
+ * - v3's per-file delta hashes the (base_blob, head_blob) pair.
652
+ * When the merge queue rebases the PR onto current main (which
653
+ * advanced past the merge-base the producer signed against), the
654
+ * base blob SHA for any shared file changes → v3 invalidates.
655
+ * - v4 hashes only `{path, headBlobSha}`. Whatever the rebase does
656
+ * to the base ref or the merge-base, as long as the head blob SHA
657
+ * (= the actual reviewed file content) is unchanged, v4 matches.
658
+ * - The reviewer never approved "base_blob X → head_blob Y"; they
659
+ * approved "the file contents at head_blob Y." v4 binds to that
660
+ * directly.
661
+ *
662
+ * Threat model preserved:
663
+ * - Genuine post-sign content tampering (someone amends the PR to
664
+ * add unreviewed code) flips the head blob SHA → v4 hash flips →
665
+ * verifier rejects. Same threat-model surface as v3.
666
+ * - The signing key still has to be a trusted reviewer's; v4
667
+ * doesn't change the signature/key flow, just what the predicate
668
+ * binds to.
669
+ *
670
+ * Pure function. Idempotent against double-enumeration via dedup-by-path
671
+ * (last-write-wins per path), same as `computeContentHash` and
672
+ * `computeContentHashV3`.
673
+ */
674
+ export function computeContentHashV4(entries) {
675
+ // Dedup by path (last entry wins) — see computeContentHash and
676
+ // computeContentHashV3 for the idempotency rationale.
677
+ const byPath = new Map();
678
+ for (const e of entries) {
679
+ if (typeof e?.path !== 'string' || e.path.length === 0) {
680
+ throw new Error(`computeContentHashV4: entry path must be a non-empty string`);
681
+ }
682
+ if (typeof e.headBlobSha !== 'string') {
683
+ throw new Error(`computeContentHashV4: entry headBlobSha must be a string for path ${e.path}`);
684
+ }
685
+ // Reject path entries containing JSON-control characters that we
686
+ // can't unambiguously round-trip. JSON.stringify would happily
687
+ // escape these, but our canonical form is supposed to be readable
688
+ // + reversible — defense in depth keeps the on-the-wire form
689
+ // injective regardless of caller input. Same rejection list as
690
+ // computeContentHash / computeContentHashV3 for consistency.
691
+ if (e.path.includes('\t') || e.path.includes('\n')) {
692
+ throw new Error(`computeContentHashV4: entry path must not contain tab or newline characters (got ${JSON.stringify(e.path)})`);
693
+ }
694
+ const normalizedPath = e.path.replace(/\\/g, '/');
695
+ byPath.set(normalizedPath, e.headBlobSha.toLowerCase());
696
+ }
697
+ const sorted = [...byPath.entries()]
698
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
699
+ .map(([path, headBlobSha]) => ({ path, headBlobSha }));
700
+ return sha256Hex(JSON.stringify(sorted));
701
+ }
702
+ /**
703
+ * Collect the per-file-delta set used to compute `contentHashV3` (AISDLC-101).
704
+ *
705
+ * Returns one `{ path, baseBlobSha, headBlobSha }` entry per file in
706
+ * `git diff --name-only <baseRef>...<headRef>`. The base blob SHA is read
707
+ * from the *merge-base* of `<baseRef>` and `<headRef>` (which the `...`
708
+ * 3-dot diff range already targets — `A...B` diffs against
709
+ * `merge-base(A,B)`); the head blob SHA from `<headRef>`. Files newly
710
+ * added in the PR have empty `baseBlobSha`; deleted files have empty
711
+ * `headBlobSha`.
712
+ *
713
+ * Mirrors `collectChangedFileEntries`'s flag set (`--no-renames`,
714
+ * `core.quotepath=false`) for consistency with the other binding's file
715
+ * enumeration.
716
+ *
717
+ * Extracted so a single source of truth handles the two ls-tree lookups
718
+ * (one per endpoint) at every signing site (`sign-attestation.mjs`).
719
+ */
720
+ export function collectChangedFileDeltaEntries(baseRef, headRef, repoRoot, options = {}) {
721
+ const runGit = options.runGit ??
722
+ ((args, cwd) => execFileSync('git', args, {
723
+ cwd,
724
+ env: cleanGitEnv(),
725
+ encoding: 'utf-8',
726
+ maxBuffer: 64 * 1024 * 1024,
727
+ }));
728
+ // Resolve the merge-base ONCE so each ls-tree below uses a stable
729
+ // commit (`<baseRef>` may be a moving ref like `origin/main`). The
730
+ // `A...B` diff range already targets merge-base(A,B), so reading
731
+ // base blob SHAs at the merge-base keeps the per-file delta
732
+ // semantically aligned with the file enumeration.
733
+ let mergeBase;
734
+ try {
735
+ mergeBase = runGit(['merge-base', baseRef, headRef], repoRoot).trim();
736
+ }
737
+ catch (err) {
738
+ const msg = err instanceof Error ? err.message : String(err);
739
+ throw new Error(`collectChangedFileDeltaEntries: git merge-base failed: ${msg}`);
740
+ }
741
+ if (!/^[0-9a-f]{40}$/.test(mergeBase)) {
742
+ throw new Error(`collectChangedFileDeltaEntries: git merge-base returned non-SHA output: ${JSON.stringify(mergeBase)}`);
743
+ }
744
+ let nameOnly;
745
+ try {
746
+ nameOnly = runGit([
747
+ '-c',
748
+ 'core.quotepath=false',
749
+ 'diff',
750
+ '--name-only',
751
+ '--no-renames',
752
+ `${baseRef}...${headRef}`,
753
+ ], repoRoot);
754
+ }
755
+ catch (err) {
756
+ const msg = err instanceof Error ? err.message : String(err);
757
+ throw new Error(`collectChangedFileDeltaEntries: git diff --name-only failed: ${msg}`);
758
+ }
759
+ const paths = nameOnly.split('\n').filter((p) => p.length > 0);
760
+ const entries = [];
761
+ /**
762
+ * Resolve a file's blob SHA at a given ref via `git ls-tree -r`. Returns
763
+ * the empty string when the path doesn't exist at the ref (= the file
764
+ * was added in the PR for `mergeBase`, or deleted in the PR for `headRef`).
765
+ */
766
+ const resolveBlobSha = (ref, path) => {
767
+ try {
768
+ const lsOut = runGit(['-c', 'core.quotepath=false', 'ls-tree', '-r', ref, '--', path], repoRoot);
769
+ const line = lsOut.split('\n').find((l) => l.length > 0);
770
+ if (line) {
771
+ const m = line.match(/^[0-9]+\s+blob\s+([0-9a-f]{40})\t/);
772
+ if (m)
773
+ return m[1];
774
+ }
775
+ }
776
+ catch {
777
+ // ls-tree failed (path missing at ref) → empty blob marker.
778
+ }
779
+ return '';
780
+ };
781
+ for (const path of paths) {
782
+ if (path.includes('\t') || path.includes('\n')) {
783
+ throw new Error(`collectChangedFileDeltaEntries: path must not contain tab or newline characters (got ${JSON.stringify(path)})`);
784
+ }
785
+ // AISDLC-193.1 envelope self-exclusion: the chore-commit pattern
786
+ // signs the predicate at the dev commit (HEAD before the envelope
787
+ // file exists), then a chore commit on top adds the envelope file
788
+ // at `.ai-sdlc/attestations/<sha>.dsse.json`. If the collector
789
+ // includes the envelope file in the hashed file set, the verifier
790
+ // (which runs against PR HEAD = dev-commit + chore commit) will
791
+ // see an EXTRA entry the signer never saw → contentHashV3 mismatch
792
+ // even on direct PR HEAD without any rebase.
793
+ //
794
+ // Applied to BOTH v3 (this collector) AND v4 (the v4 collector
795
+ // delegates to this one + projects to head-only) so existing v3
796
+ // envelopes that touched .ai-sdlc/attestations/ as part of their
797
+ // diff still work after the dual-write switchover. The verifier's
798
+ // chore-commit allowlist STILL allows the envelope file to appear
799
+ // in the chore-commit diff — the exclusion is for HASHING only.
800
+ if (isAttestationEnvelopePath(path))
801
+ continue;
802
+ // AISDLC-258: shared-churn exclude list. Files like `pnpm-lock.yaml`
803
+ // and `CHANGELOG.md` change in nearly every PR (auto-generated by
804
+ // tooling or release-please). When a merge-queue rebase regenerates
805
+ // them, their blob SHAs shift → v4 mismatches → operator must re-sign
806
+ // despite no hand-written code change. Excluding them here (and on the
807
+ // verifier side in `computeHeadContentHashV4`) prevents that loop.
808
+ // Security trade-off accepted by operator 2026-05-10 (see
809
+ // `CONTENTHASHV4_IGNORE_FILES` for full rationale).
810
+ if (isIgnoredForContentHash(path))
811
+ continue;
812
+ const baseBlobSha = resolveBlobSha(mergeBase, path);
813
+ const headBlobSha = resolveBlobSha(headRef, path);
814
+ entries.push({ path, baseBlobSha, headBlobSha });
815
+ }
816
+ return entries;
817
+ }
818
+ /**
819
+ * Project a v3 `ChangedFileDeltaEntry` set down to the v4
820
+ * `ChangedFileHeadEntry` shape (`{path, headBlobSha}`). Convenience
821
+ * for callers that already collected v3 deltas and want to dual-emit
822
+ * both hashes from the same file enumeration. Pure function.
823
+ *
824
+ * The envelope self-exclusion is enforced upstream by
825
+ * `collectChangedFileDeltaEntries`, so this projection is a simple
826
+ * field-pick — no path filtering needed here.
827
+ */
828
+ export function projectDeltaEntriesToHeadEntries(deltas) {
829
+ return deltas.map((d) => ({ path: d.path, headBlobSha: d.headBlobSha }));
830
+ }
831
+ /**
832
+ * Build the predicate payload from raw inputs. Pure function — no I/O,
833
+ * no signing. The caller (`/ai-sdlc execute` Step 10) reads files and git
834
+ * output, then hands them here.
835
+ *
836
+ * AISDLC-103 (Verifier Phase 3): always emits a v3 envelope. The caller
837
+ * MUST provide `changedFileDeltas` (use `[]` for no-op PRs); the legacy
838
+ * `diff` + `changedFiles` inputs were dropped along with the legacy
839
+ * `diffHash` + `contentHash` fields.
840
+ */
841
+ export function buildPredicate(inputs) {
842
+ if (!/^[0-9a-f]{40}$/i.test(inputs.commitSha)) {
843
+ throw new Error(`buildPredicate: commitSha must be a 40-char hex SHA-1, got ${inputs.commitSha}`);
844
+ }
845
+ if (!Array.isArray(inputs.changedFileDeltas)) {
846
+ throw new Error(`buildPredicate: changedFileDeltas must be an array (pass [] for no-op PRs)`);
847
+ }
848
+ // Per-element shape guard catches producer-side bugs early — without this,
849
+ // a malformed delta would surface as an opaque contentHashV3 mismatch on the
850
+ // verifier side (different machine), making debugging much harder.
851
+ for (let i = 0; i < inputs.changedFileDeltas.length; i++) {
852
+ const delta = inputs.changedFileDeltas[i];
853
+ if (!delta || typeof delta !== 'object') {
854
+ throw new Error(`buildPredicate: changedFileDeltas[${i}] must be an object`);
855
+ }
856
+ if (typeof delta.path !== 'string' || delta.path.length === 0) {
857
+ throw new Error(`buildPredicate: changedFileDeltas[${i}].path must be a non-empty string`);
858
+ }
859
+ if (typeof delta.baseBlobSha !== 'string') {
860
+ throw new Error(`buildPredicate: changedFileDeltas[${i}].baseBlobSha must be a string`);
861
+ }
862
+ if (typeof delta.headBlobSha !== 'string') {
863
+ throw new Error(`buildPredicate: changedFileDeltas[${i}].headBlobSha must be a string`);
864
+ }
865
+ }
866
+ // AISDLC-193.1: derive v4 head-entry set from the v3 delta set so
867
+ // the file enumeration (and the envelope self-exclusion built into
868
+ // the v3 collector) is shared between both hashes by construction.
869
+ // Producers therefore can't accidentally compute v3 over one file
870
+ // set and v4 over another.
871
+ const headEntries = projectDeltaEntriesToHeadEntries(inputs.changedFileDeltas);
872
+ const predicate = {
873
+ schemaVersion: 'v3',
874
+ subject: { digest: { sha1: inputs.commitSha.toLowerCase() } },
875
+ contentHashV3: computeContentHashV3(inputs.changedFileDeltas),
876
+ contentHashV4: computeContentHashV4(headEntries),
877
+ policyHash: sha256Hex(inputs.policy),
878
+ reviewers: inputs.reviewers.map((r) => ({
879
+ agentId: r.agentId,
880
+ agentFileHash: sha256Hex(r.agentFileContent),
881
+ harness: r.harness,
882
+ approved: r.approved,
883
+ findings: { ...r.findings },
884
+ })),
885
+ pluginVersion: inputs.pluginVersion,
886
+ iterationCount: inputs.iterationCount,
887
+ harnessNote: inputs.harnessNote,
888
+ signedAt: inputs.signedAt ?? new Date().toISOString(),
889
+ };
890
+ // AISDLC-100.6: include `pipelineVersion` only when the caller provided
891
+ // it. Omitted otherwise so envelopes signed in environments without
892
+ // pipeline-cli installed still round-trip identically through
893
+ // validatePredicateShape.
894
+ if (typeof inputs.pipelineVersion === 'string' && inputs.pipelineVersion.length > 0) {
895
+ predicate.pipelineVersion = inputs.pipelineVersion;
896
+ }
897
+ // AISDLC-202.3: include `harness` only when the caller provided it.
898
+ // Omitted on legacy / Claude Code paths so pre-202.3 envelopes round-trip
899
+ // cleanly through validatePredicateShape (which treats absence as back-compat).
900
+ if (inputs.harness && typeof inputs.harness.name === 'string' && inputs.harness.name.length > 0) {
901
+ predicate.harness = { name: inputs.harness.name };
902
+ if (typeof inputs.harness.version === 'string' && inputs.harness.version.length > 0) {
903
+ predicate.harness.version = inputs.harness.version;
904
+ }
905
+ }
906
+ return predicate;
907
+ }
908
+ /**
909
+ * DSSE Pre-Authentication Encoding. Per the spec
910
+ * (https://github.com/secure-systems-lab/dsse/blob/master/protocol.md):
911
+ *
912
+ * PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
913
+ *
914
+ * Lengths are decimal ASCII byte-counts of the UTF-8 encoding. Signing the
915
+ * PAE — not the raw payload — is what gives DSSE its domain separation:
916
+ * a signature over a payload of one `payloadType` cannot be replayed onto
917
+ * a payload of a different type.
918
+ */
919
+ export function paeEncode(payloadType, payload) {
920
+ const typeBuf = Buffer.from(payloadType, 'utf-8');
921
+ const prefix = Buffer.from(`DSSEv1 ${typeBuf.length} ${payloadType} ${payload.length} `, 'utf-8');
922
+ return Buffer.concat([prefix, payload]);
923
+ }
924
+ /** Generate a fresh ed25519 keypair as PEM strings (for `/ai-sdlc init-signing-key`). */
925
+ export function generateSigningKeyPair() {
926
+ const { privateKey, publicKey } = generateKeyPairSync('ed25519');
927
+ return {
928
+ privateKeyPem: privateKey.export({ format: 'pem', type: 'pkcs8' }).toString(),
929
+ publicKeyPem: publicKey.export({ format: 'pem', type: 'spki' }).toString(),
930
+ };
931
+ }
932
+ /**
933
+ * Sign a predicate, producing a DSSE envelope.
934
+ *
935
+ * Throws if `predicate.schemaVersion` is not in `ACCEPTED_SCHEMA_VERSIONS` —
936
+ * we don't want to issue an envelope that we'd reject ourselves.
937
+ */
938
+ export function signAttestation(opts) {
939
+ if (!ACCEPTED_SCHEMA_VERSIONS.includes(opts.predicate.schemaVersion)) {
940
+ throw new Error(`signAttestation: schemaVersion '${opts.predicate.schemaVersion}' is not in the accepted allowlist [${ACCEPTED_SCHEMA_VERSIONS.join(', ')}]`);
941
+ }
942
+ const payloadJson = Buffer.from(JSON.stringify(opts.predicate), 'utf-8');
943
+ const pae = paeEncode(DSSE_PAYLOAD_TYPE, payloadJson);
944
+ const signature = sign(null, pae, opts.privateKeyPem);
945
+ return {
946
+ payloadType: DSSE_PAYLOAD_TYPE,
947
+ payload: payloadJson.toString('base64'),
948
+ signatures: [
949
+ {
950
+ keyid: opts.keyid,
951
+ sig: signature.toString('base64'),
952
+ },
953
+ ],
954
+ };
955
+ }
956
+ /**
957
+ * Verify a DSSE envelope. Returns a discriminated union — `{ valid: true }`
958
+ * with the parsed predicate + matched trusted reviewer, or `{ valid: false }`
959
+ * with a single human-readable reason string.
960
+ *
961
+ * The reason string is what gets posted to the commit status
962
+ * (`ai-sdlc/attestation: invalid (<reason>)`), so keep it short and specific.
963
+ */
964
+ export function verifyAttestation(opts) {
965
+ const allowlist = opts.acceptedSchemaVersions ?? ACCEPTED_SCHEMA_VERSIONS;
966
+ // ── Parse the envelope ────────────────────────────────────────
967
+ if (opts.envelope.payloadType !== DSSE_PAYLOAD_TYPE) {
968
+ return {
969
+ valid: false,
970
+ reason: `payloadType mismatch: expected ${DSSE_PAYLOAD_TYPE}, got ${opts.envelope.payloadType}`,
971
+ };
972
+ }
973
+ if (!Array.isArray(opts.envelope.signatures) || opts.envelope.signatures.length === 0) {
974
+ return { valid: false, reason: 'envelope has no signatures' };
975
+ }
976
+ // Node's `Buffer.from(s, 'base64')` does NOT throw on invalid input —
977
+ // it silently drops non-base64 chars. We round-trip through .toString
978
+ // ('base64') below as part of the JSON-parse step; PAE re-encoding then
979
+ // catches any tampering at signature-verify time.
980
+ if (typeof opts.envelope.payload !== 'string' || opts.envelope.payload.length === 0) {
981
+ return { valid: false, reason: 'envelope payload is empty or non-string' };
982
+ }
983
+ const payloadJson = Buffer.from(opts.envelope.payload, 'base64');
984
+ let parsed;
985
+ try {
986
+ parsed = JSON.parse(payloadJson.toString('utf-8'));
987
+ }
988
+ catch {
989
+ return { valid: false, reason: 'payload is not valid JSON' };
990
+ }
991
+ // ── Schema validation (REGEX-BOUND) ───────────────────────────
992
+ // This MUST run before any predicate field is interpolated into a
993
+ // reason string. The shape validator returns a fixed (non-interpolated)
994
+ // reason on failure so a malicious value cannot smuggle CR/LF or
995
+ // `=` into our output. See validatePredicateShape for rationale.
996
+ const shapeError = validatePredicateShape(parsed);
997
+ if (shapeError !== null) {
998
+ return { valid: false, reason: shapeError };
999
+ }
1000
+ const predicate = parsed;
1001
+ // ── Schema version allowlist (post-shape) ─────────────────────
1002
+ // Belt-and-braces: the shape validator already enforced membership in
1003
+ // ACCEPTED_SCHEMA_VERSIONS, but callers can override the allowlist via
1004
+ // opts.acceptedSchemaVersions for forward-compat tests, so re-check here.
1005
+ if (!allowlist.includes(predicate.schemaVersion)) {
1006
+ return {
1007
+ valid: false,
1008
+ reason: `schemaVersion '${predicate.schemaVersion}' not in allowlist [${allowlist.join(', ')}]`,
1009
+ };
1010
+ }
1011
+ // ── Signature (any-of-N pubkeys) ──────────────────────────────
1012
+ const pae = paeEncode(DSSE_PAYLOAD_TYPE, payloadJson);
1013
+ let matchedReviewer = null;
1014
+ for (const sig of opts.envelope.signatures) {
1015
+ let sigBytes;
1016
+ try {
1017
+ sigBytes = Buffer.from(sig.sig, 'base64');
1018
+ }
1019
+ catch {
1020
+ continue;
1021
+ }
1022
+ for (const reviewer of opts.trustedReviewers) {
1023
+ try {
1024
+ if (verify(null, pae, reviewer.pubkey, sigBytes)) {
1025
+ matchedReviewer = reviewer;
1026
+ break;
1027
+ }
1028
+ }
1029
+ catch {
1030
+ // Bad pubkey PEM — skip, try next.
1031
+ }
1032
+ }
1033
+ if (matchedReviewer)
1034
+ break;
1035
+ }
1036
+ if (!matchedReviewer) {
1037
+ return {
1038
+ valid: false,
1039
+ reason: 'signature did not match any trusted reviewer pubkey',
1040
+ };
1041
+ }
1042
+ // ── Bind to PR state ──────────────────────────────────────────
1043
+ // Note: every field interpolated into a reason below has already been
1044
+ // regex-bounded by validatePredicateShape (sha1/sha256 hex, SHORT_ID
1045
+ // for agentId), so embedding them in reason strings cannot inject
1046
+ // CR/LF or `=` into downstream key=value writers.
1047
+ const expectedSha = opts.expected.commitSha.toLowerCase();
1048
+ if (predicate.subject.digest.sha1.toLowerCase() !== expectedSha) {
1049
+ return {
1050
+ valid: false,
1051
+ reason: `subject digest mismatch (envelope was signed for a different commit)`,
1052
+ };
1053
+ }
1054
+ // AISDLC-193.1: v4-prefer, v3-fallback. The verifier prefers
1055
+ // `contentHashV4` (base-independent — survives queue rebases) when
1056
+ // BOTH the envelope and the expected state carry it. v3 is only
1057
+ // consulted when the envelope is legacy v3-only OR when v4 is
1058
+ // present but doesn't match (unusual — implies the head blobs
1059
+ // genuinely changed, which we still reject below).
1060
+ //
1061
+ // Why prefer v4 absolute when both are present:
1062
+ // - v3 binds to (base_blob, head_blob) per file. The merge queue's
1063
+ // rebase moves the merge-base forward → base_blob changes for
1064
+ // shared files → v3 mismatches even though reviewed content is
1065
+ // unchanged. Net: required check fails on every queued PR.
1066
+ // - v4 binds only to head_blob per file. Reviewers approved the
1067
+ // content at head_blob; whatever the rebase did to base, v4
1068
+ // stays valid.
1069
+ //
1070
+ // Threat model preserved: a genuine post-sign content tampering
1071
+ // (someone amends the PR to add unreviewed code) flips head_blob →
1072
+ // v4 flips → verifier rejects.
1073
+ const envelopeHasV4 = typeof predicate.contentHashV4 === 'string';
1074
+ const expectedHasV4 = typeof opts.expected.contentHashV4 === 'string';
1075
+ if (envelopeHasV4 && expectedHasV4) {
1076
+ if (predicate.contentHashV4 !== opts.expected.contentHashV4) {
1077
+ return {
1078
+ valid: false,
1079
+ reason: 'contentHashV4 mismatch (PR content changed since attestation)',
1080
+ };
1081
+ }
1082
+ // v4 matched → skip the v3 check entirely. The producer's v3 was
1083
+ // computed against a base ref that may have moved on by now (queue
1084
+ // rebase, sibling overlap); the v4 match is the source of truth.
1085
+ }
1086
+ else {
1087
+ // Legacy v3-only envelope OR caller did not supply expected.contentHashV4
1088
+ // → fall back to v3. Same as pre-AISDLC-193.1 behavior.
1089
+ if (predicate.contentHashV3 !== opts.expected.contentHashV3) {
1090
+ return {
1091
+ valid: false,
1092
+ reason: 'contentHashV3 mismatch (PR content changed since attestation)',
1093
+ };
1094
+ }
1095
+ }
1096
+ if (predicate.policyHash !== opts.expected.policyHash) {
1097
+ return {
1098
+ valid: false,
1099
+ reason: 'policyHash mismatch (.ai-sdlc/review-policy.md changed since attestation)',
1100
+ };
1101
+ }
1102
+ for (const r of predicate.reviewers) {
1103
+ const expectedHash = opts.expected.expectedAgentFileHashes[r.agentId];
1104
+ if (expectedHash && expectedHash !== r.agentFileHash) {
1105
+ return {
1106
+ valid: false,
1107
+ reason: `agentFileHash mismatch for reviewer '${r.agentId}' (agent file changed since attestation)`,
1108
+ };
1109
+ }
1110
+ }
1111
+ // ── Reviewer-set completeness (AISDLC-252) ──────────────────────
1112
+ // Every attestation MUST cover all three required reviewer ROLES (code,
1113
+ // test, security). Each role is satisfied by ANY agentId in its
1114
+ // equivalence group — so `code-reviewer-codex` satisfies the `code-reviewer`
1115
+ // role, enabling cross-harness reviews without a redundant Claude review.
1116
+ // Security stays Claude-only (no codex variant, per policy).
1117
+ const presentIds = new Set(predicate.reviewers.map((r) => r.agentId));
1118
+ for (const [role, variants] of Object.entries(REVIEWER_ROLE_EQUIVALENCES)) {
1119
+ const satisfied = variants.some((v) => presentIds.has(v));
1120
+ if (!satisfied) {
1121
+ return {
1122
+ valid: false,
1123
+ reason: `reviewer set incomplete: missing required reviewer '${role}' (or any variant: ${variants.join(', ')})`,
1124
+ };
1125
+ }
1126
+ }
1127
+ // ── Independence enforcement (AISDLC-252, RFC-0010 §13.10) ──────
1128
+ // When the implementer ran in codex (`predicate.harness.name === 'codex'`),
1129
+ // the code-reviewer and test-reviewer MUST NOT also be codex — that would
1130
+ // defeat the cross-harness independence goal. Security is exempt because it
1131
+ // is always Claude-only.
1132
+ const implementerHarness = predicate.harness?.name?.toLowerCase();
1133
+ if (implementerHarness === 'codex') {
1134
+ for (const role of INDEPENDENCE_REQUIRED_ROLES) {
1135
+ // Find the reviewer entry that satisfied this role.
1136
+ const satisfyingVariants = REVIEWER_ROLE_EQUIVALENCES[role] ?? [];
1137
+ const reviewerEntry = predicate.reviewers.find((r) => satisfyingVariants.includes(r.agentId));
1138
+ if (reviewerEntry) {
1139
+ const reviewerHarness = reviewerEntry.harness?.toLowerCase();
1140
+ if (reviewerHarness === 'codex') {
1141
+ return {
1142
+ valid: false,
1143
+ reason: `independence violation: implementer harness is 'codex' but reviewer '${reviewerEntry.agentId}' also uses codex (requiresIndependentHarnessFrom per RFC-0010 §13.10)`,
1144
+ };
1145
+ }
1146
+ }
1147
+ }
1148
+ }
1149
+ return { valid: true, predicate, trustedReviewer: matchedReviewer };
1150
+ }
1151
+ /**
1152
+ * Validate the shape of a parsed `.ai-sdlc/trusted-reviewers.yaml` document.
1153
+ * Throws on malformed input with a specific reason. Acceptance criterion #4.
1154
+ *
1155
+ * Accepts the parsed YAML (as `unknown`) and returns the typed array.
1156
+ */
1157
+ export function validateTrustedReviewers(parsed) {
1158
+ if (parsed === null || parsed === undefined)
1159
+ return [];
1160
+ if (typeof parsed !== 'object') {
1161
+ throw new Error('trusted-reviewers.yaml: root must be an object with a `reviewers` list');
1162
+ }
1163
+ const root = parsed;
1164
+ const list = root['reviewers'];
1165
+ if (list === undefined || list === null)
1166
+ return [];
1167
+ if (!Array.isArray(list)) {
1168
+ throw new Error('trusted-reviewers.yaml: `reviewers` must be a list');
1169
+ }
1170
+ const out = [];
1171
+ for (let i = 0; i < list.length; i++) {
1172
+ const entry = list[i];
1173
+ if (typeof entry !== 'object' || entry === null) {
1174
+ throw new Error(`trusted-reviewers.yaml: reviewers[${i}] must be an object`);
1175
+ }
1176
+ const r = entry;
1177
+ for (const field of ['identity', 'machine', 'pubkey', 'addedAt', 'addedBy']) {
1178
+ if (typeof r[field] !== 'string' || r[field].length === 0) {
1179
+ throw new Error(`trusted-reviewers.yaml: reviewers[${i}].${field} must be a non-empty string`);
1180
+ }
1181
+ }
1182
+ if (!r['pubkey'].includes('BEGIN PUBLIC KEY')) {
1183
+ throw new Error(`trusted-reviewers.yaml: reviewers[${i}].pubkey must be a PEM-encoded public key`);
1184
+ }
1185
+ out.push({
1186
+ identity: r['identity'],
1187
+ machine: r['machine'],
1188
+ pubkey: r['pubkey'],
1189
+ addedAt: r['addedAt'],
1190
+ addedBy: r['addedBy'],
1191
+ });
1192
+ }
1193
+ return out;
1194
+ }
1195
+ //# sourceMappingURL=attestations.js.map