thumbgate 1.27.18 → 1.27.20

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 (170) hide show
  1. package/.claude/commands/dashboard.md +15 -0
  2. package/.claude/commands/thumbgate-blocked.md +27 -0
  3. package/.claude/commands/thumbgate-dashboard.md +15 -0
  4. package/.claude/commands/thumbgate-doctor.md +30 -0
  5. package/.claude/commands/thumbgate-guard.md +36 -0
  6. package/.claude/commands/thumbgate-protect.md +30 -0
  7. package/.claude/commands/thumbgate-rules.md +30 -0
  8. package/.claude-plugin/plugin.json +4 -3
  9. package/.well-known/agentic-verify.txt +1 -0
  10. package/.well-known/llms.txt +33 -12
  11. package/.well-known/mcp/server-card.json +8 -8
  12. package/README.md +246 -30
  13. package/adapters/claude/.mcp.json +2 -2
  14. package/adapters/codex/config.toml +2 -2
  15. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  16. package/adapters/gemini/function-declarations.json +1 -0
  17. package/adapters/letta/README.md +41 -0
  18. package/adapters/letta/thumbgate-letta-adapter.js +133 -0
  19. package/adapters/mcp/server-stdio.js +263 -11
  20. package/adapters/opencode/opencode.json +1 -1
  21. package/adapters/policy-engine/ethicore-guardian-client.js +68 -0
  22. package/adapters/policy-engine/thumbgate-policy-engine-adapter.js +260 -0
  23. package/bench/observability-eval-suite.json +26 -0
  24. package/bench/thumbgate-bench.json +2 -2
  25. package/bin/cli.js +1456 -122
  26. package/bin/dashboard-cli.js +7 -0
  27. package/bin/postinstall.js +11 -27
  28. package/commands/dashboard.md +15 -0
  29. package/commands/thumbgate-dashboard.md +15 -0
  30. package/config/gate-classifier-routing.json +98 -0
  31. package/config/gate-templates.json +216 -0
  32. package/config/gates/claim-verification.json +12 -0
  33. package/config/gates/default.json +31 -2
  34. package/config/github-about.json +2 -2
  35. package/config/mcp-allowlists.json +23 -13
  36. package/config/merge-quality-checks.json +0 -1
  37. package/config/model-candidates.json +121 -6
  38. package/config/post-deploy-marketing-pages.json +80 -0
  39. package/config/tessl-tiles.json +1 -3
  40. package/openapi/openapi.yaml +12 -0
  41. package/package.json +225 -100
  42. package/public/about.html +162 -0
  43. package/public/agent-manager.html +179 -0
  44. package/public/agents-cost-savings.html +153 -0
  45. package/public/ai-malpractice-prevention.html +818 -0
  46. package/public/assets/brand/github-social-preview.png +0 -0
  47. package/public/assets/brand/thumbgate-icon-512.png +0 -0
  48. package/public/assets/brand/thumbgate-icon-pro-512.png +0 -0
  49. package/public/assets/brand/thumbgate-icon-team-512.png +0 -0
  50. package/public/assets/brand/thumbgate-logo-1200x360.png +0 -0
  51. package/public/assets/brand/thumbgate-logo-transparent.svg +28 -0
  52. package/public/assets/brand/thumbgate-mark-inline-v3.svg +18 -0
  53. package/public/assets/brand/thumbgate-mark-pro.svg +23 -0
  54. package/public/assets/brand/thumbgate-mark-team.svg +26 -0
  55. package/public/assets/brand/thumbgate-mark.svg +21 -0
  56. package/public/assets/brand/thumbgate-wordmark.svg +20 -0
  57. package/public/assets/claude-thumbgate-statusbar.svg +8 -0
  58. package/public/assets/codex-thumbgate-statusbar-test.svg +9 -0
  59. package/public/assets/legal-intake-control-flow.svg +66 -0
  60. package/public/blog.html +4 -4
  61. package/public/brand/thumbgate-mark.svg +19 -0
  62. package/public/brand/thumbgate-og.svg +16 -0
  63. package/public/chatgpt-app.html +330 -0
  64. package/public/codex-enterprise.html +123 -0
  65. package/public/codex-plugin.html +72 -20
  66. package/public/compare.html +31 -8
  67. package/public/dashboard.html +930 -166
  68. package/public/diagnostic.html +345 -0
  69. package/public/federal.html +2 -2
  70. package/public/guide.html +33 -13
  71. package/public/index.html +469 -111
  72. package/public/install.html +193 -0
  73. package/public/js/buyer-intent.js +672 -0
  74. package/public/learn.html +183 -18
  75. package/public/lessons.html +168 -10
  76. package/public/numbers.html +7 -7
  77. package/public/pricing.html +399 -0
  78. package/public/pro.html +34 -11
  79. package/scripts/action-receipts.js +324 -0
  80. package/scripts/activation-quickstart.js +187 -0
  81. package/scripts/agent-memory-lifecycle.js +211 -0
  82. package/scripts/agent-operations-planner.js +621 -0
  83. package/scripts/agent-readiness.js +20 -3
  84. package/scripts/agent-reward-model.js +53 -1
  85. package/scripts/ai-component-inventory.js +367 -0
  86. package/scripts/async-eval-observability.js +236 -0
  87. package/scripts/audit.js +65 -0
  88. package/scripts/auto-promote-gates.js +82 -10
  89. package/scripts/auto-wire-hooks.js +14 -0
  90. package/scripts/aws-blocks-guardrails.js +272 -0
  91. package/scripts/billing.js +93 -1
  92. package/scripts/bot-detection.js +61 -3
  93. package/scripts/build-metadata.js +50 -10
  94. package/scripts/classifier-routing.js +130 -0
  95. package/scripts/cli-feedback.js +4 -2
  96. package/scripts/cli-schema.js +97 -0
  97. package/scripts/cli-telemetry.js +6 -1
  98. package/scripts/commercial-offer.js +82 -2
  99. package/scripts/context-manager.js +74 -6
  100. package/scripts/dashboard-chat.js +332 -0
  101. package/scripts/dashboard.js +68 -2
  102. package/scripts/export-databricks-bundle.js +5 -1
  103. package/scripts/export-dpo-pairs.js +7 -2
  104. package/scripts/feedback-aggregate.js +281 -0
  105. package/scripts/feedback-loop.js +123 -1
  106. package/scripts/feedback-quality.js +87 -0
  107. package/scripts/feedback-sanitizer.js +105 -0
  108. package/scripts/filesystem-search.js +35 -10
  109. package/scripts/gate-stats.js +89 -0
  110. package/scripts/gates-engine.js +1176 -85
  111. package/scripts/gemini-embedding-policy.js +2 -1
  112. package/scripts/hook-runtime.js +20 -14
  113. package/scripts/hook-stop-anti-claim.js +301 -0
  114. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  115. package/scripts/hybrid-feedback-context.js +142 -7
  116. package/scripts/install-shim.js +87 -0
  117. package/scripts/lesson-inference.js +8 -3
  118. package/scripts/lesson-search.js +17 -1
  119. package/scripts/license.js +10 -10
  120. package/scripts/llm-client.js +169 -4
  121. package/scripts/local-model-profile.js +15 -8
  122. package/scripts/mcp-config.js +7 -1
  123. package/scripts/mcp-oauth.js +293 -0
  124. package/scripts/memory-scope-readiness.js +159 -0
  125. package/scripts/meta-agent-loop.js +36 -0
  126. package/scripts/noop-detect.js +285 -0
  127. package/scripts/operational-integrity.js +39 -5
  128. package/scripts/oss-pr-opportunity-scout.js +35 -5
  129. package/scripts/parallel-workflow-orchestrator.js +293 -0
  130. package/scripts/plan-gate.js +243 -0
  131. package/scripts/plausible-domain-config.js +99 -0
  132. package/scripts/plausible-server-events.js +9 -6
  133. package/scripts/pro-local-dashboard.js +4 -4
  134. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  135. package/scripts/published-cli.js +0 -8
  136. package/scripts/qa-scenario-planner.js +136 -0
  137. package/scripts/rate-limiter.js +64 -13
  138. package/scripts/repeat-metric.js +137 -0
  139. package/scripts/secret-fixture-tokens.js +61 -0
  140. package/scripts/secret-redaction.js +166 -0
  141. package/scripts/secret-scanner.js +44 -5
  142. package/scripts/security-scanner.js +260 -10
  143. package/scripts/self-distill-agent.js +3 -1
  144. package/scripts/self-harness-optimizer.js +141 -0
  145. package/scripts/self-healing-check.js +193 -0
  146. package/scripts/self-protection.js +90 -0
  147. package/scripts/seo-gsd.js +916 -7
  148. package/scripts/silent-failure-cluster.js +531 -0
  149. package/scripts/statusline-cache-path.js +17 -2
  150. package/scripts/statusline-cache-read.js +57 -0
  151. package/scripts/statusline-local-stats.js +9 -1
  152. package/scripts/statusline-meta.js +28 -2
  153. package/scripts/statusline.sh +20 -4
  154. package/scripts/sync-telemetry-from-prod.js +374 -0
  155. package/scripts/telemetry-analytics.js +357 -0
  156. package/scripts/thompson-sampling.js +31 -10
  157. package/scripts/thumbgate-bench.js +16 -1
  158. package/scripts/thumbgate-search.js +85 -19
  159. package/scripts/tool-contract-validator.js +76 -0
  160. package/scripts/tool-registry.js +169 -1
  161. package/scripts/trajectory-scorer.js +63 -0
  162. package/scripts/vector-store.js +45 -0
  163. package/scripts/verify-marketing-pages-deployed.js +212 -0
  164. package/scripts/visitor-journey.js +172 -0
  165. package/scripts/workflow-sentinel.js +286 -53
  166. package/scripts/workspace-evolver.js +62 -2
  167. package/src/api/server.js +2683 -319
  168. package/.claude-plugin/marketplace.json +0 -85
  169. package/adapters/chatgpt/openapi.yaml +0 -1695
  170. package/scripts/bot-detector.js +0 -50
@@ -0,0 +1,65 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scripts/audit.js
5
+ *
6
+ * Heuristic-based AI bill auditor. Finds repeated mistakes in agent transcripts.
7
+ */
8
+
9
+ const fs = require('fs');
10
+
11
+ const PATTERNS = [
12
+ {
13
+ id: 'force-push-retry',
14
+ name: 'git push --force after correction',
15
+ regex: /git\s+push.*--force/gi,
16
+ tokenEstimate: 6000,
17
+ costPerRepeat: 0.44,
18
+ why: 'Full diff context reload on error.'
19
+ },
20
+ {
21
+ id: 'import-loop',
22
+ name: 'Hallucinated import retry',
23
+ regex: /(Cannot find module|Module not found|import .* from .*error)/gi,
24
+ tokenEstimate: 4000,
25
+ costPerRepeat: 0.12,
26
+ why: 'Re-indexing and path searching.'
27
+ },
28
+ {
29
+ id: 'apology-loop',
30
+ name: '"I apologize" retry cycle',
31
+ regex: /(I apologize|Let me try a different approach|I will now attempt)/gi,
32
+ tokenEstimate: 5000,
33
+ costPerRepeat: 0.15,
34
+ why: 'Reasoning chain reset.'
35
+ }
36
+ ];
37
+
38
+ function runAudit(filePath) {
39
+ if (!fs.existsSync(filePath)) {
40
+ return { error: 'File not found: ' + filePath };
41
+ }
42
+
43
+ const content = fs.readFileSync(filePath, 'utf8');
44
+ const results = [];
45
+ let totalWaste = 0;
46
+
47
+ PATTERNS.forEach(p => {
48
+ const matches = (content.match(p.regex) || []).length;
49
+ if (matches > 1) { // It's only a "repeat" if it happens more than once
50
+ const repeats = matches - 1;
51
+ const waste = repeats * p.costPerRepeat;
52
+ totalWaste += waste;
53
+ results.push({
54
+ pattern: p.name,
55
+ occurrences: repeats,
56
+ waste: waste.toFixed(2),
57
+ why: p.why
58
+ });
59
+ }
60
+ });
61
+
62
+ return { results, totalWaste: totalWaste.toFixed(2) };
63
+ }
64
+
65
+ module.exports = { runAudit, PATTERNS };
@@ -58,6 +58,47 @@ function readJSONL(filePath) {
58
58
  }).filter(Boolean);
59
59
  }
60
60
 
61
+ // --- Self-Harness stage 3: regression-gated promotion -----------------------
62
+ // Inspired by "Self-Harness: Harnesses That Improve Themselves" (arXiv 2606.09498).
63
+ // Stages 1-2 (weakness mining -> rule extraction) already exist via lesson
64
+ // inference + this promoter. Stage 3 — accept a harness change only after
65
+ // regression-testing it does not degrade behavior — was missing: a noisy 3x
66
+ // capture could hard-block an over-broad pattern with no check that it wouldn't
67
+ // have wrongly blocked actions that were previously ALLOWED. This replays a
68
+ // candidate BLOCK rule against the audit trail's prior `allow` decisions; if it
69
+ // would have blocked safe actions, the caller quarantines it to `warn` instead.
70
+ const REGRESSION_FALSE_BLOCK_LIMIT = 0; // any prior safe action it would block => quarantine
71
+
72
+ function getAuditTrailPath() {
73
+ return path.join(path.dirname(getFeedbackLogPath()), 'audit-trail.jsonl');
74
+ }
75
+
76
+ // Returns { falseBlocks, allowSampleSize } or null when there is no history /
77
+ // matcher available — in which case the caller promotes as usual (fail-open to
78
+ // existing behavior, since regression gating is an enhancement, not a hard gate).
79
+ function regressionCheck(gate, options = {}) {
80
+ const auditPath = options.auditTrailPath || getAuditTrailPath();
81
+ const entries = readJSONL(auditPath);
82
+ if (!entries.length) return null;
83
+ // Lazy-require to avoid the gates-engine <-> auto-promote-gates require cycle.
84
+ let matchesGate;
85
+ try { ({ matchesGate } = require('./gates-engine')); } catch { return null; }
86
+ if (typeof matchesGate !== 'function') return null;
87
+ const allowed = entries.filter((e) => e && e.decision === 'allow' && e.toolName);
88
+ if (!allowed.length) return null;
89
+ let falseBlocks = 0;
90
+ for (const e of allowed) {
91
+ try {
92
+ if (matchesGate(gate, e.toolName, e.toolInput || {})) falseBlocks += 1;
93
+ } catch { /* a bad pattern/entry never counts as a false block */ }
94
+ }
95
+ return { falseBlocks, allowSampleSize: allowed.length };
96
+ }
97
+
98
+ function safeRegressionCheck(gate, options) {
99
+ try { return regressionCheck(gate, options); } catch { return null; }
100
+ }
101
+
61
102
  function loadAutoGates() {
62
103
  const autoGatesPath = getAutoGatesPath();
63
104
  if (!fs.existsSync(autoGatesPath)) {
@@ -193,9 +234,9 @@ function patternToGateId(key) {
193
234
  return 'auto-' + key.replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').slice(0, 50).toLowerCase();
194
235
  }
195
236
 
196
- function buildGateRule(group) {
197
- const action = group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn');
198
- const severity = action === 'block' ? 'critical' : 'medium';
237
+ function buildGateRule(group, actionOverride) {
238
+ const action = actionOverride || (group.count === 'MANUAL' ? group.manualAction || 'block' : (group.count >= BLOCK_THRESHOLD ? 'block' : 'warn'));
239
+ const severity = action === 'block' ? 'critical' : action === 'approve' ? 'high' : 'medium';
199
240
  const context = group.latestContext.slice(0, 120);
200
241
  const kind = group.key.startsWith('diagnosis:')
201
242
  ? 'repeated diagnosis'
@@ -332,7 +373,8 @@ function forcePromote(context, action = 'block') {
332
373
  return { gateId, action, totalGates: data.gates.length };
333
374
  }
334
375
 
335
- function promote(feedbackLogPath) {
376
+ function promote(feedbackLogPath, options) {
377
+ const opts = options || {};
336
378
  const logPath = feedbackLogPath || getFeedbackLogPath();
337
379
  const entries = readJSONL(logPath);
338
380
  const groups = groupNegativeFeedback(entries, WINDOW_DAYS);
@@ -357,17 +399,38 @@ function promote(feedbackLogPath) {
357
399
  const existing = data.gates[existingIdx];
358
400
  const newAction = group.count >= BLOCK_THRESHOLD ? 'block' : 'warn';
359
401
  if (existing.action !== newAction && newAction === 'block') {
360
- // Upgrade from warn to block
361
- data.gates[existingIdx] = { ...existing, action: 'block', severity: 'critical', occurrences: group.count, upgradedAt: new Date().toISOString() };
362
- promotions.push({ type: 'upgrade', gateId, from: existing.action, to: 'block', occurrences: group.count });
402
+ // Self-Harness stage 3: regression-test before upgrading warn -> block.
403
+ const regression = opts.skipRegression ? null : safeRegressionCheck(buildGateRule(group, 'block'), opts);
404
+ if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
405
+ // Would block prior safe actions — hold at warn instead of upgrading.
406
+ promotions.push({ type: 'upgrade-quarantined', gateId, from: existing.action, occurrences: group.count, falseBlocks: regression.falseBlocks });
407
+ } else {
408
+ // Upgrade from warn to block
409
+ data.gates[existingIdx] = { ...existing, action: 'block', severity: 'critical', occurrences: group.count, upgradedAt: new Date().toISOString() };
410
+ promotions.push({ type: 'upgrade', gateId, from: existing.action, to: 'block', occurrences: group.count });
411
+ }
363
412
  }
364
413
  // Update occurrence count even if no action change
365
414
  data.gates[existingIdx].occurrences = group.count;
366
415
  continue;
367
416
  }
368
417
 
369
- // New gate
370
- const gate = buildGateRule(group);
418
+ // New gate — respect explicit gateAction override (e.g. 'approve' for human-approval rules)
419
+ const gate = buildGateRule(group, opts.gateAction);
420
+
421
+ // Self-Harness stage 3: before a feedback rule goes live as a hard block,
422
+ // regression-test it against prior allowed actions. If it would have blocked
423
+ // safe actions, quarantine it to `warn` instead of `block`.
424
+ let regression = null;
425
+ if (gate.action === 'block' && !opts.gateAction && !opts.skipRegression) {
426
+ regression = safeRegressionCheck(gate, opts);
427
+ if (regression && regression.falseBlocks > REGRESSION_FALSE_BLOCK_LIMIT) {
428
+ gate.action = 'warn';
429
+ gate.severity = 'medium';
430
+ gate.quarantined = true;
431
+ gate.regression = regression;
432
+ }
433
+ }
371
434
 
372
435
  // Enforce max limit — rotate oldest
373
436
  if (data.gates.length >= MAX_AUTO_GATES) {
@@ -376,7 +439,13 @@ function promote(feedbackLogPath) {
376
439
  }
377
440
 
378
441
  data.gates.push(gate);
379
- promotions.push({ type: 'new', gateId: gate.id, action: gate.action, occurrences: group.count });
442
+ promotions.push({
443
+ type: gate.quarantined ? 'new-quarantined' : 'new',
444
+ gateId: gate.id,
445
+ action: gate.action,
446
+ occurrences: group.count,
447
+ ...(gate.quarantined ? { falseBlocks: regression.falseBlocks, allowSampleSize: regression.allowSampleSize } : {}),
448
+ });
380
449
  }
381
450
 
382
451
  // Log promotions
@@ -437,6 +506,9 @@ module.exports = {
437
506
  groupNegativeFeedback,
438
507
  patternToGateId,
439
508
  buildGateRule,
509
+ regressionCheck,
510
+ getAuditTrailPath,
511
+ REGRESSION_FALSE_BLOCK_LIMIT,
440
512
  extractPatternKey,
441
513
  normalizeCommandSignature,
442
514
  isNegative,
@@ -27,6 +27,7 @@ const {
27
27
  statuslineCommand,
28
28
  userPromptHookCommand,
29
29
  } = require('./hook-runtime');
30
+ const { installShim } = require('./install-shim');
30
31
 
31
32
  function getHome() {
32
33
  return process.env.HOME || process.env.USERPROFILE || '';
@@ -338,6 +339,19 @@ function wireClaudeHooks(options) {
338
339
  options.projectSettingsPath || claudeProjectSettingsPath(options.projectDir);
339
340
  const dryRun = options.dryRun || false;
340
341
  const projectDir = options.projectDir || process.cwd();
342
+
343
+ // --- Install stable shim before resolving hook commands ---
344
+ // The shim at ~/.thumbgate/bin/thumbgate-hook always resolves @latest,
345
+ // so hooks never go stale across version bumps (Volta-style pattern).
346
+ // Skip in source-checkout mode — developers use direct node commands.
347
+ if (!dryRun && !require('./mcp-config').isSourceCheckout(path.join(__dirname, '..'))) {
348
+ try {
349
+ installShim();
350
+ } catch {
351
+ // Non-fatal: fall back to version-pinned commands
352
+ }
353
+ }
354
+
341
355
  const desiredStatusLine = statuslineCommand();
342
356
 
343
357
  // --- Step 0: clean up stale hooks from BOTH settings locations ---
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+
7
+ const AWS_BLOCKS_DEPENDENCY_PATTERN = /(^|\/)@aws-blocks\/(?:blocks|[^/\s"']+)/;
8
+ const PRODUCTION_DEPLOY_PATTERNS = [
9
+ /\bcdk\s+deploy\b/i,
10
+ /\bnpm\s+run\s+deploy\b/i,
11
+ /\bpnpm\s+deploy\b/i,
12
+ /\byarn\s+deploy\b/i,
13
+ /\bsst\s+deploy\b/i,
14
+ /\bblocks?\s+deploy\b/i,
15
+ ];
16
+ const DESTROY_PATTERNS = [
17
+ /\bcdk\s+destroy\b/i,
18
+ /\bnpm\s+run\s+destroy\b/i,
19
+ /\bpnpm\s+destroy\b/i,
20
+ /\byarn\s+destroy\b/i,
21
+ /\bsandbox\b.*--destroy\b/i,
22
+ /\b--destroy\b/i,
23
+ /\baws\s+cloudformation\s+delete-stack\b/i,
24
+ ];
25
+ const DESTRUCTIVE_AWS_PATTERNS = [
26
+ /\baws\s+dynamodb\s+delete-table\b/i,
27
+ /\baws\s+rds\s+delete-db-instance\b/i,
28
+ /\baws\s+s3\s+rm\b[\s\S]*--recursive\b/i,
29
+ /\baws\s+lambda\s+delete-function\b/i,
30
+ /\baws\s+bedrock-agent\s+delete-/i,
31
+ /\baws\s+bedrock-agent-runtime\b/i,
32
+ ];
33
+
34
+ function toList(value) {
35
+ if (Array.isArray(value)) return value.map(String).map((entry) => entry.trim()).filter(Boolean);
36
+ if (typeof value === 'string') return value.split(',').map((entry) => entry.trim()).filter(Boolean);
37
+ return [];
38
+ }
39
+
40
+ function normalizeText(value) {
41
+ if (value == null) return '';
42
+ if (typeof value === 'string') return value;
43
+ try {
44
+ return JSON.stringify(value);
45
+ } catch {
46
+ return String(value);
47
+ }
48
+ }
49
+
50
+ function readPackageJson(projectDir) {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'));
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ function matchesAny(text, patterns) {
59
+ return patterns.some((pattern) => pattern.test(text));
60
+ }
61
+
62
+ function hasDestructiveSql(text) {
63
+ if (/\bdrop\s+(table|database|schema|index|column)\b/i.test(text)) return true;
64
+ if (/\btruncate\s+table\b/i.test(text)) return true;
65
+ if (/\bdelete\s+from\s+[\w".-]+(?:\s*;|\s*$)/i.test(text)) return true;
66
+ const updateMatch = /\bupdate\s+[\w".-]+\s+set\b/i.exec(text);
67
+ if (!updateMatch) return false;
68
+ const afterUpdate = text.slice(updateMatch.index, updateMatch.index + 180);
69
+ return !/\bwhere\b/i.test(afterUpdate);
70
+ }
71
+
72
+ function packageUsesAwsBlocks(pkg) {
73
+ if (!pkg || typeof pkg !== 'object') return false;
74
+ const dependencyText = JSON.stringify({
75
+ dependencies: pkg.dependencies || {},
76
+ devDependencies: pkg.devDependencies || {},
77
+ peerDependencies: pkg.peerDependencies || {},
78
+ optionalDependencies: pkg.optionalDependencies || {},
79
+ });
80
+ const scriptsText = JSON.stringify(pkg.scripts || {});
81
+ return AWS_BLOCKS_DEPENDENCY_PATTERN.test(dependencyText) || /aws-blocks|blocks-app|cdk|sandbox/i.test(scriptsText);
82
+ }
83
+
84
+ function detectAwsBlocksProject(projectDir = process.cwd()) {
85
+ const pkg = readPackageJson(projectDir);
86
+ if (packageUsesAwsBlocks(pkg)) return true;
87
+ return fs.existsSync(path.join(projectDir, 'aws-blocks', 'index.ts'))
88
+ || fs.existsSync(path.join(projectDir, 'aws-blocks', 'index.js'))
89
+ || fs.existsSync(path.join(projectDir, 'blocks.spec.json'));
90
+ }
91
+
92
+ function detectAction(input = {}) {
93
+ const command = normalizeText(input.command || input.args || input.shell || input.toolInput);
94
+ const toolName = normalizeText(input.toolName || input.tool || input.name);
95
+ const code = normalizeText(input.code || input.file || input.diff || input.body);
96
+ const combined = `${toolName}\n${command}\n${code}`;
97
+ const lower = combined.toLowerCase();
98
+
99
+ const signals = [];
100
+ const add = (id, label, severity = 'medium') => signals.push({ id, label, severity });
101
+
102
+ if (matchesAny(combined, PRODUCTION_DEPLOY_PATTERNS)) {
103
+ add('aws-blocks-production-deploy', 'production deploy from an AWS Blocks workflow', 'high');
104
+ }
105
+
106
+ if (matchesAny(combined, DESTROY_PATTERNS)) {
107
+ add('aws-blocks-destroy', 'destroy command can remove AWS resources created from local Blocks code', 'critical');
108
+ }
109
+
110
+ if (hasDestructiveSql(combined)) {
111
+ add('destructive-sql-or-ddl', 'destructive or unscoped SQL mutation', 'critical');
112
+ }
113
+
114
+ if (matchesAny(combined, DESTRUCTIVE_AWS_PATTERNS)) {
115
+ add('destructive-aws-cli', 'destructive AWS CLI or Bedrock agent action', 'critical');
116
+ }
117
+
118
+ if (/\b(gcloud|aws)\b[\s\S]{0,220}\b(add-iam-policy-binding|put-role-policy|attach-role-policy|create-policy-version)\b/i.test(combined)
119
+ || /\b(AdministratorAccess|roles\/owner|iam:PassRole|serviceAccountTokenCreator)\b/i.test(combined)) {
120
+ add('iam-escalation', 'agent session attempts to grant broad IAM authority', 'critical');
121
+ }
122
+
123
+ if (/\b(new\s+Agent\s*\(|@aws-blocks\/(?:agent|blocks)|\bAgent\b[\s\S]{0,120}\btools?\b)/.test(combined)) {
124
+ add('blocks-agent-tool-call', 'AWS Blocks Agent or Bedrock-style tool action needs a tool boundary', 'medium');
125
+ }
126
+
127
+ if (/\b(npm\s+run\s+dev|npm\s+start|pnpm\s+dev|yarn\s+dev|create-blocks-app)\b/i.test(combined)) {
128
+ add('local-dev', 'local AWS Blocks development loop', 'low');
129
+ }
130
+
131
+ return {
132
+ command,
133
+ toolName,
134
+ code,
135
+ signals,
136
+ highRisk: signals.some((signal) => ['high', 'critical'].includes(signal.severity)),
137
+ localOnly: signals.some((signal) => signal.id === 'local-dev') && signals.every((signal) => signal.severity === 'low'),
138
+ lower,
139
+ };
140
+ }
141
+
142
+ function evaluateAwsBlocksAction(input = {}) {
143
+ const projectDir = input.projectDir || input.cwd || process.cwd();
144
+ const projectUsesAwsBlocks = Boolean(
145
+ input.projectUsesAwsBlocks
146
+ || input.awsBlocks
147
+ || input.blocksProject
148
+ || detectAwsBlocksProject(projectDir)
149
+ );
150
+ const evidence = new Set(toList(input.evidence || input.proof || input.receipts));
151
+ const action = detectAction(input);
152
+ const requiredEvidence = [];
153
+
154
+ const requireEvidence = (id, label) => {
155
+ if (!evidence.has(id)) requiredEvidence.push({ id, label });
156
+ };
157
+
158
+ if (projectUsesAwsBlocks && action.signals.some((signal) => signal.id === 'aws-blocks-production-deploy')) {
159
+ requireEvidence('local-tests-pass', 'local AWS Blocks tests pass against local implementations');
160
+ requireEvidence('cdk-diff-reviewed', 'CDK diff or synthesized CloudFormation change set reviewed');
161
+ requireEvidence('cost-blast-radius-reviewed', 'AWS cost and resource blast radius reviewed');
162
+ }
163
+
164
+ if (action.signals.some((signal) => signal.id === 'aws-blocks-destroy')) {
165
+ requireEvidence('resource-inventory-exported', 'resource inventory exported before destroy');
166
+ requireEvidence('human-destroy-approval', 'named human approval for destroy');
167
+ }
168
+
169
+ if (action.signals.some((signal) => signal.id === 'destructive-sql-or-ddl')) {
170
+ requireEvidence('backup-or-rollback-ready', 'backup, rollback, or restore point exists');
171
+ requireEvidence('bounded-row-count-reviewed', 'row/table impact was previewed before mutation');
172
+ }
173
+
174
+ if (action.signals.some((signal) => signal.id === 'destructive-aws-cli')) {
175
+ requireEvidence('aws-account-and-region-confirmed', 'target AWS account and region confirmed');
176
+ requireEvidence('rollback-plan-attached', 'rollback or recovery plan attached');
177
+ }
178
+
179
+ if (action.signals.some((signal) => signal.id === 'iam-escalation')) {
180
+ requireEvidence('least-privilege-review', 'least-privilege review completed');
181
+ requireEvidence('security-owner-approval', 'security owner approval captured');
182
+ }
183
+
184
+ if (action.signals.some((signal) => signal.id === 'blocks-agent-tool-call')) {
185
+ requireEvidence('agent-tool-allowlist', 'agent tool allowlist and data boundary declared');
186
+ }
187
+
188
+ const shouldBlock = projectUsesAwsBlocks && requiredEvidence.length > 0;
189
+ let status = 'allowed';
190
+ if (shouldBlock) {
191
+ status = 'blocked';
192
+ } else if (action.highRisk) {
193
+ status = 'needs-review';
194
+ }
195
+
196
+ return {
197
+ name: 'thumbgate-aws-blocks-guardrails',
198
+ status,
199
+ projectUsesAwsBlocks,
200
+ signals: action.signals,
201
+ requiredEvidence,
202
+ enforcementBoundary: 'local AWS Blocks confidence must not automatically become production AWS authority',
203
+ gates: [
204
+ 'allow local AWS Blocks dev loops without AWS credentials',
205
+ 'require local test proof plus CDK diff before production deploy',
206
+ 'block destroy and destructive data mutations until backup, blast-radius, and human approval evidence exists',
207
+ 'block IAM escalation and Bedrock/Agent tool expansion until an owner approves the tool boundary',
208
+ 'write a ThumbGate receipt for every allowed high-risk cloud action',
209
+ ],
210
+ nextActions: shouldBlock
211
+ ? requiredEvidence.map((item) => `Provide ${item.id}: ${item.label}`)
212
+ : ['Record an allow receipt when this action touches real AWS resources'],
213
+ };
214
+ }
215
+
216
+ function buildAwsBlocksHardeningOffer(input = {}) {
217
+ const workflow = String(input.workflow || input.name || 'AWS Blocks backend').trim();
218
+ const buyer = String(input.buyer || input.owner || 'platform owner').trim();
219
+ return {
220
+ name: 'thumbgate-aws-blocks-hardening-offer',
221
+ status: 'ready-for-positioning',
222
+ buyer,
223
+ workflow,
224
+ headline: 'AWS Blocks helps agents build the backend. ThumbGate stops them before unsafe cloud actions run.',
225
+ offer: 'AWS Blocks Agent Safety Review',
226
+ diagnosticPrice: '$499',
227
+ proofPlan: [
228
+ 'map one AWS Blocks local-to-cloud workflow',
229
+ 'install ThumbGate against the agent running that workflow',
230
+ 'add gates for deploy, destroy, data mutation, IAM, Bedrock Agent, and cost-blast-radius actions',
231
+ 'produce a receipt showing the first blocked repeat and the evidence required to allow it',
232
+ ],
233
+ cta: 'Send one AWS Blocks workflow that is about to deploy, mutate data, or call Bedrock tools.',
234
+ };
235
+ }
236
+
237
+ function parseArgs(argv = process.argv.slice(2)) {
238
+ const args = {};
239
+ for (const arg of argv) {
240
+ if (arg === '--json') args.json = true;
241
+ else if (arg === '--aws-blocks' || arg === '--project-uses-aws-blocks') args.projectUsesAwsBlocks = true;
242
+ else if (arg.startsWith('--command=')) args.command = arg.slice('--command='.length);
243
+ else if (arg.startsWith('--tool=')) args.toolName = arg.slice('--tool='.length);
244
+ else if (arg.startsWith('--code=')) args.code = arg.slice('--code='.length);
245
+ else if (arg.startsWith('--cwd=')) args.projectDir = arg.slice('--cwd='.length);
246
+ else if (arg.startsWith('--evidence=')) args.evidence = arg.slice('--evidence='.length);
247
+ else if (arg.startsWith('--workflow=')) args.workflow = arg.slice('--workflow='.length);
248
+ else if (arg.startsWith('--buyer=')) args.buyer = arg.slice('--buyer='.length);
249
+ else if (arg === 'offer') args.commandName = 'offer';
250
+ }
251
+ return args;
252
+ }
253
+
254
+ function runCli(args = parseArgs()) {
255
+ const report = args.commandName === 'offer'
256
+ ? buildAwsBlocksHardeningOffer(args)
257
+ : evaluateAwsBlocksAction(args);
258
+ if (args.json) console.log(JSON.stringify(report, null, 2));
259
+ else {
260
+ console.log(`${report.name}: ${report.status}`);
261
+ for (const action of report.nextActions || report.proofPlan || []) console.log(`- ${action}`);
262
+ }
263
+ }
264
+
265
+ if (require.main?.filename === __filename) runCli();
266
+
267
+ module.exports = {
268
+ detectAwsBlocksProject,
269
+ detectAction,
270
+ evaluateAwsBlocksAction,
271
+ buildAwsBlocksHardeningOffer,
272
+ };
@@ -51,6 +51,7 @@ const {
51
51
  } = require('./analytics-window');
52
52
  const { ensureParentDir } = require('./fs-utils');
53
53
  const mailer = require('./mailer');
54
+ const { recordCheckoutFunnelEvent } = require('./plausible-server-events');
54
55
 
55
56
  function loadWorkflowSprintIntakeModule() {
56
57
  const modulePath = path.resolve(__dirname, 'workflow-sprint-intake.js');
@@ -138,6 +139,10 @@ const IS_TEST = !!(
138
139
  process.env.NODE_ENV === 'test'
139
140
  );
140
141
 
142
+ function allowUnsignedStripeWebhooks() {
143
+ return IS_TEST && process.env.THUMBGATE_ALLOW_UNSIGNED_STRIPE_WEBHOOKS === '1';
144
+ }
145
+
141
146
  function shouldMergeLegacyBillingData() {
142
147
  return process.env._TEST_INCLUDE_LEGACY_BILLING_DATA === '1'
143
148
  || process.env.THUMBGATE_INCLUDE_LEGACY_BILLING_DATA === '1';
@@ -444,6 +449,57 @@ function buildCheckoutProductData({ name, description, appOrigin, planId }) {
444
449
  };
445
450
  }
446
451
 
452
+ /**
453
+ * Verify an ACTIVE Stripe product exists for the given plan name before
454
+ * we let buildSubscriptionPriceData create inline price_data under it.
455
+ *
456
+ * Stripe matches product_data by `name`. If only an archived product
457
+ * matches, new prices created via that path inherit active=false and the
458
+ * generated checkout URL renders "Something went wrong / The page you
459
+ * were looking for could not be found." for the buyer. Stripe Dashboard
460
+ * shows the session as `open` with no email captured — looks like the
461
+ * buyer abandoned, but they were never given a working page.
462
+ *
463
+ * Failing here surfaces the misconfiguration at the first checkout
464
+ * attempt instead of silently breaking every buyer for days.
465
+ *
466
+ * Verified incident: ThumbGate#2188 (May 2026) — 20 sessions abandoned in
467
+ * 7 days, all because the only product named "ThumbGate Pro" matching the
468
+ * inline product_data was archived (prod_UXxOHAfbDsPyRb), while an active
469
+ * product with the same name existed (prod_UW82THPxfNvwKT) that should
470
+ * have been used instead.
471
+ */
472
+ async function verifyActiveProductForPlan(stripe, planId) {
473
+ const expectedName = planId === 'team' ? 'ThumbGate Team' : 'ThumbGate Pro';
474
+ let products;
475
+ try {
476
+ products = await stripe.products.list({ limit: 100 });
477
+ } catch (err) {
478
+ // Network/transient failures shouldn't block checkout creation.
479
+ // The original session.create call will surface real Stripe errors.
480
+ return;
481
+ }
482
+ const matching = (products && products.data ? products.data : [])
483
+ .filter((p) => p && p.name === expectedName);
484
+ if (matching.length === 0) {
485
+ // No product with this name exists; Stripe will create a new one when
486
+ // session.create fires with inline product_data. Safe path.
487
+ return;
488
+ }
489
+ const active = matching.find((p) => p.active === true);
490
+ if (!active) {
491
+ const archived = matching[0];
492
+ throw new Error(
493
+ `Refusing to create checkout session: Stripe product named "${expectedName}" ` +
494
+ `exists only in archived state (id=${archived.id}, active=false). New prices ` +
495
+ `created via inline product_data would inherit active=false, rendering ` +
496
+ `"page not found" on Stripe checkout for every buyer. Fix: reactivate the ` +
497
+ `archived product in Stripe Dashboard, or rename the active product to ` +
498
+ `match "${expectedName}". See ThumbGate#2188 for the May 2026 incident.`
499
+ );
500
+ }
501
+ }
502
+
447
503
  function buildSubscriptionPriceData(checkoutSelection, appOrigin) {
448
504
  const isTeam = checkoutSelection.planId === 'team';
449
505
  const annual = checkoutSelection.billingCycle === 'annual';
@@ -2525,6 +2581,18 @@ async function createCheckoutSession({ successUrl, cancelUrl, customerEmail, ins
2525
2581
  }
2526
2582
 
2527
2583
  const stripe = getStripeClient();
2584
+
2585
+ // Defensive guard against ThumbGate#2188:
2586
+ // When buildSubscriptionPriceData passes inline `product_data` to Stripe,
2587
+ // Stripe name-matches existing products. If the only existing product with
2588
+ // that name is ARCHIVED (active=false), the new price inherits active=false
2589
+ // and every Stripe checkout page renders "page not found" for the buyer.
2590
+ // That bug burnt 20+ silent abandoned sessions in May 2026. Fail fast
2591
+ // instead of letting the broken page ship.
2592
+ if (!packId) {
2593
+ await verifyActiveProductForPlan(stripe, checkoutSelection.planId);
2594
+ }
2595
+
2528
2596
  const sessionPayload = buildCheckoutSessionPayload({
2529
2597
  successUrl,
2530
2598
  cancelUrl,
@@ -2837,7 +2905,7 @@ function disableCustomerKeys(customerId) {
2837
2905
  }
2838
2906
 
2839
2907
  function verifyWebhookSignature(rawBody, signature) {
2840
- if (!CONFIG.STRIPE_WEBHOOK_SECRET) return true;
2908
+ if (!CONFIG.STRIPE_WEBHOOK_SECRET) return allowUnsignedStripeWebhooks();
2841
2909
  if (!signature || !rawBody) return false;
2842
2910
 
2843
2911
  // Stripe signature format: t=<timestamp>,v1=<hmac>,...
@@ -2867,6 +2935,13 @@ function verifyWebhookSignature(rawBody, signature) {
2867
2935
 
2868
2936
  async function handleWebhook(rawBody, signature) {
2869
2937
  if (LOCAL_MODE()) return { handled: false, reason: 'local_mode' };
2938
+ if (!CONFIG.STRIPE_WEBHOOK_SECRET && !allowUnsignedStripeWebhooks()) {
2939
+ return {
2940
+ handled: false,
2941
+ reason: 'invalid_signature',
2942
+ error: 'STRIPE_WEBHOOK_SECRET is required before Stripe webhooks can be processed.',
2943
+ };
2944
+ }
2870
2945
  let event;
2871
2946
  try {
2872
2947
  if (CONFIG.STRIPE_WEBHOOK_SECRET) {
@@ -2975,6 +3050,22 @@ async function handleWebhook(rawBody, signature) {
2975
3050
  attribution,
2976
3051
  });
2977
3052
  }
3053
+ // Fire Plausible purchase event so the funnel poller can measure
3054
+ // end-to-end conversion: visitor → CTA → checkout → email → Stripe → purchase.
3055
+ // Fire-and-forget (never blocks the webhook response).
3056
+ void recordCheckoutFunnelEvent('purchase', {
3057
+ page: '/success',
3058
+ props: {
3059
+ sessionId: session.id,
3060
+ customerId,
3061
+ traceId: traceId || '',
3062
+ packId: packId || '',
3063
+ amount: session.amount_total != null ? String(session.amount_total) : '',
3064
+ currency: session.currency || '',
3065
+ ...attribution,
3066
+ },
3067
+ });
3068
+
2978
3069
  return {
2979
3070
  handled: true,
2980
3071
  action: 'provisioned_api_key',
@@ -3127,6 +3218,7 @@ module.exports = {
3127
3218
  _buildTrialActivationEmail: buildTrialActivationEmail,
3128
3219
  _sendTrialActivationEmail: sendTrialActivationEmail,
3129
3220
  _resolveSubscriptionCheckoutSelection: resolveSubscriptionCheckoutSelection,
3221
+ _verifyActiveProductForPlan: verifyActiveProductForPlan,
3130
3222
  _API_KEYS_PATH: () => CONFIG.API_KEYS_PATH,
3131
3223
  _FUNNEL_LEDGER_PATH: () => CONFIG.FUNNEL_LEDGER_PATH,
3132
3224
  _REVENUE_LEDGER_PATH: () => CONFIG.REVENUE_LEDGER_PATH,