@blamejs/exceptd-skills 0.12.15 → 0.12.18

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/scoring.js CHANGED
@@ -3,6 +3,40 @@
3
3
  /**
4
4
  * RWEP — Real-World Exploit Priority scoring engine
5
5
  * Supplements CVSS with exploit availability, active exploitation, and operational constraints.
6
+ *
7
+ * ----------------------------------------------------------------------------
8
+ * `rwep_factors` dual-semantics (audit J F2)
9
+ * ----------------------------------------------------------------------------
10
+ * Catalog entries (data/cve-catalog.json) store `rwep_factors` as an object
11
+ * whose values are POST-WEIGHT CONTRIBUTIONS for boolean / ladder factors
12
+ * but the RAW BLAST RADIUS for `blast_radius`. The two shapes coexist because
13
+ * each surface has different requirements:
14
+ *
15
+ * cisa_kev: 0 OR +25 (post-weight contribution)
16
+ * poc_available: 0 OR +20 (post-weight contribution)
17
+ * ai_factor: 0 OR +15 (post-weight contribution)
18
+ * active_exploitation: 0 / 10 / 5 / 20 (post-weight contribution from ladder)
19
+ * blast_radius: 0..30 RAW (intentionally NOT post-weight —
20
+ * mirrors the weight ceiling so it
21
+ * reads as raw blast magnitude)
22
+ * patch_available: 0 OR -15 (post-weight contribution)
23
+ * live_patch_available: 0 OR -10 (post-weight contribution)
24
+ * reboot_required: 0 OR +5 (post-weight contribution)
25
+ *
26
+ * Operator-facing implication: summing `Object.values(rwep_factors)` produces
27
+ * the stored `rwep_score` for catalog entries because the blast weight is 30
28
+ * (matches the raw cap). This dual-shape is intentional but easy to misuse;
29
+ * direct boolean inputs should go through `scoreCustom()` instead.
30
+ *
31
+ * scoreCustom() input shape is DIFFERENT — it accepts BOOLEAN factors plus
32
+ * a numeric blast_radius and a string active_exploitation ladder value.
33
+ * `deriveRwepFromFactors()` is the shape-detecting bridge: if values look
34
+ * numeric (post-weighted), it sums; if values look boolean / string-ladder,
35
+ * it routes through scoreCustom.
36
+ *
37
+ * The semantic ambiguity is grandfathered. A clean rename (raw_factors vs
38
+ * weighted_contributions) is a minor-bump change and is deferred.
39
+ * ----------------------------------------------------------------------------
6
40
  */
7
41
 
8
42
  const CVE_SCHEMA_REQUIRED = [
@@ -28,6 +62,29 @@ const RWEP_WEIGHTS = {
28
62
  reboot_required: 5
29
63
  };
30
64
 
65
+ // audit J F4: active_exploitation ladder. Aligned with playbook-runner's
66
+ // _activeExploitationLadder so the catalog scorer and the runtime evaluator
67
+ // produce identical results for the same string value. 'unknown' contributes
68
+ // a quarter of the confirmed weight (5 points) — operationally "we have not
69
+ // confirmed, but absence of evidence is not evidence of absence; do not
70
+ // score zero on a fresh CVE that hasn't been triaged yet".
71
+ const ACTIVE_EXPLOITATION_LADDER = {
72
+ confirmed: 1.0,
73
+ suspected: 0.5,
74
+ unknown: 0.25,
75
+ none: 0,
76
+ };
77
+
78
+ // The canonical set of factor keys scoreCustom recognises. Used by
79
+ // validateFactors to flag unknown keys (audit J F8).
80
+ const RECOGNISED_FACTOR_KEYS = new Set([
81
+ 'cisa_kev', 'poc_available', 'ai_assisted_weapon', 'ai_discovered',
82
+ 'active_exploitation', 'blast_radius', 'patch_available',
83
+ 'live_patch_available', 'reboot_required',
84
+ // accepted alias for the catalog field name
85
+ 'patch_required_reboot',
86
+ ]);
87
+
31
88
  function score(cveId, catalog) {
32
89
  const entry = catalog[cveId];
33
90
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
@@ -68,13 +125,29 @@ function validateFactors(factors) {
68
125
  } else if (!aeAllowed.includes(factors.active_exploitation)) {
69
126
  warnings.push(`active_exploitation: expected one of ${aeAllowed.join(', ')}, got ${JSON.stringify(factors.active_exploitation)}`);
70
127
  }
128
+ // audit J F6: NaN diagnostics. The prior message read "expected number,
129
+ // got number (null)" because `JSON.stringify(NaN) === 'null'` and `typeof
130
+ // NaN === 'number'`. Number.isFinite catches NaN + Infinity + -Infinity
131
+ // and emits a useful message.
71
132
  if (factors.blast_radius === undefined || factors.blast_radius === null) {
72
133
  warnings.push('blast_radius: missing (treated as 0)');
73
- } else if (typeof factors.blast_radius !== 'number' || Number.isNaN(factors.blast_radius)) {
134
+ } else if (typeof factors.blast_radius !== 'number') {
74
135
  warnings.push(`blast_radius: expected number, got ${typeof factors.blast_radius} (${JSON.stringify(factors.blast_radius)})`);
136
+ } else if (Number.isNaN(factors.blast_radius)) {
137
+ warnings.push('blast_radius: NaN is not a valid numeric value (treated as 0)');
138
+ } else if (!Number.isFinite(factors.blast_radius)) {
139
+ warnings.push(`blast_radius: ${factors.blast_radius > 0 ? 'Infinity' : '-Infinity'} is not a finite numeric value (treated as 0)`);
75
140
  } else if (factors.blast_radius < 0 || factors.blast_radius > 30) {
76
141
  warnings.push(`blast_radius: ${factors.blast_radius} out of expected range [0, 30] (clamped to weight ceiling, but the value usually indicates a unit-of-measure mistake)`);
77
142
  }
143
+ // audit J F8: surface unknown factor keys so a typo'd answer file
144
+ // (`patch_avilable`, `cisa-kev`, etc.) doesn't silently default to false
145
+ // with no diagnostic.
146
+ for (const k of Object.keys(factors)) {
147
+ if (!RECOGNISED_FACTOR_KEYS.has(k)) {
148
+ warnings.push(`unknown factor: ${k} (ignored — not in the recognised key set)`);
149
+ }
150
+ }
78
151
  return warnings;
79
152
  }
80
153
 
@@ -113,8 +186,15 @@ function scoreCustom(factors, opts) {
113
186
  score += cisa_kev ? RWEP_WEIGHTS.cisa_kev : 0;
114
187
  score += poc_available ? RWEP_WEIGHTS.poc_available : 0;
115
188
  score += (ai_assisted_weapon || ai_discovered) ? RWEP_WEIGHTS.ai_factor : 0;
116
- score += active_exploitation === 'confirmed' ? RWEP_WEIGHTS.active_exploitation : 0;
117
- score += active_exploitation === 'suspected' ? Math.floor(RWEP_WEIGHTS.active_exploitation / 2) : 0;
189
+ // audit J F4 + F16: active_exploitation goes through the ladder rather
190
+ // than two hand-written branches with `Math.floor(weight/2)`. The floor
191
+ // was a no-op for even weights (20/2 = 10) but would have silently
192
+ // truncated to asymmetric results if a future operator bumped the
193
+ // weight to 21. The ladder + multiplication preserves the contribution
194
+ // exactly, including the new `unknown → 0.25 × weight = 5` mapping that
195
+ // aligns the catalog scorer with playbook-runner._activeExploitationLadder.
196
+ const aeMultiplier = ACTIVE_EXPLOITATION_LADDER[active_exploitation] ?? 0;
197
+ score += RWEP_WEIGHTS.active_exploitation * aeMultiplier;
118
198
  // v0.12.15 (audit J F1, F5): blast_radius numeric coercion must reject
119
199
  // NaN, Infinity, and strings explicitly. The prior `typeof === 'number'`
120
200
  // check passed NaN (which is `typeof === 'number'`) into `Math.min/max`
@@ -128,15 +208,60 @@ function scoreCustom(factors, opts) {
128
208
  score += live_patch_available ? RWEP_WEIGHTS.live_patch_available : 0;
129
209
  score += rebootFactor ? RWEP_WEIGHTS.reboot_required : 0;
130
210
 
211
+ // audit J F10: keep the pre-clamp value so collectWarnings consumers can
212
+ // see deduction magnitude (e.g. a -25 raw score collapsed to 0 hides the
213
+ // fact that the entry had three mitigating factors).
214
+ const rawUnclamped = score;
215
+
131
216
  // v0.12.15 (audit J F1): defense-in-depth clamp against any unforeseen
132
217
  // NaN production above (negative weight + Infinity + math edge case).
133
218
  const clamped = Number.isFinite(score) ? Math.min(100, Math.max(0, score)) : 0;
134
219
  if (opts && opts.collectWarnings) {
135
- return { score: clamped, _scoring_warnings: validateFactors(factors) };
220
+ return {
221
+ score: clamped,
222
+ _rwep_raw_unclamped: Number.isFinite(rawUnclamped) ? rawUnclamped : null,
223
+ _scoring_warnings: validateFactors(factors),
224
+ };
136
225
  }
137
226
  return clamped;
138
227
  }
139
228
 
229
+ /**
230
+ * audit J F3 + audit M P1-C bridge: derive an RWEP score from a
231
+ * `rwep_factors` object regardless of which shape it uses.
232
+ *
233
+ * - SHAPE A (boolean / string-ladder): values are booleans + an
234
+ * active_exploitation string + a numeric blast_radius. Route through
235
+ * scoreCustom() — the canonical formula.
236
+ * - SHAPE B (catalog post-weight): values are numeric contributions
237
+ * (0 / ±N) plus a numeric blast_radius. Sum the numeric values and
238
+ * clamp to [0, 100]. This is how catalog `rwep_factors` are stored.
239
+ *
240
+ * Heuristic: if every value is a number, treat as Shape B (sum). If any
241
+ * value is boolean or a recognised ladder string, treat as Shape A
242
+ * (scoreCustom). This lets the curation apply-path and the auto-discovery
243
+ * builder share one canonical derivation that handles either operator
244
+ * input style without duplicating the scoring formula.
245
+ */
246
+ function deriveRwepFromFactors(factors) {
247
+ if (!factors || typeof factors !== 'object') return 0;
248
+ const values = Object.values(factors);
249
+ if (values.length === 0) return 0;
250
+ const aeAllowed = new Set(['none', 'unknown', 'suspected', 'confirmed']);
251
+ const hasBooleanOrLadder = values.some(
252
+ (v) => typeof v === 'boolean' || (typeof v === 'string' && aeAllowed.has(v)),
253
+ );
254
+ if (hasBooleanOrLadder) {
255
+ return scoreCustom(factors);
256
+ }
257
+ // Shape B: catalog post-weight. Sum + clamp.
258
+ let sum = 0;
259
+ for (const v of values) {
260
+ if (typeof v === 'number' && Number.isFinite(v)) sum += v;
261
+ }
262
+ return Math.max(0, Math.min(100, sum));
263
+ }
264
+
140
265
  function timeline(rwepScore) {
141
266
  if (rwepScore >= 90) return { hours: 4, label: 'Immediate — live patch or isolate within 4 hours' };
142
267
  if (rwepScore >= 75) return { hours: 24, label: 'Urgent — patch or compensating controls within 24 hours' };
@@ -146,17 +271,36 @@ function timeline(rwepScore) {
146
271
  return { hours: null, label: 'Low — next scheduled maintenance' };
147
272
  }
148
273
 
149
- function compare(cveId, catalog) {
274
+ function compare(cveId, catalog, opts) {
150
275
  const entry = catalog[cveId];
151
276
  if (!entry) throw new Error(`CVE not in catalog: ${cveId}`);
152
277
 
153
- const rwep = entry.rwep_score;
278
+ // audit J F11: `--recompute` ignores the stored rwep_score and forces a
279
+ // fresh computation from rwep_factors. Useful for catching catalog drift
280
+ // (stored score grew stale relative to current weights) and for auditing
281
+ // the divergence between stored vs. formula-derived scores.
282
+ const recompute = !!(opts && opts.recompute);
283
+ let rwep;
284
+ if (recompute) {
285
+ const factors = entry.rwep_factors || {};
286
+ // The catalog's rwep_factors shape is "post-weight" (Shape B). Route
287
+ // through the shape-detecting helper so a catalog whose factors were
288
+ // hand-edited in either shape still produces a usable score.
289
+ rwep = deriveRwepFromFactors(factors);
290
+ } else {
291
+ rwep = entry.rwep_score;
292
+ }
154
293
  const cvss = entry.cvss_score;
155
294
  const cvssEquivalent = cvss * 10;
156
295
  const delta = rwep - cvssEquivalent;
157
296
 
297
+ // audit J F15: narrow the "broadly aligned" band from ±20 to ±10. The old
298
+ // ±20 band swallowed the Copy Fail RWEP-vs-CVSS divergence (delta = 12)
299
+ // where the operator-facing point is precisely that the CVSS-calibrated
300
+ // SLA is insufficient. ±10 is the tightest classifier that still treats
301
+ // ordinary CVSS rounding noise as alignment.
158
302
  let explanation = '';
159
- if (delta > 20) {
303
+ if (delta > 10) {
160
304
  explanation = `RWEP significantly higher than CVSS equivalent. Factors driving delta: `;
161
305
  const driving = [];
162
306
  if (entry.cisa_kev) driving.push('CISA KEV (+25)');
@@ -166,7 +310,7 @@ function compare(cveId, catalog) {
166
310
  if (entry.patch_required_reboot && !entry.live_patch_available) driving.push('reboot required (+5)');
167
311
  explanation += driving.join(', ');
168
312
  explanation += '. Framework patch SLAs calibrated to CVSS are insufficient for this CVE.';
169
- } else if (delta < -20) {
313
+ } else if (delta < -10) {
170
314
  explanation = `RWEP lower than CVSS equivalent. Mitigating factors: `;
171
315
  const mitigating = [];
172
316
  if (entry.patch_available) mitigating.push('patch available (-15)');
@@ -178,15 +322,20 @@ function compare(cveId, catalog) {
178
322
  explanation = 'CVSS and RWEP are broadly aligned for this CVE.';
179
323
  }
180
324
 
181
- return {
325
+ const out = {
182
326
  cve_id: cveId,
183
327
  cvss: cvss,
184
328
  rwep: rwep,
185
329
  cvss_framework_sla: timeline(cvssEquivalent),
186
330
  rwep_actual_sla: timeline(rwep),
187
331
  delta,
188
- explanation
332
+ explanation,
189
333
  };
334
+ if (recompute) {
335
+ out.stored_rwep_score = entry.rwep_score;
336
+ out.recomputed = true;
337
+ }
338
+ return out;
190
339
  }
191
340
 
192
341
  function validate(catalog) {
@@ -222,4 +371,15 @@ function validate(catalog) {
222
371
  return errors;
223
372
  }
224
373
 
225
- module.exports = { score, scoreCustom, timeline, compare, validate, validateFactors, RWEP_WEIGHTS };
374
+ module.exports = {
375
+ score,
376
+ scoreCustom,
377
+ timeline,
378
+ compare,
379
+ validate,
380
+ validateFactors,
381
+ deriveRwepFromFactors,
382
+ RWEP_WEIGHTS,
383
+ ACTIVE_EXPLOITATION_LADDER,
384
+ RECOGNISED_FACTOR_KEYS,
385
+ };
package/lib/sign.js CHANGED
@@ -24,6 +24,38 @@
24
24
  * validateSkillPath() below). Without this a tampered manifest could
25
25
  * sign or verify arbitrary files outside the skills/ tree.
26
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 but does not fail keypair generation — generating the
56
+ * key was the load-bearing step; ACL tightening is best-effort hardening
57
+ * on top.
58
+ *
27
59
  * Signing ceremony:
28
60
  * 1. node lib/sign.js generate-keypair — generate keypair (one time, per deployment)
29
61
  * 2. node lib/sign.js sign-all — sign all skills (after any content change)
@@ -44,6 +76,7 @@
44
76
  const fs = require('fs');
45
77
  const path = require('path');
46
78
  const crypto = require('crypto');
79
+ const { execFileSync } = require('child_process');
47
80
 
48
81
  const ROOT = path.join(__dirname, '..');
49
82
  const MANIFEST_PATH = path.join(ROOT, 'manifest.json');
@@ -79,6 +112,11 @@ function generateKeypair({ rotate = false } = {}) {
79
112
  fs.writeFileSync(PRIVATE_KEY_PATH, privateKey, { encoding: 'utf8', mode: 0o600 });
80
113
  fs.writeFileSync(PUBLIC_KEY_PATH, publicKey, { encoding: 'utf8', mode: 0o644 });
81
114
 
115
+ // Audit I P1-3: on win32, fs.writeFileSync `mode` does not produce
116
+ // a POSIX-style restrictive ACL. Tighten via icacls so other desktop
117
+ // users on the same workstation / CI runner can't read the key.
118
+ restrictWindowsAcl(PRIVATE_KEY_PATH);
119
+
82
120
  if (rotate) {
83
121
  console.log('[sign] Keypair rotated. All existing signatures are now invalid — run: node lib/sign.js sign-all');
84
122
  } else {
@@ -128,6 +166,16 @@ function signAll() {
128
166
  signed++;
129
167
  }
130
168
 
169
+ // Audit I P1-4: sign the manifest itself. Removes any existing
170
+ // manifest_signature field so the canonical bytes are deterministic
171
+ // across re-runs, signs with the private key, then writes the result.
172
+ // A coordinated attacker who rewrites the manifest (and snapshot, and
173
+ // snapshot SHA) without the private key produces an invalid manifest
174
+ // signature; lib/verify.js refuses to load the manifest.
175
+ delete manifest.manifest_signature;
176
+ const manifestSig = signCanonicalManifest(manifest, privateKey);
177
+ manifest.manifest_signature = manifestSig;
178
+
131
179
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
132
180
 
133
181
  // S5: verdict line FIRST, fingerprint banner after. An operator
@@ -136,7 +184,7 @@ function signAll() {
136
184
  if (errors > 0) {
137
185
  console.error(`\n[sign] FAILED — ${signed} signed, ${errors} errors.`);
138
186
  } else {
139
- console.log(`\n[sign] ${signed} skills signed.`);
187
+ console.log(`\n[sign] ${signed} skills signed. Manifest signed.`);
140
188
  }
141
189
  printFingerprintBanner();
142
190
 
@@ -160,6 +208,11 @@ function signOne(skillName) {
160
208
  skill.signed_at = new Date().toISOString();
161
209
  delete skill.sha256;
162
210
 
211
+ // P1-4: re-sign the manifest after the per-skill signature changes.
212
+ // Without this a single-skill sign leaves manifest_signature stale.
213
+ delete manifest.manifest_signature;
214
+ manifest.manifest_signature = signCanonicalManifest(manifest, privateKey);
215
+
163
216
  fs.writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
164
217
  console.log(`[sign] Signed: ${skillName}`);
165
218
  printFingerprintBanner();
@@ -244,6 +297,105 @@ function loadManifest() {
244
297
  return JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
245
298
  }
246
299
 
300
+ /**
301
+ * Audit I P1-4 — canonical byte form of the manifest, used for both
302
+ * signing (lib/sign.js) and verification (lib/verify.js).
303
+ *
304
+ * Contract: the same logical manifest content must produce the same bytes
305
+ * regardless of (a) whether a stale manifest_signature is present, (b)
306
+ * key order at any depth, (c) line endings or BOM.
307
+ *
308
+ * 1. Clone, delete manifest_signature.
309
+ * 2. Recursively sort object keys at every depth (NOT the top-level
310
+ * whitelist trap — see codex P1 PR #12: passing
311
+ * `Object.keys(manifest).sort()` as the JSON.stringify replacer-array
312
+ * treats it as a property allowlist applied to EVERY object level.
313
+ * Nested fields like `skills[].path` and `skills[].signature` got
314
+ * silently dropped from the canonical bytes, letting an attacker
315
+ * swap them without breaking the signature. Now we deep-canonicalize
316
+ * every object).
317
+ * 3. Apply normalize() — strip leading BOM, convert CRLF → LF.
318
+ *
319
+ * @param {object} manifest
320
+ * @returns {Buffer} canonical UTF-8 bytes
321
+ */
322
+ function canonicalize(value) {
323
+ if (Array.isArray(value)) return value.map(canonicalize);
324
+ if (value && typeof value === 'object') {
325
+ const out = {};
326
+ for (const key of Object.keys(value).sort()) {
327
+ out[key] = canonicalize(value[key]);
328
+ }
329
+ return out;
330
+ }
331
+ return value;
332
+ }
333
+
334
+ function canonicalManifestBytes(manifest) {
335
+ const clone = { ...manifest };
336
+ delete clone.manifest_signature;
337
+ const json = JSON.stringify(canonicalize(clone), null, 2);
338
+ return Buffer.from(normalize(json), 'utf8');
339
+ }
340
+
341
+ /**
342
+ * Sign the canonical manifest bytes with the Ed25519 private key.
343
+ * Returns the manifest_signature object literal to splice into the
344
+ * manifest top level.
345
+ *
346
+ * @param {object} manifest
347
+ * @param {string} privateKey PEM-encoded Ed25519 private key
348
+ * @returns {{algorithm:'Ed25519', signature_base64:string, signed_at:string}}
349
+ */
350
+ function signCanonicalManifest(manifest, privateKey) {
351
+ const bytes = canonicalManifestBytes(manifest);
352
+ const sig = crypto.sign(null, bytes, {
353
+ key: privateKey,
354
+ dsaEncoding: 'ieee-p1363',
355
+ });
356
+ return {
357
+ algorithm: 'Ed25519',
358
+ signature_base64: sig.toString('base64'),
359
+ signed_at: new Date().toISOString(),
360
+ };
361
+ }
362
+
363
+ /**
364
+ * Audit I P1-3 — tighten Windows ACL on the private key.
365
+ *
366
+ * fs.writeFileSync({mode: 0o600}) on win32 only affects read-only
367
+ * attributes; the file inherits its ACL from the parent. icacls strips
368
+ * inheritance and grants Full Control only to the current user. Any
369
+ * failure (icacls missing, exotic shell, environment without USERNAME)
370
+ * is warned to stderr — generating the key was the load-bearing step,
371
+ * ACL tightening is best-effort hardening.
372
+ *
373
+ * @param {string} targetPath absolute path of the private key file
374
+ */
375
+ function restrictWindowsAcl(targetPath) {
376
+ if (process.platform !== 'win32') return;
377
+ const user = process.env.USERNAME;
378
+ if (!user) {
379
+ console.warn('[sign] WARN: USERNAME env var not set — skipping Windows ACL hardening on ' + targetPath);
380
+ return;
381
+ }
382
+ try {
383
+ execFileSync('icacls', [
384
+ targetPath,
385
+ '/inheritance:r',
386
+ '/grant:r',
387
+ `${user}:F`,
388
+ ], { stdio: ['ignore', 'ignore', 'pipe'] });
389
+ } catch (err) {
390
+ console.warn(
391
+ '[sign] WARN: icacls hardening failed on ' + targetPath + ': ' +
392
+ ((err && err.message) || String(err)) +
393
+ ' — the key was written but ACL inheritance was not stripped. ' +
394
+ 'Other desktop users on this machine may be able to read it.'
395
+ );
396
+ }
397
+ }
398
+
247
399
  function printFingerprintBanner() {
248
400
  if (!fs.existsSync(PUBLIC_KEY_PATH)) return;
249
401
  try {
@@ -303,4 +455,13 @@ Signing ceremony (first time):
303
455
  }
304
456
  }
305
457
 
306
- module.exports = { generateKeypair, signAll, signOne, normalize, validateSkillPath };
458
+ module.exports = {
459
+ generateKeypair,
460
+ signAll,
461
+ signOne,
462
+ normalize,
463
+ validateSkillPath,
464
+ canonicalManifestBytes,
465
+ signCanonicalManifest,
466
+ restrictWindowsAcl,
467
+ };
@@ -31,6 +31,9 @@
31
31
  * govern.jurisdiction_obligations[] (the schema does not give
32
32
  * jurisdiction_obligations an explicit `id` field; the shipped playbooks
33
33
  * reference them by this composite string).
34
+ * - _meta.mutex is symmetric across the whole playbook set: if A lists B,
35
+ * B must list A. Asymmetry surfaces as a warning in v0.12.16 (and will
36
+ * flip to error in v0.13.0) — see checkMutexReciprocity().
34
37
  *
35
38
  * Finding severity:
36
39
  * - error — structural problems that block the runner (missing required
@@ -397,6 +400,44 @@ function checkCrossRefs(playbook, ctx, playbookIds) {
397
400
  return findings;
398
401
  }
399
402
 
403
+ /* Cross-playbook mutex-reciprocity check.
404
+ *
405
+ * `_meta.mutex` is a symmetric relation: if playbook A lists B, B must list A.
406
+ * Asymmetry is a latent runner bug — the engine's mutex enforcement only
407
+ * blocks concurrent execution from whichever side declared the conflict, so
408
+ * an asymmetric declaration silently degrades to a race condition when the
409
+ * undeclared side is started first.
410
+ *
411
+ * Emits one warning per asymmetric pair (keyed off the side that declares
412
+ * the edge). v0.12.16 keeps this at warning severity per the patch-class
413
+ * cadence; v0.13.0 will flip it to error via --strict / predeploy
414
+ * `informational: false`.
415
+ */
416
+ function checkMutexReciprocity(playbooks) {
417
+ const findings = [];
418
+ const mutexMap = new Map();
419
+ for (const pb of playbooks) {
420
+ if (!pb.data || !pb.data._meta || !pb.data._meta.id) continue;
421
+ const id = pb.data._meta.id;
422
+ const mutex = Array.isArray(pb.data._meta.mutex) ? pb.data._meta.mutex : [];
423
+ mutexMap.set(id, new Set(mutex));
424
+ }
425
+ const byPlaybook = new Map(); // playbookId -> array of warning messages
426
+ for (const [id, mset] of mutexMap.entries()) {
427
+ for (const other of mset) {
428
+ const otherSet = mutexMap.get(other);
429
+ if (!otherSet) continue; // unresolved-id warning is already emitted by checkCrossRefs
430
+ if (!otherSet.has(id)) {
431
+ const msg = `_meta.mutex: asymmetric mutex with "${other}" — "${other}" does not list "${id}" in its _meta.mutex. v0.13.0 will flip this to a hard error.`;
432
+ if (!byPlaybook.has(id)) byPlaybook.set(id, []);
433
+ byPlaybook.get(id).push(msg);
434
+ }
435
+ }
436
+ }
437
+ findings.push(byPlaybook);
438
+ return byPlaybook;
439
+ }
440
+
400
441
  function main() {
401
442
  const opts = parseArgs(process.argv);
402
443
  const schema = readJson(SCHEMA_PATH);
@@ -408,6 +449,7 @@ function main() {
408
449
  playbookIds.add(pb.data._meta.id);
409
450
  }
410
451
  }
452
+ const mutexAsymmetries = checkMutexReciprocity(playbooks);
411
453
 
412
454
  let errored = 0;
413
455
  let warned = 0;
@@ -425,6 +467,9 @@ function main() {
425
467
  ...validate(pb.data, schema, 'playbook', label),
426
468
  ...checkCrossRefs(pb.data, ctx, playbookIds),
427
469
  ];
470
+ const reciprocityMsgs =
471
+ (pb.data && pb.data._meta && mutexAsymmetries.get(pb.data._meta.id)) || [];
472
+ for (const m of reciprocityMsgs) findings.push({ severity: 'warning', message: m });
428
473
  const effective = opts.strict
429
474
  ? findings.map((f) => ({ ...f, severity: 'error' }))
430
475
  : findings;
@@ -459,6 +504,7 @@ function main() {
459
504
  module.exports = {
460
505
  validate,
461
506
  checkCrossRefs,
507
+ checkMutexReciprocity,
462
508
  loadContext,
463
509
  loadPlaybooks,
464
510
  obligationKey,