@hungpg/skill-audit 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/dist/spec.js ADDED
@@ -0,0 +1,271 @@
1
+ import { readFileSync, readdirSync, statSync } from "fs";
2
+ import { join } from "path";
3
+ import matter from "gray-matter";
4
+ /**
5
+ * Validate a skill directory against Agent Skills specification
6
+ */
7
+ export function validateSkillSpec(skillPath, dirName) {
8
+ const findings = [];
9
+ let manifest;
10
+ // SPEC-01: Check SKILL.md exists
11
+ const skillMdPath = join(skillPath, "SKILL.md");
12
+ let skillMdContent;
13
+ try {
14
+ skillMdContent = readFileSync(skillMdPath, "utf-8");
15
+ }
16
+ catch (e) {
17
+ findings.push({
18
+ id: "SPEC-01",
19
+ category: "SPEC",
20
+ asixx: "SPEC",
21
+ severity: "critical",
22
+ file: skillPath,
23
+ message: "SKILL.md is required but not found",
24
+ evidence: skillMdPath
25
+ });
26
+ return { valid: false, findings };
27
+ }
28
+ // SPEC-02: Parse frontmatter
29
+ let parsed;
30
+ try {
31
+ parsed = matter(skillMdContent);
32
+ }
33
+ catch (e) {
34
+ findings.push({
35
+ id: "SPEC-02",
36
+ category: "SPEC",
37
+ asixx: "SPEC",
38
+ severity: "critical",
39
+ file: skillMdPath,
40
+ message: "Failed to parse SKILL.md frontmatter",
41
+ evidence: String(e).slice(0, 100)
42
+ });
43
+ return { valid: false, findings };
44
+ }
45
+ manifest = {
46
+ name: parsed.data.name || "",
47
+ description: parsed.data.description || "",
48
+ origin: parsed.data.origin, // Custom metadata (not spec-required)
49
+ license: parsed.data.license,
50
+ compatibility: parsed.data.compatibility,
51
+ metadata: parsed.data.metadata,
52
+ allowedTools: parsed.data["allowed-tools"],
53
+ content: parsed.content,
54
+ files: []
55
+ };
56
+ // SPEC-03: Validate required 'name' field
57
+ if (!manifest.name) {
58
+ findings.push({
59
+ id: "SPEC-03",
60
+ category: "SPEC",
61
+ asixx: "SPEC",
62
+ severity: "critical",
63
+ file: skillMdPath,
64
+ message: "Frontmatter missing required 'name' field"
65
+ });
66
+ }
67
+ else {
68
+ // SPEC-04: name length check (<=64 chars)
69
+ if (manifest.name.length > 64) {
70
+ findings.push({
71
+ id: "SPEC-04",
72
+ category: "SPEC",
73
+ asixx: "SPEC",
74
+ severity: "high",
75
+ file: skillMdPath,
76
+ message: `name exceeds 64 char limit (${manifest.name.length} chars)`
77
+ });
78
+ }
79
+ // SPEC-05: name format (lowercase a-z0-9-)
80
+ if (!/^[a-z0-9-]+$/.test(manifest.name)) {
81
+ findings.push({
82
+ id: "SPEC-05",
83
+ category: "SPEC",
84
+ asixx: "SPEC",
85
+ severity: "high",
86
+ file: skillMdPath,
87
+ message: "name must only contain lowercase letters, numbers, and hyphens"
88
+ });
89
+ }
90
+ // SPEC-06: no leading/trailing hyphen
91
+ if (manifest.name.startsWith('-') || manifest.name.endsWith('-')) {
92
+ findings.push({
93
+ id: "SPEC-06",
94
+ category: "SPEC",
95
+ asixx: "SPEC",
96
+ severity: "high",
97
+ file: skillMdPath,
98
+ message: "name cannot start or end with a hyphen"
99
+ });
100
+ }
101
+ // SPEC-07: no consecutive hyphens
102
+ if (manifest.name.includes('--')) {
103
+ findings.push({
104
+ id: "SPEC-07",
105
+ category: "SPEC",
106
+ asixx: "SPEC",
107
+ severity: "high",
108
+ file: skillMdPath,
109
+ message: "name cannot contain consecutive hyphens"
110
+ });
111
+ }
112
+ // SPEC-08: name must match directory
113
+ if (manifest.name !== dirName) {
114
+ findings.push({
115
+ id: "SPEC-08",
116
+ category: "SPEC",
117
+ asixx: "SPEC",
118
+ severity: "high",
119
+ file: skillMdPath,
120
+ message: `name '${manifest.name}' must match directory '${dirName}'`
121
+ });
122
+ }
123
+ }
124
+ // SPEC-09: Validate required 'description' field
125
+ if (!manifest.description) {
126
+ findings.push({
127
+ id: "SPEC-09",
128
+ category: "SPEC",
129
+ asixx: "SPEC",
130
+ severity: "critical",
131
+ file: skillMdPath,
132
+ message: "Frontmatter missing required 'description' field"
133
+ });
134
+ }
135
+ else if (manifest.description.length > 1024) {
136
+ findings.push({
137
+ id: "SPEC-10",
138
+ category: "SPEC",
139
+ asixx: "SPEC",
140
+ severity: "high",
141
+ file: skillMdPath,
142
+ message: `description exceeds 1024 char limit (${manifest.description.length} chars)`
143
+ });
144
+ }
145
+ // SPEC-11: Optional license field validation
146
+ if (manifest.license && typeof manifest.license !== "string") {
147
+ findings.push({
148
+ id: "SPEC-11",
149
+ category: "SPEC",
150
+ asixx: "SPEC",
151
+ severity: "medium",
152
+ file: skillMdPath,
153
+ message: "license must be a string"
154
+ });
155
+ }
156
+ // SPEC-12: Optional compatibility field length
157
+ if (manifest.compatibility && String(manifest.compatibility).length > 500) {
158
+ findings.push({
159
+ id: "SPEC-12",
160
+ category: "SPEC",
161
+ asixx: "SPEC",
162
+ severity: "medium",
163
+ file: skillMdPath,
164
+ message: "compatibility field exceeds 500 char limit"
165
+ });
166
+ }
167
+ // SPEC-13: Validate allowed-tools structure (if present)
168
+ if (manifest.allowedTools) {
169
+ findings.push(...validateAllowedTools(manifest.allowedTools, skillMdPath));
170
+ }
171
+ // SPEC-14: SKILL.md length budget check
172
+ const lineCount = skillMdContent.split('\n').length;
173
+ if (lineCount > 500) {
174
+ findings.push({
175
+ id: "SPEC-13",
176
+ category: "SPEC",
177
+ asixx: "SPEC",
178
+ severity: "info",
179
+ file: skillMdPath,
180
+ message: `SKILL.md has ${lineCount} lines - consider progressive disclosure (typical max ~500)`
181
+ });
182
+ }
183
+ // SPEC-15: Directory structure sanity check
184
+ findings.push(...validateDirectoryStructure(skillPath));
185
+ const valid = !findings.some(f => f.severity === "critical");
186
+ return { valid, manifest, findings };
187
+ }
188
+ function validateAllowedTools(allowedTools, filePath) {
189
+ const findings = [];
190
+ if (Array.isArray(allowedTools)) {
191
+ for (const tool of allowedTools) {
192
+ if (typeof tool !== "string" && typeof tool !== "object") {
193
+ findings.push({
194
+ id: "SPEC-14",
195
+ category: "SPEC",
196
+ asixx: "SPEC",
197
+ severity: "medium",
198
+ file: filePath,
199
+ message: `allowed-tools contains non-string/object: ${typeof tool}`
200
+ });
201
+ }
202
+ }
203
+ }
204
+ else if (allowedTools !== undefined) {
205
+ findings.push({
206
+ id: "SPEC-15",
207
+ category: "SPEC",
208
+ asixx: "SPEC",
209
+ severity: "medium",
210
+ file: filePath,
211
+ message: "allowed-tools should be an array or undefined"
212
+ });
213
+ }
214
+ return findings;
215
+ }
216
+ function validateDirectoryStructure(skillPath) {
217
+ const findings = [];
218
+ // Check for recommended directories
219
+ const recommendedDirs = ["scripts", "references", "assets"];
220
+ const foundDirs = [];
221
+ try {
222
+ const entries = readdirSync(skillPath);
223
+ for (const entry of entries) {
224
+ const entryPath = join(skillPath, entry);
225
+ try {
226
+ const stat = statSync(entryPath);
227
+ if (stat.isDirectory()) {
228
+ foundDirs.push(entry);
229
+ }
230
+ }
231
+ catch {
232
+ // Skip inaccessible entries
233
+ }
234
+ }
235
+ }
236
+ catch (e) {
237
+ findings.push({
238
+ id: "SPEC-16",
239
+ category: "SPEC",
240
+ asixx: "SPEC",
241
+ severity: "low",
242
+ file: skillPath,
243
+ message: "Could not read skill directory structure",
244
+ evidence: String(e).slice(0, 100)
245
+ });
246
+ return findings;
247
+ }
248
+ // Warn if no recognized directories (not critical, just informational)
249
+ const hasAnyDir = recommendedDirs.some(d => foundDirs.includes(d));
250
+ if (foundDirs.length > 0 && !hasAnyDir) {
251
+ findings.push({
252
+ id: "SPEC-17",
253
+ category: "SPEC",
254
+ asixx: "SPEC",
255
+ severity: "info",
256
+ file: skillPath,
257
+ message: `Found directories: ${foundDirs.join(', ')} - consider scripts/, references/, assets/ for organization`
258
+ });
259
+ }
260
+ return findings;
261
+ }
262
+ /**
263
+ * Quick spec validation (for use in lint mode - less strict)
264
+ */
265
+ export function quickValidate(skillPath, dirName) {
266
+ const { valid, findings } = validateSkillSpec(skillPath, dirName);
267
+ const errors = findings
268
+ .filter(f => f.severity === "critical" || f.severity === "high")
269
+ .map(f => `[${f.id}] ${f.message}`);
270
+ return { valid, errors };
271
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@hungpg/skill-audit",
3
+ "version": "0.1.0",
4
+ "description": "Security auditing CLI for AI agent skills",
5
+ "type": "module",
6
+ "bin": {
7
+ "skill-audit": "dist/index.js"
8
+ },
9
+ "scripts": {
10
+ "build": "tsc",
11
+ "start": "node dist/index.js",
12
+ "dev": "tsx src/index.ts",
13
+ "postinstall": "node dist/index.js --update-db --quiet 2>/dev/null || echo \"⚠️ Vulnerability DB update skipped (run 'npm run security:update' later)\"",
14
+ "security:update": "node dist/index.js --update-db"
15
+ },
16
+ "engines": {
17
+ "node": ">=18.0.0"
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "SKILL.md"
23
+ ],
24
+ "keywords": [
25
+ "security",
26
+ "audit",
27
+ "cli",
28
+ "ai",
29
+ "agent",
30
+ "skills",
31
+ "vulnerability",
32
+ "owasp",
33
+ "prompt-injection",
34
+ "llm-security"
35
+ ],
36
+ "author": "Hung Pham",
37
+ "license": "MIT",
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/harrypham2000/skill-audit.git"
41
+ },
42
+ "bugs": {
43
+ "url": "https://github.com/harrypham2000/skill-audit/issues"
44
+ },
45
+ "homepage": "https://github.com/harrypham2000/skill-audit#readme",
46
+ "dependencies": {
47
+ "commander": "^11.1.0",
48
+ "gray-matter": "^4.0.3",
49
+ "semver": "^7.5.4"
50
+ },
51
+ "devDependencies": {
52
+ "@types/node": "^20.10.0",
53
+ "tsx": "^4.7.0",
54
+ "typescript": "^5.3.0"
55
+ }
56
+ }