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.
@@ -10,6 +10,7 @@ const {
10
10
  } = require('./local-model-profile');
11
11
  const {
12
12
  prepareEmbeddingText,
13
+ normalizeEmbeddingKind,
13
14
  resolveGeminiEmbeddingConfig,
14
15
  resolveGeminiModelResource,
15
16
  resolveGeminiTaskType,
@@ -110,7 +111,7 @@ function hasSemanticEmbeddingProvider() {
110
111
  if (hasLocalTransformerProvider()) return true;
111
112
  try {
112
113
  const config = resolveGeminiEmbeddingConfig();
113
- return config.provider === 'coreai' || Boolean(config.enabled && config.apiKey);
114
+ return config.provider === 'coreai' || Boolean(config.apiKey);
114
115
  } catch {
115
116
  return false;
116
117
  }
@@ -211,7 +212,7 @@ async function embedWithGemini(text, options = {}) {
211
212
  }
212
213
 
213
214
  if (typeof fetch !== 'function') {
214
- throw new Error('Gemini embeddings require global fetch. Use Node 18.18+ or the local embedding provider.');
215
+ throw new TypeError('Gemini embeddings require global fetch. Use Node 18.18+ or the local embedding provider.');
215
216
  }
216
217
 
217
218
  const modelResource = resolveGeminiModelResource(config.model);
@@ -291,7 +292,18 @@ async function embedWithOllama(text, options = {}) {
291
292
  throw new Error('Ollama embeddings require THUMBGATE_OLLAMA_EMBED_MODEL');
292
293
  }
293
294
  if (typeof fetch !== 'function') {
294
- throw new Error('Ollama embeddings require global fetch. Use Node 18.18+.');
295
+ throw new TypeError('Ollama embeddings require global fetch. Use Node 18.18+.');
296
+ }
297
+
298
+ // Apply Nomic-style asymmetric prefixes. nomic-embed-text was trained
299
+ // with "search_query:" / "search_document:" role prefixes, which improve
300
+ // query-document matching fidelity on the dense retrieval path.
301
+ const kind = normalizeEmbeddingKind(options.kind);
302
+ let inputText = String(text || '');
303
+ if (kind === 'query') {
304
+ inputText = `search_query: ${inputText}`;
305
+ } else if (kind === 'document') {
306
+ inputText = `search_document: ${inputText}`;
295
307
  }
296
308
 
297
309
  let response;
@@ -301,7 +313,7 @@ async function embedWithOllama(text, options = {}) {
301
313
  headers: { 'Content-Type': 'application/json' },
302
314
  body: JSON.stringify({
303
315
  model: config.model,
304
- input: String(text || ''),
316
+ input: inputText,
305
317
  truncate: true,
306
318
  dimensions: options.outputDimensionality || undefined,
307
319
  }),
@@ -322,6 +334,39 @@ async function embedWithOllama(text, options = {}) {
322
334
  return vector.map(Number);
323
335
  }
324
336
 
337
+ async function tryGeminiManagedEmbedding(text, options, geminiConfig) {
338
+ if (!geminiConfig.apiKey && !_geminiEmbedderForTests) {
339
+ return null;
340
+ }
341
+ try {
342
+ const vector = await embedWithGemini(text, options);
343
+ _lastEmbeddingProfile = {
344
+ generatedAt: new Date().toISOString(),
345
+ source: 'managed',
346
+ activeProfile: {
347
+ id: 'gemini',
348
+ model: geminiConfig.model,
349
+ outputDimensionality: geminiConfig.outputDimensionality,
350
+ task: options.task || geminiConfig.defaultTask,
351
+ rationale: geminiConfig.enabled
352
+ ? 'Managed Gemini Embedding 2 path with task-specific query/document prefixes.'
353
+ : 'Managed Gemini Embedding 2 fallback after local providers exhausted.',
354
+ },
355
+ fallbackUsed: !geminiConfig.enabled,
356
+ ...(!geminiConfig.enabled ? { fallbackReason: 'local_providers_exhausted' } : {}),
357
+ };
358
+ return vector;
359
+ } catch (geminiError) {
360
+ if (!geminiConfig.fallbackToLocal) {
361
+ throw geminiError;
362
+ }
363
+ // Do not log raw provider/user-controlled error text (Sonar jssecurity:S5145).
364
+ const code = geminiError && (geminiError.code || geminiError.name || 'Error');
365
+ console.warn(`Gemini embedding fallback: ${code}`);
366
+ return null;
367
+ }
368
+ }
369
+
325
370
  async function embed(text, options = {}) {
326
371
  if (process.env.THUMBGATE_VECTOR_STUB_EMBED === 'true') {
327
372
  // Deterministic 384-dim unit vector: first element = 1.0, rest = 0.0
@@ -373,29 +418,8 @@ async function embed(text, options = {}) {
373
418
  }
374
419
  }
375
420
  if (geminiConfig.enabled) {
376
- try {
377
- const vector = await embedWithGemini(text, options);
378
- _lastEmbeddingProfile = {
379
- generatedAt: new Date().toISOString(),
380
- source: 'managed',
381
- activeProfile: {
382
- id: 'gemini',
383
- model: geminiConfig.model,
384
- outputDimensionality: geminiConfig.outputDimensionality,
385
- task: options.task || geminiConfig.defaultTask,
386
- rationale: 'Managed Gemini Embedding 2 path with task-specific query/document prefixes.',
387
- },
388
- fallbackUsed: false,
389
- };
390
- return vector;
391
- } catch (geminiError) {
392
- if (!geminiConfig.fallbackToLocal) {
393
- throw geminiError;
394
- }
395
- // Do not log raw provider/user-controlled error text (Sonar jssecurity:S5145).
396
- const code = geminiError && (geminiError.code || geminiError.name || 'Error');
397
- console.warn(`Gemini embedding fallback: ${code}`);
398
- }
421
+ const vector = await tryGeminiManagedEmbedding(text, options, geminiConfig);
422
+ if (vector) return vector;
399
423
  }
400
424
  if (hasLocalTransformerProvider()) {
401
425
  try {
@@ -410,6 +434,14 @@ async function embed(text, options = {}) {
410
434
  }
411
435
  }
412
436
 
437
+ // Gemini managed fallback — only when API key present but Gemini is not the
438
+ // explicitly selected provider. Honors fallbackToLocal in the catch block
439
+ // so that THUMBGATE_GEMINI_EMBED_FALLBACK_LOCAL=false makes Gemini mandatory.
440
+ if (geminiConfig.apiKey && !geminiConfig.enabled) {
441
+ const vector = await tryGeminiManagedEmbedding(text, options, geminiConfig);
442
+ if (vector) return vector;
443
+ }
444
+
413
445
  const vector = embedWithFeatureHash(text);
414
446
  // Feature-hash is a last-resort degrade, not production semantic quality.
415
447
  // Callers (prove/eval/chat health) must treat quality_tier=degraded.
@@ -536,6 +568,7 @@ module.exports = {
536
568
  TABLE_NAME,
537
569
  getEmbeddingConfig,
538
570
  getLastEmbeddingProfile,
571
+ getActiveEmbeddingProfile: getLastEmbeddingProfile,
539
572
  setPipelineLoaderForTests,
540
573
  setLanceLoaderForTests,
541
574
  setGeminiEmbedderForTests,
@@ -22,6 +22,11 @@ const {
22
22
  buildWorkflowControl,
23
23
  normalizeProviderAction,
24
24
  } = require('./provider-action-normalizer');
25
+ const {
26
+ detectEconomicAction,
27
+ evaluateFinancialControl,
28
+ getFinancialControlRuntimeOptions,
29
+ } = require('./financial-control-plane');
25
30
 
26
31
  const GOVERNANCE_STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'governance-state.json');
27
32
  const DEFAULT_PROTECTED_FILE_GLOBS = [
@@ -40,7 +45,6 @@ const DEFAULT_PROTECTED_FILE_GLOBS = [
40
45
  const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
41
46
  const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+(?:pr\s+(?:create|merge)|workflow\s+run|release\s+create)|npm\s+publish|yarn\s+publish|pnpm\s+publish|rm\s+-rf)\b/i;
42
47
  const BACKGROUND_AGENT_PATTERN = /\b(?:async(?:-job|-task)?|autonomous|background|cron|dispatch|heartbeat|job runner|job-runner|queue|queued|schedule|scheduled|worker|workflow run)\b/i;
43
- const ECONOMIC_ACTION_PATTERN = /\b(?:billing|charge|credit memo|invoice|payment(?: link|s)?|payout|refund|stripe|subscription(?:s| creation| update| cancel| delete)?|top-?up)\b/i;
44
48
  const CUSTOMER_SYSTEM_PATTERN = /\b(?:crm|customer|email|hubspot|intercom|mailgun|resend|salesforce|support|zendesk)\b/i;
45
49
 
46
50
  const SURFACE_RULES = [
@@ -259,7 +263,7 @@ function classifyActionProfile(toolInput = {}) {
259
263
  const economicAction = Boolean(
260
264
  toolInput.economicAction === true
261
265
  || metadata.economicAction === true
262
- || ECONOMIC_ACTION_PATTERN.test(combined)
266
+ || detectEconomicAction('', { ...toolInput, metadata: { ...metadata, context: combined } })
263
267
  );
264
268
  const customerSystemAction = Boolean(
265
269
  toolInput.customerSystemAction === true
@@ -573,6 +577,7 @@ function scoreRisk({
573
577
  taskScopeViolation,
574
578
  protectedSurface,
575
579
  costControl,
580
+ financialControl,
576
581
  workflowControl,
577
582
  workflowContract,
578
583
  actionProfile,
@@ -692,6 +697,15 @@ function scoreRisk({
692
697
  { mode: costControl.mode, reasons: costControl.reasons }
693
698
  );
694
699
  }
700
+ if (financialControl?.mode === 'block') {
701
+ addDriver(
702
+ drivers,
703
+ 'financial_control',
704
+ 0.65,
705
+ 'Deterministic purchase controls rejected the economic action.',
706
+ { reasonCodes: financialControl.reasonCodes }
707
+ );
708
+ }
695
709
  if (workflowControl && workflowControl.workflow && workflowControl.workflow.pattern !== 'single_action') {
696
710
  const workflow = workflowControl.workflow;
697
711
  if (workflow.pattern === 'agent') {
@@ -814,6 +828,7 @@ function buildEvidence({
814
828
  protectedSurface,
815
829
  normalizedAction,
816
830
  costControl,
831
+ financialControl,
817
832
  workflowControl,
818
833
  workflowContract,
819
834
  actionProfile,
@@ -827,6 +842,13 @@ function buildEvidence({
827
842
  if (costControl && costControl.mode && costControl.mode !== 'allow') {
828
843
  evidence.push(`Cost control ${costControl.mode}: ${costControl.reasons.join(' ')}`);
829
844
  }
845
+ if (financialControl?.economicAction) {
846
+ evidence.push(
847
+ financialControl.mode === 'allow'
848
+ ? `Financial control allow: approved reservation ${financialControl.authorization?.reservationId || 'unknown'}.`
849
+ : `Financial control block: ${financialControl.reasons.join(' ')}`
850
+ );
851
+ }
830
852
  if (workflowControl && workflowControl.workflow && workflowControl.workflow.pattern !== 'single_action') {
831
853
  const workflow = workflowControl.workflow;
832
854
  evidence.push(
@@ -960,6 +982,7 @@ function buildRemediations({
960
982
  learnedPolicy,
961
983
  executionSurface,
962
984
  costControl,
985
+ financialControl,
963
986
  workflowControl,
964
987
  workflowContract,
965
988
  actionProfile,
@@ -1000,10 +1023,10 @@ function buildRemediations({
1000
1023
  }
1001
1024
  if (actionProfile && actionProfile.economicAction) {
1002
1025
  push(
1003
- 'economic_action_approval',
1004
- 'Require operator approval for money movement',
1005
- 'Require an explicit operator checkpoint before refunds, payouts, invoice sends, or subscription changes execute.',
1006
- 'Money-touching actions are costly to reverse and need a clear human owner.'
1026
+ 'financial_requisition_lifecycle',
1027
+ 'Complete the purchase-control lifecycle',
1028
+ 'Create a purchase requisition, obtain independent human approval through the reviewer API, reserve its exact budget, and attach the matching source-message scope before retrying.',
1029
+ 'Money-touching actions require a single-use, auditable authorization instead of an advisory checkpoint.'
1007
1030
  );
1008
1031
  }
1009
1032
  if (actionProfile && actionProfile.customerSystemAction) {
@@ -1064,6 +1087,14 @@ function buildRemediations({
1064
1087
  'High token or cost estimates should be reviewed before the model/tool loop continues.'
1065
1088
  );
1066
1089
  }
1090
+ if (financialControl?.mode === 'block' && financialControl.reasonCodes.includes('zero_spend_budget')) {
1091
+ push(
1092
+ 'honor_zero_spend_budget',
1093
+ 'Honor the zero-spend budget',
1094
+ 'Use a no-cost path. Do not add a card, start a paid trial, buy credits, or upgrade a plan.',
1095
+ 'An explicit $0 budget is a hard prohibition, not an omitted configuration value.'
1096
+ );
1097
+ }
1067
1098
  if (workflowContract?.active && workflowContract.violations.length > 0) {
1068
1099
  const codes = new Set(workflowContract.violations.map((violation) => violation.code));
1069
1100
  if (codes.has('missing_required_evidence')) {
@@ -1145,6 +1176,13 @@ function buildReasoning(report) {
1145
1176
  if (report.costControl && report.costControl.mode !== 'allow') {
1146
1177
  lines.push(`Cost control: ${report.costControl.mode} — ${report.costControl.reasons.join(' ')}`);
1147
1178
  }
1179
+ if (report.financialControl?.economicAction) {
1180
+ lines.push(
1181
+ report.financialControl.mode === 'allow'
1182
+ ? `Financial control: approved reservation ${report.financialControl.authorization?.reservationId || 'unknown'}.`
1183
+ : `Financial control: block — ${report.financialControl.reasons.join(' ')}`
1184
+ );
1185
+ }
1148
1186
  if (report.workflowControl && report.workflowControl.workflow.pattern !== 'single_action') {
1149
1187
  lines.push(
1150
1188
  `Workflow control: ${report.workflowControl.mode} for ${report.workflowControl.workflow.pattern} with inspection ${report.workflowControl.workflow.hasInspectionEvidence ? 'present' : 'missing'}.`
@@ -1255,6 +1293,7 @@ function buildDecisionControl({
1255
1293
  integrity,
1256
1294
  protectedSurface,
1257
1295
  costControl,
1296
+ financialControl,
1258
1297
  workflowControl,
1259
1298
  workflowContract,
1260
1299
  actionProfile,
@@ -1269,6 +1308,7 @@ function buildDecisionControl({
1269
1308
  const hasOperationalBlockers = Boolean(integrity?.blockers?.length);
1270
1309
  const hasCostWarning = costControl?.mode === 'warn';
1271
1310
  const hasCostBlock = costControl?.mode === 'block';
1311
+ const hasFinancialBlock = financialControl?.mode === 'block';
1272
1312
  const hasWorkflowWarning = workflowControl?.mode === 'warn';
1273
1313
  const hasWorkflowBlock = workflowControl?.mode === 'block';
1274
1314
  const hasContractWarning = workflowContract?.mode === 'warn';
@@ -1277,6 +1317,7 @@ function buildDecisionControl({
1277
1317
  || (decision === 'allow' && (reversibility !== 'two_way_door' || hasOperationalBlockers || hasCostWarning || hasWorkflowWarning || hasContractWarning));
1278
1318
  const executionMode = decision === 'deny'
1279
1319
  || hasCostBlock
1320
+ || hasFinancialBlock
1280
1321
  || hasWorkflowBlock
1281
1322
  || hasContractBlock
1282
1323
  ? 'blocked'
@@ -1302,7 +1343,7 @@ function buildDecisionControl({
1302
1343
  decisionOwner,
1303
1344
  reversibility,
1304
1345
  deliberation,
1305
- requiresHumanApproval: (executionMode === 'checkpoint_required' && decisionOwner !== 'agent') || hasCostBlock || hasWorkflowBlock || hasContractBlock,
1346
+ requiresHumanApproval: (executionMode === 'checkpoint_required' && decisionOwner !== 'agent') || hasCostBlock || hasFinancialBlock || hasWorkflowBlock || hasContractBlock,
1306
1347
  recommendedAction: executionMode === 'blocked'
1307
1348
  ? 'halt'
1308
1349
  : executionMode === 'checkpoint_required'
@@ -1379,8 +1420,8 @@ function hasSoftControlWarning({ workflowContract, workflowControl, costControl,
1379
1420
  || (learnedRecall && riskScore >= 0.34);
1380
1421
  }
1381
1422
 
1382
- function chooseDecision({ riskScore, integrity, memoryGuard, learnedPolicy, blastRadius, command, costControl, workflowControl, workflowContract, actionProfile }) {
1383
- if (costControl?.mode === 'block' || workflowControl?.mode === 'block' || workflowContract?.mode === 'block') {
1423
+ function chooseDecision({ riskScore, integrity, memoryGuard, learnedPolicy, blastRadius, command, costControl, financialControl, workflowControl, workflowContract, actionProfile }) {
1424
+ if (financialControl?.mode === 'block' || costControl?.mode === 'block' || workflowControl?.mode === 'block' || workflowContract?.mode === 'block') {
1384
1425
  return 'deny';
1385
1426
  }
1386
1427
 
@@ -1406,7 +1447,7 @@ function chooseDecision({ riskScore, integrity, memoryGuard, learnedPolicy, blas
1406
1447
  return 'deny';
1407
1448
  }
1408
1449
 
1409
- if (actionProfile?.economicAction || (actionProfile?.backgroundAgent && riskScore >= 0.3)) {
1450
+ if ((actionProfile?.economicAction && financialControl?.mode !== 'allow') || (actionProfile?.backgroundAgent && riskScore >= 0.3)) {
1410
1451
  return 'warn';
1411
1452
  }
1412
1453
 
@@ -1448,7 +1489,26 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1448
1489
  const affectedFiles = Array.isArray(options.affectedFiles)
1449
1490
  ? options.affectedFiles.map((filePath) => normalizePosix(filePath)).filter(Boolean)
1450
1491
  : collectAffectedFiles(normalizedToolName, normalizedToolInput, repoRoot);
1451
- const actionProfile = classifyActionProfile(normalizedToolInput);
1492
+ let actionProfile = classifyActionProfile(normalizedToolInput);
1493
+ const financialControl = evaluateFinancialControl({
1494
+ toolName: normalizedToolName,
1495
+ toolInput: normalizedToolInput,
1496
+ actionProfile,
1497
+ costControl,
1498
+ budget: options.budget || toolInput.budget || {},
1499
+ financialControl: options.financialControl || toolInput.financialControl,
1500
+ }, getFinancialControlRuntimeOptions({
1501
+ feedbackDir: options.feedbackDir
1502
+ || process.env.THUMBGATE_FEEDBACK_DIR
1503
+ || (repoRoot ? path.join(repoRoot, '.thumbgate') : null),
1504
+ }));
1505
+ // The financial detector has additional fail-closed classifiers for opaque
1506
+ // browser/computer mutations. Reflect its verdict in the shared action
1507
+ // profile so downstream learning, risk scoring, and reporting cannot treat
1508
+ // a blocked financial action as non-economic.
1509
+ if (financialControl.economicAction && !actionProfile.economicAction) {
1510
+ actionProfile = { ...actionProfile, economicAction: true };
1511
+ }
1452
1512
  const highRiskAction = isHighRiskAction(normalizedToolName, normalizedToolInput, affectedFiles);
1453
1513
  const baseBranch = options.baseBranch
1454
1514
  || (governanceState.branchGovernance && governanceState.branchGovernance.baseBranch)
@@ -1540,6 +1600,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1540
1600
  taskScopeViolation,
1541
1601
  protectedSurface: protectedSurfaceForRisk,
1542
1602
  costControl,
1603
+ financialControl,
1543
1604
  workflowControl,
1544
1605
  workflowContract,
1545
1606
  actionProfile,
@@ -1567,6 +1628,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1567
1628
  },
1568
1629
  command: normalizedToolInput.command || '',
1569
1630
  costControl,
1631
+ financialControl,
1570
1632
  workflowControl,
1571
1633
  workflowContract,
1572
1634
  actionProfile,
@@ -1580,6 +1642,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1580
1642
  protectedSurface: protectedSurfaceForRisk,
1581
1643
  normalizedAction,
1582
1644
  costControl,
1645
+ financialControl,
1583
1646
  workflowControl,
1584
1647
  workflowContract,
1585
1648
  actionProfile,
@@ -1593,6 +1656,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1593
1656
  learnedPolicy,
1594
1657
  executionSurface,
1595
1658
  costControl,
1659
+ financialControl,
1596
1660
  workflowControl,
1597
1661
  workflowContract,
1598
1662
  actionProfile,
@@ -1607,6 +1671,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1607
1671
  toolName: normalizedToolName,
1608
1672
  normalizedAction,
1609
1673
  costControl,
1674
+ financialControl,
1610
1675
  workflowControl,
1611
1676
  workflowContract,
1612
1677
  decision,
@@ -1643,6 +1708,7 @@ function evaluateWorkflowSentinel(toolName, toolInput = {}, options = {}) {
1643
1708
  integrity,
1644
1709
  protectedSurface: protectedSurfaceForRisk,
1645
1710
  costControl,
1711
+ financialControl,
1646
1712
  workflowControl,
1647
1713
  workflowContract,
1648
1714
  actionProfile,
package/server.json CHANGED
@@ -8,13 +8,13 @@
8
8
  "source": "github",
9
9
  "url": "https://github.com/IgorGanapolsky/ThumbGate"
10
10
  },
11
- "version": "1.31.0",
11
+ "version": "1.34.0",
12
12
  "packages": [
13
13
  {
14
14
  "registryType": "npm",
15
15
  "registryBaseUrl": "https://registry.npmjs.org",
16
16
  "identifier": "thumbgate",
17
- "version": "1.31.0",
17
+ "version": "1.34.0",
18
18
  "runtimeHint": "npx",
19
19
  "runtimeArguments": [
20
20
  {
package/src/api/server.js CHANGED
@@ -9453,6 +9453,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
9453
9453
  escalationId,
9454
9454
  }, {
9455
9455
  authenticatedActor,
9456
+ approvalSigningKey: humanReviewerConfig.key,
9456
9457
  feedbackDir: requestFeedbackDir,
9457
9458
  }));
9458
9459
  } catch (error) {
@@ -10577,6 +10578,7 @@ a{color:#8b9}</style></head><body><form class="card" method="post" action="/oaut
10577
10578
  params: body.params,
10578
10579
  mcp: body.mcp,
10579
10580
  mcpToolCall: body.mcpToolCall,
10581
+ financialControl: body.financialControl || body.financial_control,
10580
10582
  budget: body.budget,
10581
10583
  usage: body.usage,
10582
10584
  };