thumbgate 1.28.0 → 1.28.2
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/plugin.json +2 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +96 -122
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +54 -34
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +100 -77
- package/config/gates/default.json +2 -2
- package/config/github-about.json +2 -5
- package/config/mcp-allowlists.json +5 -0
- package/config/post-deploy-marketing-pages.json +1 -1
- package/hooks/hooks.json +38 -0
- package/package.json +19 -8
- package/public/chatgpt-app.html +2 -2
- package/public/codex-enterprise.html +1 -1
- package/public/codex-plugin.html +1 -1
- package/public/diagnostic.html +23 -1
- package/public/guide.html +1 -1
- package/public/index.html +172 -201
- package/public/numbers.html +2 -2
- package/public/partner-intake.html +198 -0
- package/public/pricing.html +51 -14
- package/scripts/auto-wire-hooks.js +58 -2
- package/scripts/billing.js +815 -137
- package/scripts/checkout-attribution-reference.js +85 -0
- package/scripts/claude-feedback-sync.js +23 -6
- package/scripts/cli-feedback.js +20 -10
- package/scripts/feedback-history-distiller.js +385 -0
- package/scripts/feedback-loop.js +290 -1
- package/scripts/feedback-quality.js +25 -26
- package/scripts/feedback-sanitizer.js +151 -3
- package/scripts/gates-engine.js +128 -49
- package/scripts/hosted-config.js +9 -2
- package/scripts/mailer/resend-mailer.js +11 -5
- package/scripts/secret-scanner.js +363 -68
- package/scripts/self-protection.js +21 -36
- package/src/api/server.js +121 -12
- package/public/assets/brand/github-social-preview.png +0 -0
|
@@ -187,6 +187,24 @@ const PRIVATE_MCP_MODULES = Object.freeze({
|
|
|
187
187
|
lessonSearch: path.resolve(__dirname, '../../scripts/lesson-search.js'),
|
|
188
188
|
});
|
|
189
189
|
|
|
190
|
+
const PRIVATE_MCP_TOOL_REQUIREMENTS = Object.freeze({
|
|
191
|
+
search_lessons: ['lessonSearch'],
|
|
192
|
+
reflect_on_feedback: ['reflectorAgent'],
|
|
193
|
+
list_intents: ['intentRouter'],
|
|
194
|
+
plan_intent: ['intentRouter'],
|
|
195
|
+
start_handoff: ['intentRouter', 'delegationRuntime'],
|
|
196
|
+
complete_handoff: ['delegationRuntime'],
|
|
197
|
+
distribute_context_to_agents: ['swarmCoordinator'],
|
|
198
|
+
session_report: ['sessionReport'],
|
|
199
|
+
generate_operator_artifact: ['operatorArtifacts'],
|
|
200
|
+
org_dashboard: ['orgDashboard'],
|
|
201
|
+
get_business_metrics: ['semanticLayer'],
|
|
202
|
+
describe_semantic_entity: ['semanticLayer'],
|
|
203
|
+
run_managed_lesson_agent: ['managedLessonAgent'],
|
|
204
|
+
managed_agent_status: ['managedLessonAgent'],
|
|
205
|
+
context_stuff_lessons: ['lessonInference'],
|
|
206
|
+
});
|
|
207
|
+
|
|
190
208
|
function loadPrivateMcpModule(key) {
|
|
191
209
|
const modulePath = PRIVATE_MCP_MODULES[key];
|
|
192
210
|
if (!modulePath) {
|
|
@@ -204,6 +222,20 @@ function loadPrivateMcpModule(key) {
|
|
|
204
222
|
}
|
|
205
223
|
}
|
|
206
224
|
|
|
225
|
+
function isToolAvailable(toolName) {
|
|
226
|
+
const requirements = PRIVATE_MCP_TOOL_REQUIREMENTS[toolName] || [];
|
|
227
|
+
try {
|
|
228
|
+
return requirements.every((key) => Boolean(loadPrivateMcpModule(key)));
|
|
229
|
+
} catch {
|
|
230
|
+
return false;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function listAvailableTools(profileName = getActiveMcpProfile()) {
|
|
235
|
+
const allowedTools = new Set(getAllowedTools(profileName));
|
|
236
|
+
return TOOLS.filter((tool) => allowedTools.has(tool.name) && isToolAvailable(tool.name));
|
|
237
|
+
}
|
|
238
|
+
|
|
207
239
|
function unavailablePrivateMcpFeature(toolName) {
|
|
208
240
|
return toTextResult({
|
|
209
241
|
ok: false,
|
|
@@ -231,7 +263,7 @@ const {
|
|
|
231
263
|
finalizeSession: finalizeFeedbackSession,
|
|
232
264
|
} = require('../../scripts/feedback-session');
|
|
233
265
|
|
|
234
|
-
const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.28.
|
|
266
|
+
const SERVER_INFO = { name: 'thumbgate-mcp', version: '1.28.2' };
|
|
235
267
|
const COMMERCE_CATEGORIES = [
|
|
236
268
|
'product_recommendation',
|
|
237
269
|
'brand_compliance',
|
|
@@ -1357,7 +1389,7 @@ async function handleRequest(message) {
|
|
|
1357
1389
|
};
|
|
1358
1390
|
}
|
|
1359
1391
|
if (message.method === 'ping') return {};
|
|
1360
|
-
if (message.method === 'tools/list') return { tools:
|
|
1392
|
+
if (message.method === 'tools/list') return { tools: listAvailableTools() };
|
|
1361
1393
|
if (message.method === 'tools/call') return callTool(message.params.name, message.params.arguments);
|
|
1362
1394
|
throw new Error(`Unsupported method: ${message.method}`);
|
|
1363
1395
|
}
|
|
@@ -1430,22 +1462,14 @@ function writeNdjsonResponse(id, payload, error = null) {
|
|
|
1430
1462
|
process.stdout.write(`${body}\n`);
|
|
1431
1463
|
}
|
|
1432
1464
|
|
|
1433
|
-
/**
|
|
1434
|
-
* Default staleness threshold: if a lock is older than this (ms), the holder
|
|
1435
|
-
* is considered orphaned even if its PID is still alive — it likely belongs
|
|
1436
|
-
* to a defunct Claude Code session whose process was never reaped.
|
|
1437
|
-
*/
|
|
1438
|
-
const LOCK_STALE_MS = Number(process.env.THUMBGATE_LOCK_STALE_MS) || 2 * 60 * 60 * 1000; // 2 hours
|
|
1439
|
-
|
|
1440
1465
|
/**
|
|
1441
1466
|
* Acquire a file-system lock to prevent duplicate MCP server instances.
|
|
1442
|
-
* Returns { lockFile, cleanupLock } on success
|
|
1443
|
-
* if another live server holds the lock.
|
|
1467
|
+
* Returns { lockFile, cleanupLock } on success.
|
|
1444
1468
|
*
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
1447
|
-
*
|
|
1448
|
-
*
|
|
1469
|
+
* Every live stdio client needs its own MCP process, and those sessions can
|
|
1470
|
+
* legitimately run for many hours. A lock's age is therefore not evidence that
|
|
1471
|
+
* its owner is orphaned. Live owners coexist through per-session locks; only a
|
|
1472
|
+
* lock whose PID is no longer running is reclaimed.
|
|
1449
1473
|
*/
|
|
1450
1474
|
function acquireLock() {
|
|
1451
1475
|
const feedbackDir = getFeedbackPaths().FEEDBACK_DIR;
|
|
@@ -1458,26 +1482,19 @@ function acquireLock() {
|
|
|
1458
1482
|
try { process.kill(lockData.pid, 0); isRunning = true; } catch { /* process is dead */ }
|
|
1459
1483
|
|
|
1460
1484
|
if (isRunning) {
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
fs.writeFileSync(sessionLockFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
1473
|
-
const cleanupSessionLock = () => { try { fs.unlinkSync(sessionLockFile); } catch { /* already removed */ } };
|
|
1474
|
-
process.on('exit', cleanupSessionLock);
|
|
1475
|
-
process.on('SIGTERM', () => { cleanupSessionLock(); process.exit(0); });
|
|
1476
|
-
process.on('SIGINT', () => { cleanupSessionLock(); process.exit(0); });
|
|
1477
|
-
return { lockFile: sessionLockFile, cleanupLock: cleanupSessionLock };
|
|
1478
|
-
}
|
|
1485
|
+
// Another session's MCP server is running — coexist via per-session lock.
|
|
1486
|
+
// Each client communicates via its own stdio pipe and needs its own server.
|
|
1487
|
+
// SQLite WAL mode handles concurrent access safely.
|
|
1488
|
+
process.stderr.write(`[thumbgate] Another MCP server (PID ${lockData.pid}) is running for ${feedbackDir}. Starting concurrent session.\n`);
|
|
1489
|
+
const sessionLockFile = path.join(feedbackDir, `.mcp-server-${process.pid}.lock`);
|
|
1490
|
+
fs.writeFileSync(sessionLockFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
1491
|
+
const cleanupSessionLock = () => { try { fs.unlinkSync(sessionLockFile); } catch { /* already removed */ } };
|
|
1492
|
+
process.on('exit', cleanupSessionLock);
|
|
1493
|
+
process.on('SIGTERM', () => { cleanupSessionLock(); process.exit(0); });
|
|
1494
|
+
process.on('SIGINT', () => { cleanupSessionLock(); process.exit(0); });
|
|
1495
|
+
return { lockFile: sessionLockFile, cleanupLock: cleanupSessionLock };
|
|
1479
1496
|
}
|
|
1480
|
-
// Stale lock from a dead
|
|
1497
|
+
// Stale lock from a dead process — remove it.
|
|
1481
1498
|
try { fs.unlinkSync(lockFile); } catch { /* already gone */ }
|
|
1482
1499
|
process.stderr.write(`[thumbgate] Removed stale lock (PID ${lockData.pid} is no longer running).\n`);
|
|
1483
1500
|
}
|
|
@@ -1574,7 +1591,10 @@ module.exports = {
|
|
|
1574
1591
|
buildSuggestFixResponse,
|
|
1575
1592
|
__test__: {
|
|
1576
1593
|
PRIVATE_MCP_MODULES,
|
|
1594
|
+
PRIVATE_MCP_TOOL_REQUIREMENTS,
|
|
1577
1595
|
loadPrivateMcpModule,
|
|
1596
|
+
isToolAvailable,
|
|
1597
|
+
listAvailableTools,
|
|
1578
1598
|
unavailablePrivateMcpFeature,
|
|
1579
1599
|
callToolInner,
|
|
1580
1600
|
},
|
package/bin/cli.js
CHANGED
|
@@ -444,6 +444,11 @@ function codexPreToolHookSectionBlock() {
|
|
|
444
444
|
return `[hooks.pre_tool_use]\ncommand = "${entry.command}"\nargs = ${formatTomlStringArray(entry.args)}\n`;
|
|
445
445
|
}
|
|
446
446
|
|
|
447
|
+
function codexUserPromptHookSectionBlock() {
|
|
448
|
+
const entry = canonicalCodexCliEntry(['hook-auto-capture']);
|
|
449
|
+
return `[hooks.user_prompt_submit]\ncommand = "${entry.command}"\nargs = ${formatTomlStringArray(entry.args)}\n`;
|
|
450
|
+
}
|
|
451
|
+
|
|
447
452
|
function mcpSectionRegex(name) {
|
|
448
453
|
return new RegExp(
|
|
449
454
|
`^\\[mcp_servers\\.${escapeRegExp(name)}\\]\\n(?:^(?!\\[).*(?:\\n|$))*`,
|
|
@@ -461,6 +466,7 @@ function tomlSectionRegex(name) {
|
|
|
461
466
|
function upsertCodexServerConfig(content) {
|
|
462
467
|
const canonicalBlock = codexMcpSectionBlock(MCP_SERVER_NAME);
|
|
463
468
|
const canonicalHookBlock = codexPreToolHookSectionBlock();
|
|
469
|
+
const canonicalUserPromptHookBlock = codexUserPromptHookSectionBlock();
|
|
464
470
|
const sections = MCP_SERVER_NAMES.map((name) => ({
|
|
465
471
|
name,
|
|
466
472
|
regex: mcpSectionRegex(name),
|
|
@@ -473,7 +479,7 @@ function upsertCodexServerConfig(content) {
|
|
|
473
479
|
const prefix = content.trimEnd();
|
|
474
480
|
return {
|
|
475
481
|
changed: true,
|
|
476
|
-
content: `${prefix}${prefix ? '\n\n' : ''}${canonicalBlock}\n${canonicalHookBlock}`,
|
|
482
|
+
content: `${prefix}${prefix ? '\n\n' : ''}${canonicalBlock}\n${canonicalHookBlock}\n${canonicalUserPromptHookBlock}`,
|
|
477
483
|
};
|
|
478
484
|
}
|
|
479
485
|
|
|
@@ -517,6 +523,19 @@ function upsertCodexServerConfig(content) {
|
|
|
517
523
|
changed = true;
|
|
518
524
|
}
|
|
519
525
|
|
|
526
|
+
const userPromptHookRegex = tomlSectionRegex('hooks.user_prompt_submit');
|
|
527
|
+
if (userPromptHookRegex.test(nextContent)) {
|
|
528
|
+
const current = nextContent.match(userPromptHookRegex)[0];
|
|
529
|
+
if (current !== canonicalUserPromptHookBlock) {
|
|
530
|
+
nextContent = nextContent.replace(userPromptHookRegex, canonicalUserPromptHookBlock);
|
|
531
|
+
changed = true;
|
|
532
|
+
}
|
|
533
|
+
} else {
|
|
534
|
+
const prefix = nextContent.trimEnd();
|
|
535
|
+
nextContent = `${prefix}${prefix ? '\n\n' : ''}${canonicalUserPromptHookBlock}`;
|
|
536
|
+
changed = true;
|
|
537
|
+
}
|
|
538
|
+
|
|
520
539
|
return {
|
|
521
540
|
changed,
|
|
522
541
|
content: nextContent.endsWith('\n') ? nextContent : `${nextContent}\n`,
|
|
@@ -2695,23 +2714,27 @@ function install() {
|
|
|
2695
2714
|
}
|
|
2696
2715
|
|
|
2697
2716
|
async function gateCheck() {
|
|
2698
|
-
// Explicit emergency escape hatch ONLY. The 2026-06-03 hotfix made this
|
|
2699
|
-
// bypass-by-default, which silently disabled ThumbGate's enforcement entirely
|
|
2700
|
-
// (the firewall approved everything). Restored 2026-06-04: enforcement runs by
|
|
2701
|
-
// default in warn-by-default posture (see gates-engine applyEnforcementPosture);
|
|
2702
|
-
// set THUMBGATE_HOTFIX_BYPASS=1 to disable all checks if a gate ever misfires.
|
|
2703
|
-
if (process.env.THUMBGATE_HOTFIX_BYPASS === '1') {
|
|
2704
|
-
process.stdout.write(JSON.stringify({
|
|
2705
|
-
decision: 'approve',
|
|
2706
|
-
reason: 'hotfix-bypass-opt-in',
|
|
2707
|
-
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: '' }
|
|
2708
|
-
}) + '\n');
|
|
2709
|
-
return;
|
|
2710
|
-
}
|
|
2711
2717
|
try {
|
|
2712
2718
|
const payload = readStdinText();
|
|
2713
2719
|
const input = payload ? JSON.parse(payload) : {};
|
|
2714
2720
|
const gatesEngine = require(path.join(PKG_ROOT, 'scripts', 'gates-engine'));
|
|
2721
|
+
|
|
2722
|
+
// The operator bypass only skips advisory and strict-mode gates. Security and
|
|
2723
|
+
// self-protection floors are evaluated before the early approval path.
|
|
2724
|
+
if (process.env.THUMBGATE_HOTFIX_BYPASS === '1') {
|
|
2725
|
+
const hardFloorOutput = gatesEngine.runHardFloor(input);
|
|
2726
|
+
if (hardFloorOutput) {
|
|
2727
|
+
process.stdout.write(hardFloorOutput + '\n');
|
|
2728
|
+
return;
|
|
2729
|
+
}
|
|
2730
|
+
process.stdout.write(JSON.stringify({
|
|
2731
|
+
decision: 'approve',
|
|
2732
|
+
reason: 'operator-bypass-opt-in',
|
|
2733
|
+
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: '' }
|
|
2734
|
+
}) + '\n');
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
|
|
2715
2738
|
const output = await gatesEngine.runAsync(input);
|
|
2716
2739
|
process.stdout.write(output + '\n');
|
|
2717
2740
|
} catch (err) {
|
|
@@ -2749,14 +2772,21 @@ function statuslineRender() {
|
|
|
2749
2772
|
|
|
2750
2773
|
function hookAutoCapture() {
|
|
2751
2774
|
syncActiveProjectContext();
|
|
2775
|
+
const { extractPromptEnvelope } = require(path.join(PKG_ROOT, 'scripts', 'feedback-sanitizer'));
|
|
2776
|
+
const rawStdin = readStdinText();
|
|
2777
|
+
const promptEnvelope = extractPromptEnvelope(rawStdin);
|
|
2778
|
+
// stdin on a UserPromptSubmit hook is the JSON payload
|
|
2779
|
+
// {session_id, transcript_path, cwd, prompt, ...}. Persist ONLY the human
|
|
2780
|
+
// `.prompt` field — never the whole stdin object — so session metadata blobs
|
|
2781
|
+
// can't be promoted as lessons.
|
|
2752
2782
|
const prompt = process.env.CLAUDE_USER_PROMPT
|
|
2753
2783
|
|| process.env.THUMBGATE_USER_PROMPT
|
|
2754
2784
|
|| process.env.CODEX_USER_PROMPT
|
|
2755
2785
|
|| process.env.USER_PROMPT
|
|
2756
|
-
||
|
|
2786
|
+
|| promptEnvelope.prompt;
|
|
2757
2787
|
const { evaluatePromptGuard } = require(path.join(PKG_ROOT, 'scripts', 'prompt-guard'));
|
|
2758
2788
|
const { processInlineFeedback, formatCliOutput } = require(path.join(PKG_ROOT, 'scripts', 'cli-feedback'));
|
|
2759
|
-
const { detectFeedbackSignal } = require(path.join(PKG_ROOT, 'scripts', 'feedback-quality'));
|
|
2789
|
+
const { detectFeedbackSignal, isGenericFeedbackText } = require(path.join(PKG_ROOT, 'scripts', 'feedback-quality'));
|
|
2760
2790
|
const { loadOptionalModule } = require(path.join(PKG_ROOT, 'scripts', 'private-core-boundary'));
|
|
2761
2791
|
const { recordConversationEntry, readRecentConversationWindow } = loadOptionalModule(
|
|
2762
2792
|
path.join(PKG_ROOT, 'scripts', 'feedback-history-distiller'),
|
|
@@ -2766,34 +2796,60 @@ function hookAutoCapture() {
|
|
|
2766
2796
|
})
|
|
2767
2797
|
);
|
|
2768
2798
|
|
|
2769
|
-
recordConversationEntry({
|
|
2770
|
-
author: 'user',
|
|
2771
|
-
text: prompt,
|
|
2772
|
-
source: 'claude_user_prompt',
|
|
2773
|
-
});
|
|
2774
|
-
|
|
2775
2799
|
const guardResult = evaluatePromptGuard(prompt);
|
|
2776
2800
|
if (guardResult) {
|
|
2801
|
+
recordConversationEntry({
|
|
2802
|
+
author: 'user',
|
|
2803
|
+
text: prompt,
|
|
2804
|
+
source: 'claude_user_prompt',
|
|
2805
|
+
});
|
|
2777
2806
|
process.stdout.write(`${JSON.stringify(guardResult)}\n`);
|
|
2778
2807
|
return;
|
|
2779
2808
|
}
|
|
2780
2809
|
|
|
2781
2810
|
const detected = detectFeedbackSignal(prompt);
|
|
2782
2811
|
if (!detected) {
|
|
2812
|
+
recordConversationEntry({
|
|
2813
|
+
author: 'user',
|
|
2814
|
+
text: prompt,
|
|
2815
|
+
source: 'claude_user_prompt',
|
|
2816
|
+
});
|
|
2783
2817
|
return;
|
|
2784
2818
|
}
|
|
2785
2819
|
|
|
2786
2820
|
const signal = detected.signal;
|
|
2787
2821
|
const conversationWindow = readRecentConversationWindow({ limit: 8 });
|
|
2822
|
+
const chatHistory = conversationWindow.map((entry) => ({
|
|
2823
|
+
role: entry.author === 'assistant' ? 'assistant' : 'user',
|
|
2824
|
+
content: entry.text || '',
|
|
2825
|
+
}));
|
|
2826
|
+
const normalizedSignal = signal === 'down' ? 'negative' : 'positive';
|
|
2827
|
+
const genericFeedback = isGenericFeedbackText(prompt, normalizedSignal);
|
|
2828
|
+
const { buildFeedbackSourceIdentity } = require(path.join(PKG_ROOT, 'scripts', 'feedback-loop'));
|
|
2829
|
+
const sourceEvent = buildFeedbackSourceIdentity({
|
|
2830
|
+
signal,
|
|
2831
|
+
promptText: prompt,
|
|
2832
|
+
sessionId: promptEnvelope.sessionId || process.env.CLAUDE_SESSION_ID,
|
|
2833
|
+
promptId: promptEnvelope.promptId,
|
|
2834
|
+
projectDir: promptEnvelope.projectDir || CWD,
|
|
2835
|
+
timestamp: promptEnvelope.timestamp,
|
|
2836
|
+
source: 'claude-user-prompt',
|
|
2837
|
+
});
|
|
2788
2838
|
const result = processInlineFeedback({
|
|
2789
2839
|
signal,
|
|
2790
2840
|
context: prompt,
|
|
2791
|
-
chatHistory
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
whatWorked: signal === 'up' ? prompt : undefined,
|
|
2841
|
+
chatHistory,
|
|
2842
|
+
whatWentWrong: signal === 'down' && !genericFeedback ? prompt : undefined,
|
|
2843
|
+
whatWorked: signal === 'up' && !genericFeedback ? prompt : undefined,
|
|
2844
|
+
sourceEvent,
|
|
2796
2845
|
});
|
|
2846
|
+
if (!result.feedbackResult || !result.feedbackResult.duplicate) {
|
|
2847
|
+
recordConversationEntry({
|
|
2848
|
+
author: 'user',
|
|
2849
|
+
text: prompt,
|
|
2850
|
+
source: 'claude_user_prompt',
|
|
2851
|
+
});
|
|
2852
|
+
}
|
|
2797
2853
|
process.stdout.write(formatCliOutput(result) + '\n');
|
|
2798
2854
|
}
|
|
2799
2855
|
|
|
@@ -3947,53 +4003,6 @@ switch (COMMAND) {
|
|
|
3947
4003
|
case 'dispatch-brief':
|
|
3948
4004
|
dispatchBrief();
|
|
3949
4005
|
break;
|
|
3950
|
-
case 'gate-check': {
|
|
3951
|
-
// PreToolUse hook interface: reads tool call JSON from stdin, outputs gate verdict
|
|
3952
|
-
// Used by: generate-pretool-hook.sh → npx thumbgate gate-check
|
|
3953
|
-
const { run: gateRun, runAsync: gateRunAsync } = require(path.join(PKG_ROOT, 'scripts', 'gates-engine'));
|
|
3954
|
-
const { evaluateSelfProtection } = require(path.join(PKG_ROOT, 'scripts', 'self-protection'));
|
|
3955
|
-
let stdinData = '';
|
|
3956
|
-
process.stdin.setEncoding('utf8');
|
|
3957
|
-
process.stdin.on('data', (chunk) => { stdinData += chunk; });
|
|
3958
|
-
process.stdin.on('end', async () => {
|
|
3959
|
-
try {
|
|
3960
|
-
const input = JSON.parse(stdinData);
|
|
3961
|
-
const output = await gateRunAsync(input);
|
|
3962
|
-
// Self-protection overlay (2026-07-08): the gate engine only denies edits
|
|
3963
|
-
// to ThumbGate's own governance files under strict enforcement; by default
|
|
3964
|
-
// such edits pass as a clean ALLOW. Surface them (or block in strict) so an
|
|
3965
|
-
// agent can't silently disable the firewall. Only overlays when the engine
|
|
3966
|
-
// did not already produce a hard deny.
|
|
3967
|
-
let verdict = output;
|
|
3968
|
-
try {
|
|
3969
|
-
const parsed = JSON.parse(output || '{}');
|
|
3970
|
-
const alreadyDeny = (parsed.hookSpecificOutput && parsed.hookSpecificOutput.permissionDecision === 'deny')
|
|
3971
|
-
|| parsed.decision === 'block';
|
|
3972
|
-
if (!alreadyDeny) {
|
|
3973
|
-
const sp = evaluateSelfProtection(input.tool_name, input.tool_input);
|
|
3974
|
-
if (sp && sp.action === 'block') {
|
|
3975
|
-
verdict = JSON.stringify({
|
|
3976
|
-
decision: 'block',
|
|
3977
|
-
reason: sp.message,
|
|
3978
|
-
hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: sp.message },
|
|
3979
|
-
});
|
|
3980
|
-
} else if (sp && sp.action === 'warn') {
|
|
3981
|
-
verdict = JSON.stringify({
|
|
3982
|
-
hookSpecificOutput: { hookEventName: 'PreToolUse', additionalContext: sp.message },
|
|
3983
|
-
});
|
|
3984
|
-
}
|
|
3985
|
-
}
|
|
3986
|
-
} catch (_e) { /* non-JSON engine output: pass through unchanged */ }
|
|
3987
|
-
process.stdout.write(verdict + '\n');
|
|
3988
|
-
process.exit(0);
|
|
3989
|
-
} catch (err) {
|
|
3990
|
-
process.stderr.write(`gate-check error: ${err.message}\n`);
|
|
3991
|
-
process.stdout.write(JSON.stringify({}) + '\n');
|
|
3992
|
-
process.exit(0);
|
|
3993
|
-
}
|
|
3994
|
-
});
|
|
3995
|
-
break;
|
|
3996
|
-
}
|
|
3997
4006
|
case 'hermes-gate': {
|
|
3998
4007
|
// Nous Research Hermes Agent `pre_tool_call` shell hook.
|
|
3999
4008
|
// Hermes pipes each pending tool call as JSON to stdin and reads a decision from stdout;
|
|
@@ -4004,7 +4013,7 @@ switch (COMMAND) {
|
|
|
4004
4013
|
// Hermes `pre_tool_call` is binary (block or allow) with no warn channel, and the whole point
|
|
4005
4014
|
// of wiring it is to gate, so we run STRICT enforcement by default — otherwise ThumbGate's
|
|
4006
4015
|
// warn-by-default posture would pass every deny through and the hook would block nothing.
|
|
4007
|
-
// Opt out with THUMBGATE_HERMES_WARN_ONLY=1;
|
|
4016
|
+
// Opt out with THUMBGATE_HERMES_WARN_ONLY=1; the operator bypass also leaves ordinary gates advisory.
|
|
4008
4017
|
// Wire it in ~/.hermes/config.yaml — see adapters/hermes/config.yaml.
|
|
4009
4018
|
if (process.env.THUMBGATE_HERMES_WARN_ONLY !== '1' && process.env.THUMBGATE_HOTFIX_BYPASS !== '1') {
|
|
4010
4019
|
process.env.THUMBGATE_STRICT_ENFORCEMENT = '1';
|
|
@@ -4016,8 +4025,22 @@ switch (COMMAND) {
|
|
|
4016
4025
|
process.stdin.on('end', async () => {
|
|
4017
4026
|
try {
|
|
4018
4027
|
const payload = JSON.parse(hermesStdin);
|
|
4019
|
-
|
|
4020
|
-
const
|
|
4028
|
+
const hermesToolName = String(payload.tool_name || '');
|
|
4029
|
+
const canonicalToolNames = {
|
|
4030
|
+
terminal: 'Bash',
|
|
4031
|
+
process: 'Bash',
|
|
4032
|
+
execute_code: 'Bash',
|
|
4033
|
+
patch: 'Edit',
|
|
4034
|
+
write_file: 'Write',
|
|
4035
|
+
read_file: 'Read',
|
|
4036
|
+
};
|
|
4037
|
+
const verdict = await hermesGateRun({
|
|
4038
|
+
tool_name: canonicalToolNames[hermesToolName] || hermesToolName,
|
|
4039
|
+
tool_input: {
|
|
4040
|
+
...(payload.tool_input || {}),
|
|
4041
|
+
hermes_tool_name: hermesToolName,
|
|
4042
|
+
},
|
|
4043
|
+
});
|
|
4021
4044
|
let parsed = {};
|
|
4022
4045
|
try { parsed = JSON.parse(verdict); } catch (_e) { parsed = {}; }
|
|
4023
4046
|
const hso = parsed.hookSpecificOutput || {};
|
|
@@ -342,7 +342,7 @@
|
|
|
342
342
|
"id": "self-protect-config",
|
|
343
343
|
"layer": "Execution",
|
|
344
344
|
"toolNames": ["Edit", "Write", "MultiEdit"],
|
|
345
|
-
"pattern": "(?:config/gates/|config/budget\\.json|\\.thumbgate
|
|
345
|
+
"pattern": "(?:config/gates/|config/(?:budget|enforcement|mcp-allowlists)\\.json|\\.thumbgate/config\\.json|thumbgate\\.json)",
|
|
346
346
|
"action": "block",
|
|
347
347
|
"message": "Self-protection: agent cannot modify ThumbGate configuration, gate rules, or budget settings.",
|
|
348
348
|
"severity": "critical",
|
|
@@ -372,7 +372,7 @@
|
|
|
372
372
|
"id": "self-protect-hooks-disable",
|
|
373
373
|
"layer": "Execution",
|
|
374
374
|
"toolNames": ["Edit", "Write", "Bash"],
|
|
375
|
-
"pattern": "(
|
|
375
|
+
"pattern": "(?:\\.claude/settings(?:\\.local)?\\.json|\\.codex/config\\.toml|scripts/hook-[^/]+\\.(?:js|sh))",
|
|
376
376
|
"action": "block",
|
|
377
377
|
"message": "Self-protection: agent cannot modify hook registrations.",
|
|
378
378
|
"severity": "critical",
|
package/config/github-about.json
CHANGED
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
"repo": "IgorGanapolsky/ThumbGate",
|
|
3
3
|
"repositoryUrl": "https://github.com/IgorGanapolsky/ThumbGate",
|
|
4
4
|
"homepageUrl": "https://thumbgate.ai",
|
|
5
|
-
"githubDescription": "
|
|
6
|
-
"metaDescription": "ThumbGate is the pre-action governance runtime for AI coding agents (Claude Code, Cursor, Codex, Gemini CLI, Amp, Cline, OpenCode): 👍 thumbs
|
|
5
|
+
"githubDescription": "ThumbGate Pre-Action Checks derive rules from repeated failures, flag risky tool calls, hard-block detected secret leaks, and block matches in strict mode.",
|
|
6
|
+
"metaDescription": "ThumbGate is the local pre-action governance runtime for AI coding agents (Claude Code, Cursor, Codex, Gemini CLI, Amp, Cline, OpenCode): accepted 👍 thumbs-up and 👎 thumbs-down feedback becomes history-aware local lessons; repeated failures can become prevention rules. PreToolUse checks flag and log risky tool calls, hard-block detected secret leaks, and block matching actions in strict mode. Pro is for individual operators; Enterprise rollout is scoped after intake.",
|
|
7
7
|
"topics": [
|
|
8
8
|
"thumbgate",
|
|
9
9
|
"pre-action-checks",
|
|
10
|
-
"save-llm-tokens",
|
|
11
|
-
"reduce-llm-cost",
|
|
12
|
-
"ai-cost-optimization",
|
|
13
10
|
"mcp",
|
|
14
11
|
"mcp-server",
|
|
15
12
|
"ai-agents",
|
|
@@ -68,6 +68,11 @@
|
|
|
68
68
|
"distribute_context_to_agents",
|
|
69
69
|
"session_report",
|
|
70
70
|
"generate_operator_artifact",
|
|
71
|
+
"run_managed_lesson_agent",
|
|
72
|
+
"managed_agent_status",
|
|
73
|
+
"run_self_distill",
|
|
74
|
+
"self_distill_status",
|
|
75
|
+
"context_stuff_lessons",
|
|
71
76
|
"perplexity_search",
|
|
72
77
|
"perplexity_ask",
|
|
73
78
|
"perplexity_research",
|
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"hooks": {
|
|
3
|
+
"PreToolUse": [
|
|
4
|
+
{
|
|
5
|
+
"matcher": "Bash|Edit|Write",
|
|
6
|
+
"hooks": [
|
|
7
|
+
{
|
|
8
|
+
"type": "command",
|
|
9
|
+
"command": "node",
|
|
10
|
+
"args": ["${CLAUDE_PLUGIN_ROOT}/scripts/hook-pre-tool-use.js"]
|
|
11
|
+
}
|
|
12
|
+
]
|
|
13
|
+
}
|
|
14
|
+
],
|
|
15
|
+
"UserPromptSubmit": [
|
|
16
|
+
{
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node",
|
|
21
|
+
"args": ["${CLAUDE_PLUGIN_ROOT}/bin/cli.js", "hook-auto-capture"]
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"SessionStart": [
|
|
27
|
+
{
|
|
28
|
+
"hooks": [
|
|
29
|
+
{
|
|
30
|
+
"type": "command",
|
|
31
|
+
"command": "node",
|
|
32
|
+
"args": ["${CLAUDE_PLUGIN_ROOT}/bin/cli.js", "session-start"]
|
|
33
|
+
}
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
}
|