@blamejs/exceptd-skills 0.12.11 → 0.12.15

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 (91) hide show
  1. package/CHANGELOG.md +243 -0
  2. package/bin/exceptd.js +299 -48
  3. package/data/_indexes/_meta.json +49 -48
  4. package/data/_indexes/activity-feed.json +13 -5
  5. package/data/_indexes/catalog-summaries.json +51 -29
  6. package/data/_indexes/chains.json +3238 -3210
  7. package/data/_indexes/frequency.json +3 -0
  8. package/data/_indexes/jurisdiction-map.json +5 -3
  9. package/data/_indexes/section-offsets.json +712 -685
  10. package/data/_indexes/theater-fingerprints.json +1 -1
  11. package/data/_indexes/token-budget.json +355 -340
  12. package/data/atlas-ttps.json +144 -129
  13. package/data/attack-techniques.json +339 -0
  14. package/data/cve-catalog.json +515 -475
  15. package/data/cwe-catalog.json +1081 -759
  16. package/data/exploit-availability.json +63 -15
  17. package/data/framework-control-gaps.json +867 -843
  18. package/data/rfc-references.json +276 -276
  19. package/keys/EXPECTED_FINGERPRINT +1 -0
  20. package/lib/auto-discovery.js +21 -4
  21. package/lib/cross-ref-api.js +39 -6
  22. package/lib/cve-curation.js +505 -47
  23. package/lib/lint-skills.js +217 -15
  24. package/lib/playbook-runner.js +1224 -183
  25. package/lib/prefetch.js +121 -8
  26. package/lib/refresh-external.js +261 -95
  27. package/lib/refresh-network.js +208 -18
  28. package/lib/schemas/manifest.schema.json +16 -0
  29. package/lib/scoring.js +83 -7
  30. package/lib/sign.js +112 -3
  31. package/lib/source-ghsa.js +219 -37
  32. package/lib/source-osv.js +381 -122
  33. package/lib/validate-catalog-meta.js +64 -9
  34. package/lib/validate-cve-catalog.js +213 -7
  35. package/lib/validate-indexes.js +88 -37
  36. package/lib/validate-playbooks.js +469 -0
  37. package/lib/verify.js +313 -16
  38. package/manifest-snapshot.json +1 -1
  39. package/manifest-snapshot.sha256 +1 -0
  40. package/manifest.json +73 -73
  41. package/orchestrator/dispatcher.js +21 -1
  42. package/orchestrator/event-bus.js +52 -8
  43. package/orchestrator/index.js +279 -20
  44. package/orchestrator/pipeline.js +63 -2
  45. package/orchestrator/scanner.js +32 -10
  46. package/orchestrator/scheduler.js +196 -20
  47. package/package.json +3 -1
  48. package/sbom.cdx.json +9 -9
  49. package/scripts/check-manifest-snapshot.js +32 -0
  50. package/scripts/check-sbom-currency.js +65 -3
  51. package/scripts/check-test-coverage.js +142 -19
  52. package/scripts/predeploy.js +110 -40
  53. package/scripts/refresh-manifest-snapshot.js +55 -4
  54. package/scripts/validate-vendor-online.js +169 -0
  55. package/scripts/verify-shipped-tarball.js +106 -3
  56. package/skills/ai-attack-surface/skill.md +18 -10
  57. package/skills/ai-c2-detection/skill.md +7 -2
  58. package/skills/ai-risk-management/skill.md +5 -4
  59. package/skills/api-security/skill.md +3 -3
  60. package/skills/attack-surface-pentest/skill.md +5 -5
  61. package/skills/cloud-security/skill.md +1 -1
  62. package/skills/compliance-theater/skill.md +8 -8
  63. package/skills/container-runtime-security/skill.md +1 -1
  64. package/skills/dlp-gap-analysis/skill.md +5 -1
  65. package/skills/email-security-anti-phishing/skill.md +1 -1
  66. package/skills/exploit-scoring/skill.md +18 -18
  67. package/skills/framework-gap-analysis/skill.md +6 -6
  68. package/skills/global-grc/skill.md +3 -2
  69. package/skills/identity-assurance/skill.md +2 -2
  70. package/skills/incident-response-playbook/skill.md +4 -4
  71. package/skills/kernel-lpe-triage/skill.md +21 -2
  72. package/skills/mcp-agent-trust/skill.md +17 -10
  73. package/skills/mlops-security/skill.md +2 -1
  74. package/skills/ot-ics-security/skill.md +1 -1
  75. package/skills/policy-exception-gen/skill.md +3 -3
  76. package/skills/pqc-first/skill.md +1 -1
  77. package/skills/rag-pipeline-security/skill.md +7 -3
  78. package/skills/researcher/skill.md +20 -3
  79. package/skills/sector-energy/skill.md +1 -1
  80. package/skills/sector-federal-government/skill.md +1 -1
  81. package/skills/sector-financial/skill.md +3 -3
  82. package/skills/sector-healthcare/skill.md +2 -2
  83. package/skills/security-maturity-tiers/skill.md +7 -7
  84. package/skills/skill-update-loop/skill.md +19 -3
  85. package/skills/supply-chain-integrity/skill.md +1 -1
  86. package/skills/threat-model-currency/skill.md +11 -11
  87. package/skills/threat-modeling-methodology/skill.md +3 -3
  88. package/skills/webapp-security/skill.md +1 -1
  89. package/skills/zeroday-gap-learn/skill.md +51 -7
  90. package/vendor/blamejs/_PROVENANCE.json +4 -1
  91. package/vendor/blamejs/worker-pool.js +38 -0
package/lib/verify.js CHANGED
@@ -8,6 +8,28 @@
8
8
  * specific keypair signed each skill. Even if the manifest is updated, a valid
9
9
  * signature requires the private key, which never enters this repository.
10
10
  *
11
+ * Byte-stability contract (must mirror lib/sign.js):
12
+ * Skill content is normalized BEFORE the signature is verified:
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/sign.js. A skill file checked
16
+ * out with core.autocrlf=true on Windows therefore verifies against
17
+ * a signature produced on Linux CI (LF). ANY change to normalize()
18
+ * requires the matching change in lib/sign.js — round-trip stability
19
+ * is a hard contract. The v0.11.x signature regression (operators
20
+ * ran `exceptd doctor --signatures` and saw 0/38) was a single
21
+ * instance of this contract drifting; do not relax it.
22
+ *
23
+ * Manifest entries are validated through validateSkillPath() before
24
+ * any file is read. A tampered manifest with `path: "../../../etc/passwd"`
25
+ * cannot escape the skills/ tree. The whole manifest is rejected on
26
+ * the first traversal attempt.
27
+ *
28
+ * The manifest object itself is validated against
29
+ * lib/schemas/manifest.schema.json before any skill is touched.
30
+ * additionalProperties=false at the skill level catches typos and
31
+ * unknown fields that would otherwise silently be dropped.
32
+ *
11
33
  * Signing ceremony: see lib/sign.js
12
34
  * Public key: keys/public.pem (tracked in repo)
13
35
  * Private key: .keys/private.pem (gitignored, kept off-repo)
@@ -28,6 +50,15 @@ const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
28
50
  const SKILLS_DIR = path.join(ROOT, 'skills');
29
51
  const PUBLIC_KEY_PATH = path.join(ROOT, 'keys', 'public.pem');
30
52
  const PRIVATE_KEY_PATH = path.join(ROOT, '.keys', 'private.pem');
53
+ const MANIFEST_SCHEMA_PATH = path.join(__dirname, 'schemas', 'manifest.schema.json');
54
+ // Audit G F4 — key-pin file. When present, lib/verify.js compares the live
55
+ // public-key fingerprint against the pinned one and fails the verify run
56
+ // if they differ (unless the operator sets KEYS_ROTATED=1). The file format
57
+ // is a single line "SHA256:<base64>" matching the publicKeyFingerprint()
58
+ // shape. The file is OPTIONAL: when missing, the gate warns-and-continues
59
+ // rather than failing — this preserves bootstrap compatibility on fresh
60
+ // clones / new key ceremonies. Patch-class semantics.
61
+ const EXPECTED_FINGERPRINT_PATH = path.join(ROOT, 'keys', 'EXPECTED_FINGERPRINT');
31
62
 
32
63
  // --- public API ---
33
64
 
@@ -42,7 +73,7 @@ function verifyAll() {
42
73
  return { valid: [], invalid: [], missing_sig: [], missing_file: [], no_key: true };
43
74
  }
44
75
 
45
- const manifest = loadManifest();
76
+ const manifest = loadManifestValidated();
46
77
  const result = { valid: [], invalid: [], missing_sig: [], missing_file: [], no_key: false };
47
78
 
48
79
  for (const skill of manifest.skills) {
@@ -65,7 +96,7 @@ function verifyOne(skillName) {
65
96
  const publicKey = loadPublicKey();
66
97
  if (!publicKey) throw new Error('No public key at keys/public.pem');
67
98
 
68
- const manifest = loadManifest();
99
+ const manifest = loadManifestValidated();
69
100
  const skill = manifest.skills.find(s => s.name === skillName);
70
101
  if (!skill) throw new Error(`Skill not in manifest: ${skillName}`);
71
102
 
@@ -81,7 +112,7 @@ function signAll() {
81
112
  const privateKey = loadPrivateKey();
82
113
  if (!privateKey) throw new Error('No private key at .keys/private.pem — run: node lib/sign.js generate-keypair');
83
114
 
84
- const manifest = loadManifest();
115
+ const manifest = loadManifestValidated();
85
116
  const result = { signed: [], errors: [] };
86
117
 
87
118
  for (const skill of manifest.skills) {
@@ -104,6 +135,56 @@ function signAll() {
104
135
 
105
136
  // --- private helpers ---
106
137
 
138
+ /**
139
+ * Normalize skill content for byte-stable verification.
140
+ *
141
+ * Strips a leading UTF-8 BOM (U+FEFF) if present, then converts CRLF
142
+ * line endings to LF. lib/sign.js applies the exact same transform —
143
+ * see the byte-stability contract in the file header.
144
+ *
145
+ * @param {string} content
146
+ * @returns {string}
147
+ */
148
+ function normalize(content) {
149
+ let s = content;
150
+ if (s.length > 0 && s.charCodeAt(0) === 0xFEFF) s = s.slice(1);
151
+ return s.replace(/\r\n/g, '\n');
152
+ }
153
+
154
+ /**
155
+ * Validate a manifest skill.path entry to prevent path traversal.
156
+ *
157
+ * skill.path MUST be a string.
158
+ * skill.path MUST start with "skills/".
159
+ * skill.path MUST NOT contain "..".
160
+ * skill.path MUST NOT contain backslashes.
161
+ *
162
+ * Same shape as lib/sign.js validateSkillPath(); the two functions
163
+ * are intentionally duplicated rather than cross-imported so the
164
+ * verify path has no runtime dependency on the sign path.
165
+ *
166
+ * @param {string} skillPath
167
+ * @returns {string}
168
+ */
169
+ function validateSkillPath(skillPath) {
170
+ if (typeof skillPath !== 'string') {
171
+ throw new Error(`[verify] manifest skill.path must be a string, got ${typeof skillPath}`);
172
+ }
173
+ // Backslash check runs BEFORE the prefix check so a Windows-style
174
+ // path ("skills\foo\skill.md") returns the clearer "use forward
175
+ // slashes" diagnostic, not the misleading "must start with skills/".
176
+ if (skillPath.includes('\\')) {
177
+ throw new Error(`[verify] manifest skill.path must use forward slashes, not backslashes: ${JSON.stringify(skillPath)}`);
178
+ }
179
+ if (!skillPath.startsWith('skills/')) {
180
+ throw new Error(`[verify] manifest skill.path must start with 'skills/': ${JSON.stringify(skillPath)}`);
181
+ }
182
+ if (skillPath.includes('..')) {
183
+ throw new Error(`[verify] manifest skill.path must not contain '..': ${JSON.stringify(skillPath)}`);
184
+ }
185
+ return skillPath;
186
+ }
187
+
107
188
  function verifySkill(skill, publicKey) {
108
189
  if (!skill.signature) {
109
190
  return { status: 'missing_sig', reason: 'No Ed25519 signature in manifest — run: node lib/sign.js sign-all' };
@@ -128,7 +209,8 @@ function verifySkill(skill, publicKey) {
128
209
  }
129
210
 
130
211
  function sign(content, privateKey) {
131
- const signature = crypto.sign(null, Buffer.from(content, 'utf8'), {
212
+ const normalized = normalize(content);
213
+ const signature = crypto.sign(null, Buffer.from(normalized, 'utf8'), {
132
214
  key: privateKey,
133
215
  dsaEncoding: 'ieee-p1363'
134
216
  });
@@ -138,7 +220,8 @@ function sign(content, privateKey) {
138
220
  function verify(content, signatureBase64, publicKey) {
139
221
  try {
140
222
  const signature = Buffer.from(signatureBase64, 'base64');
141
- return crypto.verify(null, Buffer.from(content, 'utf8'), {
223
+ const normalized = normalize(content);
224
+ return crypto.verify(null, Buffer.from(normalized, 'utf8'), {
142
225
  key: publicKey,
143
226
  dsaEncoding: 'ieee-p1363'
144
227
  }, signature);
@@ -161,6 +244,136 @@ function loadManifest() {
161
244
  return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
162
245
  }
163
246
 
247
+ /**
248
+ * Load the manifest and validate it against
249
+ * lib/schemas/manifest.schema.json + the path-traversal guard.
250
+ *
251
+ * Throws on schema violation OR traversal-pattern paths. Either case
252
+ * is a fatal-class bug — surface it loudly rather than verify-against-
253
+ * a-corrupt-manifest.
254
+ *
255
+ * @returns {object}
256
+ */
257
+ function loadManifestValidated() {
258
+ const manifest = loadManifest();
259
+ const schema = JSON.parse(fs.readFileSync(MANIFEST_SCHEMA_PATH, 'utf8'));
260
+ const errors = validateAgainstSchema(manifest, schema, 'manifest');
261
+ if (errors.length > 0) {
262
+ const detail = errors.slice(0, 10).map(e => ' - ' + e).join('\n');
263
+ const more = errors.length > 10 ? `\n ...and ${errors.length - 10} more` : '';
264
+ throw new Error(`[verify] manifest.json failed schema validation:\n${detail}${more}`);
265
+ }
266
+ if (!Array.isArray(manifest.skills)) {
267
+ throw new Error('[verify] manifest.json: skills must be an array');
268
+ }
269
+ for (const skill of manifest.skills) {
270
+ validateSkillPath(skill.path);
271
+ }
272
+ return manifest;
273
+ }
274
+
275
+ // --- JSON schema validator (subset) ---
276
+ //
277
+ // Mirrors lib/validate-cve-catalog.js's inline validator. Supports the
278
+ // schema features manifest.schema.json actually uses: type, required,
279
+ // properties, additionalProperties, items, pattern, minLength,
280
+ // minItems, $defs / $ref (root-relative only — "#/$defs/foo"). Zero
281
+ // external deps.
282
+
283
+ function typeOf(value) {
284
+ if (value === null) return 'null';
285
+ if (Array.isArray(value)) return 'array';
286
+ return typeof value;
287
+ }
288
+
289
+ function typeMatches(value, expected) {
290
+ if (Array.isArray(expected)) return expected.some(t => typeMatches(value, t));
291
+ const actual = typeOf(value);
292
+ if (expected === 'integer') return actual === 'number' && Number.isInteger(value);
293
+ return actual === expected;
294
+ }
295
+
296
+ function resolveRef(ref, root) {
297
+ if (!ref.startsWith('#/')) {
298
+ throw new Error(`[verify] unsupported $ref form (must be root-relative): ${ref}`);
299
+ }
300
+ const parts = ref.slice(2).split('/');
301
+ let cur = root;
302
+ for (const p of parts) {
303
+ if (cur === undefined || cur === null) {
304
+ throw new Error(`[verify] cannot resolve $ref ${ref}`);
305
+ }
306
+ cur = cur[p];
307
+ }
308
+ if (cur === undefined) {
309
+ throw new Error(`[verify] $ref ${ref} did not resolve`);
310
+ }
311
+ return cur;
312
+ }
313
+
314
+ function validateAgainstSchema(value, schema, here, root) {
315
+ const rootSchema = root || schema;
316
+ const errors = [];
317
+ let effectiveSchema = schema;
318
+ if (schema && schema.$ref) {
319
+ effectiveSchema = resolveRef(schema.$ref, rootSchema);
320
+ }
321
+
322
+ if (effectiveSchema.type !== undefined) {
323
+ if (!typeMatches(value, effectiveSchema.type)) {
324
+ errors.push(`${here}: expected type ${JSON.stringify(effectiveSchema.type)}, got ${typeOf(value)}`);
325
+ return errors;
326
+ }
327
+ }
328
+
329
+ const t = typeOf(value);
330
+
331
+ if (t === 'string') {
332
+ if (effectiveSchema.minLength !== undefined && value.length < effectiveSchema.minLength) {
333
+ errors.push(`${here}: string shorter than minLength ${effectiveSchema.minLength}`);
334
+ }
335
+ if (effectiveSchema.pattern !== undefined) {
336
+ const re = new RegExp(effectiveSchema.pattern);
337
+ if (!re.test(value)) {
338
+ errors.push(`${here}: string ${JSON.stringify(value)} does not match pattern /${effectiveSchema.pattern}/`);
339
+ }
340
+ }
341
+ if (effectiveSchema.format === 'uri') {
342
+ try { new URL(value); } catch { errors.push(`${here}: not a valid URI`); }
343
+ }
344
+ }
345
+
346
+ if (t === 'array') {
347
+ if (effectiveSchema.minItems !== undefined && value.length < effectiveSchema.minItems) {
348
+ errors.push(`${here}: array shorter than minItems ${effectiveSchema.minItems}`);
349
+ }
350
+ if (effectiveSchema.items !== undefined) {
351
+ value.forEach((item, idx) => {
352
+ errors.push(...validateAgainstSchema(item, effectiveSchema.items, `${here}[${idx}]`, rootSchema));
353
+ });
354
+ }
355
+ }
356
+
357
+ if (t === 'object') {
358
+ if (effectiveSchema.required) {
359
+ for (const req of effectiveSchema.required) {
360
+ if (!(req in value)) errors.push(`${here}: missing required field "${req}"`);
361
+ }
362
+ }
363
+ const props = effectiveSchema.properties || {};
364
+ const allowAdditional = effectiveSchema.additionalProperties !== false;
365
+ for (const [k, v] of Object.entries(value)) {
366
+ if (k in props) {
367
+ errors.push(...validateAgainstSchema(v, props[k], `${here}.${k}`, rootSchema));
368
+ } else if (!allowAdditional) {
369
+ errors.push(`${here}: unexpected property "${k}"`);
370
+ }
371
+ }
372
+ }
373
+
374
+ return errors;
375
+ }
376
+
164
377
  /**
165
378
  * Public key fingerprint(s) of the DER-encoded SPKI public key,
166
379
  * base64-encoded. Emits both:
@@ -181,6 +394,38 @@ function loadManifest() {
181
394
  * @param {string|null} pemKey PEM-encoded public key (or null)
182
395
  * @returns {{sha256: string, sha3_512: string}|{error: string}}
183
396
  */
397
+ /**
398
+ * Audit G F4 — compare the live public-key fingerprint against the optional
399
+ * pinned fingerprint in keys/EXPECTED_FINGERPRINT. Returns one of:
400
+ * { status: 'no-pin' } — keys/EXPECTED_FINGERPRINT not present.
401
+ * Callers should warn and continue.
402
+ * { status: 'match' } — live fingerprint matches the pin.
403
+ * { status: 'mismatch', — divergence; caller should fail unless
404
+ * expected, actual, KEYS_ROTATED=1 is set in the environment.
405
+ * rotationOverride }
406
+ *
407
+ * @param {{sha256:string}|null} liveFp publicKeyFingerprint() output
408
+ * @param {string} [pinPath] optional override (testability)
409
+ */
410
+ function checkExpectedFingerprint(liveFp, pinPath) {
411
+ const p = pinPath || EXPECTED_FINGERPRINT_PATH;
412
+ if (!fs.existsSync(p)) return { status: 'no-pin' };
413
+ if (!liveFp || typeof liveFp.sha256 !== 'string') {
414
+ return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
415
+ }
416
+ const expected = fs.readFileSync(p, 'utf8').trim();
417
+ // Tolerate trailing comment / whitespace on the same line; the file's
418
+ // first non-empty line is the canonical fingerprint.
419
+ const firstLine = expected.split(/\r?\n/).map((l) => l.trim()).find((l) => l.length > 0) || '';
420
+ if (firstLine === liveFp.sha256) return { status: 'match' };
421
+ return {
422
+ status: 'mismatch',
423
+ expected: firstLine,
424
+ actual: liveFp.sha256,
425
+ rotationOverride: process.env.KEYS_ROTATED === '1',
426
+ };
427
+ }
428
+
184
429
  function publicKeyFingerprint(pemKey) {
185
430
  if (!pemKey) return { sha256: '(no key)', sha3_512: '(no key)' };
186
431
  try {
@@ -238,24 +483,76 @@ if (require.main === module) {
238
483
  if (result.no_key) process.exit(1);
239
484
 
240
485
  const total = Object.values(result).filter(Array.isArray).flat().length;
241
- // Compute + print the public key fingerprints so operators can pin
242
- // the key out-of-band. Without this, a swapped keys/public.pem
243
- // would still produce a "verified" message undetectable from the
244
- // exit code alone. Dual fingerprint (SHA-256 + SHA3-512) gives
245
- // ssh-keygen compatibility AND a SHA-3 family diversity hedge.
486
+ // S5 ordering: verdict line first, fingerprint banner after.
487
+ // An operator scanning `gh run watch` output should never see a
488
+ // fingerprint banner without first seeing whether the verdict
489
+ // was pass or fail. The previous order printed the success
490
+ // summary then the fingerprint; if verification was actually
491
+ // failing (TAMPERED / UNSIGNED / MISSING) the success line was
492
+ // never reached but the fingerprint had already been printed,
493
+ // which can read as "success" at a glance.
494
+ if (result.invalid.length > 0) {
495
+ console.error(`\n[verify] ${result.invalid.length}/${total} FAILED — TAMPERED: ${result.invalid.join(', ')}`);
496
+ } else if (result.missing_sig.length > 0) {
497
+ console.warn(`\n[verify] ${result.missing_sig.length}/${total} UNSIGNED: ${result.missing_sig.join(', ')}`);
498
+ } else if (result.missing_file.length > 0) {
499
+ console.error(`\n[verify] ${result.missing_file.length}/${total} MISSING: ${result.missing_file.join(', ')}`);
500
+ } else {
501
+ console.log(`\n[verify] All skills verified. ${result.valid.length}/${total} skills passed Ed25519 verification.`);
502
+ }
503
+
504
+ // Fingerprint banner comes AFTER the verdict.
246
505
  const pubKey = loadPublicKey();
247
506
  const fp = publicKeyFingerprint(pubKey);
248
- console.log(`\n[verify] ${result.valid.length}/${total} skills passed Ed25519 verification.`);
249
507
  console.log(`[verify] Public key: keys/public.pem`);
250
508
  console.log(`[verify] ${fp.sha256}`);
251
509
  console.log(`[verify] ${fp.sha3_512}`);
252
510
 
253
- if (result.invalid.length > 0) { console.error('[verify] TAMPERED:', result.invalid.join(', ')); process.exit(1); }
254
- if (result.missing_sig.length > 0) { console.warn('[verify] UNSIGNED:', result.missing_sig.join(', ')); process.exit(1); }
255
- if (result.missing_file.length > 0) { console.error('[verify] MISSING:', result.missing_file.join(', ')); process.exit(1); }
511
+ // Audit G F4 pin check. When keys/EXPECTED_FINGERPRINT exists, the
512
+ // live fingerprint MUST match it (or KEYS_ROTATED=1 must be set to
513
+ // intentionally override). When the file is absent, emit a single-line
514
+ // warning but continue — fresh clones / bootstrap workflows should not
515
+ // fail the gate before the operator has committed a fingerprint.
516
+ const pinResult = checkExpectedFingerprint(fp);
517
+ if (pinResult.status === 'no-pin') {
518
+ console.warn(
519
+ `[verify] WARN: keys/EXPECTED_FINGERPRINT not present — key-pin check skipped. ` +
520
+ `Create it with the current ${fp.sha256} line to enable pinning.`
521
+ );
522
+ } else if (pinResult.status === 'mismatch') {
523
+ if (pinResult.rotationOverride) {
524
+ console.warn(
525
+ `[verify] WARN: live key fingerprint ${pinResult.actual} differs from pin ` +
526
+ `${pinResult.expected}. KEYS_ROTATED=1 set — accepting rotation. ` +
527
+ `Update keys/EXPECTED_FINGERPRINT to lock the new pin.`
528
+ );
529
+ } else {
530
+ console.error(
531
+ `[verify] FAIL: live key fingerprint ${pinResult.actual} does not match ` +
532
+ `keys/EXPECTED_FINGERPRINT ${pinResult.expected}. ` +
533
+ `If this is an intentional rotation, re-run with KEYS_ROTATED=1 and ` +
534
+ `then commit the new fingerprint to keys/EXPECTED_FINGERPRINT.`
535
+ );
536
+ process.exit(1);
537
+ }
538
+ }
539
+
540
+ if (result.invalid.length > 0) process.exit(1);
541
+ if (result.missing_sig.length > 0) process.exit(1);
542
+ if (result.missing_file.length > 0) process.exit(1);
256
543
 
257
- console.log('[verify] All skills verified.');
258
544
  process.exit(0);
259
545
  }
260
546
 
261
- module.exports = { verifyAll, verifyOne, signAll };
547
+ module.exports = {
548
+ verifyAll,
549
+ verifyOne,
550
+ signAll,
551
+ normalize,
552
+ validateSkillPath,
553
+ loadManifestValidated,
554
+ validateAgainstSchema,
555
+ publicKeyFingerprint,
556
+ checkExpectedFingerprint,
557
+ EXPECTED_FINGERPRINT_PATH,
558
+ };
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "_comment": "Auto-generated by scripts/refresh-manifest-snapshot.js — do not hand-edit. Public skill surface used by check-manifest-snapshot.js to detect breaking removals.",
3
- "_generated_at": "2026-05-13T21:19:34.827Z",
3
+ "_generated_at": "2026-05-14T15:55:39.383Z",
4
4
  "atlas_version": "5.1.0",
5
5
  "skill_count": 38,
6
6
  "skills": [
@@ -0,0 +1 @@
1
+ ca9d31e533c9d494e1ac5875e0a45176101438c3d75d44387187e367ccae21ad manifest-snapshot.json