agent-skill-doctor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/NOTICE.md +9 -0
- package/README.en.md +275 -0
- package/README.md +275 -0
- package/bin/agent-skill-doctor-phase2.js +250 -0
- package/bin/agent-skill-doctor-phase3.js +215 -0
- package/bin/agent-skill-doctor-risk.js +147 -0
- package/bin/agent-skill-doctor.js +1834 -0
- package/examples/basic-usage.js +113 -0
- package/examples/readme-demo-skills/dangerous-deploy/SKILL.md +14 -0
- package/examples/readme-demo-skills/markdown-reporter-a/SKILL.md +8 -0
- package/examples/readme-demo-skills/markdown-reporter-b/SKILL.md +8 -0
- package/examples/readme-demo-skills/npm-installer/SKILL.md +10 -0
- package/examples/readme-demo-skills/pnpm-installer/SKILL.md +10 -0
- package/package.json +58 -0
- package/rules/default/credential-risk.json +19 -0
- package/rules/default/destructive-risk.json +17 -0
- package/rules/default/shell-network-risk.json +31 -0
- package/src/doctor/conflict.js +139 -0
- package/src/doctor/i18n.js +572 -0
- package/src/doctor/index.js +50 -0
- package/src/doctor/phase2.js +261 -0
- package/src/doctor/risk-lite.js +124 -0
- package/src/doctor/rules.js +57 -0
- package/src/doctor/zombie.js +178 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
|
|
6
|
+
function sha256(input) {
|
|
7
|
+
return crypto.createHash('sha256').update(String(input)).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function normalizePath(input) {
|
|
11
|
+
return path.resolve(String(input || '')).replaceAll('\\\\', '/');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeSourceUrl(url) {
|
|
15
|
+
return String(url || '')
|
|
16
|
+
.trim()
|
|
17
|
+
.replace(/^https?:\/\//i, '')
|
|
18
|
+
.replace(/\.git$/i, '')
|
|
19
|
+
.replace(/\/$/, '')
|
|
20
|
+
.toLowerCase();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function buildSkillIdentityKey(skill) {
|
|
24
|
+
if (skill.upstreamSkillId || skill.upstream_skill_id) {
|
|
25
|
+
return `upstream:${skill.upstreamSkillId || skill.upstream_skill_id}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const sourceUrl = skill.source?.url || skill.source_url;
|
|
29
|
+
const sourceSubdir = skill.source?.subdir || skill.source_subdir || '';
|
|
30
|
+
const slug = skill.slug;
|
|
31
|
+
if (sourceUrl && slug) {
|
|
32
|
+
return `source:${normalizeSourceUrl(sourceUrl)}:${sourceSubdir}:${slug}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const root = skill.location?.root || skill.root || skill.root_path;
|
|
36
|
+
if (root && slug) {
|
|
37
|
+
return `path:${normalizePath(root)}:${slug}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const contentHash = skill.hashes?.contentSha256 || skill.content_hash || skill.contentHash || 'unknown-hash';
|
|
41
|
+
const localPath = skill.location?.path || skill.local_path || skill.path || 'unknown-path';
|
|
42
|
+
return `hash:${contentHash}:${normalizePath(localPath)}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function buildParticipantIdentityKey(skills) {
|
|
46
|
+
return sha256(skills.map(buildSkillIdentityKey).sort().join('\n'));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sourceKey(skill) {
|
|
50
|
+
const sourceUrl = skill.source?.url || skill.source_url;
|
|
51
|
+
if (!sourceUrl) return null;
|
|
52
|
+
const subdir = skill.source?.subdir || skill.source_subdir || '';
|
|
53
|
+
return `${normalizeSourceUrl(sourceUrl)}:${subdir}:${skill.slug || ''}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function contentHash(skill) {
|
|
57
|
+
return skill.hashes?.contentSha256 || skill.content_hash || skill.contentHash || null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sourceRef(skill) {
|
|
61
|
+
return skill.sourceCommit || skill.source_commit || skill.source?.commit || skill.sourceRef || skill.source_ref || skill.source?.ref || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function modifiedTime(skill) {
|
|
65
|
+
return Date.parse(skill.modifiedAt || skill.modified_at || skill.updatedAt || 0) || 0;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sourceTrustScore(skill) {
|
|
69
|
+
const type = skill.source?.type || skill.source_type;
|
|
70
|
+
const url = normalizeSourceUrl(skill.source?.url || skill.source_url);
|
|
71
|
+
if (type === 'marketplace') return 1.0;
|
|
72
|
+
if (/^github\.com\/(anthropics|openai|github|google-gemini)\//.test(url)) return 0.9;
|
|
73
|
+
if (/^github\.com\/[^/]+\//.test(url)) return 0.6;
|
|
74
|
+
const rootType = skill.location?.rootType || skill.root_type;
|
|
75
|
+
if (rootType === 'project_local') return 0.5;
|
|
76
|
+
if (rootType === 'agent_global') return 0.4;
|
|
77
|
+
return 0.2;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function locationScore(skill) {
|
|
81
|
+
const rootType = skill.location?.rootType || skill.root_type;
|
|
82
|
+
if (rootType === 'central_library') return 1;
|
|
83
|
+
if (rootType === 'project_local') return 0.7;
|
|
84
|
+
if (rootType === 'agent_global') return 0.5;
|
|
85
|
+
return 0.2;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function recencyScore(skill) {
|
|
89
|
+
const time = modifiedTime(skill);
|
|
90
|
+
if (!time) return 0;
|
|
91
|
+
const ageDays = Math.max(0, (Date.now() - time) / 86_400_000);
|
|
92
|
+
return Math.max(0, 1 - ageDays / 365);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function usageScore(skill) {
|
|
96
|
+
const usage = skill.usage || {};
|
|
97
|
+
const agentCount = usage.installedInAgents?.length || 0;
|
|
98
|
+
const projectCount = usage.installedInProjects?.length || 0;
|
|
99
|
+
return Math.min(1, (usage.presetCount || 0) * 0.25 + agentCount * 0.25 + projectCount * 0.25);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function descriptionScore(skill) {
|
|
103
|
+
const desc = String(skill.description || '').trim();
|
|
104
|
+
if (!desc) return 0;
|
|
105
|
+
return Math.min(1, desc.length / 120);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function pinnedScore(skill) {
|
|
109
|
+
const usage = skill.usage || {};
|
|
110
|
+
const tags = skill.tags || [];
|
|
111
|
+
return usage.manuallyPinned || tags.includes('keep') || tags.includes('core') ? 1 : 0;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function canonicalScore(skill) {
|
|
115
|
+
if (pinnedScore(skill) === 1) return Number.MAX_SAFE_INTEGER;
|
|
116
|
+
return (
|
|
117
|
+
sourceTrustScore(skill) * 0.25 +
|
|
118
|
+
recencyScore(skill) * 0.20 +
|
|
119
|
+
usageScore(skill) * 0.20 +
|
|
120
|
+
descriptionScore(skill) * 0.15 +
|
|
121
|
+
locationScore(skill) * 0.10 +
|
|
122
|
+
pinnedScore(skill) * 0.10
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function chooseCanonical(skills) {
|
|
127
|
+
return [...skills].sort((a, b) => {
|
|
128
|
+
const scoreA = canonicalScore(a);
|
|
129
|
+
const scoreB = canonicalScore(b);
|
|
130
|
+
return scoreB - scoreA || String(a.slug || a.name).localeCompare(String(b.slug || b.name));
|
|
131
|
+
})[0];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function groupBy(items, keyFn) {
|
|
135
|
+
const groups = new Map();
|
|
136
|
+
for (const item of items) {
|
|
137
|
+
const key = keyFn(item);
|
|
138
|
+
if (!key) continue;
|
|
139
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
140
|
+
groups.get(key).push(item);
|
|
141
|
+
}
|
|
142
|
+
return [...groups.entries()].filter(([, members]) => members.length > 1);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function duplicateGroupId(strategy, skills) {
|
|
146
|
+
return sha256(`${strategy}:${buildParticipantIdentityKey(skills)}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function makeDuplicateGroup(strategy, confidence, skills, reason) {
|
|
150
|
+
const sorted = [...skills].sort((a, b) => buildSkillIdentityKey(a).localeCompare(buildSkillIdentityKey(b)));
|
|
151
|
+
const canonical = chooseCanonical(sorted);
|
|
152
|
+
return {
|
|
153
|
+
id: duplicateGroupId(strategy, sorted),
|
|
154
|
+
strategy,
|
|
155
|
+
confidence,
|
|
156
|
+
reason,
|
|
157
|
+
canonicalSkillId: canonical.id,
|
|
158
|
+
members: sorted.map(skill => ({
|
|
159
|
+
skillId: skill.id,
|
|
160
|
+
role: skill.id === canonical.id ? 'canonical' : 'candidate',
|
|
161
|
+
confidence,
|
|
162
|
+
})),
|
|
163
|
+
skills: sorted,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function detectDuplicateGroups(skills) {
|
|
168
|
+
const seen = new Set();
|
|
169
|
+
const groups = [];
|
|
170
|
+
|
|
171
|
+
const add = (strategy, confidence, grouped, reason) => {
|
|
172
|
+
for (const [, members] of grouped) {
|
|
173
|
+
const group = makeDuplicateGroup(strategy, confidence, members, reason);
|
|
174
|
+
if (seen.has(group.id)) continue;
|
|
175
|
+
seen.add(group.id);
|
|
176
|
+
groups.push(group);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
add('exact_duplicate', 1.0, groupBy(skills, contentHash), 'identical content hash');
|
|
181
|
+
add('same_source_duplicate', 0.95, groupBy(skills, sourceKey), 'same source URL, subdir, and slug');
|
|
182
|
+
add(
|
|
183
|
+
'same_name_duplicate',
|
|
184
|
+
0.7,
|
|
185
|
+
groupBy(skills, skill => {
|
|
186
|
+
const sameSlug = skills.filter(other => other.slug === skill.slug);
|
|
187
|
+
const distinctHashes = new Set(sameSlug.map(contentHash).filter(Boolean));
|
|
188
|
+
return distinctHashes.size > 1 ? skill.slug : null;
|
|
189
|
+
}),
|
|
190
|
+
'same normalized skill name with different content'
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
return groups;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function versionDriftId(skills, key) {
|
|
197
|
+
return sha256(`version_drift:${buildParticipantIdentityKey(skills)}:${key || ''}`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function detectVersionDrift(skills) {
|
|
201
|
+
const candidates = [
|
|
202
|
+
...groupBy(skills, sourceKey),
|
|
203
|
+
...groupBy(skills, skill => skill.slug),
|
|
204
|
+
];
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const findings = [];
|
|
207
|
+
|
|
208
|
+
for (const [key, members] of candidates) {
|
|
209
|
+
const hashes = new Set(members.map(contentHash).filter(Boolean));
|
|
210
|
+
const refs = new Set(members.map(sourceRef).filter(Boolean));
|
|
211
|
+
if (hashes.size <= 1 && refs.size <= 1) continue;
|
|
212
|
+
|
|
213
|
+
const sorted = [...members].sort((a, b) => buildSkillIdentityKey(a).localeCompare(buildSkillIdentityKey(b)));
|
|
214
|
+
const id = versionDriftId(sorted, key);
|
|
215
|
+
if (seen.has(id)) continue;
|
|
216
|
+
seen.add(id);
|
|
217
|
+
|
|
218
|
+
findings.push({
|
|
219
|
+
id,
|
|
220
|
+
type: 'version_drift',
|
|
221
|
+
severity: 'medium',
|
|
222
|
+
detectorId: 'drift-detector',
|
|
223
|
+
ruleId: 'content-or-ref-drift',
|
|
224
|
+
title: 'Version drift detected',
|
|
225
|
+
description: `Detected multiple variants for ${key || 'a skill group'} with different content hashes or source refs.`,
|
|
226
|
+
skills: sorted,
|
|
227
|
+
links: sorted.map(skill => ({
|
|
228
|
+
skillId: skill.id,
|
|
229
|
+
role: roleForDrift(skill),
|
|
230
|
+
})),
|
|
231
|
+
evidence: sorted.map(skill => ({
|
|
232
|
+
slug: skill.slug,
|
|
233
|
+
path: skill.location?.path || skill.local_path || skill.path,
|
|
234
|
+
contentHash: contentHash(skill),
|
|
235
|
+
sourceRef: sourceRef(skill),
|
|
236
|
+
})),
|
|
237
|
+
recommendation: 'Review differences and either keep a pinned version or sync from the central library.',
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return findings;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function roleForDrift(skill) {
|
|
245
|
+
const rootType = skill.location?.rootType || skill.root_type;
|
|
246
|
+
if (rootType === 'central_library') return 'central';
|
|
247
|
+
if (rootType === 'project_local') return 'project';
|
|
248
|
+
if (skill.location?.agent || skill.agent) return 'agent';
|
|
249
|
+
return 'local';
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
module.exports = {
|
|
253
|
+
buildSkillIdentityKey,
|
|
254
|
+
buildParticipantIdentityKey,
|
|
255
|
+
chooseCanonical,
|
|
256
|
+
detectDuplicateGroups,
|
|
257
|
+
detectVersionDrift,
|
|
258
|
+
normalizeSourceUrl,
|
|
259
|
+
sourceTrustScore,
|
|
260
|
+
sourceKey,
|
|
261
|
+
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { buildParticipantIdentityKey } = require('./phase2');
|
|
7
|
+
|
|
8
|
+
function sha256(input) {
|
|
9
|
+
return crypto.createHash('sha256').update(String(input)).digest('hex');
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function normalizeText(text) {
|
|
13
|
+
return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeAnchor(text) {
|
|
17
|
+
return normalizeText(text).slice(0, 300);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function getMatchedPatternId(ruleId, pattern, index) {
|
|
21
|
+
if (pattern && pattern.id && /^[a-z0-9-]{1,64}$/.test(pattern.id)) return pattern.id;
|
|
22
|
+
const text = typeof pattern === 'string' ? pattern : String((pattern && (pattern.regex || pattern.text || pattern.value)) || '');
|
|
23
|
+
if (text) return `${ruleId}_${sha256(text).slice(0, 8)}`;
|
|
24
|
+
return `${ruleId}_${index}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function loadJsonRules(rulesDir) {
|
|
28
|
+
const rules = [];
|
|
29
|
+
if (!rulesDir || !fs.existsSync(rulesDir)) return rules;
|
|
30
|
+
for (const file of fs.readdirSync(rulesDir).sort()) {
|
|
31
|
+
if (!file.endsWith('.json')) continue;
|
|
32
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(rulesDir, file), 'utf8'));
|
|
33
|
+
if (parsed.version !== 1) throw new Error(`Unsupported rule file version in ${file}`);
|
|
34
|
+
for (const rule of parsed.rules || []) {
|
|
35
|
+
const ids = new Set();
|
|
36
|
+
for (const pattern of rule.patterns || []) {
|
|
37
|
+
if (!pattern || typeof pattern !== 'object' || !pattern.id || !/^[a-z0-9-]{1,64}$/.test(pattern.id)) continue;
|
|
38
|
+
if (ids.has(pattern.id)) throw new Error(`Duplicate pattern.id "${pattern.id}" in rule "${rule.id}"`);
|
|
39
|
+
ids.add(pattern.id);
|
|
40
|
+
}
|
|
41
|
+
rules.push({ ...rule, file });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return rules;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isTextLike(filePath) {
|
|
48
|
+
return /\.(md|txt|json|ya?ml|toml|js|ts|py|rs|sh|bash|zsh)$/i.test(filePath);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function lineNumberAt(text, index) {
|
|
52
|
+
return text.slice(0, index).split(/\r?\n/).length;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findPattern(original, lower, pattern) {
|
|
56
|
+
if (pattern && typeof pattern === 'object' && pattern.regex) {
|
|
57
|
+
const re = new RegExp(pattern.regex, pattern.flags || 'i');
|
|
58
|
+
const match = re.exec(original);
|
|
59
|
+
if (!match) return null;
|
|
60
|
+
return { index: match.index, text: match[0] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const needle = String((pattern && (pattern.text || pattern.value)) || pattern || '').toLowerCase();
|
|
64
|
+
if (!needle) return null;
|
|
65
|
+
const index = lower.indexOf(needle);
|
|
66
|
+
if (index < 0) return null;
|
|
67
|
+
return { index, text: original.slice(index, index + needle.length) };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function scanSkillForRisks(skill, rules) {
|
|
71
|
+
// Collect all matches grouped by rule+pattern to deduplicate
|
|
72
|
+
const matchMap = new Map(); // key: `${ruleId}:${patternId}` -> { rule, patternId, evidence[] }
|
|
73
|
+
for (const file of skill.files || []) {
|
|
74
|
+
const filePath = file.path;
|
|
75
|
+
if (!filePath || !fs.existsSync(filePath) || !isTextLike(filePath)) continue;
|
|
76
|
+
const original = fs.readFileSync(filePath, 'utf8');
|
|
77
|
+
const lower = original.toLowerCase();
|
|
78
|
+
const relativeFile = file.relativePath || path.basename(filePath);
|
|
79
|
+
|
|
80
|
+
for (const rule of rules) {
|
|
81
|
+
const patterns = rule.patterns || [];
|
|
82
|
+
for (let i = 0; i < patterns.length; i++) {
|
|
83
|
+
const pattern = patterns[i];
|
|
84
|
+
const match = findPattern(original, lower, pattern);
|
|
85
|
+
if (!match) continue;
|
|
86
|
+
const matched = match.text;
|
|
87
|
+
const patternId = getMatchedPatternId(rule.id, pattern, i);
|
|
88
|
+
const line = lineNumberAt(original, match.index);
|
|
89
|
+
const mapKey = `${rule.id}:${patternId}`;
|
|
90
|
+
if (!matchMap.has(mapKey)) {
|
|
91
|
+
matchMap.set(mapKey, { rule, patternId, evidence: [] });
|
|
92
|
+
}
|
|
93
|
+
matchMap.get(mapKey).evidence.push({
|
|
94
|
+
file: relativeFile, lineStart: line, lineEnd: line,
|
|
95
|
+
text: matched, anchor: normalizeAnchor(matched),
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Generate one finding per rule+pattern per skill
|
|
102
|
+
const findings = [];
|
|
103
|
+
const participantKey = buildParticipantIdentityKey([skill]);
|
|
104
|
+
for (const [, { rule, patternId, evidence }] of matchMap) {
|
|
105
|
+
const signature = sha256(`${rule.id}:${patternId}:${evidence.map(e => `${e.file}:${e.lineStart}`).join(',')}`);
|
|
106
|
+
const id = sha256(`${participantKey}:risk:risk-detector:${rule.id}:${signature}`);
|
|
107
|
+
findings.push({
|
|
108
|
+
id,
|
|
109
|
+
type: 'risk',
|
|
110
|
+
severity: rule.severity || 'medium',
|
|
111
|
+
detectorId: 'risk-detector',
|
|
112
|
+
ruleId: rule.id,
|
|
113
|
+
title: rule.title || 'Risk pattern detected',
|
|
114
|
+
description: `${rule.description || 'Risk pattern detected.'} [matched: ${patternId}]`,
|
|
115
|
+
signature,
|
|
116
|
+
evidence: evidence.map(e => ({ ...e, occurrenceId: sha256(`${id}:${e.file}:${e.lineStart}`) })),
|
|
117
|
+
recommendation: rule.recommendation || 'Review the skill before enabling it automatically.',
|
|
118
|
+
skillId: skill.id,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return findings;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { getMatchedPatternId, loadJsonRules, scanSkillForRisks };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
|
|
5
|
+
function sha256(input) {
|
|
6
|
+
return crypto.createHash('sha256').update(String(input)).digest('hex');
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function normalizeText(text) {
|
|
10
|
+
return String(text || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeAnchor(text) {
|
|
14
|
+
return normalizeText(text).slice(0, 300);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function validPatternId(id) {
|
|
18
|
+
return /^[a-z0-9-]{1,64}$/.test(String(id || ''));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function matchedPatternId(ruleId, pattern, index) {
|
|
22
|
+
if (pattern && typeof pattern === 'object' && validPatternId(pattern.id)) return pattern.id;
|
|
23
|
+
const text = typeof pattern === 'string' ? pattern : (pattern && (pattern.regex || pattern.text));
|
|
24
|
+
if (text) return `${ruleId}_${sha256(text).slice(0, 8)}`;
|
|
25
|
+
return `${ruleId}_${index}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const DEFAULT_RISK_RULES = [];
|
|
29
|
+
|
|
30
|
+
const DEFAULT_CONFLICT_RULES = [
|
|
31
|
+
{
|
|
32
|
+
id: 'package-manager-conflict',
|
|
33
|
+
type: 'opposite_instruction',
|
|
34
|
+
severity: 'medium',
|
|
35
|
+
title: 'Conflicting package manager instructions',
|
|
36
|
+
alternatives: [['use npm', 'npm install'], ['use pnpm', 'pnpm install'], ['use yarn', 'yarn install']],
|
|
37
|
+
recommendation: 'Keep only one package-manager convention skill per project or preset.'
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
id: 'output-format-conflict',
|
|
41
|
+
type: 'output_format_conflict',
|
|
42
|
+
severity: 'medium',
|
|
43
|
+
title: 'Conflicting output format instructions',
|
|
44
|
+
alternatives: [['respond in json', 'output json only'], ['respond in markdown', 'output markdown only']],
|
|
45
|
+
recommendation: 'Scope output-format skills to specific projects or presets.'
|
|
46
|
+
}
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
module.exports = {
|
|
50
|
+
DEFAULT_RISK_RULES,
|
|
51
|
+
DEFAULT_CONFLICT_RULES,
|
|
52
|
+
matchedPatternId,
|
|
53
|
+
normalizeAnchor,
|
|
54
|
+
normalizeText,
|
|
55
|
+
sha256,
|
|
56
|
+
validPatternId
|
|
57
|
+
};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('node:crypto');
|
|
4
|
+
const { buildSkillIdentityKey, buildParticipantIdentityKey } = require('./phase2');
|
|
5
|
+
|
|
6
|
+
function sha256(input) {
|
|
7
|
+
return crypto.createHash('sha256').update(String(input)).digest('hex');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function clamp(value, min, max) {
|
|
11
|
+
return Math.max(min, Math.min(max, value));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Simple description quality score (0-100).
|
|
16
|
+
* Checks for presence, length, and useful content indicators.
|
|
17
|
+
*/
|
|
18
|
+
function descriptionQuality(skill) {
|
|
19
|
+
const desc = String(skill.description || '').trim();
|
|
20
|
+
if (!desc) return 0;
|
|
21
|
+
let score = 40; // has description
|
|
22
|
+
if (desc.length >= 20) score += 20;
|
|
23
|
+
if (desc.length >= 60) score += 10;
|
|
24
|
+
// Check for trigger/usage indicators
|
|
25
|
+
const lower = desc.toLowerCase();
|
|
26
|
+
if (/\b(when|use |trigger|if |run |invoke)\b/.test(lower)) score += 10;
|
|
27
|
+
// Check for I/O indicators
|
|
28
|
+
if (/\b(input|output|return|result|respond|answer)\b/.test(lower)) score += 10;
|
|
29
|
+
// Check for action verbs
|
|
30
|
+
if (/\b(scan|detect|check|analyze|generate|create|build|find)\b/.test(lower)) score += 10;
|
|
31
|
+
return clamp(score, 0, 100);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const OFFICIAL_SOURCE_RE = /github\.com\/(anthropics|openai|github|google-gemini)\//i;
|
|
35
|
+
const PLUGIN_SOURCE_RE = /superpowers|using-superpowers/i;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Determine source protection level for zombie scoring.
|
|
39
|
+
* Returns: 'official' | 'plugin' | 'thirdParty' | null
|
|
40
|
+
*/
|
|
41
|
+
function sourceProtectionLevel(skill) {
|
|
42
|
+
const sourceUrl = skill.source?.url || '';
|
|
43
|
+
const plugin = skill._sourcePlugin || '';
|
|
44
|
+
if (OFFICIAL_SOURCE_RE.test(sourceUrl)) return 'official';
|
|
45
|
+
if (plugin && PLUGIN_SOURCE_RE.test(plugin)) return 'plugin';
|
|
46
|
+
if (plugin) return 'thirdParty';
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Compute zombie score for a skill (0.0 - 1.0).
|
|
52
|
+
*
|
|
53
|
+
* Early return 0 for pinned / keep / core / system / official-source skills.
|
|
54
|
+
* Plugin-source skills get 50% score reduction. Third-party get 25% reduction.
|
|
55
|
+
*
|
|
56
|
+
* Score components:
|
|
57
|
+
* presetCount === 0 : +0.25
|
|
58
|
+
* installedInAgents.length === 0 : +0.20
|
|
59
|
+
* installedInProjects.length === 0 : +0.20
|
|
60
|
+
* !hasRecentModification : +0.15
|
|
61
|
+
* !lastActivityLogAt : +0.15
|
|
62
|
+
* descriptionQuality < 40 : +0.05
|
|
63
|
+
*/
|
|
64
|
+
function computeZombieScore(skill) {
|
|
65
|
+
const usage = skill.usage || {};
|
|
66
|
+
const tags = skill.tags || [];
|
|
67
|
+
|
|
68
|
+
// Early return for protected skills
|
|
69
|
+
if (usage.manuallyPinned || tags.includes('keep') || tags.includes('core') || tags.includes('system')) {
|
|
70
|
+
return 0.0;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Official sources are fully protected
|
|
74
|
+
const protection = sourceProtectionLevel(skill);
|
|
75
|
+
if (protection === 'official') return 0.0;
|
|
76
|
+
|
|
77
|
+
let score = 0;
|
|
78
|
+
if ((usage.presetCount || 0) === 0) score += 0.25;
|
|
79
|
+
if (!usage.installedInAgents || usage.installedInAgents.length === 0) score += 0.20;
|
|
80
|
+
if (!usage.installedInProjects || usage.installedInProjects.length === 0) score += 0.20;
|
|
81
|
+
if (!usage.hasRecentModification) score += 0.15;
|
|
82
|
+
if (!usage.lastActivityLogAt) score += 0.15;
|
|
83
|
+
if (descriptionQuality(skill) < 40) score += 0.05;
|
|
84
|
+
|
|
85
|
+
// Reduce score for plugin/third-party sources
|
|
86
|
+
if (protection === 'plugin') score *= 0.5;
|
|
87
|
+
else if (protection === 'thirdParty') score *= 0.75;
|
|
88
|
+
|
|
89
|
+
return clamp(score, 0, 1);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Get zombie severity label.
|
|
94
|
+
*/
|
|
95
|
+
function zombieLevel(score) {
|
|
96
|
+
if (score >= 0.8) return 'strong_suspicious_zombie';
|
|
97
|
+
if (score >= 0.6) return 'suspicious_zombie';
|
|
98
|
+
if (score >= 0.4) return 'low_activity';
|
|
99
|
+
return 'normal';
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get human-readable zombie level description.
|
|
104
|
+
*/
|
|
105
|
+
function zombieLevelDescription(level) {
|
|
106
|
+
switch (level) {
|
|
107
|
+
case 'strong_suspicious_zombie': return 'Strong suspected zombie skill - very low activity signals.';
|
|
108
|
+
case 'suspicious_zombie': return 'Suspected zombie skill - low activity signals.';
|
|
109
|
+
case 'low_activity': return 'Low activity skill - may be unused.';
|
|
110
|
+
default: return 'Normal activity level.';
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Detect zombie skills from a list of skill records.
|
|
116
|
+
* Returns findings for skills with zombie score >= 0.4.
|
|
117
|
+
*
|
|
118
|
+
* @param {Array} skills
|
|
119
|
+
* @returns {Array} zombie findings
|
|
120
|
+
*/
|
|
121
|
+
function detectZombies(skills) {
|
|
122
|
+
const findings = [];
|
|
123
|
+
|
|
124
|
+
for (const skill of skills) {
|
|
125
|
+
const score = computeZombieScore(skill);
|
|
126
|
+
if (score < 0.4) continue;
|
|
127
|
+
|
|
128
|
+
const level = zombieLevel(score);
|
|
129
|
+
const participantKey = buildParticipantIdentityKey([skill]);
|
|
130
|
+
const reasons = [];
|
|
131
|
+
|
|
132
|
+
const usage = skill.usage || {};
|
|
133
|
+
const factors = [];
|
|
134
|
+
if ((usage.presetCount || 0) === 0) { reasons.push('not in any preset'); factors.push('presetCount==0 (+0.25)'); }
|
|
135
|
+
if (!usage.installedInAgents || usage.installedInAgents.length === 0) { reasons.push('not installed in any agent'); factors.push('noAgents (+0.20)'); }
|
|
136
|
+
if (!usage.installedInProjects || usage.installedInProjects.length === 0) { reasons.push('not installed in any project'); factors.push('noProjects (+0.20)'); }
|
|
137
|
+
if (!usage.hasRecentModification) { reasons.push('no recent modifications'); factors.push('noModification (+0.15)'); }
|
|
138
|
+
if (!usage.lastActivityLogAt) { reasons.push('no activity log entries'); factors.push('noActivityLog (+0.15)'); }
|
|
139
|
+
if (descriptionQuality(skill) < 40) { reasons.push('poor description quality'); factors.push('lowDescriptionQuality (+0.05)'); }
|
|
140
|
+
|
|
141
|
+
// Source protection discount
|
|
142
|
+
const protection = sourceProtectionLevel(skill);
|
|
143
|
+
if (protection === 'plugin') factors.push('sourcePlugin (*0.50)');
|
|
144
|
+
else if (protection === 'thirdParty') factors.push('sourceThirdParty (*0.75)');
|
|
145
|
+
|
|
146
|
+
const reasonText = reasons.join('; ');
|
|
147
|
+
const factorText = factors.join('; ');
|
|
148
|
+
const signature = sha256(`zombie:${level}:${Math.round(score * 100)}:${skill.slug || ''}`);
|
|
149
|
+
const id = sha256(`${participantKey}:zombie:zombie-detector:${level}:${signature}`);
|
|
150
|
+
|
|
151
|
+
findings.push({
|
|
152
|
+
id,
|
|
153
|
+
type: 'zombie',
|
|
154
|
+
severity: score >= 0.8 ? 'medium' : 'low',
|
|
155
|
+
detectorId: 'zombie-detector',
|
|
156
|
+
ruleId: level,
|
|
157
|
+
title: `Zombie candidate: ${skill.name || skill.slug}`,
|
|
158
|
+
description: `${zombieLevelDescription(level)} Score: ${score.toFixed(2)}. Factors: ${factorText}.`,
|
|
159
|
+
signature,
|
|
160
|
+
evidence: [{
|
|
161
|
+
file: skill.location?.path || skill.local_path || '',
|
|
162
|
+
text: `${skill.slug}: score=${score.toFixed(2)}, level=${level}, reasons=${reasonText}`,
|
|
163
|
+
anchor: `${skill.slug} zombie ${level}`,
|
|
164
|
+
}],
|
|
165
|
+
recommendation: 'Review whether this skill is still needed. Consider removing from presets or disabling if unused.',
|
|
166
|
+
score,
|
|
167
|
+
level,
|
|
168
|
+
skills: [skill],
|
|
169
|
+
links: [{ skillId: skill.id, role: 'primary' }],
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Sort by score descending
|
|
174
|
+
findings.sort((a, b) => (b.score || 0) - (a.score || 0));
|
|
175
|
+
return findings;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
module.exports = { computeZombieScore, zombieLevel, zombieLevelDescription, detectZombies, descriptionQuality };
|