thumbgate 1.26.8 → 1.27.3
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-plugin/plugin.json +1 -1
- package/.well-known/agentic-verify.txt +1 -0
- package/.well-known/llms.txt +2 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +44 -31
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/gcp/dfcx-webhook-gate.js +295 -0
- package/adapters/mcp/server-stdio.js +41 -1
- package/adapters/opencode/opencode.json +1 -1
- package/bench/thumbgate-bench.json +2 -2
- package/bin/cli.js +184 -8
- package/bin/dashboard-cli.js +7 -0
- package/config/gate-classifier-routing.json +98 -0
- package/config/gate-templates.json +60 -0
- package/config/mcp-allowlists.json +8 -7
- package/config/model-candidates.json +71 -6
- package/package.json +28 -12
- package/public/about.html +162 -0
- package/public/chatgpt-app.html +330 -0
- package/public/codex-plugin.html +66 -14
- package/public/compare.html +2 -2
- package/public/dashboard.html +224 -36
- package/public/guide.html +2 -2
- package/public/index.html +122 -40
- package/public/learn.html +70 -0
- package/public/lessons.html +129 -6
- package/public/numbers.html +2 -2
- package/public/pricing.html +28 -23
- package/public/pro.html +3 -3
- package/scripts/agent-operations-planner.js +621 -0
- package/scripts/agent-reward-model.js +53 -1
- package/scripts/ai-component-inventory.js +367 -0
- package/scripts/classifier-routing.js +130 -0
- package/scripts/cli-schema.js +26 -0
- package/scripts/commercial-offer.js +10 -2
- package/scripts/dashboard-chat.js +199 -51
- package/scripts/feedback-sanitizer.js +105 -0
- package/scripts/gates-engine.js +301 -67
- package/scripts/hybrid-feedback-context.js +141 -7
- package/scripts/memory-scope-readiness.js +159 -0
- package/scripts/oss-pr-opportunity-scout.js +35 -5
- package/scripts/parallel-workflow-orchestrator.js +293 -0
- package/scripts/plausible-domain-config.js +86 -0
- package/scripts/plausible-server-events.js +4 -2
- package/scripts/proxy-pointer-rag-guardrails.js +42 -1
- package/scripts/qa-scenario-planner.js +136 -0
- package/scripts/rate-limiter.js +2 -2
- package/scripts/repeat-metric.js +28 -12
- package/scripts/secret-fixture-tokens.js +61 -0
- package/scripts/secret-scanner.js +44 -5
- package/scripts/security-scanner.js +80 -0
- package/scripts/seo-gsd.js +113 -0
- package/scripts/thumbgate-bench.js +16 -1
- package/scripts/tool-registry.js +37 -0
- package/scripts/workflow-sentinel.js +282 -54
- package/src/api/server.js +466 -60
- package/.claude-plugin/marketplace.json +0 -85
package/scripts/gates-engine.js
CHANGED
|
@@ -20,6 +20,10 @@ const {
|
|
|
20
20
|
recordDecisionEvaluation,
|
|
21
21
|
recordDecisionOutcome,
|
|
22
22
|
} = require('./decision-journal');
|
|
23
|
+
const {
|
|
24
|
+
actionFingerprint,
|
|
25
|
+
sanitizeFeedbackText,
|
|
26
|
+
} = require('./feedback-sanitizer');
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* Computes the SHA-256 hash of an executable binary to prevent path-based bypasses.
|
|
@@ -54,6 +58,8 @@ const {
|
|
|
54
58
|
scanHookInput,
|
|
55
59
|
buildSafeSummary,
|
|
56
60
|
redactText,
|
|
61
|
+
isSafeSecretStorageWrite,
|
|
62
|
+
SAFE_SECRET_STORAGE_DIRS,
|
|
57
63
|
} = require('./secret-scanner');
|
|
58
64
|
const {
|
|
59
65
|
evaluateSecurityScan,
|
|
@@ -109,12 +115,49 @@ const DEFAULT_PROTECTED_FILE_GLOBS = [
|
|
|
109
115
|
const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
110
116
|
const HIGH_RISK_BASH_PATTERN = /\b(?:git\s+(?:add|commit|push)|gh\s+pr\s+(?:create|merge)|npm\s+publish|yarn\s+publish|pnpm\s+publish|rm\s+-rf)\b/i;
|
|
111
117
|
const REMOTE_SIDE_EFFECT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+(?:create|merge|close|reopen|ready|edit)\b|gh\s+release\s+(?:create|delete|edit|upload)\b|npm\s+publish\b|yarn\s+publish\b|pnpm\s+publish\b)\b/i;
|
|
112
|
-
const
|
|
118
|
+
const MAX_COMMAND_SCAN_CHARS = 20000;
|
|
113
119
|
const BOOSTED_RISK_BLOCK_SCORE = 0.8;
|
|
114
120
|
const BOOSTED_RISK_MIN_EXAMPLES = 3;
|
|
115
121
|
const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
|
|
116
122
|
const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
|
|
117
123
|
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|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;
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Enforcement posture (CEO decision 2026-06-04): warn-by-default.
|
|
127
|
+
// The firewall ALWAYS fires and logs every decision, but most gates WARN rather
|
|
128
|
+
// than hard-block — only TRULY CATASTROPHIC, irreversible actions hard-block:
|
|
129
|
+
// - secret exfiltration (handled on its own deny path; never downgraded)
|
|
130
|
+
// - security-vulnerability / supply-chain denies (own deny path; not downgraded)
|
|
131
|
+
// - irreversibly destructive filesystem commands (rm -rf class, mkfs, dd to disk,
|
|
132
|
+
// fork bomb) — kept as hard deny via DESTRUCTIVE_FS_PATTERN below.
|
|
133
|
+
// Everything else (memory-high-risk, workflow-sequence, off-scope, git push, deploy,
|
|
134
|
+
// approval gates) downgrades deny/approve -> warn so legitimate work is never blocked.
|
|
135
|
+
// Opt back into full hard enforcement with THUMBGATE_STRICT_ENFORCEMENT=1.
|
|
136
|
+
// Enforcement posture (CEO decision 2026-06-04): WARN + AUDIT by default.
|
|
137
|
+
// The firewall fires and LOGS every decision, but downgrades deny/approve -> warn so
|
|
138
|
+
// legitimate work is never hard-blocked. We deliberately do NOT try to hard-block
|
|
139
|
+
// arbitrary destructive commands here: a regex "catastrophic floor" is unwinnable
|
|
140
|
+
// (sudo / bash -c / find -exec / eval / base64|sh all evade it) and gives false confidence.
|
|
141
|
+
// HARD enforcement is an explicit opt-in via THUMBGATE_STRICT_ENFORCEMENT=1, which keeps
|
|
142
|
+
// the engine's FULL gate set (its high-risk-command gates catch prefixed/obfuscated forms
|
|
143
|
+
// far better than any single regex). Secret exfiltration and the security-vulnerability
|
|
144
|
+
// scan hard-deny on their OWN paths before this runs, so irreversible data-leak / supply
|
|
145
|
+
// chain risks stay blocked regardless of posture.
|
|
146
|
+
function applyEnforcementPosture(result) {
|
|
147
|
+
if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
|
|
148
|
+
// Full hard enforcement opt-in: keep every deny.
|
|
149
|
+
if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
|
|
150
|
+
// Honor the explicit strict-knowledge-conflict opt-in for that gate.
|
|
151
|
+
if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') return result;
|
|
152
|
+
// Warn-by-default: the gate still fired and is recorded; the action is allowed through
|
|
153
|
+
// with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
|
|
154
|
+
return {
|
|
155
|
+
...result,
|
|
156
|
+
decision: 'warn',
|
|
157
|
+
warnByDefault: true,
|
|
158
|
+
message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 to hard-block, or THUMBGATE_HOTFIX_BYPASS=1 to disable checks entirely.`,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
118
161
|
const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
|
|
119
162
|
const BREAK_GLASS_SETTINGS_GLOBS = [
|
|
120
163
|
'.claude/settings.local.json',
|
|
@@ -131,6 +174,60 @@ function isRuntimePlanGateEnabled() {
|
|
|
131
174
|
const PR_THREAD_RESOLUTION_CLAIM_PATTERN = '(?:thread|review|comment).*?(?:resolved|verified|checked|addressed|fixed)|(?:resolved|verified|checked|addressed|fixed).*?(?:thread|review|comment)';
|
|
132
175
|
const PR_THREAD_RESOLUTION_REQUIRED_ACTIONS = ['pr_threads_checked', 'thread_resolution_verified'];
|
|
133
176
|
|
|
177
|
+
function commandScanText(command) {
|
|
178
|
+
return String(command || '').slice(0, MAX_COMMAND_SCAN_CHARS);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function commandWords(command) {
|
|
182
|
+
return commandScanText(command).toLowerCase().split(/\s+/).filter(Boolean);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function commandContainsSequence(words, sequence) {
|
|
186
|
+
if (!Array.isArray(words) || !Array.isArray(sequence) || sequence.length === 0) return false;
|
|
187
|
+
for (let i = 0; i <= words.length - sequence.length; i += 1) {
|
|
188
|
+
let matched = true;
|
|
189
|
+
for (let j = 0; j < sequence.length; j += 1) {
|
|
190
|
+
if (words[i + j] !== sequence[j]) {
|
|
191
|
+
matched = false;
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
if (matched) return true;
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function commandHasPostMethod(words) {
|
|
201
|
+
for (let i = 0; i < words.length; i += 1) {
|
|
202
|
+
const word = words[i];
|
|
203
|
+
if ((word === '-x' || word === '--method') && words[i + 1] === 'post') return true;
|
|
204
|
+
if (word === '--method=post' || word === '-xpost') return true;
|
|
205
|
+
}
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isGhApiPrCreateCommand(command) {
|
|
210
|
+
const words = commandWords(command);
|
|
211
|
+
if (!commandContainsSequence(words, ['gh', 'api'])) return false;
|
|
212
|
+
const hasPullsEndpoint = words.some((word) => word === '/pulls' || word.endsWith('/pulls'));
|
|
213
|
+
if (!hasPullsEndpoint) return false;
|
|
214
|
+
const fieldFlags = new Set(['-f', '--field', '--raw-field']);
|
|
215
|
+
const hasFieldWrite = words.some((word) => (
|
|
216
|
+
fieldFlags.has(word) ||
|
|
217
|
+
word.startsWith('-f=') ||
|
|
218
|
+
word.startsWith('--field=') ||
|
|
219
|
+
word.startsWith('--raw-field=')
|
|
220
|
+
));
|
|
221
|
+
return hasFieldWrite || commandHasPostMethod(words);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function isRecursiveChmodCommand(command) {
|
|
225
|
+
const words = commandWords(command);
|
|
226
|
+
const chmodIndex = words.indexOf('chmod');
|
|
227
|
+
if (chmodIndex === -1) return false;
|
|
228
|
+
return words.slice(chmodIndex + 1).includes('-r') || words.slice(chmodIndex + 1).some((word) => word.includes('r') && word.startsWith('-'));
|
|
229
|
+
}
|
|
230
|
+
|
|
134
231
|
// ---------------------------------------------------------------------------
|
|
135
232
|
// Config loading
|
|
136
233
|
// ---------------------------------------------------------------------------
|
|
@@ -227,6 +324,27 @@ function sanitizeGlobList(globs) {
|
|
|
227
324
|
return [...new Set(globs.map((glob) => normalizeGlob(glob)).filter(Boolean))];
|
|
228
325
|
}
|
|
229
326
|
|
|
327
|
+
// Affected files are compared as repo-relative paths (git --name-only output and
|
|
328
|
+
// inline paths are relative to the repo root). A caller who passes an ABSOLUTE
|
|
329
|
+
// allowedPath (e.g. "/Users/me/proj/src/**") therefore declares a glob that can never
|
|
330
|
+
// match — a silent no-op scope. When repoPath is known, rebase absolute globs that
|
|
331
|
+
// live under it to the repo-relative form so the scope actually applies. Globs already
|
|
332
|
+
// relative, or absolute but outside repoPath, are returned unchanged.
|
|
333
|
+
function rebaseGlobsToRepoRoot(globs, repoPath) {
|
|
334
|
+
// normalizeGlob strips both leading AND trailing slashes, so a repoPath with a
|
|
335
|
+
// trailing slash ("/Users/me/proj/") still matches the repo-relative globs.
|
|
336
|
+
const repoRel = normalizeGlob(repoPath);
|
|
337
|
+
if (!repoRel) return globs;
|
|
338
|
+
return globs.map((glob) => {
|
|
339
|
+
if (glob === repoRel) return '**';
|
|
340
|
+
if (glob.startsWith(`${repoRel}/`)) {
|
|
341
|
+
const rebased = glob.slice(repoRel.length + 1);
|
|
342
|
+
return rebased || '**';
|
|
343
|
+
}
|
|
344
|
+
return glob;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
230
348
|
function globToRegExp(glob) {
|
|
231
349
|
const normalized = normalizeGlob(glob);
|
|
232
350
|
let pattern = '^';
|
|
@@ -280,6 +398,9 @@ function loadGovernanceState() {
|
|
|
280
398
|
branchGovernance: raw && raw.branchGovernance && typeof raw.branchGovernance === 'object'
|
|
281
399
|
? raw.branchGovernance
|
|
282
400
|
: null,
|
|
401
|
+
workflowContract: raw && raw.workflowContract && typeof raw.workflowContract === 'object'
|
|
402
|
+
? raw.workflowContract
|
|
403
|
+
: null,
|
|
283
404
|
};
|
|
284
405
|
const now = Date.now();
|
|
285
406
|
const activeApprovals = state.protectedApprovals.filter((entry) => {
|
|
@@ -299,6 +420,7 @@ function saveGovernanceState(state) {
|
|
|
299
420
|
taskScope: state && state.taskScope ? state.taskScope : null,
|
|
300
421
|
protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
|
|
301
422
|
branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
|
|
423
|
+
workflowContract: state && state.workflowContract ? state.workflowContract : null,
|
|
302
424
|
};
|
|
303
425
|
saveJSON(module.exports.GOVERNANCE_STATE_PATH, next);
|
|
304
426
|
}
|
|
@@ -310,33 +432,39 @@ function setTaskScope(scopeInput = {}) {
|
|
|
310
432
|
taskScope: null,
|
|
311
433
|
protectedApprovals: currentState.protectedApprovals,
|
|
312
434
|
branchGovernance: currentState.branchGovernance,
|
|
435
|
+
workflowContract: null,
|
|
313
436
|
};
|
|
314
437
|
saveGovernanceState(cleared);
|
|
438
|
+
refreshLocalOnlyConstraint(cleared);
|
|
315
439
|
return null;
|
|
316
440
|
}
|
|
317
441
|
|
|
318
|
-
const
|
|
442
|
+
const repoPath = String(scopeInput.repoPath || '').trim() || null;
|
|
443
|
+
const allowedPaths = rebaseGlobsToRepoRoot(sanitizeGlobList(scopeInput.allowedPaths), repoPath);
|
|
319
444
|
if (allowedPaths.length === 0) {
|
|
320
445
|
throw new Error('allowedPaths must be a non-empty array');
|
|
321
446
|
}
|
|
322
447
|
|
|
323
|
-
const protectedPaths = sanitizeGlobList(
|
|
448
|
+
const protectedPaths = rebaseGlobsToRepoRoot(sanitizeGlobList(
|
|
324
449
|
Array.isArray(scopeInput.protectedPaths) && scopeInput.protectedPaths.length > 0
|
|
325
450
|
? scopeInput.protectedPaths
|
|
326
451
|
: DEFAULT_PROTECTED_FILE_GLOBS
|
|
327
|
-
);
|
|
452
|
+
), repoPath);
|
|
328
453
|
const taskScope = {
|
|
329
454
|
taskId: String(scopeInput.taskId || '').trim() || null,
|
|
330
455
|
summary: String(scopeInput.summary || '').trim() || null,
|
|
331
456
|
allowedPaths,
|
|
332
457
|
protectedPaths,
|
|
333
458
|
localOnly: scopeInput.localOnly === true,
|
|
334
|
-
repoPath
|
|
459
|
+
repoPath,
|
|
335
460
|
createdAt: new Date().toISOString(),
|
|
336
461
|
timestamp: Date.now(),
|
|
337
462
|
};
|
|
338
463
|
const state = loadGovernanceState();
|
|
339
464
|
state.taskScope = taskScope;
|
|
465
|
+
state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
|
|
466
|
+
? scopeInput.workflowContract
|
|
467
|
+
: null;
|
|
340
468
|
saveGovernanceState(state);
|
|
341
469
|
if (taskScope.localOnly) {
|
|
342
470
|
setConstraint('local_only', true);
|
|
@@ -409,6 +537,7 @@ function setBranchGovernance(input = {}) {
|
|
|
409
537
|
const state = loadGovernanceState();
|
|
410
538
|
state.branchGovernance = null;
|
|
411
539
|
saveGovernanceState(state);
|
|
540
|
+
refreshLocalOnlyConstraint(state);
|
|
412
541
|
return null;
|
|
413
542
|
}
|
|
414
543
|
|
|
@@ -459,6 +588,24 @@ function setConstraint(key, value) {
|
|
|
459
588
|
return constraints[key];
|
|
460
589
|
}
|
|
461
590
|
|
|
591
|
+
function clearConstraint(key) {
|
|
592
|
+
const constraints = loadConstraints();
|
|
593
|
+
delete constraints[key];
|
|
594
|
+
saveConstraints(constraints);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function refreshLocalOnlyConstraint(governanceState = loadGovernanceState()) {
|
|
598
|
+
const localOnlyActive = Boolean(
|
|
599
|
+
(governanceState.taskScope && governanceState.taskScope.localOnly) ||
|
|
600
|
+
(governanceState.branchGovernance && governanceState.branchGovernance.localOnly)
|
|
601
|
+
);
|
|
602
|
+
if (localOnlyActive) {
|
|
603
|
+
setConstraint('local_only', true);
|
|
604
|
+
} else {
|
|
605
|
+
clearConstraint('local_only');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
462
609
|
function isConditionSatisfied(conditionId) {
|
|
463
610
|
const state = loadState();
|
|
464
611
|
const entry = state[conditionId];
|
|
@@ -498,7 +645,29 @@ function loadStats() {
|
|
|
498
645
|
|
|
499
646
|
function saveStats(stats) { saveJSON(module.exports.STATS_PATH, stats); }
|
|
500
647
|
|
|
501
|
-
function
|
|
648
|
+
function buildGateActionFingerprint(gateId, options = {}) {
|
|
649
|
+
if (options.actionFingerprint) return String(options.actionFingerprint);
|
|
650
|
+
const toolName = options.toolName || options.tool_name || '';
|
|
651
|
+
const toolInput = options.toolInput || options.tool_input || {};
|
|
652
|
+
const parts = [toolName];
|
|
653
|
+
if (typeof toolInput === 'string') {
|
|
654
|
+
parts.push(toolInput);
|
|
655
|
+
} else if (toolInput && typeof toolInput === 'object') {
|
|
656
|
+
parts.push(
|
|
657
|
+
toolInput.command || '',
|
|
658
|
+
toolInput.cmd || '',
|
|
659
|
+
toolInput.file_path || '',
|
|
660
|
+
toolInput.path || '',
|
|
661
|
+
toolInput.description || '',
|
|
662
|
+
toolInput.prompt || '',
|
|
663
|
+
toolInput.pattern || '',
|
|
664
|
+
);
|
|
665
|
+
if (Array.isArray(toolInput.affectedFiles)) parts.push(...toolInput.affectedFiles);
|
|
666
|
+
}
|
|
667
|
+
return actionFingerprint(parts);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function recordStat(gateId, action, gate, options = {}) {
|
|
502
671
|
const stats = loadStats();
|
|
503
672
|
if (action === 'block') stats.blocked = (stats.blocked || 0) + 1;
|
|
504
673
|
else if (action === 'warn') stats.warned = (stats.warned || 0) + 1;
|
|
@@ -512,16 +681,25 @@ function recordStat(gateId, action, gate) {
|
|
|
512
681
|
else if (action === 'approve') stats.byGate[gateId].pendingApproval = (stats.byGate[gateId].pendingApproval || 0) + 1;
|
|
513
682
|
else if (action === 'log') stats.byGate[gateId].logged = (stats.byGate[gateId].logged || 0) + 1;
|
|
514
683
|
|
|
515
|
-
// Track
|
|
684
|
+
// Track same-action recurrence within a session for first-time fix rate.
|
|
685
|
+
// Gate-only recurrence over-counts noisy gates; repeats require a stable,
|
|
686
|
+
// sanitized action fingerprint.
|
|
516
687
|
if (action === 'block' || action === 'warn') {
|
|
517
688
|
if (!stats.sessionFiredGates) stats.sessionFiredGates = {};
|
|
689
|
+
if (!stats.sessionFiredActions) stats.sessionFiredActions = {};
|
|
518
690
|
const sessionKey = `session_${Math.floor(Date.now() / SESSION_ACTION_TTL_MS)}`;
|
|
519
691
|
if (!stats.sessionFiredGates[sessionKey]) stats.sessionFiredGates[sessionKey] = {};
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
stats.
|
|
692
|
+
stats.sessionFiredGates[sessionKey][gateId] = true;
|
|
693
|
+
|
|
694
|
+
const fingerprint = buildGateActionFingerprint(gateId, options);
|
|
695
|
+
if (fingerprint) {
|
|
696
|
+
if (!stats.sessionFiredActions[sessionKey]) stats.sessionFiredActions[sessionKey] = {};
|
|
697
|
+
if (!stats.sessionFiredActions[sessionKey][gateId]) stats.sessionFiredActions[sessionKey][gateId] = {};
|
|
698
|
+
if (stats.sessionFiredActions[sessionKey][gateId][fingerprint]) {
|
|
699
|
+
stats.recurringBlocks = (stats.recurringBlocks || 0) + 1;
|
|
700
|
+
} else {
|
|
701
|
+
stats.sessionFiredActions[sessionKey][gateId][fingerprint] = true;
|
|
702
|
+
}
|
|
525
703
|
}
|
|
526
704
|
}
|
|
527
705
|
|
|
@@ -737,7 +915,7 @@ function extractAffectedFiles(toolName, toolInput = {}) {
|
|
|
737
915
|
}
|
|
738
916
|
}
|
|
739
917
|
|
|
740
|
-
if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) ||
|
|
918
|
+
if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
|
|
741
919
|
for (const filePath of getBranchDiffFiles(repoRoot)) {
|
|
742
920
|
files.add(normalizePosix(filePath));
|
|
743
921
|
}
|
|
@@ -756,7 +934,7 @@ function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
|
|
|
756
934
|
const command = String(toolInput.command || '');
|
|
757
935
|
// Original high-risk pattern (git writes, publishes, destructive ops)
|
|
758
936
|
if (HIGH_RISK_BASH_PATTERN.test(command)) return true;
|
|
759
|
-
if (
|
|
937
|
+
if (isGhApiPrCreateCommand(command)) return true;
|
|
760
938
|
// Broadened: any Bash command that modifies files or has side effects.
|
|
761
939
|
// Excludes pure read/analysis commands (node --test, cat, ls, echo, etc.)
|
|
762
940
|
// to avoid false positives on benign operations.
|
|
@@ -995,7 +1173,7 @@ function getLocalOnlyScopeSources(governanceState = {}, constraints = {}) {
|
|
|
995
1173
|
function isRemoteSideEffectCommand(toolName, toolInput = {}) {
|
|
996
1174
|
if (toolName !== 'Bash') return false;
|
|
997
1175
|
const command = String(toolInput.command || '');
|
|
998
|
-
return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(command) ||
|
|
1176
|
+
return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(command) || isGhApiPrCreateCommand(command);
|
|
999
1177
|
}
|
|
1000
1178
|
|
|
1001
1179
|
function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governanceState = {}, constraints = {}) {
|
|
@@ -1018,7 +1196,7 @@ function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governa
|
|
|
1018
1196
|
}
|
|
1019
1197
|
|
|
1020
1198
|
function recordStructuralGateBlock(toolName, toolInput, result) {
|
|
1021
|
-
recordStat(result.gate, 'block');
|
|
1199
|
+
recordStat(result.gate, 'block', null, { toolName, toolInput });
|
|
1022
1200
|
const auditRecord = recordAuditEvent({
|
|
1023
1201
|
toolName,
|
|
1024
1202
|
toolInput,
|
|
@@ -1379,7 +1557,7 @@ function matchesGate(gate, toolName, toolInput) {
|
|
|
1379
1557
|
function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
|
|
1380
1558
|
if (toolName !== 'Bash') return false;
|
|
1381
1559
|
const command = String(toolInput.command || '').trim();
|
|
1382
|
-
if (!command ||
|
|
1560
|
+
if (!command || isRecursiveChmodCommand(command)) return false;
|
|
1383
1561
|
if (/[;&|`$()<>*?[\]{}]/.test(command)) return false;
|
|
1384
1562
|
|
|
1385
1563
|
const match = command.match(/(?:^|\s)chmod\s+(?:-[fv]\s+)?0?([46]00)\s+(['"]?)(\S+)\2\s*$/i);
|
|
@@ -1387,7 +1565,7 @@ function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
|
|
|
1387
1565
|
|
|
1388
1566
|
const target = match[3];
|
|
1389
1567
|
if (!target || target === '/' || target === '~') return false;
|
|
1390
|
-
if (
|
|
1568
|
+
if (target.includes('..')) return false;
|
|
1391
1569
|
|
|
1392
1570
|
const normalized = target.replace(/^['"]|['"]$/g, '').toLowerCase();
|
|
1393
1571
|
const looksLikeCredentialPath = /(?:^|\/)(?:\.config|\.ssh|\.gnupg|\.aws|\.gcloud|\.gemini|\.resume_secrets|\.thumbgate|secrets?|credentials?)(?:\/|$)/.test(normalized)
|
|
@@ -1400,6 +1578,16 @@ function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
|
|
|
1400
1578
|
function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
1401
1579
|
const affected = extractAffectedFiles(toolName, toolInput);
|
|
1402
1580
|
const affectedFiles = affected.files;
|
|
1581
|
+
if (isSafeSecretStorageWrite(toolName, toolInput, process.cwd())) {
|
|
1582
|
+
return null;
|
|
1583
|
+
}
|
|
1584
|
+
// Hardening a credential file's permissions (chmod 600 on a key/secret path) is
|
|
1585
|
+
// a safety action, not a risk. The same exemption already guards the
|
|
1586
|
+
// permission-change-approval gate; without it here, `chmod 600 ~/.resume_secrets/key`
|
|
1587
|
+
// gets hard-denied by recurring-negative-memory matching — the opposite of intent.
|
|
1588
|
+
if (isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
|
|
1589
|
+
return null;
|
|
1590
|
+
}
|
|
1403
1591
|
if (!isHighRiskAction(toolName, toolInput, affectedFiles)) {
|
|
1404
1592
|
return null;
|
|
1405
1593
|
}
|
|
@@ -1414,7 +1602,7 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
|
1414
1602
|
|
|
1415
1603
|
const command = String(toolInput.command || '');
|
|
1416
1604
|
const isPrCreateCommand = toolName === 'Bash' && (
|
|
1417
|
-
/\bgh\s+pr\s+create\b/i.test(command) ||
|
|
1605
|
+
/\bgh\s+pr\s+create\b/i.test(command) || isGhApiPrCreateCommand(command)
|
|
1418
1606
|
);
|
|
1419
1607
|
if (isPrCreateCommand && isConditionSatisfied('pr_create_allowed')) {
|
|
1420
1608
|
const branchGovernanceViolation = buildBranchGovernanceViolation(
|
|
@@ -1431,7 +1619,7 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
|
1431
1619
|
|
|
1432
1620
|
if (toolName === 'Bash' && (
|
|
1433
1621
|
/\b(?:gh\s+pr\s+(?:create|merge)|gh\s+release\s+create|git\s+tag\b|(?:npm|yarn|pnpm)\s+publish\b)\b/i.test(command) ||
|
|
1434
|
-
|
|
1622
|
+
isGhApiPrCreateCommand(command)
|
|
1435
1623
|
)) {
|
|
1436
1624
|
const branchGovernanceViolation = buildBranchGovernanceViolation(
|
|
1437
1625
|
governanceState,
|
|
@@ -1470,7 +1658,14 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
|
|
|
1470
1658
|
filePath: toolInput.file_path || toolInput.path || null,
|
|
1471
1659
|
affectedFiles,
|
|
1472
1660
|
});
|
|
1473
|
-
|
|
1661
|
+
// Claw/hybrid support: pass context if agent provides claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
|
|
1662
|
+
let guard;
|
|
1663
|
+
if (toolInput && (toolInput.clawContext || toolInput._claw || toolInput.hybridRoute || toolInput.agentId)) {
|
|
1664
|
+
const clawCtx = toolInput.clawContext || toolInput._claw || { actionType: toolInput.actionType || 'unknown', agentId: toolInput.agentId || 'unknown', hybridRoute: toolInput.hybridRoute || 'unknown' };
|
|
1665
|
+
guard = hybrid.evaluateClawPretool ? hybrid.evaluateClawPretool(toolName, serializedInput, clawCtx) : hybrid.evaluatePretool(toolName, serializedInput);
|
|
1666
|
+
} else {
|
|
1667
|
+
guard = hybrid.evaluatePretool(toolName, serializedInput);
|
|
1668
|
+
}
|
|
1474
1669
|
if (!guard || guard.mode === 'allow') {
|
|
1475
1670
|
return null;
|
|
1476
1671
|
}
|
|
@@ -1623,7 +1818,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1623
1818
|
|
|
1624
1819
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
1625
1820
|
if (pendingThreadResolutionGate) {
|
|
1626
|
-
recordStat(pendingThreadResolutionGate.gate, 'block');
|
|
1821
|
+
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
1627
1822
|
const auditRecord = recordAuditEvent({
|
|
1628
1823
|
toolName,
|
|
1629
1824
|
toolInput,
|
|
@@ -1639,7 +1834,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1639
1834
|
|
|
1640
1835
|
const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
|
|
1641
1836
|
if (boostedRiskGuard) {
|
|
1642
|
-
recordStat(boostedRiskGuard.gate, 'block');
|
|
1837
|
+
recordStat(boostedRiskGuard.gate, 'block', null, { toolName, toolInput });
|
|
1643
1838
|
const auditRecord = recordAuditEvent({
|
|
1644
1839
|
toolName,
|
|
1645
1840
|
toolInput,
|
|
@@ -1659,13 +1854,13 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1659
1854
|
if (isRuntimePlanGateEnabled()) {
|
|
1660
1855
|
const planGate = evaluatePlanGate(toolName, toolInput);
|
|
1661
1856
|
if (planGate) {
|
|
1662
|
-
recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn');
|
|
1857
|
+
recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
|
|
1663
1858
|
return planGate;
|
|
1664
1859
|
}
|
|
1665
1860
|
|
|
1666
1861
|
const trajectory = getTrajectoryScore();
|
|
1667
1862
|
if (trajectory.isDrifting) {
|
|
1668
|
-
recordStat('strategic-drift', 'block');
|
|
1863
|
+
recordStat('strategic-drift', 'block', null, { toolName, toolInput });
|
|
1669
1864
|
return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
|
|
1670
1865
|
}
|
|
1671
1866
|
}
|
|
@@ -1719,12 +1914,12 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1719
1914
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
1720
1915
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
1721
1916
|
if (cappedResult) {
|
|
1722
|
-
recordStat(gate.id, 'warn', gate);
|
|
1917
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
1723
1918
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
|
|
1724
1919
|
auditToFeedback(auditRecord);
|
|
1725
1920
|
return cappedResult;
|
|
1726
1921
|
}
|
|
1727
|
-
recordStat(gate.id, 'block', gate);
|
|
1922
|
+
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
1728
1923
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1729
1924
|
auditToFeedback(auditRecord);
|
|
1730
1925
|
return denyResult;
|
|
@@ -1733,13 +1928,13 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1733
1928
|
if (gate.action === 'approve') {
|
|
1734
1929
|
const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
1735
1930
|
if (approvalEnabled) {
|
|
1736
|
-
recordStat(gate.id, 'approve', gate);
|
|
1931
|
+
recordStat(gate.id, 'approve', gate, { toolName, toolInput });
|
|
1737
1932
|
const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
|
|
1738
1933
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1739
1934
|
auditToFeedback(auditRecord);
|
|
1740
1935
|
return result;
|
|
1741
1936
|
}
|
|
1742
|
-
recordStat(gate.id, 'warn', gate);
|
|
1937
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
1743
1938
|
const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
|
|
1744
1939
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1745
1940
|
auditToFeedback(auditRecord);
|
|
@@ -1747,7 +1942,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1747
1942
|
}
|
|
1748
1943
|
|
|
1749
1944
|
if (gate.action === 'log') {
|
|
1750
|
-
recordStat(gate.id, 'log', gate);
|
|
1945
|
+
recordStat(gate.id, 'log', gate, { toolName, toolInput });
|
|
1751
1946
|
const result = { decision: 'log', gate: gate.id, message, severity: gate.severity, reasoning, logged: true };
|
|
1752
1947
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1753
1948
|
auditToFeedback(auditRecord);
|
|
@@ -1756,7 +1951,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1756
1951
|
}
|
|
1757
1952
|
|
|
1758
1953
|
if (gate.action === 'warn') {
|
|
1759
|
-
recordStat(gate.id, 'warn', gate);
|
|
1954
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
1760
1955
|
const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
1761
1956
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1762
1957
|
auditToFeedback(auditRecord);
|
|
@@ -1764,14 +1959,15 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1764
1959
|
}
|
|
1765
1960
|
}
|
|
1766
1961
|
|
|
1767
|
-
const
|
|
1962
|
+
const skipAdvisoryGuards = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
|
|
1963
|
+
const sentinelReport = skipAdvisoryGuards ? null : evaluateWorkflowSentinel(toolName, toolInput, {
|
|
1768
1964
|
governanceState,
|
|
1769
1965
|
});
|
|
1770
1966
|
const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
|
|
1771
1967
|
const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
|
|
1772
1968
|
if (memoryGuard) {
|
|
1773
1969
|
const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
|
|
1774
|
-
recordStat(enrichedMemoryGuard.gate, 'block');
|
|
1970
|
+
recordStat(enrichedMemoryGuard.gate, 'block', null, { toolName, toolInput });
|
|
1775
1971
|
recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
|
|
1776
1972
|
const auditRecord = recordAuditEvent({
|
|
1777
1973
|
toolName,
|
|
@@ -1788,7 +1984,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
|
|
|
1788
1984
|
|
|
1789
1985
|
if (sentinelReport && sentinelReport.decision !== 'allow') {
|
|
1790
1986
|
const sentinelResult = buildSentinelGateResult(sentinelReport);
|
|
1791
|
-
recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
|
|
1987
|
+
recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
|
|
1792
1988
|
recordSentinelBlockDecision(sentinelDecision, sentinelResult);
|
|
1793
1989
|
const auditRecord = recordAuditEvent({
|
|
1794
1990
|
toolName,
|
|
@@ -1849,7 +2045,7 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1849
2045
|
|
|
1850
2046
|
const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
|
|
1851
2047
|
if (pendingThreadResolutionGate) {
|
|
1852
|
-
recordStat(pendingThreadResolutionGate.gate, 'block');
|
|
2048
|
+
recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
|
|
1853
2049
|
const auditRecord = recordAuditEvent({
|
|
1854
2050
|
toolName,
|
|
1855
2051
|
toolInput,
|
|
@@ -1865,7 +2061,7 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1865
2061
|
|
|
1866
2062
|
const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
|
|
1867
2063
|
if (boostedRiskGuard) {
|
|
1868
|
-
recordStat(boostedRiskGuard.gate, 'block');
|
|
2064
|
+
recordStat(boostedRiskGuard.gate, 'block', null, { toolName, toolInput });
|
|
1869
2065
|
const auditRecord = recordAuditEvent({
|
|
1870
2066
|
toolName,
|
|
1871
2067
|
toolInput,
|
|
@@ -1885,13 +2081,13 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1885
2081
|
if (isRuntimePlanGateEnabled()) {
|
|
1886
2082
|
const planGate = evaluatePlanGate(toolName, toolInput);
|
|
1887
2083
|
if (planGate) {
|
|
1888
|
-
recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn');
|
|
2084
|
+
recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
|
|
1889
2085
|
return planGate;
|
|
1890
2086
|
}
|
|
1891
2087
|
|
|
1892
2088
|
const trajectory = getTrajectoryScore();
|
|
1893
2089
|
if (trajectory.isDrifting) {
|
|
1894
|
-
recordStat('strategic-drift', 'block');
|
|
2090
|
+
recordStat('strategic-drift', 'block', null, { toolName, toolInput });
|
|
1895
2091
|
return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
|
|
1896
2092
|
}
|
|
1897
2093
|
}
|
|
@@ -1918,12 +2114,12 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1918
2114
|
// Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
|
|
1919
2115
|
const cappedResult = applyDailyBlockCap(denyResult);
|
|
1920
2116
|
if (cappedResult) {
|
|
1921
|
-
recordStat(gate.id, 'warn', gate);
|
|
2117
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
1922
2118
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
|
|
1923
2119
|
auditToFeedback(auditRecord);
|
|
1924
2120
|
return cappedResult;
|
|
1925
2121
|
}
|
|
1926
|
-
recordStat(gate.id, 'block', gate);
|
|
2122
|
+
recordStat(gate.id, 'block', gate, { toolName, toolInput });
|
|
1927
2123
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1928
2124
|
auditToFeedback(auditRecord);
|
|
1929
2125
|
return denyResult;
|
|
@@ -1932,13 +2128,13 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1932
2128
|
if (gate.action === 'approve') {
|
|
1933
2129
|
const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
|
|
1934
2130
|
if (approvalEnabled) {
|
|
1935
|
-
recordStat(gate.id, 'approve', gate);
|
|
2131
|
+
recordStat(gate.id, 'approve', gate, { toolName, toolInput });
|
|
1936
2132
|
const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
|
|
1937
2133
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1938
2134
|
auditToFeedback(auditRecord);
|
|
1939
2135
|
return result;
|
|
1940
2136
|
}
|
|
1941
|
-
recordStat(gate.id, 'warn', gate);
|
|
2137
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
1942
2138
|
const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
|
|
1943
2139
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1944
2140
|
auditToFeedback(auditRecord);
|
|
@@ -1946,7 +2142,7 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1946
2142
|
}
|
|
1947
2143
|
|
|
1948
2144
|
if (gate.action === 'log') {
|
|
1949
|
-
recordStat(gate.id, 'log', gate);
|
|
2145
|
+
recordStat(gate.id, 'log', gate, { toolName, toolInput });
|
|
1950
2146
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1951
2147
|
auditToFeedback(auditRecord);
|
|
1952
2148
|
// 'log' action allows the tool call to proceed — continue to next gate
|
|
@@ -1954,7 +2150,7 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1954
2150
|
}
|
|
1955
2151
|
|
|
1956
2152
|
if (gate.action === 'warn') {
|
|
1957
|
-
recordStat(gate.id, 'warn', gate);
|
|
2153
|
+
recordStat(gate.id, 'warn', gate, { toolName, toolInput });
|
|
1958
2154
|
const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
|
|
1959
2155
|
const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
|
|
1960
2156
|
auditToFeedback(auditRecord);
|
|
@@ -1962,14 +2158,15 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1962
2158
|
}
|
|
1963
2159
|
}
|
|
1964
2160
|
|
|
1965
|
-
const
|
|
2161
|
+
const skipAdvisoryGuards = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
|
|
2162
|
+
const sentinelReport = skipAdvisoryGuards ? null : evaluateWorkflowSentinel(toolName, toolInput, {
|
|
1966
2163
|
governanceState,
|
|
1967
2164
|
});
|
|
1968
2165
|
const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
|
|
1969
2166
|
const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
|
|
1970
2167
|
if (memoryGuard) {
|
|
1971
2168
|
const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
|
|
1972
|
-
recordStat(enrichedMemoryGuard.gate, 'block');
|
|
2169
|
+
recordStat(enrichedMemoryGuard.gate, 'block', null, { toolName, toolInput });
|
|
1973
2170
|
recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
|
|
1974
2171
|
const auditRecord = recordAuditEvent({
|
|
1975
2172
|
toolName,
|
|
@@ -1986,7 +2183,7 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
1986
2183
|
|
|
1987
2184
|
if (sentinelReport && sentinelReport.decision !== 'allow') {
|
|
1988
2185
|
const sentinelResult = buildSentinelGateResult(sentinelReport);
|
|
1989
|
-
recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
|
|
2186
|
+
recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
|
|
1990
2187
|
recordSentinelBlockDecision(sentinelDecision, sentinelResult);
|
|
1991
2188
|
const auditRecord = recordAuditEvent({
|
|
1992
2189
|
toolName,
|
|
@@ -2006,14 +2203,43 @@ function evaluateGates(toolName, toolInput, configPath) {
|
|
|
2006
2203
|
return null;
|
|
2007
2204
|
}
|
|
2008
2205
|
|
|
2009
|
-
|
|
2206
|
+
// Turn a secret-exfiltration block into actionable guidance that names the
|
|
2207
|
+
// safe path, instead of a dead-end that drives agents toward brittle
|
|
2208
|
+
// workarounds (e.g. writing secrets to /tmp). The vault dirs referenced here
|
|
2209
|
+
// are the SAME constant the scanner whitelists, so the hint can never drift
|
|
2210
|
+
// from enforcement.
|
|
2211
|
+
function buildSecretRemediation(toolName = '', toolInput = {}) {
|
|
2212
|
+
const vaultDirs = (SAFE_SECRET_STORAGE_DIRS || []).map((dir) => `~/${dir}`);
|
|
2213
|
+
const primaryVault = vaultDirs[0] || '~/.resume_secrets';
|
|
2214
|
+
const vaultList = vaultDirs.join(', ') || primaryVault;
|
|
2215
|
+
|
|
2216
|
+
if (EDIT_LIKE_TOOLS && EDIT_LIKE_TOOLS.has(toolName)) {
|
|
2217
|
+
const target = toolInput.file_path || toolInput.path || toolInput.filePath || toolInput.target_path;
|
|
2218
|
+
const where = target ? ` (you targeted ${redactText(String(target))})` : '';
|
|
2219
|
+
return `To store this secret safely, write it with the Write/Edit tool to a file under ${vaultList}${where}. `
|
|
2220
|
+
+ `Those locations are whitelisted for secret storage and will NOT be blocked. `
|
|
2221
|
+
+ `Do not route around this by writing to /tmp or another path — that leaves the secret in a world-readable location and does not make it safe.`;
|
|
2222
|
+
}
|
|
2223
|
+
|
|
2224
|
+
if (toolName === 'Bash') {
|
|
2225
|
+
return `Do not inline a live secret literal into a shell command — it leaks into shell history and process args. `
|
|
2226
|
+
+ `Instead, store the value with the Write tool to a file under ${vaultList}, then reference it via an environment variable or by reading that file at runtime.`;
|
|
2227
|
+
}
|
|
2228
|
+
|
|
2229
|
+
return `Store secrets in the whitelisted vault (${vaultList}) using the Write tool rather than passing the literal through this action.`;
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
function buildSecretGuardResult(scanResult, context = {}) {
|
|
2233
|
+
const remediation = buildSecretRemediation(context.toolName, context.toolInput || {});
|
|
2234
|
+
const summary = buildSafeSummary(
|
|
2235
|
+
scanResult.findings,
|
|
2236
|
+
'Blocked because the action appears to expose secret material'
|
|
2237
|
+
);
|
|
2010
2238
|
return {
|
|
2011
2239
|
decision: 'deny',
|
|
2012
2240
|
gate: 'secret-exfiltration',
|
|
2013
|
-
message:
|
|
2014
|
-
|
|
2015
|
-
'Blocked because the action appears to expose secret material'
|
|
2016
|
-
),
|
|
2241
|
+
message: `${summary}. ${remediation}`,
|
|
2242
|
+
remediation,
|
|
2017
2243
|
severity: 'critical',
|
|
2018
2244
|
secretScan: {
|
|
2019
2245
|
provider: scanResult.provider,
|
|
@@ -2092,9 +2318,15 @@ function evaluateSecretGuard(input = {}) {
|
|
|
2092
2318
|
if (!scanResult.detected) {
|
|
2093
2319
|
return null;
|
|
2094
2320
|
}
|
|
2095
|
-
recordStat('secret-exfiltration', 'block'
|
|
2321
|
+
recordStat('secret-exfiltration', 'block', null, {
|
|
2322
|
+
toolName: input.tool_name || input.toolName || 'unknown',
|
|
2323
|
+
toolInput: input.tool_input || {},
|
|
2324
|
+
});
|
|
2096
2325
|
recordSecretViolation(input, scanResult);
|
|
2097
|
-
const result = buildSecretGuardResult(scanResult
|
|
2326
|
+
const result = buildSecretGuardResult(scanResult, {
|
|
2327
|
+
toolName: input.tool_name || input.toolName,
|
|
2328
|
+
toolInput: input.tool_input || {},
|
|
2329
|
+
});
|
|
2098
2330
|
// Audit trail: record secret guard denial
|
|
2099
2331
|
const auditRecord = recordAuditEvent({
|
|
2100
2332
|
toolName: input.tool_name || input.toolName || 'unknown',
|
|
@@ -2422,7 +2654,7 @@ function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
|
|
|
2422
2654
|
const message = `Knowledge conflict warning: retrieved lessons disagree for this action (entropy ${entropy}). Treat the reminders below as cautionary context, but do not stop unrelated work solely because memory is noisy.`;
|
|
2423
2655
|
|
|
2424
2656
|
if (isKnowledgeConflictHardBlockAction(toolName, toolInput)) {
|
|
2425
|
-
recordStat('retrieval_entropy_high', 'block');
|
|
2657
|
+
recordStat('retrieval_entropy_high', 'block', null, { toolName, toolInput });
|
|
2426
2658
|
return {
|
|
2427
2659
|
decision: 'deny',
|
|
2428
2660
|
gate: 'knowledge-conflict-gate',
|
|
@@ -2431,7 +2663,7 @@ function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
|
|
|
2431
2663
|
};
|
|
2432
2664
|
}
|
|
2433
2665
|
|
|
2434
|
-
recordStat('retrieval_entropy_high', 'warn');
|
|
2666
|
+
recordStat('retrieval_entropy_high', 'warn', null, { toolName, toolInput });
|
|
2435
2667
|
return mergeContextStrings(`[ThumbGate] ${message}`, lessonContext);
|
|
2436
2668
|
}
|
|
2437
2669
|
|
|
@@ -2443,7 +2675,7 @@ function extractActionContext(toolName, toolInput) {
|
|
|
2443
2675
|
if (toolInput.description) parts.push(String(toolInput.description).slice(0, 200));
|
|
2444
2676
|
if (toolInput.prompt) parts.push(String(toolInput.prompt).slice(0, 400));
|
|
2445
2677
|
if (toolInput.pattern) parts.push(String(toolInput.pattern).slice(0, 200));
|
|
2446
|
-
return parts.filter(Boolean).join(' ');
|
|
2678
|
+
return sanitizeFeedbackText(parts.filter(Boolean).join(' ')) || toolName;
|
|
2447
2679
|
}
|
|
2448
2680
|
|
|
2449
2681
|
function extractAvoidanceAdvice(content) {
|
|
@@ -2472,10 +2704,11 @@ async function runAsync(input) {
|
|
|
2472
2704
|
|
|
2473
2705
|
const toolName = input.tool_name || '';
|
|
2474
2706
|
const toolInput = input.tool_input || {};
|
|
2707
|
+
const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
|
|
2475
2708
|
|
|
2476
2709
|
const sequenceGuard = evaluateSequenceState(toolName, toolInput);
|
|
2477
2710
|
if (sequenceGuard && sequenceGuard.decision === 'deny') {
|
|
2478
|
-
return formatOutput(sequenceGuard);
|
|
2711
|
+
return formatOutput(applyEnforcementPosture(sequenceGuard));
|
|
2479
2712
|
}
|
|
2480
2713
|
|
|
2481
2714
|
const result = await evaluateGatesAsync(toolName, toolInput);
|
|
@@ -2491,16 +2724,16 @@ async function runAsync(input) {
|
|
|
2491
2724
|
}
|
|
2492
2725
|
|
|
2493
2726
|
|
|
2494
|
-
const behavioralContext = buildBehavioralContext();
|
|
2495
|
-
const lessonContext = await buildRelevantLessonContextAsync(toolName, toolInput);
|
|
2727
|
+
const behavioralContext = safeSecretStorageWrite ? null : buildBehavioralContext();
|
|
2728
|
+
const lessonContext = safeSecretStorageWrite ? null : await buildRelevantLessonContextAsync(toolName, toolInput);
|
|
2496
2729
|
|
|
2497
2730
|
if (lessonContext && lessonContext.decision === "deny") {
|
|
2498
|
-
return formatOutput(lessonContext);
|
|
2731
|
+
return formatOutput(applyEnforcementPosture(lessonContext));
|
|
2499
2732
|
}
|
|
2500
2733
|
|
|
2501
2734
|
const recentContext = buildRecentCorrectiveActionsContext();
|
|
2502
2735
|
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
2503
|
-
return formatOutput(result, combinedContext);
|
|
2736
|
+
return formatOutput(applyEnforcementPosture(result), combinedContext);
|
|
2504
2737
|
|
|
2505
2738
|
}
|
|
2506
2739
|
|
|
@@ -2518,10 +2751,11 @@ function run(input) {
|
|
|
2518
2751
|
|
|
2519
2752
|
const toolName = input.tool_name || '';
|
|
2520
2753
|
const toolInput = input.tool_input || {};
|
|
2754
|
+
const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
|
|
2521
2755
|
|
|
2522
2756
|
const sequenceGuard = evaluateSequenceState(toolName, toolInput);
|
|
2523
2757
|
if (sequenceGuard && sequenceGuard.decision === 'deny') {
|
|
2524
|
-
return formatOutput(sequenceGuard);
|
|
2758
|
+
return formatOutput(applyEnforcementPosture(sequenceGuard));
|
|
2525
2759
|
}
|
|
2526
2760
|
|
|
2527
2761
|
const result = evaluateGates(toolName, toolInput);
|
|
@@ -2537,16 +2771,16 @@ function run(input) {
|
|
|
2537
2771
|
}
|
|
2538
2772
|
|
|
2539
2773
|
|
|
2540
|
-
const behavioralContext = buildBehavioralContext();
|
|
2541
|
-
const lessonContext = buildRelevantLessonContext(toolName, toolInput);
|
|
2774
|
+
const behavioralContext = safeSecretStorageWrite ? null : buildBehavioralContext();
|
|
2775
|
+
const lessonContext = safeSecretStorageWrite ? null : buildRelevantLessonContext(toolName, toolInput);
|
|
2542
2776
|
|
|
2543
2777
|
if (lessonContext && lessonContext.decision === "deny") {
|
|
2544
|
-
return formatOutput(lessonContext);
|
|
2778
|
+
return formatOutput(applyEnforcementPosture(lessonContext));
|
|
2545
2779
|
}
|
|
2546
2780
|
|
|
2547
2781
|
const recentContext = buildRecentCorrectiveActionsContext();
|
|
2548
2782
|
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
2549
|
-
return formatOutput(result, combinedContext);
|
|
2783
|
+
return formatOutput(applyEnforcementPosture(result), combinedContext);
|
|
2550
2784
|
|
|
2551
2785
|
}
|
|
2552
2786
|
|