pumuki 6.3.26 → 6.3.28
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/README.md +3 -1
- package/bin/pumuki-mcp-enterprise-stdio.js +5 -0
- package/bin/pumuki-mcp-evidence-stdio.js +5 -0
- package/core/gate/conditionMatches.ts +1 -21
- package/core/gate/evaluateGate.js +5 -0
- package/core/gate/evaluateRules.js +5 -0
- package/core/gate/evaluateRules.ts +1 -24
- package/core/gate/scopeMatcher.ts +84 -0
- package/docs/EXECUTION_BOARD.md +749 -376
- package/docs/MCP_SERVERS.md +41 -2
- package/docs/README.md +6 -2
- package/docs/REFRACTOR_PROGRESS.md +374 -6
- package/docs/validation/README.md +11 -1
- package/docs/validation/p9-ruralgo-bug-registry.md +607 -0
- package/docs/validation/p9-ruralgo-fork-validation-tracking.md +904 -0
- package/docs/validation/real-repo-manual-e2e-ruralgo-fork.md +372 -0
- package/integrations/config/skillsCompliance.ts +212 -0
- package/integrations/evidence/integrity.ts +352 -0
- package/integrations/evidence/rulesCoverage.ts +94 -0
- package/integrations/evidence/schema.test.ts +16 -0
- package/integrations/evidence/schema.ts +41 -0
- package/integrations/evidence/writeEvidence.test.ts +68 -0
- package/integrations/evidence/writeEvidence.ts +23 -2
- package/integrations/gate/evaluateAiGate.ts +382 -15
- package/integrations/gate/stagePolicies.ts +70 -15
- package/integrations/gate/waivers.ts +209 -0
- package/integrations/git/findingTraceability.ts +3 -23
- package/integrations/git/index.js +5 -0
- package/integrations/git/runCliCommand.ts +16 -0
- package/integrations/git/runPlatformGate.ts +53 -1
- package/integrations/git/runPlatformGateEvaluation.ts +13 -0
- package/integrations/git/stageRunners.ts +168 -5
- package/integrations/lifecycle/adapter.templates.json +72 -5
- package/integrations/lifecycle/adapter.ts +78 -4
- package/integrations/lifecycle/cli.ts +384 -14
- package/integrations/lifecycle/doctor.ts +534 -0
- package/integrations/lifecycle/hookBlock.ts +2 -1
- package/integrations/lifecycle/index.js +5 -0
- package/integrations/lifecycle/install.ts +115 -3
- package/integrations/lifecycle/openSpecBootstrap.ts +68 -8
- package/integrations/lifecycle/preWriteAutomation.ts +142 -0
- package/integrations/mcp/aiGateCheck.ts +6 -0
- package/integrations/mcp/aiGateReceipt.ts +188 -0
- package/integrations/mcp/enterpriseServer.ts +14 -1
- package/integrations/mcp/enterpriseStdioServer.cli.ts +315 -0
- package/integrations/mcp/evidenceStdioServer.cli.ts +342 -0
- package/integrations/mcp/index.js +5 -0
- package/integrations/sdd/index.js +5 -0
- package/integrations/sdd/index.ts +2 -0
- package/integrations/sdd/policy.ts +191 -2
- package/integrations/sdd/sessionStore.ts +139 -19
- package/integrations/sdd/syncDocs.ts +180 -0
- package/integrations/sdd/types.ts +4 -1
- package/integrations/telemetry/structuredTelemetry.ts +197 -0
- package/package.json +27 -8
- package/scripts/build-p9-validation-manifests.ts +53 -0
- package/scripts/check-p9-ruralgo-baseline-clean.ts +200 -0
- package/scripts/check-p9-ruralgo-baseline-versioned.ts +198 -0
- package/scripts/check-p9-ruralgo-branch-ready.ts +215 -0
- package/scripts/check-p9-ruralgo-install-health.ts +288 -0
- package/scripts/check-p9-ruralgo-runtime-ready.ts +188 -0
- package/scripts/check-package-manifest.ts +49 -0
- package/scripts/check-tracking-single-active.sh +40 -0
- package/scripts/framework-menu-consumer-preflight-lib.ts +31 -0
- package/scripts/framework-menu-consumer-runtime-lib.ts +3 -3
- package/scripts/framework-menu-legacy-audit-lib.ts +35 -7
- package/scripts/framework-menu-matrix-evidence-lib.ts +6 -2
- package/scripts/manage-library.sh +1 -1
- package/scripts/p9-ruralgo-baseline-clean-lib.ts +117 -0
- package/scripts/p9-ruralgo-baseline-versioned-lib.ts +119 -0
- package/scripts/p9-ruralgo-branch-ready-lib.ts +128 -0
- package/scripts/p9-ruralgo-install-health-lib.ts +121 -0
- package/scripts/p9-ruralgo-runtime-ready-lib.ts +149 -0
- package/scripts/p9-validation-manifests-lib.ts +366 -0
- package/scripts/package-manifest-lib.ts +9 -0
- package/skills.lock.json +1 -1
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import type { AiEvidenceV2_1, EvidenceIntegrity } from './schema';
|
|
3
|
+
|
|
4
|
+
const SHA256_HEX_PATTERN = /^[A-Fa-f0-9]{64}$/;
|
|
5
|
+
const INTEGRITY_SCHEMA = 'pumuki-evidence-integrity-v1' as const;
|
|
6
|
+
const HASH_ALGORITHM = 'SHA256' as const;
|
|
7
|
+
const SIGNATURE_ALGORITHM = 'HMAC-SHA256' as const;
|
|
8
|
+
const CANONICALIZATION = 'json-stable-v1' as const;
|
|
9
|
+
const GENESIS_PREVIOUS_CHAIN_HASH = 'GENESIS';
|
|
10
|
+
|
|
11
|
+
export type EvidenceSigningConfig = {
|
|
12
|
+
key: string;
|
|
13
|
+
keyId: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type EvidenceIntegrityVerification = {
|
|
17
|
+
ok: boolean;
|
|
18
|
+
status: 'valid' | 'missing' | 'invalid';
|
|
19
|
+
code: string | null;
|
|
20
|
+
message: string | null;
|
|
21
|
+
payloadHash: string | null;
|
|
22
|
+
previousChainHash: string | null;
|
|
23
|
+
chainHash: string | null;
|
|
24
|
+
signature: {
|
|
25
|
+
present: boolean;
|
|
26
|
+
keyId: string | null;
|
|
27
|
+
verified: boolean | null;
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const toCanonicalJsonValue = (value: unknown): unknown => {
|
|
32
|
+
if (Array.isArray(value)) {
|
|
33
|
+
return value.map((item) => toCanonicalJsonValue(item));
|
|
34
|
+
}
|
|
35
|
+
if (value && typeof value === 'object') {
|
|
36
|
+
const record = value as Record<string, unknown>;
|
|
37
|
+
const ordered: Record<string, unknown> = {};
|
|
38
|
+
for (const key of Object.keys(record).sort((left, right) => left.localeCompare(right))) {
|
|
39
|
+
ordered[key] = toCanonicalJsonValue(record[key]);
|
|
40
|
+
}
|
|
41
|
+
return ordered;
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const toCanonicalJsonString = (value: unknown): string =>
|
|
47
|
+
JSON.stringify(toCanonicalJsonValue(value));
|
|
48
|
+
|
|
49
|
+
const toSha256Hex = (value: string): string =>
|
|
50
|
+
createHash('sha256').update(value).digest('hex');
|
|
51
|
+
|
|
52
|
+
const toIntegritySignatureValue = (params: {
|
|
53
|
+
chainHash: string;
|
|
54
|
+
payloadHash: string;
|
|
55
|
+
timestamp: string;
|
|
56
|
+
key: string;
|
|
57
|
+
}): string =>
|
|
58
|
+
createHmac('sha256', params.key)
|
|
59
|
+
.update(`${params.chainHash}:${params.payloadHash}:${params.timestamp}`)
|
|
60
|
+
.digest('hex');
|
|
61
|
+
|
|
62
|
+
const cloneWithoutIntegrity = (evidence: AiEvidenceV2_1): Omit<AiEvidenceV2_1, 'integrity'> => {
|
|
63
|
+
const { integrity: _integrity, ...withoutIntegrity } = evidence;
|
|
64
|
+
return withoutIntegrity;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const toSignedIntegrity = (
|
|
68
|
+
base: Omit<EvidenceIntegrity, 'signature'>,
|
|
69
|
+
signatureConfig: EvidenceSigningConfig | null,
|
|
70
|
+
timestamp: string
|
|
71
|
+
): EvidenceIntegrity => {
|
|
72
|
+
if (!signatureConfig) {
|
|
73
|
+
return base;
|
|
74
|
+
}
|
|
75
|
+
return {
|
|
76
|
+
...base,
|
|
77
|
+
signature: {
|
|
78
|
+
algorithm: SIGNATURE_ALGORITHM,
|
|
79
|
+
key_id: signatureConfig.keyId,
|
|
80
|
+
value: toIntegritySignatureValue({
|
|
81
|
+
chainHash: base.chain_hash,
|
|
82
|
+
payloadHash: base.payload_hash,
|
|
83
|
+
timestamp,
|
|
84
|
+
key: signatureConfig.key,
|
|
85
|
+
}),
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const resolveEvidenceSigningConfig = (
|
|
91
|
+
env: NodeJS.ProcessEnv = process.env
|
|
92
|
+
): EvidenceSigningConfig | null => {
|
|
93
|
+
const key = env.PUMUKI_EVIDENCE_SIGNING_KEY?.trim();
|
|
94
|
+
if (!key) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
const keyId = env.PUMUKI_EVIDENCE_SIGNING_KEY_ID?.trim() || 'local';
|
|
98
|
+
return {
|
|
99
|
+
key,
|
|
100
|
+
keyId,
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export const applyEvidenceIntegrity = (params: {
|
|
105
|
+
evidence: AiEvidenceV2_1;
|
|
106
|
+
previousChainHash?: string | null;
|
|
107
|
+
signatureConfig?: EvidenceSigningConfig | null;
|
|
108
|
+
}): AiEvidenceV2_1 => {
|
|
109
|
+
const withoutIntegrity = cloneWithoutIntegrity(params.evidence);
|
|
110
|
+
const payloadHash = toSha256Hex(toCanonicalJsonString(withoutIntegrity));
|
|
111
|
+
const previousChainHash = params.previousChainHash ?? null;
|
|
112
|
+
const chainHash = toSha256Hex(`${previousChainHash ?? GENESIS_PREVIOUS_CHAIN_HASH}:${payloadHash}`);
|
|
113
|
+
const integrity: Omit<EvidenceIntegrity, 'signature'> = {
|
|
114
|
+
schema: INTEGRITY_SCHEMA,
|
|
115
|
+
hash_algorithm: HASH_ALGORITHM,
|
|
116
|
+
canonicalization: CANONICALIZATION,
|
|
117
|
+
payload_hash: payloadHash,
|
|
118
|
+
previous_chain_hash: previousChainHash,
|
|
119
|
+
chain_hash: chainHash,
|
|
120
|
+
generated_at: withoutIntegrity.timestamp,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
...withoutIntegrity,
|
|
125
|
+
integrity: toSignedIntegrity(integrity, params.signatureConfig ?? null, withoutIntegrity.timestamp),
|
|
126
|
+
};
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const buildIntegrityInvalidResult = (params: {
|
|
130
|
+
code: string;
|
|
131
|
+
message: string;
|
|
132
|
+
integrity?: EvidenceIntegrity;
|
|
133
|
+
signaturePresent?: boolean;
|
|
134
|
+
signatureKeyId?: string | null;
|
|
135
|
+
signatureVerified?: boolean | null;
|
|
136
|
+
}): EvidenceIntegrityVerification => ({
|
|
137
|
+
ok: false,
|
|
138
|
+
status: 'invalid',
|
|
139
|
+
code: params.code,
|
|
140
|
+
message: params.message,
|
|
141
|
+
payloadHash: params.integrity?.payload_hash ?? null,
|
|
142
|
+
previousChainHash: params.integrity?.previous_chain_hash ?? null,
|
|
143
|
+
chainHash: params.integrity?.chain_hash ?? null,
|
|
144
|
+
signature: {
|
|
145
|
+
present: params.signaturePresent ?? false,
|
|
146
|
+
keyId: params.signatureKeyId ?? null,
|
|
147
|
+
verified: params.signatureVerified ?? null,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
export const verifyEvidenceIntegrity = (
|
|
152
|
+
evidence: AiEvidenceV2_1,
|
|
153
|
+
options?: {
|
|
154
|
+
signatureConfig?: EvidenceSigningConfig | null;
|
|
155
|
+
enforceSignature?: boolean;
|
|
156
|
+
}
|
|
157
|
+
): EvidenceIntegrityVerification => {
|
|
158
|
+
const integrity = evidence.integrity;
|
|
159
|
+
if (!integrity) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
status: 'missing',
|
|
163
|
+
code: 'EVIDENCE_INTEGRITY_MISSING',
|
|
164
|
+
message: 'Evidence integrity metadata is missing.',
|
|
165
|
+
payloadHash: null,
|
|
166
|
+
previousChainHash: null,
|
|
167
|
+
chainHash: null,
|
|
168
|
+
signature: {
|
|
169
|
+
present: false,
|
|
170
|
+
keyId: null,
|
|
171
|
+
verified: null,
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (
|
|
177
|
+
integrity.schema !== INTEGRITY_SCHEMA ||
|
|
178
|
+
integrity.hash_algorithm !== HASH_ALGORITHM ||
|
|
179
|
+
integrity.canonicalization !== CANONICALIZATION
|
|
180
|
+
) {
|
|
181
|
+
return buildIntegrityInvalidResult({
|
|
182
|
+
code: 'EVIDENCE_INTEGRITY_SCHEMA_INVALID',
|
|
183
|
+
message: 'Evidence integrity schema metadata is invalid.',
|
|
184
|
+
integrity,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (
|
|
189
|
+
!SHA256_HEX_PATTERN.test(integrity.payload_hash) ||
|
|
190
|
+
!SHA256_HEX_PATTERN.test(integrity.chain_hash)
|
|
191
|
+
) {
|
|
192
|
+
return buildIntegrityInvalidResult({
|
|
193
|
+
code: 'EVIDENCE_INTEGRITY_HASH_FORMAT_INVALID',
|
|
194
|
+
message: 'Evidence integrity hash format is invalid.',
|
|
195
|
+
integrity,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (
|
|
200
|
+
integrity.previous_chain_hash !== null &&
|
|
201
|
+
!SHA256_HEX_PATTERN.test(integrity.previous_chain_hash)
|
|
202
|
+
) {
|
|
203
|
+
return buildIntegrityInvalidResult({
|
|
204
|
+
code: 'EVIDENCE_INTEGRITY_PREVIOUS_CHAIN_HASH_INVALID',
|
|
205
|
+
message: 'Evidence integrity previous chain hash format is invalid.',
|
|
206
|
+
integrity,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (integrity.generated_at !== evidence.timestamp) {
|
|
211
|
+
return buildIntegrityInvalidResult({
|
|
212
|
+
code: 'EVIDENCE_INTEGRITY_TIMESTAMP_MISMATCH',
|
|
213
|
+
message: 'Evidence integrity timestamp does not match evidence timestamp.',
|
|
214
|
+
integrity,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const withoutIntegrity = cloneWithoutIntegrity(evidence);
|
|
219
|
+
const expectedPayloadHash = toSha256Hex(toCanonicalJsonString(withoutIntegrity));
|
|
220
|
+
if (integrity.payload_hash !== expectedPayloadHash) {
|
|
221
|
+
return buildIntegrityInvalidResult({
|
|
222
|
+
code: 'EVIDENCE_INTEGRITY_PAYLOAD_HASH_MISMATCH',
|
|
223
|
+
message: 'Evidence integrity payload hash mismatch.',
|
|
224
|
+
integrity,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const expectedChainHash = toSha256Hex(
|
|
229
|
+
`${integrity.previous_chain_hash ?? GENESIS_PREVIOUS_CHAIN_HASH}:${expectedPayloadHash}`
|
|
230
|
+
);
|
|
231
|
+
if (integrity.chain_hash !== expectedChainHash) {
|
|
232
|
+
return buildIntegrityInvalidResult({
|
|
233
|
+
code: 'EVIDENCE_INTEGRITY_CHAIN_HASH_MISMATCH',
|
|
234
|
+
message: 'Evidence integrity chain hash mismatch.',
|
|
235
|
+
integrity,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const signature = integrity.signature;
|
|
240
|
+
const signatureConfig = options?.signatureConfig ?? null;
|
|
241
|
+
if (!signature) {
|
|
242
|
+
if (options?.enforceSignature && signatureConfig) {
|
|
243
|
+
return buildIntegrityInvalidResult({
|
|
244
|
+
code: 'EVIDENCE_INTEGRITY_SIGNATURE_REQUIRED',
|
|
245
|
+
message: 'Evidence integrity signature is required but missing.',
|
|
246
|
+
integrity,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
ok: true,
|
|
251
|
+
status: 'valid',
|
|
252
|
+
code: null,
|
|
253
|
+
message: null,
|
|
254
|
+
payloadHash: integrity.payload_hash,
|
|
255
|
+
previousChainHash: integrity.previous_chain_hash,
|
|
256
|
+
chainHash: integrity.chain_hash,
|
|
257
|
+
signature: {
|
|
258
|
+
present: false,
|
|
259
|
+
keyId: null,
|
|
260
|
+
verified: null,
|
|
261
|
+
},
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (
|
|
266
|
+
signature.algorithm !== SIGNATURE_ALGORITHM ||
|
|
267
|
+
!SHA256_HEX_PATTERN.test(signature.value) ||
|
|
268
|
+
typeof signature.key_id !== 'string' ||
|
|
269
|
+
signature.key_id.trim().length === 0
|
|
270
|
+
) {
|
|
271
|
+
return buildIntegrityInvalidResult({
|
|
272
|
+
code: 'EVIDENCE_INTEGRITY_SIGNATURE_FORMAT_INVALID',
|
|
273
|
+
message: 'Evidence integrity signature metadata is invalid.',
|
|
274
|
+
integrity,
|
|
275
|
+
signaturePresent: true,
|
|
276
|
+
signatureKeyId: signature.key_id ?? null,
|
|
277
|
+
signatureVerified: false,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (!signatureConfig) {
|
|
282
|
+
if (options?.enforceSignature) {
|
|
283
|
+
return buildIntegrityInvalidResult({
|
|
284
|
+
code: 'EVIDENCE_INTEGRITY_SIGNATURE_KEY_MISSING',
|
|
285
|
+
message: 'Evidence signature present but signing key is missing locally.',
|
|
286
|
+
integrity,
|
|
287
|
+
signaturePresent: true,
|
|
288
|
+
signatureKeyId: signature.key_id,
|
|
289
|
+
signatureVerified: null,
|
|
290
|
+
});
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
ok: true,
|
|
294
|
+
status: 'valid',
|
|
295
|
+
code: null,
|
|
296
|
+
message: null,
|
|
297
|
+
payloadHash: integrity.payload_hash,
|
|
298
|
+
previousChainHash: integrity.previous_chain_hash,
|
|
299
|
+
chainHash: integrity.chain_hash,
|
|
300
|
+
signature: {
|
|
301
|
+
present: true,
|
|
302
|
+
keyId: signature.key_id,
|
|
303
|
+
verified: null,
|
|
304
|
+
},
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (signature.key_id !== signatureConfig.keyId) {
|
|
309
|
+
return buildIntegrityInvalidResult({
|
|
310
|
+
code: 'EVIDENCE_INTEGRITY_SIGNATURE_KEY_MISMATCH',
|
|
311
|
+
message: `Evidence signature key_id mismatch (${signature.key_id} != ${signatureConfig.keyId}).`,
|
|
312
|
+
integrity,
|
|
313
|
+
signaturePresent: true,
|
|
314
|
+
signatureKeyId: signature.key_id,
|
|
315
|
+
signatureVerified: false,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const expectedSignature = toIntegritySignatureValue({
|
|
320
|
+
chainHash: integrity.chain_hash,
|
|
321
|
+
payloadHash: integrity.payload_hash,
|
|
322
|
+
timestamp: evidence.timestamp,
|
|
323
|
+
key: signatureConfig.key,
|
|
324
|
+
});
|
|
325
|
+
const provided = Buffer.from(signature.value, 'hex');
|
|
326
|
+
const expected = Buffer.from(expectedSignature, 'hex');
|
|
327
|
+
if (provided.length !== expected.length || !timingSafeEqual(provided, expected)) {
|
|
328
|
+
return buildIntegrityInvalidResult({
|
|
329
|
+
code: 'EVIDENCE_INTEGRITY_SIGNATURE_INVALID',
|
|
330
|
+
message: 'Evidence integrity signature mismatch.',
|
|
331
|
+
integrity,
|
|
332
|
+
signaturePresent: true,
|
|
333
|
+
signatureKeyId: signature.key_id,
|
|
334
|
+
signatureVerified: false,
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
ok: true,
|
|
340
|
+
status: 'valid',
|
|
341
|
+
code: null,
|
|
342
|
+
message: null,
|
|
343
|
+
payloadHash: integrity.payload_hash,
|
|
344
|
+
previousChainHash: integrity.previous_chain_hash,
|
|
345
|
+
chainHash: integrity.chain_hash,
|
|
346
|
+
signature: {
|
|
347
|
+
present: true,
|
|
348
|
+
keyId: signature.key_id,
|
|
349
|
+
verified: true,
|
|
350
|
+
},
|
|
351
|
+
};
|
|
352
|
+
};
|
|
@@ -30,6 +30,95 @@ const createCoverageRatio = (active: number, evaluated: number): number => {
|
|
|
30
30
|
return normalizeCoverageRatio(evaluated / active);
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
const normalizeSkillsCompliance = (
|
|
34
|
+
value: SnapshotRulesCoverage['skills_compliance']
|
|
35
|
+
): SnapshotRulesCoverage['skills_compliance'] => {
|
|
36
|
+
if (!value) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const normalizedByFile = value.by_file
|
|
41
|
+
.map((item) => {
|
|
42
|
+
const requiredRuleIds = normalizeStringArray(item.required_rule_ids ?? []);
|
|
43
|
+
const appliedRuleIds = normalizeStringArray(item.applied_rule_ids ?? []);
|
|
44
|
+
const evidenceRuleIds = normalizeStringArray(item.evidence_rule_ids ?? []);
|
|
45
|
+
const missingRuleIds = normalizeStringArray(item.missing_rule_ids ?? []);
|
|
46
|
+
if (requiredRuleIds.length === 0) {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
return {
|
|
50
|
+
file_path: item.file_path,
|
|
51
|
+
platform: item.platform,
|
|
52
|
+
required_rule_ids: requiredRuleIds,
|
|
53
|
+
applied_rule_ids: appliedRuleIds,
|
|
54
|
+
evidence_rule_ids: evidenceRuleIds,
|
|
55
|
+
missing_rule_ids: missingRuleIds,
|
|
56
|
+
status: missingRuleIds.length > 0 ? 'INCOMPLETE' : 'OK',
|
|
57
|
+
};
|
|
58
|
+
})
|
|
59
|
+
.filter(
|
|
60
|
+
(
|
|
61
|
+
item
|
|
62
|
+
): item is {
|
|
63
|
+
file_path: string;
|
|
64
|
+
platform: 'ios' | 'android' | 'backend' | 'frontend';
|
|
65
|
+
required_rule_ids: string[];
|
|
66
|
+
applied_rule_ids: string[];
|
|
67
|
+
evidence_rule_ids: string[];
|
|
68
|
+
missing_rule_ids: string[];
|
|
69
|
+
status: 'OK' | 'INCOMPLETE';
|
|
70
|
+
} => item !== undefined
|
|
71
|
+
)
|
|
72
|
+
.sort((left, right) => left.file_path.localeCompare(right.file_path));
|
|
73
|
+
|
|
74
|
+
if (normalizedByFile.length === 0) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const requiredRuleIds = normalizeStringArray(
|
|
79
|
+
value.required_rule_ids?.length > 0
|
|
80
|
+
? value.required_rule_ids
|
|
81
|
+
: normalizedByFile.flatMap((item) => item.required_rule_ids)
|
|
82
|
+
);
|
|
83
|
+
const appliedRuleIds = normalizeStringArray(
|
|
84
|
+
value.applied_rule_ids?.length > 0
|
|
85
|
+
? value.applied_rule_ids
|
|
86
|
+
: normalizedByFile.flatMap((item) => item.applied_rule_ids)
|
|
87
|
+
);
|
|
88
|
+
const evidenceRuleIds = normalizeStringArray(
|
|
89
|
+
value.evidence_rule_ids?.length > 0
|
|
90
|
+
? value.evidence_rule_ids
|
|
91
|
+
: normalizedByFile.flatMap((item) => item.evidence_rule_ids)
|
|
92
|
+
);
|
|
93
|
+
const missingRuleIds = normalizeStringArray(
|
|
94
|
+
value.missing_rule_ids?.length > 0
|
|
95
|
+
? value.missing_rule_ids
|
|
96
|
+
: normalizedByFile.flatMap((item) => item.missing_rule_ids)
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
return {
|
|
100
|
+
required_rule_ids: requiredRuleIds,
|
|
101
|
+
applied_rule_ids: appliedRuleIds,
|
|
102
|
+
evidence_rule_ids: evidenceRuleIds,
|
|
103
|
+
missing_rule_ids: missingRuleIds,
|
|
104
|
+
counts: {
|
|
105
|
+
files_in_scope: Math.max(
|
|
106
|
+
normalizedByFile.length,
|
|
107
|
+
normalizeCount(value.counts?.files_in_scope ?? 0)
|
|
108
|
+
),
|
|
109
|
+
files_with_missing: Math.max(
|
|
110
|
+
normalizedByFile.filter((item) => item.missing_rule_ids.length > 0).length,
|
|
111
|
+
normalizeCount(value.counts?.files_with_missing ?? 0)
|
|
112
|
+
),
|
|
113
|
+
required: Math.max(requiredRuleIds.length, normalizeCount(value.counts?.required ?? 0)),
|
|
114
|
+
applied: Math.max(appliedRuleIds.length, normalizeCount(value.counts?.applied ?? 0)),
|
|
115
|
+
evidence: Math.max(evidenceRuleIds.length, normalizeCount(value.counts?.evidence ?? 0)),
|
|
116
|
+
missing: Math.max(missingRuleIds.length, normalizeCount(value.counts?.missing ?? 0)),
|
|
117
|
+
},
|
|
118
|
+
by_file: normalizedByFile,
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
|
|
33
122
|
export const createEmptySnapshotRulesCoverage = (
|
|
34
123
|
stage: GateStage
|
|
35
124
|
): SnapshotRulesCoverage => ({
|
|
@@ -106,5 +195,10 @@ export const normalizeSnapshotRulesCoverage = (
|
|
|
106
195
|
normalized.unsupported_auto_rule_ids = unsupportedAutoRuleIds;
|
|
107
196
|
}
|
|
108
197
|
|
|
198
|
+
const skillsCompliance = normalizeSkillsCompliance(value.skills_compliance);
|
|
199
|
+
if (skillsCompliance) {
|
|
200
|
+
normalized.skills_compliance = skillsCompliance;
|
|
201
|
+
}
|
|
202
|
+
|
|
109
203
|
return normalized;
|
|
110
204
|
};
|
|
@@ -39,6 +39,20 @@ test('AiEvidenceV2_1 soporta snapshot/ledger/platforms/rulesets con contrato 2.1
|
|
|
39
39
|
const evidence: AiEvidenceV2_1 = {
|
|
40
40
|
version: '2.1',
|
|
41
41
|
timestamp: '2026-02-17T09:00:00.000Z',
|
|
42
|
+
integrity: {
|
|
43
|
+
schema: 'pumuki-evidence-integrity-v1',
|
|
44
|
+
hash_algorithm: 'SHA256',
|
|
45
|
+
canonicalization: 'json-stable-v1',
|
|
46
|
+
payload_hash: 'a'.repeat(64),
|
|
47
|
+
previous_chain_hash: null,
|
|
48
|
+
chain_hash: 'b'.repeat(64),
|
|
49
|
+
generated_at: '2026-02-17T09:00:00.000Z',
|
|
50
|
+
signature: {
|
|
51
|
+
algorithm: 'HMAC-SHA256',
|
|
52
|
+
key_id: 'local',
|
|
53
|
+
value: 'c'.repeat(64),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
42
56
|
snapshot: {
|
|
43
57
|
stage: 'PRE_PUSH',
|
|
44
58
|
outcome: 'BLOCK',
|
|
@@ -133,6 +147,8 @@ test('AiEvidenceV2_1 soporta snapshot/ledger/platforms/rulesets con contrato 2.1
|
|
|
133
147
|
};
|
|
134
148
|
|
|
135
149
|
assert.equal(evidence.version, '2.1');
|
|
150
|
+
assert.equal(evidence.integrity?.schema, 'pumuki-evidence-integrity-v1');
|
|
151
|
+
assert.equal(evidence.integrity?.signature?.algorithm, 'HMAC-SHA256');
|
|
136
152
|
assert.equal(evidence.snapshot.stage, 'PRE_PUSH');
|
|
137
153
|
assert.equal(evidence.snapshot.files_scanned, 911);
|
|
138
154
|
assert.equal(evidence.snapshot.files_affected, 1);
|
|
@@ -40,6 +40,29 @@ export type SnapshotRulesCoverage = {
|
|
|
40
40
|
matched_rule_ids: string[];
|
|
41
41
|
unevaluated_rule_ids: string[];
|
|
42
42
|
unsupported_auto_rule_ids?: string[];
|
|
43
|
+
skills_compliance?: {
|
|
44
|
+
required_rule_ids: string[];
|
|
45
|
+
applied_rule_ids: string[];
|
|
46
|
+
evidence_rule_ids: string[];
|
|
47
|
+
missing_rule_ids: string[];
|
|
48
|
+
counts: {
|
|
49
|
+
files_in_scope: number;
|
|
50
|
+
files_with_missing: number;
|
|
51
|
+
required: number;
|
|
52
|
+
applied: number;
|
|
53
|
+
evidence: number;
|
|
54
|
+
missing: number;
|
|
55
|
+
};
|
|
56
|
+
by_file: Array<{
|
|
57
|
+
file_path: string;
|
|
58
|
+
platform: 'ios' | 'android' | 'backend' | 'frontend';
|
|
59
|
+
required_rule_ids: string[];
|
|
60
|
+
applied_rule_ids: string[];
|
|
61
|
+
evidence_rule_ids: string[];
|
|
62
|
+
missing_rule_ids: string[];
|
|
63
|
+
status: 'OK' | 'INCOMPLETE';
|
|
64
|
+
}>;
|
|
65
|
+
};
|
|
43
66
|
counts: {
|
|
44
67
|
active: number;
|
|
45
68
|
evaluated: number;
|
|
@@ -88,6 +111,23 @@ export type RulesetState = {
|
|
|
88
111
|
hash: string;
|
|
89
112
|
};
|
|
90
113
|
|
|
114
|
+
export type EvidenceIntegritySignature = {
|
|
115
|
+
algorithm: 'HMAC-SHA256';
|
|
116
|
+
key_id: string;
|
|
117
|
+
value: string;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export type EvidenceIntegrity = {
|
|
121
|
+
schema: 'pumuki-evidence-integrity-v1';
|
|
122
|
+
hash_algorithm: 'SHA256';
|
|
123
|
+
canonicalization: 'json-stable-v1';
|
|
124
|
+
payload_hash: string;
|
|
125
|
+
previous_chain_hash: string | null;
|
|
126
|
+
chain_hash: string;
|
|
127
|
+
generated_at: string;
|
|
128
|
+
signature?: EvidenceIntegritySignature;
|
|
129
|
+
};
|
|
130
|
+
|
|
91
131
|
export type HumanIntentConfidence = 'high' | 'medium' | 'low' | 'unset';
|
|
92
132
|
|
|
93
133
|
export type HumanIntentState = {
|
|
@@ -170,6 +210,7 @@ export type RepoState = {
|
|
|
170
210
|
export type AiEvidenceV2_1 = {
|
|
171
211
|
version: '2.1';
|
|
172
212
|
timestamp: string;
|
|
213
|
+
integrity?: EvidenceIntegrity;
|
|
173
214
|
snapshot: Snapshot;
|
|
174
215
|
ledger: LedgerEntry[];
|
|
175
216
|
platforms: Record<string, PlatformState>;
|
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
import { withTempDir } from '../__tests__/helpers/tempDir';
|
|
7
7
|
import type { AiEvidenceV2_1 } from './schema';
|
|
8
|
+
import { verifyEvidenceIntegrity } from './integrity';
|
|
8
9
|
import { writeEvidence } from './writeEvidence';
|
|
9
10
|
|
|
10
11
|
const withCwd = async <T>(cwd: string, callback: () => Promise<T> | T): Promise<T> => {
|
|
@@ -242,6 +243,12 @@ test('writeEvidence escribe archivo estable y normaliza paths/orden/lineas', asy
|
|
|
242
243
|
});
|
|
243
244
|
assert.equal(written.repo_state?.git.branch, 'feature/write-evidence');
|
|
244
245
|
assert.equal(written.repo_state?.lifecycle.hooks.pre_commit, 'managed');
|
|
246
|
+
assert.equal(typeof written.integrity?.schema, 'string');
|
|
247
|
+
assert.match(written.integrity?.payload_hash ?? '', /^[A-Fa-f0-9]{64}$/);
|
|
248
|
+
assert.match(written.integrity?.chain_hash ?? '', /^[A-Fa-f0-9]{64}$/);
|
|
249
|
+
assert.equal(written.integrity?.previous_chain_hash, null);
|
|
250
|
+
const integrityCheck = verifyEvidenceIntegrity(written);
|
|
251
|
+
assert.equal(integrityCheck.ok, true);
|
|
245
252
|
});
|
|
246
253
|
});
|
|
247
254
|
});
|
|
@@ -315,6 +322,67 @@ test('writeEvidence conserva paths externos y elimina lines no finitas', async (
|
|
|
315
322
|
});
|
|
316
323
|
});
|
|
317
324
|
|
|
325
|
+
test('writeEvidence encadena previous_chain_hash entre escrituras consecutivas', async () => {
|
|
326
|
+
await withTempDir('pumuki-write-evidence-chain-', async (tempRoot) => {
|
|
327
|
+
initGitRepo(tempRoot);
|
|
328
|
+
await withCwd(tempRoot, async () => {
|
|
329
|
+
const firstEvidence = sampleEvidence(tempRoot);
|
|
330
|
+
const firstWrite = writeEvidence(firstEvidence);
|
|
331
|
+
assert.equal(firstWrite.ok, true);
|
|
332
|
+
const firstWritten = JSON.parse(readFileSync(firstWrite.path, 'utf8')) as AiEvidenceV2_1;
|
|
333
|
+
const firstChainHash = firstWritten.integrity?.chain_hash ?? null;
|
|
334
|
+
assert.match(firstChainHash ?? '', /^[A-Fa-f0-9]{64}$/);
|
|
335
|
+
|
|
336
|
+
const secondEvidence = sampleEvidence(tempRoot);
|
|
337
|
+
secondEvidence.timestamp = '2026-02-17T00:10:00.000Z';
|
|
338
|
+
secondEvidence.snapshot.findings.push({
|
|
339
|
+
ruleId: 'b.rule',
|
|
340
|
+
severity: 'WARN',
|
|
341
|
+
code: 'B_RULE',
|
|
342
|
+
message: 'b finding',
|
|
343
|
+
file: 'apps/backend/B.ts',
|
|
344
|
+
});
|
|
345
|
+
secondEvidence.severity_metrics.total_violations = 3;
|
|
346
|
+
secondEvidence.severity_metrics.by_severity.WARN = 2;
|
|
347
|
+
|
|
348
|
+
const secondWrite = writeEvidence(secondEvidence);
|
|
349
|
+
assert.equal(secondWrite.ok, true);
|
|
350
|
+
const secondWritten = JSON.parse(readFileSync(secondWrite.path, 'utf8')) as AiEvidenceV2_1;
|
|
351
|
+
assert.equal(secondWritten.integrity?.previous_chain_hash, firstChainHash);
|
|
352
|
+
const secondIntegrityCheck = verifyEvidenceIntegrity(secondWritten);
|
|
353
|
+
assert.equal(secondIntegrityCheck.ok, true);
|
|
354
|
+
});
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test('writeEvidence añade firma opcional cuando hay key de firmado en entorno', async () => {
|
|
359
|
+
await withTempDir('pumuki-write-evidence-signature-', async (tempRoot) => {
|
|
360
|
+
initGitRepo(tempRoot);
|
|
361
|
+
await withCwd(tempRoot, async () => {
|
|
362
|
+
const result = writeEvidence(sampleEvidence(tempRoot), {
|
|
363
|
+
env: {
|
|
364
|
+
...process.env,
|
|
365
|
+
PUMUKI_EVIDENCE_SIGNING_KEY: 'secret-test-key',
|
|
366
|
+
PUMUKI_EVIDENCE_SIGNING_KEY_ID: 'test-key-id',
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
assert.equal(result.ok, true);
|
|
370
|
+
const written = JSON.parse(readFileSync(result.path, 'utf8')) as AiEvidenceV2_1;
|
|
371
|
+
assert.equal(written.integrity?.signature?.key_id, 'test-key-id');
|
|
372
|
+
assert.match(written.integrity?.signature?.value ?? '', /^[A-Fa-f0-9]{64}$/);
|
|
373
|
+
const integrityCheck = verifyEvidenceIntegrity(written, {
|
|
374
|
+
signatureConfig: {
|
|
375
|
+
key: 'secret-test-key',
|
|
376
|
+
keyId: 'test-key-id',
|
|
377
|
+
},
|
|
378
|
+
enforceSignature: false,
|
|
379
|
+
});
|
|
380
|
+
assert.equal(integrityCheck.ok, true);
|
|
381
|
+
assert.equal(integrityCheck.signature.verified, true);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
|
|
318
386
|
test('writeEvidence preserva snapshot.tdd_bdd cuando viene en evidencia', async () => {
|
|
319
387
|
await withTempDir('pumuki-write-evidence-tdd-bdd-', async (tempRoot) => {
|
|
320
388
|
initGitRepo(tempRoot);
|
|
@@ -14,6 +14,12 @@ import { buildSnapshotPlatformSummaries } from './platformSummary';
|
|
|
14
14
|
import { normalizeHumanIntent } from './humanIntent';
|
|
15
15
|
import { normalizeSnapshotEvaluationMetrics } from './evaluationMetrics';
|
|
16
16
|
import { normalizeSnapshotRulesCoverage } from './rulesCoverage';
|
|
17
|
+
import { readEvidenceResult } from './readEvidence';
|
|
18
|
+
import {
|
|
19
|
+
applyEvidenceIntegrity,
|
|
20
|
+
resolveEvidenceSigningConfig,
|
|
21
|
+
verifyEvidenceIntegrity,
|
|
22
|
+
} from './integrity';
|
|
17
23
|
|
|
18
24
|
export type WriteEvidenceResult = {
|
|
19
25
|
ok: boolean;
|
|
@@ -343,14 +349,29 @@ const resolveRepoRoot = (): string => {
|
|
|
343
349
|
|
|
344
350
|
export function writeEvidence(
|
|
345
351
|
evidence: AiEvidenceV2_1,
|
|
346
|
-
options?: { repoRoot?: string }
|
|
352
|
+
options?: { repoRoot?: string; env?: NodeJS.ProcessEnv }
|
|
347
353
|
): WriteEvidenceResult {
|
|
348
354
|
const repoRoot = options?.repoRoot ?? resolveRepoRoot();
|
|
349
355
|
const outputPath = join(repoRoot, EVIDENCE_FILE_NAME);
|
|
350
356
|
|
|
351
357
|
try {
|
|
352
358
|
const stableEvidence = toStableEvidence(evidence, repoRoot);
|
|
353
|
-
|
|
359
|
+
const previousResult = readEvidenceResult(repoRoot);
|
|
360
|
+
const previousChainHash =
|
|
361
|
+
previousResult.kind === 'valid'
|
|
362
|
+
? (() => {
|
|
363
|
+
const verification = verifyEvidenceIntegrity(previousResult.evidence, {
|
|
364
|
+
enforceSignature: false,
|
|
365
|
+
});
|
|
366
|
+
return verification.ok ? verification.chainHash : null;
|
|
367
|
+
})()
|
|
368
|
+
: null;
|
|
369
|
+
const signedEvidence = applyEvidenceIntegrity({
|
|
370
|
+
evidence: stableEvidence,
|
|
371
|
+
previousChainHash,
|
|
372
|
+
signatureConfig: resolveEvidenceSigningConfig(options?.env ?? process.env),
|
|
373
|
+
});
|
|
374
|
+
writeFileSync(outputPath, `${JSON.stringify(signedEvidence, null, 2)}\n`, 'utf8');
|
|
354
375
|
return {
|
|
355
376
|
ok: true,
|
|
356
377
|
path: outputPath,
|