thumbgate 1.6.0 → 1.8.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.
@@ -83,6 +83,11 @@ const DEFAULT_PROTECTED_FILE_GLOBS = [
83
83
  ];
84
84
  const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
85
85
  const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+pr\s+(?:create|merge)|npm\s+publish|yarn\s+publish|pnpm\s+publish|rm\s+-rf)\b/i;
86
+ const BOOSTED_RISK_BLOCK_SCORE = 0.8;
87
+ const BOOSTED_RISK_MIN_EXAMPLES = 3;
88
+ const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
89
+ const PR_THREAD_RESOLUTION_CLAIM_PATTERN = '(?:thread|review|comment).*?(?:resolved|verified|checked|addressed|fixed)|(?:resolved|verified|checked|addressed|fixed).*?(?:thread|review|comment)';
90
+ const PR_THREAD_RESOLUTION_REQUIRED_ACTIONS = ['pr_threads_checked', 'thread_resolution_verified'];
86
91
 
87
92
  // ---------------------------------------------------------------------------
88
93
  // Config loading
@@ -609,6 +614,218 @@ function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
609
614
  return false;
610
615
  }
611
616
 
617
+ function normalizeRiskToken(value) {
618
+ return String(value || '')
619
+ .toLowerCase()
620
+ .replace(/[^a-z0-9]+/g, ' ')
621
+ .trim();
622
+ }
623
+
624
+ function singularizeRiskToken(token) {
625
+ const value = String(token || '').trim();
626
+ if (value.length > 3 && value.endsWith('ies')) return `${value.slice(0, -3)}y`;
627
+ if (value.length > 3 && value.endsWith('s')) return value.slice(0, -1);
628
+ return value;
629
+ }
630
+
631
+ function riskTokenVariants(token) {
632
+ const normalized = singularizeRiskToken(token);
633
+ const variants = new Set([token, normalized]);
634
+ const synonyms = {
635
+ comment: ['comment', 'comments', 'review', 'reviews', 'reply', 'replies', 'thread', 'threads'],
636
+ thread: ['thread', 'threads', 'review', 'reviews', 'comment', 'comments'],
637
+ bot: ['bot', 'bots', 'automation', 'automated', 'assistant', 'claude', 'codex'],
638
+ pr: ['pr', 'pull', 'pullrequest', 'pullrequests'],
639
+ file: ['file', 'files', 'path', 'paths'],
640
+ test: ['test', 'tests', 'ci', 'coverage', 'verify', 'verification'],
641
+ };
642
+ for (const candidate of [token, normalized]) {
643
+ for (const item of synonyms[candidate] || []) {
644
+ variants.add(item);
645
+ variants.add(singularizeRiskToken(item));
646
+ }
647
+ }
648
+ return [...variants].filter(Boolean);
649
+ }
650
+
651
+ function normalizeRiskTagEntry(entry) {
652
+ if (!entry) return null;
653
+ if (typeof entry === 'string') {
654
+ return { tag: entry };
655
+ }
656
+ if (typeof entry !== 'object') return null;
657
+ const tag = entry.tag || entry.key || entry.name || entry.domain || entry.label || entry.id;
658
+ if (!tag) return null;
659
+ return {
660
+ tag: String(tag),
661
+ count: Number(entry.count ?? entry.examples ?? entry.exampleCount ?? entry.total ?? entry.samples),
662
+ failures: Number(entry.failures ?? entry.failureCount),
663
+ riskRate: Number(entry.riskRate ?? entry.rate ?? entry.failureRate ?? entry.score ?? entry.riskScore),
664
+ };
665
+ }
666
+
667
+ function collectBoostedRiskTags(toolInput = {}) {
668
+ const boostedRisk = toolInput.boostedRisk && typeof toolInput.boostedRisk === 'object'
669
+ ? toolInput.boostedRisk
670
+ : {};
671
+ const sources = [
672
+ toolInput.highRiskTags,
673
+ toolInput.riskTags,
674
+ boostedRisk.highRiskTags,
675
+ boostedRisk.tags,
676
+ boostedRisk.highRiskDomains,
677
+ ];
678
+ const tags = [];
679
+ for (const source of sources) {
680
+ if (Array.isArray(source)) {
681
+ tags.push(...source.map(normalizeRiskTagEntry).filter(Boolean));
682
+ }
683
+ }
684
+ return tags;
685
+ }
686
+
687
+ function isBoostedRiskHigh(toolInput = {}) {
688
+ const boostedRisk = toolInput.boostedRisk && typeof toolInput.boostedRisk === 'object'
689
+ ? toolInput.boostedRisk
690
+ : {};
691
+ const level = String(boostedRisk.riskLevel || boostedRisk.level || boostedRisk.mode || '').toLowerCase();
692
+ if (/\b(?:high|critical|block|deny)\b/.test(level)) return true;
693
+
694
+ const riskScore = Number(boostedRisk.riskScore ?? boostedRisk.score ?? boostedRisk.riskRate ?? boostedRisk.failureRate ?? boostedRisk.baseRate);
695
+ if (Number.isFinite(riskScore) && riskScore >= BOOSTED_RISK_BLOCK_SCORE) return true;
696
+
697
+ const exampleCount = Number(boostedRisk.exampleCount ?? boostedRisk.count ?? boostedRisk.samples ?? boostedRisk.total);
698
+ const failureCount = Number(boostedRisk.failureCount ?? boostedRisk.failures);
699
+ if (
700
+ Number.isFinite(exampleCount) &&
701
+ exampleCount >= BOOSTED_RISK_MIN_EXAMPLES &&
702
+ Number.isFinite(failureCount) &&
703
+ failureCount / Math.max(exampleCount, 1) >= BOOSTED_RISK_BLOCK_SCORE
704
+ ) {
705
+ return true;
706
+ }
707
+
708
+ return collectBoostedRiskTags(toolInput).some((entry) => {
709
+ if (Number.isFinite(entry.riskRate) && entry.riskRate >= BOOSTED_RISK_BLOCK_SCORE) return true;
710
+ if (Number.isFinite(entry.count) && entry.count >= BOOSTED_RISK_MIN_EXAMPLES && !Number.isFinite(entry.riskRate)) return true;
711
+ if (
712
+ Number.isFinite(entry.count) &&
713
+ entry.count >= BOOSTED_RISK_MIN_EXAMPLES &&
714
+ Number.isFinite(entry.failures) &&
715
+ entry.failures / Math.max(entry.count, 1) >= BOOSTED_RISK_BLOCK_SCORE
716
+ ) {
717
+ return true;
718
+ }
719
+ return false;
720
+ });
721
+ }
722
+
723
+ function riskTagMatchesAction(tag, actionContext) {
724
+ const normalizedTag = normalizeRiskToken(tag);
725
+ const normalizedAction = normalizeRiskToken(actionContext);
726
+ if (!normalizedTag || !normalizedAction) return false;
727
+ const actionTokens = new Set(normalizedAction.split(/\s+/).filter(Boolean));
728
+ const tagTokens = normalizedTag.split(/\s+/).filter(Boolean);
729
+ return tagTokens.some((token) => riskTokenVariants(token).some((variant) => actionTokens.has(variant)));
730
+ }
731
+
732
+ function evaluateBoostedRiskTagGuard(toolName, toolInput = {}) {
733
+ const tags = collectBoostedRiskTags(toolInput);
734
+ if (tags.length === 0 || !isBoostedRiskHigh(toolInput)) return null;
735
+
736
+ const actionContext = extractActionContext(toolName, toolInput);
737
+ const matchedTag = tags.find((entry) => riskTagMatchesAction(entry.tag, actionContext));
738
+ if (!matchedTag) return null;
739
+
740
+ const matchText = toolInput.command || toolInput.file_path || toolInput.path || actionContext;
741
+ const message = `Boosted-risk history matched this action (${matchedTag.tag}). This pattern is denied by default until explicit evidence lowers the risk.`;
742
+ return {
743
+ decision: 'deny',
744
+ gate: 'boosted-risk-tag-default-deny',
745
+ message,
746
+ severity: 'critical',
747
+ reasoning: [
748
+ `High-risk tag "${matchedTag.tag}" matched "${String(matchText).slice(0, 120)}"`,
749
+ `Risk threshold: score >= ${BOOSTED_RISK_BLOCK_SCORE} or at least ${BOOSTED_RISK_MIN_EXAMPLES} examples`,
750
+ 'Hook enforcement blocks this pre-tool call instead of relying on advisory recall',
751
+ ],
752
+ };
753
+ }
754
+
755
+ function isGitCommitCommand(toolName, toolInput = {}) {
756
+ return toolName === 'Bash' && /\bgit\s+commit\b/i.test(String(toolInput.command || ''));
757
+ }
758
+
759
+ function isProtectedBranchName(branchName) {
760
+ return /^(?:main|master|develop|dev|trunk|release)$/i.test(String(branchName || '').trim());
761
+ }
762
+
763
+ function detectBranchName(toolInput = {}, repoRoot = null) {
764
+ const inline = toolInput.branchName || toolInput.currentBranch || toolInput.branch || toolInput.headRefName;
765
+ if (inline) return String(inline).trim();
766
+ if (!repoRoot) return '';
767
+ return safeExecFileLines('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repoRoot)[0] || '';
768
+ }
769
+
770
+ function hasPrBranchContext(toolInput = {}, repoRoot = null) {
771
+ if (toolInput.prNumber || toolInput.prUrl || toolInput.pullRequestNumber || toolInput.pullRequestUrl) {
772
+ return true;
773
+ }
774
+ const branchName = detectBranchName(toolInput, repoRoot);
775
+ return Boolean(branchName && !isProtectedBranchName(branchName));
776
+ }
777
+
778
+ function registerPrThreadResolutionClaimGate(toolName, toolInput = {}) {
779
+ if (!isGitCommitCommand(toolName, toolInput)) return null;
780
+ const repoRoot = resolveRepoRoot(toolInput);
781
+ if (!hasPrBranchContext(toolInput, repoRoot)) return null;
782
+
783
+ const branchName = detectBranchName(toolInput, repoRoot);
784
+ const claimGate = registerClaimGate(
785
+ PR_THREAD_RESOLUTION_CLAIM_PATTERN,
786
+ PR_THREAD_RESOLUTION_REQUIRED_ACTIONS,
787
+ 'A PR-branch commit requires verified review-thread resolution before more tool calls or readiness claims.',
788
+ );
789
+ trackAction(PR_THREAD_RESOLUTION_ACTION, {
790
+ branchName: branchName || null,
791
+ repoRoot: repoRoot || null,
792
+ commandHash: crypto.createHash('sha256').update(String(toolInput.command || '')).digest('hex'),
793
+ });
794
+ return claimGate;
795
+ }
796
+
797
+ function isThreadResolutionSatisfied() {
798
+ return PR_THREAD_RESOLUTION_REQUIRED_ACTIONS.some((actionId) => (
799
+ hasAction(actionId) || isConditionSatisfied(actionId)
800
+ ));
801
+ }
802
+
803
+ function isThreadResolutionEvidenceAction(toolName, toolInput = {}) {
804
+ if (isGitCommitCommand(toolName, toolInput)) return true;
805
+ if (['recall', 'search_lessons', 'verify_claim', 'satisfy_gate', 'track_action'].includes(toolName)) return true;
806
+ if (toolName !== 'Bash') return false;
807
+ const command = String(toolInput.command || '');
808
+ return /\b(?:gate-satisfy|satisfy_gate|track_action|gh\s+pr\s+(?:view|checks|status)|gh\s+api\b.*(?:reviewThreads|reviews|comments|threads)|git\s+(?:status|diff|show))\b/i.test(command);
809
+ }
810
+
811
+ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
812
+ if (!hasAction(PR_THREAD_RESOLUTION_ACTION)) return null;
813
+ if (isThreadResolutionSatisfied()) return null;
814
+ if (isThreadResolutionEvidenceAction(toolName, toolInput)) return null;
815
+
816
+ const message = 'A git commit was made on a PR branch. Verify review threads are resolved before the next tool call.';
817
+ return {
818
+ decision: 'deny',
819
+ gate: 'pr-thread-resolution-verified-required',
820
+ message,
821
+ severity: 'critical',
822
+ reasoning: [
823
+ `Tracked action ${PR_THREAD_RESOLUTION_ACTION} is pending`,
824
+ 'Satisfy pr_threads_checked or thread_resolution_verified with evidence before continuing',
825
+ ],
826
+ };
827
+ }
828
+
612
829
  function isScopeEnforcedAction(toolName, toolInput = {}, affectedFiles = []) {
613
830
  if (EDIT_LIKE_TOOLS.has(toolName) && affectedFiles.length > 0) return true;
614
831
  if (toolName !== 'Bash') return false;
@@ -1116,6 +1333,38 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1116
1333
  }
1117
1334
 
1118
1335
  const constraints = loadConstraints();
1336
+ registerPrThreadResolutionClaimGate(toolName, toolInput);
1337
+ const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
1338
+ if (pendingThreadResolutionGate) {
1339
+ recordStat(pendingThreadResolutionGate.gate, 'block');
1340
+ const auditRecord = recordAuditEvent({
1341
+ toolName,
1342
+ toolInput,
1343
+ decision: 'deny',
1344
+ gateId: pendingThreadResolutionGate.gate,
1345
+ message: pendingThreadResolutionGate.message,
1346
+ severity: pendingThreadResolutionGate.severity,
1347
+ source: 'gates-engine',
1348
+ });
1349
+ auditToFeedback(auditRecord);
1350
+ return pendingThreadResolutionGate;
1351
+ }
1352
+
1353
+ const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
1354
+ if (boostedRiskGuard) {
1355
+ recordStat(boostedRiskGuard.gate, 'block');
1356
+ const auditRecord = recordAuditEvent({
1357
+ toolName,
1358
+ toolInput,
1359
+ decision: 'deny',
1360
+ gateId: boostedRiskGuard.gate,
1361
+ message: boostedRiskGuard.message,
1362
+ severity: boostedRiskGuard.severity,
1363
+ source: 'gates-engine',
1364
+ });
1365
+ auditToFeedback(auditRecord);
1366
+ return boostedRiskGuard;
1367
+ }
1119
1368
 
1120
1369
  // Fast-path: feedback/recall tools skip metric gates entirely (avoids Stripe API calls)
1121
1370
  const METRIC_SKIP_TOOLS = ['capture_feedback', 'feedback_stats', 'recall', 'feedback_summary', 'prevention_rules'];
@@ -1254,6 +1503,38 @@ function evaluateGates(toolName, toolInput, configPath) {
1254
1503
  }
1255
1504
 
1256
1505
  const constraints = loadConstraints();
1506
+ registerPrThreadResolutionClaimGate(toolName, toolInput);
1507
+ const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
1508
+ if (pendingThreadResolutionGate) {
1509
+ recordStat(pendingThreadResolutionGate.gate, 'block');
1510
+ const auditRecord = recordAuditEvent({
1511
+ toolName,
1512
+ toolInput,
1513
+ decision: 'deny',
1514
+ gateId: pendingThreadResolutionGate.gate,
1515
+ message: pendingThreadResolutionGate.message,
1516
+ severity: pendingThreadResolutionGate.severity,
1517
+ source: 'gates-engine',
1518
+ });
1519
+ auditToFeedback(auditRecord);
1520
+ return pendingThreadResolutionGate;
1521
+ }
1522
+
1523
+ const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
1524
+ if (boostedRiskGuard) {
1525
+ recordStat(boostedRiskGuard.gate, 'block');
1526
+ const auditRecord = recordAuditEvent({
1527
+ toolName,
1528
+ toolInput,
1529
+ decision: 'deny',
1530
+ gateId: boostedRiskGuard.gate,
1531
+ message: boostedRiskGuard.message,
1532
+ severity: boostedRiskGuard.severity,
1533
+ source: 'gates-engine',
1534
+ });
1535
+ auditToFeedback(auditRecord);
1536
+ return boostedRiskGuard;
1537
+ }
1257
1538
 
1258
1539
  for (const gate of config.gates) {
1259
1540
  const matchDetails = matchGate(gate, toolName, toolInput);
@@ -1456,14 +1737,20 @@ function evaluateSecretGuard(input = {}) {
1456
1737
  // PreToolUse hook interface (stdin/stdout JSON)
1457
1738
  // ---------------------------------------------------------------------------
1458
1739
 
1740
+ function buildReminderOutput(context) {
1741
+ return {
1742
+ additionalContext: context,
1743
+ systemReminder: context,
1744
+ thumbgateSystemReminder: context,
1745
+ };
1746
+ }
1747
+
1459
1748
  function formatOutput(result, behavioralContext) {
1460
1749
  if (!result) {
1461
1750
  // No gate matched — inject behavioral context if available
1462
1751
  if (behavioralContext) {
1463
1752
  return JSON.stringify({
1464
- hookSpecificOutput: {
1465
- additionalContext: behavioralContext,
1466
- },
1753
+ hookSpecificOutput: buildReminderOutput(behavioralContext),
1467
1754
  });
1468
1755
  }
1469
1756
  return JSON.stringify({});
@@ -1474,19 +1761,27 @@ function formatOutput(result, behavioralContext) {
1474
1761
  : '';
1475
1762
 
1476
1763
  if (result.decision === 'deny') {
1764
+ const reminder = behavioralContext ? buildReminderOutput(behavioralContext) : {};
1765
+ const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
1477
1766
  return JSON.stringify({
1478
1767
  hookSpecificOutput: {
1768
+ ...reminder,
1479
1769
  permissionDecision: 'deny',
1480
- permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}`,
1770
+ permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}${reminderSuffix}`,
1481
1771
  },
1482
1772
  });
1483
1773
  }
1484
1774
 
1485
1775
  if (result.decision === 'warn') {
1486
1776
  const extra = behavioralContext ? `\n${behavioralContext}` : '';
1777
+ const context = `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`;
1487
1778
  return JSON.stringify({
1488
1779
  hookSpecificOutput: {
1489
- additionalContext: `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`,
1780
+ additionalContext: context,
1781
+ ...(behavioralContext ? {
1782
+ systemReminder: behavioralContext,
1783
+ thumbgateSystemReminder: behavioralContext,
1784
+ } : {}),
1490
1785
  },
1491
1786
  });
1492
1787
  }
@@ -1947,7 +2242,15 @@ module.exports = {
1947
2242
  extractActionContext,
1948
2243
  extractAvoidanceAdvice,
1949
2244
  mergeContextStrings,
2245
+ buildReminderOutput,
1950
2246
  isHighRiskAction,
2247
+ collectBoostedRiskTags,
2248
+ isBoostedRiskHigh,
2249
+ riskTagMatchesAction,
2250
+ evaluateBoostedRiskTagGuard,
2251
+ registerPrThreadResolutionClaimGate,
2252
+ evaluatePendingPrThreadResolutionGate,
2253
+ PR_THREAD_RESOLUTION_ACTION,
1951
2254
  };
1952
2255
 
1953
2256
  // ---------------------------------------------------------------------------