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.
- 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/mcp/server-card.json +1 -1
- package/README.md +61 -1
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/mcp/server-stdio.js +164 -7
- package/adapters/opencode/opencode.json +1 -1
- package/bin/cli.js +7 -5
- 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/mcp-allowlists.json +26 -2
- package/config/post-deploy-marketing-pages.json +26 -1
- package/config/schemas/task-outcome-receipt.schema.json +296 -0
- package/openapi/openapi.yaml +235 -0
- package/package.json +55 -11
- package/public/architecture.html +130 -0
- package/public/assets/diagrams/agent-integration.png +0 -0
- package/public/assets/diagrams/before-after.svg +21 -0
- package/public/assets/diagrams/decision.svg +36 -0
- package/public/assets/diagrams/feedback-pipeline.png +0 -0
- package/public/assets/diagrams/loop.svg +34 -0
- package/public/assets/diagrams/plugin-topology.png +0 -0
- package/public/assets/diagrams/pre-action-gate-loop.svg +59 -0
- package/public/assets/diagrams/stack.svg +18 -0
- package/public/assets/diagrams/thumbgate-architecture.png +0 -0
- package/public/case-studies.html +151 -0
- package/public/eval-scorecard.html +195 -0
- package/public/eval-scorecard.json +18 -0
- package/public/evaluations.html +168 -0
- package/public/index.html +6 -3
- package/public/numbers.html +2 -2
- package/public/whitepaper.html +189 -0
- package/scripts/activation-quickstart.js +1 -0
- package/scripts/agent-outcome-eval.js +130 -0
- package/scripts/agent-outcome-monitor.js +331 -0
- package/scripts/agent-reasoning-traces.js +8 -9
- package/scripts/async-job-runner.js +107 -13
- package/scripts/billing.js +3 -1
- package/scripts/claude-feedback-sync.js +3 -2
- package/scripts/cli-feedback.js +13 -7
- package/scripts/cross-encoder-reranker.js +3 -0
- package/scripts/durability/step.js +121 -12
- package/scripts/feedback-aggregate.js +5 -2
- package/scripts/feedback-loop.js +244 -182
- package/scripts/gates-engine.js +512 -22
- package/scripts/generate-case-study-outreach.js +253 -0
- package/scripts/generate-eval-scorecard.js +276 -0
- package/scripts/growth-campaigns.js +183 -0
- package/scripts/human-escalation.js +265 -0
- package/scripts/hybrid-feedback-context.js +93 -50
- package/scripts/jsonl-watcher.js +1 -0
- package/scripts/judge-reward-function.js +30 -18
- package/scripts/lesson-inference.js +23 -4
- package/scripts/lesson-retrieval.js +71 -4
- package/scripts/lesson-search.js +26 -3
- package/scripts/mcp-config.js +26 -5
- package/scripts/mcp-oauth.js +37 -2
- package/scripts/model-eval.js +308 -0
- package/scripts/parallel-workflow-orchestrator.js +86 -22
- package/scripts/prompt-eval.js +81 -4
- package/scripts/published-cli.js +11 -1
- package/scripts/refresh-proof-pack.js +261 -0
- package/scripts/risk-scorer.js +144 -15
- package/scripts/schedule-manager.js +249 -0
- package/scripts/statusline-local-stats.js +1 -1
- package/scripts/task-outcomes.js +425 -0
- package/scripts/thumbgate-bench.js +13 -0
- package/scripts/tool-contract-validator.js +287 -59
- package/scripts/tool-kpi-tracker.js +124 -0
- package/scripts/tool-registry.js +192 -1
- package/src/api/server.js +355 -89
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;
|
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
959
|
-
|
|
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
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
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
|
-
'
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|