thumbgate 1.28.4 → 1.29.2

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 (87) hide show
  1. package/.claude/commands/dashboard.md +11 -1
  2. package/.claude/commands/thumbgate-dashboard.md +23 -8
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.well-known/llms.txt +18 -10
  5. package/.well-known/mcp/server-card.json +1 -1
  6. package/README.md +66 -3
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/forge/forge.yaml +3 -3
  9. package/adapters/mcp/server-stdio.js +88 -2
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bin/cli.js +8 -8
  12. package/bin/postinstall.js +4 -13
  13. package/commands/dashboard.md +11 -1
  14. package/commands/thumbgate-dashboard.md +23 -8
  15. package/config/agent-outcome-monitor-thresholds.json +63 -0
  16. package/config/evals/agent-outcomes-baseline.json +17 -0
  17. package/config/evals/agent-outcomes-golden.json +412 -0
  18. package/config/evals/prompt-eval-baseline.json +23 -0
  19. package/config/github-about.json +5 -4
  20. package/config/post-deploy-marketing-pages.json +6 -6
  21. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  22. package/docs/integrations/grafana/README.md +109 -0
  23. package/docs/integrations/grafana/thumbgate-revenue-evidence-dashboard.json +1930 -0
  24. package/openapi/openapi.yaml +475 -5
  25. package/package.json +75 -22
  26. package/public/agent-manager.html +10 -11
  27. package/public/agents-cost-savings.html +2 -2
  28. package/public/assets/brand/thumbgate-logo-transparent.svg +6 -11
  29. package/public/assets/brand/thumbgate-mark-inline-v3.svg +11 -10
  30. package/public/assets/brand/thumbgate-mark.svg +10 -11
  31. package/public/blog/inside-your-boundary.html +114 -0
  32. package/public/blog/process-over-outcome-gates.html +119 -0
  33. package/public/blog.html +296 -402
  34. package/public/brand/thumbgate-mark.svg +5 -9
  35. package/public/codex-enterprise.html +2 -2
  36. package/public/compare.html +12 -3
  37. package/public/diagnostic.html +79 -29
  38. package/public/guide.html +4 -4
  39. package/public/index.html +1090 -2098
  40. package/public/install.html +3 -3
  41. package/public/js/buyer-intent.js +33 -18
  42. package/public/numbers.html +2 -2
  43. package/public/pricing.html +268 -408
  44. package/public/pro.html +4 -4
  45. package/scripts/agent-outcome-eval.js +130 -0
  46. package/scripts/agent-outcome-monitor.js +261 -0
  47. package/scripts/agent-reasoning-traces.js +8 -9
  48. package/scripts/async-job-runner.js +107 -13
  49. package/scripts/billing.js +456 -126
  50. package/scripts/buyer-paths.js +102 -0
  51. package/scripts/cli-feedback.js +2 -2
  52. package/scripts/commercial-offer.js +18 -10
  53. package/scripts/durability/step.js +121 -12
  54. package/scripts/external-customer-audit.js +881 -0
  55. package/scripts/feedback-loop.js +26 -0
  56. package/scripts/gates-engine.js +554 -19
  57. package/scripts/grafana-revenue-evidence.js +856 -0
  58. package/scripts/human-escalation.js +265 -0
  59. package/scripts/hybrid-feedback-context.js +93 -50
  60. package/scripts/jsonl-window.js +89 -0
  61. package/scripts/judge-reward-function.js +30 -18
  62. package/scripts/lesson-embedding-index.js +3 -7
  63. package/scripts/meta-agent-loop.js +20 -2
  64. package/scripts/observability-env.js +139 -0
  65. package/scripts/observability-setup.js +55 -0
  66. package/scripts/plausible-domain-config.js +4 -0
  67. package/scripts/prompt-eval.js +81 -4
  68. package/scripts/provider-live-evidence.js +1290 -0
  69. package/scripts/provider-payment-reconciler.js +442 -0
  70. package/scripts/provider-revenue-evidence.js +249 -0
  71. package/scripts/rate-limiter.js +1 -5
  72. package/scripts/revenue-action-eligibility.js +414 -0
  73. package/scripts/revenue-evidence-remediation.js +694 -0
  74. package/scripts/revenue-offer-system.js +709 -0
  75. package/scripts/sales-pipeline.js +1117 -0
  76. package/scripts/schedule-manager.js +249 -0
  77. package/scripts/seo-gsd.js +8 -4
  78. package/scripts/stripe-credentials.js +37 -0
  79. package/scripts/stripe-revenue-catalog-audit.js +363 -0
  80. package/scripts/stripe-revenue-catalog.js +164 -0
  81. package/scripts/task-outcomes.js +425 -0
  82. package/scripts/telemetry-analytics.js +23 -3
  83. package/scripts/tool-contract-validator.js +287 -59
  84. package/scripts/tool-registry.js +143 -0
  85. package/scripts/vector-store.js +83 -7
  86. package/scripts/workflow-intake-queue.js +483 -0
  87. package/src/api/server.js +647 -118
@@ -121,6 +121,9 @@ const BOOSTED_RISK_MIN_EXAMPLES = 3;
121
121
  const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
122
122
  const HELPER_BYPASS_ACTION = 'helper_script_modified';
123
123
  const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
124
+ // Generous character bound: keeps every affected file for realistic actions while still
125
+ // preventing an unbounded haystack. Chosen over a file-count cap, which dropped targets.
126
+ const MEMORY_GUARD_MAX_SERIALIZED_CHARS = 200000;
124
127
  const KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+merge\b|gh\s+release\s+(?:create|delete|edit|upload)\b|(?:npm|yarn|pnpm)\s+publish\b|rm\s+-rf\b|git\s+reset\s+--hard\b|git\s+clean\s+-f[a-z]*|railway\s+(?:deploy|up)\b|gcloud\s+(?:run\s+deploy|app\s+deploy)\b|firebase\s+deploy\b|vercel\s+--prod\b|kubectl\s+(?:apply|delete)\b|terraform\s+(?:apply|destroy)\b)\b/i;
125
128
  const HELPER_SCRIPT_FILE_PATTERN = /(?:^|\/)(?:scripts|bin|tools|tasks|\.githooks|\.github\/workflows)\/|(?:^|\/)(?:package\.json|Makefile|justfile|Taskfile\.ya?ml)$|\.(?:sh|bash|zsh|fish|js|mjs|cjs|ts|tsx|py|rb|pl|ps1|yml|yaml)$/i;
126
129
  const PACKAGE_RUN_PATTERN = /\b(?:npm|yarn|pnpm)\s+run\s+([:@./\w-]+)\b/i;
@@ -149,6 +152,20 @@ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
149
152
  'slopsquat-guard',
150
153
  ...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
151
154
  ]);
155
+ // Issue #2782 (reported by Andy Martin, 2026-07-08): after the free-tier daily
156
+ // block cap is hit, applyDailyBlockCap() downgraded EVERY config-declared
157
+ // "block" gate to a warning — including these catastrophic, effectively
158
+ // irreversible commands — with no THUMBGATE_STRICT_ENFORCEMENT check at all.
159
+ // A free-tier user past their daily cap could have `rm -rf ~`, a force push,
160
+ // `git reset --hard`, or `git clean -f` silently allowed through. These four
161
+ // map directly to CLAUDE.md's own hard-block list and must never be subject
162
+ // to the daily cap discount, regardless of tier or strict-mode setting.
163
+ const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
164
+ 'force-push',
165
+ 'git-reset-hard',
166
+ 'git-clean-force',
167
+ 'rm-rf-home-or-root',
168
+ ]);
152
169
  const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
153
170
  const SELF_PROTECT_HOOK_TARGET_PATTERN = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^/]+\.(?:js|sh))$/i;
154
171
  const SELF_PROTECT_CONFIG_COMMAND_PATTERN = /(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json\b|\.thumbgate\/config\.json\b|thumbgate\.json\b)/i;
@@ -772,6 +789,11 @@ function incrementTodayBlockCount() {
772
789
  * a deny result to a warn with an upgrade CTA. Returns null if no cap applies.
773
790
  */
774
791
  function applyDailyBlockCap(denyResult) {
792
+ // Catastrophic/irreversible commands (force-push, git reset --hard, git
793
+ // clean -f, rm -rf on home/root) never get the free-tier daily-cap
794
+ // discount — see issue #2782. Checked first, before any tier/CI shortcut,
795
+ // so nothing can accidentally exempt a catastrophic gate from this floor.
796
+ if (denyResult && CATASTROPHIC_DECLARATIVE_GATE_IDS.has(denyResult.gate)) return null;
775
797
  // Pro, trial, CI, and THUMBGATE_NO_RATE_LIMIT users are uncapped
776
798
  if (isProTier()) return null;
777
799
  if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
@@ -929,25 +951,351 @@ function getBranchDiffFiles(repoRoot) {
929
951
  return safeExecFileLines('git', ['diff', '--name-only'], repoRoot);
930
952
  }
931
953
 
954
+ // `git add`/`git commit` accept an explicit pathspec, and when one is present it — not the
955
+ // working tree — defines what the command actually touches. Scanning the whole tree here
956
+ // reported every dirty file as "affected", so in a repo with a large dirty tree (e.g. one
957
+ // shared by several agents) a correctly scoped `git add -- a.js b.js` was reported as
958
+ // thousands of affected files and tripped task-scope / protected-file gates that the command
959
+ // never actually violated. Only fall back to a full tree scan when the command really does
960
+ // stage broadly (`git add .`, `-A`, `-u`, or no pathspec at all).
961
+ const GIT_BROAD_ADD_FLAGS = new Set(['-A', '--all', '-u', '--update', '--no-ignore-removal', '--ignore-removal']);
962
+
963
+ // Minimal shell-word splitter: honours single/double quotes so a quoted path with spaces
964
+ // stays one token. Deliberately does not expand variables or globs — an unresolvable token
965
+ // is treated as "broad" by the callers below rather than guessed at.
966
+ function tokenizeShellWords(segment) {
967
+ const tokens = [];
968
+ let current = '';
969
+ let quote = null;
970
+ let hasContent = false;
971
+ for (let i = 0; i < segment.length; i++) {
972
+ const char = segment[i];
973
+ // A backslash escapes the next character outside single quotes. Without this,
974
+ // `git add my\ dir/file.js` split at the escaped space into two fictional paths
975
+ // and the gates evaluated files git never touches.
976
+ if (char === '\\' && quote !== "'" && i + 1 < segment.length) {
977
+ current += segment[i + 1];
978
+ hasContent = true;
979
+ i += 1;
980
+ continue;
981
+ }
982
+ if (quote) {
983
+ if (char === quote) quote = null;
984
+ else current += char;
985
+ continue;
986
+ }
987
+ if (char === '"' || char === "'") {
988
+ quote = char;
989
+ hasContent = true;
990
+ continue;
991
+ }
992
+ if (/\s/.test(char)) {
993
+ if (hasContent || current) tokens.push(current);
994
+ current = '';
995
+ hasContent = false;
996
+ continue;
997
+ }
998
+ current += char;
999
+ }
1000
+ if (hasContent || current) tokens.push(current);
1001
+ return tokens;
1002
+ }
1003
+
1004
+ // Isolate the `git <sub>` run from a compound command so `git add a.js && git push` only
1005
+ // contributes `a.js` to the add pathspec.
1006
+ function extractGitSubcommandSegments(command, subcommand) {
1007
+ const segments = [];
1008
+ const pattern = new RegExp(`\\bgit\\s+${subcommand}\\b`, 'gi');
1009
+ let match;
1010
+ while ((match = pattern.exec(command)) !== null) {
1011
+ const rest = command.slice(match.index + match[0].length);
1012
+ const stop = rest.search(/(?:&&|\|\||[;|\n])/);
1013
+ segments.push(stop === -1 ? rest : rest.slice(0, stop));
1014
+ }
1015
+ return segments;
1016
+ }
1017
+
1018
+ /**
1019
+ * Resolve the explicit pathspec of a git subcommand.
1020
+ *
1021
+ * @returns {{ broad: boolean, paths: string[] }} `broad` means "no usable pathspec — the
1022
+ * command may touch anything", which keeps the previous full-tree-scan behaviour.
1023
+ */
1024
+ function parseGitPathspec(command, subcommand, options = {}) {
1025
+ // `separatorOnly` is for subcommands whose bare arguments are usually flag VALUES rather
1026
+ // than paths (`git commit -m "msg"`), where only tokens after `--` are a real pathspec.
1027
+ const separatorOnly = options.separatorOnly === true;
1028
+ const segments = extractGitSubcommandSegments(command, subcommand);
1029
+ if (!segments.length) return { broad: true, paths: [] };
1030
+
1031
+ const paths = [];
1032
+ for (const segment of segments) {
1033
+ const tokens = tokenizeShellWords(segment);
1034
+ let afterSeparator = false;
1035
+ let sawPath = false;
1036
+ for (const token of tokens) {
1037
+ if (!token) continue;
1038
+ if (token === '--') { afterSeparator = true; continue; }
1039
+ if (separatorOnly && !afterSeparator) continue;
1040
+ if (!afterSeparator && token.startsWith('-')) {
1041
+ if (GIT_BROAD_ADD_FLAGS.has(token)) return { broad: true, paths: [] };
1042
+ // `--pathspec-from-file` / interactive modes read paths we cannot resolve here.
1043
+ if (/^--pathspec-from-file/.test(token) || token === '-i' || token === '--interactive'
1044
+ || token === '-p' || token === '--patch') {
1045
+ return { broad: true, paths: [] };
1046
+ }
1047
+ continue;
1048
+ }
1049
+ // A shell metacharacter or unexpanded glob/variable means the real pathspec is
1050
+ // unknown at gate time — stay conservative rather than under-reporting.
1051
+ if (/[*?$`]|^~/.test(token)) return { broad: true, paths: [] };
1052
+ if (token === '.' || token === './') return { broad: true, paths: [] };
1053
+ // Git pathspec MAGIC (gitglossary(7)): `:(exclude)x`, `:!x`, `:(icase)x`, `:/`, `:(top)`.
1054
+ // These select a materially different set than the literal string — notably an
1055
+ // exclude-only pathspec behaves as if NO pathspec were given, staging everything else.
1056
+ // Treating them literally let them evade task-scope and protected-file checks entirely.
1057
+ if (token.startsWith(':')) return { broad: true, paths: [] };
1058
+ paths.push(token);
1059
+ sawPath = true;
1060
+ }
1061
+ if (!sawPath) return { broad: true, paths: [] };
1062
+ }
1063
+
1064
+ return paths.length ? { broad: false, paths } : { broad: true, paths: [] };
1065
+ }
1066
+
1067
+ // A pathspec is relative to the shell's working directory, NOT the repo root. With
1068
+ // cwd=/repo/src, `git add a.js` stages src/a.js — reporting `a.js` made task-scope and
1069
+ // protected-file gates evaluate the wrong path, so a protected src/a.js edit could pass.
1070
+ // Track a leading `cd` too, since `cd src && git add a.js` is the common shape.
1071
+ // Returns null when the working directory cannot be determined. A `cd` whose target is a
1072
+ // glob or variable makes every later relative pathspec unresolvable — resolving it against
1073
+ // the ORIGINAL directory would silently produce a wrong path, which is exactly the
1074
+ // guess-instead-of-widen mistake that made pathspec magic evade scope checks. Callers treat
1075
+ // null as "unknown" and fall back to broad.
1076
+ function effectiveCommandCwd(command, toolInput) {
1077
+ let cwd = String(toolInput?.cwd || toolInput?.repoPath || process.cwd());
1078
+ const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
1079
+ for (const segment of segments) {
1080
+ // Parsed without a regex: /^cd\s+(?:--\s+)?(.+)$/ has adjacent \s+ groups that backtrack
1081
+ // polynomially on input like `cd\t\t\t…` (js/polynomial-redos). The command comes
1082
+ // straight off the pending tool call, so stalling here stalls the gate.
1083
+ const trimmed = segment.trim();
1084
+ if (!trimmed.startsWith('cd')) break; // only a LEADING cd chain applies
1085
+ const afterCd = trimmed.slice(2);
1086
+ if (afterCd && !/^[ \t]/.test(afterCd)) break; // `cdfoo` is not `cd`
1087
+ let argText = afterCd.trim();
1088
+ if (argText === '--') argText = '';
1089
+ else if (argText.startsWith('--') && /^[ \t]/.test(argText.slice(2))) argText = argText.slice(2).trim();
1090
+ const target = tokenizeShellWords(argText)[0];
1091
+ if (!target) break; // bare `cd` -> home; leave scope resolution alone
1092
+ if (/[*?$`]|^~/.test(target)) return null;
1093
+ cwd = path.resolve(cwd, target);
1094
+ }
1095
+ return cwd;
1096
+ }
1097
+
1098
+ // Keep a tree-derived file only when it falls inside one of the declared pathspecs, so a
1099
+ // directory pathspec (`git add src/`) still reports the files under it and nothing else.
1100
+ function isExistingDirectory(relPath, repoRoot) {
1101
+ if (!repoRoot) return false;
1102
+ try {
1103
+ return fs.statSync(path.join(repoRoot, relPath)).isDirectory();
1104
+ } catch {
1105
+ return false;
1106
+ }
1107
+ }
1108
+
1109
+ function isUnderPathspec(relPath, pathspecs) {
1110
+ return pathspecs.some((spec) => relPath === spec || relPath.startsWith(`${spec}/`));
1111
+ }
1112
+
1113
+ // Linear trailing-slash strip. A regex like /\/+$/ backtracks polynomially on a long run of
1114
+ // slashes (js/polynomial-redos), and the pathspec comes straight off the pending command —
1115
+ // stalling the gate is itself a way to defeat it.
1116
+ function stripTrailingSlashes(value) {
1117
+ let end = value.length;
1118
+ while (end > 0 && value[end - 1] === '/') end -= 1;
1119
+ return value.slice(0, end);
1120
+ }
1121
+
1122
+ function applyPathspecScope(files, treeFiles, pathspec, repoRoot, commandCwd) {
1123
+ if (pathspec.broad) {
1124
+ for (const filePath of treeFiles) files.add(normalizePosix(filePath));
1125
+ return;
1126
+ }
1127
+ const specs = pathspec.paths
1128
+ .map((entry) => (path.isAbsolute(entry) ? entry : path.resolve(commandCwd || repoRoot || '.', entry)))
1129
+ .map((entry) => toRepoRelativePath(entry, repoRoot))
1130
+ .filter(Boolean)
1131
+ .map((entry) => stripTrailingSlashes(normalizePosix(entry)));
1132
+ if (!specs.length) {
1133
+ for (const filePath of treeFiles) files.add(normalizePosix(filePath));
1134
+ return;
1135
+ }
1136
+ const matchedSpecs = new Set();
1137
+ for (const filePath of treeFiles) {
1138
+ const normalized = normalizePosix(filePath);
1139
+ for (const spec of specs) {
1140
+ if (normalized === spec || normalized.startsWith(`${spec}/`)) {
1141
+ files.add(normalized);
1142
+ matchedSpecs.add(spec);
1143
+ }
1144
+ }
1145
+ }
1146
+ // An explicitly named file is in scope even when the tree scan does not list it (e.g. it
1147
+ // is already staged). A directory spec that matched tree files is not itself a file, and
1148
+ // an unmatched directory contributes nothing.
1149
+ for (const spec of matchedSpecs.size === specs.length ? [] : specs) {
1150
+ if (matchedSpecs.has(spec)) continue;
1151
+ if (isExistingDirectory(spec, repoRoot)) continue;
1152
+ files.add(spec);
1153
+ }
1154
+ }
1155
+
1156
+ // Git accepts global options BETWEEN `git` and the subcommand: `git -C <dir> push`,
1157
+ // `git -c k=v clean`, `git --git-dir=<p> reset`. Every command-pattern gate here is written
1158
+ // against the plain `git <subcommand>` form, so inserting one option was enough to walk past
1159
+ // force-push, git-reset-hard, git-clean-force and the local-only gates entirely — and to make
1160
+ // extractAffectedFiles report nothing, which silently disarms the task-scope and
1161
+ // protected-file gates too. Canonicalize the options away so the same command is recognised
1162
+ // however it is spelled. Callers match the ORIGINAL and the canonical form, so this can only
1163
+ // ever add a match, never remove one.
1164
+ const GIT_GLOBAL_OPTION_AFTER_GIT = /\bgit\s+(?:-[cC]\s+\S+|--(?:git-dir|work-tree|namespace|exec-path|super-prefix)(?:=\S+|\s+\S+)|--(?:paginate|no-pager|bare|literal-pathspecs|glob-pathspecs|noglob-pathspecs|icase-pathspecs|no-replace-objects|no-optional-locks)|-[pP])\s+/g;
1165
+
1166
+ function canonicalizeGitCommand(command) {
1167
+ let out = String(command || '');
1168
+ // Bounded: a crafted command with many stacked options must not loop unboundedly.
1169
+ for (let i = 0; i < 12; i += 1) {
1170
+ const next = out.replace(GIT_GLOBAL_OPTION_AFTER_GIT, 'git ');
1171
+ if (next === out) break;
1172
+ out = next;
1173
+ }
1174
+ return out;
1175
+ }
1176
+
1177
+ // The catastrophic gate patterns anchor the command position as `(?:^|[;&|]\s*)`, i.e. the
1178
+ // command must sit at the very start of the string or immediately after ; & |. That anchor
1179
+ // exists to avoid matching a command mentioned inside a quoted string, but it is far too
1180
+ // narrow: it does not recognise a command on a NEW LINE, nor any of the ordinary ways a
1181
+ // binary gets invoked. Each of the following defeated git-reset-hard and git-clean-force on
1182
+ // shipped main — no gate matched at all:
1183
+ //
1184
+ // sudo git reset --hard GIT_DIR=… git reset --hard /usr/bin/git reset --hard
1185
+ // command git reset --hard "git" reset --hard \git reset --hard
1186
+ // echo hi\ngit reset --hard
1187
+ //
1188
+ // Rather than complicate every gate's regex, canonicalize the command POSITION: split on
1189
+ // separators (including newlines), strip env-assignment prefixes, wrapper binaries and any
1190
+ // directory/quoting on the binary token, then rejoin with `; ` so the existing anchor sees a
1191
+ // clean command. Callers match the original AND the canonical form, so this only ever adds.
1192
+ const COMMAND_WRAPPERS = new Set([
1193
+ 'sudo', 'doas', 'command', 'builtin', 'exec', 'nohup', 'time', 'env',
1194
+ 'nice', 'ionice', 'setsid', 'stdbuf', 'xargs',
1195
+ ]);
1196
+ const ENV_ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)\s+/;
1197
+ const WRAPPER_HEAD = /^([A-Za-z_][\w.-]*)\s+/;
1198
+
1199
+ function canonicalizeSegmentHead(segment) {
1200
+ let text = String(segment || '').trim();
1201
+ for (let i = 0; i < 12; i += 1) {
1202
+ const before = text;
1203
+ text = text.replace(ENV_ASSIGNMENT_PREFIX, '');
1204
+ const wrapper = text.match(WRAPPER_HEAD);
1205
+ if (wrapper && COMMAND_WRAPPERS.has(wrapper[1].toLowerCase())) {
1206
+ text = text.slice(wrapper[0].length);
1207
+ }
1208
+ if (text === before) break;
1209
+ }
1210
+ // Unwrap quoting/escaping on the binary token: "git" / 'git' / \git
1211
+ text = text.replace(/^\\(?=[A-Za-z_])/, '');
1212
+ text = text.replace(/^(['"])([^'"\s]+)\1/, '$2');
1213
+ // Drop a leading directory on the binary token: /usr/bin/git, ./bin/git, ../git
1214
+ text = text.replace(/^((?:\.{1,2})?(?:\/[^\s/]+)*\/)([^\s/]+)/, '$2');
1215
+ return text;
1216
+ }
1217
+
1218
+ function canonicalizeCommandPositions(command) {
1219
+ const text = String(command || '');
1220
+ if (!text) return '';
1221
+ return text
1222
+ .split(/\r?\n|&&|\|\||[;|&]/)
1223
+ .map((segment) => canonicalizeSegmentHead(segment))
1224
+ .filter((segment) => segment.length > 0)
1225
+ .join('; ');
1226
+ }
1227
+
1228
+ // Full canonical form used for gate matching.
1229
+ function canonicalizeCommandForGates(command) {
1230
+ return canonicalizeGitCommand(canonicalizeCommandPositions(command));
1231
+ }
1232
+
1233
+ // Some gates anchor with a BARE `^` (local-only-git-writes, task-scope-required,
1234
+ // branch-governance-required, release-readiness-required) rather than the
1235
+ // `(?:^|[;&|]\s*)` form. A bare `^` only ever matches the FIRST command in the string, so
1236
+ // `echo hi && git commit -m x` slipped past while `git commit -m x` was denied — and
1237
+ // chaining is how agents normally work. Offer each canonicalized SEGMENT as its own
1238
+ // candidate so a `^` anchor sees every command in the chain, not just the head.
1239
+ //
1240
+ // This stays additive: unanchored patterns already match anywhere, so per-segment testing
1241
+ // adds nothing for them, and a `^` pattern matching a later segment is exactly the gate's
1242
+ // intent. A command merely quoted inside another (`echo "git commit"`) is unaffected,
1243
+ // because the segment head is still `echo`.
1244
+ function gateMatchCandidates(matchText) {
1245
+ const canonical = canonicalizeCommandForGates(matchText);
1246
+ const candidates = [matchText];
1247
+ if (canonical && canonical !== matchText) candidates.push(canonical);
1248
+ for (const segment of canonical.split('; ')) {
1249
+ const trimmed = segment.trim();
1250
+ if (trimmed && trimmed !== canonical) candidates.push(trimmed);
1251
+ }
1252
+ return candidates;
1253
+ }
1254
+
1255
+ function patternMatchesCommand(regex, matchText) {
1256
+ return gateMatchCandidates(matchText).some((candidate) => regex.test(candidate));
1257
+ }
1258
+
932
1259
  function extractAffectedFiles(toolName, toolInput = {}) {
933
1260
  const repoRoot = resolveRepoRoot(toolInput);
934
1261
  const files = new Set(collectInlineAffectedFiles(toolInput, repoRoot));
935
- const command = String(toolInput.command || '');
1262
+ // Full canonicalization, not just the git-option pass: `"git" add .` and `sudo git add .`
1263
+ // otherwise fail the `\bgit\s+add\b` probes below and yield ZERO affected files, which in
1264
+ // turn makes the scope gates (task-scope-required, protected-file-approval-required) find
1265
+ // no violation and fall through — the file-list half of the same bypass.
1266
+ const command = canonicalizeCommandForGates(String(toolInput.command || ''));
1267
+
1268
+ const commandCwd = effectiveCommandCwd(command, toolInput);
1269
+ // An unresolvable `cd` makes every relative pathspec meaningless; widen rather than guess.
1270
+ const cwdUnknown = commandCwd === null;
936
1271
 
937
1272
  if (toolName === 'Bash' && repoRoot && command) {
938
1273
  if (/\bgit\s+commit\b/i.test(command)) {
939
- for (const filePath of safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot)) {
940
- files.add(normalizePosix(filePath));
941
- }
1274
+ // For commit only an explicit `-- <pathspec>` narrows the staged set; bare arguments
1275
+ // after `git commit` are almost always flag values (`-m "msg"`), so anything else
1276
+ // keeps the staged-diff behaviour.
1277
+ const commitSpec = /\bgit\s+commit\b[^;|&\n]*?\s--\s/i.test(command)
1278
+ ? parseGitPathspec(command, 'commit', { separatorOnly: true })
1279
+ : { broad: true, paths: [] };
1280
+ // `git commit -- <pathspec>` commits tracked files straight from the WORKING TREE, not
1281
+ // only what is staged. Filtering the cached diff alone dropped exactly those files, so
1282
+ // scope and protected-file gates missed changes the commit really carries.
1283
+ const candidates = commitSpec.broad
1284
+ ? safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot)
1285
+ : [
1286
+ ...safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot),
1287
+ ...safeExecFileLines('git', ['diff', '--name-only'], repoRoot),
1288
+ ];
1289
+ applyPathspecScope(files, candidates, cwdUnknown ? { broad: true, paths: [] } : commitSpec, repoRoot, commandCwd);
942
1290
  }
943
1291
 
944
1292
  if (/\bgit\s+add\b/i.test(command)) {
945
- for (const filePath of safeExecFileLines('git', ['diff', '--name-only'], repoRoot)) {
946
- files.add(normalizePosix(filePath));
947
- }
948
- for (const filePath of safeExecFileLines('git', ['ls-files', '--others', '--exclude-standard'], repoRoot)) {
949
- files.add(normalizePosix(filePath));
950
- }
1293
+ const treeFiles = [
1294
+ ...safeExecFileLines('git', ['diff', '--name-only'], repoRoot),
1295
+ ...safeExecFileLines('git', ['ls-files', '--others', '--exclude-standard'], repoRoot),
1296
+ ];
1297
+ const addSpec = cwdUnknown ? { broad: true, paths: [] } : parseGitPathspec(command, 'add');
1298
+ applyPathspecScope(files, treeFiles, addSpec, repoRoot, commandCwd);
951
1299
  }
952
1300
 
953
1301
  if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
@@ -1140,12 +1488,113 @@ function hasPrBranchContext(toolInput = {}, repoRoot = null) {
1140
1488
  return Boolean(branchName && !isProtectedBranchName(branchName));
1141
1489
  }
1142
1490
 
1143
- function registerPrThreadResolutionClaimGate(toolName, toolInput = {}) {
1491
+ function hasExplicitPrReference(toolInput = {}) {
1492
+ return Boolean(
1493
+ toolInput.prNumber || toolInput.prUrl || toolInput.pullRequestNumber || toolInput.pullRequestUrl
1494
+ );
1495
+ }
1496
+
1497
+ const GH_BINARY_FIXED_PATH_DIRS = ['/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin'];
1498
+
1499
+ // SonarCloud flags a bare `execFileSync('gh', ...)` as a PATH-injection hotspot
1500
+ // (same reasoning as scripts/ci-cd-hygiene-audit.js resolveGhBinary). Resolve
1501
+ // gh's absolute path from a fixed directory list — and ONLY that list. Never
1502
+ // fall back to a PATH-resolved bare name: a workspace-controlled program
1503
+ // earlier on $PATH would otherwise execute with this hook's privileges during
1504
+ // a commit-time check, and could return crafted output to fake "merged" and
1505
+ // bypass thread-resolution enforcement entirely (found by review on PR #3027).
1506
+ function resolveGhBinaryForPrCheck() {
1507
+ for (const dir of GH_BINARY_FIXED_PATH_DIRS) {
1508
+ const candidate = path.join(dir, 'gh');
1509
+ try {
1510
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return candidate;
1511
+ } catch {
1512
+ // keep searching
1513
+ }
1514
+ }
1515
+ return null;
1516
+ }
1517
+
1518
+ // 2026-07-24 self-lockout (issue #3025): a commit landing on a branch whose PR
1519
+ // was already merged armed this gate forever — the premise ("verify PR
1520
+ // threads") was checked purely by branch name, never against real GitHub PR
1521
+ // state, so there was nothing left to satisfy it once the PR closed. This
1522
+ // checks whether the branch's PR (if any) is already dormant — merged,
1523
+ // closed, or nonexistent — in which case there is nothing to verify. Returns
1524
+ // `{ dormant: true, reason }` when safe to skip the gate, `{ dormant: false }`
1525
+ // when a real open PR exists (keep gating), or `null` when the check could
1526
+ // not be completed (gh unavailable/unauthenticated/timed out) — callers must
1527
+ // treat `null` as "cannot verify" and fall back to arming the gate as before,
1528
+ // never as license to relax enforcement.
1529
+ //
1530
+ // Deliberately does NOT trust local git config (e.g. a missing
1531
+ // `branch.<name>.remote`) as a signal that no PR exists: local config can be
1532
+ // wrong or stale relative to GitHub (unset upstream, push under a different
1533
+ // ref, etc.) while a real open PR with real unresolved threads still exists
1534
+ // (found by review on PR #3027) — the live `gh` check is the only source of
1535
+ // truth here.
1536
+ function checkPrDormantForBranch(branchName, repoRoot, execFn = execFileSync) {
1537
+ if (!branchName || !repoRoot) return null;
1538
+
1539
+ const ghBinary = resolveGhBinaryForPrCheck();
1540
+ if (!ghBinary) return null; // gh not found in a trusted location — cannot verify
1541
+
1542
+ let raw;
1543
+ try {
1544
+ raw = execFn(ghBinary, ['pr', 'view', branchName, '--json', 'number,state'], {
1545
+ cwd: repoRoot,
1546
+ encoding: 'utf8',
1547
+ stdio: ['ignore', 'pipe', 'pipe'],
1548
+ timeout: 5000,
1549
+ });
1550
+ } catch (error) {
1551
+ const stderr = String((error && error.stderr) || (error && error.message) || '');
1552
+ if (/no pull requests found/i.test(stderr)) {
1553
+ return { dormant: true, reason: 'no-pr-for-branch' };
1554
+ }
1555
+ return null; // gh unauthenticated/network error/timed out — cannot verify
1556
+ }
1557
+
1558
+ let pr;
1559
+ try {
1560
+ pr = JSON.parse(raw);
1561
+ } catch {
1562
+ return null;
1563
+ }
1564
+
1565
+ if (pr && pr.state === 'MERGED') return { dormant: true, reason: 'pr-merged', prNumber: pr.number };
1566
+ if (pr && pr.state === 'CLOSED') return { dormant: true, reason: 'pr-closed', prNumber: pr.number };
1567
+ return { dormant: false, reason: 'pr-open', prNumber: pr && pr.number };
1568
+ }
1569
+
1570
+ function registerPrThreadResolutionClaimGate(toolName, toolInput = {}, execOverride) {
1144
1571
  if (!isGitCommitCommand(toolName, toolInput)) return null;
1145
1572
  const repoRoot = resolveRepoRoot(toolInput);
1146
1573
  if (!hasPrBranchContext(toolInput, repoRoot)) return null;
1147
1574
 
1148
1575
  const branchName = detectBranchName(toolInput, repoRoot);
1576
+
1577
+ // Only auto-detect dormant PRs when the branch name is the sole signal. An
1578
+ // explicit prNumber/prUrl is a direct claim from the caller that a specific
1579
+ // PR exists and matters here — trust it rather than re-deriving a possibly
1580
+ // different answer from the branch name.
1581
+ if (!hasExplicitPrReference(toolInput)) {
1582
+ const prState = checkPrDormantForBranch(branchName, repoRoot, execOverride || execFileSync);
1583
+ if (prState && prState.dormant) {
1584
+ // A dormant PR (merged/closed/nonexistent) means there is nothing to
1585
+ // verify for THIS commit — simply don't arm the gate. Deliberately does
1586
+ // NOT write to the shared pr_threads_checked/thread_resolution_verified
1587
+ // condition store: those keys are global, not scoped to a branch, so
1588
+ // writing to them here would leak satisfaction into a DIFFERENT
1589
+ // branch's pending gate if a commit on that branch arms it within the
1590
+ // same 5-minute TTL window — an agent could otherwise pre-satisfy the
1591
+ // gate by committing on an abandoned merged-PR branch first, then
1592
+ // switch to an active PR branch and skip real thread verification
1593
+ // (found by review on PR #3027).
1594
+ return null;
1595
+ }
1596
+ }
1597
+
1149
1598
  const claimGate = registerClaimGate(
1150
1599
  PR_THREAD_RESOLUTION_CLAIM_PATTERN,
1151
1600
  PR_THREAD_RESOLUTION_REQUIRED_ACTIONS,
@@ -1211,9 +1660,36 @@ function isReadOnlyObservabilityTool(toolName) {
1211
1660
  }
1212
1661
 
1213
1662
  function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
1214
- if (!hasAction(PR_THREAD_RESOLUTION_ACTION)) return null;
1663
+ const pendingEntry = loadSessionActions()[PR_THREAD_RESOLUTION_ACTION];
1664
+ if (!pendingEntry) return null;
1665
+
1666
+ // Scope to the repo that actually committed. Session-actions state lives in a
1667
+ // single global file (~/.thumbgate/session-actions.json), shared by every repo
1668
+ // on the machine. Without this check, a commit in repo A permanently locks out
1669
+ // every tool call in an unrelated repo B's session until the 1-hour TTL expires
1670
+ // (verified 2026-07-24: a commit in one repo/worktree blocked every Bash/Read/
1671
+ // Skill/ToolSearch call in a completely unrelated repo's session).
1672
+ const trackedRepoRoot = pendingEntry.metadata && pendingEntry.metadata.repoRoot;
1673
+ if (trackedRepoRoot) {
1674
+ const currentRepoRoot = resolveRepoRoot(toolInput);
1675
+ if (currentRepoRoot && currentRepoRoot !== trackedRepoRoot) return null;
1676
+ }
1677
+
1215
1678
  if (isThreadResolutionSatisfied()) return null;
1216
1679
  if (isReadOnlyObservabilityTool(toolName)) return null;
1680
+ // Evidence actions (gh pr view/checks/status, gh api .../reviewThreads, git
1681
+ // status/diff/show, the satisfy_gate/track_action tools themselves) are exempt
1682
+ // from being blocked so an agent can actually gather evidence and call
1683
+ // satisfy_gate — but running them must NOT itself satisfy the gate. This is a
1684
+ // PreToolUse hook: it fires before the command executes, so at this point the
1685
+ // command hasn't run, could still fail, or could return UNFAVORABLE evidence
1686
+ // (N unresolved threads). Auto-satisfying on the mere shape of the request —
1687
+ // as an earlier version of this fix did — let `git status` (which proves
1688
+ // nothing about thread resolution) or a `gh pr view` that later errors clear
1689
+ // a critical gate before any real verification happened (caught in review,
1690
+ // PR #3030). The only sound way to clear this gate is the explicit,
1691
+ // agent-asserted `satisfy_gate` tool call, which records real evidence via
1692
+ // satisfyCondition() — never inferred from a pre-execution command guess.
1217
1693
  if (isThreadResolutionEvidenceAction(toolName, toolInput)) return null;
1218
1694
 
1219
1695
  const message = 'A git commit was made on a PR branch. Verify review threads are resolved before the next tool call.';
@@ -1224,7 +1700,7 @@ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
1224
1700
  severity: 'critical',
1225
1701
  reasoning: [
1226
1702
  `Tracked action ${PR_THREAD_RESOLUTION_ACTION} is pending`,
1227
- 'Satisfy pr_threads_checked or thread_resolution_verified with evidence before continuing',
1703
+ 'Check review threads (e.g. gh pr view --json reviewThreads), then call the satisfy_gate tool with gateId="pr_threads_checked" and the evidence running a check command alone does not clear this gate',
1228
1704
  ],
1229
1705
  };
1230
1706
  }
@@ -1487,7 +1963,11 @@ function buildProtectedApprovalViolation(protectedGlobs, approvals, affectedFile
1487
1963
  }
1488
1964
 
1489
1965
  function buildBranchGovernanceViolation(governanceState, toolInput = {}, affectedFiles = [], repoRoot = null, requireReleaseReadiness = false) {
1490
- const command = String(toolInput.command || '').trim();
1966
+ // Canonicalized: this helper runs its OWN command analysis downstream of the gate's
1967
+ // pattern test, so passing the raw text let `"npm" publish` / `sudo gh release create`
1968
+ // through even once the pattern matched. Canonicalizing here keeps that second analysis
1969
+ // consistent with the first.
1970
+ const command = canonicalizeCommandForGates(String(toolInput.command || '').trim());
1491
1971
  if (!command) return null;
1492
1972
 
1493
1973
  const integrity = evaluateOperationalIntegrity({
@@ -1741,7 +2221,11 @@ function matchGate(gate, toolName, toolInput = {}) {
1741
2221
  if (gate.pattern) {
1742
2222
  try {
1743
2223
  const regex = new RegExp(gate.pattern);
1744
- if (!regex.test(matchText)) return { matched: false, matchText, affectedFiles };
2224
+ // Match the original text or its git-canonical form, so `git -C <dir> push --force`
2225
+ // is caught by the same pattern as `git push --force`.
2226
+ if (!patternMatchesCommand(regex, matchText)) {
2227
+ return { matched: false, matchText, affectedFiles };
2228
+ }
1745
2229
  if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
1746
2230
  return { matched: false, matchText, affectedFiles };
1747
2231
  }
@@ -1855,7 +2339,8 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
1855
2339
  if (!Array.isArray(gate.toolNames) || !gate.toolNames.includes(toolName)) return null;
1856
2340
  if (!matchText || !gate.pattern) return null;
1857
2341
  try {
1858
- if (!new RegExp(gate.pattern).test(matchText)) return null;
2342
+ const regex = new RegExp(gate.pattern);
2343
+ if (!patternMatchesCommand(regex, matchText)) return null;
1859
2344
  } catch {
1860
2345
  return null;
1861
2346
  }
@@ -1989,12 +2474,18 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1989
2474
  return null;
1990
2475
  }
1991
2476
 
2477
+ // The memory guard keyword-matches against this string. The false positives that motivated
2478
+ // a cap here came from the JSON envelope's own KEY names polluting the haystack, which is
2479
+ // fixed at the matcher (buildMatchHaystack). Truncating the file list instead silently
2480
+ // dropped action targets: for a broad action, a guard whose keywords appear only in a later
2481
+ // filename could no longer match, so a learned prevention rule was bypassable purely by
2482
+ // filename ordering. Keep every target and bound the SIZE instead.
1992
2483
  const serializedInput = JSON.stringify({
1993
2484
  toolName,
1994
2485
  command: toolInput.command || null,
1995
2486
  filePath: toolInput.file_path || toolInput.path || null,
1996
2487
  affectedFiles,
1997
- });
2488
+ }).slice(0, MEMORY_GUARD_MAX_SERIALIZED_CHARS);
1998
2489
  // Claw/hybrid support: pass context if agent provides claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
1999
2490
  let guard;
2000
2491
  if (toolInput && (toolInput.clawContext || toolInput._claw || toolInput.hybridRoute || toolInput.agentId)) {
@@ -2133,7 +2624,7 @@ function isAutonomousRun() {
2133
2624
  return raw === '1' || raw === 'true';
2134
2625
  }
2135
2626
 
2136
- async function evaluateGatesAsync(toolName, toolInput, configPath) {
2627
+ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2137
2628
  let config;
2138
2629
  try {
2139
2630
  let harnessPath;
@@ -2372,7 +2863,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
2372
2863
  return null;
2373
2864
  }
2374
2865
 
2375
- function evaluateGates(toolName, toolInput, configPath) {
2866
+ function evaluateGatesInner(toolName, toolInput, configPath) {
2376
2867
  let config;
2377
2868
  try {
2378
2869
  let harnessPath;
@@ -3172,6 +3663,42 @@ function run(input) {
3172
3663
 
3173
3664
  }
3174
3665
 
3666
+
3667
+ // A command can be spelled many ways without changing what it does: `sudo git …`,
3668
+ // `"git" …`, `/usr/bin/git …`, `GIT_DIR=… git …`, a chained `echo hi && git …`, or a git
3669
+ // global option before the subcommand. Roughly fifteen helpers below read
3670
+ // `toolInput.command` and run their own analysis on it, so patching each one individually
3671
+ // is how a spelling gets missed — that happened twice while fixing this.
3672
+ //
3673
+ // Instead: evaluate normally, and ONLY IF nothing matched, evaluate once more against the
3674
+ // canonicalized command. Every helper is covered without touching any of them, and it is
3675
+ // strictly additive — a command that already matched never reaches the second pass, so no
3676
+ // existing verdict changes and no stat is recorded twice.
3677
+ function canonicalRetryInput(toolName, toolInput) {
3678
+ if (toolName !== 'Bash') return null;
3679
+ const original = String((toolInput && toolInput.command) || '');
3680
+ if (!original) return null;
3681
+ const canonical = canonicalizeCommandForGates(original);
3682
+ if (!canonical || canonical === original) return null;
3683
+ return { ...toolInput, command: canonical, originalCommand: original };
3684
+ }
3685
+
3686
+ async function evaluateGatesAsync(toolName, toolInput, configPath) {
3687
+ const direct = await evaluateGatesAsyncInner(toolName, toolInput, configPath);
3688
+ if (direct) return direct;
3689
+ const retry = canonicalRetryInput(toolName, toolInput);
3690
+ if (!retry) return null;
3691
+ return evaluateGatesAsyncInner(toolName, retry, configPath);
3692
+ }
3693
+
3694
+ function evaluateGates(toolName, toolInput, configPath) {
3695
+ const direct = evaluateGatesInner(toolName, toolInput, configPath);
3696
+ if (direct) return direct;
3697
+ const retry = canonicalRetryInput(toolName, toolInput);
3698
+ if (!retry) return null;
3699
+ return evaluateGatesInner(toolName, retry, configPath);
3700
+ }
3701
+
3175
3702
  // ---------------------------------------------------------------------------
3176
3703
  // Session action tracking and claim verification
3177
3704
  // ---------------------------------------------------------------------------
@@ -3458,6 +3985,12 @@ module.exports = {
3458
3985
  matchesGate,
3459
3986
  evaluateGates,
3460
3987
  evaluateGatesAsync,
3988
+ extractAffectedFiles,
3989
+ parseGitPathspec,
3990
+ canonicalizeGitCommand,
3991
+ canonicalizeCommandForGates,
3992
+ canonicalizeCommandPositions,
3993
+ patternMatchesCommand,
3461
3994
  isAutonomousRun,
3462
3995
  computeExecutableHash,
3463
3996
  formatOutput,
@@ -3501,6 +4034,8 @@ module.exports = {
3501
4034
  evaluateBoostedRiskTagGuard,
3502
4035
  registerPrThreadResolutionClaimGate,
3503
4036
  evaluatePendingPrThreadResolutionGate,
4037
+ checkPrDormantForBranch,
4038
+ resolveGhBinaryForPrCheck,
3504
4039
  isReadOnlyObservabilityTool,
3505
4040
  getLocalOnlyScopeSources,
3506
4041
  isRemoteSideEffectCommand,