@vibecheckai/cli 3.9.1 → 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 (86) hide show
  1. package/README.md +1 -1
  2. package/bin/runners/context/generators/cursor-enhanced.js +99 -13
  3. package/mcp-server/.eslintrc.json +24 -0
  4. package/mcp-server/README.md +425 -135
  5. package/mcp-server/SPEC.md +583 -0
  6. package/mcp-server/configs/README.md +172 -0
  7. package/mcp-server/configs/claude-desktop-pro.json +31 -0
  8. package/mcp-server/configs/claude-desktop-with-workspace.json +25 -0
  9. package/mcp-server/configs/claude-desktop.json +19 -0
  10. package/mcp-server/configs/cursor-mcp.json +21 -0
  11. package/mcp-server/configs/windsurf-mcp.json +17 -0
  12. package/mcp-server/mcp-config.example.json +9 -0
  13. package/mcp-server/package-lock.json +1631 -0
  14. package/mcp-server/package.json +49 -34
  15. package/mcp-server/src/cli.ts +185 -0
  16. package/mcp-server/src/index.ts +85 -0
  17. package/mcp-server/src/server.ts +1933 -0
  18. package/mcp-server/src/services/cache-service.ts +466 -0
  19. package/mcp-server/src/services/cli-service.ts +345 -0
  20. package/mcp-server/src/services/context-manager.ts +717 -0
  21. package/mcp-server/src/services/firewall-service.ts +662 -0
  22. package/mcp-server/src/services/git-service.ts +671 -0
  23. package/mcp-server/src/services/index.ts +52 -0
  24. package/mcp-server/src/services/prompt-builder-service.ts +1031 -0
  25. package/mcp-server/src/services/session-service.ts +550 -0
  26. package/mcp-server/src/services/tier-service.ts +470 -0
  27. package/mcp-server/src/types.ts +351 -0
  28. package/mcp-server/tsconfig.json +16 -27
  29. package/package.json +6 -6
  30. package/mcp-server/.guardrail/audit/audit.log.jsonl +0 -2
  31. package/mcp-server/.specs/architecture.mdc +0 -90
  32. package/mcp-server/.specs/security.mdc +0 -30
  33. package/mcp-server/HARDENING_SUMMARY.md +0 -299
  34. package/mcp-server/agent-checkpoint.js +0 -364
  35. package/mcp-server/agent-firewall-interceptor.js +0 -500
  36. package/mcp-server/architect-tools.js +0 -707
  37. package/mcp-server/audit-mcp.js +0 -206
  38. package/mcp-server/authority-tools.js +0 -569
  39. package/mcp-server/codebase-architect-tools.js +0 -838
  40. package/mcp-server/conductor/conflict-resolver.js +0 -588
  41. package/mcp-server/conductor/execution-planner.js +0 -544
  42. package/mcp-server/conductor/index.js +0 -377
  43. package/mcp-server/conductor/lock-manager.js +0 -615
  44. package/mcp-server/conductor/request-queue.js +0 -550
  45. package/mcp-server/conductor/session-manager.js +0 -500
  46. package/mcp-server/conductor/tools.js +0 -510
  47. package/mcp-server/consolidated-tools.js +0 -1170
  48. package/mcp-server/deprecation-middleware.js +0 -282
  49. package/mcp-server/handlers/index.ts +0 -15
  50. package/mcp-server/handlers/tool-handler.ts +0 -593
  51. package/mcp-server/hygiene-tools.js +0 -428
  52. package/mcp-server/index-v1.js +0 -698
  53. package/mcp-server/index.js +0 -2940
  54. package/mcp-server/intelligence-tools.js +0 -664
  55. package/mcp-server/intent-drift-tools.js +0 -873
  56. package/mcp-server/intent-firewall-interceptor.js +0 -529
  57. package/mcp-server/lib/api-client.cjs +0 -13
  58. package/mcp-server/lib/cache-wrapper.cjs +0 -383
  59. package/mcp-server/lib/error-envelope.js +0 -138
  60. package/mcp-server/lib/executor.ts +0 -499
  61. package/mcp-server/lib/index.ts +0 -29
  62. package/mcp-server/lib/logger.cjs +0 -30
  63. package/mcp-server/lib/rate-limiter.js +0 -166
  64. package/mcp-server/lib/sandbox.test.ts +0 -519
  65. package/mcp-server/lib/sandbox.ts +0 -395
  66. package/mcp-server/lib/types.ts +0 -267
  67. package/mcp-server/logger.js +0 -173
  68. package/mcp-server/manifest.json +0 -473
  69. package/mcp-server/mdc-generator.js +0 -298
  70. package/mcp-server/premium-tools.js +0 -1275
  71. package/mcp-server/proof-tools.js +0 -571
  72. package/mcp-server/registry/tool-registry.js +0 -586
  73. package/mcp-server/registry/tools.json +0 -619
  74. package/mcp-server/registry.test.ts +0 -340
  75. package/mcp-server/test-mcp.js +0 -108
  76. package/mcp-server/test-tools.js +0 -36
  77. package/mcp-server/tests/tier-gating.test.js +0 -297
  78. package/mcp-server/tier-auth.js +0 -767
  79. package/mcp-server/tools/index.js +0 -72
  80. package/mcp-server/tools-reorganized.ts +0 -244
  81. package/mcp-server/tools-v3.js +0 -1004
  82. package/mcp-server/truth-context.js +0 -622
  83. package/mcp-server/truth-firewall-tools.js +0 -2183
  84. package/mcp-server/vibecheck-2.0-tools.js +0 -761
  85. package/mcp-server/vibecheck-mcp-server-3.2.0.tgz +0 -0
  86. package/mcp-server/vibecheck-tools.js +0 -1075
@@ -1,2183 +0,0 @@
1
- /**
2
- * Truth Firewall MCP Tools (Enhanced)
3
- *
4
- * Goals:
5
- * - Evidence-first outputs (file/line/snippet + stable hash)
6
- * - Policy-aware enforcement (strict/balanced/permissive)
7
- * - Project fingerprint invalidates stale claims
8
- * - Safe filesystem access (no path traversal)
9
- * - Patch verification with allowlist + timeouts
10
- * - Faster evidence search (optional ripgrep)
11
- */
12
-
13
- import fs from "fs/promises";
14
- import fssync from "fs";
15
- import path from "path";
16
- import crypto from "crypto";
17
- import { execSync, spawnSync } from "child_process";
18
- import { createRequire } from "module";
19
-
20
- // Route Truth v1 integration - AST-based route extraction (Fastify + Next.js)
21
- const require = createRequire(import.meta.url);
22
- const { RouteIndex, validateRouteExists: routeTruthValidate, canonicalizePath: routeTruthCanonicalize } = require("../bin/runners/lib/route-truth.js");
23
-
24
- // =============================================================================
25
- // TYPES (JSDoc for IDE support)
26
- // =============================================================================
27
-
28
- /**
29
- * @typedef {"strict" | "balanced" | "permissive"} PolicyName
30
- * @typedef {"true" | "false" | "unknown"} ClaimResultValue
31
- * @typedef {"high" | "medium" | "med" | "low" | number} ConfidenceLabel
32
- *
33
- * @typedef {Object} EvidenceItem
34
- * @property {string} file
35
- * @property {number} [line] - start line
36
- * @property {string} [lines] - "12-18"
37
- * @property {string} [snippet]
38
- * @property {string} [hash]
39
- * @property {number} [confidence]
40
- *
41
- * @typedef {Object} NormalizedEvidence
42
- * @property {string} file
43
- * @property {number} line
44
- * @property {string} [lines]
45
- * @property {string} snippet
46
- * @property {string} hash
47
- * @property {number} confidence
48
- *
49
- * @typedef {Object} EnforcementDecisionAllowed
50
- * @property {true} allowed
51
- * @property {number} confidence
52
- * @property {string} [reason]
53
- *
54
- * @typedef {Object} EnforcementDecisionBlocked
55
- * @property {false} allowed
56
- * @property {number} [confidence]
57
- * @property {string} reason
58
- * @property {string} [suggestion]
59
- * @property {string[]} [blockedActions]
60
- *
61
- * @typedef {EnforcementDecisionAllowed | EnforcementDecisionBlocked} EnforcementDecision
62
- *
63
- * @typedef {Object} ProjectFingerprint
64
- * @property {string} hash
65
- * @property {string} commitHash
66
- * @property {string[]} fileHashes
67
- * @property {string} generatedAt
68
- *
69
- * @typedef {Object} ToolResponseMeta
70
- * @property {true} ok
71
- * @property {string} version
72
- * @property {ProjectFingerprint} projectFingerprint
73
- * @property {string} attribution
74
- * @property {string} generatedAt
75
- */
76
-
77
- // =============================================================================
78
- // TOOL DEFINITIONS
79
- // =============================================================================
80
-
81
- export const TRUTH_FIREWALL_TOOLS = [
82
- {
83
- name: "vibecheck.get_truthpack",
84
- description: `📦 Get the Truth Pack — verified ground truth about this codebase.
85
-
86
- Returns evidence-backed facts about routes, auth, billing, env vars, and schema.
87
- Every claim should point to files/lines with confidence scores.
88
-
89
- Use this BEFORE making assertions about the repo.`,
90
- inputSchema: {
91
- type: "object",
92
- properties: {
93
- scope: {
94
- type: "string",
95
- enum: ["all", "routes", "auth", "billing", "env", "schema", "graph"],
96
- default: "all",
97
- },
98
- refresh: { type: "boolean", default: false },
99
- },
100
- },
101
- },
102
-
103
- {
104
- name: "vibecheck.validate_claim",
105
- description: `🔍 TRUTH FIREWALL — Validate a claim before acting on it.
106
-
107
- Returns: true | false | unknown + enforcement decision
108
- Policy matters:
109
- - strict/balanced: unknown blocks dependent actions
110
- - permissive: unknown allowed (but flagged)
111
-
112
- Examples:
113
- { "claim": "route_exists", "subject": { "method": "POST", "path": "/api/login" } }
114
- { "claim": "env_var_exists", "subject": { "name": "STRIPE_SECRET_KEY" } }`,
115
- inputSchema: {
116
- type: "object",
117
- properties: {
118
- claim: {
119
- type: "string",
120
- enum: [
121
- "route_exists",
122
- "route_guarded",
123
- "env_var_exists",
124
- "env_var_used",
125
- "middleware_applied",
126
- "auth_enforced",
127
- "billing_gate_exists",
128
- "file_exists",
129
- "function_exists",
130
- "model_exists",
131
- "component_exists",
132
- ],
133
- },
134
- subject: {
135
- type: "object",
136
- properties: {
137
- method: { type: "string" },
138
- path: { type: "string" },
139
- name: { type: "string" },
140
- },
141
- },
142
- expected: { type: "boolean", default: true },
143
- policy: {
144
- type: "string",
145
- enum: ["strict", "balanced", "permissive"],
146
- default: "strict",
147
- },
148
- refresh: {
149
- type: "boolean",
150
- default: false,
151
- description: "Force refresh of underlying truthpack/contracts before verifying",
152
- },
153
- },
154
- required: ["claim", "subject"],
155
- },
156
- },
157
-
158
- {
159
- name: "vibecheck.compile_context",
160
- description: `🎯 Get minimal sufficient context for a task (not a token bomb).
161
-
162
- Returns relevant nodes, edges, evidence, and invariants.`,
163
- inputSchema: {
164
- type: "object",
165
- properties: {
166
- task: { type: "string" },
167
- files: { type: "array", items: { type: "string" } },
168
- policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "balanced" },
169
- maxItems: { type: "number", default: 50 },
170
- },
171
- required: ["task"],
172
- },
173
- },
174
-
175
- {
176
- name: "vibecheck.search_evidence",
177
- description: `📎 Search for evidence in the codebase.
178
-
179
- Supports text mode or regex mode. Returns file/line/snippet + hashes.`,
180
- inputSchema: {
181
- type: "object",
182
- properties: {
183
- query: { type: "string" },
184
- mode: { type: "string", enum: ["text", "regex"], default: "text" },
185
- type: { type: "string", enum: ["route", "handler", "middleware", "component", "env_var", "model", "any"], default: "any" },
186
- limit: { type: "number", default: 10 },
187
- caseSensitive: { type: "boolean", default: false },
188
- includeTests: { type: "boolean", default: false },
189
- },
190
- required: ["query"],
191
- },
192
- },
193
-
194
- {
195
- name: "vibecheck.find_counterexamples",
196
- description: `🔴 FALSIFICATION — find counterexamples that disprove a claim.
197
-
198
- Use for auth, billing, security.`,
199
- inputSchema: {
200
- type: "object",
201
- properties: {
202
- claim: { type: "string", enum: ["auth_enforced", "billing_gate_exists", "route_guarded", "no_bypass"] },
203
- subject: {
204
- type: "object",
205
- properties: { path: { type: "string" }, name: { type: "string" } },
206
- },
207
- policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "strict" },
208
- },
209
- required: ["claim", "subject"],
210
- },
211
- },
212
-
213
- {
214
- name: "vibecheck.propose_patch",
215
- description: `📝 Propose a proof-carrying patch.
216
-
217
- Patches must attach:
218
- - findings fixed
219
- - claim dependencies (validated)
220
- - verification commands
221
-
222
- Patches without proof are NOT eligible for auto-apply.`,
223
- inputSchema: {
224
- type: "object",
225
- properties: {
226
- diff: { type: "string" },
227
- fixes: { type: "array", items: { type: "string" } },
228
- claims: { type: "array", items: { type: "string" } },
229
- verification: { type: "array", items: { type: "string" } },
230
- policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "strict" },
231
- save: { type: "boolean", default: true },
232
- },
233
- required: ["diff", "fixes"],
234
- },
235
- },
236
-
237
- {
238
- name: "vibecheck.verify_patch",
239
- description: `✅ Verify a patch meets requirements.
240
-
241
- Runs verification commands with allowlist + timeouts.
242
- Returns pass/fail and command output (truncated).`,
243
- inputSchema: {
244
- type: "object",
245
- properties: {
246
- patchId: { type: "string", description: "Patch ID from propose_patch (optional)" },
247
- diff: { type: "string", description: "If no patchId, provide diff text (not auto-applied)" },
248
- commands: { type: "array", items: { type: "string" }, description: "Commands to run" },
249
- policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "strict" },
250
- timeoutMs: { type: "number", default: 120000 },
251
- },
252
- required: ["commands"],
253
- },
254
- },
255
-
256
- {
257
- name: "vibecheck.check_invariants",
258
- description: `⚖️ Check invariants (ship-killer rules).
259
-
260
- Examples:
261
- - No paid feature without server enforcement
262
- - No success UI without confirmed success
263
- - No silent catch in auth/billing`,
264
- inputSchema: {
265
- type: "object",
266
- properties: {
267
- category: { type: "string", enum: ["all", "auth", "billing", "security", "ux", "api"], default: "all" },
268
- policy: { type: "string", enum: ["strict", "balanced", "permissive"], default: "strict" },
269
- },
270
- },
271
- },
272
-
273
- {
274
- name: "vibecheck.add_assumption",
275
- description: `⚠️ Log an assumption (budget enforced).`,
276
- inputSchema: {
277
- type: "object",
278
- properties: {
279
- description: { type: "string" },
280
- reason: { type: "string" },
281
- verificationSteps: { type: "array", items: { type: "string" } },
282
- },
283
- required: ["description", "verificationSteps"],
284
- },
285
- },
286
-
287
- {
288
- name: "vibecheck.validate_plan",
289
- description: `🛡️ Validate an AI plan against contracts (routes/env/auth/external).
290
-
291
- If validation fails: do NOT proceed.`,
292
- inputSchema: {
293
- type: "object",
294
- properties: {
295
- plan: { type: "string" },
296
- strict: { type: "boolean", default: false },
297
- },
298
- required: ["plan"],
299
- },
300
- },
301
-
302
- {
303
- name: "vibecheck.check_drift",
304
- description: `📊 Detect contract drift (routes/env/auth/external).`,
305
- inputSchema: {
306
- type: "object",
307
- properties: {
308
- category: { type: "string", enum: ["all", "routes", "env", "auth", "external"], default: "all" },
309
- },
310
- },
311
- },
312
-
313
- {
314
- name: "vibecheck.get_contracts",
315
- description: `📜 Get contracts from .vibecheck/contracts/`,
316
- inputSchema: {
317
- type: "object",
318
- properties: {
319
- type: { type: "string", enum: ["all", "routes", "env", "auth", "external"], default: "all" },
320
- },
321
- },
322
- },
323
- ];
324
-
325
- // =============================================================================
326
- // TOOL HANDLER
327
- // =============================================================================
328
-
329
- export async function handleTruthFirewallTool(toolName, args, projectPath = process.cwd()) {
330
- switch (toolName) {
331
- case "vibecheck.get_truthpack":
332
- return wrapMcpResponse(await getTruthPack(projectPath, args), projectPath);
333
-
334
- case "vibecheck.validate_claim":
335
- return wrapMcpResponse(await validateClaim(projectPath, args), projectPath);
336
-
337
- case "vibecheck.compile_context":
338
- return wrapMcpResponse(await compileContext(projectPath, args), projectPath);
339
-
340
- case "vibecheck.search_evidence":
341
- return wrapMcpResponse(await searchEvidence(projectPath, args), projectPath);
342
-
343
- case "vibecheck.find_counterexamples":
344
- return wrapMcpResponse(await findCounterexamples(projectPath, args), projectPath);
345
-
346
- case "vibecheck.propose_patch":
347
- return wrapMcpResponse(await proposePatch(projectPath, args), projectPath);
348
-
349
- case "vibecheck.verify_patch":
350
- return wrapMcpResponse(await verifyPatch(projectPath, args), projectPath);
351
-
352
- case "vibecheck.check_invariants":
353
- return wrapMcpResponse(await checkInvariants(projectPath, args), projectPath);
354
-
355
- case "vibecheck.add_assumption":
356
- return wrapMcpResponse(await addAssumption(projectPath, args), projectPath);
357
-
358
- case "vibecheck.validate_plan":
359
- return wrapMcpResponse(await getPlanValidationResult(projectPath, args), projectPath);
360
-
361
- case "vibecheck.check_drift":
362
- return wrapMcpResponse(await checkDriftTool(projectPath, args), projectPath);
363
-
364
- case "vibecheck.get_contracts":
365
- return wrapMcpResponse(await getContractsTool(projectPath, args), projectPath);
366
-
367
- default:
368
- return wrapMcpResponse({ error: `Unknown tool: ${toolName}` }, projectPath);
369
- }
370
- }
371
-
372
- // =============================================================================
373
- // STATE (Per-project aware)
374
- // =============================================================================
375
-
376
- /**
377
- * @typedef {Object} CachedClaim
378
- * @property {string} projectHash
379
- * @property {number} timestamp
380
- * @property {*} result
381
- */
382
-
383
- const state = {
384
- truthPackByProject: new Map(),
385
- assumptionsByProject: new Map(),
386
- verifiedClaims: new Map(),
387
- lastValidationByProject: new Map(),
388
- routeIndexByProject: new Map(), // Route Truth v1 index cache
389
- maxAssumptions: 2,
390
- };
391
-
392
- const MAX_EVIDENCE_SNIPPET = 240;
393
- const MAX_CMD_OUTPUT = 12_000;
394
-
395
- // =============================================================================
396
- // POLICY CONFIG
397
- // =============================================================================
398
-
399
- const POLICY_CONFIG = {
400
- strict: {
401
- minConfidence: 0.8,
402
- allowUnknown: false,
403
- requireValidation: true,
404
- blockOnDrift: true,
405
- validationTTL: 5 * 60 * 1000,
406
- },
407
- balanced: {
408
- minConfidence: 0.6,
409
- allowUnknown: false,
410
- requireValidation: true,
411
- blockOnDrift: false,
412
- validationTTL: 10 * 60 * 1000,
413
- },
414
- permissive: {
415
- minConfidence: 0.4,
416
- allowUnknown: true,
417
- requireValidation: false,
418
- blockOnDrift: false,
419
- validationTTL: 30 * 60 * 1000,
420
- },
421
- };
422
-
423
- export function getPolicyConfig(policy = "strict") {
424
- return POLICY_CONFIG[policy] || POLICY_CONFIG.strict;
425
- }
426
-
427
- function confidenceToScore(confidence) {
428
- if (typeof confidence === "number") return confidence;
429
- switch (confidence) {
430
- case "high": return 0.9;
431
- case "medium":
432
- case "med": return 0.7;
433
- case "low": return 0.5;
434
- default: return 0.6;
435
- }
436
- }
437
-
438
- // =============================================================================
439
- // SAFETY: PROJECT-ROOT SANDBOX
440
- // =============================================================================
441
-
442
- function safeProjectJoin(projectPath, rel) {
443
- const root = path.resolve(projectPath);
444
- const abs = path.resolve(root, rel);
445
- if (!abs.startsWith(root + path.sep) && abs !== root) {
446
- throw new Error(`Refusing to access path outside project root: ${rel}`);
447
- }
448
- return abs;
449
- }
450
-
451
- async function safeReadFile(projectPath, rel) {
452
- const abs = safeProjectJoin(projectPath, rel);
453
- return await fs.readFile(abs, "utf8");
454
- }
455
-
456
- // =============================================================================
457
- // EVIDENCE NORMALIZATION
458
- // =============================================================================
459
-
460
- function sha16(s) {
461
- return crypto.createHash("sha256").update(s).digest("hex").slice(0, 16);
462
- }
463
-
464
- function parseLineRange(lines) {
465
- if (typeof lines === "number") return { start: Math.max(1, lines), end: Math.max(1, lines) };
466
- if (!lines) return { start: 1, end: 1 };
467
- const s = String(lines).trim();
468
- const m = s.match(/^(\d+)(?:\s*-\s*(\d+))?$/);
469
- if (!m) return { start: 1, end: 1 };
470
- const a = Number(m[1]);
471
- const b = m[2] ? Number(m[2]) : a;
472
- return { start: Math.max(1, a), end: Math.max(1, b) };
473
- }
474
-
475
- async function readSnippet(projectPath, file, lines) {
476
- if (!file) return "";
477
- try {
478
- const content = await safeReadFile(projectPath, file);
479
- const arr = content.split(/\r?\n/);
480
- const { start, end } = parseLineRange(lines);
481
- const s = Math.max(1, Math.min(arr.length, start));
482
- const e = Math.max(s, Math.min(arr.length, end));
483
- const snippet = arr.slice(s - 1, e).join("\n");
484
- return snippet.slice(0, MAX_EVIDENCE_SNIPPET);
485
- } catch {
486
- return "";
487
- }
488
- }
489
-
490
- async function normalizeEvidence(projectPath, evidence, fallback, confidence) {
491
- const raw = Array.isArray(evidence) ? evidence : evidence ? [evidence] : [];
492
- const out = [];
493
-
494
- for (const item of raw) {
495
- const file = item?.file || fallback?.file || "";
496
- const rangeStr = item?.lines ?? item?.line ?? fallback?.line ?? 1;
497
- const { start, end } = parseLineRange(rangeStr);
498
- const snippet = (item?.snippet || (await readSnippet(projectPath, file, `${start}-${end}`)) || "").slice(0, MAX_EVIDENCE_SNIPPET);
499
- const hash = item?.hash || sha16(`${file}:${start}:${snippet}`);
500
-
501
- out.push({
502
- file,
503
- line: start,
504
- lines: end !== start ? `${start}-${end}` : `${start}`,
505
- snippet,
506
- hash,
507
- confidence: item?.confidence ?? confidenceToScore(confidence),
508
- });
509
- }
510
-
511
- if (out.length === 0 && fallback?.file) {
512
- const file = fallback.file;
513
- const line = fallback.line || 1;
514
- const snippet = await readSnippet(projectPath, file, line);
515
- out.push({
516
- file,
517
- line,
518
- lines: `${line}`,
519
- snippet,
520
- hash: sha16(`${file}:${line}:${snippet}`),
521
- confidence: confidenceToScore(confidence),
522
- });
523
- }
524
-
525
- return out;
526
- }
527
-
528
- // =============================================================================
529
- // POLICY ENFORCEMENT (FIXED)
530
- // =============================================================================
531
-
532
- /**
533
- * Correct confidence derivation (your original had precedence issues).
534
- */
535
- export function enforceClaimResult(result, policy = "strict") {
536
- const config = getPolicyConfig(policy);
537
-
538
- const derived =
539
- result?.confidence !== undefined
540
- ? confidenceToScore(result.confidence)
541
- : (result?.result === "true" ? 0.9 : result?.result === "false" ? 0.9 : 0.3);
542
-
543
- if (result.result === "unknown" && !config.allowUnknown) {
544
- return {
545
- allowed: false,
546
- confidence: derived,
547
- reason: `Unknown claims are not allowed in ${policy} mode`,
548
- suggestion: "Use search_evidence / get_truthpack / refresh=true to gather proof.",
549
- blockedActions: ["fix", "autopilot_apply", "propose_patch"],
550
- };
551
- }
552
-
553
- if (derived < config.minConfidence) {
554
- return {
555
- allowed: false,
556
- confidence: derived,
557
- reason: `Confidence ${(derived * 100).toFixed(0)}% below ${policy} threshold ${(config.minConfidence * 100).toFixed(0)}%`,
558
- suggestion: "Find more evidence or lower strictness (permissive policy).",
559
- };
560
- }
561
-
562
- if (result.result === "false") {
563
- return {
564
- allowed: false,
565
- confidence: derived,
566
- reason: "Claim is disproven. Do not proceed with dependent actions.",
567
- };
568
- }
569
-
570
- return { allowed: true, confidence: derived };
571
- }
572
-
573
- // =============================================================================
574
- // CLAIM VALIDATION WITH RACE CONDITION PROTECTION
575
- //
576
- // SECURITY FIX: Previous implementation had a TOCTOU race condition:
577
- // 1. Thread A: hasRecentClaimValidation() returns true
578
- // 2. Thread B: invalidates the claim (file change, etc.)
579
- // 3. Thread A: proceeds with stale claim → invalid state
580
- //
581
- // New implementation uses atomic check-and-consume pattern with per-project locks.
582
- // =============================================================================
583
-
584
- /**
585
- * Per-project validation locks to prevent concurrent operations
586
- * from using the same validation state.
587
- */
588
- const validationLocks = new Map(); // Map<projectPath, { locked: boolean, queue: Promise }>
589
-
590
- /**
591
- * Acquire a validation lock for a project (serializes validation checks).
592
- */
593
- function acquireValidationLock(projectPath) {
594
- let lockState = validationLocks.get(projectPath);
595
- if (!lockState) {
596
- lockState = { locked: false, queue: Promise.resolve() };
597
- validationLocks.set(projectPath, lockState);
598
- }
599
-
600
- const acquirePromise = lockState.queue.then(() => {
601
- lockState.locked = true;
602
- return () => {
603
- lockState.locked = false;
604
- };
605
- });
606
-
607
- lockState.queue = acquirePromise.catch(() => {});
608
- return acquirePromise;
609
- }
610
-
611
- /**
612
- * Check claim validation freshness (basic check, no lock).
613
- * Use checkAndConsumeClaimValidation for atomic operations.
614
- */
615
- export function hasRecentClaimValidation(projectPath, policy = "strict") {
616
- const last = state.lastValidationByProject.get(projectPath);
617
- if (typeof last !== "number") return false;
618
- const ttl = getPolicyConfig(policy).validationTTL;
619
- return Date.now() - last <= ttl;
620
- }
621
-
622
- /**
623
- * Atomic check-and-consume claim validation.
624
- *
625
- * SECURITY: Use this for operations that depend on claim validation.
626
- * It ensures no other operation can use the same validation state concurrently.
627
- *
628
- * @param {string} projectPath - Project path
629
- * @param {string} policy - Policy name (strict/balanced/permissive)
630
- * @param {string} operationId - Unique ID for this operation (for audit)
631
- * @returns {Promise<{ valid: boolean, consumedAt?: number, reason?: string }>}
632
- */
633
- export async function checkAndConsumeClaimValidation(projectPath, policy = "strict", operationId = null) {
634
- const release = await acquireValidationLock(projectPath);
635
-
636
- try {
637
- const last = state.lastValidationByProject.get(projectPath);
638
- const now = Date.now();
639
-
640
- if (typeof last !== "number") {
641
- return {
642
- valid: false,
643
- reason: "No claim validation found for this project"
644
- };
645
- }
646
-
647
- const ttl = getPolicyConfig(policy).validationTTL;
648
- const age = now - last;
649
-
650
- if (age > ttl) {
651
- return {
652
- valid: false,
653
- reason: `Claim validation expired (age: ${Math.round(age / 1000)}s, TTL: ${Math.round(ttl / 1000)}s)`
654
- };
655
- }
656
-
657
- // Mark this validation as consumed by updating the timestamp
658
- // This prevents replay/reuse of the same validation
659
- state.lastValidationByProject.set(projectPath, now);
660
-
661
- return {
662
- valid: true,
663
- consumedAt: now,
664
- operationId,
665
- };
666
-
667
- } finally {
668
- release();
669
- }
670
- }
671
-
672
- // =============================================================================
673
- // FINGERPRINT + WRAPPER
674
- // =============================================================================
675
-
676
- function getCommitHash(projectPath) {
677
- try {
678
- return execSync("git rev-parse HEAD", { cwd: projectPath, encoding: "utf8" }).trim();
679
- } catch {
680
- return "unknown";
681
- }
682
- }
683
-
684
- export function getProjectFingerprint(projectPath) {
685
- const commitHash = getCommitHash(projectPath);
686
- const keyFiles = [
687
- "package.json",
688
- "pnpm-lock.yaml",
689
- "package-lock.json",
690
- "yarn.lock",
691
- "prisma/schema.prisma",
692
- "next.config.js",
693
- "next.config.ts",
694
- ".vibecheck/contracts/routes.json",
695
- ".vibecheck/contracts/env.json",
696
- ".vibecheck/contracts/auth.json",
697
- ".vibecheck/contracts/external.json",
698
- ];
699
-
700
- const fileHashes = [];
701
- for (const rel of keyFiles) {
702
- try {
703
- const abs = safeProjectJoin(projectPath, rel);
704
- if (!fssync.existsSync(abs)) continue;
705
- const content = fssync.readFileSync(abs, "utf8");
706
- fileHashes.push(`${rel}:${sha16(content)}`);
707
- } catch { /* ignore */ }
708
- }
709
-
710
- const material = [commitHash, ...fileHashes].join("|");
711
- return {
712
- hash: sha16(material),
713
- commitHash,
714
- fileHashes,
715
- generatedAt: new Date().toISOString(),
716
- };
717
- }
718
-
719
- const CONTEXT_ATTRIBUTION = "🧠 Context enhanced by vibecheck";
720
-
721
- export function getContextAttribution() {
722
- return CONTEXT_ATTRIBUTION;
723
- }
724
-
725
- export function wrapMcpResponse(data, projectPath) {
726
- return {
727
- ok: true,
728
- version: "2.1.0",
729
- projectFingerprint: getProjectFingerprint(projectPath),
730
- attribution: CONTEXT_ATTRIBUTION,
731
- generatedAt: new Date().toISOString(),
732
- data,
733
- };
734
- }
735
-
736
- // =============================================================================
737
- // IMPLEMENTATION: TRUTHPACK
738
- // =============================================================================
739
-
740
- async function getTruthPack(projectPath, args) {
741
- const scope = args?.scope || "all";
742
- const refresh = Boolean(args?.refresh);
743
-
744
- if (refresh) {
745
- // Also clear route index on refresh
746
- state.routeIndexByProject.delete(projectPath);
747
- }
748
-
749
- if (!refresh && state.truthPackByProject.has(projectPath)) {
750
- return filterTruthPack(state.truthPackByProject.get(projectPath), scope);
751
- }
752
-
753
- const truthPack = {
754
- version: "2.0.0", // v2 with Route Truth v1 integration
755
- generatedAt: new Date().toISOString(),
756
- projectPath,
757
- commitHash: getCommitHash(projectPath),
758
- sections: {},
759
- confidence: 0,
760
- _attribution: CONTEXT_ATTRIBUTION,
761
- };
762
-
763
- if (scope === "all" || scope === "routes") truthPack.sections.routes = await extractRoutes(projectPath, refresh);
764
- if (scope === "all" || scope === "auth") truthPack.sections.auth = await extractAuth(projectPath);
765
- if (scope === "all" || scope === "billing") truthPack.sections.billing = await extractBilling(projectPath);
766
- if (scope === "all" || scope === "env") truthPack.sections.env = await extractEnv(projectPath);
767
- if (scope === "all" || scope === "schema") truthPack.sections.schema = await extractSchema(projectPath);
768
- if (scope === "all" || scope === "graph") truthPack.sections.graph = await extractGraph(projectPath);
769
-
770
- const sections = Object.values(truthPack.sections);
771
- truthPack.confidence = sections.length
772
- ? sections.reduce((sum, s) => sum + (s?.confidence || 0), 0) / sections.length
773
- : 0.4;
774
-
775
- state.truthPackByProject.set(projectPath, truthPack);
776
- return truthPack;
777
- }
778
-
779
- function filterTruthPack(pack, scope) {
780
- if (scope === "all") return pack;
781
- return { ...pack, sections: { [scope]: pack.sections?.[scope] } };
782
- }
783
-
784
- // =============================================================================
785
- // IMPLEMENTATION: validate_claim (policy-aware + fingerprint cache)
786
- // =============================================================================
787
-
788
- async function validateClaim(projectPath, args) {
789
- const { claim, subject, expected = true, policy = "strict", refresh = false } = args || {};
790
- const pol = policy || "strict";
791
-
792
- if (refresh) {
793
- // force refresh truthpack + route index for this project
794
- state.truthPackByProject.delete(projectPath);
795
- state.routeIndexByProject.delete(projectPath);
796
- }
797
-
798
- const fingerprint = getProjectFingerprint(projectPath);
799
- const claimKey = { claim, subject, expected };
800
- const claimId = `claim_${sha16(JSON.stringify(claimKey))}`;
801
-
802
- // policy TTL cache + fingerprint invalidation
803
- const cached = state.verifiedClaims.get(claimId);
804
- if (cached && cached.projectHash === fingerprint.hash) {
805
- const ttl = getPolicyConfig(pol).validationTTL;
806
- if (Date.now() - cached.timestamp <= ttl) {
807
- return { claimId, ...cached.result, cached: true };
808
- }
809
- }
810
-
811
- let result = { result: "unknown", confidence: "low", evidence: [], nextSteps: [] };
812
-
813
- try {
814
- switch (claim) {
815
- case "route_exists":
816
- result = await verifyRouteExists(projectPath, subject, refresh);
817
- break;
818
-
819
- case "file_exists":
820
- result = await verifyFileExists(projectPath, subject);
821
- break;
822
-
823
- case "env_var_exists":
824
- case "env_var_used":
825
- result = await verifyEnvVar(projectPath, subject, claim);
826
- break;
827
-
828
- case "auth_enforced":
829
- case "route_guarded":
830
- result = await verifyRouteGuarded(projectPath, subject);
831
- break;
832
-
833
- case "function_exists":
834
- case "component_exists":
835
- case "model_exists":
836
- result = await verifyEntityExists(projectPath, subject, claim);
837
- break;
838
-
839
- default:
840
- result = {
841
- result: "unknown",
842
- confidence: "low",
843
- evidence: [],
844
- nextSteps: [`Claim type "${claim}" not implemented. Use search_evidence.`],
845
- };
846
- break;
847
- }
848
- } catch (error) {
849
- result = {
850
- result: "unknown",
851
- confidence: "low",
852
- evidence: [],
853
- nextSteps: [`Verification error: ${error?.message || String(error)}`],
854
- };
855
- }
856
-
857
- // normalize evidence consistently
858
- const normalized = await normalizeEvidence(
859
- projectPath,
860
- result.evidence,
861
- { file: subject?.path || subject?.name, line: 1 },
862
- result.confidence
863
- );
864
-
865
- // enforcement (policy-driven)
866
- const enforcement = enforceClaimResult({ result: result.result, confidence: result.confidence }, pol);
867
-
868
- // guidance
869
- const nextSteps = Array.isArray(result.nextSteps) ? result.nextSteps : [];
870
- if (result.result === "unknown") {
871
- nextSteps.push("call vibecheck.search_evidence for proof", "call vibecheck.get_truthpack refresh=true");
872
- }
873
- if (result.result === "false" && expected === true) {
874
- nextSteps.push("Claim expected true but evaluated false: check path/method/name canonicalization.");
875
- }
876
-
877
- const finalResult = {
878
- claimId,
879
- claim,
880
- subject,
881
- expected,
882
- result: result.result,
883
- confidence: result.confidence,
884
- evidence: normalized,
885
- enforcement,
886
- nextSteps,
887
- timestamp: new Date().toISOString(),
888
- _attribution: CONTEXT_ATTRIBUTION,
889
- };
890
-
891
- state.verifiedClaims.set(claimId, { projectHash: fingerprint.hash, timestamp: Date.now(), result: finalResult });
892
- state.lastValidationByProject.set(projectPath, Date.now());
893
-
894
- return finalResult;
895
- }
896
-
897
- // =============================================================================
898
- // IMPLEMENTATION: compile_context
899
- // =============================================================================
900
-
901
- async function compileContext(projectPath, args) {
902
- const { task, files = [], policy = "balanced", maxItems = 50 } = args || {};
903
- const pol = policy || "balanced";
904
-
905
- const domains = detectDomains(task);
906
- const keywords = extractKeywords(task);
907
- const truthPack = await getTruthPack(projectPath, { scope: "all", refresh: false });
908
-
909
- const routesAll = truthPack.sections?.routes?.routes || [];
910
- const relevantRoutes = routesAll.filter((r) => {
911
- const hay = `${r.path || ""} ${r.file || ""}`.toLowerCase();
912
- return keywords.some((k) => hay.includes(k));
913
- });
914
-
915
- const context = {
916
- routes: relevantRoutes.slice(0, Math.max(0, maxItems)),
917
- auth: domains.includes("auth") ? truthPack.sections?.auth : null,
918
- billing: domains.includes("billing") ? truthPack.sections?.billing : null,
919
- env: domains.includes("env") ? truthPack.sections?.env : null,
920
- focusFiles: Array.isArray(files) ? files : [],
921
- };
922
-
923
- const invariants = getInvariantsForDomains(domains);
924
- const tokenCount = estimateTokens(context);
925
-
926
- return {
927
- task,
928
- policy: pol,
929
- domains,
930
- context,
931
- invariants,
932
- tokenCount,
933
- warnings: generateContextWarnings(domains, pol, relevantRoutes.length),
934
- _attribution: CONTEXT_ATTRIBUTION,
935
- };
936
- }
937
-
938
- // =============================================================================
939
- // IMPLEMENTATION: search_evidence (rg accel + safe scan)
940
- // =============================================================================
941
-
942
- function escapeRegex(s) {
943
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
944
- }
945
-
946
- function isTestFilePath(rel) {
947
- return /(^|\/)(__tests__|test|tests|spec)\//i.test(rel) || /\.(test|spec)\.(ts|tsx|js|jsx)$/i.test(rel);
948
- }
949
-
950
- async function searchEvidence(projectPath, args) {
951
- const {
952
- query,
953
- mode = "text",
954
- type = "any",
955
- limit = 10,
956
- caseSensitive = false,
957
- includeTests = false,
958
- } = args || {};
959
-
960
- const q = String(query || "").trim();
961
- if (!q) return { query: q, count: 0, results: [], _attribution: CONTEXT_ATTRIBUTION };
962
-
963
- // Try ripgrep for speed (optional)
964
- const rgResults = tryRipgrep(projectPath, q, { mode, caseSensitive, limit, includeTests });
965
- if (rgResults) {
966
- return { query: q, count: rgResults.length, results: rgResults, engine: "ripgrep", _attribution: CONTEXT_ATTRIBUTION };
967
- }
968
-
969
- const files = await findSourceFiles(projectPath, { includeTests });
970
- const flags = caseSensitive ? "g" : "gi";
971
- const re = new RegExp(mode === "regex" ? q : escapeRegex(q), flags);
972
-
973
- const results = [];
974
- for (const fileAbs of files) {
975
- const relPath = path.relative(projectPath, fileAbs).replace(/\\/g, "/");
976
- if (!includeTests && isTestFilePath(relPath)) continue;
977
-
978
- try {
979
- const content = await fs.readFile(fileAbs, "utf8");
980
- const lines = content.split(/\r?\n/);
981
-
982
- for (let i = 0; i < lines.length; i++) {
983
- if (re.test(lines[i])) {
984
- const snippet = lines.slice(Math.max(0, i - 2), Math.min(lines.length, i + 3)).join("\n").slice(0, 320);
985
- results.push({
986
- file: relPath,
987
- line: i + 1,
988
- lines: `${i + 1}`,
989
- snippet,
990
- hash: sha16(`${relPath}:${i + 1}:${lines[i]}`),
991
- confidence: 0.6,
992
- });
993
- if (results.length >= limit) break;
994
- }
995
- re.lastIndex = 0;
996
- }
997
-
998
- if (results.length >= limit) break;
999
- } catch { /* ignore */ }
1000
- }
1001
-
1002
- return { query: q, count: results.length, results, engine: "scan", _attribution: CONTEXT_ATTRIBUTION };
1003
- }
1004
-
1005
- function tryRipgrep(projectPath, query, opts) {
1006
- try {
1007
- const rgArgs = ["-n", "--hidden", "--no-heading", "--color", "never"];
1008
- rgArgs.push("--glob", "!**/node_modules/**");
1009
- rgArgs.push("--glob", "!**/.next/**");
1010
- rgArgs.push("--glob", "!**/dist/**");
1011
- rgArgs.push("--glob", "!**/build/**");
1012
- rgArgs.push("--glob", "!**/coverage/**");
1013
- if (!opts.includeTests) {
1014
- rgArgs.push("--glob", "!**/__tests__/**");
1015
- rgArgs.push("--glob", "!**/tests/**");
1016
- rgArgs.push("--glob", "!**/*.test.*");
1017
- rgArgs.push("--glob", "!**/*.spec.*");
1018
- }
1019
- if (!opts.caseSensitive) rgArgs.push("-i");
1020
- if (opts.mode === "text") rgArgs.push("-F"); // fixed string
1021
- rgArgs.push("--max-count", String(Math.max(1, opts.limit)));
1022
- rgArgs.push(query);
1023
- rgArgs.push(".");
1024
-
1025
- const out = spawnSync("rg", rgArgs, { cwd: projectPath, encoding: "utf8" });
1026
- if (out.error || out.status !== 0) return null;
1027
-
1028
- const lines = String(out.stdout || "").split(/\r?\n/).filter(Boolean);
1029
- const results = lines.slice(0, opts.limit).map((l) => {
1030
- // format: file:line:match
1031
- const m = l.match(/^(.+?):(\d+):(.*)$/);
1032
- if (!m) return null;
1033
- const file = m[1].replace(/\\/g, "/");
1034
- const lineNum = Number(m[2]);
1035
- const text = m[3] || "";
1036
- return {
1037
- file,
1038
- line: lineNum,
1039
- lines: `${lineNum}`,
1040
- snippet: text.slice(0, 320),
1041
- hash: sha16(`${file}:${lineNum}:${text}`),
1042
- confidence: 0.65,
1043
- };
1044
- }).filter(Boolean);
1045
-
1046
- return results;
1047
- } catch {
1048
- return null;
1049
- }
1050
- }
1051
-
1052
- // =============================================================================
1053
- // IMPLEMENTATION: find_counterexamples
1054
- // =============================================================================
1055
-
1056
- async function findCounterexamples(projectPath, args) {
1057
- const { claim, subject, policy = "strict" } = args || {};
1058
- const pol = policy || "strict";
1059
-
1060
- const counterexamples = [];
1061
-
1062
- if (claim === "auth_enforced" || claim === "route_guarded") {
1063
- // Look for client-only guards (classic bypass)
1064
- const guardEvidence = await searchEvidence(projectPath, { query: "useEffect|client|localStorage|sessionStorage", mode: "regex", limit: 20 });
1065
- const middlewareEvidence = await searchEvidence(projectPath, { query: "middleware", mode: "text", limit: 10 });
1066
-
1067
- if (guardEvidence.count > 0 && middlewareEvidence.count === 0) {
1068
- counterexamples.push({
1069
- type: "bypass_possible",
1070
- severity: "ship_killer",
1071
- description: "Auth appears client-only (no middleware/server guard evidence found).",
1072
- evidence: guardEvidence.results.slice(0, 3),
1073
- });
1074
- }
1075
- }
1076
-
1077
- if (claim === "billing_gate_exists") {
1078
- const billingEvidence = await searchEvidence(projectPath, { query: "isPro|tier|plan|subscription|stripe", mode: "regex", limit: 20 });
1079
- for (const ev of billingEvidence.results) {
1080
- if (/localStorage|sessionStorage|client/i.test(ev.snippet)) {
1081
- counterexamples.push({
1082
- type: "bypass_possible",
1083
- severity: "ship_killer",
1084
- description: "Billing gate likely client-side (bypassable).",
1085
- evidence: ev,
1086
- });
1087
- }
1088
- }
1089
- }
1090
-
1091
- return {
1092
- claim,
1093
- subject,
1094
- policy: pol,
1095
- counterexamples,
1096
- claimDemoted: counterexamples.length > 0,
1097
- _attribution: CONTEXT_ATTRIBUTION,
1098
- };
1099
- }
1100
-
1101
- // =============================================================================
1102
- // IMPLEMENTATION: propose_patch + verify_patch
1103
- // =============================================================================
1104
-
1105
- function ensureDir(dirAbs) {
1106
- if (!fssync.existsSync(dirAbs)) fssync.mkdirSync(dirAbs, { recursive: true });
1107
- }
1108
-
1109
- async function proposePatch(projectPath, args) {
1110
- const { diff, fixes, claims = [], verification = [], policy = "strict", save = true } = args || {};
1111
- const pol = policy || "strict";
1112
-
1113
- const claimValidation = [];
1114
- for (const claimId of claims) {
1115
- const cached = state.verifiedClaims.get(claimId);
1116
- if (!cached) {
1117
- claimValidation.push({ claimId, valid: false, error: "Claim not verified" });
1118
- continue;
1119
- }
1120
- const res = cached.result;
1121
- if (res?.result === "unknown") claimValidation.push({ claimId, valid: false, error: "Claim is unknown" });
1122
- else if (res?.result === "false") claimValidation.push({ claimId, valid: false, error: "Claim is false" });
1123
- else claimValidation.push({ claimId, valid: true });
1124
- }
1125
-
1126
- const allClaimsValid = claimValidation.every((c) => c.valid);
1127
- const patchId = `patch_${crypto.randomUUID().slice(0, 12)}`;
1128
-
1129
- const patch = {
1130
- patchId,
1131
- diff: String(diff || "").slice(0, 50_000),
1132
- fixes: Array.isArray(fixes) ? fixes : [],
1133
- dependsOnClaims: Array.isArray(claims) ? claims : [],
1134
- verification: (Array.isArray(verification) && verification.length > 0) ? verification : ["vibecheck ship", "pnpm test"],
1135
- createdAt: new Date().toISOString(),
1136
- eligible: allClaimsValid && (Array.isArray(fixes) && fixes.length > 0),
1137
- claimValidation,
1138
- policy: pol,
1139
- };
1140
-
1141
- if (!patch.eligible) {
1142
- patch.blockers = claimValidation.filter((c) => !c.valid);
1143
- patch.message = "⚠️ Patch NOT eligible for auto-apply. Fix blockers first.";
1144
- }
1145
-
1146
- if (save) {
1147
- try {
1148
- const dir = safeProjectJoin(projectPath, ".vibecheck/patches");
1149
- ensureDir(dir);
1150
- const out = path.join(dir, `${patchId}.json`);
1151
- await fs.writeFile(out, JSON.stringify(patch, null, 2), "utf8");
1152
- patch.savedTo = path.relative(projectPath, out).replace(/\\/g, "/");
1153
- } catch (e) {
1154
- patch.saveError = e?.message || String(e);
1155
- }
1156
- }
1157
-
1158
- return patch;
1159
- }
1160
-
1161
- /**
1162
- * Validate command against strict allowlist.
1163
- *
1164
- * SECURITY FIX: Previous allowlist was too permissive, allowing arbitrary code execution:
1165
- * - "node -e 'require(\"child_process\").exec(\"rm -rf /\")'" would pass
1166
- * - "npm exec malicious-package" would pass
1167
- * - "pnpm dlx evil-tool" would pass
1168
- *
1169
- * New allowlist only permits specific safe subcommands.
1170
- */
1171
- function commandAllowlisted(cmd) {
1172
- const trimmed = cmd.trim();
1173
-
1174
- // Reject commands with shell metacharacters that could enable injection
1175
- // These are dangerous even in "safe" commands: ; | & $ ` \ ( ) { } < > \n
1176
- if (/[;|&$`\\(){}<>\n]/.test(trimmed)) {
1177
- return false;
1178
- }
1179
-
1180
- // Reject commands that use flags commonly used for code execution
1181
- if (/\s-[eEc]\s|\s--eval\s|\s--exec\s/i.test(trimmed)) {
1182
- return false;
1183
- }
1184
-
1185
- // Strict allowlist: only specific commands with specific safe subcommands
1186
- const strictAllow = [
1187
- // Vibecheck CLI - only specific safe commands
1188
- /^vibecheck\s+(ship|scan|ctx|lint|status)\b/,
1189
- /^vibecheck\s+--help\b/,
1190
- /^vibecheck\s+--version\b/,
1191
-
1192
- // Package managers - only test/build/lint (no exec, dlx, or install scripts)
1193
- /^pnpm\s+(test|build|lint|typecheck|check|run\s+(test|build|lint|typecheck))\b/,
1194
- /^npm\s+(test|run\s+(test|build|lint|typecheck))\b/,
1195
- /^yarn\s+(test|build|lint|typecheck|run\s+(test|build|lint|typecheck))\b/,
1196
- /^bun\s+(test|run\s+(test|build|lint))\b/,
1197
-
1198
- // TypeScript compiler - only type checking (no emit)
1199
- /^tsc\s+(--noEmit|--build)\b/,
1200
- /^tsc$/, // Default tsc with no args is safe
1201
-
1202
- // Linters - safe read-only operations
1203
- /^eslint\s+/, // ESLint with any args (read-only)
1204
- /^eslint$/,
1205
-
1206
- // Test runners - only run tests
1207
- /^vitest\s*(run|--run)?\b/,
1208
- /^vitest$/,
1209
- /^jest\s*(--ci|--coverage|--passWithNoTests)?\b/,
1210
- /^jest$/,
1211
-
1212
- // Playwright - only test mode (no codegen which opens browsers)
1213
- /^playwright\s+test\b/,
1214
- /^npx\s+playwright\s+test\b/,
1215
- ];
1216
-
1217
- return strictAllow.some((re) => re.test(trimmed));
1218
- }
1219
-
1220
- async function verifyPatch(projectPath, args) {
1221
- const { patchId, diff, commands, policy = "strict", timeoutMs = 120000 } = args || {};
1222
- const pol = policy || "strict";
1223
-
1224
- let patch = null;
1225
- if (patchId) {
1226
- try {
1227
- const abs = safeProjectJoin(projectPath, `.vibecheck/patches/${patchId}.json`);
1228
- const content = await fs.readFile(abs, "utf8");
1229
- patch = JSON.parse(content);
1230
- } catch (error) {
1231
- // Invalid JSON or file not found - treat as no patch
1232
- patch = null;
1233
- }
1234
- }
1235
-
1236
- // NOTE: This does NOT auto-apply diff. It only runs verification commands.
1237
- // Auto-apply should be a separate tool with explicit guardrails.
1238
- const cmds = Array.isArray(commands) ? commands : [];
1239
- const results = [];
1240
-
1241
- for (const cmd of cmds) {
1242
- if (!commandAllowlisted(cmd)) {
1243
- results.push({ cmd, ok: false, blocked: true, reason: "Command not allowlisted" });
1244
- continue;
1245
- }
1246
- const started = Date.now();
1247
- const out = spawnSync(cmd, {
1248
- cwd: projectPath,
1249
- shell: true,
1250
- encoding: "utf8",
1251
- timeout: Math.max(1000, Number(timeoutMs) || 120000),
1252
- maxBuffer: 1024 * 1024 * 5,
1253
- });
1254
-
1255
- const stdout = String(out.stdout || "").slice(0, MAX_CMD_OUTPUT);
1256
- const stderr = String(out.stderr || "").slice(0, MAX_CMD_OUTPUT);
1257
-
1258
- results.push({
1259
- cmd,
1260
- ok: out.status === 0 && !out.error,
1261
- status: out.status,
1262
- durationMs: Date.now() - started,
1263
- stdout,
1264
- stderr,
1265
- error: out.error ? String(out.error.message || out.error) : null,
1266
- });
1267
- }
1268
-
1269
- const pass = results.every((r) => r.ok);
1270
-
1271
- return {
1272
- patchId: patch?.patchId || patchId || null,
1273
- hasPatchRecord: !!patch,
1274
- policy: pol,
1275
- pass,
1276
- results,
1277
- note: "verify_patch runs commands only. Applying diffs should be explicit + guarded.",
1278
- _attribution: CONTEXT_ATTRIBUTION,
1279
- };
1280
- }
1281
-
1282
- // =============================================================================
1283
- // IMPLEMENTATION: invariants
1284
- // =============================================================================
1285
-
1286
- async function checkInvariants(projectPath, args) {
1287
- const { category = "all", policy = "strict" } = args || {};
1288
- const pol = policy || "strict";
1289
-
1290
- const shipKillers = [];
1291
- const warnings = [];
1292
-
1293
- // 1) Silent catches in auth/billing/middleware are ship killers
1294
- const silentCatches = await searchEvidence(projectPath, {
1295
- query: String.raw`catch\s*\(\s*\w*\s*\)\s*\{\s*(?:\}|\/\/|console\.log|return\s*;|return\s*null\s*;)`,
1296
- mode: "regex",
1297
- limit: 50,
1298
- });
1299
-
1300
- for (const ev of silentCatches.results) {
1301
- if (/auth|billing|middleware|payment/i.test(ev.file)) {
1302
- shipKillers.push({
1303
- invariant: "security_no_silent_catch",
1304
- rule: "No silent catch in auth/billing/middleware",
1305
- evidence: ev,
1306
- });
1307
- }
1308
- }
1309
-
1310
- // 2) Hardcoded secrets (ship killer)
1311
- const secrets = await searchEvidence(projectPath, {
1312
- query: String.raw`(sk_live_|AKIA[0-9A-Z]{16}|AIza[0-9A-Za-z\-_]{35}|xox[baprs]-[0-9A-Za-z\-]{10,})`,
1313
- mode: "regex",
1314
- limit: 20,
1315
- });
1316
-
1317
- for (const ev of secrets.results) {
1318
- if (!/\.example|\.test\.|\.spec\./i.test(ev.file)) {
1319
- shipKillers.push({
1320
- invariant: "security_no_exposed_secrets",
1321
- rule: "No hardcoded secrets or API keys",
1322
- evidence: ev,
1323
- });
1324
- }
1325
- }
1326
-
1327
- // 3) “Success UI without confirmed success” (warning by default)
1328
- const fakeSuccess = await searchEvidence(projectPath, {
1329
- query: String.raw`toast\.(success|info)|setSuccess\s*\(|"success"|success:\s*true`,
1330
- mode: "regex",
1331
- limit: 30,
1332
- });
1333
-
1334
- for (const ev of fakeSuccess.results) {
1335
- // This is heuristic: you’ll tighten it by correlating with network calls later.
1336
- warnings.push({
1337
- invariant: "ux_no_fake_success",
1338
- rule: "Success UI should be tied to confirmed success (network/response)",
1339
- evidence: ev,
1340
- });
1341
- }
1342
-
1343
- const passed = shipKillers.length === 0;
1344
- return {
1345
- policy: pol,
1346
- category,
1347
- passed,
1348
- shipKillers,
1349
- warnings,
1350
- summary: passed ? "✅ Invariants pass" : `❌ ${shipKillers.length} ship killers found`,
1351
- _attribution: CONTEXT_ATTRIBUTION,
1352
- };
1353
- }
1354
-
1355
- // =============================================================================
1356
- // IMPLEMENTATION: assumptions
1357
- // =============================================================================
1358
-
1359
- async function addAssumption(projectPath, args) {
1360
- const { description, reason, verificationSteps } = args || {};
1361
-
1362
- const list = state.assumptionsByProject.get(projectPath) || [];
1363
- if (list.length >= state.maxAssumptions) {
1364
- return {
1365
- error: `Assumption budget exceeded (${list.length}/${state.maxAssumptions})`,
1366
- message: "⚠️ Verify or delete assumptions. Don’t stack guesses.",
1367
- currentAssumptions: list,
1368
- _attribution: CONTEXT_ATTRIBUTION,
1369
- };
1370
- }
1371
-
1372
- const assumption = {
1373
- id: `assumption_${Date.now()}_${crypto.randomUUID().slice(0, 6)}`,
1374
- description: String(description || ""),
1375
- reason: reason ? String(reason) : "",
1376
- verificationSteps: Array.isArray(verificationSteps) ? verificationSteps : [],
1377
- madeAt: new Date().toISOString(),
1378
- verified: false,
1379
- };
1380
-
1381
- list.push(assumption);
1382
- state.assumptionsByProject.set(projectPath, list);
1383
-
1384
- return {
1385
- assumption,
1386
- budget: { used: list.length, max: state.maxAssumptions, remaining: state.maxAssumptions - list.length },
1387
- warning: list.length >= state.maxAssumptions ? "⚠️ Assumption limit reached." : null,
1388
- _attribution: CONTEXT_ATTRIBUTION,
1389
- };
1390
- }
1391
-
1392
- // =============================================================================
1393
- // PLAN VALIDATION & DRIFT
1394
- // =============================================================================
1395
-
1396
- async function getPlanValidationResult(projectPath, args) {
1397
- const { plan, strict = false } = args || {};
1398
-
1399
- const contracts = await loadContractsFromDisk(projectPath);
1400
- if (!contracts || Object.keys(contracts).length === 0) {
1401
- return {
1402
- valid: true,
1403
- warning: 'No contracts found. Run "vibecheck ctx sync" to generate contracts.',
1404
- violations: [],
1405
- warnings: [],
1406
- suggestions: ['Generate contracts with: vibecheck ctx sync'],
1407
- _attribution: CONTEXT_ATTRIBUTION,
1408
- };
1409
- }
1410
-
1411
- const actions = parsePlanActions(plan);
1412
-
1413
- const violations = [];
1414
- const warnings = [];
1415
- const suggestions = [];
1416
-
1417
- // routes
1418
- if (contracts.routes && actions.routes.length > 0) {
1419
- const contractRoutes = (contracts.routes.routes || []).map((r) => ({
1420
- method: String(r.method || "*").toUpperCase(),
1421
- path: canonicalizePath(String(r.path || "")),
1422
- }));
1423
-
1424
- for (const r of actions.routes) {
1425
- const wanted = { method: String(r.method || "GET").toUpperCase(), path: canonicalizePath(r.path) };
1426
- const exists = contractRoutes.some((cr) => (cr.method === "*" || cr.method === wanted.method) && matchesParameterizedPath(cr.path, wanted.path));
1427
- if (!exists) {
1428
- violations.push({
1429
- type: "invented_route",
1430
- severity: "BLOCK",
1431
- route: wanted.path,
1432
- method: wanted.method,
1433
- message: `Plan references route ${wanted.method} ${wanted.path} not in routes contract`,
1434
- });
1435
- }
1436
- }
1437
- }
1438
-
1439
- // env vars
1440
- if (contracts.env && actions.envVars.length > 0) {
1441
- const contractVars = new Set((contracts.env.vars || []).map((v) => v.name));
1442
- for (const v of actions.envVars) {
1443
- if (!contractVars.has(v)) {
1444
- warnings.push({
1445
- type: "undeclared_env",
1446
- severity: "WARN",
1447
- name: v,
1448
- message: `Plan uses env var ${v} not declared in env contract`,
1449
- suggestion: "Add to .vibecheck/contracts/env.json and .env.example",
1450
- });
1451
- }
1452
- }
1453
- }
1454
-
1455
- // external services
1456
- if (contracts.external && actions.externalCalls.length > 0) {
1457
- const contractServices = new Set((contracts.external.services || []).map((s) => s.name));
1458
- for (const call of actions.externalCalls) {
1459
- if (!contractServices.has(call.service)) {
1460
- warnings.push({
1461
- type: "undeclared_service",
1462
- severity: "WARN",
1463
- service: call.service,
1464
- message: `Plan uses ${call.service} not declared in external contract`,
1465
- suggestion: "Add to .vibecheck/contracts/external.json",
1466
- });
1467
- }
1468
- }
1469
- }
1470
-
1471
- const valid = violations.length === 0 && (!strict || warnings.length === 0);
1472
-
1473
- return {
1474
- valid,
1475
- violations,
1476
- warnings,
1477
- suggestions,
1478
- parsedActions: actions,
1479
- contractsLoaded: Object.keys(contracts),
1480
- message: valid ? "✅ Plan validated" : `❌ Plan invalid: ${violations.length} violations, ${warnings.length} warnings`,
1481
- _attribution: CONTEXT_ATTRIBUTION,
1482
- };
1483
- }
1484
-
1485
- async function checkDriftTool(projectPath, args) {
1486
- const category = args?.category || "all";
1487
-
1488
- const contracts = await loadContractsFromDisk(projectPath);
1489
- if (!contracts || Object.keys(contracts).length === 0) {
1490
- return { hasDrift: false, message: 'No contracts found. Run "vibecheck ctx sync".', findings: [], _attribution: CONTEXT_ATTRIBUTION };
1491
- }
1492
-
1493
- const truthpack = await buildCurrentTruthpack(projectPath);
1494
- const findings = [];
1495
-
1496
- if (category === "all" || category === "routes") findings.push(...detectRouteDrift(contracts.routes, truthpack));
1497
- if (category === "all" || category === "env") findings.push(...detectEnvDrift(contracts.env, truthpack));
1498
- if (category === "all" || category === "auth") findings.push(...detectAuthDrift(contracts.auth, truthpack));
1499
-
1500
- const blocks = findings.filter((f) => f.severity === "BLOCK");
1501
- const warns = findings.filter((f) => f.severity === "WARN");
1502
-
1503
- return {
1504
- hasDrift: findings.length > 0,
1505
- verdict: blocks.length > 0 ? "BLOCK" : warns.length > 0 ? "WARN" : "PASS",
1506
- summary: { blocks: blocks.length, warns: warns.length, total: findings.length },
1507
- findings,
1508
- message: findings.length === 0 ? "✅ No drift detected" : `⚠️ Drift detected: ${blocks.length} blocks, ${warns.length} warnings`,
1509
- _attribution: CONTEXT_ATTRIBUTION,
1510
- };
1511
- }
1512
-
1513
- async function getContractsTool(projectPath, args) {
1514
- const type = args?.type || "all";
1515
- const contracts = await loadContractsFromDisk(projectPath);
1516
-
1517
- if (!contracts || Object.keys(contracts).length === 0) {
1518
- return { found: false, message: 'No contracts found. Run "vibecheck ctx sync".', contracts: {}, _attribution: CONTEXT_ATTRIBUTION };
1519
- }
1520
-
1521
- if (type === "all") {
1522
- return {
1523
- found: true,
1524
- contracts,
1525
- summary: {
1526
- routes: contracts.routes?.routes?.length || 0,
1527
- envVars: contracts.env?.vars?.length || 0,
1528
- authPatterns: contracts.auth?.protectedPatterns?.length || 0,
1529
- services: contracts.external?.services?.length || 0,
1530
- },
1531
- _attribution: CONTEXT_ATTRIBUTION,
1532
- };
1533
- }
1534
-
1535
- return { found: !!contracts[type], contracts: { [type]: contracts[type] }, _attribution: CONTEXT_ATTRIBUTION };
1536
- }
1537
-
1538
- async function loadContractsFromDisk(projectPath) {
1539
- const contractDir = safeProjectJoin(projectPath, ".vibecheck/contracts");
1540
- const contracts = {};
1541
-
1542
- const files = {
1543
- routes: "routes.json",
1544
- env: "env.json",
1545
- auth: "auth.json",
1546
- external: "external.json",
1547
- };
1548
-
1549
- for (const [key, file] of Object.entries(files)) {
1550
- try {
1551
- const abs = path.join(contractDir, file);
1552
- const content = await fs.readFile(abs, "utf8");
1553
- contracts[key] = JSON.parse(content);
1554
- } catch (error) {
1555
- // Invalid JSON or file not found - skip this contract
1556
- // ignore
1557
- }
1558
- }
1559
-
1560
- return contracts;
1561
- }
1562
-
1563
- // =============================================================================
1564
- // PARSING HELPERS
1565
- // =============================================================================
1566
-
1567
- function canonicalizePath(p) {
1568
- let s = String(p || "").trim();
1569
- if (!s.startsWith("/")) s = "/" + s;
1570
- s = s.replace(/\/+/g, "/");
1571
- if (s.length > 1) s = s.replace(/\/$/, "");
1572
- return s;
1573
- }
1574
-
1575
- function dedupe(arr, keyFn) {
1576
- const seen = new Set();
1577
- const out = [];
1578
- for (const item of arr) {
1579
- const k = keyFn(item);
1580
- if (seen.has(k)) continue;
1581
- seen.add(k);
1582
- out.push(item);
1583
- }
1584
- return out;
1585
- }
1586
-
1587
- function parsePlanActions(plan) {
1588
- const actions = {
1589
- routes: [],
1590
- envVars: [],
1591
- authAssumptions: [],
1592
- externalCalls: [],
1593
- };
1594
-
1595
- const planText = typeof plan === "string" ? plan : JSON.stringify(plan);
1596
-
1597
- // routes
1598
- const routePatterns = [
1599
- /(?:GET|POST|PUT|PATCH|DELETE)\s+([/][^\s"'`]+)/gi,
1600
- /\/api\/[a-z0-9/_-]+/gi,
1601
- ];
1602
-
1603
- for (const pattern of routePatterns) {
1604
- let match;
1605
- while ((match = pattern.exec(planText)) !== null) {
1606
- const p = (match[1] || match[0]).replace(/['"`]/g, "");
1607
- if (p.startsWith("/")) {
1608
- actions.routes.push({ path: canonicalizePath(p), method: inferMethodFromText(match[0]) });
1609
- }
1610
- }
1611
- }
1612
-
1613
- actions.routes = dedupe(actions.routes, (r) => `${r.method}:${r.path}`);
1614
-
1615
- // env vars
1616
- const envPatterns = [
1617
- /process\.env\.([A-Z_][A-Z0-9_]*)/g,
1618
- /import\.meta\.env\.([A-Z_][A-Z0-9_]*)/g,
1619
- ];
1620
- for (const pattern of envPatterns) {
1621
- let match;
1622
- while ((match = pattern.exec(planText)) !== null) actions.envVars.push(match[1]);
1623
- }
1624
- actions.envVars = dedupe(actions.envVars, (v) => v);
1625
-
1626
- // auth assumptions
1627
- if (/(authenticated|logged in|auth required|protected)/i.test(planText)) actions.authAssumptions.push({ type: "requires_auth" });
1628
- if (/(public|no auth|unauthenticated)/i.test(planText)) actions.authAssumptions.push({ type: "no_auth" });
1629
-
1630
- // external services
1631
- const services = [
1632
- { re: /stripe\./i, service: "stripe" },
1633
- { re: /github\./i, service: "github" },
1634
- { re: /sendgrid\./i, service: "sendgrid" },
1635
- { re: /twilio\./i, service: "twilio" },
1636
- { re: /supabase\./i, service: "supabase" },
1637
- ];
1638
- for (const s of services) {
1639
- if (s.re.test(planText)) actions.externalCalls.push({ service: s.service });
1640
- }
1641
- actions.externalCalls = dedupe(actions.externalCalls, (c) => c.service);
1642
-
1643
- return actions;
1644
- }
1645
-
1646
- function inferMethodFromText(text) {
1647
- const upper = String(text || "").toUpperCase();
1648
- if (upper.includes("POST")) return "POST";
1649
- if (upper.includes("PUT")) return "PUT";
1650
- if (upper.includes("PATCH")) return "PATCH";
1651
- if (upper.includes("DELETE")) return "DELETE";
1652
- return "GET";
1653
- }
1654
-
1655
- function matchesParameterizedPath(pattern, actual) {
1656
- const pParts = canonicalizePath(pattern).split("/").filter(Boolean);
1657
- const aParts = canonicalizePath(actual).split("/").filter(Boolean);
1658
- if (pParts.length !== aParts.length) return false;
1659
-
1660
- for (let i = 0; i < pParts.length; i++) {
1661
- const p = pParts[i];
1662
- if (p.startsWith(":") || p.startsWith("*") || p.startsWith("[")) continue;
1663
- if (p !== aParts[i]) return false;
1664
- }
1665
- return true;
1666
- }
1667
-
1668
- // =============================================================================
1669
- // DRIFT DETECTORS (improved: added removals)
1670
- // =============================================================================
1671
-
1672
- async function buildCurrentTruthpack(projectPath, refresh = false) {
1673
- const routes = await extractRoutes(projectPath, refresh);
1674
- const env = await extractEnv(projectPath);
1675
- const auth = await extractAuth(projectPath);
1676
-
1677
- return {
1678
- routes: {
1679
- server: routes.routes || [],
1680
- gaps: routes.gaps || [],
1681
- engine: routes.engine,
1682
- },
1683
- env: { vars: env.used || [], declared: (env.declared || []).map((d) => d.name) },
1684
- auth: { nextMatcherPatterns: auth?.nextMatcherPatterns || [] },
1685
- };
1686
- }
1687
-
1688
- function detectRouteDrift(routeContract, truthpack) {
1689
- const findings = [];
1690
- if (!routeContract?.routes) return findings;
1691
-
1692
- // Use Route Truth v1 canonicalization for consistency
1693
- const canonicalize = routeTruthCanonicalize || canonicalizePath;
1694
-
1695
- const contractSet = new Set(routeContract.routes.map((r) => `${String(r.method || "*").toUpperCase()}_${canonicalize(r.path)}`));
1696
- const server = truthpack?.routes?.server || [];
1697
- const serverSet = new Set(server.map((r) => `${String(r.method || "*").toUpperCase()}_${canonicalize(r.path)}`));
1698
- const gaps = truthpack?.routes?.gaps || [];
1699
-
1700
- // new routes (in code, not in contract)
1701
- for (const key of serverSet) {
1702
- if (!contractSet.has(key)) {
1703
- const [method, routePath] = key.split("_");
1704
- const routeInfo = server.find((r) => canonicalize(r.path) === routePath && String(r.method || "*").toUpperCase() === method);
1705
-
1706
- findings.push({
1707
- type: "new_route_not_in_contract",
1708
- severity: "BLOCK",
1709
- title: `New route not in contract: ${method} ${routePath}`,
1710
- message: "Route exists in code but not synced to routes contract.",
1711
- file: routeInfo?.file,
1712
- framework: routeInfo?.framework,
1713
- });
1714
- }
1715
- }
1716
-
1717
- // removed routes (in contract, not in code)
1718
- for (const key of contractSet) {
1719
- if (!serverSet.has(key)) {
1720
- const hasGaps = gaps.length > 0;
1721
- findings.push({
1722
- type: "contract_route_missing_in_code",
1723
- severity: hasGaps ? "WARN" : "WARN", // Demote if gaps exist
1724
- title: `Contract route missing in code: ${key.replace("_", " ")}`,
1725
- message: hasGaps
1726
- ? "Contract lists a route not detected in code. Note: some plugins couldn't be resolved."
1727
- : "Contract lists a route not detected in code (stale contract?).",
1728
- mayBeExtractorGap: hasGaps,
1729
- });
1730
- }
1731
- }
1732
-
1733
- // Report gaps as info
1734
- if (gaps.length > 0) {
1735
- findings.push({
1736
- type: "extractor_gaps",
1737
- severity: "INFO",
1738
- title: `Route extractor has ${gaps.length} unresolved module(s)`,
1739
- message: "Some Fastify plugins or imports couldn't be resolved - routes may be incomplete.",
1740
- gaps: gaps.slice(0, 5), // Limit to first 5
1741
- });
1742
- }
1743
-
1744
- return findings;
1745
- }
1746
-
1747
- function detectEnvDrift(envContract, truthpack) {
1748
- const findings = [];
1749
- if (!envContract?.vars) return findings;
1750
-
1751
- const contractVars = new Set(envContract.vars.map((v) => v.name));
1752
- const usedVars = new Set((truthpack?.env?.vars || []).map((v) => v.name));
1753
-
1754
- for (const name of usedVars) {
1755
- if (!contractVars.has(name)) {
1756
- findings.push({
1757
- type: "new_env_not_in_contract",
1758
- severity: "WARN",
1759
- title: `Env var used but not in contract: ${name}`,
1760
- message: "Env var used in code but not declared in contracts.",
1761
- });
1762
- }
1763
- }
1764
-
1765
- for (const name of contractVars) {
1766
- if (!usedVars.has(name)) {
1767
- findings.push({
1768
- type: "contract_env_unused",
1769
- severity: "WARN",
1770
- title: `Env var in contract appears unused: ${name}`,
1771
- message: "Contract env var not detected in usage (stale contract or extractor gap).",
1772
- });
1773
- }
1774
- }
1775
-
1776
- return findings;
1777
- }
1778
-
1779
- function detectAuthDrift(authContract, truthpack) {
1780
- const findings = [];
1781
- if (!authContract?.protectedPatterns) return findings;
1782
-
1783
- const contract = new Set(authContract.protectedPatterns);
1784
- const current = new Set(truthpack?.auth?.nextMatcherPatterns || []);
1785
-
1786
- for (const pattern of current) {
1787
- if (!contract.has(pattern)) {
1788
- findings.push({
1789
- type: "new_auth_pattern",
1790
- severity: "BLOCK",
1791
- title: `New auth pattern not in contract: ${pattern}`,
1792
- message: "Auth matcher/pattern changed but contracts not updated.",
1793
- });
1794
- }
1795
- }
1796
-
1797
- return findings;
1798
- }
1799
-
1800
- // =============================================================================
1801
- // DOMAIN HELPERS
1802
- // =============================================================================
1803
-
1804
- function detectDomains(task) {
1805
- const domains = [];
1806
- const t = String(task || "");
1807
- if (/auth|login|logout|session|password/i.test(t)) domains.push("auth");
1808
- if (/billing|payment|stripe|subscription/i.test(t)) domains.push("billing");
1809
- if (/env|secret|config/i.test(t)) domains.push("env");
1810
- if (/route|api|endpoint/i.test(t)) domains.push("api");
1811
- if (/component|button|ui|form/i.test(t)) domains.push("ui");
1812
- return domains.length ? domains : ["general"];
1813
- }
1814
-
1815
- function extractKeywords(task) {
1816
- return String(task || "")
1817
- .toLowerCase()
1818
- .replace(/[^a-z0-9\s/_-]/g, " ")
1819
- .split(/\s+/)
1820
- .filter((w) => w.length > 2)
1821
- .slice(0, 40);
1822
- }
1823
-
1824
- function getInvariantsForDomains(domains) {
1825
- const invariants = [];
1826
- if (domains.includes("auth")) invariants.push("No protected route without server middleware");
1827
- if (domains.includes("billing")) invariants.push("No paid feature without server-side enforcement");
1828
- invariants.push("No success UI without confirmed success");
1829
- invariants.push("No invented routes/env vars/functions in plans");
1830
- return invariants;
1831
- }
1832
-
1833
- function estimateTokens(context) {
1834
- let tokens = 0;
1835
- if (context?.routes) tokens += context.routes.length * 50;
1836
- if (context?.auth) tokens += 220;
1837
- if (context?.billing) tokens += 220;
1838
- if (context?.env) tokens += 180;
1839
- return tokens;
1840
- }
1841
-
1842
- function generateContextWarnings(domains, policy, routeCount) {
1843
- const warnings = [];
1844
- if (domains.includes("auth") || domains.includes("billing")) warnings.push("High-stakes domain: verify claims before edits.");
1845
- if (routeCount > 50 && policy === "strict") warnings.push("Large route surface: narrow task or specify files.");
1846
- return warnings;
1847
- }
1848
-
1849
- // =============================================================================
1850
- // SOURCE FILE DISCOVERY (safe + bounded)
1851
- // =============================================================================
1852
-
1853
- async function findSourceFiles(projectPath, opts) {
1854
- const files = [];
1855
- const ignoreDirs = new Set(["node_modules", "dist", "build", ".git", ".next", "coverage", "out"]);
1856
-
1857
- async function walk(dirAbs) {
1858
- let entries = [];
1859
- try {
1860
- entries = await fs.readdir(dirAbs, { withFileTypes: true });
1861
- } catch {
1862
- return;
1863
- }
1864
-
1865
- for (const entry of entries) {
1866
- const full = path.join(dirAbs, entry.name);
1867
- if (entry.isDirectory()) {
1868
- if (ignoreDirs.has(entry.name) || entry.name.startsWith(".")) continue;
1869
- await walk(full);
1870
- } else if (entry.isFile()) {
1871
- if (!/\.(ts|tsx|js|jsx)$/.test(entry.name)) continue;
1872
- const rel = path.relative(projectPath, full).replace(/\\/g, "/");
1873
- if (!opts?.includeTests && isTestFilePath(rel)) continue;
1874
- files.push(full);
1875
- if (files.length > 2500) return; // hard cap
1876
- }
1877
- }
1878
- }
1879
-
1880
- await walk(path.resolve(projectPath));
1881
- return files;
1882
- }
1883
-
1884
- // =============================================================================
1885
- // ROUTE TRUTH V1 INTEGRATION (AST-based, follows Fastify register prefixes + Next.js app/pages)
1886
- // =============================================================================
1887
-
1888
- /**
1889
- * Get or build the Route Truth v1 index for a project.
1890
- * This is the SINGLE SOURCE OF TRUTH for route reality.
1891
- */
1892
- async function getRouteIndex(projectPath, refresh = false) {
1893
- if (!refresh && state.routeIndexByProject.has(projectPath)) {
1894
- return state.routeIndexByProject.get(projectPath);
1895
- }
1896
-
1897
- const index = new RouteIndex();
1898
- await index.build(projectPath);
1899
- state.routeIndexByProject.set(projectPath, index);
1900
- return index;
1901
- }
1902
-
1903
- /**
1904
- * Extract routes using Route Truth v1 (AST-based).
1905
- * - Fastify: follows register() prefixes, resolves relative plugin imports
1906
- * - Next.js: App Router (route.ts exports) + Pages Router (/pages/api/**)
1907
- * - Proper path canonicalization (dynamic segments, splats)
1908
- */
1909
- async function extractRoutes(projectPath, refresh = false) {
1910
- const index = await getRouteIndex(projectPath, refresh);
1911
- const routeMap = index.getRouteMap();
1912
-
1913
- // Transform to truthpack format
1914
- const routes = (routeMap.server || []).map((r) => ({
1915
- method: r.method,
1916
- path: r.path,
1917
- file: r.handler,
1918
- line: r.evidence?.[0]?.lines?.split("-")[0] || 1,
1919
- framework: r.framework,
1920
- routerType: r.routerType,
1921
- confidence: r.confidence === "high" ? 0.95 : r.confidence === "med" ? 0.8 : 0.6,
1922
- evidence: r.evidence,
1923
- }));
1924
-
1925
- const gaps = routeMap.gaps || [];
1926
- const hasGaps = gaps.length > 0;
1927
-
1928
- return {
1929
- count: routes.length,
1930
- routes,
1931
- gaps,
1932
- confidence: hasGaps ? 0.7 : (routes.length > 0 ? 0.95 : 0.3),
1933
- engine: "route-truth-v1",
1934
- _note: hasGaps
1935
- ? `⚠️ ${gaps.length} unresolved plugins/modules - some routes may be missing`
1936
- : "AST-based extraction (Fastify register prefixes + Next.js app/pages)",
1937
- };
1938
- }
1939
-
1940
- async function extractAuth(projectPath) {
1941
- // TODO: parse Next middleware matcher for real; this is evidence-based heuristics
1942
- const evidence = await searchEvidence(projectPath, { query: "middleware|auth|authenticate|authorize|jwt|session", mode: "regex", limit: 30 });
1943
- return {
1944
- count: evidence.count,
1945
- indicators: evidence.results,
1946
- nextMatcherPatterns: [],
1947
- confidence: evidence.count > 5 ? 0.8 : 0.4,
1948
- };
1949
- }
1950
-
1951
- async function extractBilling(projectPath) {
1952
- const evidence = await searchEvidence(projectPath, { query: "stripe|billing|payment|subscription|checkout|tier|isPro", mode: "regex", limit: 25 });
1953
- return { count: evidence.count, indicators: evidence.results, confidence: evidence.count > 3 ? 0.75 : 0.3 };
1954
- }
1955
-
1956
- async function extractEnv(projectPath) {
1957
- const declared = [];
1958
- const used = [];
1959
-
1960
- // .env.example declarations
1961
- try {
1962
- const content = await safeReadFile(projectPath, ".env.example");
1963
- const lines = content.split(/\r?\n/);
1964
- for (let i = 0; i < lines.length; i++) {
1965
- const m = lines[i].match(/^([A-Z][A-Z0-9_]*)=/);
1966
- if (m) declared.push({ name: m[1], line: i + 1 });
1967
- }
1968
- } catch { /* ignore */ }
1969
-
1970
- // usage
1971
- const usage = await searchEvidence(projectPath, { query: "process\\.env\\.([A-Z][A-Z0-9_]*)", mode: "regex", limit: 200, includeTests: false });
1972
- for (const ev of usage.results) {
1973
- const m = ev.snippet.match(/process\.env\.([A-Z][A-Z0-9_]*)/);
1974
- if (m) used.push({ name: m[1], file: ev.file, line: ev.line });
1975
- }
1976
-
1977
- const declaredNames = new Set(declared.map((d) => d.name));
1978
- const usedNames = new Set(used.map((u) => u.name));
1979
-
1980
- return {
1981
- declared,
1982
- used,
1983
- mismatches: {
1984
- undeclared: Array.from(usedNames).filter((n) => !declaredNames.has(n)),
1985
- unused: Array.from(declaredNames).filter((n) => !usedNames.has(n)),
1986
- },
1987
- confidence: 0.8,
1988
- };
1989
- }
1990
-
1991
- async function extractSchema(projectPath) {
1992
- const schemas = [];
1993
- try {
1994
- const content = await safeReadFile(projectPath, "prisma/schema.prisma");
1995
- const models = content.matchAll(/model\s+(\w+)\s*\{/g);
1996
- for (const m of models) schemas.push({ type: "prisma_model", name: m[1] });
1997
- } catch { /* ignore */ }
1998
- return { count: schemas.length, schemas, confidence: schemas.length > 0 ? 0.9 : 0.3 };
1999
- }
2000
-
2001
- async function extractGraph(_projectPath) {
2002
- return { nodes: [], edges: [], message: "Graph extraction not implemented in this module." };
2003
- }
2004
-
2005
- // =============================================================================
2006
- // CLAIM VERIFIERS (tightened)
2007
- // =============================================================================
2008
-
2009
- /**
2010
- * Verify route_exists claim using Route Truth v1 index.
2011
- * This uses proper AST-based route detection with:
2012
- * - Fastify register() prefix resolution
2013
- * - Next.js App/Pages router support
2014
- * - Parameterized path matching (:id, *slug)
2015
- * - Closest route suggestions on miss
2016
- */
2017
- async function verifyRouteExists(projectPath, subject, refresh = false) {
2018
- const index = await getRouteIndex(projectPath, refresh);
2019
-
2020
- const claim = {
2021
- method: subject?.method || "*",
2022
- path: subject?.path,
2023
- };
2024
-
2025
- // Use Route Truth v1 validation (handles parameterized matching, gaps, etc.)
2026
- const result = await routeTruthValidate(claim, projectPath, index);
2027
-
2028
- if (result.result === "true") {
2029
- const matched = result.matchedRoute;
2030
- return {
2031
- result: "true",
2032
- confidence: result.confidence === "high" ? "high" : result.confidence === "med" ? "medium" : "low",
2033
- evidence: (matched?.evidence || []).map((e) => ({
2034
- file: e.file,
2035
- lines: e.lines,
2036
- snippet: "",
2037
- hash: e.snippetHash || sha16(`${e.file}:${e.lines}`),
2038
- })),
2039
- matchedRoute: {
2040
- method: matched?.method,
2041
- path: matched?.path,
2042
- file: matched?.handler,
2043
- framework: matched?.framework,
2044
- },
2045
- };
2046
- }
2047
-
2048
- if (result.result === "unknown") {
2049
- // Has gaps (unresolved plugins) - can't be certain
2050
- return {
2051
- result: "unknown",
2052
- confidence: "low",
2053
- evidence: [],
2054
- gaps: result.gaps,
2055
- closestRoutes: (result.closestRoutes || []).map((r) => ({
2056
- method: r.method,
2057
- path: r.path,
2058
- file: r.handler,
2059
- })),
2060
- nextSteps: result.nextSteps || [
2061
- "Some plugins/modules could not be resolved",
2062
- "Route may exist but extractor couldn't follow import chain",
2063
- ],
2064
- };
2065
- }
2066
-
2067
- // result === "false" - route definitely doesn't exist
2068
- const closest = (result.closestRoutes || []).map((r) => ({
2069
- method: r.method,
2070
- path: r.path,
2071
- file: r.handler,
2072
- }));
2073
-
2074
- return {
2075
- result: "false",
2076
- confidence: "high",
2077
- evidence: [],
2078
- closestRoutes: closest,
2079
- nextSteps: result.nextSteps || (closest.length
2080
- ? [`Route not found. Did you mean: ${closest.map((r) => `${r.method} ${r.path}`).join(", ")}?`]
2081
- : ["Route not found. No similar routes detected."]),
2082
- };
2083
- }
2084
-
2085
- async function verifyFileExists(projectPath, subject) {
2086
- const rel = String(subject?.path || subject?.name || "");
2087
- if (!rel) return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Missing subject.path"] };
2088
- try {
2089
- const abs = safeProjectJoin(projectPath, rel);
2090
- await fs.access(abs);
2091
- return { result: "true", confidence: "high", evidence: [{ file: rel, lines: "1", hash: sha16(rel) }] };
2092
- } catch {
2093
- return { result: "false", confidence: "high", evidence: [] };
2094
- }
2095
- }
2096
-
2097
- async function verifyEnvVar(projectPath, subject, claim) {
2098
- const env = await extractEnv(projectPath);
2099
- const name = String(subject?.name || "");
2100
- if (!name) return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Missing subject.name"] };
2101
-
2102
- const declared = env.declared?.find((d) => d.name === name);
2103
- const used = (env.used || []).filter((u) => u.name === name);
2104
-
2105
- if (claim === "env_var_exists") {
2106
- if (declared) {
2107
- return { result: "true", confidence: "high", evidence: [{ file: ".env.example", lines: `${declared.line}`, hash: sha16(`${name}:${declared.line}`) }] };
2108
- }
2109
- // "exists" might be in real env, but contract says no -> unknown
2110
- return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Not declared in .env.example / contract"] };
2111
- }
2112
-
2113
- // env_var_used
2114
- if (used.length > 0) {
2115
- return {
2116
- result: "true",
2117
- confidence: "high",
2118
- evidence: used.map((u) => ({ file: u.file, lines: `${u.line}`, hash: sha16(`${u.file}:${u.line}:${name}`) })),
2119
- };
2120
- }
2121
-
2122
- return { result: "false", confidence: "high", evidence: [] };
2123
- }
2124
-
2125
- async function verifyRouteGuarded(projectPath, subject) {
2126
- const routePath = canonicalizePath(String(subject?.path || ""));
2127
- if (!routePath) return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Missing subject.path"] };
2128
-
2129
- // heuristic: route mentioned near middleware/auth patterns
2130
- const evidence = await searchEvidence(projectPath, {
2131
- query: String.raw`(middleware|auth|authorize|session).*(\Q${routePath}\E)|\Q${routePath}\E.*(middleware|auth|authorize|session)`.replace(/\Q|\E/g, ""),
2132
- mode: "regex",
2133
- limit: 10,
2134
- });
2135
-
2136
- if (evidence.count > 0) {
2137
- return {
2138
- result: "true",
2139
- confidence: "med",
2140
- evidence: evidence.results.map((e) => ({ file: e.file, lines: `${e.line}`, hash: e.hash, snippet: e.snippet })),
2141
- };
2142
- }
2143
-
2144
- return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["No guard evidence found (may still exist; extractor is heuristic)"] };
2145
- }
2146
-
2147
- async function verifyEntityExists(projectPath, subject, _claim) {
2148
- const name = String(subject?.name || "");
2149
- if (!name) return { result: "unknown", confidence: "low", evidence: [], nextSteps: ["Missing subject.name"] };
2150
-
2151
- const evidence = await searchEvidence(projectPath, { query: name, mode: "text", limit: 8 });
2152
- if (evidence.count > 0) {
2153
- return {
2154
- result: "true",
2155
- confidence: "med",
2156
- evidence: evidence.results.map((e) => ({ file: e.file, lines: `${e.line}`, hash: e.hash, snippet: e.snippet })),
2157
- };
2158
- }
2159
-
2160
- return { result: "unknown", confidence: "low", evidence: [] };
2161
- }
2162
-
2163
- // =============================================================================
2164
- // EXPORTS
2165
- // =============================================================================
2166
-
2167
- export {
2168
- getRouteIndex, // Route Truth v1 index - single source of truth for routes
2169
- extractRoutes, // Route extraction via Route Truth v1
2170
- };
2171
-
2172
- export default {
2173
- TRUTH_FIREWALL_TOOLS,
2174
- handleTruthFirewallTool,
2175
- hasRecentClaimValidation,
2176
- enforceClaimResult,
2177
- getPolicyConfig,
2178
- getProjectFingerprint,
2179
- wrapMcpResponse,
2180
- getContextAttribution,
2181
- getRouteIndex, // Also on default export
2182
- CONTEXT_ATTRIBUTION,
2183
- };