@blamejs/exceptd-skills 0.10.1 → 0.10.3

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.
@@ -161,8 +161,13 @@ Examples:
161
161
  // --- command implementations ---
162
162
 
163
163
  async function runScan() {
164
- console.log('[orchestrator] Scanning environment...\n');
164
+ const jsonOut = process.argv.includes('--json');
165
+ if (!jsonOut) console.log('[orchestrator] Scanning environment...\n');
165
166
  const result = await scan();
167
+ if (jsonOut) {
168
+ process.stdout.write(JSON.stringify(result) + '\n');
169
+ return result;
170
+ }
166
171
 
167
172
  console.log('Host:', JSON.stringify(result.host, null, 2));
168
173
  console.log('\nFindings by domain:');
@@ -190,10 +195,16 @@ async function runScan() {
190
195
  }
191
196
 
192
197
  async function runDispatch() {
193
- console.log('[orchestrator] Scanning then dispatching...\n');
198
+ const jsonOut = process.argv.includes('--json');
199
+ if (!jsonOut) console.log('[orchestrator] Scanning then dispatching...\n');
194
200
  const scanResult = await scan();
195
201
  const plan = dispatch(scanResult.findings);
196
202
 
203
+ if (jsonOut) {
204
+ process.stdout.write(JSON.stringify({ scan: scanResult, dispatch: plan }) + '\n');
205
+ return plan;
206
+ }
207
+
197
208
  console.log(`Dispatch plan — ${plan.plan.length} skills to invoke:\n`);
198
209
 
199
210
  for (const item of plan.plan) {
@@ -233,7 +244,8 @@ function runSkillContext(skillName) {
233
244
 
234
245
  const context = getSkillContext(skillName);
235
246
  if (!context) {
236
- console.error(`Skill not found: ${skillName}`);
247
+ // Unified error shape across the CLI surface — see v0.10.3 bug #18.
248
+ process.stderr.write(JSON.stringify({ ok: false, error: `Skill not found: ${skillName}`, verb: "skill", hint: "Run `exceptd plan` or check skills/ for available skill IDs." }) + "\n");
237
249
  process.exit(1);
238
250
  }
239
251
 
@@ -266,9 +278,15 @@ function runPipeline(triggerType, payload) {
266
278
  }
267
279
 
268
280
  function runCurrency() {
281
+ const jsonOut = process.argv.includes('--json');
269
282
  const result = runCurrencyNow();
270
283
  const { currency_report, action_required, critical_count } = currencyCheck();
271
284
 
285
+ if (jsonOut) {
286
+ process.stdout.write(JSON.stringify({ currency_report, action_required, critical_count, generated_at: new Date().toISOString() }) + '\n');
287
+ return;
288
+ }
289
+
272
290
  console.log(`\nSkill currency check — ${new Date().toISOString()}\n`);
273
291
  console.log('Score | Days | Skill');
274
292
  console.log('------|------|-----');
@@ -383,13 +401,33 @@ async function runValidateCves(rawArgs = []) {
383
401
  process.exit(2);
384
402
  }
385
403
 
386
- const cveIds = Object.keys(catalog).filter(k => /^CVE-\d{4}-\d{4,7}$/.test(k));
404
+ // --since <ISO|YYYY-MM-DD>: scope-limit validation to CVEs whose
405
+ // last_updated (or cisa_kev_date when missing) is on or after the given
406
+ // date. Cuts upstream calls for fleet operators running cron jobs.
407
+ let sinceDate = null;
408
+ for (let i = 0; i < rawArgs.length; i++) {
409
+ if (rawArgs[i] === '--since' && rawArgs[i + 1]) sinceDate = rawArgs[i + 1];
410
+ else if (rawArgs[i].startsWith('--since=')) sinceDate = rawArgs[i].slice('--since='.length);
411
+ }
412
+
413
+ let cveIds = Object.keys(catalog).filter(k => /^CVE-\d{4}-\d{4,7}$/.test(k));
414
+ if (sinceDate) {
415
+ const since = sinceDate.length === 10 ? `${sinceDate}T00:00:00Z` : sinceDate;
416
+ const before = cveIds.length;
417
+ cveIds = cveIds.filter(id => {
418
+ const e = catalog[id];
419
+ const stamp = e.last_updated || e.cisa_kev_date || e.first_seen;
420
+ if (!stamp) return false;
421
+ return stamp >= since;
422
+ });
423
+ console.log(`[validate-cves] --since ${sinceDate} filtered ${before} → ${cveIds.length} CVE(s).`);
424
+ }
387
425
 
388
426
  console.log(`\nCVE Validation — ${new Date().toISOString()}`);
389
427
  const modeStr = offline
390
428
  ? 'offline (local view only)'
391
429
  : (cacheDir ? `live with cache (${path.relative(path.join(__dirname, '..'), cacheDir)})` : 'live (NVD + CISA KEV)');
392
- console.log(`${cveIds.length} CVEs in catalog. Mode: ${modeStr}`);
430
+ console.log(`${cveIds.length} CVEs in catalog. Mode: ${modeStr}${sinceDate ? ` · since=${sinceDate}` : ''}`);
393
431
  console.log(`Fail-on-drift: ${noFail ? 'disabled' : 'enabled'}\n`);
394
432
 
395
433
  // --- Header (fixed-width; works with the existing currency command's style)
@@ -436,7 +474,23 @@ async function runValidateCves(rawArgs = []) {
436
474
  // is set. Cache-resolved CVEs short-circuit the network fetch; missing
437
475
  // entries fall through to the live validator. Both paths produce the
438
476
  // same ValidationResult shape.
439
- const { validateAllCves } = require('../sources/validators');
477
+ //
478
+ // Graceful fallback when sources/validators isn't shipped (matches the
479
+ // pattern validate-rfcs uses below). Pre-v0.10.3 this crashed with
480
+ // MODULE_NOT_FOUND in installed npm packages because sources/ wasn't
481
+ // in the files allowlist.
482
+ let validateAllCves;
483
+ try {
484
+ ({ validateAllCves } = require('../sources/validators'));
485
+ } catch (e) {
486
+ if (e.code === 'MODULE_NOT_FOUND') {
487
+ console.warn('[validate-cves] validator module unavailable (MODULE_NOT_FOUND); falling back to offline mode.');
488
+ console.log(`\n[validate-cves] offline mode (forced by missing validators) — ${cveIds.length} entries listed from local catalog.`);
489
+ process.exit(0);
490
+ return;
491
+ }
492
+ throw e;
493
+ }
440
494
  let report;
441
495
  if (cacheDir && fs.existsSync(cacheDir)) {
442
496
  report = await validateAllCvesPreferCache(catalog, cacheDir);
@@ -568,13 +622,29 @@ async function runValidateRfcs(rawArgs = []) {
568
622
  process.exit(2);
569
623
  }
570
624
 
571
- const ids = Object.keys(refs).filter(k => !k.startsWith('_'));
625
+ // --since <ISO|YYYY-MM-DD>: scope-limit (parity with validate-cves).
626
+ let sinceDate = null;
627
+ for (let i = 0; i < rawArgs.length; i++) {
628
+ if (rawArgs[i] === '--since' && rawArgs[i + 1]) sinceDate = rawArgs[i + 1];
629
+ else if (rawArgs[i].startsWith('--since=')) sinceDate = rawArgs[i].slice('--since='.length);
630
+ }
631
+ let ids = Object.keys(refs).filter(k => !k.startsWith('_'));
632
+ if (sinceDate) {
633
+ const since = sinceDate.length === 10 ? `${sinceDate}T00:00:00Z` : sinceDate;
634
+ const before = ids.length;
635
+ ids = ids.filter(id => {
636
+ const e = refs[id];
637
+ const stamp = e.last_verified || e.published || e.last_updated;
638
+ return stamp && stamp >= since;
639
+ });
640
+ console.log(`[validate-rfcs] --since ${sinceDate} filtered ${before} → ${ids.length} entry(ies).`);
641
+ }
572
642
 
573
643
  console.log(`\nRFC Validation — ${new Date().toISOString()}`);
574
644
  const modeStr = offline
575
645
  ? 'offline (local view only)'
576
646
  : (cacheDir ? `live with cache (${path.relative(path.join(__dirname, '..'), cacheDir)})` : 'live (IETF Datatracker)');
577
- console.log(`${ids.length} RFC / draft entries in catalog. Mode: ${modeStr}`);
647
+ console.log(`${ids.length} RFC / draft entries in catalog. Mode: ${modeStr}${sinceDate ? ` · since=${sinceDate}` : ''}`);
578
648
  console.log(`Fail-on-drift: ${noFail ? 'disabled' : 'enabled'}\n`);
579
649
 
580
650
  const header = 'ID | Status | Errata | Last verified | Live status';
@@ -753,6 +823,26 @@ function runWatchlist(rawArgs = []) {
753
823
  }
754
824
  }
755
825
 
826
+ const jsonOut = rawArgs.includes('--json');
827
+ if (jsonOut) {
828
+ const out = {
829
+ generated_at: new Date().toISOString(),
830
+ skills_scanned: skills.length,
831
+ parse_errors: parseErrors,
832
+ mode: byskill ? 'by-skill' : 'by-item',
833
+ };
834
+ if (byskill) {
835
+ out.by_skill = Object.fromEntries([...skillToItems.entries()]
836
+ .sort(([a], [b]) => a.localeCompare(b))
837
+ .map(([k, v]) => [k, v]));
838
+ } else {
839
+ out.by_item = Object.fromEntries([...itemToSkills.entries()]
840
+ .sort(([a], [b]) => a.localeCompare(b)));
841
+ }
842
+ process.stdout.write(JSON.stringify(out) + '\n');
843
+ return;
844
+ }
845
+
756
846
  console.log(`\nForward-Watch Aggregator — ${new Date().toISOString()}`);
757
847
  console.log(`Skills scanned: ${skills.length} parse errors: ${parseErrors}`);
758
848
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/exceptd-skills",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
4
4
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
5
5
  "keywords": [
6
6
  "ai-security",
@@ -48,6 +48,7 @@
48
48
  "lib/",
49
49
  "orchestrator/",
50
50
  "scripts/",
51
+ "sources/validators/",
51
52
  "vendor/",
52
53
  "agents/",
53
54
  "data/",
package/sbom.cdx.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "bomFormat": "CycloneDX",
3
3
  "specVersion": "1.6",
4
- "serialNumber": "urn:uuid:e91a4a28-720d-4270-94ce-eddb39e029c7",
4
+ "serialNumber": "urn:uuid:9c80f3b8-1fb8-46a8-b68f-e1b6a0ddedb7",
5
5
  "version": 1,
6
6
  "metadata": {
7
- "timestamp": "2026-05-12T13:34:59.113Z",
7
+ "timestamp": "2026-05-12T14:06:23.001Z",
8
8
  "tools": [
9
9
  {
10
10
  "name": "hand-written",
@@ -13,10 +13,10 @@
13
13
  }
14
14
  ],
15
15
  "component": {
16
- "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.10.1",
16
+ "bom-ref": "pkg:npm/@blamejs/exceptd-skills@0.10.3",
17
17
  "type": "application",
18
18
  "name": "@blamejs/exceptd-skills",
19
- "version": "0.10.1",
19
+ "version": "0.10.3",
20
20
  "description": "AI security skills grounded in mid-2026 threat reality, not stale framework documentation. 38 skills, 10 catalogs, 34 jurisdictions, pre-computed indexes, Ed25519-signed.",
21
21
  "licenses": [
22
22
  {
@@ -25,11 +25,11 @@
25
25
  }
26
26
  }
27
27
  ],
28
- "purl": "pkg:npm/%40blamejs/exceptd-skills@0.10.1",
28
+ "purl": "pkg:npm/%40blamejs/exceptd-skills@0.10.3",
29
29
  "externalReferences": [
30
30
  {
31
31
  "type": "distribution",
32
- "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.10.1"
32
+ "url": "https://www.npmjs.com/package/@blamejs/exceptd-skills/v/0.10.3"
33
33
  },
34
34
  {
35
35
  "type": "vcs",
@@ -0,0 +1,170 @@
1
+ # Sources
2
+
3
+ The sources directory is the data quality gate for exceptd Security. Every claim in every skill must trace to a primary source. Bad data in produces bad analysis out — this directory makes source integrity a first-class concern.
4
+
5
+ ## The Problem: Data Corruption in Security Intelligence
6
+
7
+ Security intelligence has several common failure modes:
8
+ - **Stale data**: A CVE is marked as "no public PoC" when a PoC went public six months ago
9
+ - **Misattribution**: A CVSS score copied from a secondary source that applied the wrong vector
10
+ - **Fabricated details**: AI-summarized threat intel that introduced plausible-but-wrong specifics
11
+ - **Framework version drift**: A control ID that changed in a framework revision but wasn't updated in skills
12
+ - **Dead links**: Source URLs that return 404 — removing the ability to verify
13
+
14
+ The sources system prevents these failures by:
15
+ 1. Maintaining a registry of authoritative primary sources per data type
16
+ 2. Providing validators that check data against primary sources
17
+ 3. Tracking source verification dates and flagging stale verifications
18
+ 4. Making multi-agent research verifiable and auditable
19
+
20
+ ---
21
+
22
+ ## Directory Structure
23
+
24
+ ```
25
+ sources/
26
+ ├── README.md # This file
27
+ ├── index.json # Source registry — authoritative sources per data type
28
+ ├── SOURCES.md # Guide for adding and verifying sources
29
+ ├── validators/
30
+ │ ├── cve-validator.js # Cross-check CVE data against NVD API
31
+ │ ├── kev-validator.js # Verify CISA KEV status against official feed
32
+ │ ├── atlas-validator.js # Verify ATLAS TTP IDs against mitre.org
33
+ │ └── framework-validator.js # Verify framework control IDs
34
+ └── feeds/
35
+ ├── cisa-kev-snapshot.json # Snapshot of CISA KEV at last verification
36
+ ├── atlas-version.json # Current ATLAS version metadata
37
+ └── nvd-recent.json # Recent NVD entries (last 30 days)
38
+ ```
39
+
40
+ ---
41
+
42
+ ## Primary Sources by Data Type
43
+
44
+ ### CVE Data
45
+
46
+ | Field | Authoritative Source | Update Frequency |
47
+ |---|---|---|
48
+ | CVSS score + vector | NVD (nvd.nist.gov/vuln/detail/CVE-XXXX) | On NVD analysis |
49
+ | CISA KEV status | CISA KEV catalog (cisa.gov/known-exploited-vulnerabilities-catalog) | Real-time feed |
50
+ | PoC availability | NVD references + researcher advisories | Monitor CVE references |
51
+ | Active exploitation | CISA KEV, threat intelligence, incident reports | Monitor |
52
+ | Affected versions | Vendor advisory (Red Hat, Ubuntu, etc.) | On vendor advisory |
53
+ | Patch availability | Vendor advisory | On vendor advisory |
54
+ | Live patch support | kpatch.com, ubuntu.com/security/livepatch, suse.com/products/live-patching | On vendor announcement |
55
+
56
+ **Never use as primary source:** Wikipedia, news articles, blog posts, AI-generated summaries, secondary aggregators without NVD cross-reference.
57
+
58
+ ### ATLAS TTPs
59
+
60
+ | Field | Authoritative Source |
61
+ |---|---|
62
+ | TTP ID | atlas.mitre.org (canonical IDs may change between versions) |
63
+ | TTP name | atlas.mitre.org/techniques/ |
64
+ | TTP version | atlas.mitre.org/resources/changelog |
65
+
66
+ **ATLAS version pinning:** All skills reference a specific ATLAS version. When ATLAS updates, TTP IDs must be re-verified. The `atlas-validator.js` checks all skill `atlas_refs` against the current published ATLAS.
67
+
68
+ ### Framework Controls
69
+
70
+ | Framework | Authoritative Source |
71
+ |---|---|
72
+ | NIST 800-53 Rev 5 | csrc.nist.gov/publications/detail/sp/800-53/rev-5/final |
73
+ | ISO 27001:2022 | iso.org/standard/27001 (requires purchase for full text) |
74
+ | SOC 2 | aicpa.org (TSC 2017) |
75
+ | PCI DSS 4.0 | pcisecuritystandards.org/document_library |
76
+ | NIS2 | eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32022L2555 |
77
+ | DORA | eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32022R2554 |
78
+ | EU AI Act | eur-lex.europa.eu/legal-content/EN/TXT/?uri=CELEX:32024R1689 |
79
+ | EU CRA | Official Journal of EU |
80
+ | NCSC CAF | ncsc.gov.uk/collection/cyber-assessment-framework |
81
+ | ASD ISM | cyber.gov.au/resources-business-and-government/essential-cyber-security/ism |
82
+ | ASD Essential 8 | cyber.gov.au/resources-business-and-government/essential-cyber-security/essential-eight |
83
+ | MAS TRM | mas.gov.sg/regulation/guidelines/technology-risk-management-guidelines |
84
+ | CIS Controls v8 | cisecurity.org/controls/v8 |
85
+ | CSA CCM v4 | cloudsecurityalliance.org/research/cloud-controls-matrix |
86
+
87
+ ### PQC Standards
88
+
89
+ | Standard | Authoritative Source |
90
+ |---|---|
91
+ | FIPS 203 (ML-KEM) | csrc.nist.gov/pubs/fips/203/final |
92
+ | FIPS 204 (ML-DSA) | csrc.nist.gov/pubs/fips/204/final |
93
+ | FIPS 205 (SLH-DSA) | csrc.nist.gov/pubs/fips/205/final |
94
+ | FIPS 206 (HQC, pending) | csrc.nist.gov/projects/post-quantum-cryptography |
95
+ | OpenSSL 3.5 release notes | github.com/openssl/openssl/blob/master/CHANGES.md |
96
+ | CNSA 2.0 | cnss.gov |
97
+
98
+ ---
99
+
100
+ ## Source Verification Requirement
101
+
102
+ Every entry in `data/cve-catalog.json` must have a `source_verified` field:
103
+ ```json
104
+ {
105
+ "source_verified": "2026-05-01",
106
+ "verification_sources": [
107
+ "https://nvd.nist.gov/vuln/detail/CVE-2026-31431",
108
+ "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"
109
+ ]
110
+ }
111
+ ```
112
+
113
+ A `source_verified` date older than 90 days triggers a reverification requirement in the skill-update-loop.
114
+
115
+ ---
116
+
117
+ ## Multi-Agent Research Protocol
118
+
119
+ When agents research new threat intelligence, they must:
120
+ 1. Identify primary sources (from the registry above)
121
+ 2. Record what was found at each source and when
122
+ 3. Cross-reference across at least 2 independent sources for critical claims
123
+ 4. Flag any claim that could only be verified from a single source
124
+ 5. Record the agent ID and timestamp in the `source_verified` audit trail
125
+
126
+ See `agents/threat-researcher.md` for the research agent protocol.
127
+
128
+ ---
129
+
130
+ ## Bad Data Prevention
131
+
132
+ These categories of sources are **rejected** for skill data:
133
+
134
+ | Source Type | Why Rejected |
135
+ |---|---|
136
+ | AI-generated summaries without primary source citation | Plausible hallucination risk |
137
+ | News articles | Often inaccurate on technical details, not updated when details change |
138
+ | Blog posts | No editorial standard, often repost errors from other blogs |
139
+ | Wikipedia | Community-edited, not authoritative for CVE details or framework text |
140
+ | Secondary aggregators without NVD cross-reference | May lag or misquote NVD |
141
+ | Social media / X posts | Not citable, not stable |
142
+ | Forum posts | Not authoritative |
143
+
144
+ The only exception: researcher/discoverer announcements about their own research (e.g., Hyunwoo Kim's Dirty Frag disclosure) may be used as a source alongside NVD, since the researcher is the primary source for their own findings.
145
+
146
+ ---
147
+
148
+ ## Validators
149
+
150
+ Real validation against primary sources lives in `sources/validators/`. These are
151
+ zero-dependency Node 24 modules (stdlib `fetch`, `AbortController`, `fs/promises`
152
+ only). Every network call has a 10s timeout and degrades to an `unreachable`
153
+ status rather than throwing — the validators are safe to run in airgapped CI.
154
+
155
+ | Module | Purpose | Upstream |
156
+ |---|---|---|
157
+ | [`validators/cve-validator.js`](validators/cve-validator.js) | Cross-check one CVE's CVSS score, vector, and KEV status against NVD and the CISA KEV feed. Caches the KEV feed once per process. | NVD `services.nvd.nist.gov` + CISA KEV JSON |
158
+ | [`validators/atlas-validator.js`](validators/atlas-validator.js) | Confirm the pinned MITRE ATLAS version (in `manifest.json` and `sources/index.json`) matches the latest upstream release. | GitHub releases for `mitre-atlas/atlas-data`, raw `ATLAS.yaml` fallback |
159
+ | [`validators/index.js`](validators/index.js) | Barrel export plus `validateAllCves(catalog)` for catalog-wide aggregation with bounded concurrency. | — |
160
+
161
+ The orchestrator wires the CVE validator into the CLI:
162
+
163
+ ```
164
+ node orchestrator/index.js validate-cves # live cross-check, non-zero exit on drift
165
+ node orchestrator/index.js validate-cves --offline # local view only, no network
166
+ node orchestrator/index.js validate-cves --no-fail # report drift but always exit 0
167
+ ```
168
+
169
+ Feed snapshots are written under `sources/feeds/`; see `sources/feeds/README.md`
170
+ for the cache contract and freshness thresholds.
@@ -0,0 +1,158 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * atlas-validator.js — Confirm pinned MITRE ATLAS version against upstream.
5
+ *
6
+ * Zero npm dependencies. Node 24 stdlib only.
7
+ *
8
+ * MITRE ATLAS does not (as of v5.x) publish a stable machine-readable changelog JSON.
9
+ * The canonical source-of-truth for releases is the public GitHub repo:
10
+ * https://raw.githubusercontent.com/mitre-atlas/atlas-data/main/dist/ATLAS.yaml
11
+ * which carries an `id: ATLAS` / `version: x.y.z` header. The GitHub releases API
12
+ * also lists tagged versions:
13
+ * https://api.github.com/repos/mitre-atlas/atlas-data/releases/latest
14
+ *
15
+ * We prefer the releases API (lightweight JSON, no YAML parsing), fall back to the
16
+ * raw YAML version line, and finally report unreachable if both fail. Both are
17
+ * read-only public endpoints; no auth is required.
18
+ *
19
+ * Exported:
20
+ * validateAtlasVersion(opts?) -> Promise<{
21
+ * pinned: string|null,
22
+ * pinned_sources: { manifest: string|null, index: string|null },
23
+ * latest: string|null,
24
+ * drift: boolean,
25
+ * status: 'match'|'drift'|'unreachable'|'unknown',
26
+ * fetched_from: string|null,
27
+ * error: string|null
28
+ * }>
29
+ */
30
+
31
+ const fs = require('node:fs/promises');
32
+ const path = require('node:path');
33
+
34
+ const REQUEST_TIMEOUT_MS = 10_000;
35
+ const USER_AGENT = 'exceptd-security/atlas-validator (+https://exceptd.com)';
36
+
37
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
38
+ const MANIFEST_PATH = path.join(REPO_ROOT, 'manifest.json');
39
+ const SOURCES_INDEX_PATH = path.join(REPO_ROOT, 'sources', 'index.json');
40
+
41
+ const GH_RELEASE_URL = 'https://api.github.com/repos/mitre-atlas/atlas-data/releases/latest';
42
+ const RAW_ATLAS_YAML = 'https://raw.githubusercontent.com/mitre-atlas/atlas-data/main/dist/ATLAS.yaml';
43
+
44
+ async function timedFetch(url, accept = 'application/json') {
45
+ const controller = new AbortController();
46
+ const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
47
+ try {
48
+ const res = await fetch(url, {
49
+ signal: controller.signal,
50
+ headers: { 'User-Agent': USER_AGENT, Accept: accept },
51
+ });
52
+ if (!res.ok) return { ok: false, error: `HTTP ${res.status}` };
53
+ const body = accept.includes('json') ? await res.json() : await res.text();
54
+ return { ok: true, body };
55
+ } catch (err) {
56
+ const code = err.name === 'AbortError' ? 'timeout' : (err.code || 'network_error');
57
+ return { ok: false, error: `${code}: ${err.message}` };
58
+ } finally {
59
+ clearTimeout(timer);
60
+ }
61
+ }
62
+
63
+ function normalizeVersion(v) {
64
+ if (!v || typeof v !== 'string') return null;
65
+ // Strip leading "v" / "ATLAS-v" prefixes; trim.
66
+ return v.trim().replace(/^ATLAS[-_ ]?/i, '').replace(/^v/i, '');
67
+ }
68
+
69
+ async function readPinnedVersions() {
70
+ const out = { manifest: null, index: null };
71
+ try {
72
+ const manifest = JSON.parse(await fs.readFile(MANIFEST_PATH, 'utf8'));
73
+ out.manifest = normalizeVersion(
74
+ manifest?._meta?.atlas_version || manifest?.atlas_version || null
75
+ );
76
+ } catch { /* leave null */ }
77
+ try {
78
+ const idx = JSON.parse(await fs.readFile(SOURCES_INDEX_PATH, 'utf8'));
79
+ out.index = normalizeVersion(idx?.sources?.atlas?.current_version || null);
80
+ } catch { /* leave null */ }
81
+ return out;
82
+ }
83
+
84
+ async function fetchLatestFromGithubReleases() {
85
+ const res = await timedFetch(GH_RELEASE_URL);
86
+ if (!res.ok) return { ok: false, error: res.error };
87
+ const tag = res.body?.tag_name || res.body?.name || null;
88
+ const version = normalizeVersion(tag);
89
+ if (!version) return { ok: false, error: 'no tag_name in response' };
90
+ return { ok: true, version, source: 'github-releases' };
91
+ }
92
+
93
+ async function fetchLatestFromRawYaml() {
94
+ const res = await timedFetch(RAW_ATLAS_YAML, 'text/yaml');
95
+ if (!res.ok) return { ok: false, error: res.error };
96
+ // Naive YAML scrape: look for a top-level `version:` line within the first 200 lines.
97
+ const text = String(res.body).split(/\r?\n/).slice(0, 200).join('\n');
98
+ const match = text.match(/^version:\s*['"]?([0-9]+(?:\.[0-9]+){1,2})['"]?\s*$/m);
99
+ if (!match) return { ok: false, error: 'version line not found in ATLAS.yaml' };
100
+ return { ok: true, version: normalizeVersion(match[1]), source: 'raw-yaml' };
101
+ }
102
+
103
+ async function validateAtlasVersion(_opts = {}) {
104
+ const pinned_sources = await readPinnedVersions();
105
+ // Canonical pinned value: prefer manifest._meta or top-level, then sources/index.json.
106
+ const pinned = pinned_sources.manifest || pinned_sources.index || null;
107
+
108
+ // Cross-check that the two pinned locations agree.
109
+ const pinnedDisagree =
110
+ pinned_sources.manifest &&
111
+ pinned_sources.index &&
112
+ pinned_sources.manifest !== pinned_sources.index;
113
+
114
+ // Try GitHub releases first, fall back to raw YAML.
115
+ let upstream = await fetchLatestFromGithubReleases();
116
+ if (!upstream.ok) {
117
+ const fallback = await fetchLatestFromRawYaml();
118
+ if (fallback.ok) upstream = fallback;
119
+ }
120
+
121
+ if (!upstream.ok) {
122
+ return {
123
+ pinned,
124
+ pinned_sources,
125
+ latest: null,
126
+ drift: pinnedDisagree === true, // internal drift is still reportable offline
127
+ status: 'unreachable',
128
+ fetched_from: null,
129
+ error: upstream.error,
130
+ };
131
+ }
132
+
133
+ const latest = upstream.version;
134
+ if (!pinned) {
135
+ return {
136
+ pinned: null,
137
+ pinned_sources,
138
+ latest,
139
+ drift: true,
140
+ status: 'unknown',
141
+ fetched_from: upstream.source,
142
+ error: 'no pinned ATLAS version found in manifest.json or sources/index.json',
143
+ };
144
+ }
145
+
146
+ const drift = pinned !== latest || pinnedDisagree === true;
147
+ return {
148
+ pinned,
149
+ pinned_sources,
150
+ latest,
151
+ drift,
152
+ status: drift ? 'drift' : 'match',
153
+ fetched_from: upstream.source,
154
+ error: null,
155
+ };
156
+ }
157
+
158
+ module.exports = { validateAtlasVersion };