thumbgate 1.27.9 → 1.27.11
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/README.md +4 -0
- package/config/gate-templates.json +228 -0
- package/config/gates/claim-verification.json +18 -0
- package/package.json +3 -3
- package/public/index.html +180 -0
- package/public/learn/agent-identity-connector-governance.html +146 -0
- package/public/learn/anthropomorphic-claim-gates.html +180 -0
- package/public/learn.html +28 -0
- package/scripts/gate-stats.js +11 -5
- package/scripts/hook-stop-anti-claim.js +369 -0
- package/scripts/tool-registry.js +2 -2
- package/src/api/server.js +2 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Stop hook: anti-claim enforcement.
|
|
6
|
+
*
|
|
7
|
+
* Scans the assistant's most recent turn (assistant text + same-turn tool_use
|
|
8
|
+
* blocks) and blocks the "deployed / live / done / fixed / ready" claim
|
|
9
|
+
* unless that same turn included a proof tool call (curl / grep / test).
|
|
10
|
+
*
|
|
11
|
+
* Why: CLAUDE.md anti-lying directive ("Never claim fix done until
|
|
12
|
+
* committed+pushed. Never claim 'ready' without running e2e.") was
|
|
13
|
+
* aspirational, not enforced. Per CEO 2026-05-13 feedback after a session in
|
|
14
|
+
* which 5+ unverified claims slipped through, this is the harness-level
|
|
15
|
+
* enforcement that ends the recurring trust-burn pattern. ThumbGate-on-
|
|
16
|
+
* ThumbGate dogfood — we are the prevention-rule generator and a perfect
|
|
17
|
+
* customer for our own gate.
|
|
18
|
+
*
|
|
19
|
+
* Wires through .claude/settings.json Stop hooks list. Always exits 0 and
|
|
20
|
+
* emits a Claude-hook `decision:"block"` JSON payload when a final-response
|
|
21
|
+
* violation can be evaluated before the response is accepted. Transcript-only
|
|
22
|
+
* runs still surface a reminder for the next turn when the host has already
|
|
23
|
+
* written the assistant response.
|
|
24
|
+
*
|
|
25
|
+
* Stdin: Claude Code passes the hook payload as JSON on stdin. We read
|
|
26
|
+
* `transcript_path` to locate the JSONL session log and scan the last
|
|
27
|
+
* assistant message.
|
|
28
|
+
*
|
|
29
|
+
* Stdout: any text printed is shown to the agent on the next turn.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
const fs = require('node:fs');
|
|
33
|
+
|
|
34
|
+
// Lie-phrase patterns. These match common "claim of completion" wording
|
|
35
|
+
// the agent emits without verification. Word-boundary anchored to avoid
|
|
36
|
+
// false positives ("ready-made", "live-streaming", etc).
|
|
37
|
+
const CLAIM_PATTERNS = [
|
|
38
|
+
/\bis\s+live\b/i,
|
|
39
|
+
/\bnow\s+live\b/i,
|
|
40
|
+
/\bgoing\s+live\b/i,
|
|
41
|
+
/\bdeployed\b(?!\s*(yet|to\s+staging|on\s+a\s+branch))/i,
|
|
42
|
+
/\b(?:is|are|it'?s)\s+(?:now\s+)?(?:fully\s+)?(?:fixed|resolved|merged|shipped)\b/i,
|
|
43
|
+
/\bproduction[-\s]ready\b/i,
|
|
44
|
+
/\beverything\s+(?:is\s+)?(?:done|working|ready)\b/i,
|
|
45
|
+
/\b(?:github|repo|repository)\s+(?:about|metadata|description|topics?)\b.*\b(?:updated|verified|fixed|match(?:es|ed)?)\b/i,
|
|
46
|
+
/\b(?:about|metadata|description|topics?)\b.*\b(?:updated|verified|fixed|match(?:es|ed)?)\b.*\b(?:github|repo|repository)\b/i,
|
|
47
|
+
/\b(?:money|payment|charge|checkout|revenue|price|pricing|invoice|billing|tax|sales tax|inventory|stock|permission|access|customer[-\s]facing)\b.*\b(?:correct|accurate|verified|valid|matches|working|fixed|resolved|calculated|configured)\b/i,
|
|
48
|
+
/\b(?:correct|accurate|verified|valid|matches|working|fixed|resolved|calculated|configured)\b.*\b(?:money|payment|charge|checkout|revenue|price|pricing|invoice|billing|tax|sales tax|inventory|stock|permission|access|customer[-\s]facing)\b/i,
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
// Proof-of-verification patterns. If the SAME turn included one of these
|
|
52
|
+
// tool calls or shell command tokens, the claim is considered backed and
|
|
53
|
+
// the hook stays silent.
|
|
54
|
+
const PROOF_PATTERNS = [
|
|
55
|
+
/\bcurl\b/,
|
|
56
|
+
/\bgh\s+pr\s+(?:view|checks|status)\b/,
|
|
57
|
+
/\bgh\s+run\s+view\b/,
|
|
58
|
+
/\bgh\s+api\b/,
|
|
59
|
+
/\bnode\s+--test\b/,
|
|
60
|
+
/\bnpm\s+(?:run\s+)?test\b/,
|
|
61
|
+
/\bnpm\s+pack\b/,
|
|
62
|
+
/\bjest\b/,
|
|
63
|
+
/\bmocha\b/,
|
|
64
|
+
/\bpytest\b/,
|
|
65
|
+
/\bplaywright\b/,
|
|
66
|
+
/\bgrep\b/,
|
|
67
|
+
/\bstripe\b/,
|
|
68
|
+
/\bplaid\b/,
|
|
69
|
+
/\bshopify\b/,
|
|
70
|
+
/\bsquare\b/,
|
|
71
|
+
/\bquickbooks\b/,
|
|
72
|
+
/\bgh\s+api\b/,
|
|
73
|
+
/\bls\b/,
|
|
74
|
+
/\bcat\b/,
|
|
75
|
+
/Read\s*\(/, // Claude Code Read tool call
|
|
76
|
+
/Bash\s*\(/, // Claude Code Bash tool call
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
const POSITIVE_FEEDBACK_PATTERNS = [
|
|
80
|
+
/\bthumbs?\s*up\b/i,
|
|
81
|
+
/👍/,
|
|
82
|
+
/\bthank(s| you)\b/i,
|
|
83
|
+
/\bgood\b/i,
|
|
84
|
+
/\bgreat\b/i,
|
|
85
|
+
/\bperfect\b/i,
|
|
86
|
+
/\bok(?:ay)?\b/i,
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const LOW_VALUE_CLOSEOUT_PATTERNS = [
|
|
90
|
+
/^(?:good|great|perfect|ok(?:ay)?|thanks?|thank you)[.!,\s-]*(?:$|\b)/i,
|
|
91
|
+
/\buse\s+\w+\s*\/\s*\w+\b/i,
|
|
92
|
+
/\bsounds good\b/i,
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
const SUBSTANTIVE_CLOSEOUT_PATTERNS = [
|
|
96
|
+
/\b(?:evidence|verified|tested|proof|result|residual risk|next state|next action|timestamp|source|url|file|line|passed|failed|blocked|unknown)\b/i,
|
|
97
|
+
/\b(?:node --test|npm run|curl|gh |git |pytest|playwright|screenshot|diff --check)\b/i,
|
|
98
|
+
/https?:\/\//i,
|
|
99
|
+
/\/[\w.-]+\/[\w./-]+/,
|
|
100
|
+
/`[^`]+`/,
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
function readTranscriptEntries(transcriptPath) {
|
|
104
|
+
if (!transcriptPath || !fs.existsSync(transcriptPath)) return [];
|
|
105
|
+
let content;
|
|
106
|
+
try {
|
|
107
|
+
content = fs.readFileSync(transcriptPath, 'utf8');
|
|
108
|
+
} catch {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
return content
|
|
112
|
+
.trim()
|
|
113
|
+
.split('\n')
|
|
114
|
+
.map((raw) => {
|
|
115
|
+
try {
|
|
116
|
+
return JSON.parse(raw);
|
|
117
|
+
} catch {
|
|
118
|
+
return null;
|
|
119
|
+
}
|
|
120
|
+
})
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function readLastAssistantTurn(transcriptPath) {
|
|
125
|
+
const entries = readTranscriptEntries(transcriptPath);
|
|
126
|
+
// Walk backwards to find the last assistant message
|
|
127
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
128
|
+
const entry = entries[i];
|
|
129
|
+
if (entry.type === 'assistant' && entry.message) {
|
|
130
|
+
return entry.message;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function readPreviousUserText(transcriptPath) {
|
|
137
|
+
const entries = readTranscriptEntries(transcriptPath);
|
|
138
|
+
let seenAssistant = false;
|
|
139
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
140
|
+
const entry = entries[i];
|
|
141
|
+
if (!seenAssistant && entry.type === 'assistant') {
|
|
142
|
+
seenAssistant = true;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
if (seenAssistant && entry.type === 'user' && entry.message) {
|
|
146
|
+
return extractText(entry.message);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return '';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function extractText(message) {
|
|
153
|
+
if (!message || !Array.isArray(message.content)) return '';
|
|
154
|
+
return message.content
|
|
155
|
+
.filter((b) => b && typeof b.text === 'string')
|
|
156
|
+
.map((b) => b.text)
|
|
157
|
+
.join('\n');
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function extractToolUseSummary(message) {
|
|
161
|
+
if (!message || !Array.isArray(message.content)) return '';
|
|
162
|
+
return message.content
|
|
163
|
+
.filter((b) => b?.type === 'tool_use')
|
|
164
|
+
.map((b) => {
|
|
165
|
+
const name = b.name || 'tool';
|
|
166
|
+
let summary = '';
|
|
167
|
+
if (b.input && typeof b.input === 'object') {
|
|
168
|
+
if (typeof b.input.command === 'string') summary = b.input.command;
|
|
169
|
+
else if (typeof b.input.file_path === 'string') summary = b.input.file_path;
|
|
170
|
+
else if (typeof b.input.query === 'string') summary = b.input.query;
|
|
171
|
+
else summary = JSON.stringify(b.input).slice(0, 200);
|
|
172
|
+
}
|
|
173
|
+
return `${name}: ${summary}`;
|
|
174
|
+
})
|
|
175
|
+
.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function wordCount(text) {
|
|
179
|
+
return String(text || '').trim().split(/\s+/).filter(Boolean).length;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function hasPositiveFeedback(text) {
|
|
183
|
+
return POSITIVE_FEEDBACK_PATTERNS.some((p) => p.test(text || ''));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function isLowValueCloseout(text, toolUseSummary = '') {
|
|
187
|
+
const normalized = String(text || '').trim();
|
|
188
|
+
if (!normalized) return false;
|
|
189
|
+
if (toolUseSummary.trim()) return false;
|
|
190
|
+
if (wordCount(normalized) > 45) return false;
|
|
191
|
+
if (SUBSTANTIVE_CLOSEOUT_PATTERNS.some((p) => p.test(normalized))) return false;
|
|
192
|
+
return LOW_VALUE_CLOSEOUT_PATTERNS.some((p) => p.test(normalized));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function findClaim(text) {
|
|
196
|
+
for (const p of CLAIM_PATTERNS) {
|
|
197
|
+
const m = text.match(p);
|
|
198
|
+
if (m) return m[0];
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function hasProof(combined) {
|
|
204
|
+
return PROOF_PATTERNS.some((p) => p.test(combined));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function extractPayloadText(payload) {
|
|
208
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
209
|
+
const candidates = [
|
|
210
|
+
payload.response,
|
|
211
|
+
payload.assistant_response,
|
|
212
|
+
payload.assistantResponse,
|
|
213
|
+
payload.final_response,
|
|
214
|
+
payload.finalResponse,
|
|
215
|
+
payload.text,
|
|
216
|
+
payload.output,
|
|
217
|
+
payload.message,
|
|
218
|
+
];
|
|
219
|
+
for (const candidate of candidates) {
|
|
220
|
+
if (typeof candidate === 'string' && candidate.trim()) return candidate;
|
|
221
|
+
if (candidate && typeof candidate === 'object') {
|
|
222
|
+
const extracted = extractText(candidate);
|
|
223
|
+
if (extracted.trim()) return extracted;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return '';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function extractPayloadPreviousUserText(payload) {
|
|
230
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
231
|
+
const candidates = [
|
|
232
|
+
payload.previous_user_text,
|
|
233
|
+
payload.previousUserText,
|
|
234
|
+
payload.user_prompt,
|
|
235
|
+
payload.userPrompt,
|
|
236
|
+
payload.prompt,
|
|
237
|
+
];
|
|
238
|
+
for (const candidate of candidates) {
|
|
239
|
+
if (typeof candidate === 'string' && candidate.trim()) return candidate;
|
|
240
|
+
if (candidate && typeof candidate === 'object') {
|
|
241
|
+
const extracted = extractText(candidate);
|
|
242
|
+
if (extracted.trim()) return extracted;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return '';
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function buildResponseQualityReason() {
|
|
249
|
+
return [
|
|
250
|
+
'ThumbGate response-quality gate: final response answered positive feedback',
|
|
251
|
+
'with a low-value social closeout instead of silence-level brevity or an evidence checkpoint.',
|
|
252
|
+
'Positive feedback after operational work should trigger either no extra noise,',
|
|
253
|
+
'a compact evidence checkpoint, or a concrete next-state update.',
|
|
254
|
+
'Do not reply with generic "Good / Great / Use X/Y" filler.',
|
|
255
|
+
].join(' ');
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function buildResponseQualityReminder() {
|
|
259
|
+
return [
|
|
260
|
+
'⚠️ ThumbGate response-quality gate: previous turn answered positive feedback',
|
|
261
|
+
' with a low-value social closeout instead of silence-level brevity or an evidence checkpoint.',
|
|
262
|
+
' Positive feedback after operational work should trigger either no extra noise,',
|
|
263
|
+
' a compact evidence checkpoint, or a concrete next-state update.',
|
|
264
|
+
' Do not reply with generic "Good / Great / Use X/Y" filler.',
|
|
265
|
+
].join('\n');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function writeBlock(reason) {
|
|
269
|
+
process.stdout.write(JSON.stringify({
|
|
270
|
+
decision: 'block',
|
|
271
|
+
reason,
|
|
272
|
+
hookSpecificOutput: {
|
|
273
|
+
hookEventName: 'Stop',
|
|
274
|
+
permissionDecision: 'deny',
|
|
275
|
+
permissionDecisionReason: reason,
|
|
276
|
+
},
|
|
277
|
+
}) + '\n');
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function readStdinSync() {
|
|
281
|
+
try {
|
|
282
|
+
return fs.readFileSync(0, 'utf8');
|
|
283
|
+
} catch {
|
|
284
|
+
return '';
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function main() {
|
|
289
|
+
const raw = readStdinSync();
|
|
290
|
+
let payload = {};
|
|
291
|
+
try {
|
|
292
|
+
payload = raw ? JSON.parse(raw) : {};
|
|
293
|
+
} catch {
|
|
294
|
+
payload = {};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
const transcriptPath = payload.transcript_path || process.env.CLAUDE_TRANSCRIPT_PATH;
|
|
298
|
+
const directText = extractPayloadText(payload) || process.env.CLAUDE_RESPONSE || '';
|
|
299
|
+
const directPreviousUserText = extractPayloadPreviousUserText(payload)
|
|
300
|
+
|| process.env.CLAUDE_PREVIOUS_USER_TEXT
|
|
301
|
+
|| process.env.CLAUDE_PREVIOUS_USER
|
|
302
|
+
|| '';
|
|
303
|
+
|
|
304
|
+
if (directText && hasPositiveFeedback(directPreviousUserText) && isLowValueCloseout(directText, '')) {
|
|
305
|
+
writeBlock(buildResponseQualityReason());
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const message = readLastAssistantTurn(transcriptPath);
|
|
310
|
+
if (!message) return; // no transcript visible; nothing to check
|
|
311
|
+
|
|
312
|
+
const text = extractText(message);
|
|
313
|
+
const toolUseSummary = extractToolUseSummary(message);
|
|
314
|
+
const previousUserText = readPreviousUserText(transcriptPath);
|
|
315
|
+
|
|
316
|
+
if (hasPositiveFeedback(previousUserText) && isLowValueCloseout(text, toolUseSummary)) {
|
|
317
|
+
process.stdout.write(buildResponseQualityReminder() + '\n');
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const claim = findClaim(text);
|
|
322
|
+
if (!claim) return; // no completion claim made; silent
|
|
323
|
+
|
|
324
|
+
const proofText = `${text}\n${toolUseSummary}`;
|
|
325
|
+
if (hasProof(proofText)) return; // claim backed by proof in same turn
|
|
326
|
+
|
|
327
|
+
// Surface a system reminder for the NEXT turn. Do not hard-block.
|
|
328
|
+
const reminder = [
|
|
329
|
+
'⚠️ ThumbGate anti-claim gate: previous turn claimed completion',
|
|
330
|
+
` ("${claim}") without a proof tool call in the same message.`,
|
|
331
|
+
' Per CLAUDE.md anti-lying: never claim "done / live / deployed / fixed"',
|
|
332
|
+
' or commercial truth (money / tax / inventory / permissions / customer-facing state)',
|
|
333
|
+
' without curl / grep / test / source-of-truth output in the SAME turn.',
|
|
334
|
+
' If the work really is verified, re-state the claim with the proof.',
|
|
335
|
+
' If not, retract and run the verification before re-asserting.',
|
|
336
|
+
].join('\n');
|
|
337
|
+
process.stdout.write(reminder + '\n');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Path-resolve check instead of `require.main === module`. SonarCloud's
|
|
341
|
+
// strict type inference (rule S3403) flags the === form as always-false
|
|
342
|
+
// in CommonJS, and CLAUDE.md "Hard-Won Lessons" pins the path-based form
|
|
343
|
+
// as the portable fix (incident 2026-04-21 / PR #1115). Resolve BOTH sides
|
|
344
|
+
// so the comparison is between two normalized absolute paths.
|
|
345
|
+
const path = require('node:path');
|
|
346
|
+
if (path.resolve(process.argv[1] || '') === path.resolve(__filename)) {
|
|
347
|
+
try {
|
|
348
|
+
main();
|
|
349
|
+
} catch {
|
|
350
|
+
// never crash the agent
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
module.exports = {
|
|
355
|
+
CLAIM_PATTERNS,
|
|
356
|
+
PROOF_PATTERNS,
|
|
357
|
+
POSITIVE_FEEDBACK_PATTERNS,
|
|
358
|
+
LOW_VALUE_CLOSEOUT_PATTERNS,
|
|
359
|
+
findClaim,
|
|
360
|
+
hasProof,
|
|
361
|
+
hasPositiveFeedback,
|
|
362
|
+
isLowValueCloseout,
|
|
363
|
+
extractPayloadText,
|
|
364
|
+
extractPayloadPreviousUserText,
|
|
365
|
+
buildResponseQualityReason,
|
|
366
|
+
extractText,
|
|
367
|
+
extractToolUseSummary,
|
|
368
|
+
readPreviousUserText,
|
|
369
|
+
};
|
package/scripts/tool-registry.js
CHANGED
|
@@ -971,12 +971,12 @@ const TOOLS = [
|
|
|
971
971
|
}),
|
|
972
972
|
readOnlyTool({
|
|
973
973
|
name: 'verify_claim',
|
|
974
|
-
description: 'Check whether a claim has enough tracked evidence before the agent asserts it.',
|
|
974
|
+
description: 'Check whether a claim has enough tracked evidence before the agent asserts it, including tests-pass, commercial truth, GitHub metadata, and anthropomorphic AI claims such as "the model understands" or "the agent decided".',
|
|
975
975
|
inputSchema: {
|
|
976
976
|
type: 'object',
|
|
977
977
|
required: ['claim'],
|
|
978
978
|
properties: {
|
|
979
|
-
claim: { type: 'string', description: 'The claim text to verify' },
|
|
979
|
+
claim: { type: 'string', description: 'The claim text to verify before assertion; human-like AI claims require anthropomorphic_claim_verified evidence.' },
|
|
980
980
|
goalContract: GOAL_CONTRACT_SCHEMA,
|
|
981
981
|
},
|
|
982
982
|
},
|
package/src/api/server.js
CHANGED
|
@@ -3334,6 +3334,8 @@ function renderSitemapXml(runtimeConfig) {
|
|
|
3334
3334
|
{ path: '/learn/agentic-os-team-governance', changefreq: 'weekly', priority: '0.85' },
|
|
3335
3335
|
{ path: '/learn/cost-aware-agent-gate-routing', changefreq: 'weekly', priority: '0.85' },
|
|
3336
3336
|
{ path: '/learn/databricks-unity-ai-gateway-runtime-governance', changefreq: 'weekly', priority: '0.85' },
|
|
3337
|
+
{ path: '/learn/anthropomorphic-claim-gates', changefreq: 'weekly', priority: '0.85' },
|
|
3338
|
+
{ path: '/learn/agent-identity-connector-governance', changefreq: 'weekly', priority: '0.9' },
|
|
3337
3339
|
{ path: '/learn/pretix-stripe-connect-marketplaces', changefreq: 'weekly', priority: '0.9' },
|
|
3338
3340
|
{ path: '/compare/claude-code-hooks', changefreq: 'weekly', priority: '0.85' },
|
|
3339
3341
|
{ path: '/compare/bumblebee', changefreq: 'weekly', priority: '0.85' },
|