@ai-sdlc/orchestrator 0.9.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.
- package/dist/__test-helpers/git-env.d.ts +40 -0
- package/dist/__test-helpers/git-env.js +73 -0
- package/dist/admission-composite.d.ts +22 -0
- package/dist/admission-composite.js +85 -2
- package/dist/admission-enrichment.js +12 -2
- package/dist/admission-hc.d.ts +20 -1
- package/dist/admission-hc.js +12 -1
- package/dist/admission-score.d.ts +14 -0
- package/dist/admission-score.js +7 -1
- package/dist/cli/commands/git-remote.d.ts +25 -0
- package/dist/cli/commands/git-remote.js +80 -1
- package/dist/cli/commands/init-features.d.ts +208 -0
- package/dist/cli/commands/init-features.js +473 -0
- package/dist/cli/commands/init-templates.d.ts +104 -0
- package/dist/cli/commands/init-templates.js +399 -0
- package/dist/cli/commands/init.d.ts +22 -0
- package/dist/cli/commands/init.js +158 -1
- package/dist/design-authority.d.ts +13 -0
- package/dist/design-authority.js +13 -0
- package/dist/execute.js +8 -5
- package/dist/models/classifier.d.ts +3 -1
- package/dist/models/classifier.js +61 -5
- package/dist/pillar-breakdown.d.ts +23 -1
- package/dist/pillar-breakdown.js +9 -0
- package/dist/runtime/attestations.d.ts +502 -10
- package/dist/runtime/attestations.js +759 -24
- package/dist/runtime/parallelism-flag.d.ts +21 -3
- package/dist/runtime/parallelism-flag.js +27 -6
- package/dist/runtime/port-allocator.d.ts +11 -0
- package/dist/runtime/port-allocator.js +30 -0
- package/package.json +3 -2
|
@@ -32,15 +32,27 @@
|
|
|
32
32
|
* supports it natively.
|
|
33
33
|
*/
|
|
34
34
|
import { createHash, generateKeyPairSync, sign, verify } from 'node:crypto';
|
|
35
|
+
import { execFileSync } from 'node:child_process';
|
|
36
|
+
import { cleanGitEnv } from './git-env.js';
|
|
35
37
|
/**
|
|
36
38
|
* The currently-accepted predicate schema versions. CI rejects any envelope
|
|
37
39
|
* whose `payload.schemaVersion` is not in this allowlist — this is the
|
|
38
|
-
* forward-compatibility hatch
|
|
39
|
-
*
|
|
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`).
|
|
40
52
|
*
|
|
41
53
|
* Exported so the `verify-attestation` workflow can `import`/inline it.
|
|
42
54
|
*/
|
|
43
|
-
export const ACCEPTED_SCHEMA_VERSIONS = ['
|
|
55
|
+
export const ACCEPTED_SCHEMA_VERSIONS = ['v3'];
|
|
44
56
|
/**
|
|
45
57
|
* The DSSE PAE payload type for our predicate. DSSE spec mandates a payload
|
|
46
58
|
* type URI — we use a project-controlled vendor URI rather than the
|
|
@@ -59,8 +71,11 @@ export const DSSE_PAYLOAD_TYPE = 'application/vnd.ai-sdlc.attestation+json';
|
|
|
59
71
|
// value — never give the attacker a way to smuggle their payload past
|
|
60
72
|
// us by burying it in our reason text.
|
|
61
73
|
//
|
|
62
|
-
// Mirror of `.ai-sdlc/schemas/attestation.
|
|
63
|
-
//
|
|
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.
|
|
64
79
|
/** sha1 git commit (40 lowercase hex chars). */
|
|
65
80
|
const SHA1_HEX = /^[0-9a-f]{40}$/;
|
|
66
81
|
/** sha256 hex (64 lowercase hex chars). */
|
|
@@ -69,6 +84,13 @@ const SHA256_HEX = /^[0-9a-f]{64}$/;
|
|
|
69
84
|
const ISO_8601 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
70
85
|
/** Free-form short identifier — letters, digits, dot, dash, underscore. */
|
|
71
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.]+)?$/;
|
|
72
94
|
/**
|
|
73
95
|
* `harnessNote` is the only field the operator can put long-form text
|
|
74
96
|
* in. We allow letters/digits/punctuation/whitespace but reject CR/LF
|
|
@@ -76,13 +98,18 @@ const SHORT_ID = /^[A-Za-z0-9._-]+$/;
|
|
|
76
98
|
*/
|
|
77
99
|
const SAFE_TEXT = /^[^\r\n]*$/;
|
|
78
100
|
/**
|
|
79
|
-
* Validate a parsed predicate against the
|
|
101
|
+
* Validate a parsed predicate against the v3 schema regex patterns.
|
|
80
102
|
*
|
|
81
103
|
* Returns `null` when the predicate is shape-valid; otherwise returns
|
|
82
104
|
* a static failure reason that does NOT embed any user-controlled
|
|
83
105
|
* value (just the field path). This is the load-bearing property:
|
|
84
106
|
* the malicious value never reaches the `reason` string, so it can't
|
|
85
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.
|
|
86
113
|
*/
|
|
87
114
|
export function validatePredicateShape(parsed) {
|
|
88
115
|
if (parsed === null || typeof parsed !== 'object') {
|
|
@@ -112,11 +139,40 @@ export function validatePredicateShape(parsed) {
|
|
|
112
139
|
if (typeof sha1 !== 'string' || !SHA1_HEX.test(sha1)) {
|
|
113
140
|
return 'schema validation failed: subject.digest.sha1 does not match pattern';
|
|
114
141
|
}
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
const v = p[
|
|
142
|
+
// policyHash — 64 hex chars. Required.
|
|
143
|
+
{
|
|
144
|
+
const v = p['policyHash'];
|
|
118
145
|
if (typeof v !== 'string' || !SHA256_HEX.test(v)) {
|
|
119
|
-
return
|
|
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';
|
|
120
176
|
}
|
|
121
177
|
}
|
|
122
178
|
// pluginVersion — short ID (no CR/LF, no `=`).
|
|
@@ -126,6 +182,17 @@ export function validatePredicateShape(parsed) {
|
|
|
126
182
|
!SHORT_ID.test(pluginVersion)) {
|
|
127
183
|
return 'schema validation failed: pluginVersion does not match pattern';
|
|
128
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
|
+
}
|
|
129
196
|
// iterationCount — positive integer.
|
|
130
197
|
const iterationCount = p['iterationCount'];
|
|
131
198
|
if (typeof iterationCount !== 'number' ||
|
|
@@ -139,6 +206,27 @@ export function validatePredicateShape(parsed) {
|
|
|
139
206
|
if (typeof harnessNote !== 'string' || !SAFE_TEXT.test(harnessNote)) {
|
|
140
207
|
return 'schema validation failed: harnessNote contains forbidden characters';
|
|
141
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
|
+
}
|
|
142
230
|
// signedAt — ISO 8601.
|
|
143
231
|
const signedAt = p['signedAt'];
|
|
144
232
|
if (typeof signedAt !== 'string' || !ISO_8601.test(signedAt)) {
|
|
@@ -195,6 +283,144 @@ export const REQUIRED_REVIEWER_AGENT_IDS = Object.freeze([
|
|
|
195
283
|
'test-reviewer',
|
|
196
284
|
'security-reviewer',
|
|
197
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
|
+
}
|
|
198
424
|
/**
|
|
199
425
|
* Compute a sha256 hex digest. Single source of truth for the hashing
|
|
200
426
|
* algorithm — every predicate field that ends in `Hash` flows through here.
|
|
@@ -206,19 +432,448 @@ export function sha256Hex(input) {
|
|
|
206
432
|
export function sha1Hex(input) {
|
|
207
433
|
return createHash('sha1').update(input).digest('hex');
|
|
208
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
|
+
}
|
|
209
831
|
/**
|
|
210
832
|
* Build the predicate payload from raw inputs. Pure function — no I/O,
|
|
211
833
|
* no signing. The caller (`/ai-sdlc execute` Step 10) reads files and git
|
|
212
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.
|
|
213
840
|
*/
|
|
214
841
|
export function buildPredicate(inputs) {
|
|
215
842
|
if (!/^[0-9a-f]{40}$/i.test(inputs.commitSha)) {
|
|
216
843
|
throw new Error(`buildPredicate: commitSha must be a 40-char hex SHA-1, got ${inputs.commitSha}`);
|
|
217
844
|
}
|
|
218
|
-
|
|
219
|
-
|
|
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',
|
|
220
874
|
subject: { digest: { sha1: inputs.commitSha.toLowerCase() } },
|
|
221
|
-
|
|
875
|
+
contentHashV3: computeContentHashV3(inputs.changedFileDeltas),
|
|
876
|
+
contentHashV4: computeContentHashV4(headEntries),
|
|
222
877
|
policyHash: sha256Hex(inputs.policy),
|
|
223
878
|
reviewers: inputs.reviewers.map((r) => ({
|
|
224
879
|
agentId: r.agentId,
|
|
@@ -232,6 +887,23 @@ export function buildPredicate(inputs) {
|
|
|
232
887
|
harnessNote: inputs.harnessNote,
|
|
233
888
|
signedAt: inputs.signedAt ?? new Date().toISOString(),
|
|
234
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;
|
|
235
907
|
}
|
|
236
908
|
/**
|
|
237
909
|
* DSSE Pre-Authentication Encoding. Per the spec
|
|
@@ -379,8 +1051,47 @@ export function verifyAttestation(opts) {
|
|
|
379
1051
|
reason: `subject digest mismatch (envelope was signed for a different commit)`,
|
|
380
1052
|
};
|
|
381
1053
|
}
|
|
382
|
-
|
|
383
|
-
|
|
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
|
+
}
|
|
384
1095
|
}
|
|
385
1096
|
if (predicate.policyHash !== opts.expected.policyHash) {
|
|
386
1097
|
return {
|
|
@@ -397,20 +1108,44 @@ export function verifyAttestation(opts) {
|
|
|
397
1108
|
};
|
|
398
1109
|
}
|
|
399
1110
|
}
|
|
400
|
-
// ── Reviewer-set completeness
|
|
401
|
-
// Every attestation MUST cover all three required
|
|
402
|
-
// test, security).
|
|
403
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
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) {
|
|
408
1121
|
return {
|
|
409
1122
|
valid: false,
|
|
410
|
-
reason: `reviewer set incomplete: missing required reviewer '${
|
|
1123
|
+
reason: `reviewer set incomplete: missing required reviewer '${role}' (or any variant: ${variants.join(', ')})`,
|
|
411
1124
|
};
|
|
412
1125
|
}
|
|
413
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
|
+
}
|
|
414
1149
|
return { valid: true, predicate, trustedReviewer: matchedReviewer };
|
|
415
1150
|
}
|
|
416
1151
|
/**
|