@blamejs/exceptd-skills 0.15.48 → 0.15.50

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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.50 — 2026-05-30
4
+
5
+ Hardening: `--operator` validation and the operator-text sanitizer now classify and strip Unicode threat codepoints — Trojan-Source bidirectional overrides (CVE-2021-42574), zero-width/invisible marks, C0 controls, and null — through a shared vendored codepoint-threat table, and the `--operator` rejection now names the specific codepoint family (for example "bidirectional-override codepoint") instead of a generic message. Unicode General Category C remains the reject/strip backstop, so the broader control / private-use / unassigned set is still refused.
6
+
7
+ Internal: a new codebase-pattern gate class, `bidi-codepoint-literal`, blocks raw bidi-override / zero-width / null codepoints embedded literally in source (invisible-in-review code reordering — the Trojan-Source class); source must escape them or route through the shared table.
8
+
9
+ ## 0.15.49 — 2026-05-30
10
+
11
+ Internal: a new predeploy gate, `scripts/check-codebase-patterns.js`, enforces code-shape bug classes that recurred across releases. It blocks a library-callable function that writes to stdout and then calls `process.exit()` (which truncates buffered output when the stream is piped — the class the v0.15.47 validate-cves fix addressed) and a stale or reason-less `// allow:` suppression marker, and warns on dynamic `RegExp` construction. The flagged `process.exit` sites across the catalog, playbook, package, and vendor validators were converted to the flush-safe `safeExit` form, and the dynamic-`RegExp` sites carry inline justification markers. A companion advisory, wired into the release `prepare` step, flags when the upstream pattern catalog grows a class exceptd hasn't triaged. No change to the shipped CLI surface, catalogs, or skills.
12
+
3
13
  ## 0.15.48 — 2026-05-30
4
14
 
5
15
  Internal: the release flow is now driven by a phased orchestrator, `scripts/release.js`. Each subcommand (prepare, gates, commit, push, watch, merge, tag, release) runs one idempotent, resumable phase and exits with a script-safe code; the tag phase enforces a GUARD against tag-on-stale-HEAD and version skew between `package.json`, `manifest.json`, and the CHANGELOG heading. No change to the shipped CLI, catalogs, or skills.
package/bin/exceptd.js CHANGED
@@ -63,6 +63,7 @@ const PKG_ROOT = path.resolve(__dirname, "..");
63
63
  const { EXIT_CODES, listExitCodes } = require(path.join(PKG_ROOT, "lib", "exit-codes.js"));
64
64
  const { validateIdComponent } = require(path.join(PKG_ROOT, "lib", "id-validation.js"));
65
65
  const { suggestFlag, flagsFor, VERB_FLAG_ALLOWLIST } = require(path.join(PKG_ROOT, "lib", "flag-suggest.js"));
66
+ const codepointClass = require(path.join(PKG_ROOT, "vendor", "blamejs", "codepoint-class.js"));
66
67
 
67
68
  // Union of every flag known to ANY verb. A flag that is valid somewhere but
68
69
  // not on the active verb (e.g. `--csaf-status` on `brief`) is cross-verb
@@ -1524,6 +1525,7 @@ function dispatchPlaybook(cmd, argv) {
1524
1525
  // Cc / Cf / Co / Cn — bidi overrides (U+202E "RTL OVERRIDE"),
1525
1526
  // zero-width joiners (U+200B-D), invisible format chars, private-use
1526
1527
  // codepoints, unassigned codepoints. An operator string like
1528
+ // allow:bidi-codepoint-literal — illustrative bidi-forgery example in the --operator reject-path doc comment
1527
1529
  // "alice‮evilbob" renders as "alicebobevila" in any UI that respects
1528
1530
  // bidi — a forgery surface where the attested name looks like Bob but the
1529
1531
  // bytes are Alice. Reject anything outside a positive allowlist of
@@ -1552,18 +1554,27 @@ function dispatchPlaybook(cmd, argv) {
1552
1554
  );
1553
1555
  }
1554
1556
  if (/\p{C}/u.test(normalized)) {
1555
- // Find the offending codepoint to surface a useful hint without
1556
- // round-tripping the raw bytes into the error body.
1557
+ // \p{C} (Cc/Cf/Cs/Co/Cn) is the reject gate it is strictly broader
1558
+ // than the named family regexes (bidi / C0-control / zero-width / null),
1559
+ // so it stays the backstop and catches the divergent remainder the
1560
+ // family tables miss (U+007F, U+0080-009F, private-use, unassigned).
1561
+ // The vendored codepoint tables only CLASSIFY the first offending
1562
+ // codepoint into a human family name for the hint.
1557
1563
  let offending = "";
1564
+ let family = "control / format / private-use / unassigned codepoint";
1558
1565
  for (const cp of normalized) {
1559
1566
  if (/\p{C}/u.test(cp)) {
1560
1567
  offending = "U+" + cp.codePointAt(0).toString(16).toUpperCase().padStart(4, "0");
1568
+ if (codepointClass.BIDI_RE.test(cp)) family = "bidirectional-override codepoint";
1569
+ else if (codepointClass.ZERO_WIDTH_RE.test(cp)) family = "zero-width / invisible codepoint";
1570
+ else if (cp === codepointClass.NULL_BYTE) family = "null byte";
1571
+ else if (codepointClass.C0_CTRL_RE.test(cp)) family = "C0 control character";
1561
1572
  break;
1562
1573
  }
1563
1574
  }
1564
1575
  return emitError(
1565
- `${cmd}: --operator contains a Unicode control / format / private-use / unassigned codepoint (${offending}). Bidi overrides (U+202E), zero-width joiners (U+200B–D), and format marks corrupt attestation rendering and enable name-forgery. Use printable identifiers only.`,
1566
- { verb: cmd, provided_length: args.operator.length, offending_codepoint: offending },
1576
+ `${cmd}: --operator contains a Unicode ${family} (${offending}). Bidi overrides, zero-width joiners, and format marks corrupt attestation rendering and enable name-forgery. Use printable identifiers only.`,
1577
+ { verb: cmd, provided_length: args.operator.length, offending_codepoint: offending, offending_family: family },
1567
1578
  pretty
1568
1579
  );
1569
1580
  }
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "schema_version": "1.1.0",
3
- "generated_at": "2026-05-30T19:25:42.050Z",
3
+ "generated_at": "2026-05-30T22:44:21.522Z",
4
4
  "generator": "scripts/build-indexes.js",
5
5
  "source_count": 54,
6
6
  "source_hashes": {
7
- "manifest.json": "136eb6c81cbfd016dad3104269115be5b1a1466088c8666868fd91ba5bd5fa5b",
7
+ "manifest.json": "33da1072778152239ab47e8b4ef930f702678299bfa641e297a233dc9022dbfa",
8
8
  "data/atlas-ttps.json": "878b4a08bb73c8d20396d85cf433a88f2bc5e7a8cbf7f6ab773ce7ede0a11251",
9
9
  "data/attack-techniques.json": "84fad74c8497cab922ed64b814752f54aa4620c2a938cb06642ff1510e1c5cb3",
10
10
  "data/cve-catalog.json": "7a5f4e31401505e53330cdc4b54b39f8a8b04459d6b9411676d291c583ae535f",
@@ -73,16 +73,16 @@ function walkWorkflows(root) {
73
73
  // - mapping: `on:\n push:\n pull_request_target:`
74
74
  // Heuristic accepts all four.
75
75
  function workflowHasTrigger(content, name) {
76
- if (new RegExp(`^\\s*on:\\s*['"]?${name}['"]?\\s*(?:#.*)?$`, "m").test(content)) return true;
76
+ if (new RegExp(`^\\s*on:\\s*['"]?${name}['"]?\\s*(?:#.*)?$`, "m").test(content)) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal (pull_request_target / issue_comment / pull_request), never operator/file input
77
77
  const listMatch = content.match(/^\s*on:\s*\[([^\]]*)\]/m);
78
- if (listMatch && new RegExp(`(?:^|,)\\s*['"]?${name}['"]?\\s*(?:,|$)`).test(listMatch[1])) return true;
78
+ if (listMatch && new RegExp(`(?:^|,)\\s*['"]?${name}['"]?\\s*(?:,|$)`).test(listMatch[1])) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal, never operator/file input
79
79
  // block list AND mapping forms both follow `on:\n` with indented
80
80
  // continuation lines. Capture the block and inspect for either
81
81
  // `- <name>` (list) or `<name>:` (mapping) within it.
82
82
  const blockMatch = content.match(/^\s*on:\s*\n((?:[ \t]+[^\n]+\n?)+)/m);
83
83
  if (blockMatch) {
84
- if (new RegExp(`^[ \\t]+-\\s+['"]?${name}['"]?\\s*(?:#.*)?\\s*$`, "m").test(blockMatch[1])) return true;
85
- if (new RegExp(`^[ \\t]+${name}:`, "m").test(blockMatch[1])) return true;
84
+ if (new RegExp(`^[ \\t]+-\\s+['"]?${name}['"]?\\s*(?:#.*)?\\s*$`, "m").test(blockMatch[1])) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal, never operator/file input
85
+ if (new RegExp(`^[ \\t]+${name}:`, "m").test(blockMatch[1])) return true; // allow:dynamic-regex — `name` is a hardcoded trigger literal, never operator/file input
86
86
  }
87
87
  return false;
88
88
  }
@@ -189,7 +189,7 @@ function buildEvidenceLocations(hits) {
189
189
  const uri = raw.replace(/\\/g, "/");
190
190
  const line = Number(h.line);
191
191
  const hasLine = Number.isInteger(line) && line > 0;
192
- const key = hasLine ? `${uri}${line}` : uri;
192
+ const key = hasLine ? `${uri}\u0000${line}` : uri;
193
193
  if (seen.has(key)) continue;
194
194
  seen.add(key);
195
195
  out.push(hasLine ? { uri, startLine: line } : { uri });
@@ -40,6 +40,7 @@
40
40
  const fs = require('node:fs');
41
41
  const path = require('node:path');
42
42
  const process = require('node:process');
43
+ const { safeExit } = require('./exit-codes');
43
44
 
44
45
  const REPO_ROOT = path.resolve(__dirname, '..');
45
46
  const MANIFEST_PATH = path.join(REPO_ROOT, 'manifest.json');
@@ -866,7 +867,8 @@ function main() {
866
867
  if (strictFail) {
867
868
  console.log(`[lint-skills] --strict: ${warned + (airGapWarnings ? airGapWarnings.length : 0)} warning(s) treated as failures.`);
868
869
  }
869
- process.exit(failed === 0 && orphans.length === 0 && !strictFail ? 0 : 1);
870
+ safeExit(failed === 0 && orphans.length === 0 && !strictFail ? 0 : 1);
871
+ return;
870
872
  }
871
873
 
872
874
  // Export the minimal frontmatter parser for downstream consumers
@@ -48,6 +48,7 @@ const path = require('path');
48
48
  const os = require('os');
49
49
  const crypto = require('crypto');
50
50
  const scoring = require('./scoring');
51
+ const codepointClass = require('../vendor/blamejs/codepoint-class.js');
51
52
 
52
53
  // cross-ref-api wraps catalog reads. If cve-catalog.json is corrupt
53
54
  // JSON, cross-ref-api's loadCatalog (post-v0.12.14) catches the parse
@@ -2184,9 +2185,20 @@ function sanitizeOperatorText(s) {
2184
2185
  let normalised;
2185
2186
  try { normalised = s.normalize('NFC'); }
2186
2187
  catch { return null; }
2187
- // Strip every Unicode codepoint matching General Category C
2188
- // (Cc, Cf, Cs, Co, Cn). \p{C} under the `u` flag matches all five.
2189
- const stripped = normalised.replace(/\p{C}/gu, '');
2188
+ // Two-pass strip. First remove the named threat families (bidi-override /
2189
+ // C0-control / zero-width / null) via the shared vendored codepoint tables,
2190
+ // so the family vocabulary has a single source of truth. Then strip any
2191
+ // remaining General Category C codepoint: \p{C} (Cc/Cf/Cs/Co/Cn) is the
2192
+ // backstop — it is strictly broader than the family union (also catches
2193
+ // U+007F, U+0080-009F, private-use, unassigned), so the family pass is a
2194
+ // documented-intent superset removal and the result is identical to the
2195
+ // single \p{C} strip.
2196
+ const familyStripped = normalised
2197
+ .replace(codepointClass.BIDI_RE_G, '')
2198
+ .replace(codepointClass.C0_CTRL_RE_G, '')
2199
+ .replace(codepointClass.ZW_RE_G, '')
2200
+ .replace(codepointClass.NULL_RE_G, '');
2201
+ const stripped = familyStripped.replace(/\p{C}/gu, '');
2190
2202
  const trimmed = stripped.trim();
2191
2203
  if (trimmed.length === 0) return null;
2192
2204
  // Cap at 256 codepoints (Array.from counts codepoints, not UTF-16 code
@@ -3632,7 +3644,7 @@ function evalCondition(expr, ctx, playbook) {
3632
3644
  // analyze() can surface analyze.runtime_errors[] without losing the
3633
3645
  // diagnostic.
3634
3646
  try {
3635
- return new RegExp(m[2], 'i').test(val);
3647
+ return new RegExp(m[2], 'i').test(val); // allow:dynamic-regex — m[2] is the pattern from a signed-catalog playbook condition (/…/), and construction + .test() are already wrapped in this try/catch to neutralize a malformed/pathological pattern
3636
3648
  } catch (e) {
3637
3649
  const errorRec = { _regex_eval_error: { source: m[1], expr: m[2], message: e && e.message ? String(e.message) : String(e) } };
3638
3650
  // Two sites where ctx may carry an accumulator: runOpts._runErrors
package/lib/sign.js CHANGED
@@ -77,6 +77,7 @@ const fs = require('fs');
77
77
  const path = require('path');
78
78
  const crypto = require('crypto');
79
79
  const { execFileSync } = require('child_process');
80
+ const { safeExit } = require('./exit-codes');
80
81
 
81
82
  const ROOT = path.join(__dirname, '..');
82
83
  const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
@@ -219,7 +220,7 @@ function signAll() {
219
220
  }
220
221
  printFingerprintBanner();
221
222
 
222
- if (errors > 0) process.exit(1);
223
+ if (errors > 0) { safeExit(1); return; }
223
224
  }
224
225
 
225
226
  /**
@@ -23,6 +23,7 @@
23
23
 
24
24
  const path = require("path");
25
25
  const fs = require("fs");
26
+ const { safeExit } = require("./exit-codes");
26
27
 
27
28
  const ROOT = path.resolve(__dirname, "..");
28
29
  const { fetchLatestPublished, buildFreshnessReport } = require("./upstream-check.js");
@@ -65,7 +66,8 @@ function readManifest() {
65
66
  reason: "registry probe disabled in air-gap mode",
66
67
  source: "upstream-check",
67
68
  }) + "\n");
68
- process.exit(0);
69
+ safeExit(0);
70
+ return;
69
71
  }
70
72
  const registry = await fetchLatestPublished({ timeoutMs: opts.timeoutMs });
71
73
  if (opts.raw) {
@@ -31,6 +31,7 @@
31
31
  const fs = require('node:fs');
32
32
  const path = require('node:path');
33
33
  const process = require('node:process');
34
+ const { safeExit } = require('./exit-codes');
34
35
 
35
36
  const REPO_ROOT = path.resolve(__dirname, '..');
36
37
  const DATA_DIR = path.join(REPO_ROOT, 'data');
@@ -64,10 +65,12 @@ function parseArgs(argv) {
64
65
  ' --quiet Suppress per-catalog PASS output; show failures only.\n' +
65
66
  ' --strict Promote freshness warnings to errors (used by the predeploy gate).\n',
66
67
  );
67
- process.exit(0);
68
+ safeExit(0);
69
+ return null;
68
70
  } else {
69
71
  console.error(`Unknown argument: ${a}`);
70
- process.exit(2);
72
+ safeExit(2);
73
+ return null;
71
74
  }
72
75
  }
73
76
  return opts;
@@ -208,6 +211,7 @@ function validateMeta(catalogPath, opts) {
208
211
 
209
212
  function main() {
210
213
  const opts = parseArgs(process.argv);
214
+ if (opts === null) return; // parseArgs handled --help / bad-arg and set the exit code
211
215
  const files = fs
212
216
  .readdirSync(DATA_DIR)
213
217
  .filter((f) => f.endsWith('.json'))
@@ -26,6 +26,7 @@
26
26
  const fs = require('node:fs');
27
27
  const path = require('node:path');
28
28
  const process = require('node:process');
29
+ const { safeExit } = require('./exit-codes');
29
30
 
30
31
  const REPO_ROOT = path.resolve(__dirname, '..');
31
32
  const SCHEMA_PATH = path.join(REPO_ROOT, 'lib', 'schemas', 'cve-catalog.schema.json');
@@ -82,10 +83,12 @@ function parseArgs(argv) {
82
83
  ' --quiet Suppress per-CVE PASS output; show failures only.\n' +
83
84
  ' --strict Promote advisory warnings to errors (used by the predeploy gate). Off by default.\n',
84
85
  );
85
- process.exit(0);
86
+ safeExit(0);
87
+ return null;
86
88
  } else {
87
89
  console.error(`Unknown argument: ${a}`);
88
- process.exit(2);
90
+ safeExit(2);
91
+ return null;
89
92
  }
90
93
  }
91
94
  return opts;
@@ -138,7 +141,7 @@ function validate(value, schema, schemaName, pathStr) {
138
141
  errors.push(`${here}: string shorter than minLength ${schema.minLength}`);
139
142
  }
140
143
  if (schema.pattern !== undefined) {
141
- const re = new RegExp(schema.pattern);
144
+ const re = new RegExp(schema.pattern); // allow:dynamic-regex — bundled schema.pattern, not operator input
142
145
  if (!re.test(value)) {
143
146
  errors.push(`${here}: string ${JSON.stringify(value)} does not match pattern /${schema.pattern}/`);
144
147
  }
@@ -312,6 +315,7 @@ function additionalChecks(key, entry, ctx) {
312
315
 
313
316
  function main() {
314
317
  const opts = parseArgs(process.argv);
318
+ if (opts === null) return; // parseArgs handled --help / bad-arg and set the exit code
315
319
  const schema = readJson(SCHEMA_PATH);
316
320
  const catalog = readJson(CATALOG_PATH);
317
321
  const lessons = readJson(LESSONS_PATH);
@@ -19,6 +19,7 @@
19
19
  const fs = require("fs");
20
20
  const path = require("path");
21
21
  const { spawnSync } = require("child_process");
22
+ const { safeExit } = require("./exit-codes");
22
23
 
23
24
  const ROOT = path.join(__dirname, "..");
24
25
  const ABS = (p) => path.join(ROOT, p);
@@ -154,12 +155,14 @@ function main() {
154
155
  `${packInfo.files.length} files, ` +
155
156
  `${sizeMB} MB packed / ${unpackedMB} MB unpacked.\n`
156
157
  );
157
- process.exit(0);
158
+ safeExit(0);
159
+ return;
158
160
  }
159
161
 
160
162
  process.stderr.write(`[validate-package] FAILED — ${issues.length} issue(s):\n`);
161
163
  for (const i of issues) process.stderr.write(` • ${i}\n`);
162
- process.exit(1);
164
+ safeExit(1);
165
+ return;
163
166
  }
164
167
 
165
168
  if (require.main === module) main();
@@ -70,6 +70,7 @@
70
70
  const fs = require('node:fs');
71
71
  const path = require('node:path');
72
72
  const process = require('node:process');
73
+ const { safeExit } = require('./exit-codes');
73
74
 
74
75
  const REPO_ROOT = path.resolve(__dirname, '..');
75
76
  const SCHEMA_PATH = path.join(REPO_ROOT, 'lib', 'schemas', 'playbook.schema.json');
@@ -94,10 +95,12 @@ function parseArgs(argv) {
94
95
  ' --quiet Suppress per-playbook PASS output; show failures only.\n' +
95
96
  ' --strict Treat warnings as errors (used by the predeploy gate).\n',
96
97
  );
97
- process.exit(0);
98
+ safeExit(0);
99
+ return null;
98
100
  } else {
99
101
  console.error(`Unknown argument: ${a}`);
100
- process.exit(2);
102
+ safeExit(2);
103
+ return null;
101
104
  }
102
105
  }
103
106
  return opts;
@@ -161,7 +164,7 @@ function validate(value, schema, schemaName, pathStr) {
161
164
  err(`${here}: string shorter than minLength ${schema.minLength}`);
162
165
  }
163
166
  if (schema.pattern !== undefined) {
164
- const re = new RegExp(schema.pattern);
167
+ const re = new RegExp(schema.pattern); // allow:dynamic-regex — bundled schema.pattern, not operator input
165
168
  if (!re.test(value)) {
166
169
  err(`${here}: string ${JSON.stringify(value)} does not match pattern /${schema.pattern}/`);
167
170
  }
@@ -559,6 +562,7 @@ function checkMutexReciprocity(playbooks) {
559
562
 
560
563
  function main() {
561
564
  const opts = parseArgs(process.argv);
565
+ if (opts === null) return; // parseArgs handled --help / bad-arg and set the exit code
562
566
  const schema = readJson(SCHEMA_PATH);
563
567
  const ctx = loadContext();
564
568
  const playbooks = loadPlaybooks();
@@ -617,7 +621,8 @@ function main() {
617
621
  (warned ? `, ${warned} with warnings` : '') +
618
622
  (errored ? `, ${errored} failed` : '') + '.',
619
623
  );
620
- process.exit(errored === 0 ? 0 : 1);
624
+ safeExit(errored === 0 ? 0 : 1);
625
+ return;
621
626
  }
622
627
 
623
628
  module.exports = {
@@ -20,6 +20,7 @@
20
20
  const fs = require("fs");
21
21
  const path = require("path");
22
22
  const crypto = require("crypto");
23
+ const { safeExit } = require("./exit-codes");
23
24
 
24
25
  const ROOT = path.join(__dirname, "..");
25
26
  const PROV = path.join(ROOT, "vendor", "blamejs", "_PROVENANCE.json");
@@ -71,13 +72,15 @@ function main() {
71
72
  if (issues.length === 0) {
72
73
  const fileCount = Object.keys(prov.files || {}).length;
73
74
  console.log(`[validate-vendor] vendor tree current — ${fileCount} file(s) validated against pin ${prov.pinned_commit?.slice(0, 12) || "?"}.`);
74
- process.exit(0);
75
+ safeExit(0);
76
+ return;
75
77
  }
76
78
 
77
79
  console.error("[validate-vendor] vendor tree DRIFT:");
78
80
  for (const i of issues) console.error(" • " + i);
79
81
  console.error("[validate-vendor] re-vendor instructions: vendor/blamejs/README.md");
80
- process.exit(1);
82
+ safeExit(1);
83
+ return;
81
84
  }
82
85
 
83
86
  if (require.main === module) main();
package/lib/verify.js CHANGED
@@ -520,7 +520,7 @@ function validateAgainstSchema(value, schema, here, root) {
520
520
  errors.push(`${here}: string shorter than minLength ${effectiveSchema.minLength}`);
521
521
  }
522
522
  if (effectiveSchema.pattern !== undefined) {
523
- const re = new RegExp(effectiveSchema.pattern);
523
+ const re = new RegExp(effectiveSchema.pattern); // allow:dynamic-regex — bundled schema.pattern, not operator input
524
524
  if (!re.test(value)) {
525
525
  errors.push(`${here}: string ${JSON.stringify(value)} does not match pattern /${effectiveSchema.pattern}/`);
526
526
  }
@@ -648,6 +648,7 @@ function checkExpectedFingerprint(liveFp, pinPath) {
648
648
  // Route through the shared loader so a BOM-prefixed pin file
649
649
  // (Notepad with files.encoding=utf8bom) is tolerated identically across
650
650
  // every verify site. Pre-fix the verbatim split-trim-find produced a
651
+ // allow:bidi-codepoint-literal — illustrative BOM-prefixed first-line in the pin-loader doc comment
651
652
  // first-line of "SHA256:..." (with leading BOM) that would never equal
652
653
  // a live fingerprint.
653
654
  const firstLine = loadExpectedFingerprintFirstLine(p) || '';