chore: import upstream snapshot with attribution
CI / Test (ubuntu-latest, Node 18.x, bun) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, npm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, pnpm) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 18.x, yarn) (push) Failing after 15m1s
CI / Test (ubuntu-latest, Node 20.x, bun) (push) Failing after 17m13s
CI / Test (ubuntu-latest, Node 20.x, npm) (push) Failing after 18m42s
CI / Test (ubuntu-latest, Node 20.x, pnpm) (push) Failing after 15m0s
CI / Test (ubuntu-latest, Node 20.x, yarn) (push) Failing after 49m44s
CI / Test (ubuntu-latest, Node 22.x, bun) (push) Failing after 51m55s
CI / Test (ubuntu-latest, Node 22.x, pnpm) (push) Failing after 21m57s
CI / Test (ubuntu-latest, Node 22.x, npm) (push) Failing after 37m39s
CI / Test (ubuntu-latest, Node 22.x, yarn) (push) Failing after 34m7s
CI / Validate Components (push) Failing after 37m15s
CI / Python Tests (push) Failing after 10m1s
CI / Security Scan (push) Failing after 10m1s
CI / Lint (push) Failing after 17m12s
CI / Coverage (push) Failing after 20m19s
CI / Test (macos-latest, Node 18.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 18.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 20.x, yarn) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, bun) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (macos-latest, Node 22.x, yarn) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, npm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, pnpm) (push) Has been cancelled
CI / Test (windows-latest, Node 22.x, yarn) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 11:55:55 +08:00
commit d48cda4081
3322 changed files with 668744 additions and 0 deletions
+808
View File
@@ -0,0 +1,808 @@
#!/usr/bin/env node
/**
* Verify repo catalog counts against tracked documentation files.
*
* Usage:
* node scripts/ci/catalog.js
* node scripts/ci/catalog.js --json
* node scripts/ci/catalog.js --md
* node scripts/ci/catalog.js --text
* node scripts/ci/catalog.js --write --text
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '../..');
const README_PATH = path.join(ROOT, 'README.md');
const AGENTS_PATH = path.join(ROOT, 'AGENTS.md');
const README_ZH_CN_PATH = path.join(ROOT, 'README.zh-CN.md');
const DOCS_ZH_CN_README_PATH = path.join(ROOT, 'docs', 'zh-CN', 'README.md');
const DOCS_ZH_CN_AGENTS_PATH = path.join(ROOT, 'docs', 'zh-CN', 'AGENTS.md');
const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json');
const MARKETPLACE_JSON_PATH = path.join(ROOT, '.claude-plugin', 'marketplace.json');
const WRITE_MODE = process.argv.includes('--write');
const OUTPUT_MODE = process.argv.includes('--md')
? 'md'
: process.argv.includes('--text')
? 'text'
: 'json';
function normalizePathSegments(relativePath) {
return relativePath.split(path.sep).join('/');
}
function listMatchingFiles(root, relativeDir, matcher) {
const directory = path.join(root, relativeDir);
if (!fs.existsSync(directory)) {
return [];
}
return fs.readdirSync(directory, { withFileTypes: true })
.filter(entry => matcher(entry))
.map(entry => normalizePathSegments(path.join(relativeDir, entry.name)))
.sort();
}
function buildCatalog(root = ROOT) {
const agents = listMatchingFiles(root, 'agents', entry => entry.isFile() && entry.name.endsWith('.md'));
const commands = listMatchingFiles(root, 'commands', entry => entry.isFile() && entry.name.endsWith('.md'));
const skills = listMatchingFiles(root, 'skills', entry => (
entry.isDirectory() && fs.existsSync(path.join(root, 'skills', entry.name, 'SKILL.md'))
)).map(skillDir => `${skillDir}/SKILL.md`);
return {
agents: { count: agents.length, files: agents, glob: 'agents/*.md' },
commands: { count: commands.length, files: commands, glob: 'commands/*.md' },
skills: { count: skills.length, files: skills, glob: 'skills/*/SKILL.md' }
};
}
function readFileOrThrow(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch (error) {
throw new Error(`Failed to read ${path.basename(filePath)}: ${error.message}`);
}
}
function writeFileOrThrow(filePath, content) {
try {
fs.writeFileSync(filePath, content, 'utf8');
} catch (error) {
throw new Error(`Failed to write ${path.basename(filePath)}: ${error.message}`);
}
}
function replaceOrThrow(content, regex, replacer, source) {
if (!regex.test(content)) {
throw new Error(`${source} is missing the expected catalog marker`);
}
return content.replace(regex, replacer);
}
function parseReadmeExpectations(readmeContent) {
const expectations = [];
const quickStartMatch = readmeContent.match(
/access to\s+(\d+)\s+agents,\s+(\d+)\s+skills,\s+and\s+(\d+)\s+(?:commands|legacy command shims?)/i
);
if (!quickStartMatch) {
throw new Error('README.md is missing the quick-start catalog summary');
}
expectations.push(
{ category: 'agents', mode: 'exact', expected: Number(quickStartMatch[1]), source: 'README.md quick-start summary' },
{ category: 'skills', mode: 'exact', expected: Number(quickStartMatch[2]), source: 'README.md quick-start summary' },
{ category: 'commands', mode: 'exact', expected: Number(quickStartMatch[3]), source: 'README.md quick-start summary' }
);
const projectTreeAgentsMatch = readmeContent.match(/^\|\s*--\s*agents\/\s*#\s*(\d+)\s+specialized subagents for delegation\s*$/im);
if (!projectTreeAgentsMatch) {
throw new Error('README.md project tree is missing the agents count');
}
expectations.push({
category: 'agents',
mode: 'exact',
expected: Number(projectTreeAgentsMatch[1]),
source: 'README.md project tree (agents)'
});
const tablePatterns = [
{ category: 'agents', regex: /\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s+agents\s*\|/i, source: 'README.md comparison table' },
{ category: 'commands', regex: /\|\s*(?:\*\*)?Commands(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s+commands(?:\s*\([^)]*\))?\s*\|/i, source: 'README.md comparison table' },
{ category: 'skills', regex: /\|\s*(?:\*\*)?Skills(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s+skills\s*\|/i, source: 'README.md comparison table' }
];
for (const pattern of tablePatterns) {
const match = readmeContent.match(pattern.regex);
if (!match) {
throw new Error(`${pattern.source} is missing the ${pattern.category} row`);
}
expectations.push({
category: pattern.category,
mode: 'exact',
expected: Number(match[1]),
source: `${pattern.source} (${pattern.category})`
});
}
const parityPatterns = [
{
category: 'agents',
regex: /^\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*(\d+)\s*\|\s*Shared\s*\(AGENTS\.md\)\s*\|\s*Shared\s*\(AGENTS\.md\)\s*\|\s*12\s*\|(?:\s*N\/A\s*\|)?$/im,
source: 'README.md parity table'
},
{
category: 'commands',
regex: /^\|\s*(?:\*\*)?Commands(?:\*\*)?\s*\|\s*(\d+)\s*\|\s*Shared\s*\|\s*Instruction-based\s*\|\s*\d+\s*\|(?:\s*\d+\s+prompts\s*\|)?$/im,
source: 'README.md parity table'
},
{
category: 'skills',
regex: /^\|\s*(?:\*\*)?Skills(?:\*\*)?\s*\|\s*(\d+)\s*\|\s*Shared\s*\|\s*10\s*\(native format\)\s*\|\s*37\s*\|(?:\s*Via instructions\s*\|)?$/im,
source: 'README.md parity table'
}
];
for (const pattern of parityPatterns) {
const match = readmeContent.match(pattern.regex);
if (!match) {
throw new Error(`${pattern.source} is missing the ${pattern.category} row`);
}
expectations.push({
category: pattern.category,
mode: 'exact',
expected: Number(match[1]),
source: `${pattern.source} (${pattern.category})`
});
}
return expectations;
}
function parseZhRootReadmeExpectations(readmeContent) {
const match = readmeContent.match(/你现在可以使用\s+(\d+)\s+个代理、\s*(\d+)\s*个技能和\s*(\d+)\s*个命令/i);
if (!match) {
throw new Error('README.zh-CN.md is missing the quick-start catalog summary');
}
return [
{ category: 'agents', mode: 'exact', expected: Number(match[1]), source: 'README.zh-CN.md quick-start summary' },
{ category: 'skills', mode: 'exact', expected: Number(match[2]), source: 'README.zh-CN.md quick-start summary' },
{ category: 'commands', mode: 'exact', expected: Number(match[3]), source: 'README.zh-CN.md quick-start summary' }
];
}
function parseZhDocsReadmeExpectations(readmeContent) {
const expectations = [];
const quickStartMatch = readmeContent.match(/你现在可以使用\s+(\d+)\s+个智能体、\s*(\d+)\s*项技能和\s*(\d+)\s*个命令了/i);
if (!quickStartMatch) {
throw new Error('docs/zh-CN/README.md is missing the quick-start catalog summary');
}
expectations.push(
{ category: 'agents', mode: 'exact', expected: Number(quickStartMatch[1]), source: 'docs/zh-CN/README.md quick-start summary' },
{ category: 'skills', mode: 'exact', expected: Number(quickStartMatch[2]), source: 'docs/zh-CN/README.md quick-start summary' },
{ category: 'commands', mode: 'exact', expected: Number(quickStartMatch[3]), source: 'docs/zh-CN/README.md quick-start summary' }
);
const tablePatterns = [
{ category: 'agents', regex: /\|\s*智能体\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s*个\s*\|/i, source: 'docs/zh-CN/README.md comparison table' },
{ category: 'commands', regex: /\|\s*命令\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s*个\s*\|/i, source: 'docs/zh-CN/README.md comparison table' },
{ category: 'skills', regex: /\|\s*技能\s*\|\s*(?:(?:PASS:|\u2705)\s*)?(\d+)\s*项\s*\|/i, source: 'docs/zh-CN/README.md comparison table' }
];
for (const pattern of tablePatterns) {
const match = readmeContent.match(pattern.regex);
if (!match) {
throw new Error(`${pattern.source} is missing the ${pattern.category} row`);
}
expectations.push({
category: pattern.category,
mode: 'exact',
expected: Number(match[1]),
source: `${pattern.source} (${pattern.category})`
});
}
const parityPatterns = [
{
category: 'agents',
regex: /^\|\s*(?:\*\*)?智能体(?:\*\*)?\s*\|\s*(\d+)\s*\|\s*共享\s*\(AGENTS\.md\)\s*\|\s*共享\s*\(AGENTS\.md\)\s*\|\s*12\s*\|$/im,
source: 'docs/zh-CN/README.md parity table'
},
{
category: 'commands',
regex: /^\|\s*(?:\*\*)?命令(?:\*\*)?\s*\|\s*(\d+)\s*\|\s*共享\s*\|\s*基于指令\s*\|\s*\d+\s*\|$/im,
source: 'docs/zh-CN/README.md parity table'
},
{
category: 'skills',
regex: /^\|\s*(?:\*\*)?技能(?:\*\*)?\s*\|\s*(\d+)\s*\|\s*共享\s*\|\s*10\s*\(原生格式\)\s*\|\s*37\s*\|$/im,
source: 'docs/zh-CN/README.md parity table'
}
];
for (const pattern of parityPatterns) {
const match = readmeContent.match(pattern.regex);
if (!match) {
throw new Error(`${pattern.source} is missing the ${pattern.category} row`);
}
expectations.push({
category: pattern.category,
mode: 'exact',
expected: Number(match[1]),
source: `${pattern.source} (${pattern.category})`
});
}
return expectations;
}
function parseAgentsDocExpectations(agentsContent) {
const summaryMatch = agentsContent.match(/providing\s+(\d+)\s+specialized agents,\s+(\d+)(\+)?\s+skills,\s+(\d+)\s+commands/i);
if (!summaryMatch) {
throw new Error('AGENTS.md is missing the catalog summary line');
}
const expectations = [
{ category: 'agents', mode: 'exact', expected: Number(summaryMatch[1]), source: 'AGENTS.md summary' },
{
category: 'skills',
mode: summaryMatch[3] ? 'minimum' : 'exact',
expected: Number(summaryMatch[2]),
source: 'AGENTS.md summary'
},
{ category: 'commands', mode: 'exact', expected: Number(summaryMatch[4]), source: 'AGENTS.md summary' }
];
const structurePatterns = [
{
category: 'agents',
mode: 'exact',
regex: /^\s*agents\/\s*[—–-]\s*(\d+)\s+specialized subagents\s*$/im,
source: 'AGENTS.md project structure'
},
{
category: 'skills',
mode: 'minimum',
regex: /^\s*skills\/\s*[—–-]\s*(\d+)(\+)?\s+workflow skills and domain knowledge\s*$/im,
source: 'AGENTS.md project structure'
},
{
category: 'commands',
mode: 'exact',
regex: /^\s*commands\/\s*[—–-]\s*(\d+)\s+slash commands\s*$/im,
source: 'AGENTS.md project structure'
}
];
for (const pattern of structurePatterns) {
const match = agentsContent.match(pattern.regex);
if (!match) {
throw new Error(`${pattern.source} is missing the ${pattern.category} entry`);
}
expectations.push({
category: pattern.category,
mode: pattern.mode === 'minimum' && match[2] ? 'minimum' : pattern.mode,
expected: Number(match[1]),
source: `${pattern.source} (${pattern.category})`
});
}
return expectations;
}
function parseZhAgentsDocExpectations(agentsContent) {
const summaryMatch = agentsContent.match(/提供\s+(\d+)\s+个专业代理、\s*(\d+)(\+)?\s*项技能、\s*(\d+)\s+条命令/i);
if (!summaryMatch) {
throw new Error('docs/zh-CN/AGENTS.md is missing the catalog summary line');
}
const expectations = [
{ category: 'agents', mode: 'exact', expected: Number(summaryMatch[1]), source: 'docs/zh-CN/AGENTS.md summary' },
{
category: 'skills',
mode: summaryMatch[3] ? 'minimum' : 'exact',
expected: Number(summaryMatch[2]),
source: 'docs/zh-CN/AGENTS.md summary'
},
{ category: 'commands', mode: 'exact', expected: Number(summaryMatch[4]), source: 'docs/zh-CN/AGENTS.md summary' }
];
const structurePatterns = [
{
category: 'agents',
mode: 'exact',
regex: /^\s*agents\/\s*[—–-]\s*(\d+)\s+个专业子代理\s*$/im,
source: 'docs/zh-CN/AGENTS.md project structure'
},
{
category: 'skills',
mode: 'minimum',
regex: /^\s*skills\/\s*[—–-]\s*(\d+)(\+)?\s+个工作流技能和领域知识\s*$/im,
source: 'docs/zh-CN/AGENTS.md project structure'
},
{
category: 'commands',
mode: 'exact',
regex: /^\s*commands\/\s*[—–-]\s*(\d+)\s+个斜杠命令\s*$/im,
source: 'docs/zh-CN/AGENTS.md project structure'
}
];
for (const pattern of structurePatterns) {
const match = agentsContent.match(pattern.regex);
if (!match) {
throw new Error(`${pattern.source} is missing the ${pattern.category} entry`);
}
expectations.push({
category: pattern.category,
mode: pattern.mode === 'minimum' && match[2] ? 'minimum' : pattern.mode,
expected: Number(match[1]),
source: `${pattern.source} (${pattern.category})`
});
}
return expectations;
}
function parseCatalogDescriptionExpectations(content, source, getDescription) {
let parsed;
try {
parsed = JSON.parse(content);
} catch (error) {
throw new Error(`${source} is not valid JSON: ${error.message}`);
}
const description = getDescription(parsed);
if (typeof description !== 'string') {
throw new Error(`${source} is missing the catalog count description`);
}
const match = description.match(/(\d+)\s+agents,\s+(\d+)\s+skills,\s+(\d+)\s+legacy command shims?/i);
if (!match) {
throw new Error(`${source} is missing the catalog count description`);
}
return [
{ category: 'agents', mode: 'exact', expected: Number(match[1]), source },
{ category: 'skills', mode: 'exact', expected: Number(match[2]), source },
{ category: 'commands', mode: 'exact', expected: Number(match[3]), source },
];
}
function evaluateExpectations(catalog, expectations) {
return expectations.map(expectation => {
const actual = catalog[expectation.category].count;
const ok = expectation.mode === 'minimum'
? actual >= expectation.expected
: actual === expectation.expected;
return {
...expectation,
actual,
ok
};
});
}
function formatExpectation(expectation) {
const comparator = expectation.mode === 'minimum' ? '>=' : '=';
return `${expectation.source}: ${expectation.category} documented ${comparator} ${expectation.expected}, actual ${expectation.actual}`;
}
function syncEnglishReadme(content, catalog) {
let nextContent = content;
nextContent = replaceOrThrow(
nextContent,
/(access to\s+)(\d+)(\s+agents,\s+)(\d+)(\s+skills,\s+and\s+)(\d+)(\s+(?:commands|legacy command shims?))/i,
(_, prefix, __, agentsSuffix, ___, skillsSuffix) =>
`${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count} legacy command shims`,
'README.md quick-start summary'
);
nextContent = replaceOrThrow(
nextContent,
/^(\|\s*--\s*agents\/\s*#\s*)(\d+)(\s+specialized subagents for delegation\s*)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`,
'README.md project tree (agents)'
);
nextContent = replaceOrThrow(
nextContent,
/(\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?)(\d+)(\s+agents\s*\|)/i,
(_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`,
'README.md comparison table (agents)'
);
nextContent = replaceOrThrow(
nextContent,
/(\|\s*(?:\*\*)?Commands(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?)(\d+)(\s+commands\s*\|)/i,
(_, prefix, __, suffix) => `${prefix}${catalog.commands.count}${suffix}`,
'README.md comparison table (commands)'
);
nextContent = replaceOrThrow(
nextContent,
/(\|\s*(?:\*\*)?Skills(?:\*\*)?\s*\|\s*(?:(?:PASS:|\u2705)\s*)?)(\d+)(\s+skills\s*\|)/i,
(_, prefix, __, suffix) => `${prefix}${catalog.skills.count}${suffix}`,
'README.md comparison table (skills)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\|\s*(?:\*\*)?Agents(?:\*\*)?\s*\|\s*)(\d+)(\s*\|\s*Shared\s*\(AGENTS\.md\)\s*\|\s*Shared\s*\(AGENTS\.md\)\s*\|\s*12\s*\|(?:\s*N\/A\s*\|)?)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`,
'README.md parity table (agents)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\|\s*(?:\*\*)?Commands(?:\*\*)?\s*\|\s*)(\d+)(\s*\|\s*Shared\s*\|\s*Instruction-based\s*\|\s*\d+\s*\|(?:\s*\d+\s+prompts\s*\|)?)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.commands.count}${suffix}`,
'README.md parity table (commands)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\|\s*(?:\*\*)?Skills(?:\*\*)?\s*\|\s*)(\d+)(\s*\|\s*Shared\s*\|\s*10\s*\(native format\)\s*\|\s*37\s*\|(?:\s*Via instructions\s*\|)?)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.skills.count}${suffix}`,
'README.md parity table (skills)'
);
return nextContent;
}
function syncEnglishAgents(content, catalog) {
let nextContent = content;
nextContent = replaceOrThrow(
nextContent,
/(providing\s+)(\d+)(\s+specialized agents,\s+)(\d+)(\+?)(\s+skills,\s+)(\d+)(\s+commands)/i,
(_, prefix, __, agentsSuffix, ___, skillsPlus, skillsSuffix, ____, commandsSuffix) =>
`${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsPlus}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`,
'AGENTS.md summary'
);
nextContent = replaceOrThrow(
nextContent,
/^(\s*agents\/\s*[—–-]\s*)(\d+)(\s+specialized subagents\s*)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`,
'AGENTS.md project structure (agents)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\s*skills\/\s*[—–-]\s*)(\d+)(\+?)(\s+workflow skills and domain knowledge\s*)$/im,
(_, prefix, __, plus, suffix) => `${prefix}${catalog.skills.count}${plus}${suffix}`,
'AGENTS.md project structure (skills)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\s*commands\/\s*[—–-]\s*)(\d+)(\s+slash commands\s*)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.commands.count}${suffix}`,
'AGENTS.md project structure (commands)'
);
return nextContent;
}
function syncZhRootReadme(content, catalog) {
return replaceOrThrow(
content,
/(你现在可以使用\s+)(\d+)(\s+个代理、\s*)(\d+)(\s*个技能和\s*)(\d+)(\s*个命令[。.!]?)/i,
(_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) =>
`${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`,
'README.zh-CN.md quick-start summary'
);
}
function syncZhDocsReadme(content, catalog) {
let nextContent = content;
nextContent = replaceOrThrow(
nextContent,
/(你现在可以使用\s+)(\d+)(\s+个智能体、\s*)(\d+)(\s*项技能和\s*)(\d+)(\s*个命令了[。.!]?)/i,
(_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) =>
`${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`,
'docs/zh-CN/README.md quick-start summary'
);
nextContent = replaceOrThrow(
nextContent,
/(\|\s*智能体\s*\|\s*(?:(?:PASS:|\u2705)\s*)?)(\d+)(\s*个\s*\|)/i,
(_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`,
'docs/zh-CN/README.md comparison table (agents)'
);
nextContent = replaceOrThrow(
nextContent,
/(\|\s*命令\s*\|\s*(?:(?:PASS:|\u2705)\s*)?)(\d+)(\s*个\s*\|)/i,
(_, prefix, __, suffix) => `${prefix}${catalog.commands.count}${suffix}`,
'docs/zh-CN/README.md comparison table (commands)'
);
nextContent = replaceOrThrow(
nextContent,
/(\|\s*技能\s*\|\s*(?:(?:PASS:|\u2705)\s*)?)(\d+)(\s*项\s*\|)/i,
(_, prefix, __, suffix) => `${prefix}${catalog.skills.count}${suffix}`,
'docs/zh-CN/README.md comparison table (skills)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\|\s*(?:\*\*)?智能体(?:\*\*)?\s*\|\s*)(\d+)(\s*\|\s*共享\s*\(AGENTS\.md\)\s*\|\s*共享\s*\(AGENTS\.md\)\s*\|\s*12\s*\|)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`,
'docs/zh-CN/README.md parity table (agents)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\|\s*(?:\*\*)?命令(?:\*\*)?\s*\|\s*)(\d+)(\s*\|\s*共享\s*\|\s*基于指令\s*\|\s*\d+\s*\|)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.commands.count}${suffix}`,
'docs/zh-CN/README.md parity table (commands)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\|\s*(?:\*\*)?技能(?:\*\*)?\s*\|\s*)(\d+)(\s*\|\s*共享\s*\|\s*10\s*\(原生格式\)\s*\|\s*37\s*\|)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.skills.count}${suffix}`,
'docs/zh-CN/README.md parity table (skills)'
);
return nextContent;
}
function syncZhAgents(content, catalog) {
let nextContent = content;
nextContent = replaceOrThrow(
nextContent,
/(提供\s+)(\d+)(\s+个专业代理、\s*)(\d+)(\+?)(\s*项技能、\s*)(\d+)(\s+条命令)/i,
(_, prefix, __, agentsSuffix, ___, skillsPlus, skillsSuffix, ____, commandsSuffix) =>
`${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsPlus}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`,
'docs/zh-CN/AGENTS.md summary'
);
nextContent = replaceOrThrow(
nextContent,
/^(\s*agents\/\s*[—–-]\s*)(\d+)(\s+个专业子代理\s*)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.agents.count}${suffix}`,
'docs/zh-CN/AGENTS.md project structure (agents)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\s*skills\/\s*[—–-]\s*)(\d+)(\+?)(\s+个工作流技能和领域知识\s*)$/im,
(_, prefix, __, plus, suffix) => `${prefix}${catalog.skills.count}${plus}${suffix}`,
'docs/zh-CN/AGENTS.md project structure (skills)'
);
nextContent = replaceOrThrow(
nextContent,
/^(\s*commands\/\s*[—–-]\s*)(\d+)(\s+个斜杠命令\s*)$/im,
(_, prefix, __, suffix) => `${prefix}${catalog.commands.count}${suffix}`,
'docs/zh-CN/AGENTS.md project structure (commands)'
);
return nextContent;
}
function syncCatalogDescription(content, catalog, source, getDescription, setDescription) {
let parsed;
try {
parsed = JSON.parse(content);
} catch (error) {
throw new Error(`${source} is not valid JSON: ${error.message}`);
}
const description = getDescription(parsed);
if (typeof description !== 'string') {
throw new Error(`${source} is missing the catalog count description`);
}
const nextDescription = replaceOrThrow(
description,
/(\d+)(\s+agents,\s+)(\d+)(\s+skills,\s+)(\d+)(\s+legacy command shims?)/i,
(_, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) =>
`${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`,
source
);
setDescription(parsed, nextDescription);
return `${JSON.stringify(parsed, null, 2)}\n`;
}
function createDocumentSpecs(paths = {}) {
const {
readmePath = README_PATH,
agentsPath = AGENTS_PATH,
zhRootReadmePath = README_ZH_CN_PATH,
zhDocsReadmePath = DOCS_ZH_CN_README_PATH,
zhDocsAgentsPath = DOCS_ZH_CN_AGENTS_PATH,
pluginJsonPath = PLUGIN_JSON_PATH,
marketplaceJsonPath = MARKETPLACE_JSON_PATH,
} = paths;
return [
{
filePath: readmePath,
parseExpectations: parseReadmeExpectations,
syncContent: syncEnglishReadme,
},
{
filePath: agentsPath,
parseExpectations: parseAgentsDocExpectations,
syncContent: syncEnglishAgents,
},
{
filePath: zhRootReadmePath,
parseExpectations: parseZhRootReadmeExpectations,
syncContent: syncZhRootReadme,
},
{
filePath: zhDocsReadmePath,
parseExpectations: parseZhDocsReadmeExpectations,
syncContent: syncZhDocsReadme,
},
{
filePath: zhDocsAgentsPath,
parseExpectations: parseZhAgentsDocExpectations,
syncContent: syncZhAgents,
},
{
filePath: pluginJsonPath,
parseExpectations: content => parseCatalogDescriptionExpectations(
content,
'.claude-plugin/plugin.json description',
parsed => parsed.description
),
syncContent: (content, catalog) => syncCatalogDescription(
content,
catalog,
'.claude-plugin/plugin.json description',
parsed => parsed.description,
(parsed, description) => { parsed.description = description; }
),
},
{
filePath: marketplaceJsonPath,
parseExpectations: content => parseCatalogDescriptionExpectations(
content,
'.claude-plugin/marketplace.json plugin description',
parsed => parsed.plugins?.[0]?.description
),
syncContent: (content, catalog) => syncCatalogDescription(
content,
catalog,
'.claude-plugin/marketplace.json plugin description',
parsed => parsed.plugins?.[0]?.description,
(parsed, description) => { parsed.plugins[0].description = description; }
),
},
];
}
function createDocumentSpecsForRoot(root) {
return createDocumentSpecs({
readmePath: path.join(root, 'README.md'),
agentsPath: path.join(root, 'AGENTS.md'),
zhRootReadmePath: path.join(root, 'README.zh-CN.md'),
zhDocsReadmePath: path.join(root, 'docs', 'zh-CN', 'README.md'),
zhDocsAgentsPath: path.join(root, 'docs', 'zh-CN', 'AGENTS.md'),
pluginJsonPath: path.join(root, '.claude-plugin', 'plugin.json'),
marketplaceJsonPath: path.join(root, '.claude-plugin', 'marketplace.json'),
});
}
const DOCUMENT_SPECS = createDocumentSpecs();
function renderText(result) {
console.log('Catalog counts:');
console.log(`- agents: ${result.catalog.agents.count}`);
console.log(`- commands: ${result.catalog.commands.count}`);
console.log(`- skills: ${result.catalog.skills.count}`);
console.log('');
const mismatches = result.checks.filter(check => !check.ok);
if (mismatches.length === 0) {
console.log('Documentation counts match the repository catalog.');
return;
}
console.error('Documentation count mismatches found:');
for (const mismatch of mismatches) {
console.error(`- ${formatExpectation(mismatch)}`);
}
}
function renderMarkdown(result) {
const mismatches = result.checks.filter(check => !check.ok);
console.log('# ECC Catalog Verification\n');
console.log('| Category | Count | Pattern |');
console.log('| --- | ---: | --- |');
console.log(`| Agents | ${result.catalog.agents.count} | \`${result.catalog.agents.glob}\` |`);
console.log(`| Commands | ${result.catalog.commands.count} | \`${result.catalog.commands.glob}\` |`);
console.log(`| Skills | ${result.catalog.skills.count} | \`${result.catalog.skills.glob}\` |`);
console.log('');
if (mismatches.length === 0) {
console.log('Documentation counts match the repository catalog.');
return;
}
console.log('## Mismatches\n');
for (const mismatch of mismatches) {
console.log(`- ${formatExpectation(mismatch)}`);
}
}
function runCatalogCheck(options = {}) {
const root = options.root || ROOT;
const writeMode = options.writeMode ?? WRITE_MODE;
const documentSpecs = options.documentSpecs || (
root === ROOT ? DOCUMENT_SPECS : createDocumentSpecsForRoot(root)
);
const catalog = buildCatalog(root);
if (writeMode) {
for (const spec of documentSpecs) {
const currentContent = readFileOrThrow(spec.filePath);
const nextContent = spec.syncContent(currentContent, catalog);
if (nextContent !== currentContent) {
writeFileOrThrow(spec.filePath, nextContent);
}
}
}
const expectations = documentSpecs.flatMap(spec => (
spec.parseExpectations(readFileOrThrow(spec.filePath))
));
const checks = evaluateExpectations(catalog, expectations);
return { catalog, checks };
}
function main(options = {}) {
const outputMode = options.outputMode || OUTPUT_MODE;
const result = runCatalogCheck(options);
if (outputMode === 'json') {
console.log(JSON.stringify(result, null, 2));
} else if (outputMode === 'md') {
renderMarkdown(result);
} else {
renderText(result);
}
if (result.checks.some(check => !check.ok)) {
process.exit(1);
}
}
if (require.main === module) {
try {
main();
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
}
module.exports = {
buildCatalog,
createDocumentSpecs,
createDocumentSpecsForRoot,
evaluateExpectations,
formatExpectation,
main,
parseAgentsDocExpectations,
parseCatalogDescriptionExpectations,
parseReadmeExpectations,
parseZhAgentsDocExpectations,
parseZhDocsReadmeExpectations,
parseZhRootReadmeExpectations,
runCatalogCheck,
syncCatalogDescription,
syncEnglishAgents,
syncEnglishReadme,
syncZhAgents,
syncZhDocsReadme,
syncZhRootReadme,
};
+272
View File
@@ -0,0 +1,272 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const repoRoot = process.env.ECC_UNICODE_SCAN_ROOT
? path.resolve(process.env.ECC_UNICODE_SCAN_ROOT)
: path.resolve(__dirname, '..', '..');
const writeMode = process.argv.includes('--write');
const ignoredDirs = new Set([
'.git',
'node_modules',
'.dmux',
'.next',
'.venv',
'coverage',
'venv',
]);
const textExtensions = new Set([
'.md',
'.mdx',
'.txt',
'.js',
'.cjs',
'.mjs',
'.ts',
'.tsx',
'.jsx',
'.json',
'.toml',
'.yml',
'.yaml',
'.sh',
'.bash',
'.zsh',
'.ps1',
'.py',
'.rs',
]);
const writableExtensions = new Set([
'.md',
'.mdx',
'.txt',
]);
const writeModeSkip = new Set([
path.normalize('scripts/ci/check-unicode-safety.js'),
path.normalize('tests/scripts/check-unicode-safety.test.js'),
]);
const emojiRe = /(?:\p{Extended_Pictographic}|\p{Regional_Indicator})/gu;
const allowedSymbolCodePoints = new Set([
0x00A9,
0x00AE,
0x2122,
]);
const targetedReplacements = [
[new RegExp(`${String.fromCodePoint(0x26A0)}(?:\\uFE0F)?`, 'gu'), 'WARNING:'],
[new RegExp(`${String.fromCodePoint(0x23ED)}(?:\\uFE0F)?`, 'gu'), 'SKIPPED:'],
[new RegExp(String.fromCodePoint(0x2705), 'gu'), 'PASS:'],
[new RegExp(String.fromCodePoint(0x274C), 'gu'), 'FAIL:'],
[new RegExp(String.fromCodePoint(0x2728), 'gu'), ''],
];
function shouldSkip(entryPath) {
return entryPath.split(path.sep).some(part => ignoredDirs.has(part));
}
function isTextFile(filePath) {
return textExtensions.has(path.extname(filePath).toLowerCase());
}
function canAutoWrite(relativePath) {
return writableExtensions.has(path.extname(relativePath).toLowerCase());
}
function listFiles(dirPath) {
const results = [];
for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
const entryPath = path.join(dirPath, entry.name);
if (shouldSkip(entryPath)) continue;
if (entry.isDirectory()) {
results.push(...listFiles(entryPath));
continue;
}
if (entry.isFile() && isTextFile(entryPath)) {
results.push(entryPath);
}
}
return results;
}
function lineAndColumn(text, index) {
const line = text.slice(0, index).split('\n').length;
const lastNewline = text.lastIndexOf('\n', index - 1);
const column = index - lastNewline;
return { line, column };
}
function isAllowedEmojiLikeSymbol(char) {
return allowedSymbolCodePoints.has(char.codePointAt(0));
}
function isDangerousInvisibleCodePoint(codePoint) {
return (
(codePoint >= 0x200B && codePoint <= 0x200D) ||
codePoint === 0x2060 ||
codePoint === 0xFEFF ||
(codePoint >= 0x202A && codePoint <= 0x202E) ||
(codePoint >= 0x2066 && codePoint <= 0x2069) ||
(codePoint >= 0xFE00 && codePoint <= 0xFE0F) ||
(codePoint >= 0xE0100 && codePoint <= 0xE01EF) ||
// Unicode Tag block (U+E0000U+E007F). Tag characters were proposed
// for language tagging in Unicode 3.1 and have been deprecated since
// Unicode 5.1, so no legitimate text uses them. They are the canonical
// vector for "ASCII smuggling" / "Tag smuggling" prompt injection:
// an attacker hides instructions inside ASCII-looking strings (PR
// bodies, SKILL.md, frontmatter), the LLM consumes the tag bytes,
// and the human reviewer sees nothing.
(codePoint >= 0xE0000 && codePoint <= 0xE007F) ||
// U+180E MONGOLIAN VOWEL SEPARATOR — formerly classified as a space
// separator, reclassified as a format control in Unicode 6.3; renders
// as zero-width and routinely abused for homograph / smuggling.
codePoint === 0x180E ||
// U+115F / U+1160 HANGUL CHOSEONG/JUNGSEONG FILLER — zero-width fillers
// used in Korean text shaping; abused as invisible characters.
codePoint === 0x115F ||
codePoint === 0x1160 ||
// U+2061U+2064 invisible math operators (FUNCTION APPLICATION,
// INVISIBLE TIMES, INVISIBLE SEPARATOR, INVISIBLE PLUS). Zero-width
// and not used outside math typesetting; legitimate Markdown / source
// does not contain them.
(codePoint >= 0x2061 && codePoint <= 0x2064) ||
// U+3164 HANGUL FILLER — zero-width filler reportedly used in Discord
// / Twitter smuggling attacks; not used in legitimate Korean text.
codePoint === 0x3164
);
}
function stripDangerousInvisibleChars(text) {
let next = '';
for (const char of text) {
if (!isDangerousInvisibleCodePoint(char.codePointAt(0))) {
next += char;
}
}
return next;
}
function sanitizeText(text) {
let next = text;
next = stripDangerousInvisibleChars(next);
for (const [pattern, replacement] of targetedReplacements) {
next = next.replace(pattern, replacement);
}
next = next.replace(emojiRe, match => (isAllowedEmojiLikeSymbol(match) ? match : ''));
next = next.replace(/^ +(?=\*\*)/gm, '');
next = next.replace(/^(\*\*)\s+/gm, '$1');
next = next.replace(/^(#+)\s{2,}/gm, '$1 ');
next = next.replace(/^>\s{2,}/gm, '> ');
next = next.replace(/^-\s{2,}/gm, '- ');
next = next.replace(/^(\d+\.)\s{2,}/gm, '$1 ');
next = next.replace(/[ \t]+$/gm, '');
return next;
}
function collectMatches(text, regex, kind) {
const matches = [];
for (const match of text.matchAll(regex)) {
const char = match[0];
if (kind === 'emoji' && isAllowedEmojiLikeSymbol(char)) {
continue;
}
const index = match.index ?? 0;
const { line, column } = lineAndColumn(text, index);
matches.push({
kind,
char,
codePoint: `U+${char.codePointAt(0).toString(16).toUpperCase()}`,
line,
column,
});
}
return matches;
}
function collectDangerousInvisibleMatches(text) {
const matches = [];
let index = 0;
for (const char of text) {
const codePoint = char.codePointAt(0);
if (isDangerousInvisibleCodePoint(codePoint)) {
const { line, column } = lineAndColumn(text, index);
matches.push({
kind: 'dangerous-invisible',
char,
codePoint: `U+${codePoint.toString(16).toUpperCase()}`,
line,
column,
});
}
index += char.length;
}
return matches;
}
const changedFiles = [];
const violations = [];
for (const filePath of listFiles(repoRoot)) {
const relativePath = path.relative(repoRoot, filePath);
let text;
try {
text = fs.readFileSync(filePath, 'utf8');
} catch {
continue;
}
if (
writeMode &&
!writeModeSkip.has(path.normalize(relativePath)) &&
canAutoWrite(relativePath)
) {
const sanitized = sanitizeText(text);
if (sanitized !== text) {
fs.writeFileSync(filePath, sanitized, 'utf8');
changedFiles.push(relativePath);
text = sanitized;
}
}
const fileViolations = [
...collectDangerousInvisibleMatches(text),
...collectMatches(text, emojiRe, 'emoji'),
];
for (const violation of fileViolations) {
violations.push({
file: relativePath,
...violation,
});
}
}
if (changedFiles.length > 0) {
console.log(`Sanitized ${changedFiles.length} files:`);
for (const file of changedFiles) {
console.log(`- ${file}`);
}
}
if (violations.length > 0) {
console.error('Unicode safety violations detected:');
for (const violation of violations) {
console.error(
`${violation.file}:${violation.line}:${violation.column} ${violation.kind} ${violation.codePoint}`
);
}
process.exit(1);
}
console.log('Unicode safety check passed.');
+318
View File
@@ -0,0 +1,318 @@
#!/usr/bin/env node
/**
* Generate a deterministic command-to-agent/skill registry.
*
* Usage:
* node scripts/ci/generate-command-registry.js
* node scripts/ci/generate-command-registry.js --json
* node scripts/ci/generate-command-registry.js --write
* node scripts/ci/generate-command-registry.js --check
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '../..');
const DEFAULT_OUTPUT_PATH = path.join(ROOT, 'docs', 'COMMAND-REGISTRY.json');
function normalizePath(relativePath) {
return relativePath.split(path.sep).join('/');
}
function listMarkdownFiles(root, relativeDir) {
const directory = path.join(root, relativeDir);
if (!fs.existsSync(directory)) {
return [];
}
return fs.readdirSync(directory, { withFileTypes: true })
.filter(entry => entry.isFile() && entry.name.endsWith('.md'))
.map(entry => entry.name)
.sort();
}
function listKnownAgents(root) {
return new Set(
listMarkdownFiles(root, 'agents')
.map(filename => filename.replace(/\.md$/, ''))
);
}
function listKnownSkills(root) {
const skillsDir = path.join(root, 'skills');
if (!fs.existsSync(skillsDir)) {
return new Set();
}
return new Set(
fs.readdirSync(skillsDir, { withFileTypes: true })
.filter(entry => (
entry.isDirectory() && fs.existsSync(path.join(skillsDir, entry.name, 'SKILL.md'))
))
.map(entry => entry.name)
.sort()
);
}
function cleanYamlScalar(value) {
return value.trim()
.replace(/^['"]/, '')
.replace(/['"]$/, '');
}
function extractDescription(content) {
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (frontmatter) {
const description = frontmatter[1].match(/^description:\s*(.+)$/m);
if (description) {
return cleanYamlScalar(description[1]);
}
}
const heading = content.match(/^#\s+(.+)$/m);
return heading ? heading[1].trim() : '';
}
function collectKnownReferences(content, patterns, knownNames) {
const refs = new Set();
for (const pattern of patterns) {
for (const match of content.matchAll(pattern)) {
const ref = match[1];
if (knownNames.has(ref)) {
refs.add(ref);
}
}
}
return refs;
}
function extractReferences(content, knownAgents, knownSkills) {
const agentPatterns = [
/@([a-z][a-z0-9-]*)/gi,
/\bagent:\s*['"]?([a-z][a-z0-9-]*)/gi,
/\bsubagent(?:_type)?:\s*['"]?([a-z][a-z0-9-]*)/gi,
/\bagents\/([a-z][a-z0-9-]*)\.md\b/gi,
];
const skillPatterns = [
/\bskill:\s*['"]?\/?([a-z][a-z0-9-]*)/gi,
/\bskills\/([a-z][a-z0-9-]*)\/SKILL\.md\b/gi,
/\bskills\/([a-z][a-z0-9-]*)\b/gi,
/\/([a-z][a-z0-9-]*)\b/gi,
];
return {
agents: Array.from(collectKnownReferences(content, agentPatterns, knownAgents)).sort(),
skills: Array.from(collectKnownReferences(content, skillPatterns, knownSkills)).sort(),
};
}
function inferCommandType(content, commandName) {
const lower = `${commandName}\n${content}`.toLowerCase();
if (commandName.startsWith('multi-') || lower.includes('orchestrat')) {
return 'orchestration';
}
if (lower.includes('test') || lower.includes('tdd') || lower.includes('coverage')) {
return 'testing';
}
if (lower.includes('review') || lower.includes('audit') || lower.includes('security')) {
return 'review';
}
if (lower.includes('plan') || lower.includes('design') || lower.includes('architecture')) {
return 'planning';
}
if (lower.includes('refactor') || lower.includes('clean') || lower.includes('simplify')) {
return 'refactoring';
}
if (lower.includes('build') || lower.includes('compile') || lower.includes('setup')) {
return 'build';
}
return 'general';
}
function processCommandFile(root, filename, knownAgents, knownSkills) {
const commandName = filename.replace(/\.md$/, '');
const relativePath = normalizePath(path.join('commands', filename));
const content = fs.readFileSync(path.join(root, relativePath), 'utf8');
const references = extractReferences(content, knownAgents, knownSkills);
return {
command: commandName,
description: extractDescription(content),
type: inferCommandType(content, commandName),
primaryAgents: references.agents.slice(0, 3),
allAgents: references.agents,
skills: references.skills,
path: relativePath,
};
}
function sortCountMap(countMap) {
return Object.fromEntries(
Object.entries(countMap).sort(([left], [right]) => left.localeCompare(right))
);
}
function topUsage(countMap, keyName) {
return Object.entries(countMap)
.sort(([leftName, leftCount], [rightName, rightCount]) => (
rightCount - leftCount || leftName.localeCompare(rightName)
))
.slice(0, 10)
.map(([name, count]) => ({ [keyName]: name, count }));
}
function generateRegistry(options = {}) {
const root = options.root || ROOT;
const commandFiles = listMarkdownFiles(root, 'commands');
const knownAgents = listKnownAgents(root);
const knownSkills = listKnownSkills(root);
const commands = commandFiles.map(filename => (
processCommandFile(root, filename, knownAgents, knownSkills)
));
const byType = {};
const agentUsage = {};
const skillUsage = {};
for (const command of commands) {
byType[command.type] = (byType[command.type] || 0) + 1;
for (const agent of command.allAgents) {
agentUsage[agent] = (agentUsage[agent] || 0) + 1;
}
for (const skill of command.skills) {
skillUsage[skill] = (skillUsage[skill] || 0) + 1;
}
}
return {
schemaVersion: 1,
totalCommands: commands.length,
commands,
statistics: {
byType: sortCountMap(byType),
topAgents: topUsage(agentUsage, 'agent'),
topSkills: topUsage(skillUsage, 'skill'),
},
};
}
function formatRegistry(registry) {
return `${JSON.stringify(registry, null, 2)}\n`;
}
function writeRegistry(registry, outputPath = DEFAULT_OUTPUT_PATH) {
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
fs.writeFileSync(outputPath, formatRegistry(registry), 'utf8');
}
function checkRegistry(registry, outputPath = DEFAULT_OUTPUT_PATH) {
const expected = formatRegistry(registry);
let current;
try {
current = fs.readFileSync(outputPath, 'utf8');
} catch (error) {
throw new Error(`Failed to read ${normalizePath(path.relative(ROOT, outputPath))}: ${error.message}`);
}
if (current !== expected) {
throw new Error(`${normalizePath(path.relative(ROOT, outputPath))} is out of date; run npm run command-registry:write`);
}
}
function formatTextSummary(registry) {
const lines = [
'Command registry statistics',
'',
`Total commands: ${registry.totalCommands}`,
'',
'By type:',
];
for (const [type, count] of Object.entries(registry.statistics.byType)) {
lines.push(` ${type}: ${count}`);
}
lines.push('', 'Top agents:');
for (const { agent, count } of registry.statistics.topAgents) {
lines.push(` ${agent}: ${count}`);
}
lines.push('', 'Top skills:');
for (const { skill, count } of registry.statistics.topSkills) {
lines.push(` ${skill}: ${count}`);
}
return `${lines.join('\n')}\n`;
}
function parseArgs(argv) {
const allowed = new Set(['--json', '--write', '--check']);
const flags = new Set();
for (const arg of argv) {
if (!allowed.has(arg)) {
throw new Error(`Unknown argument: ${arg}`);
}
flags.add(arg);
}
return {
json: flags.has('--json'),
write: flags.has('--write'),
check: flags.has('--check'),
};
}
function run(argv = process.argv.slice(2), options = {}) {
const stdout = options.stdout || process.stdout;
const stderr = options.stderr || process.stderr;
const outputPath = options.outputPath || DEFAULT_OUTPUT_PATH;
try {
const args = parseArgs(argv);
const registry = generateRegistry({ root: options.root || ROOT });
if (args.check) {
checkRegistry(registry, outputPath);
stdout.write('Command registry is up to date.\n');
return 0;
}
if (args.write) {
writeRegistry(registry, outputPath);
stdout.write(`Command registry written to ${normalizePath(path.relative(process.cwd(), outputPath))}\n`);
return 0;
}
stdout.write(args.json ? formatRegistry(registry) : formatTextSummary(registry));
return 0;
} catch (error) {
stderr.write(`${error.message}\n`);
return 1;
}
}
if (require.main === module) {
process.exit(run());
}
module.exports = {
checkRegistry,
extractDescription,
extractReferences,
formatRegistry,
generateRegistry,
inferCommandType,
parseArgs,
run,
writeRegistry,
};
+853
View File
@@ -0,0 +1,853 @@
#!/usr/bin/env node
/**
* Scan dependency manifests, lockfiles, AI-tool configs, and installed package
* payload paths for active supply-chain incident indicators.
*/
const fs = require('fs');
const crypto = require('crypto');
const os = require('os');
const path = require('path');
const DEFAULT_ROOT = path.resolve(__dirname, '../..');
const MALICIOUS_PACKAGE_VERSIONS = {
'@beproduct/nestjs-auth': [
'0.1.2',
'0.1.3',
'0.1.4',
'0.1.5',
'0.1.6',
'0.1.7',
'0.1.8',
'0.1.9',
'0.1.10',
'0.1.11',
'0.1.12',
'0.1.13',
'0.1.14',
'0.1.15',
'0.1.16',
'0.1.17',
'0.1.18',
'0.1.19',
],
'@cap-js/db-service': ['2.10.1'],
'@cap-js/postgres': ['2.2.2'],
'@cap-js/sqlite': ['2.2.2'],
'@dirigible-ai/sdk': ['0.6.2', '0.6.3'],
'@draftauth/client': ['0.2.1', '0.2.2'],
'@draftauth/core': ['0.13.1', '0.13.2'],
'@draftlab/auth': ['0.24.1', '0.24.2'],
'@draftlab/auth-router': ['0.5.1', '0.5.2'],
'@draftlab/db': ['0.16.1', '0.16.2'],
'@mesadev/rest': ['0.28.3'],
'@mesadev/saguaro': ['0.4.22'],
'@mesadev/sdk': ['0.28.3'],
'@ml-toolkit-ts/preprocessing': ['1.0.2', '1.0.3'],
'@ml-toolkit-ts/xgboost': ['1.0.3', '1.0.4'],
'@mistralai/mistralai': ['2.2.2', '2.2.3', '2.2.4'],
'@mistralai/mistralai-azure': ['1.7.1', '1.7.2', '1.7.3'],
'@mistralai/mistralai-gcp': ['1.7.1', '1.7.2', '1.7.3'],
'@opensearch-project/opensearch': ['3.5.3', '3.6.2', '3.7.0', '3.8.0'],
'@squawk/airport-data': ['0.7.4', '0.7.5', '0.7.6', '0.7.7', '0.7.8'],
'@squawk/airports': ['0.6.2', '0.6.3', '0.6.4', '0.6.5', '0.6.6'],
'@squawk/airspace': ['0.8.1', '0.8.2', '0.8.3', '0.8.4', '0.8.5'],
'@squawk/airspace-data': ['0.5.3', '0.5.4', '0.5.5', '0.5.6', '0.5.7'],
'@squawk/airway-data': ['0.5.4', '0.5.5', '0.5.6', '0.5.7', '0.5.8'],
'@squawk/airways': ['0.4.2', '0.4.3', '0.4.4', '0.4.5', '0.4.6'],
'@squawk/fix-data': ['0.6.4', '0.6.5', '0.6.6', '0.6.7', '0.6.8'],
'@squawk/fixes': ['0.3.2', '0.3.3', '0.3.4', '0.3.5', '0.3.6'],
'@squawk/flight-math': ['0.5.4', '0.5.5', '0.5.6', '0.5.7', '0.5.8'],
'@squawk/flightplan': ['0.5.2', '0.5.3', '0.5.4', '0.5.5', '0.5.6'],
'@squawk/geo': ['0.4.4', '0.4.5', '0.4.6', '0.4.7', '0.4.8'],
'@squawk/icao-registry': ['0.5.2', '0.5.3', '0.5.4', '0.5.5', '0.5.6'],
'@squawk/icao-registry-data': ['0.8.4', '0.8.5', '0.8.6', '0.8.7', '0.8.8'],
'@squawk/mcp': ['0.9.1', '0.9.2', '0.9.3', '0.9.4', '0.9.5'],
'@squawk/navaid-data': ['0.6.4', '0.6.5', '0.6.6', '0.6.7', '0.6.8'],
'@squawk/navaids': ['0.4.2', '0.4.3', '0.4.4', '0.4.5', '0.4.6'],
'@squawk/notams': ['0.3.6', '0.3.7', '0.3.8', '0.3.9', '0.3.10'],
'@squawk/procedure-data': ['0.7.3', '0.7.4', '0.7.5', '0.7.6', '0.7.7'],
'@squawk/procedures': ['0.5.2', '0.5.3', '0.5.4', '0.5.5', '0.5.6'],
'@squawk/types': ['0.8.1', '0.8.2', '0.8.3', '0.8.4', '0.8.5'],
'@squawk/units': ['0.4.3', '0.4.4', '0.4.5', '0.4.6', '0.4.7'],
'@squawk/weather': ['0.5.6', '0.5.7', '0.5.8', '0.5.9', '0.5.10'],
'@supersurkhet/cli': ['0.0.2', '0.0.3', '0.0.4', '0.0.5', '0.0.6', '0.0.7'],
'@supersurkhet/sdk': ['0.0.2', '0.0.3', '0.0.4', '0.0.5', '0.0.6', '0.0.7'],
'@tallyui/components': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/connector-medusa': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/connector-shopify': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/connector-vendure': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/connector-woocommerce': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/core': ['0.2.1', '0.2.2', '0.2.3'],
'@tallyui/database': ['1.0.1', '1.0.2', '1.0.3'],
'@tallyui/pos': ['0.1.1', '0.1.2', '0.1.3'],
'@tallyui/storage-sqlite': ['0.2.1', '0.2.2', '0.2.3'],
'@tallyui/theme': ['0.2.1', '0.2.2', '0.2.3'],
'@tanstack/arktype-adapter': ['1.166.12', '1.166.15'],
'@tanstack/eslint-plugin-router': ['1.161.9', '1.161.12'],
'@tanstack/eslint-plugin-start': ['0.0.4', '0.0.7'],
'@tanstack/history': ['1.161.9', '1.161.12'],
'@tanstack/nitro-v2-vite-plugin': ['1.154.12', '1.154.15'],
'@tanstack/react-router': ['1.169.5', '1.169.8'],
'@tanstack/react-router-devtools': ['1.166.16', '1.166.19'],
'@tanstack/react-router-ssr-query': ['1.166.15', '1.166.18'],
'@tanstack/react-start': ['1.167.68', '1.167.71'],
'@tanstack/react-start-client': ['1.166.51', '1.166.54'],
'@tanstack/react-start-rsc': ['0.0.47', '0.0.50'],
'@tanstack/react-start-server': ['1.166.55', '1.166.58'],
'@tanstack/router-cli': ['1.166.46', '1.166.49'],
'@tanstack/router-core': ['1.169.5', '1.169.8'],
'@tanstack/router-devtools': ['1.166.16', '1.166.19'],
'@tanstack/router-devtools-core': ['1.167.6', '1.167.9'],
'@tanstack/router-generator': ['1.166.45', '1.166.48'],
'@tanstack/router-plugin': ['1.167.38', '1.167.41'],
'@tanstack/router-ssr-query-core': ['1.168.3', '1.168.6'],
'@tanstack/router-utils': ['1.161.11', '1.161.14'],
'@tanstack/router-vite-plugin': ['1.166.53', '1.166.56'],
'@tanstack/solid-router': ['1.169.5', '1.169.8'],
'@tanstack/solid-router-devtools': ['1.166.16', '1.166.19'],
'@tanstack/solid-router-ssr-query': ['1.166.15', '1.166.18'],
'@tanstack/solid-start': ['1.167.65', '1.167.68'],
'@tanstack/solid-start-client': ['1.166.50', '1.166.53'],
'@tanstack/solid-start-server': ['1.166.54', '1.166.57'],
'@tanstack/start-client-core': ['1.168.5', '1.168.8'],
'@tanstack/start-fn-stubs': ['1.161.9', '1.161.12'],
'@tanstack/start-plugin-core': ['1.169.23', '1.169.26'],
'@tanstack/start-server-core': ['1.167.33', '1.167.36'],
'@tanstack/start-static-server-functions': ['1.166.44', '1.166.47'],
'@tanstack/start-storage-context': ['1.166.38', '1.166.41'],
'@tanstack/valibot-adapter': ['1.166.12', '1.166.15'],
'@tanstack/virtual-file-routes': ['1.161.10', '1.161.13'],
'@tanstack/vue-router': ['1.169.5', '1.169.8'],
'@tanstack/vue-router-devtools': ['1.166.16', '1.166.19'],
'@tanstack/vue-router-ssr-query': ['1.166.15', '1.166.18'],
'@tanstack/vue-start': ['1.167.61', '1.167.64'],
'@tanstack/vue-start-client': ['1.166.46', '1.166.49'],
'@tanstack/vue-start-server': ['1.166.50', '1.166.53'],
'@tanstack/zod-adapter': ['1.166.12', '1.166.15'],
'@taskflow-corp/cli': ['0.1.24', '0.1.25', '0.1.26', '0.1.27', '0.1.28', '0.1.29'],
'@tolka/cli': ['1.0.2', '1.0.3', '1.0.4', '1.0.5', '1.0.6'],
'@uipath/access-policy-sdk': ['0.3.1'],
'@uipath/access-policy-tool': ['0.3.1'],
'@uipath/agent.sdk': ['0.0.18'],
'@uipath/agent-sdk': ['1.0.2'],
'@uipath/agent-tool': ['1.0.1'],
'@uipath/admin-tool': ['0.1.1'],
'@uipath/aops-policy-tool': ['0.3.1'],
'@uipath/ap-chat': ['1.5.7'],
'@uipath/api-workflow-tool': ['1.0.1'],
'@uipath/apollo-core': ['5.9.2'],
'@uipath/apollo-react': ['4.24.5'],
'@uipath/apollo-wind': ['2.16.2'],
'@uipath/auth': ['1.0.1'],
'@uipath/case-tool': ['1.0.1'],
'@uipath/cli': ['1.0.1'],
'@uipath/codedagent-tool': ['1.0.1'],
'@uipath/codedagents-tool': ['0.1.12'],
'@uipath/codedapp-tool': ['1.0.1'],
'@uipath/common': ['1.0.1'],
'@uipath/context-grounding-tool': ['0.1.1'],
'@uipath/data-fabric-tool': ['1.0.2'],
'@uipath/docsai-tool': ['1.0.1'],
'@uipath/filesystem': ['1.0.1'],
'@uipath/flow-tool': ['1.0.2'],
'@uipath/functions-tool': ['1.0.1'],
'@uipath/gov-tool': ['0.3.1'],
'@uipath/identity-tool': ['0.1.1'],
'@uipath/insights-sdk': ['1.0.1'],
'@uipath/insights-tool': ['1.0.1'],
'@uipath/integrationservice-sdk': ['1.0.2'],
'@uipath/integrationservice-tool': ['1.0.2'],
'@uipath/llmgw-tool': ['1.0.1'],
'@uipath/maestro-sdk': ['1.0.1'],
'@uipath/maestro-tool': ['1.0.1'],
'@uipath/orchestrator-tool': ['1.0.1'],
'@uipath/packager-tool-apiworkflow': ['0.0.19'],
'@uipath/packager-tool-bpmn': ['0.0.9'],
'@uipath/packager-tool-case': ['0.0.9'],
'@uipath/packager-tool-connector': ['0.0.19'],
'@uipath/packager-tool-flow': ['0.0.19'],
'@uipath/packager-tool-functions': ['0.1.1'],
'@uipath/packager-tool-webapp': ['1.0.6'],
'@uipath/packager-tool-workflowcompiler': ['0.0.16'],
'@uipath/packager-tool-workflowcompiler-browser': ['0.0.34'],
'@uipath/platform-tool': ['1.0.1'],
'@uipath/project-packager': ['1.1.16'],
'@uipath/resource-tool': ['1.0.1'],
'@uipath/resourcecatalog-tool': ['0.1.1'],
'@uipath/resources-tool': ['0.1.11'],
'@uipath/robot': ['1.3.4'],
'@uipath/rpa-legacy-tool': ['1.0.1'],
'@uipath/rpa-tool': ['0.9.5'],
'@uipath/solution-packager': ['0.0.35'],
'@uipath/solution-tool': ['1.0.1'],
'@uipath/solutionpackager-sdk': ['1.0.11'],
'@uipath/solutionpackager-tool-core': ['0.0.34'],
'@uipath/tasks-tool': ['1.0.1'],
'@uipath/telemetry': ['0.0.7'],
'@uipath/test-manager-tool': ['1.0.2'],
'@uipath/tool-workflowcompiler': ['0.0.12'],
'@uipath/traces-tool': ['1.0.1'],
'@uipath/ui-widgets-multi-file-upload': ['1.0.1'],
'@uipath/uipath-python-bridge': ['1.0.1'],
'@uipath/vertical-solutions-tool': ['1.0.1'],
'@uipath/vss': ['0.1.6'],
'@uipath/widget.sdk': ['1.2.3'],
'agentwork-cli': ['0.1.4', '0.1.5'],
'cmux-agent-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.6', '0.1.7', '0.1.8'],
'cross-stitch': ['1.1.3', '1.1.4', '1.1.5', '1.1.6', '1.1.7'],
'git-branch-selector': ['1.3.3', '1.3.4', '1.3.5', '1.3.6', '1.3.7'],
'git-git-git': ['1.0.8', '1.0.9', '1.0.10', '1.0.11', '1.0.12'],
'guardrails-ai': ['0.10.1'],
'intercom-client': ['7.0.4'],
'lightning': ['2.6.2', '2.6.3'],
'mbt': ['1.2.48'],
'mistralai': ['2.4.6'],
'ml-toolkit-ts': ['1.0.4', '1.0.5'],
'node-ipc': ['9.1.6', '9.2.3', '10.1.1', '10.1.2', '11.0.0', '11.1.0', '12.0.1'],
'nextmove-mcp': ['0.1.3', '0.1.4', '0.1.5', '0.1.7'],
'safe-action': ['0.8.3', '0.8.4'],
'ts-dna': ['3.0.1', '3.0.2', '3.0.3', '3.0.4', '3.0.5'],
'wot-api': ['0.8.1', '0.8.2', '0.8.3', '0.8.4'],
};
const CRITICAL_TEXT_INDICATORS = [
'@tanstack/setup',
[
'github:tanstack/router#79ac49eedf774dd4b0cf',
'a308722bc463cfe5885c',
].join(''),
[
'79ac49eedf774dd4b0cf',
'a308722bc463cfe5885c',
].join(''),
'router_init.js',
'router_runtime.js',
'tanstack_runner.js',
'opensearch_init.js',
'vite_setup.mjs',
'bun run tanstack_runner.js',
'execution.js',
'transformers.pyz',
'pgmonitor.py',
'pgsql-monitor.service',
'gh-token-monitor',
'com.user.gh-token-monitor',
'IfYouRevokeThisTokenItWillWipeTheComputerOfTheOwner',
[
'ab4fcadaec49c032',
'78063dd269ea5ee',
'f82d24f2124a8e15',
'd7b90f2fa8601266c',
].join(''),
[
'2ec78d556d696e20',
'8927cc503d48e4b5e',
'b56b31abc2870c2e',
'd2e98d6be27fc96',
].join(''),
[
'7c12d8619f2db233',
'e3d965a930709335',
'5f149d5babc45891',
'2757a5e88fec0f54',
].join(''),
[
'0c0e8730695e997b',
'3a53d77483f28573',
'392319ec023f8fd6',
'd7282121cf7cf192',
].join(''),
'svksjrhjkcejg',
'filev2.getsession.org',
'seed1.getsession.org',
'seed2.getsession.org',
'seed3.getsession.org',
'signalservice',
'git-tanstack.com',
'169.254.169.254',
'169.254.170.2',
'127.0.0.1:8200',
'litter.catbox.moe/h8nc9u.js',
'litter.catbox.moe/7rrc6l.mjs',
'83.142.209.194',
'api.masscan.cloud',
'claude@users.noreply.github.com',
'dependabot/github_actions/format/',
'OhNoWhatsGoingOnWithGitHub',
'voicproducoes',
'A Mini Shai-Hulud has Appeared',
'Shai-Hulud: Here We Go Again',
'PUSH UR T3MPRR',
'codeql_analysis.yml',
'shai-hulud-workflow.yml',
[
'96097e0612d9575c',
'b133021017fb1a5c',
'68a03b60f9f3d24e',
'bdc0e628d9034144',
].join(''),
[
'449e4265979b5fdb',
'2d3446c021af437e',
'815debd66de7da2f',
'e54f1ad93cbcc75e',
].join(''),
[
'c2f4dc64aec46315',
'40a568e88932b61d',
'aebbfb7e8281b812',
'fa01b7215f9be9ea',
].join(''),
[
'78a82d93b4f58083',
'5f5823b85a3d9ee1',
'f03a15ee6f0e01b',
'4eac86252a7002981',
].join(''),
'sh.azurestaticprovider.net',
'37.16.75.69',
'bt.node.js',
'__ntw',
'__ntRun',
'/nt-',
'uname.txt',
'envs.txt',
'fixtures/_paths.txt',
];
const MALICIOUS_FILE_HASHES = {
'96097e0612d9575cb133021017fb1a5c68a03b60f9f3d24ebdc0e628d9034144': {
indicator: 'node-ipc.cjs sha256',
message: 'Known malicious node-ipc CommonJS payload hash is present',
},
'449e4265979b5fdb2d3446c021af437e815debd66de7da2fe54f1ad93cbcc75e': {
indicator: 'node-ipc-9.1.6.tgz sha256',
message: 'Known malicious node-ipc tarball hash is present',
},
'c2f4dc64aec4631540a568e88932b61daebbfb7e8281b812fa01b7215f9be9ea': {
indicator: 'node-ipc-9.2.3.tgz sha256',
message: 'Known malicious node-ipc tarball hash is present',
},
'78a82d93b4f580835f5823b85a3d9ee1f03a15ee6f0e01b4eac86252a7002981': {
indicator: 'node-ipc-12.0.1.tar.gz sha256',
message: 'Known malicious node-ipc tarball hash is present',
},
};
const DEPENDENCY_FILENAMES = new Set([
'package.json',
'package-lock.json',
'pnpm-lock.yaml',
'yarn.lock',
'bun.lock',
'pyproject.toml',
'poetry.lock',
'requirements.txt',
]);
const INSPECT_ONLY_FILENAMES = new Set([
'node-ipc.cjs',
'node-ipc-9.1.6.tgz',
'node-ipc-9.2.3.tgz',
'node-ipc-12.0.1.tar.gz',
]);
const PERSISTENCE_FILENAMES = new Set([
'settings.json',
'settings.local.json',
'hooks.json',
'tasks.json',
'router_runtime.js',
'setup.mjs',
'pgmonitor.py',
'gh-token-monitor.sh',
'com.user.gh-token-monitor.plist',
'gh-token-monitor.service',
'pgsql-monitor.service',
'codeql_analysis.yml',
'shai-hulud-workflow.yml',
]);
const PAYLOAD_FILENAMES = new Set([
'router_init.js',
'router_runtime.js',
'tanstack_runner.js',
'opensearch_init.js',
'vite_setup.mjs',
'execution.js',
'transformers.pyz',
'pgmonitor.py',
'gh-token-monitor.sh',
'com.user.gh-token-monitor.plist',
'gh-token-monitor.service',
'pgsql-monitor.service',
'codeql_analysis.yml',
'shai-hulud-workflow.yml',
]);
function normalizedPath(filePath) {
return filePath.split(path.sep).join('/');
}
function isGhTokenMonitorTokenPath(filePath) {
return /\/\.config\/gh-token-monitor\/token$/.test(normalizedPath(filePath));
}
const IGNORED_DIRS = new Set([
'.git',
'.next',
'.pytest_cache',
'__pycache__',
'coverage',
'dist',
'docs',
'target',
'tests',
]);
function normalizeForMatch(value) {
return value.toLowerCase();
}
function isInSpecialConfigPath(filePath) {
const normalized = normalizedPath(filePath);
return /\/\.claude\//.test(normalized)
|| /\/\.cursor\//.test(normalized)
|| /\/\.vscode\//.test(normalized)
|| /\/\.kiro\/settings\//.test(normalized)
|| /\/Library\/LaunchAgents\//.test(normalized)
|| /\/\.config\/systemd\/user\//.test(normalized)
|| /\/\.local\/bin\//.test(normalized)
|| /\/\.github\/workflows\//.test(normalized);
}
function shouldInspectFile(filePath) {
const base = path.basename(filePath);
if (isGhTokenMonitorTokenPath(filePath)) return true;
if (DEPENDENCY_FILENAMES.has(base)) return true;
if (PERSISTENCE_FILENAMES.has(base) && isInSpecialConfigPath(filePath)) return true;
if (PAYLOAD_FILENAMES.has(base) && filePath.includes(`${path.sep}node_modules${path.sep}`)) return true;
if (INSPECT_ONLY_FILENAMES.has(base)) return true;
return false;
}
function walkFiles(rootDir, files = []) {
if (!fs.existsSync(rootDir)) return files;
const stat = fs.statSync(rootDir);
if (stat.isFile()) {
if (shouldInspectFile(rootDir)) files.push(rootDir);
return files;
}
for (const entry of fs.readdirSync(rootDir, { withFileTypes: true })) {
const fullPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
if (IGNORED_DIRS.has(entry.name) && entry.name !== 'node_modules') continue;
if (entry.name === 'node_modules') {
walkNodeModules(fullPath, files);
} else {
walkFiles(fullPath, files);
}
} else if (entry.isFile() && shouldInspectFile(fullPath)) {
files.push(fullPath);
}
}
return files;
}
function walkNodeModules(nodeModulesDir, files) {
if (!fs.existsSync(nodeModulesDir)) return;
for (const entry of fs.readdirSync(nodeModulesDir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(nodeModulesDir, entry.name);
if (entry.isDirectory()) {
if (entry.name.startsWith('@')) {
for (const scopedEntry of fs.readdirSync(fullPath, { withFileTypes: true })) {
if (scopedEntry.isDirectory()) {
inspectPackageDir(path.join(fullPath, scopedEntry.name), files);
}
}
} else {
inspectPackageDir(fullPath, files);
}
}
}
}
function inspectPackageDir(packageDir, files) {
for (const filename of [
...DEPENDENCY_FILENAMES,
...PAYLOAD_FILENAMES,
...INSPECT_ONLY_FILENAMES,
'setup.mjs',
'execution.js',
]) {
const candidate = path.join(packageDir, filename);
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
files.push(candidate);
}
}
}
function readText(filePath) {
try {
return fs.readFileSync(filePath, 'utf8');
} catch {
return '';
}
}
function sha256File(filePath) {
try {
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
} catch {
return '';
}
}
function lineForIndex(text, index) {
return text.slice(0, index).split(/\r?\n/).length;
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function versionSpecifierMatches(value, version) {
if (value === undefined || value === null) return false;
const specifier = String(value);
const versionPattern = new RegExp(`(^|[^0-9A-Za-z.])${escapeRegExp(version)}([^0-9A-Za-z.]|$)`, 'i');
return specifier === version || versionPattern.test(specifier);
}
function packageKeyMatches(key, packageName) {
return key === packageName
|| key === `node_modules/${packageName}`
|| key.endsWith(`/node_modules/${packageName}`);
}
function jsonReferencesPackageVersion(value, packageName, version) {
if (!value || typeof value !== 'object') return false;
if (value.name === packageName && versionSpecifierMatches(value.version, version)) {
return true;
}
for (const [key, child] of Object.entries(value)) {
if (packageKeyMatches(key, packageName)) {
if (typeof child === 'string' && versionSpecifierMatches(child, version)) {
return true;
}
if (child && typeof child === 'object' && versionSpecifierMatches(child.version, version)) {
return true;
}
}
if (child && typeof child === 'object' && jsonReferencesPackageVersion(child, packageName, version)) {
return true;
}
}
return false;
}
function textReferencesPackageVersion(text, packageName, version) {
const escapedPackage = escapeRegExp(packageName);
const escapedVersion = escapeRegExp(version);
const packageToken = `${escapedPackage}(?![A-Za-z0-9._/-])`;
const sameLinePattern = new RegExp(`${packageToken}[^\\n]{0,200}${escapedVersion}(?![0-9A-Za-z.])`, 'i');
const requirementsPattern = new RegExp(`^\\s*${packageToken}\\s*(?:==|===|~=|>=|<=|>|<)\\s*${escapedVersion}(?![0-9A-Za-z.])`, 'im');
const poetryNamePattern = new RegExp(`name\\s*=\\s*["']${escapedPackage}["'][\\s\\S]{0,300}?version\\s*=\\s*["']${escapedVersion}["']`, 'i');
return sameLinePattern.test(text)
|| requirementsPattern.test(text)
|| poetryNamePattern.test(text);
}
function dependencyFileReferencesPackageVersion(text, packageName, version) {
try {
return jsonReferencesPackageVersion(JSON.parse(text), packageName, version);
} catch {
return textReferencesPackageVersion(text, packageName, version);
}
}
function addFinding(findings, severity, filePath, line, indicator, message) {
findings.push({ severity, filePath, line, indicator, message });
}
function isClaudeSettingsFile(filePath) {
const normalized = normalizedPath(filePath);
return /\/\.claude\/settings(?:\.local)?\.json$/.test(normalized);
}
function claudePermissionDenyRanges(filePath, text) {
if (!isClaudeSettingsFile(filePath)) return [];
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return [];
}
const denyEntries = parsed?.permissions?.deny;
if (!Array.isArray(denyEntries)) return [];
const ranges = [];
for (const entry of denyEntries) {
if (typeof entry !== 'string' || entry.length === 0) continue;
for (const needle of [...new Set([JSON.stringify(entry), entry])]) {
let index = text.indexOf(needle);
while (index !== -1) {
ranges.push([index, index + needle.length]);
index = text.indexOf(needle, index + needle.length);
}
}
}
return ranges;
}
function indexInRanges(index, ranges) {
return ranges.some(([start, end]) => index >= start && index < end);
}
function scanFile(filePath, rootDir, findings) {
const base = path.basename(filePath);
const relativePath = path.relative(rootDir, filePath) || filePath;
const text = readText(filePath);
const lowerText = normalizeForMatch(text);
const hashFinding = MALICIOUS_FILE_HASHES[sha256File(filePath)];
const defensiveClaudeDenyRanges = claudePermissionDenyRanges(filePath, text);
if (hashFinding) {
addFinding(
findings,
'critical',
relativePath,
1,
hashFinding.indicator,
hashFinding.message,
);
}
if (PAYLOAD_FILENAMES.has(base)) {
addFinding(
findings,
'critical',
relativePath,
1,
base,
'Known Mini Shai-Hulud/TanStack payload or persistence filename is present',
);
}
if (isGhTokenMonitorTokenPath(filePath)) {
addFinding(
findings,
'critical',
relativePath,
1,
'~/.config/gh-token-monitor/token',
'Known Mini Shai-Hulud dead-man switch token store is present',
);
}
for (const indicator of CRITICAL_TEXT_INDICATORS) {
const normalizedIndicator = normalizeForMatch(indicator);
// Require a non-filename character before the indicator so legitimate
// names that merely end with an IOC filename (e.g. the stock Cursor hook
// `before-shell-execution.js` vs the payload `execution.js`) do not match.
const indicatorPattern = new RegExp(
`(?<![a-z0-9_-])${escapeRegExp(normalizedIndicator)}`,
'g',
);
let match;
while ((match = indicatorPattern.exec(lowerText)) !== null) {
if (!indexInRanges(match.index, defensiveClaudeDenyRanges)) {
addFinding(
findings,
'critical',
relativePath,
lineForIndex(text, match.index),
indicator,
'Known active supply-chain IOC is present',
);
break;
}
}
}
if (!DEPENDENCY_FILENAMES.has(base)) return;
for (const [packageName, versions] of Object.entries(MALICIOUS_PACKAGE_VERSIONS)) {
for (const version of versions) {
if (dependencyFileReferencesPackageVersion(text, packageName, version)) {
const packageIndex = lowerText.indexOf(normalizeForMatch(packageName));
addFinding(
findings,
'critical',
relativePath,
lineForIndex(text, packageIndex === -1 ? 0 : packageIndex),
`${packageName}@${version}`,
'Dependency manifest or lockfile references a known compromised package version',
);
}
}
}
}
function homeTargets(homeDir) {
return [
'.claude/settings.json',
'.claude/settings.local.json',
'.claude/hooks/hooks.json',
'.claude/router_runtime.js',
'.claude/setup.mjs',
'.vscode/tasks.json',
'.vscode/setup.mjs',
'Library/Application Support/Code/User/tasks.json',
'Library/Application Support/Code - Insiders/User/tasks.json',
'.config/Code/User/tasks.json',
'.config/Code - Insiders/User/tasks.json',
'AppData/Roaming/Code/User/tasks.json',
'AppData/Roaming/Code - Insiders/User/tasks.json',
'Library/LaunchAgents/com.user.gh-token-monitor.plist',
'.config/systemd/user/gh-token-monitor.service',
'.config/systemd/user/pgsql-monitor.service',
'.config/gh-token-monitor/token',
'.local/bin/gh-token-monitor.sh',
'.local/bin/pgmonitor.py',
].map(relativePath => path.join(homeDir, relativePath));
}
function runtimeTargets() {
return [
'/tmp/transformers.pyz',
'/tmp/pgmonitor.py',
'/tmp/node-ipc-9.1.6.tgz',
'/tmp/node-ipc-9.2.3.tgz',
'/tmp/node-ipc-12.0.1.tar.gz',
'/private/tmp/transformers.pyz',
'/private/tmp/pgmonitor.py',
'/private/tmp/node-ipc-9.1.6.tgz',
'/private/tmp/node-ipc-9.2.3.tgz',
'/private/tmp/node-ipc-12.0.1.tar.gz',
];
}
function scanSupplyChainIocs(options = {}) {
const rootDir = path.resolve(options.rootDir || DEFAULT_ROOT);
const files = walkFiles(rootDir);
const findings = [];
if (options.home) {
for (const target of homeTargets(options.homeDir || os.homedir())) {
if (fs.existsSync(target)) files.push(target);
}
for (const target of runtimeTargets()) {
if (fs.existsSync(target)) files.push(target);
}
}
for (const filePath of [...new Set(files)].sort()) {
scanFile(filePath, rootDir, findings);
}
return {
rootDir,
scannedFiles: files.length,
findings,
};
}
function parseArgs(argv) {
const options = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg === '--root') {
options.rootDir = argv[++i];
} else if (arg === '--home') {
options.home = true;
} else if (arg === '--home-dir') {
options.home = true;
options.homeDir = argv[++i];
} else if (arg === '--json') {
options.json = true;
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printHelp() {
console.log(`Usage: node scripts/ci/scan-supply-chain-iocs.js [options]
Scan dependency manifests, lockfiles, installed package payloads, and AI-tool
persistence paths for active supply-chain IOC markers.
Options:
--root <dir> Directory to scan (default: repo root)
--home Also scan user-level Claude, VS Code, LaunchAgent, systemd,
local bin, and /tmp persistence targets
--home-dir <dir> Home directory to use with --home
--json Emit JSON instead of text
--help, -h Show this help
Examples:
node scripts/ci/scan-supply-chain-iocs.js --home
node scripts/ci/scan-supply-chain-iocs.js --root /path/to/project --json
`);
}
function printReport(result, json = false) {
if (json) {
console.log(JSON.stringify(result, null, 2));
return;
}
if (result.findings.length === 0) {
console.log(`Supply-chain IOC scan passed for ${result.rootDir} (${result.scannedFiles} files inspected)`);
return;
}
for (const finding of result.findings) {
console.error(
`${finding.severity.toUpperCase()}: ${finding.filePath}:${finding.line} ${finding.indicator}`,
);
console.error(` ${finding.message}`);
}
}
if (require.main === module) {
try {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
process.exit(0);
}
const result = scanSupplyChainIocs(options);
printReport(result, options.json);
process.exit(result.findings.length > 0 ? 1 : 0);
} catch (error) {
console.error(error.message);
process.exit(2);
}
}
module.exports = {
CRITICAL_TEXT_INDICATORS,
MALICIOUS_FILE_HASHES,
MALICIOUS_PACKAGE_VERSIONS,
scanSupplyChainIocs,
};
+469
View File
@@ -0,0 +1,469 @@
#!/usr/bin/env node
/**
* Build a refreshable source report for active supply-chain advisories.
*/
const fs = require('fs');
const http = require('http');
const https = require('https');
const path = require('path');
const DEFAULT_GENERATED_AT = () => new Date().toISOString();
const DEFAULT_TIMEOUT_MS = 5000;
const MAX_REDIRECTS = 5;
const DEFAULT_ADVISORY_SOURCES = [
{
id: 'tanstack-postmortem',
title: 'TanStack npm supply-chain compromise postmortem',
publisher: 'TanStack',
url: 'https://tanstack.com/blog/npm-supply-chain-compromise-postmortem',
sourceType: 'primary-incident-postmortem',
ecosystems: ['npm', 'GitHub Actions'],
signals: ['tanstack', 'trusted-publishing-limits', 'github-actions-cache-poisoning'],
},
{
id: 'github-ghsa-g7cv-rxg3-hmpx',
title: 'GitHub Advisory GHSA-g7cv-rxg3-hmpx / CVE-2026-45321',
publisher: 'GitHub Advisory Database',
url: 'https://github.com/advisories/GHSA-g7cv-rxg3-hmpx',
sourceType: 'security-advisory',
ecosystems: ['npm', 'AI developer tooling'],
signals: ['credential-theft', 'malicious-lifecycle-script', 'tanstack'],
},
{
id: 'tanstack-followup',
title: 'TanStack incident follow-up',
publisher: 'TanStack',
url: 'https://tanstack.com/blog/incident-followup',
sourceType: 'primary-incident-followup',
ecosystems: ['npm', 'GitHub Actions'],
signals: ['remediation', 'trusted-publishing-limits'],
},
{
id: 'stepsecurity-mini-shai-hulud',
title: 'Mini Shai-Hulud campaign analysis',
publisher: 'StepSecurity',
url: 'https://www.stepsecurity.io/blog/mini-shai-hulud-is-back-a-self-spreading-supply-chain-attack-hits-the-npm-ecosystem',
sourceType: 'incident-analysis',
ecosystems: ['npm', 'PyPI', 'AI developer tooling'],
signals: ['mini-shai-hulud', 'claude-code-persistence', 'vscode-persistence', 'os-persistence'],
},
{
id: 'openai-tanstack-response',
title: 'OpenAI response to the TanStack npm supply-chain attack',
publisher: 'OpenAI',
url: 'https://openai.com/index/our-response-to-the-tanstack-npm-supply-chain-attack/',
sourceType: 'vendor-response',
ecosystems: ['npm', 'AI developer tooling'],
signals: ['codex-update', 'developer-tooling-exposure', 'remediation'],
},
{
id: 'wiz-mini-shai-hulud',
title: 'Mini Shai-Hulud broader npm campaign coverage',
publisher: 'Wiz',
url: 'https://www.wiz.io/blog/mini-shai-hulud-strikes-again-tanstack-more-npm-packages-compromised',
sourceType: 'incident-analysis',
ecosystems: ['npm', 'PyPI', 'AI developer tooling'],
signals: ['mini-shai-hulud', 'opensearch', 'mistral-ai', 'uipath', 'squawk'],
},
{
id: 'socket-node-ipc',
title: 'node-ipc package compromise',
publisher: 'Socket',
url: 'https://socket.dev/blog/node-ipc-package-compromised',
sourceType: 'incident-analysis',
ecosystems: ['npm'],
signals: ['node-ipc', 'payload-hash', 'destructive-package-behavior'],
},
{
id: 'npm-trusted-publishers',
title: 'npm trusted publishing documentation',
publisher: 'npm',
url: 'https://docs.npmjs.com/trusted-publishers/',
sourceType: 'registry-control-reference',
ecosystems: ['npm', 'GitHub Actions'],
signals: ['trusted-publishing-limits', 'provenance'],
},
{
id: 'cisa-npm-compromise',
title: 'CISA widespread supply-chain compromise impacting npm ecosystem',
publisher: 'CISA',
url: 'https://www.cisa.gov/news-events/alerts/2025/09/23/widespread-supply-chain-compromise-impacting-npm-ecosystem',
sourceType: 'government-alert',
ecosystems: ['npm'],
signals: ['incident-response', 'credential-rotation', 'npm-compromise'],
},
];
function normalizeArray(values) {
return Array.isArray(values) ? values.filter(Boolean) : [];
}
function createCheck(id, status, summary, fix) {
return { id, status, summary, fix };
}
function uniqueValues(sources, field) {
return new Set(sources.flatMap(source => normalizeArray(source[field])));
}
function validateSources(sources) {
const checks = [];
const ids = new Set();
const duplicateIds = [];
const invalidSources = [];
for (const source of sources) {
if (ids.has(source.id)) duplicateIds.push(source.id);
ids.add(source.id);
if (!source.id || !source.title || !source.publisher || !source.url) {
invalidSources.push(source.id || '(missing id)');
}
}
checks.push(createCheck(
'advisory-source-count',
sources.length >= 8 ? 'pass' : 'fail',
`${sources.length} advisory sources registered`,
'Track at least eight sources spanning primary advisories, vendor responses, and registry controls.',
));
checks.push(createCheck(
'advisory-source-shape',
invalidSources.length === 0 && duplicateIds.length === 0 ? 'pass' : 'fail',
invalidSources.length === 0 && duplicateIds.length === 0
? 'all sources include id, title, publisher, and URL'
: `invalid sources: ${[...invalidSources, ...duplicateIds].join(', ')}`,
'Fix duplicate or incomplete advisory source records before relying on the watch artifact.',
));
const ecosystems = uniqueValues(sources, 'ecosystems');
const requiredEcosystems = ['npm', 'PyPI', 'AI developer tooling'];
const missingEcosystems = requiredEcosystems.filter(ecosystem => !ecosystems.has(ecosystem));
checks.push(createCheck(
'advisory-ecosystem-coverage',
missingEcosystems.length === 0 ? 'pass' : 'fail',
missingEcosystems.length === 0
? 'sources cover npm, PyPI, and AI developer tooling'
: `missing ecosystem coverage: ${missingEcosystems.join(', ')}`,
'Add sources for every active ecosystem touched by the campaign.',
));
const signals = uniqueValues(sources, 'signals');
const requiredSignals = [
'tanstack',
'mini-shai-hulud',
'claude-code-persistence',
'vscode-persistence',
'os-persistence',
'node-ipc',
'trusted-publishing-limits',
'remediation',
];
const missingSignals = requiredSignals.filter(signal => !signals.has(signal));
checks.push(createCheck(
'advisory-signal-coverage',
missingSignals.length === 0 ? 'pass' : 'fail',
missingSignals.length === 0
? 'sources cover package versions, persistence hooks, provenance limits, and remediation'
: `missing signal coverage: ${missingSignals.join(', ')}`,
'Update the source registry before adding or removing scanner indicators.',
));
return checks;
}
function refreshStatusFromResult(result) {
if (result && result.ok) {
return {
status: 'ok',
statusCode: result.statusCode || null,
finalUrl: result.finalUrl || null,
checkedAt: result.checkedAt || null,
};
}
return {
status: 'warning',
statusCode: result && result.statusCode ? result.statusCode : null,
finalUrl: result && result.finalUrl ? result.finalUrl : null,
checkedAt: result && result.checkedAt ? result.checkedAt : null,
error: result && result.error ? String(result.error) : 'source refresh failed',
};
}
async function defaultFetchSource(source, options = {}) {
const checkedAt = options.checkedAt || DEFAULT_GENERATED_AT();
try {
const result = await requestUrl(source.url, {
timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS,
redirectsRemaining: MAX_REDIRECTS,
method: 'HEAD',
});
if (result.statusCode === 405 || result.statusCode === 403) {
return requestUrl(source.url, {
timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS,
redirectsRemaining: MAX_REDIRECTS,
method: 'GET',
checkedAt,
});
}
return { ...result, checkedAt };
} catch (error) {
return {
ok: false,
statusCode: null,
finalUrl: source.url,
checkedAt,
error: error.message,
};
}
}
function requestUrl(url, options) {
return new Promise(resolve => {
const parsed = new URL(url);
const client = parsed.protocol === 'http:' ? http : https;
const request = client.request(parsed, {
method: options.method || 'HEAD',
timeout: options.timeoutMs || DEFAULT_TIMEOUT_MS,
headers: {
'User-Agent': 'ecc-supply-chain-watch/2.0',
Accept: 'text/html,application/json;q=0.9,*/*;q=0.8',
},
}, response => {
const statusCode = response.statusCode || 0;
const location = response.headers.location;
if (
statusCode >= 300
&& statusCode < 400
&& location
&& options.redirectsRemaining > 0
) {
response.resume();
const nextUrl = new URL(location, parsed).toString();
resolve(requestUrl(nextUrl, {
...options,
redirectsRemaining: options.redirectsRemaining - 1,
}));
return;
}
response.resume();
response.on('end', () => {
resolve({
ok: statusCode >= 200 && statusCode < 400,
statusCode,
finalUrl: url,
});
});
});
request.on('timeout', () => {
request.destroy(new Error(`timed out after ${options.timeoutMs || DEFAULT_TIMEOUT_MS}ms`));
});
request.on('error', error => {
resolve({
ok: false,
statusCode: null,
finalUrl: url,
error: error.message,
});
});
request.end();
});
}
function buildLinearStatus(report, sources) {
const primaryEvidence = sources
.filter(source => [
'primary-incident-postmortem',
'security-advisory',
'vendor-response',
'incident-analysis',
].includes(source.sourceType))
.slice(0, 5)
.map(source => `${source.publisher}: ${source.title}`);
return {
issueId: 'ITO-57',
status: 'in_progress',
summary: report.ready
? 'Advisory sources current; scheduled supply-chain watch now emits source refresh evidence.'
: 'Advisory source coverage needs repair before release readiness.',
evidence: primaryEvidence,
remaining: 'Linear status synchronization still needs a live connector/status-update pass after each significant merge batch.',
};
}
async function buildAdvisorySourceReport(options = {}) {
const generatedAt = options.generatedAt || DEFAULT_GENERATED_AT();
const sources = (options.sources || DEFAULT_ADVISORY_SOURCES).map(source => ({
...source,
ecosystems: normalizeArray(source.ecosystems),
signals: normalizeArray(source.signals),
}));
const checks = validateSources(sources);
const refreshEnabled = Boolean(options.refresh);
const fetchSource = options.fetchSource || defaultFetchSource;
let refreshWarnings = 0;
const reportSources = [];
for (const source of sources) {
let refreshStatus = { status: 'not_requested' };
if (refreshEnabled && source.refresh !== false) {
const result = await fetchSource(source, {
timeoutMs: options.timeoutMs || DEFAULT_TIMEOUT_MS,
checkedAt: generatedAt,
});
refreshStatus = refreshStatusFromResult(result);
if (refreshStatus.status !== 'ok') refreshWarnings += 1;
}
reportSources.push({ ...source, refreshStatus });
}
if (refreshEnabled) {
checks.push(createCheck(
'advisory-refresh',
refreshWarnings === 0 ? 'pass' : 'warn',
refreshWarnings === 0
? 'all advisory source URLs responded during refresh'
: `${refreshWarnings} advisory source URL(s) returned warnings during refresh`,
'Review warning sources manually before changing IOC coverage or release evidence.',
));
} else {
checks.push(createCheck(
'advisory-refresh',
'pass',
'live advisory refresh not requested for this offline source contract report',
'Run with --refresh in the scheduled watch to capture live URL status evidence.',
));
}
const ready = checks.every(check => check.status !== 'fail');
const report = {
schema_version: 'ecc.supply-chain-advisory-sources.v1',
generatedAt,
ready,
refresh: {
enabled: refreshEnabled,
ok: refreshEnabled ? refreshWarnings === 0 : null,
warningCount: refreshWarnings,
},
sources: reportSources,
checks,
};
report.linear = {
status: buildLinearStatus(report, reportSources),
};
return report;
}
function parseArgs(argv) {
const options = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg === '--json') {
options.json = true;
} else if (arg === '--refresh') {
options.refresh = true;
} else if (arg === '--strict-refresh') {
options.strictRefresh = true;
options.refresh = true;
} else if (arg === '--generated-at') {
options.generatedAt = argv[++i];
} else if (arg === '--timeout-ms') {
options.timeoutMs = Number(argv[++i]);
if (!Number.isFinite(options.timeoutMs) || options.timeoutMs <= 0) {
throw new Error('--timeout-ms must be a positive number');
}
} else if (arg === '--write') {
options.writePath = argv[++i];
if (!options.writePath) throw new Error('--write requires a path');
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
return options;
}
function printHelp() {
console.log(`Usage: node scripts/ci/supply-chain-advisory-sources.js [options]
Build the active supply-chain advisory source report used by the scheduled
watch workflow and Linear ITO-57 status updates.
Options:
--json Emit JSON instead of text
--refresh Check source URLs and record warning status
--strict-refresh Fail when a refreshed source URL returns a warning
--generated-at <ts> Override the report timestamp
--timeout-ms <n> Per-source refresh timeout (default: ${DEFAULT_TIMEOUT_MS})
--write <path> Write the report to a file
--help, -h Show this help
`);
}
function renderText(report) {
const lines = [
`Supply-chain advisory sources: ${report.ready ? 'ready' : 'blocked'}`,
`Sources: ${report.sources.length}`,
`Refresh: ${report.refresh.enabled ? (report.refresh.ok ? 'ok' : `warnings=${report.refresh.warningCount}`) : 'not requested'}`,
`Linear ${report.linear.status.issueId}: ${report.linear.status.summary}`,
];
for (const check of report.checks) {
lines.push(`- ${check.status.toUpperCase()} ${check.id}: ${check.summary}`);
}
return `${lines.join('\n')}\n`;
}
function writeReport(report, writePath) {
const absolutePath = path.resolve(writePath);
fs.mkdirSync(path.dirname(absolutePath), { recursive: true });
fs.writeFileSync(absolutePath, `${JSON.stringify(report, null, 2)}\n`);
}
if (require.main === module) {
(async () => {
try {
const options = parseArgs(process.argv.slice(2));
if (options.help) {
printHelp();
process.exit(0);
}
const report = await buildAdvisorySourceReport(options);
if (options.writePath) writeReport(report, options.writePath);
if (options.json) {
console.log(JSON.stringify(report, null, 2));
} else {
process.stdout.write(renderText(report));
}
const failed = !report.ready || (options.strictRefresh && report.refresh.enabled && !report.refresh.ok);
process.exit(failed ? 1 : 0);
} catch (error) {
console.error(error.message);
process.exit(2);
}
})();
}
module.exports = {
DEFAULT_ADVISORY_SOURCES,
buildAdvisorySourceReport,
parseArgs,
renderText,
};
+96
View File
@@ -0,0 +1,96 @@
#!/usr/bin/env node
/**
* Validate agent markdown files have required frontmatter
*/
const fs = require('fs');
const path = require('path');
const AGENTS_DIR = path.join(__dirname, '../../agents');
const REQUIRED_FIELDS = ['model', 'tools'];
const VALID_MODELS = ['haiku', 'sonnet', 'opus'];
function extractFrontmatter(content) {
// Strip BOM if present (UTF-8 BOM: \uFEFF)
const cleanContent = content.replace(/^\uFEFF/, '');
// Support both LF and CRLF line endings
const match = cleanContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
if (!match) return null;
const frontmatter = {};
const duplicates = [];
const lines = match[1].split(/\r?\n/);
for (const line of lines) {
// Only top-level keys are unique. Indented YAML belongs to nested values.
if (/^\s/.test(line)) continue;
const colonIdx = line.indexOf(':');
if (colonIdx > 0) {
const key = line.slice(0, colonIdx).trim();
const value = line.slice(colonIdx + 1).trim();
if (Object.prototype.hasOwnProperty.call(frontmatter, key)) {
duplicates.push(key);
}
frontmatter[key] = value;
}
}
Object.defineProperty(frontmatter, '__duplicates__', {
value: duplicates,
enumerable: false,
});
return frontmatter;
}
function validateAgents() {
if (!fs.existsSync(AGENTS_DIR)) {
console.log('No agents directory found, skipping validation');
process.exit(0);
}
const files = fs.readdirSync(AGENTS_DIR).filter(f => f.endsWith('.md'));
let hasErrors = false;
for (const file of files) {
const filePath = path.join(AGENTS_DIR, file);
let content;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch (err) {
console.error(`ERROR: ${file} - ${err.message}`);
hasErrors = true;
continue;
}
const frontmatter = extractFrontmatter(content);
if (!frontmatter) {
console.error(`ERROR: ${file} - Missing frontmatter`);
hasErrors = true;
continue;
}
if (frontmatter.__duplicates__.length > 0) {
console.error(`ERROR: ${file} - Duplicate frontmatter keys: ${[...new Set(frontmatter.__duplicates__)].join(', ')}`);
hasErrors = true;
}
for (const field of REQUIRED_FIELDS) {
if (!frontmatter[field] || (typeof frontmatter[field] === 'string' && !frontmatter[field].trim())) {
console.error(`ERROR: ${file} - Missing required field: ${field}`);
hasErrors = true;
}
}
// Validate model is a known value
if (frontmatter.model && !VALID_MODELS.includes(frontmatter.model)) {
console.error(`ERROR: ${file} - Invalid model '${frontmatter.model}'. Must be one of: ${VALID_MODELS.join(', ')}`);
hasErrors = true;
}
}
if (hasErrors) {
process.exit(1);
}
console.log(`Validated ${files.length} agent files`);
}
validateAgents();
+188
View File
@@ -0,0 +1,188 @@
#!/usr/bin/env node
/**
* Validate command markdown files are non-empty, readable,
* and have valid cross-references to other commands, agents, and skills.
*/
const fs = require('fs');
const path = require('path');
const ROOT_DIR = path.join(__dirname, '../..');
const COMMANDS_DIR = path.join(ROOT_DIR, 'commands');
const AGENTS_DIR = path.join(ROOT_DIR, 'agents');
const SKILLS_DIR = path.join(ROOT_DIR, 'skills');
function validateFrontmatter(file, content) {
if (!content.startsWith('---\n')) {
return [];
}
const endIndex = content.indexOf('\n---\n', 4);
if (endIndex === -1) {
return [`${file} - frontmatter block is missing a closing --- delimiter`];
}
const block = content.slice(4, endIndex);
const errors = [];
for (const rawLine of block.split('\n')) {
const line = rawLine.trim();
if (!line || line.startsWith('#')) {
continue;
}
const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!match) {
errors.push(`${file} - invalid frontmatter line: ${rawLine}`);
continue;
}
const value = match[2].trim();
const isQuoted = (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
);
if (!isQuoted && value.startsWith('[') && !value.endsWith(']')) {
errors.push(
`${file} - frontmatter value for "${match[1]}" starts with "[" but is not a closed YAML sequence; wrap it in quotes`,
);
}
if (!isQuoted && value.startsWith('{') && !value.endsWith('}')) {
errors.push(
`${file} - frontmatter value for "${match[1]}" starts with "{" but is not a closed YAML mapping; wrap it in quotes`,
);
}
}
return errors;
}
function validateCommands() {
if (!fs.existsSync(COMMANDS_DIR)) {
console.log('No commands directory found, skipping validation');
process.exit(0);
}
const files = fs.readdirSync(COMMANDS_DIR).filter(f => f.endsWith('.md'));
let hasErrors = false;
let warnCount = 0;
// Build set of valid command names (without .md extension)
const validCommands = new Set(files.map(f => f.replace(/\.md$/, '')));
// Build set of valid agent names (without .md extension)
const validAgents = new Set();
if (fs.existsSync(AGENTS_DIR)) {
for (const f of fs.readdirSync(AGENTS_DIR)) {
if (f.endsWith('.md')) {
validAgents.add(f.replace(/\.md$/, ''));
}
}
}
// Build set of valid skill directory names
const validSkills = new Set();
if (fs.existsSync(SKILLS_DIR)) {
for (const f of fs.readdirSync(SKILLS_DIR)) {
const skillPath = path.join(SKILLS_DIR, f);
try {
if (fs.statSync(skillPath).isDirectory()) {
validSkills.add(f);
}
} catch {
// skip unreadable entries
}
}
}
for (const file of files) {
const filePath = path.join(COMMANDS_DIR, file);
let content;
try {
content = fs.readFileSync(filePath, 'utf-8');
} catch (err) {
console.error(`ERROR: ${file} - ${err.message}`);
hasErrors = true;
continue;
}
// Validate the file is non-empty readable markdown
if (content.trim().length === 0) {
console.error(`ERROR: ${file} - Empty command file`);
hasErrors = true;
continue;
}
for (const error of validateFrontmatter(file, content)) {
console.error(`ERROR: ${error}`);
hasErrors = true;
}
// Strip fenced code blocks before checking cross-references.
// Examples/templates inside ``` blocks are not real references.
const contentNoCodeBlocks = content.replace(/```[\s\S]*?```/g, '');
// Check cross-references to other commands (e.g., `/build-fix`)
// Skip lines that describe hypothetical output (e.g., "→ Creates: `/new-table`")
// Process line-by-line so ALL command refs per line are captured
// (previous anchored regex /^.*`\/...`.*$/gm only matched the last ref per line)
for (const line of contentNoCodeBlocks.split('\n')) {
if (/creates:|would create:/i.test(line)) continue;
const lineRefs = line.matchAll(/`\/([a-z][-a-z0-9]*)`/g);
for (const match of lineRefs) {
const refName = match[1];
if (!validCommands.has(refName)) {
console.error(`ERROR: ${file} - references non-existent command /${refName}`);
hasErrors = true;
}
}
}
// Check agent references (e.g., "agents/planner.md" or "`planner` agent")
const agentPathRefs = contentNoCodeBlocks.matchAll(/agents\/([a-z][-a-z0-9]*)\.md/g);
for (const match of agentPathRefs) {
const refName = match[1];
if (!validAgents.has(refName)) {
console.error(`ERROR: ${file} - references non-existent agent agents/${refName}.md`);
hasErrors = true;
}
}
// Check skill directory references (e.g., "skills/tdd-workflow/")
// learned and imported are reserved roots (~/.claude/skills/); no local dir expected
const reservedSkillRoots = new Set(['learned', 'imported']);
const skillRefs = contentNoCodeBlocks.matchAll(/skills\/([a-z][-a-z0-9]*)\//g);
for (const match of skillRefs) {
const refName = match[1];
if (reservedSkillRoots.has(refName) || validSkills.has(refName)) continue;
console.warn(`WARN: ${file} - references skill directory skills/${refName}/ (not found locally)`);
warnCount++;
}
// Check agent name references in workflow diagrams (e.g., "planner -> tdd-guide")
const workflowLines = contentNoCodeBlocks.matchAll(/^([a-z][-a-z0-9]*(?:\s*->\s*[a-z][-a-z0-9]*)+)$/gm);
for (const match of workflowLines) {
const agents = match[1].split(/\s*->\s*/);
for (const agent of agents) {
if (!validAgents.has(agent)) {
console.error(`ERROR: ${file} - workflow references non-existent agent "${agent}"`);
hasErrors = true;
}
}
}
}
if (hasErrors) {
process.exit(1);
}
let msg = `Validated ${files.length} command files`;
if (warnCount > 0) {
msg += ` (${warnCount} warnings)`;
}
console.log(msg);
}
validateCommands();
+239
View File
@@ -0,0 +1,239 @@
#!/usr/bin/env node
/**
* Validate hooks.json schema and hook entry rules.
*/
const fs = require('fs');
const path = require('path');
const vm = require('vm');
const Ajv = require('ajv');
const HOOKS_FILE = path.join(__dirname, '../../hooks/hooks.json');
const HOOKS_SCHEMA_PATH = path.join(__dirname, '../../schemas/hooks.schema.json');
const VALID_EVENTS = [
'SessionStart',
'UserPromptSubmit',
'PreToolUse',
'PermissionRequest',
'PostToolUse',
'PostToolUseFailure',
'Notification',
'SubagentStart',
'Stop',
'SubagentStop',
'PreCompact',
'InstructionsLoaded',
'TeammateIdle',
'TaskCompleted',
'ConfigChange',
'WorktreeCreate',
'WorktreeRemove',
'SessionEnd',
];
const VALID_HOOK_TYPES = ['command', 'http', 'prompt', 'agent'];
const EVENTS_WITHOUT_MATCHER = new Set(['UserPromptSubmit', 'Notification', 'Stop', 'SubagentStop']);
function isNonEmptyString(value) {
return typeof value === 'string' && value.trim().length > 0;
}
function isNonEmptyStringArray(value) {
return Array.isArray(value) && value.length > 0 && value.every(item => isNonEmptyString(item));
}
/**
* Validate a single hook entry has required fields and valid inline JS
* @param {object} hook - Hook object with type and command fields
* @param {string} label - Label for error messages (e.g., "PreToolUse[0].hooks[1]")
* @returns {boolean} true if errors were found
*/
function validateHookEntry(hook, label) {
let hasErrors = false;
if (!hook.type || typeof hook.type !== 'string') {
console.error(`ERROR: ${label} missing or invalid 'type' field`);
hasErrors = true;
} else if (!VALID_HOOK_TYPES.includes(hook.type)) {
console.error(`ERROR: ${label} has unsupported hook type '${hook.type}'`);
hasErrors = true;
}
if ('timeout' in hook && (typeof hook.timeout !== 'number' || hook.timeout < 0)) {
console.error(`ERROR: ${label} 'timeout' must be a non-negative number`);
hasErrors = true;
}
if (hook.type === 'command') {
if ('async' in hook && typeof hook.async !== 'boolean') {
console.error(`ERROR: ${label} 'async' must be a boolean`);
hasErrors = true;
}
if (!isNonEmptyString(hook.command) && !isNonEmptyStringArray(hook.command)) {
console.error(`ERROR: ${label} missing or invalid 'command' field`);
hasErrors = true;
} else if (typeof hook.command === 'string') {
const nodeEMatch = hook.command.match(/^node -e "((?:[^"\\]|\\.)*)"(?:\s|$)/s);
if (nodeEMatch) {
try {
new vm.Script(nodeEMatch[1].replace(/\\\\/g, '\\').replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\t/g, '\t'));
} catch (syntaxErr) {
console.error(`ERROR: ${label} has invalid inline JS: ${syntaxErr.message}`);
hasErrors = true;
}
}
}
return hasErrors;
}
if ('async' in hook) {
console.error(`ERROR: ${label} 'async' is only supported for command hooks`);
hasErrors = true;
}
if (hook.type === 'http') {
if (!isNonEmptyString(hook.url)) {
console.error(`ERROR: ${label} missing or invalid 'url' field`);
hasErrors = true;
}
if ('headers' in hook && (typeof hook.headers !== 'object' || hook.headers === null || Array.isArray(hook.headers) || !Object.values(hook.headers).every(value => typeof value === 'string'))) {
console.error(`ERROR: ${label} 'headers' must be an object with string values`);
hasErrors = true;
}
if ('allowedEnvVars' in hook && (!Array.isArray(hook.allowedEnvVars) || !hook.allowedEnvVars.every(value => isNonEmptyString(value)))) {
console.error(`ERROR: ${label} 'allowedEnvVars' must be an array of strings`);
hasErrors = true;
}
return hasErrors;
}
if (!isNonEmptyString(hook.prompt)) {
console.error(`ERROR: ${label} missing or invalid 'prompt' field`);
hasErrors = true;
}
if ('model' in hook && !isNonEmptyString(hook.model)) {
console.error(`ERROR: ${label} 'model' must be a non-empty string`);
hasErrors = true;
}
return hasErrors;
}
function validateHooks() {
if (!fs.existsSync(HOOKS_FILE)) {
console.log('No hooks.json found, skipping validation');
process.exit(0);
}
let data;
try {
data = JSON.parse(fs.readFileSync(HOOKS_FILE, 'utf-8'));
} catch (e) {
console.error(`ERROR: Invalid JSON in hooks.json: ${e.message}`);
process.exit(1);
}
// Validate against JSON schema
if (fs.existsSync(HOOKS_SCHEMA_PATH)) {
const schema = JSON.parse(fs.readFileSync(HOOKS_SCHEMA_PATH, 'utf-8'));
const ajv = new Ajv({ allErrors: true });
const validate = ajv.compile(schema);
const valid = validate(data);
if (!valid) {
for (const err of validate.errors) {
console.error(`ERROR: hooks.json schema: ${err.instancePath || '/'} ${err.message}`);
}
process.exit(1);
}
}
// Support both object format { hooks: {...} } and array format
const hooks = data.hooks || data;
let hasErrors = false;
let totalMatchers = 0;
if (typeof hooks === 'object' && !Array.isArray(hooks)) {
// Object format: { EventType: [matchers] }
for (const [eventType, matchers] of Object.entries(hooks)) {
if (!VALID_EVENTS.includes(eventType)) {
console.error(`ERROR: Invalid event type: ${eventType}`);
hasErrors = true;
continue;
}
if (!Array.isArray(matchers)) {
console.error(`ERROR: ${eventType} must be an array`);
hasErrors = true;
continue;
}
for (let i = 0; i < matchers.length; i++) {
const matcher = matchers[i];
if (typeof matcher !== 'object' || matcher === null) {
console.error(`ERROR: ${eventType}[${i}] is not an object`);
hasErrors = true;
continue;
}
if (!('matcher' in matcher) && !EVENTS_WITHOUT_MATCHER.has(eventType)) {
console.error(`ERROR: ${eventType}[${i}] missing 'matcher' field`);
hasErrors = true;
} else if ('matcher' in matcher && typeof matcher.matcher !== 'string' && (typeof matcher.matcher !== 'object' || matcher.matcher === null)) {
console.error(`ERROR: ${eventType}[${i}] has invalid 'matcher' field`);
hasErrors = true;
}
if (!matcher.hooks || !Array.isArray(matcher.hooks)) {
console.error(`ERROR: ${eventType}[${i}] missing 'hooks' array`);
hasErrors = true;
} else {
// Validate each hook entry
for (let j = 0; j < matcher.hooks.length; j++) {
if (validateHookEntry(matcher.hooks[j], `${eventType}[${i}].hooks[${j}]`)) {
hasErrors = true;
}
}
}
totalMatchers++;
}
}
} else if (Array.isArray(hooks)) {
// Array format (legacy)
for (let i = 0; i < hooks.length; i++) {
const hook = hooks[i];
if (!('matcher' in hook)) {
console.error(`ERROR: Hook ${i} missing 'matcher' field`);
hasErrors = true;
} else if (typeof hook.matcher !== 'string' && (typeof hook.matcher !== 'object' || hook.matcher === null)) {
console.error(`ERROR: Hook ${i} has invalid 'matcher' field`);
hasErrors = true;
}
if (!hook.hooks || !Array.isArray(hook.hooks)) {
console.error(`ERROR: Hook ${i} missing 'hooks' array`);
hasErrors = true;
} else {
// Validate each hook entry
for (let j = 0; j < hook.hooks.length; j++) {
if (validateHookEntry(hook.hooks[j], `Hook ${i}.hooks[${j}]`)) {
hasErrors = true;
}
}
}
totalMatchers++;
}
} else {
console.error('ERROR: hooks.json must be an object or array');
process.exit(1);
}
if (hasErrors) {
process.exit(1);
}
console.log(`Validated ${totalMatchers} hook matchers`);
}
validateHooks();
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env node
/**
* Validate selective-install manifests and profile/module relationships.
* Module paths are curated repo paths only. Generated/imported skill roots
* (~/.claude/skills/learned, etc.) are never in manifests.
*/
const fs = require('fs');
const path = require('path');
const Ajv = require('ajv');
const REPO_ROOT = path.join(__dirname, '../..');
const MODULES_MANIFEST_PATH = path.join(REPO_ROOT, 'manifests/install-modules.json');
const PROFILES_MANIFEST_PATH = path.join(REPO_ROOT, 'manifests/install-profiles.json');
const COMPONENTS_MANIFEST_PATH = path.join(REPO_ROOT, 'manifests/install-components.json');
const MODULES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-modules.schema.json');
const PROFILES_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-profiles.schema.json');
const COMPONENTS_SCHEMA_PATH = path.join(REPO_ROOT, 'schemas/install-components.schema.json');
const CURATED_SKILLS_DIR = path.join(REPO_ROOT, 'skills');
// Empty by default; add only curated skills that are intentionally unshipped.
const INTENTIONALLY_UNSHIPPED_SKILL_IDS = new Set([
'skill-comply', // meta/measurement dev-skill; ships committed .pyc artifacts and a nested .gitignore, revisit after packaging cleanup
]);
const COMPONENT_FAMILY_PREFIXES = {
baseline: 'baseline:',
language: 'lang:',
framework: 'framework:',
capability: 'capability:',
locale: 'locale:',
};
function readJson(filePath, label) {
try {
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
throw new Error(`Invalid JSON in ${label}: ${error.message}`);
}
}
function normalizeRelativePath(relativePath) {
return String(relativePath).replace(/\\/g, '/').replace(/\/+$/, '');
}
function isCuratedSkillReferenced(claimedPaths, skillId) {
const skillRoot = `skills/${skillId}`;
for (const claimedPath of claimedPaths.keys()) {
if (claimedPath === skillRoot || claimedPath.startsWith(`${skillRoot}/`)) {
return true;
}
}
return false;
}
function validateSchema(ajv, schemaPath, data, label) {
const schema = readJson(schemaPath, `${label} schema`);
const validate = ajv.compile(schema);
const valid = validate(data);
if (!valid) {
for (const error of validate.errors) {
console.error(
`ERROR: ${label} schema: ${error.instancePath || '/'} ${error.message}`
);
}
return true;
}
return false;
}
function validateInstallManifests() {
if (!fs.existsSync(MODULES_MANIFEST_PATH) || !fs.existsSync(PROFILES_MANIFEST_PATH)) {
console.log('Install manifests not found, skipping validation');
process.exit(0);
}
let hasErrors = false;
let modulesData;
let profilesData;
let componentsData = { version: null, components: [] };
try {
modulesData = readJson(MODULES_MANIFEST_PATH, 'install-modules.json');
profilesData = readJson(PROFILES_MANIFEST_PATH, 'install-profiles.json');
if (fs.existsSync(COMPONENTS_MANIFEST_PATH)) {
componentsData = readJson(COMPONENTS_MANIFEST_PATH, 'install-components.json');
}
} catch (error) {
console.error(`ERROR: ${error.message}`);
process.exit(1);
}
const ajv = new Ajv({ allErrors: true });
hasErrors = validateSchema(ajv, MODULES_SCHEMA_PATH, modulesData, 'install-modules.json') || hasErrors;
hasErrors = validateSchema(ajv, PROFILES_SCHEMA_PATH, profilesData, 'install-profiles.json') || hasErrors;
if (fs.existsSync(COMPONENTS_MANIFEST_PATH)) {
hasErrors = validateSchema(ajv, COMPONENTS_SCHEMA_PATH, componentsData, 'install-components.json') || hasErrors;
}
if (hasErrors) {
process.exit(1);
}
const modules = Array.isArray(modulesData.modules) ? modulesData.modules : [];
const moduleIds = new Set();
const claimedPaths = new Map();
for (const module of modules) {
if (moduleIds.has(module.id)) {
console.error(`ERROR: Duplicate install module id: ${module.id}`);
hasErrors = true;
}
moduleIds.add(module.id);
for (const dependency of module.dependencies) {
if (!moduleIds.has(dependency) && !modules.some(candidate => candidate.id === dependency)) {
console.error(`ERROR: Module ${module.id} depends on unknown module ${dependency}`);
hasErrors = true;
}
if (dependency === module.id) {
console.error(`ERROR: Module ${module.id} cannot depend on itself`);
hasErrors = true;
}
}
for (const relativePath of module.paths) {
const normalizedPath = normalizeRelativePath(relativePath);
const absolutePath = path.join(REPO_ROOT, normalizedPath);
// All module paths must exist; no optional/generated paths in manifests
if (!fs.existsSync(absolutePath)) {
console.error(
`ERROR: Module ${module.id} references missing path: ${normalizedPath}`
);
hasErrors = true;
}
if (claimedPaths.has(normalizedPath)) {
console.error(
`ERROR: Install path ${normalizedPath} is claimed by both ${claimedPaths.get(normalizedPath)} and ${module.id}`
);
hasErrors = true;
} else {
claimedPaths.set(normalizedPath, module.id);
}
}
}
if (fs.existsSync(CURATED_SKILLS_DIR)) {
const entries = fs.readdirSync(CURATED_SKILLS_DIR, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory() || entry.name.startsWith('.')) {
continue;
}
const skillMdPath = path.join(CURATED_SKILLS_DIR, entry.name, 'SKILL.md');
if (!fs.existsSync(skillMdPath)) {
continue;
}
if (
!INTENTIONALLY_UNSHIPPED_SKILL_IDS.has(entry.name)
&& !isCuratedSkillReferenced(claimedPaths, entry.name)
) {
console.error(
`ERROR: curated skill skills/${entry.name} is not referenced by any install module`
);
hasErrors = true;
}
}
}
const profiles = profilesData.profiles || {};
const components = Array.isArray(componentsData.components) ? componentsData.components : [];
const expectedProfileIds = ['core', 'developer', 'security', 'research', 'full'];
for (const profileId of expectedProfileIds) {
if (!profiles[profileId]) {
console.error(`ERROR: Missing required install profile: ${profileId}`);
hasErrors = true;
}
}
for (const [profileId, profile] of Object.entries(profiles)) {
const seenModules = new Set();
for (const moduleId of profile.modules) {
if (!moduleIds.has(moduleId)) {
console.error(
`ERROR: Profile ${profileId} references unknown module ${moduleId}`
);
hasErrors = true;
}
if (seenModules.has(moduleId)) {
console.error(
`ERROR: Profile ${profileId} contains duplicate module ${moduleId}`
);
hasErrors = true;
}
seenModules.add(moduleId);
}
}
if (profiles.full) {
const fullModules = new Set(profiles.full.modules);
for (const module of modules) {
if (module.kind === 'docs' && module.defaultInstall === false) {
continue;
}
if (!fullModules.has(module.id)) {
console.error(`ERROR: full profile is missing module ${module.id}`);
hasErrors = true;
}
}
}
const componentIds = new Set();
for (const component of components) {
if (componentIds.has(component.id)) {
console.error(`ERROR: Duplicate install component id: ${component.id}`);
hasErrors = true;
}
componentIds.add(component.id);
const expectedPrefix = COMPONENT_FAMILY_PREFIXES[component.family];
if (expectedPrefix && !component.id.startsWith(expectedPrefix)) {
console.error(
`ERROR: Component ${component.id} does not match expected ${component.family} prefix ${expectedPrefix}`
);
hasErrors = true;
}
const seenModules = new Set();
for (const moduleId of component.modules) {
if (!moduleIds.has(moduleId)) {
console.error(`ERROR: Component ${component.id} references unknown module ${moduleId}`);
hasErrors = true;
}
if (seenModules.has(moduleId)) {
console.error(`ERROR: Component ${component.id} contains duplicate module ${moduleId}`);
hasErrors = true;
}
seenModules.add(moduleId);
}
}
if (hasErrors) {
process.exit(1);
}
console.log(
`Validated ${modules.length} install modules, ${components.length} install components, and ${Object.keys(profiles).length} profiles`
);
}
validateInstallManifests();
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* Prevent shipping user-specific absolute paths in public docs/skills/commands.
*
* Catches generic `/Users/<name>` (macOS) and `C:\Users\<name>` (Windows) paths,
* while allowing obvious placeholder usernames used in templates/examples.
* Forensic incident reports under `docs/fixes/` are exempt because they may
* legitimately document a reporter's local machine path.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '../..');
const TARGETS = [
'README.md',
'skills',
'commands',
'agents',
'docs',
'.opencode/commands',
];
const EXEMPT_PREFIXES = [
'docs/fixes/',
];
const PLACEHOLDER_USERNAMES = new Set([
'example',
'me',
'user',
'username',
'you',
'yourname',
'yourusername',
'your-username',
]);
const POSIX_USER_RE = /\/Users\/([a-zA-Z][a-zA-Z0-9._-]*)/g;
const WIN_USER_RE = /C:\\Users\\([a-zA-Z][a-zA-Z0-9._-]*)/gi;
function repoRelative(file) {
return path.relative(ROOT, file).split(path.sep).join('/');
}
function isExempt(file) {
const rel = repoRelative(file);
return EXEMPT_PREFIXES.some(prefix => rel.startsWith(prefix));
}
function findLeaks(content) {
const leaks = [];
for (const pattern of [POSIX_USER_RE, WIN_USER_RE]) {
pattern.lastIndex = 0;
let match;
while ((match = pattern.exec(content)) !== null) {
if (!PLACEHOLDER_USERNAMES.has(match[1].toLowerCase())) {
leaks.push(match[0]);
}
}
}
return leaks;
}
function collectFiles(targetPath, out) {
if (!fs.existsSync(targetPath)) return;
const stat = fs.statSync(targetPath);
if (stat.isFile()) {
out.push(targetPath);
return;
}
for (const entry of fs.readdirSync(targetPath)) {
if (entry === 'node_modules' || entry === '.git') continue;
collectFiles(path.join(targetPath, entry), out);
}
}
const files = [];
for (const target of TARGETS) {
collectFiles(path.join(ROOT, target), files);
}
let failures = 0;
for (const file of files) {
if (!/\.(md|json|js|ts|sh|toml|yml|yaml)$/i.test(file)) continue;
if (isExempt(file)) continue;
const content = fs.readFileSync(file, 'utf8');
const leaks = findLeaks(content);
for (const leak of leaks) {
console.error(`ERROR: personal path "${leak}" detected in ${repoRelative(file)}`);
failures += 1;
}
}
if (failures > 0) {
process.exit(1);
}
console.log('Validated: no personal absolute paths in shipped docs/skills/commands');
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env node
/**
* Validate rule markdown files
*/
const fs = require('fs');
const path = require('path');
const RULES_DIR = path.join(__dirname, '../../rules');
/**
* Recursively collect markdown rule files.
* Uses explicit traversal for portability across Node versions.
* @param {string} dir - Directory to scan
* @returns {string[]} Relative file paths from RULES_DIR
*/
function collectRuleFiles(dir) {
const files = [];
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch {
return files;
}
for (const entry of entries) {
const absolute = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...collectRuleFiles(absolute));
continue;
}
if (entry.name.endsWith('.md')) {
files.push(path.relative(RULES_DIR, absolute));
}
// Non-markdown files are ignored.
}
return files;
}
function validateRules() {
if (!fs.existsSync(RULES_DIR)) {
console.log('No rules directory found, skipping validation');
process.exit(0);
}
const files = collectRuleFiles(RULES_DIR);
let hasErrors = false;
let validatedCount = 0;
for (const file of files) {
const filePath = path.join(RULES_DIR, file);
try {
const stat = fs.statSync(filePath);
if (!stat.isFile()) continue;
const content = fs.readFileSync(filePath, 'utf-8');
if (content.trim().length === 0) {
console.error(`ERROR: ${file} - Empty rule file`);
hasErrors = true;
continue;
}
validatedCount++;
} catch (err) {
console.error(`ERROR: ${file} - ${err.message}`);
hasErrors = true;
}
}
if (hasErrors) {
process.exit(1);
}
console.log(`Validated ${validatedCount} rule files`);
}
validateRules();
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env node
/**
* Validate curated skill directories (skills/ in repo).
*
* Checks:
* 1. Each sub-directory of skills/ contains a SKILL.md file.
* 2. SKILL.md is non-empty.
* 3. SKILL.md frontmatter (if present) declares a `name:` field.
* 4. SKILL.md frontmatter `description:` uses an inline scalar — not a
* literal block scalar (`|` / `|-` / `|+`), which preserves internal
* newlines and breaks flat-table renderers keyed off `description`.
*
* Frontmatter findings default to WARN so CI does not break while
* pre-existing data defects are being cleaned up out of band (see #1663).
* Pass `--strict` or set `CI_STRICT_SKILLS=1` to promote frontmatter
* findings to errors (exit 1).
*
* Structural findings (missing/empty SKILL.md) are always errors.
*
* Scope: curated only. Learned/imported/evolved roots are out of scope.
* If skills/ does not exist, exit 0 (no curated skills to validate).
*/
const fs = require('fs');
const path = require('path');
const SKILLS_DIR = path.join(__dirname, '../../skills');
const STRICT = process.argv.includes('--strict') || process.env.CI_STRICT_SKILLS === '1';
/**
* Parse the leading YAML frontmatter of a markdown document.
*
* Returns `{ present, lines }` so callers can inspect raw lines
* (needed to detect block-scalar `description:` values).
*
* Tolerant of UTF-8 BOM and CRLF line endings, matching the other
* validators in this directory.
*
* @param {string} content
* @returns {{present: boolean, lines: string[]}}
*/
function extractFrontmatter(content) {
// Strip BOM if present (UTF-8 BOM: U+FEFF).
const clean = content.replace(/^\uFEFF/, '');
const match = clean.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
if (!match) return { present: false, lines: [] };
return {
present: true,
lines: match[1].split(/\r?\n/)
};
}
/**
* Extract top-level keys (with trimmed values) and flag block-scalar
* `description:` values.
*
* Lines that continue a block scalar (`|` or `>`) are skipped — we only
* care about the top-level key set and the raw indicator on the
* `description:` line. Block-scalar indicators accept YAML chomp and
* indent modifiers and trailing comments, e.g. `|`, `|-`, `|+`, `|2`,
* `|-2`, `>- # note`.
*
* @param {string[]} lines
* @returns {{values: Record<string,string>, descriptionIndicator: string|null}}
*/
function inspectFrontmatter(lines) {
const values = Object.create(null);
let descriptionIndicator = null;
let inBlockScalar = false;
let blockScalarIndent = -1;
for (const rawLine of lines) {
if (inBlockScalar) {
// Stay inside the block until a line with indent <= the opener's
// indent (or an empty continuation).
const leadingSpaces = rawLine.match(/^(\s*)/)[1].length;
if (rawLine.trim() === '' || leadingSpaces > blockScalarIndent) {
continue;
}
inBlockScalar = false;
blockScalarIndent = -1;
}
const match = rawLine.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
if (!match) continue;
const key = match[1];
const rawValue = match[2];
// Strip unquoted comments for value/indicator inspection. Handles both
// trailing comments (`foo: bar # note`) and comment-only values
// (`foo: # todo`) so the latter is treated as empty.
const valueNoComment = rawValue
.replace(/^\s*#.*$/, '')
.replace(/\s+#.*$/, '')
.trim();
values[key] = valueNoComment;
// Detect literal / folded block-scalar indicators. Accept chomp
// modifiers (`-` / `+`) and optional indent-indicator digits in
// either order, per YAML 1.2.
if (/^[|>](?:[+-]?\d+|\d+[+-]?|[+-])?$/.test(valueNoComment)) {
if (key === 'description') {
descriptionIndicator = valueNoComment;
}
inBlockScalar = true;
blockScalarIndent = rawLine.match(/^(\s*)/)[1].length;
}
}
return { values, descriptionIndicator };
}
/**
* Validate a single skill directory.
*
* Returns `{ fatal }` where `fatal` indicates a structural error that
* should be surfaced via `console.error` and abort CI (missing/empty
* SKILL.md). Frontmatter findings are routed through
* `reportFrontmatterFinding`, which owns the WARN/ERROR decision based
* on strict mode.
*
* @param {string} dir
* @param {string} skillsDir
* @param {(msg: string) => void} reportFrontmatterFinding
* @returns {{fatal: boolean}}
*/
function validateSkillDir(dir, skillsDir, reportFrontmatterFinding) {
const skillMd = path.join(skillsDir, dir, 'SKILL.md');
if (!fs.existsSync(skillMd)) {
console.error(`ERROR: ${dir}/ - Missing SKILL.md`);
return { fatal: true };
}
let content;
try {
content = fs.readFileSync(skillMd, 'utf-8');
} catch (err) {
console.error(`ERROR: ${dir}/SKILL.md - ${err.message}`);
return { fatal: true };
}
if (content.trim().length === 0) {
console.error(`ERROR: ${dir}/SKILL.md - Empty file`);
return { fatal: true };
}
const fm = extractFrontmatter(content);
if (fm.present) {
const { values, descriptionIndicator } = inspectFrontmatter(fm.lines);
if (!Object.prototype.hasOwnProperty.call(values, 'name')) {
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter missing required field: name`);
} else if (values.name === '') {
reportFrontmatterFinding(`${dir}/SKILL.md - frontmatter 'name' is empty`);
}
if (descriptionIndicator && descriptionIndicator.startsWith('|')) {
reportFrontmatterFinding(
`${dir}/SKILL.md - frontmatter description uses literal block scalar ` + `'${descriptionIndicator}' which preserves internal newlines; ` + `use an inline string or folded '>' scalar instead`
);
}
}
return { fatal: false };
}
function validateSkills() {
if (!fs.existsSync(SKILLS_DIR)) {
console.log('No curated skills directory (skills/), skipping');
process.exit(0);
}
const entries = fs.readdirSync(SKILLS_DIR, { withFileTypes: true });
const dirs = entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => e.name);
let hasErrors = false;
let warnCount = 0;
let validCount = 0;
const reportFrontmatterFinding = msg => {
if (STRICT) {
console.error(`ERROR: ${msg}`);
hasErrors = true;
} else {
console.warn(`WARN: ${msg}`);
warnCount++;
}
};
for (const dir of dirs) {
const { fatal } = validateSkillDir(dir, SKILLS_DIR, reportFrontmatterFinding);
if (fatal) {
hasErrors = true;
continue;
}
validCount++;
}
if (hasErrors) {
process.exit(1);
}
let msg = `Validated ${validCount} skill directories`;
if (warnCount > 0) {
msg += ` (${warnCount} warning${warnCount === 1 ? '' : 's'})`;
}
console.log(msg);
}
validateSkills();
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env node
/**
* Reject unsafe GitHub Actions patterns that execute or checkout untrusted PR code
* from privileged events such as workflow_run or pull_request_target.
*/
const fs = require('fs');
const path = require('path');
const DEFAULT_WORKFLOWS_DIR = path.join(__dirname, '../../.github/workflows');
const RULES = [
{
event: 'workflow_run',
eventPattern: /\bworkflow_run\s*:/m,
description: 'workflow_run must not checkout an untrusted workflow_run head ref/repository',
expressionPattern: /\$\{\{\s*github\.event\.workflow_run\.(?:head_branch|head_sha|head_repository(?:\.[A-Za-z0-9_.]+)?)\s*\}\}|\$\{\{\s*github\.event\.workflow_run\.pull_requests\[\d+\]\.head\.(?:ref|sha|repo\.full_name)\s*\}\}/g,
},
{
event: 'pull_request_target',
eventPattern: /\bpull_request_target\s*:/m,
description: 'pull_request_target must not checkout an untrusted pull_request head ref/repository',
expressionPattern: /\$\{\{\s*github\.event\.pull_request\.head\.(?:ref|sha|repo\.full_name)\s*\}\}/g,
// Even without the standard `github.event.pull_request.head.*` expression,
// a checkout under `pull_request_target` that fetches a `refs/pull/<N>/{head,merge}`
// ref pulls attacker-controlled code into a workflow with write-scoped
// tokens. GitHub's security guidance treats both forms equivalently;
// we match the ref value directly so any interpolation that resolves
// to such a ref (`refs/pull/${{ github.event.pull_request.number }}/merge`,
// a hardcoded `refs/pull/123/head`, a `${{ env.X }}` that the maintainer
// assumes is safe, etc.) trips the same rule.
refPattern: /^\s*ref:\s*['"]?[^'"\n]*refs\/(?:remotes\/)?pull\/[^'"\n\s]+/m,
},
];
const WRITE_PERMISSION_PATTERN = /^\s*(?:contents|issues|pull-requests|actions|checks|deployments|discussions|id-token|packages|pages|repository-projects|security-events|statuses):\s*write\b/m;
// `permissions: write-all` is GitHub Actions' shorthand for granting every
// scope write access. The named-scope pattern above misses it because there
// is no scope name on the left of the colon — just the literal `write-all`
// value at the permissions key. Treat both as equivalent for the purposes
// of the persist-credentials gate below. The optional single/double quotes
// match valid YAML `permissions: "write-all"` / `'write-all'` forms.
const WRITE_ALL_PATTERN = /^\s*permissions:\s*["']?write-all["']?\s*$/m;
const NPM_AUDIT_PATTERN = /\bnpm\s+audit\b(?!\s+signatures\b)/;
const NPM_AUDIT_SIGNATURES_PATTERN = /\bnpm\s+audit\s+signatures\b/;
const ACTIONS_CACHE_PATTERN = /uses:\s*['"]?actions\/cache@/m;
const ID_TOKEN_WRITE_PATTERN = /^\s*id-token:\s*write\b/m;
const TOP_LEVEL_JOBS_PATTERN = /^jobs:\s*$/m;
const UNSAFE_INSTALL_PATTERNS = [
{
pattern: /\bnpm\s+ci\b(?![^\n]*--ignore-scripts)/g,
description: 'npm ci must include --ignore-scripts',
},
{
pattern: /\bpnpm\s+install\b(?![^\n]*--ignore-scripts)/g,
description: 'pnpm install must include --ignore-scripts',
},
{
pattern: /\byarn\s+install\b(?![^\n]*--mode=skip-build)/g,
description: 'yarn install must use --mode=skip-build',
},
{
pattern: /\bbun\s+install\b(?![^\n]*--ignore-scripts)/g,
description: 'bun install must include --ignore-scripts',
},
];
function getWorkflowFiles(workflowsDir) {
if (!fs.existsSync(workflowsDir)) {
return [];
}
return fs.readdirSync(workflowsDir)
.filter(file => /\.(?:yml|yaml)$/i.test(file))
.map(file => path.join(workflowsDir, file))
.sort();
}
function getLineNumber(source, index) {
return source.slice(0, index).split(/\r?\n/).length;
}
function extractCheckoutSteps(source) {
const blocks = [];
const lines = source.split(/\r?\n/);
let current = null;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const stepStart = line.match(/^(\s*)-\s+/);
if (stepStart) {
if (current) {
blocks.push(current);
}
current = {
indent: stepStart[1].length,
startLine: i + 1,
lines: [line],
};
continue;
}
if (current) {
current.lines.push(line);
}
}
if (current) {
blocks.push(current);
}
return blocks
.map(block => ({
startLine: block.startLine,
text: block.lines.join('\n'),
}))
.filter(block => /uses:\s*['"]?actions\/checkout@/m.test(block.text));
}
function findViolations(filePath, source) {
const violations = [];
const checkoutSteps = extractCheckoutSteps(source);
const jobsIndex = source.search(TOP_LEVEL_JOBS_PATTERN);
const workflowHeader = jobsIndex >= 0 ? source.slice(0, jobsIndex) : source;
for (const rule of RULES) {
if (!rule.eventPattern.test(source)) {
continue;
}
for (const step of checkoutSteps) {
// Track whether the expression-based rule already produced a
// violation for this step. If it did, skip the refPattern fallback
// — a `refs/pull/${{ github.event.pull_request.head.sha }}/merge`
// value matches both patterns under the same rule, and the second
// push would print a duplicate ERROR line that says exactly the
// same thing with a different `expression:` echo.
let stepFlagged = false;
for (const match of step.text.matchAll(rule.expressionPattern)) {
violations.push({
filePath,
event: rule.event,
description: rule.description,
expression: match[0],
line: step.startLine + getLineNumber(step.text, match.index) - 1,
});
stepFlagged = true;
}
if (rule.refPattern && !stepFlagged) {
const refMatch = step.text.match(rule.refPattern);
if (refMatch) {
violations.push({
filePath,
event: rule.event,
description: rule.description,
expression: refMatch[0].trim(),
line: step.startLine + getLineNumber(step.text, refMatch.index) - 1,
});
}
}
}
}
if (WRITE_PERMISSION_PATTERN.test(source) || WRITE_ALL_PATTERN.test(source)) {
for (const step of checkoutSteps) {
if (!/persist-credentials:\s*['"]?false['"]?\b/m.test(step.text)) {
violations.push({
filePath,
event: 'write-permission checkout',
description: 'workflows with write permissions must disable checkout credential persistence',
expression: 'actions/checkout without persist-credentials: false',
line: step.startLine,
});
}
}
}
if (ID_TOKEN_WRITE_PATTERN.test(workflowHeader)) {
violations.push({
filePath,
event: 'workflow-scoped id-token',
description: 'id-token: write must be scoped to a publish-only job, not the entire workflow',
expression: 'top-level id-token: write',
line: getLineNumber(source, source.search(ID_TOKEN_WRITE_PATTERN)),
});
}
for (const installRule of UNSAFE_INSTALL_PATTERNS) {
for (const match of source.matchAll(installRule.pattern)) {
violations.push({
filePath,
event: 'dependency install scripts',
description: `workflow dependency installs must not run lifecycle scripts: ${installRule.description}`,
expression: match[0],
line: getLineNumber(source, match.index),
});
}
}
if (ID_TOKEN_WRITE_PATTERN.test(source) && ACTIONS_CACHE_PATTERN.test(source)) {
violations.push({
filePath,
event: 'id-token cache',
description: 'workflows with id-token: write must not restore or save shared dependency caches',
expression: 'id-token: write + actions/cache',
line: getLineNumber(source, source.search(ID_TOKEN_WRITE_PATTERN)),
});
}
if (ACTIONS_CACHE_PATTERN.test(source)) {
violations.push({
filePath,
event: 'dependency cache',
description: 'GitHub Actions dependency caches are disabled during active supply-chain hardening',
expression: 'actions/cache',
line: getLineNumber(source, source.search(ACTIONS_CACHE_PATTERN)),
});
}
if (/\bpull_request_target\s*:/m.test(source) && ACTIONS_CACHE_PATTERN.test(source)) {
violations.push({
filePath,
event: 'pull_request_target cache',
description: 'pull_request_target workflows must not restore or save shared dependency caches',
expression: 'pull_request_target + actions/cache',
line: getLineNumber(source, source.search(/\bpull_request_target\s*:/m)),
});
}
if (NPM_AUDIT_PATTERN.test(source) && !NPM_AUDIT_SIGNATURES_PATTERN.test(source)) {
violations.push({
filePath,
event: 'npm audit signatures',
description: 'workflows that run npm audit must also verify registry signatures',
expression: 'npm audit without npm audit signatures',
line: getLineNumber(source, source.search(NPM_AUDIT_PATTERN)),
});
}
return violations;
}
function validateWorkflowSecurity(workflowsDir = DEFAULT_WORKFLOWS_DIR) {
const files = getWorkflowFiles(workflowsDir);
const violations = [];
for (const filePath of files) {
const source = fs.readFileSync(filePath, 'utf8');
violations.push(...findViolations(filePath, source));
}
if (violations.length > 0) {
for (const violation of violations) {
console.error(
`ERROR: ${path.basename(violation.filePath)}:${violation.line} - ${violation.description}`,
);
console.error(` Unsafe expression: ${violation.expression}`);
}
return 1;
}
console.log(`Validated workflow security for ${files.length} workflow files`);
return 0;
}
if (require.main === module) {
process.exit(validateWorkflowSecurity(process.env.ECC_WORKFLOWS_DIR || DEFAULT_WORKFLOWS_DIR));
}
module.exports = {
DEFAULT_WORKFLOWS_DIR,
extractCheckoutSteps,
findViolations,
validateWorkflowSecurity,
};