@ivorycanvas/qamap 0.3.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 (98) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +21 -0
  3. package/README.md +636 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +879 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/config.d.ts +5 -0
  8. package/dist/config.js +114 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/context.d.ts +1 -0
  11. package/dist/context.js +126 -0
  12. package/dist/context.js.map +1 -0
  13. package/dist/doctor.d.ts +32 -0
  14. package/dist/doctor.js +200 -0
  15. package/dist/doctor.js.map +1 -0
  16. package/dist/domain-language.d.ts +25 -0
  17. package/dist/domain-language.js +655 -0
  18. package/dist/domain-language.js.map +1 -0
  19. package/dist/domains.d.ts +33 -0
  20. package/dist/domains.js +258 -0
  21. package/dist/domains.js.map +1 -0
  22. package/dist/e2e.d.ts +326 -0
  23. package/dist/e2e.js +6869 -0
  24. package/dist/e2e.js.map +1 -0
  25. package/dist/eval.d.ts +40 -0
  26. package/dist/eval.js +374 -0
  27. package/dist/eval.js.map +1 -0
  28. package/dist/flows.d.ts +32 -0
  29. package/dist/flows.js +246 -0
  30. package/dist/flows.js.map +1 -0
  31. package/dist/fs.d.ts +7 -0
  32. package/dist/fs.js +132 -0
  33. package/dist/fs.js.map +1 -0
  34. package/dist/github.d.ts +34 -0
  35. package/dist/github.js +183 -0
  36. package/dist/github.js.map +1 -0
  37. package/dist/history.d.ts +173 -0
  38. package/dist/history.js +247 -0
  39. package/dist/history.js.map +1 -0
  40. package/dist/index.d.ts +35 -0
  41. package/dist/index.js +20 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/manifest-suggestions.d.ts +67 -0
  44. package/dist/manifest-suggestions.js +673 -0
  45. package/dist/manifest-suggestions.js.map +1 -0
  46. package/dist/manifest.d.ts +212 -0
  47. package/dist/manifest.js +1607 -0
  48. package/dist/manifest.js.map +1 -0
  49. package/dist/qa.d.ts +53 -0
  50. package/dist/qa.js +361 -0
  51. package/dist/qa.js.map +1 -0
  52. package/dist/report.d.ts +5 -0
  53. package/dist/report.js +164 -0
  54. package/dist/report.js.map +1 -0
  55. package/dist/review.d.ts +31 -0
  56. package/dist/review.js +383 -0
  57. package/dist/review.js.map +1 -0
  58. package/dist/scanner.d.ts +3 -0
  59. package/dist/scanner.js +797 -0
  60. package/dist/scanner.js.map +1 -0
  61. package/dist/severity.d.ts +3 -0
  62. package/dist/severity.js +9 -0
  63. package/dist/severity.js.map +1 -0
  64. package/dist/test-evidence.d.ts +39 -0
  65. package/dist/test-evidence.js +303 -0
  66. package/dist/test-evidence.js.map +1 -0
  67. package/dist/test-plan.d.ts +35 -0
  68. package/dist/test-plan.js +573 -0
  69. package/dist/test-plan.js.map +1 -0
  70. package/dist/types.d.ts +60 -0
  71. package/dist/types.js +2 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/verify.d.ts +27 -0
  74. package/dist/verify.js +244 -0
  75. package/dist/verify.js.map +1 -0
  76. package/dist/version.d.ts +2 -0
  77. package/dist/version.js +3 -0
  78. package/dist/version.js.map +1 -0
  79. package/docs/adoption.md +166 -0
  80. package/docs/agent-skill.md +94 -0
  81. package/docs/api-contracts.md +30 -0
  82. package/docs/assets/qamap-30s-demo.gif +0 -0
  83. package/docs/configuration.md +277 -0
  84. package/docs/e2e-output-examples.md +464 -0
  85. package/docs/ecosystem.md +41 -0
  86. package/docs/eval.md +52 -0
  87. package/docs/github-action.md +89 -0
  88. package/docs/manifest.md +350 -0
  89. package/docs/quickstart-demo.md +268 -0
  90. package/docs/release-validation.md +247 -0
  91. package/docs/releasing.md +112 -0
  92. package/docs/roadmap.md +67 -0
  93. package/docs/rules.md +28 -0
  94. package/docs/verify.md +44 -0
  95. package/package.json +80 -0
  96. package/schema/qamap-manifest.schema.json +323 -0
  97. package/schema/qamap.schema.json +58 -0
  98. package/skills/qamap-pr-qa/SKILL.md +81 -0
@@ -0,0 +1,797 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { collectProjectFiles, getFile, getFilesUnder, pathExists } from "./fs.js";
4
+ import { TOOL_NAME, VERSION } from "./version.js";
5
+ const defaultMaxFiles = 2000;
6
+ const instructionFileNames = new Set([
7
+ "AGENTS.md",
8
+ "AGENTS.override.md",
9
+ "CLAUDE.md",
10
+ ".claude/CLAUDE.md",
11
+ "GEMINI.md",
12
+ ".github/copilot-instructions.md",
13
+ ".cursorrules",
14
+ ]);
15
+ const instructionDirectoryPatterns = [
16
+ { directory: ".cursor/rules", pattern: /\.(md|mdc)$/i },
17
+ { directory: ".claude/rules", pattern: /\.(md|mdc)$/i },
18
+ { directory: ".github/instructions", pattern: /\.instructions\.md$/i },
19
+ ];
20
+ const mcpConfigNames = new Set([
21
+ ".mcp.json",
22
+ "mcp.json",
23
+ ".cursor/mcp.json",
24
+ ".vscode/mcp.json",
25
+ "claude_desktop_config.json",
26
+ ]);
27
+ const agentSettingsNames = new Set([".claude/settings.json", ".claude/settings.local.json", ".gemini/settings.json"]);
28
+ const secretKeyPattern = /(token|secret|password|passwd|api[_-]?key|private[_-]?key|credential)/i;
29
+ export async function scanProject(rootInput, options = {}) {
30
+ const root = path.resolve(rootInput);
31
+ const stat = await fs.stat(root);
32
+ if (!stat.isDirectory()) {
33
+ throw new Error(`QAMap expected a directory: ${root}`);
34
+ }
35
+ const workspaceRoot = options.workspaceRoot ? path.resolve(options.workspaceRoot) : undefined;
36
+ if (workspaceRoot) {
37
+ const workspaceStat = await fs.stat(workspaceRoot);
38
+ if (!workspaceStat.isDirectory()) {
39
+ throw new Error(`QAMap expected a workspace root directory: ${workspaceRoot}`);
40
+ }
41
+ const relativeRoot = path.relative(workspaceRoot, root);
42
+ if (relativeRoot.startsWith("..") || path.isAbsolute(relativeRoot)) {
43
+ throw new Error(`QAMap scan path must be inside workspace root: ${root}`);
44
+ }
45
+ }
46
+ const files = await collectProjectFiles(root, options.maxFiles ?? defaultMaxFiles);
47
+ const workspaceFiles = workspaceRoot && workspaceRoot !== root ? await collectWorkspaceGuardrailFiles(workspaceRoot) : [];
48
+ const guardrailFiles = dedupeFiles([...files, ...workspaceFiles]);
49
+ const rawFindings = [
50
+ ...checkAgentInstructions(guardrailFiles),
51
+ ...checkInstructionConflicts(guardrailFiles),
52
+ ...checkSuspiciousInstructionText(guardrailFiles),
53
+ ...checkMcpConfig(guardrailFiles),
54
+ ...checkAgentSettings(guardrailFiles),
55
+ ...checkPackageScripts(files),
56
+ ...checkApiContractSource(files),
57
+ ...checkGitHubActions(guardrailFiles),
58
+ ...checkCommittedEnvFiles(files),
59
+ ...checkCommunityHealth(guardrailFiles),
60
+ ];
61
+ const findings = applyFindingPolicy(rawFindings, options);
62
+ return {
63
+ tool: {
64
+ name: TOOL_NAME,
65
+ version: VERSION,
66
+ },
67
+ root,
68
+ workspaceRoot,
69
+ scannedAt: new Date().toISOString(),
70
+ filesInspected: files.length + workspaceFiles.length,
71
+ config: options.configPath || options.ignoreRules?.length || Object.keys(options.severityOverrides ?? {}).length
72
+ ? {
73
+ path: options.configPath,
74
+ ignoredRules: normalizeRuleIds(options.ignoreRules ?? []),
75
+ severityOverrides: normalizeSeverityOverrides(options.severityOverrides ?? {}),
76
+ }
77
+ : undefined,
78
+ findings,
79
+ counts: countFindings(findings),
80
+ };
81
+ }
82
+ async function collectWorkspaceGuardrailFiles(workspaceRoot) {
83
+ const files = [];
84
+ const candidatePaths = [
85
+ ...instructionFileNames,
86
+ ...mcpConfigNames,
87
+ ...agentSettingsNames,
88
+ "LICENSE",
89
+ "SECURITY.md",
90
+ "CONTRIBUTING.md",
91
+ ];
92
+ for (const relativePath of candidatePaths) {
93
+ const file = await readProjectFileIfPresent(workspaceRoot, relativePath);
94
+ if (file) {
95
+ files.push(file);
96
+ }
97
+ }
98
+ for (const ruleDirectory of instructionDirectoryPatterns) {
99
+ const instructionRoot = path.join(workspaceRoot, ruleDirectory.directory);
100
+ if (await pathExists(instructionRoot)) {
101
+ files.push(...(await collectTextFilesUnder(workspaceRoot, instructionRoot, ruleDirectory.pattern)));
102
+ }
103
+ }
104
+ const workflowRoot = path.join(workspaceRoot, ".github/workflows");
105
+ if (await pathExists(workflowRoot)) {
106
+ files.push(...(await collectTextFilesUnder(workspaceRoot, workflowRoot, /\.(ya?ml)$/i)));
107
+ }
108
+ return dedupeFiles(files);
109
+ }
110
+ async function readProjectFileIfPresent(root, relativePath) {
111
+ const absolutePath = path.join(root, relativePath);
112
+ try {
113
+ const stat = await fs.stat(absolutePath);
114
+ if (!stat.isFile()) {
115
+ return undefined;
116
+ }
117
+ return {
118
+ path: relativePath,
119
+ absolutePath,
120
+ size: stat.size,
121
+ text: await fs.readFile(absolutePath, "utf8"),
122
+ };
123
+ }
124
+ catch {
125
+ return undefined;
126
+ }
127
+ }
128
+ async function collectTextFilesUnder(root, directory, pattern) {
129
+ const files = [];
130
+ async function walk(currentDirectory) {
131
+ let entries;
132
+ try {
133
+ entries = await fs.readdir(currentDirectory, { withFileTypes: true });
134
+ }
135
+ catch {
136
+ return;
137
+ }
138
+ entries.sort((left, right) => left.name.localeCompare(right.name));
139
+ for (const entry of entries) {
140
+ const absolutePath = path.join(currentDirectory, entry.name);
141
+ if (entry.isDirectory()) {
142
+ await walk(absolutePath);
143
+ continue;
144
+ }
145
+ if (!entry.isFile()) {
146
+ continue;
147
+ }
148
+ const relativePath = path.relative(root, absolutePath).split(path.sep).join("/");
149
+ if (!pattern.test(relativePath)) {
150
+ continue;
151
+ }
152
+ const stat = await fs.stat(absolutePath);
153
+ files.push({
154
+ path: relativePath,
155
+ absolutePath,
156
+ size: stat.size,
157
+ text: await fs.readFile(absolutePath, "utf8"),
158
+ });
159
+ }
160
+ }
161
+ await walk(directory);
162
+ return files;
163
+ }
164
+ function dedupeFiles(files) {
165
+ const seen = new Set();
166
+ return files.filter((file) => {
167
+ if (seen.has(file.absolutePath)) {
168
+ return false;
169
+ }
170
+ seen.add(file.absolutePath);
171
+ return true;
172
+ });
173
+ }
174
+ function countFindings(findings) {
175
+ return findings.reduce((counts, finding) => {
176
+ counts[finding.severity] += 1;
177
+ return counts;
178
+ }, { info: 0, low: 0, medium: 0, high: 0 });
179
+ }
180
+ function finding(input) {
181
+ return {
182
+ recommendation: "Review this finding and add an explicit repository policy.",
183
+ ...input,
184
+ };
185
+ }
186
+ function applyFindingPolicy(findings, options) {
187
+ const ignoredRules = new Set(normalizeRuleIds(options.ignoreRules ?? []));
188
+ const severityOverrides = normalizeSeverityOverrides(options.severityOverrides ?? {});
189
+ return findings
190
+ .filter((item) => !ignoredRules.has(item.id.toUpperCase()))
191
+ .map((item) => {
192
+ const override = severityOverrides[item.id.toUpperCase()];
193
+ if (!override || override === item.severity) {
194
+ return item;
195
+ }
196
+ return {
197
+ ...item,
198
+ originalSeverity: item.severity,
199
+ severity: override,
200
+ };
201
+ });
202
+ }
203
+ function normalizeRuleIds(ruleIds) {
204
+ return [...new Set(ruleIds.map((ruleId) => ruleId.toUpperCase()))];
205
+ }
206
+ function normalizeSeverityOverrides(overrides) {
207
+ return Object.fromEntries(Object.entries(overrides).map(([ruleId, severity]) => [ruleId.toUpperCase(), severity]));
208
+ }
209
+ function getInstructionFiles(files) {
210
+ return files.filter((file) => {
211
+ if (instructionFileNames.has(file.path)) {
212
+ return true;
213
+ }
214
+ return instructionDirectoryPatterns.some((ruleDirectory) => matchesDirectoryPattern(file.path, ruleDirectory.directory, ruleDirectory.pattern));
215
+ });
216
+ }
217
+ function matchesDirectoryPattern(filePath, directory, pattern) {
218
+ const prefix = directory.endsWith("/") ? directory : `${directory}/`;
219
+ return filePath.startsWith(prefix) && pattern.test(filePath);
220
+ }
221
+ function checkAgentInstructions(files) {
222
+ const instructionFiles = getInstructionFiles(files);
223
+ if (instructionFiles.length > 0) {
224
+ return [];
225
+ }
226
+ return [
227
+ finding({
228
+ id: "QM001",
229
+ title: "Missing agent instructions",
230
+ severity: "medium",
231
+ message: "No AGENTS.md, Claude, Cursor, Copilot, or Gemini instruction file was found.",
232
+ recommendation: "Add AGENTS.md or an equivalent agent instruction file with build, test, review, and repository boundary rules.",
233
+ }),
234
+ ];
235
+ }
236
+ function checkInstructionConflicts(files) {
237
+ const instructionFiles = getInstructionFiles(files).filter((file) => file.text);
238
+ if (instructionFiles.length === 0) {
239
+ return [];
240
+ }
241
+ const lines = instructionFiles.flatMap((file) => file
242
+ .text.split(/\r?\n/)
243
+ .map((line, index) => ({ file: file.path, line: index + 1, text: line.trim() }))
244
+ .filter((line) => line.text.length > 0));
245
+ const signals = new Map();
246
+ for (const line of lines) {
247
+ addSignal(signals, line, "package-manager:npm", /\b(use|prefer|run)\s+npm\b/i);
248
+ addSignal(signals, line, "package-manager:pnpm", /\b(use|prefer|run)\s+pnpm\b/i);
249
+ addSignal(signals, line, "package-manager:yarn", /\b(use|prefer|run)\s+yarn\b/i);
250
+ addSignal(signals, line, "package-manager:bun", /\b(use|prefer|run)\s+bun\b/i);
251
+ addSignal(signals, line, "tests:required", /\b(always|must|required|run)\b.{0,40}\btests?\b/i);
252
+ addSignal(signals, line, "tests:skip", /\b(skip|avoid|do not|don't|never)\b.{0,40}\btests?\b/i);
253
+ if (/push directly to (main|master)/i.test(line.text) && !/\b(do not|don't|never|avoid|no)\b/i.test(line.text)) {
254
+ pushSignal(signals, "main-push:allowed", line);
255
+ }
256
+ if (/\b(do not|don't|never|avoid|no)\b.{0,40}push directly to (main|master)/i.test(line.text)) {
257
+ pushSignal(signals, "main-push:forbidden", line);
258
+ }
259
+ }
260
+ const conflicts = [];
261
+ const packageManagers = ["npm", "pnpm", "yarn", "bun"].filter((name) => signals.has(`package-manager:${name}`));
262
+ if (packageManagers.length > 1) {
263
+ conflicts.push(`Multiple package managers mentioned: ${packageManagers.join(", ")}`);
264
+ }
265
+ if (signals.has("tests:required") && signals.has("tests:skip")) {
266
+ conflicts.push("Instructions both require and discourage tests.");
267
+ }
268
+ if (signals.has("main-push:allowed") && signals.has("main-push:forbidden")) {
269
+ conflicts.push("Instructions both allow and forbid direct pushes to the default branch.");
270
+ }
271
+ if (conflicts.length === 0) {
272
+ return [];
273
+ }
274
+ return [
275
+ finding({
276
+ id: "QM002",
277
+ title: "Conflicting agent instructions",
278
+ severity: "medium",
279
+ message: conflicts.join(" "),
280
+ recommendation: "Consolidate agent instructions so coding agents get one clear policy for package management, validation, and protected branches.",
281
+ evidence: Array.from(signals.values()).flat().slice(0, 4).join(" | "),
282
+ }),
283
+ ];
284
+ }
285
+ function addSignal(signals, line, key, pattern) {
286
+ if (pattern.test(line.text)) {
287
+ pushSignal(signals, key, line);
288
+ }
289
+ }
290
+ function pushSignal(signals, key, line) {
291
+ const existing = signals.get(key) ?? [];
292
+ existing.push(`${line.file}:${line.line}`);
293
+ signals.set(key, existing);
294
+ }
295
+ function checkSuspiciousInstructionText(files) {
296
+ const instructionFiles = getInstructionFiles(files).filter((file) => file.text);
297
+ const findings = [];
298
+ const suspiciousPatterns = [
299
+ {
300
+ label: "instruction override",
301
+ pattern: new RegExp(["ignore", "(?:all\\s+)?previous", "instructions"].join("\\s+"), "i"),
302
+ },
303
+ {
304
+ label: "secret exposure request",
305
+ pattern: /\b(?:print|reveal|dump|show|upload|send)\b.{0,80}\b(?:secret|token|api key|private key|\.env)\b/i,
306
+ },
307
+ {
308
+ label: "credential exfiltration",
309
+ pattern: /\b(?:curl|wget|post|upload)\b.{0,80}\b(?:secret|token|\.env|credential)\b/i,
310
+ },
311
+ ];
312
+ for (const file of instructionFiles) {
313
+ const lines = file.text.split(/\r?\n/);
314
+ for (const [index, line] of lines.entries()) {
315
+ const match = suspiciousPatterns.find((item) => item.pattern.test(line));
316
+ if (!match) {
317
+ continue;
318
+ }
319
+ findings.push(finding({
320
+ id: "QM003",
321
+ title: "Suspicious agent instruction text",
322
+ severity: "high",
323
+ file: file.path,
324
+ message: `Instruction file contains text that matches a suspicious ${match.label} pattern.`,
325
+ recommendation: "Remove untrusted instruction text or move examples into clearly fenced documentation that agents should not follow.",
326
+ evidence: `${file.path}:${index + 1} ${redact(line.trim())}`,
327
+ }));
328
+ }
329
+ }
330
+ return findings;
331
+ }
332
+ function checkMcpConfig(files) {
333
+ const findings = [];
334
+ const configs = files.filter((file) => (mcpConfigNames.has(file.path) || agentSettingsNames.has(file.path)) && file.text);
335
+ for (const config of configs) {
336
+ let parsed;
337
+ try {
338
+ parsed = JSON.parse(config.text);
339
+ }
340
+ catch {
341
+ if (!mcpConfigNames.has(config.path)) {
342
+ continue;
343
+ }
344
+ findings.push(finding({
345
+ id: "QM004",
346
+ title: "Unreadable MCP configuration",
347
+ severity: "medium",
348
+ file: config.path,
349
+ message: "MCP configuration could not be parsed as JSON.",
350
+ recommendation: "Fix the JSON syntax so MCP tooling and security checks can inspect the configuration.",
351
+ }));
352
+ continue;
353
+ }
354
+ if (mcpConfigNames.has(config.path)) {
355
+ inspectMcpValue(parsed, config.path, "$", findings);
356
+ continue;
357
+ }
358
+ if (isRecord(parsed) && parsed.mcpServers) {
359
+ inspectMcpValue(parsed.mcpServers, config.path, "$.mcpServers", findings);
360
+ }
361
+ }
362
+ return findings;
363
+ }
364
+ function checkAgentSettings(files) {
365
+ const findings = [];
366
+ const settingsFiles = files.filter((file) => agentSettingsNames.has(file.path) && file.text);
367
+ for (const settingsFile of settingsFiles) {
368
+ let parsed;
369
+ try {
370
+ parsed = JSON.parse(settingsFile.text);
371
+ }
372
+ catch {
373
+ findings.push(finding({
374
+ id: "QM012",
375
+ title: "Unreadable agent settings",
376
+ severity: "medium",
377
+ file: settingsFile.path,
378
+ message: "Agent settings could not be parsed as JSON.",
379
+ recommendation: "Fix the JSON syntax so agent hooks, permissions, and tool configuration can be inspected.",
380
+ }));
381
+ continue;
382
+ }
383
+ inspectAgentHooks(parsed, settingsFile.path, findings);
384
+ inspectAgentPermissions(parsed, settingsFile.path, findings);
385
+ }
386
+ return findings;
387
+ }
388
+ function inspectMcpValue(value, file, location, findings) {
389
+ if (!value || typeof value !== "object") {
390
+ return;
391
+ }
392
+ if (Array.isArray(value)) {
393
+ value.forEach((item, index) => inspectMcpValue(item, file, `${location}[${index}]`, findings));
394
+ return;
395
+ }
396
+ const record = value;
397
+ if (typeof record.command === "string") {
398
+ const args = Array.isArray(record.args) ? record.args.map(String) : [];
399
+ const commandRisk = classifyCommandRisk(record.command, args);
400
+ if (commandRisk) {
401
+ findings.push(finding({
402
+ id: "QM004",
403
+ title: "Risky MCP command",
404
+ severity: commandRisk.severity,
405
+ file,
406
+ message: commandRisk.message,
407
+ recommendation: "Prefer pinned, narrow MCP server commands and avoid shell wrappers, publish commands, direct pushes, or pipe-to-shell installers.",
408
+ evidence: `${location}.command=${record.command}`,
409
+ }));
410
+ }
411
+ }
412
+ if (record.env && typeof record.env === "object" && !Array.isArray(record.env)) {
413
+ for (const [key, envValue] of Object.entries(record.env)) {
414
+ if (!secretKeyPattern.test(key) || typeof envValue !== "string" || isPlaceholderSecret(envValue)) {
415
+ continue;
416
+ }
417
+ findings.push(finding({
418
+ id: "QM005",
419
+ title: "Secret-like value in MCP config",
420
+ severity: "high",
421
+ file,
422
+ message: `MCP config appears to embed a real value for ${key}.`,
423
+ recommendation: "Reference secrets through environment variables or a secret manager instead of committing concrete values.",
424
+ evidence: `${location}.env.${key}=${redact(envValue)}`,
425
+ }));
426
+ }
427
+ }
428
+ for (const [key, child] of Object.entries(record)) {
429
+ inspectMcpValue(child, file, `${location}.${key}`, findings);
430
+ }
431
+ }
432
+ function inspectAgentHooks(value, file, findings) {
433
+ if (!isRecord(value) || !value.hooks) {
434
+ return;
435
+ }
436
+ inspectHookValue(value.hooks, file, "$.hooks", findings);
437
+ }
438
+ function inspectHookValue(value, file, location, findings) {
439
+ if (!value || typeof value !== "object") {
440
+ return;
441
+ }
442
+ if (Array.isArray(value)) {
443
+ value.forEach((item, index) => inspectHookValue(item, file, `${location}[${index}]`, findings));
444
+ return;
445
+ }
446
+ const record = value;
447
+ if (typeof record.command === "string") {
448
+ const risk = classifyHookRisk(record.command);
449
+ if (risk) {
450
+ findings.push(finding({
451
+ id: "QM012",
452
+ title: "Risky agent hook command",
453
+ severity: risk.severity,
454
+ file,
455
+ message: risk.message,
456
+ recommendation: "Keep agent hooks narrow, auditable, and non-destructive; avoid hooks that publish, push, merge, remove files, or exfiltrate secrets.",
457
+ evidence: `${location}.command=${redact(record.command)}`,
458
+ }));
459
+ }
460
+ }
461
+ for (const [key, child] of Object.entries(record)) {
462
+ inspectHookValue(child, file, `${location}.${key}`, findings);
463
+ }
464
+ }
465
+ function inspectAgentPermissions(value, file, findings) {
466
+ if (!isRecord(value) || !isRecord(value.permissions)) {
467
+ return;
468
+ }
469
+ const allowList = value.permissions.allow;
470
+ if (!Array.isArray(allowList)) {
471
+ return;
472
+ }
473
+ for (const [index, item] of allowList.entries()) {
474
+ const risk = typeof item === "string" ? classifyShellPermission(item) : undefined;
475
+ if (!risk) {
476
+ continue;
477
+ }
478
+ findings.push(finding({
479
+ id: "QM012",
480
+ title: risk.title,
481
+ severity: risk.severity,
482
+ file,
483
+ message: risk.message,
484
+ recommendation: "Replace broad Bash allow rules with specific, read-only commands and keep destructive operations behind explicit maintainer review.",
485
+ evidence: `$.permissions.allow[${index}]=${redact(item)}`,
486
+ }));
487
+ }
488
+ }
489
+ function classifyCommandRisk(command, args) {
490
+ const executable = path.basename(command).toLowerCase();
491
+ const argsText = args.join(" ");
492
+ const fullCommand = `${command} ${argsText}`;
493
+ if (["sh", "bash", "zsh", "fish", "cmd", "powershell", "pwsh"].includes(executable)) {
494
+ return {
495
+ severity: "high",
496
+ message: "MCP server is launched through a shell, which makes the executed behavior harder to audit.",
497
+ };
498
+ }
499
+ if (/\b(?:curl|wget)\b.+\|\s*(?:sh|bash|zsh|pwsh|powershell)\b/i.test(fullCommand)) {
500
+ return {
501
+ severity: "high",
502
+ message: "MCP command appears to download and execute a remote script.",
503
+ };
504
+ }
505
+ if (/\b(?:git\s+push|gh\s+pr\s+merge|npm\s+publish|pnpm\s+publish|yarn\s+npm\s+publish)\b/i.test(fullCommand)) {
506
+ return {
507
+ severity: "high",
508
+ message: "MCP command appears able to publish, push, or merge changes.",
509
+ };
510
+ }
511
+ if (/\b(?:sudo|chmod\s+777|rm\s+-rf\s+(?:\/|~|\$HOME|\*))\b/i.test(fullCommand)) {
512
+ return {
513
+ severity: "high",
514
+ message: "MCP command contains a destructive or privileged shell operation.",
515
+ };
516
+ }
517
+ if (/\bdocker\b/i.test(command) && /\b--privileged\b/i.test(argsText)) {
518
+ return {
519
+ severity: "medium",
520
+ message: "MCP command starts a privileged container.",
521
+ };
522
+ }
523
+ return undefined;
524
+ }
525
+ function isPlaceholderSecret(value) {
526
+ const normalized = value.trim();
527
+ if (normalized.length === 0) {
528
+ return true;
529
+ }
530
+ return (normalized.startsWith("$") ||
531
+ normalized.startsWith("${") ||
532
+ /^<.*>$/.test(normalized) ||
533
+ /\b(?:your|example|placeholder|replace|changeme|todo)\b/i.test(normalized));
534
+ }
535
+ function checkPackageScripts(files) {
536
+ const packageFile = getFile(files, "package.json");
537
+ if (!packageFile?.text) {
538
+ return [];
539
+ }
540
+ let parsed;
541
+ try {
542
+ parsed = JSON.parse(packageFile.text);
543
+ }
544
+ catch {
545
+ return [
546
+ finding({
547
+ id: "QM006",
548
+ title: "Unreadable package.json",
549
+ severity: "medium",
550
+ file: "package.json",
551
+ message: "package.json could not be parsed.",
552
+ recommendation: "Fix package.json so agents and CI can discover project scripts reliably.",
553
+ }),
554
+ ];
555
+ }
556
+ const findings = [];
557
+ const testScript = parsed.scripts?.test;
558
+ if (!testScript || /no test specified|exit\s+1/i.test(testScript)) {
559
+ findings.push(finding({
560
+ id: "QM006",
561
+ title: "Missing test script",
562
+ severity: "medium",
563
+ file: "package.json",
564
+ message: "package.json does not define a usable test script.",
565
+ recommendation: "Add a real test script so AI-generated changes have a default validation command.",
566
+ }));
567
+ }
568
+ for (const [name, script] of Object.entries(parsed.scripts ?? {})) {
569
+ const risk = classifyScriptRisk(script);
570
+ if (!risk) {
571
+ continue;
572
+ }
573
+ findings.push(finding({
574
+ id: "QM009",
575
+ title: "Risky package script",
576
+ severity: risk.severity,
577
+ file: "package.json",
578
+ message: `The "${name}" script ${risk.message}`,
579
+ recommendation: "Keep publish, push, merge, and destructive scripts outside default agent workflows or gate them with maintainer-only release processes.",
580
+ evidence: `"${name}": "${redact(script)}"`,
581
+ }));
582
+ }
583
+ return findings;
584
+ }
585
+ function classifyScriptRisk(script) {
586
+ if (/\b(?:git\s+push|gh\s+pr\s+merge|npm\s+publish|pnpm\s+publish|yarn\s+npm\s+publish)\b/i.test(script)) {
587
+ return { severity: "high", message: "can publish, push, or merge changes." };
588
+ }
589
+ if (/\b(?:curl|wget)\b.+\|\s*(?:sh|bash|zsh|pwsh|powershell)\b/i.test(script)) {
590
+ return { severity: "high", message: "downloads and executes a remote script." };
591
+ }
592
+ if (/\brm\s+-rf\s+(?:\/|~|\$HOME|\*)\b/i.test(script)) {
593
+ return { severity: "high", message: "contains a destructive remove command." };
594
+ }
595
+ return undefined;
596
+ }
597
+ function classifyHookRisk(command) {
598
+ const scriptRisk = classifyScriptRisk(command);
599
+ if (scriptRisk) {
600
+ return {
601
+ severity: scriptRisk.severity,
602
+ message: `Agent hook ${scriptRisk.message}`,
603
+ };
604
+ }
605
+ if (/\b(?:sudo|chmod\s+777)\b/i.test(command)) {
606
+ return { severity: "high", message: "Agent hook contains a privileged or broad permission command." };
607
+ }
608
+ if (/\b(?:curl|wget|nc|ncat|scp|rsync)\b.{0,120}\b(?:secret|token|credential|private[_-]?key|\.env)\b/i.test(command)) {
609
+ return { severity: "high", message: "Agent hook appears able to exfiltrate secrets or local environment data." };
610
+ }
611
+ if (/\b(?:cat|printenv|env)\b.{0,80}\b(?:secret|token|credential|private[_-]?key|\.env)\b/i.test(command)) {
612
+ return { severity: "high", message: "Agent hook appears to print or expose secret-like data." };
613
+ }
614
+ return undefined;
615
+ }
616
+ function classifyShellPermission(value) {
617
+ const match = value.match(/^Bash\((.*)\)$/i);
618
+ if (!match) {
619
+ return undefined;
620
+ }
621
+ const commandPattern = match[1].trim();
622
+ if (commandPattern === "*") {
623
+ return {
624
+ title: "Broad agent shell permission",
625
+ severity: "medium",
626
+ message: "Agent settings allow any Bash command.",
627
+ };
628
+ }
629
+ if (classifyScriptRisk(commandPattern)) {
630
+ return {
631
+ title: "Risky agent shell permission",
632
+ severity: "high",
633
+ message: "Agent settings allow risky publish, push, merge, destructive, or pipe-to-shell command patterns.",
634
+ };
635
+ }
636
+ if (/\b(?:sudo|chmod\s+777)\b/i.test(commandPattern)) {
637
+ return {
638
+ title: "Risky agent shell permission",
639
+ severity: "high",
640
+ message: "Agent settings allow privileged or broad permission command patterns.",
641
+ };
642
+ }
643
+ return undefined;
644
+ }
645
+ function checkApiContractSource(files) {
646
+ const proseApiDocs = files.filter(isProseApiDoc);
647
+ if (proseApiDocs.length === 0 || hasMachineReadableApiContract(files)) {
648
+ return [];
649
+ }
650
+ const evidence = proseApiDocs
651
+ .slice(0, 3)
652
+ .map((file) => file.path)
653
+ .join(", ");
654
+ return [
655
+ finding({
656
+ id: "QM013",
657
+ title: "Documentation-only API contract",
658
+ severity: "low",
659
+ file: proseApiDocs[0].path,
660
+ message: "API endpoints appear to be documented in prose, but no machine-readable contract source was found.",
661
+ recommendation: "Choose a single source of truth for API contracts, such as OpenAPI, protobuf, GraphQL SDL, AsyncAPI, or another IDL that can generate docs and client types.",
662
+ evidence,
663
+ }),
664
+ ];
665
+ }
666
+ function isProseApiDoc(file) {
667
+ if (!file.text || !/\.(md|mdx|txt)$/i.test(file.path)) {
668
+ return false;
669
+ }
670
+ const basename = path.basename(file.path).toLowerCase();
671
+ const pathSignal = /(?:^|[-_/])(api|apis|endpoint|endpoints|contract|swagger|openapi)(?:[-_.]|$)/i.test(file.path);
672
+ if (!pathSignal && !/\bapi\b/i.test(basename)) {
673
+ return false;
674
+ }
675
+ return /\b(GET|POST|PUT|PATCH|DELETE)\s+\/[A-Za-z0-9_/{:.-]+/.test(file.text);
676
+ }
677
+ function hasMachineReadableApiContract(files) {
678
+ return files.some((file) => {
679
+ const basename = path.basename(file.path).toLowerCase();
680
+ const extension = path.extname(file.path).toLowerCase();
681
+ if (["buf.yaml", "buf.gen.yaml", "asyncapi.yaml", "asyncapi.yml", "asyncapi.json"].includes(basename)) {
682
+ return true;
683
+ }
684
+ if (/^openapi\.(json|ya?ml)$/i.test(basename) || /^swagger\.(json|ya?ml)$/i.test(basename)) {
685
+ return true;
686
+ }
687
+ if (extension === ".proto" || extension === ".graphql" || extension === ".graphqls") {
688
+ return true;
689
+ }
690
+ if (/schema\.graphqls?$/i.test(file.path)) {
691
+ return true;
692
+ }
693
+ return false;
694
+ });
695
+ }
696
+ function checkGitHubActions(files) {
697
+ const workflowFiles = getFilesUnder(files, ".github/workflows").filter((file) => /\.(ya?ml)$/i.test(file.path));
698
+ if (workflowFiles.length === 0) {
699
+ return [
700
+ finding({
701
+ id: "QM007",
702
+ title: "Missing CI workflow",
703
+ severity: "low",
704
+ message: "No GitHub Actions workflow was found.",
705
+ recommendation: "Add a CI workflow that runs tests for pull requests and the default branch.",
706
+ }),
707
+ ];
708
+ }
709
+ const findings = [];
710
+ for (const workflow of workflowFiles) {
711
+ const text = workflow.text ?? "";
712
+ if (/permissions:\s*write-all/i.test(text)) {
713
+ findings.push(finding({
714
+ id: "QM010",
715
+ title: "Broad workflow permissions",
716
+ severity: "medium",
717
+ file: workflow.path,
718
+ message: "Workflow grants write-all permissions.",
719
+ recommendation: "Set the narrowest required permissions, usually contents: read for test workflows.",
720
+ }));
721
+ }
722
+ if (/contents:\s*write/i.test(text) && !/release|publish/i.test(workflow.path)) {
723
+ findings.push(finding({
724
+ id: "QM010",
725
+ title: "Broad workflow permissions",
726
+ severity: "medium",
727
+ file: workflow.path,
728
+ message: "Workflow grants contents: write outside an obvious release workflow.",
729
+ recommendation: "Use contents: read for validation workflows and reserve contents: write for maintainer-controlled release jobs.",
730
+ }));
731
+ }
732
+ if (/pull_request_target:/i.test(text) && /actions\/checkout/i.test(text)) {
733
+ findings.push(finding({
734
+ id: "QM010",
735
+ title: "Risky pull_request_target workflow",
736
+ severity: "medium",
737
+ file: workflow.path,
738
+ message: "Workflow uses pull_request_target with checkout.",
739
+ recommendation: "Avoid checking out untrusted pull request code in pull_request_target workflows unless the workflow is carefully sandboxed.",
740
+ }));
741
+ }
742
+ }
743
+ return findings;
744
+ }
745
+ function checkCommittedEnvFiles(files) {
746
+ return files
747
+ .filter((file) => {
748
+ const basename = path.basename(file.path);
749
+ return basename.startsWith(".env") && basename !== ".env.example";
750
+ })
751
+ .map((file) => finding({
752
+ id: "QM008",
753
+ title: "Committed environment file",
754
+ severity: "high",
755
+ file: file.path,
756
+ message: "A local environment file appears to be present in the repository.",
757
+ recommendation: "Remove committed environment files, rotate any exposed secrets, and keep only safe examples such as .env.example.",
758
+ }));
759
+ }
760
+ function checkCommunityHealth(files) {
761
+ const required = ["LICENSE", "SECURITY.md", "CONTRIBUTING.md"];
762
+ const missing = required.filter((file) => !files.some((candidate) => candidate.path === file));
763
+ if (missing.length === 0) {
764
+ return [];
765
+ }
766
+ return [
767
+ finding({
768
+ id: "QM011",
769
+ title: "Missing community health files",
770
+ severity: "low",
771
+ message: `Missing ${missing.join(", ")}.`,
772
+ recommendation: "Add license, contribution, and security policy files so public contributors know how to participate safely.",
773
+ }),
774
+ ];
775
+ }
776
+ function redact(value) {
777
+ return value
778
+ .replace(/(ghp_|github_pat_|sk-[A-Za-z0-9_-]*|[A-Za-z0-9_]{20,})/g, "[redacted]")
779
+ .slice(0, 180);
780
+ }
781
+ function isRecord(value) {
782
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
783
+ }
784
+ export async function hasAgentInstructions(root) {
785
+ for (const fileName of instructionFileNames) {
786
+ if (await pathExists(path.join(root, fileName))) {
787
+ return true;
788
+ }
789
+ }
790
+ for (const ruleDirectory of instructionDirectoryPatterns) {
791
+ if (await pathExists(path.join(root, ruleDirectory.directory))) {
792
+ return true;
793
+ }
794
+ }
795
+ return false;
796
+ }
797
+ //# sourceMappingURL=scanner.js.map