thumbgate 1.27.20 → 1.28.1

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.
Files changed (63) hide show
  1. package/.claude-plugin/plugin.json +2 -1
  2. package/.well-known/mcp/server-card.json +1 -1
  3. package/README.md +124 -129
  4. package/adapters/claude/.mcp.json +2 -2
  5. package/adapters/mcp/server-stdio.js +18 -33
  6. package/adapters/opencode/opencode.json +1 -1
  7. package/bin/cli.js +95 -78
  8. package/bin/postinstall.js +1 -1
  9. package/config/entitlement-public-keys.json +6 -0
  10. package/config/gates/default.json +3 -3
  11. package/config/github-about.json +2 -5
  12. package/config/merge-quality-checks.json +3 -0
  13. package/config/post-deploy-marketing-pages.json +1 -1
  14. package/hooks/hooks.json +38 -0
  15. package/package.json +28 -10
  16. package/public/about.html +1 -4
  17. package/public/agent-manager.html +6 -6
  18. package/public/agents-cost-savings.html +3 -3
  19. package/public/ai-malpractice-prevention.html +1 -1
  20. package/public/blog.html +12 -9
  21. package/public/chatgpt-app.html +3 -3
  22. package/public/codex-enterprise.html +3 -3
  23. package/public/codex-plugin.html +5 -5
  24. package/public/compare.html +10 -10
  25. package/public/dashboard.html +1 -1
  26. package/public/diagnostic.html +23 -1
  27. package/public/federal.html +2 -2
  28. package/public/guide.html +14 -14
  29. package/public/index.html +202 -220
  30. package/public/install.html +14 -14
  31. package/public/learn.html +4 -17
  32. package/public/numbers.html +2 -2
  33. package/public/partner-intake.html +198 -0
  34. package/public/pricing.html +61 -31
  35. package/public/pro.html +2 -2
  36. package/scripts/agent-reward-model.js +13 -0
  37. package/scripts/bayes-optimal-gate.js +6 -1
  38. package/scripts/billing.js +815 -137
  39. package/scripts/checkout-attribution-reference.js +85 -0
  40. package/scripts/claude-feedback-sync.js +23 -6
  41. package/scripts/cli-feedback.js +33 -8
  42. package/scripts/commercial-offer.js +3 -3
  43. package/scripts/entitlement.js +250 -0
  44. package/scripts/export-databricks-bundle.js +5 -0
  45. package/scripts/export-dpo-pairs.js +6 -0
  46. package/scripts/export-hf-dataset.js +5 -0
  47. package/scripts/feedback-loop.js +290 -1
  48. package/scripts/feedback-quality.js +25 -26
  49. package/scripts/feedback-sanitizer.js +151 -3
  50. package/scripts/gates-engine.js +134 -43
  51. package/scripts/hosted-config.js +9 -2
  52. package/scripts/imperative-detector.js +85 -0
  53. package/scripts/intervention-policy.js +13 -0
  54. package/scripts/mailer/resend-mailer.js +11 -5
  55. package/scripts/pr-manager.js +9 -22
  56. package/scripts/pro-local-dashboard.js +198 -0
  57. package/scripts/risk-scorer.js +6 -0
  58. package/scripts/secret-scanner.js +363 -68
  59. package/scripts/self-protection.js +21 -36
  60. package/scripts/seo-gsd.js +2 -2
  61. package/scripts/thompson-sampling.js +11 -2
  62. package/src/api/server.js +202 -22
  63. package/public/assets/brand/github-social-preview.png +0 -0
@@ -18,7 +18,7 @@ const SECRET_PATTERNS = [
18
18
  { id: 'stripe_live_secret', label: 'Stripe live secret key', regex: /\bsk_live_[A-Za-z0-9]{16,}\b/g },
19
19
  { id: 'slack_token', label: 'Slack token', regex: /\bxox(?:a|b|p|r|s)-[A-Za-z0-9-]{10,}\b/g },
20
20
  { id: 'aws_access_key', label: 'AWS access key', regex: /\bAKIA[0-9A-Z]{16}\b/g },
21
- { id: 'jwt_token', label: 'JWT token', regex: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9._-]{8,}\.[A-Za-z0-9._-]{8,}\b/g },
21
+ { id: 'jwt_token', label: 'JWT token', regex: /\beyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]{8,}\b/g },
22
22
  { id: 'pem_private_key', label: 'Private key block', regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----[\s\S]+?-----END (?:RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----/g },
23
23
  {
24
24
  id: 'generic_assignment',
@@ -54,6 +54,30 @@ const BASH_SECRET_READ_PREFIXES = [
54
54
  'printenv',
55
55
  ];
56
56
 
57
+ const OUTBOUND_FILE_COMMANDS = new Set(['curl', 'wget']);
58
+ const OUTBOUND_COMMAND_WRAPPERS = new Set(['command', 'env', 'nohup', 'sudo']);
59
+ const SHELL_QUOTES = new Set(['"', "'"]);
60
+ const SHELL_SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n']);
61
+ const WRAPPER_OPTIONS_WITH_VALUE = {
62
+ env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']),
63
+ sudo: new Set([
64
+ '-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt',
65
+ '-C', '--close-from', '-T', '--command-timeout', '-R', '--chroot',
66
+ '-D', '--chdir',
67
+ ]),
68
+ };
69
+ const CURL_DATA_FILE_OPTIONS = new Set([
70
+ '-d',
71
+ '--data',
72
+ '--data-ascii',
73
+ '--data-binary',
74
+ '--data-urlencode',
75
+ '--json',
76
+ ]);
77
+ const CURL_FORM_FILE_OPTIONS = new Set(['-F', '--form']);
78
+ const CURL_UPLOAD_FILE_OPTIONS = new Set(['-T', '--upload-file']);
79
+ const WGET_POST_FILE_OPTIONS = new Set(['--post-file', '--body-file']);
80
+
57
81
  const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
58
82
  const SAFE_SECRET_STORAGE_DIRS = [
59
83
  '.resume_secrets',
@@ -248,7 +272,7 @@ function scanText(text, options = {}) {
248
272
  }
249
273
 
250
274
  function scanFile(filePath, options = {}) {
251
- const pathFinding = classifySecretPath(filePath);
275
+ const pathFinding = options.includePathFinding === false ? null : classifySecretPath(filePath);
252
276
  const provider = resolveProvider(options.provider);
253
277
  const findings = [];
254
278
  if (pathFinding) findings.push(pathFinding);
@@ -279,14 +303,47 @@ function scanFile(filePath, options = {}) {
279
303
  };
280
304
  }
281
305
 
306
+ function flushToken(tokens, state) {
307
+ if (!state.current) return;
308
+ tokens.push(state.current);
309
+ state.current = '';
310
+ }
311
+
312
+ function consumeTokenCharacter(state, tokens, char) {
313
+ if (state.escaped) {
314
+ state.current += char;
315
+ state.escaped = false;
316
+ return;
317
+ }
318
+ if (char === '\\' && state.quote !== "'") {
319
+ state.escaped = true;
320
+ return;
321
+ }
322
+ if (state.quote) {
323
+ if (char === state.quote) state.quote = null;
324
+ else state.current += char;
325
+ return;
326
+ }
327
+ if (SHELL_QUOTES.has(char)) {
328
+ state.quote = char;
329
+ return;
330
+ }
331
+ if (/\s/.test(char)) {
332
+ flushToken(tokens, state);
333
+ return;
334
+ }
335
+ state.current += char;
336
+ }
337
+
282
338
  function tokenizeCommand(command) {
283
339
  const tokens = [];
284
- const regex = /"([^"]+)"|'([^']+)'|(\S+)/g;
285
- let match = regex.exec(String(command || ''));
286
- while (match) {
287
- tokens.push(match[1] || match[2] || match[3]);
288
- match = regex.exec(String(command || ''));
340
+ const state = { current: '', quote: null, escaped: false };
341
+
342
+ for (const char of String(command || '')) {
343
+ consumeTokenCharacter(state, tokens, char);
289
344
  }
345
+ if (state.escaped) state.current += '\\';
346
+ flushToken(tokens, state);
290
347
  return tokens;
291
348
  }
292
349
 
@@ -307,6 +364,202 @@ function resolvePathToken(token, cwd) {
307
364
  return path.join(cwd || process.cwd(), normalized);
308
365
  }
309
366
 
367
+ function flushCommandSegment(segments, state) {
368
+ const segment = state.current.trim();
369
+ if (segment) segments.push(segment);
370
+ state.current = '';
371
+ }
372
+
373
+ function consumeSegmentCharacter(state, segments, char) {
374
+ if (state.escaped) {
375
+ state.current += char;
376
+ state.escaped = false;
377
+ return;
378
+ }
379
+ if (char === '\\' && state.quote !== "'") {
380
+ state.current += char;
381
+ state.escaped = true;
382
+ return;
383
+ }
384
+ if (state.quote) {
385
+ state.current += char;
386
+ if (char === state.quote) state.quote = null;
387
+ return;
388
+ }
389
+ if (SHELL_QUOTES.has(char)) {
390
+ state.quote = char;
391
+ state.current += char;
392
+ return;
393
+ }
394
+ if (SHELL_SEGMENT_SEPARATORS.has(char)) {
395
+ flushCommandSegment(segments, state);
396
+ return;
397
+ }
398
+ state.current += char;
399
+ }
400
+
401
+ function splitCommandSegments(command) {
402
+ const segments = [];
403
+ const state = { current: '', quote: null, escaped: false };
404
+
405
+ for (const char of String(command || '')) {
406
+ consumeSegmentCharacter(state, segments, char);
407
+ }
408
+ flushCommandSegment(segments, state);
409
+ return segments;
410
+ }
411
+
412
+ function isShellAssignment(token) {
413
+ return /^[A-Za-z_]\w*=/.test(String(token || ''));
414
+ }
415
+
416
+ function skipWrapperOptions(tokens, startIndex, wrapper) {
417
+ let index = startIndex;
418
+ const valueOptions = WRAPPER_OPTIONS_WITH_VALUE[wrapper] || new Set();
419
+
420
+ while (index < tokens.length) {
421
+ const token = String(tokens[index] || '');
422
+ if (wrapper === 'env' && isShellAssignment(token)) {
423
+ index += 1;
424
+ continue;
425
+ }
426
+ if (token === '--') return index + 1;
427
+ if (!token.startsWith('-')) return index;
428
+ if (wrapper === 'command' && (token === '-v' || token === '-V')) return -1;
429
+
430
+ const optionName = token.split('=', 1)[0];
431
+ if (valueOptions.has(optionName) && !token.includes('=')) index += 2;
432
+ else index += 1;
433
+ }
434
+
435
+ return index;
436
+ }
437
+
438
+ function findOutboundCommand(tokens) {
439
+ let index = 0;
440
+ while (isShellAssignment(tokens[index])) index += 1;
441
+
442
+ while (index < tokens.length) {
443
+ const wrapper = path.basename(String(tokens[index] || '')).toLowerCase();
444
+ if (!OUTBOUND_COMMAND_WRAPPERS.has(wrapper)) break;
445
+ index += 1;
446
+ index = skipWrapperOptions(tokens, index, wrapper);
447
+ if (index < 0) return null;
448
+ }
449
+
450
+ const command = path.basename(String(tokens[index] || '')).toLowerCase();
451
+ return OUTBOUND_FILE_COMMANDS.has(command) ? { command, index } : null;
452
+ }
453
+
454
+ function stripCurlFormMetadata(fileReference) {
455
+ return String(fileReference || '').split(';', 1)[0];
456
+ }
457
+
458
+ function curlDataFileReference(value, allowNamedReference = false) {
459
+ const normalized = String(value || '');
460
+ if (normalized.startsWith('@')) return normalized.slice(1);
461
+ if (!allowNamedReference) return null;
462
+ const markerIndex = normalized.indexOf('@');
463
+ return markerIndex > 0 ? normalized.slice(markerIndex + 1) : null;
464
+ }
465
+
466
+ function curlFormFileReference(value) {
467
+ const normalized = String(value || '');
468
+ const marker = /(?:^|=)[@<](.+)$/.exec(normalized);
469
+ return marker ? stripCurlFormMetadata(marker[1]) : null;
470
+ }
471
+
472
+ function addOutboundReference(references, fileReference, cwd, metadata) {
473
+ const normalized = String(fileReference || '').trim();
474
+ if (!normalized || normalized === '-') return;
475
+ const resolvedPath = resolvePathToken(normalized, cwd);
476
+ if (!resolvedPath) return;
477
+ references.push({
478
+ path: resolvedPath,
479
+ ...metadata,
480
+ });
481
+ }
482
+
483
+ function readOptionArgument(args, index, option) {
484
+ const token = String(args[index] || '');
485
+ if (token === option) {
486
+ return { value: args[index + 1], consumesNext: true };
487
+ }
488
+ if (option.startsWith('--') && token.startsWith(`${option}=`)) {
489
+ return { value: token.slice(option.length + 1), consumesNext: false };
490
+ }
491
+ if (option.length === 2 && token.startsWith(option) && token.length > 2) {
492
+ return { value: token.slice(2), consumesNext: false };
493
+ }
494
+ return null;
495
+ }
496
+
497
+ function outboundOptionSpecs(command) {
498
+ if (command === 'wget') {
499
+ return [{
500
+ options: WGET_POST_FILE_OPTIONS,
501
+ fileReference: (value) => value,
502
+ }];
503
+ }
504
+ return [
505
+ {
506
+ options: CURL_DATA_FILE_OPTIONS,
507
+ fileReference: (value, option) => curlDataFileReference(value, option === '--data-urlencode'),
508
+ },
509
+ {
510
+ options: CURL_FORM_FILE_OPTIONS,
511
+ fileReference: curlFormFileReference,
512
+ },
513
+ {
514
+ options: CURL_UPLOAD_FILE_OPTIONS,
515
+ fileReference: (value) => value,
516
+ },
517
+ ];
518
+ }
519
+
520
+ function findOutboundOptionArgument(args, index, specs) {
521
+ for (const spec of specs) {
522
+ for (const option of spec.options) {
523
+ const argument = readOptionArgument(args, index, option);
524
+ if (argument) return { argument, fileReference: spec.fileReference, option };
525
+ }
526
+ }
527
+ return null;
528
+ }
529
+
530
+ function extractCommandFileReferences(command, args, cwd, references) {
531
+ const specs = outboundOptionSpecs(command);
532
+ for (let index = 0; index < args.length; index += 1) {
533
+ const match = findOutboundOptionArgument(args, index, specs);
534
+ if (!match) continue;
535
+ addOutboundReference(references, match.fileReference(match.argument.value, match.option), cwd, {
536
+ command,
537
+ option: match.option,
538
+ });
539
+ if (match.argument.consumesNext) index += 1;
540
+ }
541
+ }
542
+
543
+ function extractOutboundFileReferences(command, cwd = process.cwd()) {
544
+ // Only file-bearing client options enter this path. Endpoint-only egress
545
+ // remains governed by the existing warn-level network gate.
546
+ const references = [];
547
+ for (const segment of splitCommandSegments(command)) {
548
+ const tokens = tokenizeCommand(segment);
549
+ const outboundCommand = findOutboundCommand(tokens);
550
+ if (!outboundCommand) continue;
551
+ const args = tokens.slice(outboundCommand.index + 1);
552
+ extractCommandFileReferences(outboundCommand.command, args, cwd, references);
553
+ }
554
+
555
+ const seen = new Set();
556
+ return references.filter((reference) => {
557
+ if (seen.has(reference.path)) return false;
558
+ seen.add(reference.path);
559
+ return true;
560
+ });
561
+ }
562
+
310
563
  function normalizePathForPolicy(filePath) {
311
564
  return path.resolve(String(filePath || '').replace(/^~(?=\/|$)/, os.homedir()));
312
565
  }
@@ -327,37 +580,70 @@ function isSafeSecretStorageWrite(toolName, toolInput = {}, cwd = process.cwd())
327
580
  return paths.length > 0 && paths.every((filePath) => isSafeSecretStoragePath(filePath));
328
581
  }
329
582
 
330
- function scanBashCommand(command, options = {}) {
331
- const cwd = options.cwd || process.cwd();
332
- const findings = [];
333
- const inlineScan = scanText(command, { provider: options.provider, source: 'command' });
334
- findings.push(...inlineScan.findings.map((finding) => ({
583
+ function commandTextFindings(inlineScan) {
584
+ return inlineScan.findings.map((finding) => ({
335
585
  ...finding,
336
586
  reason: `${finding.label} found in command text`,
337
- })));
587
+ }));
588
+ }
338
589
 
590
+ function scanCommandReadFiles(command, cwd, provider) {
339
591
  const tokens = tokenizeCommand(command);
340
592
  const verb = String(tokens[0] || '').toLowerCase();
341
- const inspectsFiles = BASH_SECRET_READ_PREFIXES.includes(verb);
342
-
343
- if (inspectsFiles) {
344
- for (const token of tokens.slice(1)) {
345
- if (!looksLikePath(token)) continue;
346
- const resolved = resolvePathToken(token, cwd);
347
- const fileScan = scanFile(resolved, { provider: options.provider });
348
- if (!fileScan.detected) continue;
349
- findings.push(...fileScan.findings.map((finding) => ({
350
- ...finding,
351
- source: 'command_file',
352
- })));
353
- }
593
+ if (!BASH_SECRET_READ_PREFIXES.includes(verb)) return [];
594
+
595
+ const findings = [];
596
+ for (const token of tokens.slice(1)) {
597
+ if (!looksLikePath(token)) continue;
598
+ const resolved = resolvePathToken(token, cwd);
599
+ const fileScan = scanFile(resolved, { provider });
600
+ if (!fileScan.detected) continue;
601
+ findings.push(...fileScan.findings.map((finding) => ({
602
+ ...finding,
603
+ source: 'command_file',
604
+ })));
354
605
  }
606
+ return findings;
607
+ }
608
+
609
+ function scanOutboundCommandFiles(command, cwd, provider) {
610
+ const findings = [];
611
+ const fileHashes = [];
612
+ for (const reference of extractOutboundFileReferences(command, cwd)) {
613
+ const fileScan = scanFile(reference.path, {
614
+ provider,
615
+ includePathFinding: false,
616
+ });
617
+ if (!fileScan.detected) continue;
618
+ findings.push(...fileScan.findings.map((finding) => ({
619
+ ...finding,
620
+ source: 'outbound_file',
621
+ reason: `${finding.label} found in file referenced by ${reference.command} ${reference.option}`,
622
+ })));
623
+ if (fileScan.fileHash) fileHashes.push(fileScan.fileHash);
624
+ }
625
+ return { findings, fileHashes };
626
+ }
627
+
628
+ function scanBashCommand(command, options = {}) {
629
+ const cwd = options.cwd || process.cwd();
630
+ const inlineScan = scanText(command, { provider: options.provider, source: 'command' });
631
+ const findings = commandTextFindings(inlineScan);
632
+ findings.push(...scanCommandReadFiles(command, cwd, options.provider));
633
+
634
+ const outboundScan = inlineScan.provider === 'off'
635
+ ? { findings: [], fileHashes: [] }
636
+ : scanOutboundCommandFiles(command, cwd, options.provider);
637
+ findings.push(...outboundScan.findings);
638
+
639
+ const uniqueFileHashes = [...new Set(outboundScan.fileHashes)];
355
640
 
356
641
  return {
357
642
  detected: findings.length > 0,
358
643
  provider: inlineScan.provider,
359
644
  findings: uniqueFindings(findings),
360
645
  commandHash: hashText(command),
646
+ fileHashes: uniqueFileHashes,
361
647
  };
362
648
  }
363
649
 
@@ -371,61 +657,70 @@ function getToolInputPaths(toolInput = {}, cwd = process.cwd()) {
371
657
  return candidates.map((candidate) => resolvePathToken(candidate, cwd));
372
658
  }
373
659
 
374
- function scanHookInput(input = {}, options = {}) {
375
- const toolName = String(input.tool_name || input.toolName || '').trim();
376
- const toolInput = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
377
- const cwd = input.cwd || options.cwd || process.cwd();
378
- const findings = [];
379
- let provider = resolveProvider(options.provider);
380
- let commandHash = null;
381
- let fileHashes = [];
382
- const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, cwd);
383
-
384
- const contentFields = [
660
+ function getToolInputContent(toolInput) {
661
+ return [
385
662
  toolInput.content,
386
663
  toolInput.new_string,
387
664
  toolInput.value,
388
665
  toolInput.text,
389
666
  ].filter((value) => typeof value === 'string' && value.trim());
667
+ }
390
668
 
391
- if (!EDIT_LIKE_TOOLS.has(toolName)) {
392
- const paths = getToolInputPaths(toolInput, cwd);
393
- for (const filePath of paths) {
394
- const result = scanFile(filePath, { provider });
395
- if (result.detected) {
396
- provider = result.provider;
397
- fileHashes.push(result.fileHash);
398
- findings.push(...result.findings);
399
- }
400
- }
669
+ function scanHookPaths(state, toolName, toolInput, cwd) {
670
+ if (EDIT_LIKE_TOOLS.has(toolName)) return;
671
+ for (const filePath of getToolInputPaths(toolInput, cwd)) {
672
+ const result = scanFile(filePath, { provider: state.provider });
673
+ if (!result.detected) continue;
674
+ state.provider = result.provider;
675
+ state.fileHashes.push(result.fileHash);
676
+ state.findings.push(...result.findings);
401
677
  }
678
+ }
402
679
 
403
- if (typeof toolInput.command === 'string' && toolInput.command.trim()) {
404
- const result = scanBashCommand(toolInput.command, { provider, cwd });
405
- if (result.detected) {
406
- provider = result.provider;
407
- commandHash = result.commandHash;
408
- findings.push(...result.findings);
409
- }
410
- }
680
+ function scanHookCommand(state, toolInput, cwd) {
681
+ const command = toolInput.command;
682
+ if (typeof command !== 'string' || !command.trim()) return;
683
+ const result = scanBashCommand(command, { provider: state.provider, cwd });
684
+ if (!result.detected) return;
685
+ state.provider = result.provider;
686
+ state.commandHash = result.commandHash;
687
+ state.fileHashes.push(...(result.fileHashes || []));
688
+ state.findings.push(...result.findings);
689
+ }
411
690
 
412
- if (!safeSecretStorageWrite) {
413
- for (const content of contentFields) {
414
- const result = scanText(content, { provider, source: 'tool_input' });
415
- if (result.detected) {
416
- provider = result.provider;
417
- findings.push(...result.findings);
418
- }
419
- }
691
+ function scanHookContent(state, toolInput, safeSecretStorageWrite) {
692
+ if (safeSecretStorageWrite) return;
693
+ for (const content of getToolInputContent(toolInput)) {
694
+ const result = scanText(content, { provider: state.provider, source: 'tool_input' });
695
+ if (!result.detected) continue;
696
+ state.provider = result.provider;
697
+ state.findings.push(...result.findings);
420
698
  }
699
+ }
700
+
701
+ function scanHookInput(input = {}, options = {}) {
702
+ const toolName = String(input.tool_name || input.toolName || '').trim();
703
+ const toolInput = input.tool_input && typeof input.tool_input === 'object' ? input.tool_input : {};
704
+ const cwd = input.cwd || options.cwd || process.cwd();
705
+ const state = {
706
+ provider: resolveProvider(options.provider),
707
+ findings: [],
708
+ commandHash: null,
709
+ fileHashes: [],
710
+ };
711
+ const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, cwd);
712
+
713
+ scanHookPaths(state, toolName, toolInput, cwd);
714
+ scanHookCommand(state, toolInput, cwd);
715
+ scanHookContent(state, toolInput, safeSecretStorageWrite);
421
716
 
422
717
  return {
423
- detected: findings.length > 0,
424
- provider,
718
+ detected: state.findings.length > 0,
719
+ provider: state.provider,
425
720
  toolName,
426
- findings: uniqueFindings(findings),
427
- commandHash,
428
- fileHashes: fileHashes.filter(Boolean),
721
+ findings: uniqueFindings(state.findings),
722
+ commandHash: state.commandHash,
723
+ fileHashes: state.fileHashes.filter(Boolean),
429
724
  };
430
725
  }
431
726
 
@@ -9,20 +9,19 @@
9
9
  * an agent could disable the firewall before continuing and the surfaced
10
10
  * verdict was a clean ALLOW.
11
11
  *
12
- * This module is the single source of truth for "is this edit touching the
13
- * firewall's own kill-switches", used by BOTH the shipped `npx thumbgate
14
- * gate-check` entrypoint and this repo's dogfood PreToolUse hook.
12
+ * This module retains the path-classification API used by the dogfood hook and
13
+ * third-party integrations. Enforcement itself lives in gates-engine.js so the
14
+ * plugin hook and `thumbgate gate-check` cannot drift apart.
15
15
  *
16
16
  * Posture threads three constraints:
17
17
  * * Andy's ask: don't let the agent quietly rewrite/disable the gate.
18
- * * CEO warn-by-default (2026-06-04): never hard-block legitimate work.
19
- * * Self-lockout lesson (2026-07-07): a PreToolUse hook must NEVER deny the
20
- * tools needed to repair its own config, or the agent is bricked.
18
+ * * CEO warn-by-default (2026-06-04): ordinary operational gates stay advisory.
19
+ * * Self-lockout lesson (2026-07-07): every protected-file floor needs an
20
+ * audited, short-lived repair path.
21
21
  *
22
- * Resolution:
23
- * default -> WARN (surfaced + logged, never silent)
24
- * THUMBGATE_STRICT_ENFORCEMENT=1 -> BLOCK
25
- * THUMBGATE_ALLOW_SELF_EDIT=1 -> full opt-out (preserves the repair path)
22
+ * Self-protection is now an unconditional floor. Intentional repairs use the
23
+ * audited, time-limited protected approval / break-glass path; environment
24
+ * flags cannot disable it.
26
25
  */
27
26
 
28
27
  const SELF_GOVERNANCE_PATH_PATTERNS = [
@@ -30,18 +29,15 @@ const SELF_GOVERNANCE_PATH_PATTERNS = [
30
29
  /(?:^|\/)\.codex\/config\.toml$/i,
31
30
  /(?:^|\/)scripts\/hook-[^/]+\.(?:js|sh)$/i, // the hook scripts themselves
32
31
  /(?:^|\/)config\/gates\//i, // gate definitions
32
+ /(?:^|\/)config\/budget\.json$/i,
33
33
  /(?:^|\/)config\/enforcement\.json$/i, // enforcement policy
34
34
  /(?:^|\/)config\/mcp-allowlists\.json$/i, // MCP policy surface
35
+ /(?:^|\/)\.thumbgate\/config\.json$/i,
36
+ /(?:^|\/)thumbgate\.json$/i,
35
37
  ];
36
38
 
37
39
  const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
38
40
 
39
- function isTrueEnv(value) {
40
- if (value === undefined || value === null) return false;
41
- const v = String(value).trim().toLowerCase();
42
- return v === '1' || v === 'true' || v === 'yes' || v === 'on';
43
- }
44
-
45
41
  /**
46
42
  * Returns the matched governance file path, or null. Accepts both camelCase
47
43
  * (`tool_name`/`tool_input`) and the already-normalized shapes callers use.
@@ -55,31 +51,20 @@ function selfProtectionTarget(toolName, toolInput) {
55
51
 
56
52
  /**
57
53
  * Evaluate the self-protection posture for a tool call.
58
- * @returns {{action:'block'|'warn', target:string, message:string}|null}
54
+ * @returns {{action:'block', target:string, message:string}|null}
59
55
  */
60
- function evaluateSelfProtection(toolName, toolInput, env = process.env) {
56
+ function evaluateSelfProtection(toolName, toolInput) {
61
57
  const target = selfProtectionTarget(toolName, toolInput);
62
58
  if (!target) return null;
63
- // Escape hatch: an operator repairing the gate opts out explicitly. Honors the
64
- // self-lockout lesson the repair path is never denied.
65
- if (isTrueEnv(env.THUMBGATE_ALLOW_SELF_EDIT)) return null;
66
- if (isTrueEnv(env.THUMBGATE_STRICT_ENFORCEMENT)) {
67
- return {
68
- action: 'block',
69
- target,
70
- message:
71
- `ThumbGate self-protection: blocked ${toolName} to its own governance file "${target}". `
72
- + `Editing the firewall's own hook wiring / gate config while strict enforcement is on is denied. `
73
- + `Set THUMBGATE_ALLOW_SELF_EDIT=1 to make an intentional repair.`,
74
- };
75
- }
59
+ const { runHardFloor } = require('./gates-engine');
60
+ const output = runHardFloor({ tool_name: toolName, tool_input: toolInput });
61
+ if (!output) return null;
62
+ const parsed = JSON.parse(output);
63
+ const message = parsed.hookSpecificOutput && parsed.hookSpecificOutput.permissionDecisionReason;
76
64
  return {
77
- action: 'warn',
65
+ action: 'block',
78
66
  target,
79
- message:
80
- `⚠️ ThumbGate self-protection: this ${toolName} targets a governance file that configures the firewall itself `
81
- + `("${target}"). It is being ALLOWED and LOGGED (warn-by-default). An agent editing this could weaken or disable `
82
- + `its own guardrails — confirm this is intentional. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block such edits.`,
67
+ message: message || `ThumbGate self-protection blocked ${toolName} to "${target}".`,
83
68
  };
84
69
  }
85
70
 
@@ -562,7 +562,7 @@ const PRETOOLUSE_HOOK_GUIDE_SPEC = Object.freeze({
562
562
  ],
563
563
  [
564
564
  'Does this run locally or call a cloud service?',
565
- 'Local-first. The PreToolUse decision happens in the hook process on your machine in milliseconds — no network round-trip, no cloud dependency, no data leaving the laptop. Optional hosted sync exists for teams that want to share rules across seats.',
565
+ 'Local-first. The PreToolUse decision happens in the hook process on your machine in milliseconds — no network round-trip, no cloud dependency, no data leaving the laptop. Pro adds personal recall, exports, dashboard proof, and managed adapter coverage. Enterprise adds hosted sharing for teams that want to share rules across seats.',
566
566
  ],
567
567
  ],
568
568
  relatedPaths: ['/guides/mcp-tool-governance', '/guides/ai-agent-pre-action-approval-gates', '/guides/ai-coding-agent-zero-trust'],
@@ -1972,7 +1972,7 @@ const PAGE_BLUEPRINTS = [
1972
1972
  faq: [
1973
1973
  {
1974
1974
  question: 'Why pay $19/mo for ThumbGate Pro when disler hooks are free?',
1975
- answer: 'Free disler hooks are static patterns you maintain per machine — re-copying them when they change, debugging false positives alone, and re-applying them in every new project. ThumbGate Pro adds the learning loop (thumbs-down → cross-session prevention rule), the dashboard, hosted sync across machines, and adapter maintenance across the weekly breaking-change cycle of Claude Code, Cursor, and Cline. Free disler is the right answer if you only ever work in one project on one machine and never want to learn from past mistakes. Pro is the right answer when those assumptions stop holding.',
1975
+ answer: 'Free disler hooks are static patterns you maintain per machine — re-copying them when they change, debugging false positives alone, and re-applying them in every new project. ThumbGate Pro adds the learning loop (repeated thumbs-downs → cross-session prevention rule), the personal dashboard, lesson recall/search, DPO export, and adapter maintenance across the weekly breaking-change cycle of Claude Code, Cursor, and Cline. Free disler is the right answer if you only ever work in one project on one machine and never want to learn from past mistakes. Pro is the right answer when those assumptions stop holding; Enterprise adds shared hosted lessons across seats.',
1976
1976
  },
1977
1977
  {
1978
1978
  question: 'Is ThumbGate just a packaged version of disler/claude-code-hooks-mastery?',
@@ -22,6 +22,7 @@
22
22
  const fs = require('fs');
23
23
  const { parseTimestamp } = require('./feedback-schema');
24
24
  const { getEffectiveSetting } = require('./evolution-state');
25
+ const { requireLearnedModelsEntitlement } = require('./entitlement');
25
26
 
26
27
  // ---------------------------------------------------------------------------
27
28
  // Constants
@@ -179,7 +180,11 @@ function saveModel(model, modelPath) {
179
180
  * @param {number} [params.weightMultiplier] - Optional multiplier for stronger/slower updates
180
181
  * @returns {Object} The mutated model
181
182
  */
182
- function updateModel(model, { signal, timestamp, categories, weightMultiplier, failureType }) {
183
+ function updateModel(model, { signal, timestamp, categories, weightMultiplier, failureType, entitlement }) {
184
+ requireLearnedModelsEntitlement({
185
+ ...(entitlement || {}),
186
+ label: 'Thompson Sampling model update',
187
+ });
183
188
  const multiplier = Number.isFinite(weightMultiplier) && weightMultiplier > 0 ? weightMultiplier : 1;
184
189
  const weight = timeDecayWeight(timestamp) * multiplier;
185
190
  const isPositive = signal === 'positive';
@@ -334,7 +339,11 @@ function getTemperatureScaledPosteriorParams(params, temperature = 1.0) {
334
339
  * @param {number} temperature - Scaling factor (default 1.0)
335
340
  * @returns {Object} Map of category → float sample in [0, 1]
336
341
  */
337
- function samplePosteriors(model, temperature = 1.0) {
342
+ function samplePosteriors(model, temperature = 1.0, options = {}) {
343
+ requireLearnedModelsEntitlement({
344
+ ...(options.entitlement || {}),
345
+ label: 'Thompson Sampling posterior sampling',
346
+ });
338
347
  const samples = {};
339
348
 
340
349
  for (const [cat, params] of Object.entries(model.categories || {})) {