@danielblomma/cortex-mcp 1.7.1 → 2.0.2

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 (79) hide show
  1. package/bin/cortex.mjs +679 -32
  2. package/bin/style.mjs +349 -0
  3. package/package.json +4 -3
  4. package/scaffold/mcp/package-lock.json +834 -671
  5. package/scaffold/mcp/package.json +1 -1
  6. package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
  7. package/scaffold/mcp/src/cli/govern.ts +987 -0
  8. package/scaffold/mcp/src/cli/run.ts +306 -0
  9. package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
  10. package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
  11. package/scaffold/mcp/src/core/audit/query.ts +81 -0
  12. package/scaffold/mcp/src/core/audit/writer.ts +68 -0
  13. package/scaffold/mcp/src/core/config.ts +329 -0
  14. package/scaffold/mcp/src/core/index.ts +34 -0
  15. package/scaffold/mcp/src/core/license.ts +202 -0
  16. package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
  17. package/scaffold/mcp/src/core/policy/injection.ts +229 -0
  18. package/scaffold/mcp/src/core/policy/store.ts +197 -0
  19. package/scaffold/mcp/src/core/rbac/check.ts +40 -0
  20. package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
  21. package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
  22. package/scaffold/mcp/src/core/validators/config.ts +47 -0
  23. package/scaffold/mcp/src/core/validators/engine.ts +199 -0
  24. package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
  25. package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
  26. package/scaffold/mcp/src/daemon/client.ts +155 -0
  27. package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
  28. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
  29. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
  30. package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
  31. package/scaffold/mcp/src/daemon/main.ts +300 -0
  32. package/scaffold/mcp/src/daemon/paths.ts +41 -0
  33. package/scaffold/mcp/src/daemon/protocol.ts +101 -0
  34. package/scaffold/mcp/src/daemon/server.ts +227 -0
  35. package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
  36. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
  37. package/scaffold/mcp/src/embed.ts +1 -1
  38. package/scaffold/mcp/src/embeddings.ts +1 -1
  39. package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
  40. package/scaffold/mcp/src/enterprise/index.ts +415 -0
  41. package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
  42. package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
  43. package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
  44. package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
  45. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
  46. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
  47. package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
  48. package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
  49. package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
  50. package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
  51. package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
  52. package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
  53. package/scaffold/mcp/src/hooks/session-end.ts +73 -0
  54. package/scaffold/mcp/src/hooks/session-start.ts +78 -0
  55. package/scaffold/mcp/src/hooks/shared.ts +134 -0
  56. package/scaffold/mcp/src/hooks/stop.ts +60 -0
  57. package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
  58. package/scaffold/mcp/src/plugin.ts +150 -0
  59. package/scaffold/mcp/src/server.ts +218 -7
  60. package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
  61. package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
  62. package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
  63. package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
  64. package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
  65. package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
  66. package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
  67. package/scaffold/mcp/tests/govern.test.mjs +74 -0
  68. package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
  69. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
  70. package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
  71. package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
  72. package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
  73. package/scaffold/mcp/tests/run.test.mjs +109 -0
  74. package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
  75. package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
  76. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
  77. package/scaffold/scripts/bootstrap.sh +0 -11
  78. package/scaffold/scripts/doctor.sh +24 -4
  79. package/types.js +5 -0
@@ -0,0 +1,711 @@
1
+ import { createHash } from "node:crypto";
2
+ import { statSync, readFileSync, existsSync, readdirSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { registerValidator, type ValidatorContext, type ValidatorResult } from "./engine.js";
5
+ // Side-effect imports: register generic evaluators (type-based dispatch)
6
+ // alongside the name-based validators defined below.
7
+ import "./evaluators/regex.js";
8
+ import "./evaluators/code_comments.js";
9
+
10
+ const DEFAULT_COVERAGE_PATHS = [
11
+ "coverage/coverage-summary.json",
12
+ "coverage-summary.json",
13
+ "coverage/lcov.info",
14
+ "lcov.info",
15
+ ] as const;
16
+
17
+ type CoverageStats = {
18
+ sourcePath: string;
19
+ overall: number;
20
+ linePct: number | null;
21
+ branchPct: number | null;
22
+ };
23
+
24
+ function parseCoverageSummary(raw: string, sourcePath: string): CoverageStats | null {
25
+ const report = JSON.parse(raw);
26
+ const total = report?.total;
27
+ if (!total) return null;
28
+
29
+ const linePct = typeof total.lines?.pct === "number" ? total.lines.pct : null;
30
+ const branchPct = typeof total.branches?.pct === "number" ? total.branches.pct : null;
31
+ const overall = linePct ?? branchPct ?? null;
32
+ if (overall === null) return null;
33
+
34
+ return { sourcePath, overall, linePct, branchPct };
35
+ }
36
+
37
+ function parseLcov(raw: string, sourcePath: string): CoverageStats | null {
38
+ let linesFound = 0;
39
+ let linesHit = 0;
40
+ let branchesFound = 0;
41
+ let branchesHit = 0;
42
+
43
+ for (const line of raw.split("\n")) {
44
+ const trimmed = line.trim();
45
+ if (trimmed.startsWith("LF:")) {
46
+ linesFound += Number(trimmed.slice(3)) || 0;
47
+ } else if (trimmed.startsWith("LH:")) {
48
+ linesHit += Number(trimmed.slice(3)) || 0;
49
+ } else if (trimmed.startsWith("BRF:")) {
50
+ branchesFound += Number(trimmed.slice(4)) || 0;
51
+ } else if (trimmed.startsWith("BRH:")) {
52
+ branchesHit += Number(trimmed.slice(4)) || 0;
53
+ }
54
+ }
55
+
56
+ const linePct = linesFound > 0 ? (linesHit / linesFound) * 100 : null;
57
+ const branchPct = branchesFound > 0 ? (branchesHit / branchesFound) * 100 : null;
58
+ const overall = linePct ?? branchPct ?? null;
59
+ if (overall === null) return null;
60
+
61
+ return { sourcePath, overall, linePct, branchPct };
62
+ }
63
+
64
+ function loadCoverageStats(
65
+ projectRoot: string,
66
+ options: Record<string, unknown>,
67
+ ): CoverageStats | null {
68
+ const configuredPaths = Array.isArray(options.coverage_paths)
69
+ ? options.coverage_paths.filter((value): value is string => typeof value === "string" && value.length > 0)
70
+ : [];
71
+ const candidatePaths = typeof options.coverage_path === "string" && options.coverage_path.length > 0
72
+ ? [options.coverage_path]
73
+ : configuredPaths.length > 0
74
+ ? configuredPaths
75
+ : [...DEFAULT_COVERAGE_PATHS];
76
+
77
+ for (const coveragePath of candidatePaths) {
78
+ const abs = join(projectRoot, coveragePath);
79
+ if (!existsSync(abs)) continue;
80
+
81
+ try {
82
+ const raw = readFileSync(abs, "utf8");
83
+ const parsed = coveragePath.endsWith(".info")
84
+ ? parseLcov(raw, coveragePath)
85
+ : parseCoverageSummary(raw, coveragePath);
86
+ if (parsed) return parsed;
87
+ } catch {
88
+ // Try later fallback candidates before giving up on coverage entirely.
89
+ }
90
+ }
91
+
92
+ return null;
93
+ }
94
+
95
+ type ReviewStatusPayload = {
96
+ reviewed: boolean;
97
+ reviewer: string;
98
+ timestamp: string;
99
+ source: "legacy-review-status" | "workflow-state" | "workflow-artifact";
100
+ reviewedFiles: ReviewedFileSnapshot[] | null;
101
+ };
102
+
103
+ type ReviewedFileSnapshot = {
104
+ path: string;
105
+ exists: boolean;
106
+ hash: string | null;
107
+ };
108
+
109
+ type CurrentReviewedFileSnapshot = ReviewedFileSnapshot & {
110
+ mtimeMs: number | null;
111
+ };
112
+
113
+ function normalizeReviewedFiles(value: unknown): ReviewedFileSnapshot[] | null {
114
+ if (!Array.isArray(value)) return null;
115
+
116
+ return value
117
+ .map((entry) => {
118
+ if (!entry || typeof entry !== "object") return null;
119
+ const candidate = entry as Record<string, unknown>;
120
+ if (typeof candidate.path !== "string" || candidate.path.length === 0) return null;
121
+ return {
122
+ path: candidate.path,
123
+ exists: candidate.exists === true,
124
+ hash: typeof candidate.hash === "string" ? candidate.hash : null,
125
+ };
126
+ })
127
+ .filter((entry): entry is ReviewedFileSnapshot => entry !== null)
128
+ .sort((left, right) => left.path.localeCompare(right.path));
129
+ }
130
+
131
+ function readLegacyReviewStatus(contextDir: string): ReviewStatusPayload | null {
132
+ const statusPath = join(contextDir, "review-status.json");
133
+ if (!existsSync(statusPath)) return null;
134
+
135
+ try {
136
+ const raw = readFileSync(statusPath, "utf8");
137
+ const status = JSON.parse(raw);
138
+ return {
139
+ reviewed: status?.reviewed === true,
140
+ reviewer: typeof status?.reviewer === "string" ? status.reviewer : "unknown",
141
+ timestamp: typeof status?.timestamp === "string" ? status.timestamp : "unknown",
142
+ source: "legacy-review-status",
143
+ reviewedFiles: normalizeReviewedFiles(status?.reviewed_files),
144
+ };
145
+ } catch {
146
+ return {
147
+ reviewed: false,
148
+ reviewer: "unknown",
149
+ timestamp: "unknown",
150
+ source: "legacy-review-status",
151
+ reviewedFiles: null,
152
+ };
153
+ }
154
+ }
155
+
156
+ function parseReviewTimestamp(timestamp: string): number | null {
157
+ const parsed = Date.parse(timestamp);
158
+ return Number.isNaN(parsed) ? null : parsed;
159
+ }
160
+
161
+ function chooseLatestReviewStatus(
162
+ ...statuses: Array<ReviewStatusPayload | null>
163
+ ): ReviewStatusPayload | null {
164
+ let latest: ReviewStatusPayload | null = null;
165
+ let latestTs: number | null = null;
166
+
167
+ for (const status of statuses) {
168
+ if (!status) continue;
169
+
170
+ const currentTs = parseReviewTimestamp(status.timestamp);
171
+ if (!latest) {
172
+ latest = status;
173
+ latestTs = currentTs;
174
+ continue;
175
+ }
176
+
177
+ if (latestTs === null && currentTs !== null) {
178
+ latest = status;
179
+ latestTs = currentTs;
180
+ continue;
181
+ }
182
+
183
+ if (latestTs !== null && currentTs !== null && currentTs > latestTs) {
184
+ latest = status;
185
+ latestTs = currentTs;
186
+ }
187
+ }
188
+
189
+ return latest;
190
+ }
191
+
192
+ function readWorkflowReviewStatus(contextDir: string): ReviewStatusPayload | null {
193
+ let workflowStateStatus: ReviewStatusPayload | null = null;
194
+ const workflowStatePath = join(contextDir, "workflow", "state.json");
195
+ if (existsSync(workflowStatePath)) {
196
+ try {
197
+ const raw = readFileSync(workflowStatePath, "utf8");
198
+ const state = JSON.parse(raw);
199
+ const lastReview = state?.last_review;
200
+ if (typeof lastReview?.reviewed_at === "string" && lastReview.reviewed_at) {
201
+ workflowStateStatus = {
202
+ reviewed: lastReview.status === "passed",
203
+ reviewer: "context.review",
204
+ timestamp: lastReview.reviewed_at,
205
+ source: "workflow-state",
206
+ reviewedFiles: normalizeReviewedFiles(lastReview.reviewed_files),
207
+ };
208
+ }
209
+ } catch {
210
+ // Fall through to artifact lookup.
211
+ }
212
+ }
213
+
214
+ const reviewsDir = join(contextDir, "workflow", "reviews");
215
+ if (!existsSync(reviewsDir)) return workflowStateStatus;
216
+
217
+ try {
218
+ const fileNames = readdirSync(reviewsDir)
219
+ .filter((name) => name.endsWith(".json"))
220
+ .sort();
221
+ const latest = fileNames.at(-1);
222
+ if (!latest) return workflowStateStatus;
223
+
224
+ const raw = readFileSync(join(reviewsDir, latest), "utf8");
225
+ const artifact = JSON.parse(raw);
226
+ const summary = artifact?.summary;
227
+ const reviewedAt = typeof artifact?.recorded_at === "string" ? artifact.recorded_at : null;
228
+ if (!reviewedAt) return workflowStateStatus;
229
+
230
+ return chooseLatestReviewStatus(workflowStateStatus, {
231
+ reviewed: Number(summary?.failed ?? 0) === 0,
232
+ reviewer: "context.review",
233
+ timestamp: reviewedAt,
234
+ source: "workflow-artifact",
235
+ reviewedFiles: normalizeReviewedFiles(artifact?.reviewed_files),
236
+ });
237
+ } catch {
238
+ return workflowStateStatus;
239
+ }
240
+ }
241
+
242
+ function snapshotChangedFiles(
243
+ projectRoot: string,
244
+ changedFiles: string[],
245
+ ): CurrentReviewedFileSnapshot[] {
246
+ return [...new Set(changedFiles)]
247
+ .sort()
248
+ .map((file): CurrentReviewedFileSnapshot => {
249
+ const abs = join(projectRoot, file);
250
+ try {
251
+ const stat = statSync(abs);
252
+ if (!stat.isFile()) {
253
+ return { path: file, exists: false, hash: null, mtimeMs: null };
254
+ }
255
+
256
+ const hash = createHash("sha256")
257
+ .update(readFileSync(abs))
258
+ .digest("hex");
259
+ return {
260
+ path: file,
261
+ exists: true,
262
+ hash,
263
+ mtimeMs: stat.mtimeMs,
264
+ };
265
+ } catch {
266
+ return { path: file, exists: false, hash: null, mtimeMs: null };
267
+ }
268
+ });
269
+ }
270
+
271
+ function reviewMatchesCurrentChanges(
272
+ reviewStatus: ReviewStatusPayload,
273
+ ctx: ValidatorContext,
274
+ ): { matches: boolean; detail?: string } {
275
+ const changedFiles = ctx.changedFiles;
276
+ if (!reviewStatus.reviewed || !changedFiles || changedFiles.length === 0) {
277
+ return { matches: true };
278
+ }
279
+
280
+ const currentSnapshot = snapshotChangedFiles(ctx.projectRoot, changedFiles);
281
+
282
+ if (reviewStatus.reviewedFiles) {
283
+ if (reviewStatus.reviewedFiles.length !== currentSnapshot.length) {
284
+ return {
285
+ matches: false,
286
+ detail: "Current changed files differ from the reviewed snapshot.",
287
+ };
288
+ }
289
+
290
+ for (let index = 0; index < reviewStatus.reviewedFiles.length; index += 1) {
291
+ const reviewedFile = reviewStatus.reviewedFiles[index];
292
+ const currentFile = currentSnapshot[index];
293
+ if (
294
+ reviewedFile.path !== currentFile.path ||
295
+ reviewedFile.exists !== currentFile.exists ||
296
+ reviewedFile.hash !== currentFile.hash
297
+ ) {
298
+ return {
299
+ matches: false,
300
+ detail: "Current changed files differ from the reviewed snapshot.",
301
+ };
302
+ }
303
+ }
304
+
305
+ return { matches: true };
306
+ }
307
+
308
+ const reviewTimestamp = parseReviewTimestamp(reviewStatus.timestamp);
309
+ if (reviewTimestamp === null) {
310
+ return { matches: true };
311
+ }
312
+
313
+ for (const file of currentSnapshot) {
314
+ if (!file.exists) {
315
+ return {
316
+ matches: false,
317
+ detail: "Current changed files cannot be matched to the recorded review.",
318
+ };
319
+ }
320
+ if (file.mtimeMs !== null && file.mtimeMs > reviewTimestamp) {
321
+ return {
322
+ matches: false,
323
+ detail: "Current changed files were modified after the recorded review.",
324
+ };
325
+ }
326
+ }
327
+
328
+ return { matches: true };
329
+ }
330
+
331
+ // ── max-file-size ──
332
+
333
+ registerValidator({
334
+ policyId: "max-file-size",
335
+ async check(ctx: ValidatorContext, options: Record<string, unknown>): Promise<ValidatorResult> {
336
+ const maxBytes = typeof options.max_bytes === "number" ? options.max_bytes : 500_000;
337
+ const files = ctx.changedFiles ?? [];
338
+
339
+ if (files.length === 0) {
340
+ return { pass: true, severity: "info", message: "No changed files to check" };
341
+ }
342
+
343
+ const violations: string[] = [];
344
+ for (const file of files) {
345
+ const abs = join(ctx.projectRoot, file);
346
+ try {
347
+ const stat = statSync(abs);
348
+ if (stat.size > maxBytes) {
349
+ violations.push(`${file} (${formatBytes(stat.size)} > ${formatBytes(maxBytes)})`);
350
+ }
351
+ } catch {
352
+ // File may have been deleted
353
+ }
354
+ }
355
+
356
+ if (violations.length === 0) {
357
+ return { pass: true, severity: "info", message: `All ${files.length} files within size limit (${formatBytes(maxBytes)})` };
358
+ }
359
+
360
+ return {
361
+ pass: false,
362
+ severity: "warning",
363
+ message: `${violations.length} file${violations.length > 1 ? "s" : ""} exceed${violations.length === 1 ? "s" : ""} max size (${formatBytes(maxBytes)})`,
364
+ detail: violations.join("\n"),
365
+ };
366
+ },
367
+ });
368
+
369
+ // ── require-test-coverage ──
370
+
371
+ registerValidator({
372
+ policyId: "require-test-coverage",
373
+ async check(ctx: ValidatorContext, options: Record<string, unknown>): Promise<ValidatorResult> {
374
+ const threshold = typeof options.threshold === "number" ? options.threshold : 80;
375
+ const configuredPaths = Array.isArray(options.coverage_paths)
376
+ ? options.coverage_paths.filter((value): value is string => typeof value === "string" && value.length > 0)
377
+ : [];
378
+ const candidatePaths = typeof options.coverage_path === "string" && options.coverage_path.length > 0
379
+ ? [options.coverage_path]
380
+ : configuredPaths.length > 0
381
+ ? configuredPaths
382
+ : [...DEFAULT_COVERAGE_PATHS];
383
+
384
+ let stats: CoverageStats | null = null;
385
+ try {
386
+ stats = loadCoverageStats(ctx.projectRoot, options);
387
+ } catch {
388
+ const target = candidatePaths[0] ?? "coverage artifact";
389
+ return { pass: false, severity: "warning", message: `Failed to parse coverage report at ${target}` };
390
+ }
391
+
392
+ if (!stats) {
393
+ return {
394
+ pass: false,
395
+ severity: "warning",
396
+ message: `Coverage report not found at ${candidatePaths[0] ?? "known coverage paths"}`,
397
+ detail: `Looked for: ${candidatePaths.join(", ")}. Run your test suite with coverage enabled and retry.`,
398
+ };
399
+ }
400
+
401
+ const pass = stats.overall >= threshold;
402
+ return {
403
+ pass,
404
+ severity: pass ? "info" : "error",
405
+ message: pass
406
+ ? `Coverage ${stats.overall.toFixed(1)}% meets threshold (${threshold}%)`
407
+ : `Coverage ${stats.overall.toFixed(1)}% below threshold (${threshold}%)`,
408
+ detail: [
409
+ `Source: ${stats.sourcePath}`,
410
+ stats.linePct !== null ? `Lines: ${stats.linePct.toFixed(1)}%` : null,
411
+ stats.branchPct !== null ? `Branches: ${stats.branchPct.toFixed(1)}%` : null,
412
+ ].filter(Boolean).join(", "),
413
+ };
414
+ },
415
+ });
416
+
417
+ // ── no-external-api-calls ──
418
+
419
+ registerValidator({
420
+ policyId: "no-external-api-calls",
421
+ async check(ctx: ValidatorContext, options: Record<string, unknown>): Promise<ValidatorResult> {
422
+ const patterns = Array.isArray(options.patterns)
423
+ ? options.patterns.filter((p): p is string => typeof p === "string")
424
+ : ["fetch(", "axios.", "http.get", "http.post", "https.get", "https.request"];
425
+
426
+ const files = ctx.changedFiles ?? [];
427
+ if (files.length === 0) {
428
+ return { pass: true, severity: "info", message: "No changed files to scan" };
429
+ }
430
+
431
+ const hits: string[] = [];
432
+ for (const file of files) {
433
+ const abs = join(ctx.projectRoot, file);
434
+ try {
435
+ const content = readFileSync(abs, "utf8");
436
+ for (const pattern of patterns) {
437
+ if (content.includes(pattern)) {
438
+ hits.push(`${file}: ${pattern}`);
439
+ }
440
+ }
441
+ } catch {
442
+ // File may be deleted or binary
443
+ }
444
+ }
445
+
446
+ if (hits.length === 0) {
447
+ return { pass: true, severity: "info", message: `No external API call patterns found in ${files.length} changed files` };
448
+ }
449
+
450
+ return {
451
+ pass: false,
452
+ severity: "warning",
453
+ message: `Found ${hits.length} external API call pattern${hits.length > 1 ? "s" : ""} in changed files`,
454
+ detail: hits.slice(0, 20).join("\n") + (hits.length > 20 ? `\n... and ${hits.length - 20} more` : ""),
455
+ };
456
+ },
457
+ });
458
+
459
+ // ── require-code-review ──
460
+
461
+ registerValidator({
462
+ policyId: "require-code-review",
463
+ async check(ctx: ValidatorContext, _options: Record<string, unknown>): Promise<ValidatorResult> {
464
+ const workflowStatus = readWorkflowReviewStatus(ctx.contextDir);
465
+ const legacyStatus = readLegacyReviewStatus(ctx.contextDir);
466
+ const reviewStatus = chooseLatestReviewStatus(workflowStatus, legacyStatus);
467
+ const freshness = reviewStatus ? reviewMatchesCurrentChanges(reviewStatus, ctx) : { matches: true };
468
+
469
+ if (reviewStatus?.reviewed && !freshness.matches) {
470
+ const sourceLabel = reviewStatus.source === "legacy-review-status"
471
+ ? "Code review"
472
+ : "Enterprise review";
473
+ return {
474
+ pass: false,
475
+ severity: "warning",
476
+ message: `${sourceLabel} at ${reviewStatus.timestamp} is stale for current changes`,
477
+ detail: `Source: ${reviewStatus.source}. ${freshness.detail ?? "Current changes no longer match the reviewed code."}`,
478
+ };
479
+ }
480
+
481
+ if (reviewStatus?.source === "workflow-state" || reviewStatus?.source === "workflow-artifact") {
482
+ return {
483
+ pass: reviewStatus.reviewed,
484
+ severity: reviewStatus.reviewed ? "info" : "warning",
485
+ message: reviewStatus.reviewed
486
+ ? `Enterprise review completed by ${reviewStatus.reviewer} at ${reviewStatus.timestamp}`
487
+ : `Enterprise review recorded at ${reviewStatus.timestamp} but did not pass`,
488
+ detail: `Source: ${reviewStatus.source}`,
489
+ };
490
+ }
491
+
492
+ if (reviewStatus) {
493
+ return {
494
+ pass: reviewStatus.reviewed,
495
+ severity: reviewStatus.reviewed ? "info" : "warning",
496
+ message: reviewStatus.reviewed
497
+ ? `Code review completed by ${reviewStatus.reviewer} at ${reviewStatus.timestamp}`
498
+ : "Code review recorded but not approved",
499
+ detail: reviewStatus.reviewed ? undefined : `Source: ${reviewStatus.source}`,
500
+ };
501
+ }
502
+
503
+ return {
504
+ pass: false,
505
+ severity: "warning",
506
+ message: "No code review recorded for current changes",
507
+ detail: "Run /review or context.review, or ensure your CI writes .context/review-status.json.",
508
+ };
509
+ },
510
+ });
511
+
512
+ // ── no-secrets-in-code ──
513
+
514
+ // `placeholderAware` patterns capture a user-provided value (password, API
515
+ // key, etc.) where a placeholder ("changeme", "<password>") is an acceptable
516
+ // match. Opaque token patterns (AWS keys, GitHub PATs, private keys) are
517
+ // shape-based — no placeholder filtering, since the shape itself is
518
+ // effectively never a placeholder.
519
+ const SECRET_PATTERNS: Array<{ name: string; re: RegExp; placeholderAware: boolean }> = [
520
+ { name: "AWS access key", re: /\bAKIA[0-9A-Z]{16}\b/, placeholderAware: false },
521
+ { name: "GitHub PAT", re: /\bghp_[A-Za-z0-9]{36}\b/, placeholderAware: false },
522
+ { name: "GitHub OAuth", re: /\bgho_[A-Za-z0-9]{36}\b/, placeholderAware: false },
523
+ { name: "GitHub refresh", re: /\bghr_[A-Za-z0-9]{36}\b/, placeholderAware: false },
524
+ { name: "GitHub app", re: /\bghs_[A-Za-z0-9]{36}\b/, placeholderAware: false },
525
+ { name: "Slack token", re: /\bxox[abpsr]-[A-Za-z0-9-]{10,}\b/, placeholderAware: false },
526
+ { name: "Google API key", re: /\bAIza[0-9A-Za-z\-_]{35}\b/, placeholderAware: false },
527
+ { name: "Stripe key", re: /\b(?:sk|rk)_live_[0-9a-zA-Z]{24,}\b/, placeholderAware: false },
528
+ { name: "Bearer token", re: /\bBearer\s+[A-Za-z0-9\-._~+/]{20,}={0,2}\b/, placeholderAware: false },
529
+ { name: "Private key", re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----/, placeholderAware: false },
530
+ { name: "Hardcoded password", re: /\b(?:password|passwd|pwd)\s*[=:]\s*["'][^"'\s<>{}]{4,}["']/i, placeholderAware: true },
531
+ { name: "Hardcoded API key", re: /\b(?:api[-_]?key|apikey|secret|auth[-_]?token)\s*[=:]\s*["'][^"'\s<>{}]{8,}["']/i, placeholderAware: true },
532
+ { name: "Connection string password", re: /\b(?:password|pwd)\s*=\s*[^;'"<>\s]{4,}(?:;|$)/i, placeholderAware: true },
533
+ ];
534
+
535
+ // Heuristic placeholder values — matched as whole-token, not substring,
536
+ // so a real secret like "Sup3rSecret!" doesn't get filtered by the
537
+ // placeholder "secret". Compared case-insensitively after stripping
538
+ // surrounding quotes, braces, angle brackets, and whitespace.
539
+ const PLACEHOLDER_VALUES = new Set([
540
+ "changeme", "change-me", "changethis", "todo",
541
+ "password", "passwd", "pwd", "password123", "secret", "example",
542
+ "your-password-here", "yourpasswordhere", "your-api-key", "yourapikey",
543
+ "xxx", "xxxx", "xxxxxx", "redacted", "hidden",
544
+ ]);
545
+
546
+ function extractSecretValue(match: string): string {
547
+ // Strip a leading "name=" or "name:" prefix, surrounding quotes/braces/angles.
548
+ const afterAssign = match.replace(/^[^=:]*[=:]\s*/, "");
549
+ return afterAssign
550
+ .trim()
551
+ .replace(/^["'`<{]+|["'`>};]+$/g, "")
552
+ .toLowerCase();
553
+ }
554
+
555
+ const TEXT_FILE_RES = /\.(?:json|ya?ml|toml|ini|env|config|xml|properties|tf|tfvars|sh|ps1|py|js|mjs|cjs|ts|tsx|jsx|cs|vb|java|go|rs|rb|php|sql|md|txt)$/i;
556
+ const BINARY_SNIFF_BYTES = 512;
557
+
558
+ registerValidator({
559
+ policyId: "no-secrets-in-code",
560
+ async check(ctx: ValidatorContext, options: Record<string, unknown>): Promise<ValidatorResult> {
561
+ const files = ctx.changedFiles ?? [];
562
+ if (files.length === 0) {
563
+ return { pass: true, severity: "info", message: "No changed files to scan for secrets" };
564
+ }
565
+
566
+ const allowlist = Array.isArray(options.allowlist_paths)
567
+ ? options.allowlist_paths.filter((p): p is string => typeof p === "string")
568
+ : ["tests/", "test/", "__tests__/", "fixtures/", "mocks/", "docs/"];
569
+
570
+ const maxBytes = typeof options.max_scan_bytes === "number" ? options.max_scan_bytes : 2_000_000;
571
+
572
+ const hits: string[] = [];
573
+ let scanned = 0;
574
+
575
+ for (const file of files) {
576
+ if (allowlist.some((p) => file.includes(p))) continue;
577
+ if (!TEXT_FILE_RES.test(file) && !/(?:^|\/)\.env(?:\.|$)|(?:^|\/)appsettings/i.test(file)) continue;
578
+
579
+ const abs = join(ctx.projectRoot, file);
580
+ try {
581
+ const stat = statSync(abs);
582
+ if (stat.size > maxBytes) continue;
583
+
584
+ const buf = readFileSync(abs);
585
+ // Skip binaries: null byte in sniff region → binary.
586
+ const sniff = buf.subarray(0, Math.min(buf.length, BINARY_SNIFF_BYTES));
587
+ if (sniff.includes(0)) continue;
588
+
589
+ const content = buf.toString("utf8");
590
+ scanned += 1;
591
+
592
+ const lines = content.split("\n");
593
+ for (let i = 0; i < lines.length; i += 1) {
594
+ const line = lines[i];
595
+ for (const { name, re, placeholderAware } of SECRET_PATTERNS) {
596
+ const match = line.match(re);
597
+ if (!match) continue;
598
+ if (placeholderAware) {
599
+ const value = extractSecretValue(match[0]);
600
+ if (PLACEHOLDER_VALUES.has(value)) continue;
601
+ }
602
+ hits.push(`${file}:${i + 1} — ${name}`);
603
+ }
604
+ }
605
+ } catch {
606
+ // Unreadable file — skip
607
+ }
608
+ }
609
+
610
+ if (hits.length === 0) {
611
+ return {
612
+ pass: true,
613
+ severity: "info",
614
+ message: `No secret patterns detected in ${scanned} changed file${scanned === 1 ? "" : "s"}`,
615
+ };
616
+ }
617
+
618
+ return {
619
+ pass: false,
620
+ severity: "error",
621
+ message: `${hits.length} potential secret${hits.length === 1 ? "" : "s"} detected in changed files`,
622
+ detail: hits.slice(0, 30).join("\n") + (hits.length > 30 ? `\n... and ${hits.length - 30} more` : ""),
623
+ };
624
+ },
625
+ });
626
+
627
+ // ── no-env-in-prompts ──
628
+
629
+ // Lines that both reference an env var AND contain prompt-like signals.
630
+ const ENV_ACCESS_RES: RegExp[] = [
631
+ /\bprocess\.env\.[A-Z][A-Z0-9_]*/,
632
+ /\bprocess\.env\[\s*['"][A-Z][A-Z0-9_]*['"]\s*\]/,
633
+ /\bos\.environ\[\s*['"][A-Z][A-Z0-9_]*['"]\s*\]/,
634
+ /\bos\.getenv\(\s*['"][A-Z][A-Z0-9_]*['"]/,
635
+ ];
636
+
637
+ const PROMPT_CONTEXT_RES = /\b(?:prompt|system[_ -]?message|instructions?|role\s*[:=]\s*["'](?:system|user|assistant)["']|you\s+are\s+(?:a|an|the)\b|respond\s+with|answer\s+as|act\s+as)\b/i;
638
+
639
+ const PROMPT_VAR_NAMES_RES = /\b(?:prompt|system[_ -]?prompt|instructions?|messages?|content|completion[_ -]?input)\s*[:=]/i;
640
+
641
+ registerValidator({
642
+ policyId: "no-env-in-prompts",
643
+ async check(ctx: ValidatorContext, options: Record<string, unknown>): Promise<ValidatorResult> {
644
+ const files = ctx.changedFiles ?? [];
645
+ if (files.length === 0) {
646
+ return { pass: true, severity: "info", message: "No changed files to scan" };
647
+ }
648
+
649
+ const allowlist = Array.isArray(options.allowlist_paths)
650
+ ? options.allowlist_paths.filter((p): p is string => typeof p === "string")
651
+ : ["tests/", "test/", "__tests__/", "fixtures/", "docs/"];
652
+
653
+ const hits: string[] = [];
654
+ let scanned = 0;
655
+
656
+ for (const file of files) {
657
+ if (allowlist.some((p) => file.includes(p))) continue;
658
+ if (!/\.(?:ts|tsx|js|mjs|cjs|jsx|py)$/i.test(file)) continue;
659
+
660
+ const abs = join(ctx.projectRoot, file);
661
+ try {
662
+ const content = readFileSync(abs, "utf8");
663
+ scanned += 1;
664
+ const lines = content.split("\n");
665
+
666
+ // Track a small rolling window so env access and prompt signal can live
667
+ // on adjacent lines (template literals spanning lines etc.).
668
+ const WINDOW = 3;
669
+ for (let i = 0; i < lines.length; i += 1) {
670
+ const windowStart = Math.max(0, i - WINDOW);
671
+ const windowEnd = Math.min(lines.length, i + WINDOW + 1);
672
+ const window = lines.slice(windowStart, windowEnd).join("\n");
673
+
674
+ const envMatch = ENV_ACCESS_RES.find((re) => re.test(lines[i]));
675
+ if (!envMatch) continue;
676
+
677
+ const looksLikePrompt = PROMPT_CONTEXT_RES.test(window) || PROMPT_VAR_NAMES_RES.test(window);
678
+ if (!looksLikePrompt) continue;
679
+
680
+ const envName = lines[i].match(envMatch)?.[0] ?? "env var";
681
+ hits.push(`${file}:${i + 1} — ${envName.trim()} used in prompt context`);
682
+ }
683
+ } catch {
684
+ // unreadable — skip
685
+ }
686
+ }
687
+
688
+ if (hits.length === 0) {
689
+ return {
690
+ pass: true,
691
+ severity: "info",
692
+ message: `No env-in-prompt patterns detected in ${scanned} changed file${scanned === 1 ? "" : "s"}`,
693
+ };
694
+ }
695
+
696
+ return {
697
+ pass: false,
698
+ severity: "error",
699
+ message: `${hits.length} env-in-prompt violation${hits.length === 1 ? "" : "s"} detected`,
700
+ detail: hits.slice(0, 20).join("\n") + (hits.length > 20 ? `\n... and ${hits.length - 20} more` : ""),
701
+ };
702
+ },
703
+ });
704
+
705
+ // ── helpers ──
706
+
707
+ function formatBytes(bytes: number): string {
708
+ if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(1)}MB`;
709
+ if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(0)}KB`;
710
+ return `${bytes}B`;
711
+ }