thumbgate 1.26.8 → 1.27.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.well-known/agentic-verify.txt +1 -0
  4. package/.well-known/llms.txt +2 -0
  5. package/.well-known/mcp/server-card.json +1 -1
  6. package/README.md +20 -9
  7. package/adapters/claude/.mcp.json +2 -2
  8. package/adapters/gcp/dfcx-webhook-gate.js +295 -0
  9. package/adapters/mcp/server-stdio.js +28 -1
  10. package/adapters/opencode/opencode.json +1 -1
  11. package/bench/thumbgate-bench.json +2 -2
  12. package/bin/cli.js +132 -7
  13. package/bin/dashboard-cli.js +7 -0
  14. package/config/gate-classifier-routing.json +98 -0
  15. package/config/gate-templates.json +60 -0
  16. package/config/mcp-allowlists.json +8 -7
  17. package/config/model-candidates.json +71 -6
  18. package/package.json +26 -10
  19. package/public/chatgpt-app.html +330 -0
  20. package/public/codex-plugin.html +66 -14
  21. package/public/dashboard.html +203 -17
  22. package/public/index.html +79 -4
  23. package/public/learn.html +70 -0
  24. package/public/lessons.html +129 -6
  25. package/public/numbers.html +2 -2
  26. package/public/pricing.html +20 -2
  27. package/scripts/agent-operations-planner.js +621 -0
  28. package/scripts/agent-reward-model.js +53 -1
  29. package/scripts/ai-component-inventory.js +367 -0
  30. package/scripts/classifier-routing.js +130 -0
  31. package/scripts/cli-schema.js +26 -0
  32. package/scripts/dashboard-chat.js +64 -17
  33. package/scripts/feedback-sanitizer.js +105 -0
  34. package/scripts/gates-engine.js +258 -61
  35. package/scripts/hybrid-feedback-context.js +141 -7
  36. package/scripts/memory-scope-readiness.js +159 -0
  37. package/scripts/parallel-workflow-orchestrator.js +293 -0
  38. package/scripts/plausible-domain-config.js +86 -0
  39. package/scripts/plausible-server-events.js +4 -2
  40. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  41. package/scripts/qa-scenario-planner.js +136 -0
  42. package/scripts/repeat-metric.js +28 -12
  43. package/scripts/secret-fixture-tokens.js +61 -0
  44. package/scripts/secret-scanner.js +44 -5
  45. package/scripts/security-scanner.js +80 -0
  46. package/scripts/seo-gsd.js +53 -0
  47. package/scripts/thumbgate-bench.js +16 -1
  48. package/scripts/tool-registry.js +37 -0
  49. package/scripts/workflow-sentinel.js +189 -4
  50. package/src/api/server.js +276 -10
@@ -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,7 +115,7 @@ 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 GH_API_PR_CREATE_PATTERN = /\bgh\s+api\b(?=.*(?:\/pulls\b|repos\/[^\s]+\/[^\s]+\/pulls\b))(?=.*(?:-f\b|--field\b|-F\b|--raw-field\b|--method\s+POST\b|-X\s+POST\b))/i;
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';
@@ -131,6 +137,60 @@ function isRuntimePlanGateEnabled() {
131
137
  const PR_THREAD_RESOLUTION_CLAIM_PATTERN = '(?:thread|review|comment).*?(?:resolved|verified|checked|addressed|fixed)|(?:resolved|verified|checked|addressed|fixed).*?(?:thread|review|comment)';
132
138
  const PR_THREAD_RESOLUTION_REQUIRED_ACTIONS = ['pr_threads_checked', 'thread_resolution_verified'];
133
139
 
140
+ function commandScanText(command) {
141
+ return String(command || '').slice(0, MAX_COMMAND_SCAN_CHARS);
142
+ }
143
+
144
+ function commandWords(command) {
145
+ return commandScanText(command).toLowerCase().split(/\s+/).filter(Boolean);
146
+ }
147
+
148
+ function commandContainsSequence(words, sequence) {
149
+ if (!Array.isArray(words) || !Array.isArray(sequence) || sequence.length === 0) return false;
150
+ for (let i = 0; i <= words.length - sequence.length; i += 1) {
151
+ let matched = true;
152
+ for (let j = 0; j < sequence.length; j += 1) {
153
+ if (words[i + j] !== sequence[j]) {
154
+ matched = false;
155
+ break;
156
+ }
157
+ }
158
+ if (matched) return true;
159
+ }
160
+ return false;
161
+ }
162
+
163
+ function commandHasPostMethod(words) {
164
+ for (let i = 0; i < words.length; i += 1) {
165
+ const word = words[i];
166
+ if ((word === '-x' || word === '--method') && words[i + 1] === 'post') return true;
167
+ if (word === '--method=post' || word === '-xpost') return true;
168
+ }
169
+ return false;
170
+ }
171
+
172
+ function isGhApiPrCreateCommand(command) {
173
+ const words = commandWords(command);
174
+ if (!commandContainsSequence(words, ['gh', 'api'])) return false;
175
+ const hasPullsEndpoint = words.some((word) => word === '/pulls' || word.endsWith('/pulls'));
176
+ if (!hasPullsEndpoint) return false;
177
+ const fieldFlags = new Set(['-f', '--field', '--raw-field']);
178
+ const hasFieldWrite = words.some((word) => (
179
+ fieldFlags.has(word) ||
180
+ word.startsWith('-f=') ||
181
+ word.startsWith('--field=') ||
182
+ word.startsWith('--raw-field=')
183
+ ));
184
+ return hasFieldWrite || commandHasPostMethod(words);
185
+ }
186
+
187
+ function isRecursiveChmodCommand(command) {
188
+ const words = commandWords(command);
189
+ const chmodIndex = words.indexOf('chmod');
190
+ if (chmodIndex === -1) return false;
191
+ return words.slice(chmodIndex + 1).includes('-r') || words.slice(chmodIndex + 1).some((word) => word.includes('r') && word.startsWith('-'));
192
+ }
193
+
134
194
  // ---------------------------------------------------------------------------
135
195
  // Config loading
136
196
  // ---------------------------------------------------------------------------
@@ -227,6 +287,27 @@ function sanitizeGlobList(globs) {
227
287
  return [...new Set(globs.map((glob) => normalizeGlob(glob)).filter(Boolean))];
228
288
  }
229
289
 
290
+ // Affected files are compared as repo-relative paths (git --name-only output and
291
+ // inline paths are relative to the repo root). A caller who passes an ABSOLUTE
292
+ // allowedPath (e.g. "/Users/me/proj/src/**") therefore declares a glob that can never
293
+ // match — a silent no-op scope. When repoPath is known, rebase absolute globs that
294
+ // live under it to the repo-relative form so the scope actually applies. Globs already
295
+ // relative, or absolute but outside repoPath, are returned unchanged.
296
+ function rebaseGlobsToRepoRoot(globs, repoPath) {
297
+ // normalizeGlob strips both leading AND trailing slashes, so a repoPath with a
298
+ // trailing slash ("/Users/me/proj/") still matches the repo-relative globs.
299
+ const repoRel = normalizeGlob(repoPath);
300
+ if (!repoRel) return globs;
301
+ return globs.map((glob) => {
302
+ if (glob === repoRel) return '**';
303
+ if (glob.startsWith(`${repoRel}/`)) {
304
+ const rebased = glob.slice(repoRel.length + 1);
305
+ return rebased || '**';
306
+ }
307
+ return glob;
308
+ });
309
+ }
310
+
230
311
  function globToRegExp(glob) {
231
312
  const normalized = normalizeGlob(glob);
232
313
  let pattern = '^';
@@ -280,6 +361,9 @@ function loadGovernanceState() {
280
361
  branchGovernance: raw && raw.branchGovernance && typeof raw.branchGovernance === 'object'
281
362
  ? raw.branchGovernance
282
363
  : null,
364
+ workflowContract: raw && raw.workflowContract && typeof raw.workflowContract === 'object'
365
+ ? raw.workflowContract
366
+ : null,
283
367
  };
284
368
  const now = Date.now();
285
369
  const activeApprovals = state.protectedApprovals.filter((entry) => {
@@ -299,6 +383,7 @@ function saveGovernanceState(state) {
299
383
  taskScope: state && state.taskScope ? state.taskScope : null,
300
384
  protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
301
385
  branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
386
+ workflowContract: state && state.workflowContract ? state.workflowContract : null,
302
387
  };
303
388
  saveJSON(module.exports.GOVERNANCE_STATE_PATH, next);
304
389
  }
@@ -310,33 +395,39 @@ function setTaskScope(scopeInput = {}) {
310
395
  taskScope: null,
311
396
  protectedApprovals: currentState.protectedApprovals,
312
397
  branchGovernance: currentState.branchGovernance,
398
+ workflowContract: null,
313
399
  };
314
400
  saveGovernanceState(cleared);
401
+ refreshLocalOnlyConstraint(cleared);
315
402
  return null;
316
403
  }
317
404
 
318
- const allowedPaths = sanitizeGlobList(scopeInput.allowedPaths);
405
+ const repoPath = String(scopeInput.repoPath || '').trim() || null;
406
+ const allowedPaths = rebaseGlobsToRepoRoot(sanitizeGlobList(scopeInput.allowedPaths), repoPath);
319
407
  if (allowedPaths.length === 0) {
320
408
  throw new Error('allowedPaths must be a non-empty array');
321
409
  }
322
410
 
323
- const protectedPaths = sanitizeGlobList(
411
+ const protectedPaths = rebaseGlobsToRepoRoot(sanitizeGlobList(
324
412
  Array.isArray(scopeInput.protectedPaths) && scopeInput.protectedPaths.length > 0
325
413
  ? scopeInput.protectedPaths
326
414
  : DEFAULT_PROTECTED_FILE_GLOBS
327
- );
415
+ ), repoPath);
328
416
  const taskScope = {
329
417
  taskId: String(scopeInput.taskId || '').trim() || null,
330
418
  summary: String(scopeInput.summary || '').trim() || null,
331
419
  allowedPaths,
332
420
  protectedPaths,
333
421
  localOnly: scopeInput.localOnly === true,
334
- repoPath: String(scopeInput.repoPath || '').trim() || null,
422
+ repoPath,
335
423
  createdAt: new Date().toISOString(),
336
424
  timestamp: Date.now(),
337
425
  };
338
426
  const state = loadGovernanceState();
339
427
  state.taskScope = taskScope;
428
+ state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
429
+ ? scopeInput.workflowContract
430
+ : null;
340
431
  saveGovernanceState(state);
341
432
  if (taskScope.localOnly) {
342
433
  setConstraint('local_only', true);
@@ -409,6 +500,7 @@ function setBranchGovernance(input = {}) {
409
500
  const state = loadGovernanceState();
410
501
  state.branchGovernance = null;
411
502
  saveGovernanceState(state);
503
+ refreshLocalOnlyConstraint(state);
412
504
  return null;
413
505
  }
414
506
 
@@ -459,6 +551,24 @@ function setConstraint(key, value) {
459
551
  return constraints[key];
460
552
  }
461
553
 
554
+ function clearConstraint(key) {
555
+ const constraints = loadConstraints();
556
+ delete constraints[key];
557
+ saveConstraints(constraints);
558
+ }
559
+
560
+ function refreshLocalOnlyConstraint(governanceState = loadGovernanceState()) {
561
+ const localOnlyActive = Boolean(
562
+ (governanceState.taskScope && governanceState.taskScope.localOnly) ||
563
+ (governanceState.branchGovernance && governanceState.branchGovernance.localOnly)
564
+ );
565
+ if (localOnlyActive) {
566
+ setConstraint('local_only', true);
567
+ } else {
568
+ clearConstraint('local_only');
569
+ }
570
+ }
571
+
462
572
  function isConditionSatisfied(conditionId) {
463
573
  const state = loadState();
464
574
  const entry = state[conditionId];
@@ -498,7 +608,29 @@ function loadStats() {
498
608
 
499
609
  function saveStats(stats) { saveJSON(module.exports.STATS_PATH, stats); }
500
610
 
501
- function recordStat(gateId, action, gate) {
611
+ function buildGateActionFingerprint(gateId, options = {}) {
612
+ if (options.actionFingerprint) return String(options.actionFingerprint);
613
+ const toolName = options.toolName || options.tool_name || '';
614
+ const toolInput = options.toolInput || options.tool_input || {};
615
+ const parts = [toolName];
616
+ if (typeof toolInput === 'string') {
617
+ parts.push(toolInput);
618
+ } else if (toolInput && typeof toolInput === 'object') {
619
+ parts.push(
620
+ toolInput.command || '',
621
+ toolInput.cmd || '',
622
+ toolInput.file_path || '',
623
+ toolInput.path || '',
624
+ toolInput.description || '',
625
+ toolInput.prompt || '',
626
+ toolInput.pattern || '',
627
+ );
628
+ if (Array.isArray(toolInput.affectedFiles)) parts.push(...toolInput.affectedFiles);
629
+ }
630
+ return actionFingerprint(parts);
631
+ }
632
+
633
+ function recordStat(gateId, action, gate, options = {}) {
502
634
  const stats = loadStats();
503
635
  if (action === 'block') stats.blocked = (stats.blocked || 0) + 1;
504
636
  else if (action === 'warn') stats.warned = (stats.warned || 0) + 1;
@@ -512,16 +644,25 @@ function recordStat(gateId, action, gate) {
512
644
  else if (action === 'approve') stats.byGate[gateId].pendingApproval = (stats.byGate[gateId].pendingApproval || 0) + 1;
513
645
  else if (action === 'log') stats.byGate[gateId].logged = (stats.byGate[gateId].logged || 0) + 1;
514
646
 
515
- // Track per-gate recurrence within a session for first-time fix rate
647
+ // Track same-action recurrence within a session for first-time fix rate.
648
+ // Gate-only recurrence over-counts noisy gates; repeats require a stable,
649
+ // sanitized action fingerprint.
516
650
  if (action === 'block' || action === 'warn') {
517
651
  if (!stats.sessionFiredGates) stats.sessionFiredGates = {};
652
+ if (!stats.sessionFiredActions) stats.sessionFiredActions = {};
518
653
  const sessionKey = `session_${Math.floor(Date.now() / SESSION_ACTION_TTL_MS)}`;
519
654
  if (!stats.sessionFiredGates[sessionKey]) stats.sessionFiredGates[sessionKey] = {};
520
- if (stats.sessionFiredGates[sessionKey][gateId]) {
521
- // Same gate fired again in this session — it's a recurring block
522
- stats.recurringBlocks = (stats.recurringBlocks || 0) + 1;
523
- } else {
524
- stats.sessionFiredGates[sessionKey][gateId] = true;
655
+ stats.sessionFiredGates[sessionKey][gateId] = true;
656
+
657
+ const fingerprint = buildGateActionFingerprint(gateId, options);
658
+ if (fingerprint) {
659
+ if (!stats.sessionFiredActions[sessionKey]) stats.sessionFiredActions[sessionKey] = {};
660
+ if (!stats.sessionFiredActions[sessionKey][gateId]) stats.sessionFiredActions[sessionKey][gateId] = {};
661
+ if (stats.sessionFiredActions[sessionKey][gateId][fingerprint]) {
662
+ stats.recurringBlocks = (stats.recurringBlocks || 0) + 1;
663
+ } else {
664
+ stats.sessionFiredActions[sessionKey][gateId][fingerprint] = true;
665
+ }
525
666
  }
526
667
  }
527
668
 
@@ -737,7 +878,7 @@ function extractAffectedFiles(toolName, toolInput = {}) {
737
878
  }
738
879
  }
739
880
 
740
- if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || GH_API_PR_CREATE_PATTERN.test(command)) {
881
+ if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
741
882
  for (const filePath of getBranchDiffFiles(repoRoot)) {
742
883
  files.add(normalizePosix(filePath));
743
884
  }
@@ -756,7 +897,7 @@ function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
756
897
  const command = String(toolInput.command || '');
757
898
  // Original high-risk pattern (git writes, publishes, destructive ops)
758
899
  if (HIGH_RISK_BASH_PATTERN.test(command)) return true;
759
- if (GH_API_PR_CREATE_PATTERN.test(command)) return true;
900
+ if (isGhApiPrCreateCommand(command)) return true;
760
901
  // Broadened: any Bash command that modifies files or has side effects.
761
902
  // Excludes pure read/analysis commands (node --test, cat, ls, echo, etc.)
762
903
  // to avoid false positives on benign operations.
@@ -995,7 +1136,7 @@ function getLocalOnlyScopeSources(governanceState = {}, constraints = {}) {
995
1136
  function isRemoteSideEffectCommand(toolName, toolInput = {}) {
996
1137
  if (toolName !== 'Bash') return false;
997
1138
  const command = String(toolInput.command || '');
998
- return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(command) || GH_API_PR_CREATE_PATTERN.test(command);
1139
+ return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(command) || isGhApiPrCreateCommand(command);
999
1140
  }
1000
1141
 
1001
1142
  function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governanceState = {}, constraints = {}) {
@@ -1018,7 +1159,7 @@ function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governa
1018
1159
  }
1019
1160
 
1020
1161
  function recordStructuralGateBlock(toolName, toolInput, result) {
1021
- recordStat(result.gate, 'block');
1162
+ recordStat(result.gate, 'block', null, { toolName, toolInput });
1022
1163
  const auditRecord = recordAuditEvent({
1023
1164
  toolName,
1024
1165
  toolInput,
@@ -1379,7 +1520,7 @@ function matchesGate(gate, toolName, toolInput) {
1379
1520
  function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
1380
1521
  if (toolName !== 'Bash') return false;
1381
1522
  const command = String(toolInput.command || '').trim();
1382
- if (!command || /(?:^|\s)chmod\s+[^&;|]*\s+-R\b/i.test(command)) return false;
1523
+ if (!command || isRecursiveChmodCommand(command)) return false;
1383
1524
  if (/[;&|`$()<>*?[\]{}]/.test(command)) return false;
1384
1525
 
1385
1526
  const match = command.match(/(?:^|\s)chmod\s+(?:-[fv]\s+)?0?([46]00)\s+(['"]?)(\S+)\2\s*$/i);
@@ -1387,7 +1528,7 @@ function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
1387
1528
 
1388
1529
  const target = match[3];
1389
1530
  if (!target || target === '/' || target === '~') return false;
1390
- if (/^\.\.?(?:\/|$)/.test(target)) return false;
1531
+ if (target.includes('..')) return false;
1391
1532
 
1392
1533
  const normalized = target.replace(/^['"]|['"]$/g, '').toLowerCase();
1393
1534
  const looksLikeCredentialPath = /(?:^|\/)(?:\.config|\.ssh|\.gnupg|\.aws|\.gcloud|\.gemini|\.resume_secrets|\.thumbgate|secrets?|credentials?)(?:\/|$)/.test(normalized)
@@ -1400,6 +1541,16 @@ function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
1400
1541
  function evaluateMemoryGuard(toolName, toolInput = {}) {
1401
1542
  const affected = extractAffectedFiles(toolName, toolInput);
1402
1543
  const affectedFiles = affected.files;
1544
+ if (isSafeSecretStorageWrite(toolName, toolInput, process.cwd())) {
1545
+ return null;
1546
+ }
1547
+ // Hardening a credential file's permissions (chmod 600 on a key/secret path) is
1548
+ // a safety action, not a risk. The same exemption already guards the
1549
+ // permission-change-approval gate; without it here, `chmod 600 ~/.resume_secrets/key`
1550
+ // gets hard-denied by recurring-negative-memory matching — the opposite of intent.
1551
+ if (isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
1552
+ return null;
1553
+ }
1403
1554
  if (!isHighRiskAction(toolName, toolInput, affectedFiles)) {
1404
1555
  return null;
1405
1556
  }
@@ -1414,7 +1565,7 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1414
1565
 
1415
1566
  const command = String(toolInput.command || '');
1416
1567
  const isPrCreateCommand = toolName === 'Bash' && (
1417
- /\bgh\s+pr\s+create\b/i.test(command) || GH_API_PR_CREATE_PATTERN.test(command)
1568
+ /\bgh\s+pr\s+create\b/i.test(command) || isGhApiPrCreateCommand(command)
1418
1569
  );
1419
1570
  if (isPrCreateCommand && isConditionSatisfied('pr_create_allowed')) {
1420
1571
  const branchGovernanceViolation = buildBranchGovernanceViolation(
@@ -1431,7 +1582,7 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1431
1582
 
1432
1583
  if (toolName === 'Bash' && (
1433
1584
  /\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
- GH_API_PR_CREATE_PATTERN.test(command)
1585
+ isGhApiPrCreateCommand(command)
1435
1586
  )) {
1436
1587
  const branchGovernanceViolation = buildBranchGovernanceViolation(
1437
1588
  governanceState,
@@ -1470,7 +1621,14 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1470
1621
  filePath: toolInput.file_path || toolInput.path || null,
1471
1622
  affectedFiles,
1472
1623
  });
1473
- const guard = hybrid.evaluatePretool(toolName, serializedInput);
1624
+ // Claw/hybrid support: pass context if agent provides claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
1625
+ let guard;
1626
+ if (toolInput && (toolInput.clawContext || toolInput._claw || toolInput.hybridRoute || toolInput.agentId)) {
1627
+ const clawCtx = toolInput.clawContext || toolInput._claw || { actionType: toolInput.actionType || 'unknown', agentId: toolInput.agentId || 'unknown', hybridRoute: toolInput.hybridRoute || 'unknown' };
1628
+ guard = hybrid.evaluateClawPretool ? hybrid.evaluateClawPretool(toolName, serializedInput, clawCtx) : hybrid.evaluatePretool(toolName, serializedInput);
1629
+ } else {
1630
+ guard = hybrid.evaluatePretool(toolName, serializedInput);
1631
+ }
1474
1632
  if (!guard || guard.mode === 'allow') {
1475
1633
  return null;
1476
1634
  }
@@ -1623,7 +1781,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1623
1781
 
1624
1782
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
1625
1783
  if (pendingThreadResolutionGate) {
1626
- recordStat(pendingThreadResolutionGate.gate, 'block');
1784
+ recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
1627
1785
  const auditRecord = recordAuditEvent({
1628
1786
  toolName,
1629
1787
  toolInput,
@@ -1639,7 +1797,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1639
1797
 
1640
1798
  const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
1641
1799
  if (boostedRiskGuard) {
1642
- recordStat(boostedRiskGuard.gate, 'block');
1800
+ recordStat(boostedRiskGuard.gate, 'block', null, { toolName, toolInput });
1643
1801
  const auditRecord = recordAuditEvent({
1644
1802
  toolName,
1645
1803
  toolInput,
@@ -1659,13 +1817,13 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1659
1817
  if (isRuntimePlanGateEnabled()) {
1660
1818
  const planGate = evaluatePlanGate(toolName, toolInput);
1661
1819
  if (planGate) {
1662
- recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn');
1820
+ recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
1663
1821
  return planGate;
1664
1822
  }
1665
1823
 
1666
1824
  const trajectory = getTrajectoryScore();
1667
1825
  if (trajectory.isDrifting) {
1668
- recordStat('strategic-drift', 'block');
1826
+ recordStat('strategic-drift', 'block', null, { toolName, toolInput });
1669
1827
  return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
1670
1828
  }
1671
1829
  }
@@ -1719,12 +1877,12 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1719
1877
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
1720
1878
  const cappedResult = applyDailyBlockCap(denyResult);
1721
1879
  if (cappedResult) {
1722
- recordStat(gate.id, 'warn', gate);
1880
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
1723
1881
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
1724
1882
  auditToFeedback(auditRecord);
1725
1883
  return cappedResult;
1726
1884
  }
1727
- recordStat(gate.id, 'block', gate);
1885
+ recordStat(gate.id, 'block', gate, { toolName, toolInput });
1728
1886
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1729
1887
  auditToFeedback(auditRecord);
1730
1888
  return denyResult;
@@ -1733,13 +1891,13 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1733
1891
  if (gate.action === 'approve') {
1734
1892
  const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
1735
1893
  if (approvalEnabled) {
1736
- recordStat(gate.id, 'approve', gate);
1894
+ recordStat(gate.id, 'approve', gate, { toolName, toolInput });
1737
1895
  const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1738
1896
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1739
1897
  auditToFeedback(auditRecord);
1740
1898
  return result;
1741
1899
  }
1742
- recordStat(gate.id, 'warn', gate);
1900
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
1743
1901
  const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
1744
1902
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1745
1903
  auditToFeedback(auditRecord);
@@ -1747,7 +1905,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1747
1905
  }
1748
1906
 
1749
1907
  if (gate.action === 'log') {
1750
- recordStat(gate.id, 'log', gate);
1908
+ recordStat(gate.id, 'log', gate, { toolName, toolInput });
1751
1909
  const result = { decision: 'log', gate: gate.id, message, severity: gate.severity, reasoning, logged: true };
1752
1910
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1753
1911
  auditToFeedback(auditRecord);
@@ -1756,7 +1914,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1756
1914
  }
1757
1915
 
1758
1916
  if (gate.action === 'warn') {
1759
- recordStat(gate.id, 'warn', gate);
1917
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
1760
1918
  const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
1761
1919
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1762
1920
  auditToFeedback(auditRecord);
@@ -1764,14 +1922,15 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1764
1922
  }
1765
1923
  }
1766
1924
 
1767
- const sentinelReport = evaluateWorkflowSentinel(toolName, toolInput, {
1925
+ const skipAdvisoryGuards = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
1926
+ const sentinelReport = skipAdvisoryGuards ? null : evaluateWorkflowSentinel(toolName, toolInput, {
1768
1927
  governanceState,
1769
1928
  });
1770
1929
  const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
1771
1930
  const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
1772
1931
  if (memoryGuard) {
1773
1932
  const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
1774
- recordStat(enrichedMemoryGuard.gate, 'block');
1933
+ recordStat(enrichedMemoryGuard.gate, 'block', null, { toolName, toolInput });
1775
1934
  recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
1776
1935
  const auditRecord = recordAuditEvent({
1777
1936
  toolName,
@@ -1788,7 +1947,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1788
1947
 
1789
1948
  if (sentinelReport && sentinelReport.decision !== 'allow') {
1790
1949
  const sentinelResult = buildSentinelGateResult(sentinelReport);
1791
- recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
1950
+ recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
1792
1951
  recordSentinelBlockDecision(sentinelDecision, sentinelResult);
1793
1952
  const auditRecord = recordAuditEvent({
1794
1953
  toolName,
@@ -1849,7 +2008,7 @@ function evaluateGates(toolName, toolInput, configPath) {
1849
2008
 
1850
2009
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
1851
2010
  if (pendingThreadResolutionGate) {
1852
- recordStat(pendingThreadResolutionGate.gate, 'block');
2011
+ recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
1853
2012
  const auditRecord = recordAuditEvent({
1854
2013
  toolName,
1855
2014
  toolInput,
@@ -1865,7 +2024,7 @@ function evaluateGates(toolName, toolInput, configPath) {
1865
2024
 
1866
2025
  const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
1867
2026
  if (boostedRiskGuard) {
1868
- recordStat(boostedRiskGuard.gate, 'block');
2027
+ recordStat(boostedRiskGuard.gate, 'block', null, { toolName, toolInput });
1869
2028
  const auditRecord = recordAuditEvent({
1870
2029
  toolName,
1871
2030
  toolInput,
@@ -1885,13 +2044,13 @@ function evaluateGates(toolName, toolInput, configPath) {
1885
2044
  if (isRuntimePlanGateEnabled()) {
1886
2045
  const planGate = evaluatePlanGate(toolName, toolInput);
1887
2046
  if (planGate) {
1888
- recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn');
2047
+ recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
1889
2048
  return planGate;
1890
2049
  }
1891
2050
 
1892
2051
  const trajectory = getTrajectoryScore();
1893
2052
  if (trajectory.isDrifting) {
1894
- recordStat('strategic-drift', 'block');
2053
+ recordStat('strategic-drift', 'block', null, { toolName, toolInput });
1895
2054
  return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
1896
2055
  }
1897
2056
  }
@@ -1918,12 +2077,12 @@ function evaluateGates(toolName, toolInput, configPath) {
1918
2077
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
1919
2078
  const cappedResult = applyDailyBlockCap(denyResult);
1920
2079
  if (cappedResult) {
1921
- recordStat(gate.id, 'warn', gate);
2080
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
1922
2081
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
1923
2082
  auditToFeedback(auditRecord);
1924
2083
  return cappedResult;
1925
2084
  }
1926
- recordStat(gate.id, 'block', gate);
2085
+ recordStat(gate.id, 'block', gate, { toolName, toolInput });
1927
2086
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1928
2087
  auditToFeedback(auditRecord);
1929
2088
  return denyResult;
@@ -1932,13 +2091,13 @@ function evaluateGates(toolName, toolInput, configPath) {
1932
2091
  if (gate.action === 'approve') {
1933
2092
  const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
1934
2093
  if (approvalEnabled) {
1935
- recordStat(gate.id, 'approve', gate);
2094
+ recordStat(gate.id, 'approve', gate, { toolName, toolInput });
1936
2095
  const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1937
2096
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1938
2097
  auditToFeedback(auditRecord);
1939
2098
  return result;
1940
2099
  }
1941
- recordStat(gate.id, 'warn', gate);
2100
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
1942
2101
  const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
1943
2102
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1944
2103
  auditToFeedback(auditRecord);
@@ -1946,7 +2105,7 @@ function evaluateGates(toolName, toolInput, configPath) {
1946
2105
  }
1947
2106
 
1948
2107
  if (gate.action === 'log') {
1949
- recordStat(gate.id, 'log', gate);
2108
+ recordStat(gate.id, 'log', gate, { toolName, toolInput });
1950
2109
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1951
2110
  auditToFeedback(auditRecord);
1952
2111
  // 'log' action allows the tool call to proceed — continue to next gate
@@ -1954,7 +2113,7 @@ function evaluateGates(toolName, toolInput, configPath) {
1954
2113
  }
1955
2114
 
1956
2115
  if (gate.action === 'warn') {
1957
- recordStat(gate.id, 'warn', gate);
2116
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
1958
2117
  const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
1959
2118
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1960
2119
  auditToFeedback(auditRecord);
@@ -1962,14 +2121,15 @@ function evaluateGates(toolName, toolInput, configPath) {
1962
2121
  }
1963
2122
  }
1964
2123
 
1965
- const sentinelReport = evaluateWorkflowSentinel(toolName, toolInput, {
2124
+ const skipAdvisoryGuards = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
2125
+ const sentinelReport = skipAdvisoryGuards ? null : evaluateWorkflowSentinel(toolName, toolInput, {
1966
2126
  governanceState,
1967
2127
  });
1968
2128
  const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
1969
2129
  const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
1970
2130
  if (memoryGuard) {
1971
2131
  const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
1972
- recordStat(enrichedMemoryGuard.gate, 'block');
2132
+ recordStat(enrichedMemoryGuard.gate, 'block', null, { toolName, toolInput });
1973
2133
  recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
1974
2134
  const auditRecord = recordAuditEvent({
1975
2135
  toolName,
@@ -1986,7 +2146,7 @@ function evaluateGates(toolName, toolInput, configPath) {
1986
2146
 
1987
2147
  if (sentinelReport && sentinelReport.decision !== 'allow') {
1988
2148
  const sentinelResult = buildSentinelGateResult(sentinelReport);
1989
- recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
2149
+ recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
1990
2150
  recordSentinelBlockDecision(sentinelDecision, sentinelResult);
1991
2151
  const auditRecord = recordAuditEvent({
1992
2152
  toolName,
@@ -2006,14 +2166,43 @@ function evaluateGates(toolName, toolInput, configPath) {
2006
2166
  return null;
2007
2167
  }
2008
2168
 
2009
- function buildSecretGuardResult(scanResult) {
2169
+ // Turn a secret-exfiltration block into actionable guidance that names the
2170
+ // safe path, instead of a dead-end that drives agents toward brittle
2171
+ // workarounds (e.g. writing secrets to /tmp). The vault dirs referenced here
2172
+ // are the SAME constant the scanner whitelists, so the hint can never drift
2173
+ // from enforcement.
2174
+ function buildSecretRemediation(toolName = '', toolInput = {}) {
2175
+ const vaultDirs = (SAFE_SECRET_STORAGE_DIRS || []).map((dir) => `~/${dir}`);
2176
+ const primaryVault = vaultDirs[0] || '~/.resume_secrets';
2177
+ const vaultList = vaultDirs.join(', ') || primaryVault;
2178
+
2179
+ if (EDIT_LIKE_TOOLS && EDIT_LIKE_TOOLS.has(toolName)) {
2180
+ const target = toolInput.file_path || toolInput.path || toolInput.filePath || toolInput.target_path;
2181
+ const where = target ? ` (you targeted ${redactText(String(target))})` : '';
2182
+ return `To store this secret safely, write it with the Write/Edit tool to a file under ${vaultList}${where}. `
2183
+ + `Those locations are whitelisted for secret storage and will NOT be blocked. `
2184
+ + `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.`;
2185
+ }
2186
+
2187
+ if (toolName === 'Bash') {
2188
+ return `Do not inline a live secret literal into a shell command — it leaks into shell history and process args. `
2189
+ + `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.`;
2190
+ }
2191
+
2192
+ return `Store secrets in the whitelisted vault (${vaultList}) using the Write tool rather than passing the literal through this action.`;
2193
+ }
2194
+
2195
+ function buildSecretGuardResult(scanResult, context = {}) {
2196
+ const remediation = buildSecretRemediation(context.toolName, context.toolInput || {});
2197
+ const summary = buildSafeSummary(
2198
+ scanResult.findings,
2199
+ 'Blocked because the action appears to expose secret material'
2200
+ );
2010
2201
  return {
2011
2202
  decision: 'deny',
2012
2203
  gate: 'secret-exfiltration',
2013
- message: buildSafeSummary(
2014
- scanResult.findings,
2015
- 'Blocked because the action appears to expose secret material'
2016
- ),
2204
+ message: `${summary}. ${remediation}`,
2205
+ remediation,
2017
2206
  severity: 'critical',
2018
2207
  secretScan: {
2019
2208
  provider: scanResult.provider,
@@ -2092,9 +2281,15 @@ function evaluateSecretGuard(input = {}) {
2092
2281
  if (!scanResult.detected) {
2093
2282
  return null;
2094
2283
  }
2095
- recordStat('secret-exfiltration', 'block');
2284
+ recordStat('secret-exfiltration', 'block', null, {
2285
+ toolName: input.tool_name || input.toolName || 'unknown',
2286
+ toolInput: input.tool_input || {},
2287
+ });
2096
2288
  recordSecretViolation(input, scanResult);
2097
- const result = buildSecretGuardResult(scanResult);
2289
+ const result = buildSecretGuardResult(scanResult, {
2290
+ toolName: input.tool_name || input.toolName,
2291
+ toolInput: input.tool_input || {},
2292
+ });
2098
2293
  // Audit trail: record secret guard denial
2099
2294
  const auditRecord = recordAuditEvent({
2100
2295
  toolName: input.tool_name || input.toolName || 'unknown',
@@ -2422,7 +2617,7 @@ function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
2422
2617
  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
2618
 
2424
2619
  if (isKnowledgeConflictHardBlockAction(toolName, toolInput)) {
2425
- recordStat('retrieval_entropy_high', 'block');
2620
+ recordStat('retrieval_entropy_high', 'block', null, { toolName, toolInput });
2426
2621
  return {
2427
2622
  decision: 'deny',
2428
2623
  gate: 'knowledge-conflict-gate',
@@ -2431,7 +2626,7 @@ function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
2431
2626
  };
2432
2627
  }
2433
2628
 
2434
- recordStat('retrieval_entropy_high', 'warn');
2629
+ recordStat('retrieval_entropy_high', 'warn', null, { toolName, toolInput });
2435
2630
  return mergeContextStrings(`[ThumbGate] ${message}`, lessonContext);
2436
2631
  }
2437
2632
 
@@ -2443,7 +2638,7 @@ function extractActionContext(toolName, toolInput) {
2443
2638
  if (toolInput.description) parts.push(String(toolInput.description).slice(0, 200));
2444
2639
  if (toolInput.prompt) parts.push(String(toolInput.prompt).slice(0, 400));
2445
2640
  if (toolInput.pattern) parts.push(String(toolInput.pattern).slice(0, 200));
2446
- return parts.filter(Boolean).join(' ');
2641
+ return sanitizeFeedbackText(parts.filter(Boolean).join(' ')) || toolName;
2447
2642
  }
2448
2643
 
2449
2644
  function extractAvoidanceAdvice(content) {
@@ -2472,6 +2667,7 @@ async function runAsync(input) {
2472
2667
 
2473
2668
  const toolName = input.tool_name || '';
2474
2669
  const toolInput = input.tool_input || {};
2670
+ const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
2475
2671
 
2476
2672
  const sequenceGuard = evaluateSequenceState(toolName, toolInput);
2477
2673
  if (sequenceGuard && sequenceGuard.decision === 'deny') {
@@ -2491,8 +2687,8 @@ async function runAsync(input) {
2491
2687
  }
2492
2688
 
2493
2689
 
2494
- const behavioralContext = buildBehavioralContext();
2495
- const lessonContext = await buildRelevantLessonContextAsync(toolName, toolInput);
2690
+ const behavioralContext = safeSecretStorageWrite ? null : buildBehavioralContext();
2691
+ const lessonContext = safeSecretStorageWrite ? null : await buildRelevantLessonContextAsync(toolName, toolInput);
2496
2692
 
2497
2693
  if (lessonContext && lessonContext.decision === "deny") {
2498
2694
  return formatOutput(lessonContext);
@@ -2518,6 +2714,7 @@ function run(input) {
2518
2714
 
2519
2715
  const toolName = input.tool_name || '';
2520
2716
  const toolInput = input.tool_input || {};
2717
+ const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
2521
2718
 
2522
2719
  const sequenceGuard = evaluateSequenceState(toolName, toolInput);
2523
2720
  if (sequenceGuard && sequenceGuard.decision === 'deny') {
@@ -2537,8 +2734,8 @@ function run(input) {
2537
2734
  }
2538
2735
 
2539
2736
 
2540
- const behavioralContext = buildBehavioralContext();
2541
- const lessonContext = buildRelevantLessonContext(toolName, toolInput);
2737
+ const behavioralContext = safeSecretStorageWrite ? null : buildBehavioralContext();
2738
+ const lessonContext = safeSecretStorageWrite ? null : buildRelevantLessonContext(toolName, toolInput);
2542
2739
 
2543
2740
  if (lessonContext && lessonContext.decision === "deny") {
2544
2741
  return formatOutput(lessonContext);