thumbgate 1.26.2 → 1.26.4

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.
@@ -109,9 +109,21 @@ const DEFAULT_PROTECTED_FILE_GLOBS = [
109
109
  const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
110
110
  const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+pr\s+(?:create|merge)|npm\s+publish|yarn\s+publish|pnpm\s+publish|rm\s+-rf)\b/i;
111
111
  const REMOTE_SIDE_EFFECT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+(?:create|merge|close|reopen|ready|edit)\b|gh\s+release\s+(?:create|delete|edit|upload)\b|npm\s+publish\b|yarn\s+publish\b|pnpm\s+publish\b)\b/i;
112
+ const GH_API_PR_CREATE_PATTERN = /\bgh\s+api\b(?=.*(?:\/pulls\b|repos\/[^\s]+\/[^\s]+\/pulls\b))(?=.*(?:-f\b|--field\b|-F\b|--raw-field\b|--method\s+POST\b|-X\s+POST\b))/i;
112
113
  const BOOSTED_RISK_BLOCK_SCORE = 0.8;
113
114
  const BOOSTED_RISK_MIN_EXAMPLES = 3;
114
115
  const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
116
+ const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
117
+ const KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+merge\b|gh\s+release\s+(?:create|delete|edit|upload)\b|(?:npm|yarn|pnpm)\s+publish\b|rm\s+-rf\b|git\s+reset\s+--hard\b|git\s+clean\s+-f|railway\s+(?:deploy|up)\b|gcloud\s+(?:run\s+deploy|app\s+deploy)\b|firebase\s+deploy\b|vercel\s+--prod\b|kubectl\s+(?:apply|delete)\b|terraform\s+(?:apply|destroy)\b)\b/i;
118
+ const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
119
+ const BREAK_GLASS_SETTINGS_GLOBS = [
120
+ '.claude/settings.local.json',
121
+ '.claude/settings.json',
122
+ '**/.claude/settings.local.json',
123
+ '**/.claude/settings.json',
124
+ '.codex/config.toml',
125
+ '**/.codex/config.toml',
126
+ ];
115
127
 
116
128
  function isRuntimePlanGateEnabled() {
117
129
  return process.env.THUMBGATE_PLAN_GATE === '1' || process.env.THUMBGATE_PLAN_GATE === 'true';
@@ -360,6 +372,38 @@ function approveProtectedAction(input = {}) {
360
372
  return entry;
361
373
  }
362
374
 
375
+ function breakGlassEmergency(input = {}) {
376
+ const reason = String(input.reason || '').trim();
377
+ if (!reason) {
378
+ throw new Error('reason is required');
379
+ }
380
+
381
+ const ttlMs = Math.min(clampTtlMs(input.ttlMs, TTL_MS), TTL_MS);
382
+ const evidence = `BREAK GLASS: ${reason}`;
383
+ const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
384
+ const satisfied = {};
385
+ for (const gateId of gates) {
386
+ satisfied[gateId] = satisfyCondition(gateId, evidence);
387
+ }
388
+
389
+ const approval = approveProtectedAction({
390
+ pathGlobs: BREAK_GLASS_SETTINGS_GLOBS,
391
+ reason: evidence,
392
+ evidence,
393
+ ttlMs,
394
+ });
395
+
396
+ return {
397
+ ok: true,
398
+ reason,
399
+ ttlMs,
400
+ expiresAt: new Date(Date.now() + ttlMs).toISOString(),
401
+ satisfied,
402
+ approval,
403
+ settingsGlobs: BREAK_GLASS_SETTINGS_GLOBS.slice(),
404
+ };
405
+ }
406
+
363
407
  function setBranchGovernance(input = {}) {
364
408
  if (input && input.clear === true) {
365
409
  const state = loadGovernanceState();
@@ -693,7 +737,7 @@ function extractAffectedFiles(toolName, toolInput = {}) {
693
737
  }
694
738
  }
695
739
 
696
- if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command)) {
740
+ if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || GH_API_PR_CREATE_PATTERN.test(command)) {
697
741
  for (const filePath of getBranchDiffFiles(repoRoot)) {
698
742
  files.add(normalizePosix(filePath));
699
743
  }
@@ -712,6 +756,7 @@ function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
712
756
  const command = String(toolInput.command || '');
713
757
  // Original high-risk pattern (git writes, publishes, destructive ops)
714
758
  if (HIGH_RISK_BASH_PATTERN.test(command)) return true;
759
+ if (GH_API_PR_CREATE_PATTERN.test(command)) return true;
715
760
  // Broadened: any Bash command that modifies files or has side effects.
716
761
  // Excludes pure read/analysis commands (node --test, cat, ls, echo, etc.)
717
762
  // to avoid false positives on benign operations.
@@ -949,7 +994,8 @@ function getLocalOnlyScopeSources(governanceState = {}, constraints = {}) {
949
994
 
950
995
  function isRemoteSideEffectCommand(toolName, toolInput = {}) {
951
996
  if (toolName !== 'Bash') return false;
952
- return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(String(toolInput.command || ''));
997
+ const command = String(toolInput.command || '');
998
+ return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(command) || GH_API_PR_CREATE_PATTERN.test(command);
953
999
  }
954
1000
 
955
1001
  function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governanceState = {}, constraints = {}) {
@@ -1003,6 +1049,25 @@ function shouldEnforceTaskScope(gate, governanceState, toolName, toolInput = {},
1003
1049
  return isScopeEnforcedAction(toolName, toolInput, affectedFiles);
1004
1050
  }
1005
1051
 
1052
+ function isAgentHookSettingsFile(filePath) {
1053
+ return matchesAnyGlob(filePath, BREAK_GLASS_SETTINGS_GLOBS);
1054
+ }
1055
+
1056
+ function isBreakGlassSettingsBypass(gate, affectedFiles) {
1057
+ if (!gate || !['task-scope-edit-boundary', 'protected-file-approval-required'].includes(gate.id)) {
1058
+ return false;
1059
+ }
1060
+ if (!isConditionSatisfied(BREAK_GLASS_CONDITION)) return false;
1061
+ return Array.isArray(affectedFiles) && affectedFiles.length > 0 && affectedFiles.every(isAgentHookSettingsFile);
1062
+ }
1063
+
1064
+ function isBreakGlassSettingsRecoveryAction(toolName, toolInput = {}) {
1065
+ if (!EDIT_LIKE_TOOLS.has(toolName)) return false;
1066
+ if (!isConditionSatisfied(BREAK_GLASS_CONDITION)) return false;
1067
+ const affectedFiles = extractAffectedFiles(toolName, toolInput).files;
1068
+ return affectedFiles.length > 0 && affectedFiles.every(isAgentHookSettingsFile);
1069
+ }
1070
+
1006
1071
  function formatFileList(files, limit = 5) {
1007
1072
  const items = Array.isArray(files) ? files.filter(Boolean) : [];
1008
1073
  if (items.length === 0) return 'none';
@@ -1234,6 +1299,12 @@ function matchGate(gate, toolName, toolInput = {}) {
1234
1299
  try {
1235
1300
  const regex = new RegExp(gate.pattern);
1236
1301
  if (!regex.test(matchText)) return { matched: false, matchText, affectedFiles };
1302
+ if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
1303
+ return { matched: false, matchText, affectedFiles };
1304
+ }
1305
+ if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
1306
+ return { matched: false, matchText, affectedFiles };
1307
+ }
1237
1308
  } catch {
1238
1309
  return { matched: false, matchText, affectedFiles };
1239
1310
  }
@@ -1251,6 +1322,9 @@ function matchGate(gate, toolName, toolInput = {}) {
1251
1322
 
1252
1323
  let taskScopeViolation = null;
1253
1324
  if (gate.requireTaskScope) {
1325
+ if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
1326
+ return { matched: false, matchText, affectedFiles };
1327
+ }
1254
1328
  if (!shouldEnforceTaskScope(gate, governanceState, toolName, toolInput, affectedFiles)) {
1255
1329
  return { matched: false, matchText, affectedFiles };
1256
1330
  }
@@ -1260,6 +1334,9 @@ function matchGate(gate, toolName, toolInput = {}) {
1260
1334
 
1261
1335
  let protectedApprovalViolation = null;
1262
1336
  if (gate.requireProtectedApproval) {
1337
+ if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
1338
+ return { matched: false, matchText, affectedFiles };
1339
+ }
1263
1340
  const protectedGlobs = sanitizeGlobList(
1264
1341
  Array.isArray(gate.protectedGlobs) && gate.protectedGlobs.length > 0
1265
1342
  ? gate.protectedGlobs
@@ -1299,6 +1376,27 @@ function matchesGate(gate, toolName, toolInput) {
1299
1376
  return matchGate(gate, toolName, toolInput).matched;
1300
1377
  }
1301
1378
 
1379
+ function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
1380
+ if (toolName !== 'Bash') return false;
1381
+ const command = String(toolInput.command || '').trim();
1382
+ if (!command || /(?:^|\s)chmod\s+[^&;|]*\s+-R\b/i.test(command)) return false;
1383
+ if (/[;&|`$()<>*?[\]{}]/.test(command)) return false;
1384
+
1385
+ const match = command.match(/(?:^|\s)chmod\s+(?:-[fv]\s+)?0?([46]00)\s+(['"]?)(\S+)\2\s*$/i);
1386
+ if (!match) return false;
1387
+
1388
+ const target = match[3];
1389
+ if (!target || target === '/' || target === '~') return false;
1390
+ if (/^\.\.?(?:\/|$)/.test(target)) return false;
1391
+
1392
+ const normalized = target.replace(/^['"]|['"]$/g, '').toLowerCase();
1393
+ const looksLikeCredentialPath = /(?:^|\/)(?:\.config|\.ssh|\.gnupg|\.aws|\.gcloud|\.gemini|\.resume_secrets|\.thumbgate|secrets?|credentials?)(?:\/|$)/.test(normalized)
1394
+ || /(?:key|secret|token|credential|gemini|gcloud|google|operator).*\.(?:json|pem|key|env)$/i.test(normalized)
1395
+ || /\.(?:pem|key)$/i.test(normalized);
1396
+
1397
+ return looksLikeCredentialPath;
1398
+ }
1399
+
1302
1400
  function evaluateMemoryGuard(toolName, toolInput = {}) {
1303
1401
  const affected = extractAffectedFiles(toolName, toolInput);
1304
1402
  const affectedFiles = affected.files;
@@ -1315,7 +1413,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1315
1413
  }
1316
1414
 
1317
1415
  const command = String(toolInput.command || '');
1318
- if (toolName === 'Bash' && /\bgh\s+pr\s+create\b/i.test(command) && isConditionSatisfied('pr_create_allowed')) {
1416
+ const isPrCreateCommand = toolName === 'Bash' && (
1417
+ /\bgh\s+pr\s+create\b/i.test(command) || GH_API_PR_CREATE_PATTERN.test(command)
1418
+ );
1419
+ if (isPrCreateCommand && isConditionSatisfied('pr_create_allowed')) {
1319
1420
  const branchGovernanceViolation = buildBranchGovernanceViolation(
1320
1421
  governanceState,
1321
1422
  toolInput,
@@ -1328,7 +1429,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1328
1429
  }
1329
1430
  }
1330
1431
 
1331
- if (toolName === 'Bash' && /\b(?:gh\s+pr\s+(?:create|merge)|gh\s+release\s+create|git\s+tag\b|(?:npm|yarn|pnpm)\s+publish\b)\b/i.test(command)) {
1432
+ if (toolName === 'Bash' && (
1433
+ /\b(?:gh\s+pr\s+(?:create|merge)|gh\s+release\s+create|git\s+tag\b|(?:npm|yarn|pnpm)\s+publish\b)\b/i.test(command) ||
1434
+ GH_API_PR_CREATE_PATTERN.test(command)
1435
+ )) {
1332
1436
  const branchGovernanceViolation = buildBranchGovernanceViolation(
1333
1437
  governanceState,
1334
1438
  toolInput,
@@ -1504,6 +1608,18 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1504
1608
  if (localOnlyRemoteSideEffectGate) {
1505
1609
  return recordStructuralGateBlock(toolName, toolInput, localOnlyRemoteSideEffectGate);
1506
1610
  }
1611
+ if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
1612
+ recordAuditEvent({
1613
+ toolName,
1614
+ toolInput,
1615
+ decision: 'allow',
1616
+ gateId: BREAK_GLASS_CONDITION,
1617
+ message: 'Break-glass recovery allowed hook settings edit',
1618
+ severity: 'high',
1619
+ source: 'gates-engine',
1620
+ });
1621
+ return null;
1622
+ }
1507
1623
 
1508
1624
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
1509
1625
  if (pendingThreadResolutionGate) {
@@ -1718,6 +1834,18 @@ function evaluateGates(toolName, toolInput, configPath) {
1718
1834
  if (localOnlyRemoteSideEffectGate) {
1719
1835
  return recordStructuralGateBlock(toolName, toolInput, localOnlyRemoteSideEffectGate);
1720
1836
  }
1837
+ if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
1838
+ recordAuditEvent({
1839
+ toolName,
1840
+ toolInput,
1841
+ decision: 'allow',
1842
+ gateId: BREAK_GLASS_CONDITION,
1843
+ message: 'Break-glass recovery allowed hook settings edit',
1844
+ severity: 'high',
1845
+ source: 'gates-engine',
1846
+ });
1847
+ return null;
1848
+ }
1721
1849
 
1722
1850
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
1723
1851
  if (pendingThreadResolutionGate) {
@@ -1989,12 +2117,19 @@ function isApprovalGatesEnabled() {
1989
2117
  // PreToolUse hook interface (stdin/stdout JSON)
1990
2118
  // ---------------------------------------------------------------------------
1991
2119
 
1992
- function buildReminderOutput(context) {
2120
+ function buildPreToolUseOutput(fields = {}) {
1993
2121
  return {
2122
+ hookEventName: 'PreToolUse',
2123
+ ...fields,
2124
+ };
2125
+ }
2126
+
2127
+ function buildReminderOutput(context) {
2128
+ return buildPreToolUseOutput({
1994
2129
  additionalContext: context,
1995
2130
  systemReminder: context,
1996
2131
  thumbgateSystemReminder: context,
1997
- };
2132
+ });
1998
2133
  }
1999
2134
 
2000
2135
  // ---------------------------------------------------------------------------
@@ -2047,6 +2182,7 @@ function formatOutput(result, behavioralContext) {
2047
2182
  const proCta = buildBlockActionProCta() || '';
2048
2183
  return JSON.stringify({
2049
2184
  hookSpecificOutput: {
2185
+ hookEventName: 'PreToolUse',
2050
2186
  ...reminder,
2051
2187
  permissionDecision: 'deny',
2052
2188
  permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}${reminderSuffix}${proCta}`,
@@ -2059,6 +2195,7 @@ function formatOutput(result, behavioralContext) {
2059
2195
  const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
2060
2196
  return JSON.stringify({
2061
2197
  hookSpecificOutput: {
2198
+ hookEventName: 'PreToolUse',
2062
2199
  ...reminder,
2063
2200
  permissionDecision: 'deny',
2064
2201
  permissionDecisionReason: `[GATE:${result.gate}] APPROVAL REQUIRED: ${result.message} — Ask the human to confirm this action before proceeding.${reasoningSuffix}${reminderSuffix}`,
@@ -2071,6 +2208,7 @@ function formatOutput(result, behavioralContext) {
2071
2208
  const context = `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`;
2072
2209
  return JSON.stringify({
2073
2210
  hookSpecificOutput: {
2211
+ hookEventName: 'PreToolUse',
2074
2212
  additionalContext: context,
2075
2213
  ...(behavioralContext ? {
2076
2214
  systemReminder: behavioralContext,
@@ -2204,9 +2342,8 @@ function buildRelevantLessonContext(toolName, toolInput) {
2204
2342
  const lessons = retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
2205
2343
 
2206
2344
  const entropy = calculateRetrievalEntropy(lessons);
2207
- if (entropy > 0.7) {
2208
- recordStat("retrieval_entropy_high", "block");
2209
- return { decision: "deny", gate: "knowledge-conflict-gate", message: "✗ THUMBGATE: Action blocked due to high Knowledge Entropy (conflicting past lessons).", severity: "high" };
2345
+ if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
2346
+ return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
2210
2347
  }
2211
2348
  return formatNegativeLessonContext(lessons);
2212
2349
  } catch {
@@ -2237,16 +2374,11 @@ async function buildRelevantLessonContextAsync(toolName, toolInput) {
2237
2374
  : retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
2238
2375
 
2239
2376
  // Knowledge Conflict Detection: if retrieved lessons have high sentiment entropy,
2240
- // it indicates conflicting past evidence. Block and require human disambiguation.
2377
+ // it indicates conflicting past evidence. Warn by default; hard-block only in
2378
+ // strict mode for external/destructive side-effect commands.
2241
2379
  const entropy = calculateRetrievalEntropy(lessons);
2242
- if (entropy > 0.7) {
2243
- recordStat('retrieval_entropy_high', 'block');
2244
- return {
2245
- decision: 'deny',
2246
- gate: 'knowledge-conflict-gate',
2247
- message: '✗ THUMBGATE: Action blocked due to high Knowledge Entropy (conflicting past lessons). Please disambiguate your instructions or verify the intended behavior manually.',
2248
- severity: 'high',
2249
- };
2380
+ if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
2381
+ return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
2250
2382
  }
2251
2383
 
2252
2384
  return formatNegativeLessonContext(lessons);
@@ -2273,6 +2405,36 @@ function formatNegativeLessonContext(lessons) {
2273
2405
  return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
2274
2406
  }
2275
2407
 
2408
+ function isStrictKnowledgeConflictMode() {
2409
+ return process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1'
2410
+ || process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === 'true';
2411
+ }
2412
+
2413
+ function isKnowledgeConflictHardBlockAction(toolName, toolInput = {}) {
2414
+ if (!isStrictKnowledgeConflictMode()) return false;
2415
+ if (EDIT_LIKE_TOOLS.has(toolName)) return true;
2416
+ if (toolName !== 'Bash') return false;
2417
+ return KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN.test(String(toolInput.command || ''));
2418
+ }
2419
+
2420
+ function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
2421
+ const lessonContext = formatNegativeLessonContext(lessons);
2422
+ const message = `Knowledge conflict warning: retrieved lessons disagree for this action (entropy ${entropy}). Treat the reminders below as cautionary context, but do not stop unrelated work solely because memory is noisy.`;
2423
+
2424
+ if (isKnowledgeConflictHardBlockAction(toolName, toolInput)) {
2425
+ recordStat('retrieval_entropy_high', 'block');
2426
+ return {
2427
+ decision: 'deny',
2428
+ gate: 'knowledge-conflict-gate',
2429
+ message: `✗ THUMBGATE: ${message} Strict mode is enabled for destructive or external side-effect actions; verify intent or narrow the task before proceeding.`,
2430
+ severity: 'high',
2431
+ };
2432
+ }
2433
+
2434
+ recordStat('retrieval_entropy_high', 'warn');
2435
+ return mergeContextStrings(`[ThumbGate] ${message}`, lessonContext);
2436
+ }
2437
+
2276
2438
  function extractActionContext(toolName, toolInput) {
2277
2439
  if (!toolInput) return toolName;
2278
2440
  const parts = [toolName];
@@ -2659,6 +2821,7 @@ module.exports = {
2659
2821
  setTaskScope,
2660
2822
  setBranchGovernance,
2661
2823
  approveProtectedAction,
2824
+ breakGlassEmergency,
2662
2825
  getScopeState,
2663
2826
  getBranchGovernanceState,
2664
2827
  isConditionSatisfied,
@@ -2717,6 +2880,8 @@ module.exports = {
2717
2880
  getLocalOnlyScopeSources,
2718
2881
  isRemoteSideEffectCommand,
2719
2882
  evaluateLocalOnlyRemoteSideEffectGate,
2883
+ isAgentHookSettingsFile,
2884
+ isBreakGlassSettingsRecoveryAction,
2720
2885
  PR_THREAD_RESOLUTION_ACTION,
2721
2886
  buildBlockActionProCta,
2722
2887
  applyDailyBlockCap,
@@ -3,13 +3,12 @@
3
3
  /**
4
4
  * install-shim.js — Install a stable shim at ~/.thumbgate/bin/thumbgate-hook
5
5
  *
6
- * The shim is a tiny shell script that always resolves thumbgate@latest,
7
- * so hook commands in settings.local.json never go stale. This is the
8
- * Volta-style pattern: a version-agnostic indirection layer that survives
9
- * across thumbgate upgrades.
6
+ * The shim is a tiny shell script that resolves the cached ThumbGate runtime
7
+ * first, so hook commands in settings.local.json stay stable across projects
8
+ * and agent restarts.
10
9
  *
11
10
  * The shim checks for a cached runtime binary first (fast path), and falls
12
- * back to `npx --yes thumbgate@latest` (slow path, self-installs).
11
+ * back to `npx --yes thumbgate@latest` (slow path, first-time self-install).
13
12
  */
14
13
 
15
14
  const fs = require('fs');
@@ -24,9 +23,10 @@ const RUNTIME_BIN = path.join(os.homedir(), '.thumbgate', 'runtime', 'node_modul
24
23
  * The shim script. Key design choices:
25
24
  * - Uses `exec` to replace the shell process (no zombie processes)
26
25
  * - Fast path: if cached runtime binary exists, exec it directly
27
- * - Slow path: npx --yes thumbgate@latest (auto-installs)
28
- * - Background upgrade: after the fast path succeeds once, spawn a
29
- * detached npm install to refresh the cache for next time
26
+ * - Slow path: npx --yes thumbgate@latest (first-time auto-installs)
27
+ * - No default self-mutation: background upgrades are opt-in via
28
+ * THUMBGATE_SHIM_AUTO_UPDATE=1 so source checkouts, enterprise pins, and
29
+ * dogfood runtimes cannot be overwritten by a hook side effect.
30
30
  */
31
31
  function shimContent() {
32
32
  const escapedRuntimeBin = JSON.stringify(RUNTIME_BIN);
@@ -35,7 +35,7 @@ function shimContent() {
35
35
  return `#!/usr/bin/env bash
36
36
  # ThumbGate hook shim — DO NOT EDIT
37
37
  # Installed by: thumbgate init
38
- # Purpose: version-agnostic hook entry point that always runs latest ThumbGate
38
+ # Purpose: stable hook entry point that runs the cached ThumbGate runtime
39
39
  # Pattern: Volta-style stable shim (see https://volta.sh)
40
40
 
41
41
  set -euo pipefail
@@ -45,8 +45,11 @@ RUNTIME_DIR=${escapedRuntimeDir}
45
45
 
46
46
  # Fast path: cached runtime binary exists and is executable
47
47
  if [ -x "$RUNTIME_BIN" ]; then
48
- # Spawn background upgrade (detached, no stdout/stderr, won't block hook)
49
- ( nohup npm install --prefix "$RUNTIME_DIR" --no-save --omit=dev thumbgate@latest >/dev/null 2>&1 & ) 2>/dev/null || true
48
+ # Optional background upgrade. Disabled by default so hooks never mutate a
49
+ # source checkout, enterprise pin, or dogfood runtime behind the operator's back.
50
+ if [ "\${THUMBGATE_SHIM_AUTO_UPDATE:-0}" = "1" ]; then
51
+ ( nohup npm install --prefix "$RUNTIME_DIR" --no-save --omit=dev thumbgate@latest >/dev/null 2>&1 & ) 2>/dev/null || true
52
+ fi
50
53
  exec "$RUNTIME_BIN" "$@"
51
54
  fi
52
55
 
package/src/api/server.js CHANGED
@@ -418,34 +418,18 @@ const TRACKED_LINK_TARGETS = Object.freeze({
418
418
  },
419
419
  allowCustomerEmail: true,
420
420
  },
421
- // 2026-05-19: Team is intake-led. Keep the tracked shortlink alive for
422
- // marketplaces and old outreach, but route it to workflow scope first
423
- // instead of blind 3-seat checkout.
421
+ // 2026-06-02: Teams/Aiventyx deprecated. Redirect legacy links to Pro.
424
422
  teams: {
425
- path: '/#workflow-sprint-intake',
426
- ctaId: 'go_teams',
423
+ path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
424
+ ctaId: 'go_pro',
427
425
  ctaPlacement: 'link_router',
428
- eventType: 'team_intake_started',
429
- defaults: {
430
- utm_source: 'website',
431
- utm_medium: 'link_router',
432
- utm_campaign: 'team_intake',
433
- plan_id: 'team',
434
- },
426
+ eventType: 'cta_click',
435
427
  },
436
- // Aliases: /go/team → same as /go/teams, /go/checkout → same as /go/pro,
437
- // /go/trial → install guide (trial starts on init)
438
428
  team: {
439
- path: '/#workflow-sprint-intake',
440
- ctaId: 'go_team',
429
+ path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
430
+ ctaId: 'go_pro',
441
431
  ctaPlacement: 'link_router',
442
- eventType: 'team_intake_started',
443
- defaults: {
444
- utm_source: 'website',
445
- utm_medium: 'link_router',
446
- utm_campaign: 'team_intake',
447
- plan_id: 'team',
448
- },
432
+ eventType: 'cta_click',
449
433
  },
450
434
  checkout: {
451
435
  path: '/checkout/pro',
@@ -6405,30 +6389,7 @@ ${hidden}
6405
6389
  <h1>Case Studies</h1>
6406
6390
  <p class="lede">Real integrations. No fabricated logos, no aspirational numbers — every claim below is reproducible.</p>
6407
6391
 
6408
- <article>
6409
- <h2>Aiventyx marketplace — Teams listing intake recovery</h2>
6410
- <p class="meta">Integration partner: <a href="https://www.aiventyx.com">Aiventyx</a> · Reported by: Qaiser Mehdi · Verified: 2026-05-13</p>
6411
-
6412
- <h3>The problem</h3>
6413
- <p>Aiventyx is a marketplace for AI tools. ThumbGate's Teams listing was their highest-CTR external surface — <span class="metric">62% CTR</span> (5 clicks on 8 views, May 7–9 window). When their integrator rolled out canonical tracked URLs, every Teams click started landing on:</p>
6414
- <p><code>{"error":"Tracked link not found","allowed":["gpt","pro","install","reddit","linkedin","x","github"]}</code></p>
6415
- <p>The <code>/go/teams</code> slug wasn't registered in our redirector — a 404 was eating every paid-intent click from their strongest external surface.</p>
6416
-
6417
- <h3>The fix</h3>
6418
- <p>Added <code>teams</code> to <code>TRACKED_LINK_TARGETS</code> and now routes it to <code>/?plan_id=team#workflow-sprint-intake</code>. Caller-supplied UTMs flow through to the intake path so the workflow, owner, and proof boundary are explicit before any Team checkout.</p>
6419
-
6420
- <h3>The verification</h3>
6421
- <p>Qaiser's own incognito test, May 13 6:04 AM (full email on record):</p>
6422
- <p><code>https://thumbgate.ai/go/teams?utm_source=aiventyx</code><br>
6423
- → 302 to the Team workflow intake<br>
6424
- → pricing source, campaign, and plan metadata preserved<br>
6425
- → buyer sees the scope-first path before any subscription decision</p>
6426
-
6427
- <h3>What this proves</h3>
6428
- <p>End-to-end attribution from a third-party marketplace through ThumbGate's redirector into the Team intake path, with the caller's UTM chain preserved. Regression tests pin the redirect contract so it can't silently break or regress into a blind Team checkout.</p>
6429
-
6430
- <p><a href="/go/teams?utm_source=case-study">Try the live redirect →</a></p>
6431
- </article>
6392
+ <p><em>New case studies for individual Pro operators coming soon.</em></p>
6432
6393
 
6433
6394
  <footer>
6434
6395
  <p>Want to be the next case study? The product is real, the integration is 30 seconds: <code>npx thumbgate init</code>. If you ship something with ThumbGate and want it documented here, email <a href="mailto:igor.ganapolsky@gmail.com">igor.ganapolsky@gmail.com</a>.</p>
@@ -1,160 +0,0 @@
1
- 'use strict';
2
-
3
- const { resolveAnalyticsWindow } = require('./analytics-window');
4
- const { getBillingSummaryLive } = require('./billing');
5
- const { generateDashboard } = require('./dashboard');
6
- const { getFeedbackPaths } = require('./feedback-loop');
7
- const { resolveHostedBillingConfig } = require('./hosted-config');
8
- const { loadOperatorConfig } = require('./operational-summary');
9
-
10
- function normalizeText(value) {
11
- if (value === undefined || value === null) return null;
12
- const text = String(value).trim();
13
- return text || null;
14
- }
15
-
16
- function shouldPreferHostedDashboard() {
17
- return String(process.env.THUMBGATE_METRICS_SOURCE || '').trim().toLowerCase() !== 'local';
18
- }
19
-
20
- function resolveHostedDashboardConfig() {
21
- const runtimeConfig = resolveHostedBillingConfig();
22
- const operatorConfig = loadOperatorConfig();
23
- // Match operational-summary's key priority chain so north-star and cfo
24
- // authenticate against the same hosted deployment consistently. Prior to
25
- // this change, north-star only read THUMBGATE_API_KEY, silently 401'ing
26
- // on machines configured via operator.json or THUMBGATE_OPERATOR_KEY.
27
- const apiKey = normalizeText(process.env.THUMBGATE_OPERATOR_KEY)
28
- || operatorConfig.operatorKey
29
- || normalizeText(process.env.THUMBGATE_API_KEY);
30
- const apiBaseUrl = normalizeText(process.env.THUMBGATE_BILLING_API_BASE_URL)
31
- || operatorConfig.baseUrl
32
- || runtimeConfig.billingApiBaseUrl;
33
- return {
34
- apiBaseUrl,
35
- apiKey,
36
- };
37
- }
38
-
39
- async function buildOperationalDashboard(options = {}) {
40
- const analyticsWindow = resolveAnalyticsWindow(options);
41
- const feedbackDir = options.feedbackDir || getFeedbackPaths().FEEDBACK_DIR;
42
- const billingSummary = await getBillingSummaryLive(analyticsWindow);
43
-
44
- return generateDashboard(feedbackDir, {
45
- analyticsWindow,
46
- billingSummary,
47
- billingSource: 'live',
48
- billingFallbackReason: null,
49
- });
50
- }
51
-
52
- async function fetchHostedDashboard(options = {}, config = resolveHostedDashboardConfig()) {
53
- const analyticsWindow = resolveAnalyticsWindow(options);
54
- if (!shouldPreferHostedDashboard()) {
55
- const err = new Error('Hosted operational dashboard is disabled.');
56
- err.code = 'hosted_dashboard_disabled';
57
- throw err;
58
- }
59
- if (!config.apiBaseUrl || !config.apiKey) {
60
- const err = new Error('Hosted operational dashboard is not configured.');
61
- err.code = 'hosted_dashboard_unconfigured';
62
- throw err;
63
- }
64
-
65
- const requestUrl = new URL('/v1/dashboard', config.apiBaseUrl);
66
- requestUrl.searchParams.set('window', analyticsWindow.window);
67
- requestUrl.searchParams.set('timezone', analyticsWindow.timeZone);
68
- requestUrl.searchParams.set('now', analyticsWindow.now);
69
-
70
- const response = await fetch(requestUrl, {
71
- method: 'GET',
72
- headers: {
73
- authorization: `Bearer ${config.apiKey}`,
74
- accept: 'application/json',
75
- },
76
- });
77
-
78
- if (!response.ok) {
79
- const detail = await response.text().catch(() => '');
80
- const err = new Error(`Hosted operational dashboard request failed (${response.status}): ${detail || 'unknown error'}`);
81
- err.code = 'hosted_dashboard_http_error';
82
- err.status = response.status;
83
- throw err;
84
- }
85
-
86
- return response.json();
87
- }
88
-
89
- async function getOperationalDashboard(options = {}) {
90
- const analyticsWindow = resolveAnalyticsWindow(options);
91
- try {
92
- const data = await fetchHostedDashboard(analyticsWindow);
93
- return {
94
- source: 'hosted',
95
- data,
96
- fallbackReason: null,
97
- hostedStatus: 200,
98
- };
99
- } catch (err) {
100
- const reason = err && err.message ? err.message : 'hosted_dashboard_unavailable';
101
- const status = err && typeof err.status === 'number' ? err.status : null;
102
- const code = err && err.code ? err.code : null;
103
-
104
- // Hosted deliberately disabled or never configured — local fallback is
105
- // intentional, not a degraded state. Tag as plain 'local'.
106
- if (code === 'hosted_dashboard_disabled' || code === 'hosted_dashboard_unconfigured') {
107
- return {
108
- source: 'local',
109
- data: await buildOperationalDashboard(analyticsWindow),
110
- fallbackReason: reason,
111
- hostedStatus: null,
112
- };
113
- }
114
-
115
- // Mirror operational-summary: auth failure is the dangerous case. A
116
- // dashboard that silently shows $0 revenue (from the local ledger) when
117
- // Stripe actually has paid customers is a lie the operator acts on.
118
- // Refuse to guess — surface an actionable error.
119
- if (status === 401 || status === 403) {
120
- const authErr = new Error(
121
- `Hosted operational dashboard rejected credentials (HTTP ${status}). ` +
122
- `The operator key on this machine does not match the one on the ` +
123
- `hosted deployment. Fix: set THUMBGATE_OPERATOR_KEY in this shell, ` +
124
- `or update the operatorKey field in ~/.config/thumbgate/operator.json, ` +
125
- `to match Railway's THUMBGATE_OPERATOR_KEY. ` +
126
- `Running north-star without hosted auth would report local-only ` +
127
- `data as ground truth, which may not reflect actual Stripe revenue. ` +
128
- `Original response: ${reason}`
129
- );
130
- authErr.code = 'hosted_dashboard_unauthorized';
131
- authErr.status = status;
132
- throw authErr;
133
- }
134
-
135
- // Non-auth failure — local fallback is still useful for dev workflows,
136
- // but tag the source so downstream renderers do not mistake it for
137
- // verified hosted truth.
138
- //
139
- // Log only the status code (trusted) — the full reason contains upstream
140
- // response text and is only returned structurally via fallbackReason.
141
- console.warn(
142
- `[operational-dashboard] Hosted dashboard unreachable (status=${status ?? 'network'}); ` +
143
- `falling back to LOCAL-UNVERIFIED state. Numbers below may not reflect actual Stripe revenue.`
144
- );
145
- return {
146
- source: 'local-unverified',
147
- data: await buildOperationalDashboard(analyticsWindow),
148
- fallbackReason: reason,
149
- hostedStatus: status,
150
- };
151
- }
152
- }
153
-
154
- module.exports = {
155
- buildOperationalDashboard,
156
- fetchHostedDashboard,
157
- getOperationalDashboard,
158
- resolveHostedDashboardConfig,
159
- shouldPreferHostedDashboard,
160
- };