@blamejs/exceptd-skills 0.12.22 → 0.12.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/prefetch.js CHANGED
@@ -18,11 +18,10 @@
18
18
  * rfc/<doc-name>.json — IETF Datatracker doc record
19
19
  * pins/<owner>__<repo>__releases.json — MITRE GitHub releases listing
20
20
  *
21
- * K: the registered source names in SOURCES below are `rfc` and
22
- * `pins`. Earlier comments + --help text said `ietf` and `github`; an
23
- * operator running `--source ietf` or `--source github` would hit "unknown
24
- * source" because no such key exists. The names below are the canonical
25
- * ones consumed by --source filtering.
21
+ * The registered source names in SOURCES below are `rfc` and `pins`.
22
+ * `--source ietf` or `--source github` would hit "unknown source"
23
+ * because no such key exists. The names below are the canonical ones
24
+ * consumed by --source filtering.
26
25
  *
27
26
  * Usage:
28
27
  * node lib/prefetch.js # fetch everything not fresh
@@ -237,7 +236,7 @@ async function withIndexLock(cacheDir, mutator) {
237
236
  // raised when the other process is mid-unlink). Treat both as
238
237
  // "lock held, back off" rather than a fatal error.
239
238
  if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
240
- // T P1-1: PID-liveness check. Same pattern as withCatalogLock in
239
+ // PID-liveness check. Same pattern as withCatalogLock in
241
240
  // lib/refresh-external.js — read the lockfile's PID, probe with
242
241
  // process.kill(pid, 0); ESRCH → holder dead, reclaim immediately;
243
242
  // EPERM → holder alive (different user), keep waiting. The mtime
@@ -322,11 +321,12 @@ function isFresh(idx, source, id, maxAgeMs) {
322
321
 
323
322
  function authHeadersForSource(source) {
324
323
  if (source === "nvd" && process.env.NVD_API_KEY) return { apiKey: process.env.NVD_API_KEY };
325
- // J: the registered source name for MITRE GitHub releases is
326
- // `pins` (see SOURCES above). The prior check looked for `github`, so
327
- // GITHUB_TOKEN never reached the per-request Authorization header and
328
- // anonymous-rate-limited fetches were always used even when an operator
329
- // had supplied a token. Accept both spellings so this is forgiving of
324
+ // The registered source name for MITRE GitHub releases is `pins`
325
+ // (see SOURCES above). Accept both `pins` and `github` so GITHUB_TOKEN
326
+ // reaches the per-request Authorization header regardless of which
327
+ // spelling the operator's automation uses; without this, anonymous
328
+ // rate-limited fetches happen even when a token is configured. Be
329
+ // forgiving of
330
330
  // the historical naming and the registered name.
331
331
  if ((source === "pins" || source === "github") && process.env.GITHUB_TOKEN) {
332
332
  return { Authorization: `Bearer ${process.env.GITHUB_TOKEN}` };
@@ -415,14 +415,14 @@ async function prefetch(options = {}) {
415
415
  const dir = path.dirname(targetPath);
416
416
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
417
417
  const body = JSON.stringify(res.json, null, 2) + "\n";
418
- // T P1-3: stage the payload to a same-volume tmp file BEFORE
419
- // attempting to acquire the index lock. If withIndexLock fails
420
- // (timeout after MAX_RETRIES), we want the partially-completed
421
- // download discarded — not left on disk as an orphan payload
422
- // with no index entry. Air-gap operators feed off `readCached`,
423
- // which consults the index; an unindexed payload silently becomes
424
- // junk taking cache space. Pattern: stage → lock → rename+index
425
- // release. The rename is atomic same-volume; if it fails inside
418
+ // Stage the payload to a same-volume tmp file BEFORE attempting
419
+ // to acquire the index lock. If withIndexLock fails (timeout
420
+ // after MAX_RETRIES), the partially-completed download must be
421
+ // discarded — not left on disk as an orphan payload with no
422
+ // index entry. Air-gap operators feed off `readCached`, which
423
+ // consults the index; an unindexed payload silently becomes junk
424
+ // taking cache space. Pattern: stage → lock → rename+index
425
+ // release. The rename is atomic same-volume; if it fails inside
426
426
  // the lock we clean up the tmp file. If we never reach the rename
427
427
  // (lock acquisition throws), the tmp file is unlinked in the
428
428
  // catch block below.
@@ -520,8 +520,8 @@ function readCached(cacheDir, source, id, opts = {}) {
520
520
  const idx = loadIndex(cacheDir);
521
521
  const meta = idx.entries[entryKey(source, id)];
522
522
  if (!meta) return null;
523
- // L: when `fetched_at` is missing / non-string / unparseable,
524
- // `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false
523
+ // When `fetched_at` is missing / non-string / unparseable,
524
+ // `new Date(undefined).getTime()` is NaN and `NaN > maxAgeMs` is false,
525
525
  // so the cached entry would have been returned as if fresh. Treat any
526
526
  // non-finite age as "no provenance, refuse" unless the caller explicitly
527
527
  // opted into allowStale.
@@ -94,11 +94,9 @@ function parseArgs(argv) {
94
94
  else if (a.startsWith("--from-fixture=")) out.fromFixture = a.slice("--from-fixture=".length);
95
95
  else if (a === "--report-out") out.reportOut = argv[++i];
96
96
  else if (a.startsWith("--report-out=")) out.reportOut = a.slice("--report-out=".length);
97
- // FF P1-3: previously only EXCEPTD_AIR_GAP=1 reached the GHSA/OSV source
98
- // modules the CLI flag was undocumented in parseArgs, so a downstream
99
- // operator following the documented `--air-gap` path silently allowed
100
- // network calls. Now the flag is honoured; env var still works as a
101
- // fallback so existing automation isn't broken.
97
+ // Honour `--air-gap` here so it reaches the GHSA/OSV source modules.
98
+ // EXCEPTD_AIR_GAP=1 still works as an env-var fallback so existing
99
+ // automation isn't broken.
102
100
  else if (a === "--air-gap") out.airGap = true;
103
101
  }
104
102
  return out;
@@ -852,10 +850,9 @@ function loadCtx(opts) {
852
850
  d3fendCatalog: JSON.parse(fs.readFileSync(ABS("data/d3fend-catalog.json"), "utf8")),
853
851
  fixtures: null,
854
852
  cacheDir: null,
855
- // FF P1-3: thread --air-gap (or EXCEPTD_AIR_GAP=1) through to ctx.airGap
856
- // so the GHSA + OSV source modules (lib/source-ghsa.js, lib/source-osv.js)
857
- // can branch on ctx.airGap and refuse network egress. Pre-fix the GHSA/OSV
858
- // sources only saw `ctx?.airGap` as undefined when the CLI flag was used.
853
+ // Thread --air-gap (or EXCEPTD_AIR_GAP=1) through to ctx.airGap so the
854
+ // GHSA + OSV source modules (lib/source-ghsa.js, lib/source-osv.js)
855
+ // branch on it and refuse network egress.
859
856
  airGap: !!(opts && opts.airGap) || process.env.EXCEPTD_AIR_GAP === "1",
860
857
  };
861
858
  if (opts.fromFixture) {
@@ -949,15 +946,15 @@ async function withCatalogLock(catalogPath, mutator) {
949
946
  // Windows the same race surfaces as EPERM (sharing-violation raised
950
947
  // when the holder is mid-unlink). Treat both as "lock held, back off."
951
948
  if (e.code !== "EEXIST" && e.code !== "EPERM") throw e;
952
- // T P1-1: PID-liveness check before falling back to mtime. The
953
- // lockfile already contains String(process.pid) of the holder; parse
954
- // it and probe with `process.kill(pid, 0)`. ESRCH means the holder is
955
- // dead — reclaim immediately rather than waiting STALE_LOCK_MS for
956
- // the mtime gate to expire. EPERM (holder alive, different user) is
957
- // treated as "alive, keep waiting." The mtime gate remains as a
958
- // belt-and-suspenders for the case where the lockfile content is
959
- // missing / malformed / belongs to a recycled PID. Matches the PID
960
- // pattern in orchestrator/index.js _acquireWatchLock and
949
+ // PID-liveness check before falling back to mtime. The lockfile
950
+ // contains String(process.pid) of the holder; parse it and probe with
951
+ // `process.kill(pid, 0)`. ESRCH means the holder is dead — reclaim
952
+ // immediately rather than waiting STALE_LOCK_MS for the mtime gate
953
+ // to expire. EPERM (holder alive, different user) is treated as
954
+ // "alive, keep waiting." The mtime gate remains as a belt-and-
955
+ // suspenders for cases where the lockfile content is missing /
956
+ // malformed / belongs to a recycled PID. Matches the PID pattern in
957
+ // orchestrator/index.js _acquireWatchLock and
961
958
  // lib/playbook-runner.js pidAlive().
962
959
  let reclaimedByPid = false;
963
960
  try {
@@ -418,27 +418,48 @@ async function main() {
418
418
  // re-bootstrap. Missing EXPECTED_FINGERPRINT file → warn-and-continue
419
419
  // (don't break existing installs whose tree predates the pin file).
420
420
  const expectedFingerprintPath = path.join(ROOT, "keys", "EXPECTED_FINGERPRINT");
421
+ if (fs.existsSync(expectedFingerprintPath) && process.env.KEYS_ROTATED === "1") {
422
+ process.emitWarning(
423
+ `EXPECTED_FINGERPRINT pin check skipped via KEYS_ROTATED=1 during refresh-network. ` +
424
+ `Update keys/EXPECTED_FINGERPRINT to lock the new pin once rotation completes.`,
425
+ { code: 'EXCEPTD_KEYS_ROTATED_OVERRIDE' }
426
+ );
427
+ }
421
428
  if (fs.existsSync(expectedFingerprintPath) && !process.env.KEYS_ROTATED) {
422
429
  try {
423
- // KK P1-5: route through the shared lib/verify loader so a BOM-prefixed
424
- // pin file (Notepad with files.encoding=utf8bom) is tolerated identically
425
- // across every verify site. Pre-fix the inline split-trim-find returned
430
+ // Route through the shared lib/verify loader so a BOM-prefixed pin
431
+ // file (Notepad with files.encoding=utf8bom) is tolerated identically
432
+ // across every verify site. An inline split-trim-find would retain
426
433
  // the BOM as part of the first line, which would never match a live
427
434
  // fingerprint and would block every legitimate refresh-network run.
428
435
  const { loadExpectedFingerprintFirstLine } = require("./verify.js");
429
436
  const expectedFp = loadExpectedFingerprintFirstLine(expectedFingerprintPath);
430
- // v0.12.16 (codex P1 PR #11): `expectedFp` is read verbatim from
431
- // keys/EXPECTED_FINGERPRINT (formatted as `SHA256:<base64>`), but
437
+ // Pin file present but the loader returned null. The loader refuses
438
+ // UTF-16LE / UTF-16BE pin files and any other shape that cannot be
439
+ // safely decoded. Treat this as a hard fail: the operator placed a
440
+ // pin file but the bytes are not consumable, so we must not fall
441
+ // through to an unpinned refresh. Re-save the pin as UTF-8 (with or
442
+ // without BOM) and retry.
443
+ if (expectedFp === null) {
444
+ emit({
445
+ ok: false,
446
+ error: `keys/EXPECTED_FINGERPRINT exists but its bytes could not be parsed (likely UTF-16 BOM or non-UTF-8 encoding)`,
447
+ pin_path: expectedFingerprintPath,
448
+ hint: "Re-save the pin file as UTF-8 (the loader accepts UTF-8-with-BOM). Refusing to swap on --network with an unparseable pin.",
449
+ }, opts.json);
450
+ process.exitCode = 5; return;
451
+ }
452
+ // The pin file is canonically formatted as `SHA256:<base64>`, but
432
453
  // `fingerprintPublicKey()` returns the raw base64 without the
433
454
  // `SHA256:` prefix. Comparing the two raw strings would refuse every
434
455
  // legitimate run unless KEYS_ROTATED=1 was set. Normalize by stripping
435
456
  // the prefix from the pin file before compare. lib/verify.js's
436
457
  // checkExpectedFingerprint() does the symmetric thing (adds the
437
458
  // prefix to localFp); either side works as long as one is canonical.
438
- const expectedFpBase64 = expectedFp && expectedFp.startsWith("SHA256:")
459
+ const expectedFpBase64 = expectedFp.startsWith("SHA256:")
439
460
  ? expectedFp.slice("SHA256:".length)
440
461
  : expectedFp;
441
- if (expectedFpBase64 && expectedFpBase64 !== localFp) {
462
+ if (expectedFpBase64 !== localFp) {
442
463
  emit({
443
464
  ok: false,
444
465
  error: `local keys/public.pem fingerprint diverges from keys/EXPECTED_FINGERPRINT pin`,
@@ -687,13 +708,13 @@ if (require.main === module) {
687
708
  module.exports = {
688
709
  parseTar,
689
710
  fingerprintPublicKey,
690
- // A: exported for tests/normalize-contract.test.js so the
691
- // byte-stability contract can be asserted across all four normalize()
692
- // implementations (lib/sign.js, lib/verify.js, lib/refresh-network.js,
711
+ // Exported for tests/normalize-contract.test.js so the byte-stability
712
+ // contract can be asserted across all four normalize() implementations
713
+ // (lib/sign.js, lib/verify.js, lib/refresh-network.js,
693
714
  // scripts/verify-shipped-tarball.js).
694
715
  normalizeSkillBytes,
695
- // B + Q P1: exported for in-process tests of the refresh
696
- // path's manifest envelope check.
716
+ // Exported for in-process tests of the refresh path's manifest envelope
717
+ // check.
697
718
  verifyTarballManifestSignature,
698
719
  canonicalManifestBytesForRefresh,
699
720
  };
package/lib/scoring.js CHANGED
@@ -92,7 +92,7 @@ function score(cveId, catalog) {
92
92
  }
93
93
 
94
94
  /**
95
- * E10: Validate an RWEP factor bag. Returns an array of warning strings
95
+ * Validate an RWEP factor bag. Returns an array of warning strings
96
96
  * for missing-but-defaultable fields and out-of-range values. Does NOT
97
97
  * throw — operators wanting hard enforcement should treat a non-empty
98
98
  * return as a failure themselves.
@@ -342,13 +342,14 @@ function validate(catalog) {
342
342
  const errors = [];
343
343
  for (const [cveId, entry] of Object.entries(catalog)) {
344
344
  if (cveId.startsWith('_')) continue;
345
- // FF P1-1: skip auto-imported drafts. KEV/GHSA/OSV-discovered drafts
346
- // store a conservative-default rwep_score (poc=true, reboot=true, etc.)
345
+ // Skip auto-imported drafts. KEV/GHSA/OSV-discovered drafts store a
346
+ // conservative-default rwep_score (poc=true, reboot=true, etc.)
347
347
  // alongside `poc_available: null` and other null-until-curated factor
348
- // fields, so the recomputed-vs-stored divergence check ALWAYS fires
349
- // against them flooding the predeploy gate. Drafts are reviewed
350
- // separately via the `_auto_imported_meta.curation_needed` list and the
351
- // strict catalog validator's draft-warning tier. Once curation promotes
348
+ // fields, so the recomputed-vs-stored divergence check would always
349
+ // fire against them and flood the predeploy gate. Drafts are reviewed
350
+ // separately via the `_auto_imported_meta.curation_needed` list and
351
+ // the strict catalog validator's draft-warning tier. Once curation
352
+ // promotes
352
353
  // an entry, `_auto_imported` is cleared and full validation resumes.
353
354
  if (entry && entry._auto_imported === true) continue;
354
355
  for (const field of CVE_SCHEMA_REQUIRED) {
package/lib/sign.js CHANGED
@@ -178,9 +178,9 @@ function signAll() {
178
178
 
179
179
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
180
180
 
181
- // S5: verdict line FIRST, fingerprint banner after. An operator
182
- // scrolling output should not be able to see "fingerprint: SHA256..."
183
- // and assume success when errors > 0.
181
+ // Verdict line FIRST, fingerprint banner after. An operator scrolling
182
+ // output should not be able to see "fingerprint: SHA256..." and assume
183
+ // success when errors > 0.
184
184
  if (errors > 0) {
185
185
  console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
186
186
  } else {
@@ -343,14 +343,13 @@ function canonicalManifestBytes(manifest) {
343
343
  * Returns the manifest_signature object literal to splice into the
344
344
  * manifest top level.
345
345
  *
346
- * A: the previous shape included a `signed_at` ISO timestamp.
347
- * That field was stripped from the canonical bytes before signing (via
348
- * `delete clone.manifest_signature`), so it was NOT covered by the
349
- * signature — an attacker who replayed a known-valid signature could
350
- * rewrite `signed_at` to any value, lending false freshness authority to
351
- * a stale signature. The field is now omitted entirely. Freshness signal
352
- * lives outside the signed bytes (git-log mtime of manifest.json, npm
353
- * publish timestamp).
346
+ * The manifest_signature shape carries `algorithm` + `signature_base64`
347
+ * only no `signed_at` ISO timestamp. A `signed_at` field stripped from
348
+ * the canonical bytes before signing would be unsigned metadata; an
349
+ * attacker who replayed a known-valid signature could rewrite it to any
350
+ * value, lending false freshness authority to a stale signature.
351
+ * Freshness signal lives outside the signed bytes (git-log mtime of
352
+ * manifest.json, npm publish timestamp).
354
353
  *
355
354
  * @param {object} manifest
356
355
  * @param {string} privateKey PEM-encoded Ed25519 private key
package/lib/source-osv.js CHANGED
@@ -175,7 +175,7 @@ function osvRequestOnce({ method, reqPath, body, timeoutMs }) {
175
175
  return new Promise((resolve, reject) => {
176
176
  const t = osvTransport();
177
177
  if (t.error) {
178
- // F13: surface the validation error structurally; no retry.
178
+ // Surface the validation error structurally; no retry.
179
179
  return resolve({ ok: false, error: t.error, source: "offline" });
180
180
  }
181
181
  const { mod, host, port } = t;
@@ -539,7 +539,7 @@ function extractCvss(rec) {
539
539
  const prev = vectorsByVersion.get(ver);
540
540
  if (!prev) vectorsByVersion.set(ver, v);
541
541
  }
542
- // F10: try versions in descending order. CVSS 4.0 derivation is not yet
542
+ // Try versions in descending order. CVSS 4.0 derivation is not yet
543
543
  // implemented here — if v4 was the highest but can't be computed, walk
544
544
  // down to v3.x. Only return null when ALL versions fail.
545
545
  const versions = Array.from(vectorsByVersion.keys()).sort((a, b) => b - a);
@@ -737,7 +737,7 @@ function normalizeAdvisory(rec) {
737
737
  // OSV.dev canonical advisory URL — used as the primary vendor advisory.
738
738
  const osvUrl = `https://osv.dev/vulnerability/${encodeURIComponent(rec.id)}`;
739
739
 
740
- // F6: dedupe verification_sources. OSV records frequently carry the
740
+ // Dedupe verification_sources. OSV records frequently carry the
741
741
  // canonical osv.dev URL in references[] as well, which would otherwise
742
742
  // produce a duplicate alongside the prepended `osvUrl`.
743
743
  const verification_sources = Array.from(new Set([
@@ -746,9 +746,9 @@ function normalizeAdvisory(rec) {
746
746
  ...refUrls.slice(0, 10),
747
747
  ]));
748
748
 
749
- // F5: EPSS coverage does not extend to non-CVE identifiers. Surface this
749
+ // EPSS coverage does not extend to non-CVE identifiers. Surface this
750
750
  // explicitly so curators know to re-query if MITRE later assigns a CVE
751
- // id to the entry. Wording mirrors the MAL-2026-3083 catalog entry.
751
+ // id to the entry.
752
752
  const isCveKey = /^CVE-/i.test(catalogKey);
753
753
  const epss_note = isCveKey
754
754
  ? null
@@ -840,13 +840,13 @@ async function buildDiff(ctx) {
840
840
  const cveCatalog = ctx.cveCatalog || {};
841
841
  const existingKeys = new Set(Object.keys(cveCatalog));
842
842
  const diffs = [];
843
- // F7: distinguish unreachable (fetch failed, network or 5xx) from
843
+ // Distinguish unreachable (fetch failed, network or 5xx) from
844
844
  // normalize-rejected (record fetched but normalization produced null).
845
845
  // Operators triaging a refresh-report want to know whether to chase a
846
846
  // network outage or a malformed upstream record.
847
847
  let unreachable = 0;
848
848
  let normalizeErrors = 0;
849
- // Finding 18: ids that ARE in the catalog but skipped because of overlap
849
+ // Ids that ARE in the catalog but skipped because of overlap
850
850
  // are not "errors"; surface them so the summary doesn't read as silently
851
851
  // dropping work. Particularly useful when a curator dispatches the same
852
852
  // batch twice and wonders why nothing happened.
@@ -242,7 +242,7 @@ function main() {
242
242
  console.log(
243
243
  `\n${passed}/${total} catalogs validated${warnSuffix}${failSuffix}.`,
244
244
  );
245
- // F18: process.exitCode + return so buffered writes drain.
245
+ // process.exitCode + return so buffered writes drain.
246
246
  process.exitCode = failed === 0 ? 0 : 1;
247
247
  }
248
248
 
@@ -418,7 +418,7 @@ function main() {
418
418
  (warned ? `, ${warned} with warnings` : '') +
419
419
  (failed ? `, ${failed} failed` : '') + '.';
420
420
  console.log(summary);
421
- // F18: process.exitCode + return so buffered output drains.
421
+ // process.exitCode + return so buffered output drains.
422
422
  process.exitCode = failed === 0 ? 0 : 1;
423
423
  }
424
424
 
package/lib/verify.js CHANGED
@@ -164,14 +164,14 @@ function signAll() {
164
164
  const manifestSig = crypto.sign(null, canonical, {
165
165
  key: privateKey, dsaEncoding: 'ieee-p1363',
166
166
  });
167
- // A: `signed_at` is intentionally OMITTED. The previous shape
168
- // emitted a `signed_at` timestamp alongside the Ed25519 signature, but
169
- // `signed_at` was stripped from the canonical bytes before signing so
170
- // an attacker could replay a known-valid signature against the same
171
- // canonical content while rewriting `signed_at` to any value, lending
172
- // false freshness authority to a stale signature. Operators who need
173
- // freshness signal should consult the git-log mtime of manifest.json
174
- // (or the npm publish timestamp), which are external to the signed bytes.
167
+ // `signed_at` is intentionally OMITTED. A `signed_at` timestamp
168
+ // alongside the Ed25519 signature would be unsigned metadata (stripped
169
+ // from the canonical bytes before signing), so an attacker could replay
170
+ // a known-valid signature against the same canonical content while
171
+ // rewriting `signed_at` to any value lending false freshness
172
+ // authority to a stale signature. Operators who need a freshness
173
+ // signal should consult the git-log mtime of manifest.json (or the
174
+ // npm publish timestamp), which are external to the signed bytes.
175
175
  manifest.manifest_signature = {
176
176
  algorithm: 'Ed25519',
177
177
  signature_base64: manifestSig.toString('base64'),
@@ -347,11 +347,12 @@ function verifyManifestSignature(manifest) {
347
347
  if (typeof sig.signature_base64 !== 'string') {
348
348
  return { status: 'invalid', reason: 'manifest_signature.signature_base64 missing or not a string' };
349
349
  }
350
- // E: require the algorithm field to be present and exactly
351
- // 'Ed25519'. The previous form accepted a missing algorithm field
352
- // (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`) which let a
353
- // future downgrade attacker drop the field to bait a weaker default.
354
- // lib/sign.js always writes the field, so no legitimate consumer breaks.
350
+ // Require the algorithm field to be present and exactly 'Ed25519'.
351
+ // Accepting a missing algorithm field
352
+ // (`if (sig.algorithm && sig.algorithm !== 'Ed25519')`) would let a
353
+ // downgrade attacker drop the field to bait a weaker default.
354
+ // lib/sign.js always writes the field, so no legitimate consumer
355
+ // breaks.
355
356
  if (sig.algorithm !== 'Ed25519') {
356
357
  return {
357
358
  status: 'invalid',
@@ -441,10 +442,10 @@ function loadManifestValidated() {
441
442
  throw new Error(`[verify] manifest_signature verification FAILED — ${sigResult.reason}. The manifest has been modified (or signed with a different key) since last sign-all. Refusing to verify any skill against this manifest.`);
442
443
  }
443
444
  if (sigResult.status === 'missing') {
444
- // D: dedupe the legacy-tarball warning. Many CLI verbs
445
- // call loadManifestValidated() more than once per invocation; the
446
- // previous console.warn spammed stderr per call. Node's emitWarning()
447
- // with a stable `code` collapses repeated emissions automatically.
445
+ // Dedupe the legacy-tarball warning. Many CLI verbs call
446
+ // loadManifestValidated() more than once per invocation, so a plain
447
+ // console.warn would spam stderr per call. Node's emitWarning() with
448
+ // a stable `code` collapses repeated emissions automatically.
448
449
  process.emitWarning(
449
450
  'manifest.json has no top-level manifest_signature field. This tarball predates v0.12.17 manifest signing; skills will still be verified but a coordinated rewrite of manifest.json could go undetected. Re-run `node lib/sign.js sign-all` to add the signature.',
450
451
  { code: 'EXCEPTD_MANIFEST_UNSIGNED' }
@@ -594,12 +595,12 @@ function validateAgainstSchema(value, schema, here, root) {
594
595
  * @param {string} [pinPath] optional override (testability)
595
596
  */
596
597
  /**
597
- * KK P1-5: shared loader for keys/EXPECTED_FINGERPRINT. Reads the pin file,
598
- * strips a leading UTF-8 BOM (Notepad with files.encoding=utf8bom would
599
- * otherwise prepend U+FEFF and silently break every verify path on the host),
600
- * tolerates CRLF line endings, ignores comment lines (`#`) and blanks, and
601
- * returns the first non-comment / non-empty line. Returns null if the file
602
- * is unreadable / empty.
598
+ * Shared loader for keys/EXPECTED_FINGERPRINT. Reads the pin file, strips
599
+ * a leading UTF-8 BOM (Notepad with files.encoding=utf8bom would otherwise
600
+ * prepend U+FEFF and silently break every verify path on the host),
601
+ * tolerates CRLF line endings, ignores comment lines (`#`) and blanks,
602
+ * and returns the first non-comment / non-empty line. Returns null if
603
+ * the file is unreadable / empty.
603
604
  *
604
605
  * Shared across four sites so every loader normalises identically:
605
606
  * - lib/verify.js (manifest signature gate)
@@ -610,9 +611,18 @@ function validateAgainstSchema(value, schema, here, root) {
610
611
  * four sites under a BOM + CRLF fuzz corpus.
611
612
  */
612
613
  function loadExpectedFingerprintFirstLine(pinPath) {
613
- let raw;
614
- try { raw = fs.readFileSync(pinPath, 'utf8'); }
614
+ let buf;
615
+ try { buf = fs.readFileSync(pinPath); }
615
616
  catch { return null; }
617
+ if (buf.length >= 2) {
618
+ const b0 = buf[0];
619
+ const b1 = buf[1];
620
+ // UTF-16LE (FF FE) and UTF-16BE (FE FF) pin files would silently decode
621
+ // as UTF-8 mojibake — the first line never matches a live fingerprint
622
+ // and the operator sees no signal. Refuse them; re-save as UTF-8.
623
+ if ((b0 === 0xFF && b1 === 0xFE) || (b0 === 0xFE && b1 === 0xFF)) return null;
624
+ }
625
+ let raw = buf.toString('utf8');
616
626
  if (raw.length > 0 && raw.charCodeAt(0) === 0xFEFF) raw = raw.slice(1);
617
627
  const lines = raw
618
628
  .split(/\r?\n/)
@@ -627,7 +637,7 @@ function checkExpectedFingerprint(liveFp, pinPath) {
627
637
  if (!liveFp || typeof liveFp.sha256 !== 'string') {
628
638
  return { status: 'mismatch', expected: 'unknown', actual: '(invalid)', rotationOverride: false };
629
639
  }
630
- // KK P1-5: route through the shared loader so a BOM-prefixed pin file
640
+ // Route through the shared loader so a BOM-prefixed pin file
631
641
  // (Notepad with files.encoding=utf8bom) is tolerated identically across
632
642
  // every verify site. Pre-fix the verbatim split-trim-find produced a
633
643
  // first-line of "SHA256:..." (with leading BOM) that would never equal