@event4u/agent-config 17.3.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +164 -342
  5. package/CREDITS.md +2 -2
  6. package/README.md +1 -1
  7. package/dist/agent-src/skills/code-intelligence/SKILL.md +3 -0
  8. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  9. package/dist/cli/registry.js +2 -2
  10. package/dist/cli-delegate/{chunk-DHSSP7V3.js → chunk-5AYJGUA3.js} +0 -2
  11. package/dist/cli-delegate/chunk-DIFZBP4G.js +1336 -0
  12. package/dist/cli-delegate/{chunk-MT2WOOCY.js → chunk-WY5ZZPGL.js} +2 -2
  13. package/dist/cli-delegate/{chunk-ORJPBER2.js → chunk-X6LZ7W5A.js} +73 -10
  14. package/dist/cli-delegate/cmd_conformance.js +175 -183
  15. package/dist/cli-delegate/cmd_doctor.js +5 -5
  16. package/dist/cli-delegate/cmd_doctor_neighbours.js +3 -3
  17. package/dist/cli-delegate/cmd_explain.js +1 -1
  18. package/dist/cli-delegate/cmd_handoff.js +2 -2
  19. package/dist/cli-delegate/cmd_memory_get.js +1 -1
  20. package/dist/cli-delegate/cmd_route_audit.js +1 -1
  21. package/dist/cli-delegate/cmd_session_recycle.js +2 -2
  22. package/dist/cli-delegate/cmd_update.js +24 -13
  23. package/dist/cli-delegate/cmd_upgrade.js +2 -2
  24. package/dist/cli-delegate/dist-25AZAHOP.js +3240 -0
  25. package/dist/cli-delegate/dist-P7MFPMF3.js +411 -0
  26. package/dist/cli-delegate/dist-XAS5ZLQB.js +6 -0
  27. package/dist/cli-delegate/{upgrade_unattended-VVL6JLSX.js → upgrade_unattended-QCWOKTUN.js} +178 -163
  28. package/dist/discovery/deprecation-report.md +1 -1
  29. package/dist/discovery/discovery-manifest.json +3 -3
  30. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  31. package/dist/discovery/discovery-manifest.summary.md +1 -1
  32. package/dist/discovery/dormancy-report.md +1 -1
  33. package/dist/discovery/orphan-report.md +1 -1
  34. package/dist/discovery/packs.json +2 -2
  35. package/dist/discovery/trust-report.md +1 -1
  36. package/dist/discovery/workspaces.json +2 -2
  37. package/dist/hooks/code_graph_refresh.chunk.cjs +56 -27
  38. package/dist/hooks/code_graph_refresh.chunk.cjs.sha256 +1 -1
  39. package/dist/hooks/dispatch.js +982 -929
  40. package/dist/hooks/dispatch.sha256 +1 -1
  41. package/dist/hooks/egress_engine.chunk.cjs +56 -6
  42. package/dist/hooks/egress_engine.chunk.cjs.sha256 +1 -1
  43. package/dist/hooks/update_notice.chunk.cjs +914 -0
  44. package/dist/hooks/update_notice.chunk.cjs.sha256 +1 -0
  45. package/dist/mcp/registry-manifest.json +1 -1
  46. package/dist/mcp/server.json +2 -2
  47. package/dist/mcp/server.mjs +71259 -66437
  48. package/dist/scripts/condense.js +9 -6
  49. package/package.json +4 -1
  50. package/src/config/evaluator-budgets.json +9 -8
  51. package/src/config/gate-violation-baselines.json +2 -2
  52. package/src/config/hook-bundle-budget.json +31 -6
  53. package/src/config/packed-binary-manifest.json +9 -0
  54. package/src/config/sigstore/npm_keys.json +50 -0
  55. package/src/config/sigstore/trust-material.json +9 -0
  56. package/src/config/sigstore/trusted_root.json +126 -0
  57. package/src/config/skill-body-budget.json +7 -1
  58. package/src/scripts/_cli/cmd_update.ts +48 -20
  59. package/src/scripts/_cli/cmd_upgrade.ts +7 -1
  60. package/src/scripts/_cli/upgrade_unattended.ts +90 -76
  61. package/src/scripts/_dispatch.bash +3 -2
  62. package/src/scripts/_lib/confusables.ts +0 -19
  63. package/src/scripts/_lib/egress_normalize.ts +53 -23
  64. package/src/scripts/_lib/egress_text.ts +5 -3
  65. package/src/scripts/_lib/egress_token.ts +7 -5
  66. package/src/scripts/_lib/gate_collector_experimental.ts +633 -0
  67. package/src/scripts/_lib/git_common_dir.ts +10 -0
  68. package/src/scripts/_lib/graph_feeder_record.ts +3 -2
  69. package/src/scripts/_lib/install_reach_checks.ts +32 -2
  70. package/src/scripts/_lib/lineage_backfill.ts +136 -0
  71. package/src/scripts/_lib/lineage_ledger.ts +211 -0
  72. package/src/scripts/_lib/lineage_map.ts +55 -0
  73. package/src/scripts/_lib/lineage_query.ts +140 -0
  74. package/src/scripts/_lib/lineage_seed.ts +116 -0
  75. package/src/scripts/_lib/lineage_watch.ts +249 -0
  76. package/src/scripts/_lib/link_crypto.ts +8 -4
  77. package/src/scripts/_lib/production_reach.ts +3 -3
  78. package/src/scripts/_lib/project_registry.ts +227 -0
  79. package/src/scripts/_lib/provenance_verify.ts +14 -242
  80. package/src/scripts/_lib/sigstore_provenance.ts +177 -0
  81. package/src/scripts/_lib/source_id.ts +48 -0
  82. package/src/scripts/_lib/source_shape.ts +4 -0
  83. package/src/scripts/_lib/unattended_gates.ts +6 -1
  84. package/src/scripts/advisories.ts +26 -10
  85. package/src/scripts/ai_council/council_egress.ts +5 -6
  86. package/src/scripts/bench_graph_feeder_latency.ts +10 -3
  87. package/src/scripts/check_dependency_floors.ts +7 -0
  88. package/src/scripts/check_egress_mutations.ts +1 -0
  89. package/src/scripts/code_graph/build.ts +67 -3
  90. package/src/scripts/code_graph/cli.ts +131 -28
  91. package/src/scripts/code_graph/conventions_default.ts +20 -1
  92. package/src/scripts/code_graph/detect.ts +5 -5
  93. package/src/scripts/code_graph/estate_cli.ts +135 -0
  94. package/src/scripts/code_graph/estate_index.ts +394 -0
  95. package/src/scripts/code_graph/estate_manifests.ts +223 -0
  96. package/src/scripts/code_graph/estate_map.ts +141 -0
  97. package/src/scripts/code_graph/extract.ts +4 -1
  98. package/src/scripts/code_graph/extract_more.ts +137 -2
  99. package/src/scripts/code_graph/graph_budget.ts +158 -0
  100. package/src/scripts/code_graph/graph_home.ts +80 -0
  101. package/src/scripts/code_graph/lineage_cli.ts +315 -0
  102. package/src/scripts/code_graph/map.ts +22 -13
  103. package/src/scripts/code_graph/map_model.ts +67 -12
  104. package/src/scripts/code_graph/map_verify.ts +68 -5
  105. package/src/scripts/code_graph/map_viewer/viewer.css +12 -0
  106. package/src/scripts/code_graph/map_viewer/viewer.js +45 -15
  107. package/src/scripts/code_graph/project_ref.ts +25 -0
  108. package/src/scripts/code_graph/projects_cli.ts +254 -0
  109. package/src/scripts/code_graph/providers/catalog.ts +14 -5
  110. package/src/scripts/code_graph/providers/django.ts +52 -9
  111. package/src/scripts/code_graph/providers/index.ts +2 -0
  112. package/src/scripts/code_graph/providers/rails.ts +204 -0
  113. package/src/scripts/code_graph/providers/spring.ts +38 -1
  114. package/src/scripts/code_graph/query.ts +9 -9
  115. package/src/scripts/code_graph/refresh_lock.ts +12 -8
  116. package/src/scripts/code_graph/repo_identity.ts +169 -0
  117. package/src/scripts/code_graph/sqlite_store.ts +22 -25
  118. package/src/scripts/code_graph/types.ts +6 -2
  119. package/src/scripts/condense.ts +6 -6
  120. package/src/scripts/hook_chunks.mjs +7 -0
  121. package/src/scripts/hook_manifest.json +1 -1
  122. package/src/scripts/hook_manifest.yaml +20 -17
  123. package/src/scripts/hooks/code_graph_refresh_hook.ts +10 -7
  124. package/src/scripts/hooks/dispatch_hook.ts +6 -7
  125. package/src/scripts/hooks/host_semantics.ts +72 -0
  126. package/src/scripts/hooks/lazy_concern.ts +4 -0
  127. package/src/scripts/hooks/user_notice.ts +105 -0
  128. package/src/scripts/lint_harvest_provenance.ts +137 -11
  129. package/src/scripts/mcp_server/consumer_tool_catalog.json +69 -15
  130. package/src/scripts/mcp_server/graph_tools.ts +161 -41
  131. package/src/scripts/network_sources.ts +24 -6
  132. package/src/scripts/schemas/provenance-mechanism.schema.json +46 -0
  133. package/src/scripts/update_notice_hook.ts +75 -7
  134. package/src/vendor/grammars/README.md +7 -6
  135. package/src/vendor/grammars/tree-sitter-ruby.wasm +0 -0
  136. /package/dist/cli-delegate/{chunk-FKYZOWVD.js → chunk-QBAFP67O.js} +0 -0
  137. /package/dist/cli-delegate/{chunk-BMGUGSY6.js → chunk-TQG6UMPC.js} +0 -0
@@ -1,31 +1,12 @@
1
1
  /**
2
- * Tie a published version's provenance attestation to THIS package's OIDC
3
- * publish workflow.
2
+ * Which workflow, in which repository, a legitimate release of this package is
3
+ * built by — and the check that a SIGNED provenance payload names them.
4
4
  *
5
- * The gate this replaces read `dist.attestations.provenance.predicateType` out
6
- * of the packument and accepted anything starting with the SLSA provenance
7
- * URL. That is a presence check wearing a verification's clothes: any workflow
8
- * in any repository with `id-token: write` produces a document that passes it,
9
- * so an attacker who can publish from their own CI already satisfies it. The
10
- * step that opened this file (`road-to-an-update-that-installs-itself-only-when-it-is-safe`
11
- * 2.1) named exactly that gap — *"does not verify the attestation's signature
12
- * or tie it to `publish-npm.yml`"* — and this module closes the second half.
13
- *
14
- * **The first half is only partly closed here, and the distinction is
15
- * load-bearing.** `provenance_matches_publish` parses an UNSIGNED reading of
16
- * the DSSE payload. `verify_attestation_signature` below checks the envelope's
17
- * signature against the certificate the bundle carries, and requires the
18
- * payload it verified to name the same workflow — but checks no trust root
19
- * (see its own note). The registry-key half is `npm audit signatures`, which
20
- * verifies against npm's own trusted keys and is run by the caller as a
21
- * separate step; these functions are only meaningful WITH it, and the caller
22
- * says so where it composes them.
23
- *
24
- * Why not verify the signature in-process: it needs a sigstore/cosign
25
- * verification path, which is a dependency this package does not carry and
26
- * which the hook and pack byte budgets would both have to absorb. Shelling out
27
- * to the npm client — already a hard dependency of an upgrade — buys the same
28
- * property for nothing.
5
+ * Signatures, trust roots and the tie to the tarball are verified in
6
+ * `sigstore_provenance.ts`; this module only reads a payload that has already
7
+ * been verified there. It used to carry a certificate-bound signature check of
8
+ * its own, which checked no issuer and so passed a self-signed certificate
9
+ * carrying the expected SAN; that check is gone rather than kept as a fallback.
29
10
  */
30
11
 
31
12
  /**
@@ -44,65 +25,16 @@ function asRecord(v: unknown): Record<string, unknown> | null {
44
25
  return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
45
26
  }
46
27
 
47
- /** Decode the DSSE payload of the first attestation; `null` when unreadable. */
48
- function decode_payload(doc: unknown): Record<string, unknown> | null {
49
- const list = asRecord(doc)?.['attestations'];
50
- if (!Array.isArray(list) || list.length === 0) return null;
51
- for (const entry of list) {
52
- const payload = asRecord(asRecord(asRecord(entry)?.['bundle'])?.['dsseEnvelope'])?.['payload'];
53
- if (typeof payload !== 'string' || payload === '') continue;
54
- try {
55
- const parsed: unknown = JSON.parse(Buffer.from(payload, 'base64').toString('utf8'));
56
- const rec = asRecord(parsed);
57
- if (rec !== null) return rec;
58
- } catch {
59
- // A payload that is not base64 JSON is not evidence of anything.
60
- // Keep looking: npm may serve several attestations and only one of
61
- // them has to be the provenance statement.
62
- }
63
- }
64
- return null;
65
- }
66
-
67
- function external_workflow(doc: unknown): Record<string, unknown> | null {
68
- const payload = decode_payload(doc);
69
- if (payload === null) return null;
70
- const predicate = asRecord(payload['predicate']);
71
- const external = asRecord(asRecord(predicate?.['buildDefinition'])?.['externalParameters']);
72
- return asRecord(external?.['workflow']);
73
- }
74
-
75
- /**
76
- * The workflow path named by the attestation, or `null` when the document
77
- * carries none that can be read.
78
- */
79
- export function attestation_workflow_ref(doc: unknown): string | null {
80
- const path_value = external_workflow(doc)?.['path'];
81
- return typeof path_value === 'string' && path_value !== '' ? path_value : null;
82
- }
83
-
84
- /** The repository named by the attestation, or `null`. */
85
- export function attestation_repository(doc: unknown): string | null {
86
- const repo = external_workflow(doc)?.['repository'];
87
- return typeof repo === 'string' && repo !== '' ? repo : null;
88
- }
89
-
90
28
  /**
91
- * Does this attestation say the tarball was built by this package's publish
92
- * workflow, in this package's repository?
29
+ * Does this provenance payload say the tarball was built by this package's
30
+ * publish workflow, in this package's repository?
93
31
  *
94
- * Both halves are required and both reject on absence: a document that names
95
- * no workflow is not a document that names the right one. Repository is
96
- * compared with the trailing `.git` and any trailing slash removed, because
97
- * the same remote is written both ways and neither spelling is an attacker
98
- * signal.
32
+ * Both halves are required and both reject on absence: a payload that names no
33
+ * workflow is not one that names the right one. Repository is compared with
34
+ * the trailing `.git` and any trailing slash removed, because the same remote
35
+ * is written both ways and neither spelling is an attacker signal.
99
36
  */
100
- export function provenance_matches_publish(doc: unknown): boolean {
101
- return payload_names_publish(decode_payload(doc));
102
- }
103
-
104
- /** The workflow and repository check, over one already-decoded payload. */
105
- function payload_names_publish(payload: Record<string, unknown> | null): boolean {
37
+ export function payload_names_publish(payload: Record<string, unknown> | null): boolean {
106
38
  const predicate = asRecord(payload?.['predicate']);
107
39
  const workflow = asRecord(asRecord(asRecord(predicate?.['buildDefinition'])?.['externalParameters'])?.['workflow']);
108
40
  if (workflow?.['path'] !== EXPECTED_WORKFLOW_REF) return false;
@@ -110,163 +42,3 @@ function payload_names_publish(payload: Record<string, unknown> | null): boolean
110
42
  if (typeof repo !== 'string' || repo === '') return false;
111
43
  return repo.replace(/\.git$/, '').replace(/\/$/, '') === EXPECTED_REPOSITORY;
112
44
  }
113
-
114
- /**
115
- * Does the certificate's SAN carry the workflow identity as a whole URI entry?
116
- * Anchored: the entry is the expected workflow followed by `@<ref>`, never a
117
- * SAN that merely contains the string.
118
- */
119
- function san_names_workflow(san: string): boolean {
120
- return san.split(/,\s*/).some((e) => e.startsWith(`URI:${SAN_WORKFLOW_MARKER}@`));
121
- }
122
-
123
- // --- the signature half -------------------------------------------------------
124
-
125
- /**
126
- * What the clauses below do and do not establish.
127
- *
128
- * The header above says the signature half is not closed here. It is now
129
- * HALF-closed, and the half that is missing is stated first so nothing below
130
- * reads as more than it is.
131
- *
132
- * **Closed:** the DSSE envelope's signature is verified against the public key
133
- * of the certificate the bundle itself carries, over the DSSE
134
- * pre-authentication encoding — so a payload edited after signing is caught,
135
- * and a bundle whose signature does not belong to its own certificate is
136
- * caught. And the certificate's own SAN is required to name the expected
137
- * workflow, so a bundle whose PAYLOAD claims `publish-npm.yml` while its
138
- * certificate was issued to some other identity is caught. Before this, both
139
- * were a base64 decode and a string compare.
140
- *
141
- * **Not closed, and this is the residue:** there is no trust root. Nothing
142
- * here checks that the certificate was issued by Fulcio, and nothing checks a
143
- * Rekor inclusion proof. An attacker who can serve a registry response can mint
144
- * a self-signed certificate carrying the expected SAN, sign their own payload
145
- * with it, and pass every check in this file. Closing that needs a sigstore
146
- * verification path with pinned roots — a dependency decision, not a line in
147
- * this function, and it is registered as the `dsse-trust-root` blocker on
148
- * `road-to-an-update-that-installs-itself-only-when-it-is-safe`.
149
- *
150
- * So the honest statement of what the provenance gate is worth today: it
151
- * defeats a partial compromise — a mis-scoped workflow, an edited payload, a
152
- * bundle reused from another build — and it does not defeat a forged bundle
153
- * from someone who already controls what the install reads.
154
- */
155
-
156
- /** Why the signature does not verify, or `null` when it does. */
157
- export type SignatureVerdict = string | null;
158
-
159
- /** The SAN extension OID the Fulcio leaf carries the workflow identity in. */
160
- const SAN_WORKFLOW_MARKER = `${EXPECTED_REPOSITORY}/${EXPECTED_WORKFLOW_REF}`;
161
-
162
- /**
163
- * The DSSE pre-authentication encoding — the exact bytes a DSSE signature is
164
- * taken over.
165
- *
166
- * `DSSEv1 SP len(payloadType) SP payloadType SP len(payload) SP payload`, with
167
- * both lengths in BYTES rather than characters. Built as a Buffer throughout
168
- * because the payload is arbitrary bytes and a string round-trip would mangle
169
- * any that are not valid UTF-8.
170
- */
171
- export function dssePae(payloadType: string, payload: Buffer): Buffer {
172
- const type = Buffer.from(payloadType, 'utf8');
173
- return Buffer.concat([
174
- Buffer.from(`DSSEv1 ${String(type.length)} `, 'utf8'),
175
- type,
176
- Buffer.from(` ${String(payload.length)} `, 'utf8'),
177
- payload,
178
- ]);
179
- }
180
-
181
- interface Envelope {
182
- readonly payloadType: string;
183
- readonly payload: Buffer;
184
- readonly sig: Buffer;
185
- readonly certDer: Buffer;
186
- }
187
-
188
- function readEnvelope(entry: unknown): Envelope | null {
189
- const bundle = asRecord(asRecord(entry)?.['bundle']);
190
- const dsse = asRecord(bundle?.['dsseEnvelope']);
191
- if (dsse === null) return null;
192
- const payloadB64 = dsse['payload'];
193
- const payloadType = dsse['payloadType'];
194
- const sigs = dsse['signatures'];
195
- if (typeof payloadB64 !== 'string' || typeof payloadType !== 'string' || !Array.isArray(sigs) || sigs.length === 0) return null;
196
- const sigB64 = asRecord(sigs[0])?.['sig'];
197
- if (typeof sigB64 !== 'string' || sigB64 === '') return null;
198
-
199
- const material = asRecord(bundle?.['verificationMaterial']);
200
- // Both shapes sigstore has used: a single certificate, and a chain.
201
- const single = asRecord(material?.['certificate'])?.['rawBytes'];
202
- const chain = asRecord(material?.['x509CertificateChain'])?.['certificates'];
203
- const first = Array.isArray(chain) ? asRecord(chain[0])?.['rawBytes'] : undefined;
204
- const certB64 = typeof single === 'string' ? single : typeof first === 'string' ? first : null;
205
- if (certB64 === null || certB64 === '') return null;
206
-
207
- return {
208
- payloadType,
209
- payload: Buffer.from(payloadB64, 'base64'),
210
- sig: Buffer.from(sigB64, 'base64'),
211
- certDer: Buffer.from(certB64, 'base64'),
212
- };
213
- }
214
-
215
- /**
216
- * Verify the first readable attestation's DSSE signature against its own
217
- * certificate, and require that certificate to name this package's workflow.
218
- *
219
- * Returns a reason on every failure rather than a boolean, because the caller
220
- * prints it: a refusal a user cannot act on is a refusal they route around.
221
- * `crypto` is imported lazily so a caller that never verifies a signature does
222
- * not pay for the module — this runs on an install path, never in a hook.
223
- */
224
- export async function verify_attestation_signature(doc: unknown): Promise<SignatureVerdict> {
225
- const list = asRecord(doc)?.['attestations'];
226
- if (!Array.isArray(list) || list.length === 0) return 'the attestation bundle carries no attestations';
227
- const crypto = await import('node:crypto');
228
-
229
- let lastReason = 'no attestation carried a readable DSSE envelope';
230
- for (const entry of list) {
231
- const env = readEnvelope(entry);
232
- if (env === null) continue;
233
- let cert: InstanceType<typeof crypto.X509Certificate>;
234
- try {
235
- cert = new crypto.X509Certificate(env.certDer);
236
- } catch {
237
- lastReason = 'the signing certificate could not be parsed';
238
- continue;
239
- }
240
- // The certificate's own identity, not the payload's claim about it.
241
- const san = cert.subjectAltName ?? '';
242
- if (!san_names_workflow(san)) {
243
- lastReason = `the signing certificate names ${san === '' ? 'no identity' : san}, not ${SAN_WORKFLOW_MARKER}`;
244
- continue;
245
- }
246
- let ok = false;
247
- try {
248
- ok = crypto.verify(null, dssePae(env.payloadType, env.payload), cert.publicKey, env.sig);
249
- } catch {
250
- ok = false;
251
- }
252
- if (!ok) {
253
- lastReason = 'the DSSE signature does not verify against the certificate in the bundle';
254
- continue;
255
- }
256
- // The payload this signature covers must itself name the workflow:
257
- // otherwise a valid signature on one entry vouches for a claim made in
258
- // another, which is all the unsigned check reads.
259
- let verified: Record<string, unknown> | null = null;
260
- try {
261
- verified = asRecord(JSON.parse(env.payload.toString('utf8')));
262
- } catch {
263
- verified = null;
264
- }
265
- if (!payload_names_publish(verified)) {
266
- lastReason = 'the signed payload does not name this package\'s publish workflow';
267
- continue;
268
- }
269
- return null;
270
- }
271
- return lastReason;
272
- }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Verify a release's two attestations against pinned sigstore trust material,
3
+ * and bind both to the exact tarball bytes that will be installed.
4
+ *
5
+ * WHY THIS REPLACES THE CERTIFICATE-BOUND CHECK. `provenance_verify.ts` checked
6
+ * the DSSE signature against the certificate a bundle carries and that
7
+ * certificate's SAN — and nothing about who issued it, so anyone who could
8
+ * serve the registry answer could mint a self-signed certificate with the
9
+ * expected SAN and pass. Pinned roots close that; a root fetched at run time
10
+ * would let the same attacker choose what the bundle is checked against.
11
+ *
12
+ * WHAT IS VERIFIED, both attestations required:
13
+ * - the SLSA provenance bundle: Fulcio chain to the pinned root, the signing
14
+ * certificate's validity at the time the transparency log recorded it, a
15
+ * Rekor entry and an SCT checked against the pinned logs, the DSSE signature,
16
+ * and an identity policy — issuer `token.actions.githubusercontent.com` and a
17
+ * SAN that is exactly this repository's `publish-npm.yml` at the tag of the
18
+ * version being installed;
19
+ * - the npm publish attestation: its signature against npm's pinned
20
+ * `npm:attestations` keys, and its Rekor entry — a second trust domain,
21
+ * verified separately;
22
+ * - in both payloads, exactly one subject naming this package at this version,
23
+ * whose sha512 equals the sha512 of the bytes about to be installed.
24
+ *
25
+ * WHAT IT NEVER DOES: fetch trust material (the files are read from the
26
+ * installed package and their digests checked against the recorded ones), fall
27
+ * back to the old check or to `npm audit signatures`, or pass anything it could
28
+ * not verify. Every failure is a reason string, because the caller prints it.
29
+ *
30
+ * The sigstore modules are imported lazily: this runs on the unattended upgrade
31
+ * path only, and nothing else should pay for loading them.
32
+ */
33
+ import { createHash } from 'node:crypto';
34
+ import * as fs from 'node:fs';
35
+ import * as path from 'node:path';
36
+
37
+ import { resolvePackageRoot } from './package_root.js';
38
+ import { EXPECTED_REPOSITORY, EXPECTED_WORKFLOW_REF, payload_names_publish } from './provenance_verify.js';
39
+
40
+ export const SLSA_PROVENANCE_V1 = 'https://slsa.dev/provenance/v1';
41
+ export const NPM_PUBLISH_V01 = 'https://github.com/npm/attestation/tree/main/specs/publish/v0.1';
42
+ export const GITHUB_OIDC_ISSUER = 'https://token.actions.githubusercontent.com';
43
+ export const PACKAGE_PURL_BASE = 'pkg:npm/%40event4u/agent-config';
44
+
45
+ /** Why verification refused, or `null` when everything held. */
46
+ export type ProvenanceVerdict = string | null;
47
+
48
+ function asRecord(v: unknown): Record<string, unknown> | null {
49
+ return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>) : null;
50
+ }
51
+
52
+ function escapeRegExp(s: string): string {
53
+ return s.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
54
+ }
55
+
56
+ /**
57
+ * The only SAN accepted: this repository's publish workflow at the release tag
58
+ * of exactly the version being installed. Anchored at both ends — the library
59
+ * treats the policy as a pattern, and an unanchored one would accept any SAN
60
+ * that merely contains it.
61
+ */
62
+ export function expectedSan(version: string): RegExp {
63
+ return new RegExp(`^${escapeRegExp(`${EXPECTED_REPOSITORY}/${EXPECTED_WORKFLOW_REF}@refs/tags/${version}`)}$`);
64
+ }
65
+
66
+ export interface TrustFiles {
67
+ readonly trustedRoot: unknown;
68
+ readonly npmKeys: unknown;
69
+ }
70
+
71
+ /**
72
+ * Read the pinned trust material and refuse when a file's digest differs from
73
+ * the recorded one — a release can only be checked against the material it
74
+ * shipped with, as it shipped.
75
+ */
76
+ export function readTrustFiles(packageRoot: string = resolvePackageRoot(import.meta.url)): TrustFiles | string {
77
+ const dir = path.join(packageRoot, 'src', 'config', 'sigstore');
78
+ try {
79
+ const manifest = JSON.parse(fs.readFileSync(path.join(dir, 'trust-material.json'), 'utf-8')) as {
80
+ files?: Record<string, string>;
81
+ };
82
+ const read = (name: string): unknown => {
83
+ const bytes = fs.readFileSync(path.join(dir, name));
84
+ const want = manifest.files?.[name];
85
+ const got = createHash('sha256').update(bytes).digest('hex');
86
+ if (want !== got) throw new Error(`${name} has sha256 ${got}, the recorded one is ${String(want)}`);
87
+ return JSON.parse(bytes.toString('utf-8')) as unknown;
88
+ };
89
+ return { trustedRoot: read('trusted_root.json'), npmKeys: read('npm_keys.json') };
90
+ } catch (err) {
91
+ return `the pinned sigstore trust material is unusable: ${err instanceof Error ? err.message : String(err)}`;
92
+ }
93
+ }
94
+
95
+ /** Exactly one subject, naming this package at this version, with this sha512. */
96
+ function subjectMatches(payload: Record<string, unknown> | null, version: string, sha512Hex: string): string | null {
97
+ const subjects = payload?.['subject'];
98
+ if (!Array.isArray(subjects) || subjects.length !== 1) return 'it does not name exactly one subject';
99
+ const subject = asRecord(subjects[0]);
100
+ if (subject?.['name'] !== `${PACKAGE_PURL_BASE}@${version}`) return `its subject is ${String(subject?.['name'])}, not ${version}`;
101
+ const digest = asRecord(subject['digest'])?.['sha512'];
102
+ if (typeof digest !== 'string' || digest.toLowerCase() !== sha512Hex.toLowerCase()) {
103
+ return 'its subject digest does not match the tarball about to be installed';
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export interface ReleaseToVerify {
109
+ readonly version: string;
110
+ /** sha512 of the exact tarball bytes that will be installed, lower-case hex. */
111
+ readonly tarballSha512Hex: string;
112
+ /** The registry's attestation document for this version. */
113
+ readonly attestations: unknown;
114
+ }
115
+
116
+ export async function verify_release_provenance(
117
+ release: ReleaseToVerify,
118
+ trust: TrustFiles | string = readTrustFiles(),
119
+ ): Promise<ProvenanceVerdict> {
120
+ if (typeof trust === 'string') return trust;
121
+ const list = asRecord(release.attestations)?.['attestations'];
122
+ if (!Array.isArray(list)) return 'the attestation document carries no attestations';
123
+ const byType = new Map<string, unknown>();
124
+ for (const entry of list) {
125
+ const type = asRecord(entry)?.['predicateType'];
126
+ if (typeof type === 'string' && !byType.has(type)) byType.set(type, asRecord(entry)?.['bundle']);
127
+ }
128
+ for (const type of [SLSA_PROVENANCE_V1, NPM_PUBLISH_V01]) {
129
+ if (!byType.has(type)) return `the release carries no ${type} attestation`;
130
+ }
131
+
132
+ const { Verifier, toSignedEntity, toTrustMaterial } = await import('@sigstore/verify');
133
+ const { bundleFromJSON } = await import('@sigstore/bundle');
134
+ const { PublicKey, TrustedRoot } = await import('@sigstore/protobuf-specs');
135
+
136
+ let material: ReturnType<typeof toTrustMaterial>;
137
+ try {
138
+ const keys: Record<string, ReturnType<typeof PublicKey.fromJSON>> = {};
139
+ const listed = asRecord(trust.npmKeys)?.['keys'];
140
+ for (const k of Array.isArray(listed) ? listed : []) {
141
+ const key = asRecord(k);
142
+ if (key?.['keyUsage'] === 'npm:attestations' && typeof key['keyId'] === 'string') {
143
+ keys[key['keyId']] = PublicKey.fromJSON(key['publicKey']);
144
+ }
145
+ }
146
+ material = toTrustMaterial(TrustedRoot.fromJSON(trust.trustedRoot), keys);
147
+ } catch (err) {
148
+ return `the pinned sigstore trust material could not be loaded: ${err instanceof Error ? err.message : String(err)}`;
149
+ }
150
+
151
+ for (const type of [SLSA_PROVENANCE_V1, NPM_PUBLISH_V01]) {
152
+ let payload: Record<string, unknown> | null;
153
+ try {
154
+ const bundle = bundleFromJSON(byType.get(type));
155
+ const verifier = new Verifier(material, { tlogThreshold: 1, ctlogThreshold: 1, timestampThreshold: 1 });
156
+ const policy =
157
+ type === SLSA_PROVENANCE_V1
158
+ ? { subjectAlternativeName: expectedSan(release.version), extensions: { issuer: GITHUB_OIDC_ISSUER } }
159
+ : undefined;
160
+ verifier.verify(toSignedEntity(bundle), policy);
161
+ if (bundle.content.$case !== 'dsseEnvelope') return `the ${type} attestation is not a DSSE envelope`;
162
+ payload = asRecord(JSON.parse(bundle.content.dsseEnvelope.payload.toString('utf-8')));
163
+ } catch (err) {
164
+ const code = asRecord(err)?.['code'];
165
+ return `the ${type} attestation does not verify${typeof code === 'string' ? ` (${code})` : ''}: ${
166
+ err instanceof Error ? err.message : String(err)
167
+ }`;
168
+ }
169
+ if (payload?.['predicateType'] !== type) return `the ${type} attestation's statement names another predicate type`;
170
+ const subject = subjectMatches(payload, release.version, release.tarballSha512Hex);
171
+ if (subject !== null) return `the ${type} attestation is not for this tarball: ${subject}`;
172
+ if (type === SLSA_PROVENANCE_V1 && !payload_names_publish(payload)) {
173
+ return `the signed provenance does not name ${EXPECTED_WORKFLOW_REF} in ${EXPECTED_REPOSITORY}`;
174
+ }
175
+ }
176
+ return null;
177
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Stable, keyed source ids — `src_` plus 10 hex of an HMAC over the canonical identity.
3
+ *
4
+ * `ENC1` tokens are salted per call, so two tokens for one source never compare
5
+ * equal; a round-scoped `S1…` code is renumbered every round. Neither can key a
6
+ * ledger. This id can: it is stable across rounds and URL spellings, and keyed
7
+ * with `secrets.link_encryption_key`, so nobody without the key can confirm a
8
+ * guessed source by hashing its URL. Without a key there is no id — callers
9
+ * refuse rather than mint an unkeyed one.
10
+ */
11
+
12
+ import * as crypto from 'node:crypto';
13
+
14
+ import { canonicalize } from '../harvest_reference_tokens.js';
15
+
16
+ export const SOURCE_ID_PREFIX = 'src_';
17
+ export const SOURCE_ID_RE = /^src_[0-9a-f]{10}$/;
18
+ // Domain-separates this HMAC from any other use of the same key.
19
+ const ID_CONTEXT = 'agent-config/source-id/v1\0';
20
+
21
+ /**
22
+ * Repository URLs collapse through `canonicalize`; anything else (an article,
23
+ * a video, a paper) keeps host, path and query with scheme, credentials,
24
+ * fragment and a trailing slash dropped. A string that is not a URL is used
25
+ * trimmed, as written — an opaque reference names itself.
26
+ */
27
+ export function canonicalSourceIdentity(raw: string): string {
28
+ const repo = canonicalize(raw);
29
+ if (repo !== null) return repo;
30
+ const s = raw.trim();
31
+ if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(s)) return s;
32
+ let u: URL;
33
+ try {
34
+ u = new URL(s);
35
+ } catch {
36
+ return s;
37
+ }
38
+ const host = u.hostname.toLowerCase().replace(/^www\./, '');
39
+ const port = u.port === '' ? '' : `:${u.port}`;
40
+ const pathname = u.pathname.replace(/\/+$/, '');
41
+ return `${host}${port}${pathname}${u.search}`;
42
+ }
43
+
44
+ export function sourceId(identity: string, key: string): string {
45
+ if (!key) throw new Error('no link_encryption_key — a source id is never minted without the key');
46
+ const mac = crypto.createHmac('sha256', key).update(ID_CONTEXT + canonicalSourceIdentity(identity)).digest('hex');
47
+ return SOURCE_ID_PREFIX + mac.slice(0, 10);
48
+ }
@@ -155,6 +155,10 @@ const ALLOWED_OWNERS: readonly string[] = [
155
155
  // looked; this is the answer. Fixing them here rather than by widening the
156
156
  // dedup rule is what keeps that rule strict.
157
157
  'someone', 'o',
158
+ // `someone-else/<repo>` is the wrong-repository fixture of the provenance
159
+ // tests; a committed council prompt quoting those tests verbatim was the
160
+ // measured false positive (2026-10-24). Same shape as the two above.
161
+ 'someone-else',
158
162
  // Vendors whose tools this suite integrates or recommends (rule carve-out).
159
163
  'anthropics', 'anthropic', 'microsoft', 'github', 'modelcontextprotocol',
160
164
  'cloudflare', 'openai', 'nodejs', 'vitest-dev', 'vercel', 'tailwindlabs',
@@ -30,9 +30,12 @@ export interface RegistryCandidate {
30
30
  * workflow produced the document. Tying it to this package's publish
31
31
  * workflow needs the bundle itself, which the packument only points at —
32
32
  * so the pointer travels with the candidate and the caller fetches it
33
- * (`provenance_verify.provenance_matches_publish`).
33
+ * (`sigstore_provenance.verify_release_provenance`).
34
34
  */
35
35
  readonly attestationsUrl: string | null;
36
+ /** Where npm serves this version's tarball, and the integrity it records for it. */
37
+ readonly tarballUrl: string | null;
38
+ readonly integrity: string | null;
36
39
  }
37
40
 
38
41
  function asRecord(v: unknown): Record<string, unknown> | null {
@@ -58,6 +61,8 @@ export function read_candidate(packument: unknown): RegistryCandidate | null {
58
61
  provenance: typeof predicate === 'string' && predicate.startsWith('https://slsa.dev/provenance/'),
59
62
  impactMap: asRecord(manifest['agentConfig'])?.['impact'] ?? null,
60
63
  attestationsUrl: typeof attestations?.['url'] === 'string' ? (attestations['url'] as string) : null,
64
+ tarballUrl: typeof asRecord(manifest['dist'])?.['tarball'] === 'string' ? (asRecord(manifest['dist'])?.['tarball'] as string) : null,
65
+ integrity: typeof asRecord(manifest['dist'])?.['integrity'] === 'string' ? (asRecord(manifest['dist'])?.['integrity'] as string) : null,
61
66
  };
62
67
  }
63
68
 
@@ -63,6 +63,7 @@ import {
63
63
  emptyLedger,
64
64
  validateAcceptance,
65
65
  } from './_lib/advisory_verdicts.js';
66
+ import { readRegistry, recordProject } from './_lib/project_registry.js';
66
67
  import { event4u_root } from './_lib/user_global_paths.js';
67
68
  import {
68
69
  advance,
@@ -363,25 +364,40 @@ export function markOwnActionsPrivate<T extends { entries: Array<{ ecosystem: st
363
364
  return inv;
364
365
  }
365
366
 
366
- /** The user-global index of project roots — roots and fingerprints only (5.4). */
367
+ /**
368
+ * The project list is the shared user-global registry (`_lib/project_registry`,
369
+ * ADR-299): a sibling of the advisories machine dir, so an injected dir keeps
370
+ * its registry beside it. Roots and fingerprints only (5.4).
371
+ */
372
+ function registryDirFor(mdir: string): string {
373
+ return path.join(path.dirname(mdir), 'projects');
374
+ }
375
+
367
376
  function recordProjects(mdir: string, rows: Array<{ root: string; fingerprint: string }>, now: Date): void {
377
+ for (const r of rows) recordProject(r.root, { fingerprint: r.fingerprint }, { dir: registryDirFor(mdir), now });
378
+ }
379
+
380
+ /** The pre-registry `projects.json`, imported once and then moved aside. */
381
+ function migrateLegacyProjects(mdir: string): void {
368
382
  const file = path.join(mdir, 'projects.json');
369
- let doc: Record<string, { fingerprint: string; at: string }> = {};
383
+ let doc: Record<string, { fingerprint?: string; at?: string }>;
370
384
  try {
371
385
  doc = JSON.parse(fs.readFileSync(file, 'utf-8'));
372
386
  } catch {
373
- doc = {};
387
+ return;
388
+ }
389
+ for (const [root, e] of Object.entries(doc ?? {})) {
390
+ const at = typeof e?.at === 'string' && !Number.isNaN(Date.parse(e.at)) ? new Date(e.at) : new Date();
391
+ recordProject(root, { fingerprint: typeof e?.fingerprint === 'string' ? e.fingerprint : null }, { dir: registryDirFor(mdir), now: at });
374
392
  }
375
- for (const r of rows) doc[r.root] = { fingerprint: r.fingerprint, at: now.toISOString() };
376
- writeJson(file, doc);
393
+ fs.renameSync(file, `${file}.migrated`);
377
394
  }
378
395
 
379
396
  export function knownProjects(mdir: string = machineDir()): string[] {
380
- try {
381
- return Object.keys(JSON.parse(fs.readFileSync(path.join(mdir, 'projects.json'), 'utf-8'))).filter((r) => fs.existsSync(r));
382
- } catch {
383
- return [];
384
- }
397
+ migrateLegacyProjects(mdir);
398
+ return Object.values(readRegistry({ dir: registryDirFor(mdir) }).projects)
399
+ .map((e) => e.root)
400
+ .filter((r) => fs.existsSync(r));
385
401
  }
386
402
 
387
403
  // ---------------------------------------------------------------------------
@@ -17,8 +17,8 @@
17
17
  * decision D8, which this module does not take.
18
18
  */
19
19
 
20
+ import { detectNormalized, detectWithExtent } from '../_lib/egress_normalize.js';
20
21
  import {
21
- detect,
22
22
  EGRESS_RULES,
23
23
  type EgressRule,
24
24
  type EgressSpan,
@@ -26,8 +26,6 @@ import {
26
26
  prefixesOf,
27
27
  rawKeyPrefixPattern,
28
28
  replaceSpans,
29
- resolveOverlaps,
30
- findSpans,
31
29
  REVIEW_CREDENTIAL_ROWS,
32
30
  } from '../_lib/egress_rules.js';
33
31
  import { parseTokens, TokenScope } from '../_lib/egress_token.js';
@@ -88,9 +86,10 @@ export class CouncilEgress {
88
86
  * proven is replaced through the nearest END marker, as the line masks did.
89
87
  */
90
88
  replace(text: string): string {
91
- const spans = resolveOverlaps(findSpans(text, BUNDLE_RULES))
89
+ const at = detectWithExtent(text, BUNDLE_RULES);
90
+ const spans = at.spans
92
91
  .filter((s) => s.class === 'credential' || this.opts.identifiers)
93
- .map((s) => ({ ...s, end: maskingEnd(text, s) }));
92
+ .map((s) => ({ ...s, end: at.end(s, maskingEnd) ?? s.end }));
94
93
  spans.sort((a, b) => a.start - b.start);
95
94
  const merged: EgressSpan[] = [];
96
95
  for (const s of spans) {
@@ -124,7 +123,7 @@ export class CouncilEgress {
124
123
  for (const value of this.replaced) {
125
124
  if (body.includes(value)) return { reason: 'replaced-value-present', what: 'value replaced in this run' };
126
125
  }
127
- for (const s of detect(body)) {
126
+ for (const s of detectNormalized(body)) {
128
127
  const high = s.class === 'credential' ? s.rule !== 'generic-assignment' : s.validated && this.opts.identifiers;
129
128
  if (high) return { reason: 'high-confidence-finding', what: s.rule };
130
129
  }