thumbgate 1.31.0 → 1.34.0

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.
@@ -16,6 +16,13 @@ const {
16
16
  const {
17
17
  evaluateWorkflowSentinel,
18
18
  } = require('./workflow-sentinel');
19
+ const {
20
+ evaluateFinancialControl,
21
+ } = require('./financial-control-plane');
22
+ const {
23
+ buildCostControl,
24
+ normalizeProviderAction,
25
+ } = require('./provider-action-normalizer');
19
26
  const {
20
27
  recordDecisionEvaluation,
21
28
  recordDecisionOutcome,
@@ -1936,6 +1943,43 @@ function recordStructuralGateBlock(toolName, toolInput, result) {
1936
1943
  return result;
1937
1944
  }
1938
1945
 
1946
+ /**
1947
+ * Resolve catastrophic declarative gates before the ordinary first-match loop.
1948
+ *
1949
+ * Config order is useful for normal policy routing, but it must not let a broad
1950
+ * rule mask a narrower irreversible-action rule. For example, the generic
1951
+ * `push-without-thread-check` gate also matches `git push --force`; selecting it
1952
+ * first allowed the free-tier daily cap to downgrade the action to a warning
1953
+ * before the exempt `force-push` gate was ever evaluated.
1954
+ *
1955
+ * Catastrophic gates are deliberately limited to the audited allowlist above.
1956
+ * Metric-backed gates are excluded because their condition is asynchronous and
1957
+ * none of the catastrophic command boundaries may depend on a remote metric.
1958
+ */
1959
+ function evaluateCatastrophicDeclarativeGate(config, constraints, toolName, toolInput) {
1960
+ if (!config || !Array.isArray(config.gates)) return null;
1961
+
1962
+ for (const gate of config.gates) {
1963
+ if (!CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate.id)) continue;
1964
+ if (gate.action !== 'block' || gate.metrics) continue;
1965
+
1966
+ const matchDetails = matchGate(gate, toolName, toolInput);
1967
+ if (!matchDetails.matched) continue;
1968
+ if (gate.when && !checkWhenClause(gate.when, constraints)) continue;
1969
+ if (gate.unless && isConditionSatisfied(gate.unless)) continue;
1970
+
1971
+ return {
1972
+ decision: 'deny',
1973
+ gate: gate.id,
1974
+ message: buildGateMessage(gate, matchDetails),
1975
+ severity: gate.severity,
1976
+ reasoning: buildReasoning(gate, toolName, toolInput, matchDetails),
1977
+ };
1978
+ }
1979
+
1980
+ return null;
1981
+ }
1982
+
1939
1983
  function isScopeEnforcedAction(toolName, toolInput = {}, affectedFiles = []) {
1940
1984
  if (EDIT_LIKE_TOOLS.has(toolName) && affectedFiles.length > 0) return true;
1941
1985
  if (toolName !== 'Bash') return false;
@@ -2776,6 +2820,16 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2776
2820
  return boostedRiskGuard;
2777
2821
  }
2778
2822
 
2823
+ const catastrophicDeclarativeGate = evaluateCatastrophicDeclarativeGate(
2824
+ config,
2825
+ constraints,
2826
+ toolName,
2827
+ toolInput,
2828
+ );
2829
+ if (catastrophicDeclarativeGate) {
2830
+ return recordStructuralGateBlock(toolName, toolInput, catastrophicDeclarativeGate);
2831
+ }
2832
+
2779
2833
  // Tier 1b: Planning and Trajectory (v1.26.0 - CodeRabbit Pattern).
2780
2834
  // Keep runtime enforcement explicit so advisory planning checks do not mask
2781
2835
  // higher-priority deny/approve gates in established workflows.
@@ -3022,6 +3076,16 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3022
3076
  return boostedRiskGuard;
3023
3077
  }
3024
3078
 
3079
+ const catastrophicDeclarativeGate = evaluateCatastrophicDeclarativeGate(
3080
+ config,
3081
+ constraints,
3082
+ toolName,
3083
+ toolInput,
3084
+ );
3085
+ if (catastrophicDeclarativeGate) {
3086
+ return recordStructuralGateBlock(toolName, toolInput, catastrophicDeclarativeGate);
3087
+ }
3088
+
3025
3089
  // Tier 1b: Planning and Trajectory (v1.26.0 - CodeRabbit Pattern).
3026
3090
  // Keep runtime enforcement explicit so advisory planning checks do not mask
3027
3091
  // higher-priority deny/approve gates in established workflows.
@@ -3303,7 +3367,7 @@ function evaluateSecretGuard(input = {}) {
3303
3367
  return result;
3304
3368
  }
3305
3369
 
3306
- function evaluateUnconditionalHardFloor(input = {}) {
3370
+ function evaluateUnconditionalHardFloor(input = {}, options = {}) {
3307
3371
  const secretGuard = evaluateSecretGuard(input);
3308
3372
  if (secretGuard) return { hardFloor: secretGuard, securityScan: null };
3309
3373
 
@@ -3312,14 +3376,81 @@ function evaluateUnconditionalHardFloor(input = {}) {
3312
3376
  return { hardFloor: securityScan, securityScan };
3313
3377
  }
3314
3378
 
3379
+ const financialHardFloor = evaluateFinancialHardFloor(input, false, options);
3380
+ if (financialHardFloor) return { hardFloor: financialHardFloor, securityScan };
3381
+
3315
3382
  return {
3316
3383
  hardFloor: evaluateSelfProtectHardFloor(input),
3317
3384
  securityScan,
3318
3385
  };
3319
3386
  }
3320
3387
 
3321
- function runHardFloor(input) {
3322
- const { hardFloor } = evaluateUnconditionalHardFloor(input);
3388
+ function evaluateFinancialHardFloor(input = {}, consumeReservation = false, options = {}) {
3389
+ const toolName = input.tool_name || input.toolName || 'unknown';
3390
+ const toolInput = input.tool_input && typeof input.tool_input === 'object'
3391
+ ? input.tool_input
3392
+ : {};
3393
+ const normalizedAction = normalizeProviderAction({
3394
+ toolName,
3395
+ toolInput,
3396
+ usage: input.usage || toolInput.usage,
3397
+ costUsd: input.costUsd ?? toolInput.costUsd,
3398
+ budget: input.budget || toolInput.budget,
3399
+ });
3400
+ const costControl = buildCostControl(
3401
+ normalizedAction,
3402
+ input.budget || toolInput.budget || {}
3403
+ );
3404
+ const financialControl = evaluateFinancialControl({
3405
+ toolName,
3406
+ toolInput,
3407
+ actionProfile: {
3408
+ economicAction: undefined,
3409
+ },
3410
+ costControl,
3411
+ }, { ...options, consumeReservation });
3412
+ if (financialControl.mode === 'block') {
3413
+ const result = {
3414
+ decision: 'deny',
3415
+ gate: 'financial-control',
3416
+ message: financialControl.reasons.join(' '),
3417
+ severity: 'critical',
3418
+ financialControl,
3419
+ reasoning: [
3420
+ 'Economic actions default to deny at the pre-tool boundary.',
3421
+ 'Learned policy and advisory memories cannot override this deterministic control.',
3422
+ ],
3423
+ };
3424
+ recordStat('financial-control', 'block', null, { toolName, toolInput });
3425
+ const auditRecord = recordAuditEvent({
3426
+ toolName,
3427
+ toolInput,
3428
+ decision: 'deny',
3429
+ gateId: 'financial-control',
3430
+ message: result.message,
3431
+ severity: result.severity,
3432
+ source: 'financial-control',
3433
+ });
3434
+ auditToFeedback(auditRecord);
3435
+ return result;
3436
+ }
3437
+ return null;
3438
+ }
3439
+
3440
+ // Reservations are single-use. They are consumed only after every other gate
3441
+ // has reached its final allow/warn boundary, never during the preliminary hard
3442
+ // floor preview. This prevents a later workflow or learned-risk denial from
3443
+ // burning an approval for an action that did not execute.
3444
+ function finalizeFinancialAuthorization(input = {}, options = {}) {
3445
+ return evaluateFinancialHardFloor(input, true, options);
3446
+ }
3447
+
3448
+ function isBlockingDecision(result) {
3449
+ return result?.decision === 'deny' || result?.decision === 'approve';
3450
+ }
3451
+
3452
+ function runHardFloor(input, options = {}) {
3453
+ const { hardFloor } = evaluateUnconditionalHardFloor(input, options);
3323
3454
  return hardFloor ? formatOutput(hardFloor) : null;
3324
3455
  }
3325
3456
 
@@ -3393,7 +3524,7 @@ function formatOutput(result, behavioralContext) {
3393
3524
  if (result.decision === 'deny') {
3394
3525
  const reminder = behavioralContext ? buildReminderOutput(behavioralContext) : {};
3395
3526
  const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
3396
- const proCta = buildBlockActionProCta() || '';
3527
+ const proCta = result.gate === 'financial-control' ? '' : (buildBlockActionProCta() || '');
3397
3528
  return JSON.stringify({
3398
3529
  hookSpecificOutput: {
3399
3530
  hookEventName: 'PreToolUse',
@@ -3707,10 +3838,18 @@ async function runAsync(input) {
3707
3838
  if (lessonContext && lessonContext.decision === "deny") {
3708
3839
  return formatOutput(applyEnforcementPosture(lessonContext));
3709
3840
  }
3841
+
3842
+ const posturedResult = applyEnforcementPosture(result);
3843
+ if (isBlockingDecision(posturedResult)) {
3844
+ return formatOutput(posturedResult);
3845
+ }
3846
+
3847
+ const financialAuthorization = finalizeFinancialAuthorization(input);
3848
+ if (financialAuthorization) return formatOutput(financialAuthorization);
3710
3849
 
3711
3850
  const recentContext = buildRecentCorrectiveActionsContext();
3712
3851
  const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
3713
- return formatOutput(applyEnforcementPosture(result), combinedContext);
3852
+ return formatOutput(posturedResult, combinedContext);
3714
3853
 
3715
3854
  }
3716
3855
 
@@ -3746,10 +3885,18 @@ function run(input) {
3746
3885
  if (lessonContext && lessonContext.decision === "deny") {
3747
3886
  return formatOutput(applyEnforcementPosture(lessonContext));
3748
3887
  }
3888
+
3889
+ const posturedResult = applyEnforcementPosture(result);
3890
+ if (isBlockingDecision(posturedResult)) {
3891
+ return formatOutput(posturedResult);
3892
+ }
3893
+
3894
+ const financialAuthorization = finalizeFinancialAuthorization(input);
3895
+ if (financialAuthorization) return formatOutput(financialAuthorization);
3749
3896
 
3750
3897
  const recentContext = buildRecentCorrectiveActionsContext();
3751
3898
  const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
3752
- return formatOutput(applyEnforcementPosture(result), combinedContext);
3899
+ return formatOutput(posturedResult, combinedContext);
3753
3900
 
3754
3901
  }
3755
3902
 
@@ -4037,10 +4184,40 @@ function verifyClaimEvidence(claimText, options = {}) {
4037
4184
  });
4038
4185
  }
4039
4186
 
4187
+ // Universal factual claims (row counts, file lines/bytes/existence, version values)
4188
+ // recheck the configured source of truth. Fail-closed on mismatch or missing verifier.
4189
+ let universal = null;
4190
+ if (options.skipUniversal !== true) {
4191
+ try {
4192
+ const {
4193
+ evaluateUniversalClaimsAsGateChecks,
4194
+ } = require('./universal-claim-evaluator');
4195
+ universal = evaluateUniversalClaimsAsGateChecks(normalizedClaimText, {
4196
+ cwd: options.cwd,
4197
+ verifiers: options.verifiers,
4198
+ configPath: options.claimVerifiersPath,
4199
+ config: options.claimVerifiers,
4200
+ feedbackDir: options.feedbackDir,
4201
+ failUnconfigured: options.failUnconfigured,
4202
+ });
4203
+ for (const check of universal.checks) {
4204
+ checks.push(check);
4205
+ }
4206
+ } catch (error) {
4207
+ checks.push({
4208
+ claim: 'universal_evaluator',
4209
+ passed: false,
4210
+ missing: ['universal_claim_evaluator'],
4211
+ message: `Universal claim evaluator failed closed: ${error && error.message ? error.message : 'unknown error'}`,
4212
+ });
4213
+ }
4214
+ }
4215
+
4040
4216
  return {
4041
- verified: checks.every((check) => check.passed),
4217
+ verified: checks.length === 0 ? true : checks.every((check) => check.passed),
4042
4218
  checks,
4043
4219
  goalContract,
4220
+ universal,
4044
4221
  };
4045
4222
  }
4046
4223
 
@@ -4088,6 +4265,7 @@ module.exports = {
4088
4265
  isAutonomousRun,
4089
4266
  computeExecutableHash,
4090
4267
  formatOutput,
4268
+ finalizeFinancialAuthorization,
4091
4269
  isApprovalGatesEnabled,
4092
4270
  runHardFloor,
4093
4271
  run,
@@ -190,6 +190,7 @@ module.exports = {
190
190
  GEMINI_TASK_TYPES,
191
191
  buildGeminiEmbeddingRolloutPlan,
192
192
  isSymmetricTask,
193
+ normalizeEmbeddingKind,
193
194
  normalizeOutputDimensionality,
194
195
  normalizeTask,
195
196
  prepareEmbeddingText,
@@ -92,6 +92,10 @@ function cacheUpdateHookCommand() {
92
92
  return buildPortableHookCommand('cache-update');
93
93
  }
94
94
 
95
+ function claimStopHookCommand() {
96
+ return buildPortableHookCommand('claim-stop-check');
97
+ }
98
+
95
99
  function statuslineCommand() {
96
100
  return buildPortableHookCommand('statusline-render');
97
101
  }
@@ -120,6 +124,7 @@ module.exports = {
120
124
  buildPortableHookCommand,
121
125
  buildCodexPortableHookCommand,
122
126
  cacheUpdateHookCommand,
127
+ claimStopHookCommand,
123
128
  codexCacheUpdateHookCommand,
124
129
  codexPreToolHookCommand,
125
130
  codexSessionStartHookCommand,
@@ -29,6 +29,9 @@
29
29
  */
30
30
 
31
31
  const fs = require('node:fs');
32
+ const {
33
+ evaluateUniversalClaims,
34
+ } = require('./universal-claim-evaluator');
32
35
 
33
36
  // Lie-phrase patterns. These match common "claim of completion" wording
34
37
  // the agent emits without verification. Word-boundary anchored to avoid
@@ -233,6 +236,45 @@ function readStdinSync() {
233
236
  }
234
237
  }
235
238
 
239
+ function factualClaimBlock(text, options = {}) {
240
+ try {
241
+ const result = evaluateUniversalClaims(text, {
242
+ cwd: options.cwd,
243
+ configPath: options.configPath,
244
+ feedbackDir: options.feedbackDir,
245
+ failUnconfigured: true,
246
+ });
247
+ if (result.parsedCount === 0) return null;
248
+ if (result.verified) return null;
249
+ const failures = result.checks.filter((check) => !check.passed);
250
+ return {
251
+ decision: 'block',
252
+ reason: `ThumbGate factual-claim gate: ${failures.map((check) => check.message).join('; ')}. Recheck the configured source of truth and restate the observed value, or retract the claim.`,
253
+ verification: {
254
+ verified: false,
255
+ parsedCount: result.parsedCount,
256
+ failures: failures.map((check) => ({
257
+ status: check.status,
258
+ kind: check.kind,
259
+ verifierId: check.verifierId || null,
260
+ expected: check.expected,
261
+ actual: Object.hasOwn(check, 'actual') ? check.actual : null,
262
+ })),
263
+ },
264
+ };
265
+ } catch (error) {
266
+ return {
267
+ decision: 'block',
268
+ reason: `ThumbGate factual-claim gate failed closed: ${error.message}`,
269
+ verification: {
270
+ verified: false,
271
+ parsedCount: null,
272
+ failures: [{ status: 'evaluator_error' }],
273
+ },
274
+ };
275
+ }
276
+ }
277
+
236
278
  function main() {
237
279
  const raw = readStdinSync();
238
280
  let payload = {};
@@ -242,12 +284,28 @@ function main() {
242
284
  payload = {};
243
285
  }
244
286
 
287
+ // Claude Code invokes a blocked Stop hook once more with this marker so the
288
+ // agent can correct its response. Re-blocking the same payload would hit the
289
+ // host's block cap instead of giving the agent a correction turn.
290
+ if (payload.stop_hook_active === true) return;
291
+
245
292
  const transcriptPath = payload.transcript_path || process.env.CLAUDE_TRANSCRIPT_PATH;
246
293
  const message = readLastAssistantTurn(transcriptPath);
247
- if (!message) return; // no transcript visible; nothing to check
294
+ const text = message
295
+ ? extractText(message)
296
+ : String(payload.last_assistant_message || process.env.CLAUDE_RESPONSE || '');
297
+ if (!text.trim()) return; // no assistant response visible; nothing to check
298
+
299
+ const toolUseSummary = message ? extractToolUseSummary(message) : '';
300
+ const factualBlock = factualClaimBlock(text, {
301
+ cwd: payload.cwd || payload.workspace_root || process.cwd(),
302
+ configPath: process.env.THUMBGATE_CLAIM_VERIFIERS_PATH,
303
+ });
304
+ if (factualBlock) {
305
+ process.stdout.write(`${JSON.stringify(factualBlock)}\n`);
306
+ return;
307
+ }
248
308
 
249
- const text = extractText(message);
250
- const toolUseSummary = extractToolUseSummary(message);
251
309
  const claim = findClaim(text);
252
310
  if (!claim) return; // no completion claim made; silent
253
311
 
@@ -298,4 +356,6 @@ module.exports = {
298
356
  hasProof,
299
357
  extractText,
300
358
  extractToolUseSummary,
359
+ factualClaimBlock,
360
+ main,
301
361
  };