thumbgate 1.27.18 → 1.27.19

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 (96) hide show
  1. package/.claude-plugin/marketplace.json +6 -6
  2. package/.claude-plugin/plugin.json +4 -3
  3. package/.well-known/agentic-verify.txt +1 -0
  4. package/.well-known/llms.txt +33 -12
  5. package/.well-known/mcp/server-card.json +8 -8
  6. package/README.md +249 -30
  7. package/adapters/chatgpt/openapi.yaml +12 -0
  8. package/adapters/claude/.mcp.json +2 -2
  9. package/adapters/codex/config.toml +2 -2
  10. package/adapters/gemini/function-declarations.json +1 -0
  11. package/adapters/mcp/server-stdio.js +263 -11
  12. package/adapters/opencode/opencode.json +1 -1
  13. package/bench/thumbgate-bench.json +2 -2
  14. package/bin/cli.js +1429 -121
  15. package/bin/postinstall.js +1 -8
  16. package/config/gate-classifier-routing.json +98 -0
  17. package/config/gate-templates.json +216 -0
  18. package/config/gates/claim-verification.json +12 -0
  19. package/config/gates/default.json +31 -2
  20. package/config/github-about.json +2 -2
  21. package/config/mcp-allowlists.json +23 -13
  22. package/config/merge-quality-checks.json +0 -1
  23. package/config/model-candidates.json +121 -6
  24. package/config/post-deploy-marketing-pages.json +80 -0
  25. package/config/tessl-tiles.json +1 -3
  26. package/openapi/openapi.yaml +12 -0
  27. package/package.json +1 -1
  28. package/public/blog.html +4 -4
  29. package/public/codex-plugin.html +72 -20
  30. package/public/compare.html +31 -8
  31. package/public/dashboard.html +930 -166
  32. package/public/federal.html +2 -2
  33. package/public/guide.html +33 -13
  34. package/public/index.html +469 -111
  35. package/public/learn.html +183 -18
  36. package/public/lessons.html +168 -10
  37. package/public/numbers.html +7 -7
  38. package/public/pro.html +34 -11
  39. package/scripts/agent-memory-lifecycle.js +211 -0
  40. package/scripts/agent-readiness.js +20 -3
  41. package/scripts/agent-reward-model.js +53 -1
  42. package/scripts/auto-promote-gates.js +82 -10
  43. package/scripts/auto-wire-hooks.js +14 -0
  44. package/scripts/billing.js +93 -1
  45. package/scripts/bot-detection.js +61 -3
  46. package/scripts/build-metadata.js +50 -10
  47. package/scripts/cli-feedback.js +4 -2
  48. package/scripts/cli-schema.js +97 -0
  49. package/scripts/cli-telemetry.js +6 -1
  50. package/scripts/commercial-offer.js +82 -2
  51. package/scripts/context-manager.js +74 -6
  52. package/scripts/dashboard.js +68 -2
  53. package/scripts/export-databricks-bundle.js +5 -1
  54. package/scripts/export-dpo-pairs.js +7 -2
  55. package/scripts/feedback-loop.js +123 -1
  56. package/scripts/feedback-quality.js +87 -0
  57. package/scripts/filesystem-search.js +35 -10
  58. package/scripts/gate-stats.js +89 -0
  59. package/scripts/gates-engine.js +1176 -85
  60. package/scripts/gemini-embedding-policy.js +2 -1
  61. package/scripts/hook-runtime.js +20 -14
  62. package/scripts/hook-thumbgate-cache-updater.js +18 -2
  63. package/scripts/hybrid-feedback-context.js +142 -7
  64. package/scripts/lesson-inference.js +8 -3
  65. package/scripts/lesson-search.js +17 -1
  66. package/scripts/license.js +10 -10
  67. package/scripts/llm-client.js +169 -4
  68. package/scripts/local-model-profile.js +15 -8
  69. package/scripts/mcp-config.js +7 -1
  70. package/scripts/memory-scope-readiness.js +159 -0
  71. package/scripts/meta-agent-loop.js +36 -0
  72. package/scripts/operational-integrity.js +39 -5
  73. package/scripts/oss-pr-opportunity-scout.js +35 -5
  74. package/scripts/plausible-server-events.js +9 -6
  75. package/scripts/pro-local-dashboard.js +4 -4
  76. package/scripts/proxy-pointer-rag-guardrails.js +42 -1
  77. package/scripts/published-cli.js +0 -8
  78. package/scripts/rate-limiter.js +64 -13
  79. package/scripts/secret-scanner.js +44 -5
  80. package/scripts/security-scanner.js +260 -10
  81. package/scripts/self-distill-agent.js +3 -1
  82. package/scripts/seo-gsd.js +916 -7
  83. package/scripts/statusline-cache-path.js +17 -2
  84. package/scripts/statusline-local-stats.js +9 -1
  85. package/scripts/statusline-meta.js +28 -2
  86. package/scripts/statusline.sh +20 -4
  87. package/scripts/telemetry-analytics.js +357 -0
  88. package/scripts/thompson-sampling.js +31 -10
  89. package/scripts/thumbgate-bench.js +16 -1
  90. package/scripts/thumbgate-search.js +85 -19
  91. package/scripts/tool-registry.js +169 -1
  92. package/scripts/vector-store.js +45 -0
  93. package/scripts/workflow-sentinel.js +286 -53
  94. package/scripts/workspace-evolver.js +62 -2
  95. package/src/api/server.js +2683 -319
  96. package/scripts/bot-detector.js +0 -50
@@ -3,11 +3,12 @@
3
3
 
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
+ const os = require('os');
6
7
  const crypto = require('crypto');
7
- const { execSync, execFileSync } = require('child_process');
8
+ const { execFileSync } = require('child_process');
8
9
  const { loadOptionalModule } = require('./private-core-boundary');
9
10
 
10
- const { isProTier, FREE_TIER_MAX_GATES } = require('./rate-limiter');
11
+ const { isProTier, isInTrialPeriod, FREE_TIER_MAX_GATES, FREE_TIER_DAILY_BLOCKS, todayKey } = require('./rate-limiter');
11
12
  const {
12
13
  DEFAULT_BASE_BRANCH,
13
14
  evaluateOperationalIntegrity,
@@ -19,6 +20,10 @@ const {
19
20
  recordDecisionEvaluation,
20
21
  recordDecisionOutcome,
21
22
  } = require('./decision-journal');
23
+ const {
24
+ actionFingerprint,
25
+ sanitizeFeedbackText,
26
+ } = require('./feedback-sanitizer');
22
27
 
23
28
  /**
24
29
  * Computes the SHA-256 hash of an executable binary to prevent path-based bypasses.
@@ -30,10 +35,12 @@ function computeExecutableHash(command) {
30
35
  const firstWord = command.trim().split(/\s+/)[0];
31
36
  if (!firstWord) return null;
32
37
 
33
- // Resolve absolute path using 'which'
38
+ // Resolve absolute path using 'which'. Use execFileSync (no shell) and pass
39
+ // firstWord as an argv element, never interpolated into a command string, so
40
+ // a hostile `command` value cannot inject shell metacharacters here.
34
41
  let fullPath;
35
42
  try {
36
- fullPath = execSync(`which ${firstWord}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
43
+ fullPath = execFileSync('which', [firstWord], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
37
44
  } catch (e) {
38
45
  // If 'which' fails, it might be an absolute path or a non-existent command
39
46
  fullPath = path.isAbsolute(firstWord) ? firstWord : null;
@@ -51,21 +58,44 @@ const {
51
58
  scanHookInput,
52
59
  buildSafeSummary,
53
60
  redactText,
61
+ isSafeSecretStorageWrite,
62
+ SAFE_SECRET_STORAGE_DIRS,
54
63
  } = require('./secret-scanner');
55
64
  const {
56
65
  evaluateSecurityScan,
57
66
  } = require('./security-scanner');
67
+ const { evaluatePlanGate } = require('./plan-gate');
68
+ const { getTrajectoryScore } = require('./trajectory-scorer');
69
+ const { evaluateSequenceState } = loadOptionalModule('./sequence-guard', () => ({
70
+ evaluateSequenceState: () => null,
71
+ }));
58
72
  const { getAutoGatesPath } = require('./auto-promote-gates');
59
73
  const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
60
74
 
61
75
  const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config', 'gates', 'default.json');
62
76
  const DEFAULT_CLAIM_GATES_PATH = path.join(__dirname, '..', 'config', 'gates', 'claim-verification.json');
63
- const STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'gate-state.json');
64
- const CONSTRAINTS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'session-constraints.json');
65
- const STATS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'gate-stats.json');
66
- const SESSION_ACTIONS_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'session-actions.json');
67
- const CUSTOM_CLAIM_GATES_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'claim-verification.json');
68
- const GOVERNANCE_STATE_PATH = path.join(process.env.HOME || '/tmp', '.thumbgate', 'governance-state.json');
77
+
78
+ function resolveThumbgateStateDir() {
79
+ if (process.env.THUMBGATE_STATE_DIR) return process.env.THUMBGATE_STATE_DIR;
80
+
81
+ if (process.env.XDG_STATE_HOME) {
82
+ return path.join(process.env.XDG_STATE_HOME, 'thumbgate');
83
+ }
84
+
85
+ if (process.env.CODEX_SANDBOX) {
86
+ return path.join(os.tmpdir(), 'thumbgate');
87
+ }
88
+
89
+ return path.join(process.env.HOME || os.tmpdir(), '.thumbgate');
90
+ }
91
+
92
+ const STATE_DIR = resolveThumbgateStateDir();
93
+ const STATE_PATH = path.join(STATE_DIR, 'gate-state.json');
94
+ const CONSTRAINTS_PATH = path.join(STATE_DIR, 'session-constraints.json');
95
+ const STATS_PATH = path.join(STATE_DIR, 'gate-stats.json');
96
+ const SESSION_ACTIONS_PATH = path.join(STATE_DIR, 'session-actions.json');
97
+ const CUSTOM_CLAIM_GATES_PATH = path.join(STATE_DIR, 'claim-verification.json');
98
+ const GOVERNANCE_STATE_PATH = path.join(STATE_DIR, 'governance-state.json');
69
99
  const TTL_MS = 5 * 60 * 1000; // 5 minutes
70
100
  const SESSION_ACTION_TTL_MS = 60 * 60 * 1000; // 1 hour
71
101
  const PROTECTED_APPROVAL_TTL_MS = 60 * 60 * 1000; // 1 hour
@@ -85,12 +115,127 @@ const DEFAULT_PROTECTED_FILE_GLOBS = [
85
115
  const EDIT_LIKE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
86
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;
87
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;
118
+ const MAX_COMMAND_SCAN_CHARS = 20000;
88
119
  const BOOSTED_RISK_BLOCK_SCORE = 0.8;
89
120
  const BOOSTED_RISK_MIN_EXAMPLES = 3;
90
121
  const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
122
+ const HELPER_BYPASS_ACTION = 'helper_script_modified';
123
+ const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
124
+ const KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN = /\b(?:git\s+push\b|gh\s+pr\s+merge\b|gh\s+release\s+(?:create|delete|edit|upload)\b|(?:npm|yarn|pnpm)\s+publish\b|rm\s+-rf\b|git\s+reset\s+--hard\b|git\s+clean\s+-f[a-z]*|railway\s+(?:deploy|up)\b|gcloud\s+(?:run\s+deploy|app\s+deploy)\b|firebase\s+deploy\b|vercel\s+--prod\b|kubectl\s+(?:apply|delete)\b|terraform\s+(?:apply|destroy)\b)\b/i;
125
+ const HELPER_SCRIPT_FILE_PATTERN = /(?:^|\/)(?:scripts|bin|tools|tasks|\.githooks|\.github\/workflows)\/|(?:^|\/)(?:package\.json|Makefile|justfile|Taskfile\.ya?ml)$|\.(?:sh|bash|zsh|fish|js|mjs|cjs|ts|tsx|py|rb|pl|ps1|yml|yaml)$/i;
126
+ const PACKAGE_RUN_PATTERN = /\b(?:npm|yarn|pnpm)\s+run\s+([:@./\w-]+)\b/i;
127
+ const HELPER_EXEC_PATTERN = /(?:^|[;&|]\s*|\b(?:bash|sh|zsh|node|python3?|ruby|perl|tsx|ts-node)\s+)(?:(?:\.?\.?\/)?(?:scripts|bin|tools|tasks|tmp|build|dist|\.tmp|\.cache)\/[^\s;&|]+|\.\/[^\s;&|]+\.(?:sh|bash|zsh|js|mjs|cjs|ts|py|rb|pl|ps1))\b/i;
128
+ const HELPER_WRITE_PATTERN = /\b(?:cat|printf|echo|tee|npm\s+pkg\s+set|jq|node\s+-e|python3?\s+-c)\b[\s\S]{0,300}(?:>|--field|scripts\.|package\.json|(?:scripts|bin|tools|tasks|tmp|build|dist|\.tmp|\.cache)\/[^\s;&|]+|\.(?:sh|js|mjs|cjs|ts|py|rb|pl|ps1))\b/i;
129
+ const NETWORK_OR_PROCESS_BOUNDARY_PATTERN = /\b(?:curl|wget|nc|ncat|socat|ssh|scp|rsync|ftp|python3?\s+-m\s+http\.server|node\s+-e|python3?\s+-c|perl\s+-e|ruby\s+-e|bash\s+-c|sh\s+-c|osascript|open)\b/i;
130
+ const DOWNLOAD_EXEC_CHAIN_PATTERN = /\b(?:curl|wget)\b[\s\S]{0,400}(?:\|\s*(?:bash|sh|zsh)|&&[\s\S]{0,200}\bchmod\s+\+x\b[\s\S]{0,200}&&[\s\S]{0,120}(?:\.\/|bash|sh|node|python3?))/i;
131
+ const DESTRUCTIVE_OR_PRIVILEGE_BOUNDARY_PATTERN = /\b(?:rm\s+-rf|chmod\s+(?:\+x|777)|chown\b|sudo\b|dd\s+if=|mkfs|git\s+reset\s+--hard|git\s+clean\s+-f[a-z]*|kubectl\s+(?:apply|delete)|terraform\s+(?:apply|destroy)|railway\s+(?:deploy|up)|gcloud\s+(?:run\s+deploy|app\s+deploy)|vercel\s+--prod|firebase\s+deploy)\b/i;
132
+
133
+ // ---------------------------------------------------------------------------
134
+ // Enforcement posture (CEO decision 2026-06-04): warn-by-default.
135
+ // The firewall ALWAYS fires and logs every decision, but most gates WARN rather
136
+ // than hard-block — only TRULY CATASTROPHIC, irreversible actions hard-block:
137
+ // - secret exfiltration (handled on its own deny path; never downgraded)
138
+ // - security-vulnerability / supply-chain denies (own deny path; not downgraded)
139
+ // - irreversibly destructive filesystem commands (rm -rf class, mkfs, dd to disk,
140
+ // fork bomb) — kept as hard deny via DESTRUCTIVE_FS_PATTERN below.
141
+ // Everything else (memory-high-risk, workflow-sequence, off-scope, git push, deploy,
142
+ // approval gates) downgrades deny/approve -> warn so legitimate work is never blocked.
143
+ // Opt back into full hard enforcement with THUMBGATE_STRICT_ENFORCEMENT=1.
144
+ // Enforcement posture (CEO decision 2026-06-04): WARN + AUDIT by default.
145
+ // The firewall fires and LOGS every decision, but downgrades deny/approve -> warn so
146
+ // legitimate work is never hard-blocked. We deliberately do NOT try to hard-block
147
+ // arbitrary destructive commands here: a regex "catastrophic floor" is unwinnable
148
+ // (sudo / bash -c / find -exec / eval / base64|sh all evade it) and gives false confidence.
149
+ // HARD enforcement is an explicit opt-in via THUMBGATE_STRICT_ENFORCEMENT=1, which keeps
150
+ // the engine's FULL gate set (its high-risk-command gates catch prefixed/obfuscated forms
151
+ // far better than any single regex). Secret exfiltration and the security-vulnerability
152
+ // scan hard-deny on their OWN paths before this runs, so irreversible data-leak / supply
153
+ // chain risks stay blocked regardless of posture.
154
+ function applyEnforcementPosture(result) {
155
+ if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
156
+ // Full hard enforcement opt-in: keep every deny.
157
+ if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
158
+ // Honor the explicit strict-knowledge-conflict opt-in for that gate.
159
+ if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') return result;
160
+ // Warn-by-default: the gate still fired and is recorded; the action is allowed through
161
+ // with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
162
+ return {
163
+ ...result,
164
+ decision: 'warn',
165
+ warnByDefault: true,
166
+ 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.`,
167
+ };
168
+ }
169
+ const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
170
+ const BREAK_GLASS_SETTINGS_GLOBS = [
171
+ '.claude/settings.local.json',
172
+ '.claude/settings.json',
173
+ '**/.claude/settings.local.json',
174
+ '**/.claude/settings.json',
175
+ '.codex/config.toml',
176
+ '**/.codex/config.toml',
177
+ ];
178
+
179
+ function isRuntimePlanGateEnabled() {
180
+ return process.env.THUMBGATE_PLAN_GATE === '1' || process.env.THUMBGATE_PLAN_GATE === 'true';
181
+ }
91
182
  const PR_THREAD_RESOLUTION_CLAIM_PATTERN = '(?:thread|review|comment).*?(?:resolved|verified|checked|addressed|fixed)|(?:resolved|verified|checked|addressed|fixed).*?(?:thread|review|comment)';
92
183
  const PR_THREAD_RESOLUTION_REQUIRED_ACTIONS = ['pr_threads_checked', 'thread_resolution_verified'];
93
184
 
185
+ function commandScanText(command) {
186
+ return String(command || '').slice(0, MAX_COMMAND_SCAN_CHARS);
187
+ }
188
+
189
+ function commandWords(command) {
190
+ return commandScanText(command).toLowerCase().split(/\s+/).filter(Boolean);
191
+ }
192
+
193
+ function commandContainsSequence(words, sequence) {
194
+ if (!Array.isArray(words) || !Array.isArray(sequence) || sequence.length === 0) return false;
195
+ for (let i = 0; i <= words.length - sequence.length; i += 1) {
196
+ let matched = true;
197
+ for (let j = 0; j < sequence.length; j += 1) {
198
+ if (words[i + j] !== sequence[j]) {
199
+ matched = false;
200
+ break;
201
+ }
202
+ }
203
+ if (matched) return true;
204
+ }
205
+ return false;
206
+ }
207
+
208
+ function commandHasPostMethod(words) {
209
+ for (let i = 0; i < words.length; i += 1) {
210
+ const word = words[i];
211
+ if ((word === '-x' || word === '--method') && words[i + 1] === 'post') return true;
212
+ if (word === '--method=post' || word === '-xpost') return true;
213
+ }
214
+ return false;
215
+ }
216
+
217
+ function isGhApiPrCreateCommand(command) {
218
+ const words = commandWords(command);
219
+ if (!commandContainsSequence(words, ['gh', 'api'])) return false;
220
+ const hasPullsEndpoint = words.some((word) => word === '/pulls' || word.endsWith('/pulls'));
221
+ if (!hasPullsEndpoint) return false;
222
+ const fieldFlags = new Set(['-f', '--field', '--raw-field']);
223
+ const hasFieldWrite = words.some((word) => (
224
+ fieldFlags.has(word) ||
225
+ word.startsWith('-f=') ||
226
+ word.startsWith('--field=') ||
227
+ word.startsWith('--raw-field=')
228
+ ));
229
+ return hasFieldWrite || commandHasPostMethod(words);
230
+ }
231
+
232
+ function isRecursiveChmodCommand(command) {
233
+ const words = commandWords(command);
234
+ const chmodIndex = words.indexOf('chmod');
235
+ if (chmodIndex === -1) return false;
236
+ return words.slice(chmodIndex + 1).includes('-r') || words.slice(chmodIndex + 1).some((word) => word.includes('r') && word.startsWith('-'));
237
+ }
238
+
94
239
  // ---------------------------------------------------------------------------
95
240
  // Config loading
96
241
  // ---------------------------------------------------------------------------
@@ -187,6 +332,27 @@ function sanitizeGlobList(globs) {
187
332
  return [...new Set(globs.map((glob) => normalizeGlob(glob)).filter(Boolean))];
188
333
  }
189
334
 
335
+ // Affected files are compared as repo-relative paths (git --name-only output and
336
+ // inline paths are relative to the repo root). A caller who passes an ABSOLUTE
337
+ // allowedPath (e.g. "/Users/me/proj/src/**") therefore declares a glob that can never
338
+ // match — a silent no-op scope. When repoPath is known, rebase absolute globs that
339
+ // live under it to the repo-relative form so the scope actually applies. Globs already
340
+ // relative, or absolute but outside repoPath, are returned unchanged.
341
+ function rebaseGlobsToRepoRoot(globs, repoPath) {
342
+ // normalizeGlob strips both leading AND trailing slashes, so a repoPath with a
343
+ // trailing slash ("/Users/me/proj/") still matches the repo-relative globs.
344
+ const repoRel = normalizeGlob(repoPath);
345
+ if (!repoRel) return globs;
346
+ return globs.map((glob) => {
347
+ if (glob === repoRel) return '**';
348
+ if (glob.startsWith(`${repoRel}/`)) {
349
+ const rebased = glob.slice(repoRel.length + 1);
350
+ return rebased || '**';
351
+ }
352
+ return glob;
353
+ });
354
+ }
355
+
190
356
  function globToRegExp(glob) {
191
357
  const normalized = normalizeGlob(glob);
192
358
  let pattern = '^';
@@ -240,6 +406,9 @@ function loadGovernanceState() {
240
406
  branchGovernance: raw && raw.branchGovernance && typeof raw.branchGovernance === 'object'
241
407
  ? raw.branchGovernance
242
408
  : null,
409
+ workflowContract: raw && raw.workflowContract && typeof raw.workflowContract === 'object'
410
+ ? raw.workflowContract
411
+ : null,
243
412
  };
244
413
  const now = Date.now();
245
414
  const activeApprovals = state.protectedApprovals.filter((entry) => {
@@ -259,6 +428,7 @@ function saveGovernanceState(state) {
259
428
  taskScope: state && state.taskScope ? state.taskScope : null,
260
429
  protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
261
430
  branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
431
+ workflowContract: state && state.workflowContract ? state.workflowContract : null,
262
432
  };
263
433
  saveJSON(module.exports.GOVERNANCE_STATE_PATH, next);
264
434
  }
@@ -270,33 +440,39 @@ function setTaskScope(scopeInput = {}) {
270
440
  taskScope: null,
271
441
  protectedApprovals: currentState.protectedApprovals,
272
442
  branchGovernance: currentState.branchGovernance,
443
+ workflowContract: null,
273
444
  };
274
445
  saveGovernanceState(cleared);
446
+ refreshLocalOnlyConstraint(cleared);
275
447
  return null;
276
448
  }
277
449
 
278
- const allowedPaths = sanitizeGlobList(scopeInput.allowedPaths);
450
+ const repoPath = String(scopeInput.repoPath || '').trim() || null;
451
+ const allowedPaths = rebaseGlobsToRepoRoot(sanitizeGlobList(scopeInput.allowedPaths), repoPath);
279
452
  if (allowedPaths.length === 0) {
280
453
  throw new Error('allowedPaths must be a non-empty array');
281
454
  }
282
455
 
283
- const protectedPaths = sanitizeGlobList(
456
+ const protectedPaths = rebaseGlobsToRepoRoot(sanitizeGlobList(
284
457
  Array.isArray(scopeInput.protectedPaths) && scopeInput.protectedPaths.length > 0
285
458
  ? scopeInput.protectedPaths
286
459
  : DEFAULT_PROTECTED_FILE_GLOBS
287
- );
460
+ ), repoPath);
288
461
  const taskScope = {
289
462
  taskId: String(scopeInput.taskId || '').trim() || null,
290
463
  summary: String(scopeInput.summary || '').trim() || null,
291
464
  allowedPaths,
292
465
  protectedPaths,
293
466
  localOnly: scopeInput.localOnly === true,
294
- repoPath: String(scopeInput.repoPath || '').trim() || null,
467
+ repoPath,
295
468
  createdAt: new Date().toISOString(),
296
469
  timestamp: Date.now(),
297
470
  };
298
471
  const state = loadGovernanceState();
299
472
  state.taskScope = taskScope;
473
+ state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
474
+ ? scopeInput.workflowContract
475
+ : null;
300
476
  saveGovernanceState(state);
301
477
  if (taskScope.localOnly) {
302
478
  setConstraint('local_only', true);
@@ -332,11 +508,44 @@ function approveProtectedAction(input = {}) {
332
508
  return entry;
333
509
  }
334
510
 
511
+ function breakGlassEmergency(input = {}) {
512
+ const reason = String(input.reason || '').trim();
513
+ if (!reason) {
514
+ throw new Error('reason is required');
515
+ }
516
+
517
+ const ttlMs = Math.min(clampTtlMs(input.ttlMs, TTL_MS), TTL_MS);
518
+ const evidence = `BREAK GLASS: ${reason}`;
519
+ const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
520
+ const satisfied = {};
521
+ for (const gateId of gates) {
522
+ satisfied[gateId] = satisfyCondition(gateId, evidence);
523
+ }
524
+
525
+ const approval = approveProtectedAction({
526
+ pathGlobs: BREAK_GLASS_SETTINGS_GLOBS,
527
+ reason: evidence,
528
+ evidence,
529
+ ttlMs,
530
+ });
531
+
532
+ return {
533
+ ok: true,
534
+ reason,
535
+ ttlMs,
536
+ expiresAt: new Date(Date.now() + ttlMs).toISOString(),
537
+ satisfied,
538
+ approval,
539
+ settingsGlobs: BREAK_GLASS_SETTINGS_GLOBS.slice(),
540
+ };
541
+ }
542
+
335
543
  function setBranchGovernance(input = {}) {
336
544
  if (input && input.clear === true) {
337
545
  const state = loadGovernanceState();
338
546
  state.branchGovernance = null;
339
547
  saveGovernanceState(state);
548
+ refreshLocalOnlyConstraint(state);
340
549
  return null;
341
550
  }
342
551
 
@@ -387,6 +596,24 @@ function setConstraint(key, value) {
387
596
  return constraints[key];
388
597
  }
389
598
 
599
+ function clearConstraint(key) {
600
+ const constraints = loadConstraints();
601
+ delete constraints[key];
602
+ saveConstraints(constraints);
603
+ }
604
+
605
+ function refreshLocalOnlyConstraint(governanceState = loadGovernanceState()) {
606
+ const localOnlyActive = Boolean(
607
+ (governanceState.taskScope && governanceState.taskScope.localOnly) ||
608
+ (governanceState.branchGovernance && governanceState.branchGovernance.localOnly)
609
+ );
610
+ if (localOnlyActive) {
611
+ setConstraint('local_only', true);
612
+ } else {
613
+ clearConstraint('local_only');
614
+ }
615
+ }
616
+
390
617
  function isConditionSatisfied(conditionId) {
391
618
  const state = loadState();
392
619
  const entry = state[conditionId];
@@ -426,7 +653,29 @@ function loadStats() {
426
653
 
427
654
  function saveStats(stats) { saveJSON(module.exports.STATS_PATH, stats); }
428
655
 
429
- function recordStat(gateId, action, gate) {
656
+ function buildGateActionFingerprint(gateId, options = {}) {
657
+ if (options.actionFingerprint) return String(options.actionFingerprint);
658
+ const toolName = options.toolName || options.tool_name || '';
659
+ const toolInput = options.toolInput || options.tool_input || {};
660
+ const parts = [toolName];
661
+ if (typeof toolInput === 'string') {
662
+ parts.push(toolInput);
663
+ } else if (toolInput && typeof toolInput === 'object') {
664
+ parts.push(
665
+ toolInput.command || '',
666
+ toolInput.cmd || '',
667
+ toolInput.file_path || '',
668
+ toolInput.path || '',
669
+ toolInput.description || '',
670
+ toolInput.prompt || '',
671
+ toolInput.pattern || '',
672
+ );
673
+ if (Array.isArray(toolInput.affectedFiles)) parts.push(...toolInput.affectedFiles);
674
+ }
675
+ return actionFingerprint(parts);
676
+ }
677
+
678
+ function recordStat(gateId, action, gate, options = {}) {
430
679
  const stats = loadStats();
431
680
  if (action === 'block') stats.blocked = (stats.blocked || 0) + 1;
432
681
  else if (action === 'warn') stats.warned = (stats.warned || 0) + 1;
@@ -439,6 +688,29 @@ function recordStat(gateId, action, gate) {
439
688
  else if (action === 'warn') stats.byGate[gateId].warned += 1;
440
689
  else if (action === 'approve') stats.byGate[gateId].pendingApproval = (stats.byGate[gateId].pendingApproval || 0) + 1;
441
690
  else if (action === 'log') stats.byGate[gateId].logged = (stats.byGate[gateId].logged || 0) + 1;
691
+
692
+ // Track same-action recurrence within a session for first-time fix rate.
693
+ // Gate-only recurrence over-counts noisy gates; repeats require a stable,
694
+ // sanitized action fingerprint.
695
+ if (action === 'block' || action === 'warn') {
696
+ if (!stats.sessionFiredGates) stats.sessionFiredGates = {};
697
+ if (!stats.sessionFiredActions) stats.sessionFiredActions = {};
698
+ const sessionKey = `session_${Math.floor(Date.now() / SESSION_ACTION_TTL_MS)}`;
699
+ if (!stats.sessionFiredGates[sessionKey]) stats.sessionFiredGates[sessionKey] = {};
700
+ stats.sessionFiredGates[sessionKey][gateId] = true;
701
+
702
+ const fingerprint = buildGateActionFingerprint(gateId, options);
703
+ if (fingerprint) {
704
+ if (!stats.sessionFiredActions[sessionKey]) stats.sessionFiredActions[sessionKey] = {};
705
+ if (!stats.sessionFiredActions[sessionKey][gateId]) stats.sessionFiredActions[sessionKey][gateId] = {};
706
+ if (stats.sessionFiredActions[sessionKey][gateId][fingerprint]) {
707
+ stats.recurringBlocks = (stats.recurringBlocks || 0) + 1;
708
+ } else {
709
+ stats.sessionFiredActions[sessionKey][gateId][fingerprint] = true;
710
+ }
711
+ }
712
+ }
713
+
442
714
  saveStats(stats);
443
715
  // Track lesson freshness when an auto-promoted gate fires
444
716
  if (gate && gate.sourceLessonId) {
@@ -452,6 +724,69 @@ function recordStat(gateId, action, gate) {
452
724
  }
453
725
  }
454
726
 
727
+ // ---------------------------------------------------------------------------
728
+ // Free-tier daily block cap
729
+ // ---------------------------------------------------------------------------
730
+
731
+ /**
732
+ * Count today's gate blocks from stats. Free tier gets FREE_TIER_DAILY_BLOCKS
733
+ * blocks/day. After the limit, deny → warn + upgrade CTA so the action proceeds
734
+ * but the user sees they lost protection.
735
+ */
736
+ function getTodayBlockCount() {
737
+ const stats = loadStats();
738
+ const today = todayKey();
739
+ if (!stats.dailyBlocks || !stats.dailyBlocks[today]) return 0;
740
+ return stats.dailyBlocks[today];
741
+ }
742
+
743
+ function incrementTodayBlockCount() {
744
+ const stats = loadStats();
745
+ const today = todayKey();
746
+ if (!stats.dailyBlocks) stats.dailyBlocks = {};
747
+ // Clean old dates (keep only last 7 days to prevent unbounded growth)
748
+ const keys = Object.keys(stats.dailyBlocks);
749
+ if (keys.length > 7) {
750
+ keys.sort();
751
+ for (const k of keys.slice(0, keys.length - 7)) {
752
+ delete stats.dailyBlocks[k];
753
+ }
754
+ }
755
+ stats.dailyBlocks[today] = (stats.dailyBlocks[today] || 0) + 1;
756
+ saveStats(stats);
757
+ return stats.dailyBlocks[today];
758
+ }
759
+
760
+ /**
761
+ * If the user is free-tier and has exceeded daily block limit, downgrade
762
+ * a deny result to a warn with an upgrade CTA. Returns null if no cap applies.
763
+ */
764
+ function applyDailyBlockCap(denyResult) {
765
+ // Pro, trial, CI, and THUMBGATE_NO_RATE_LIMIT users are uncapped
766
+ if (isProTier()) return null;
767
+ if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
768
+
769
+ const todayCount = getTodayBlockCount();
770
+ if (todayCount < FREE_TIER_DAILY_BLOCKS) {
771
+ // Under limit: allow the block, increment counter
772
+ incrementTodayBlockCount();
773
+ return null;
774
+ }
775
+
776
+ // Over limit: downgrade deny → warn with upgrade CTA
777
+ const remaining = 0;
778
+ return {
779
+ decision: 'warn',
780
+ gate: denyResult.gate,
781
+ message: `⚠️ ${denyResult.message}\n\n🔓 Daily protection limit reached (${FREE_TIER_DAILY_BLOCKS}/${FREE_TIER_DAILY_BLOCKS} blocks used). This action was allowed through. Upgrade for unlimited protection: https://thumbgate.ai/go/pro`,
782
+ severity: denyResult.severity,
783
+ reasoning: (denyResult.reasoning || []).concat([
784
+ `Free-tier daily block limit (${FREE_TIER_DAILY_BLOCKS}) exceeded — deny downgraded to warn`,
785
+ ]),
786
+ dailyBlockCapApplied: true,
787
+ };
788
+ }
789
+
455
790
  // ---------------------------------------------------------------------------
456
791
  // Reasoning chain builder
457
792
  // ---------------------------------------------------------------------------
@@ -507,9 +842,26 @@ function toRepoRelativePath(filePath, repoRoot) {
507
842
  const value = String(filePath || '').trim();
508
843
  if (!value) return '';
509
844
  if (repoRoot && path.isAbsolute(value)) {
510
- const relative = path.relative(repoRoot, value);
511
- if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
512
- return normalizePosix(relative);
845
+ const candidates = [[path.resolve(repoRoot), path.resolve(value)]];
846
+ try {
847
+ const rootReal = fs.realpathSync.native(repoRoot);
848
+ let valueReal = null;
849
+ try {
850
+ valueReal = fs.realpathSync.native(value);
851
+ } catch {
852
+ const parentReal = fs.realpathSync.native(path.dirname(value));
853
+ valueReal = path.join(parentReal, path.basename(value));
854
+ }
855
+ candidates.push([rootReal, valueReal]);
856
+ } catch {
857
+ // Fall back to lexical path comparison below.
858
+ }
859
+
860
+ for (const [rootCandidate, valueCandidate] of candidates) {
861
+ const relative = path.relative(rootCandidate, valueCandidate);
862
+ if (!relative.startsWith('..') && !path.isAbsolute(relative)) {
863
+ return normalizePosix(relative);
864
+ }
513
865
  }
514
866
  }
515
867
  return normalizePosix(value);
@@ -588,7 +940,7 @@ function extractAffectedFiles(toolName, toolInput = {}) {
588
940
  }
589
941
  }
590
942
 
591
- if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command)) {
943
+ if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
592
944
  for (const filePath of getBranchDiffFiles(repoRoot)) {
593
945
  files.add(normalizePosix(filePath));
594
946
  }
@@ -607,6 +959,7 @@ function isHighRiskAction(toolName, toolInput = {}, affectedFiles = []) {
607
959
  const command = String(toolInput.command || '');
608
960
  // Original high-risk pattern (git writes, publishes, destructive ops)
609
961
  if (HIGH_RISK_BASH_PATTERN.test(command)) return true;
962
+ if (isGhApiPrCreateCommand(command)) return true;
610
963
  // Broadened: any Bash command that modifies files or has side effects.
611
964
  // Excludes pure read/analysis commands (node --test, cat, ls, echo, etc.)
612
965
  // to avoid false positives on benign operations.
@@ -810,9 +1163,47 @@ function isThreadResolutionEvidenceAction(toolName, toolInput = {}) {
810
1163
  return /\b(?:gate-satisfy|satisfy_gate|track_action|gh\s+pr\s+(?:view|checks|status)|gh\s+api\b.*(?:reviewThreads|reviews|comments|threads)|git\s+(?:status|diff|show))\b/i.test(command);
811
1164
  }
812
1165
 
1166
+ // Read-only observability/metrics MCP tools must NEVER be blocked by the pending
1167
+ // PR-thread-resolution gate. Reading revenue, the dashboard, gate stats, or a
1168
+ // semantic entity cannot advance a "done" claim or mutate any state, so gating it
1169
+ // only blinds the operator to their own numbers. This is the exact failure the CEO
1170
+ // hit on 2026-06-30: `get_business_metrics` denied with "a git commit was made on a
1171
+ // PR branch" — a governance gate eating the observability path. The exempt set is
1172
+ // sourced from the canonical `readonly` MCP profile (config/mcp-allowlists.json) so
1173
+ // it cannot drift from the product's own definition of "safe to read"; the hard-coded
1174
+ // fallback guarantees the core observability tools stay readable even if that policy
1175
+ // file is unreadable at runtime.
1176
+ const READ_ONLY_TOOL_FALLBACK = new Set([
1177
+ 'get_business_metrics', 'describe_semantic_entity', 'describe_reliability_entity',
1178
+ 'get_reliability_rules', 'dashboard', 'org_dashboard', 'gate_stats', 'feedback_stats',
1179
+ 'feedback_summary', 'session_report', 'generate_operator_artifact', 'settings_status',
1180
+ 'get_scope_state', 'get_branch_governance', 'context_provenance', 'native_messaging_audit',
1181
+ 'list_harnesses', 'list_intents', 'list_imported_documents', 'get_imported_document',
1182
+ 'check_operational_integrity', 'workflow_sentinel', 'recall', 'search_lessons',
1183
+ 'retrieve_lessons', 'search_thumbgate', 'unified_context', 'verify_claim',
1184
+ ]);
1185
+ let readOnlyToolCache = null;
1186
+ function getReadOnlyToolNames() {
1187
+ if (readOnlyToolCache) return readOnlyToolCache;
1188
+ const names = new Set(READ_ONLY_TOOL_FALLBACK);
1189
+ try {
1190
+ const { getAllowedTools } = require('./mcp-policy');
1191
+ for (const tool of getAllowedTools('readonly')) names.add(tool);
1192
+ } catch (_) {
1193
+ // Policy file missing/malformed — the fallback set still covers the core
1194
+ // observability tools, so the operator can always read state.
1195
+ }
1196
+ readOnlyToolCache = names;
1197
+ return names;
1198
+ }
1199
+ function isReadOnlyObservabilityTool(toolName) {
1200
+ return Boolean(toolName) && getReadOnlyToolNames().has(toolName);
1201
+ }
1202
+
813
1203
  function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
814
1204
  if (!hasAction(PR_THREAD_RESOLUTION_ACTION)) return null;
815
1205
  if (isThreadResolutionSatisfied()) return null;
1206
+ if (isReadOnlyObservabilityTool(toolName)) return null;
816
1207
  if (isThreadResolutionEvidenceAction(toolName, toolInput)) return null;
817
1208
 
818
1209
  const message = 'A git commit was made on a PR branch. Verify review threads are resolved before the next tool call.';
@@ -844,7 +1235,8 @@ function getLocalOnlyScopeSources(governanceState = {}, constraints = {}) {
844
1235
 
845
1236
  function isRemoteSideEffectCommand(toolName, toolInput = {}) {
846
1237
  if (toolName !== 'Bash') return false;
847
- return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(String(toolInput.command || ''));
1238
+ const command = String(toolInput.command || '');
1239
+ return REMOTE_SIDE_EFFECT_BASH_PATTERN.test(command) || isGhApiPrCreateCommand(command);
848
1240
  }
849
1241
 
850
1242
  function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governanceState = {}, constraints = {}) {
@@ -866,8 +1258,127 @@ function evaluateLocalOnlyRemoteSideEffectGate(toolName, toolInput = {}, governa
866
1258
  };
867
1259
  }
868
1260
 
1261
+ function helperRiskReasons(text) {
1262
+ const value = String(text || '');
1263
+ const reasons = [];
1264
+ if (DOWNLOAD_EXEC_CHAIN_PATTERN.test(value)) reasons.push('download-then-execute chain');
1265
+ if (NETWORK_OR_PROCESS_BOUNDARY_PATTERN.test(value)) reasons.push('network/process boundary');
1266
+ if (DESTRUCTIVE_OR_PRIVILEGE_BOUNDARY_PATTERN.test(value)) reasons.push('destructive/privileged side effect');
1267
+ if (/\b(?:child_process|spawn\(|exec\(|execFile\(|subprocess|ProcessBuilder)\b/i.test(value)) {
1268
+ reasons.push('process spawn');
1269
+ }
1270
+ return [...new Set(reasons)];
1271
+ }
1272
+
1273
+ function readPackageScript(repoRoot, scriptName) {
1274
+ if (!repoRoot || !scriptName) return '';
1275
+ try {
1276
+ const packagePath = path.join(repoRoot, 'package.json');
1277
+ if (!fs.existsSync(packagePath)) return '';
1278
+ const { scripts = {} } = JSON.parse(fs.readFileSync(packagePath, 'utf8')) || {};
1279
+ return typeof scripts[scriptName] === 'string' ? scripts[scriptName] : '';
1280
+ } catch {
1281
+ return '';
1282
+ }
1283
+ }
1284
+
1285
+ function recordHelperScriptWrite(toolName, toolInput = {}) {
1286
+ if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
1287
+
1288
+ const affected = extractAffectedFiles(toolName, toolInput);
1289
+ const affectedFiles = affected.files || [];
1290
+ const command = String(toolInput.command || '');
1291
+ const actionContext = extractActionContext(toolName, toolInput);
1292
+ const helperFiles = affectedFiles.filter((filePath) => HELPER_SCRIPT_FILE_PATTERN.test(normalizePosix(filePath)));
1293
+ const packageScriptTouched = affectedFiles.some((filePath) => normalizePosix(filePath) === 'package.json') || /\bpackage\.json\b|\bscripts\./i.test(actionContext);
1294
+ const commandWrite = toolName === 'Bash' && HELPER_WRITE_PATTERN.test(command);
1295
+
1296
+ if (helperFiles.length === 0 && !packageScriptTouched && !commandWrite) return null;
1297
+
1298
+ const riskText = [
1299
+ actionContext,
1300
+ command,
1301
+ helperFiles.join(' '),
1302
+ packageScriptTouched ? 'package.json scripts' : '',
1303
+ ].filter(Boolean).join('\n');
1304
+ const reasons = helperRiskReasons(riskText);
1305
+
1306
+ const metadata = {
1307
+ repoRoot: affected.repoRoot || resolveRepoRoot(toolInput) || null,
1308
+ helperFiles,
1309
+ packageScriptTouched,
1310
+ reasons,
1311
+ };
1312
+ trackAction(HELPER_BYPASS_ACTION, metadata);
1313
+ return metadata;
1314
+ }
1315
+
1316
+ function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
1317
+ if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
1318
+ if (toolName !== 'Bash') {
1319
+ recordHelperScriptWrite(toolName, toolInput);
1320
+ return null;
1321
+ }
1322
+
1323
+ const command = String(toolInput.command || '').trim();
1324
+ if (!command) return null;
1325
+
1326
+ if (DOWNLOAD_EXEC_CHAIN_PATTERN.test(command)) {
1327
+ return {
1328
+ decision: 'deny',
1329
+ gate: 'stateful-helper-script-bypass',
1330
+ message: 'Download-then-execute chains are blocked before the real action moves below the visible tool call.',
1331
+ severity: 'critical',
1332
+ reasoning: [
1333
+ `Command matched download/execute chain: ${command.slice(0, 180)}`,
1334
+ 'PreToolUse must review the whole action chain, not only the first low-risk command',
1335
+ ],
1336
+ };
1337
+ }
1338
+
1339
+ const writeMetadata = recordHelperScriptWrite(toolName, toolInput);
1340
+ const actions = listSessionActions();
1341
+ const recentWrite = actions[HELPER_BYPASS_ACTION];
1342
+ const recentMetadata = recentWrite && recentWrite.metadata && typeof recentWrite.metadata === 'object'
1343
+ ? recentWrite.metadata
1344
+ : null;
1345
+
1346
+ const scriptMatch = command.match(PACKAGE_RUN_PATTERN);
1347
+ const scriptName = scriptMatch ? scriptMatch[1] : '';
1348
+ const repoRoot = resolveRepoRoot(toolInput);
1349
+ const packageScript = scriptName ? readPackageScript(repoRoot, scriptName) : '';
1350
+ const commandBoundaryReasons = helperRiskReasons(`${command}\n${packageScript}`);
1351
+ const executesRecentHelper = HELPER_EXEC_PATTERN.test(command);
1352
+ const runsRecentPackageScript = Boolean(scriptName && recentMetadata && recentMetadata.packageScriptTouched);
1353
+ const recentReasons = recentMetadata && Array.isArray(recentMetadata.reasons) ? recentMetadata.reasons : [];
1354
+ const writeRiskReasons = writeMetadata && Array.isArray(writeMetadata.reasons) ? writeMetadata.reasons : [];
1355
+ const correlatedReasons = [...new Set([...recentReasons, ...writeRiskReasons, ...commandBoundaryReasons])];
1356
+
1357
+ if (
1358
+ (executesRecentHelper || runsRecentPackageScript) &&
1359
+ (recentMetadata || writeMetadata) &&
1360
+ correlatedReasons.length > 0
1361
+ ) {
1362
+ const target = scriptName ? `package script "${scriptName}"` : 'recent helper script';
1363
+ return {
1364
+ decision: 'deny',
1365
+ gate: 'stateful-helper-script-bypass',
1366
+ message: `A recently modified ${target} now crosses a risky boundary. Review it or constrain cwd, network, process, and writable paths before execution.`,
1367
+ severity: 'critical',
1368
+ reasoning: [
1369
+ `Recent helper/package modification: ${(recentMetadata && recentMetadata.helperFiles || []).join(', ') || (recentMetadata && recentMetadata.packageScriptTouched ? 'package.json scripts' : 'current command write')}`,
1370
+ `Execution command: ${command.slice(0, 180)}`,
1371
+ `Risk reasons: ${correlatedReasons.join(', ')}`,
1372
+ 'This blocks the helper-script/package-script bypass class raised in external review',
1373
+ ],
1374
+ };
1375
+ }
1376
+
1377
+ return null;
1378
+ }
1379
+
869
1380
  function recordStructuralGateBlock(toolName, toolInput, result) {
870
- recordStat(result.gate, 'block');
1381
+ recordStat(result.gate, 'block', null, { toolName, toolInput });
871
1382
  const auditRecord = recordAuditEvent({
872
1383
  toolName,
873
1384
  toolInput,
@@ -898,6 +1409,25 @@ function shouldEnforceTaskScope(gate, governanceState, toolName, toolInput = {},
898
1409
  return isScopeEnforcedAction(toolName, toolInput, affectedFiles);
899
1410
  }
900
1411
 
1412
+ function isAgentHookSettingsFile(filePath) {
1413
+ return matchesAnyGlob(filePath, BREAK_GLASS_SETTINGS_GLOBS);
1414
+ }
1415
+
1416
+ function isBreakGlassSettingsBypass(gate, affectedFiles) {
1417
+ if (!gate || !['task-scope-edit-boundary', 'protected-file-approval-required'].includes(gate.id)) {
1418
+ return false;
1419
+ }
1420
+ if (!isConditionSatisfied(BREAK_GLASS_CONDITION)) return false;
1421
+ return Array.isArray(affectedFiles) && affectedFiles.length > 0 && affectedFiles.every(isAgentHookSettingsFile);
1422
+ }
1423
+
1424
+ function isBreakGlassSettingsRecoveryAction(toolName, toolInput = {}) {
1425
+ if (!EDIT_LIKE_TOOLS.has(toolName)) return false;
1426
+ if (!isConditionSatisfied(BREAK_GLASS_CONDITION)) return false;
1427
+ const affectedFiles = extractAffectedFiles(toolName, toolInput).files;
1428
+ return affectedFiles.length > 0 && affectedFiles.every(isAgentHookSettingsFile);
1429
+ }
1430
+
901
1431
  function formatFileList(files, limit = 5) {
902
1432
  const items = Array.isArray(files) ? files.filter(Boolean) : [];
903
1433
  if (items.length === 0) return 'none';
@@ -1106,8 +1636,19 @@ function checkWhenClause(when, constraints) {
1106
1636
  if (!when || !when.constraints) return true;
1107
1637
 
1108
1638
  for (const [key, expectedValue] of Object.entries(when.constraints)) {
1109
- const constraint = constraints[key];
1110
- if (!constraint || constraint.value !== expectedValue) {
1639
+ let value;
1640
+ if (key === 'careful_mode') {
1641
+ const isEnvTrue = process.env.THUMBGATE_CAREFUL_MODE === '1' ||
1642
+ String(process.env.THUMBGATE_CAREFUL_MODE).toLowerCase() === 'true';
1643
+ value = isEnvTrue || (constraints[key] && constraints[key].value === expectedValue);
1644
+ } else if (key === 'freeze_mode') {
1645
+ value = Boolean(process.env.THUMBGATE_FREEZE_PATHS) ||
1646
+ (constraints[key] && constraints[key].value !== false && constraints[key].value !== null && constraints[key].value !== undefined);
1647
+ } else {
1648
+ const constraint = constraints[key];
1649
+ value = constraint && constraint.value === expectedValue;
1650
+ }
1651
+ if (!value) {
1111
1652
  return false;
1112
1653
  }
1113
1654
  }
@@ -1115,11 +1656,69 @@ function checkWhenClause(when, constraints) {
1115
1656
  }
1116
1657
 
1117
1658
  function matchGate(gate, toolName, toolInput = {}) {
1118
- const matchText = toolInput.command || toolInput.file_path || toolInput.path || '';
1659
+ let matchText = toolInput.command || toolInput.file_path || toolInput.path || '';
1660
+
1661
+ // Claw/hybrid support: enrich matchText with claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
1662
+ const clawCtx = toolInput.clawContext || toolInput._claw || (toolInput.agentId ? {
1663
+ actionType: toolInput.actionType || 'unknown',
1664
+ agentId: toolInput.agentId || 'unknown',
1665
+ hybridRoute: toolInput.hybridRoute || 'unknown',
1666
+ screenInteraction: !!toolInput.screenInteraction,
1667
+ fileAccess: !!toolInput.fileAccess,
1668
+ } : null);
1669
+
1670
+ if (clawCtx) {
1671
+ const actionType = clawCtx.actionType || clawCtx.claw_action_type || 'unknown';
1672
+ const parts = [
1673
+ matchText,
1674
+ `claw_style: true`,
1675
+ `agent_identity: ${clawCtx.agentId || 'unknown'}`,
1676
+ `claw_action_type: ${actionType}`,
1677
+ `hybrid_route: ${clawCtx.hybridRoute || 'unknown'}`,
1678
+ ];
1679
+
1680
+ if (clawCtx.screenInteraction || actionType.includes('screen')) {
1681
+ parts.push('screen_interaction');
1682
+ parts.push('interact screen');
1683
+ }
1684
+ if (clawCtx.fileAccess || actionType.includes('file') || actionType.includes('fs')) {
1685
+ parts.push('file_system_access');
1686
+ parts.push('local device file system access');
1687
+ }
1688
+ if (actionType === 'dynamic-tool-creation' || actionType.includes('create-tool') || actionType.includes('define-tool')) {
1689
+ parts.push('create tool');
1690
+ }
1691
+
1692
+ matchText = parts.filter(Boolean).join(' | ');
1693
+ }
1694
+
1119
1695
  const affected = extractAffectedFiles(toolName, toolInput);
1120
1696
  const affectedFiles = affected.files;
1121
1697
  const repoRoot = affected.repoRoot;
1122
1698
  const governanceState = loadGovernanceState();
1699
+ const constraints = loadConstraints();
1700
+
1701
+ if (gate.id === 'on-demand-freeze-mode' || (gate.when && gate.when.constraints && gate.when.constraints.freeze_mode)) {
1702
+ let freezePaths = [];
1703
+ if (process.env.THUMBGATE_FREEZE_PATHS) {
1704
+ freezePaths = process.env.THUMBGATE_FREEZE_PATHS.split(',').map(p => p.trim()).filter(Boolean);
1705
+ } else if (constraints.freeze_mode && typeof constraints.freeze_mode.value === 'string') {
1706
+ freezePaths = constraints.freeze_mode.value.split(',').map(p => p.trim()).filter(Boolean);
1707
+ } else if (constraints.freeze_mode && Array.isArray(constraints.freeze_mode.value)) {
1708
+ freezePaths = constraints.freeze_mode.value;
1709
+ } else if (governanceState.taskScope && Array.isArray(governanceState.taskScope.allowedPaths)) {
1710
+ freezePaths = governanceState.taskScope.allowedPaths;
1711
+ }
1712
+
1713
+ if (freezePaths.length > 0) {
1714
+ const outsideFiles = affectedFiles.filter((filePath) => !matchesAnyGlob(filePath, freezePaths));
1715
+ if (outsideFiles.length > 0) {
1716
+ return { matched: true, matchText, affectedFiles };
1717
+ } else {
1718
+ return { matched: false, matchText, affectedFiles };
1719
+ }
1720
+ }
1721
+ }
1123
1722
 
1124
1723
  if (Array.isArray(gate.toolNames) && gate.toolNames.length > 0 && !gate.toolNames.includes(toolName)) {
1125
1724
  return { matched: false, matchText, affectedFiles };
@@ -1129,6 +1728,12 @@ function matchGate(gate, toolName, toolInput = {}) {
1129
1728
  try {
1130
1729
  const regex = new RegExp(gate.pattern);
1131
1730
  if (!regex.test(matchText)) return { matched: false, matchText, affectedFiles };
1731
+ if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
1732
+ return { matched: false, matchText, affectedFiles };
1733
+ }
1734
+ if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
1735
+ return { matched: false, matchText, affectedFiles };
1736
+ }
1132
1737
  } catch {
1133
1738
  return { matched: false, matchText, affectedFiles };
1134
1739
  }
@@ -1146,6 +1751,9 @@ function matchGate(gate, toolName, toolInput = {}) {
1146
1751
 
1147
1752
  let taskScopeViolation = null;
1148
1753
  if (gate.requireTaskScope) {
1754
+ if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
1755
+ return { matched: false, matchText, affectedFiles };
1756
+ }
1149
1757
  if (!shouldEnforceTaskScope(gate, governanceState, toolName, toolInput, affectedFiles)) {
1150
1758
  return { matched: false, matchText, affectedFiles };
1151
1759
  }
@@ -1155,6 +1763,9 @@ function matchGate(gate, toolName, toolInput = {}) {
1155
1763
 
1156
1764
  let protectedApprovalViolation = null;
1157
1765
  if (gate.requireProtectedApproval) {
1766
+ if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
1767
+ return { matched: false, matchText, affectedFiles };
1768
+ }
1158
1769
  const protectedGlobs = sanitizeGlobList(
1159
1770
  Array.isArray(gate.protectedGlobs) && gate.protectedGlobs.length > 0
1160
1771
  ? gate.protectedGlobs
@@ -1194,9 +1805,40 @@ function matchesGate(gate, toolName, toolInput) {
1194
1805
  return matchGate(gate, toolName, toolInput).matched;
1195
1806
  }
1196
1807
 
1808
+ function isSafeLocalCredentialHardeningCommand(toolName, toolInput = {}) {
1809
+ if (toolName !== 'Bash') return false;
1810
+ const command = String(toolInput.command || '').trim();
1811
+ if (!command || isRecursiveChmodCommand(command)) return false;
1812
+ if (/[;&|`$()<>*?[\]{}]/.test(command)) return false;
1813
+
1814
+ const match = command.match(/(?:^|\s)chmod\s+(?:-[fv]\s+)?0?([46]00)\s+(['"]?)(\S+)\2\s*$/i);
1815
+ if (!match) return false;
1816
+
1817
+ const target = match[3];
1818
+ if (!target || target === '/' || target === '~') return false;
1819
+ if (target.includes('..')) return false;
1820
+
1821
+ const normalized = target.replace(/^['"]|['"]$/g, '').toLowerCase();
1822
+ const looksLikeCredentialPath = /(?:^|\/)(?:\.config|\.ssh|\.gnupg|\.aws|\.gcloud|\.gemini|\.resume_secrets|\.thumbgate|secrets?|credentials?)(?:\/|$)/.test(normalized)
1823
+ || /(?:key|secret|token|credential|gemini|gcloud|google|operator).*\.(?:json|pem|key|env)$/i.test(normalized)
1824
+ || /\.(?:pem|key)$/i.test(normalized);
1825
+
1826
+ return looksLikeCredentialPath;
1827
+ }
1828
+
1197
1829
  function evaluateMemoryGuard(toolName, toolInput = {}) {
1198
1830
  const affected = extractAffectedFiles(toolName, toolInput);
1199
1831
  const affectedFiles = affected.files;
1832
+ if (isSafeSecretStorageWrite(toolName, toolInput, process.cwd())) {
1833
+ return null;
1834
+ }
1835
+ // Hardening a credential file's permissions (chmod 600 on a key/secret path) is
1836
+ // a safety action, not a risk. The same exemption already guards the
1837
+ // permission-change-approval gate; without it here, `chmod 600 ~/.resume_secrets/key`
1838
+ // gets hard-denied by recurring-negative-memory matching — the opposite of intent.
1839
+ if (isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
1840
+ return null;
1841
+ }
1200
1842
  if (!isHighRiskAction(toolName, toolInput, affectedFiles)) {
1201
1843
  return null;
1202
1844
  }
@@ -1210,7 +1852,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1210
1852
  }
1211
1853
 
1212
1854
  const command = String(toolInput.command || '');
1213
- if (toolName === 'Bash' && /\bgh\s+pr\s+create\b/i.test(command) && isConditionSatisfied('pr_create_allowed')) {
1855
+ const isPrCreateCommand = toolName === 'Bash' && (
1856
+ /\bgh\s+pr\s+create\b/i.test(command) || isGhApiPrCreateCommand(command)
1857
+ );
1858
+ if (isPrCreateCommand && isConditionSatisfied('pr_create_allowed')) {
1214
1859
  const branchGovernanceViolation = buildBranchGovernanceViolation(
1215
1860
  governanceState,
1216
1861
  toolInput,
@@ -1223,7 +1868,10 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1223
1868
  }
1224
1869
  }
1225
1870
 
1226
- if (toolName === 'Bash' && /\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)) {
1871
+ if (toolName === 'Bash' && (
1872
+ /\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) ||
1873
+ isGhApiPrCreateCommand(command)
1874
+ )) {
1227
1875
  const branchGovernanceViolation = buildBranchGovernanceViolation(
1228
1876
  governanceState,
1229
1877
  toolInput,
@@ -1261,7 +1909,14 @@ function evaluateMemoryGuard(toolName, toolInput = {}) {
1261
1909
  filePath: toolInput.file_path || toolInput.path || null,
1262
1910
  affectedFiles,
1263
1911
  });
1264
- const guard = hybrid.evaluatePretool(toolName, serializedInput);
1912
+ // Claw/hybrid support: pass context if agent provides claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
1913
+ let guard;
1914
+ if (toolInput && (toolInput.clawContext || toolInput._claw || toolInput.hybridRoute || toolInput.agentId)) {
1915
+ const clawCtx = toolInput.clawContext || toolInput._claw || { actionType: toolInput.actionType || 'unknown', agentId: toolInput.agentId || 'unknown', hybridRoute: toolInput.hybridRoute || 'unknown' };
1916
+ guard = hybrid.evaluateClawPretool ? hybrid.evaluateClawPretool(toolName, serializedInput, clawCtx) : hybrid.evaluatePretool(toolName, serializedInput);
1917
+ } else {
1918
+ guard = hybrid.evaluatePretool(toolName, serializedInput);
1919
+ }
1265
1920
  if (!guard || guard.mode === 'allow') {
1266
1921
  return null;
1267
1922
  }
@@ -1374,6 +2029,19 @@ async function checkMetricCondition(metricCondition) {
1374
2029
  return true;
1375
2030
  }
1376
2031
 
2032
+ /**
2033
+ * Whether this run is autonomous — i.e. no human is present to resolve an
2034
+ * `approve` (human-in-the-loop) gate. Opt-in ONLY via THUMBGATE_AUTONOMOUS=1
2035
+ * (or "true"); interactive and existing CI behavior is unchanged unless an
2036
+ * operator explicitly sets it. In an autonomous agent loop an approval gate has
2037
+ * nobody to sign off, so it must fail CLOSED (deny) rather than defer forever or
2038
+ * slip through — a guardrail has to guard precisely when it is unattended.
2039
+ */
2040
+ function isAutonomousRun() {
2041
+ const raw = String(process.env.THUMBGATE_AUTONOMOUS || '').trim().toLowerCase();
2042
+ return raw === '1' || raw === 'true';
2043
+ }
2044
+
1377
2045
  async function evaluateGatesAsync(toolName, toolInput, configPath) {
1378
2046
  let config;
1379
2047
  try {
@@ -1399,10 +2067,26 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1399
2067
  if (localOnlyRemoteSideEffectGate) {
1400
2068
  return recordStructuralGateBlock(toolName, toolInput, localOnlyRemoteSideEffectGate);
1401
2069
  }
2070
+ const statefulHelperBypassGate = evaluateStatefulHelperBypassGate(toolName, toolInput);
2071
+ if (statefulHelperBypassGate) {
2072
+ return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
2073
+ }
2074
+ if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
2075
+ recordAuditEvent({
2076
+ toolName,
2077
+ toolInput,
2078
+ decision: 'allow',
2079
+ gateId: BREAK_GLASS_CONDITION,
2080
+ message: 'Break-glass recovery allowed hook settings edit',
2081
+ severity: 'high',
2082
+ source: 'gates-engine',
2083
+ });
2084
+ return null;
2085
+ }
1402
2086
 
1403
2087
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
1404
2088
  if (pendingThreadResolutionGate) {
1405
- recordStat(pendingThreadResolutionGate.gate, 'block');
2089
+ recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
1406
2090
  const auditRecord = recordAuditEvent({
1407
2091
  toolName,
1408
2092
  toolInput,
@@ -1418,7 +2102,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1418
2102
 
1419
2103
  const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
1420
2104
  if (boostedRiskGuard) {
1421
- recordStat(boostedRiskGuard.gate, 'block');
2105
+ recordStat(boostedRiskGuard.gate, 'block', null, { toolName, toolInput });
1422
2106
  const auditRecord = recordAuditEvent({
1423
2107
  toolName,
1424
2108
  toolInput,
@@ -1432,6 +2116,23 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1432
2116
  return boostedRiskGuard;
1433
2117
  }
1434
2118
 
2119
+ // Tier 1b: Planning and Trajectory (v1.26.0 - CodeRabbit Pattern).
2120
+ // Keep runtime enforcement explicit so advisory planning checks do not mask
2121
+ // higher-priority deny/approve gates in established workflows.
2122
+ if (isRuntimePlanGateEnabled()) {
2123
+ const planGate = evaluatePlanGate(toolName, toolInput);
2124
+ if (planGate) {
2125
+ recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
2126
+ return planGate;
2127
+ }
2128
+
2129
+ const trajectory = getTrajectoryScore();
2130
+ if (trajectory.isDrifting) {
2131
+ recordStat('strategic-drift', 'block', null, { toolName, toolInput });
2132
+ return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
2133
+ }
2134
+ }
2135
+
1435
2136
  // Fast-path: feedback/recall tools skip metric gates entirely (avoids Stripe API calls)
1436
2137
  const METRIC_SKIP_TOOLS = ['capture_feedback', 'feedback_stats', 'recall', 'feedback_summary', 'prevention_rules'];
1437
2138
  const skipMetrics = METRIC_SKIP_TOOLS.includes(toolName);
@@ -1477,23 +2178,48 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1477
2178
  });
1478
2179
 
1479
2180
  if (gate.action === 'block') {
1480
- recordStat(gate.id, 'block', gate);
1481
- const result = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
2181
+ const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
2182
+ // Free-tier daily block cap: after N blocks/day, deny warn + upgrade CTA
2183
+ const cappedResult = applyDailyBlockCap(denyResult);
2184
+ if (cappedResult) {
2185
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
2186
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
2187
+ auditToFeedback(auditRecord);
2188
+ return cappedResult;
2189
+ }
2190
+ recordStat(gate.id, 'block', gate, { toolName, toolInput });
1482
2191
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1483
2192
  auditToFeedback(auditRecord);
1484
- return result;
2193
+ return denyResult;
1485
2194
  }
1486
2195
 
1487
2196
  if (gate.action === 'approve') {
1488
- recordStat(gate.id, 'approve', gate);
1489
- const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1490
- const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
2197
+ const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
2198
+ if (approvalEnabled) {
2199
+ if (isAutonomousRun()) {
2200
+ // Autonomous run: no human to approve. Fail CLOSED so the actions that
2201
+ // most need sign-off cannot slip through unattended.
2202
+ const failClosedMessage = `[autonomous run — no approver present, failing closed] ${message}`;
2203
+ recordStat(gate.id, 'block', gate, { toolName, toolInput });
2204
+ const failClosedAudit = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: failClosedMessage, severity: gate.severity, source: 'gates-engine', autonomousFailClosed: true });
2205
+ auditToFeedback(failClosedAudit);
2206
+ return { decision: 'deny', gate: gate.id, message: failClosedMessage, severity: gate.severity, reasoning, requiresApproval: true, failedClosed: true };
2207
+ }
2208
+ recordStat(gate.id, 'approve', gate, { toolName, toolInput });
2209
+ const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
2210
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
2211
+ auditToFeedback(auditRecord);
2212
+ return result;
2213
+ }
2214
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
2215
+ const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
2216
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1491
2217
  auditToFeedback(auditRecord);
1492
2218
  return result;
1493
2219
  }
1494
2220
 
1495
2221
  if (gate.action === 'log') {
1496
- recordStat(gate.id, 'log', gate);
2222
+ recordStat(gate.id, 'log', gate, { toolName, toolInput });
1497
2223
  const result = { decision: 'log', gate: gate.id, message, severity: gate.severity, reasoning, logged: true };
1498
2224
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1499
2225
  auditToFeedback(auditRecord);
@@ -1502,7 +2228,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1502
2228
  }
1503
2229
 
1504
2230
  if (gate.action === 'warn') {
1505
- recordStat(gate.id, 'warn', gate);
2231
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
1506
2232
  const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
1507
2233
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1508
2234
  auditToFeedback(auditRecord);
@@ -1510,14 +2236,15 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1510
2236
  }
1511
2237
  }
1512
2238
 
1513
- const sentinelReport = evaluateWorkflowSentinel(toolName, toolInput, {
2239
+ const skipAdvisoryGuards = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
2240
+ const sentinelReport = skipAdvisoryGuards ? null : evaluateWorkflowSentinel(toolName, toolInput, {
1514
2241
  governanceState,
1515
2242
  });
1516
2243
  const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
1517
2244
  const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
1518
2245
  if (memoryGuard) {
1519
2246
  const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
1520
- recordStat(enrichedMemoryGuard.gate, 'block');
2247
+ recordStat(enrichedMemoryGuard.gate, 'block', null, { toolName, toolInput });
1521
2248
  recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
1522
2249
  const auditRecord = recordAuditEvent({
1523
2250
  toolName,
@@ -1534,7 +2261,7 @@ async function evaluateGatesAsync(toolName, toolInput, configPath) {
1534
2261
 
1535
2262
  if (sentinelReport && sentinelReport.decision !== 'allow') {
1536
2263
  const sentinelResult = buildSentinelGateResult(sentinelReport);
1537
- recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
2264
+ recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
1538
2265
  recordSentinelBlockDecision(sentinelDecision, sentinelResult);
1539
2266
  const auditRecord = recordAuditEvent({
1540
2267
  toolName,
@@ -1580,10 +2307,26 @@ function evaluateGates(toolName, toolInput, configPath) {
1580
2307
  if (localOnlyRemoteSideEffectGate) {
1581
2308
  return recordStructuralGateBlock(toolName, toolInput, localOnlyRemoteSideEffectGate);
1582
2309
  }
2310
+ const statefulHelperBypassGate = evaluateStatefulHelperBypassGate(toolName, toolInput);
2311
+ if (statefulHelperBypassGate) {
2312
+ return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
2313
+ }
2314
+ if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
2315
+ recordAuditEvent({
2316
+ toolName,
2317
+ toolInput,
2318
+ decision: 'allow',
2319
+ gateId: BREAK_GLASS_CONDITION,
2320
+ message: 'Break-glass recovery allowed hook settings edit',
2321
+ severity: 'high',
2322
+ source: 'gates-engine',
2323
+ });
2324
+ return null;
2325
+ }
1583
2326
 
1584
2327
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
1585
2328
  if (pendingThreadResolutionGate) {
1586
- recordStat(pendingThreadResolutionGate.gate, 'block');
2329
+ recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
1587
2330
  const auditRecord = recordAuditEvent({
1588
2331
  toolName,
1589
2332
  toolInput,
@@ -1599,7 +2342,7 @@ function evaluateGates(toolName, toolInput, configPath) {
1599
2342
 
1600
2343
  const boostedRiskGuard = evaluateBoostedRiskTagGuard(toolName, toolInput);
1601
2344
  if (boostedRiskGuard) {
1602
- recordStat(boostedRiskGuard.gate, 'block');
2345
+ recordStat(boostedRiskGuard.gate, 'block', null, { toolName, toolInput });
1603
2346
  const auditRecord = recordAuditEvent({
1604
2347
  toolName,
1605
2348
  toolInput,
@@ -1613,6 +2356,23 @@ function evaluateGates(toolName, toolInput, configPath) {
1613
2356
  return boostedRiskGuard;
1614
2357
  }
1615
2358
 
2359
+ // Tier 1b: Planning and Trajectory (v1.26.0 - CodeRabbit Pattern).
2360
+ // Keep runtime enforcement explicit so advisory planning checks do not mask
2361
+ // higher-priority deny/approve gates in established workflows.
2362
+ if (isRuntimePlanGateEnabled()) {
2363
+ const planGate = evaluatePlanGate(toolName, toolInput);
2364
+ if (planGate) {
2365
+ recordStat(planGate.gate, planGate.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
2366
+ return planGate;
2367
+ }
2368
+
2369
+ const trajectory = getTrajectoryScore();
2370
+ if (trajectory.isDrifting) {
2371
+ recordStat('strategic-drift', 'block', null, { toolName, toolInput });
2372
+ return { decision: 'deny', gate: 'strategic-drift', message: trajectory.message, severity: 'high' };
2373
+ }
2374
+ }
2375
+
1616
2376
  for (const gate of config.gates) {
1617
2377
  const matchDetails = matchGate(gate, toolName, toolInput);
1618
2378
  if (!matchDetails.matched) continue;
@@ -1631,23 +2391,48 @@ function evaluateGates(toolName, toolInput, configPath) {
1631
2391
  const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
1632
2392
 
1633
2393
  if (gate.action === 'block') {
1634
- recordStat(gate.id, 'block', gate);
1635
- const result = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
2394
+ const denyResult = { decision: 'deny', gate: gate.id, message, severity: gate.severity, reasoning };
2395
+ // Free-tier daily block cap: after N blocks/day, deny warn + upgrade CTA
2396
+ const cappedResult = applyDailyBlockCap(denyResult);
2397
+ if (cappedResult) {
2398
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
2399
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message: cappedResult.message, severity: gate.severity, source: 'gates-engine', dailyBlockCapApplied: true });
2400
+ auditToFeedback(auditRecord);
2401
+ return cappedResult;
2402
+ }
2403
+ recordStat(gate.id, 'block', gate, { toolName, toolInput });
1636
2404
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1637
2405
  auditToFeedback(auditRecord);
1638
- return result;
2406
+ return denyResult;
1639
2407
  }
1640
2408
 
1641
2409
  if (gate.action === 'approve') {
1642
- recordStat(gate.id, 'approve', gate);
1643
- const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
1644
- const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
2410
+ const approvalEnabled = process.env.THUMBGATE_APPROVAL_GATES !== '0';
2411
+ if (approvalEnabled) {
2412
+ if (isAutonomousRun()) {
2413
+ // Autonomous run: no human to approve. Fail CLOSED so the actions that
2414
+ // most need sign-off cannot slip through unattended.
2415
+ const failClosedMessage = `[autonomous run — no approver present, failing closed] ${message}`;
2416
+ recordStat(gate.id, 'block', gate, { toolName, toolInput });
2417
+ const failClosedAudit = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: failClosedMessage, severity: gate.severity, source: 'gates-engine', autonomousFailClosed: true });
2418
+ auditToFeedback(failClosedAudit);
2419
+ return { decision: 'deny', gate: gate.id, message: failClosedMessage, severity: gate.severity, reasoning, requiresApproval: true, failedClosed: true };
2420
+ }
2421
+ recordStat(gate.id, 'approve', gate, { toolName, toolInput });
2422
+ const result = { decision: 'approve', gate: gate.id, message, severity: gate.severity, reasoning, requiresApproval: true };
2423
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'approve', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
2424
+ auditToFeedback(auditRecord);
2425
+ return result;
2426
+ }
2427
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
2428
+ const result = { decision: 'warn', gate: gate.id, message: `[approval gate disabled] ${message}`, severity: gate.severity, reasoning };
2429
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1645
2430
  auditToFeedback(auditRecord);
1646
2431
  return result;
1647
2432
  }
1648
2433
 
1649
2434
  if (gate.action === 'log') {
1650
- recordStat(gate.id, 'log', gate);
2435
+ recordStat(gate.id, 'log', gate, { toolName, toolInput });
1651
2436
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'log', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1652
2437
  auditToFeedback(auditRecord);
1653
2438
  // 'log' action allows the tool call to proceed — continue to next gate
@@ -1655,7 +2440,7 @@ function evaluateGates(toolName, toolInput, configPath) {
1655
2440
  }
1656
2441
 
1657
2442
  if (gate.action === 'warn') {
1658
- recordStat(gate.id, 'warn', gate);
2443
+ recordStat(gate.id, 'warn', gate, { toolName, toolInput });
1659
2444
  const result = { decision: 'warn', gate: gate.id, message, severity: gate.severity, reasoning };
1660
2445
  const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'warn', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
1661
2446
  auditToFeedback(auditRecord);
@@ -1663,14 +2448,15 @@ function evaluateGates(toolName, toolInput, configPath) {
1663
2448
  }
1664
2449
  }
1665
2450
 
1666
- const sentinelReport = evaluateWorkflowSentinel(toolName, toolInput, {
2451
+ const skipAdvisoryGuards = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
2452
+ const sentinelReport = skipAdvisoryGuards ? null : evaluateWorkflowSentinel(toolName, toolInput, {
1667
2453
  governanceState,
1668
2454
  });
1669
2455
  const sentinelDecision = recordSentinelDecision(sentinelReport, toolName, toolInput);
1670
2456
  const memoryGuard = evaluateMemoryGuard(toolName, toolInput);
1671
2457
  if (memoryGuard) {
1672
2458
  const enrichedMemoryGuard = enrichResultWithSentinel(memoryGuard, sentinelReport);
1673
- recordStat(enrichedMemoryGuard.gate, 'block');
2459
+ recordStat(enrichedMemoryGuard.gate, 'block', null, { toolName, toolInput });
1674
2460
  recordMemoryGuardDecision(sentinelDecision, enrichedMemoryGuard);
1675
2461
  const auditRecord = recordAuditEvent({
1676
2462
  toolName,
@@ -1687,7 +2473,7 @@ function evaluateGates(toolName, toolInput, configPath) {
1687
2473
 
1688
2474
  if (sentinelReport && sentinelReport.decision !== 'allow') {
1689
2475
  const sentinelResult = buildSentinelGateResult(sentinelReport);
1690
- recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn');
2476
+ recordStat(sentinelResult.gate, sentinelResult.decision === 'deny' ? 'block' : 'warn', null, { toolName, toolInput });
1691
2477
  recordSentinelBlockDecision(sentinelDecision, sentinelResult);
1692
2478
  const auditRecord = recordAuditEvent({
1693
2479
  toolName,
@@ -1707,14 +2493,43 @@ function evaluateGates(toolName, toolInput, configPath) {
1707
2493
  return null;
1708
2494
  }
1709
2495
 
1710
- function buildSecretGuardResult(scanResult) {
2496
+ // Turn a secret-exfiltration block into actionable guidance that names the
2497
+ // safe path, instead of a dead-end that drives agents toward brittle
2498
+ // workarounds (e.g. writing secrets to /tmp). The vault dirs referenced here
2499
+ // are the SAME constant the scanner whitelists, so the hint can never drift
2500
+ // from enforcement.
2501
+ function buildSecretRemediation(toolName = '', toolInput = {}) {
2502
+ const vaultDirs = (SAFE_SECRET_STORAGE_DIRS || []).map((dir) => `~/${dir}`);
2503
+ const primaryVault = vaultDirs[0] || '~/.resume_secrets';
2504
+ const vaultList = vaultDirs.join(', ') || primaryVault;
2505
+
2506
+ if (EDIT_LIKE_TOOLS && EDIT_LIKE_TOOLS.has(toolName)) {
2507
+ const target = toolInput.file_path || toolInput.path || toolInput.filePath || toolInput.target_path;
2508
+ const where = target ? ` (you targeted ${redactText(String(target))})` : '';
2509
+ return `To store this secret safely, write it with the Write/Edit tool to a file under ${vaultList}${where}. `
2510
+ + `Those locations are whitelisted for secret storage and will NOT be blocked. `
2511
+ + `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.`;
2512
+ }
2513
+
2514
+ if (toolName === 'Bash') {
2515
+ return `Do not inline a live secret literal into a shell command — it leaks into shell history and process args. `
2516
+ + `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.`;
2517
+ }
2518
+
2519
+ return `Store secrets in the whitelisted vault (${vaultList}) using the Write tool rather than passing the literal through this action.`;
2520
+ }
2521
+
2522
+ function buildSecretGuardResult(scanResult, context = {}) {
2523
+ const remediation = buildSecretRemediation(context.toolName, context.toolInput || {});
2524
+ const summary = buildSafeSummary(
2525
+ scanResult.findings,
2526
+ 'Blocked because the action appears to expose secret material'
2527
+ );
1711
2528
  return {
1712
2529
  decision: 'deny',
1713
2530
  gate: 'secret-exfiltration',
1714
- message: buildSafeSummary(
1715
- scanResult.findings,
1716
- 'Blocked because the action appears to expose secret material'
1717
- ),
2531
+ message: `${summary}. ${remediation}`,
2532
+ remediation,
1718
2533
  severity: 'critical',
1719
2534
  secretScan: {
1720
2535
  provider: scanResult.provider,
@@ -1793,9 +2608,15 @@ function evaluateSecretGuard(input = {}) {
1793
2608
  if (!scanResult.detected) {
1794
2609
  return null;
1795
2610
  }
1796
- recordStat('secret-exfiltration', 'block');
2611
+ recordStat('secret-exfiltration', 'block', null, {
2612
+ toolName: input.tool_name || input.toolName || 'unknown',
2613
+ toolInput: input.tool_input || {},
2614
+ });
1797
2615
  recordSecretViolation(input, scanResult);
1798
- const result = buildSecretGuardResult(scanResult);
2616
+ const result = buildSecretGuardResult(scanResult, {
2617
+ toolName: input.tool_name || input.toolName,
2618
+ toolInput: input.tool_input || {},
2619
+ });
1799
2620
  // Audit trail: record secret guard denial
1800
2621
  const auditRecord = recordAuditEvent({
1801
2622
  toolName: input.tool_name || input.toolName || 'unknown',
@@ -1811,15 +2632,55 @@ function evaluateSecretGuard(input = {}) {
1811
2632
  }
1812
2633
 
1813
2634
  // ---------------------------------------------------------------------------
2635
+ function isApprovalGatesEnabled() {
2636
+ return process.env.THUMBGATE_APPROVAL_GATES !== '0';
2637
+ }
2638
+
1814
2639
  // PreToolUse hook interface (stdin/stdout JSON)
1815
2640
  // ---------------------------------------------------------------------------
1816
2641
 
1817
- function buildReminderOutput(context) {
2642
+ function buildPreToolUseOutput(fields = {}) {
1818
2643
  return {
2644
+ hookEventName: 'PreToolUse',
2645
+ ...fields,
2646
+ };
2647
+ }
2648
+
2649
+ function buildReminderOutput(context) {
2650
+ return buildPreToolUseOutput({
1819
2651
  additionalContext: context,
1820
2652
  systemReminder: context,
1821
2653
  thumbgateSystemReminder: context,
1822
- };
2654
+ });
2655
+ }
2656
+
2657
+ // ---------------------------------------------------------------------------
2658
+ // Upgrade nudge: surfaces Pro value at usage milestones and trial expiry.
2659
+ // Block-action Pro CTA: brief upgrade mention after a deny/warn decision.
2660
+ // Highest-intent moment — user just saw ThumbGate save them from a mistake.
2661
+ // ---------------------------------------------------------------------------
2662
+
2663
+ function buildBlockActionProCta() {
2664
+ try {
2665
+ if (process.env.THUMBGATE_NO_NUDGE === '1') return null;
2666
+ if (process.env.CI || process.env.GITHUB_ACTIONS) return null;
2667
+ if (isProTier()) return null;
2668
+ if (isInTrialPeriod()) return null; // Already have full access
2669
+
2670
+ const stats = loadStats();
2671
+ const totalBlocks = stats.blocked || 0;
2672
+ if (totalBlocks < 5) return null; // Too early — let them experience the product
2673
+
2674
+ if (totalBlocks < 25) {
2675
+ return '\n\n💡 Pro: keep this rule synced across laptops, CI, containers, and agent runtimes → thumbgate.ai/go/pro';
2676
+ }
2677
+ if (totalBlocks < 100) {
2678
+ return `\n\n💡 ${totalBlocks} actions blocked. Pro keeps these lessons/rules synced everywhere → thumbgate.ai/go/pro ($19/mo)`;
2679
+ }
2680
+ return `\n\n💡 ${totalBlocks} mistakes caught. Your team could use shared hosted enforcement → thumbgate.ai/go/pro`;
2681
+ } catch (_) {
2682
+ return null;
2683
+ }
1823
2684
  }
1824
2685
 
1825
2686
  function formatOutput(result, behavioralContext) {
@@ -1838,13 +2699,28 @@ function formatOutput(result, behavioralContext) {
1838
2699
  : '';
1839
2700
 
1840
2701
  if (result.decision === 'deny') {
2702
+ const reminder = behavioralContext ? buildReminderOutput(behavioralContext) : {};
2703
+ const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
2704
+ const proCta = buildBlockActionProCta() || '';
2705
+ return JSON.stringify({
2706
+ hookSpecificOutput: {
2707
+ hookEventName: 'PreToolUse',
2708
+ ...reminder,
2709
+ permissionDecision: 'deny',
2710
+ permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}${reminderSuffix}${proCta}`,
2711
+ },
2712
+ });
2713
+ }
2714
+
2715
+ if (result.decision === 'approve') {
1841
2716
  const reminder = behavioralContext ? buildReminderOutput(behavioralContext) : {};
1842
2717
  const reminderSuffix = behavioralContext ? `\n\nSystem reminder:\n${behavioralContext}` : '';
1843
2718
  return JSON.stringify({
1844
2719
  hookSpecificOutput: {
2720
+ hookEventName: 'PreToolUse',
1845
2721
  ...reminder,
1846
2722
  permissionDecision: 'deny',
1847
- permissionDecisionReason: `[GATE:${result.gate}] ${result.message}${reasoningSuffix}${reminderSuffix}`,
2723
+ permissionDecisionReason: `[GATE:${result.gate}] APPROVAL REQUIRED: ${result.message} — Ask the human to confirm this action before proceeding.${reasoningSuffix}${reminderSuffix}`,
1848
2724
  },
1849
2725
  });
1850
2726
  }
@@ -1854,6 +2730,7 @@ function formatOutput(result, behavioralContext) {
1854
2730
  const context = `[GATE:${result.gate}] WARNING: ${result.message}${reasoningSuffix}${extra}`;
1855
2731
  return JSON.stringify({
1856
2732
  hookSpecificOutput: {
2733
+ hookEventName: 'PreToolUse',
1857
2734
  additionalContext: context,
1858
2735
  ...(behavioralContext ? {
1859
2736
  systemReminder: behavioralContext,
@@ -1977,9 +2854,7 @@ function buildRecentCorrectiveActionsContext(options = {}) {
1977
2854
  function buildRelevantLessonContext(toolName, toolInput) {
1978
2855
  if (!toolName) return null;
1979
2856
 
1980
- const { retrieveRelevantLessons } = loadOptionalModule('./lesson-retrieval', () => ({
1981
- retrieveRelevantLessons: () => [],
1982
- }));
2857
+ const { retrieveRelevantLessons, calculateRetrievalEntropy } = loadOptionalModule("./lesson-retrieval", () => ({ retrieveRelevantLessons: () => [], calculateRetrievalEntropy: () => 0 }));
1983
2858
 
1984
2859
  // Extract a searchable action context from the tool input
1985
2860
  const actionContext = extractActionContext(toolName, toolInput);
@@ -1987,32 +2862,113 @@ function buildRelevantLessonContext(toolName, toolInput) {
1987
2862
 
1988
2863
  try {
1989
2864
  const lessons = retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
1990
- // retrieveRelevantLessons already filters at relevanceScore > 0.1 internally;
1991
- // any negative lesson that survives retrieval is relevant enough to surface.
1992
- const negative = lessons.filter((l) => l.signal === 'negative');
1993
- if (negative.length === 0) return null;
1994
-
1995
- const formatted = negative.map((l) => {
1996
- const title = (l.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
1997
- const advice = extractAvoidanceAdvice(l.content);
1998
- return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
1999
- });
2000
2865
 
2001
- return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
2866
+ const entropy = calculateRetrievalEntropy(lessons);
2867
+ if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
2868
+ return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
2869
+ }
2870
+ return formatNegativeLessonContext(lessons);
2871
+ } catch {
2872
+ return null;
2873
+ }
2874
+ }
2875
+
2876
+ /**
2877
+ * Async counterpart of buildRelevantLessonContext: uses HYBRID (dense embeddings +
2878
+ * lexical) retrieval so the agent is warned about semantically-related past mistakes
2879
+ * even when they share no keywords with the current action. Wired into runAsync.
2880
+ * Degrades to the lexical result automatically when no embedder is available.
2881
+ */
2882
+ async function buildRelevantLessonContextAsync(toolName, toolInput) {
2883
+ if (!toolName) return null;
2884
+
2885
+ const { retrieveRelevantLessonsAsync, retrieveRelevantLessons, calculateRetrievalEntropy } = loadOptionalModule(
2886
+ './lesson-retrieval',
2887
+ () => ({ retrieveRelevantLessonsAsync: null, retrieveRelevantLessons: () => [], calculateRetrievalEntropy: () => 0 }),
2888
+ );
2889
+
2890
+ const actionContext = extractActionContext(toolName, toolInput);
2891
+ if (!actionContext) return null;
2892
+
2893
+ try {
2894
+ const lessons = retrieveRelevantLessonsAsync
2895
+ ? await retrieveRelevantLessonsAsync(toolName, actionContext, { maxResults: 3 })
2896
+ : retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
2897
+
2898
+ // Knowledge Conflict Detection: if retrieved lessons have high sentiment entropy,
2899
+ // it indicates conflicting past evidence. Warn by default; hard-block only in
2900
+ // strict mode for external/destructive side-effect commands.
2901
+ const entropy = calculateRetrievalEntropy(lessons);
2902
+ if (entropy > KNOWLEDGE_ENTROPY_THRESHOLD) {
2903
+ return buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy);
2904
+ }
2905
+
2906
+ return formatNegativeLessonContext(lessons);
2002
2907
  } catch {
2003
2908
  return null;
2004
2909
  }
2005
2910
  }
2006
2911
 
2912
+ /**
2913
+ * Shared formatter: render the negative (mistake) lessons that survived retrieval
2914
+ * into the PreToolUse warning block. Retrieval already filters by relevance, so any
2915
+ * negative lesson present is relevant enough to surface.
2916
+ */
2917
+ function formatNegativeLessonContext(lessons) {
2918
+ const negative = (lessons || []).filter((l) => l.signal === 'negative');
2919
+ if (negative.length === 0) return null;
2920
+
2921
+ const formatted = negative.map((l) => {
2922
+ const title = (l.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
2923
+ const advice = extractAvoidanceAdvice(l.content);
2924
+ return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
2925
+ });
2926
+
2927
+ return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
2928
+ }
2929
+
2930
+ function isStrictKnowledgeConflictMode() {
2931
+ return process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1'
2932
+ || process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === 'true';
2933
+ }
2934
+
2935
+ function isKnowledgeConflictHardBlockAction(toolName, toolInput = {}) {
2936
+ if (!isStrictKnowledgeConflictMode()) return false;
2937
+ if (EDIT_LIKE_TOOLS.has(toolName)) return true;
2938
+ if (toolName !== 'Bash') return false;
2939
+ return KNOWLEDGE_CONFLICT_STRICT_BASH_PATTERN.test(String(toolInput.command || ''));
2940
+ }
2941
+
2942
+ function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
2943
+ const lessonContext = formatNegativeLessonContext(lessons);
2944
+ 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.`;
2945
+
2946
+ if (isKnowledgeConflictHardBlockAction(toolName, toolInput)) {
2947
+ recordStat('retrieval_entropy_high', 'block', null, { toolName, toolInput });
2948
+ return {
2949
+ decision: 'deny',
2950
+ gate: 'knowledge-conflict-gate',
2951
+ message: `✗ THUMBGATE: ${message} Strict mode is enabled for destructive or external side-effect actions; verify intent or narrow the task before proceeding.`,
2952
+ severity: 'high',
2953
+ };
2954
+ }
2955
+
2956
+ recordStat('retrieval_entropy_high', 'warn', null, { toolName, toolInput });
2957
+ return mergeContextStrings(`[ThumbGate] ${message}`, lessonContext);
2958
+ }
2959
+
2007
2960
  function extractActionContext(toolName, toolInput) {
2008
2961
  if (!toolInput) return toolName;
2009
2962
  const parts = [toolName];
2010
2963
  if (toolInput.command) parts.push(String(toolInput.command).slice(0, 400));
2011
2964
  if (toolInput.file_path) parts.push(String(toolInput.file_path));
2965
+ if (toolInput.content) parts.push(String(toolInput.content).slice(0, 600));
2966
+ if (toolInput.new_string) parts.push(String(toolInput.new_string).slice(0, 600));
2967
+ if (toolInput.old_string) parts.push(String(toolInput.old_string).slice(0, 240));
2012
2968
  if (toolInput.description) parts.push(String(toolInput.description).slice(0, 200));
2013
2969
  if (toolInput.prompt) parts.push(String(toolInput.prompt).slice(0, 400));
2014
2970
  if (toolInput.pattern) parts.push(String(toolInput.pattern).slice(0, 200));
2015
- return parts.filter(Boolean).join(' ');
2971
+ return sanitizeFeedbackText(parts.filter(Boolean).join(' ')) || toolName;
2016
2972
  }
2017
2973
 
2018
2974
  function extractAvoidanceAdvice(content) {
@@ -2041,6 +2997,13 @@ async function runAsync(input) {
2041
2997
 
2042
2998
  const toolName = input.tool_name || '';
2043
2999
  const toolInput = input.tool_input || {};
3000
+ const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
3001
+
3002
+ const sequenceGuard = evaluateSequenceState(toolName, toolInput);
3003
+ if (sequenceGuard && sequenceGuard.decision === 'deny') {
3004
+ return formatOutput(applyEnforcementPosture(sequenceGuard));
3005
+ }
3006
+
2044
3007
  const result = await evaluateGatesAsync(toolName, toolInput);
2045
3008
 
2046
3009
  // Attach security warnings to allow/warn results
@@ -2053,11 +3016,18 @@ async function runAsync(input) {
2053
3016
  }
2054
3017
  }
2055
3018
 
2056
- const behavioralContext = buildBehavioralContext();
2057
- const lessonContext = buildRelevantLessonContext(toolName, toolInput);
3019
+
3020
+ const behavioralContext = safeSecretStorageWrite ? null : buildBehavioralContext();
3021
+ const lessonContext = safeSecretStorageWrite ? null : await buildRelevantLessonContextAsync(toolName, toolInput);
3022
+
3023
+ if (lessonContext && lessonContext.decision === "deny") {
3024
+ return formatOutput(applyEnforcementPosture(lessonContext));
3025
+ }
3026
+
2058
3027
  const recentContext = buildRecentCorrectiveActionsContext();
2059
3028
  const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
2060
- return formatOutput(result, combinedContext);
3029
+ return formatOutput(applyEnforcementPosture(result), combinedContext);
3030
+
2061
3031
  }
2062
3032
 
2063
3033
  function run(input) {
@@ -2074,6 +3044,13 @@ function run(input) {
2074
3044
 
2075
3045
  const toolName = input.tool_name || '';
2076
3046
  const toolInput = input.tool_input || {};
3047
+ const safeSecretStorageWrite = isSafeSecretStorageWrite(toolName, toolInput, process.cwd());
3048
+
3049
+ const sequenceGuard = evaluateSequenceState(toolName, toolInput);
3050
+ if (sequenceGuard && sequenceGuard.decision === 'deny') {
3051
+ return formatOutput(applyEnforcementPosture(sequenceGuard));
3052
+ }
3053
+
2077
3054
  const result = evaluateGates(toolName, toolInput);
2078
3055
 
2079
3056
  // Attach security warnings to allow/warn results
@@ -2086,11 +3063,18 @@ function run(input) {
2086
3063
  }
2087
3064
  }
2088
3065
 
2089
- const behavioralContext = buildBehavioralContext();
2090
- const lessonContext = buildRelevantLessonContext(toolName, toolInput);
3066
+
3067
+ const behavioralContext = safeSecretStorageWrite ? null : buildBehavioralContext();
3068
+ const lessonContext = safeSecretStorageWrite ? null : buildRelevantLessonContext(toolName, toolInput);
3069
+
3070
+ if (lessonContext && lessonContext.decision === "deny") {
3071
+ return formatOutput(applyEnforcementPosture(lessonContext));
3072
+ }
3073
+
2091
3074
  const recentContext = buildRecentCorrectiveActionsContext();
2092
3075
  const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
2093
- return formatOutput(result, combinedContext);
3076
+ return formatOutput(applyEnforcementPosture(result), combinedContext);
3077
+
2094
3078
  }
2095
3079
 
2096
3080
  // ---------------------------------------------------------------------------
@@ -2223,7 +3207,84 @@ function registerClaimGate(claimPattern, requiredActions, blockMessage) {
2223
3207
  return entry;
2224
3208
  }
2225
3209
 
2226
- function verifyClaimEvidence(claimText) {
3210
+ function normalizeStringArray(value) {
3211
+ if (!Array.isArray(value)) return [];
3212
+ return Array.from(new Set(
3213
+ value
3214
+ .map((item) => String(item || '').trim())
3215
+ .filter(Boolean)
3216
+ ));
3217
+ }
3218
+
3219
+ function normalizeGoalContract(goalContract) {
3220
+ if (!goalContract || typeof goalContract !== 'object' || Array.isArray(goalContract)) {
3221
+ return null;
3222
+ }
3223
+
3224
+ const goal = String(goalContract.goal || '').trim();
3225
+ const doneWhen = normalizeStringArray(goalContract.doneWhen);
3226
+ const requiredActions = normalizeStringArray(goalContract.proveBy);
3227
+ const mustNotChange = normalizeStringArray(goalContract.mustNotChange);
3228
+ const handoff = {
3229
+ workerAgent: String(goalContract.workerAgent || '').trim() || null,
3230
+ reviewerAgent: String(goalContract.reviewerAgent || '').trim() || null,
3231
+ orchestratorAgent: String(goalContract.orchestratorAgent || '').trim() || null,
3232
+ };
3233
+
3234
+ const matched = Boolean(
3235
+ goal ||
3236
+ doneWhen.length > 0 ||
3237
+ requiredActions.length > 0 ||
3238
+ mustNotChange.length > 0 ||
3239
+ handoff.workerAgent ||
3240
+ handoff.reviewerAgent ||
3241
+ handoff.orchestratorAgent
3242
+ );
3243
+
3244
+ if (!matched) return null;
3245
+
3246
+ return {
3247
+ goal: goal || null,
3248
+ doneWhen,
3249
+ requiredActions,
3250
+ mustNotChange,
3251
+ handoff,
3252
+ };
3253
+ }
3254
+
3255
+ function evaluateGoalContract(goalContract, actions = loadSessionActions()) {
3256
+ const normalized = normalizeGoalContract(goalContract);
3257
+ if (!normalized) {
3258
+ return {
3259
+ matched: false,
3260
+ passed: true,
3261
+ goal: null,
3262
+ doneWhen: [],
3263
+ requiredActions: [],
3264
+ missingActions: [],
3265
+ mustNotChange: [],
3266
+ handoff: {
3267
+ workerAgent: null,
3268
+ reviewerAgent: null,
3269
+ orchestratorAgent: null,
3270
+ },
3271
+ };
3272
+ }
3273
+
3274
+ const missingActions = normalized.requiredActions.filter((actionId) => !actions[actionId]);
3275
+ return {
3276
+ matched: true,
3277
+ passed: missingActions.length === 0,
3278
+ goal: normalized.goal,
3279
+ doneWhen: normalized.doneWhen,
3280
+ requiredActions: normalized.requiredActions,
3281
+ missingActions,
3282
+ mustNotChange: normalized.mustNotChange,
3283
+ handoff: normalized.handoff,
3284
+ };
3285
+ }
3286
+
3287
+ function verifyClaimEvidence(claimText, options = {}) {
2227
3288
  const normalizedClaimText = String(claimText || '').trim();
2228
3289
  if (!normalizedClaimText) {
2229
3290
  throw new Error('claimText is required');
@@ -2251,9 +3312,23 @@ function verifyClaimEvidence(claimText) {
2251
3312
  });
2252
3313
  }
2253
3314
 
3315
+ const goalContract = evaluateGoalContract(options.goalContract, actions);
3316
+ if (goalContract.matched) {
3317
+ checks.push({
3318
+ claim: 'goal_contract',
3319
+ passed: goalContract.passed,
3320
+ missing: goalContract.missingActions,
3321
+ message: goalContract.passed
3322
+ ? 'Goal contract evidence present'
3323
+ : `Goal contract requires evidence: ${goalContract.missingActions.join(', ')}`,
3324
+ goalContract,
3325
+ });
3326
+ }
3327
+
2254
3328
  return {
2255
3329
  verified: checks.every((check) => check.passed),
2256
3330
  checks,
3331
+ goalContract,
2257
3332
  };
2258
3333
  }
2259
3334
 
@@ -2273,6 +3348,7 @@ module.exports = {
2273
3348
  setTaskScope,
2274
3349
  setBranchGovernance,
2275
3350
  approveProtectedAction,
3351
+ breakGlassEmergency,
2276
3352
  getScopeState,
2277
3353
  getBranchGovernanceState,
2278
3354
  isConditionSatisfied,
@@ -2287,8 +3363,10 @@ module.exports = {
2287
3363
  matchesGate,
2288
3364
  evaluateGates,
2289
3365
  evaluateGatesAsync,
3366
+ isAutonomousRun,
2290
3367
  computeExecutableHash,
2291
3368
  formatOutput,
3369
+ isApprovalGatesEnabled,
2292
3370
  run,
2293
3371
  runAsync,
2294
3372
  trackAction,
@@ -2297,6 +3375,8 @@ module.exports = {
2297
3375
  clearSessionActions,
2298
3376
  loadClaimGates,
2299
3377
  registerClaimGate,
3378
+ normalizeGoalContract,
3379
+ evaluateGoalContract,
2300
3380
  verifyClaimEvidence,
2301
3381
  DEFAULT_CONFIG_PATH,
2302
3382
  DEFAULT_CLAIM_GATES_PATH,
@@ -2313,6 +3393,7 @@ module.exports = {
2313
3393
  buildBehavioralContext,
2314
3394
  buildRecentCorrectiveActionsContext,
2315
3395
  buildRelevantLessonContext,
3396
+ buildRelevantLessonContextAsync,
2316
3397
  extractActionContext,
2317
3398
  extractAvoidanceAdvice,
2318
3399
  mergeContextStrings,
@@ -2324,10 +3405,20 @@ module.exports = {
2324
3405
  evaluateBoostedRiskTagGuard,
2325
3406
  registerPrThreadResolutionClaimGate,
2326
3407
  evaluatePendingPrThreadResolutionGate,
3408
+ isReadOnlyObservabilityTool,
2327
3409
  getLocalOnlyScopeSources,
2328
3410
  isRemoteSideEffectCommand,
2329
3411
  evaluateLocalOnlyRemoteSideEffectGate,
3412
+ recordHelperScriptWrite,
3413
+ evaluateStatefulHelperBypassGate,
3414
+ isAgentHookSettingsFile,
3415
+ isBreakGlassSettingsRecoveryAction,
2330
3416
  PR_THREAD_RESOLUTION_ACTION,
3417
+ HELPER_BYPASS_ACTION,
3418
+ buildBlockActionProCta,
3419
+ applyDailyBlockCap,
3420
+ getTodayBlockCount,
3421
+ incrementTodayBlockCount,
2331
3422
  };
2332
3423
 
2333
3424
  // ---------------------------------------------------------------------------