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
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:
@@ -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,
|
||||
};
|
||||
@@ -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+E0000–U+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+2061–U+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.');
|
||||
@@ -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,
|
||||
};
|
||||
Executable
+853
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
Executable
+107
@@ -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');
|
||||
@@ -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();
|
||||
@@ -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();
|
||||
@@ -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,
|
||||
};
|
||||
Reference in New Issue
Block a user