pr-checkmate 3.3.0 → 4.0.1

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 (127) hide show
  1. package/LICENSE +8 -3
  2. package/README.md +59 -4
  3. package/cspell.json +1 -0
  4. package/dist/cjs/agent/hook.d.ts +38 -0
  5. package/dist/cjs/agent/hook.js +175 -0
  6. package/dist/cjs/agent/registration.d.ts +10 -0
  7. package/dist/cjs/agent/registration.js +28 -0
  8. package/dist/cjs/agent/setup.d.ts +21 -0
  9. package/dist/cjs/agent/setup.js +229 -0
  10. package/dist/cjs/cli.js +88 -0
  11. package/dist/cjs/config/constants.d.ts +2 -0
  12. package/dist/cjs/config/constants.js +5 -1
  13. package/dist/cjs/config/pr-checkmate.template.json +10 -0
  14. package/dist/cjs/core/checks/findings.js +33 -7
  15. package/dist/cjs/core/checks/git/diff-smell.js +3 -1
  16. package/dist/cjs/core/checks/git/large-files.d.ts +1 -0
  17. package/dist/cjs/core/checks/git/large-files.js +3 -3
  18. package/dist/cjs/core/checks/languages/lint.js +27 -0
  19. package/dist/cjs/core/checks/languages/locate.d.ts +7 -0
  20. package/dist/cjs/core/checks/languages/locate.js +68 -0
  21. package/dist/cjs/core/checks/languages/prettier.js +15 -2
  22. package/dist/cjs/core/checks/languages/typecheck.js +4 -1
  23. package/dist/cjs/core/checks/pr/pr-size-check.d.ts +2 -0
  24. package/dist/cjs/core/checks/pr/pr-size-check.js +5 -5
  25. package/dist/cjs/core/checks/security/agent-instructions.d.ts +4 -0
  26. package/dist/cjs/core/checks/security/agent-instructions.js +149 -0
  27. package/dist/cjs/core/checks/security/diff-security.d.ts +7 -0
  28. package/dist/cjs/core/checks/security/diff-security.js +21 -1
  29. package/dist/cjs/core/checks/security/security.d.ts +0 -13
  30. package/dist/cjs/core/checks/security/security.js +27 -0
  31. package/dist/cjs/core/checks/security/workflow-security.js +33 -0
  32. package/dist/cjs/core/commands.d.ts +6 -0
  33. package/dist/cjs/core/commands.js +11 -1
  34. package/dist/cjs/core/finding-store.d.ts +16 -0
  35. package/dist/cjs/core/finding-store.js +73 -0
  36. package/dist/cjs/core/registry.js +2 -0
  37. package/dist/cjs/core/reporters.js +9 -0
  38. package/dist/cjs/core/run-checks.js +15 -5
  39. package/dist/cjs/core/sarif.d.ts +15 -0
  40. package/dist/cjs/core/sarif.js +60 -0
  41. package/dist/cjs/core/types.d.ts +7 -2
  42. package/dist/cjs/index.d.ts +2 -1
  43. package/dist/cjs/index.js +4 -1
  44. package/dist/cjs/init.js +11 -0
  45. package/dist/cjs/interfaces/index.d.ts +12 -0
  46. package/dist/cjs/mcp/config-tools.d.ts +30 -0
  47. package/dist/cjs/mcp/config-tools.js +320 -0
  48. package/dist/cjs/mcp/dependency.d.ts +34 -0
  49. package/dist/cjs/mcp/dependency.js +413 -0
  50. package/dist/cjs/mcp/protocol.d.ts +91 -0
  51. package/dist/cjs/mcp/protocol.js +181 -0
  52. package/dist/cjs/mcp/review.d.ts +64 -0
  53. package/dist/cjs/mcp/review.js +150 -0
  54. package/dist/cjs/mcp/serve.d.ts +8 -0
  55. package/dist/cjs/mcp/serve.js +49 -0
  56. package/dist/cjs/mcp/settings.d.ts +13 -0
  57. package/dist/cjs/mcp/settings.js +50 -0
  58. package/dist/cjs/mcp/tools.d.ts +23 -0
  59. package/dist/cjs/mcp/tools.js +618 -0
  60. package/dist/cjs/utils/execa.d.ts +7 -0
  61. package/dist/cjs/utils/execa.js +41 -1
  62. package/dist/cjs/utils/result-collector.d.ts +17 -1
  63. package/dist/cjs/utils/result-collector.js +7 -2
  64. package/dist/esm/agent/hook.d.ts +38 -0
  65. package/dist/esm/agent/hook.js +167 -0
  66. package/dist/esm/agent/registration.d.ts +10 -0
  67. package/dist/esm/agent/registration.js +21 -0
  68. package/dist/esm/agent/setup.d.ts +21 -0
  69. package/dist/esm/agent/setup.js +220 -0
  70. package/dist/esm/cli.js +86 -1
  71. package/dist/esm/config/constants.d.ts +2 -0
  72. package/dist/esm/config/constants.js +4 -0
  73. package/dist/esm/config/pr-checkmate.template.json +10 -0
  74. package/dist/esm/core/checks/findings.js +33 -7
  75. package/dist/esm/core/checks/git/diff-smell.js +3 -1
  76. package/dist/esm/core/checks/git/large-files.d.ts +1 -0
  77. package/dist/esm/core/checks/git/large-files.js +1 -1
  78. package/dist/esm/core/checks/languages/lint.js +28 -1
  79. package/dist/esm/core/checks/languages/locate.d.ts +7 -0
  80. package/dist/esm/core/checks/languages/locate.js +60 -0
  81. package/dist/esm/core/checks/languages/prettier.js +15 -2
  82. package/dist/esm/core/checks/languages/typecheck.js +4 -1
  83. package/dist/esm/core/checks/pr/pr-size-check.d.ts +2 -0
  84. package/dist/esm/core/checks/pr/pr-size-check.js +2 -2
  85. package/dist/esm/core/checks/security/agent-instructions.d.ts +4 -0
  86. package/dist/esm/core/checks/security/agent-instructions.js +146 -0
  87. package/dist/esm/core/checks/security/diff-security.d.ts +7 -0
  88. package/dist/esm/core/checks/security/diff-security.js +20 -0
  89. package/dist/esm/core/checks/security/security.d.ts +0 -13
  90. package/dist/esm/core/checks/security/security.js +27 -0
  91. package/dist/esm/core/checks/security/workflow-security.js +33 -0
  92. package/dist/esm/core/commands.d.ts +6 -0
  93. package/dist/esm/core/commands.js +10 -0
  94. package/dist/esm/core/finding-store.d.ts +16 -0
  95. package/dist/esm/core/finding-store.js +67 -0
  96. package/dist/esm/core/registry.js +2 -0
  97. package/dist/esm/core/reporters.js +9 -0
  98. package/dist/esm/core/run-checks.js +15 -5
  99. package/dist/esm/core/sarif.d.ts +15 -0
  100. package/dist/esm/core/sarif.js +57 -0
  101. package/dist/esm/core/types.d.ts +7 -2
  102. package/dist/esm/index.d.ts +2 -1
  103. package/dist/esm/index.js +2 -0
  104. package/dist/esm/init.js +12 -1
  105. package/dist/esm/interfaces/index.d.ts +12 -0
  106. package/dist/esm/mcp/config-tools.d.ts +30 -0
  107. package/dist/esm/mcp/config-tools.js +310 -0
  108. package/dist/esm/mcp/dependency.d.ts +34 -0
  109. package/dist/esm/mcp/dependency.js +408 -0
  110. package/dist/esm/mcp/protocol.d.ts +91 -0
  111. package/dist/esm/mcp/protocol.js +175 -0
  112. package/dist/esm/mcp/review.d.ts +64 -0
  113. package/dist/esm/mcp/review.js +139 -0
  114. package/dist/esm/mcp/serve.d.ts +8 -0
  115. package/dist/esm/mcp/serve.js +43 -0
  116. package/dist/esm/mcp/settings.d.ts +13 -0
  117. package/dist/esm/mcp/settings.js +43 -0
  118. package/dist/esm/mcp/tools.d.ts +23 -0
  119. package/dist/esm/mcp/tools.js +611 -0
  120. package/dist/esm/utils/execa.d.ts +7 -0
  121. package/dist/esm/utils/execa.js +40 -1
  122. package/dist/esm/utils/result-collector.d.ts +17 -1
  123. package/dist/esm/utils/result-collector.js +7 -2
  124. package/package.json +7 -3
  125. package/pr-checkmate.schema.json +35 -0
  126. package/src/config/cspell.json +1 -0
  127. package/src/config/pr-checkmate.schema.json +35 -0
@@ -0,0 +1,146 @@
1
+ // cspell:ignore windsurfrules
2
+ import { logger } from '../../../utils/index.js';
3
+ import { listTrackedFiles } from '../../targets.js';
4
+ import { skip } from '../../types.js';
5
+ import { clientFindingOptions, diffAddedLines, emptyBuckets, mergeSeverityBuckets, reportSeverityBuckets, scanDiffPatterns, scanTrackedFiles, } from '../findings.js';
6
+ /**
7
+ * Files that tell an AI agent what to do, or what it may do.
8
+ *
9
+ * They are code in every sense that matters: an agent reads them and acts, with
10
+ * the developer's shell and the developer's credentials. A pull request that edits
11
+ * one changes what every contributor's agent will do on its next run, and the diff
12
+ * is prose — which review tends to skim. That makes this the cheapest place in a
13
+ * repository to plant an instruction, and the least reviewed.
14
+ */
15
+ const INSTRUCTION_GLOBS = [
16
+ 'AGENTS.md',
17
+ '**/AGENTS.md',
18
+ 'CLAUDE.md',
19
+ '**/CLAUDE.md',
20
+ 'GEMINI.md',
21
+ '.cursorrules',
22
+ '.windsurfrules',
23
+ '.cursor/rules/**',
24
+ '.github/copilot-instructions.md',
25
+ '.claude/commands/**',
26
+ '.claude/agents/**',
27
+ '.claude/skills/**',
28
+ '.claude/settings.json',
29
+ '.mcp.json',
30
+ ];
31
+ /** Dot-directories the file walk drops by default and this check lives in. */
32
+ const KEEP_DIRS = ['.claude', '.cursor', '.github'];
33
+ /**
34
+ * Characters a person cannot see and a model reads anyway: zero-width characters,
35
+ * bidirectional overrides and the Unicode "tag" block. Each has been used to hide
36
+ * an instruction inside text that looks harmless on screen. A byte-order mark is
37
+ * left out on purpose — editors put one at the top of ordinary files.
38
+ */
39
+ const INVISIBLE = '[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069]|[\\u{E0000}-\\u{E007F}]';
40
+ export const AGENT_PATTERNS = [
41
+ {
42
+ pattern: new RegExp(`^\\+(?!\\+).*(?:${INVISIBLE})`, 'u'),
43
+ label: 'invisible Unicode characters (can hide instructions from a human reviewer)',
44
+ severity: 'error',
45
+ },
46
+ {
47
+ pattern:
48
+ // "Do not add X without telling the user" asks the agent to check in, so a
49
+ // `without` that follows a negation in the same sentence is not a match.
50
+ /^\+(?!\+)(?![^\n]*\b(?:do not|don't|never)\b[^.\n]*\bwithout\b).*\b(?:do not|don't|never|without)\b[^.\n]{0,30}\b(?:tell|telling|inform|informing|mention|show|reveal|notify)\b[^.\n]{0,20}\b(?:the )?(?:user|developer|human)\b/i,
51
+ label: 'tells the agent to hide something from the user',
52
+ severity: 'error',
53
+ },
54
+ {
55
+ pattern: /^\+(?!\+).*\b(?:send|post|upload|curl|wget|fetch|exfiltrate|email)\b[^\n]{0,80}(?:\.env\b|credentials?|secrets?|api[_ -]?keys?|\btokens?\b|\bssh\b|id_rsa)[^\n]{0,80}https?:\/\//i,
56
+ label: 'sends credentials or secrets to a URL',
57
+ severity: 'error',
58
+ },
59
+ {
60
+ pattern: /^\+(?!\+).*\b(?:ignore|disregard|forget|override)\b[^.\n]{0,40}\b(?:previous|prior|above|earlier|all|any)\b[^.\n]{0,30}\b(?:instructions?|rules|prompts?|guidelines)\b/i,
61
+ label: 'prompt-injection phrasing ("ignore previous instructions")',
62
+ severity: 'warn',
63
+ },
64
+ {
65
+ pattern: /^\+(?!\+).*\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+)?(?:sh|bash|zsh)\b/i,
66
+ label: 'pipes a download straight into a shell (an agent will run what it is told to run)',
67
+ severity: 'warn',
68
+ },
69
+ {
70
+ pattern: /^\+(?!\+).*(?:--no-verify|--dangerously-skip-permissions|--no-gpg-sign)\b/,
71
+ label: 'instructs the agent to skip a safety check',
72
+ severity: 'warn',
73
+ },
74
+ {
75
+ pattern: /^\+(?!\+)\s*"defaultMode"\s*:\s*"bypassPermissions"/,
76
+ label: 'bypassPermissions as the project default (every contributor’s agent runs without asking)',
77
+ severity: 'error',
78
+ },
79
+ {
80
+ pattern: /^\+(?!\+)\s*"enableAllProjectMcpServers"\s*:\s*true/,
81
+ label: 'auto-approves every MCP server this repository declares',
82
+ severity: 'warn',
83
+ },
84
+ {
85
+ pattern: /^\+(?!\+)\s*"(?:Bash|Write|Edit)(?:\((?:\*|:\*)\))?"\s*,?\s*$/,
86
+ label: 'permission granted for every shell command or file write',
87
+ severity: 'warn',
88
+ },
89
+ {
90
+ pattern: /^\+(?!\+).*"url"\s*:\s*"http:\/\/(?!localhost|127\.0\.0\.1|\[::1\])/,
91
+ label: 'MCP server reached over plain HTTP',
92
+ severity: 'warn',
93
+ },
94
+ {
95
+ // `"args": ["-y", "pkg"]` with no @version: whatever is published next runs on
96
+ // the machine of everyone who opens the repository.
97
+ pattern: /^\+(?!\+).*"args"\s*:\s*\[\s*"-y"\s*,\s*"(?:@[^/"@]+\/)?[^"@]+"/,
98
+ label: 'MCP server fetched with npx and no pinned version (the next publish runs on every contributor’s machine)',
99
+ severity: 'warn',
100
+ },
101
+ ];
102
+ export const agentInstructionsCheck = {
103
+ name: 'Agent Instructions',
104
+ phase: 'informational',
105
+ // No `requiresDiff`: a hidden instruction is as live in a file that arrived last
106
+ // year as in one added today, so a whole-repository run reads them too.
107
+ applies(ctx) {
108
+ return ctx.config.agentInstructions?.enabled !== false;
109
+ },
110
+ async run(ctx) {
111
+ try {
112
+ const cfg = ctx.config.agentInstructions ?? {};
113
+ const found = emptyBuckets();
114
+ const scanInto = (stdout) => {
115
+ // `comments: 'off'`: these are Markdown and JSON, where a leading `#` is a
116
+ // heading and `//` is part of a URL — stripping them as comments would
117
+ // blank exactly the lines worth reading.
118
+ mergeSeverityBuckets(found, scanDiffPatterns(stdout, AGENT_PATTERNS, {
119
+ comments: 'off',
120
+ ignorePaths: cfg.ignorePaths,
121
+ }));
122
+ };
123
+ if (ctx.baseSha) {
124
+ scanInto(await diffAddedLines(ctx, INSTRUCTION_GLOBS));
125
+ }
126
+ else if (ctx.config.run?.fullScanBehaviour !== 'skip') {
127
+ const files = await listTrackedFiles(ctx, INSTRUCTION_GLOBS, ['.'], {
128
+ keepDirs: KEEP_DIRS,
129
+ });
130
+ if (files === null)
131
+ return skip('git unavailable');
132
+ await scanTrackedFiles(ctx, files, scanInto);
133
+ }
134
+ return reportSeverityBuckets(found.errors, found.warnings, {
135
+ prefix: 'Agent Instructions',
136
+ errorSummary: n => `${n} issue(s)`,
137
+ warningSummary: n => `${n} warning(s)`,
138
+ ...clientFindingOptions(ctx, cfg),
139
+ });
140
+ }
141
+ catch (err) {
142
+ logger.warn(`[Agent Instructions]: ⚠️ Could not analyze agent files\n${err}`);
143
+ return skip('agent files unavailable');
144
+ }
145
+ },
146
+ };
@@ -1,2 +1,9 @@
1
1
  import { Check } from '../../types.js';
2
+ import { DiffPattern } from '../findings.js';
3
+ /**
4
+ * npm supply-chain entry points, matched in `package.json`. Both are how a
5
+ * compromised package reaches a developer machine or CI runner, so they are
6
+ * worth a human look whenever one is added — neither is wrong by itself.
7
+ */
8
+ export declare const PACKAGE_JSON_PATTERNS: DiffPattern[];
2
9
  export declare const diffSecurityCheck: Check;
@@ -232,6 +232,25 @@ const KOTLIN_SECURITY_PATTERNS = [
232
232
  },
233
233
  ];
234
234
  const JS_GLOBS = ['*.ts', '*.tsx', '*.js', '*.jsx', '*.mjs', '*.cjs'];
235
+ const PACKAGE_JSON_GLOBS = ['package.json', '*/package.json'];
236
+ /**
237
+ * npm supply-chain entry points, matched in `package.json`. Both are how a
238
+ * compromised package reaches a developer machine or CI runner, so they are
239
+ * worth a human look whenever one is added — neither is wrong by itself.
240
+ */
241
+ export const PACKAGE_JSON_PATTERNS = [
242
+ {
243
+ pattern: /^\+(?!\+)\s*"(?:preinstall|install|postinstall)"\s*:/,
244
+ label: 'npm install lifecycle script (runs on every consumer install)',
245
+ severity: 'warn',
246
+ },
247
+ {
248
+ // `repository.url` is the one key that legitimately holds a `git+https:` value.
249
+ pattern: /^\+(?!\+)\s*"(?!url")[^"]+"\s*:\s*"(?:git\+[a-z]+:|git:\/\/|git@|github:|https?:\/\/[^"]+\.(?:tgz|tar\.gz)")/,
250
+ label: 'dependency from a git or tarball URL (bypasses registry integrity and audit)',
251
+ severity: 'warn',
252
+ },
253
+ ];
235
254
  /**
236
255
  * Compile the client's `extraPatterns` into the same shape as the built-ins, so
237
256
  * a project rule gets identical treatment: comment stripping, the code/raw view,
@@ -320,6 +339,7 @@ export const diffSecurityCheck = {
320
339
  // Client `extraPatterns` run against every scanned language, since a
321
340
  // project rule is not tied to the built-in language split.
322
341
  const sets = [[JS_GLOBS, [...SECURITY_PATTERNS, ...extra]]];
342
+ sets.push([PACKAGE_JSON_GLOBS, PACKAGE_JSON_PATTERNS]);
323
343
  if (ctx.languages.includes('python')) {
324
344
  sets.push([['*.py'], [...PYTHON_SECURITY_PATTERNS, ...extra]]);
325
345
  }
@@ -30,19 +30,6 @@ import { Check } from '../../types.js';
30
30
  * the caller keeps its old wording rather than claiming a number it does not have.
31
31
  */
32
32
  export declare function countFindings(reportPath: string): number | null;
33
- /**
34
- * How the outcome names what was found.
35
- *
36
- * Its own function so both halves are reachable without running the scanner. The
37
- * corpus for this check deliberately does not: the wrapper downloads a gitleaks
38
- * binary on first use, and a unit suite that reaches the network is the thing the
39
- * `installer` seam in `ensure.ts` exists to avoid. What broke here was the wording
40
- * — a row that read the same for one secret and for thirty-nine — and the wording
41
- * is testable on its own.
42
- *
43
- * `null` keeps the old sentence rather than inventing a number: a report we could
44
- * not read is not a reason to claim a count.
45
- */
46
33
  export declare function secretsSummary(found: number | null): string;
47
34
  /**
48
35
  * Whether the wrapper died instead of reporting, judged from its stderr.
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { getPackageConfigPath, hasLocalConfig } from '../../../config/index.js';
5
5
  import { execa } from '../../../utils/execa.js';
6
6
  import { logger, resolveGitRef, resolvePackageBin } from '../../../utils/index.js';
7
+ import { addFinding } from '../../finding-store.js';
7
8
  import { fail, pass, skip, warn } from '../../types.js';
8
9
  /**
9
10
  * A HOME the scanner can actually write to.
@@ -57,6 +58,31 @@ export function countFindings(reportPath) {
57
58
  * `null` keeps the old sentence rather than inventing a number: a report we could
58
59
  * not read is not a reason to claim a count.
59
60
  */
61
+ /**
62
+ * Tell the report where each leak is. The secret itself is never copied: the
63
+ * finding carries a file, a line and the rule that matched, which is all anyone
64
+ * needs to go and remove it — and all that is safe to hand to an agent's context.
65
+ */
66
+ function recordLeaks(reportPath, cwd) {
67
+ try {
68
+ const report = JSON.parse(fs.readFileSync(reportPath, 'utf-8'));
69
+ if (!Array.isArray(report))
70
+ return;
71
+ for (const leak of report) {
72
+ if (typeof leak?.File !== 'string')
73
+ continue;
74
+ addFinding({
75
+ file: path.isAbsolute(leak.File) ? path.relative(cwd, leak.File) : leak.File,
76
+ line: typeof leak.StartLine === 'number' ? leak.StartLine : undefined,
77
+ rule: typeof leak.RuleID === 'string' ? leak.RuleID : 'secret',
78
+ severity: 'error',
79
+ });
80
+ }
81
+ }
82
+ catch {
83
+ // An unreadable report already shows up as an uncounted summary; no locations to add.
84
+ }
85
+ }
60
86
  export function secretsSummary(found) {
61
87
  return found === null ? 'potential secrets detected' : `${found} potential secret(s)`;
62
88
  }
@@ -177,6 +203,7 @@ export const securityCheck = {
177
203
  if (stderr.trim())
178
204
  logger.warn(stderr.trimEnd());
179
205
  const found = countFindings(reportPath);
206
+ recordLeaks(reportPath, ctx.cwd);
180
207
  fs.rmSync(reportDir, { recursive: true, force: true });
181
208
  if (result.exitCode === 0)
182
209
  return pass();
@@ -52,6 +52,39 @@ export const WORKFLOW_PATTERNS = [
52
52
  label: 'pull_request_target trigger (runs with repo secrets on untrusted PRs)',
53
53
  severity: 'warn',
54
54
  },
55
+ {
56
+ // The second half of the "pwn request": `pull_request_target` alone is harmless,
57
+ // checking out the PR's own code under it hands the attacker a privileged
58
+ // runner. One line cannot see the trigger, so this stays a warning.
59
+ pattern: /^\+(?!\+)\s*(?:ref|repository):\s*['"]?\$\{\{\s*github\.event\.pull_request\.head\.(?:sha|ref|repo\.full_name)\s*\}\}/,
60
+ label: 'checkout of the PR head (code execution with secrets under pull_request_target)',
61
+ severity: 'warn',
62
+ },
63
+ {
64
+ pattern: /^\+(?!\+)\s*permissions:\s*write-all\b/,
65
+ label: 'permissions: write-all (grant the token only the scopes the job needs)',
66
+ severity: 'error',
67
+ },
68
+ {
69
+ pattern: /^\+(?!\+)\s*secrets:\s*inherit\b/,
70
+ label: 'secrets: inherit (hands every repository secret to the called workflow)',
71
+ severity: 'warn',
72
+ },
73
+ {
74
+ pattern: /^\+(?!\+)\s*ACTIONS_ALLOW_UNSECURE_COMMANDS:\s*['"]?true\b/i,
75
+ label: 'ACTIONS_ALLOW_UNSECURE_COMMANDS (re-enables the deprecated set-env / add-path injection vector)',
76
+ severity: 'error',
77
+ },
78
+ {
79
+ pattern: /^\+(?!\+).*\b(?:curl|wget)\b[^|]*\|\s*(?:sudo\s+)?(?:sh|bash)\b/i,
80
+ label: 'piping a download straight to a shell (curl/wget … | sh)',
81
+ severity: 'warn',
82
+ },
83
+ {
84
+ pattern: /^\+(?!\+)\s*runs-on:\s*\[?\s*['"]?self-hosted\b/,
85
+ label: 'self-hosted runner (a PR from a fork can run code on your own infrastructure)',
86
+ severity: 'warn',
87
+ },
55
88
  ];
56
89
  export const workflowSecurityCheck = {
57
90
  name: 'Workflow Security',
@@ -22,6 +22,12 @@ export declare const COMMAND_TO_CHECK: Record<string, string>;
22
22
  * and not a reading of our source.
23
23
  */
24
24
  export declare const TOOLING_COMMANDS: readonly string[];
25
+ /**
26
+ * Commands that serve AI agents rather than review a diff. They share the CLI's
27
+ * entry point and nothing else: no scope flags apply, and `mcp` in particular owns
28
+ * stdout for the protocol, so none of them may be confused with a check name.
29
+ */
30
+ export declare const AGENT_COMMANDS: readonly string[];
25
31
  export declare const STANDALONE_COMMANDS: readonly string[];
26
32
  /** Flags accepted anywhere on the command line, with what each one means. */
27
33
  export declare const FLAGS: Record<string, string>;
@@ -41,6 +41,7 @@ export const COMMAND_TO_CHECK = {
41
41
  'license-header': 'License Header',
42
42
  'config-validation': 'Config Validation',
43
43
  'dockerfile-security': 'Dockerfile Security',
44
+ 'agent-instructions': 'Agent Instructions',
44
45
  'case-collision': 'Case Collision',
45
46
  'vuln-scan': 'Vuln Scan',
46
47
  'leftover-debug': 'Leftover Debug',
@@ -76,14 +77,23 @@ export const COMMAND_TO_CHECK = {
76
77
  * and not a reading of our source.
77
78
  */
78
79
  export const TOOLING_COMMANDS = ['install', 'doctor', 'cache-prune'];
80
+ /**
81
+ * Commands that serve AI agents rather than review a diff. They share the CLI's
82
+ * entry point and nothing else: no scope flags apply, and `mcp` in particular owns
83
+ * stdout for the protocol, so none of them may be confused with a check name.
84
+ */
85
+ export const AGENT_COMMANDS = ['mcp', 'hook', 'agent-setup'];
79
86
  export const STANDALONE_COMMANDS = [
80
87
  'init',
81
88
  'all',
82
89
  'precommit',
83
90
  ...TOOLING_COMMANDS,
91
+ ...AGENT_COMMANDS,
84
92
  ];
85
93
  /** Flags accepted anywhere on the command line, with what each one means. */
86
94
  export const FLAGS = {
87
95
  '--full': 'review every tracked file instead of the diff (the default outside CI)',
88
96
  '--all': 'with "install": fetch every profile, not only the ones this repository needs',
97
+ '--sarif': 'write findings as SARIF 2.1.0 to pr-checkmate.sarif (check-running commands only)',
98
+ '--no-hook': 'with "agent-setup": skip the git-commit gate for Claude Code',
89
99
  };
@@ -0,0 +1,16 @@
1
+ import type { Finding } from '../utils/result-collector.js';
2
+ /** Run `work` collecting every finding filed inside it. */
3
+ export declare function collectFindings<T>(work: () => Promise<T>): Promise<{
4
+ result: T;
5
+ findings: Finding[];
6
+ }>;
7
+ /** A scanner saw `sample` under `label` at `file:line`. Claimed later by {@link fileFindings}. */
8
+ export declare function noteLocation(label: string, sample: string, file: string, line: number): void;
9
+ /**
10
+ * Turn the findings that survived filtering into located ones. Each sample claims
11
+ * the oldest location noted for it, so the same line appearing twice resolves to
12
+ * two places rather than one place twice.
13
+ */
14
+ export declare function fileFindings(bucket: Record<string, string[]>, severity: Finding['severity']): void;
15
+ /** File a finding directly, for scanners that already know exactly where it is. */
16
+ export declare function addFinding(finding: Finding): void;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Where a finding was, carried from the scanner that saw it to the report that
3
+ * names it.
4
+ *
5
+ * Scanners reduce what they find to strings in a bucket — `"debugger statement" →
6
+ * ["debugger;"]` — because the report only ever needed a count and a sample. An
7
+ * agent needs more: which file, which line. Rewriting forty checks to carry that
8
+ * would have been forty chances to disagree with each other, so the location is
9
+ * noted once, where the diff is walked, and attached once, where the buckets are
10
+ * filed.
11
+ *
12
+ * It is attached at filing time, not scan time, on purpose. `ignore` and
13
+ * `severityOverrides` run in between, and a finding the client has silenced must
14
+ * not reach an agent that would then dutifully "fix" it.
15
+ *
16
+ * The store is an `AsyncLocalStorage` scope per check. Checks of one phase run in
17
+ * parallel, so a module-level list would mix their findings; a scope cannot. Code
18
+ * running outside `collectFindings` (the library API, unit tests) finds no store
19
+ * and every call here does nothing.
20
+ */
21
+ import { AsyncLocalStorage } from 'node:async_hooks';
22
+ /** More than this and the list is noise to a reader and a cost to an agent. */
23
+ const MAX_FINDINGS_PER_CHECK = 200;
24
+ const scope = new AsyncLocalStorage();
25
+ const keyOf = (label, sample) => `${label}\u0000${sample}`;
26
+ /** Run `work` collecting every finding filed inside it. */
27
+ export async function collectFindings(work) {
28
+ const store = { found: [], pending: new Map() };
29
+ const result = await scope.run(store, work);
30
+ return { result, findings: store.found };
31
+ }
32
+ /** A scanner saw `sample` under `label` at `file:line`. Claimed later by {@link fileFindings}. */
33
+ export function noteLocation(label, sample, file, line) {
34
+ const store = scope.getStore();
35
+ if (!store)
36
+ return;
37
+ const key = keyOf(label, sample);
38
+ const queue = store.pending.get(key) ?? [];
39
+ queue.push({ file, line });
40
+ store.pending.set(key, queue);
41
+ }
42
+ /**
43
+ * Turn the findings that survived filtering into located ones. Each sample claims
44
+ * the oldest location noted for it, so the same line appearing twice resolves to
45
+ * two places rather than one place twice.
46
+ */
47
+ export function fileFindings(bucket, severity) {
48
+ const store = scope.getStore();
49
+ if (!store)
50
+ return;
51
+ for (const [label, samples] of Object.entries(bucket)) {
52
+ for (const sample of samples) {
53
+ if (store.found.length >= MAX_FINDINGS_PER_CHECK)
54
+ return;
55
+ const where = store.pending.get(keyOf(label, sample))?.shift();
56
+ if (!where)
57
+ continue;
58
+ store.found.push({ ...where, rule: label, snippet: sample, severity });
59
+ }
60
+ }
61
+ }
62
+ /** File a finding directly, for scanners that already know exactly where it is. */
63
+ export function addFinding(finding) {
64
+ const store = scope.getStore();
65
+ if (store && store.found.length < MAX_FINDINGS_PER_CHECK)
66
+ store.found.push(finding);
67
+ }
@@ -38,6 +38,7 @@ import { packageSizeCheck } from './checks/quality/package-size.js';
38
38
  import { spellcheckCheck } from './checks/quality/spellcheck.js';
39
39
  import { symlinksCheck } from './checks/quality/symlinks.js';
40
40
  import { yamlLintCheck } from './checks/quality/yaml-lint.js';
41
+ import { agentInstructionsCheck } from './checks/security/agent-instructions.js';
41
42
  import { diffSecurityCheck } from './checks/security/diff-security.js';
42
43
  import { dockerfileSecurityCheck } from './checks/security/dockerfile-security.js';
43
44
  import { migrationSafetyCheck } from './checks/security/migration-safety.js';
@@ -63,6 +64,7 @@ export const REGISTRY = [
63
64
  diffSecurityCheck,
64
65
  workflowSecurityCheck,
65
66
  dockerfileSecurityCheck,
67
+ agentInstructionsCheck,
66
68
  caseCollisionCheck,
67
69
  vulnScanCheck,
68
70
  grypeScanCheck,
@@ -1,4 +1,7 @@
1
1
  import fs from 'node:fs';
2
+ import { isMcpRegistered } from '../agent/registration.js';
3
+ import { AGENT_SETUP_COMMAND } from '../config/constants.js';
4
+ import { readMcpSettings } from '../mcp/settings.js';
2
5
  import { paginate, request } from '../utils/http.js';
3
6
  import { logger } from '../utils/index.js';
4
7
  import { packageVersion } from '../utils/package-version.js';
@@ -100,6 +103,12 @@ function printStdout(report, cfg) {
100
103
  if (cfg.include?.docsLink !== false) {
101
104
  // The docs cover every check and config key.
102
105
  logger.print(' DOCUMENTATION: https://pr-checkmate-docs.pages.dev/');
106
+ // Said once per project, and never in CI, where nobody is there to act on it.
107
+ if (!process.env.CI &&
108
+ readMcpSettings(process.cwd()).enabled &&
109
+ !isMcpRegistered(process.cwd())) {
110
+ logger.print(` AI AGENT: ${AGENT_SETUP_COMMAND} — your agent then writes code that passes.`);
111
+ }
103
112
  }
104
113
  }
105
114
  /** Network settings shared by every platform call. */
@@ -4,6 +4,7 @@ import { resolveConcurrency, runPool, Semaphore, withTimeout } from '../utils/po
4
4
  import { clearToolPaths } from '../utils/resolve-bin.js';
5
5
  import { ResultCollector } from '../utils/result-collector.js';
6
6
  import { buildContext } from './context.js';
7
+ import { collectFindings } from './finding-store.js';
7
8
  import { noTooling, prepareTooling } from './install/prepare.js';
8
9
  import { REGISTRY } from './registry.js';
9
10
  import { report } from './reporters.js';
@@ -169,15 +170,24 @@ async function runOne(check, ctx, collector, heavy, tooling) {
169
170
  }
170
171
  const started = Date.now();
171
172
  const timeoutMs = ctx.config.run?.checkTimeoutMs ?? 300000;
172
- const execute = () => withTimeout(check.run(ctx), timeoutMs, () => {
173
- logger.warn(`[runChecks]: ⏱️ ${check.name} exceeded ${timeoutMs}ms`);
174
- return { status: 'warn', details: `timed out after ${timeoutMs}ms` };
175
- });
173
+ // Locations noted while this check runs, collected per check: the phase runs
174
+ // its checks in parallel, so a shared list would mix their findings.
175
+ let located = [];
176
+ const execute = async () => {
177
+ const { result, findings } = await collectFindings(() => withTimeout(check.run(ctx), timeoutMs, () => {
178
+ logger.warn(`[runChecks]: ⏱️ ${check.name} exceeded ${timeoutMs}ms`);
179
+ return { status: 'warn', details: `timed out after ${timeoutMs}ms` };
180
+ }));
181
+ located = findings;
182
+ return result;
183
+ };
176
184
  const outcome = check.weight === 'heavy' ? await heavy.run(execute) : await execute();
177
185
  if (ctx.config.run?.timings) {
178
186
  logger.info(`[runChecks]: ${check.name} took ${Date.now() - started}ms`);
179
187
  }
180
- collector.register(check.name, applySeverity(check.name, outcome.status, ctx), outcome.details);
188
+ collector.register(check.name, applySeverity(check.name, outcome.status, ctx), outcome.details,
189
+ // A clean result has nothing to locate, even when `failOn: never` filed some.
190
+ outcome.status === 'pass' ? undefined : (outcome.findings ?? located));
181
191
  }
182
192
  catch (err) {
183
193
  // An unexpected throw in a blocking check fails the run; in informational/format
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A report as SARIF 2.1.0 — the format GitHub code scanning, VS Code and most
3
+ * security dashboards read, so a finding shows up inline on the line it names
4
+ * instead of only in a summary table.
5
+ *
6
+ * Only what a consumer can use is written. A failed or warned check that located
7
+ * its problems yields one result per location; one that could not yields a single
8
+ * result with no location, because a finding with nowhere to point is still a
9
+ * finding. GitHub code scanning displays only results that have a location, so
10
+ * those one-line summaries reach editors and other viewers, not its alerts.
11
+ * Passes and skips are not results.
12
+ */
13
+ import type { CheckReport } from './types.js';
14
+ /** Convert a report to a SARIF log object, ready for `JSON.stringify`. */
15
+ export declare function toSarif(report: CheckReport, toolVersion: string): Record<string, unknown>;
@@ -0,0 +1,57 @@
1
+ const SARIF_SCHEMA = 'https://json.schemastore.org/sarif-2.1.0.json';
2
+ const ruleIdFor = (check, rule) => (rule ? `${check}/${rule}` : check);
3
+ function locatedResult(check, f) {
4
+ return {
5
+ ruleId: ruleIdFor(check.name, f.rule),
6
+ level: f.severity === 'error' ? 'error' : 'warning',
7
+ message: { text: f.snippet ? `${f.rule}: ${f.snippet}` : f.rule },
8
+ locations: [
9
+ {
10
+ physicalLocation: {
11
+ artifactLocation: { uri: f.file },
12
+ ...(f.line !== undefined && { region: { startLine: f.line } }),
13
+ },
14
+ },
15
+ ],
16
+ };
17
+ }
18
+ function summaryResult(check) {
19
+ return {
20
+ ruleId: ruleIdFor(check.name),
21
+ level: check.status === 'fail' ? 'error' : 'warning',
22
+ message: { text: check.details ?? `${check.name} reported a problem` },
23
+ };
24
+ }
25
+ /** Convert a report to a SARIF log object, ready for `JSON.stringify`. */
26
+ export function toSarif(report, toolVersion) {
27
+ const results = [];
28
+ const rules = new Map();
29
+ for (const check of report.results) {
30
+ if (check.status !== 'fail' && check.status !== 'warn')
31
+ continue;
32
+ const found = check.findings && check.findings.length > 0
33
+ ? check.findings.map(f => locatedResult(check, f))
34
+ : [summaryResult(check)];
35
+ for (const r of found) {
36
+ results.push(r);
37
+ rules.set(r.ruleId, { id: r.ruleId, name: r.ruleId });
38
+ }
39
+ }
40
+ return {
41
+ $schema: SARIF_SCHEMA,
42
+ version: '2.1.0',
43
+ runs: [
44
+ {
45
+ tool: {
46
+ driver: {
47
+ name: 'pr-checkmate',
48
+ version: toolVersion,
49
+ informationUri: 'https://www.npmjs.com/package/pr-checkmate',
50
+ rules: [...rules.values()],
51
+ },
52
+ },
53
+ results,
54
+ },
55
+ ],
56
+ };
57
+ }
@@ -1,7 +1,7 @@
1
1
  import type { PRCheckMateConfig, ReporterConfig } from '../interfaces/index.js';
2
2
  import type { Language, LanguageUsage } from '../utils/detect-language.js';
3
- import type { CheckResult, CheckStatus } from '../utils/result-collector.js';
4
- export type { CheckResult, CheckStatus, Language, LanguageUsage };
3
+ import type { CheckResult, CheckStatus, Finding } from '../utils/result-collector.js';
4
+ export type { CheckResult, CheckStatus, Finding, Language, LanguageUsage };
5
5
  /**
6
6
  * Execution phases, run strictly in order. Within a phase checks run in parallel.
7
7
  * `format` runs last because formatters (prettier) may write files.
@@ -67,6 +67,11 @@ export interface CheckContext {
67
67
  export interface CheckOutcome {
68
68
  status: CheckStatus;
69
69
  details?: string;
70
+ /**
71
+ * Where the problems are. Left to the orchestrator for pattern-based checks,
72
+ * which note locations as they scan; a check that knows better may set it.
73
+ */
74
+ findings?: Finding[];
70
75
  }
71
76
  /**
72
77
  * A single quality gate. `applies` decides relevance (language/marker gating);
@@ -4,8 +4,9 @@
4
4
  * @module pr-checkmate
5
5
  */
6
6
  export { runChecks, buildContext, REGISTRY } from './core/index.js';
7
+ export { toSarif } from './core/sarif.js';
7
8
  export { pass, warn, fail, skip, defineCheck } from './core/index.js';
8
- export type { Check, CheckContext, CheckOutcome, CheckReport, CheckResult, CheckStatus, Language, Phase, ReporterFn, ReporterKind, RunOptions, } from './core/index.js';
9
+ export type { Check, CheckContext, CheckOutcome, CheckReport, CheckResult, CheckStatus, Finding, Language, Phase, ReporterFn, ReporterKind, RunOptions, } from './core/index.js';
9
10
  export { getSourcePath, getSourcePaths, getIgnoredDirs, getPackageConfigPath, hasLocalConfig, } from './config/index.js';
10
11
  export type { PRCheckMateConfig } from './interfaces/index.js';
11
12
  export { Logger } from './utils/logger.js';
package/dist/esm/index.js CHANGED
@@ -6,6 +6,8 @@
6
6
  // Primary programmatic API: run checks and receive a structured report.
7
7
  // Never throws for violations, never calls process.exit — the caller decides.
8
8
  export { runChecks, buildContext, REGISTRY } from './core/index.js';
9
+ // SARIF 2.1.0 export of a report, for code-scanning dashboards and editors.
10
+ export { toSarif } from './core/sarif.js';
9
11
  // Authoring helpers for consumers writing their own checks (plugins).
10
12
  export { pass, warn, fail, skip, defineCheck } from './core/index.js';
11
13
  // Config helpers for consumers embedding pr-checkmate.