@blamejs/exceptd-skills 0.19.32 → 0.19.34

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 (127) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/bin/exceptd.js +896 -2824
  3. package/data/_indexes/_meta.json +8 -8
  4. package/data/_indexes/activity-feed.json +2 -2
  5. package/data/_indexes/catalog-summaries.json +7 -7
  6. package/data/_indexes/chains.json +60118 -0
  7. package/data/attack-techniques.json +267 -7
  8. package/data/cve-catalog.json +9991 -3
  9. package/data/cwe-catalog.json +109 -2
  10. package/data/framework-control-gaps.json +578 -3
  11. package/data/zeroday-lessons.json +8330 -1
  12. package/lib/auto-discovery.js +56 -286
  13. package/lib/canonical-eq.js +7 -40
  14. package/lib/citation-resolve.js +22 -70
  15. package/lib/collectors/ai-api.js +20 -54
  16. package/lib/collectors/cicd-pipeline-compromise.js +40 -108
  17. package/lib/collectors/citation-hygiene.js +72 -210
  18. package/lib/collectors/containers.js +41 -130
  19. package/lib/collectors/cred-stores.js +31 -115
  20. package/lib/collectors/crypto-codebase.js +55 -138
  21. package/lib/collectors/crypto.js +24 -54
  22. package/lib/collectors/hardening.js +20 -78
  23. package/lib/collectors/kernel.js +16 -46
  24. package/lib/collectors/library-author.js +57 -206
  25. package/lib/collectors/mcp.js +24 -70
  26. package/lib/collectors/runtime.js +24 -86
  27. package/lib/collectors/sbom.js +34 -106
  28. package/lib/collectors/scan-excludes.js +31 -138
  29. package/lib/collectors/secrets.js +62 -178
  30. package/lib/cross-ref-api.js +39 -123
  31. package/lib/currency-severity.js +8 -27
  32. package/lib/cve-batch.js +13 -21
  33. package/lib/cve-cli.js +13 -20
  34. package/lib/cve-curation.js +72 -239
  35. package/lib/cve-regression-watcher.js +29 -152
  36. package/lib/cvss.js +13 -54
  37. package/lib/doctor-bucketing.js +3 -19
  38. package/lib/exit-codes.js +10 -42
  39. package/lib/flag-suggest.js +7 -25
  40. package/lib/framework-gap.js +35 -114
  41. package/lib/gap-detectors.js +37 -159
  42. package/lib/id-validation.js +9 -30
  43. package/lib/job-queue.js +13 -36
  44. package/lib/lint-skills.js +64 -232
  45. package/lib/playbook-runner.js +693 -2095
  46. package/lib/prefetch.js +100 -376
  47. package/lib/refresh-external.js +199 -627
  48. package/lib/refresh-network.js +75 -307
  49. package/lib/rfc-cli.js +23 -68
  50. package/lib/scoring.js +77 -145
  51. package/lib/sign.js +43 -229
  52. package/lib/source-advisories.js +43 -194
  53. package/lib/source-ghsa.js +37 -120
  54. package/lib/source-osv.js +94 -266
  55. package/lib/ttp-mapper.js +14 -24
  56. package/lib/upstream-check-cli.js +10 -28
  57. package/lib/upstream-check.js +19 -44
  58. package/lib/validate-catalog-meta.js +17 -61
  59. package/lib/validate-cve-catalog.js +43 -119
  60. package/lib/validate-indexes.js +25 -76
  61. package/lib/validate-package.js +16 -62
  62. package/lib/validate-playbooks.js +69 -275
  63. package/lib/validate-vendor.js +16 -49
  64. package/lib/verify.js +56 -286
  65. package/lib/version-pins.js +5 -34
  66. package/lib/worker-pool.js +11 -30
  67. package/lib/xml-tokenizer.js +47 -152
  68. package/manifest.json +53 -53
  69. package/orchestrator/dispatcher.js +17 -68
  70. package/orchestrator/event-bus.js +11 -74
  71. package/orchestrator/index.js +138 -412
  72. package/orchestrator/pipeline.js +28 -85
  73. package/orchestrator/scanner.js +34 -138
  74. package/orchestrator/scheduler.js +20 -84
  75. package/package.json +2 -2
  76. package/sbom.cdx.json +253 -253
  77. package/scripts/audit-catalog-gaps.js +9 -62
  78. package/scripts/audit-cross-skill.js +5 -31
  79. package/scripts/audit-perf.js +6 -16
  80. package/scripts/backfill-theater-test.js +7 -64
  81. package/scripts/bootstrap.js +12 -44
  82. package/scripts/build-indexes.js +40 -154
  83. package/scripts/builders/activity-feed.js +4 -14
  84. package/scripts/builders/catalog-summaries.js +3 -10
  85. package/scripts/builders/currency.js +7 -20
  86. package/scripts/builders/cwe-chains.js +7 -30
  87. package/scripts/builders/did-ladders.js +6 -13
  88. package/scripts/builders/frequency.js +5 -19
  89. package/scripts/builders/jurisdiction-clocks.js +6 -25
  90. package/scripts/builders/recipes.js +6 -14
  91. package/scripts/builders/section-offsets.js +13 -51
  92. package/scripts/builders/stale-content.js +7 -28
  93. package/scripts/builders/summary-cards.js +8 -29
  94. package/scripts/builders/theater-fingerprints.js +12 -27
  95. package/scripts/builders/token-budget.js +4 -31
  96. package/scripts/check-agents-md-collectors.js +11 -54
  97. package/scripts/check-catalog-gap-budget.js +15 -32
  98. package/scripts/check-changelog-extract.js +18 -48
  99. package/scripts/check-codebase-patterns-currency.js +6 -22
  100. package/scripts/check-codebase-patterns.js +50 -143
  101. package/scripts/check-epss-consistency.js +9 -64
  102. package/scripts/check-framework-gap-coverage.js +13 -31
  103. package/scripts/check-manifest-snapshot.js +13 -73
  104. package/scripts/check-sbom-currency.js +44 -142
  105. package/scripts/check-test-count.js +15 -52
  106. package/scripts/check-test-coverage.js +66 -197
  107. package/scripts/check-test-subjects.js +21 -62
  108. package/scripts/check-ttp-references.js +14 -38
  109. package/scripts/check-ttp-upstream.js +8 -40
  110. package/scripts/check-version-bump.js +9 -61
  111. package/scripts/check-version-tags.js +20 -121
  112. package/scripts/predeploy.js +38 -184
  113. package/scripts/refresh-manifest-snapshot.js +16 -38
  114. package/scripts/refresh-mitre-atlas.js +3 -8
  115. package/scripts/refresh-mitre-attack.js +1 -8
  116. package/scripts/refresh-mitre-d3fend.js +3 -9
  117. package/scripts/refresh-mitre-ics-attack.js +3 -8
  118. package/scripts/refresh-reverse-refs.js +27 -94
  119. package/scripts/refresh-rfc-index.js +2 -10
  120. package/scripts/refresh-sbom.js +31 -161
  121. package/scripts/refresh-upstream-catalogs.js +40 -137
  122. package/scripts/release.js +69 -232
  123. package/scripts/run-e2e-scenarios.js +24 -71
  124. package/scripts/sync-manifest-metadata.js +10 -34
  125. package/scripts/sync-package-description.js +8 -17
  126. package/scripts/validate-vendor-online.js +13 -44
  127. package/scripts/verify-shipped-tarball.js +35 -140
package/lib/sign.js CHANGED
@@ -2,78 +2,11 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * Skill signing utility — Ed25519 keypair management and skill signing.
5
+ * Ed25519 keypair management and skill signing.
6
6
  *
7
- * The private key never enters this repository. It is stored at .keys/private.pem
8
- * which is gitignored. The public key at keys/public.pem is tracked and used
9
- * by lib/verify.js for signature verification.
10
- *
11
- * Byte-stability contract (must mirror lib/verify.js):
12
- * Skill content is normalized BEFORE the bytes are signed:
13
- * 1. Strip a UTF-8 BOM (U+FEFF) if present.
14
- * 2. Convert CRLF line endings to LF.
15
- * The same normalization runs in lib/verify.js. A skill file checked
16
- * out with core.autocrlf=true on Windows therefore signs to the SAME
17
- * signature as the LF copy on Linux CI — closing the regression class
18
- * that broke v0.11.x signatures across the Windows/CI line-ending
19
- * boundary. ANY change to normalize() requires the matching change in
20
- * lib/verify.js; round-trip stability is a hard contract.
21
- *
22
- * Manifest entries are also validated before iteration: skill.path must
23
- * begin with "skills/" and must not contain ".." or backslashes (see
24
- * validateSkillPath() below). Without this a tampered manifest could
25
- * sign or verify arbitrary files outside the skills/ tree.
26
- *
27
- * Manifest signing contract (must mirror lib/verify.js):
28
- * After all individual skill signatures are written, sign-all signs
29
- * the manifest itself. The canonical bytes are computed as:
30
- * 1. Read the manifest object after all skill signatures land.
31
- * 2. Delete the top-level `manifest_signature` field if present
32
- * (idempotency — re-signing after rotation must produce the same
33
- * canonical bytes whether or not a stale signature is there).
34
- * 3. Serialize via JSON.stringify(obj, sortedTopLevelKeys, 2). The
35
- * top-level keys are stringified in lexicographic order so a
36
- * re-ordered manifest signs to the same bytes. Nested objects
37
- * keep their natural key order (skills[] entries already follow
38
- * a stable convention).
39
- * 4. Apply normalize() (CRLF→LF, BOM strip) — same transform skills
40
- * use, so the manifest signature survives any line-ending churn.
41
- * ANY change to canonicalManifestBytes() or this contract requires
42
- * the matching change in lib/verify.js. A coordinated attacker who
43
- * rewrites manifest.json + manifest-snapshot.json + manifest-snapshot.sha256
44
- * without the private key produces a manifest_signature mismatch that
45
- * lib/verify.js refuses to load.
46
- *
47
- * Windows ACL contract:
48
- * On win32, `fs.writeFileSync(..., { mode: 0o600 })` only affects
49
- * read-only attributes — it does NOT establish a POSIX-style restrictive
50
- * ACL. Any process running under the same desktop user can read the key
51
- * by default ACL inheritance from the parent. After writing
52
- * .keys/private.pem on Windows, restrictWindowsAcl() shells to icacls
53
- * to strip inherited entries and grant Full Control only to the current
54
- * user. If icacls is unavailable (Server Core, exotic shells), the call
55
- * warns to stderr and generateKeypair() returns { aclHardened: false }.
56
- * The CLI dispatch then exits non-zero so automation (bootstrap.js,
57
- * doctor --fix) does not treat an unhardened key as a clean generation —
58
- * set EXCEPTD_ALLOW_WEAK_KEY_ACL=1 to accept the weaker ACL on a
59
- * single-user host. (The key write itself still succeeds; the non-zero
60
- * exit signals "key present but not ACL-hardened," not "no key.")
61
- *
62
- * Signing ceremony:
63
- * 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
64
- * 2. node lib/sign.js sign-all — sign all skills (after any content change)
65
- * 3. node lib/verify.js — verify all signatures
66
- *
67
- * Key rotation:
68
- * 1. node lib/sign.js generate-keypair --rotate — generate new keypair, old sigs become invalid
69
- * 2. node lib/sign.js sign-all — re-sign all skills with new key
70
- * 3. Commit keys/public.pem update
71
- *
72
- * Usage:
73
- * node lib/sign.js generate-keypair [--rotate] — generate Ed25519 keypair
74
- * node lib/sign.js sign-all — sign all skills in manifest
75
- * node lib/sign.js sign <skill-name> — sign one skill
76
- * node lib/sign.js show-pubkey — print the public key
7
+ * normalize() and canonicalManifestBytes() are one half of a contract with
8
+ * lib/verify.js — a change to either requires the matching change there, or the
9
+ * verifier rejects everything this signs.
77
10
  */
78
11
 
79
12
  const fs = require('fs');
@@ -81,11 +14,8 @@ const path = require('path');
81
14
  const crypto = require('crypto');
82
15
  const { execFileSync } = require('child_process');
83
16
  const { safeExit } = require('./exit-codes');
84
- // Reuse the EXACT validator lib/verify.js applies at load time, so the signer
85
- // and the verifier can never disagree about what a well-formed manifest is.
86
- // Requiring verify.js is side-effect-free (its CLI block is guarded by
87
- // `require.main === module`) and verify.js does not require sign.js, so there
88
- // is no circular dependency.
17
+ // The verifier's own validator, so signer and verifier cannot disagree about
18
+ // what a well-formed manifest is. verify.js does not require sign.js.
89
19
  const { validateAgainstSchema } = require('./verify');
90
20
 
91
21
  const ROOT = path.join(__dirname, '..');
@@ -96,14 +26,9 @@ const PRIVATE_KEY_PATH = path.join(KEYS_DIR, 'private.pem');
96
26
  const PUBLIC_KEY_PATH = path.join(PUBLIC_KEYS_DIR, 'public.pem');
97
27
  const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
98
28
 
99
- // --- public API ---
100
-
101
29
  /**
102
- * Generate an Ed25519 keypair.
103
- * Private key → .keys/private.pem (gitignored)
104
- * Public key → keys/public.pem (tracked)
105
- *
106
- * @param {{ rotate: boolean }} options
30
+ * Generate an Ed25519 keypair: private key → .keys/private.pem, public key →
31
+ * keys/public.pem. `aclHardened` comes back false on win32 when icacls failed.
107
32
  */
108
33
  function generateKeypair({ rotate = false } = {}) {
109
34
  fs.mkdirSync(KEYS_DIR, { recursive: true, mode: 0o700 });
@@ -114,14 +39,9 @@ function generateKeypair({ rotate = false } = {}) {
114
39
  publicKeyEncoding: { type: 'spki', format: 'pem' }
115
40
  });
116
41
 
117
- // Atomic create-exclusive (flag 'wx' = O_CREAT|O_EXCL): the open itself
118
- // refuses an existing key — there is no separate existsSync to race against,
119
- // and exclusive-create won't follow a symlink/file an attacker preplanted at
120
- // the key path. EEXIST translates to the same operator-facing refusals the
121
- // bootstrap has always surfaced; --rotate is a deliberate re-key (flag 'w').
122
- // The public-key refusal is the v0.11.x signature-regression guard: a host
123
- // with a working pubkey but no privkey must NOT silently regenerate the
124
- // pubkey (that orphans every shipped signature) — force --rotate to confirm.
42
+ // 'wx' (O_CREAT|O_EXCL) refuses an existing key in the open itself: no
43
+ // existsSync to race against, and no following a preplanted symlink. Only
44
+ // --rotate re-keys ('w'); silently regenerating orphans every signature.
125
45
  const openFlag = rotate ? 'w' : 'wx';
126
46
  let privFd;
127
47
  try {
@@ -139,8 +59,7 @@ function generateKeypair({ rotate = false } = {}) {
139
59
  pubFd = fs.openSync(PUBLIC_KEY_PATH, openFlag, 0o644);
140
60
  } catch (e) {
141
61
  fs.closeSync(privFd);
142
- // A pubkey clash after we exclusively created the privkey must not leave a
143
- // half-written signing identity behind.
62
+ // A public-key clash must not leave a half signing identity behind.
144
63
  try { fs.unlinkSync(PRIVATE_KEY_PATH); } catch { /* best effort */ }
145
64
  if (e.code === 'EEXIST') {
146
65
  console.error('[sign] Public key already exists at keys/public.pem but no matching private key.');
@@ -158,9 +77,6 @@ function generateKeypair({ rotate = false } = {}) {
158
77
  fs.closeSync(pubFd);
159
78
  }
160
79
 
161
- // on win32, fs.writeFileSync `mode` does not produce
162
- // a POSIX-style restrictive ACL. Tighten via icacls so other desktop
163
- // users on the same workstation / CI runner can't read the key.
164
80
  const aclHardened = restrictWindowsAcl(PRIVATE_KEY_PATH);
165
81
 
166
82
  if (rotate) {
@@ -176,10 +92,6 @@ function generateKeypair({ rotate = false } = {}) {
176
92
 
177
93
  console.log('\nNext steps:');
178
94
  if (rotate) {
179
- // After --rotate the private key IS present, so `doctor --fix`'s
180
- // missing-key path won't fire. Tell the operator to re-sign
181
- // directly. (doctor --fix v0.12.41+ also detects this case and
182
- // chains sign-all, so either path converges.)
183
95
  console.log(' 1. exceptd doctor --fix — detects post-rotate stale signatures and chains sign-all');
184
96
  console.log(' (or: node $(exceptd path)/lib/sign.js sign-all — re-sign directly)');
185
97
  console.log(' 2. exceptd doctor — confirm signatures verify against the new public key');
@@ -192,23 +104,13 @@ function generateKeypair({ rotate = false } = {}) {
192
104
  return { aclHardened };
193
105
  }
194
106
 
195
- /**
196
- * Sign all skills in manifest.json using the private key.
197
- * Updates manifest.json with Ed25519 signatures.
198
- *
199
- * Each manifest entry's `path` is validated through validateSkillPath()
200
- * BEFORE the file is read — a tampered manifest with an out-of-tree
201
- * path will reject the whole run.
202
- */
107
+ /** Sign every skill in manifest.json and then the manifest itself, in place. */
203
108
  function signAll() {
204
109
  const privateKey = loadPrivateKey();
205
110
  const manifest = loadManifest();
206
- // Schema-validate BEFORE any mutation/write — refuse to sign a manifest
207
- // the verifier would reject. Matches lib/verify.js signAll().
111
+ // Schema and every skill.path are checked before any I/O, so a tampered
112
+ // manifest is refused whole rather than left half-signed.
208
113
  validateManifestSchema(manifest, 'sign-all');
209
- // Validate every entry's path before doing any I/O. Reject the whole
210
- // manifest on the first traversal attempt — we never want to sign
211
- // half a manifest then exit non-zero with a partial mutation.
212
114
  for (const skill of manifest.skills) {
213
115
  validateSkillPath(skill.path);
214
116
  }
@@ -230,21 +132,14 @@ function signAll() {
230
132
  signed++;
231
133
  }
232
134
 
233
- // sign the manifest itself. Removes any existing
234
- // manifest_signature field so the canonical bytes are deterministic
235
- // across re-runs, signs with the private key, then writes the result.
236
- // A coordinated attacker who rewrites the manifest (and snapshot, and
237
- // snapshot SHA) without the private key produces an invalid manifest
238
- // signature; lib/verify.js refuses to load the manifest.
135
+ // Drop any existing signature first so the canonical bytes are re-run stable.
239
136
  delete manifest.manifest_signature;
240
137
  const manifestSig = signCanonicalManifest(manifest, privateKey);
241
138
  manifest.manifest_signature = manifestSig;
242
139
 
243
140
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
244
141
 
245
- // Verdict line FIRST, fingerprint banner after. An operator scrolling
246
- // output should not be able to see "fingerprint: SHA256..." and assume
247
- // success when errors > 0.
142
+ // Verdict before the fingerprint banner, so "SHA256..." cannot read as success.
248
143
  if (errors > 0) {
249
144
  console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
250
145
  } else {
@@ -255,15 +150,9 @@ function signAll() {
255
150
  if (errors > 0) { safeExit(1); return; }
256
151
  }
257
152
 
258
- /**
259
- * Sign a single skill by name.
260
- * @param {string} skillName
261
- */
262
153
  function signOne(skillName) {
263
154
  const privateKey = loadPrivateKey();
264
155
  const manifest = loadManifest();
265
- // Schema-validate BEFORE any mutation/write — refuse to sign a manifest
266
- // the verifier would reject. Matches lib/verify.js signAll().
267
156
  validateManifestSchema(manifest, 'sign');
268
157
  const skill = manifest.skills.find(s => s.name === skillName);
269
158
  if (!skill) { console.error(`Skill not found: ${skillName}`); process.exit(1); }
@@ -275,8 +164,7 @@ function signOne(skillName) {
275
164
  skill.signed_at = new Date().toISOString();
276
165
  delete skill.sha256;
277
166
 
278
- // P1-4: re-sign the manifest after the per-skill signature changes.
279
- // Without this a single-skill sign leaves manifest_signature stale.
167
+ // Changing one skill signature stales the manifest signature, so recompute it.
280
168
  delete manifest.manifest_signature;
281
169
  manifest.manifest_signature = signCanonicalManifest(manifest, privateKey);
282
170
 
@@ -285,21 +173,10 @@ function signOne(skillName) {
285
173
  printFingerprintBanner();
286
174
  }
287
175
 
288
- // --- helpers ---
289
-
290
176
  /**
291
- * Normalize skill content for byte-stable signing.
292
- *
293
- * Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
294
- * line endings to LF. lib/verify.js applies the exact same transform.
295
- *
296
- * Without this, a Windows checkout with core.autocrlf=true reads a
297
- * skill with \r\n while CI reads the same skill with \n — same bytes
298
- * on disk in git, different bytes in the working tree, different
299
- * signature. v0.11.x shipped 0/38 verifies for exactly this reason.
300
- *
301
- * @param {string} content
302
- * @returns {string}
177
+ * Normalize content for byte-stable signing: strip a leading UTF-8 BOM, then
178
+ * CRLF → LF, so a core.autocrlf=true checkout signs the bytes CI reads.
179
+ * lib/verify.js applies the identical transform.
303
180
  */
304
181
  function normalize(content) {
305
182
  let s = content;
@@ -308,28 +185,14 @@ function normalize(content) {
308
185
  }
309
186
 
310
187
  /**
311
- * Validate a manifest skill.path entry to prevent path traversal.
312
- *
313
- * skill.path MUST be a string.
314
- * skill.path MUST start with "skills/".
315
- * skill.path MUST NOT contain "..".
316
- * skill.path MUST NOT contain backslashes (POSIX-style forward slashes
317
- * only — manifest paths are not platform-specific).
318
- *
319
- * A tampered manifest with "../../../etc/passwd" or
320
- * "skills/foo/../../.keys/private.pem" is refused; the whole run
321
- * aborts before any file I/O.
322
- *
323
- * @param {string} skillPath
324
- * @returns {string}
188
+ * Throw unless skillPath is a POSIX-style string under "skills/" with no "..",
189
+ * so a tampered manifest cannot point the signer outside the skills tree.
325
190
  */
326
191
  function validateSkillPath(skillPath) {
327
192
  if (typeof skillPath !== 'string') {
328
193
  throw new Error(`[sign] manifest skill.path must be a string, got ${typeof skillPath}`);
329
194
  }
330
- // Backslash check runs BEFORE the prefix check so a Windows-style
331
- // path ("skills\foo\skill.md") returns the clearer "use forward
332
- // slashes" diagnostic, not the misleading "must start with skills/".
195
+ // Before the prefix check, so a backslash path reports the backslash.
333
196
  if (skillPath.includes('\\')) {
334
197
  throw new Error(`[sign] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
335
198
  }
@@ -365,21 +228,9 @@ function loadManifest() {
365
228
  }
366
229
 
367
230
  /**
368
- * Validate the manifest against lib/schemas/manifest.schema.json BEFORE
369
- * signing, mirroring lib/verify.js signAll() (which throws on schema
370
- * violation before re-signing). Without this the signer is the WEAKER
371
- * check: a manifest that is path-safe but schema-invalid (unknown
372
- * per-skill field, malformed version, bad atlas_ref, missing required
373
- * field) gets a valid manifest_signature here, then lib/verify.js
374
- * loadManifestValidated() THROWS on the same schema at install time and
375
- * refuses to verify any skill. The producer must never emit an artifact
376
- * the consumer rejects, so the signer is held to at least the verifier's
377
- * bar. The validator itself is imported from lib/verify.js so the two
378
- * halves can never drift to different rules.
379
- *
380
- * @param {object} manifest parsed manifest.json
381
- * @param {string} who 'sign-all' | 'sign' — surfaced in the error
382
- * @throws on any schema violation
231
+ * Validate the manifest against lib/schemas/manifest.schema.json before signing:
232
+ * anything schema-invalid signed here throws in lib/verify.js
233
+ * loadManifestValidated() at install time. `who` is the calling verb.
383
234
  */
384
235
  function validateManifestSchema(manifest, who) {
385
236
  const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
@@ -392,26 +243,10 @@ function validateManifestSchema(manifest, who) {
392
243
  }
393
244
 
394
245
  /**
395
- * canonical byte form of the manifest, used for both
396
- * signing (lib/sign.js) and verification (lib/verify.js).
397
- *
398
- * Contract: the same logical manifest content must produce the same bytes
399
- * regardless of (a) whether a stale manifest_signature is present, (b)
400
- * key order at any depth, (c) line endings or BOM.
401
- *
402
- * 1. Clone, delete manifest_signature.
403
- * 2. Recursively sort object keys at every depth (NOT the top-level
404
- * whitelist trap — see codex P1 PR #12: passing
405
- * `Object.keys(manifest).sort()` as the JSON.stringify replacer-array
406
- * treats it as a property allowlist applied to EVERY object level.
407
- * Nested fields like `skills[].path` and `skills[].signature` got
408
- * silently dropped from the canonical bytes, letting an attacker
409
- * swap them without breaking the signature. Now we deep-canonicalize
410
- * every object).
411
- * 3. Apply normalize() — strip leading BOM, convert CRLF → LF.
412
- *
413
- * @param {object} manifest
414
- * @returns {Buffer} canonical UTF-8 bytes
246
+ * Sort object keys recursively, at every depth. A top-level-only sort — passing
247
+ * `Object.keys(manifest).sort()` as the JSON.stringify replacer array — makes
248
+ * that array a property allowlist applied to EVERY object, dropping
249
+ * `skills[].path` and `skills[].signature` out of the signed bytes.
415
250
  */
416
251
  function canonicalize(value) {
417
252
  if (Array.isArray(value)) return value.map(canonicalize);
@@ -425,6 +260,8 @@ function canonicalize(value) {
425
260
  return value;
426
261
  }
427
262
 
263
+ // The signed bytes: identical for the same logical manifest whatever its key
264
+ // order, line endings, or leftover manifest_signature. lib/verify.js matches this.
428
265
  function canonicalManifestBytes(manifest) {
429
266
  const clone = { ...manifest };
430
267
  delete clone.manifest_signature;
@@ -433,21 +270,10 @@ function canonicalManifestBytes(manifest) {
433
270
  }
434
271
 
435
272
  /**
436
- * Sign the canonical manifest bytes with the Ed25519 private key.
437
- * Returns the manifest_signature object literal to splice into the
438
- * manifest top level.
439
- *
440
- * The manifest_signature shape carries `algorithm` + `signature_base64`
441
- * only — no `signed_at` ISO timestamp. A `signed_at` field stripped from
442
- * the canonical bytes before signing would be unsigned metadata; an
443
- * attacker who replayed a known-valid signature could rewrite it to any
444
- * value, lending false freshness authority to a stale signature.
445
- * Freshness signal lives outside the signed bytes (git-log mtime of
446
- * manifest.json, npm publish timestamp).
447
- *
448
- * @param {object} manifest
449
- * @param {string} privateKey PEM-encoded Ed25519 private key
450
- * @returns {{algorithm:'Ed25519', signature_base64:string}}
273
+ * Sign the canonical manifest bytes, returning the manifest's top-level
274
+ * `manifest_signature` object. No `signed_at` in that shape: a timestamp
275
+ * stripped from the bytes before signing is unsigned metadata, so a replayed
276
+ * signature could carry any date and lend false freshness.
451
277
  */
452
278
  function signCanonicalManifest(manifest, privateKey) {
453
279
  const bytes = canonicalManifestBytes(manifest);
@@ -462,16 +288,10 @@ function signCanonicalManifest(manifest, privateKey) {
462
288
  }
463
289
 
464
290
  /**
465
- * tighten Windows ACL on the private key.
466
- *
467
- * fs.writeFileSync({mode: 0o600}) on win32 only affects read-only
468
- * attributes; the file inherits its ACL from the parent. icacls strips
469
- * inheritance and grants Full Control only to the current user. Any
470
- * failure (icacls missing, exotic shell, environment without USERNAME)
471
- * is warned to stderr — generating the key was the load-bearing step,
472
- * ACL tightening is best-effort hardening.
473
- *
474
- * @param {string} targetPath absolute path of the private key file
291
+ * Tighten the Windows ACL on the private key: `mode: 0o600` on win32 only sets
292
+ * the read-only attribute, so the file otherwise inherits the parent's ACL and
293
+ * every desktop user can read it. Returns true off win32; returns false and
294
+ * warns on any failure, which is best-effort hardening, not the key write.
475
295
  */
476
296
  function restrictWindowsAcl(targetPath) {
477
297
  if (process.platform !== 'win32') return true;
@@ -515,8 +335,6 @@ function printFingerprintBanner() {
515
335
  }
516
336
  }
517
337
 
518
- // --- CLI ---
519
-
520
338
  if (require.main === module) {
521
339
  const cmd = process.argv[2];
522
340
  const arg = process.argv[3];
@@ -524,12 +342,8 @@ if (require.main === module) {
524
342
  switch (cmd) {
525
343
  case 'generate-keypair': {
526
344
  const { aclHardened } = generateKeypair({ rotate: process.argv.includes('--rotate') });
527
- // On win32 a failed icacls hardening leaves the private key inheriting
528
- // the parent ACL — potentially readable by other desktop users. Make
529
- // that detectable to automation (bootstrap.js, doctor --fix) instead of
530
- // burying it in a mid-banner line under a 0 exit: fail loud unless the
531
- // operator opts into the weaker ACL on a host where it is acceptable.
532
- // exitCode (not process.exit) so the buffered stdout banner drains.
345
+ // A non-zero exit so automation (bootstrap.js, doctor --fix) sees it, not a
346
+ // clean 0. safeExit, not process.exit, so the banner drains.
533
347
  if (process.platform === 'win32' && aclHardened === false) {
534
348
  if (process.env.EXCEPTD_ALLOW_WEAK_KEY_ACL === '1') {
535
349
  console.warn('[sign] WARN: private-key ACL was NOT hardened; continuing because EXCEPTD_ALLOW_WEAK_KEY_ACL=1.');