@denial-web/clawguard 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.
Files changed (83) hide show
  1. package/.clawguard.example.json +16 -0
  2. package/LICENSE +21 -0
  3. package/README.md +241 -0
  4. package/SECURITY.md +33 -0
  5. package/action.yml +72 -0
  6. package/docs/ARCHITECTURE.md +312 -0
  7. package/docs/ARCHITECTURE_ROADMAP.md +267 -0
  8. package/docs/CLAWHUB_METADATA.md +57 -0
  9. package/docs/DEMO_CAPTURE.md +25 -0
  10. package/docs/DEMO_SCRIPT.md +87 -0
  11. package/docs/DEPENDENCY_SCANNING.md +61 -0
  12. package/docs/GITHUB_ACTION.md +56 -0
  13. package/docs/GITHUB_REPO_SETUP.md +76 -0
  14. package/docs/HTML_REPORTS.md +27 -0
  15. package/docs/INTEGRATION_SPEC.md +253 -0
  16. package/docs/LAUNCH_CHECKLIST.md +64 -0
  17. package/docs/LAUNCH_PLAN.md +40 -0
  18. package/docs/LOCAL_PROJECT_ASSETS.md +250 -0
  19. package/docs/MCP_PLUGIN_SCANNING.md +53 -0
  20. package/docs/NEXT_SESSION.md +110 -0
  21. package/docs/NPM_PUBLISHING.md +66 -0
  22. package/docs/OPENCLAW_CLAWHUB_RESEARCH.md +128 -0
  23. package/docs/POLICY_MODEL.md +198 -0
  24. package/docs/PROJECT_REVIEW.md +108 -0
  25. package/docs/REAL_WORLD_VALIDATION.md +57 -0
  26. package/docs/RELEASE_NOTES_v0.1.0.md +52 -0
  27. package/docs/REPORT_SCHEMA.md +81 -0
  28. package/docs/RULES.md +92 -0
  29. package/docs/THREAT_MODEL.md +50 -0
  30. package/docs/WEB_DEMO.md +39 -0
  31. package/docs/WORKSPACE_SCANNING.md +41 -0
  32. package/examples/clawhub-origin-without-lock/skills/orphan-helper/.clawhub/origin.json +6 -0
  33. package/examples/clawhub-origin-without-lock/skills/orphan-helper/SKILL.md +11 -0
  34. package/examples/clawhub-workspace/.clawhub/lock.json +22 -0
  35. package/examples/clawhub-workspace/skills/drift-helper/.clawhub/origin.json +6 -0
  36. package/examples/clawhub-workspace/skills/drift-helper/SKILL.md +11 -0
  37. package/examples/clawhub-workspace/skills/missing-origin/SKILL.md +11 -0
  38. package/examples/clawhub-workspace/skills/weather-helper/.clawhub/origin.json +6 -0
  39. package/examples/clawhub-workspace/skills/weather-helper/SKILL.md +15 -0
  40. package/examples/declared-api-skill/SKILL.md +27 -0
  41. package/examples/dependency-python-skill/SKILL.md +16 -0
  42. package/examples/dependency-python-skill/pyproject.toml +5 -0
  43. package/examples/dependency-python-skill/requirements.txt +3 -0
  44. package/examples/dependency-risky-skill/SKILL.md +16 -0
  45. package/examples/dependency-risky-skill/package.json +12 -0
  46. package/examples/dependency-safe-skill/SKILL.md +16 -0
  47. package/examples/dependency-safe-skill/package-lock.json +19 -0
  48. package/examples/dependency-safe-skill/package.json +7 -0
  49. package/examples/metadata-mismatch-skill/SKILL.md +22 -0
  50. package/examples/openclaw-plugin-config/.openclaw/plugins.json +18 -0
  51. package/examples/openclaw-workspace/.agents/skills/research-helper/SKILL.md +11 -0
  52. package/examples/openclaw-workspace/skills/notes/SKILL.md +3 -0
  53. package/examples/openclaw-workspace/skills/research-helper/SKILL.md +17 -0
  54. package/examples/risky-mcp-config/.cursor/mcp.json +29 -0
  55. package/examples/risky-openclaw-plugin/openclaw.plugin.json +6 -0
  56. package/examples/risky-openclaw-plugin/package.json +7 -0
  57. package/examples/risky-openclaw-plugin/src/index.ts +1 -0
  58. package/examples/risky-skill/SKILL.md +17 -0
  59. package/examples/safe-mcp-config/.cursor/mcp.json +15 -0
  60. package/examples/safe-openclaw-plugin/dist/index.js +1 -0
  61. package/examples/safe-openclaw-plugin/openclaw.plugin.json +5 -0
  62. package/examples/safe-openclaw-plugin/package.json +14 -0
  63. package/examples/safe-skill/SKILL.md +12 -0
  64. package/package.json +49 -0
  65. package/schemas/clawguard-report.schema.json +266 -0
  66. package/scripts/capture-demo.js +206 -0
  67. package/src/clawhub.js +383 -0
  68. package/src/cli.js +296 -0
  69. package/src/config.js +205 -0
  70. package/src/dependencies.js +417 -0
  71. package/src/mcp-config.js +592 -0
  72. package/src/policy.js +165 -0
  73. package/src/reporters/html.js +482 -0
  74. package/src/reporters/sarif.js +121 -0
  75. package/src/rule-catalog.js +400 -0
  76. package/src/rules.js +121 -0
  77. package/src/scanner.js +387 -0
  78. package/src/skill-metadata.js +516 -0
  79. package/src/web-server.js +395 -0
  80. package/src/workspace.js +233 -0
  81. package/web/app.js +374 -0
  82. package/web/index.html +119 -0
  83. package/web/styles.css +453 -0
@@ -0,0 +1,592 @@
1
+ import path from "node:path";
2
+
3
+ const packageRunnerCommands = new Set(["npx", "pnpm", "pnpm dlx", "uvx"]);
4
+ const shellCommands = new Set(["bash", "sh", "zsh", "powershell", "pwsh", "python", "python3", "node"]);
5
+ const sensitiveNamePattern = /(?:API_KEY|TOKEN|SECRET|PASSWORD|PASS|PRIVATE_KEY|ACCESS_KEY|CREDENTIALS)/i;
6
+
7
+ export function analyzeMcpConfigs(fileRecords, basePath = process.cwd()) {
8
+ const findings = [];
9
+
10
+ for (const record of fileRecords) {
11
+ if (!isMcpConfigFile(record.file, basePath)) {
12
+ continue;
13
+ }
14
+
15
+ const parsed = parseConfigJson(record);
16
+ if (!parsed.ok) {
17
+ findings.push(createFinding({
18
+ ruleId: "invalid-mcp-config",
19
+ title: "MCP or plugin config is not valid JSON",
20
+ severity: "medium",
21
+ recommendation: "Fix invalid JSON so security tools and runtimes can read the config reliably.",
22
+ record,
23
+ basePath,
24
+ line: 1,
25
+ evidence: parsed.error
26
+ }));
27
+ continue;
28
+ }
29
+
30
+ findings.push(...analyzeRawConfigText(record, basePath));
31
+ findings.push(...analyzeCommandObjects(parsed.value, record, basePath));
32
+ findings.push(...analyzeEnvObjects(parsed.value, record, basePath));
33
+
34
+ if (isOpenClawPluginManifest(record.file)) {
35
+ findings.push(...analyzeOpenClawPluginManifest(parsed.value, record, fileRecords, basePath));
36
+ }
37
+ }
38
+
39
+ return dedupeFindings(findings).slice(0, 30);
40
+ }
41
+
42
+ export function isMcpConfigFile(filePath, basePath = process.cwd()) {
43
+ const normalized = relativePath(basePath, filePath).replaceAll(path.sep, "/");
44
+ const basename = path.basename(filePath);
45
+
46
+ return (
47
+ basename === "mcp.json" ||
48
+ basename === "openclaw.plugin.json" ||
49
+ normalized.endsWith(".cursor/mcp.json") ||
50
+ normalized.endsWith(".openclaw/mcp.json") ||
51
+ normalized.endsWith(".openclaw/plugins.json")
52
+ );
53
+ }
54
+
55
+ function isOpenClawPluginManifest(filePath) {
56
+ return path.basename(filePath) === "openclaw.plugin.json";
57
+ }
58
+
59
+ function analyzeOpenClawPluginManifest(value, record, fileRecords, basePath) {
60
+ const findings = [];
61
+ const packageRecord = findSiblingPackageJson(record, fileRecords);
62
+
63
+ if (!packageRecord) {
64
+ findings.push(createFinding({
65
+ ruleId: "openclaw-plugin-missing-package-manifest",
66
+ title: "OpenClaw plugin manifest has no package.json metadata",
67
+ severity: "medium",
68
+ recommendation: "Keep openclaw.plugin.json next to package.json so compatibility, version, and runtime metadata can be reviewed.",
69
+ record,
70
+ basePath,
71
+ evidence: "package.json not found next to openclaw.plugin.json"
72
+ }));
73
+ return findings;
74
+ }
75
+
76
+ const packageJson = parseConfigJson(packageRecord);
77
+ if (!packageJson.ok) {
78
+ return findings;
79
+ }
80
+
81
+ const openclaw = isPlainObject(packageJson.value.openclaw) ? packageJson.value.openclaw : {};
82
+ const missingFields = missingOpenClawPluginFields(openclaw);
83
+ if (missingFields.length > 0) {
84
+ findings.push(createFinding({
85
+ ruleId: "openclaw-plugin-missing-compat-metadata",
86
+ title: "OpenClaw plugin package is missing ClawHub compatibility metadata",
87
+ severity: "medium",
88
+ recommendation: "Add openclaw.compat.pluginApi and openclaw.build.openclawVersion before publishing or installing the plugin.",
89
+ record: packageRecord,
90
+ basePath,
91
+ line: lineForPackageJsonKey(packageRecord.text, missingFields[0].split(".").at(-1)),
92
+ evidence: `Missing fields: ${missingFields.join(", ")}`
93
+ }));
94
+ }
95
+
96
+ const runtimeEntries = stringArray(openclaw.runtimeExtensions);
97
+ const sourceEntries = stringArray(openclaw.extensions);
98
+ const executableEntries = runtimeEntries.length > 0 ? runtimeEntries : sourceEntries;
99
+ if (executableEntries.length > 0) {
100
+ findings.push(createFinding({
101
+ ruleId: "openclaw-plugin-code-execution",
102
+ title: "OpenClaw plugin package executes local runtime code",
103
+ severity: "high",
104
+ recommendation: "Review plugin runtime entries, source provenance, and sandboxing before installing or enabling the plugin.",
105
+ record: packageRecord,
106
+ basePath,
107
+ line: lineForPackageJsonKey(packageRecord.text, runtimeEntries.length > 0 ? "runtimeExtensions" : "extensions"),
108
+ evidence: executableEntries.join(", ")
109
+ }));
110
+ }
111
+
112
+ findings.push(...findMissingCompiledRuntimeOutputs(openclaw, record, fileRecords, basePath));
113
+ findings.push(...findOpenClawPluginSensitiveCapabilities(value, record, basePath));
114
+
115
+ return findings;
116
+ }
117
+
118
+ function findOpenClawPluginSensitiveCapabilities(value, record, basePath) {
119
+ const findings = [];
120
+ const serialized = JSON.stringify(value);
121
+
122
+ if (/\b(?:shell|terminal|filesystem|process|exec|spawn)\b/i.test(serialized)) {
123
+ findings.push(createFinding({
124
+ ruleId: "openclaw-plugin-sensitive-capability",
125
+ title: "OpenClaw plugin manifest declares sensitive host capabilities",
126
+ severity: "high",
127
+ recommendation: "Require manual review and least-privilege sandboxing for plugins that expose shell, process, or filesystem capabilities.",
128
+ record,
129
+ basePath,
130
+ evidence: firstCapabilityEvidence(serialized)
131
+ }));
132
+ }
133
+
134
+ return findings;
135
+ }
136
+
137
+ function findMissingCompiledRuntimeOutputs(openclaw, manifestRecord, fileRecords, basePath) {
138
+ const findings = [];
139
+ const runtimeEntries = stringArray(openclaw.runtimeExtensions);
140
+ if (runtimeEntries.length > 0) {
141
+ return findings;
142
+ }
143
+
144
+ const sourceEntries = stringArray(openclaw.extensions).filter(isTypeScriptRuntimeEntry);
145
+ if (sourceEntries.length === 0) {
146
+ return findings;
147
+ }
148
+
149
+ const packageDir = path.dirname(manifestRecord.file);
150
+ const packageFiles = new Set(fileRecords
151
+ .filter((record) => isInsideDir(record.file, packageDir))
152
+ .map((record) => normalizePackagePath(path.relative(packageDir, record.file))));
153
+
154
+ for (const entry of sourceEntries) {
155
+ const candidates = compiledRuntimeCandidates(entry);
156
+ if (candidates.some((candidate) => packageFiles.has(candidate))) {
157
+ continue;
158
+ }
159
+
160
+ findings.push(createFinding({
161
+ ruleId: "openclaw-plugin-missing-runtime-output",
162
+ title: "OpenClaw plugin TypeScript entry has no compiled runtime output",
163
+ severity: "high",
164
+ recommendation: "Build and ship compiled JavaScript runtime output, or declare runtimeExtensions that point to committed runtime files.",
165
+ record: manifestRecord,
166
+ basePath,
167
+ evidence: `${entry} expected ${candidates.join(", ")}`
168
+ }));
169
+ }
170
+
171
+ return findings;
172
+ }
173
+
174
+ function analyzeRawConfigText(record, basePath) {
175
+ const findings = [];
176
+
177
+ collectPatternFindings(findings, record, basePath, {
178
+ ruleId: "mcp-shell-execution",
179
+ title: "MCP or plugin config can execute shell code",
180
+ severity: "high",
181
+ recommendation: "Avoid shell interpreters in tool config unless the command is local, pinned, and reviewed.",
182
+ patterns: [
183
+ /"(?:bash|sh|zsh|powershell|pwsh)"\s*,?\s*(?:\]|\n|.){0,120}?"-c"/gi,
184
+ /"(?:python|python3|node)"\s*,?\s*(?:\]|\n|.){0,120}?"-(?:c|e)"/gi,
185
+ /\b(?:curl|wget)\b[\s\S]{0,120}?\|\s*(?:sh|bash|zsh|python|node)\b/gi
186
+ ]
187
+ });
188
+
189
+ collectPatternFindings(findings, record, basePath, {
190
+ ruleId: "mcp-runtime-package-command",
191
+ title: "MCP or plugin config runs a package manager command",
192
+ severity: "high",
193
+ recommendation: "Prefer pinned local commands over runtime package fetches such as npx, uvx, or pnpm dlx.",
194
+ patterns: [
195
+ /"(?:command|setup_commands?)"\s*:\s*"?\s*(?:npx|uvx|pnpm\s+dlx)\b/gi,
196
+ /\b(?:npx|uvx|pnpm\s+dlx)\b/gi
197
+ ]
198
+ });
199
+
200
+ collectPatternFindings(findings, record, basePath, {
201
+ ruleId: "mcp-remote-url",
202
+ title: "MCP or plugin config references a remote URL",
203
+ severity: "medium",
204
+ recommendation: "Confirm the remote endpoint is expected, trusted, and does not receive secrets.",
205
+ patterns: [/https?:\/\/[^\s"',)]+/gi]
206
+ });
207
+
208
+ collectPatternFindings(findings, record, basePath, {
209
+ ruleId: "mcp-broad-filesystem-access",
210
+ title: "MCP or plugin config grants broad filesystem access",
211
+ severity: "high",
212
+ recommendation: "Restrict filesystem access to the smallest required workspace path.",
213
+ patterns: [
214
+ /"(?:\$HOME|~\/|\/Users\/[^"']+|\/)"\s*[,}\]]/gi,
215
+ /"--(?:allow-dir|root|filesystem)"\s*,\s*"(?:\$HOME|~\/|\/Users\/[^"']+|\/)"/gi,
216
+ /"--(?:allow-dir|root|filesystem)=(?:\$HOME|~\/|\/Users\/[^"']+|\/)"/gi
217
+ ]
218
+ });
219
+
220
+ collectPatternFindings(findings, record, basePath, {
221
+ ruleId: "mcp-write-capability",
222
+ title: "MCP or plugin config exposes write-capable tools",
223
+ severity: "high",
224
+ recommendation: "Require explicit approval or sandboxing for tools that can post, send, delete, or create external changes.",
225
+ patterns: [
226
+ /\b(?:browser|email|calendar|slack|github)[_-]?(?:write|send|delete|post|create|modify)\b/gi,
227
+ /\b(?:write|send|delete|post|create|modify)[_-]?(?:browser|email|calendar|slack|github)\b/gi,
228
+ /\b(?:browser|email|calendar|slack|github)\b[\s\S]{0,80}?\b(?:write|send|delete|post|create|modify)\b/gi,
229
+ /\b(?:write|send|delete|post|create|modify)\b[\s\S]{0,80}?\b(?:browser|email|calendar|slack|github)\b/gi
230
+ ]
231
+ });
232
+
233
+ return findings;
234
+ }
235
+
236
+ function analyzeCommandObjects(value, record, basePath) {
237
+ const findings = [];
238
+
239
+ for (const commandObject of findObjectsWithCommand(value)) {
240
+ const command = normalizeCommand(commandObject.command);
241
+ const args = Array.isArray(commandObject.args) ? commandObject.args.map(String) : [];
242
+
243
+ if (packageRunnerCommands.has(command) || (command === "pnpm" && args[0] === "dlx")) {
244
+ findings.push(createFinding({
245
+ ruleId: "mcp-runtime-package-command",
246
+ title: "MCP or plugin config runs a package manager command",
247
+ severity: "high",
248
+ recommendation: "Prefer pinned local commands over runtime package fetches such as npx, uvx, or pnpm dlx.",
249
+ record,
250
+ basePath,
251
+ evidence: commandObject.command
252
+ }));
253
+
254
+ const unpinned = firstUnpinnedPackage(command, args);
255
+ if (unpinned) {
256
+ findings.push(createFinding({
257
+ ruleId: "mcp-unpinned-package",
258
+ title: "MCP or plugin config uses an unpinned package",
259
+ severity: "medium",
260
+ recommendation: "Pin package versions so tool behavior cannot change unexpectedly between runs.",
261
+ record,
262
+ basePath,
263
+ evidence: unpinned
264
+ }));
265
+ }
266
+ }
267
+
268
+ if (shellCommands.has(command) && hasDynamicExecutionArg(command, args)) {
269
+ findings.push(createFinding({
270
+ ruleId: "mcp-shell-execution",
271
+ title: "MCP or plugin config can execute shell code",
272
+ severity: "high",
273
+ recommendation: "Avoid shell interpreters in tool config unless the command is local, pinned, and reviewed.",
274
+ record,
275
+ basePath,
276
+ evidence: `${command} ${args.join(" ")}`.trim()
277
+ }));
278
+ }
279
+
280
+ if (isUnknownExecutable(commandObject.command)) {
281
+ findings.push(createFinding({
282
+ ruleId: "mcp-unknown-executable",
283
+ title: "MCP or plugin config uses a local or unknown executable path",
284
+ severity: "medium",
285
+ recommendation: "Review local executable paths and prefer committed, pinned, least-privilege tools.",
286
+ record,
287
+ basePath,
288
+ evidence: commandObject.command
289
+ }));
290
+ }
291
+
292
+ const broadPath = args.find(isBroadFilesystemValue);
293
+ if (broadPath) {
294
+ findings.push(createFinding({
295
+ ruleId: "mcp-broad-filesystem-access",
296
+ title: "MCP or plugin config grants broad filesystem access",
297
+ severity: "high",
298
+ recommendation: "Restrict filesystem access to the smallest required workspace path.",
299
+ record,
300
+ basePath,
301
+ evidence: broadPath
302
+ }));
303
+ }
304
+ }
305
+
306
+ return findings;
307
+ }
308
+
309
+ function analyzeEnvObjects(value, record, basePath) {
310
+ const findings = [];
311
+
312
+ for (const envObject of findEnvObjects(value)) {
313
+ for (const [key, envValue] of Object.entries(envObject)) {
314
+ const serializedValue = String(envValue);
315
+
316
+ if (sensitiveNamePattern.test(key) || sensitiveNamePattern.test(serializedValue)) {
317
+ findings.push(createFinding({
318
+ ruleId: "mcp-secret-env",
319
+ title: "MCP or plugin config injects sensitive environment variables",
320
+ severity: "high",
321
+ recommendation: "Avoid passing broad secrets into MCP tools unless the server is trusted and least-privileged.",
322
+ record,
323
+ basePath,
324
+ evidence: key
325
+ }));
326
+ }
327
+ }
328
+ }
329
+
330
+ return findings;
331
+ }
332
+
333
+ function collectPatternFindings(findings, record, basePath, rule) {
334
+ for (const pattern of rule.patterns) {
335
+ let match;
336
+
337
+ while ((match = pattern.exec(record.text))) {
338
+ findings.push(createFinding({
339
+ ruleId: rule.ruleId,
340
+ title: rule.title,
341
+ severity: rule.severity,
342
+ recommendation: rule.recommendation,
343
+ record,
344
+ basePath,
345
+ line: lineNumberForIndex(record.text, match.index ?? 0),
346
+ evidence: cleanEvidence(match[0])
347
+ }));
348
+ }
349
+ }
350
+ }
351
+
352
+ function findObjectsWithCommand(value) {
353
+ const objects = [];
354
+
355
+ walkJson(value, (item) => {
356
+ if (item && typeof item === "object" && !Array.isArray(item) && typeof item.command === "string") {
357
+ objects.push(item);
358
+ }
359
+ });
360
+
361
+ return objects;
362
+ }
363
+
364
+ function findEnvObjects(value) {
365
+ const objects = [];
366
+
367
+ walkJson(value, (item, key) => {
368
+ if (
369
+ item &&
370
+ typeof item === "object" &&
371
+ !Array.isArray(item) &&
372
+ ["env", "environment", "environmentVariables"].includes(key)
373
+ ) {
374
+ objects.push(item);
375
+ }
376
+ });
377
+
378
+ return objects;
379
+ }
380
+
381
+ function findSiblingPackageJson(record, fileRecords) {
382
+ const expected = path.join(path.dirname(record.file), "package.json");
383
+ return fileRecords.find((candidate) => candidate.file === expected);
384
+ }
385
+
386
+ function missingOpenClawPluginFields(openclaw) {
387
+ const fields = [];
388
+ const compat = isPlainObject(openclaw.compat) ? openclaw.compat : {};
389
+ const build = isPlainObject(openclaw.build) ? openclaw.build : {};
390
+
391
+ if (!trimmedString(compat.pluginApi)) {
392
+ fields.push("openclaw.compat.pluginApi");
393
+ }
394
+
395
+ if (!trimmedString(build.openclawVersion)) {
396
+ fields.push("openclaw.build.openclawVersion");
397
+ }
398
+
399
+ return fields;
400
+ }
401
+
402
+ function compiledRuntimeCandidates(entry) {
403
+ const normalized = normalizePackagePath(entry);
404
+ const withoutExtension = normalized.replace(/\.[^.]+$/, "");
405
+ const distBase = normalized.startsWith("src/")
406
+ ? `dist/${normalized.slice("src/".length).replace(/\.[^.]+$/, "")}`
407
+ : `dist/${withoutExtension}`;
408
+
409
+ return [".js", ".mjs", ".cjs"].flatMap((extension) => [
410
+ `${distBase}${extension}`,
411
+ `${withoutExtension}${extension}`
412
+ ]);
413
+ }
414
+
415
+ function firstCapabilityEvidence(serialized) {
416
+ const match = /\b(?:shell|terminal|filesystem|process|exec|spawn)\b/i.exec(serialized);
417
+ return match?.[0] ?? "sensitive capability";
418
+ }
419
+
420
+ function walkJson(value, visit, key = "") {
421
+ visit(value, key);
422
+
423
+ if (Array.isArray(value)) {
424
+ for (const item of value) {
425
+ walkJson(item, visit, key);
426
+ }
427
+ return;
428
+ }
429
+
430
+ if (value && typeof value === "object") {
431
+ for (const [childKey, childValue] of Object.entries(value)) {
432
+ walkJson(childValue, visit, childKey);
433
+ }
434
+ }
435
+ }
436
+
437
+ function firstUnpinnedPackage(command, args) {
438
+ const packageArg = args.find((arg) => isPackageLikeArg(command, arg));
439
+ if (!packageArg) {
440
+ return "";
441
+ }
442
+
443
+ return isPinnedPackage(packageArg) ? "" : packageArg;
444
+ }
445
+
446
+ function isPackageLikeArg(command, arg) {
447
+ if (!arg || arg.startsWith("-")) {
448
+ return false;
449
+ }
450
+
451
+ if (command === "pnpm") {
452
+ return arg !== "dlx";
453
+ }
454
+
455
+ return !arg.startsWith("http://") && !arg.startsWith("https://");
456
+ }
457
+
458
+ function isPinnedPackage(value) {
459
+ if (value.includes("==")) {
460
+ return true;
461
+ }
462
+
463
+ const slashIndex = value.lastIndexOf("/");
464
+ const atIndex = value.lastIndexOf("@");
465
+
466
+ return atIndex > slashIndex && !value.endsWith("@latest");
467
+ }
468
+
469
+ function hasDynamicExecutionArg(command, args) {
470
+ if (["bash", "sh", "zsh", "powershell", "pwsh"].includes(command)) {
471
+ return args.some((arg) => ["-c", "-Command", "-EncodedCommand"].includes(arg));
472
+ }
473
+
474
+ if (["python", "python3"].includes(command)) {
475
+ return args.includes("-c");
476
+ }
477
+
478
+ return command === "node" && args.includes("-e");
479
+ }
480
+
481
+ function isUnknownExecutable(command) {
482
+ const value = String(command ?? "");
483
+ return value.startsWith("/") || value.startsWith("./") || value.startsWith("../") || value.startsWith("~/");
484
+ }
485
+
486
+ function isBroadFilesystemValue(value) {
487
+ return value === "/" || value === "$HOME" || value === "~/" || value.startsWith("/Users/");
488
+ }
489
+
490
+ function isInsideDir(file, dir) {
491
+ const relative = path.relative(dir, file);
492
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
493
+ }
494
+
495
+ function isPlainObject(value) {
496
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
497
+ }
498
+
499
+ function isTypeScriptRuntimeEntry(value) {
500
+ return /\.(?:c|m)?ts$/i.test(value);
501
+ }
502
+
503
+ function normalizeCommand(value) {
504
+ return String(value ?? "").trim().toLowerCase();
505
+ }
506
+
507
+ function normalizePackagePath(value) {
508
+ return String(value ?? "").trim().replaceAll("\\", "/").replace(/^\.\//, "");
509
+ }
510
+
511
+ function stringArray(value) {
512
+ return Array.isArray(value)
513
+ ? value.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim())
514
+ : [];
515
+ }
516
+
517
+ function trimmedString(value) {
518
+ return typeof value === "string" && value.trim() ? value.trim() : "";
519
+ }
520
+
521
+ function parseConfigJson(record) {
522
+ try {
523
+ return {
524
+ ok: true,
525
+ value: JSON.parse(record.text)
526
+ };
527
+ } catch (error) {
528
+ return {
529
+ ok: false,
530
+ error: error.message
531
+ };
532
+ }
533
+ }
534
+
535
+ function createFinding({ ruleId, title, severity, recommendation, record, basePath, line, evidence }) {
536
+ const clean = cleanEvidence(evidence);
537
+
538
+ return {
539
+ ruleId,
540
+ title,
541
+ severity,
542
+ recommendation,
543
+ file: relativePath(basePath, record.file),
544
+ line: line ?? lineForEvidence(record.text, clean),
545
+ evidence: clean
546
+ };
547
+ }
548
+
549
+ function dedupeFindings(findings) {
550
+ const seen = new Set();
551
+ const unique = [];
552
+
553
+ for (const finding of findings) {
554
+ const key = `${finding.ruleId}:${finding.file}:${finding.line}`;
555
+ if (seen.has(key)) {
556
+ continue;
557
+ }
558
+
559
+ seen.add(key);
560
+ unique.push(finding);
561
+ }
562
+
563
+ return unique;
564
+ }
565
+
566
+ function lineForEvidence(text, evidence) {
567
+ const index = text.indexOf(evidence);
568
+ return lineNumberForIndex(text, index >= 0 ? index : 0);
569
+ }
570
+
571
+ function lineNumberForIndex(text, index) {
572
+ return text.slice(0, index).split("\n").length;
573
+ }
574
+
575
+ function lineForPackageJsonKey(text, key) {
576
+ if (!key) {
577
+ return 1;
578
+ }
579
+
580
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
581
+ const match = new RegExp(`"${escaped}"\\s*:`).exec(text);
582
+ return lineNumberForIndex(text, match?.index ?? 0);
583
+ }
584
+
585
+ function cleanEvidence(value) {
586
+ return String(value ?? "").replace(/\s+/g, " ").trim().slice(0, 160);
587
+ }
588
+
589
+ function relativePath(basePath, filePath) {
590
+ const relative = path.relative(basePath, filePath);
591
+ return relative || path.basename(filePath);
592
+ }