@open-agent-toolkit/cli 0.2.30 → 0.2.31

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 (72) hide show
  1. package/assets/bundle-metadata.json +1 -1
  2. package/assets/docs/cli-utilities/configuration.md +41 -0
  3. package/assets/docs/contributing/code.md +19 -7
  4. package/assets/docs/contributing/explainer-kit-verification.md +9 -1
  5. package/assets/docs/contributing/skills.md +9 -0
  6. package/assets/docs/workflows/skills/explainer-kit-providers.md +16 -5
  7. package/assets/docs/workflows/skills/explainer-kit.md +98 -38
  8. package/assets/public-package-versions.json +4 -4
  9. package/assets/skills/explainer-kit/SKILL.md +33 -8
  10. package/assets/skills/explainer-kit/briefs/project-recap.md +25 -7
  11. package/assets/skills/explainer-kit/recipes/project-recap.v2.json +72 -0
  12. package/assets/skills/explainer-kit/references/contracts.md +49 -17
  13. package/assets/skills/explainer-kit/references/destination-contract.md +141 -25
  14. package/assets/skills/explainer-kit/references/extension-contract.md +19 -10
  15. package/assets/skills/explainer-kit/references/visual-authoring.md +24 -0
  16. package/assets/skills/explainer-kit/references/visual-review.md +19 -5
  17. package/assets/skills/explainer-kit/schemas/author-request.v3.schema.json +241 -0
  18. package/assets/skills/explainer-kit/schemas/publish-receipt.v2.schema.json +215 -0
  19. package/assets/skills/explainer-kit/schemas/publish-request.v2.schema.json +34 -0
  20. package/assets/skills/explainer-kit/schemas/run-request.schema.json +4 -1
  21. package/assets/skills/explainer-kit/schemas/terminal-evidence.v1.schema.json +81 -0
  22. package/assets/skills/explainer-kit/schemas/visual-review-evidence.v1.schema.json +66 -0
  23. package/assets/skills/explainer-kit/scripts/lib/catalog.mjs +109 -3
  24. package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +550 -17
  25. package/assets/skills/explainer-kit/scripts/lib/durability.mjs +90 -8
  26. package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +5 -0
  27. package/assets/skills/explainer-kit/scripts/lib/internal-references.mjs +538 -0
  28. package/assets/skills/explainer-kit/scripts/lib/package-coverage.mjs +129 -11
  29. package/assets/skills/explainer-kit/scripts/lib/publication-policy.mjs +54 -0
  30. package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +2 -1
  31. package/assets/skills/explainer-kit/scripts/lib/records.mjs +139 -22
  32. package/assets/skills/explainer-kit/scripts/lib/s3-roots.mjs +353 -0
  33. package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +237 -107
  34. package/assets/skills/explainer-kit/scripts/lib/set-plan.mjs +1 -0
  35. package/assets/skills/explainer-kit/scripts/lib/terminal-evidence.mjs +157 -0
  36. package/assets/skills/explainer-kit/scripts/lib/visual-review.mjs +26 -6
  37. package/assets/skills/explainer-kit/scripts/run.mjs +1006 -144
  38. package/assets/skills/oat-explainer-kit/SKILL.md +16 -3
  39. package/assets/skills/oat-explainer-kit/references/config-contract.md +13 -8
  40. package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +50 -6
  41. package/assets/skills/oat-explainer-kit/references/migration.md +2 -1
  42. package/assets/skills/oat-explainer-kit/references/visual-review-callback.md +11 -0
  43. package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +37 -15
  44. package/assets/skills/oat-explainer-kit/scripts/check-terminal-outcome.mjs +83 -0
  45. package/assets/skills/oat-explainer-kit/scripts/derive-destination.mjs +91 -0
  46. package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +66 -10
  47. package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +60 -21
  48. package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +52 -8
  49. package/assets/skills/oat-explainer-kit/scripts/run.mjs +271 -36
  50. package/assets/skills/oat-project-autonomous/references/gate-inventory.md +2 -2
  51. package/assets/skills/oat-project-complete/SKILL.md +19 -3
  52. package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +2 -2
  53. package/assets/skills/oat-project-implement/references/completion-and-closeout.md +8 -0
  54. package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +2 -2
  55. package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +2 -2
  56. package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +2 -2
  57. package/dist/commands/config/index.d.ts.map +1 -1
  58. package/dist/commands/config/index.js +18 -0
  59. package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
  60. package/dist/commands/project/archive/archive-utils.js +57 -7
  61. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts +29 -0
  62. package/dist/commands/project/archive/explainer-terminal-evidence.d.ts.map +1 -0
  63. package/dist/commands/project/archive/explainer-terminal-evidence.js +37 -0
  64. package/dist/config/oat-config.d.ts +2 -0
  65. package/dist/config/oat-config.d.ts.map +1 -1
  66. package/dist/config/oat-config.js +4 -0
  67. package/dist/config/resolve.d.ts.map +1 -1
  68. package/dist/config/resolve.js +1 -0
  69. package/package.json +2 -2
  70. /package/assets/skills/explainer-kit/recipes/{project-recap.json → project-recap.v1.json} +0 -0
  71. /package/assets/skills/explainer-kit/schemas/{publish-receipt.schema.json → publish-receipt.v1.schema.json} +0 -0
  72. /package/assets/skills/explainer-kit/schemas/{publish-request.schema.json → publish-request.v1.schema.json} +0 -0
@@ -15,10 +15,25 @@ import { promisify } from 'node:util';
15
15
  import {
16
16
  catalogFromManifest,
17
17
  initiativeCatalogPath,
18
+ resolvePublicVerificationPolicy,
18
19
  serializeInitiativeCatalog,
19
20
  validateInitiativeCatalog,
20
21
  } from './catalog.mjs';
21
22
  import { validateContract } from './contracts.mjs';
23
+ import { assertManifestPublishable } from './publication-policy.mjs';
24
+ import {
25
+ composePublicationTarget,
26
+ describeRootDivergence,
27
+ isPrivatePublicHost,
28
+ normalizePublishRoots,
29
+ privatePublicRootAllowed,
30
+ rootDivergenceWarningSuppressed,
31
+ } from './s3-roots.mjs';
32
+
33
+ export {
34
+ composePublicationTarget,
35
+ normalizePublishRoots,
36
+ } from './s3-roots.mjs';
22
37
 
23
38
  const execFile = promisify(execFileCallback);
24
39
  const SENTINEL_BODY = 'explainer-kit sentinel\n';
@@ -28,54 +43,6 @@ const AUTH_PATTERN =
28
43
  /(?:credential|expired|sso session|access key|invalidclienttokenid|unrecognizedclient|token)/i;
29
44
  const NOT_FOUND_PATTERN = /(?:\b404\b|not found|nosuchkey)/i;
30
45
 
31
- export function normalizePublishRoots(s3Uri, publicBaseUrl) {
32
- if (typeof s3Uri !== 'string' || !s3Uri.startsWith('s3://')) {
33
- throw publishError('E_PUBLISH_ROOTS', 'S3 root must use s3://.');
34
- }
35
- const withoutScheme = s3Uri.slice('s3://'.length).replace(/\/+$/, '');
36
- const slash = withoutScheme.indexOf('/');
37
- const bucket = slash === -1 ? withoutScheme : withoutScheme.slice(0, slash);
38
- const keyPrefix =
39
- slash === -1
40
- ? ''
41
- : withoutScheme
42
- .slice(slash + 1)
43
- .split('/')
44
- .filter(Boolean)
45
- .join('/');
46
- if (!bucket || /\s/.test(bucket)) {
47
- throw publishError('E_PUBLISH_ROOTS', 'S3 root has an invalid bucket.');
48
- }
49
-
50
- let publicUrl;
51
- try {
52
- publicUrl = new URL(publicBaseUrl);
53
- } catch {
54
- throw publishError('E_PUBLISH_ROOTS', 'Public root must be a valid URL.');
55
- }
56
- if (
57
- publicUrl.protocol !== 'https:' ||
58
- publicUrl.username ||
59
- publicUrl.password ||
60
- publicUrl.search ||
61
- publicUrl.hash
62
- ) {
63
- throw publishError(
64
- 'E_PUBLISH_ROOTS',
65
- 'Public root must be credential-free HTTPS without query or fragment.',
66
- );
67
- }
68
- publicUrl.pathname = publicUrl.pathname.replace(/\/+$/, '');
69
- const normalizedPublic = publicUrl.toString().replace(/\/$/, '');
70
- const normalizedS3 = `s3://${bucket}${keyPrefix ? `/${keyPrefix}` : ''}`;
71
- return {
72
- bucket,
73
- keyPrefix,
74
- s3Uri: normalizedS3,
75
- publicBaseUrl: normalizedPublic,
76
- };
77
- }
78
-
79
46
  export function createSentinelRelativePath(
80
47
  runId,
81
48
  randomBytes = nodeRandomBytes,
@@ -115,6 +82,14 @@ export async function publishS3Static(request, dependencies = {}) {
115
82
  const siteRoot = resolve(cwd, request.siteRoot);
116
83
  const manifestPath = resolve(cwd, request.manifestPath);
117
84
  const manifest = await readJson(manifestPath, 'manifest');
85
+ const buildRecord =
86
+ manifest.outcome === 'incomplete'
87
+ ? await readJson(
88
+ resolve(dirname(manifestPath), manifest.buildRecord?.path ?? ''),
89
+ 'build record',
90
+ )
91
+ : undefined;
92
+ assertManifestPublishable(manifest, { buildRecord });
118
93
  const manifestValidation = validateContract('manifest', manifest);
119
94
  if (!manifestValidation.valid) {
120
95
  throw publishError(
@@ -136,12 +111,33 @@ export async function publishS3Static(request, dependencies = {}) {
136
111
  manifest.runId,
137
112
  randomBytes,
138
113
  );
139
- const sentinelTargetPath = targetPath(sentinelRelativePath, roots);
140
- const catalog = catalogFromManifest(manifest, roots.publicBaseUrl);
114
+ const sentinelTargetPath = composePublicationTarget(
115
+ sentinelRelativePath,
116
+ roots,
117
+ );
118
+ // Resolved before the catalog is built: the catalog is hashed and uploaded
119
+ // before any public verification runs, so it must carry the policy up front.
120
+ const publicAccess =
121
+ request.schemaVersion === 'explainer-kit.publish-request/v1'
122
+ ? 'public'
123
+ : request.publicAccess;
124
+ const verificationPolicy = resolvePublicVerificationPolicy(publicAccess);
125
+
126
+ const divergence = describeRootDivergence(roots);
127
+ if (divergence && !rootDivergenceWarningSuppressed()) {
128
+ (dependencies.warn ?? ((message) => console.warn(message)))(
129
+ `explainer-kit: ${divergence}`,
130
+ );
131
+ }
132
+
133
+ const catalog = catalogFromManifest(manifest, roots.publicBaseUrl, {
134
+ publicAccess,
135
+ });
141
136
  const catalogValidation = validateInitiativeCatalog(
142
137
  catalog,
143
138
  manifest,
144
139
  roots.publicBaseUrl,
140
+ { publicAccess },
145
141
  );
146
142
  if (!catalogValidation.valid) {
147
143
  throw publishError(
@@ -164,6 +160,7 @@ export async function publishS3Static(request, dependencies = {}) {
164
160
  const sentinelBodyPath = join(sentinelDirectory, 'sentinel.txt');
165
161
  const catalogBodyPath = join(sentinelDirectory, 'catalog.json');
166
162
  const catalogArtifact = {
163
+ source: { kind: 'auxiliary', name: 'catalog' },
167
164
  manifestPath: catalogManifestPath,
168
165
  publishPath: catalogPublishPath,
169
166
  filePath: catalogBodyPath,
@@ -192,21 +189,41 @@ export async function publishS3Static(request, dependencies = {}) {
192
189
  { sleep },
193
190
  );
194
191
  sentinelUploaded = true;
195
- await runAws(
192
+ const sentinelMetadata = await readObjectMetadata(
196
193
  command,
197
194
  headObjectArgs(roots, sentinelRelativePath, awsOptions),
198
195
  { sleep },
199
196
  );
200
- const sentinelResponse = await httpGet(sentinelTargetPath.publicUrl);
201
- if (
202
- sentinelResponse.status < 200 ||
203
- sentinelResponse.status > 299 ||
204
- !responseBytes(sentinelResponse.body).equals(Buffer.from(SENTINEL_BODY))
205
- ) {
206
- throw publishError(
207
- 'E_PUBLISH_ROOTS',
208
- 'Public root did not serve the uploaded sentinel.',
209
- );
197
+ const sentinelObjectVerification = await verifyObjectBytes({
198
+ command,
199
+ metadata: sentinelMetadata,
200
+ roots,
201
+ relativePath: sentinelRelativePath,
202
+ expectedHash: fileHash(Buffer.from(SENTINEL_BODY)),
203
+ downloadPath: join(sentinelDirectory, 'sentinel-download.txt'),
204
+ awsOptions,
205
+ sleep,
206
+ });
207
+ let sentinelPublicVerification = {
208
+ status: verificationPolicy.receiptSkipStatus,
209
+ };
210
+ if (verificationPolicy.verifyPublicly) {
211
+ const sentinelResponse = await httpGet(sentinelTargetPath.publicUrl);
212
+ if (
213
+ sentinelResponse.status < 200 ||
214
+ sentinelResponse.status > 299 ||
215
+ !responseBytes(sentinelResponse.body).equals(Buffer.from(SENTINEL_BODY))
216
+ ) {
217
+ throw publishError(
218
+ 'E_PUBLISH_ROOTS',
219
+ 'Public root did not serve the uploaded sentinel.',
220
+ );
221
+ }
222
+ sentinelPublicVerification = {
223
+ status: 'verified',
224
+ httpStatus: sentinelResponse.status,
225
+ hash: fileHash(responseBytes(sentinelResponse.body)),
226
+ };
210
227
  }
211
228
  await runAws(
212
229
  command,
@@ -217,7 +234,7 @@ export async function publishS3Static(request, dependencies = {}) {
217
234
 
218
235
  const receiptArtifacts = [];
219
236
  for (const artifact of publicationArtifacts) {
220
- const target = targetPath(artifact.publishPath, roots);
237
+ const target = composePublicationTarget(artifact.publishPath, roots);
221
238
  const metadata = await readExistingMetadata(
222
239
  command,
223
240
  headObjectArgs(roots, artifact.publishPath, awsOptions),
@@ -238,51 +255,88 @@ export async function publishS3Static(request, dependencies = {}) {
238
255
  { sleep },
239
256
  );
240
257
  }
241
- const uploadedMetadata = await runAws(
258
+ const uploadedMetadata = await readObjectMetadata(
242
259
  command,
243
260
  headObjectArgs(roots, artifact.publishPath, awsOptions),
244
261
  { sleep },
245
262
  );
246
- assertMetadata(uploadedMetadata.stdout, artifact);
247
- const response = await httpGet(target.publicUrl);
248
- if (response.status < 200 || response.status > 299) {
249
- throw publishError(
250
- 'E_PUBLISH_VERIFY',
251
- `Public verification failed for ${artifact.manifestPath}.`,
252
- );
253
- }
254
- const servedType = headerValue(response.headers, 'content-type');
255
- if (!contentTypesMatch(servedType, artifact.contentType)) {
256
- throw publishError(
257
- 'E_PUBLISH_VERIFY',
258
- `Public content type mismatch for ${artifact.manifestPath}.`,
259
- );
260
- }
261
- if (fileHash(responseBytes(response.body)) !== artifact.hash) {
262
- throw publishError(
263
- 'E_PUBLISH_VERIFY',
264
- `Public bytes do not match the manifest for ${artifact.manifestPath}.`,
265
- );
263
+ assertMetadata(uploadedMetadata, artifact);
264
+ const objectVerification = await verifyObjectBytes({
265
+ command,
266
+ metadata: uploadedMetadata,
267
+ roots,
268
+ relativePath: artifact.publishPath,
269
+ expectedHash: artifact.hash,
270
+ downloadPath: join(
271
+ sentinelDirectory,
272
+ `object-${receiptArtifacts.length}.download`,
273
+ ),
274
+ awsOptions,
275
+ sleep,
276
+ });
277
+ let publicVerification = {
278
+ status: verificationPolicy.receiptSkipStatus,
279
+ };
280
+ if (verificationPolicy.verifyPublicly) {
281
+ const response = await httpGet(target.publicUrl);
282
+ if (response.status < 200 || response.status > 299) {
283
+ throw publishError(
284
+ 'E_PUBLISH_VERIFY',
285
+ `Public verification failed for ${artifact.manifestPath}.`,
286
+ );
287
+ }
288
+ const servedType = headerValue(response.headers, 'content-type');
289
+ if (!contentTypesMatch(servedType, artifact.contentType)) {
290
+ throw publishError(
291
+ 'E_PUBLISH_VERIFY',
292
+ `Public content type mismatch for ${artifact.manifestPath}.`,
293
+ );
294
+ }
295
+ const publicHash = fileHash(responseBytes(response.body));
296
+ if (publicHash !== artifact.hash) {
297
+ throw publishError(
298
+ 'E_PUBLISH_VERIFY',
299
+ `Public bytes do not match the manifest for ${artifact.manifestPath}.`,
300
+ );
301
+ }
302
+ publicVerification = {
303
+ status: 'verified',
304
+ httpStatus: response.status,
305
+ hash: publicHash,
306
+ };
266
307
  }
267
308
  receiptArtifacts.push({
309
+ source: artifact.source,
268
310
  relativePath: artifact.manifestPath,
269
311
  hash: artifact.hash,
270
312
  s3Uri: target.s3Uri,
271
313
  publicUrl: target.publicUrl,
272
- httpStatus: response.status,
273
314
  contentType: artifact.contentType,
315
+ objectVerification,
316
+ publicVerification,
274
317
  });
275
318
  }
276
319
 
277
320
  const receipt = {
278
- schemaVersion: 'explainer-kit.publish-receipt/v1',
321
+ schemaVersion: 'explainer-kit.publish-receipt/v2',
279
322
  provider: 's3-static',
280
323
  publishedAt: now(),
324
+ publicAccess,
325
+ // Durable trace for the anti-SSRF opt-in. Without it, a run that
326
+ // published to an internal address with the control disabled is
327
+ // indistinguishable in retained evidence from one that did not, because
328
+ // the variable is read straight from the environment and enters no other
329
+ // record. Emitted only in that exceptional case, so ordinary receipts are
330
+ // byte-identical to before.
331
+ ...(privatePublicRootAllowed() &&
332
+ isPrivatePublicHost(new URL(roots.publicBaseUrl).hostname) && {
333
+ publicRootPolicy: 'private-allowed',
334
+ }),
281
335
  roots: { s3Uri: roots.s3Uri, publicBaseUrl: roots.publicBaseUrl },
282
336
  sentinel: {
283
337
  relativePath: sentinelRelativePath,
284
- uploadVerified: true,
285
- publicVerified: true,
338
+ objectVerification: sentinelObjectVerification,
339
+ publicVerification: sentinelPublicVerification,
286
340
  deleted: sentinelDeleted,
287
341
  },
288
342
  artifacts: receiptArtifacts,
@@ -375,6 +429,7 @@ async function collectArtifacts(manifest, siteRoot) {
375
429
  }
376
430
  const contentType = contentTypeFor(publishPath, entry.mediaType);
377
431
  artifacts.push({
432
+ source: { kind: 'manifest', artifactId: entry.id },
378
433
  manifestPath: entry.renderedPath,
379
434
  publishPath,
380
435
  filePath,
@@ -411,6 +466,8 @@ function putObjectArgs({
411
466
  cacheControl,
412
467
  '--metadata',
413
468
  `explainer-sha256=${hash.slice('sha256:'.length)}`,
469
+ '--checksum-sha256',
470
+ Buffer.from(hash.slice('sha256:'.length), 'hex').toString('base64'),
414
471
  '--no-cli-pager',
415
472
  ],
416
473
  awsOptions,
@@ -426,6 +483,8 @@ function headObjectArgs(roots, relativePath, awsOptions) {
426
483
  roots.bucket,
427
484
  '--key',
428
485
  objectKey(roots, relativePath),
486
+ '--checksum-mode',
487
+ 'ENABLED',
429
488
  '--output',
430
489
  'json',
431
490
  '--no-cli-pager',
@@ -434,6 +493,24 @@ function headObjectArgs(roots, relativePath, awsOptions) {
434
493
  );
435
494
  }
436
495
 
496
+ function getObjectArgs(roots, relativePath, downloadPath, awsOptions) {
497
+ return withAwsOptions(
498
+ [
499
+ 's3api',
500
+ 'get-object',
501
+ '--bucket',
502
+ roots.bucket,
503
+ '--key',
504
+ objectKey(roots, relativePath),
505
+ '--checksum-mode',
506
+ 'ENABLED',
507
+ downloadPath,
508
+ '--no-cli-pager',
509
+ ],
510
+ awsOptions,
511
+ );
512
+ }
513
+
437
514
  function deleteObjectArgs(roots, relativePath, awsOptions) {
438
515
  return withAwsOptions(
439
516
  [
@@ -500,6 +577,11 @@ async function readExistingMetadata(command, args, { sleep }) {
500
577
  }
501
578
  }
502
579
 
580
+ async function readObjectMetadata(command, args, { sleep }) {
581
+ const result = await runAws(command, args, { sleep });
582
+ return parseAwsJson(result.stdout);
583
+ }
584
+
503
585
  function matchesPublishedArtifact(metadata, artifact) {
504
586
  return (
505
587
  metadata?.Metadata?.['explainer-sha256'] ===
@@ -509,20 +591,75 @@ function matchesPublishedArtifact(metadata, artifact) {
509
591
  );
510
592
  }
511
593
 
512
- function assertMetadata(stdout, artifact) {
513
- let metadata;
594
+ function assertMetadata(metadata, artifact) {
595
+ if (!matchesPublishedArtifact(metadata, artifact)) {
596
+ throw publishError(
597
+ 'E_PUBLISH_VERIFY',
598
+ `Uploaded metadata mismatch for ${artifact.manifestPath}.`,
599
+ );
600
+ }
601
+ }
602
+
603
+ async function verifyObjectBytes({
604
+ command,
605
+ metadata,
606
+ roots,
607
+ relativePath,
608
+ expectedHash,
609
+ downloadPath,
610
+ awsOptions,
611
+ sleep,
612
+ }) {
613
+ const expectedBase64 = Buffer.from(
614
+ expectedHash.slice('sha256:'.length),
615
+ 'hex',
616
+ ).toString('base64');
617
+ if (typeof metadata.ChecksumSHA256 === 'string') {
618
+ if (metadata.ChecksumSHA256 !== expectedBase64) {
619
+ throw publishError(
620
+ 'E_PUBLISH_VERIFY',
621
+ `Authenticated object checksum mismatch for ${relativePath}.`,
622
+ );
623
+ }
624
+ return {
625
+ status: 'verified',
626
+ method: 'service-checksum',
627
+ hash: expectedHash,
628
+ };
629
+ }
630
+
514
631
  try {
515
- metadata = JSON.parse(stdout || '{}');
632
+ await runAws(
633
+ command,
634
+ getObjectArgs(roots, relativePath, downloadPath, awsOptions),
635
+ { sleep },
636
+ );
516
637
  } catch {
517
638
  throw publishError(
518
639
  'E_PUBLISH_VERIFY',
519
- 'AWS returned invalid object metadata.',
640
+ `Authenticated object-byte verification is unavailable for ${relativePath}.`,
520
641
  );
521
642
  }
522
- if (!matchesPublishedArtifact(metadata, artifact)) {
643
+ if (fileHash(await readFile(downloadPath)) !== expectedHash) {
523
644
  throw publishError(
524
645
  'E_PUBLISH_VERIFY',
525
- `Uploaded metadata mismatch for ${artifact.manifestPath}.`,
646
+ `Authenticated object bytes do not match ${relativePath}.`,
647
+ );
648
+ }
649
+ return {
650
+ status: 'verified',
651
+ method: 'authenticated-download',
652
+ hash: expectedHash,
653
+ };
654
+ }
655
+
656
+ function parseAwsJson(stdout) {
657
+ try {
658
+ return JSON.parse(stdout || '{}');
659
+ } catch {
660
+ throw publishError(
661
+ 'E_PUBLISH_VERIFY',
662
+ 'AWS returned invalid object metadata.',
526
663
  );
527
664
  }
528
665
  }
@@ -554,17 +691,6 @@ function objectKey(roots, relativePath) {
554
691
  return roots.keyPrefix ? `${roots.keyPrefix}/${relativePath}` : relativePath;
555
692
  }
556
693
 
557
- function targetPath(relativePath, roots) {
558
- const suffix = relativePath
559
- .split('/')
560
- .map((part) => encodeURIComponent(part))
561
- .join('/');
562
- return {
563
- s3Uri: `${roots.s3Uri}/${relativePath}`,
564
- publicUrl: `${roots.publicBaseUrl}/${suffix}`,
565
- };
566
- }
567
-
568
694
  function fileHash(bytes) {
569
695
  return `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
570
696
  }
@@ -596,9 +722,13 @@ async function defaultCommand(file, args) {
596
722
  return execFile(file, args, { maxBuffer: 1024 * 1024 });
597
723
  }
598
724
 
599
- async function defaultHttpGet(url) {
600
- const response = await fetch(url, {
601
- redirect: 'follow',
725
+ export async function defaultHttpGet(url, { fetchImpl = fetch } = {}) {
726
+ // A canonical public artifact URL is uploaded to a known key and should never
727
+ // legitimately redirect. Following redirects turned public verification into
728
+ // an outbound GET primitive that a third party could bounce inward, so a
729
+ // redirect is a verification failure rather than something to chase.
730
+ const response = await fetchImpl(url, {
731
+ redirect: 'error',
602
732
  signal: AbortSignal.timeout(15_000),
603
733
  });
604
734
  return {
@@ -14,6 +14,7 @@ export async function planExplainerSet({
14
14
  schemaVersion: 'explainer-kit.set-plan-request/v1',
15
15
  recipe: structuredClone(recipe),
16
16
  factBase: structuredClone(factBase),
17
+ sourceIds: structuredClone(sourceIds),
17
18
  discovery: structuredClone(discovery),
18
19
  });
19
20
  const callback =
@@ -0,0 +1,157 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lstat, readFile, realpath } from 'node:fs/promises';
3
+ import { resolve } from 'node:path';
4
+
5
+ import { canonicalHash, validateContract } from './contracts.mjs';
6
+
7
+ export const TERMINAL_EVIDENCE_VERSION = 'explainer-kit.terminal-evidence/v1';
8
+ export const VISUAL_REVIEW_EVIDENCE_VERSION =
9
+ 'explainer-kit.visual-review-evidence/v1';
10
+ export const EVIDENCE_REASON_MAX_COUNT = 50;
11
+
12
+ export function createTerminalEvidence({
13
+ runId,
14
+ outcome,
15
+ manifest,
16
+ reasons,
17
+ evidenceDisposition,
18
+ supersededBy,
19
+ } = {}) {
20
+ const evidence = {
21
+ schemaVersion: TERMINAL_EVIDENCE_VERSION,
22
+ runId,
23
+ outcome,
24
+ ...(manifest && { manifestHash: canonicalHash(manifest) }),
25
+ reasons: structuredClone(reasons),
26
+ evidenceDisposition,
27
+ ...(supersededBy && {
28
+ supersededBy: structuredClone(supersededBy),
29
+ }),
30
+ };
31
+ assertTerminalEvidence(evidence, { manifest });
32
+ return evidence;
33
+ }
34
+
35
+ export function assertTerminalEvidence(evidence, { manifest } = {}) {
36
+ const validation = validateContract('terminal-evidence', evidence, {
37
+ manifest,
38
+ });
39
+ if (!validation.valid) {
40
+ throw new Error(
41
+ `Invalid terminal evidence: ${validation.errors
42
+ .map(({ path, code }) => `${path} [${code}]`)
43
+ .join('; ')}`,
44
+ );
45
+ }
46
+ return evidence;
47
+ }
48
+
49
+ export function createVisualReviewEvidence({
50
+ request,
51
+ attempt,
52
+ result,
53
+ failureKind,
54
+ } = {}) {
55
+ const evidence = {
56
+ schemaVersion: VISUAL_REVIEW_EVIDENCE_VERSION,
57
+ requestHash: request?.requestHash,
58
+ attempt,
59
+ disposition: failureKind
60
+ ? 'failed'
61
+ : result?.disposition === 'pass'
62
+ ? 'pass'
63
+ : 'correct',
64
+ reasons: failureKind
65
+ ? [{ stage: 'visual-review', kind: failureKind, count: 1 }]
66
+ : result?.disposition === 'pass'
67
+ ? []
68
+ : findingReasons(result?.findings),
69
+ };
70
+ assertVisualReviewEvidence(evidence, { request, attempt });
71
+ return evidence;
72
+ }
73
+
74
+ export function assertVisualReviewEvidence(
75
+ evidence,
76
+ { request, attempt } = {},
77
+ ) {
78
+ const validation = validateContract('visual-review-evidence', evidence, {
79
+ visualReviewRequest: request,
80
+ attempt,
81
+ });
82
+ if (!validation.valid) {
83
+ throw new Error(
84
+ `Invalid retained visual evidence: ${validation.errors
85
+ .map(({ path, code }) => `${path} [${code}]`)
86
+ .join('; ')}`,
87
+ );
88
+ }
89
+ return evidence;
90
+ }
91
+
92
+ export function evidenceReason(stage, kind, { artifactId, count = 1 } = {}) {
93
+ return {
94
+ stage,
95
+ kind,
96
+ ...(artifactId !== undefined && { artifactId }),
97
+ count,
98
+ };
99
+ }
100
+
101
+ export function projectThrownReason(stage, kind = 'pipeline-failure') {
102
+ return evidenceReason(stage, kind);
103
+ }
104
+
105
+ export async function readTerminalEvidenceFile(
106
+ runRoot,
107
+ { manifest, expectedBytes, expectedHash } = {},
108
+ ) {
109
+ if (typeof runRoot !== 'string' || runRoot.length === 0) {
110
+ throw new TypeError('Terminal evidence run root must be a path string.');
111
+ }
112
+ const canonicalRunRoot = await realpath(runRoot);
113
+ const evidencePath = resolve(canonicalRunRoot, 'terminal-evidence.json');
114
+ const stats = await lstat(evidencePath);
115
+ if (stats.isSymbolicLink() || !stats.isFile()) {
116
+ throw new Error(
117
+ 'Terminal evidence must be a regular file, not a symbolic link.',
118
+ );
119
+ }
120
+ const canonicalEvidencePath = await realpath(evidencePath);
121
+ if (canonicalEvidencePath !== evidencePath) {
122
+ throw new Error('Terminal evidence must remain within the run root.');
123
+ }
124
+ const bytes = await readFile(canonicalEvidencePath);
125
+ const hash = `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
126
+ if (expectedHash !== undefined && hash !== expectedHash) {
127
+ throw new Error('Terminal evidence bytes changed while staging.');
128
+ }
129
+ if (
130
+ expectedBytes !== undefined &&
131
+ !bytes.equals(Buffer.from(expectedBytes))
132
+ ) {
133
+ throw new Error('Terminal evidence bytes changed while staging.');
134
+ }
135
+ let evidence;
136
+ try {
137
+ evidence = JSON.parse(bytes.toString('utf8'));
138
+ } catch {
139
+ throw new Error('Terminal evidence must contain valid JSON.');
140
+ }
141
+ assertTerminalEvidence(evidence, { manifest });
142
+ return { evidence, bytes, hash };
143
+ }
144
+
145
+ function findingReasons(findings) {
146
+ const counts = new Map();
147
+ let total = 0;
148
+ for (const finding of Array.isArray(findings) ? findings : []) {
149
+ if (total >= EVIDENCE_REASON_MAX_COUNT) break;
150
+ const artifactId = finding?.artifactId;
151
+ counts.set(artifactId, (counts.get(artifactId) ?? 0) + 1);
152
+ total += 1;
153
+ }
154
+ return [...counts].map(([artifactId, count]) =>
155
+ evidenceReason('visual-review', 'finding', { artifactId, count }),
156
+ );
157
+ }