@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.
@@ -0,0 +1,129 @@
1
+ const SEVERITY_SCORES = {
2
+ critical: 5.0,
3
+ high: 3.0,
4
+ medium: 1.5,
5
+ low: 0.5,
6
+ info: 0.1
7
+ };
8
+ const CATEGORY_WEIGHTS = {
9
+ SC: 2.0,
10
+ CE: 1.5,
11
+ PI: 1.2,
12
+ BM: 1.0,
13
+ TM: 1.0,
14
+ SPEC: 0.5, // Spec errors are less severe than security
15
+ PROV: 0.8, // Provenance issues
16
+ INTEL: 1.0 // Intelligence findings
17
+ };
18
+ export function calculateRiskScore(findings) {
19
+ const breakdown = { critical: 0, high: 0, medium: 0, low: 0 };
20
+ const categories = {};
21
+ const asixx = {};
22
+ for (const finding of findings) {
23
+ const severityScore = SEVERITY_SCORES[finding.severity] || 1.0;
24
+ const categoryWeight = CATEGORY_WEIGHTS[finding.category] || 1.0;
25
+ if (finding.severity in breakdown) {
26
+ breakdown[finding.severity]++;
27
+ }
28
+ categories[finding.category] = (categories[finding.category] || 0) + 1;
29
+ if (finding.asixx) {
30
+ asixx[finding.asixx] = (asixx[finding.asixx] || 0) + 1;
31
+ }
32
+ }
33
+ let total = 0;
34
+ for (const finding of findings) {
35
+ const severityScore = SEVERITY_SCORES[finding.severity] || 1.0;
36
+ const categoryWeight = CATEGORY_WEIGHTS[finding.category] || 1.0;
37
+ total += severityScore * categoryWeight;
38
+ }
39
+ total = Math.min(total, 10.0);
40
+ return {
41
+ total: Math.round(total * 10) / 10,
42
+ breakdown,
43
+ categories,
44
+ asixx
45
+ };
46
+ }
47
+ export function getRiskLevel(score) {
48
+ if (score === 0) {
49
+ return { label: "Safe", icon: "✅", color: "green" };
50
+ }
51
+ else if (score <= 3.0) {
52
+ return { label: "Risky", icon: "⚠️", color: "yellow" };
53
+ }
54
+ else if (score <= 7.0) {
55
+ return { label: "Dangerous", icon: "🔴", color: "red" };
56
+ }
57
+ else {
58
+ return { label: "Malicious", icon: "☠️", color: "black" };
59
+ }
60
+ }
61
+ export function getOWASPDescription(asixx) {
62
+ const descriptions = {
63
+ ASI01: "Goal Hijacking - Prompt Injection",
64
+ ASI02: "Tool Misuse and Exploitation",
65
+ ASI03: "Planning Strategy Manipulation",
66
+ ASI04: "Supply Chain Vulnerabilities",
67
+ ASI05: "Unexpected Code Execution",
68
+ ASI06: "Agentic Prompt Leakage",
69
+ ASI07: "Insecure Agent Output Handling",
70
+ ASI08: "Insufficient Human Oversight",
71
+ ASI09: "Trust Boundary Violation",
72
+ ASI10: "Agent Model Denial of Service"
73
+ };
74
+ return descriptions[asixx] || asixx;
75
+ }
76
+ export function createAuditResult(skill, manifest, findings, depFindings) {
77
+ const allFindings = [...findings, ...depFindings];
78
+ const riskScore = calculateRiskScore(allFindings);
79
+ const riskLevelInfo = getRiskLevel(riskScore.total);
80
+ // Map label to enum value
81
+ const riskLevel = riskLevelInfo.label === "Safe" ? "safe" :
82
+ riskLevelInfo.label === "Risky" ? "risky" :
83
+ riskLevelInfo.label === "Dangerous" ? "dangerous" : "malicious";
84
+ return {
85
+ skill,
86
+ manifest,
87
+ findings: allFindings,
88
+ riskScore: riskScore.total,
89
+ riskLevel
90
+ };
91
+ }
92
+ /**
93
+ * Create grouped audit result for layered output
94
+ * Spec findings drive the decision to block, security/intel are warnings
95
+ */
96
+ export function createGroupedAuditResult(skill, manifest, specFindings, securityFindings, intelFindings) {
97
+ // Spec findings get lower weight - they're blockers but not security-critical
98
+ const specScore = calculateRiskScore(specFindings);
99
+ const securityScore = calculateRiskScore(securityFindings);
100
+ const intelScore = calculateRiskScore(intelFindings);
101
+ // Combined score with weights
102
+ const totalScore = specScore.total * 0.3 + securityScore.total * 0.5 + intelScore.total * 0.2;
103
+ const finalScore = Math.min(totalScore, 10.0);
104
+ const riskLevelInfo = getRiskLevel(finalScore);
105
+ const riskLevel = riskLevelInfo.label === "Safe" ? "safe" :
106
+ riskLevelInfo.label === "Risky" ? "risky" :
107
+ riskLevelInfo.label === "Dangerous" ? "dangerous" : "malicious";
108
+ return {
109
+ skill,
110
+ manifest,
111
+ specFindings,
112
+ securityFindings,
113
+ intelFindings,
114
+ riskScore: Math.round(finalScore * 10) / 10,
115
+ riskLevel
116
+ };
117
+ }
118
+ /**
119
+ * Check if spec errors should block (critical or high severity)
120
+ */
121
+ export function hasBlockingSpecErrors(findings) {
122
+ return findings.some(f => f.severity === "critical" && f.category === "SPEC");
123
+ }
124
+ /**
125
+ * Check if security findings should block (critical severity)
126
+ */
127
+ export function hasBlockingSecurityFindings(findings) {
128
+ return findings.some(f => f.severity === "critical");
129
+ }
@@ -0,0 +1,341 @@
1
+ import { readFileSync } from "fs";
2
+ import { basename, extname } from "path";
3
+ import { resolveSkillPath, getSkillFiles } from "./discover.js";
4
+ /**
5
+ * Phase 1 - Layer 2: Security Auditor
6
+ *
7
+ * Detects dangerous behavior in skill content and bundled files.
8
+ * This runs AFTER spec validation - security findings may be warnings.
9
+ *
10
+ * Security categories (OWASP Agentic Top 10):
11
+ * - ASI01: Prompt Injection
12
+ * - ASI02: Tool Misuse / Exfiltration
13
+ * - ASI04: Secrets / Supply Chain
14
+ * - ASI05: Code Execution
15
+ * - ASI09: Behavioral Manipulation
16
+ */
17
+ // ============================================================
18
+ // PROMPT INJECTION PATTERNS (ASI01 - Goal Hijacking)
19
+ // ============================================================
20
+ const PROMPT_INJECTION_PATTERNS = [
21
+ // Critical - Block immediately
22
+ { pattern: /ignore\s+(all\s+)?previous\s+(instructions?|rules?)/i, id: "PI01", severity: "critical", message: "Instruction override - ignore previous instructions" },
23
+ { pattern: /you\s+(are\s+)?now\s+(a|an|i am)\s+\w+/i, id: "PI02", severity: "critical", message: "Roleplay bypass - role impersonation" },
24
+ { pattern: /forget\s+(everything|all|your)/i, id: "PI03", severity: "critical", message: "Context forgetting attempt" },
25
+ { pattern: /system\s+prompt\s+override/i, id: "PI04", severity: "critical", message: "System prompt override attempt" },
26
+ { pattern: /act\s+as\s+if\s+(you\s+)?(have|no)\s+(restrictions|limits)/i, id: "PI05", severity: "critical", message: "Restriction removal attempt" },
27
+ { pattern: /\[SYSTEM\]|\[ADMIN\]|\[ROOT\]/i, id: "PI06", severity: "critical", message: "Fake role tags injection" },
28
+ // High - Flag for review
29
+ { pattern: /end\s+of\s+system\s+prompt|---END---/i, id: "PI07", severity: "high", message: "Prompt termination marker" },
30
+ { pattern: /debug\s+mode\s*:\s*enabled|safety\s+mode\s*:\s*off/i, id: "PI08", severity: "high", message: "Safety toggle disable" },
31
+ { pattern: /<!--[\s\S]*?-->/g, id: "PI09", severity: "high", message: "Hidden instructions in HTML comments" },
32
+ { pattern: /note\s+to\s+AI:|AI\s+instruction:/i, id: "PI10", severity: "high", message: "AI directive injection" },
33
+ // Medium - Evaluate context
34
+ { pattern: /(?:you\s+must|you\s+should)\s+(not|never)/i, id: "PI11", severity: "medium", message: "Command to override restrictions" },
35
+ { pattern: /bypass\s+(restriction|rule|limit|safety)/i, id: "PI12", severity: "medium", message: "Bypass attempt" },
36
+ { pattern: /disregard\s+(all|your|the)\s+(previous|system)/i, id: "PI13", severity: "medium", message: "Disregard instruction pattern" },
37
+ { pattern: /i.*am\s+the\s+developer.*trust\s+me/i, id: "PI14", severity: "medium", message: "Social engineering - developer trust exploitation" },
38
+ ];
39
+ // ============================================================
40
+ // CREDENTIAL LEAKS (ASI04 - Supply Chain)
41
+ // ============================================================
42
+ // Only scan code files for credential patterns
43
+ const CREDENTIAL_PATTERNS_CODE = [
44
+ { pattern: /~\/\.ssh|\/\.ssh\//, id: "CL01", severity: "critical", message: "SSH credential path reference" },
45
+ { pattern: /~\/\.aws|\/\.aws\//, id: "CL02", severity: "critical", message: "AWS credential path reference" },
46
+ { pattern: /~\/\.env|mkdir.*\.env/, id: "CL03", severity: "critical", message: ".env file reference (potential secret exposure)" },
47
+ { pattern: /curl\s+(?!.*(-fsSL|-f\s|-L)).*\|\s*(sh|bash|perl|python)/, id: "CL04", severity: "critical", message: "Pipe to shell - code execution risk" },
48
+ { pattern: /wget\s+(?!.*(-q|-O)).*\|\s*(sh|bash)/, id: "CL05", severity: "critical", message: "Pipe to shell - code execution risk" },
49
+ { pattern: /nc\s+-[elv]\s+|netcat\s+-[elv]/, id: "CL06", severity: "critical", message: "Netcat reverse shell pattern" },
50
+ { pattern: /bash\s+-i\s+.*\&\s*\/dev\/tcp/, id: "CL07", severity: "critical", message: "Bash reverse shell pattern" },
51
+ ];
52
+ const CREDENTIAL_PATTERNS_MD = [
53
+ { pattern: /bash\s+-i\s+.*\&\s*\/dev\/tcp/, id: "CL07", severity: "critical", message: "Bash reverse shell pattern" },
54
+ ];
55
+ // ============================================================
56
+ // NETWORK EXFILTRATION (ASI02 - Tool Misuse)
57
+ // ============================================================
58
+ const EXFILTRATION_PATTERNS = [
59
+ { pattern: /https?:\/\/[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+/, id: "EX01", severity: "critical", message: "Raw IP address in URL - potential exfiltration" },
60
+ { pattern: /fetch\s*\(\s*["'`][^"']+\?(key|token|secret|password)/i, id: "EX03", severity: "critical", message: "API key in URL query string - exfiltration risk" },
61
+ { pattern: /\.send\(.*(http|https|external)/i, id: "EX04", severity: "critical", message: "Data send to external server" },
62
+ { pattern: /dns\.resolve|dns\.query|new\s+DNS/i, id: "EX05", severity: "critical", message: "DNS resolution - potential DNS tunneling" },
63
+ { pattern: /new\s+WebSocket\s*\(\s*["'`][^'"`]+["'`]\s*\)/, id: "EX06", severity: "high", message: "WebSocket connection - check target" },
64
+ { pattern: /readFile.*send|fetch.*readFile|read_file.*fetch/i, id: "EX07", severity: "critical", message: "File read + send exfiltration chain" },
65
+ ];
66
+ // ============================================================
67
+ // DANGEROUS CODE EXECUTION (ASI05)
68
+ // ============================================================
69
+ const DANGEROUS_PATTERNS = [
70
+ { pattern: /rm\s+-rf\s+\/\s*$/, id: "CE01", severity: "critical", message: "Destructive rm -rf / command (root)" },
71
+ { pattern: /rm\s+-rf\s+\$HOME|rm\s+-rf\s+~\s*$|rm\s+-rf\s+\/home\s*$|rm\s+-rf\s+\/tmp\s*$/, id: "CE02", severity: "high", message: "Recursive delete in user directory" },
72
+ { pattern: /exec\s+\$\(/, id: "CE03", severity: "high", message: "Dynamic command execution" },
73
+ { pattern: /eval\s+\$/, id: "CE04", severity: "high", message: "Eval with variable interpolation" },
74
+ { pattern: /subprocess.*shell\s*=\s*true/i, id: "CE05", severity: "medium", message: "Subprocess with shell=True" },
75
+ { pattern: /os\.system\s*\(/, id: "CE06", severity: "high", message: "os.system() call - shell injection risk" },
76
+ { pattern: /child_process.*exec\s*\(/, id: "CE07", severity: "medium", message: "child_process.exec - verify input sanitization" },
77
+ { pattern: /chmod\s+[47]777/, id: "CE08", severity: "high", message: "World-writable permissions" },
78
+ { pattern: /process\.fork\s*\(|child_process\.spawn\s*\(|subprocess\.spawn\s*\(/i, id: "CE09", severity: "high", message: "Process fork/spawn - potential crypto miner" },
79
+ ];
80
+ // ============================================================
81
+ // SECRET PATTERNS (ASI04 - Supply Chain)
82
+ // ============================================================
83
+ const SECRET_PATTERNS = [
84
+ { pattern: /sk-[a-zA-Z0-9]{20,}/, id: "SC01", severity: "critical", message: "OpenAI API key pattern" },
85
+ { pattern: /github_pat_[a-zA-Z0-9_]{20,}/, id: "SC02", severity: "critical", message: "GitHub PAT pattern" },
86
+ { pattern: /ghp_[a-zA-Z0-9]{36}/, id: "SC03", severity: "critical", message: "GitHub OAuth token pattern" },
87
+ { pattern: /xox[baprs]-[a-zA-Z0-9]{10,}/, id: "SC04", severity: "critical", message: "Slack token pattern" },
88
+ { pattern: /AKIA[0-9A-Z]{16}/, id: "SC05", severity: "critical", message: "AWS access key pattern" },
89
+ ];
90
+ // ============================================================
91
+ // TOOL MISUSE PATTERNS (ASI02)
92
+ // ============================================================
93
+ const TOOL_MISUSE_PATTERNS = [
94
+ { pattern: /upload.*(file|data).*(external|remote|server)/i, id: "TM01", severity: "high", message: "Potential data exfiltration pattern" },
95
+ { pattern: /export\s+(API|TOKEN|KEY|SECRET|PASSWORD|CREENTIAL)/i, id: "TM02", severity: "high", message: "Exporting sensitive environment variable" },
96
+ { pattern: /setenv.*(PASSWORD|TOKEN|KEY|SECRET|CREDS)/i, id: "TM03", severity: "high", message: "Setting sensitive environment variable" },
97
+ { pattern: /process\.env\[.*(KEY|SECRET|TOKEN|PASSWORD)/i, id: "TM04", severity: "medium", message: "Accessing sensitive env vars" },
98
+ ];
99
+ // ============================================================
100
+ // BEHAVIORAL/MANIPULATION (ASI09)
101
+ // ============================================================
102
+ const BEHAVIORAL_PATTERNS = [
103
+ { pattern: /^(always|never)\s+(say|do|follow|use|assume|accept|must|should)/im, id: "BM01", severity: "medium", message: "Absolute command pattern - may override safety" },
104
+ { pattern: /^(never|always)\s+(question|verify|check|ask)/im, id: "BM02", severity: "medium", message: "Verification suppression" },
105
+ { pattern: /^trust\s+(me|this| blindly)/im, id: "BM03", severity: "medium", message: "Blind trust request" },
106
+ { pattern: /^don.*t\s+(need|require).*(permission|approval|confirm)/im, id: "BM04", severity: "medium", message: "Permission bypass encouragement" },
107
+ { pattern: /^keep.*(this|secret|hidden).*(from|between)/im, id: "BM05", severity: "medium", message: "Secret keeping instruction" },
108
+ ];
109
+ // ============================================================
110
+ // PROVENANCE CHECKS (ASI04)
111
+ // ============================================================
112
+ const TRUSTED_DOMAINS = [
113
+ 'github.com',
114
+ 'raw.githubusercontent.com',
115
+ 'vercel.com',
116
+ 'www.github.com'
117
+ ];
118
+ const TRUSTED_PROTOCOLS = ['https:', 'git:'];
119
+ // ============================================================
120
+ // Helper Functions
121
+ // ============================================================
122
+ function isCodeFile(filename) {
123
+ const codeExtensions = [".sh", ".bash", ".py", ".js", ".ts", ".tsx", ".jsx", ".rb", ".go", ".rs", ".java", ".c", ".cpp", ".cs", ".php", ".yaml", ".yml"];
124
+ return codeExtensions.includes(extname(filename).toLowerCase());
125
+ }
126
+ function getCategoryFromId(id) {
127
+ if (id.startsWith("PI"))
128
+ return "PI";
129
+ if (id.startsWith("CL"))
130
+ return "SC";
131
+ if (id.startsWith("EX"))
132
+ return "TM";
133
+ if (id.startsWith("CE"))
134
+ return "CE";
135
+ if (id.startsWith("SC"))
136
+ return "SC";
137
+ if (id.startsWith("TM"))
138
+ return "TM";
139
+ if (id.startsWith("BM"))
140
+ return "BM";
141
+ if (id.startsWith("PROV"))
142
+ return "PROV";
143
+ return "SC";
144
+ }
145
+ function getASIXXFromId(id) {
146
+ if (id.startsWith("PI"))
147
+ return "ASI01";
148
+ if (id.startsWith("CL"))
149
+ return "ASI04";
150
+ if (id.startsWith("EX"))
151
+ return "ASI02";
152
+ if (id.startsWith("CE"))
153
+ return "ASI05";
154
+ if (id.startsWith("SC"))
155
+ return "ASI04";
156
+ if (id.startsWith("TM"))
157
+ return "ASI02";
158
+ if (id.startsWith("BM"))
159
+ return "ASI09";
160
+ if (id.startsWith("PROV"))
161
+ return "ASI04";
162
+ return "ASI04";
163
+ }
164
+ function scanContent(content, file, patterns) {
165
+ const findings = [];
166
+ const lines = content.split("\n");
167
+ for (const { pattern, id, severity = "medium", message } of patterns) {
168
+ for (let i = 0; i < lines.length; i++) {
169
+ if (pattern.test(lines[i])) {
170
+ findings.push({
171
+ id,
172
+ category: getCategoryFromId(id),
173
+ asixx: getASIXXFromId(id),
174
+ severity: severity,
175
+ file,
176
+ line: i + 1,
177
+ message,
178
+ evidence: lines[i].substring(0, 100)
179
+ });
180
+ }
181
+ }
182
+ }
183
+ return findings;
184
+ }
185
+ function scanCodeBlocksInMarkdown(content, file) {
186
+ const findings = [];
187
+ const codeBlockRegex = /```(\w+)?\n([\s\S]*?)```/g;
188
+ let match;
189
+ while ((match = codeBlockRegex.exec(content)) !== null) {
190
+ const code = match[2];
191
+ findings.push(...scanContent(code, file + " (code block)", CREDENTIAL_PATTERNS_CODE));
192
+ findings.push(...scanContent(code, file + " (code block)", EXFILTRATION_PATTERNS));
193
+ findings.push(...scanContent(code, file + " (code block)", DANGEROUS_PATTERNS));
194
+ findings.push(...scanContent(code, file + " (code block)", SECRET_PATTERNS));
195
+ }
196
+ return findings;
197
+ }
198
+ function checkProvenance(origin, skillPath) {
199
+ const findings = [];
200
+ try {
201
+ let url;
202
+ try {
203
+ url = new URL(origin);
204
+ }
205
+ catch {
206
+ findings.push({
207
+ id: "PROV-01",
208
+ category: "PROV",
209
+ asixx: "ASI04",
210
+ severity: "medium",
211
+ file: skillPath,
212
+ message: "Origin is not a URL - cannot verify provenance",
213
+ evidence: origin
214
+ });
215
+ return findings;
216
+ }
217
+ if (!TRUSTED_PROTOCOLS.includes(url.protocol)) {
218
+ findings.push({
219
+ id: "PROV-02",
220
+ category: "PROV",
221
+ asixx: "ASI04",
222
+ severity: "critical",
223
+ file: skillPath,
224
+ message: "Untrusted protocol - only https and git allowed",
225
+ evidence: origin
226
+ });
227
+ }
228
+ const hostname = url.hostname.toLowerCase();
229
+ const isTrusted = TRUSTED_DOMAINS.some(d => hostname === d || hostname.endsWith('.' + d));
230
+ if (!isTrusted) {
231
+ findings.push({
232
+ id: "PROV-03",
233
+ category: "PROV",
234
+ asixx: "ASI04",
235
+ severity: "high",
236
+ file: skillPath,
237
+ message: "Origin domain is not in trusted list",
238
+ evidence: origin
239
+ });
240
+ }
241
+ const isPinned = /[a-f0-9]{7,40}|v\d+\.\d+|release/.test(origin);
242
+ if (!isPinned && url.pathname.includes('/blob/')) {
243
+ findings.push({
244
+ id: "PROV-04",
245
+ category: "PROV",
246
+ asixx: "ASI04",
247
+ severity: "medium",
248
+ file: skillPath,
249
+ message: "Origin does not use pinned ref (commit SHA or tag)",
250
+ evidence: origin
251
+ });
252
+ }
253
+ }
254
+ catch (e) {
255
+ findings.push({
256
+ id: "PROV-ERR-01",
257
+ category: "PROV",
258
+ asixx: "ASI04",
259
+ severity: "low",
260
+ file: skillPath,
261
+ message: "Provenance check failed",
262
+ evidence: String(e).slice(0, 100)
263
+ });
264
+ }
265
+ return findings;
266
+ }
267
+ export function auditSecurity(skill, manifest) {
268
+ let resolvedPath;
269
+ try {
270
+ resolvedPath = resolveSkillPath(skill.path);
271
+ }
272
+ catch (e) {
273
+ return {
274
+ findings: [{
275
+ id: "SCAN-ERR-01",
276
+ category: "SC",
277
+ asixx: "ASI04",
278
+ severity: "medium",
279
+ file: skill.path,
280
+ message: "Could not resolve skill path",
281
+ evidence: String(e)
282
+ }],
283
+ unreadableFiles: []
284
+ };
285
+ }
286
+ const files = getSkillFiles(resolvedPath);
287
+ const findings = [];
288
+ const unreadableFiles = [];
289
+ for (const file of files) {
290
+ const filename = basename(file);
291
+ try {
292
+ const content = readFileSync(file, "utf-8");
293
+ if (filename === "SKILL.md" || filename === "AGENTS.md") {
294
+ findings.push(...scanContent(content, file, PROMPT_INJECTION_PATTERNS));
295
+ findings.push(...scanContent(content, file, CREDENTIAL_PATTERNS_MD));
296
+ findings.push(...scanContent(content, file, EXFILTRATION_PATTERNS));
297
+ findings.push(...scanContent(content, file, BEHAVIORAL_PATTERNS));
298
+ findings.push(...scanContent(content, file, DANGEROUS_PATTERNS));
299
+ findings.push(...scanCodeBlocksInMarkdown(content, file));
300
+ }
301
+ else if (isCodeFile(file)) {
302
+ findings.push(...scanContent(content, file, CREDENTIAL_PATTERNS_CODE));
303
+ findings.push(...scanContent(content, file, EXFILTRATION_PATTERNS));
304
+ findings.push(...scanContent(content, file, DANGEROUS_PATTERNS));
305
+ findings.push(...scanContent(content, file, SECRET_PATTERNS));
306
+ findings.push(...scanContent(content, file, TOOL_MISUSE_PATTERNS));
307
+ }
308
+ }
309
+ catch (e) {
310
+ unreadableFiles.push(file);
311
+ }
312
+ }
313
+ // Add findings for unreadable files
314
+ if (unreadableFiles.length > 0) {
315
+ findings.push({
316
+ id: "SCAN-ERR-02",
317
+ category: "SC",
318
+ asixx: "ASI04",
319
+ severity: "medium",
320
+ file: resolvedPath,
321
+ message: `Could not read ${unreadableFiles.length} file(s) - security scan incomplete`,
322
+ evidence: unreadableFiles.join(", ")
323
+ });
324
+ }
325
+ if (files.length === 0) {
326
+ findings.push({
327
+ id: "SCAN-ERR-03",
328
+ category: "SC",
329
+ asixx: "ASI04",
330
+ severity: "medium",
331
+ file: resolvedPath,
332
+ message: "No files found in skill directory",
333
+ evidence: resolvedPath
334
+ });
335
+ }
336
+ // Provenance checks (origin is optional metadata, not spec-required)
337
+ if (manifest?.origin) {
338
+ findings.push(...checkProvenance(manifest.origin, resolvedPath));
339
+ }
340
+ return { findings, unreadableFiles };
341
+ }