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.
- package/.claude/commands/dashboard.md +11 -1
- package/.claude/commands/thumbgate-dashboard.md +23 -8
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +18 -10
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +66 -3
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +88 -2
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +8 -8
- package/bin/postinstall.js +4 -13
- package/commands/dashboard.md +11 -1
- package/commands/thumbgate-dashboard.md +23 -8
- package/config/agent-outcome-monitor-thresholds.json +63 -0
- package/config/evals/agent-outcomes-baseline.json +17 -0
- package/config/evals/agent-outcomes-golden.json +412 -0
- package/config/evals/prompt-eval-baseline.json +23 -0
- package/config/github-about.json +5 -4
- package/config/post-deploy-marketing-pages.json +6 -6
- package/config/schemas/task-outcome-receipt.schema.json +296 -0
- package/docs/integrations/grafana/README.md +109 -0
- package/docs/integrations/grafana/thumbgate-revenue-evidence-dashboard.json +1930 -0
- package/openapi/openapi.yaml +475 -5
- package/package.json +75 -22
- package/public/agent-manager.html +10 -11
- package/public/agents-cost-savings.html +2 -2
- package/public/assets/brand/thumbgate-logo-transparent.svg +6 -11
- package/public/assets/brand/thumbgate-mark-inline-v3.svg +11 -10
- package/public/assets/brand/thumbgate-mark.svg +10 -11
- package/public/blog/inside-your-boundary.html +114 -0
- package/public/blog/process-over-outcome-gates.html +119 -0
- package/public/blog.html +296 -402
- package/public/brand/thumbgate-mark.svg +5 -9
- package/public/codex-enterprise.html +2 -2
- package/public/compare.html +12 -3
- package/public/diagnostic.html +79 -29
- package/public/guide.html +4 -4
- package/public/index.html +1090 -2098
- package/public/install.html +3 -3
- package/public/js/buyer-intent.js +33 -18
- package/public/numbers.html +2 -2
- package/public/pricing.html +268 -408
- package/public/pro.html +4 -4
- package/scripts/agent-outcome-eval.js +130 -0
- package/scripts/agent-outcome-monitor.js +261 -0
- package/scripts/agent-reasoning-traces.js +8 -9
- package/scripts/async-job-runner.js +107 -13
- package/scripts/billing.js +456 -126
- package/scripts/buyer-paths.js +102 -0
- package/scripts/cli-feedback.js +2 -2
- package/scripts/commercial-offer.js +18 -10
- package/scripts/durability/step.js +121 -12
- package/scripts/external-customer-audit.js +881 -0
- package/scripts/feedback-loop.js +26 -0
- package/scripts/gates-engine.js +554 -19
- package/scripts/grafana-revenue-evidence.js +856 -0
- package/scripts/human-escalation.js +265 -0
- package/scripts/hybrid-feedback-context.js +93 -50
- package/scripts/jsonl-window.js +89 -0
- package/scripts/judge-reward-function.js +30 -18
- package/scripts/lesson-embedding-index.js +3 -7
- package/scripts/meta-agent-loop.js +20 -2
- package/scripts/observability-env.js +139 -0
- package/scripts/observability-setup.js +55 -0
- package/scripts/plausible-domain-config.js +4 -0
- package/scripts/prompt-eval.js +81 -4
- package/scripts/provider-live-evidence.js +1290 -0
- package/scripts/provider-payment-reconciler.js +442 -0
- package/scripts/provider-revenue-evidence.js +249 -0
- package/scripts/rate-limiter.js +1 -5
- package/scripts/revenue-action-eligibility.js +414 -0
- package/scripts/revenue-evidence-remediation.js +694 -0
- package/scripts/revenue-offer-system.js +709 -0
- package/scripts/sales-pipeline.js +1117 -0
- package/scripts/schedule-manager.js +249 -0
- package/scripts/seo-gsd.js +8 -4
- package/scripts/stripe-credentials.js +37 -0
- package/scripts/stripe-revenue-catalog-audit.js +363 -0
- package/scripts/stripe-revenue-catalog.js +164 -0
- package/scripts/task-outcomes.js +425 -0
- package/scripts/telemetry-analytics.js +23 -3
- package/scripts/tool-contract-validator.js +287 -59
- package/scripts/tool-registry.js +143 -0
- package/scripts/vector-store.js +83 -7
- package/scripts/workflow-intake-queue.js +483 -0
- package/src/api/server.js +647 -118
package/scripts/gates-engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
940
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|