pr-checkmate 3.3.0 → 4.0.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 (113) hide show
  1. package/LICENSE +8 -3
  2. package/README.md +53 -4
  3. package/dist/cjs/agent/hook.d.ts +37 -0
  4. package/dist/cjs/agent/hook.js +175 -0
  5. package/dist/cjs/agent/registration.d.ts +10 -0
  6. package/dist/cjs/agent/registration.js +28 -0
  7. package/dist/cjs/agent/setup.d.ts +20 -0
  8. package/dist/cjs/agent/setup.js +229 -0
  9. package/dist/cjs/cli.js +88 -0
  10. package/dist/cjs/config/constants.d.ts +2 -0
  11. package/dist/cjs/config/constants.js +5 -1
  12. package/dist/cjs/config/pr-checkmate.template.json +10 -0
  13. package/dist/cjs/core/checks/findings.js +33 -7
  14. package/dist/cjs/core/checks/git/large-files.d.ts +1 -0
  15. package/dist/cjs/core/checks/git/large-files.js +3 -3
  16. package/dist/cjs/core/checks/pr/pr-size-check.d.ts +2 -0
  17. package/dist/cjs/core/checks/pr/pr-size-check.js +5 -5
  18. package/dist/cjs/core/checks/security/agent-instructions.d.ts +4 -0
  19. package/dist/cjs/core/checks/security/agent-instructions.js +149 -0
  20. package/dist/cjs/core/checks/security/diff-security.d.ts +7 -0
  21. package/dist/cjs/core/checks/security/diff-security.js +21 -1
  22. package/dist/cjs/core/checks/security/security.d.ts +0 -13
  23. package/dist/cjs/core/checks/security/security.js +27 -0
  24. package/dist/cjs/core/checks/security/workflow-security.js +33 -0
  25. package/dist/cjs/core/commands.d.ts +6 -0
  26. package/dist/cjs/core/commands.js +11 -1
  27. package/dist/cjs/core/finding-store.d.ts +16 -0
  28. package/dist/cjs/core/finding-store.js +73 -0
  29. package/dist/cjs/core/registry.js +2 -0
  30. package/dist/cjs/core/reporters.js +9 -0
  31. package/dist/cjs/core/run-checks.js +15 -5
  32. package/dist/cjs/core/sarif.d.ts +13 -0
  33. package/dist/cjs/core/sarif.js +60 -0
  34. package/dist/cjs/core/types.d.ts +7 -2
  35. package/dist/cjs/index.d.ts +2 -1
  36. package/dist/cjs/index.js +4 -1
  37. package/dist/cjs/init.js +11 -0
  38. package/dist/cjs/interfaces/index.d.ts +12 -0
  39. package/dist/cjs/mcp/config-tools.d.ts +30 -0
  40. package/dist/cjs/mcp/config-tools.js +320 -0
  41. package/dist/cjs/mcp/dependency.d.ts +33 -0
  42. package/dist/cjs/mcp/dependency.js +407 -0
  43. package/dist/cjs/mcp/protocol.d.ts +97 -0
  44. package/dist/cjs/mcp/protocol.js +181 -0
  45. package/dist/cjs/mcp/review.d.ts +63 -0
  46. package/dist/cjs/mcp/review.js +150 -0
  47. package/dist/cjs/mcp/serve.d.ts +8 -0
  48. package/dist/cjs/mcp/serve.js +49 -0
  49. package/dist/cjs/mcp/settings.d.ts +13 -0
  50. package/dist/cjs/mcp/settings.js +50 -0
  51. package/dist/cjs/mcp/tools.d.ts +23 -0
  52. package/dist/cjs/mcp/tools.js +633 -0
  53. package/dist/cjs/utils/execa.d.ts +7 -0
  54. package/dist/cjs/utils/execa.js +41 -1
  55. package/dist/cjs/utils/result-collector.d.ts +17 -1
  56. package/dist/cjs/utils/result-collector.js +7 -2
  57. package/dist/esm/agent/hook.d.ts +37 -0
  58. package/dist/esm/agent/hook.js +167 -0
  59. package/dist/esm/agent/registration.d.ts +10 -0
  60. package/dist/esm/agent/registration.js +21 -0
  61. package/dist/esm/agent/setup.d.ts +20 -0
  62. package/dist/esm/agent/setup.js +220 -0
  63. package/dist/esm/cli.js +86 -1
  64. package/dist/esm/config/constants.d.ts +2 -0
  65. package/dist/esm/config/constants.js +4 -0
  66. package/dist/esm/config/pr-checkmate.template.json +10 -0
  67. package/dist/esm/core/checks/findings.js +33 -7
  68. package/dist/esm/core/checks/git/large-files.d.ts +1 -0
  69. package/dist/esm/core/checks/git/large-files.js +1 -1
  70. package/dist/esm/core/checks/pr/pr-size-check.d.ts +2 -0
  71. package/dist/esm/core/checks/pr/pr-size-check.js +2 -2
  72. package/dist/esm/core/checks/security/agent-instructions.d.ts +4 -0
  73. package/dist/esm/core/checks/security/agent-instructions.js +146 -0
  74. package/dist/esm/core/checks/security/diff-security.d.ts +7 -0
  75. package/dist/esm/core/checks/security/diff-security.js +20 -0
  76. package/dist/esm/core/checks/security/security.d.ts +0 -13
  77. package/dist/esm/core/checks/security/security.js +27 -0
  78. package/dist/esm/core/checks/security/workflow-security.js +33 -0
  79. package/dist/esm/core/commands.d.ts +6 -0
  80. package/dist/esm/core/commands.js +10 -0
  81. package/dist/esm/core/finding-store.d.ts +16 -0
  82. package/dist/esm/core/finding-store.js +67 -0
  83. package/dist/esm/core/registry.js +2 -0
  84. package/dist/esm/core/reporters.js +9 -0
  85. package/dist/esm/core/run-checks.js +15 -5
  86. package/dist/esm/core/sarif.d.ts +13 -0
  87. package/dist/esm/core/sarif.js +57 -0
  88. package/dist/esm/core/types.d.ts +7 -2
  89. package/dist/esm/index.d.ts +2 -1
  90. package/dist/esm/index.js +2 -0
  91. package/dist/esm/init.js +12 -1
  92. package/dist/esm/interfaces/index.d.ts +12 -0
  93. package/dist/esm/mcp/config-tools.d.ts +30 -0
  94. package/dist/esm/mcp/config-tools.js +310 -0
  95. package/dist/esm/mcp/dependency.d.ts +33 -0
  96. package/dist/esm/mcp/dependency.js +402 -0
  97. package/dist/esm/mcp/protocol.d.ts +97 -0
  98. package/dist/esm/mcp/protocol.js +175 -0
  99. package/dist/esm/mcp/review.d.ts +63 -0
  100. package/dist/esm/mcp/review.js +139 -0
  101. package/dist/esm/mcp/serve.d.ts +8 -0
  102. package/dist/esm/mcp/serve.js +43 -0
  103. package/dist/esm/mcp/settings.d.ts +13 -0
  104. package/dist/esm/mcp/settings.js +43 -0
  105. package/dist/esm/mcp/tools.d.ts +23 -0
  106. package/dist/esm/mcp/tools.js +626 -0
  107. package/dist/esm/utils/execa.d.ts +7 -0
  108. package/dist/esm/utils/execa.js +40 -1
  109. package/dist/esm/utils/result-collector.d.ts +17 -1
  110. package/dist/esm/utils/result-collector.js +7 -2
  111. package/package.json +6 -2
  112. package/pr-checkmate.schema.json +35 -0
  113. package/src/config/pr-checkmate.schema.json +35 -0
package/dist/esm/cli.js CHANGED
@@ -1,11 +1,20 @@
1
1
  #!/usr/bin/env node
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { evaluateHook } from './agent/hook.js';
5
+ import { reportSetup, setupAgent } from './agent/setup.js';
2
6
  import { loadProjectConfig } from './config/loader.js';
3
- import { COMMAND_TO_CHECK, FLAGS, STANDALONE_COMMANDS, TOOLING_COMMANDS } from './core/commands.js';
7
+ import { AGENT_COMMANDS, COMMAND_TO_CHECK, FLAGS, STANDALONE_COMMANDS, TOOLING_COMMANDS, } from './core/commands.js';
4
8
  import { runChecks } from './core/index.js';
5
9
  import { installProfiles, pruneCache, runDoctor } from './core/install/doctor.js';
6
10
  import { ensureGrypeDb } from './core/install/grype-db.js';
11
+ import { toSarif } from './core/sarif.js';
7
12
  import { init } from './init.js';
13
+ import { serve } from './mcp/serve.js';
14
+ import { createServerDefinition } from './mcp/tools.js';
15
+ import { isolateInheritedStdio } from './utils/execa.js';
8
16
  import { getUpdateNotice, logger } from './utils/index.js';
17
+ import { packageVersion } from './utils/package-version.js';
9
18
  const argv = process.argv.slice(2);
10
19
  /**
11
20
  * Flags are separated from positional arguments so they compose with every command
@@ -52,6 +61,28 @@ function rejectConflictingScope() {
52
61
  logger.error('[cli]: "--all" applies only to "install". Did you mean "--full"?');
53
62
  process.exit(1);
54
63
  }
64
+ // `mcp` speaks a protocol on stdout and `hook` is invoked by a harness with a
65
+ // fixed payload: neither has anything to configure from the command line, and a
66
+ // flag that quietly did nothing would look like it had.
67
+ if ((cmd === 'mcp' || cmd === 'hook') && flags.length > 0) {
68
+ logger.error(`[cli]: "${cmd}" takes no flags, got: ${flags.join(', ')}.`);
69
+ process.exit(1);
70
+ }
71
+ // Only a command that runs checks has findings to export; `all`, `precommit` and a
72
+ // named check do, every other standalone command does not.
73
+ const runsChecks = !cmd || cmd === 'all' || cmd === 'precommit' || !STANDALONE_COMMANDS.includes(cmd);
74
+ if (flags.includes('--sarif') && !runsChecks) {
75
+ logger.error('[cli]: "--sarif" applies only to commands that run checks.');
76
+ process.exit(1);
77
+ }
78
+ if (flags.includes('--no-hook') && cmd !== 'agent-setup') {
79
+ logger.error('[cli]: "--no-hook" applies only to "agent-setup".');
80
+ process.exit(1);
81
+ }
82
+ if (flags.includes('--full') && AGENT_COMMANDS.includes(cmd ?? '')) {
83
+ logger.error(`[cli]: "${cmd}" reviews nothing on its own, so "--full" has no meaning.`);
84
+ process.exit(1);
85
+ }
55
86
  if (flags.includes('--full') && TOOLING_COMMANDS.includes(cmd ?? '')) {
56
87
  logger.error(`[cli]: "${cmd}" manages tooling and reviews nothing, so "--full" has no meaning.`);
57
88
  process.exit(1);
@@ -73,6 +104,55 @@ function rejectExtraArgs() {
73
104
  logger.error(`[cli]: "${cmd}" takes no other arguments, got: ${args.slice(1).join(', ')}. Run them as a separate command.`);
74
105
  process.exit(1);
75
106
  }
107
+ /**
108
+ * Write the report as SARIF when `--sarif` was passed. Written before the exit code
109
+ * is decided, so a failing run still leaves the file a dashboard needs to show why.
110
+ */
111
+ function writeSarifIfRequested(report) {
112
+ if (!flags.includes('--sarif'))
113
+ return;
114
+ const file = path.resolve(process.cwd(), 'pr-checkmate.sarif');
115
+ fs.writeFileSync(file, `${JSON.stringify(toSarif(report, packageVersion()), null, 2)}\n`);
116
+ logger.info(`[sarif]: wrote ${file}`);
117
+ }
118
+ /** Everything the harness wrote to stdin, as text; empty when stdin is closed or a terminal. */
119
+ async function readStdin() {
120
+ if (process.stdin.isTTY)
121
+ return '';
122
+ const chunks = [];
123
+ for await (const chunk of process.stdin)
124
+ chunks.push(Buffer.from(chunk));
125
+ return Buffer.concat(chunks).toString('utf-8');
126
+ }
127
+ /**
128
+ * The commands that serve AI agents. Returns whether `cmd` was one of them (and has
129
+ * therefore already exited); kept out of the main dispatcher so that one stays
130
+ * readable as the list of commands grows.
131
+ *
132
+ * `mcp` must stay ahead of anything that could print: from here until stdin closes,
133
+ * stdout carries protocol messages and nothing else.
134
+ */
135
+ async function runAgentCommand() {
136
+ if (cmd === 'mcp') {
137
+ await serve(createServerDefinition());
138
+ process.exit(0);
139
+ }
140
+ if (cmd === 'hook') {
141
+ // Discarded rather than shown: the harness reads stdout and stderr of a hook
142
+ // as instructions to the model, so only the verdict may reach either.
143
+ isolateInheritedStdio('discard');
144
+ logger.setSink({ out: () => undefined, err: () => undefined });
145
+ const verdict = await evaluateHook(await readStdin(), process.cwd());
146
+ if (verdict.stderr)
147
+ process.stderr.write(`${verdict.stderr}\n`);
148
+ process.exit(verdict.exitCode);
149
+ }
150
+ if (cmd === 'agent-setup') {
151
+ reportSetup(setupAgent(process.cwd(), { hook: !flags.includes('--no-hook') }));
152
+ process.exit(0);
153
+ }
154
+ return false;
155
+ }
76
156
  void (async () => {
77
157
  try {
78
158
  rejectUnknownFlags();
@@ -82,6 +162,8 @@ void (async () => {
82
162
  await init();
83
163
  process.exit(0);
84
164
  }
165
+ if (await runAgentCommand())
166
+ return;
85
167
  // Tooling management. These run no checks — they prepare, inspect or clean
86
168
  // up what the checks need, so they never touch the diff or the reporter.
87
169
  if (cmd === 'install') {
@@ -123,12 +205,14 @@ void (async () => {
123
205
  logger.info('');
124
206
  notice.forEach(line => logger.info(line));
125
207
  }
208
+ writeSarifIfRequested(report);
126
209
  process.exit(report.ok ? 0 : 1);
127
210
  }
128
211
  // Local pre-commit: check the staged changes (index vs HEAD), print to
129
212
  // stdout, never modify files. Exits non-zero on failure to block the commit.
130
213
  if (cmd === 'precommit') {
131
214
  const report = await runChecks({ staged: true, reporter: 'stdout', write: false });
215
+ writeSarifIfRequested(report);
132
216
  process.exit(report.ok ? 0 : 1);
133
217
  }
134
218
  // Every remaining argument is a check name, so `pr-checkmate lint spellcheck`
@@ -141,6 +225,7 @@ void (async () => {
141
225
  }
142
226
  const checkNames = [...new Set(args.map(arg => COMMAND_TO_CHECK[arg]))];
143
227
  const report = await runChecks({ only: checkNames, reporter: 'stdout', write: true, full });
228
+ writeSarifIfRequested(report);
144
229
  process.exit(report.ok ? 0 : 1);
145
230
  }
146
231
  catch (err) {
@@ -95,6 +95,7 @@ export declare const SOURCE_EXTENSIONS: {
95
95
  json: string[];
96
96
  all: string[];
97
97
  };
98
+ export declare const AGENT_SETUP_COMMAND = "npx pr-checkmate agent-setup";
98
99
  export declare const DEFAULT_COMMANDS: {
99
100
  'npx pr-checkmate init': string;
100
101
  'npx pr-checkmate all': string;
@@ -109,6 +110,7 @@ export declare const DEFAULT_COMMANDS: {
109
110
  'npx pr-checkmate diff-smell': string;
110
111
  'npx pr-checkmate pr-body': string;
111
112
  'npx pr-checkmate duplicate': string;
113
+ "npx pr-checkmate agent-setup": string;
112
114
  };
113
115
  /**
114
116
  * Packages allow-listed for the license check by default.
@@ -225,6 +225,7 @@ export const SOURCE_EXTENSIONS = {
225
225
  json: ['.json'],
226
226
  all: ['.ts', '.tsx', '.js', '.jsx', '.json', '.md', '.mdx'],
227
227
  };
228
+ export const AGENT_SETUP_COMMAND = 'npx pr-checkmate agent-setup';
228
229
  export const DEFAULT_COMMANDS = {
229
230
  'npx pr-checkmate init': 'Create pr-checkmate.json and the CI workflow (first-run setup)',
230
231
  'npx pr-checkmate all': 'Run all checks over the changes — the PR diff in CI, your branch changes locally',
@@ -239,6 +240,7 @@ export const DEFAULT_COMMANDS = {
239
240
  'npx pr-checkmate diff-smell': 'Scan git diff for code smells',
240
241
  'npx pr-checkmate pr-body': 'Validate PR description',
241
242
  'npx pr-checkmate duplicate': 'Detect duplicate code blocks (jscpd)',
243
+ [AGENT_SETUP_COMMAND]: 'Connect your AI agent: MCP server, rules for it and edit hooks (writes .mcp.json)',
242
244
  };
243
245
  /**
244
246
  * Packages allow-listed for the license check by default.
@@ -320,6 +322,8 @@ export const KNOWN_CONFIG_KEYS = new Set([
320
322
  'diffSecurity',
321
323
  'workflowSecurity',
322
324
  'dockerfileSecurity',
325
+ 'agentInstructions',
326
+ 'mcp',
323
327
  'sensitiveFileGuard',
324
328
  'migrationSafety',
325
329
  // dependencies
@@ -97,6 +97,16 @@
97
97
  "enabled": true,
98
98
  "ignore": []
99
99
  },
100
+ "agentInstructions": {
101
+ "enabled": true,
102
+ "ignore": []
103
+ },
104
+ "//mcp": "What the MCP server offers an AI agent here. 'enabled': false turns it off; 'disabledTools' hides tools by name; 'readOnly' hides the ones that write files. Applies from the agent's next request.",
105
+ "mcp": {
106
+ "enabled": true,
107
+ "disabledTools": [],
108
+ "readOnly": false
109
+ },
100
110
  "//sensitiveFileGuard": "Flags .env files, private keys, CI config and lockfiles. 'ignorePaths' excuses ONE file by glob (a .pem test fixture); 'ignore' silences a whole CATEGORY and would excuse the next real key too — reach for ignorePaths first.",
101
111
  "sensitiveFileGuard": {
102
112
  "enabled": true,
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { execa } from '../../utils/execa.js';
4
4
  import { logger, matchesAnyGlob } from '../../utils/index.js';
5
+ import { fileFindings, noteLocation } from '../finding-store.js';
5
6
  import { listTrackedFiles } from '../targets.js';
6
7
  import { fail, pass, warn } from '../types.js';
7
8
  /**
@@ -370,6 +371,8 @@ export const DEFAULT_DATAFLOW_WINDOW = 10;
370
371
  const CROSS_LINE_SQL_LABEL = 'Interpolated value reaches a query (SQL injection risk, cross-line)';
371
372
  /** `@@ ... @@` — a new hunk, and the boundary taint never crosses. */
372
373
  const DIFF_HUNK_HEADER = /^@@ /;
374
+ /** The first line of the new side of a hunk: `@@ -3,2 +10,4 @@` → 10. */
375
+ const HUNK_NEW_START = /^@@ -\d+(?:,\d+)? \+(\d+)/;
373
376
  /**
374
377
  * Whether this line hands `name` to a query sink.
375
378
  *
@@ -443,6 +446,9 @@ export function scanDiffPatterns(stdout, patterns, opts = {}) {
443
446
  const mode = opts.comments ?? 'language';
444
447
  const ignorePaths = opts.ignorePaths ?? [];
445
448
  let syntax = 'both';
449
+ // Where the walk is, for findings that need to say so.
450
+ let file = '';
451
+ let newLine = 0;
446
452
  // Block comments span lines, so the state belongs to the walk, not to one line.
447
453
  let inBlockComment = false;
448
454
  // Whole files can be excluded by glob; the flag persists until the next header.
@@ -453,6 +459,7 @@ export function scanDiffPatterns(stdout, patterns, opts = {}) {
453
459
  for (const line of stdout.split('\n')) {
454
460
  const header = DIFF_FILE_HEADER.exec(line);
455
461
  if (header) {
462
+ file = header[1];
456
463
  syntax = mode === 'language' ? commentSyntaxFor(header[1]) : 'both';
457
464
  inBlockComment = false;
458
465
  skippingFile = ignorePaths.length > 0 && matchesAnyGlob(header[1], ignorePaths);
@@ -461,9 +468,15 @@ export function scanDiffPatterns(stdout, patterns, opts = {}) {
461
468
  }
462
469
  // Checked before the `+` filter below, which a hunk header does not satisfy.
463
470
  if (DIFF_HUNK_HEADER.test(line)) {
471
+ newLine = Number(HUNK_NEW_START.exec(line)?.[1] ?? 0);
464
472
  taint.reset();
465
473
  continue;
466
474
  }
475
+ // Counted before any skip: an ignored or excluded line still occupies a line
476
+ // number in the file, and the ones after it must not shift.
477
+ const lineNo = newLine;
478
+ if (line.startsWith('+'))
479
+ newLine++;
467
480
  if (skippingFile)
468
481
  continue;
469
482
  // Only added lines carry block-comment state: a `-` line is code being
@@ -475,18 +488,28 @@ export function scanDiffPatterns(stdout, patterns, opts = {}) {
475
488
  const prepared = uncomment(line, { mode, syntax, inBlockComment });
476
489
  inBlockComment = prepared.inBlockComment;
477
490
  const asCode = stripStringLiterals(prepared.text);
478
- for (const { pattern, label, severity, view } of patterns) {
479
- if (pattern.test(view === 'code' ? asCode : prepared.text)) {
480
- const bucket = severity === 'error' ? errors : warnings;
481
- (bucket[label] ?? (bucket[label] = [])).push(line.slice(1).trim().substring(0, SAMPLE_MAX_LENGTH));
482
- }
483
- }
491
+ const where = { file, line: lineNo };
492
+ recordMatches(patterns, { text: prepared.text, asCode }, line, where, { errors, warnings });
484
493
  if (taint.inspect(prepared.text, asCode)) {
485
- (warnings[CROSS_LINE_SQL_LABEL] ?? (warnings[CROSS_LINE_SQL_LABEL] = [])).push(line.slice(1).trim().substring(0, SAMPLE_MAX_LENGTH));
494
+ record(warnings, CROSS_LINE_SQL_LABEL, line, where);
486
495
  }
487
496
  }
488
497
  return { errors, warnings };
489
498
  }
499
+ /** File one added line under `label` and remember where it was, for findings that say so. */
500
+ function record(bucket, label, line, where) {
501
+ const sample = line.slice(1).trim().substring(0, SAMPLE_MAX_LENGTH);
502
+ (bucket[label] ?? (bucket[label] = [])).push(sample);
503
+ noteLocation(label, sample, where.file, where.line);
504
+ }
505
+ /** File every pattern that matches one prepared line under its own label and severity. */
506
+ function recordMatches(patterns, view, line, where, into) {
507
+ for (const { pattern, label, severity, view: wants } of patterns) {
508
+ if (pattern.test(wants === 'code' ? view.asCode : view.text)) {
509
+ record(severity === 'error' ? into.errors : into.warnings, label, line, where);
510
+ }
511
+ }
512
+ }
490
513
  /**
491
514
  * Reduce one added line to what should be matched against: block comments
492
515
  * blanked (carrying the open state on), then the trailing line comment removed.
@@ -577,6 +600,7 @@ export function bucketTotal(bucket) {
577
600
  */
578
601
  export function reportCountedFindings(findings, ctx, opts) {
579
602
  const sev = severityEmitter(opts.severity, opts.fallback ?? 'warn');
603
+ fileFindings(findings, sev.icon === '❌' ? 'error' : 'warn');
580
604
  logFindings(findings, {
581
605
  prefix: opts.prefix,
582
606
  sampleLimit: opts.sampleLimit ?? ctx.config.log?.sampleLimit ?? 3,
@@ -681,6 +705,8 @@ export function reportSeverityBuckets(errorsIn, warningsIn, opts) {
681
705
  const ignore = opts.ignore ?? [];
682
706
  const filed = applyOverrides(dropIgnored(errorsIn, ignore), dropIgnored(warningsIn, ignore), opts.severityOverrides ?? {});
683
707
  const { errors, warnings } = filed;
708
+ fileFindings(errors, 'error');
709
+ fileFindings(warnings, 'warn');
684
710
  const totalErrors = bucketTotal(errors);
685
711
  const totalWarnings = bucketTotal(warnings);
686
712
  if (totalErrors === 0 && totalWarnings === 0)
@@ -1,2 +1,3 @@
1
1
  import { Check } from '../../types.js';
2
+ export declare const DEFAULT_MAX_KB = 500;
2
3
  export declare const largeFilesCheck: Check;
@@ -5,7 +5,7 @@ import { logger, matchesAnyGlob } from '../../../utils/index.js';
5
5
  import { resolveTargetFiles } from '../../targets.js';
6
6
  import { pass, skip } from '../../types.js';
7
7
  import { severityEmitter } from '../findings.js';
8
- const DEFAULT_MAX_KB = 500;
8
+ export const DEFAULT_MAX_KB = 500;
9
9
  /**
10
10
  * Lockfiles are legitimately large and always generated, never accidental — so
11
11
  * they're exempt from the size/binary guard by default (a 600 KB
@@ -1,2 +1,4 @@
1
1
  import { Check } from '../../types.js';
2
+ export declare const DEFAULT_MAX_FILES = 50;
3
+ export declare const DEFAULT_MAX_LINES = 1000;
2
4
  export declare const prSizeCheck: Check;
@@ -1,8 +1,8 @@
1
1
  import { execa } from '../../../utils/execa.js';
2
2
  import { logger } from '../../../utils/index.js';
3
3
  import { pass, skip, warn } from '../../types.js';
4
- const DEFAULT_MAX_FILES = 50;
5
- const DEFAULT_MAX_LINES = 1000;
4
+ export const DEFAULT_MAX_FILES = 50;
5
+ export const DEFAULT_MAX_LINES = 1000;
6
6
  export const prSizeCheck = {
7
7
  name: 'PR Size',
8
8
  phase: 'informational',
@@ -0,0 +1,4 @@
1
+ import { Check } from '../../types.js';
2
+ import { DiffPattern } from '../findings.js';
3
+ export declare const AGENT_PATTERNS: DiffPattern[];
4
+ export declare const agentInstructionsCheck: Check;
@@ -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>;