@blamejs/exceptd-skills 0.18.6 → 0.18.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/bin/exceptd.js +261 -56
- package/data/_indexes/_meta.json +22 -3
- package/data/cve-catalog.json +25 -0
- package/data/playbooks/framework.json +2 -2
- package/data/playbooks/post-quantum-migration.json +1 -1
- package/lib/auto-discovery.js +30 -10
- package/lib/collectors/ai-api.js +9 -2
- package/lib/collectors/cicd-pipeline-compromise.js +24 -5
- package/lib/collectors/cred-stores.js +17 -4
- package/lib/collectors/crypto.js +9 -2
- package/lib/collectors/hardening.js +9 -2
- package/lib/collectors/library-author.js +24 -3
- package/lib/collectors/mcp.js +9 -2
- package/lib/collectors/runtime.js +9 -2
- package/lib/collectors/sbom.js +28 -15
- package/lib/collectors/scan-excludes.js +25 -0
- package/lib/collectors/secrets.js +40 -4
- package/lib/cve-curation.js +84 -8
- package/lib/lint-skills.js +75 -3
- package/lib/playbook-runner.js +375 -40
- package/lib/prefetch.js +6 -1
- package/lib/refresh-external.js +32 -1
- package/lib/refresh-network.js +201 -24
- package/lib/schemas/cve-catalog.schema.json +5 -0
- package/lib/scoring.js +106 -13
- package/lib/sign.js +107 -29
- package/lib/source-advisories.js +23 -5
- package/lib/source-ghsa.js +25 -1
- package/lib/source-osv.js +26 -1
- package/lib/upstream-check.js +1 -1
- package/lib/validate-cve-catalog.js +19 -3
- package/lib/validate-indexes.js +62 -1
- package/lib/validate-playbooks.js +4 -1
- package/lib/validate-vendor.js +69 -8
- package/manifest.json +53 -53
- package/orchestrator/index.js +69 -13
- package/package.json +1 -1
- package/sbom.cdx.json +124 -124
- package/scripts/audit-cross-skill.js +1 -1
- package/scripts/bootstrap.js +1 -0
- package/scripts/build-indexes.js +58 -4
- package/scripts/check-agents-md-collectors.js +41 -13
- package/scripts/check-changelog-extract.js +4 -4
- package/scripts/check-codebase-patterns.js +19 -5
- package/scripts/check-manifest-snapshot.js +74 -30
- package/scripts/check-sbom-currency.js +25 -5
- package/scripts/check-test-count.js +26 -7
- package/scripts/check-test-coverage.js +44 -4
- package/scripts/check-version-tags.js +27 -8
- package/scripts/predeploy.js +1 -1
- package/scripts/refresh-manifest-snapshot.js +14 -4
- package/scripts/refresh-reverse-refs.js +7 -1
- package/scripts/refresh-sbom.js +1 -1
- package/scripts/release.js +3 -3
- package/scripts/run-e2e-scenarios.js +18 -8
- package/scripts/validate-vendor-online.js +20 -2
- package/scripts/verify-shipped-tarball.js +65 -6
- package/sources/validators/cve-validator.js +17 -1
- package/vendor/blamejs/_PROVENANCE.json +4 -2
|
@@ -83,11 +83,26 @@ const CONTENT_GATED_KEY_EXTS = new Set([".pem", ".key"]);
|
|
|
83
83
|
function carriesPrivateKey(file) {
|
|
84
84
|
const ext = path.extname(file.name).toLowerCase();
|
|
85
85
|
if (!CONTENT_GATED_KEY_EXTS.has(ext)) return true; // .p12/.pfx/id_* → private by nature
|
|
86
|
+
let fd;
|
|
86
87
|
try {
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
fd = fs.openSync(file.full, "r");
|
|
89
|
+
const st = fs.fstatSync(fd); // fstat on the open fd — no stat-then-read race
|
|
90
|
+
if (st.size > MAX_FILE_BYTES) return true; // too big to scan → conservative
|
|
91
|
+
// Read the whole descriptor to EOF. A single readSync may return fewer
|
|
92
|
+
// bytes than requested (a short read on a network / FUSE / interrupted fd),
|
|
93
|
+
// which would leave the buffer's tail NUL-padded; if the BEGIN marker sat
|
|
94
|
+
// past that boundary — e.g. a .pem with a leading `Bag Attributes` /
|
|
95
|
+
// subject= header block, the default OpenSSL pkcs12→PEM layout — the marker
|
|
96
|
+
// test would return false and silently drop a real private key. readFileSync
|
|
97
|
+
// on the open fd loops internally until EOF, so the test always sees the
|
|
98
|
+
// complete content. Reading from the already-open fd keeps the no-TOCTOU
|
|
99
|
+
// open→fstat ordering (no second path lookup).
|
|
100
|
+
const content = fs.readFileSync(fd, "utf8");
|
|
101
|
+
return PEM_PRIVATE_KEY_MARKER.test(content);
|
|
89
102
|
} catch {
|
|
90
103
|
return true; // unreadable → conservative (treat as a key)
|
|
104
|
+
} finally {
|
|
105
|
+
if (fd !== undefined) { try { fs.closeSync(fd); } catch { /* non-fatal */ } }
|
|
91
106
|
}
|
|
92
107
|
}
|
|
93
108
|
|
|
@@ -227,7 +242,14 @@ const INDICATOR_PATTERNS = [
|
|
|
227
242
|
// run halts at the first `-` of `-----END` (no backtracking, ReDoS-safe).
|
|
228
243
|
// Private-key *files* (.pem/.key) are detected separately by file presence.
|
|
229
244
|
{ id: "ssh-private-key-block", re: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |ENCRYPTED |)PRIVATE KEY-----[A-Za-z0-9+/=\s]{20,4000}-----END/g },
|
|
230
|
-
|
|
245
|
+
// The `(?!ant-)` lookahead keeps an Anthropic `sk-ant-*` key from also
|
|
246
|
+
// matching here. Without it the trailing empty alternative made the prefix
|
|
247
|
+
// optional (`sk-<20+ chars>`), so one Anthropic key double-fired as both
|
|
248
|
+
// openai-api-key and anthropic-api-key — inflating the hit count and
|
|
249
|
+
// mislabeling the vendor. The optional `(?:proj-|svcacct-|admin-)?` group
|
|
250
|
+
// still admits every real OpenAI shape (proj/svcacct/admin and the bare
|
|
251
|
+
// legacy `sk-` key); the lookahead is anchored, so no backtracking / ReDoS.
|
|
252
|
+
{ id: "openai-api-key", re: /\bsk-(?!ant-)(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}\b/g },
|
|
231
253
|
{ id: "anthropic-api-key", re: /\bsk-ant-[A-Za-z0-9_-]{20,}\b/g },
|
|
232
254
|
];
|
|
233
255
|
|
|
@@ -339,12 +361,26 @@ function collect({ cwd = process.cwd(), env = process.env, args = {} } = {}) {
|
|
|
339
361
|
const root = path.resolve(cwd);
|
|
340
362
|
|
|
341
363
|
let files;
|
|
364
|
+
const truncations = [];
|
|
342
365
|
try {
|
|
343
|
-
files = walkTree(root, { maxDepth: DEFAULT_MAX_DEPTH, excludes: DEFAULT_EXCLUDES });
|
|
366
|
+
files = walkTree(root, { maxDepth: DEFAULT_MAX_DEPTH, excludes: DEFAULT_EXCLUDES, truncations });
|
|
344
367
|
} catch (e) {
|
|
345
368
|
errors.push({ kind: "walk_failed", reason: e.message });
|
|
346
369
|
files = [];
|
|
347
370
|
}
|
|
371
|
+
// Subtrees pruned for exceeding the depth cap are NOT scanned. Surface them
|
|
372
|
+
// so an unscanned deep .env / id_rsa / api-key file is an observable gap,
|
|
373
|
+
// not a silent miss indistinguishable from "scanned, found nothing" —
|
|
374
|
+
// symmetric with the per-file `file_too_large_skipped` reporting below.
|
|
375
|
+
if (truncations.length) {
|
|
376
|
+
errors.push({
|
|
377
|
+
artifact_id: "repo-tree",
|
|
378
|
+
kind: "depth_capped",
|
|
379
|
+
reason: `${truncations.length} subtree(s) beyond depth ${DEFAULT_MAX_DEPTH} not scanned for secrets: ${truncations.slice(0, 20).map(t => t.rel).join(", ")}${truncations.length > 20 ? ", …" : ""}`,
|
|
380
|
+
truncated_paths: truncations.slice(0, 50).map(t => t.rel),
|
|
381
|
+
truncated_count: truncations.length,
|
|
382
|
+
});
|
|
383
|
+
}
|
|
348
384
|
if (files.length > 50000) {
|
|
349
385
|
errors.push({
|
|
350
386
|
kind: "file_count_capped",
|
package/lib/cve-curation.js
CHANGED
|
@@ -42,7 +42,19 @@ const path = require("path");
|
|
|
42
42
|
// 3. Validates the post-apply entry against lib/schemas/cve-catalog.schema.json
|
|
43
43
|
// before deciding promotion.
|
|
44
44
|
const { withCatalogLock } = require("./refresh-external");
|
|
45
|
-
|
|
45
|
+
// `validate` is the LOOSE JSON-schema checker; `additionalChecks` is the
|
|
46
|
+
// strict rule set (STRICT_CVSS_PATTERN, isUsableDate on every date field,
|
|
47
|
+
// cisa_kev=true-requires-valid-date, cross-catalog ref resolution, Hard-Rule
|
|
48
|
+
// #14 IoCs). The predeploy gate runs `validate-cve-catalog.js --strict`, which
|
|
49
|
+
// promotes additionalChecks warnings to FATAL errors on NON-draft entries. The
|
|
50
|
+
// apply path must gate promotion on BOTH — otherwise it strips the _draft
|
|
51
|
+
// markers off an entry the very next predeploy run then FATAL-rejects.
|
|
52
|
+
const {
|
|
53
|
+
validate: validateAgainstSchema,
|
|
54
|
+
additionalChecks,
|
|
55
|
+
STRICT_CVSS_PATTERN,
|
|
56
|
+
isUsableDate,
|
|
57
|
+
} = require("./validate-cve-catalog");
|
|
46
58
|
// derive rwep_score via the canonical scoring helper rather
|
|
47
59
|
// than a blind `Object.values(...).reduce(sum)`. The helper detects shape
|
|
48
60
|
// (boolean inputs → scoreCustom; post-weight numeric inputs → sum + clamp)
|
|
@@ -107,6 +119,30 @@ function loadJson(relOrAbs) {
|
|
|
107
119
|
return v;
|
|
108
120
|
}
|
|
109
121
|
|
|
122
|
+
// Build the cross-reference context additionalChecks() consumes — the same
|
|
123
|
+
// id Sets lib/validate-cve-catalog.js#main() assembles. Cached per process
|
|
124
|
+
// (the underlying catalogs are immutable inside one CLI run). A `null` Set
|
|
125
|
+
// (catalog absent) makes additionalChecks() skip that ref family, matching
|
|
126
|
+
// the validator's defense-in-depth behaviour.
|
|
127
|
+
let _strictCtxCache = null;
|
|
128
|
+
function strictCheckCtx() {
|
|
129
|
+
if (_strictCtxCache) return _strictCtxCache;
|
|
130
|
+
const keySet = (relPath) => {
|
|
131
|
+
const cat = loadJson(relPath);
|
|
132
|
+
return cat && typeof cat === "object"
|
|
133
|
+
? new Set(Object.keys(cat).filter((k) => !k.startsWith("_")))
|
|
134
|
+
: null;
|
|
135
|
+
};
|
|
136
|
+
_strictCtxCache = {
|
|
137
|
+
atlasKeys: keySet("data/atlas-ttps.json"),
|
|
138
|
+
cweKeys: keySet("data/cwe-catalog.json"),
|
|
139
|
+
attackKeys: keySet("data/attack-techniques.json"),
|
|
140
|
+
d3fendKeys: keySet("data/d3fend-catalog.json"),
|
|
141
|
+
frameworkKeys: keySet("data/framework-control-gaps.json"),
|
|
142
|
+
};
|
|
143
|
+
return _strictCtxCache;
|
|
144
|
+
}
|
|
145
|
+
|
|
110
146
|
/**
|
|
111
147
|
* Score a candidate by counting keyword overlap with the draft entry.
|
|
112
148
|
* Returns 0..100. Pure heuristic — reviewer makes the final call.
|
|
@@ -519,19 +555,42 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
519
555
|
// is ignored (so a typo'd key in answers.json doesn't pollute the entry).
|
|
520
556
|
// Each entry: [field, validator(value) -> bool, transformer(value) -> stored].
|
|
521
557
|
const id = (x) => x;
|
|
558
|
+
|
|
559
|
+
// rwep_factors must arrive as the post-weight (Shape B) block the schema
|
|
560
|
+
// mandates: a non-array object whose EVERY value is a finite number. A
|
|
561
|
+
// permissive "any object" check let a mixed-shape bag through — post-weight
|
|
562
|
+
// integers carrying a string active_exploitation, or an all-numeric block
|
|
563
|
+
// plus a rogue extra key holding a ladder string ('confirmed'). Either
|
|
564
|
+
// shape flips deriveRwepFromFactors() onto its Shape-A (scoreCustom) route,
|
|
565
|
+
// which reads the post-weight integers as booleans and DROPS the unmapped
|
|
566
|
+
// ai_factor weight — deriving a silently wrong rwep_score (e.g. 55/75 for a
|
|
567
|
+
// bag the operator intended as 90) and writing it to the catalog. Rejecting
|
|
568
|
+
// any non-numeric value here turns that silent mis-score into a surfaced
|
|
569
|
+
// rejected_fields entry at apply time, instead of relying on the downstream
|
|
570
|
+
// scoring.validate() divergence gate (which runs only at predeploy, after
|
|
571
|
+
// the wrong score is already on disk).
|
|
572
|
+
const isPostWeightFactors = (v) =>
|
|
573
|
+
v && typeof v === "object" && !Array.isArray(v) &&
|
|
574
|
+
Object.values(v).every((x) => typeof x === "number" && Number.isFinite(x));
|
|
575
|
+
|
|
522
576
|
const ALLOWED = [
|
|
523
577
|
["framework_control_gaps", (v) => v && typeof v === "object" && !Array.isArray(v), id],
|
|
524
578
|
["iocs", (v) => v && typeof v === "object" && !Array.isArray(v), id],
|
|
525
579
|
["atlas_refs", (v) => Array.isArray(v), id],
|
|
526
580
|
["attack_refs", (v) => Array.isArray(v), id],
|
|
527
|
-
["rwep_factors",
|
|
581
|
+
["rwep_factors", isPostWeightFactors, id],
|
|
528
582
|
["rwep_score", (v) => typeof v === "number", id],
|
|
529
583
|
["rwep_notes", (v) => typeof v === "string", id],
|
|
530
584
|
["cvss_score", (v) => typeof v === "number", id],
|
|
531
|
-
|
|
585
|
+
// Match the predeploy strict gate at apply time: a non-existent CVSS
|
|
586
|
+
// version (`CVSS:9/...`) and a malformed/impossible KEV date previously
|
|
587
|
+
// survived the per-field check, landed on the entry, then blocked
|
|
588
|
+
// promotion downstream. Rejecting them here surfaces the exact field as a
|
|
589
|
+
// rejected_fields entry instead of a silent block.
|
|
590
|
+
["cvss_vector", (v) => typeof v === "string" && STRICT_CVSS_PATTERN.test(v), id],
|
|
532
591
|
["cisa_kev", (v) => typeof v === "boolean", id],
|
|
533
|
-
["cisa_kev_date", (v) => v === null || typeof v === "string", id],
|
|
534
|
-
["cisa_kev_due_date", (v) => v === null || typeof v === "string", id],
|
|
592
|
+
["cisa_kev_date", (v) => v === null || (typeof v === "string" && isUsableDate(v).ok), id],
|
|
593
|
+
["cisa_kev_due_date", (v) => v === null || (typeof v === "string" && isUsableDate(v).ok), id],
|
|
535
594
|
["poc_available", (v) => typeof v === "boolean", id],
|
|
536
595
|
["poc_description", (v) => typeof v === "string", id],
|
|
537
596
|
["ai_discovered", (v) => typeof v === "boolean", id],
|
|
@@ -597,8 +656,20 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
597
656
|
const errs = validateAgainstSchema(entry, entrySchema, "cve-entry", `${cveId}`);
|
|
598
657
|
if (Array.isArray(errs) && errs.length > 0) schemaErrors.push(...errs);
|
|
599
658
|
}
|
|
659
|
+
// The loose schema accepts values the predeploy `validate-cve-catalog.js
|
|
660
|
+
// --strict` gate FATAL-rejects on a non-draft entry: a cvss_vector for a
|
|
661
|
+
// non-existent CVSS version (`CVSS:9/...` passes the schema's
|
|
662
|
+
// `^CVSS:[0-9]+(\.[0-9]+)?/` but fails STRICT_CVSS_PATTERN), a cisa_kev=true
|
|
663
|
+
// with a malformed/impossible cisa_kev_date, an atlas/attack/cwe/d3fend/
|
|
664
|
+
// framework ref that resolves to no catalog entry, or a poc+public-exploit
|
|
665
|
+
// source with no iocs. Run the SAME additionalChecks() the validator runs
|
|
666
|
+
// and fold its findings into the promotion gate, so the apply path never
|
|
667
|
+
// strips _draft off an entry the next predeploy run then rejects. Surfaced
|
|
668
|
+
// under a dedicated `strict_warnings` key so the operator sees exactly which
|
|
669
|
+
// strict rule blocked promotion.
|
|
670
|
+
const strictWarnings = additionalChecks(cveId, entry, strictCheckCtx());
|
|
600
671
|
let promoted = false;
|
|
601
|
-
if (warnings.length === 0 && schemaErrors.length === 0) {
|
|
672
|
+
if (warnings.length === 0 && schemaErrors.length === 0 && strictWarnings.length === 0) {
|
|
602
673
|
delete entry._auto_imported;
|
|
603
674
|
delete entry._draft;
|
|
604
675
|
delete entry._draft_reason;
|
|
@@ -621,15 +692,20 @@ function applyAnswersUnderLock(cveId, catalog, catalogPath, answers) {
|
|
|
621
692
|
is_draft: !promoted,
|
|
622
693
|
residual_warnings: warnings,
|
|
623
694
|
schema_errors: schemaErrors,
|
|
695
|
+
strict_warnings: strictWarnings,
|
|
624
696
|
catalog_path: catalogPath,
|
|
625
697
|
next_steps: promoted
|
|
626
698
|
? [
|
|
627
|
-
`Draft promoted to full entry. Run \`node lib/validate-cve-catalog.js --quiet\` to confirm it passes the
|
|
699
|
+
`Draft promoted to full entry. Run \`node lib/validate-cve-catalog.js --strict --quiet\` to confirm it passes the predeploy gate.`,
|
|
628
700
|
`Add a matching entry to data/zeroday-lessons.json (AGENTS.md rule #6).`,
|
|
629
701
|
`Run \`npm run predeploy\` before tagging.`,
|
|
630
702
|
]
|
|
631
703
|
: [
|
|
632
|
-
|
|
704
|
+
warnings.length > 0
|
|
705
|
+
? `Entry remains a DRAFT (${warnings.length} required field(s) still unpopulated). Supply answers for: ${warnings.slice(0, 6).join(", ")}${warnings.length > 6 ? ", ..." : ""}.`
|
|
706
|
+
: (schemaErrors.length > 0
|
|
707
|
+
? `Entry remains a DRAFT — schema validation failed: ${schemaErrors.slice(0, 4).join("; ")}${schemaErrors.length > 4 ? "; ..." : ""}.`
|
|
708
|
+
: `Entry remains a DRAFT — the predeploy --strict checks rejected it: ${strictWarnings.slice(0, 4).join("; ")}${strictWarnings.length > 4 ? "; ..." : ""}.`),
|
|
633
709
|
`Re-run \`exceptd refresh --curate ${cveId}\` to see the updated questionnaire.`,
|
|
634
710
|
],
|
|
635
711
|
};
|
package/lib/lint-skills.js
CHANGED
|
@@ -679,7 +679,7 @@ function lintSkill(entry, ctx) {
|
|
|
679
679
|
// on their own cadence and the catalog frequently carries
|
|
680
680
|
// auto-imported drafts that skills can legitimately cite.
|
|
681
681
|
if (ctx.cveCatalog && body && typeof body === 'string') {
|
|
682
|
-
const cveRefRe = /\b(CVE
|
|
682
|
+
const cveRefRe = /\b(CVE-\d{4}-\d{4,7}|MAL-\d{4}-[A-Z0-9-]+)\b/g;
|
|
683
683
|
const seen = new Set();
|
|
684
684
|
let m;
|
|
685
685
|
while ((m = cveRefRe.exec(body)) !== null) {
|
|
@@ -819,6 +819,59 @@ function findOrphanSkillFiles(manifestSkills) {
|
|
|
819
819
|
return orphans;
|
|
820
820
|
}
|
|
821
821
|
|
|
822
|
+
// Manifest cover arrays that must resolve to a real catalog entry, paired
|
|
823
|
+
// with the loadContext() key-set they resolve against. The manifest is an
|
|
824
|
+
// enriched superset of frontmatter (it may carry curated refs absent from
|
|
825
|
+
// any skill body), and those manifest-only refs are what build-indexes'
|
|
826
|
+
// reverse-ref surface and refresh-reverse-refs read — yet the per-skill
|
|
827
|
+
// frontmatter ref-resolution above never sees them. Without this pass a
|
|
828
|
+
// typo'd or stale manifest-only ref (e.g. a hand-edit, or one re-signed
|
|
829
|
+
// into manifest_signature) becomes an orphaned control reference in the
|
|
830
|
+
// signed manifest + every derived surface, the exact "no orphaned controls"
|
|
831
|
+
// failure (AGENTS.md Hard Rule #4) the frontmatter resolution prevents —
|
|
832
|
+
// applied to the manifest-only delta the frontmatter pass is blind to.
|
|
833
|
+
const MANIFEST_COVER_RESOLUTION = [
|
|
834
|
+
{ field: 'atlas_refs', ctxKey: 'atlasKeys', catalog: 'data/atlas-ttps.json' },
|
|
835
|
+
{ field: 'attack_refs', ctxKey: 'attackKeys', catalog: 'data/attack-techniques.json' },
|
|
836
|
+
{ field: 'framework_gaps', ctxKey: 'frameworkKeys', catalog: 'data/framework-control-gaps.json' },
|
|
837
|
+
{ field: 'rfc_refs', ctxKey: 'rfcKeys', catalog: 'data/rfc-references.json' },
|
|
838
|
+
{ field: 'cwe_refs', ctxKey: 'cweKeys', catalog: 'data/cwe-catalog.json' },
|
|
839
|
+
{ field: 'd3fend_refs', ctxKey: 'd3fendKeys', catalog: 'data/d3fend-catalog.json' },
|
|
840
|
+
{ field: 'dlp_refs', ctxKey: 'dlpKeys', catalog: 'data/dlp-controls.json' },
|
|
841
|
+
];
|
|
842
|
+
|
|
843
|
+
/*
|
|
844
|
+
* Manifest cover-array ref resolution. For every manifest skill entry,
|
|
845
|
+
* assert each ref in its cross-reference cover arrays resolves to a
|
|
846
|
+
* top-level non-underscore key in the matching catalog. This extends the
|
|
847
|
+
* per-skill frontmatter ref-resolution (which reads only skill bodies) to
|
|
848
|
+
* the MANIFEST cover arrays — the signed, index-feeding source of truth
|
|
849
|
+
* that build-indexes' reverse-ref surface and refresh-reverse-refs read.
|
|
850
|
+
*
|
|
851
|
+
* A ctxKey set that is null (the optional attack-techniques.json catalog is
|
|
852
|
+
* absent in older trees) skips that field, mirroring loadContext()'s
|
|
853
|
+
* graceful-degradation contract for ctx.attackKeys.
|
|
854
|
+
*
|
|
855
|
+
* @returns {string[]} `<skill>.<field>: <ref> not present in <catalog>` lines
|
|
856
|
+
*/
|
|
857
|
+
function findUnresolvedManifestCoverRefs(manifestSkills, ctx) {
|
|
858
|
+
const errors = [];
|
|
859
|
+
for (const entry of manifestSkills) {
|
|
860
|
+
const name = entry.name || entry.id || '<unknown>';
|
|
861
|
+
for (const { field, ctxKey, catalog } of MANIFEST_COVER_RESOLUTION) {
|
|
862
|
+
const keySet = ctx[ctxKey];
|
|
863
|
+
if (!keySet) continue; // catalog absent — degrade gracefully
|
|
864
|
+
const refs = Array.isArray(entry[field]) ? entry[field] : [];
|
|
865
|
+
for (const ref of refs) {
|
|
866
|
+
if (!keySet.has(ref)) {
|
|
867
|
+
errors.push(`${name}.${field}: "${ref}" not present in ${catalog}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
return errors;
|
|
873
|
+
}
|
|
874
|
+
|
|
822
875
|
// Substrings that indicate an artifact `source` makes a network call. Used
|
|
823
876
|
// by lintPlaybookAirGap() to flag artifacts that lack an air_gap_alternative.
|
|
824
877
|
// Conservative-by-design — false positives are surfaced as `warn` (not
|
|
@@ -917,6 +970,7 @@ function main() {
|
|
|
917
970
|
// A targeted single-skill lint is for diagnosing one entry; running
|
|
918
971
|
// the orphan walk there would surface unrelated findings.
|
|
919
972
|
let orphans = [];
|
|
973
|
+
let manifestRefErrors = [];
|
|
920
974
|
let airGapWarnings = [];
|
|
921
975
|
if (!opts.skill) {
|
|
922
976
|
orphans = findOrphanSkillFiles(manifest.skills);
|
|
@@ -925,6 +979,18 @@ function main() {
|
|
|
925
979
|
console.log(` - skill.md exists on disk but not in manifest: ${o}`);
|
|
926
980
|
console.log(` fix: re-run sign-all (\`node $(exceptd path)/lib/sign.js sign-all\` from a contributor checkout) after adding it to manifest.json, OR delete the orphan directory`);
|
|
927
981
|
}
|
|
982
|
+
// Manifest cover-array ref resolution (Hard Rule #4 — no orphaned
|
|
983
|
+
// controls). The per-skill pass above resolves frontmatter refs; this
|
|
984
|
+
// resolves the manifest cover arrays the reverse-ref surface reads, so a
|
|
985
|
+
// typo'd/stale manifest-only ref can't ship as an orphaned control. A
|
|
986
|
+
// hard failure (not a --strict warning): an unresolved control ref is
|
|
987
|
+
// unconditionally wrong, matching the frontmatter ref-resolution voice.
|
|
988
|
+
manifestRefErrors = findUnresolvedManifestCoverRefs(manifest.skills, ctx);
|
|
989
|
+
for (const e of manifestRefErrors) {
|
|
990
|
+
console.log(`FAIL <manifest-cover-ref>`);
|
|
991
|
+
console.log(` - ${e}`);
|
|
992
|
+
console.log(` fix: correct the typo'd/stale ref in manifest.json (or add the entry to the catalog), then re-run sign-all + refresh-reverse-refs + build-indexes`);
|
|
993
|
+
}
|
|
928
994
|
// P4 — air-gap completeness lint over data/playbooks/*.json.
|
|
929
995
|
airGapWarnings = lintPlaybookAirGap();
|
|
930
996
|
for (const w of airGapWarnings) {
|
|
@@ -938,12 +1004,15 @@ function main() {
|
|
|
938
1004
|
const total = results.length;
|
|
939
1005
|
const passed = total - failed - warned;
|
|
940
1006
|
const orphanSummary = orphans.length ? `, ${orphans.length} orphan skill.md file(s)` : '';
|
|
1007
|
+
const manifestRefSummary = manifestRefErrors.length
|
|
1008
|
+
? `, ${manifestRefErrors.length} unresolved manifest cover ref(s)`
|
|
1009
|
+
: '';
|
|
941
1010
|
const warnSummary = warned ? `, ${warned} with warnings` : '';
|
|
942
1011
|
const airGapSummary = airGapWarnings && airGapWarnings.length
|
|
943
1012
|
? `, ${airGapWarnings.length} playbook artifact(s) missing air_gap_alternative`
|
|
944
1013
|
: '';
|
|
945
1014
|
console.log(
|
|
946
|
-
`\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${airGapSummary}.`,
|
|
1015
|
+
`\n${passed}/${total} skills passed${warnSummary}${failed ? `, ${failed} failed` : ''}${orphanSummary}${manifestRefSummary}${airGapSummary}.`,
|
|
947
1016
|
);
|
|
948
1017
|
// --strict treats any warning (per-skill or playbook air-gap) as a
|
|
949
1018
|
// release-blocking failure so a warned regression cannot ship silently.
|
|
@@ -951,7 +1020,7 @@ function main() {
|
|
|
951
1020
|
if (strictFail) {
|
|
952
1021
|
console.log(`[lint-skills] --strict: ${warned + (airGapWarnings ? airGapWarnings.length : 0)} warning(s) treated as failures.`);
|
|
953
1022
|
}
|
|
954
|
-
safeExit(failed === 0 && orphans.length === 0 && !strictFail ? 0 : 1);
|
|
1023
|
+
safeExit(failed === 0 && orphans.length === 0 && manifestRefErrors.length === 0 && !strictFail ? 0 : 1);
|
|
955
1024
|
return;
|
|
956
1025
|
}
|
|
957
1026
|
|
|
@@ -962,6 +1031,9 @@ module.exports = {
|
|
|
962
1031
|
extractFrontmatterBlock,
|
|
963
1032
|
unquote,
|
|
964
1033
|
findOrphanSkillFiles,
|
|
1034
|
+
findUnresolvedManifestCoverRefs,
|
|
1035
|
+
loadContext,
|
|
1036
|
+
MANIFEST_COVER_RESOLUTION,
|
|
965
1037
|
findMissingSections,
|
|
966
1038
|
lintPlaybookAirGap,
|
|
967
1039
|
PLAYBOOK_NET_PATTERNS,
|