thumbgate 1.29.1 → 1.30.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.
Files changed (77) 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/mcp/server-card.json +1 -1
  5. package/README.md +61 -1
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/forge/forge.yaml +3 -3
  8. package/adapters/mcp/server-stdio.js +164 -7
  9. package/adapters/opencode/opencode.json +1 -1
  10. package/bin/cli.js +7 -5
  11. package/commands/dashboard.md +11 -1
  12. package/commands/thumbgate-dashboard.md +23 -8
  13. package/config/agent-outcome-monitor-thresholds.json +63 -0
  14. package/config/evals/agent-outcomes-baseline.json +17 -0
  15. package/config/evals/agent-outcomes-golden.json +412 -0
  16. package/config/evals/prompt-eval-baseline.json +23 -0
  17. package/config/mcp-allowlists.json +26 -2
  18. package/config/post-deploy-marketing-pages.json +26 -1
  19. package/config/schemas/task-outcome-receipt.schema.json +296 -0
  20. package/openapi/openapi.yaml +235 -0
  21. package/package.json +55 -11
  22. package/public/architecture.html +130 -0
  23. package/public/assets/diagrams/agent-integration.png +0 -0
  24. package/public/assets/diagrams/before-after.svg +21 -0
  25. package/public/assets/diagrams/decision.svg +36 -0
  26. package/public/assets/diagrams/feedback-pipeline.png +0 -0
  27. package/public/assets/diagrams/loop.svg +34 -0
  28. package/public/assets/diagrams/plugin-topology.png +0 -0
  29. package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
  30. package/public/assets/diagrams/stack.svg +18 -0
  31. package/public/assets/diagrams/thumbgate-architecture.png +0 -0
  32. package/public/case-studies.html +151 -0
  33. package/public/eval-scorecard.html +195 -0
  34. package/public/eval-scorecard.json +18 -0
  35. package/public/evaluations.html +168 -0
  36. package/public/index.html +6 -3
  37. package/public/numbers.html +2 -2
  38. package/public/whitepaper.html +189 -0
  39. package/scripts/activation-quickstart.js +1 -0
  40. package/scripts/agent-outcome-eval.js +130 -0
  41. package/scripts/agent-outcome-monitor.js +331 -0
  42. package/scripts/agent-reasoning-traces.js +8 -9
  43. package/scripts/async-job-runner.js +107 -13
  44. package/scripts/billing.js +3 -1
  45. package/scripts/claude-feedback-sync.js +3 -2
  46. package/scripts/cli-feedback.js +13 -7
  47. package/scripts/cross-encoder-reranker.js +3 -0
  48. package/scripts/durability/step.js +121 -12
  49. package/scripts/feedback-aggregate.js +5 -2
  50. package/scripts/feedback-loop.js +244 -182
  51. package/scripts/gates-engine.js +512 -22
  52. package/scripts/generate-case-study-outreach.js +253 -0
  53. package/scripts/generate-eval-scorecard.js +276 -0
  54. package/scripts/growth-campaigns.js +183 -0
  55. package/scripts/human-escalation.js +265 -0
  56. package/scripts/hybrid-feedback-context.js +93 -50
  57. package/scripts/jsonl-watcher.js +1 -0
  58. package/scripts/judge-reward-function.js +30 -18
  59. package/scripts/lesson-inference.js +23 -4
  60. package/scripts/lesson-retrieval.js +71 -4
  61. package/scripts/lesson-search.js +26 -3
  62. package/scripts/mcp-config.js +26 -5
  63. package/scripts/mcp-oauth.js +37 -2
  64. package/scripts/model-eval.js +308 -0
  65. package/scripts/parallel-workflow-orchestrator.js +86 -22
  66. package/scripts/prompt-eval.js +81 -4
  67. package/scripts/published-cli.js +11 -1
  68. package/scripts/refresh-proof-pack.js +261 -0
  69. package/scripts/risk-scorer.js +144 -15
  70. package/scripts/schedule-manager.js +249 -0
  71. package/scripts/statusline-local-stats.js +1 -1
  72. package/scripts/task-outcomes.js +425 -0
  73. package/scripts/thumbgate-bench.js +13 -0
  74. package/scripts/tool-contract-validator.js +287 -59
  75. package/scripts/tool-kpi-tracker.js +124 -0
  76. package/scripts/tool-registry.js +192 -1
  77. package/src/api/server.js +355 -89
@@ -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;
@@ -143,10 +146,18 @@ const SELF_PROTECT_HARD_FLOOR_GATE_IDS = new Set([
143
146
  'self-protect-env-override',
144
147
  'self-protect-hooks-disable',
145
148
  ]);
149
+ // An expired task-scope lease gets its OWN gate id so it can be exempted from the two downgrade
150
+ // paths without touching ordinary task-scope denials. Without this the fail-closed guarantee is
151
+ // cosmetic: applyEnforcementPosture turns denials into warnings by default, and applyDailyBlockCap
152
+ // does the same for capped free-tier users — so an edit under a lapsed lease would execute anyway.
153
+ // A lease that stops binding when you are busy or over quota is not a lease.
154
+ const TASK_SCOPE_LEASE_EXPIRED_GATE_ID = 'task-scope-lease-expired';
155
+
146
156
  const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
147
157
  'secret-exfiltration',
148
158
  'security-vuln-scan',
149
159
  'slopsquat-guard',
160
+ TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
150
161
  ...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
151
162
  ]);
152
163
  // Issue #2782 (reported by Andy Martin, 2026-07-08): after the free-tier daily
@@ -158,6 +169,7 @@ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
158
169
  // map directly to CLAUDE.md's own hard-block list and must never be subject
159
170
  // to the daily cap discount, regardless of tier or strict-mode setting.
160
171
  const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
172
+ TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
161
173
  'force-push',
162
174
  'git-reset-hard',
163
175
  'git-clean-force',
@@ -422,6 +434,25 @@ function clampTtlMs(value, fallbackMs) {
422
434
  return Math.min(Math.max(numeric, 60 * 1000), 24 * 60 * 60 * 1000);
423
435
  }
424
436
 
437
+ // Default lease when a caller asks for one without saying how long. clampTtlMs floors at 60s.
438
+ const TASK_SCOPE_LEASE_MS = 15 * 60 * 1000;
439
+
440
+ /**
441
+ * A task scope with no `expiresAt` is permanent — that is the historical contract and every
442
+ * existing scope on disk has it. Only a scope that explicitly took a lease can expire.
443
+ */
444
+ function isTaskScopeExpired(taskScope, nowMs = Date.now()) {
445
+ if (!taskScope || typeof taskScope !== 'object') return false;
446
+ // `expiresAt: null` means permanent, and it MUST be checked before the numeric coercion:
447
+ // Number(null) is 0, not NaN, so a null deadline would otherwise read as "expired in 1970".
448
+ // Combined with fail-closed enforcement that would revoke authority from every permanent
449
+ // scope the moment this shipped. Caught by tests/task-scope-lease.test.js.
450
+ if (taskScope.expiresAt == null) return false;
451
+ const deadline = Number(taskScope.expiresAt);
452
+ if (!Number.isFinite(deadline)) return false;
453
+ return nowMs >= deadline;
454
+ }
455
+
425
456
  function loadGovernanceState() {
426
457
  const raw = loadJSON(module.exports.GOVERNANCE_STATE_PATH);
427
458
  const state = {
@@ -435,6 +466,12 @@ function loadGovernanceState() {
435
466
  : null,
436
467
  };
437
468
  const now = Date.now();
469
+ // Annotate rather than delete. A vanished scope is indistinguishable from one never set, and
470
+ // the difference matters: "your lease lapsed, renew it" is a different instruction from
471
+ // "you never declared a scope".
472
+ if (state.taskScope) {
473
+ state.taskScope = { ...state.taskScope, expired: isTaskScopeExpired(state.taskScope, now) };
474
+ }
438
475
  const activeApprovals = state.protectedApprovals.filter((entry) => {
439
476
  if (!entry || typeof entry !== 'object') return false;
440
477
  if (!entry.timestamp || !entry.expiresAt) return false;
@@ -482,6 +519,11 @@ function setTaskScope(scopeInput = {}) {
482
519
  ? scopeInput.protectedPaths
483
520
  : DEFAULT_PROTECTED_FILE_GLOBS
484
521
  ), repoPath);
522
+ // Optional LEASE. Without ttlMs the scope is permanent, which is the historical behaviour
523
+ // and stays byte-identical. With ttlMs the scope becomes time-bounded authority: "write under
524
+ // ./src for 90 seconds" rather than a standing approval that never says when it stops.
525
+ const scopeNow = Date.now();
526
+ const leaseMs = scopeInput.ttlMs == null ? null : clampTtlMs(scopeInput.ttlMs, TASK_SCOPE_LEASE_MS);
485
527
  const taskScope = {
486
528
  taskId: String(scopeInput.taskId || '').trim() || null,
487
529
  summary: String(scopeInput.summary || '').trim() || null,
@@ -490,7 +532,9 @@ function setTaskScope(scopeInput = {}) {
490
532
  localOnly: scopeInput.localOnly === true,
491
533
  repoPath,
492
534
  createdAt: new Date().toISOString(),
493
- timestamp: Date.now(),
535
+ timestamp: scopeNow,
536
+ leaseMs,
537
+ expiresAt: leaseMs == null ? null : scopeNow + leaseMs,
494
538
  };
495
539
  const state = loadGovernanceState();
496
540
  state.taskScope = taskScope;
@@ -948,25 +992,351 @@ function getBranchDiffFiles(repoRoot) {
948
992
  return safeExecFileLines('git', ['diff', '--name-only'], repoRoot);
949
993
  }
950
994
 
995
+ // `git add`/`git commit` accept an explicit pathspec, and when one is present it — not the
996
+ // working tree — defines what the command actually touches. Scanning the whole tree here
997
+ // reported every dirty file as "affected", so in a repo with a large dirty tree (e.g. one
998
+ // shared by several agents) a correctly scoped `git add -- a.js b.js` was reported as
999
+ // thousands of affected files and tripped task-scope / protected-file gates that the command
1000
+ // never actually violated. Only fall back to a full tree scan when the command really does
1001
+ // stage broadly (`git add .`, `-A`, `-u`, or no pathspec at all).
1002
+ const GIT_BROAD_ADD_FLAGS = new Set(['-A', '--all', '-u', '--update', '--no-ignore-removal', '--ignore-removal']);
1003
+
1004
+ // Minimal shell-word splitter: honours single/double quotes so a quoted path with spaces
1005
+ // stays one token. Deliberately does not expand variables or globs — an unresolvable token
1006
+ // is treated as "broad" by the callers below rather than guessed at.
1007
+ function tokenizeShellWords(segment) {
1008
+ const tokens = [];
1009
+ let current = '';
1010
+ let quote = null;
1011
+ let hasContent = false;
1012
+ for (let i = 0; i < segment.length; i++) {
1013
+ const char = segment[i];
1014
+ // A backslash escapes the next character outside single quotes. Without this,
1015
+ // `git add my\ dir/file.js` split at the escaped space into two fictional paths
1016
+ // and the gates evaluated files git never touches.
1017
+ if (char === '\\' && quote !== "'" && i + 1 < segment.length) {
1018
+ current += segment[i + 1];
1019
+ hasContent = true;
1020
+ i += 1;
1021
+ continue;
1022
+ }
1023
+ if (quote) {
1024
+ if (char === quote) quote = null;
1025
+ else current += char;
1026
+ continue;
1027
+ }
1028
+ if (char === '"' || char === "'") {
1029
+ quote = char;
1030
+ hasContent = true;
1031
+ continue;
1032
+ }
1033
+ if (/\s/.test(char)) {
1034
+ if (hasContent || current) tokens.push(current);
1035
+ current = '';
1036
+ hasContent = false;
1037
+ continue;
1038
+ }
1039
+ current += char;
1040
+ }
1041
+ if (hasContent || current) tokens.push(current);
1042
+ return tokens;
1043
+ }
1044
+
1045
+ // Isolate the `git <sub>` run from a compound command so `git add a.js && git push` only
1046
+ // contributes `a.js` to the add pathspec.
1047
+ function extractGitSubcommandSegments(command, subcommand) {
1048
+ const segments = [];
1049
+ const pattern = new RegExp(`\\bgit\\s+${subcommand}\\b`, 'gi');
1050
+ let match;
1051
+ while ((match = pattern.exec(command)) !== null) {
1052
+ const rest = command.slice(match.index + match[0].length);
1053
+ const stop = rest.search(/(?:&&|\|\||[;|\n])/);
1054
+ segments.push(stop === -1 ? rest : rest.slice(0, stop));
1055
+ }
1056
+ return segments;
1057
+ }
1058
+
1059
+ /**
1060
+ * Resolve the explicit pathspec of a git subcommand.
1061
+ *
1062
+ * @returns {{ broad: boolean, paths: string[] }} `broad` means "no usable pathspec — the
1063
+ * command may touch anything", which keeps the previous full-tree-scan behaviour.
1064
+ */
1065
+ function parseGitPathspec(command, subcommand, options = {}) {
1066
+ // `separatorOnly` is for subcommands whose bare arguments are usually flag VALUES rather
1067
+ // than paths (`git commit -m "msg"`), where only tokens after `--` are a real pathspec.
1068
+ const separatorOnly = options.separatorOnly === true;
1069
+ const segments = extractGitSubcommandSegments(command, subcommand);
1070
+ if (!segments.length) return { broad: true, paths: [] };
1071
+
1072
+ const paths = [];
1073
+ for (const segment of segments) {
1074
+ const tokens = tokenizeShellWords(segment);
1075
+ let afterSeparator = false;
1076
+ let sawPath = false;
1077
+ for (const token of tokens) {
1078
+ if (!token) continue;
1079
+ if (token === '--') { afterSeparator = true; continue; }
1080
+ if (separatorOnly && !afterSeparator) continue;
1081
+ if (!afterSeparator && token.startsWith('-')) {
1082
+ if (GIT_BROAD_ADD_FLAGS.has(token)) return { broad: true, paths: [] };
1083
+ // `--pathspec-from-file` / interactive modes read paths we cannot resolve here.
1084
+ if (/^--pathspec-from-file/.test(token) || token === '-i' || token === '--interactive'
1085
+ || token === '-p' || token === '--patch') {
1086
+ return { broad: true, paths: [] };
1087
+ }
1088
+ continue;
1089
+ }
1090
+ // A shell metacharacter or unexpanded glob/variable means the real pathspec is
1091
+ // unknown at gate time — stay conservative rather than under-reporting.
1092
+ if (/[*?$`]|^~/.test(token)) return { broad: true, paths: [] };
1093
+ if (token === '.' || token === './') return { broad: true, paths: [] };
1094
+ // Git pathspec MAGIC (gitglossary(7)): `:(exclude)x`, `:!x`, `:(icase)x`, `:/`, `:(top)`.
1095
+ // These select a materially different set than the literal string — notably an
1096
+ // exclude-only pathspec behaves as if NO pathspec were given, staging everything else.
1097
+ // Treating them literally let them evade task-scope and protected-file checks entirely.
1098
+ if (token.startsWith(':')) return { broad: true, paths: [] };
1099
+ paths.push(token);
1100
+ sawPath = true;
1101
+ }
1102
+ if (!sawPath) return { broad: true, paths: [] };
1103
+ }
1104
+
1105
+ return paths.length ? { broad: false, paths } : { broad: true, paths: [] };
1106
+ }
1107
+
1108
+ // A pathspec is relative to the shell's working directory, NOT the repo root. With
1109
+ // cwd=/repo/src, `git add a.js` stages src/a.js — reporting `a.js` made task-scope and
1110
+ // protected-file gates evaluate the wrong path, so a protected src/a.js edit could pass.
1111
+ // Track a leading `cd` too, since `cd src && git add a.js` is the common shape.
1112
+ // Returns null when the working directory cannot be determined. A `cd` whose target is a
1113
+ // glob or variable makes every later relative pathspec unresolvable — resolving it against
1114
+ // the ORIGINAL directory would silently produce a wrong path, which is exactly the
1115
+ // guess-instead-of-widen mistake that made pathspec magic evade scope checks. Callers treat
1116
+ // null as "unknown" and fall back to broad.
1117
+ function effectiveCommandCwd(command, toolInput) {
1118
+ let cwd = String(toolInput?.cwd || toolInput?.repoPath || process.cwd());
1119
+ const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
1120
+ for (const segment of segments) {
1121
+ // Parsed without a regex: /^cd\s+(?:--\s+)?(.+)$/ has adjacent \s+ groups that backtrack
1122
+ // polynomially on input like `cd\t\t\t…` (js/polynomial-redos). The command comes
1123
+ // straight off the pending tool call, so stalling here stalls the gate.
1124
+ const trimmed = segment.trim();
1125
+ if (!trimmed.startsWith('cd')) break; // only a LEADING cd chain applies
1126
+ const afterCd = trimmed.slice(2);
1127
+ if (afterCd && !/^[ \t]/.test(afterCd)) break; // `cdfoo` is not `cd`
1128
+ let argText = afterCd.trim();
1129
+ if (argText === '--') argText = '';
1130
+ else if (argText.startsWith('--') && /^[ \t]/.test(argText.slice(2))) argText = argText.slice(2).trim();
1131
+ const target = tokenizeShellWords(argText)[0];
1132
+ if (!target) break; // bare `cd` -> home; leave scope resolution alone
1133
+ if (/[*?$`]|^~/.test(target)) return null;
1134
+ cwd = path.resolve(cwd, target);
1135
+ }
1136
+ return cwd;
1137
+ }
1138
+
1139
+ // Keep a tree-derived file only when it falls inside one of the declared pathspecs, so a
1140
+ // directory pathspec (`git add src/`) still reports the files under it and nothing else.
1141
+ function isExistingDirectory(relPath, repoRoot) {
1142
+ if (!repoRoot) return false;
1143
+ try {
1144
+ return fs.statSync(path.join(repoRoot, relPath)).isDirectory();
1145
+ } catch {
1146
+ return false;
1147
+ }
1148
+ }
1149
+
1150
+ function isUnderPathspec(relPath, pathspecs) {
1151
+ return pathspecs.some((spec) => relPath === spec || relPath.startsWith(`${spec}/`));
1152
+ }
1153
+
1154
+ // Linear trailing-slash strip. A regex like /\/+$/ backtracks polynomially on a long run of
1155
+ // slashes (js/polynomial-redos), and the pathspec comes straight off the pending command —
1156
+ // stalling the gate is itself a way to defeat it.
1157
+ function stripTrailingSlashes(value) {
1158
+ let end = value.length;
1159
+ while (end > 0 && value[end - 1] === '/') end -= 1;
1160
+ return value.slice(0, end);
1161
+ }
1162
+
1163
+ function applyPathspecScope(files, treeFiles, pathspec, repoRoot, commandCwd) {
1164
+ if (pathspec.broad) {
1165
+ for (const filePath of treeFiles) files.add(normalizePosix(filePath));
1166
+ return;
1167
+ }
1168
+ const specs = pathspec.paths
1169
+ .map((entry) => (path.isAbsolute(entry) ? entry : path.resolve(commandCwd || repoRoot || '.', entry)))
1170
+ .map((entry) => toRepoRelativePath(entry, repoRoot))
1171
+ .filter(Boolean)
1172
+ .map((entry) => stripTrailingSlashes(normalizePosix(entry)));
1173
+ if (!specs.length) {
1174
+ for (const filePath of treeFiles) files.add(normalizePosix(filePath));
1175
+ return;
1176
+ }
1177
+ const matchedSpecs = new Set();
1178
+ for (const filePath of treeFiles) {
1179
+ const normalized = normalizePosix(filePath);
1180
+ for (const spec of specs) {
1181
+ if (normalized === spec || normalized.startsWith(`${spec}/`)) {
1182
+ files.add(normalized);
1183
+ matchedSpecs.add(spec);
1184
+ }
1185
+ }
1186
+ }
1187
+ // An explicitly named file is in scope even when the tree scan does not list it (e.g. it
1188
+ // is already staged). A directory spec that matched tree files is not itself a file, and
1189
+ // an unmatched directory contributes nothing.
1190
+ for (const spec of matchedSpecs.size === specs.length ? [] : specs) {
1191
+ if (matchedSpecs.has(spec)) continue;
1192
+ if (isExistingDirectory(spec, repoRoot)) continue;
1193
+ files.add(spec);
1194
+ }
1195
+ }
1196
+
1197
+ // Git accepts global options BETWEEN `git` and the subcommand: `git -C <dir> push`,
1198
+ // `git -c k=v clean`, `git --git-dir=<p> reset`. Every command-pattern gate here is written
1199
+ // against the plain `git <subcommand>` form, so inserting one option was enough to walk past
1200
+ // force-push, git-reset-hard, git-clean-force and the local-only gates entirely — and to make
1201
+ // extractAffectedFiles report nothing, which silently disarms the task-scope and
1202
+ // protected-file gates too. Canonicalize the options away so the same command is recognised
1203
+ // however it is spelled. Callers match the ORIGINAL and the canonical form, so this can only
1204
+ // ever add a match, never remove one.
1205
+ 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;
1206
+
1207
+ function canonicalizeGitCommand(command) {
1208
+ let out = String(command || '');
1209
+ // Bounded: a crafted command with many stacked options must not loop unboundedly.
1210
+ for (let i = 0; i < 12; i += 1) {
1211
+ const next = out.replace(GIT_GLOBAL_OPTION_AFTER_GIT, 'git ');
1212
+ if (next === out) break;
1213
+ out = next;
1214
+ }
1215
+ return out;
1216
+ }
1217
+
1218
+ // The catastrophic gate patterns anchor the command position as `(?:^|[;&|]\s*)`, i.e. the
1219
+ // command must sit at the very start of the string or immediately after ; & |. That anchor
1220
+ // exists to avoid matching a command mentioned inside a quoted string, but it is far too
1221
+ // narrow: it does not recognise a command on a NEW LINE, nor any of the ordinary ways a
1222
+ // binary gets invoked. Each of the following defeated git-reset-hard and git-clean-force on
1223
+ // shipped main — no gate matched at all:
1224
+ //
1225
+ // sudo git reset --hard GIT_DIR=… git reset --hard /usr/bin/git reset --hard
1226
+ // command git reset --hard "git" reset --hard \git reset --hard
1227
+ // echo hi\ngit reset --hard
1228
+ //
1229
+ // Rather than complicate every gate's regex, canonicalize the command POSITION: split on
1230
+ // separators (including newlines), strip env-assignment prefixes, wrapper binaries and any
1231
+ // directory/quoting on the binary token, then rejoin with `; ` so the existing anchor sees a
1232
+ // clean command. Callers match the original AND the canonical form, so this only ever adds.
1233
+ const COMMAND_WRAPPERS = new Set([
1234
+ 'sudo', 'doas', 'command', 'builtin', 'exec', 'nohup', 'time', 'env',
1235
+ 'nice', 'ionice', 'setsid', 'stdbuf', 'xargs',
1236
+ ]);
1237
+ const ENV_ASSIGNMENT_PREFIX = /^[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|[^\s]*)\s+/;
1238
+ const WRAPPER_HEAD = /^([A-Za-z_][\w.-]*)\s+/;
1239
+
1240
+ function canonicalizeSegmentHead(segment) {
1241
+ let text = String(segment || '').trim();
1242
+ for (let i = 0; i < 12; i += 1) {
1243
+ const before = text;
1244
+ text = text.replace(ENV_ASSIGNMENT_PREFIX, '');
1245
+ const wrapper = text.match(WRAPPER_HEAD);
1246
+ if (wrapper && COMMAND_WRAPPERS.has(wrapper[1].toLowerCase())) {
1247
+ text = text.slice(wrapper[0].length);
1248
+ }
1249
+ if (text === before) break;
1250
+ }
1251
+ // Unwrap quoting/escaping on the binary token: "git" / 'git' / \git
1252
+ text = text.replace(/^\\(?=[A-Za-z_])/, '');
1253
+ text = text.replace(/^(['"])([^'"\s]+)\1/, '$2');
1254
+ // Drop a leading directory on the binary token: /usr/bin/git, ./bin/git, ../git
1255
+ text = text.replace(/^((?:\.{1,2})?(?:\/[^\s/]+)*\/)([^\s/]+)/, '$2');
1256
+ return text;
1257
+ }
1258
+
1259
+ function canonicalizeCommandPositions(command) {
1260
+ const text = String(command || '');
1261
+ if (!text) return '';
1262
+ return text
1263
+ .split(/\r?\n|&&|\|\||[;|&]/)
1264
+ .map((segment) => canonicalizeSegmentHead(segment))
1265
+ .filter((segment) => segment.length > 0)
1266
+ .join('; ');
1267
+ }
1268
+
1269
+ // Full canonical form used for gate matching.
1270
+ function canonicalizeCommandForGates(command) {
1271
+ return canonicalizeGitCommand(canonicalizeCommandPositions(command));
1272
+ }
1273
+
1274
+ // Some gates anchor with a BARE `^` (local-only-git-writes, task-scope-required,
1275
+ // branch-governance-required, release-readiness-required) rather than the
1276
+ // `(?:^|[;&|]\s*)` form. A bare `^` only ever matches the FIRST command in the string, so
1277
+ // `echo hi && git commit -m x` slipped past while `git commit -m x` was denied — and
1278
+ // chaining is how agents normally work. Offer each canonicalized SEGMENT as its own
1279
+ // candidate so a `^` anchor sees every command in the chain, not just the head.
1280
+ //
1281
+ // This stays additive: unanchored patterns already match anywhere, so per-segment testing
1282
+ // adds nothing for them, and a `^` pattern matching a later segment is exactly the gate's
1283
+ // intent. A command merely quoted inside another (`echo "git commit"`) is unaffected,
1284
+ // because the segment head is still `echo`.
1285
+ function gateMatchCandidates(matchText) {
1286
+ const canonical = canonicalizeCommandForGates(matchText);
1287
+ const candidates = [matchText];
1288
+ if (canonical && canonical !== matchText) candidates.push(canonical);
1289
+ for (const segment of canonical.split('; ')) {
1290
+ const trimmed = segment.trim();
1291
+ if (trimmed && trimmed !== canonical) candidates.push(trimmed);
1292
+ }
1293
+ return candidates;
1294
+ }
1295
+
1296
+ function patternMatchesCommand(regex, matchText) {
1297
+ return gateMatchCandidates(matchText).some((candidate) => regex.test(candidate));
1298
+ }
1299
+
951
1300
  function extractAffectedFiles(toolName, toolInput = {}) {
952
1301
  const repoRoot = resolveRepoRoot(toolInput);
953
1302
  const files = new Set(collectInlineAffectedFiles(toolInput, repoRoot));
954
- const command = String(toolInput.command || '');
1303
+ // Full canonicalization, not just the git-option pass: `"git" add .` and `sudo git add .`
1304
+ // otherwise fail the `\bgit\s+add\b` probes below and yield ZERO affected files, which in
1305
+ // turn makes the scope gates (task-scope-required, protected-file-approval-required) find
1306
+ // no violation and fall through — the file-list half of the same bypass.
1307
+ const command = canonicalizeCommandForGates(String(toolInput.command || ''));
1308
+
1309
+ const commandCwd = effectiveCommandCwd(command, toolInput);
1310
+ // An unresolvable `cd` makes every relative pathspec meaningless; widen rather than guess.
1311
+ const cwdUnknown = commandCwd === null;
955
1312
 
956
1313
  if (toolName === 'Bash' && repoRoot && command) {
957
1314
  if (/\bgit\s+commit\b/i.test(command)) {
958
- for (const filePath of safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot)) {
959
- files.add(normalizePosix(filePath));
960
- }
1315
+ // For commit only an explicit `-- <pathspec>` narrows the staged set; bare arguments
1316
+ // after `git commit` are almost always flag values (`-m "msg"`), so anything else
1317
+ // keeps the staged-diff behaviour.
1318
+ const commitSpec = /\bgit\s+commit\b[^;|&\n]*?\s--\s/i.test(command)
1319
+ ? parseGitPathspec(command, 'commit', { separatorOnly: true })
1320
+ : { broad: true, paths: [] };
1321
+ // `git commit -- <pathspec>` commits tracked files straight from the WORKING TREE, not
1322
+ // only what is staged. Filtering the cached diff alone dropped exactly those files, so
1323
+ // scope and protected-file gates missed changes the commit really carries.
1324
+ const candidates = commitSpec.broad
1325
+ ? safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot)
1326
+ : [
1327
+ ...safeExecFileLines('git', ['diff', '--cached', '--name-only'], repoRoot),
1328
+ ...safeExecFileLines('git', ['diff', '--name-only'], repoRoot),
1329
+ ];
1330
+ applyPathspecScope(files, candidates, cwdUnknown ? { broad: true, paths: [] } : commitSpec, repoRoot, commandCwd);
961
1331
  }
962
1332
 
963
1333
  if (/\bgit\s+add\b/i.test(command)) {
964
- for (const filePath of safeExecFileLines('git', ['diff', '--name-only'], repoRoot)) {
965
- files.add(normalizePosix(filePath));
966
- }
967
- for (const filePath of safeExecFileLines('git', ['ls-files', '--others', '--exclude-standard'], repoRoot)) {
968
- files.add(normalizePosix(filePath));
969
- }
1334
+ const treeFiles = [
1335
+ ...safeExecFileLines('git', ['diff', '--name-only'], repoRoot),
1336
+ ...safeExecFileLines('git', ['ls-files', '--others', '--exclude-standard'], repoRoot),
1337
+ ];
1338
+ const addSpec = cwdUnknown ? { broad: true, paths: [] } : parseGitPathspec(command, 'add');
1339
+ applyPathspecScope(files, treeFiles, addSpec, repoRoot, commandCwd);
970
1340
  }
971
1341
 
972
1342
  if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
@@ -1331,9 +1701,36 @@ function isReadOnlyObservabilityTool(toolName) {
1331
1701
  }
1332
1702
 
1333
1703
  function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
1334
- if (!hasAction(PR_THREAD_RESOLUTION_ACTION)) return null;
1704
+ const pendingEntry = loadSessionActions()[PR_THREAD_RESOLUTION_ACTION];
1705
+ if (!pendingEntry) return null;
1706
+
1707
+ // Scope to the repo that actually committed. Session-actions state lives in a
1708
+ // single global file (~/.thumbgate/session-actions.json), shared by every repo
1709
+ // on the machine. Without this check, a commit in repo A permanently locks out
1710
+ // every tool call in an unrelated repo B's session until the 1-hour TTL expires
1711
+ // (verified 2026-07-24: a commit in one repo/worktree blocked every Bash/Read/
1712
+ // Skill/ToolSearch call in a completely unrelated repo's session).
1713
+ const trackedRepoRoot = pendingEntry.metadata && pendingEntry.metadata.repoRoot;
1714
+ if (trackedRepoRoot) {
1715
+ const currentRepoRoot = resolveRepoRoot(toolInput);
1716
+ if (currentRepoRoot && currentRepoRoot !== trackedRepoRoot) return null;
1717
+ }
1718
+
1335
1719
  if (isThreadResolutionSatisfied()) return null;
1336
1720
  if (isReadOnlyObservabilityTool(toolName)) return null;
1721
+ // Evidence actions (gh pr view/checks/status, gh api .../reviewThreads, git
1722
+ // status/diff/show, the satisfy_gate/track_action tools themselves) are exempt
1723
+ // from being blocked so an agent can actually gather evidence and call
1724
+ // satisfy_gate — but running them must NOT itself satisfy the gate. This is a
1725
+ // PreToolUse hook: it fires before the command executes, so at this point the
1726
+ // command hasn't run, could still fail, or could return UNFAVORABLE evidence
1727
+ // (N unresolved threads). Auto-satisfying on the mere shape of the request —
1728
+ // as an earlier version of this fix did — let `git status` (which proves
1729
+ // nothing about thread resolution) or a `gh pr view` that later errors clear
1730
+ // a critical gate before any real verification happened (caught in review,
1731
+ // PR #3030). The only sound way to clear this gate is the explicit,
1732
+ // agent-asserted `satisfy_gate` tool call, which records real evidence via
1733
+ // satisfyCondition() — never inferred from a pre-execution command guess.
1337
1734
  if (isThreadResolutionEvidenceAction(toolName, toolInput)) return null;
1338
1735
 
1339
1736
  const message = 'A git commit was made on a PR branch. Verify review threads are resolved before the next tool call.';
@@ -1344,7 +1741,7 @@ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
1344
1741
  severity: 'critical',
1345
1742
  reasoning: [
1346
1743
  `Tracked action ${PR_THREAD_RESOLUTION_ACTION} is pending`,
1347
- 'Satisfy pr_threads_checked or thread_resolution_verified with evidence before continuing',
1744
+ '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',
1348
1745
  ],
1349
1746
  };
1350
1747
  }
@@ -1565,8 +1962,23 @@ function formatFileList(files, limit = 5) {
1565
1962
  return `${items.slice(0, limit).join(', ')} (+${items.length - limit} more)`;
1566
1963
  }
1567
1964
 
1568
- function buildTaskScopeViolation(taskScope, affectedFiles) {
1965
+ function buildTaskScopeViolation(taskScope, affectedFiles, nowMs = Date.now()) {
1569
1966
  if (!Array.isArray(affectedFiles) || affectedFiles.length === 0) return null;
1967
+ // EXPIRY FAILS CLOSED, and that direction is the whole point.
1968
+ //
1969
+ // A task scope is a restriction, so simply dropping it on expiry would make the agent MORE
1970
+ // powerful the moment its lease ran out — expiry would remove a boundary instead of removing
1971
+ // authority. A lease has to mean the opposite: while it is live you may work in these paths,
1972
+ // and when it lapses the authority is gone until it is renewed.
1973
+ if (taskScope && isTaskScopeExpired(taskScope, nowMs)) {
1974
+ return {
1975
+ reasonCode: 'expired_task_scope',
1976
+ outsideFiles: affectedFiles.slice(),
1977
+ allowedPaths: Array.isArray(taskScope.allowedPaths) ? taskScope.allowedPaths.slice() : [],
1978
+ summary: taskScope.summary || null,
1979
+ expiresAt: taskScope.expiresAt || null,
1980
+ };
1981
+ }
1570
1982
  if (!taskScope || !Array.isArray(taskScope.allowedPaths) || taskScope.allowedPaths.length === 0) {
1571
1983
  return {
1572
1984
  reasonCode: 'missing_task_scope',
@@ -1607,7 +2019,11 @@ function buildProtectedApprovalViolation(protectedGlobs, approvals, affectedFile
1607
2019
  }
1608
2020
 
1609
2021
  function buildBranchGovernanceViolation(governanceState, toolInput = {}, affectedFiles = [], repoRoot = null, requireReleaseReadiness = false) {
1610
- const command = String(toolInput.command || '').trim();
2022
+ // Canonicalized: this helper runs its OWN command analysis downstream of the gate's
2023
+ // pattern test, so passing the raw text let `"npm" publish` / `sudo gh release create`
2024
+ // through even once the pattern matched. Canonicalizing here keeps that second analysis
2025
+ // consistent with the first.
2026
+ const command = canonicalizeCommandForGates(String(toolInput.command || '').trim());
1611
2027
  if (!command) return null;
1612
2028
 
1613
2029
  const integrity = evaluateOperationalIntegrity({
@@ -1635,6 +2051,11 @@ function buildBranchGovernanceViolation(governanceState, toolInput = {}, affecte
1635
2051
  function buildGateMessage(gate, matchDetails) {
1636
2052
  if (matchDetails && matchDetails.taskScopeViolation) {
1637
2053
  const violation = matchDetails.taskScopeViolation;
2054
+ if (violation.reasonCode === 'expired_task_scope') {
2055
+ const lapsed = violation.expiresAt ? new Date(violation.expiresAt).toISOString() : 'unknown time';
2056
+ return `The task-scope lease expired at ${lapsed}, so its authority no longer applies. `
2057
+ + `Renew it with set_task_scope (allowed paths were: ${formatFileList(violation.allowedPaths)}).`;
2058
+ }
1638
2059
  if (violation.reasonCode === 'missing_task_scope') {
1639
2060
  return `No task scope is declared for this high-risk action. Affected files: ${formatFileList(violation.outsideFiles)}.`;
1640
2061
  }
@@ -1861,7 +2282,11 @@ function matchGate(gate, toolName, toolInput = {}) {
1861
2282
  if (gate.pattern) {
1862
2283
  try {
1863
2284
  const regex = new RegExp(gate.pattern);
1864
- if (!regex.test(matchText)) return { matched: false, matchText, affectedFiles };
2285
+ // Match the original text or its git-canonical form, so `git -C <dir> push --force`
2286
+ // is caught by the same pattern as `git push --force`.
2287
+ if (!patternMatchesCommand(regex, matchText)) {
2288
+ return { matched: false, matchText, affectedFiles };
2289
+ }
1865
2290
  if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
1866
2291
  return { matched: false, matchText, affectedFiles };
1867
2292
  }
@@ -1975,7 +2400,8 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
1975
2400
  if (!Array.isArray(gate.toolNames) || !gate.toolNames.includes(toolName)) return null;
1976
2401
  if (!matchText || !gate.pattern) return null;
1977
2402
  try {
1978
- if (!new RegExp(gate.pattern).test(matchText)) return null;
2403
+ const regex = new RegExp(gate.pattern);
2404
+ if (!patternMatchesCommand(regex, matchText)) return null;
1979
2405
  } catch {
1980
2406
  return null;
1981
2407
  }
@@ -2109,12 +2535,18 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
2109
2535
  return null;
2110
2536
  }
2111
2537
 
2538
+ // The memory guard keyword-matches against this string. The false positives that motivated
2539
+ // a cap here came from the JSON envelope's own KEY names polluting the haystack, which is
2540
+ // fixed at the matcher (buildMatchHaystack). Truncating the file list instead silently
2541
+ // dropped action targets: for a broad action, a guard whose keywords appear only in a later
2542
+ // filename could no longer match, so a learned prevention rule was bypassable purely by
2543
+ // filename ordering. Keep every target and bound the SIZE instead.
2112
2544
  const serializedInput = JSON.stringify({
2113
2545
  toolName,
2114
2546
  command: toolInput.command || null,
2115
2547
  filePath: toolInput.file_path || toolInput.path || null,
2116
2548
  affectedFiles,
2117
- });
2549
+ }).slice(0, MEMORY_GUARD_MAX_SERIALIZED_CHARS);
2118
2550
  // Claw/hybrid support: pass context if agent provides claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
2119
2551
  let guard;
2120
2552
  if (toolInput && (toolInput.clawContext || toolInput._claw || toolInput.hybridRoute || toolInput.agentId)) {
@@ -2253,7 +2685,7 @@ function isAutonomousRun() {
2253
2685
  return raw === '1' || raw === 'true';
2254
2686
  }
2255
2687
 
2256
- async function evaluateGatesAsync(toolName, toolInput, configPath) {
2688
+ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2257
2689
  let config;
2258
2690
  try {
2259
2691
  let harnessPath;
@@ -2389,7 +2821,13 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
2389
2821
  });
2390
2822
 
2391
2823
  if (gate.action === 'block') {
2392
- const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
2824
+ // Expired leases report under their own gate id so neither the enforcement posture nor
2825
+ // the daily block cap can quietly turn this denial into a warning.
2826
+ const gateId = matchDetails && matchDetails.taskScopeViolation
2827
+ && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
2828
+ ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
2829
+ : gate.id;
2830
+ const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
2393
2831
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
2394
2832
  const cappedResult = applyDailyBlockCap(denyResult);
2395
2833
  if (cappedResult) {
@@ -2492,7 +2930,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
2492
2930
  return null;
2493
2931
  }
2494
2932
 
2495
- function evaluateGates(toolName, toolInput, configPath) {
2933
+ function evaluateGatesInner(toolName, toolInput, configPath) {
2496
2934
  let config;
2497
2935
  try {
2498
2936
  let harnessPath;
@@ -2602,7 +3040,13 @@ function evaluateGates(toolName, toolInput, configPath) {
2602
3040
  const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
2603
3041
 
2604
3042
  if (gate.action === 'block') {
2605
- const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
3043
+ // Expired leases report under their own gate id so neither the enforcement posture nor
3044
+ // the daily block cap can quietly turn this denial into a warning.
3045
+ const gateId = matchDetails && matchDetails.taskScopeViolation
3046
+ && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
3047
+ ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
3048
+ : gate.id;
3049
+ const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
2606
3050
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
2607
3051
  const cappedResult = applyDailyBlockCap(denyResult);
2608
3052
  if (cappedResult) {
@@ -3292,6 +3736,42 @@ function run(input) {
3292
3736
 
3293
3737
  }
3294
3738
 
3739
+
3740
+ // A command can be spelled many ways without changing what it does: `sudo git …`,
3741
+ // `"git" …`, `/usr/bin/git …`, `GIT_DIR=… git …`, a chained `echo hi && git …`, or a git
3742
+ // global option before the subcommand. Roughly fifteen helpers below read
3743
+ // `toolInput.command` and run their own analysis on it, so patching each one individually
3744
+ // is how a spelling gets missed — that happened twice while fixing this.
3745
+ //
3746
+ // Instead: evaluate normally, and ONLY IF nothing matched, evaluate once more against the
3747
+ // canonicalized command. Every helper is covered without touching any of them, and it is
3748
+ // strictly additive — a command that already matched never reaches the second pass, so no
3749
+ // existing verdict changes and no stat is recorded twice.
3750
+ function canonicalRetryInput(toolName, toolInput) {
3751
+ if (toolName !== 'Bash') return null;
3752
+ const original = String((toolInput && toolInput.command) || '');
3753
+ if (!original) return null;
3754
+ const canonical = canonicalizeCommandForGates(original);
3755
+ if (!canonical || canonical === original) return null;
3756
+ return { ...toolInput, command: canonical, originalCommand: original };
3757
+ }
3758
+
3759
+ async function evaluateGatesAsync(toolName, toolInput, configPath) {
3760
+ const direct = await evaluateGatesAsyncInner(toolName, toolInput, configPath);
3761
+ if (direct) return direct;
3762
+ const retry = canonicalRetryInput(toolName, toolInput);
3763
+ if (!retry) return null;
3764
+ return evaluateGatesAsyncInner(toolName, retry, configPath);
3765
+ }
3766
+
3767
+ function evaluateGates(toolName, toolInput, configPath) {
3768
+ const direct = evaluateGatesInner(toolName, toolInput, configPath);
3769
+ if (direct) return direct;
3770
+ const retry = canonicalRetryInput(toolName, toolInput);
3771
+ if (!retry) return null;
3772
+ return evaluateGatesInner(toolName, retry, configPath);
3773
+ }
3774
+
3295
3775
  // ---------------------------------------------------------------------------
3296
3776
  // Session action tracking and claim verification
3297
3777
  // ---------------------------------------------------------------------------
@@ -3561,6 +4041,10 @@ module.exports = {
3561
4041
  loadGovernanceState,
3562
4042
  saveGovernanceState,
3563
4043
  setTaskScope,
4044
+ isTaskScopeExpired,
4045
+ TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
4046
+ applyEnforcementPosture,
4047
+ buildTaskScopeViolation,
3564
4048
  setBranchGovernance,
3565
4049
  approveProtectedAction,
3566
4050
  breakGlassEmergency,
@@ -3578,6 +4062,12 @@ module.exports = {
3578
4062
  matchesGate,
3579
4063
  evaluateGates,
3580
4064
  evaluateGatesAsync,
4065
+ extractAffectedFiles,
4066
+ parseGitPathspec,
4067
+ canonicalizeGitCommand,
4068
+ canonicalizeCommandForGates,
4069
+ canonicalizeCommandPositions,
4070
+ patternMatchesCommand,
3581
4071
  isAutonomousRun,
3582
4072
  computeExecutableHash,
3583
4073
  formatOutput,