skill-preflight 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,713 @@
1
+ import { estimateTokens, firstMatchLine } from "./utils.js";
2
+ const securityPatterns = [
3
+ {
4
+ id: "security.remote-script-execution",
5
+ category: "security",
6
+ severity: "critical",
7
+ title: "Remote script execution pattern",
8
+ pattern: /(curl|wget|Invoke-WebRequest|iwr)\b.+(\|\s*(sh|bash|pwsh|powershell)|Invoke-Expression|\biex\b)/i,
9
+ description: "The skill appears to download and execute remote code in one step.",
10
+ recommendation: "Download artifacts separately, verify checksums, and require explicit user approval before execution.",
11
+ scoreImpact: 14
12
+ },
13
+ {
14
+ id: "security.invoke-expression",
15
+ category: "security",
16
+ severity: "high",
17
+ title: "Dynamic PowerShell execution",
18
+ pattern: /\b(Invoke-Expression|iex)\b/i,
19
+ description: "Dynamic evaluation makes it hard to inspect what command will actually run.",
20
+ recommendation: "Replace dynamic evaluation with explicit commands and documented arguments.",
21
+ scoreImpact: 9
22
+ },
23
+ {
24
+ id: "security.powershell-encoded-command",
25
+ category: "security",
26
+ severity: "high",
27
+ title: "Encoded PowerShell command",
28
+ pattern: /\b(powershell|pwsh)(\.exe)?\b.+-(enc|encodedcommand)\b/i,
29
+ description: "Encoded PowerShell commands hide the operation from casual review.",
30
+ recommendation: "Use readable commands and document every argument that needs elevated trust.",
31
+ scoreImpact: 9
32
+ },
33
+ {
34
+ id: "security.execution-policy-bypass",
35
+ category: "security",
36
+ severity: "medium",
37
+ title: "PowerShell execution policy bypass",
38
+ pattern: /\b(Set-ExecutionPolicy|ExecutionPolicy)\b.+\b(Bypass|Unrestricted)\b/i,
39
+ description: "Bypassing execution policy lowers local script safeguards.",
40
+ recommendation: "Avoid changing policy; document a manual, user-approved setup path instead.",
41
+ scoreImpact: 6
42
+ },
43
+ {
44
+ id: "security.shell-eval",
45
+ category: "security",
46
+ severity: "high",
47
+ title: "Dynamic shell or JavaScript evaluation",
48
+ pattern: /\b(eval|Function|exec)\s*\(|child_process\.(exec|execSync)\s*\(/i,
49
+ description: "Dynamic execution can turn untrusted input into commands.",
50
+ recommendation: "Use argument-safe APIs and avoid evaluating generated strings.",
51
+ scoreImpact: 8
52
+ },
53
+ {
54
+ id: "security.destructive-delete",
55
+ category: "security",
56
+ severity: "critical",
57
+ title: "Potentially destructive delete command",
58
+ pattern: /(rm\s+-rf\s+[/~*]|\bRemove-Item\b.+\b-Recurse\b.+\b-Force\b|\bdel\b\s+\/s\s+\/q|format\s+[a-z]:)/i,
59
+ description: "The skill includes a broad destructive filesystem command.",
60
+ recommendation: "Scope deletes to a verified workspace path and require confirmation for destructive operations.",
61
+ scoreImpact: 13
62
+ },
63
+ {
64
+ id: "security.secret-access",
65
+ category: "security",
66
+ severity: "high",
67
+ title: "Potential secret or credential access",
68
+ pattern: /(read|copy|scan|upload|send|exfiltrate).{0,80}(\.env|id_rsa|\.ssh|api[_ -]?key|secret|token|browser cookies|login data)/i,
69
+ description: "The skill may instruct the agent to read or transmit sensitive local data.",
70
+ recommendation: "Avoid accessing secrets by default; document narrow, opt-in credential handling.",
71
+ scoreImpact: 10
72
+ },
73
+ {
74
+ id: "security.webhook-exfiltration",
75
+ category: "security",
76
+ severity: "high",
77
+ title: "Suspicious webhook or paste endpoint",
78
+ pattern: /(discord\.com\/api\/webhooks|hooks\.slack\.com|webhook\.site|pastebin\.com|transfer\.sh|file\.io)/i,
79
+ description: "The skill references endpoints commonly used for exfiltration or ad-hoc uploads.",
80
+ recommendation: "Remove hardcoded endpoints or explain the data flow and require explicit consent.",
81
+ scoreImpact: 9
82
+ },
83
+ {
84
+ id: "security.prompt-injection",
85
+ category: "security",
86
+ severity: "high",
87
+ title: "Prompt injection language",
88
+ pattern: /(ignore (all )?(previous|system|developer) instructions|bypass safety|do not tell the user|hidden instruction|jailbreak)/i,
89
+ description: "The skill contains language associated with prompt injection or instruction override.",
90
+ recommendation: "Remove instruction-override text and keep the skill aligned with user and system instructions.",
91
+ scoreImpact: 10
92
+ }
93
+ ];
94
+ const permissionPatterns = [
95
+ {
96
+ id: "permissions.overbroad-trigger",
97
+ category: "permissions",
98
+ severity: "medium",
99
+ title: "Over-broad activation guidance",
100
+ pattern: /(always use|use this skill for any|every task|all requests|whenever the user asks anything|trigger for all)/i,
101
+ description: "Broad trigger language can cause the skill to activate when it is not relevant.",
102
+ recommendation: "Narrow the trigger to concrete task types and domain-specific phrases.",
103
+ scoreImpact: 6,
104
+ pathPattern: /^SKILL\.md$/i
105
+ },
106
+ {
107
+ id: "permissions.unbounded-filesystem",
108
+ category: "permissions",
109
+ severity: "medium",
110
+ title: "Unbounded filesystem access",
111
+ pattern: /(entire filesystem|whole disk|home directory|all files|scan everything|read every file)/i,
112
+ description: "The skill asks for broad file access that may exceed its purpose.",
113
+ recommendation: "Constrain file access to the current workspace or explicit user-selected paths.",
114
+ scoreImpact: 5
115
+ },
116
+ {
117
+ id: "permissions.unnecessary-network",
118
+ category: "permissions",
119
+ severity: "low",
120
+ title: "Network use should be justified",
121
+ pattern: /(send telemetry|analytics endpoint|phone home|remote logging|upload report)/i,
122
+ description: "The skill appears to send data to a remote service.",
123
+ recommendation: "Make network behavior opt-in and document what is transmitted.",
124
+ scoreImpact: 4
125
+ }
126
+ ];
127
+ const compatibilityPatterns = [
128
+ {
129
+ id: "compatibility.hardcoded-user-path",
130
+ category: "compatibility",
131
+ severity: "medium",
132
+ title: "Hardcoded local user path",
133
+ pattern: /(C:\\Users\\[^\\\s]+|\/Users\/[^/\s]+|\/home\/[^/\s]+)/i,
134
+ description: "Hardcoded local paths make the skill fragile across machines.",
135
+ recommendation: "Use environment variables, workspace-relative paths, or user-provided paths.",
136
+ scoreImpact: 3
137
+ },
138
+ {
139
+ id: "compatibility.os-specific-command",
140
+ category: "compatibility",
141
+ severity: "low",
142
+ title: "OS-specific command without fallback",
143
+ pattern: /\b(osascript|open -a|xdg-open|Start-Process|powershell\.exe|cmd\.exe)\b/i,
144
+ description: "The skill may rely on an OS-specific command.",
145
+ recommendation: "Document supported platforms or provide cross-platform alternatives.",
146
+ scoreImpact: 2
147
+ }
148
+ ];
149
+ export const rules = [
150
+ patternRule("security.patterns", securityPatterns),
151
+ patternRule("permissions.patterns", permissionPatterns),
152
+ patternRule("compatibility.patterns", compatibilityPatterns),
153
+ dependencyRule(),
154
+ mcpConfigRule(),
155
+ tokenRule(),
156
+ footprintRule(),
157
+ maintainabilityRule(),
158
+ reliabilityRule()
159
+ ];
160
+ function patternRule(id, patterns) {
161
+ return {
162
+ id,
163
+ run(context) {
164
+ const findings = [];
165
+ for (const file of context.textFiles) {
166
+ for (const rule of patterns) {
167
+ if (rule.pathPattern && !rule.pathPattern.test(file.path)) {
168
+ continue;
169
+ }
170
+ const line = firstMatchLine(file.lines, rule.pattern);
171
+ if (!line) {
172
+ continue;
173
+ }
174
+ findings.push({
175
+ id: rule.id,
176
+ category: rule.category,
177
+ severity: rule.severity,
178
+ title: rule.title,
179
+ description: rule.description,
180
+ recommendation: rule.recommendation,
181
+ scoreImpact: rule.scoreImpact,
182
+ file: file.path,
183
+ line
184
+ });
185
+ }
186
+ }
187
+ return findings;
188
+ }
189
+ };
190
+ }
191
+ function tokenRule() {
192
+ return {
193
+ id: "token.efficiency",
194
+ run(context) {
195
+ const findings = [];
196
+ const skillFile = context.skillFile;
197
+ if (!skillFile) {
198
+ findings.push({
199
+ id: "token.missing-skill-md",
200
+ category: "token",
201
+ severity: "critical",
202
+ title: "Missing SKILL.md",
203
+ description: "A skill cannot be evaluated properly without a SKILL.md entrypoint.",
204
+ recommendation: "Add a SKILL.md with a concise description and progressive disclosure.",
205
+ scoreImpact: 15
206
+ });
207
+ return findings;
208
+ }
209
+ const tokens = estimateTokens(skillFile.content);
210
+ if (tokens > 4000) {
211
+ findings.push({
212
+ id: "token.skill-md-huge",
213
+ category: "token",
214
+ severity: "high",
215
+ title: "SKILL.md is very large",
216
+ description: `SKILL.md is estimated at ${tokens} activation tokens.`,
217
+ recommendation: "Move detailed references into separate files and load them only when needed.",
218
+ scoreImpact: 10,
219
+ file: skillFile.path
220
+ });
221
+ }
222
+ else if (tokens > 2200) {
223
+ findings.push({
224
+ id: "token.skill-md-large",
225
+ category: "token",
226
+ severity: "medium",
227
+ title: "SKILL.md may be token-heavy",
228
+ description: `SKILL.md is estimated at ${tokens} activation tokens.`,
229
+ recommendation: "Keep core activation instructions short and move examples to references.",
230
+ scoreImpact: 6,
231
+ file: skillFile.path
232
+ });
233
+ }
234
+ if (skillFile.bytes > 6000 && context.metrics.referenceFiles === 0) {
235
+ findings.push({
236
+ id: "token.no-progressive-disclosure",
237
+ category: "token",
238
+ severity: "medium",
239
+ title: "No progressive disclosure structure",
240
+ description: "The main skill file is large but no reference files were detected.",
241
+ recommendation: "Split long background material into a references directory.",
242
+ scoreImpact: 5,
243
+ file: skillFile.path
244
+ });
245
+ }
246
+ const duplicateLineCount = countDuplicateMeaningfulLines(skillFile);
247
+ if (duplicateLineCount >= 6) {
248
+ findings.push({
249
+ id: "token.repeated-lines",
250
+ category: "token",
251
+ severity: "low",
252
+ title: "Repeated content in SKILL.md",
253
+ description: `${duplicateLineCount} repeated instruction lines were detected.`,
254
+ recommendation: "Remove duplicated instructions to reduce activation cost and ambiguity.",
255
+ scoreImpact: 3,
256
+ file: skillFile.path
257
+ });
258
+ }
259
+ return findings;
260
+ }
261
+ };
262
+ }
263
+ function footprintRule() {
264
+ return {
265
+ id: "footprint.size",
266
+ run(context) {
267
+ const findings = [];
268
+ const totalMb = context.metrics.totalBytes / 1024 / 1024;
269
+ if (totalMb > 20) {
270
+ findings.push({
271
+ id: "footprint.large-package",
272
+ category: "footprint",
273
+ severity: "high",
274
+ title: "Large skill package",
275
+ description: `The skill directory is ${totalMb.toFixed(1)} MB.`,
276
+ recommendation: "Remove generated assets, vendored dependencies, and large binaries from the skill package.",
277
+ scoreImpact: 8
278
+ });
279
+ }
280
+ else if (totalMb > 5) {
281
+ findings.push({
282
+ id: "footprint.medium-package",
283
+ category: "footprint",
284
+ severity: "medium",
285
+ title: "Skill package may be heavy",
286
+ description: `The skill directory is ${totalMb.toFixed(1)} MB.`,
287
+ recommendation: "Keep only the files needed at runtime and document optional assets separately.",
288
+ scoreImpact: 4
289
+ });
290
+ }
291
+ if (context.metrics.dependencyFiles > 0 && !hasLockfile(context)) {
292
+ findings.push({
293
+ id: "footprint.unlocked-dependencies",
294
+ category: "footprint",
295
+ severity: "medium",
296
+ title: "Dependency manifest without lockfile",
297
+ description: "A dependency manifest was found, but no lockfile was detected.",
298
+ recommendation: "Commit a lockfile or pin exact versions for reproducible installs.",
299
+ scoreImpact: 4
300
+ });
301
+ }
302
+ if (context.metrics.scriptFiles > 8) {
303
+ findings.push({
304
+ id: "footprint.many-scripts",
305
+ category: "footprint",
306
+ severity: "low",
307
+ title: "Many executable scripts",
308
+ description: `${context.metrics.scriptFiles} script files were detected.`,
309
+ recommendation: "Keep scripts minimal and document what each one does.",
310
+ scoreImpact: 2
311
+ });
312
+ }
313
+ return findings;
314
+ }
315
+ };
316
+ }
317
+ function dependencyRule() {
318
+ return {
319
+ id: "dependencies.manifests",
320
+ run(context) {
321
+ return [
322
+ ...scanPackageJsonFiles(context),
323
+ ...scanPythonRequirements(context)
324
+ ];
325
+ }
326
+ };
327
+ }
328
+ function mcpConfigRule() {
329
+ return {
330
+ id: "mcp.config",
331
+ run(context) {
332
+ const findings = [];
333
+ const mcpFiles = context.textFiles.filter((file) => isMcpConfigFile(file));
334
+ for (const file of mcpFiles) {
335
+ const parsed = parseJsonObject(file);
336
+ if (!parsed) {
337
+ findings.push({
338
+ id: "mcp.invalid-json",
339
+ category: "reliability",
340
+ severity: "medium",
341
+ title: "Invalid MCP JSON config",
342
+ description: "The file looks like an MCP config but could not be parsed as JSON.",
343
+ recommendation: "Fix JSON syntax so tools can inspect server commands and permissions.",
344
+ scoreImpact: 3,
345
+ file: file.path
346
+ });
347
+ continue;
348
+ }
349
+ const servers = getMcpServers(parsed);
350
+ for (const [serverName, serverValue] of servers) {
351
+ if (!isRecord(serverValue)) {
352
+ continue;
353
+ }
354
+ const command = stringValue(serverValue.command);
355
+ const args = Array.isArray(serverValue.args) ? serverValue.args.map(String) : [];
356
+ const env = isRecord(serverValue.env) ? serverValue.env : {};
357
+ if (command && /^(bash|sh|zsh|cmd|powershell|pwsh)(\.exe)?$/i.test(command)) {
358
+ findings.push({
359
+ id: "mcp.shell-server-command",
360
+ category: "security",
361
+ severity: "high",
362
+ title: "MCP server launches through a shell",
363
+ description: `MCP server "${serverName}" uses ${command}, which can obscure the actual command being executed.`,
364
+ recommendation: "Launch a pinned executable directly and avoid shell string expansion.",
365
+ scoreImpact: 8,
366
+ file: file.path,
367
+ line: firstMatchLine(file.lines, new RegExp(escapeRegExp(command), "i"))
368
+ });
369
+ }
370
+ if (command && /^(npx|uvx|pipx)$/i.test(command) && !hasPinnedToolPackage(args)) {
371
+ findings.push({
372
+ id: "mcp.unpinned-tool-package",
373
+ category: "footprint",
374
+ severity: "medium",
375
+ title: "Unpinned MCP server package",
376
+ description: `MCP server "${serverName}" uses ${command} without a pinned package version.`,
377
+ recommendation: "Pin the MCP server package version, for example package@1.2.3.",
378
+ scoreImpact: 4,
379
+ file: file.path,
380
+ line: firstMatchLine(file.lines, /"args"\s*:/i)
381
+ });
382
+ }
383
+ if (args.some((arg) => /(\/|\\|\b)(Users|home|root)(\/|\\|$)|C:\\Users\\/i.test(arg))) {
384
+ findings.push({
385
+ id: "mcp.broad-local-path",
386
+ category: "permissions",
387
+ severity: "medium",
388
+ title: "MCP server references a broad local path",
389
+ description: `MCP server "${serverName}" references a home or root-level path.`,
390
+ recommendation: "Scope MCP server access to the current project or an explicit user-selected directory.",
391
+ scoreImpact: 5,
392
+ file: file.path
393
+ });
394
+ }
395
+ const hardcodedSecretKeys = Object.entries(env).filter(([key, value]) => /(api[_-]?key|token|secret|password|credential)/i.test(key) && typeof value === "string" && value.trim().length > 0);
396
+ if (hardcodedSecretKeys.length > 0) {
397
+ findings.push({
398
+ id: "mcp.hardcoded-secret-env",
399
+ category: "security",
400
+ severity: "high",
401
+ title: "Hardcoded secret-like MCP environment value",
402
+ description: `MCP server "${serverName}" defines secret-like environment keys: ${hardcodedSecretKeys.map(([key]) => key).join(", ")}.`,
403
+ recommendation: "Read secrets from the user's environment instead of committing them in config files.",
404
+ scoreImpact: 8,
405
+ file: file.path
406
+ });
407
+ }
408
+ }
409
+ }
410
+ return findings;
411
+ }
412
+ };
413
+ }
414
+ function maintainabilityRule() {
415
+ return {
416
+ id: "maintainability.basics",
417
+ run(context) {
418
+ const findings = [];
419
+ if (!context.metrics.hasReadme) {
420
+ findings.push({
421
+ id: "maintainability.missing-readme",
422
+ category: "maintainability",
423
+ severity: "medium",
424
+ title: "Missing README",
425
+ description: "No README file was detected.",
426
+ recommendation: "Add a README with purpose, installation, examples, and safety notes.",
427
+ scoreImpact: 3
428
+ });
429
+ }
430
+ if (!context.metrics.hasLicense) {
431
+ findings.push({
432
+ id: "maintainability.missing-license",
433
+ category: "maintainability",
434
+ severity: "medium",
435
+ title: "Missing license",
436
+ description: "No license file was detected.",
437
+ recommendation: "Add a clear open-source license so users know whether they can install and reuse the skill.",
438
+ scoreImpact: 3
439
+ });
440
+ }
441
+ if (context.skillFile && !hasSkillFrontmatter(context.skillFile)) {
442
+ findings.push({
443
+ id: "maintainability.missing-frontmatter",
444
+ category: "maintainability",
445
+ severity: "low",
446
+ title: "SKILL.md lacks metadata frontmatter",
447
+ description: "The skill file does not appear to include name and description metadata.",
448
+ recommendation: "Add frontmatter with name and description to make the skill discoverable.",
449
+ scoreImpact: 2,
450
+ file: context.skillFile.path
451
+ });
452
+ }
453
+ return findings;
454
+ }
455
+ };
456
+ }
457
+ function reliabilityRule() {
458
+ return {
459
+ id: "reliability.evidence",
460
+ run(context) {
461
+ const findings = [];
462
+ if (!context.metrics.hasExamples) {
463
+ findings.push({
464
+ id: "reliability.missing-examples",
465
+ category: "reliability",
466
+ severity: "low",
467
+ title: "No examples detected",
468
+ description: "No examples directory or example files were detected.",
469
+ recommendation: "Add small example tasks that show when and how the skill should be used.",
470
+ scoreImpact: 3
471
+ });
472
+ }
473
+ if (!context.metrics.hasTests) {
474
+ findings.push({
475
+ id: "reliability.missing-tests",
476
+ category: "reliability",
477
+ severity: "medium",
478
+ title: "No tests or fixtures detected",
479
+ description: "No tests, fixtures, or eval files were found.",
480
+ recommendation: "Add fixtures or eval cases so users can verify the skill behavior before installing.",
481
+ scoreImpact: 4
482
+ });
483
+ }
484
+ if (context.skillFile) {
485
+ const line = firstMatchLine(context.skillFile.lines, /\b(maybe|roughly|try to|do your best|whatever works)\b/i);
486
+ if (line) {
487
+ findings.push({
488
+ id: "reliability.vague-instructions",
489
+ category: "reliability",
490
+ severity: "low",
491
+ title: "Vague operational language",
492
+ description: "The skill uses vague language that may reduce repeatability.",
493
+ recommendation: "Prefer explicit inputs, outputs, and acceptance criteria.",
494
+ scoreImpact: 2,
495
+ file: context.skillFile.path,
496
+ line
497
+ });
498
+ }
499
+ }
500
+ return findings;
501
+ }
502
+ };
503
+ }
504
+ function countDuplicateMeaningfulLines(file) {
505
+ const seen = new Map();
506
+ for (const line of file.lines) {
507
+ const normalized = line.trim().toLowerCase();
508
+ if (normalized.length < 40 || normalized.startsWith("#")) {
509
+ continue;
510
+ }
511
+ seen.set(normalized, (seen.get(normalized) ?? 0) + 1);
512
+ }
513
+ return [...seen.values()].reduce((sum, count) => sum + Math.max(0, count - 1), 0);
514
+ }
515
+ function hasLockfile(context) {
516
+ return context.files.some((file) => /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|uv\.lock|poetry\.lock|Cargo\.lock|go\.sum)$/i.test(file.path));
517
+ }
518
+ function hasSkillFrontmatter(file) {
519
+ if (!file.content.startsWith("---")) {
520
+ return false;
521
+ }
522
+ const end = file.content.indexOf("\n---", 3);
523
+ if (end < 0) {
524
+ return false;
525
+ }
526
+ const frontmatter = file.content.slice(3, end);
527
+ return /(^|\n)name\s*:/i.test(frontmatter) && /(^|\n)description\s*:/i.test(frontmatter);
528
+ }
529
+ function scanPackageJsonFiles(context) {
530
+ const findings = [];
531
+ const packageFiles = context.textFiles.filter((file) => /(^|\/)package\.json$/i.test(file.path));
532
+ for (const file of packageFiles) {
533
+ const parsed = parseJsonObject(file);
534
+ if (!parsed) {
535
+ findings.push({
536
+ id: "dependencies.invalid-package-json",
537
+ category: "reliability",
538
+ severity: "medium",
539
+ title: "Invalid package.json",
540
+ description: "package.json could not be parsed.",
541
+ recommendation: "Fix package.json so dependency and install-script risks can be inspected.",
542
+ scoreImpact: 3,
543
+ file: file.path
544
+ });
545
+ continue;
546
+ }
547
+ const scripts = isRecord(parsed.scripts) ? parsed.scripts : {};
548
+ for (const scriptName of ["preinstall", "install", "postinstall", "prepare"]) {
549
+ const script = stringValue(scripts[scriptName]);
550
+ if (!script) {
551
+ continue;
552
+ }
553
+ const lifecycleLine = firstMatchLine(file.lines, new RegExp(`"${scriptName}"\\s*:`, "i"));
554
+ findings.push({
555
+ id: "dependencies.lifecycle-script",
556
+ category: "security",
557
+ severity: "medium",
558
+ title: "npm lifecycle install script",
559
+ description: `package.json defines a ${scriptName} script that runs during install or packaging.`,
560
+ recommendation: "Avoid implicit install-time code execution; move setup to explicit user-run commands.",
561
+ scoreImpact: 5,
562
+ file: file.path,
563
+ line: lifecycleLine
564
+ });
565
+ if (/(curl|wget|Invoke-WebRequest|iwr).+(\|\s*(sh|bash|pwsh|powershell)|Invoke-Expression|\biex\b)|\beval\b|child_process\.(exec|execSync)/i.test(script)) {
566
+ findings.push({
567
+ id: "dependencies.dangerous-lifecycle-script",
568
+ category: "security",
569
+ severity: "high",
570
+ title: "Dangerous npm lifecycle script",
571
+ description: `${scriptName} contains dynamic or remote code execution.`,
572
+ recommendation: "Remove remote execution from lifecycle scripts and require explicit user consent.",
573
+ scoreImpact: 9,
574
+ file: file.path,
575
+ line: lifecycleLine
576
+ });
577
+ }
578
+ }
579
+ const dependencyFindings = collectUnpinnedNodeDependencies(file, parsed);
580
+ findings.push(...dependencyFindings);
581
+ }
582
+ return findings;
583
+ }
584
+ function scanPythonRequirements(context) {
585
+ const findings = [];
586
+ const requirementFiles = context.textFiles.filter((file) => /(^|\/)requirements[^/]*\.txt$/i.test(file.path));
587
+ for (const file of requirementFiles) {
588
+ const unpinned = [];
589
+ const remoteRefs = [];
590
+ file.lines.forEach((line, index) => {
591
+ const trimmed = line.trim();
592
+ if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith("-r ") || trimmed.startsWith("--")) {
593
+ return;
594
+ }
595
+ if (/(git\+|https?:\/\/)/i.test(trimmed)) {
596
+ remoteRefs.push(`${trimmed} at line ${index + 1}`);
597
+ return;
598
+ }
599
+ if (!/[<>=!~]=/.test(trimmed)) {
600
+ unpinned.push(`${trimmed} at line ${index + 1}`);
601
+ }
602
+ });
603
+ if (remoteRefs.length > 0) {
604
+ findings.push({
605
+ id: "dependencies.python-remote-reference",
606
+ category: "security",
607
+ severity: "high",
608
+ title: "Python dependency uses remote URL",
609
+ description: `requirements file includes remote dependency references: ${remoteRefs.slice(0, 3).join(", ")}.`,
610
+ recommendation: "Use pinned package versions from a trusted index, or pin Git URLs to immutable commits.",
611
+ scoreImpact: 8,
612
+ file: file.path,
613
+ line: Number(remoteRefs[0]?.match(/line (\d+)/)?.[1])
614
+ });
615
+ }
616
+ if (unpinned.length > 0) {
617
+ findings.push({
618
+ id: "dependencies.python-unpinned",
619
+ category: "footprint",
620
+ severity: "medium",
621
+ title: "Unpinned Python dependencies",
622
+ description: `requirements file includes unpinned packages: ${unpinned.slice(0, 5).join(", ")}.`,
623
+ recommendation: "Pin versions or use a lockfile for reproducible installs.",
624
+ scoreImpact: 4,
625
+ file: file.path,
626
+ line: Number(unpinned[0]?.match(/line (\d+)/)?.[1])
627
+ });
628
+ }
629
+ }
630
+ return findings;
631
+ }
632
+ function collectUnpinnedNodeDependencies(file, parsed) {
633
+ const findings = [];
634
+ const dependencyGroups = ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"];
635
+ const riskySpecs = [];
636
+ const looseSpecs = [];
637
+ for (const group of dependencyGroups) {
638
+ const deps = isRecord(parsed[group]) ? parsed[group] : {};
639
+ for (const [name, value] of Object.entries(deps)) {
640
+ const spec = String(value);
641
+ if (/^(git\+|https?:|file:)/i.test(spec)) {
642
+ riskySpecs.push(`${name}@${spec}`);
643
+ }
644
+ else if (spec === "*" || /^latest$/i.test(spec) || /^[\^~]/.test(spec)) {
645
+ looseSpecs.push(`${name}@${spec}`);
646
+ }
647
+ }
648
+ }
649
+ if (riskySpecs.length > 0) {
650
+ findings.push({
651
+ id: "dependencies.node-remote-spec",
652
+ category: "security",
653
+ severity: "high",
654
+ title: "Node dependency uses remote or local spec",
655
+ description: `package.json includes dependency specs outside normal pinned registry versions: ${riskySpecs.slice(0, 5).join(", ")}.`,
656
+ recommendation: "Use trusted registry packages pinned to exact versions, or pin remote specs to immutable commits.",
657
+ scoreImpact: 8,
658
+ file: file.path
659
+ });
660
+ }
661
+ if (looseSpecs.length > 0) {
662
+ findings.push({
663
+ id: "dependencies.node-loose-version",
664
+ category: "footprint",
665
+ severity: "medium",
666
+ title: "Loose Node dependency versions",
667
+ description: `package.json includes loose dependency specs: ${looseSpecs.slice(0, 5).join(", ")}.`,
668
+ recommendation: "Pin exact dependency versions or commit a lockfile for reproducible installs.",
669
+ scoreImpact: 4,
670
+ file: file.path
671
+ });
672
+ }
673
+ return findings;
674
+ }
675
+ function isMcpConfigFile(file) {
676
+ return /(^|\/)(\.?mcp.*\.json|claude_desktop_config\.json|mcp_servers\.json)$/i.test(file.path) || /"mcpServers"\s*:/.test(file.content);
677
+ }
678
+ function parseJsonObject(file) {
679
+ try {
680
+ const parsed = JSON.parse(file.content);
681
+ return isRecord(parsed) ? parsed : undefined;
682
+ }
683
+ catch {
684
+ return undefined;
685
+ }
686
+ }
687
+ function getMcpServers(parsed) {
688
+ if (isRecord(parsed.mcpServers)) {
689
+ return Object.entries(parsed.mcpServers);
690
+ }
691
+ if (isRecord(parsed.servers)) {
692
+ return Object.entries(parsed.servers);
693
+ }
694
+ return [];
695
+ }
696
+ function hasPinnedToolPackage(args) {
697
+ return args.some((arg) => {
698
+ if (arg.startsWith("-")) {
699
+ return false;
700
+ }
701
+ return /^(@[^/]+\/)?[^@\s]+@\d+\.\d+\.\d+/.test(arg);
702
+ });
703
+ }
704
+ function isRecord(value) {
705
+ return typeof value === "object" && value !== null && !Array.isArray(value);
706
+ }
707
+ function stringValue(value) {
708
+ return typeof value === "string" ? value : undefined;
709
+ }
710
+ function escapeRegExp(value) {
711
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
712
+ }
713
+ //# sourceMappingURL=rules.js.map