@studiomeyer-io/skilldoctor 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/LICENSE +21 -0
- package/README.md +187 -0
- package/SECURITY.md +53 -0
- package/dist/cli.cjs +1718 -0
- package/dist/cli.d.cts +31 -0
- package/dist/cli.d.ts +31 -0
- package/dist/cli.js +1715 -0
- package/dist/index.cjs +1569 -0
- package/dist/index.d.cts +220 -0
- package/dist/index.d.ts +220 -0
- package/dist/index.js +1540 -0
- package/dist/types-lUfaWSNG.d.cts +117 -0
- package/dist/types-lUfaWSNG.d.ts +117 -0
- package/package.json +77 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1540 @@
|
|
|
1
|
+
import { readFileSync, existsSync, statSync, readdirSync } from 'fs';
|
|
2
|
+
import { basename, resolve, join, sep, posix, relative, dirname } from 'path';
|
|
3
|
+
import { parse, YAMLParseError } from 'yaml';
|
|
4
|
+
import { createHash } from 'crypto';
|
|
5
|
+
|
|
6
|
+
// src/types.ts
|
|
7
|
+
var SEVERITY_RANK = {
|
|
8
|
+
info: 1,
|
|
9
|
+
warning: 2,
|
|
10
|
+
error: 3
|
|
11
|
+
};
|
|
12
|
+
var OPENING_FENCE = /^---[ \t]*\r?\n/;
|
|
13
|
+
function extractFrontmatter(raw) {
|
|
14
|
+
if (!OPENING_FENCE.test(raw)) {
|
|
15
|
+
return {
|
|
16
|
+
frontmatter: {
|
|
17
|
+
present: false,
|
|
18
|
+
raw: "",
|
|
19
|
+
data: void 0,
|
|
20
|
+
error: void 0,
|
|
21
|
+
startLine: 0,
|
|
22
|
+
endLine: 0
|
|
23
|
+
},
|
|
24
|
+
body: raw,
|
|
25
|
+
bodyStartLine: 1
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
const lines = raw.split(/\r?\n/);
|
|
29
|
+
let closingIndex = -1;
|
|
30
|
+
for (let i = 1; i < lines.length; i++) {
|
|
31
|
+
if (/^---[ \t]*$/.test(lines[i] ?? "")) {
|
|
32
|
+
closingIndex = i;
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (closingIndex === -1) {
|
|
37
|
+
const fmLines2 = lines.slice(1);
|
|
38
|
+
const fmRaw2 = fmLines2.join("\n");
|
|
39
|
+
return {
|
|
40
|
+
frontmatter: {
|
|
41
|
+
present: true,
|
|
42
|
+
raw: fmRaw2,
|
|
43
|
+
data: void 0,
|
|
44
|
+
error: "Unterminated frontmatter: opening '---' has no closing '---'.",
|
|
45
|
+
startLine: 2,
|
|
46
|
+
endLine: lines.length
|
|
47
|
+
},
|
|
48
|
+
body: "",
|
|
49
|
+
bodyStartLine: lines.length + 1
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const fmLines = lines.slice(1, closingIndex);
|
|
53
|
+
const fmRaw = fmLines.join("\n");
|
|
54
|
+
const bodyLines = lines.slice(closingIndex + 1);
|
|
55
|
+
const body = bodyLines.join("\n");
|
|
56
|
+
const bodyStartLine = closingIndex + 2;
|
|
57
|
+
let data;
|
|
58
|
+
let error;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = parse(fmRaw, {
|
|
61
|
+
// Defensive: keep parsing strict-ish but lenient on duplicate keys so we
|
|
62
|
+
// can detect duplicates ourselves rather than throwing.
|
|
63
|
+
uniqueKeys: false,
|
|
64
|
+
strict: false
|
|
65
|
+
});
|
|
66
|
+
if (parsed === null || parsed === void 0) {
|
|
67
|
+
data = {};
|
|
68
|
+
} else if (typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
69
|
+
data = parsed;
|
|
70
|
+
} else {
|
|
71
|
+
error = "Frontmatter must be a YAML mapping (key: value pairs).";
|
|
72
|
+
}
|
|
73
|
+
} catch (e) {
|
|
74
|
+
if (e instanceof YAMLParseError) {
|
|
75
|
+
error = e.message.split("\n")[0] ?? e.message;
|
|
76
|
+
} else if (e instanceof Error) {
|
|
77
|
+
error = e.message;
|
|
78
|
+
} else {
|
|
79
|
+
error = "Unknown YAML parse error.";
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return {
|
|
83
|
+
frontmatter: {
|
|
84
|
+
present: true,
|
|
85
|
+
raw: fmRaw,
|
|
86
|
+
data,
|
|
87
|
+
error,
|
|
88
|
+
startLine: 2,
|
|
89
|
+
endLine: closingIndex + 1
|
|
90
|
+
// 1-based line of the closing fence
|
|
91
|
+
},
|
|
92
|
+
body,
|
|
93
|
+
bodyStartLine
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function normalizePath(filePath) {
|
|
97
|
+
return filePath.replace(/\\/g, "/").toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
function detectKind(filePath, frontmatter) {
|
|
100
|
+
const norm = normalizePath(filePath);
|
|
101
|
+
const base = basename(norm);
|
|
102
|
+
if (base === "agents.md") return "agents-md";
|
|
103
|
+
if (base === "skill.md") return "skill";
|
|
104
|
+
const inAgentsDir = /(^|\/)agents\/[^/]+\.md$/.test(norm);
|
|
105
|
+
const data = frontmatter.data;
|
|
106
|
+
const hasName = !!data && typeof data["name"] === "string";
|
|
107
|
+
const hasDescription = !!data && typeof data["description"] === "string";
|
|
108
|
+
if (inAgentsDir && frontmatter.present && (hasName || hasDescription)) {
|
|
109
|
+
return "subagent";
|
|
110
|
+
}
|
|
111
|
+
if (frontmatter.present && hasName && hasDescription) {
|
|
112
|
+
return "skill";
|
|
113
|
+
}
|
|
114
|
+
return "unknown";
|
|
115
|
+
}
|
|
116
|
+
function parseFile(filePath, raw, forceKind) {
|
|
117
|
+
if (raw.charCodeAt(0) === 65279) raw = raw.slice(1);
|
|
118
|
+
const { frontmatter, body, bodyStartLine } = extractFrontmatter(raw);
|
|
119
|
+
const kind = forceKind ?? detectKind(filePath, frontmatter);
|
|
120
|
+
return { filePath, kind, frontmatter, body, bodyStartLine, raw };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/registry.ts
|
|
124
|
+
var RULES = [
|
|
125
|
+
// ---- Lint rules (frontmatter / structure) ---------------------------------
|
|
126
|
+
{
|
|
127
|
+
ruleId: "skill/missing-name",
|
|
128
|
+
title: "Missing name",
|
|
129
|
+
category: "lint",
|
|
130
|
+
defaultSeverity: "error",
|
|
131
|
+
description: "A skill or subagent frontmatter must declare a non-empty `name`. The Agent Skills spec requires it; Claude Code falls back to the directory name but a missing name is fragile.",
|
|
132
|
+
fixable: false
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
ruleId: "skill/invalid-name",
|
|
136
|
+
title: "Invalid name format",
|
|
137
|
+
category: "lint",
|
|
138
|
+
defaultSeverity: "error",
|
|
139
|
+
description: "`name` must be 1-64 lowercase characters using only a-z, 0-9 and hyphens, with no leading/trailing/consecutive hyphens (Agent Skills spec).",
|
|
140
|
+
fixable: false
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
ruleId: "skill/name-dir-mismatch",
|
|
144
|
+
title: "Name does not match directory",
|
|
145
|
+
category: "lint",
|
|
146
|
+
defaultSeverity: "warning",
|
|
147
|
+
description: "The Agent Skills spec says a skill's `name` must match its parent directory name. A mismatch breaks invocation in some clients.",
|
|
148
|
+
fixable: false
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
ruleId: "skill/missing-description",
|
|
152
|
+
title: "Missing description",
|
|
153
|
+
category: "lint",
|
|
154
|
+
defaultSeverity: "error",
|
|
155
|
+
description: "`description` is required and is what an agent uses to decide when to load the skill. Without it the skill is effectively invisible.",
|
|
156
|
+
fixable: true
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
ruleId: "skill/empty-description",
|
|
160
|
+
title: "Empty description",
|
|
161
|
+
category: "lint",
|
|
162
|
+
defaultSeverity: "error",
|
|
163
|
+
description: "`description` is present but blank. It must be non-empty.",
|
|
164
|
+
fixable: true
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
ruleId: "skill/description-too-short",
|
|
168
|
+
title: "Description too short",
|
|
169
|
+
category: "lint",
|
|
170
|
+
defaultSeverity: "warning",
|
|
171
|
+
description: "A very short description gives the agent almost no signal about when to use the skill. Describe both what it does and when to use it.",
|
|
172
|
+
fixable: false
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
ruleId: "skill/description-too-long",
|
|
176
|
+
title: "Description too long",
|
|
177
|
+
category: "lint",
|
|
178
|
+
defaultSeverity: "warning",
|
|
179
|
+
description: "`description` exceeds the 1024-character spec limit (Claude Code truncates the combined description+when_to_use at 1,536 chars in the skill listing).",
|
|
180
|
+
fixable: false
|
|
181
|
+
},
|
|
182
|
+
{
|
|
183
|
+
ruleId: "skill/vague-description",
|
|
184
|
+
title: "Vague description",
|
|
185
|
+
category: "lint",
|
|
186
|
+
defaultSeverity: "info",
|
|
187
|
+
description: "The description is generic (e.g. 'helps with things') and lacks trigger keywords. Agents match descriptions to tasks, so specificity matters.",
|
|
188
|
+
fixable: false
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
ruleId: "skill/empty-body",
|
|
192
|
+
title: "Empty body",
|
|
193
|
+
category: "lint",
|
|
194
|
+
defaultSeverity: "warning",
|
|
195
|
+
description: "The markdown body (the actual instructions) is empty or whitespace-only. A skill with no instructions does nothing.",
|
|
196
|
+
fixable: false
|
|
197
|
+
},
|
|
198
|
+
{
|
|
199
|
+
ruleId: "skill/frontmatter-schema",
|
|
200
|
+
title: "Frontmatter schema error",
|
|
201
|
+
category: "lint",
|
|
202
|
+
defaultSeverity: "error",
|
|
203
|
+
description: "The YAML frontmatter could not be parsed, is not a mapping, or a known field has the wrong type.",
|
|
204
|
+
fixable: false
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
ruleId: "skill/unknown-field",
|
|
208
|
+
title: "Unknown frontmatter field",
|
|
209
|
+
category: "lint",
|
|
210
|
+
defaultSeverity: "info",
|
|
211
|
+
description: "A frontmatter field is not part of the Agent Skills spec or the known Claude Code extensions. Handled leniently (info) since clients may add their own fields.",
|
|
212
|
+
fixable: false
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
ruleId: "skill/duplicate-key",
|
|
216
|
+
title: "Duplicate frontmatter key",
|
|
217
|
+
category: "lint",
|
|
218
|
+
defaultSeverity: "warning",
|
|
219
|
+
description: "A frontmatter key appears more than once. YAML keeps the last value, silently dropping the earlier one.",
|
|
220
|
+
fixable: false
|
|
221
|
+
},
|
|
222
|
+
{
|
|
223
|
+
ruleId: "skill/trailing-whitespace",
|
|
224
|
+
title: "Trailing whitespace in frontmatter",
|
|
225
|
+
category: "lint",
|
|
226
|
+
defaultSeverity: "info",
|
|
227
|
+
description: "A frontmatter line has trailing whitespace. Cosmetic, but mechanically fixable.",
|
|
228
|
+
fixable: true
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
ruleId: "skill/duplicate-name",
|
|
232
|
+
title: "Duplicate skill name in set",
|
|
233
|
+
category: "lint",
|
|
234
|
+
defaultSeverity: "error",
|
|
235
|
+
description: "Two files in the analyzed set declare the same `name`. Clients keep one and silently discard the other.",
|
|
236
|
+
fixable: false
|
|
237
|
+
},
|
|
238
|
+
// ---- Least-privilege / tool-grant rules -----------------------------------
|
|
239
|
+
{
|
|
240
|
+
ruleId: "tools/wildcard-grant",
|
|
241
|
+
title: "Wildcard tool grant",
|
|
242
|
+
category: "lint",
|
|
243
|
+
defaultSeverity: "warning",
|
|
244
|
+
description: "The tool grant includes a bare `*` / `all` wildcard. Least-privilege: grant only the specific tools the skill needs.",
|
|
245
|
+
fixable: false
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
ruleId: "tools/over-broad-for-readonly",
|
|
249
|
+
title: "Over-broad tools for a read-only skill",
|
|
250
|
+
category: "lint",
|
|
251
|
+
defaultSeverity: "warning",
|
|
252
|
+
description: "The description implies a read-only/docs task but the skill grants write/exec/network tools (e.g. Bash, Write, Edit, WebFetch). Least-privilege violation.",
|
|
253
|
+
fixable: false
|
|
254
|
+
},
|
|
255
|
+
{
|
|
256
|
+
ruleId: "tools/duplicate-tool",
|
|
257
|
+
title: "Duplicate tool in grant",
|
|
258
|
+
category: "lint",
|
|
259
|
+
defaultSeverity: "info",
|
|
260
|
+
description: "The same tool is listed more than once in the tool grant.",
|
|
261
|
+
fixable: true
|
|
262
|
+
},
|
|
263
|
+
// ---- Security scan rules --------------------------------------------------
|
|
264
|
+
{
|
|
265
|
+
ruleId: "sec/prompt-injection",
|
|
266
|
+
title: "Prompt-injection phrasing",
|
|
267
|
+
category: "security",
|
|
268
|
+
defaultSeverity: "error",
|
|
269
|
+
description: "The content contains prompt-injection-style instructions (e.g. 'ignore previous instructions', 'disregard your system prompt'). Skill content is untrusted input.",
|
|
270
|
+
fixable: false
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
ruleId: "sec/disable-safety",
|
|
274
|
+
title: "Instruction to disable safety/guardrails",
|
|
275
|
+
category: "security",
|
|
276
|
+
defaultSeverity: "error",
|
|
277
|
+
description: "The content instructs the agent to disable safety checks, hooks, guardrails, or approval gates.",
|
|
278
|
+
fixable: false
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
ruleId: "sec/data-exfiltration",
|
|
282
|
+
title: "Possible data-exfiltration pattern",
|
|
283
|
+
category: "security",
|
|
284
|
+
defaultSeverity: "error",
|
|
285
|
+
description: "The content combines an outbound network call (curl/POST/fetch to an external URL) with secrets/credentials/environment variables \u2014 a classic exfiltration shape.",
|
|
286
|
+
fixable: false
|
|
287
|
+
},
|
|
288
|
+
{
|
|
289
|
+
ruleId: "sec/env-base64",
|
|
290
|
+
title: "Encoding of secrets/env",
|
|
291
|
+
category: "security",
|
|
292
|
+
defaultSeverity: "warning",
|
|
293
|
+
description: "The content base64-encodes (or otherwise obfuscates) environment variables or secrets, often a precursor to covert exfiltration.",
|
|
294
|
+
fixable: false
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
ruleId: "sec/secret-access",
|
|
298
|
+
title: "Reads sensitive files/secrets",
|
|
299
|
+
category: "security",
|
|
300
|
+
defaultSeverity: "warning",
|
|
301
|
+
description: "The content references reading sensitive locations (~/.ssh, .env, credentials, cloud token files). Flagged for review; may be legitimate.",
|
|
302
|
+
fixable: false
|
|
303
|
+
},
|
|
304
|
+
{
|
|
305
|
+
ruleId: "sec/suspicious-tool-combo",
|
|
306
|
+
title: "Suspicious tool + content combination",
|
|
307
|
+
category: "security",
|
|
308
|
+
defaultSeverity: "warning",
|
|
309
|
+
description: "A skill described as read-only/docs grants Bash plus a network/exec capability \u2014 a combination that enables exfiltration from otherwise-innocent content.",
|
|
310
|
+
fixable: false
|
|
311
|
+
},
|
|
312
|
+
{
|
|
313
|
+
ruleId: "sec/destructive-command",
|
|
314
|
+
title: "Destructive shell command in content",
|
|
315
|
+
category: "security",
|
|
316
|
+
defaultSeverity: "warning",
|
|
317
|
+
description: "The content embeds a destructive command (e.g. `rm -rf /`, `curl | sh`, `git push --force`). Review before installing.",
|
|
318
|
+
fixable: false
|
|
319
|
+
},
|
|
320
|
+
{
|
|
321
|
+
ruleId: "sec/hidden-unicode",
|
|
322
|
+
title: "Hidden / bidi / zero-width Unicode",
|
|
323
|
+
category: "security",
|
|
324
|
+
defaultSeverity: "warning",
|
|
325
|
+
description: "The content contains zero-width or bidirectional control characters that can hide instructions from a human reviewer (Trojan-Source style).",
|
|
326
|
+
fixable: false
|
|
327
|
+
}
|
|
328
|
+
];
|
|
329
|
+
var RULE_MAP = new Map(
|
|
330
|
+
RULES.map((r) => [r.ruleId, r])
|
|
331
|
+
);
|
|
332
|
+
function getRule(ruleId) {
|
|
333
|
+
const r = RULE_MAP.get(ruleId);
|
|
334
|
+
if (!r) {
|
|
335
|
+
throw new Error(
|
|
336
|
+
`Internal error: rule '${ruleId}' is not registered in registry.ts`
|
|
337
|
+
);
|
|
338
|
+
}
|
|
339
|
+
return r;
|
|
340
|
+
}
|
|
341
|
+
function allRuleIds() {
|
|
342
|
+
return RULES.map((r) => r.ruleId);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// src/locate.ts
|
|
346
|
+
function offsetToLineCol(text, offset) {
|
|
347
|
+
const clamped = Math.max(0, Math.min(offset, text.length));
|
|
348
|
+
let line = 1;
|
|
349
|
+
let lastNewline = -1;
|
|
350
|
+
for (let i = 0; i < clamped; i++) {
|
|
351
|
+
if (text.charCodeAt(i) === 10) {
|
|
352
|
+
line++;
|
|
353
|
+
lastNewline = i;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
const column = clamped - lastNewline;
|
|
357
|
+
return { line, column };
|
|
358
|
+
}
|
|
359
|
+
function findKeyLine(frontmatterRaw, key, frontmatterStartLine) {
|
|
360
|
+
const lines = frontmatterRaw.split(/\r?\n/);
|
|
361
|
+
const re = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:`);
|
|
362
|
+
for (let i = 0; i < lines.length; i++) {
|
|
363
|
+
if (re.test(lines[i] ?? "")) {
|
|
364
|
+
return frontmatterStartLine + i;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return frontmatterStartLine;
|
|
368
|
+
}
|
|
369
|
+
function escapeRegExp(s) {
|
|
370
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
371
|
+
}
|
|
372
|
+
function makeEvidence(snippet, max = 120) {
|
|
373
|
+
const oneLine = snippet.replace(/\s+/g, " ").trim();
|
|
374
|
+
if (oneLine.length <= max) return oneLine;
|
|
375
|
+
return oneLine.slice(0, max - 1) + "\u2026";
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// src/spec.ts
|
|
379
|
+
var SKILL_NAME_MAX = 64;
|
|
380
|
+
var SKILL_DESCRIPTION_MAX = 1024;
|
|
381
|
+
var SKILL_NAME_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
382
|
+
var SPEC_SKILL_FIELDS = /* @__PURE__ */ new Set([
|
|
383
|
+
"name",
|
|
384
|
+
"description",
|
|
385
|
+
"license",
|
|
386
|
+
"compatibility",
|
|
387
|
+
"metadata",
|
|
388
|
+
"allowed-tools"
|
|
389
|
+
]);
|
|
390
|
+
var CLAUDE_CODE_SKILL_FIELDS = /* @__PURE__ */ new Set([
|
|
391
|
+
"when_to_use",
|
|
392
|
+
"argument-hint",
|
|
393
|
+
"arguments",
|
|
394
|
+
"disable-model-invocation",
|
|
395
|
+
"user-invocable",
|
|
396
|
+
"disallowed-tools",
|
|
397
|
+
"model",
|
|
398
|
+
"effort",
|
|
399
|
+
"context",
|
|
400
|
+
"agent",
|
|
401
|
+
"hooks",
|
|
402
|
+
"paths",
|
|
403
|
+
"shell"
|
|
404
|
+
]);
|
|
405
|
+
var SUBAGENT_FIELDS = /* @__PURE__ */ new Set([
|
|
406
|
+
"name",
|
|
407
|
+
"description",
|
|
408
|
+
"tools",
|
|
409
|
+
"disallowedTools",
|
|
410
|
+
"model",
|
|
411
|
+
"permissionMode",
|
|
412
|
+
"mcpServers",
|
|
413
|
+
"hooks",
|
|
414
|
+
"maxTurns",
|
|
415
|
+
"skills",
|
|
416
|
+
"initialPrompt",
|
|
417
|
+
"memory",
|
|
418
|
+
"effort",
|
|
419
|
+
"background",
|
|
420
|
+
"isolation",
|
|
421
|
+
"color"
|
|
422
|
+
]);
|
|
423
|
+
var SENSITIVE_TOOLS = /* @__PURE__ */ new Set([
|
|
424
|
+
"bash",
|
|
425
|
+
"shell",
|
|
426
|
+
"execute",
|
|
427
|
+
"exec",
|
|
428
|
+
"write",
|
|
429
|
+
"edit",
|
|
430
|
+
"multiedit",
|
|
431
|
+
"notebookedit",
|
|
432
|
+
"webfetch",
|
|
433
|
+
"websearch",
|
|
434
|
+
"fetch",
|
|
435
|
+
"applypatch",
|
|
436
|
+
"patch"
|
|
437
|
+
]);
|
|
438
|
+
var NETWORK_TOOLS = /* @__PURE__ */ new Set([
|
|
439
|
+
"webfetch",
|
|
440
|
+
"websearch",
|
|
441
|
+
"fetch"
|
|
442
|
+
]);
|
|
443
|
+
var EXEC_TOOLS = /* @__PURE__ */ new Set(["bash", "shell", "execute", "exec"]);
|
|
444
|
+
var READONLY_HINT_RE = /\b(read[- ]?only|look[- ]?up|lookup|summari[sz]e|explains?|describes?)\b/i;
|
|
445
|
+
function normalizeToolName(token) {
|
|
446
|
+
return token.trim().replace(/\(.*\)\s*$/, "").toLowerCase();
|
|
447
|
+
}
|
|
448
|
+
function parseToolList(value) {
|
|
449
|
+
if (Array.isArray(value)) {
|
|
450
|
+
return value.filter((v) => typeof v === "string");
|
|
451
|
+
}
|
|
452
|
+
if (typeof value === "string") {
|
|
453
|
+
return value.split(/[,\s]+/).map((t) => t.trim()).filter((t) => t.length > 0);
|
|
454
|
+
}
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
function isWildcardTool(token) {
|
|
458
|
+
const t = token.trim().toLowerCase();
|
|
459
|
+
return t === "*" || t === "all" || t === "any" || t === "everything";
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// src/rules/lint.ts
|
|
463
|
+
function finding(ruleId, message, line, opts = {}) {
|
|
464
|
+
const rule = getRule(ruleId);
|
|
465
|
+
const f = {
|
|
466
|
+
ruleId: rule.ruleId,
|
|
467
|
+
title: rule.title,
|
|
468
|
+
category: rule.category,
|
|
469
|
+
severity: rule.defaultSeverity,
|
|
470
|
+
message,
|
|
471
|
+
line: Math.max(1, line),
|
|
472
|
+
column: Math.max(1, opts.column ?? 1),
|
|
473
|
+
fixable: rule.fixable
|
|
474
|
+
};
|
|
475
|
+
if (opts.evidence !== void 0) f.evidence = opts.evidence;
|
|
476
|
+
return f;
|
|
477
|
+
}
|
|
478
|
+
var VAGUE_RE = /^(helps?( you)?( with)?|does (stuff|things)|a skill( for)?|utility|tool|assistant|various|misc(ellaneous)?)\b/i;
|
|
479
|
+
function lintFile(file) {
|
|
480
|
+
const findings = [];
|
|
481
|
+
const { frontmatter, kind } = file;
|
|
482
|
+
if (kind === "agents-md") {
|
|
483
|
+
if (file.raw.trim().length === 0) {
|
|
484
|
+
findings.push(
|
|
485
|
+
finding(
|
|
486
|
+
"skill/empty-body",
|
|
487
|
+
"AGENTS.md is empty. It should contain project instructions for agents.",
|
|
488
|
+
1
|
|
489
|
+
)
|
|
490
|
+
);
|
|
491
|
+
}
|
|
492
|
+
return findings;
|
|
493
|
+
}
|
|
494
|
+
if (kind === "unknown") {
|
|
495
|
+
return findings;
|
|
496
|
+
}
|
|
497
|
+
const fmStart = frontmatter.startLine || 1;
|
|
498
|
+
if (frontmatter.present && frontmatter.error) {
|
|
499
|
+
findings.push(
|
|
500
|
+
finding(
|
|
501
|
+
"skill/frontmatter-schema",
|
|
502
|
+
`Frontmatter could not be parsed: ${frontmatter.error}`,
|
|
503
|
+
fmStart
|
|
504
|
+
)
|
|
505
|
+
);
|
|
506
|
+
if (!frontmatter.data) return findings;
|
|
507
|
+
}
|
|
508
|
+
if (!frontmatter.present || !frontmatter.data) {
|
|
509
|
+
findings.push(
|
|
510
|
+
finding(
|
|
511
|
+
"skill/missing-name",
|
|
512
|
+
"No frontmatter found; a skill/subagent requires `name` and `description`.",
|
|
513
|
+
1
|
|
514
|
+
)
|
|
515
|
+
);
|
|
516
|
+
findings.push(
|
|
517
|
+
finding(
|
|
518
|
+
"skill/missing-description",
|
|
519
|
+
"No frontmatter found; `description` is required.",
|
|
520
|
+
1
|
|
521
|
+
)
|
|
522
|
+
);
|
|
523
|
+
return findings;
|
|
524
|
+
}
|
|
525
|
+
const data = frontmatter.data;
|
|
526
|
+
lintName(file, data, findings);
|
|
527
|
+
lintDescription(file, data, findings);
|
|
528
|
+
lintBody(file, findings);
|
|
529
|
+
lintTools(file, data, findings);
|
|
530
|
+
lintUnknownFields(file, data, findings);
|
|
531
|
+
lintTrailingWhitespace(file, findings);
|
|
532
|
+
lintDuplicateKeys(file, findings);
|
|
533
|
+
return findings;
|
|
534
|
+
}
|
|
535
|
+
function lintName(file, data, findings) {
|
|
536
|
+
const fmStart = file.frontmatter.startLine || 1;
|
|
537
|
+
const nameRaw = data["name"];
|
|
538
|
+
const nameLine = findKeyLine(file.frontmatter.raw, "name", fmStart);
|
|
539
|
+
if (nameRaw === void 0 || nameRaw === null) {
|
|
540
|
+
findings.push(
|
|
541
|
+
finding("skill/missing-name", "Missing required `name` field.", fmStart)
|
|
542
|
+
);
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
if (typeof nameRaw !== "string") {
|
|
546
|
+
findings.push(
|
|
547
|
+
finding(
|
|
548
|
+
"skill/frontmatter-schema",
|
|
549
|
+
`\`name\` must be a string, got ${typeof nameRaw}.`,
|
|
550
|
+
nameLine
|
|
551
|
+
)
|
|
552
|
+
);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
const name = nameRaw.trim();
|
|
556
|
+
if (name.length === 0) {
|
|
557
|
+
findings.push(
|
|
558
|
+
finding("skill/missing-name", "`name` is empty.", nameLine)
|
|
559
|
+
);
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
if (name.length > SKILL_NAME_MAX || !SKILL_NAME_RE.test(name)) {
|
|
563
|
+
findings.push(
|
|
564
|
+
finding(
|
|
565
|
+
"skill/invalid-name",
|
|
566
|
+
`\`name\` "${name}" is invalid. Use 1-${SKILL_NAME_MAX} lowercase chars (a-z, 0-9, hyphens), no leading/trailing/consecutive hyphens.`,
|
|
567
|
+
nameLine,
|
|
568
|
+
{ evidence: makeEvidence(name) }
|
|
569
|
+
)
|
|
570
|
+
);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (basename(file.filePath).toLowerCase() === "skill.md") {
|
|
574
|
+
const dir = basename(dirname(file.filePath));
|
|
575
|
+
if (dir && dir !== "." && dir !== "" && dir.toLowerCase() !== name) {
|
|
576
|
+
findings.push(
|
|
577
|
+
finding(
|
|
578
|
+
"skill/name-dir-mismatch",
|
|
579
|
+
`\`name\` "${name}" does not match the parent directory "${dir}". The Agent Skills spec requires them to match.`,
|
|
580
|
+
nameLine
|
|
581
|
+
)
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
function lintDescription(file, data, findings) {
|
|
587
|
+
const fmStart = file.frontmatter.startLine || 1;
|
|
588
|
+
const descRaw = data["description"];
|
|
589
|
+
const descLine = findKeyLine(file.frontmatter.raw, "description", fmStart);
|
|
590
|
+
if (descRaw === void 0 || descRaw === null) {
|
|
591
|
+
findings.push(
|
|
592
|
+
finding(
|
|
593
|
+
"skill/missing-description",
|
|
594
|
+
"Missing required `description` field. Agents use it to decide when to load the skill.",
|
|
595
|
+
fmStart
|
|
596
|
+
)
|
|
597
|
+
);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
if (typeof descRaw !== "string") {
|
|
601
|
+
findings.push(
|
|
602
|
+
finding(
|
|
603
|
+
"skill/frontmatter-schema",
|
|
604
|
+
`\`description\` must be a string, got ${typeof descRaw}.`,
|
|
605
|
+
descLine
|
|
606
|
+
)
|
|
607
|
+
);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
const desc = descRaw.trim();
|
|
611
|
+
if (desc.length === 0) {
|
|
612
|
+
findings.push(
|
|
613
|
+
finding("skill/empty-description", "`description` is empty.", descLine)
|
|
614
|
+
);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
if (desc.length < 20) {
|
|
618
|
+
findings.push(
|
|
619
|
+
finding(
|
|
620
|
+
"skill/description-too-short",
|
|
621
|
+
`\`description\` is only ${desc.length} characters. Describe what the skill does AND when to use it (include trigger keywords).`,
|
|
622
|
+
descLine,
|
|
623
|
+
{ evidence: makeEvidence(desc) }
|
|
624
|
+
)
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
if (desc.length > SKILL_DESCRIPTION_MAX) {
|
|
628
|
+
findings.push(
|
|
629
|
+
finding(
|
|
630
|
+
"skill/description-too-long",
|
|
631
|
+
`\`description\` is ${desc.length} characters, over the ${SKILL_DESCRIPTION_MAX}-char spec limit.`,
|
|
632
|
+
descLine
|
|
633
|
+
)
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
if (VAGUE_RE.test(desc)) {
|
|
637
|
+
findings.push(
|
|
638
|
+
finding(
|
|
639
|
+
"skill/vague-description",
|
|
640
|
+
"`description` reads as generic. Add concrete capabilities and trigger phrases so agents match it to real tasks.",
|
|
641
|
+
descLine,
|
|
642
|
+
{ evidence: makeEvidence(desc) }
|
|
643
|
+
)
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
function lintBody(file, findings) {
|
|
648
|
+
if (file.body.trim().length === 0) {
|
|
649
|
+
findings.push(
|
|
650
|
+
finding(
|
|
651
|
+
"skill/empty-body",
|
|
652
|
+
"The instruction body is empty. Add the steps/instructions the agent should follow.",
|
|
653
|
+
file.bodyStartLine
|
|
654
|
+
)
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
function lintTools(file, data, findings) {
|
|
659
|
+
const fmStart = file.frontmatter.startLine || 1;
|
|
660
|
+
const keys = ["allowed-tools", "tools"];
|
|
661
|
+
for (const key of keys) {
|
|
662
|
+
if (!(key in data)) continue;
|
|
663
|
+
const value = data[key];
|
|
664
|
+
const line = findKeyLine(file.frontmatter.raw, key, fmStart);
|
|
665
|
+
if (value !== void 0 && value !== null && typeof value !== "string" && !Array.isArray(value)) {
|
|
666
|
+
findings.push(
|
|
667
|
+
finding(
|
|
668
|
+
"skill/frontmatter-schema",
|
|
669
|
+
`\`${key}\` must be a space/comma-separated string or a YAML list.`,
|
|
670
|
+
line
|
|
671
|
+
)
|
|
672
|
+
);
|
|
673
|
+
continue;
|
|
674
|
+
}
|
|
675
|
+
const tokens = parseToolList(value);
|
|
676
|
+
if (tokens.length === 0) continue;
|
|
677
|
+
const wild = tokens.find((t) => isWildcardTool(t));
|
|
678
|
+
if (wild) {
|
|
679
|
+
findings.push(
|
|
680
|
+
finding(
|
|
681
|
+
"tools/wildcard-grant",
|
|
682
|
+
`\`${key}\` grants a wildcard ("${wild}"). Least-privilege: list only the specific tools this skill needs.`,
|
|
683
|
+
line,
|
|
684
|
+
{ evidence: makeEvidence(tokens.join(", ")) }
|
|
685
|
+
)
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
const seen = /* @__PURE__ */ new Set();
|
|
689
|
+
const dups = /* @__PURE__ */ new Set();
|
|
690
|
+
for (const t of tokens) {
|
|
691
|
+
const key2 = t.trim();
|
|
692
|
+
if (seen.has(key2)) dups.add(key2);
|
|
693
|
+
seen.add(key2);
|
|
694
|
+
}
|
|
695
|
+
if (dups.size > 0) {
|
|
696
|
+
findings.push(
|
|
697
|
+
finding(
|
|
698
|
+
"tools/duplicate-tool",
|
|
699
|
+
`\`${key}\` lists duplicate tool(s): ${[...dups].join(", ")}.`,
|
|
700
|
+
line
|
|
701
|
+
)
|
|
702
|
+
);
|
|
703
|
+
}
|
|
704
|
+
const desc = typeof data["description"] === "string" ? data["description"] : "";
|
|
705
|
+
const sensitiveGranted = tokens.map((t) => normalizeToolName(t)).filter((n) => SENSITIVE_TOOLS.has(n));
|
|
706
|
+
if (!wild && sensitiveGranted.length > 0 && READONLY_HINT_RE.test(desc)) {
|
|
707
|
+
findings.push(
|
|
708
|
+
finding(
|
|
709
|
+
"tools/over-broad-for-readonly",
|
|
710
|
+
`Description implies a read-only task but \`${key}\` grants write/exec/network tool(s): ${[
|
|
711
|
+
...new Set(sensitiveGranted)
|
|
712
|
+
].join(", ")}. Drop them or adjust the description.`,
|
|
713
|
+
line,
|
|
714
|
+
{ evidence: makeEvidence(desc) }
|
|
715
|
+
)
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
function lintUnknownFields(file, data, findings) {
|
|
721
|
+
const fmStart = file.frontmatter.startLine || 1;
|
|
722
|
+
const allowed = file.kind === "subagent" ? SUBAGENT_FIELDS : unionFields();
|
|
723
|
+
for (const key of Object.keys(data)) {
|
|
724
|
+
if (!allowed.has(key)) {
|
|
725
|
+
findings.push(
|
|
726
|
+
finding(
|
|
727
|
+
"skill/unknown-field",
|
|
728
|
+
`Unknown frontmatter field "${key}" \u2014 not part of the Agent Skills spec or known Claude Code extensions. (Lenient: clients may add custom fields.)`,
|
|
729
|
+
findKeyLine(file.frontmatter.raw, key, fmStart)
|
|
730
|
+
)
|
|
731
|
+
);
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
function unionFields() {
|
|
736
|
+
const s = new Set(SPEC_SKILL_FIELDS);
|
|
737
|
+
for (const f of CLAUDE_CODE_SKILL_FIELDS) s.add(f);
|
|
738
|
+
return s;
|
|
739
|
+
}
|
|
740
|
+
function lintTrailingWhitespace(file, findings) {
|
|
741
|
+
if (!file.frontmatter.present) return;
|
|
742
|
+
const lines = file.frontmatter.raw.split(/\r?\n/);
|
|
743
|
+
const fmStart = file.frontmatter.startLine || 1;
|
|
744
|
+
for (let i = 0; i < lines.length; i++) {
|
|
745
|
+
const l = lines[i] ?? "";
|
|
746
|
+
if (/[ \t]+$/.test(l) && l.trim().length > 0) {
|
|
747
|
+
findings.push(
|
|
748
|
+
finding(
|
|
749
|
+
"skill/trailing-whitespace",
|
|
750
|
+
"Frontmatter line has trailing whitespace.",
|
|
751
|
+
fmStart + i
|
|
752
|
+
)
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function lintDuplicateKeys(file, findings) {
|
|
758
|
+
if (!file.frontmatter.present) return;
|
|
759
|
+
const lines = file.frontmatter.raw.split(/\r?\n/);
|
|
760
|
+
const fmStart = file.frontmatter.startLine || 1;
|
|
761
|
+
const seen = /* @__PURE__ */ new Map();
|
|
762
|
+
for (let i = 0; i < lines.length; i++) {
|
|
763
|
+
const l = lines[i] ?? "";
|
|
764
|
+
const m = /^([A-Za-z0-9_-]+)\s*:/.exec(l);
|
|
765
|
+
if (!m) continue;
|
|
766
|
+
const key = m[1];
|
|
767
|
+
if (seen.has(key)) {
|
|
768
|
+
findings.push(
|
|
769
|
+
finding(
|
|
770
|
+
"skill/duplicate-key",
|
|
771
|
+
`Frontmatter key "${key}" appears more than once; YAML keeps only the last value.`,
|
|
772
|
+
fmStart + i
|
|
773
|
+
)
|
|
774
|
+
);
|
|
775
|
+
} else {
|
|
776
|
+
seen.set(key, i);
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/security/scan.ts
|
|
782
|
+
var INJECTION_PATTERNS = [
|
|
783
|
+
{
|
|
784
|
+
ruleId: "sec/prompt-injection",
|
|
785
|
+
re: /ignore (?:all |any |the )?(?:previous|prior|above|earlier|preceding)[^\n]{0,30}\b(?:instruction|prompt|message|context|rule)s?/gi,
|
|
786
|
+
message: () => 'Contains "ignore previous instructions"-style injection.'
|
|
787
|
+
},
|
|
788
|
+
{
|
|
789
|
+
ruleId: "sec/prompt-injection",
|
|
790
|
+
re: /disregard[^\n]{0,30}\b(?:previous|prior|system|above|all)[^\n]{0,20}\b(?:instruction|prompt|rule|message)s?/gi,
|
|
791
|
+
message: () => 'Contains "disregard ... instructions/system prompt" injection.'
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
ruleId: "sec/prompt-injection",
|
|
795
|
+
re: /(?:forget|override|bypass)[^\n]{0,30}\b(?:your |the )?(?:system )?(?:prompt|instruction|rule|guideline)s?/gi,
|
|
796
|
+
message: () => "Instructs the agent to forget/override/bypass its prompt or rules."
|
|
797
|
+
},
|
|
798
|
+
{
|
|
799
|
+
ruleId: "sec/prompt-injection",
|
|
800
|
+
re: /\b(?:you are now|from now on,? you are|act as)[^\n]{0,40}\b(?:DAN|jailbroken|unrestricted|developer mode|no(?:t bound by| longer bound by| restrictions))/gi,
|
|
801
|
+
message: () => "Contains a role-override / jailbreak persona instruction."
|
|
802
|
+
},
|
|
803
|
+
{
|
|
804
|
+
ruleId: "sec/prompt-injection",
|
|
805
|
+
re: /\bnew (?:system )?(?:instructions?|prompt|directive)s?\s*:/gi,
|
|
806
|
+
message: () => 'Injects a "new instructions:" block (prompt-override pattern).'
|
|
807
|
+
}
|
|
808
|
+
];
|
|
809
|
+
var SAFETY_PATTERNS = [
|
|
810
|
+
{
|
|
811
|
+
ruleId: "sec/disable-safety",
|
|
812
|
+
// Negative lookbehind: don't flag safety *documentation* like
|
|
813
|
+
// "never disable the content filter" / "do not bypass guardrails".
|
|
814
|
+
re: /(?<!\b(?:never|not|do not|don'?t|cannot|must not|should not)\s{1,6})\b(?:disable|turn off|bypass|skip|remove|ignore)[^\n]{0,40}\b(?:safety|guardrail|guard|content[- ]?filter|moderation|approval|confirmation|hook)s?\b/gi,
|
|
815
|
+
message: () => "Instructs the agent to disable safety/guardrails/hooks."
|
|
816
|
+
},
|
|
817
|
+
{
|
|
818
|
+
ruleId: "sec/disable-safety",
|
|
819
|
+
re: /(?<!\b(?:never|not|do not|don'?t|cannot|must not|should not)\s{1,6})\b(?:without|skip|no need for|don'?t (?:ask|request|require))[^\n]{0,30}\b(?:permission|approval|confirmation|consent)\b/gi,
|
|
820
|
+
message: () => "Tells the agent to act without permission/approval/confirmation."
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
ruleId: "sec/disable-safety",
|
|
824
|
+
re: /--dangerously-skip-permissions|CLAUDE_SKIP_[A-Z_]+\s*=\s*1|--yolo\b/g,
|
|
825
|
+
message: () => "References a flag/env that disables the harness safety prompts."
|
|
826
|
+
}
|
|
827
|
+
];
|
|
828
|
+
var ENCODE_PATTERNS = [
|
|
829
|
+
{
|
|
830
|
+
ruleId: "sec/env-base64",
|
|
831
|
+
re: /\b(?:base64|btoa|b64encode|xxd|openssl enc)\b[^\n]{0,60}\b(?:env|environ|secret|token|key|password|credential)/gi,
|
|
832
|
+
message: () => "Encodes environment/secret values (possible covert exfil)."
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
ruleId: "sec/env-base64",
|
|
836
|
+
re: /\b(?:env|printenv|cat[^\n]{0,20}\.env)\b[^\n]{0,30}\|\s*(?:base64|xxd|openssl)/gi,
|
|
837
|
+
message: () => "Pipes environment/.env contents into an encoder."
|
|
838
|
+
}
|
|
839
|
+
];
|
|
840
|
+
var SECRET_ACCESS_PATTERNS = [
|
|
841
|
+
{
|
|
842
|
+
ruleId: "sec/secret-access",
|
|
843
|
+
re: /(?:~\/?\.ssh\/|\.ssh\/id_(?:rsa|ed25519|ecdsa)|authorized_keys|known_hosts)/g,
|
|
844
|
+
message: () => "References SSH key material (~/.ssh, id_rsa, \u2026)."
|
|
845
|
+
},
|
|
846
|
+
{
|
|
847
|
+
ruleId: "sec/secret-access",
|
|
848
|
+
re: /(?:\.aws\/credentials|\.config\/gcloud|\.kube\/config|\.docker\/config\.json|\.netrc|\.npmrc)\b/g,
|
|
849
|
+
message: () => "References a cloud/credential config file."
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
ruleId: "sec/secret-access",
|
|
853
|
+
re: /\b(?:cat|read|open|less|head|tail)\b[^\n]{0,20}\b[^\n]{0,40}\.env(?:\.[a-z]+)?\b/gi,
|
|
854
|
+
message: () => "Reads a .env file (may contain secrets)."
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
ruleId: "sec/secret-access",
|
|
858
|
+
re: /\b(?:AWS_SECRET_ACCESS_KEY|AWS_ACCESS_KEY_ID|GITHUB_TOKEN|OPENAI_API_KEY|ANTHROPIC_API_KEY|GH_TOKEN|NPM_TOKEN|STRIPE_[A-Z_]*KEY|DATABASE_URL)\b/g,
|
|
859
|
+
message: (m) => `References a known secret environment variable (${m[0]}).`
|
|
860
|
+
}
|
|
861
|
+
];
|
|
862
|
+
var DESTRUCTIVE_PATTERNS = [
|
|
863
|
+
{
|
|
864
|
+
ruleId: "sec/destructive-command",
|
|
865
|
+
re: /\brm\s+-[a-z]*r[a-z]*f?\s+(?:\/|~|\$HOME|\*)/gi,
|
|
866
|
+
message: () => "Contains a recursive force-delete (rm -rf) of a broad path."
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
ruleId: "sec/destructive-command",
|
|
870
|
+
re: /\b(?:curl|wget)\b[^\n]{0,80}\|\s*(?:sudo\s+)?(?:bash|sh|zsh|python3?|node)\b/gi,
|
|
871
|
+
message: () => "Pipes a downloaded script straight into a shell (curl | sh)."
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
ruleId: "sec/destructive-command",
|
|
875
|
+
re: /\bgit\s+push[^\n]{0,40}(?:--force\b|-f\b|\+[A-Za-z])/gi,
|
|
876
|
+
message: () => "Contains a force-push (git push --force)."
|
|
877
|
+
},
|
|
878
|
+
{
|
|
879
|
+
ruleId: "sec/destructive-command",
|
|
880
|
+
re: /\b(?:chmod|chown)\s+-R[^\n]{0,20}(?:777|a\+rwx)/gi,
|
|
881
|
+
message: () => "Recursively grants world-writable permissions."
|
|
882
|
+
}
|
|
883
|
+
];
|
|
884
|
+
var OUTBOUND_RE = /\b(?:curl|wget|fetch|axios|http(?:s)?\.request|requests\.(?:post|get)|invoke-webrequest|Invoke-RestMethod)\b[^\n]{0,200}https?:\/\/[^\s"'`]+/gi;
|
|
885
|
+
var SECRET_NEAR_RE = /\b(?:env|environ|process\.env|secret|token|api[_-]?key|password|credential|\$[A-Z_]{3,})\b/i;
|
|
886
|
+
var SINGLE_PATTERN_GROUPS = [
|
|
887
|
+
INJECTION_PATTERNS,
|
|
888
|
+
SAFETY_PATTERNS,
|
|
889
|
+
ENCODE_PATTERNS,
|
|
890
|
+
SECRET_ACCESS_PATTERNS,
|
|
891
|
+
DESTRUCTIVE_PATTERNS
|
|
892
|
+
];
|
|
893
|
+
var HIDDEN_UNICODE_RE = new RegExp(
|
|
894
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060\\u2066-\\u2069\\uFEFF]",
|
|
895
|
+
"g"
|
|
896
|
+
);
|
|
897
|
+
function finding2(ruleId, message, line, column, evidence) {
|
|
898
|
+
const rule = getRule(ruleId);
|
|
899
|
+
const f = {
|
|
900
|
+
ruleId: rule.ruleId,
|
|
901
|
+
title: rule.title,
|
|
902
|
+
category: rule.category,
|
|
903
|
+
severity: rule.defaultSeverity,
|
|
904
|
+
message,
|
|
905
|
+
line: Math.max(1, line),
|
|
906
|
+
column: Math.max(1, column),
|
|
907
|
+
fixable: rule.fixable
|
|
908
|
+
};
|
|
909
|
+
if (evidence !== void 0) f.evidence = evidence;
|
|
910
|
+
return f;
|
|
911
|
+
}
|
|
912
|
+
function scanFile(file) {
|
|
913
|
+
const findings = [];
|
|
914
|
+
const segments = [];
|
|
915
|
+
if (file.body.length > 0) {
|
|
916
|
+
segments.push({ text: file.body, baseLine: file.bodyStartLine });
|
|
917
|
+
}
|
|
918
|
+
const desc = file.frontmatter.data?.["description"];
|
|
919
|
+
if (typeof desc === "string" && desc.length > 0) {
|
|
920
|
+
segments.push({ text: desc, baseLine: file.frontmatter.startLine || 1 });
|
|
921
|
+
}
|
|
922
|
+
if (file.kind === "agents-md" || file.kind === "unknown") {
|
|
923
|
+
segments.length = 0;
|
|
924
|
+
segments.push({ text: file.raw, baseLine: 1 });
|
|
925
|
+
}
|
|
926
|
+
for (const seg of segments) {
|
|
927
|
+
runSinglePatterns(seg.text, seg.baseLine, findings);
|
|
928
|
+
runExfilCheck(seg.text, seg.baseLine, findings);
|
|
929
|
+
runHiddenUnicode(seg.text, seg.baseLine, findings);
|
|
930
|
+
}
|
|
931
|
+
runSuspiciousToolCombo(file, findings);
|
|
932
|
+
return findings;
|
|
933
|
+
}
|
|
934
|
+
function locInSegment(text, index, baseLine) {
|
|
935
|
+
const { line, column } = offsetToLineCol(text, index);
|
|
936
|
+
return { line: baseLine + (line - 1), column };
|
|
937
|
+
}
|
|
938
|
+
function runSinglePatterns(text, baseLine, findings) {
|
|
939
|
+
for (const group of SINGLE_PATTERN_GROUPS) {
|
|
940
|
+
for (const pat of group) {
|
|
941
|
+
pat.re.lastIndex = 0;
|
|
942
|
+
let m;
|
|
943
|
+
let guard = 0;
|
|
944
|
+
while ((m = pat.re.exec(text)) !== null) {
|
|
945
|
+
const { line, column } = locInSegment(text, m.index, baseLine);
|
|
946
|
+
findings.push(
|
|
947
|
+
finding2(pat.ruleId, pat.message(m), line, column, makeEvidence(m[0]))
|
|
948
|
+
);
|
|
949
|
+
if (m.index === pat.re.lastIndex) pat.re.lastIndex++;
|
|
950
|
+
if (++guard > 1e3) break;
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
function runExfilCheck(text, baseLine, findings) {
|
|
956
|
+
OUTBOUND_RE.lastIndex = 0;
|
|
957
|
+
let m;
|
|
958
|
+
let guard = 0;
|
|
959
|
+
while ((m = OUTBOUND_RE.exec(text)) !== null) {
|
|
960
|
+
const start = Math.max(0, m.index - 160);
|
|
961
|
+
const end = Math.min(text.length, m.index + m[0].length + 160);
|
|
962
|
+
const window = text.slice(start, end);
|
|
963
|
+
if (SECRET_NEAR_RE.test(window)) {
|
|
964
|
+
const { line, column } = locInSegment(text, m.index, baseLine);
|
|
965
|
+
findings.push(
|
|
966
|
+
finding2(
|
|
967
|
+
"sec/data-exfiltration",
|
|
968
|
+
"Outbound network call near secret/env values \u2014 possible data exfiltration.",
|
|
969
|
+
line,
|
|
970
|
+
column,
|
|
971
|
+
makeEvidence(m[0])
|
|
972
|
+
)
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
if (m.index === OUTBOUND_RE.lastIndex) OUTBOUND_RE.lastIndex++;
|
|
976
|
+
if (++guard > 1e3) break;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
function runHiddenUnicode(text, baseLine, findings) {
|
|
980
|
+
HIDDEN_UNICODE_RE.lastIndex = 0;
|
|
981
|
+
const m = HIDDEN_UNICODE_RE.exec(text);
|
|
982
|
+
if (m) {
|
|
983
|
+
const { line, column } = locInSegment(text, m.index, baseLine);
|
|
984
|
+
findings.push(
|
|
985
|
+
finding2(
|
|
986
|
+
"sec/hidden-unicode",
|
|
987
|
+
"Contains zero-width or bidirectional Unicode control characters that can hide text from a human reviewer.",
|
|
988
|
+
line,
|
|
989
|
+
column
|
|
990
|
+
)
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
function runSuspiciousToolCombo(file, findings) {
|
|
995
|
+
const data = file.frontmatter.data;
|
|
996
|
+
if (!data) return;
|
|
997
|
+
const toolValue = data["allowed-tools"] ?? data["tools"];
|
|
998
|
+
const tokens = parseToolList(toolValue).map((t) => normalizeToolName(t));
|
|
999
|
+
if (tokens.length === 0) return;
|
|
1000
|
+
const hasExec = tokens.some((t) => EXEC_TOOLS.has(t));
|
|
1001
|
+
const hasNet = tokens.some((t) => NETWORK_TOOLS.has(t));
|
|
1002
|
+
const desc = typeof data["description"] === "string" ? data["description"] : "";
|
|
1003
|
+
const readonly = READONLY_HINT_RE.test(desc);
|
|
1004
|
+
if (readonly && hasExec && hasNet) {
|
|
1005
|
+
findings.push(
|
|
1006
|
+
finding2(
|
|
1007
|
+
"sec/suspicious-tool-combo",
|
|
1008
|
+
"A read-only/docs skill grants both shell execution and network access \u2014 this combination enables data exfiltration. Remove one or revise the description.",
|
|
1009
|
+
file.frontmatter.startLine || 1,
|
|
1010
|
+
1,
|
|
1011
|
+
makeEvidence(tokens.join(", "))
|
|
1012
|
+
)
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
|
|
1017
|
+
// src/grade.ts
|
|
1018
|
+
var PENALTY = {
|
|
1019
|
+
lint: { error: 12, warning: 5, info: 1 },
|
|
1020
|
+
// Security errors are deliberately severe: a single hard hit should never
|
|
1021
|
+
// leave a file with a passing grade.
|
|
1022
|
+
security: { error: 60, warning: 20, info: 4 }
|
|
1023
|
+
};
|
|
1024
|
+
function scoreFindings(findings) {
|
|
1025
|
+
let penalty = 0;
|
|
1026
|
+
for (const f of findings) {
|
|
1027
|
+
penalty += PENALTY[f.category][f.severity];
|
|
1028
|
+
}
|
|
1029
|
+
return Math.max(0, Math.min(100, 100 - penalty));
|
|
1030
|
+
}
|
|
1031
|
+
function scoreToGrade(score) {
|
|
1032
|
+
if (score >= 90) return "A";
|
|
1033
|
+
if (score >= 80) return "B";
|
|
1034
|
+
if (score >= 70) return "C";
|
|
1035
|
+
if (score >= 60) return "D";
|
|
1036
|
+
return "F";
|
|
1037
|
+
}
|
|
1038
|
+
function tally(findings) {
|
|
1039
|
+
const totals = { error: 0, warning: 0, info: 0 };
|
|
1040
|
+
for (const f of findings) totals[f.severity]++;
|
|
1041
|
+
return totals;
|
|
1042
|
+
}
|
|
1043
|
+
function aggregateScore(fileScores) {
|
|
1044
|
+
if (fileScores.length === 0) return 100;
|
|
1045
|
+
const mean = fileScores.reduce((a, b) => a + b, 0) / fileScores.length;
|
|
1046
|
+
const worst = Math.min(...fileScores);
|
|
1047
|
+
return Math.round(mean * 0.6 + worst * 0.4);
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// src/analyze.ts
|
|
1051
|
+
function sortFindings(findings) {
|
|
1052
|
+
return [...findings].sort(
|
|
1053
|
+
(a, b) => a.line - b.line || a.column - b.column || a.ruleId.localeCompare(b.ruleId) || a.message.localeCompare(b.message)
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
function analyzeParsed(file) {
|
|
1057
|
+
const findings = [];
|
|
1058
|
+
findings.push(...lintFile(file));
|
|
1059
|
+
findings.push(...scanFile(file));
|
|
1060
|
+
return findings;
|
|
1061
|
+
}
|
|
1062
|
+
function toFileReport(file, findings) {
|
|
1063
|
+
const sorted = sortFindings(findings);
|
|
1064
|
+
const score = scoreFindings(sorted);
|
|
1065
|
+
return {
|
|
1066
|
+
filePath: file.filePath,
|
|
1067
|
+
kind: file.kind,
|
|
1068
|
+
findings: sorted,
|
|
1069
|
+
score,
|
|
1070
|
+
grade: scoreToGrade(score)
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
function applyDuplicateNameRule(files, perFileFindings) {
|
|
1074
|
+
const rule = getRule("skill/duplicate-name");
|
|
1075
|
+
const byName = /* @__PURE__ */ new Map();
|
|
1076
|
+
files.forEach((f, i) => {
|
|
1077
|
+
const name = f.frontmatter.data?.["name"];
|
|
1078
|
+
if (typeof name === "string" && name.trim().length > 0) {
|
|
1079
|
+
const key = name.trim();
|
|
1080
|
+
const arr = byName.get(key) ?? [];
|
|
1081
|
+
arr.push(i);
|
|
1082
|
+
byName.set(key, arr);
|
|
1083
|
+
}
|
|
1084
|
+
});
|
|
1085
|
+
for (const [name, indices] of byName) {
|
|
1086
|
+
if (indices.length < 2) continue;
|
|
1087
|
+
for (const i of indices) {
|
|
1088
|
+
const others = indices.filter((j) => j !== i).map((j) => files[j]?.filePath ?? "?");
|
|
1089
|
+
perFileFindings[i]?.push({
|
|
1090
|
+
ruleId: rule.ruleId,
|
|
1091
|
+
title: rule.title,
|
|
1092
|
+
category: rule.category,
|
|
1093
|
+
severity: rule.defaultSeverity,
|
|
1094
|
+
message: `Duplicate skill name "${name}" \u2014 also declared in: ${others.join(
|
|
1095
|
+
", "
|
|
1096
|
+
)}. Clients keep only one.`,
|
|
1097
|
+
line: files[i]?.frontmatter.startLine || 1,
|
|
1098
|
+
column: 1,
|
|
1099
|
+
fixable: rule.fixable
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
function analyzeContent(filePath, content, options = {}) {
|
|
1105
|
+
const parsed = parseFile(filePath, content, options.forceKind);
|
|
1106
|
+
const findings = analyzeParsed(parsed);
|
|
1107
|
+
return toFileReport(parsed, findings);
|
|
1108
|
+
}
|
|
1109
|
+
function analyzeFiles(inputs, options = {}) {
|
|
1110
|
+
const parsed = inputs.map(
|
|
1111
|
+
(i) => parseFile(i.filePath, i.content, options.forceKind)
|
|
1112
|
+
);
|
|
1113
|
+
const perFileFindings = parsed.map((p) => analyzeParsed(p));
|
|
1114
|
+
applyDuplicateNameRule(parsed, perFileFindings);
|
|
1115
|
+
const reports = parsed.map(
|
|
1116
|
+
(p, i) => toFileReport(p, perFileFindings[i] ?? [])
|
|
1117
|
+
);
|
|
1118
|
+
const all = reports.flatMap((r) => r.findings);
|
|
1119
|
+
const aggregate = aggregateScore(reports.map((r) => r.score));
|
|
1120
|
+
return {
|
|
1121
|
+
files: reports,
|
|
1122
|
+
score: aggregate,
|
|
1123
|
+
grade: scoreToGrade(aggregate),
|
|
1124
|
+
totals: tally(all)
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
function analyzePaths(paths, options = {}) {
|
|
1128
|
+
const inputs = paths.map((p) => ({
|
|
1129
|
+
filePath: p,
|
|
1130
|
+
content: readFileSync(p, "utf-8")
|
|
1131
|
+
}));
|
|
1132
|
+
return analyzeFiles(inputs, options);
|
|
1133
|
+
}
|
|
1134
|
+
function parseForFix(filePath, content) {
|
|
1135
|
+
return parseFile(filePath, content);
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
// src/fix.ts
|
|
1139
|
+
var DESCRIPTION_STUB = "TODO describe what this skill does and when to use it.";
|
|
1140
|
+
function fixFile(file) {
|
|
1141
|
+
const applied = [];
|
|
1142
|
+
if (file.kind === "agents-md" || file.kind === "unknown") {
|
|
1143
|
+
return { output: file.raw, changed: false, applied };
|
|
1144
|
+
}
|
|
1145
|
+
if (!file.frontmatter.present) {
|
|
1146
|
+
return { output: file.raw, changed: false, applied };
|
|
1147
|
+
}
|
|
1148
|
+
if (file.frontmatter.error) {
|
|
1149
|
+
return { output: file.raw, changed: false, applied };
|
|
1150
|
+
}
|
|
1151
|
+
const fmLines = file.frontmatter.raw.split("\n");
|
|
1152
|
+
let trimmedAny = false;
|
|
1153
|
+
for (let i = 0; i < fmLines.length; i++) {
|
|
1154
|
+
const line = fmLines[i] ?? "";
|
|
1155
|
+
const trimmed = line.replace(/[ \t]+$/, "");
|
|
1156
|
+
if (trimmed !== line) {
|
|
1157
|
+
fmLines[i] = trimmed;
|
|
1158
|
+
trimmedAny = true;
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
if (trimmedAny) applied.push("trailing-whitespace");
|
|
1162
|
+
for (const key of ["allowed-tools", "tools"]) {
|
|
1163
|
+
const idx = fmLines.findIndex(
|
|
1164
|
+
(l) => new RegExp(`^${key}\\s*:`).test(l ?? "")
|
|
1165
|
+
);
|
|
1166
|
+
if (idx === -1) continue;
|
|
1167
|
+
const line = fmLines[idx] ?? "";
|
|
1168
|
+
const m = new RegExp(`^(${key}\\s*:\\s*)(.*)$`).exec(line);
|
|
1169
|
+
if (!m) continue;
|
|
1170
|
+
const prefix = m[1];
|
|
1171
|
+
const rest = (m[2] ?? "").trim();
|
|
1172
|
+
if (rest.length === 0 || rest.startsWith("[")) continue;
|
|
1173
|
+
const tokens = parseToolList(rest);
|
|
1174
|
+
if (tokens.length === 0) continue;
|
|
1175
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1176
|
+
const deduped = [];
|
|
1177
|
+
for (const t of tokens) {
|
|
1178
|
+
const key2 = t.trim();
|
|
1179
|
+
if (!seen.has(key2)) {
|
|
1180
|
+
deduped.push(t);
|
|
1181
|
+
seen.add(key2);
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
if (deduped.length !== tokens.length) {
|
|
1185
|
+
const sep2 = rest.includes(",") ? ", " : " ";
|
|
1186
|
+
fmLines[idx] = prefix + deduped.join(sep2);
|
|
1187
|
+
applied.push(`dedupe-${key}`);
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
const hasDescriptionKey = fmLines.some((l) => /^description\s*:/.test(l ?? ""));
|
|
1191
|
+
const data = file.frontmatter.data;
|
|
1192
|
+
const descMissing = !data || data["description"] === void 0 || data["description"] === null;
|
|
1193
|
+
if (!hasDescriptionKey && descMissing) {
|
|
1194
|
+
const nameIdx = fmLines.findIndex((l) => /^name\s*:/.test(l ?? ""));
|
|
1195
|
+
let insertAt = 0;
|
|
1196
|
+
if (nameIdx !== -1) {
|
|
1197
|
+
const nameHasInlineValue = /^name\s*:\s*\S/.test(fmLines[nameIdx] ?? "");
|
|
1198
|
+
const nextIsContinuation = /^\s/.test(fmLines[nameIdx + 1] ?? "");
|
|
1199
|
+
insertAt = nameHasInlineValue && !nextIsContinuation ? nameIdx + 1 : 0;
|
|
1200
|
+
}
|
|
1201
|
+
fmLines.splice(insertAt, 0, `description: "${DESCRIPTION_STUB}"`);
|
|
1202
|
+
applied.push("add-description-stub");
|
|
1203
|
+
}
|
|
1204
|
+
if (applied.length === 0) {
|
|
1205
|
+
return { output: file.raw, changed: false, applied };
|
|
1206
|
+
}
|
|
1207
|
+
const newFrontmatter = fmLines.join("\n");
|
|
1208
|
+
const eol = file.raw.includes("\r\n") ? "\r\n" : "\n";
|
|
1209
|
+
const rebuilt = "---" + eol + newFrontmatter.split("\n").join(eol) + eol + "---" + eol + file.body.split("\n").join(eol);
|
|
1210
|
+
return { output: rebuilt, changed: true, applied };
|
|
1211
|
+
}
|
|
1212
|
+
var IGNORED_DIRS = /* @__PURE__ */ new Set([
|
|
1213
|
+
"node_modules",
|
|
1214
|
+
".git",
|
|
1215
|
+
"dist",
|
|
1216
|
+
"build",
|
|
1217
|
+
"coverage",
|
|
1218
|
+
".next",
|
|
1219
|
+
".turbo",
|
|
1220
|
+
".cache"
|
|
1221
|
+
]);
|
|
1222
|
+
function isCandidateMarkdown(name) {
|
|
1223
|
+
const lower = name.toLowerCase();
|
|
1224
|
+
if (!lower.endsWith(".md")) return false;
|
|
1225
|
+
return true;
|
|
1226
|
+
}
|
|
1227
|
+
function isGlob(p) {
|
|
1228
|
+
return /[*?[\]{}]/.test(p);
|
|
1229
|
+
}
|
|
1230
|
+
function globToRegExp(glob) {
|
|
1231
|
+
const g = glob.replace(/\\/g, "/");
|
|
1232
|
+
let re = "";
|
|
1233
|
+
for (let i = 0; i < g.length; i++) {
|
|
1234
|
+
const c = g[i];
|
|
1235
|
+
if (c === "*") {
|
|
1236
|
+
if (g[i + 1] === "*") {
|
|
1237
|
+
i++;
|
|
1238
|
+
if (g[i + 1] === "/") i++;
|
|
1239
|
+
while (g[i + 1] === "*" && g[i + 2] === "*") {
|
|
1240
|
+
i += 2;
|
|
1241
|
+
if (g[i + 1] === "/") i++;
|
|
1242
|
+
}
|
|
1243
|
+
re += "(?:.*/)?";
|
|
1244
|
+
} else {
|
|
1245
|
+
re += "[^/]*";
|
|
1246
|
+
}
|
|
1247
|
+
} else if (c === "?") {
|
|
1248
|
+
re += "[^/]";
|
|
1249
|
+
} else if ("\\^$.|+()[]{}".includes(c)) {
|
|
1250
|
+
re += "\\" + c;
|
|
1251
|
+
} else {
|
|
1252
|
+
re += c;
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return new RegExp("^" + re + "$");
|
|
1256
|
+
}
|
|
1257
|
+
function walk(dir, out) {
|
|
1258
|
+
let entries;
|
|
1259
|
+
try {
|
|
1260
|
+
entries = readdirSync(dir);
|
|
1261
|
+
} catch {
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
for (const entry of entries) {
|
|
1265
|
+
const full = join(dir, entry);
|
|
1266
|
+
let st;
|
|
1267
|
+
try {
|
|
1268
|
+
st = statSync(full);
|
|
1269
|
+
} catch {
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
1272
|
+
if (st.isDirectory()) {
|
|
1273
|
+
if (IGNORED_DIRS.has(entry)) continue;
|
|
1274
|
+
walk(full, out);
|
|
1275
|
+
} else if (st.isFile() && isCandidateMarkdown(entry)) {
|
|
1276
|
+
out.push(full);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
function globBaseDir(glob) {
|
|
1281
|
+
const norm = glob.replace(/\\/g, "/");
|
|
1282
|
+
const firstMeta = norm.search(/[*?[\]{}]/);
|
|
1283
|
+
const head = firstMeta === -1 ? norm : norm.slice(0, firstMeta);
|
|
1284
|
+
const lastSlash = head.lastIndexOf("/");
|
|
1285
|
+
const base = lastSlash === -1 ? "." : head.slice(0, lastSlash) || "/";
|
|
1286
|
+
return base;
|
|
1287
|
+
}
|
|
1288
|
+
function toPosix(p) {
|
|
1289
|
+
return p.split(sep).join(posix.sep);
|
|
1290
|
+
}
|
|
1291
|
+
function discoverFiles(specs) {
|
|
1292
|
+
const found = /* @__PURE__ */ new Set();
|
|
1293
|
+
for (const spec of specs) {
|
|
1294
|
+
if (isGlob(spec)) {
|
|
1295
|
+
const base = resolve(globBaseDir(spec));
|
|
1296
|
+
const collected = [];
|
|
1297
|
+
walk(base, collected);
|
|
1298
|
+
const re = globToRegExp(toPosix(resolve(spec)));
|
|
1299
|
+
for (const f of collected) {
|
|
1300
|
+
if (re.test(toPosix(f))) found.add(f);
|
|
1301
|
+
}
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
const abs = resolve(spec);
|
|
1305
|
+
if (!existsSync(abs)) {
|
|
1306
|
+
continue;
|
|
1307
|
+
}
|
|
1308
|
+
const st = statSync(abs);
|
|
1309
|
+
if (st.isDirectory()) {
|
|
1310
|
+
const collected = [];
|
|
1311
|
+
walk(abs, collected);
|
|
1312
|
+
for (const f of collected) found.add(f);
|
|
1313
|
+
} else if (st.isFile()) {
|
|
1314
|
+
found.add(abs);
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
return [...found].sort();
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// src/output/terminal.ts
|
|
1321
|
+
var ANSI = {
|
|
1322
|
+
reset: "\x1B[0m",
|
|
1323
|
+
bold: "\x1B[1m",
|
|
1324
|
+
dim: "\x1B[2m",
|
|
1325
|
+
red: "\x1B[31m",
|
|
1326
|
+
green: "\x1B[32m",
|
|
1327
|
+
yellow: "\x1B[33m",
|
|
1328
|
+
blue: "\x1B[34m",
|
|
1329
|
+
cyan: "\x1B[36m",
|
|
1330
|
+
gray: "\x1B[90m"
|
|
1331
|
+
};
|
|
1332
|
+
function colorEnabled(opt) {
|
|
1333
|
+
if (opt !== void 0) return opt;
|
|
1334
|
+
if (process.env["NO_COLOR"] !== void 0) return false;
|
|
1335
|
+
if (process.env["FORCE_COLOR"] !== void 0) return true;
|
|
1336
|
+
return Boolean(process.stdout.isTTY);
|
|
1337
|
+
}
|
|
1338
|
+
function paint(s, code, on) {
|
|
1339
|
+
return on ? `${code}${s}${ANSI.reset}` : s;
|
|
1340
|
+
}
|
|
1341
|
+
var SEVERITY_ICON = {
|
|
1342
|
+
error: "\u2716",
|
|
1343
|
+
warning: "\u26A0",
|
|
1344
|
+
info: "\u2139"
|
|
1345
|
+
};
|
|
1346
|
+
var SEVERITY_COLOR = {
|
|
1347
|
+
error: ANSI.red,
|
|
1348
|
+
warning: ANSI.yellow,
|
|
1349
|
+
info: ANSI.blue
|
|
1350
|
+
};
|
|
1351
|
+
var GRADE_COLOR = {
|
|
1352
|
+
A: ANSI.green,
|
|
1353
|
+
B: ANSI.green,
|
|
1354
|
+
C: ANSI.yellow,
|
|
1355
|
+
D: ANSI.yellow,
|
|
1356
|
+
F: ANSI.red
|
|
1357
|
+
};
|
|
1358
|
+
function severityLabel(sev, on) {
|
|
1359
|
+
return paint(`${SEVERITY_ICON[sev]} ${sev}`, SEVERITY_COLOR[sev], on);
|
|
1360
|
+
}
|
|
1361
|
+
function fileHeader(file, on) {
|
|
1362
|
+
const gradeBadge = paint(
|
|
1363
|
+
` ${file.grade} `,
|
|
1364
|
+
`${ANSI.bold}${GRADE_COLOR[file.grade]}`,
|
|
1365
|
+
on
|
|
1366
|
+
);
|
|
1367
|
+
const kind = paint(`[${file.kind}]`, ANSI.gray, on);
|
|
1368
|
+
return `${paint(file.filePath, ANSI.bold, on)} ${kind} ${gradeBadge}${paint(
|
|
1369
|
+
`(${file.score}/100)`,
|
|
1370
|
+
ANSI.dim,
|
|
1371
|
+
on
|
|
1372
|
+
)}`;
|
|
1373
|
+
}
|
|
1374
|
+
function findingLine(f, on) {
|
|
1375
|
+
const loc = paint(`${f.line}:${f.column}`, ANSI.gray, on);
|
|
1376
|
+
const sev = severityLabel(f.severity, on);
|
|
1377
|
+
const rule = paint(f.ruleId, ANSI.cyan, on);
|
|
1378
|
+
let out = ` ${loc} ${sev} ${f.message} ${rule}`;
|
|
1379
|
+
if (f.fixable) out += paint(" (fixable)", ANSI.dim, on);
|
|
1380
|
+
if (f.evidence) {
|
|
1381
|
+
out += `
|
|
1382
|
+
${paint("\u21B3 " + f.evidence, ANSI.gray, on)}`;
|
|
1383
|
+
}
|
|
1384
|
+
return out;
|
|
1385
|
+
}
|
|
1386
|
+
function renderTerminal(report, options = {}) {
|
|
1387
|
+
const on = colorEnabled(options.color);
|
|
1388
|
+
const lines = [];
|
|
1389
|
+
if (report.files.length === 0) {
|
|
1390
|
+
return paint("No skill / instruction files found.", ANSI.yellow, on);
|
|
1391
|
+
}
|
|
1392
|
+
for (const file of report.files) {
|
|
1393
|
+
lines.push(fileHeader(file, on));
|
|
1394
|
+
if (file.findings.length === 0) {
|
|
1395
|
+
lines.push(paint(" \u2713 no findings", ANSI.green, on));
|
|
1396
|
+
} else {
|
|
1397
|
+
for (const f of file.findings) {
|
|
1398
|
+
lines.push(findingLine(f, on));
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
lines.push("");
|
|
1402
|
+
}
|
|
1403
|
+
const { error, warning, info } = report.totals;
|
|
1404
|
+
const summaryBadge = paint(
|
|
1405
|
+
` Grade ${report.grade} `,
|
|
1406
|
+
`${ANSI.bold}${GRADE_COLOR[report.grade]}`,
|
|
1407
|
+
on
|
|
1408
|
+
);
|
|
1409
|
+
lines.push(
|
|
1410
|
+
paint("\u2500".repeat(48), ANSI.gray, on)
|
|
1411
|
+
);
|
|
1412
|
+
lines.push(
|
|
1413
|
+
`${summaryBadge} ${paint(`${report.score}/100`, ANSI.bold, on)} across ${report.files.length} file(s)`
|
|
1414
|
+
);
|
|
1415
|
+
lines.push(
|
|
1416
|
+
` ${paint(`${error} error(s)`, error ? ANSI.red : ANSI.gray, on)} ${paint(`${warning} warning(s)`, warning ? ANSI.yellow : ANSI.gray, on)} ${paint(`${info} info`, info ? ANSI.blue : ANSI.gray, on)}`
|
|
1417
|
+
);
|
|
1418
|
+
return lines.join("\n");
|
|
1419
|
+
}
|
|
1420
|
+
|
|
1421
|
+
// src/output/json.ts
|
|
1422
|
+
var JSON_REPORT_VERSION = 1;
|
|
1423
|
+
function toJsonReport(report, toolVersion) {
|
|
1424
|
+
return {
|
|
1425
|
+
schemaVersion: JSON_REPORT_VERSION,
|
|
1426
|
+
tool: { name: "skilldoctor", version: toolVersion },
|
|
1427
|
+
summary: {
|
|
1428
|
+
grade: report.grade,
|
|
1429
|
+
score: report.score,
|
|
1430
|
+
fileCount: report.files.length,
|
|
1431
|
+
errors: report.totals.error,
|
|
1432
|
+
warnings: report.totals.warning,
|
|
1433
|
+
infos: report.totals.info
|
|
1434
|
+
},
|
|
1435
|
+
files: report.files
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
function jsonString(report, toolVersion) {
|
|
1439
|
+
return JSON.stringify(toJsonReport(report, toolVersion), null, 2);
|
|
1440
|
+
}
|
|
1441
|
+
var SARIF_VERSION = "2.1.0";
|
|
1442
|
+
var SARIF_SCHEMA = "https://json.schemastore.org/sarif-2.1.0.json";
|
|
1443
|
+
var TOOL_NAME = "skilldoctor";
|
|
1444
|
+
var TOOL_INFO_URI = "https://github.com/studiomeyer-io/skilldoctor";
|
|
1445
|
+
function toSarifLevel(sev) {
|
|
1446
|
+
switch (sev) {
|
|
1447
|
+
case "error":
|
|
1448
|
+
return "error";
|
|
1449
|
+
case "warning":
|
|
1450
|
+
return "warning";
|
|
1451
|
+
case "info":
|
|
1452
|
+
return "note";
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
function toUri(filePath, baseDir) {
|
|
1456
|
+
const rel = relative(baseDir, filePath);
|
|
1457
|
+
const norm = (rel === "" ? filePath : rel).split("\\").join("/");
|
|
1458
|
+
return norm.replace(/^\.\//, "");
|
|
1459
|
+
}
|
|
1460
|
+
function fingerprint(uri, f) {
|
|
1461
|
+
const norm = (f.evidence ?? f.message).replace(/\s+/g, " ").trim().toLowerCase();
|
|
1462
|
+
return createHash("sha256").update(f.ruleId).update("\0").update(uri).update("\0").update(norm).digest("hex").slice(0, 16);
|
|
1463
|
+
}
|
|
1464
|
+
function toSarif(report, options = {}) {
|
|
1465
|
+
const baseDir = options.baseDir ?? process.cwd();
|
|
1466
|
+
const toolVersion = options.version ?? "0.0.0";
|
|
1467
|
+
const ruleIndex = /* @__PURE__ */ new Map();
|
|
1468
|
+
const rules = RULES.map((r, i) => {
|
|
1469
|
+
ruleIndex.set(r.ruleId, i);
|
|
1470
|
+
return {
|
|
1471
|
+
id: r.ruleId,
|
|
1472
|
+
name: toPascalName(r.ruleId),
|
|
1473
|
+
shortDescription: { text: r.title },
|
|
1474
|
+
fullDescription: { text: r.description },
|
|
1475
|
+
defaultConfiguration: { level: toSarifLevel(r.defaultSeverity) },
|
|
1476
|
+
properties: {
|
|
1477
|
+
category: r.category,
|
|
1478
|
+
fixable: r.fixable
|
|
1479
|
+
}
|
|
1480
|
+
};
|
|
1481
|
+
});
|
|
1482
|
+
const results = [];
|
|
1483
|
+
for (const file of report.files) {
|
|
1484
|
+
const uri = toUri(file.filePath, baseDir);
|
|
1485
|
+
for (const f of file.findings) {
|
|
1486
|
+
const idx = ruleIndex.get(f.ruleId);
|
|
1487
|
+
if (idx === void 0) continue;
|
|
1488
|
+
results.push({
|
|
1489
|
+
ruleId: f.ruleId,
|
|
1490
|
+
ruleIndex: idx,
|
|
1491
|
+
level: toSarifLevel(f.severity),
|
|
1492
|
+
message: { text: f.message },
|
|
1493
|
+
locations: [
|
|
1494
|
+
{
|
|
1495
|
+
physicalLocation: {
|
|
1496
|
+
artifactLocation: { uri },
|
|
1497
|
+
region: {
|
|
1498
|
+
startLine: f.line,
|
|
1499
|
+
startColumn: f.column,
|
|
1500
|
+
...f.evidence ? { snippet: { text: f.evidence } } : {}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
],
|
|
1505
|
+
partialFingerprints: {
|
|
1506
|
+
skilldoctor: fingerprint(uri, f)
|
|
1507
|
+
},
|
|
1508
|
+
properties: {
|
|
1509
|
+
category: f.category,
|
|
1510
|
+
fixable: f.fixable
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
return {
|
|
1516
|
+
$schema: SARIF_SCHEMA,
|
|
1517
|
+
version: SARIF_VERSION,
|
|
1518
|
+
runs: [
|
|
1519
|
+
{
|
|
1520
|
+
tool: {
|
|
1521
|
+
driver: {
|
|
1522
|
+
name: TOOL_NAME,
|
|
1523
|
+
informationUri: TOOL_INFO_URI,
|
|
1524
|
+
version: toolVersion,
|
|
1525
|
+
rules
|
|
1526
|
+
}
|
|
1527
|
+
},
|
|
1528
|
+
results
|
|
1529
|
+
}
|
|
1530
|
+
]
|
|
1531
|
+
};
|
|
1532
|
+
}
|
|
1533
|
+
function sarifString(report, options) {
|
|
1534
|
+
return JSON.stringify(toSarif(report, options), null, 2);
|
|
1535
|
+
}
|
|
1536
|
+
function toPascalName(ruleId) {
|
|
1537
|
+
return ruleId.split(/[/\-_]/).filter(Boolean).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
export { DESCRIPTION_STUB, JSON_REPORT_VERSION, RULES, SARIF_SCHEMA, SARIF_VERSION, SEVERITY_RANK, aggregateScore, allRuleIds, analyzeContent, analyzeFiles, analyzePaths, detectKind, discoverFiles, extractFrontmatter, fixFile, getRule, globToRegExp, isGlob, jsonString, parseFile, parseForFix, renderTerminal, sarifString, scoreFindings, scoreToGrade, tally, toJsonReport, toSarif };
|