@blamejs/exceptd-skills 0.9.1
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/AGENTS.md +232 -0
- package/ARCHITECTURE.md +267 -0
- package/CHANGELOG.md +616 -0
- package/CONTEXT.md +203 -0
- package/LICENSE +200 -0
- package/NOTICE +82 -0
- package/README.md +307 -0
- package/SECURITY.md +73 -0
- package/agents/README.md +81 -0
- package/agents/report-generator.md +156 -0
- package/agents/skill-updater.md +102 -0
- package/agents/source-validator.md +119 -0
- package/agents/threat-researcher.md +149 -0
- package/bin/exceptd.js +183 -0
- package/data/_indexes/_meta.json +88 -0
- package/data/_indexes/activity-feed.json +362 -0
- package/data/_indexes/catalog-summaries.json +229 -0
- package/data/_indexes/chains.json +7135 -0
- package/data/_indexes/currency.json +359 -0
- package/data/_indexes/did-ladders.json +451 -0
- package/data/_indexes/frequency.json +2072 -0
- package/data/_indexes/handoff-dag.json +476 -0
- package/data/_indexes/jurisdiction-clocks.json +967 -0
- package/data/_indexes/jurisdiction-map.json +536 -0
- package/data/_indexes/recipes.json +319 -0
- package/data/_indexes/section-offsets.json +3656 -0
- package/data/_indexes/stale-content.json +14 -0
- package/data/_indexes/summary-cards.json +1736 -0
- package/data/_indexes/theater-fingerprints.json +381 -0
- package/data/_indexes/token-budget.json +2137 -0
- package/data/_indexes/trigger-table.json +1374 -0
- package/data/_indexes/xref.json +818 -0
- package/data/atlas-ttps.json +282 -0
- package/data/cve-catalog.json +496 -0
- package/data/cwe-catalog.json +1017 -0
- package/data/d3fend-catalog.json +738 -0
- package/data/dlp-controls.json +1039 -0
- package/data/exploit-availability.json +67 -0
- package/data/framework-control-gaps.json +1255 -0
- package/data/global-frameworks.json +2913 -0
- package/data/rfc-references.json +324 -0
- package/data/zeroday-lessons.json +377 -0
- package/keys/public.pem +3 -0
- package/lib/framework-gap.js +328 -0
- package/lib/job-queue.js +195 -0
- package/lib/lint-skills.js +536 -0
- package/lib/prefetch.js +372 -0
- package/lib/refresh-external.js +713 -0
- package/lib/schemas/cve-catalog.schema.json +151 -0
- package/lib/schemas/manifest.schema.json +106 -0
- package/lib/schemas/skill-frontmatter.schema.json +113 -0
- package/lib/scoring.js +149 -0
- package/lib/sign.js +197 -0
- package/lib/ttp-mapper.js +80 -0
- package/lib/validate-catalog-meta.js +198 -0
- package/lib/validate-cve-catalog.js +213 -0
- package/lib/validate-indexes.js +83 -0
- package/lib/validate-package.js +162 -0
- package/lib/validate-vendor.js +85 -0
- package/lib/verify.js +216 -0
- package/lib/worker-pool.js +84 -0
- package/manifest-snapshot.json +1833 -0
- package/manifest.json +2108 -0
- package/orchestrator/README.md +124 -0
- package/orchestrator/dispatcher.js +140 -0
- package/orchestrator/event-bus.js +146 -0
- package/orchestrator/index.js +874 -0
- package/orchestrator/pipeline.js +201 -0
- package/orchestrator/scanner.js +327 -0
- package/orchestrator/scheduler.js +137 -0
- package/package.json +113 -0
- package/sbom.cdx.json +158 -0
- package/scripts/audit-cross-skill.js +261 -0
- package/scripts/audit-perf.js +160 -0
- package/scripts/bootstrap.js +205 -0
- package/scripts/build-indexes.js +721 -0
- package/scripts/builders/activity-feed.js +79 -0
- package/scripts/builders/catalog-summaries.js +67 -0
- package/scripts/builders/currency.js +109 -0
- package/scripts/builders/cwe-chains.js +105 -0
- package/scripts/builders/did-ladders.js +149 -0
- package/scripts/builders/frequency.js +89 -0
- package/scripts/builders/jurisdiction-clocks.js +126 -0
- package/scripts/builders/recipes.js +159 -0
- package/scripts/builders/section-offsets.js +162 -0
- package/scripts/builders/stale-content.js +171 -0
- package/scripts/builders/summary-cards.js +166 -0
- package/scripts/builders/theater-fingerprints.js +198 -0
- package/scripts/builders/token-budget.js +96 -0
- package/scripts/check-manifest-snapshot.js +217 -0
- package/scripts/predeploy.js +267 -0
- package/scripts/refresh-manifest-snapshot.js +57 -0
- package/scripts/refresh-sbom.js +222 -0
- package/skills/age-gates-child-safety/skill.md +456 -0
- package/skills/ai-attack-surface/skill.md +282 -0
- package/skills/ai-c2-detection/skill.md +440 -0
- package/skills/ai-risk-management/skill.md +311 -0
- package/skills/api-security/skill.md +287 -0
- package/skills/attack-surface-pentest/skill.md +381 -0
- package/skills/cloud-security/skill.md +384 -0
- package/skills/compliance-theater/skill.md +365 -0
- package/skills/container-runtime-security/skill.md +379 -0
- package/skills/coordinated-vuln-disclosure/skill.md +473 -0
- package/skills/defensive-countermeasure-mapping/skill.md +300 -0
- package/skills/dlp-gap-analysis/skill.md +337 -0
- package/skills/email-security-anti-phishing/skill.md +206 -0
- package/skills/exploit-scoring/skill.md +331 -0
- package/skills/framework-gap-analysis/skill.md +374 -0
- package/skills/fuzz-testing-strategy/skill.md +313 -0
- package/skills/global-grc/skill.md +564 -0
- package/skills/identity-assurance/skill.md +272 -0
- package/skills/incident-response-playbook/skill.md +546 -0
- package/skills/kernel-lpe-triage/skill.md +303 -0
- package/skills/mcp-agent-trust/skill.md +326 -0
- package/skills/mlops-security/skill.md +325 -0
- package/skills/ot-ics-security/skill.md +340 -0
- package/skills/policy-exception-gen/skill.md +437 -0
- package/skills/pqc-first/skill.md +546 -0
- package/skills/rag-pipeline-security/skill.md +294 -0
- package/skills/researcher/skill.md +310 -0
- package/skills/sector-energy/skill.md +409 -0
- package/skills/sector-federal-government/skill.md +302 -0
- package/skills/sector-financial/skill.md +398 -0
- package/skills/sector-healthcare/skill.md +373 -0
- package/skills/security-maturity-tiers/skill.md +464 -0
- package/skills/skill-update-loop/skill.md +463 -0
- package/skills/supply-chain-integrity/skill.md +318 -0
- package/skills/threat-model-currency/skill.md +404 -0
- package/skills/threat-modeling-methodology/skill.md +312 -0
- package/skills/webapp-security/skill.md +281 -0
- package/skills/zeroday-gap-learn/skill.md +350 -0
- package/vendor/blamejs/LICENSE +201 -0
- package/vendor/blamejs/README.md +54 -0
- package/vendor/blamejs/_PROVENANCE.json +54 -0
- package/vendor/blamejs/retry.js +335 -0
- package/vendor/blamejs/worker-pool.js +418 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* scripts/builders/summary-cards.js
|
|
4
|
+
*
|
|
5
|
+
* Builds `data/_indexes/summary-cards.json` — for each skill, a compact
|
|
6
|
+
* abstract that downstream AI consumers (researcher dispatch in particular)
|
|
7
|
+
* can render without loading the full skill body.
|
|
8
|
+
*
|
|
9
|
+
* Card shape per skill:
|
|
10
|
+
* {
|
|
11
|
+
* description: manifest description
|
|
12
|
+
* threat_context_excerpt: first paragraph of Threat Context section
|
|
13
|
+
* produces: first paragraph of Output Format section (if present)
|
|
14
|
+
* key_xrefs: {
|
|
15
|
+
* cwe_refs, d3fend_refs, framework_gaps, atlas_refs,
|
|
16
|
+
* attack_refs, rfc_refs, dlp_refs
|
|
17
|
+
* }
|
|
18
|
+
* trigger_count, atlas_count, attack_count, framework_gap_count,
|
|
19
|
+
* last_threat_review, path,
|
|
20
|
+
* handoff_targets: skills referenced from this skill's Hand-Off section
|
|
21
|
+
* }
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const path = require("path");
|
|
26
|
+
|
|
27
|
+
// Walk a body and yield real H2 lines (outside fenced code blocks).
|
|
28
|
+
function findRealH2Indices(lines) {
|
|
29
|
+
const out = [];
|
|
30
|
+
let inFence = false;
|
|
31
|
+
for (let i = 0; i < lines.length; i++) {
|
|
32
|
+
if (/^```/.test(lines[i])) {
|
|
33
|
+
inFence = !inFence;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if (!inFence && /^## /.test(lines[i])) out.push(i);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function locateHeader(lines, headerRegex) {
|
|
42
|
+
const h2 = findRealH2Indices(lines);
|
|
43
|
+
for (const idx of h2) {
|
|
44
|
+
if (headerRegex.test(lines[idx])) return idx;
|
|
45
|
+
}
|
|
46
|
+
return -1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function firstParagraphAfterHeader(body, headerRegex) {
|
|
50
|
+
// Locate the first real H2 matching the regex, then find the first prose
|
|
51
|
+
// paragraph beneath it — skip any H3 / H4 / bold-prefix metadata lines /
|
|
52
|
+
// horizontal rules / table separators that often sit at the top of a
|
|
53
|
+
// section. Real H2 means outside of fenced code blocks.
|
|
54
|
+
const lines = body.split(/\r?\n/);
|
|
55
|
+
const hdrIdx = locateHeader(lines, headerRegex);
|
|
56
|
+
if (hdrIdx < 0) return null;
|
|
57
|
+
// Find the next real H2 as the section boundary.
|
|
58
|
+
const allH2 = findRealH2Indices(lines);
|
|
59
|
+
const nextH2 = allH2.find((i) => i > hdrIdx);
|
|
60
|
+
const sectionEnd = nextH2 != null ? nextH2 : lines.length;
|
|
61
|
+
|
|
62
|
+
let i = hdrIdx + 1;
|
|
63
|
+
const isSkippableLeading = (line) => {
|
|
64
|
+
const t = line.trim();
|
|
65
|
+
if (t === "") return true;
|
|
66
|
+
if (t === "---") return true;
|
|
67
|
+
if (/^#{1,6}\s/.test(t)) return true;
|
|
68
|
+
if (/^\|/.test(t)) return true;
|
|
69
|
+
if (/^\*\*[^*]+:\*\*/.test(t)) return true;
|
|
70
|
+
if (/^[-=]{3,}$/.test(t)) return true;
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
for (; i < sectionEnd; i++) {
|
|
74
|
+
if (!isSkippableLeading(lines[i])) break;
|
|
75
|
+
}
|
|
76
|
+
if (i >= sectionEnd) return null;
|
|
77
|
+
|
|
78
|
+
const para = [];
|
|
79
|
+
for (; i < sectionEnd; i++) {
|
|
80
|
+
if (lines[i].trim() === "" && para.length) break;
|
|
81
|
+
para.push(lines[i]);
|
|
82
|
+
}
|
|
83
|
+
const joined = para.join(" ").replace(/\s+/g, " ").trim();
|
|
84
|
+
if (!joined) return null;
|
|
85
|
+
if (joined.length <= 600) return joined;
|
|
86
|
+
const cut = joined.slice(0, 600);
|
|
87
|
+
const lastSpace = cut.lastIndexOf(" ");
|
|
88
|
+
return (lastSpace > 400 ? cut.slice(0, lastSpace) : cut) + " ...";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function firstChunkAfterHeader(body, headerRegex, maxChars = 600) {
|
|
92
|
+
const lines = body.split(/\r?\n/);
|
|
93
|
+
const hdrIdx = locateHeader(lines, headerRegex);
|
|
94
|
+
if (hdrIdx < 0) return null;
|
|
95
|
+
const allH2 = findRealH2Indices(lines);
|
|
96
|
+
const nextH2 = allH2.find((i) => i > hdrIdx);
|
|
97
|
+
const sectionEnd = nextH2 != null ? nextH2 : lines.length;
|
|
98
|
+
|
|
99
|
+
let i = hdrIdx + 1;
|
|
100
|
+
while (i < sectionEnd && lines[i].trim() === "") i++;
|
|
101
|
+
const chunk = lines.slice(i, sectionEnd);
|
|
102
|
+
const joined = chunk.join("\n").trim();
|
|
103
|
+
if (!joined) return null;
|
|
104
|
+
if (joined.length <= maxChars) return joined;
|
|
105
|
+
return joined.slice(0, maxChars) + " ...";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function handoffTargets(body, allSkillNames, selfName) {
|
|
109
|
+
// Look in the Hand-Off section; backtick-quoted skill names count as a target.
|
|
110
|
+
const handoffStart = body.search(/^## Hand-?Off/m);
|
|
111
|
+
if (handoffStart < 0) return [];
|
|
112
|
+
const slice = body.slice(handoffStart);
|
|
113
|
+
const targets = new Set();
|
|
114
|
+
for (const name of allSkillNames) {
|
|
115
|
+
if (name === selfName) continue;
|
|
116
|
+
if (slice.includes("`" + name + "`")) targets.add(name);
|
|
117
|
+
}
|
|
118
|
+
return [...targets].sort();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function buildSummaryCards({ root, manifest, skills }) {
|
|
122
|
+
const cards = {};
|
|
123
|
+
const allNames = new Set(skills.map((s) => s.name));
|
|
124
|
+
|
|
125
|
+
for (const s of skills) {
|
|
126
|
+
const body = fs.readFileSync(path.join(root, s.path), "utf8");
|
|
127
|
+
|
|
128
|
+
const threatCtx = firstParagraphAfterHeader(body, /^## Threat Context/);
|
|
129
|
+
const produces = firstChunkAfterHeader(body, /^## Output Format/, 600);
|
|
130
|
+
|
|
131
|
+
cards[s.name] = {
|
|
132
|
+
description: s.description || null,
|
|
133
|
+
threat_context_excerpt: threatCtx,
|
|
134
|
+
produces: produces,
|
|
135
|
+
key_xrefs: {
|
|
136
|
+
cwe_refs: s.cwe_refs || [],
|
|
137
|
+
d3fend_refs: s.d3fend_refs || [],
|
|
138
|
+
framework_gaps: s.framework_gaps || [],
|
|
139
|
+
atlas_refs: s.atlas_refs || [],
|
|
140
|
+
attack_refs: s.attack_refs || [],
|
|
141
|
+
rfc_refs: s.rfc_refs || [],
|
|
142
|
+
dlp_refs: s.dlp_refs || [],
|
|
143
|
+
},
|
|
144
|
+
trigger_count: (s.triggers || []).length,
|
|
145
|
+
atlas_count: (s.atlas_refs || []).length,
|
|
146
|
+
attack_count: (s.attack_refs || []).length,
|
|
147
|
+
framework_gap_count: (s.framework_gaps || []).length,
|
|
148
|
+
cwe_count: (s.cwe_refs || []).length,
|
|
149
|
+
d3fend_count: (s.d3fend_refs || []).length,
|
|
150
|
+
rfc_count: (s.rfc_refs || []).length,
|
|
151
|
+
last_threat_review: s.last_threat_review || null,
|
|
152
|
+
path: s.path,
|
|
153
|
+
handoff_targets: handoffTargets(body, allNames, s.name),
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
_meta: {
|
|
159
|
+
schema_version: "1.0.0",
|
|
160
|
+
note: "Compact per-skill abstract for researcher dispatch and AI consumer planning. See scripts/builders/summary-cards.js.",
|
|
161
|
+
},
|
|
162
|
+
skills: cards,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { buildSummaryCards };
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* scripts/builders/theater-fingerprints.js
|
|
4
|
+
*
|
|
5
|
+
* Builds `data/_indexes/theater-fingerprints.json` — for each Compliance
|
|
6
|
+
* Theater pattern in the `compliance-theater` skill, a structured record:
|
|
7
|
+
* - the claim (what auditors hear)
|
|
8
|
+
* - the audit evidence (what passes the audit)
|
|
9
|
+
* - the reality (why it's theater)
|
|
10
|
+
* - the detection test (operational steps)
|
|
11
|
+
* - the controls it spans (NIST 800-53 / ISO 27001 / PCI / SOC 2)
|
|
12
|
+
* - the evidence CVE / campaign tying the pattern to the real world
|
|
13
|
+
*
|
|
14
|
+
* Extracted from `skills/compliance-theater/skill.md`. The compliance-theater
|
|
15
|
+
* skill is the source-of-truth — this index just structures the pattern
|
|
16
|
+
* library so downstream consumers (audit defense, framework-gap-analysis)
|
|
17
|
+
* can join on control IDs without re-parsing the markdown.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const fs = require("fs");
|
|
21
|
+
const path = require("path");
|
|
22
|
+
|
|
23
|
+
// Stable mapping of each Pattern → the controls it spans. Manually curated
|
|
24
|
+
// from the skill's Framework Lag Declaration table — keep this in lockstep
|
|
25
|
+
// with skills/compliance-theater/skill.md.
|
|
26
|
+
const PATTERN_CONTROL_MAP = {
|
|
27
|
+
1: {
|
|
28
|
+
pattern_name: "Patch Management Theater",
|
|
29
|
+
primary_attack_class: "patch-cycle vs. KEV-listed instant-root exploits",
|
|
30
|
+
controls: [
|
|
31
|
+
{ framework: "NIST 800-53", control_id: "SI-2", note: "30-day critical patch SLA designed for slow-weaponization era" },
|
|
32
|
+
{ framework: "ISO 27001:2022", control_id: "A.8.8", note: "'Appropriate timescales' undefined; commonly read as 30 days for High" },
|
|
33
|
+
{ framework: "PCI DSS 4.0", control_id: "6.3.3", note: "One-month critical-patch window" },
|
|
34
|
+
{ framework: "NIS2", control_id: "Art. 21", note: "No specific patching SLA" },
|
|
35
|
+
{ framework: "CIS Controls v8", control_id: "Control 7", note: "Continuous vulnerability management; 'within one month' still too long" },
|
|
36
|
+
],
|
|
37
|
+
evidence: { cve: "CVE-2026-31431", rationale: "Copy Fail: deterministic 732-byte root, CISA KEV, AI-discovered, public PoC" },
|
|
38
|
+
ttps: ["T1068", "T1203"],
|
|
39
|
+
fast_test: "Pull last 12 months of patch records. Any CISA KEV patched > 72 hours after KEV listing = THEATER FLAG.",
|
|
40
|
+
},
|
|
41
|
+
2: {
|
|
42
|
+
pattern_name: "Network Segmentation Theater (IPsec)",
|
|
43
|
+
primary_attack_class: "IPsec subsystem as both control and attack surface",
|
|
44
|
+
controls: [
|
|
45
|
+
{ framework: "NIST 800-53", control_id: "SC-8", note: "Transmission confidentiality — IPsec common compensating control" },
|
|
46
|
+
{ framework: "NIST 800-53", control_id: "SC-7", note: "Boundary protection — IPsec tunnel as zone separator" },
|
|
47
|
+
{ framework: "PCI DSS 4.0", control_id: "Req 1", note: "Network segmentation between trust zones" },
|
|
48
|
+
],
|
|
49
|
+
evidence: { cve: "CVE-2026-43284", rationale: "Dirty Frag: kernel IPsec subsystem LPE — the control's cryptographic mechanism is the attack surface" },
|
|
50
|
+
ttps: ["T1190"],
|
|
51
|
+
fast_test: "Identify hosts using IPsec for segmentation compliance. If kernel patch for CVE-2026-43284 not applied = THEATER FLAG.",
|
|
52
|
+
},
|
|
53
|
+
3: {
|
|
54
|
+
pattern_name: "Access Control Theater (AI Agent)",
|
|
55
|
+
primary_attack_class: "prompt injection bypasses access control via authorized service account",
|
|
56
|
+
controls: [
|
|
57
|
+
{ framework: "SOC 2", control_id: "CC6", note: "Logical access — designed for human-controlled accounts" },
|
|
58
|
+
{ framework: "NIST 800-53", control_id: "AC-2", note: "Account management — no concept of AI agent authority delegation" },
|
|
59
|
+
{ framework: "NIST 800-53", control_id: "AC-3", note: "Access enforcement — model judgment is the gate, not a recognized control" },
|
|
60
|
+
{ framework: "ISO 27001:2022", control_id: "A.5.15", note: "Access control policy" },
|
|
61
|
+
],
|
|
62
|
+
evidence: { cve: "CVE-2025-53773", rationale: "Copilot prompt-injection RCE: AI service account executes attacker-chosen actions; no identity boundary crossed" },
|
|
63
|
+
ttps: ["AML.T0051", "AML.T0054", "T1059"],
|
|
64
|
+
fast_test: "If AI agents have prod access and (a) prompt content + tool calls aren't logged or (b) no behavioral baseline = THEATER FLAG.",
|
|
65
|
+
},
|
|
66
|
+
4: {
|
|
67
|
+
pattern_name: "Incident Response Theater (AI Pipeline)",
|
|
68
|
+
primary_attack_class: "IR program with no detection input or procedure for AI-class incidents",
|
|
69
|
+
controls: [
|
|
70
|
+
{ framework: "SOC 2", control_id: "CC7", note: "System operations / anomaly detection — no baseline for AI-API traffic" },
|
|
71
|
+
{ framework: "NIST 800-53", control_id: "IR-4", note: "Incident handling — phases defined but not AI-class triggers" },
|
|
72
|
+
{ framework: "ISO 27001:2022", control_id: "A.5.24-A.5.28", note: "IR planning/preparation/reporting/response/learning" },
|
|
73
|
+
],
|
|
74
|
+
evidence: { campaign: "SesameOp", rationale: "AML.T0096 LLM Integration Abuse as C2 — no detection triggers exist, so IR procedures have no input" },
|
|
75
|
+
ttps: ["AML.T0020", "AML.T0096", "AML.T0010"],
|
|
76
|
+
fast_test: "Search IR playbooks for 'prompt injection', 'model poisoning', 'AI agent', 'LLM', 'MCP server'. Zero matches = THEATER FLAG.",
|
|
77
|
+
},
|
|
78
|
+
5: {
|
|
79
|
+
pattern_name: "Change Management Theater (AI Model)",
|
|
80
|
+
primary_attack_class: "external model updates bypass operator change control",
|
|
81
|
+
controls: [
|
|
82
|
+
{ framework: "NIST 800-53", control_id: "CM-3", note: "Configuration change control — drafted for changes the operator controls" },
|
|
83
|
+
{ framework: "ISO 27001:2022", control_id: "A.8.32", note: "Change management" },
|
|
84
|
+
{ framework: "SOC 2", control_id: "CC8", note: "Change management" },
|
|
85
|
+
],
|
|
86
|
+
evidence: { campaign: "Continuous provider model updates", rationale: "Vendor-managed model updates bypass operator change control entirely; safety properties can shift silently" },
|
|
87
|
+
ttps: ["AML.T0018", "AML.T0020"],
|
|
88
|
+
fast_test: "List LLM API deps. Does each provider update open a change ticket? Is there a behavioral test suite? Is the model version pinned? Any 'no' = THEATER FLAG.",
|
|
89
|
+
},
|
|
90
|
+
6: {
|
|
91
|
+
pattern_name: "Vendor/Third-Party Risk Theater (AI API + MCP)",
|
|
92
|
+
primary_attack_class: "vendor program scope excludes LLM APIs and MCP servers",
|
|
93
|
+
controls: [
|
|
94
|
+
{ framework: "SOC 2", control_id: "CC9", note: "Risk mitigation; vendor management" },
|
|
95
|
+
{ framework: "NIST 800-53", control_id: "SA-12", note: "Supply chain protection" },
|
|
96
|
+
{ framework: "ISO 27001:2022", control_id: "A.5.19", note: "Supplier relationships — drafted for SaaS-style vendors" },
|
|
97
|
+
{ framework: "ISO 27001:2022", control_id: "A.5.20", note: "Information security in supplier agreements" },
|
|
98
|
+
{ framework: "US FedRAMP", control_id: "Rev 5 Moderate", note: "Authorization-as-evidence pattern; ATO does not cover tenant-side MCP" },
|
|
99
|
+
{ framework: "US DoD CMMC", control_id: "2.0 Level 2", note: "Certification-as-evidence; does not cover AI coding-assistant supply chain" },
|
|
100
|
+
],
|
|
101
|
+
evidence: { cve: "CVE-2026-30615", rationale: "Windsurf MCP zero-interaction RCE — vendor management program had no coverage of MCP servers as third-party code" },
|
|
102
|
+
ttps: ["AML.T0010"],
|
|
103
|
+
fast_test: "List LLM API providers. Is there a vendor risk assessment + DPA for each? List MCP servers on dev workstations — did each pass vendor review? Either gap = THEATER FLAG.",
|
|
104
|
+
},
|
|
105
|
+
7: {
|
|
106
|
+
pattern_name: "Security Awareness Theater (AI Phishing)",
|
|
107
|
+
primary_attack_class: "phishing simulation tests resistance to template-era phish, not AI-generated content",
|
|
108
|
+
controls: [
|
|
109
|
+
{ framework: "NIST 800-53", control_id: "AT-2", note: "Security awareness training — drafted against human-template phishing" },
|
|
110
|
+
{ framework: "ISO 27001:2022", control_id: "A.6.3", note: "Information security awareness, education and training" },
|
|
111
|
+
{ framework: "PCI DSS 4.0", control_id: "12.6", note: "Security awareness program" },
|
|
112
|
+
],
|
|
113
|
+
evidence: { campaign: "AI-generated phishing baseline (82.6% of phish contain AI-generated content)", rationale: "Grammar/style heuristics are no longer reliable detectors; <5% click rate on template phish is non-informative" },
|
|
114
|
+
ttps: ["T1566", "AML.T0016"],
|
|
115
|
+
fast_test: "Were any simulation emails AI-generated (not template-based) in the last 3 sims? Is MFA phishing-resistant (hardware keys / passkeys)? Either 'no' = THEATER FLAG.",
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
function extractPatternBodyFromSkill(skillBody, patternNumber) {
|
|
120
|
+
// Find "### Pattern N:" and capture until the next "### Pattern N+1:" OR
|
|
121
|
+
// the next ## H2 after the header line itself. We skip the header's own
|
|
122
|
+
// line before scanning for an H2 boundary — otherwise the `### Pattern N:`
|
|
123
|
+
// line would match the `## ` prefix regex once its leading `#` is sliced.
|
|
124
|
+
const startRe = new RegExp(`^### Pattern ${patternNumber}:`, "m");
|
|
125
|
+
const startMatch = skillBody.match(startRe);
|
|
126
|
+
if (!startMatch) return null;
|
|
127
|
+
const startIdx = startMatch.index;
|
|
128
|
+
const headerEnd = skillBody.indexOf("\n", startIdx);
|
|
129
|
+
const afterHeader = headerEnd >= 0 ? headerEnd + 1 : skillBody.length;
|
|
130
|
+
const tail = skillBody.slice(startIdx);
|
|
131
|
+
const nextPatternRe = new RegExp(`^### Pattern ${patternNumber + 1}:`, "m");
|
|
132
|
+
const nextPatternMatch = tail.match(nextPatternRe);
|
|
133
|
+
const h2Re = /^## /m;
|
|
134
|
+
const afterHeaderSlice = skillBody.slice(afterHeader);
|
|
135
|
+
const h2Match = afterHeaderSlice.match(h2Re);
|
|
136
|
+
const stops = [
|
|
137
|
+
nextPatternMatch ? nextPatternMatch.index : Infinity,
|
|
138
|
+
h2Match ? (afterHeader - startIdx) + h2Match.index : Infinity,
|
|
139
|
+
];
|
|
140
|
+
const stopAt = Math.min(...stops);
|
|
141
|
+
return tail.slice(0, Number.isFinite(stopAt) ? stopAt : tail.length).trim();
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function pullField(body, label) {
|
|
145
|
+
// The patterns use a "**Label:** ..." prose convention. Return the line(s)
|
|
146
|
+
// after the label until the next "**" or blank line.
|
|
147
|
+
const re = new RegExp(`\\*\\*${label.replace(/[-/\\^$*+?.()|[\\]{}]/g, "\\$&")}:?\\*\\*\\s*([\\s\\S]*?)(?=\\n\\n|\\n\\*\\*|$)`);
|
|
148
|
+
const m = body.match(re);
|
|
149
|
+
return m ? m[1].trim() : null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function buildTheaterFingerprints({ root }) {
|
|
153
|
+
const skillPath = path.join(root, "skills/compliance-theater/skill.md");
|
|
154
|
+
const body = fs.readFileSync(skillPath, "utf8");
|
|
155
|
+
|
|
156
|
+
const out = {};
|
|
157
|
+
for (const [num, meta] of Object.entries(PATTERN_CONTROL_MAP)) {
|
|
158
|
+
const patternBody = extractPatternBodyFromSkill(body, Number(num));
|
|
159
|
+
out[`pattern-${num}`] = {
|
|
160
|
+
pattern_number: Number(num),
|
|
161
|
+
pattern_name: meta.pattern_name,
|
|
162
|
+
primary_attack_class: meta.primary_attack_class,
|
|
163
|
+
claim: pullField(patternBody || "", "The claim"),
|
|
164
|
+
audit_evidence: pullField(patternBody || "", "The audit evidence"),
|
|
165
|
+
reality: pullField(patternBody || "", "The reality"),
|
|
166
|
+
why_its_theater: pullField(patternBody || "", "Why it's theater"),
|
|
167
|
+
fast_test: meta.fast_test,
|
|
168
|
+
controls: meta.controls,
|
|
169
|
+
evidence: meta.evidence,
|
|
170
|
+
ttps: meta.ttps,
|
|
171
|
+
source_skill: "compliance-theater",
|
|
172
|
+
source_section: `### Pattern ${num}: ${meta.pattern_name}`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Inverted index: control_id → pattern(s) it spans, so a consumer can ask
|
|
177
|
+
// "is this control implicated in a theater pattern?" without scanning all
|
|
178
|
+
// seven patterns.
|
|
179
|
+
const byControl = {};
|
|
180
|
+
for (const [pid, p] of Object.entries(out)) {
|
|
181
|
+
for (const c of p.controls) {
|
|
182
|
+
const key = `${c.framework}::${c.control_id}`;
|
|
183
|
+
(byControl[key] = byControl[key] || []).push(pid);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return {
|
|
188
|
+
_meta: {
|
|
189
|
+
schema_version: "1.0.0",
|
|
190
|
+
source: "skills/compliance-theater/skill.md (7 documented patterns)",
|
|
191
|
+
pattern_count: Object.keys(out).length,
|
|
192
|
+
},
|
|
193
|
+
patterns: out,
|
|
194
|
+
by_control: byControl,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
module.exports = { buildTheaterFingerprints };
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* scripts/builders/token-budget.js
|
|
4
|
+
*
|
|
5
|
+
* Builds `data/_indexes/token-budget.json` — per-skill approximate token
|
|
6
|
+
* counts using a character-density heuristic. Zero-dep (no tiktoken). The
|
|
7
|
+
* approximation is documented as such so consumers know to recompute with
|
|
8
|
+
* their own tokenizer if precision matters.
|
|
9
|
+
*
|
|
10
|
+
* Heuristic: 1 token ≈ 4 characters for English prose mixed with technical
|
|
11
|
+
* tokens (matches the well-known OpenAI rule-of-thumb). This is an upper
|
|
12
|
+
* bound for Claude (Anthropic's tokenizer is more efficient on common
|
|
13
|
+
* prose) but is good enough for context-budget planning where consumers
|
|
14
|
+
* just need to know "is this load 5K or 50K tokens".
|
|
15
|
+
*
|
|
16
|
+
* Per-skill shape:
|
|
17
|
+
* {
|
|
18
|
+
* path: skill file path
|
|
19
|
+
* bytes: total file bytes
|
|
20
|
+
* chars: total character count
|
|
21
|
+
* lines: line count
|
|
22
|
+
* approx_tokens: chars / 4 (integer)
|
|
23
|
+
* approx_chars_per_token: 4
|
|
24
|
+
* sections: {
|
|
25
|
+
* <normalized_section_name>: { bytes, approx_tokens }
|
|
26
|
+
* }
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* Plus a totals block:
|
|
30
|
+
* {
|
|
31
|
+
* total_chars, total_approx_tokens,
|
|
32
|
+
* by_recipe: { … } — placeholder consumers can use to estimate bundles
|
|
33
|
+
* }
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const fs = require("fs");
|
|
37
|
+
const path = require("path");
|
|
38
|
+
|
|
39
|
+
function approxTokens(chars) {
|
|
40
|
+
return Math.round(chars / 4);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildTokenBudget({ root, skills, sectionOffsets }) {
|
|
44
|
+
const skillBudgets = {};
|
|
45
|
+
let totalChars = 0;
|
|
46
|
+
let totalApprox = 0;
|
|
47
|
+
|
|
48
|
+
for (const s of skills) {
|
|
49
|
+
const abs = path.join(root, s.path);
|
|
50
|
+
const buf = fs.readFileSync(abs);
|
|
51
|
+
const text = buf.toString("utf8");
|
|
52
|
+
const chars = text.length;
|
|
53
|
+
const tokens = approxTokens(chars);
|
|
54
|
+
totalChars += chars;
|
|
55
|
+
totalApprox += tokens;
|
|
56
|
+
|
|
57
|
+
const sectionMap = {};
|
|
58
|
+
const sectionEntry = sectionOffsets.skills?.[s.name];
|
|
59
|
+
if (sectionEntry && Array.isArray(sectionEntry.sections)) {
|
|
60
|
+
for (const sec of sectionEntry.sections) {
|
|
61
|
+
const sliceText = buf
|
|
62
|
+
.slice(sec.byte_start, sec.byte_end)
|
|
63
|
+
.toString("utf8");
|
|
64
|
+
sectionMap[sec.normalized_name] = {
|
|
65
|
+
bytes: sec.bytes,
|
|
66
|
+
chars: sliceText.length,
|
|
67
|
+
approx_tokens: approxTokens(sliceText.length),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
skillBudgets[s.name] = {
|
|
73
|
+
path: s.path,
|
|
74
|
+
bytes: buf.length,
|
|
75
|
+
chars,
|
|
76
|
+
lines: text.split(/\r?\n/).length,
|
|
77
|
+
approx_tokens: tokens,
|
|
78
|
+
approx_chars_per_token: 4,
|
|
79
|
+
sections: sectionMap,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
_meta: {
|
|
85
|
+
schema_version: "1.0.0",
|
|
86
|
+
tokenizer_note: "Character-density approximation: 1 token ≈ 4 chars. This is the canonical rule-of-thumb for OpenAI tokenizers on English+technical text. Claude's tokenizer is typically more efficient on prose; treat this as an upper-bound budget for both. Consumers with stricter precision needs should re-tokenize with their own tokenizer.",
|
|
87
|
+
approx_chars_per_token: 4,
|
|
88
|
+
total_chars: totalChars,
|
|
89
|
+
total_approx_tokens: totalApprox,
|
|
90
|
+
skill_count: skills.length,
|
|
91
|
+
},
|
|
92
|
+
skills: skillBudgets,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
module.exports = { buildTokenBudget };
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* scripts/check-manifest-snapshot.js
|
|
4
|
+
*
|
|
5
|
+
* CI gate. Captures the current public skill surface (skill name +
|
|
6
|
+
* version + triggers + data_deps + atlas_refs + attack_refs +
|
|
7
|
+
* framework_gaps) from manifest.json and compares it to the committed
|
|
8
|
+
* manifest-snapshot.json baseline.
|
|
9
|
+
*
|
|
10
|
+
* The skill surface is the public contract this repo offers downstream
|
|
11
|
+
* AI assistants: skill names that downstream prompts may reference,
|
|
12
|
+
* trigger keywords that downstream skill-matchers index on, and the
|
|
13
|
+
* data files that skills rely on. Removing a skill or trigger keyword
|
|
14
|
+
* silently breaks every consumer that pinned that surface.
|
|
15
|
+
*
|
|
16
|
+
* Exit codes:
|
|
17
|
+
* 0 — no breaking changes (additive changes printed but not failing)
|
|
18
|
+
* 1 — breaking changes detected
|
|
19
|
+
* 2 — script-level error (missing baseline, IO failure, etc.)
|
|
20
|
+
*
|
|
21
|
+
* Operators see this gate in SECURITY.md / CONTRIBUTING.md as a CI
|
|
22
|
+
* promise: "removed skills, removed triggers, or removed data deps fail
|
|
23
|
+
* the build before they reach main."
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* node scripts/check-manifest-snapshot.js
|
|
27
|
+
*
|
|
28
|
+
* Regenerate the baseline after an intentional removal:
|
|
29
|
+
* node scripts/refresh-manifest-snapshot.js
|
|
30
|
+
* git add manifest-snapshot.json && git commit
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const fs = require("fs");
|
|
34
|
+
const path = require("path");
|
|
35
|
+
|
|
36
|
+
const ROOT = path.join(__dirname, "..");
|
|
37
|
+
const MANIFEST_PATH = path.join(ROOT, "manifest.json");
|
|
38
|
+
const SNAPSHOT_PATH = path.join(ROOT, "manifest-snapshot.json");
|
|
39
|
+
|
|
40
|
+
function captureSurface(manifest) {
|
|
41
|
+
// Public surface = the set of facts downstream consumers may have
|
|
42
|
+
// pinned against. NOT included: sha256 / signature / signed_at —
|
|
43
|
+
// those change every commit and are not a public contract.
|
|
44
|
+
const skills = (manifest.skills || []).map(s => ({
|
|
45
|
+
name: s.name,
|
|
46
|
+
version: s.version || null,
|
|
47
|
+
triggers: [...(s.triggers || [])].sort(),
|
|
48
|
+
data_deps: [...(s.data_deps || [])].sort(),
|
|
49
|
+
atlas_refs: [...(s.atlas_refs || [])].sort(),
|
|
50
|
+
attack_refs: [...(s.attack_refs || [])].sort(),
|
|
51
|
+
framework_gaps: [...(s.framework_gaps || [])].sort(),
|
|
52
|
+
rfc_refs: [...(s.rfc_refs || [])].sort(),
|
|
53
|
+
cwe_refs: [...(s.cwe_refs || [])].sort(),
|
|
54
|
+
d3fend_refs: [...(s.d3fend_refs || [])].sort(),
|
|
55
|
+
dlp_refs: [...(s.dlp_refs || [])].sort(),
|
|
56
|
+
})).sort((a, b) => a.name.localeCompare(b.name));
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
atlas_version: manifest.atlas_version || null,
|
|
60
|
+
skill_count: skills.length,
|
|
61
|
+
skills,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function diff(baseline, current) {
|
|
66
|
+
const breaking = [];
|
|
67
|
+
const additive = [];
|
|
68
|
+
|
|
69
|
+
const bSkills = new Map(baseline.skills.map(s => [s.name, s]));
|
|
70
|
+
const cSkills = new Map(current.skills.map(s => [s.name, s]));
|
|
71
|
+
|
|
72
|
+
// Removed skills are breaking.
|
|
73
|
+
for (const name of bSkills.keys()) {
|
|
74
|
+
if (!cSkills.has(name)) {
|
|
75
|
+
breaking.push(`removed skill: ${name}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Added skills are additive.
|
|
80
|
+
for (const name of cSkills.keys()) {
|
|
81
|
+
if (!bSkills.has(name)) {
|
|
82
|
+
additive.push(`added skill: ${name}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// For each skill present in both, diff the pinned facts.
|
|
87
|
+
for (const [name, b] of bSkills) {
|
|
88
|
+
const c = cSkills.get(name);
|
|
89
|
+
if (!c) continue;
|
|
90
|
+
|
|
91
|
+
// version downgrades are breaking; bumps are additive.
|
|
92
|
+
if (b.version && c.version && b.version !== c.version) {
|
|
93
|
+
// Use a simple lexicographic compare — semver isn't enforced
|
|
94
|
+
// upstream and the manifest version field is informational. The
|
|
95
|
+
// operator should bump, not unbump.
|
|
96
|
+
if (c.version < b.version) {
|
|
97
|
+
breaking.push(`${name}: version downgraded ${b.version} -> ${c.version}`);
|
|
98
|
+
} else {
|
|
99
|
+
additive.push(`${name}: version bumped ${b.version} -> ${c.version}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Removed trigger keywords break downstream skill matchers.
|
|
104
|
+
const removedTriggers = b.triggers.filter(t => !c.triggers.includes(t));
|
|
105
|
+
if (removedTriggers.length > 0) {
|
|
106
|
+
breaking.push(`${name}: removed trigger keywords: ${removedTriggers.join(", ")}`);
|
|
107
|
+
}
|
|
108
|
+
const addedTriggers = c.triggers.filter(t => !b.triggers.includes(t));
|
|
109
|
+
if (addedTriggers.length > 0) {
|
|
110
|
+
additive.push(`${name}: added trigger keywords: ${addedTriggers.join(", ")}`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Removed data deps break the skill at load time. Additions are fine.
|
|
114
|
+
const removedDeps = b.data_deps.filter(d => !c.data_deps.includes(d));
|
|
115
|
+
if (removedDeps.length > 0) {
|
|
116
|
+
breaking.push(`${name}: removed data deps: ${removedDeps.join(", ")}`);
|
|
117
|
+
}
|
|
118
|
+
const addedDeps = c.data_deps.filter(d => !b.data_deps.includes(d));
|
|
119
|
+
if (addedDeps.length > 0) {
|
|
120
|
+
additive.push(`${name}: added data deps: ${addedDeps.join(", ")}`);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Removed ATLAS/ATT&CK/framework refs are surface narrowing.
|
|
124
|
+
// Per AGENTS.md rule #4 (no orphaned controls) and #12 (external
|
|
125
|
+
// data version pinning), narrowing the cited surface is a
|
|
126
|
+
// deliberate decision worth surfacing in CI. Treat as breaking;
|
|
127
|
+
// the operator can refresh the baseline alongside the intent.
|
|
128
|
+
for (const field of ["atlas_refs", "attack_refs", "framework_gaps", "rfc_refs", "cwe_refs", "d3fend_refs", "dlp_refs"]) {
|
|
129
|
+
const removed = b[field].filter(r => !c[field].includes(r));
|
|
130
|
+
if (removed.length > 0) {
|
|
131
|
+
breaking.push(`${name}: removed ${field}: ${removed.join(", ")}`);
|
|
132
|
+
}
|
|
133
|
+
const added = c[field].filter(r => !b[field].includes(r));
|
|
134
|
+
if (added.length > 0) {
|
|
135
|
+
additive.push(`${name}: added ${field}: ${added.join(", ")}`);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ATLAS pinned-version change is breaking per AGENTS.md rule #12
|
|
141
|
+
// (never silently inherit version changes). The operator must update
|
|
142
|
+
// the baseline alongside the audit of TTP ID changes.
|
|
143
|
+
if (baseline.atlas_version && current.atlas_version &&
|
|
144
|
+
baseline.atlas_version !== current.atlas_version) {
|
|
145
|
+
breaking.push(
|
|
146
|
+
`atlas_version changed ${baseline.atlas_version} -> ${current.atlas_version} ` +
|
|
147
|
+
`(per AGENTS.md rule #12, audit TTP IDs and refresh baseline together)`
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return { breaking, additive };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function formatDiff(result) {
|
|
155
|
+
const lines = [];
|
|
156
|
+
if (result.breaking.length === 0 && result.additive.length === 0) {
|
|
157
|
+
lines.push("[check-manifest-snapshot] surface unchanged.");
|
|
158
|
+
return lines.join("\n");
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (result.breaking.length > 0) {
|
|
162
|
+
lines.push(`[check-manifest-snapshot] ${result.breaking.length} breaking change(s):`);
|
|
163
|
+
for (const b of result.breaking) lines.push(` ! ${b}`);
|
|
164
|
+
}
|
|
165
|
+
if (result.additive.length > 0) {
|
|
166
|
+
lines.push(`[check-manifest-snapshot] ${result.additive.length} additive change(s):`);
|
|
167
|
+
for (const a of result.additive) lines.push(` + ${a}`);
|
|
168
|
+
}
|
|
169
|
+
return lines.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = { captureSurface, diff, formatDiff };
|
|
173
|
+
|
|
174
|
+
if (require.main === module) {
|
|
175
|
+
try {
|
|
176
|
+
let baseline;
|
|
177
|
+
try {
|
|
178
|
+
baseline = JSON.parse(fs.readFileSync(SNAPSHOT_PATH, "utf8"));
|
|
179
|
+
} catch (e) {
|
|
180
|
+
console.error(
|
|
181
|
+
"[check-manifest-snapshot] baseline missing or unreadable: " +
|
|
182
|
+
((e && e.message) || String(e))
|
|
183
|
+
);
|
|
184
|
+
console.error(
|
|
185
|
+
"[check-manifest-snapshot] generate one with " +
|
|
186
|
+
"`node scripts/refresh-manifest-snapshot.js` and commit it."
|
|
187
|
+
);
|
|
188
|
+
process.exit(2);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8"));
|
|
192
|
+
const current = captureSurface(manifest);
|
|
193
|
+
const result = diff(baseline, current);
|
|
194
|
+
|
|
195
|
+
console.log(formatDiff(result));
|
|
196
|
+
|
|
197
|
+
if (result.breaking.length > 0) {
|
|
198
|
+
console.error(
|
|
199
|
+
"[check-manifest-snapshot] BREAKING changes detected. If intentional, " +
|
|
200
|
+
"regenerate the baseline with `node scripts/refresh-manifest-snapshot.js` " +
|
|
201
|
+
"and commit it alongside the change."
|
|
202
|
+
);
|
|
203
|
+
process.exit(1);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (result.additive.length > 0) {
|
|
207
|
+
console.log(
|
|
208
|
+
"[check-manifest-snapshot] additive changes only — refresh the baseline " +
|
|
209
|
+
"(`node scripts/refresh-manifest-snapshot.js`) so the new surface is tracked."
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
process.exit(0);
|
|
213
|
+
} catch (e) {
|
|
214
|
+
console.error("[check-manifest-snapshot] error: " + ((e && e.stack) || e));
|
|
215
|
+
process.exit(2);
|
|
216
|
+
}
|
|
217
|
+
}
|