@vibecheckai/cli 3.2.6 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/bin/registry.js +192 -5
  2. package/bin/runners/lib/agent-firewall/change-packet/builder.js +280 -6
  3. package/bin/runners/lib/agent-firewall/critic/index.js +151 -0
  4. package/bin/runners/lib/agent-firewall/critic/judge.js +432 -0
  5. package/bin/runners/lib/agent-firewall/critic/prompts.js +305 -0
  6. package/bin/runners/lib/agent-firewall/lawbook/distributor.js +465 -0
  7. package/bin/runners/lib/agent-firewall/lawbook/evaluator.js +604 -0
  8. package/bin/runners/lib/agent-firewall/lawbook/index.js +304 -0
  9. package/bin/runners/lib/agent-firewall/lawbook/registry.js +514 -0
  10. package/bin/runners/lib/agent-firewall/lawbook/schema.js +420 -0
  11. package/bin/runners/lib/agent-firewall/logger.js +141 -0
  12. package/bin/runners/lib/agent-firewall/policy/loader.js +312 -4
  13. package/bin/runners/lib/agent-firewall/policy/rules/ghost-env.js +113 -1
  14. package/bin/runners/lib/agent-firewall/policy/rules/ghost-route.js +133 -6
  15. package/bin/runners/lib/agent-firewall/proposal/extractor.js +394 -0
  16. package/bin/runners/lib/agent-firewall/proposal/index.js +212 -0
  17. package/bin/runners/lib/agent-firewall/proposal/schema.js +251 -0
  18. package/bin/runners/lib/agent-firewall/proposal/validator.js +386 -0
  19. package/bin/runners/lib/agent-firewall/reality/index.js +332 -0
  20. package/bin/runners/lib/agent-firewall/reality/state.js +625 -0
  21. package/bin/runners/lib/agent-firewall/reality/watcher.js +322 -0
  22. package/bin/runners/lib/agent-firewall/risk/index.js +173 -0
  23. package/bin/runners/lib/agent-firewall/risk/scorer.js +328 -0
  24. package/bin/runners/lib/agent-firewall/risk/thresholds.js +321 -0
  25. package/bin/runners/lib/agent-firewall/risk/vectors.js +421 -0
  26. package/bin/runners/lib/agent-firewall/simulator/diff-simulator.js +472 -0
  27. package/bin/runners/lib/agent-firewall/simulator/import-resolver.js +346 -0
  28. package/bin/runners/lib/agent-firewall/simulator/index.js +181 -0
  29. package/bin/runners/lib/agent-firewall/simulator/route-validator.js +380 -0
  30. package/bin/runners/lib/agent-firewall/time-machine/incident-correlator.js +661 -0
  31. package/bin/runners/lib/agent-firewall/time-machine/index.js +267 -0
  32. package/bin/runners/lib/agent-firewall/time-machine/replay-engine.js +436 -0
  33. package/bin/runners/lib/agent-firewall/time-machine/state-reconstructor.js +490 -0
  34. package/bin/runners/lib/agent-firewall/time-machine/timeline-builder.js +530 -0
  35. package/bin/runners/lib/analyzers.js +81 -18
  36. package/bin/runners/lib/authority-badge.js +425 -0
  37. package/bin/runners/lib/cli-output.js +7 -1
  38. package/bin/runners/lib/error-handler.js +16 -9
  39. package/bin/runners/lib/exit-codes.js +275 -0
  40. package/bin/runners/lib/global-flags.js +37 -0
  41. package/bin/runners/lib/help-formatter.js +413 -0
  42. package/bin/runners/lib/logger.js +38 -0
  43. package/bin/runners/lib/unified-cli-output.js +604 -0
  44. package/bin/runners/lib/upsell.js +148 -0
  45. package/bin/runners/runApprove.js +1200 -0
  46. package/bin/runners/runAuth.js +324 -95
  47. package/bin/runners/runCheckpoint.js +39 -21
  48. package/bin/runners/runClassify.js +859 -0
  49. package/bin/runners/runContext.js +136 -24
  50. package/bin/runners/runDoctor.js +108 -68
  51. package/bin/runners/runFix.js +6 -5
  52. package/bin/runners/runGuard.js +212 -118
  53. package/bin/runners/runInit.js +3 -2
  54. package/bin/runners/runMcp.js +130 -52
  55. package/bin/runners/runPolish.js +43 -20
  56. package/bin/runners/runProve.js +1 -2
  57. package/bin/runners/runReport.js +3 -2
  58. package/bin/runners/runScan.js +63 -44
  59. package/bin/runners/runShip.js +3 -4
  60. package/bin/runners/runValidate.js +19 -2
  61. package/bin/runners/runWatch.js +104 -53
  62. package/bin/vibecheck.js +106 -19
  63. package/mcp-server/HARDENING_SUMMARY.md +299 -0
  64. package/mcp-server/agent-firewall-interceptor.js +367 -31
  65. package/mcp-server/authority-tools.js +569 -0
  66. package/mcp-server/conductor/conflict-resolver.js +588 -0
  67. package/mcp-server/conductor/execution-planner.js +544 -0
  68. package/mcp-server/conductor/index.js +377 -0
  69. package/mcp-server/conductor/lock-manager.js +615 -0
  70. package/mcp-server/conductor/request-queue.js +550 -0
  71. package/mcp-server/conductor/session-manager.js +500 -0
  72. package/mcp-server/conductor/tools.js +510 -0
  73. package/mcp-server/index.js +1149 -243
  74. package/mcp-server/lib/{api-client.js → api-client.cjs} +40 -4
  75. package/mcp-server/lib/logger.cjs +30 -0
  76. package/mcp-server/logger.js +173 -0
  77. package/mcp-server/package.json +2 -2
  78. package/mcp-server/premium-tools.js +2 -2
  79. package/mcp-server/tier-auth.js +245 -35
  80. package/mcp-server/truth-firewall-tools.js +145 -15
  81. package/mcp-server/vibecheck-tools.js +2 -2
  82. package/package.json +2 -3
  83. package/mcp-server/index.old.js +0 -4137
  84. package/mcp-server/package-lock.json +0 -165
@@ -1,7 +1,11 @@
1
1
  /**
2
2
  * Policy Loader
3
3
  *
4
- * Loads and validates agent firewall policy from .vibecheck/policy.json
4
+ * Loads and validates agent firewall policy from:
5
+ * - .vibecheck/policy.json (JSON policy)
6
+ * - .vibecheck/rules.yaml (YAML rule DSL)
7
+ * - .vibecheckrc (legacy)
8
+ *
5
9
  * Falls back to default policy if not found.
6
10
  */
7
11
 
@@ -11,6 +15,14 @@ const fs = require("fs");
11
15
  const path = require("path");
12
16
  const Ajv = require("ajv").default || require("ajv");
13
17
 
18
+ // Try to load YAML parser
19
+ let yaml = null;
20
+ try {
21
+ yaml = require("js-yaml");
22
+ } catch {
23
+ // js-yaml not available, YAML support disabled
24
+ }
25
+
14
26
  // Load schema
15
27
  const schemaPath = path.join(__dirname, "schema.json");
16
28
  const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
@@ -19,6 +31,94 @@ const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
19
31
  const defaultPolicyPath = path.join(__dirname, "default-policy.json");
20
32
  const defaultPolicy = JSON.parse(fs.readFileSync(defaultPolicyPath, "utf8"));
21
33
 
34
+ /**
35
+ * Load YAML rules file
36
+ * @param {string} rulesPath - Path to rules.yaml
37
+ * @returns {Array} Parsed rules array
38
+ */
39
+ function loadYamlRules(rulesPath) {
40
+ if (!yaml) {
41
+ console.warn("js-yaml not installed, YAML rules not loaded");
42
+ return [];
43
+ }
44
+
45
+ try {
46
+ const content = fs.readFileSync(rulesPath, "utf8");
47
+ const parsed = yaml.load(content);
48
+
49
+ if (!parsed || !parsed.rules) {
50
+ return [];
51
+ }
52
+
53
+ return normalizeYamlRules(parsed.rules);
54
+ } catch (error) {
55
+ console.warn(`Failed to load YAML rules: ${error.message}`);
56
+ return [];
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Normalize YAML rules to policy format
62
+ * @param {Array} rules - Raw YAML rules
63
+ * @returns {Array} Normalized rules
64
+ */
65
+ function normalizeYamlRules(rules) {
66
+ return rules.map(rule => {
67
+ // Map YAML DSL actions to policy severity
68
+ const severityMap = {
69
+ block: "block",
70
+ warn: "warn",
71
+ allow: "allow",
72
+ require_confirmation: "warn",
73
+ };
74
+
75
+ return {
76
+ id: rule.id,
77
+ description: rule.description || "",
78
+ severity: severityMap[rule.action] || "warn",
79
+ enabled: rule.enabled !== false,
80
+ match: rule.match || {},
81
+ action: rule.action || "warn",
82
+ message: rule.message || rule.description || "",
83
+ // Custom rule evaluator info
84
+ custom: rule.custom || null,
85
+ block_if_domain: rule.block_if_domain || [],
86
+ };
87
+ });
88
+ }
89
+
90
+ /**
91
+ * Merge YAML rules into policy
92
+ * @param {object} policy - Policy object
93
+ * @param {Array} yamlRules - Normalized YAML rules
94
+ * @returns {object} Policy with merged rules
95
+ */
96
+ function mergeYamlRulesIntoPolicy(policy, yamlRules) {
97
+ if (!yamlRules || yamlRules.length === 0) {
98
+ return policy;
99
+ }
100
+
101
+ const merged = { ...policy };
102
+ merged.rules = merged.rules || {};
103
+ merged.customRules = merged.customRules || [];
104
+
105
+ for (const rule of yamlRules) {
106
+ // Add to custom rules array
107
+ merged.customRules.push(rule);
108
+
109
+ // Also add to rules object for backward compatibility
110
+ if (rule.id) {
111
+ merged.rules[rule.id] = {
112
+ severity: rule.severity,
113
+ enabled: rule.enabled,
114
+ block_if_domain: rule.block_if_domain,
115
+ };
116
+ }
117
+ }
118
+
119
+ return merged;
120
+ }
121
+
22
122
  /**
23
123
  * Load policy from project root
24
124
  * @param {string} projectRoot - Project root directory
@@ -27,28 +127,52 @@ const defaultPolicy = JSON.parse(fs.readFileSync(defaultPolicyPath, "utf8"));
27
127
  */
28
128
  function loadPolicy(projectRoot, overrides = {}) {
29
129
  const policyPath = path.join(projectRoot, ".vibecheck", "policy.json");
130
+ const yamlRulesPath = path.join(projectRoot, ".vibecheck", "rules.yaml");
131
+ const legacyRcPath = path.join(projectRoot, ".vibecheckrc");
30
132
 
31
133
  let policy;
32
134
 
33
- // Try to load project policy
135
+ // Try to load project policy (JSON)
34
136
  if (fs.existsSync(policyPath)) {
35
137
  try {
36
138
  policy = JSON.parse(fs.readFileSync(policyPath, "utf8"));
37
139
  } catch (error) {
38
140
  throw new Error(`Failed to parse policy file: ${error.message}`);
39
141
  }
142
+ } else if (fs.existsSync(legacyRcPath)) {
143
+ // Try legacy .vibecheckrc
144
+ try {
145
+ policy = JSON.parse(fs.readFileSync(legacyRcPath, "utf8"));
146
+ } catch (error) {
147
+ // Might be YAML format
148
+ if (yaml) {
149
+ try {
150
+ policy = yaml.load(fs.readFileSync(legacyRcPath, "utf8"));
151
+ } catch {
152
+ throw new Error(`Failed to parse .vibecheckrc: ${error.message}`);
153
+ }
154
+ } else {
155
+ throw new Error(`Failed to parse .vibecheckrc: ${error.message}`);
156
+ }
157
+ }
40
158
  } else {
41
159
  // Use default policy
42
160
  policy = JSON.parse(JSON.stringify(defaultPolicy));
43
161
  }
44
162
 
163
+ // Load and merge YAML rules if present
164
+ if (fs.existsSync(yamlRulesPath)) {
165
+ const yamlRules = loadYamlRules(yamlRulesPath);
166
+ policy = mergeYamlRulesIntoPolicy(policy, yamlRules);
167
+ }
168
+
45
169
  // Apply overrides (deep merge)
46
170
  if (Object.keys(overrides).length > 0) {
47
171
  policy = deepMerge(policy, overrides);
48
172
  }
49
173
 
50
174
  // Validate against schema
51
- const ajv = new Ajv({ allErrors: true });
175
+ const ajv = new Ajv({ allErrors: true, strict: false });
52
176
  const validate = ajv.compile(schema);
53
177
  const valid = validate(policy);
54
178
 
@@ -120,7 +244,7 @@ function savePolicy(projectRoot, policy) {
120
244
  }
121
245
 
122
246
  // Validate before saving
123
- const ajv = new Ajv({ allErrors: true });
247
+ const ajv = new Ajv({ allErrors: true, strict: false });
124
248
  const validate = ajv.compile(schema);
125
249
  const valid = validate(policy);
126
250
 
@@ -134,10 +258,194 @@ function savePolicy(projectRoot, policy) {
134
258
  fs.writeFileSync(policyPath, JSON.stringify(policy, null, 2));
135
259
  }
136
260
 
261
+ /**
262
+ * Save YAML rules to project root
263
+ * @param {string} projectRoot - Project root directory
264
+ * @param {Array} rules - Rules array
265
+ */
266
+ function saveYamlRules(projectRoot, rules) {
267
+ if (!yaml) {
268
+ throw new Error("js-yaml not installed, cannot save YAML rules");
269
+ }
270
+
271
+ const vibecheckDir = path.join(projectRoot, ".vibecheck");
272
+ const rulesPath = path.join(vibecheckDir, "rules.yaml");
273
+
274
+ // Ensure .vibecheck directory exists
275
+ if (!fs.existsSync(vibecheckDir)) {
276
+ fs.mkdirSync(vibecheckDir, { recursive: true });
277
+ }
278
+
279
+ const content = yaml.dump({ rules }, {
280
+ lineWidth: 120,
281
+ quotingType: '"',
282
+ forceQuotes: false,
283
+ });
284
+
285
+ fs.writeFileSync(rulesPath, content);
286
+ }
287
+
288
+ /**
289
+ * Create default YAML rules file
290
+ * @param {string} projectRoot - Project root directory
291
+ */
292
+ function createDefaultYamlRules(projectRoot) {
293
+ const defaultRules = [
294
+ {
295
+ id: "no-phantom-env",
296
+ description: "Block undeclared environment variables",
297
+ match: { type: "env", exists: false },
298
+ action: "block",
299
+ message: "Env vars must be declared before use",
300
+ },
301
+ {
302
+ id: "no-ghost-routes",
303
+ description: "Block routes that reference unregistered paths",
304
+ match: { type: "route", registered: false },
305
+ action: "block",
306
+ message: "Routes must be registered in the router",
307
+ },
308
+ {
309
+ id: "core-folder-protection",
310
+ description: "Require confirmation for changes to core code",
311
+ match: { path: "^src/core/" },
312
+ action: "require_confirmation",
313
+ message: "Changes to core require explicit confirmation",
314
+ },
315
+ {
316
+ id: "no-silent-deletes",
317
+ description: "Require confirmation for file deletions",
318
+ match: { operation: "delete" },
319
+ action: "require_confirmation",
320
+ message: "File deletions require explicit confirmation",
321
+ },
322
+ {
323
+ id: "auth-changes-high-risk",
324
+ description: "Flag authentication changes as high risk",
325
+ match: { tags: ["auth", "security"] },
326
+ action: "warn",
327
+ block_if_domain: ["payments"],
328
+ message: "Auth changes require careful review",
329
+ },
330
+ {
331
+ id: "max-files-per-change",
332
+ description: "Limit number of files in a single change",
333
+ match: { files_count: { gt: 10 } },
334
+ action: "require_confirmation",
335
+ message: "Large changes (>10 files) require confirmation",
336
+ },
337
+ ];
338
+
339
+ saveYamlRules(projectRoot, defaultRules);
340
+ }
341
+
342
+ /**
343
+ * Get custom rules from policy
344
+ * @param {object} policy - Policy object
345
+ * @returns {Array} Custom rules
346
+ */
347
+ function getCustomRules(policy) {
348
+ return policy.customRules || [];
349
+ }
350
+
351
+ /**
352
+ * Evaluate a custom YAML rule against a change
353
+ * @param {object} rule - Rule definition
354
+ * @param {object} context - Change context
355
+ * @returns {object|null} Violation if rule triggered, null otherwise
356
+ */
357
+ function evaluateCustomRule(rule, context) {
358
+ if (!rule.enabled) return null;
359
+
360
+ const match = rule.match || {};
361
+ let triggered = false;
362
+
363
+ // Match by type
364
+ if (match.type) {
365
+ const claims = context.claims || [];
366
+ const typeClaims = claims.filter(c => c.type === match.type);
367
+
368
+ if (typeClaims.length > 0) {
369
+ if (match.exists === false) {
370
+ // Check if any claim doesn't exist
371
+ triggered = typeClaims.some(c => !c.exists);
372
+ } else if (match.registered === false) {
373
+ triggered = typeClaims.some(c => !c.registered);
374
+ } else {
375
+ triggered = true;
376
+ }
377
+ }
378
+ }
379
+
380
+ // Match by path pattern
381
+ if (match.path) {
382
+ const files = context.files || [];
383
+ const regex = new RegExp(match.path);
384
+ triggered = triggered || files.some(f => regex.test(f.path || f));
385
+ }
386
+
387
+ // Match by operation
388
+ if (match.operation) {
389
+ const operations = context.operations || [];
390
+ triggered = triggered || operations.some(op => op.type === match.operation);
391
+ }
392
+
393
+ // Match by file count
394
+ if (match.files_count) {
395
+ const fileCount = (context.files || []).length;
396
+ if (match.files_count.gt && fileCount > match.files_count.gt) {
397
+ triggered = true;
398
+ }
399
+ if (match.files_count.lt && fileCount < match.files_count.lt) {
400
+ triggered = true;
401
+ }
402
+ }
403
+
404
+ // Match by tags
405
+ if (match.tags && Array.isArray(match.tags)) {
406
+ const domains = context.domains || [];
407
+ triggered = triggered || match.tags.some(tag => domains.includes(tag));
408
+ }
409
+
410
+ if (!triggered) return null;
411
+
412
+ // Check block_if_domain upgrade
413
+ let severity = rule.severity || rule.action;
414
+ if (rule.block_if_domain && rule.block_if_domain.length > 0) {
415
+ const domains = context.domains || [];
416
+ if (rule.block_if_domain.some(d => domains.includes(d))) {
417
+ severity = "block";
418
+ }
419
+ }
420
+
421
+ return {
422
+ rule: rule.id,
423
+ type: "custom_rule",
424
+ severity,
425
+ message: rule.message || rule.description || `Rule ${rule.id} triggered`,
426
+ };
427
+ }
428
+
429
+ /**
430
+ * Check if YAML support is available
431
+ * @returns {boolean} YAML support available
432
+ */
433
+ function hasYamlSupport() {
434
+ return yaml !== null;
435
+ }
436
+
137
437
  module.exports = {
138
438
  loadPolicy,
139
439
  getDefaultPolicy,
140
440
  savePolicy,
441
+ loadYamlRules,
442
+ saveYamlRules,
443
+ createDefaultYamlRules,
444
+ getCustomRules,
445
+ evaluateCustomRule,
446
+ mergeYamlRulesIntoPolicy,
447
+ normalizeYamlRules,
448
+ hasYamlSupport,
141
449
  schema,
142
450
  defaultPolicy
143
451
  };
@@ -2,12 +2,97 @@
2
2
  * Ghost Env Rule
3
3
  *
4
4
  * Blocks if process.env.X used but not declared.
5
+ * Includes smart whitelisting to reduce false positives.
5
6
  */
6
7
 
7
8
  "use strict";
8
9
 
9
10
  const { CLAIM_TYPES } = require("../../claims/claim-types");
10
11
 
12
+ /**
13
+ * Common environment variables that are safe to use without explicit declaration.
14
+ * These are well-known Node.js, Next.js, and common deployment platform vars.
15
+ */
16
+ const SAFE_ENV_VARS = new Set([
17
+ // Node.js core
18
+ "NODE_ENV",
19
+ "NODE_OPTIONS",
20
+ "NODE_PATH",
21
+ "NODE_DEBUG",
22
+
23
+ // Next.js / React
24
+ "NEXT_PUBLIC_", // Prefix pattern
25
+ "REACT_APP_", // Prefix pattern
26
+ "VERCEL",
27
+ "VERCEL_ENV",
28
+ "VERCEL_URL",
29
+ "NEXT_RUNTIME",
30
+
31
+ // Common deployment
32
+ "PORT",
33
+ "HOST",
34
+ "HOSTNAME",
35
+ "CI",
36
+ "DEBUG",
37
+ "LOG_LEVEL",
38
+ "TZ",
39
+ "LANG",
40
+ "HOME",
41
+ "USER",
42
+ "PATH",
43
+ "PWD",
44
+ "SHELL",
45
+ "TERM",
46
+
47
+ // Testing
48
+ "TEST",
49
+ "JEST_WORKER_ID",
50
+ "VITEST",
51
+
52
+ // Build tools
53
+ "npm_package_name",
54
+ "npm_package_version",
55
+ "npm_lifecycle_event",
56
+ ]);
57
+
58
+ /**
59
+ * Prefix patterns that are safe
60
+ */
61
+ const SAFE_ENV_PREFIXES = [
62
+ "NEXT_PUBLIC_",
63
+ "REACT_APP_",
64
+ "VITE_",
65
+ "npm_",
66
+ "GITHUB_",
67
+ "CI_",
68
+ "VERCEL_",
69
+ "RAILWAY_",
70
+ "RENDER_",
71
+ "HEROKU_",
72
+ "AWS_",
73
+ ];
74
+
75
+ /**
76
+ * Check if an env var is in the safe list
77
+ * @param {string} varName - Environment variable name
78
+ * @returns {boolean} Is safe
79
+ */
80
+ function isSafeEnvVar(varName) {
81
+ if (!varName || typeof varName !== 'string') return false;
82
+
83
+ const normalized = varName.toUpperCase();
84
+
85
+ // Direct match
86
+ if (SAFE_ENV_VARS.has(normalized)) return true;
87
+
88
+ // Prefix match
89
+ for (const prefix of SAFE_ENV_PREFIXES) {
90
+ if (normalized.startsWith(prefix)) return true;
91
+ }
92
+
93
+ return false;
94
+ }
95
+
11
96
  /**
12
97
  * Evaluate ghost env rule
13
98
  * @param {object} params
@@ -23,6 +108,9 @@ function evaluate({ claims, evidence, policy }) {
23
108
  return null;
24
109
  }
25
110
 
111
+ // Get custom whitelist from policy
112
+ const customWhitelist = new Set(ruleConfig.whitelist || []);
113
+
26
114
  // Find env claims with UNPROVEN evidence
27
115
  for (let i = 0; i < claims.length; i++) {
28
116
  const claim = claims[i];
@@ -31,10 +119,34 @@ function evaluate({ claims, evidence, policy }) {
31
119
  const ev = evidence.find(e => e.claimId === `claim_${i}`);
32
120
 
33
121
  if (ev && ev.result === "UNPROVEN") {
122
+ const envVar = String(claim.value || claim.key || "");
123
+
124
+ // Skip if it's a known safe env var
125
+ if (isSafeEnvVar(envVar)) {
126
+ continue;
127
+ }
128
+
129
+ // Skip if in custom whitelist
130
+ if (customWhitelist.has(envVar)) {
131
+ continue;
132
+ }
133
+
134
+ // Skip if it's a fallback pattern (e.g., process.env.X || 'default')
135
+ if (claim.hasFallback) {
136
+ // Downgrade to warning instead of block
137
+ return {
138
+ rule: "ghost_env",
139
+ severity: "warn",
140
+ message: `Env var ${envVar} is used with fallback but not declared`,
141
+ claimId: `claim_${i}`,
142
+ claim
143
+ };
144
+ }
145
+
34
146
  return {
35
147
  rule: "ghost_env",
36
148
  severity: ruleConfig.severity || "block",
37
- message: `Ghost env var: ${claim.value} is used but not declared`,
149
+ message: `Ghost env var: ${envVar} is used but not declared`,
38
150
  claimId: `claim_${i}`,
39
151
  claim
40
152
  };
@@ -2,12 +2,120 @@
2
2
  * Ghost Route Rule
3
3
  *
4
4
  * Blocks if UI references route not registered in truthpack.
5
+ * Includes smart detection of external APIs and common patterns.
5
6
  */
6
7
 
7
8
  "use strict";
8
9
 
9
10
  const { CLAIM_TYPES } = require("../../claims/claim-types");
10
11
 
12
+ /**
13
+ * Patterns that indicate an external API (not a local route)
14
+ */
15
+ const EXTERNAL_API_PATTERNS = [
16
+ // Full URLs
17
+ /^https?:\/\//i,
18
+ /^\/\/[a-z]/i,
19
+
20
+ // Common external API domains
21
+ /api\.github\.com/i,
22
+ /api\.stripe\.com/i,
23
+ /api\.openai\.com/i,
24
+ /api\.anthropic\.com/i,
25
+ /api\.twilio\.com/i,
26
+ /api\.sendgrid\.com/i,
27
+ /graph\.facebook\.com/i,
28
+ /api\.twitter\.com/i,
29
+ /googleapis\.com/i,
30
+ /aws\.amazon\.com/i,
31
+ /cloudflare\.com/i,
32
+ /api\.clerk\.dev/i,
33
+ /api\.auth0\.com/i,
34
+ /api\.supabase\.co/i,
35
+ /api\.vercel\.app/i,
36
+
37
+ // GraphQL endpoints
38
+ /graphql/i,
39
+
40
+ // Webhook patterns
41
+ /webhook/i,
42
+ /hook\//i,
43
+ ];
44
+
45
+ /**
46
+ * Path patterns that are safe to skip (dynamic or template)
47
+ */
48
+ const SAFE_ROUTE_PATTERNS = [
49
+ // Dynamic segments
50
+ /\$\{/, // Template literals
51
+ /\[.*\]/, // Dynamic route segments [id]
52
+ /:\w+/, // URL params :id
53
+ /\{\{.*\}\}/, // Template syntax
54
+
55
+ // Hash-only routes
56
+ /^#/,
57
+
58
+ // Query-only
59
+ /^\?/,
60
+
61
+ // Empty or undefined
62
+ /^undefined$/i,
63
+ /^null$/i,
64
+ ];
65
+
66
+ /**
67
+ * Check if a route is external
68
+ * @param {string} route - Route path
69
+ * @returns {boolean} Is external
70
+ */
71
+ function isExternalRoute(route) {
72
+ if (!route || typeof route !== 'string') return true;
73
+
74
+ const trimmed = route.trim();
75
+
76
+ // Check external patterns
77
+ for (const pattern of EXTERNAL_API_PATTERNS) {
78
+ if (pattern.test(trimmed)) return true;
79
+ }
80
+
81
+ return false;
82
+ }
83
+
84
+ /**
85
+ * Check if a route should be skipped
86
+ * @param {string} route - Route path
87
+ * @returns {boolean} Should skip
88
+ */
89
+ function shouldSkipRoute(route) {
90
+ if (!route || typeof route !== 'string') return true;
91
+
92
+ const trimmed = route.trim();
93
+
94
+ // Skip empty routes
95
+ if (trimmed.length === 0) return true;
96
+
97
+ // Check safe patterns
98
+ for (const pattern of SAFE_ROUTE_PATTERNS) {
99
+ if (pattern.test(trimmed)) return true;
100
+ }
101
+
102
+ return false;
103
+ }
104
+
105
+ /**
106
+ * Check if route is a local API route
107
+ * @param {string} route - Route path
108
+ * @returns {boolean} Is local API
109
+ */
110
+ function isLocalApiRoute(route) {
111
+ if (!route || typeof route !== 'string') return false;
112
+
113
+ const trimmed = route.trim().toLowerCase();
114
+
115
+ // Must start with /api/ to be a local Next.js API route
116
+ return trimmed.startsWith('/api/');
117
+ }
118
+
11
119
  /**
12
120
  * Evaluate ghost route rule
13
121
  * @param {object} params
@@ -23,6 +131,9 @@ function evaluate({ claims, evidence, policy }) {
23
131
  return null;
24
132
  }
25
133
 
134
+ // Get custom whitelist from policy
135
+ const customWhitelist = new Set(ruleConfig.whitelist || []);
136
+
26
137
  // Find route claims with UNPROVEN evidence
27
138
  for (let i = 0; i < claims.length; i++) {
28
139
  const claim = claims[i];
@@ -30,23 +141,39 @@ function evaluate({ claims, evidence, policy }) {
30
141
  if (claim.type === CLAIM_TYPES.ROUTE || claim.type === CLAIM_TYPES.HTTP_CALL) {
31
142
  const ev = evidence.find(e => e.claimId === `claim_${i}`);
32
143
 
33
- // Skip if evidence shows it's an external API call or ignored file
34
- if (ev && (ev.result === "PROVEN" && (ev.reason?.includes("external API") || ev.reason?.includes("ignored file")))) {
144
+ // Skip if evidence shows it's already proven
145
+ if (ev && ev.result === "PROVEN") {
35
146
  continue;
36
147
  }
37
148
 
38
149
  if (ev && ev.result === "UNPROVEN") {
39
- // Double-check: skip external API calls (routes not starting with /api/)
40
150
  const routePath = String(claim.value || "").trim();
41
- if (!routePath.startsWith('/api/')) {
42
- // This is an external API call, not a Next.js route - skip
151
+
152
+ // Skip dynamic or template routes
153
+ if (shouldSkipRoute(routePath)) {
154
+ continue;
155
+ }
156
+
157
+ // Skip external API calls
158
+ if (isExternalRoute(routePath)) {
159
+ continue;
160
+ }
161
+
162
+ // Only flag local API routes
163
+ if (!isLocalApiRoute(routePath)) {
164
+ // Not a local API route - could be page navigation, skip
165
+ continue;
166
+ }
167
+
168
+ // Skip if in custom whitelist
169
+ if (customWhitelist.has(routePath)) {
43
170
  continue;
44
171
  }
45
172
 
46
173
  return {
47
174
  rule: "ghost_route",
48
175
  severity: ruleConfig.severity || "block",
49
- message: `Ghost route: ${claim.value} is referenced but not registered in truthpack`,
176
+ message: `Ghost route: ${routePath} is referenced but not registered in truthpack`,
50
177
  claimId: `claim_${i}`,
51
178
  claim
52
179
  };