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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +2 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +138 -18
- package/config/gates/default.json +11 -2
- package/package.json +2 -4
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/agent-readiness.js +20 -3
- package/scripts/cli-feedback.js +4 -2
- package/scripts/commercial-offer.js +0 -3
- package/scripts/feedback-quality.js +87 -0
- package/scripts/gates-engine.js +183 -18
- package/scripts/install-shim.js +14 -11
- package/src/api/server.js +8 -47
- package/scripts/operational-dashboard.js +0 -160
- package/scripts/operational-summary.js +0 -178
package/scripts/gates-engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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' &&
|
|
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
|
|
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 >
|
|
2208
|
-
|
|
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.
|
|
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 >
|
|
2243
|
-
|
|
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,
|
package/scripts/install-shim.js
CHANGED
|
@@ -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
|
|
7
|
-
* so hook commands in settings.local.json
|
|
8
|
-
*
|
|
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-
|
|
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
|
-
* -
|
|
29
|
-
*
|
|
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:
|
|
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
|
-
#
|
|
49
|
-
|
|
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-
|
|
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: '
|
|
426
|
-
ctaId: '
|
|
423
|
+
path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
|
|
424
|
+
ctaId: 'go_pro',
|
|
427
425
|
ctaPlacement: 'link_router',
|
|
428
|
-
eventType: '
|
|
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: '
|
|
440
|
-
ctaId: '
|
|
429
|
+
path: '/go/pro?utm_source=legacy_teams&utm_medium=redirect',
|
|
430
|
+
ctaId: 'go_pro',
|
|
441
431
|
ctaPlacement: 'link_router',
|
|
442
|
-
eventType: '
|
|
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
|
-
<
|
|
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
|
-
};
|