thumbgate 1.35.0 → 1.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (204) hide show
  1. package/.agents/skills/cobble-hot-store-compare-not-clone/SKILL.md +80 -0
  2. package/.agents/skills/colab-compute-honesty-not-clone/SKILL.md +70 -0
  3. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  4. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  5. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  6. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  7. package/.agents/skills/token-shunt-honesty-not-clone/SKILL.md +67 -0
  8. package/.agents/skills/typesafe-typed-questions-not-clone/SKILL.md +82 -0
  9. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  10. package/.claude-plugin/plugin.json +1 -1
  11. package/.well-known/llms.txt +1 -0
  12. package/.well-known/mcp/server-card.json +1 -1
  13. package/CONTRIBUTING.md +95 -0
  14. package/README.md +195 -632
  15. package/THIRD_PARTY_NOTICES.md +89 -0
  16. package/adapters/claude/.mcp.json +2 -2
  17. package/adapters/forge/forge.yaml +3 -3
  18. package/adapters/future-agi/.mcp.json +8 -0
  19. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  20. package/adapters/future-agi/config.toml +3 -0
  21. package/adapters/future-agi/future-agi-bridge.js +9 -0
  22. package/adapters/future-agi/opencode.json +8 -0
  23. package/adapters/herdr/herdr-plugin.toml +18 -0
  24. package/adapters/mcp/server-stdio.js +238 -25
  25. package/adapters/opencode/opencode.json +1 -1
  26. package/adapters/workos/WORKOS.md +52 -0
  27. package/bin/cli.js +465 -3
  28. package/bin/futureagi-bridge +9 -0
  29. package/config/gate-templates.json +629 -4
  30. package/config/gates/actor-critic-audit.json +34 -0
  31. package/config/gates/default.json +9 -3
  32. package/config/gates/five-walls-governance.json +34 -0
  33. package/config/gates/future-agi-guardrails.json +34 -0
  34. package/config/gates/radware-threat-defense-2026.json +61 -0
  35. package/config/gates/simatree-data-governance.json +33 -0
  36. package/config/mcp-allowlists.json +4 -0
  37. package/config/merge-quality-checks.json +6 -0
  38. package/config/model-candidates.json +312 -29
  39. package/config/model-tiers.json +18 -0
  40. package/config/post-deploy-marketing-pages.json +10 -0
  41. package/config/progressive/01-wire-only.json +11 -0
  42. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  43. package/config/progressive/03-one-lesson.json +10 -0
  44. package/config/progressive/04-warn-fires.json +11 -0
  45. package/config/progressive/05-strict-optional.json +11 -0
  46. package/config/progressive/README.md +15 -0
  47. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  48. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  49. package/conformance/provider-attestation/vectors.json +320 -0
  50. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  51. package/openapi/openapi.yaml +15 -0
  52. package/package.json +408 -150
  53. package/public/about.html +2 -2
  54. package/public/ai-malpractice-prevention.html +7 -7
  55. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  56. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  57. package/public/blog/git-at-agent-scale.html +374 -0
  58. package/public/blog/no-llm-in-the-gate.html +133 -0
  59. package/public/blog.html +80 -0
  60. package/public/case-studies.html +16 -1
  61. package/public/compare.html +28 -0
  62. package/public/diagnostic.html +216 -7
  63. package/public/docs/connectors.html +39 -0
  64. package/public/federal.html +2 -2
  65. package/public/founders.html +639 -0
  66. package/public/index.html +87 -9
  67. package/public/install.html +8 -8
  68. package/public/learn.html +39 -0
  69. package/public/numbers.html +2 -2
  70. package/public/peter.html +310 -0
  71. package/public/platform-partners.html +119 -0
  72. package/public/pricing.html +24 -3
  73. package/public/privacy.html +117 -0
  74. package/public/pro.html +17 -0
  75. package/public/support.html +62 -0
  76. package/public/terms.html +130 -0
  77. package/public/third-party-notices.html +95 -0
  78. package/public/yt.html +351 -0
  79. package/scripts/action-receipts.js +133 -3
  80. package/scripts/adaptive-governance-arena.js +349 -0
  81. package/scripts/admin-override.js +205 -0
  82. package/scripts/agent-action-inventory.js +869 -0
  83. package/scripts/agent-audit-trace.js +42 -2
  84. package/scripts/agent-egress-policy.js +1117 -0
  85. package/scripts/agent-memory-lifecycle.js +141 -2
  86. package/scripts/agent-operations-planner.js +441 -1
  87. package/scripts/agent-readiness.js +68 -0
  88. package/scripts/agent-security-central.js +647 -0
  89. package/scripts/allowlist-bridge-honesty.js +417 -0
  90. package/scripts/async-job-runner.js +102 -11
  91. package/scripts/audit-trail.js +212 -0
  92. package/scripts/billing.js +1 -1
  93. package/scripts/broker-execution-receipts.js +719 -0
  94. package/scripts/budget-aware-gates-proof.js +423 -0
  95. package/scripts/claude-feedback-sync.js +29 -3
  96. package/scripts/claw-harness-production.js +237 -0
  97. package/scripts/cli-schema.js +203 -1
  98. package/scripts/cobble-hot-store-split.js +600 -0
  99. package/scripts/codex-runbook-flywheel.js +318 -0
  100. package/scripts/colab-compute-honesty.js +281 -0
  101. package/scripts/context-footprint.js +186 -0
  102. package/scripts/contextfs.js +143 -61
  103. package/scripts/dashboard.js +251 -32
  104. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  105. package/scripts/docker-sandbox-planner.js +18 -0
  106. package/scripts/double-blind-eval-protocol.js +252 -0
  107. package/scripts/edotenv-rl-gateway.js +259 -0
  108. package/scripts/ensure-production-search-corpus.js +162 -0
  109. package/scripts/eval-holdout.js +311 -0
  110. package/scripts/feedback-aggregate.js +21 -2
  111. package/scripts/feedback-loop.js +87 -5
  112. package/scripts/feedback-quality.js +9 -0
  113. package/scripts/file-ledger-lock.js +4 -1
  114. package/scripts/financial-control-plane.js +41 -1
  115. package/scripts/find-dormant-requires.js +118 -0
  116. package/scripts/fs-utils.js +84 -8
  117. package/scripts/gates-engine.js +826 -68
  118. package/scripts/generate-case-study-outreach.js +24 -15
  119. package/scripts/git-at-scale.js +628 -0
  120. package/scripts/governance-conflict-audit.js +1650 -0
  121. package/scripts/governance-difficulty-curriculum.js +328 -0
  122. package/scripts/graphrag-retrieval.js +275 -0
  123. package/scripts/gurobi-optimizer.js +324 -0
  124. package/scripts/gurobi_optimizer.py +485 -0
  125. package/scripts/harness-selector.js +82 -1
  126. package/scripts/hidden-entry-points.js +284 -0
  127. package/scripts/human-escalation.js +199 -1
  128. package/scripts/hybrid-feedback-context.js +152 -19
  129. package/scripts/intent-governed-execution.js +602 -0
  130. package/scripts/intervention-policy.js +123 -20
  131. package/scripts/jit-harness-compose.js +628 -0
  132. package/scripts/jsonl-watcher.js +10 -0
  133. package/scripts/lesson-embedding-index.js +95 -12
  134. package/scripts/lesson-retrieval.js +105 -19
  135. package/scripts/local-model-profile.js +19 -2
  136. package/scripts/mailer/resend-mailer.js +1 -1
  137. package/scripts/matryoshka-embedding.js +235 -0
  138. package/scripts/mcp-oauth.js +42 -4
  139. package/scripts/mcp-session-handles.js +1016 -0
  140. package/scripts/mcp-wiring-doctor.js +314 -0
  141. package/scripts/memory-firewall.js +115 -2
  142. package/scripts/memory-scope-readiness.js +299 -0
  143. package/scripts/memory-vs-rag-route.js +161 -0
  144. package/scripts/model-tier-router.js +148 -21
  145. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  146. package/scripts/openui-catalog-compose-honesty.js +593 -0
  147. package/scripts/operational-integrity.js +19 -1
  148. package/scripts/override-audit.js +213 -0
  149. package/scripts/package-manager-honesty-doctor.js +458 -0
  150. package/scripts/pr-manager.js +63 -1
  151. package/scripts/prove-herdr-adapter.js +52 -0
  152. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  153. package/scripts/prove-workos.js +73 -0
  154. package/scripts/provider-attestation-conformance.js +192 -0
  155. package/scripts/provider-receipt-contract.js +136 -0
  156. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  157. package/scripts/radware-threat-defense.js +280 -0
  158. package/scripts/rag-embedding-identity.js +221 -0
  159. package/scripts/rag-precision-guardrails.js +112 -2
  160. package/scripts/remote-feedback-capture.js +159 -0
  161. package/scripts/research-agent-harness.js +256 -0
  162. package/scripts/rsi-safety-hillclimb.js +200 -0
  163. package/scripts/rule-sprawl.js +188 -0
  164. package/scripts/schedule-manager.js +147 -0
  165. package/scripts/self-heal.js +8 -0
  166. package/scripts/session-lease.js +415 -0
  167. package/scripts/simatree-data-governance.js +347 -0
  168. package/scripts/slo-alert-engine.js +172 -7
  169. package/scripts/solver-parity.js +539 -0
  170. package/scripts/stealth-memory-injection-gate.js +333 -0
  171. package/scripts/switchyard-router.js +366 -0
  172. package/scripts/telemetry-analytics.js +84 -27
  173. package/scripts/temporal-decay-weighting.js +138 -0
  174. package/scripts/test-all.js +165 -0
  175. package/scripts/token-savings.js +42 -0
  176. package/scripts/token-shunt-honesty.js +502 -0
  177. package/scripts/tool-kpi-tracker.js +108 -5
  178. package/scripts/tool-registry.js +193 -5
  179. package/scripts/typesafe-typed-questions.js +813 -0
  180. package/scripts/universal-claim-evaluator.js +14 -2
  181. package/scripts/vector-store.js +279 -9
  182. package/scripts/workflow-notebook.js +391 -0
  183. package/scripts/workflow-sentinel.js +111 -12
  184. package/scripts/workos-production-guard.js +260 -0
  185. package/scripts/workspace-search-route.js +515 -0
  186. package/server.json +2 -2
  187. package/src/agent-identity-boundary.js +76 -0
  188. package/src/agent-retrieval-cache.js +155 -0
  189. package/src/alert-noise-ledger.js +502 -0
  190. package/src/api/server.js +724 -153
  191. package/src/git-fast-cache.js +220 -0
  192. package/src/git-wal-sync.js +156 -0
  193. package/src/hash-anchored-edit.js +82 -0
  194. package/src/hermes-platform-protocol.js +475 -0
  195. package/src/hermes-sync-plane.js +241 -0
  196. package/src/index.js +30 -1
  197. package/src/iso42001-compliance-guard.js +97 -0
  198. package/src/latency-budget.js +244 -0
  199. package/src/mcp-writeguard.js +316 -0
  200. package/src/miminions-adapter.js +106 -0
  201. package/src/pipeline-compass.js +104 -0
  202. package/src/ppl-alert-pipeline.js +284 -0
  203. package/src/rendezvous-router.js +90 -0
  204. package/src/security-questionnaire.js +195 -0
@@ -19,6 +19,9 @@ const {
19
19
  const {
20
20
  evaluateFinancialControl,
21
21
  } = require('./financial-control-plane');
22
+ const {
23
+ evaluateBrokerReceiptGate,
24
+ } = require('./broker-execution-receipts');
22
25
  const {
23
26
  buildCostControl,
24
27
  normalizeProviderAction,
@@ -72,12 +75,14 @@ const {
72
75
  evaluateSecurityScan,
73
76
  } = require('./security-scanner');
74
77
  const { evaluatePlanGate } = require('./plan-gate');
78
+ const { evaluateStealthMemoryInjection } = require('./stealth-memory-injection-gate');
75
79
  const { getTrajectoryScore } = require('./trajectory-scorer');
76
80
  const { evaluateSequenceState } = loadOptionalModule('./sequence-guard', () => ({
77
81
  evaluateSequenceState: () => null,
78
82
  }));
79
83
  const { getAutoGatesPath } = require('./auto-promote-gates');
80
84
  const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
85
+ const { consumeVerifiedApproval, listEscalations, requestEscalation } = require('./human-escalation');
81
86
 
82
87
  const DEFAULT_CONFIG_PATH = path.join(__dirname, '..', 'config', 'gates', 'default.json');
83
88
  const DEFAULT_CLAIM_GATES_PATH = path.join(__dirname, '..', 'config', 'gates', 'claim-verification.json');
@@ -103,9 +108,22 @@ const STATS_PATH = path.join(STATE_DIR, 'gate-stats.json');
103
108
  const SESSION_ACTIONS_PATH = path.join(STATE_DIR, 'session-actions.json');
104
109
  const CUSTOM_CLAIM_GATES_PATH = path.join(STATE_DIR, 'claim-verification.json');
105
110
  const GOVERNANCE_STATE_PATH = path.join(STATE_DIR, 'governance-state.json');
111
+ const REMEDY_TOOL_NAMES = new Set([
112
+ 'satisfy_gate',
113
+ 'capture_feedback',
114
+ 'capture_memory_feedback',
115
+ 'record_task_outcome',
116
+ 'diagnose_failure',
117
+ 'set_task_scope',
118
+ 'approve_protected_action',
119
+ 'track_action',
120
+ 'verify_claim',
121
+ 'break_glass_emergency',
122
+ ]);
106
123
  const TTL_MS = 5 * 60 * 1000; // 5 minutes
107
124
  const SESSION_ACTION_TTL_MS = 60 * 60 * 1000; // 1 hour
108
125
  const PROTECTED_APPROVAL_TTL_MS = 60 * 60 * 1000; // 1 hour
126
+ const DEFAULT_ADMIN_OVERRIDE_TTL_MS = 15 * 60 * 1000; // 15 minutes
109
127
  const DEFAULT_PROTECTED_FILE_GLOBS = [
110
128
  'AGENTS.md',
111
129
  'CLAUDE.md',
@@ -128,6 +146,10 @@ const BOOSTED_RISK_MIN_EXAMPLES = 3;
128
146
  const PR_THREAD_RESOLUTION_ACTION = 'pr_thread_resolution_verified_after_commit';
129
147
  const HELPER_BYPASS_ACTION = 'helper_script_modified';
130
148
  const KNOWLEDGE_ENTROPY_THRESHOLD = 0.7;
149
+ // Issue #3689: do not inject lessons that only barely matched. Retrieval already
150
+ // filters >0.1 for ranking; injection requires a higher bar so low-relevance
151
+ // memories cannot ride along with an entropy disclaimer.
152
+ const MIN_LESSON_INJECTION_RELEVANCE = 0.75;
131
153
  // Generous character bound: keeps every affected file for realistic actions while still
132
154
  // preventing an unbounded haystack. Chosen over a file-count cap, which dropped targets.
133
155
  const MEMORY_GUARD_MAX_SERIALIZED_CHARS = 200000;
@@ -168,8 +190,9 @@ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
168
190
  // applyEnforcementPosture, never demote spend blocks to warn-by-default.
169
191
  // Apollo $588 incident class (2026-08).
170
192
  'financial-control',
171
- // Outbound email: irreversible delivery. Must never demote to warn-by-default
172
- // even when THUMBGATE_STRICT_ENFORCEMENT is unset (AGENT-259 / District Cyber 2026-08-04).
193
+ // Outbound email: irreversible delivery. Keep the hard gate intact until a
194
+ // separately authenticated admin override bound to the exact action digest
195
+ // authorizes one retry (AGENT-259 / District Cyber 2026-08-04).
173
196
  'outbound-email-send',
174
197
  TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
175
198
  ...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
@@ -191,7 +214,7 @@ const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
191
214
  'git-clean-force',
192
215
  'rm-rf-home-or-root',
193
216
  'financial-control',
194
- // Never daily-cap discount agent email send (same class as force-push).
217
+ // Never daily-cap discount a denied autonomous email send (same class as force-push).
195
218
  'outbound-email-send',
196
219
  ]);
197
220
  const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
@@ -204,21 +227,76 @@ function isSelfProtectGate(gateId) {
204
227
  return SELF_PROTECT_HARD_FLOOR_GATE_IDS.has(gateId);
205
228
  }
206
229
 
230
+ /**
231
+ * Name the existing warn-by-default vs strict postures in Trustwise Control Tower
232
+ * vocabulary (live / sidecar / simulation / batch) without cloning their product.
233
+ * Default remains sidecar (= warn-by-default). Hard floors never demote.
234
+ */
235
+ function resolveGovernanceMode(env = process.env) {
236
+ // Strict opt-in wins over inherited simulation/batch. Hermes CLI sets
237
+ // THUMBGATE_STRICT_ENFORCEMENT=1 internally; a leftover shadow mode in the
238
+ // process env must not demote those denials.
239
+ if (env.THUMBGATE_STRICT_ENFORCEMENT === '1') return 'live';
240
+ const raw = String(env.THUMBGATE_GOVERNANCE_MODE || '').trim().toLowerCase();
241
+ if (raw === 'simulation' || raw === 'batch') return raw;
242
+ if (raw === 'live') return 'live';
243
+ return 'sidecar';
244
+ }
245
+
246
+ function alignmentLayerForResult(result) {
247
+ const gate = String(result && result.gate || '');
248
+ if (
249
+ UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(gate)
250
+ || CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate)
251
+ || /secret|self-protect|exfil/i.test(gate)
252
+ ) {
253
+ return 'safety';
254
+ }
255
+ if (/task-scope|local-only|pr_thread|sla/i.test(gate)) return 'sla';
256
+ return 'business';
257
+ }
258
+
207
259
  function applyEnforcementPosture(result) {
208
260
  if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
261
+ const mode = resolveGovernanceMode();
262
+ const alignmentLayer = alignmentLayerForResult(result);
209
263
  // Defensive backstop: hard-floor results must never be posture-downgraded.
210
- if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate)) return result;
264
+ if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate)) {
265
+ return { ...result, governanceMode: mode, alignmentLayer };
266
+ }
267
+ // Simulation/batch shadow live traffic: record, do not block (except floors).
268
+ // Only denials are shadowed — allow/approve stay as-is.
269
+ if (mode === 'simulation' || mode === 'batch') {
270
+ if (result.decision === 'deny') {
271
+ return {
272
+ ...result,
273
+ decision: 'warn',
274
+ warnByDefault: true,
275
+ governanceMode: mode,
276
+ alignmentLayer,
277
+ simulated: true,
278
+ message: `${result.message}\n\n⚠️ ThumbGate governance mode=${mode} — flagged and logged, not blocked.`,
279
+ };
280
+ }
281
+ return { ...result, governanceMode: mode, alignmentLayer, simulated: true };
282
+ }
211
283
  // Full hard enforcement opt-in: keep every deny.
212
- if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
284
+ if (mode === 'live' || process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') {
285
+ return { ...result, governanceMode: 'live', alignmentLayer };
286
+ }
213
287
  // Honor the explicit strict-knowledge-conflict opt-in for that gate.
214
- if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') return result;
215
- // Warn-by-default: the gate still fired and is recorded; the action is allowed through
288
+ if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') {
289
+ return { ...result, governanceMode: mode, alignmentLayer };
290
+ }
291
+ // Sidecar / warn-by-default: the gate still fired and is recorded; the action is allowed through
216
292
  // with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
217
293
  return {
218
294
  ...result,
219
295
  decision: 'warn',
220
296
  warnByDefault: true,
221
- 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 other flagged actions.`,
297
+ governanceMode: 'sidecar',
298
+ alignmentLayer,
299
+ message: `${result.message}\n\n⚠️ ThumbGate is in warn-by-default mode (sidecar) — this was flagged and logged, not blocked. Set THUMBGATE_STRICT_ENFORCEMENT=1 or THUMBGATE_GOVERNANCE_MODE=live to hard-block other flagged actions.`,
222
300
  };
223
301
  }
224
302
  const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
@@ -261,19 +339,51 @@ function commandContainsSequence(words, sequence) {
261
339
  }
262
340
 
263
341
  function commandHasPostMethod(words) {
264
- for (let i = 0; i < words.length; i += 1) {
265
- const word = words[i];
266
- if ((word === '-x' || word === '--method') && words[i + 1] === 'post') return true;
267
- if (word === '--method=post' || word === '-xpost') return true;
342
+ return ghApiHttpMethod(words) === 'post';
343
+ }
344
+
345
+ function ghApiHttpMethod(words) {
346
+ const list = Array.isArray(words) ? words : [];
347
+ for (let i = 0; i < list.length; i += 1) {
348
+ const word = list[i];
349
+ if ((word === '-x' || word === '--method') && list[i + 1]) return String(list[i + 1]).toLowerCase();
350
+ if (word.startsWith('--method=')) return word.slice('--method='.length).toLowerCase();
351
+ if (word.startsWith('-x') && word.length > 2 && !word.startsWith('-x=')) {
352
+ return word.slice(2).toLowerCase();
353
+ }
268
354
  }
269
- return false;
355
+ return null;
356
+ }
357
+
358
+ function ghApiEndpoint(words) {
359
+ const list = Array.isArray(words) ? words : [];
360
+ const apiIndex = list.findIndex((word, i) => word === 'api' && list[i - 1] === 'gh');
361
+ if (apiIndex < 0) return null;
362
+ const flagsWithValue = new Set([
363
+ '-x', '--method', '-f', '--field', '-F', '--raw-field',
364
+ '-h', '--header', '--hostname', '--jq', '--input', '--cache',
365
+ ]);
366
+ for (let i = apiIndex + 1; i < list.length; i += 1) {
367
+ const word = list[i];
368
+ if (word.startsWith('-')) {
369
+ if (word.includes('=')) continue;
370
+ if (flagsWithValue.has(word)) i += 1;
371
+ continue;
372
+ }
373
+ return word;
374
+ }
375
+ return null;
270
376
  }
271
377
 
272
378
  function isGhApiPrCreateCommand(command) {
273
379
  const words = commandWords(command);
274
380
  if (!commandContainsSequence(words, ['gh', 'api'])) return false;
275
- const hasPullsEndpoint = words.some((word) => word === '/pulls' || word.endsWith('/pulls'));
276
- if (!hasPullsEndpoint) return false;
381
+ const method = ghApiHttpMethod(words);
382
+ if (method && method !== 'post') return false;
383
+ const endpoint = ghApiEndpoint(words);
384
+ if (!endpoint) return false;
385
+ if (/\/pulls\/\d+/.test(endpoint)) return false;
386
+ if (!(endpoint === '/pulls' || /\/pulls$/.test(endpoint))) return false;
277
387
  const fieldFlags = new Set(['-f', '--field', '--raw-field']);
278
388
  const hasFieldWrite = words.some((word) => (
279
389
  fieldFlags.has(word) ||
@@ -472,8 +582,39 @@ function isTaskScopeExpired(taskScope, nowMs = Date.now()) {
472
582
  return nowMs >= deadline;
473
583
  }
474
584
 
475
- function loadGovernanceState() {
476
- const raw = loadJSON(module.exports.GOVERNANCE_STATE_PATH);
585
+ function currentScopeSessionId(explicitSessionId = null) {
586
+ if (explicitSessionId) return String(explicitSessionId).trim();
587
+ const raw = String(
588
+ process.env.THUMBGATE_SESSION_AGENT
589
+ || process.env.THUMBGATE_SESSION_ID
590
+ || process.env.CLAUDE_SESSION_ID
591
+ || ''
592
+ ).trim();
593
+ return raw || null;
594
+ }
595
+
596
+ function sanitizeScopeSessionId(sessionId) {
597
+ if (!sessionId) return '';
598
+ const hash = crypto.createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 16);
599
+ const prefix = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 32);
600
+ return prefix ? `${prefix}-${hash}` : hash;
601
+ }
602
+
603
+ // Sibling agents share ~/.thumbgate/governance-state.json today, so one
604
+ // set_task_scope rebinds every other live session (#3522). When a session id
605
+ // is present, persist a per-session file next to the legacy slot.
606
+ function governanceStatePath(explicitSessionId = null) {
607
+ const base = module.exports.GOVERNANCE_STATE_PATH;
608
+ const sessionId = currentScopeSessionId(explicitSessionId);
609
+ if (!sessionId) return base;
610
+ const safe = sanitizeScopeSessionId(sessionId);
611
+ if (!safe) return base;
612
+ const parsed = path.parse(base);
613
+ return path.join(parsed.dir, `${parsed.name}.${safe}${parsed.ext}`);
614
+ }
615
+
616
+ function loadGovernanceState(explicitSessionId = null) {
617
+ const raw = loadJSON(governanceStatePath(explicitSessionId));
477
618
  const state = {
478
619
  taskScope: raw && raw.taskScope && typeof raw.taskScope === 'object' ? raw.taskScope : null,
479
620
  protectedApprovals: Array.isArray(raw && raw.protectedApprovals) ? raw.protectedApprovals : [],
@@ -494,35 +635,142 @@ function loadGovernanceState() {
494
635
  const activeApprovals = state.protectedApprovals.filter((entry) => {
495
636
  if (!entry || typeof entry !== 'object') return false;
496
637
  if (!entry.timestamp || !entry.expiresAt) return false;
497
- return now < entry.expiresAt;
638
+ return entry.expiresAt > now;
498
639
  });
499
- if (activeApprovals.length !== state.protectedApprovals.length) {
500
- state.protectedApprovals = activeApprovals;
501
- saveGovernanceState(state);
502
- }
640
+ state.protectedApprovals = activeApprovals;
503
641
  return state;
504
642
  }
505
643
 
506
- function saveGovernanceState(state) {
644
+ function saveGovernanceState(state, explicitSessionId = null) {
507
645
  const next = {
508
646
  taskScope: state && state.taskScope ? state.taskScope : null,
509
647
  protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
510
648
  branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
511
649
  workflowContract: state && state.workflowContract ? state.workflowContract : null,
512
650
  };
513
- saveJSON(module.exports.GOVERNANCE_STATE_PATH, next);
651
+ saveJSON(governanceStatePath(explicitSessionId), next);
652
+ }
653
+
654
+ function stableCanonicalStringify(value) {
655
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
656
+ if (Array.isArray(value)) return `[${value.map(stableCanonicalStringify).join(',')}]`;
657
+ return `{${Object.keys(value).sort().map((key) => (
658
+ `${JSON.stringify(key)}:${stableCanonicalStringify(value[key])}`
659
+ )).join(',')}}`;
660
+ }
661
+
662
+ function actionApprovalDigest(toolName, toolInput) {
663
+ return crypto.createHash('sha256').update(stableCanonicalStringify({
664
+ toolInput: toolInput && typeof toolInput === 'object' ? toolInput : {},
665
+ toolName: String(toolName || ''),
666
+ })).digest('hex');
667
+ }
668
+
669
+ function adminOverrideTaskId(gateId, digest) {
670
+ return `admin-override:${gateId}:${digest}`;
671
+ }
672
+
673
+ function selectAdminOverrideAttempt(baseTaskId) {
674
+ const attempts = listEscalations().filter((entry) => (
675
+ entry.taskId === baseTaskId || String(entry.taskId || '').startsWith(`${baseTaskId}:attempt:`)
676
+ ));
677
+ const latest = attempts[0] || null;
678
+ if (!latest) return baseTaskId;
679
+ const terminal = ['rejected', 'cancelled', 'expired'].includes(latest.status)
680
+ || latest.eventType === 'consumed'
681
+ || Date.parse(latest.expiresAt) <= Date.now();
682
+ if (!terminal) return latest.taskId;
683
+ const highestAttempt = attempts.reduce((highest, entry) => {
684
+ const match = String(entry.taskId || '').match(/:attempt:(\d+)$/);
685
+ return Math.max(highest, match ? Number(match[1]) : 1);
686
+ }, 1);
687
+ return `${baseTaskId}:attempt:${highestAttempt + 1}`;
688
+ }
689
+
690
+ function evaluateAdminOverride(gate, toolName, toolInput) {
691
+ const policy = gate && gate.adminOverride;
692
+ if (!policy || policy.required !== true) return null;
693
+
694
+ const approvalContextDigest = actionApprovalDigest(toolName, toolInput);
695
+ const baseTaskId = adminOverrideTaskId(gate.id, approvalContextDigest);
696
+ let taskId;
697
+ try {
698
+ taskId = selectAdminOverrideAttempt(baseTaskId);
699
+ } catch {
700
+ return {
701
+ authorized: false,
702
+ approvalContextDigest,
703
+ escalationId: null,
704
+ taskId: baseTaskId,
705
+ approvalUnavailable: true,
706
+ };
707
+ }
708
+ const ttlMs = Math.min(
709
+ 60 * 60 * 1000,
710
+ Math.max(60 * 1000, Number(policy.ttlMs) || DEFAULT_ADMIN_OVERRIDE_TTL_MS),
711
+ );
712
+ let escalation;
713
+ try {
714
+ escalation = requestEscalation({
715
+ taskId,
716
+ reason: `Admin override required for hard gate '${gate.id}'.`,
717
+ severity: gate.severity || 'critical',
718
+ requester: { id: 'thumbgate-gates-engine', kind: 'service' },
719
+ evidence: [`sha256:${approvalContextDigest}`],
720
+ ttlMs,
721
+ idempotencyKey: taskId,
722
+ approvalContextDigest,
723
+ requiredReviewerRole: 'admin',
724
+ });
725
+ } catch {
726
+ return {
727
+ authorized: false,
728
+ approvalContextDigest,
729
+ escalationId: null,
730
+ taskId,
731
+ approvalUnavailable: true,
732
+ };
733
+ }
734
+ const escalationId = escalation.escalation.escalationId;
735
+ let consumption = null;
736
+ try {
737
+ consumption = consumeVerifiedApproval(escalationId, {
738
+ consumer: { id: 'thumbgate-gates-engine', kind: 'service' },
739
+ });
740
+ } catch {
741
+ consumption = null;
742
+ }
743
+
744
+ if (!consumption || !consumption.consumed) {
745
+ return {
746
+ authorized: false,
747
+ approvalContextDigest,
748
+ escalationId,
749
+ taskId,
750
+ replayed: consumption?.replayed === true,
751
+ };
752
+ }
753
+ return {
754
+ authorized: true,
755
+ approvalContextDigest,
756
+ escalationId,
757
+ taskId,
758
+ approver: consumption.approval.actor,
759
+ consumptionReceipt: consumption.consumption.eventHash,
760
+ };
514
761
  }
515
762
 
516
763
  function setTaskScope(scopeInput = {}) {
764
+ const explicitSessionId = scopeInput && scopeInput.sessionId ? String(scopeInput.sessionId).trim() : null;
517
765
  if (scopeInput && scopeInput.clear === true) {
518
- const currentState = loadGovernanceState();
766
+ const currentState = loadGovernanceState(explicitSessionId);
519
767
  const cleared = {
520
768
  taskScope: null,
521
769
  protectedApprovals: currentState.protectedApprovals,
522
770
  branchGovernance: currentState.branchGovernance,
523
771
  workflowContract: null,
524
772
  };
525
- saveGovernanceState(cleared);
773
+ saveGovernanceState(cleared, explicitSessionId);
526
774
  refreshLocalOnlyConstraint(cleared);
527
775
  return null;
528
776
  }
@@ -545,6 +793,7 @@ function setTaskScope(scopeInput = {}) {
545
793
  const leaseMs = scopeInput.ttlMs == null ? null : clampTtlMs(scopeInput.ttlMs, TASK_SCOPE_LEASE_MS);
546
794
  const taskScope = {
547
795
  taskId: String(scopeInput.taskId || '').trim() || null,
796
+ sessionId: currentScopeSessionId(explicitSessionId),
548
797
  summary: String(scopeInput.summary || '').trim() || null,
549
798
  allowedPaths,
550
799
  protectedPaths,
@@ -555,12 +804,12 @@ function setTaskScope(scopeInput = {}) {
555
804
  leaseMs,
556
805
  expiresAt: leaseMs == null ? null : scopeNow + leaseMs,
557
806
  };
558
- const state = loadGovernanceState();
807
+ const state = loadGovernanceState(explicitSessionId);
559
808
  state.taskScope = taskScope;
560
809
  state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
561
810
  ? scopeInput.workflowContract
562
811
  : null;
563
- saveGovernanceState(state);
812
+ saveGovernanceState(state, explicitSessionId);
564
813
  if (taskScope.localOnly) {
565
814
  setConstraint('local_only', true);
566
815
  }
@@ -606,7 +855,14 @@ function breakGlassEmergency(input = {}) {
606
855
  const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
607
856
  const satisfied = {};
608
857
  for (const gateId of gates) {
609
- satisfied[gateId] = satisfyCondition(gateId, evidence);
858
+ // Break glass is the highest-scrutiny unlock there is. Without an explicit
859
+ // source these records defaulted to 'cli', hiding emergency overrides among
860
+ // ordinary ones — the exact case an auditor looks for first.
861
+ satisfied[gateId] = satisfyCondition(gateId, evidence, null, {
862
+ source: 'break-glass',
863
+ actor: input.actor || 'operator',
864
+ reason,
865
+ });
610
866
  }
611
867
 
612
868
  const approval = approveProtectedAction({
@@ -665,8 +921,9 @@ function setBranchGovernance(input = {}) {
665
921
  return governance;
666
922
  }
667
923
 
668
- function getScopeState() {
669
- return loadGovernanceState();
924
+ function getScopeState(options = {}) {
925
+ const sessionId = typeof options === 'string' ? options : (options?.sessionId || process.env.THUMBGATE_SESSION_AGENT || null);
926
+ return loadGovernanceState(sessionId);
670
927
  }
671
928
 
672
929
  function getBranchGovernanceState() {
@@ -709,7 +966,7 @@ function isConditionSatisfied(conditionId) {
709
966
  return age < TTL_MS;
710
967
  }
711
968
 
712
- function satisfyCondition(conditionId, evidence, structuredReasoning) {
969
+ function satisfyCondition(conditionId, evidence, structuredReasoning, options = {}) {
713
970
  const state = loadState();
714
971
  const entry = {
715
972
  timestamp: Date.now(),
@@ -725,6 +982,28 @@ function satisfyCondition(conditionId, evidence, structuredReasoning) {
725
982
  }
726
983
  state[conditionId] = entry;
727
984
  saveState(state);
985
+
986
+ // Satisfying a gate condition IS an override: it unlocks an action the gate
987
+ // had blocked. Previously this wrote only to the state store, so an override
988
+ // performed through the CLI left no trace in the audit trail at all — the
989
+ // least-supervised path was also the least-recorded. Record it explicitly.
990
+ // Never let an audit failure break the unlock the caller is relying on.
991
+ try {
992
+ // Lazy require: override-audit depends on audit-trail, which this module
993
+ // already loads; requiring at call time avoids any circular-init surprise.
994
+ const { recordOverride } = require('./override-audit');
995
+ recordOverride({
996
+ gateId: conditionId,
997
+ source: options.source || 'cli',
998
+ actor: options.actor,
999
+ reason: options.reason,
1000
+ evidence: entry.evidence,
1001
+ structuredReasoning: entry.structuredReasoning,
1002
+ });
1003
+ } catch {
1004
+ /* audit is best-effort; the gate state is authoritative */
1005
+ }
1006
+
728
1007
  return entry;
729
1008
  }
730
1009
 
@@ -905,14 +1184,111 @@ function safeExecFileLines(binary, args, cwd) {
905
1184
  }
906
1185
  }
907
1186
 
1187
+ function extractGitMinusCPaths(command) {
1188
+ const found = [];
1189
+ const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
1190
+ for (const segment of segments) {
1191
+ const tokens = tokenizeShellWords(segment);
1192
+ const gitIdx = tokens.findIndex((token) => token === 'git' || /(?:^|\/)git$/.test(token));
1193
+ if (gitIdx === -1) continue;
1194
+ for (let i = gitIdx + 1; i < tokens.length; i += 1) {
1195
+ const token = tokens[i];
1196
+ if (token === '-C' && tokens[i + 1]) {
1197
+ found.push(tokens[i + 1]);
1198
+ i += 1;
1199
+ continue;
1200
+ }
1201
+ if (token === '--work-tree' && tokens[i + 1]) {
1202
+ found.push(tokens[i + 1]);
1203
+ i += 1;
1204
+ continue;
1205
+ }
1206
+ if (token.startsWith('--work-tree=')) {
1207
+ found.push(token.slice('--work-tree='.length));
1208
+ continue;
1209
+ }
1210
+ if (!token.startsWith('-')) break;
1211
+ }
1212
+ }
1213
+ return found;
1214
+ }
1215
+
1216
+ function extractGitContextPair(command) {
1217
+ const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
1218
+ for (const segment of segments) {
1219
+ const tokens = tokenizeShellWords(segment);
1220
+ const gitIdx = tokens.findIndex((token) => token === 'git' || /(?:^|\/)git$/.test(token));
1221
+ if (gitIdx === -1) continue;
1222
+ let gitDir = null;
1223
+ let workTree = null;
1224
+ for (let i = gitIdx + 1; i < tokens.length; i += 1) {
1225
+ const token = tokens[i];
1226
+ if (token === '--git-dir' && tokens[i + 1]) {
1227
+ gitDir = tokens[i + 1];
1228
+ i += 1;
1229
+ continue;
1230
+ }
1231
+ if (token.startsWith('--git-dir=')) {
1232
+ gitDir = token.slice('--git-dir='.length);
1233
+ continue;
1234
+ }
1235
+ if (token === '--work-tree' && tokens[i + 1]) {
1236
+ workTree = tokens[i + 1];
1237
+ i += 1;
1238
+ continue;
1239
+ }
1240
+ if (token.startsWith('--work-tree=')) {
1241
+ workTree = token.slice('--work-tree='.length);
1242
+ continue;
1243
+ }
1244
+ if (token === '-C' && tokens[i + 1]) {
1245
+ workTree = tokens[i + 1];
1246
+ i += 1;
1247
+ continue;
1248
+ }
1249
+ if (!token.startsWith('-')) break;
1250
+ }
1251
+ if (gitDir || workTree) return { gitDir, workTree };
1252
+ }
1253
+ return null;
1254
+ }
1255
+
908
1256
  function resolveRepoRoot(toolInput = {}) {
909
- const candidates = [
1257
+ const command = String(toolInput.command || '');
1258
+ const baseCwd = toolInput.cwd ? path.resolve(String(toolInput.cwd)) : process.cwd();
1259
+ const paired = extractGitContextPair(command);
1260
+ if (paired && paired.gitDir && paired.workTree) {
1261
+ try {
1262
+ const resolvedGitDir = path.resolve(baseCwd, paired.gitDir);
1263
+ const resolvedWorkTree = path.resolve(baseCwd, paired.workTree);
1264
+ const root = execFileSync('git', ['--git-dir', resolvedGitDir, '--work-tree', resolvedWorkTree, 'rev-parse', '--show-toplevel'], {
1265
+ cwd: resolvedWorkTree,
1266
+ encoding: 'utf8',
1267
+ stdio: ['ignore', 'pipe', 'ignore'],
1268
+ }).trim();
1269
+ if (root) return root;
1270
+ } catch {
1271
+ // Fall through to standard candidate probing
1272
+ }
1273
+ }
1274
+
1275
+ const minusC = extractGitMinusCPaths(command).map((entry) => path.resolve(baseCwd, entry));
1276
+ const commandCwd = effectiveCommandCwd(command, toolInput);
1277
+ const candidates = [];
1278
+ const seen = new Set();
1279
+ for (const value of [
1280
+ ...minusC,
1281
+ commandCwd,
910
1282
  toolInput.repoPath,
911
1283
  toolInput.cwd,
912
1284
  process.cwd(),
913
- ]
914
- .filter(Boolean)
915
- .map((value) => path.resolve(String(value)));
1285
+ ]) {
1286
+ if (!value) continue;
1287
+ const resolved = path.resolve(String(value));
1288
+ if (seen.has(resolved)) continue;
1289
+ seen.add(resolved);
1290
+ candidates.push(resolved);
1291
+ }
916
1292
 
917
1293
  for (const cwd of candidates) {
918
1294
  try {
@@ -1135,7 +1511,17 @@ function parseGitPathspec(command, subcommand, options = {}) {
1135
1511
  // null as "unknown" and fall back to broad.
1136
1512
  function effectiveCommandCwd(command, toolInput) {
1137
1513
  let cwd = String(toolInput?.cwd || toolInput?.repoPath || process.cwd());
1138
- const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
1514
+ const commandStr = String(command || '');
1515
+ const gitCMatch = commandStr.match(/(?:^|\s)git(?:\s+-[^\s]+)*\s+-C(?:\s+|=)(?:'([^']+)'|"([^"]+)"|([^\s'"]+))/i);
1516
+ const gitCDir = gitCMatch ? (gitCMatch[1] || gitCMatch[2] || gitCMatch[3] || '').trim() : '';
1517
+ if (gitCDir) {
1518
+ let target = gitCDir;
1519
+ if (target.startsWith('~/')) {
1520
+ target = path.join(os.homedir(), target.slice(2));
1521
+ }
1522
+ return path.resolve(cwd, target);
1523
+ }
1524
+ const segments = commandStr.split(/\r?\n|&&|\|\||[;|&]/);
1139
1525
  for (const segment of segments) {
1140
1526
  // Parsed without a regex: /^cd\s+(?:--\s+)?(.+)$/ has adjacent \s+ groups that backtrack
1141
1527
  // polynomially on input like `cd\t\t\t…` (js/polynomial-redos). The command comes
@@ -1149,8 +1535,12 @@ function effectiveCommandCwd(command, toolInput) {
1149
1535
  else if (argText.startsWith('--') && /^[ \t]/.test(argText.slice(2))) argText = argText.slice(2).trim();
1150
1536
  const target = tokenizeShellWords(argText)[0];
1151
1537
  if (!target) break; // bare `cd` -> home; leave scope resolution alone
1152
- if (/[*?$`]|^~/.test(target)) return null;
1153
- cwd = path.resolve(cwd, target);
1538
+ if (/[*?$`]/.test(target)) return null;
1539
+ let targetResolved = target;
1540
+ if (targetResolved.startsWith('~/')) {
1541
+ targetResolved = path.join(os.homedir(), targetResolved.slice(2));
1542
+ }
1543
+ cwd = path.resolve(cwd, targetResolved);
1154
1544
  }
1155
1545
  return cwd;
1156
1546
  }
@@ -1376,8 +1766,10 @@ function extractAffectedFiles(toolName, toolInput = {}) {
1376
1766
  }
1377
1767
 
1378
1768
  if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
1379
- for (const filePath of getBranchDiffFiles(repoRoot)) {
1380
- files.add(normalizePosix(filePath));
1769
+ if (files.size === 0) {
1770
+ for (const filePath of getBranchDiffFiles(repoRoot)) {
1771
+ files.add(normalizePosix(filePath));
1772
+ }
1381
1773
  }
1382
1774
  }
1383
1775
  }
@@ -1691,9 +2083,51 @@ function isThreadResolutionSatisfied() {
1691
2083
  ));
1692
2084
  }
1693
2085
 
2086
+ // Hook payloads name MCP tools `mcp__<server>__<tool>` while exemption lists
2087
+ // hold bare tool names. Exemptions must compare on the stripped name or they
2088
+ // never fire for real hook traffic — 2026-08-05: the pending-thread gate
2089
+ // blocked `mcp__thumbgate__satisfy_gate` itself, deadlocking its own
2090
+ // documented escape hatch until the session-actions TTL expired.
2091
+ function bareToolName(toolName) {
2092
+ return String(toolName || '').replace(/^mcp__.+?__/, '');
2093
+ }
2094
+
2095
+ function isRemedyToolName(toolName) {
2096
+ return REMEDY_TOOL_NAMES.has(bareToolName(toolName));
2097
+ }
2098
+
2099
+ // permission-change-approval used to regex the entire command string, so
2100
+ // quoting `chmod 755` inside `gh issue create --body` was itself a deny (#3523).
2101
+ function isCommandPositionPermissionChange(toolName, toolInput = {}) {
2102
+ if (toolName !== 'Bash') return false;
2103
+ const command = String(toolInput.command || '');
2104
+ if (!command) return false;
2105
+ const segments = canonicalizeCommandForGates(command).split('; ');
2106
+ for (const segment of segments) {
2107
+ const tokens = tokenizeShellWords(segment);
2108
+ if (tokens.length === 0) continue;
2109
+ const bin = tokens[0].toLowerCase();
2110
+ if (bin === 'chmod' || bin === 'chown' || bin === 'setfacl') return tokens.length >= 2;
2111
+ if (bin === 'busybox' && tokens[1] && ['chmod', 'chown', 'setfacl'].includes(tokens[1].toLowerCase())) {
2112
+ return tokens.length >= 3;
2113
+ }
2114
+ if (bin === 'grant' || bin === 'revoke') return tokens.length >= 2;
2115
+ const nextFew = tokens.slice(1, 6).map((token) => token.toLowerCase());
2116
+ if ((bin === 'pm' || bin === 'adb') && nextFew.includes('grant')) return true;
2117
+ if (
2118
+ (bin === 'aws' || bin === 'gcloud' || bin === 'az')
2119
+ && nextFew.some((token) => token === 'iam' || token === 'policy' || token === 'role'
2120
+ || token === 'grant' || token === 'revoke')
2121
+ ) {
2122
+ return true;
2123
+ }
2124
+ }
2125
+ return false;
2126
+ }
2127
+
1694
2128
  function isThreadResolutionEvidenceAction(toolName, toolInput = {}) {
1695
2129
  if (isGitCommitCommand(toolName, toolInput)) return true;
1696
- if (['recall', 'search_lessons', 'verify_claim', 'satisfy_gate', 'track_action'].includes(toolName)) return true;
2130
+ if (['recall', 'search_lessons', 'verify_claim', 'satisfy_gate', 'track_action'].includes(bareToolName(toolName))) return true;
1697
2131
  if (toolName !== 'Bash') return false;
1698
2132
  const command = String(toolInput.command || '');
1699
2133
  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);
@@ -1733,7 +2167,9 @@ function getReadOnlyToolNames() {
1733
2167
  return names;
1734
2168
  }
1735
2169
  function isReadOnlyObservabilityTool(toolName) {
1736
- return Boolean(toolName) && getReadOnlyToolNames().has(toolName);
2170
+ if (!toolName) return false;
2171
+ const names = getReadOnlyToolNames();
2172
+ return names.has(toolName) || names.has(bareToolName(toolName));
1737
2173
  }
1738
2174
 
1739
2175
  function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
@@ -1777,7 +2213,7 @@ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
1777
2213
  severity: 'critical',
1778
2214
  reasoning: [
1779
2215
  `Tracked action ${PR_THREAD_RESOLUTION_ACTION} is pending`,
1780
- 'Check review threads (e.g. gh pr view --json reviewThreads), then call the satisfy_gate tool with gateId="pr_threads_checked" and the evidence — running a check command alone does not clear this gate',
2216
+ 'Check review threads (e.g. gh pr view --json reviewThreads), then call the satisfy_gate tool with gate="pr_threads_checked" (param name is gate, not gateId) and evidence — running a check command alone does not clear this gate',
1781
2217
  ],
1782
2218
  };
1783
2219
  }
@@ -1872,10 +2308,15 @@ function recordHelperScriptWrite(toolName, toolInput = {}) {
1872
2308
  packageScriptTouched,
1873
2309
  reasons,
1874
2310
  };
1875
- trackAction(HELPER_BYPASS_ACTION, metadata);
2311
+ trackAction(helperBypassActionKey(), metadata);
1876
2312
  return metadata;
1877
2313
  }
1878
2314
 
2315
+ function helperBypassActionKey() {
2316
+ const sessionId = currentScopeSessionId();
2317
+ return sessionId ? `${HELPER_BYPASS_ACTION}:${sessionId}` : HELPER_BYPASS_ACTION;
2318
+ }
2319
+
1879
2320
  function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
1880
2321
  if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
1881
2322
  if (toolName !== 'Bash') {
@@ -1901,7 +2342,7 @@ function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
1901
2342
 
1902
2343
  const writeMetadata = recordHelperScriptWrite(toolName, toolInput);
1903
2344
  const actions = listSessionActions();
1904
- const recentWrite = actions[HELPER_BYPASS_ACTION];
2345
+ const recentWrite = actions[helperBypassActionKey()];
1905
2346
  const recentMetadata = recentWrite && recentWrite.metadata && typeof recentWrite.metadata === 'object'
1906
2347
  ? recentWrite.metadata
1907
2348
  : null;
@@ -1974,6 +2415,10 @@ function evaluateCatastrophicDeclarativeGate(config, constraints, toolName, tool
1974
2415
  for (const gate of config.gates) {
1975
2416
  if (!CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate.id)) continue;
1976
2417
  if (gate.action !== 'block' || gate.metrics) continue;
2418
+ // Override-capable hard gates must reach the ordinary loop so it can
2419
+ // authenticate and consume the exact-action admin authorization. They are
2420
+ // still unconditional hard floors and cannot be posture/cap downgraded.
2421
+ if (gate.adminOverride && gate.adminOverride.required === true) continue;
1977
2422
 
1978
2423
  const matchDetails = matchGate(gate, toolName, toolInput);
1979
2424
  if (!matchDetails.matched) continue;
@@ -2399,17 +2844,27 @@ function matchGate(gate, toolName, toolInput = {}) {
2399
2844
 
2400
2845
  if (gate.pattern) {
2401
2846
  try {
2402
- const regex = new RegExp(gate.pattern);
2403
- // Match command text, tool name, and light payload surfaces. MCP tools
2404
- // (e.g. Gmail send_message) have no `command` field — without multi-surface
2405
- // matching, every pattern gate against them is permanently inert.
2406
- const surfaces = matchSurfaces.length > 0 ? matchSurfaces : [matchText];
2407
- const anySurfaceMatch = surfaces.some((surface) => patternMatchesCommand(regex, surface));
2408
- if (!anySurfaceMatch) {
2409
- return { matched: false, matchText, affectedFiles };
2410
- }
2411
- if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
2412
- return { matched: false, matchText, affectedFiles };
2847
+ if (gate.id === 'permission-change-approval') {
2848
+ if (isRemedyToolName(toolName) || !isCommandPositionPermissionChange(toolName, toolInput)) {
2849
+ return { matched: false, matchText, affectedFiles };
2850
+ }
2851
+ if (isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
2852
+ return { matched: false, matchText, affectedFiles };
2853
+ }
2854
+ } else if (gate.id === 'gh-api-pr-create-restricted') {
2855
+ if (!isGhApiPrCreateCommand(String(toolInput.command || ''))) {
2856
+ return { matched: false, matchText, affectedFiles };
2857
+ }
2858
+ } else {
2859
+ const regex = new RegExp(gate.pattern);
2860
+ // Match command text, tool name, and light payload surfaces. MCP tools
2861
+ // (e.g. Gmail send_message) have no `command` field — without multi-surface
2862
+ // matching, every pattern gate against them is permanently inert.
2863
+ const surfaces = matchSurfaces.length > 0 ? matchSurfaces : [matchText];
2864
+ const anySurfaceMatch = surfaces.some((surface) => patternMatchesCommand(regex, surface));
2865
+ if (!anySurfaceMatch) {
2866
+ return { matched: false, matchText, affectedFiles };
2867
+ }
2413
2868
  }
2414
2869
  if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
2415
2870
  return { matched: false, matchText, affectedFiles };
@@ -2476,7 +2931,6 @@ function matchGate(gate, toolName, toolInput = {}) {
2476
2931
  matchText,
2477
2932
  affectedFiles,
2478
2933
  taskScopeViolation,
2479
- protectedApprovalViolation,
2480
2934
  branchGovernanceViolation,
2481
2935
  };
2482
2936
  }
@@ -2502,6 +2956,7 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
2502
2956
 
2503
2957
  const command = String(toolInput.command || '');
2504
2958
  let matchText = command;
2959
+
2505
2960
  if (gate.id === 'self-protect-config' || gate.id === 'self-protect-hooks-disable') {
2506
2961
  const targetPattern = gate.id === 'self-protect-config'
2507
2962
  ? SELF_PROTECT_CONFIG_TARGET_PATTERN
@@ -2513,7 +2968,28 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
2513
2968
  const commandTargetPattern = gate.id === 'self-protect-config'
2514
2969
  ? SELF_PROTECT_CONFIG_COMMAND_PATTERN
2515
2970
  : SELF_PROTECT_HOOK_COMMAND_PATTERN;
2516
- if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) return null;
2971
+
2972
+ // Inspect every shell redirection destination (optional fd, optional/no
2973
+ // spaces, attached forms like printf x>file, multiple redirects).
2974
+ const redirectPattern = /(?:^|[\s;&|]|[^\s;&|<>])(?:\d*)>{1,2}\s*([^\s;&|<>]+)/g;
2975
+ const redirectTargets = [];
2976
+ let redirectMatch = redirectPattern.exec(command);
2977
+ while (redirectMatch) {
2978
+ if (redirectMatch[1]) redirectTargets.push(redirectMatch[1]);
2979
+ redirectMatch = redirectPattern.exec(command);
2980
+ }
2981
+ if (redirectTargets.length > 0) {
2982
+ // Deny when any redirect destination is protected.
2983
+ if (redirectTargets.some((target) => commandTargetPattern.test(target))) {
2984
+ // fall through to deny
2985
+ } else if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) {
2986
+ // Benign redirects and no other protected mutation → allow.
2987
+ return null;
2988
+ }
2989
+ // Benign redirects but command still mutates a protected path another way → deny.
2990
+ } else {
2991
+ if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) return null;
2992
+ }
2517
2993
  } else {
2518
2994
  return null;
2519
2995
  }
@@ -2813,6 +3289,27 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2813
3289
  try {
2814
3290
  const { selectHarness } = require('./harness-selector');
2815
3291
  harnessPath = selectHarness(toolName, toolInput);
3292
+ try {
3293
+ // Opt-in RateBurst only (THUMBGATE_RADWARE_RATE=1). Never persist in tests/CI.
3294
+ if (process.env.THUMBGATE_RADWARE_RATE === '1') {
3295
+ const radware = require('./radware-threat-defense.js');
3296
+ const inTest = Boolean(process.env.NODE_TEST || process.env.NODE_TEST_CONTEXT || process.env.CI || process.env.GITHUB_ACTIONS || process.env.VITEST || process.argv.some((a) => a.includes('node:test') || a.endsWith('.test.js')));
3297
+ if (!inTest) {
3298
+ radware.persistCallTimestamp();
3299
+ const burst = radware.checkRateBurst(radware.loadCallTimestamps());
3300
+ if (burst.tripped) {
3301
+ return recordStructuralGateBlock(toolName, toolInput, {
3302
+ decision: 'deny',
3303
+ gate: 'algorithmic-token-drain-circuit-breaker',
3304
+ action: 'block',
3305
+ severity: 'high',
3306
+ message: burst.message,
3307
+ receipt: 'threat_defense_interdicted=true:type=RateBurst:action=block',
3308
+ });
3309
+ }
3310
+ }
3311
+ }
3312
+ } catch { /* optional */ }
2816
3313
  } catch { /* harness-selector is optional */ }
2817
3314
  config = loadGatesConfig(configPath, harnessPath);
2818
3315
  } catch {
@@ -2835,6 +3332,10 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2835
3332
  if (statefulHelperBypassGate) {
2836
3333
  return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
2837
3334
  }
3335
+ const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
3336
+ if (stealthMemoryInjectionGate) {
3337
+ return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
3338
+ }
2838
3339
  if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
2839
3340
  recordAuditEvent({
2840
3341
  toolName,
@@ -2848,6 +3349,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2848
3349
  return null;
2849
3350
  }
2850
3351
 
3352
+ const agentIdentityLifecycleGate = evaluateAgentIdentityLifecycleGate(toolName, toolInput);
3353
+ if (agentIdentityLifecycleGate) {
3354
+ recordStat(agentIdentityLifecycleGate.gate, 'block', null, { toolName, toolInput });
3355
+ const identityAuditRecord = recordAuditEvent({
3356
+ toolName,
3357
+ toolInput,
3358
+ decision: 'deny',
3359
+ gateId: agentIdentityLifecycleGate.gate,
3360
+ message: agentIdentityLifecycleGate.message,
3361
+ severity: agentIdentityLifecycleGate.severity,
3362
+ source: 'gates-engine',
3363
+ });
3364
+ auditToFeedback(identityAuditRecord);
3365
+ return agentIdentityLifecycleGate;
3366
+ }
3367
+
2851
3368
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
2852
3369
  if (pendingThreadResolutionGate) {
2853
3370
  recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
@@ -2952,13 +3469,45 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2952
3469
  });
2953
3470
 
2954
3471
  if (gate.action === 'block') {
3472
+ const adminOverride = evaluateAdminOverride(gate, toolName, toolInput);
3473
+ if (adminOverride && adminOverride.authorized) {
3474
+ recordStat(gate.id, 'approve', gate, { toolName, toolInput });
3475
+ const auditRecord = recordAuditEvent({
3476
+ toolName,
3477
+ toolInput,
3478
+ decision: 'allow',
3479
+ gateId: gate.id,
3480
+ message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
3481
+ severity: gate.severity,
3482
+ source: 'gates-engine-admin-override',
3483
+ });
3484
+ auditToFeedback(auditRecord);
3485
+ return {
3486
+ decision: 'allow',
3487
+ gate: gate.id,
3488
+ message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
3489
+ severity: gate.severity,
3490
+ reasoning,
3491
+ adminOverride,
3492
+ };
3493
+ }
2955
3494
  // Expired leases report under their own gate id so neither the enforcement posture nor
2956
3495
  // the daily block cap can quietly turn this denial into a warning.
2957
3496
  const gateId = matchDetails && matchDetails.taskScopeViolation
2958
3497
  && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
2959
3498
  ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
2960
3499
  : gate.id;
2961
- const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
3500
+ const overrideMessage = adminOverride
3501
+ ? `${message} Approval request: ${adminOverride.escalationId}. Action digest: sha256:${adminOverride.approvalContextDigest}.${adminOverride.replayed ? ' The approved override was already consumed.' : ''}`
3502
+ : message;
3503
+ const denyResult = {
3504
+ decision: 'deny',
3505
+ gate: gateId,
3506
+ message: overrideMessage,
3507
+ severity: gate.severity,
3508
+ reasoning,
3509
+ ...(adminOverride ? { requiresAdminOverride: true, adminOverride } : {}),
3510
+ };
2962
3511
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
2963
3512
  const cappedResult = applyDailyBlockCap(denyResult);
2964
3513
  if (cappedResult) {
@@ -2968,7 +3517,7 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2968
3517
  return cappedResult;
2969
3518
  }
2970
3519
  recordStat(gate.id, 'block', gate, { toolName, toolInput });
2971
- const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
3520
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
2972
3521
  auditToFeedback(auditRecord);
2973
3522
  return denyResult;
2974
3523
  }
@@ -3056,6 +3605,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
3056
3605
  return sentinelResult;
3057
3606
  }
3058
3607
 
3608
+ const brokerReceiptResultAsync = evaluateBrokerReceiptGate(toolName, toolInput);
3609
+ if (brokerReceiptResultAsync && brokerReceiptResultAsync.decision === 'deny') {
3610
+ recordStat(brokerReceiptResultAsync.gate, 'block', null, { toolName, toolInput });
3611
+ const auditRecord = recordAuditEvent({
3612
+ toolName,
3613
+ toolInput,
3614
+ decision: 'deny',
3615
+ gateId: brokerReceiptResultAsync.gate,
3616
+ message: brokerReceiptResultAsync.message,
3617
+ severity: brokerReceiptResultAsync.severity,
3618
+ source: 'broker-execution-receipts',
3619
+ });
3620
+ auditToFeedback(auditRecord);
3621
+ return brokerReceiptResultAsync;
3622
+ }
3623
+
3059
3624
  // Audit trail: record allow (no gate matched)
3060
3625
  recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
3061
3626
  return null;
@@ -3068,6 +3633,27 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3068
3633
  try {
3069
3634
  const { selectHarness } = require('./harness-selector');
3070
3635
  harnessPath = selectHarness(toolName, toolInput);
3636
+ try {
3637
+ // Opt-in RateBurst only (THUMBGATE_RADWARE_RATE=1). Never persist in tests/CI.
3638
+ if (process.env.THUMBGATE_RADWARE_RATE === '1') {
3639
+ const radware = require('./radware-threat-defense.js');
3640
+ const inTest = Boolean(process.env.NODE_TEST || process.env.NODE_TEST_CONTEXT || process.env.CI || process.env.GITHUB_ACTIONS || process.env.VITEST || process.argv.some((a) => a.includes('node:test') || a.endsWith('.test.js')));
3641
+ if (!inTest) {
3642
+ radware.persistCallTimestamp();
3643
+ const burst = radware.checkRateBurst(radware.loadCallTimestamps());
3644
+ if (burst.tripped) {
3645
+ return recordStructuralGateBlock(toolName, toolInput, {
3646
+ decision: 'deny',
3647
+ gate: 'algorithmic-token-drain-circuit-breaker',
3648
+ action: 'block',
3649
+ severity: 'high',
3650
+ message: burst.message,
3651
+ receipt: 'threat_defense_interdicted=true:type=RateBurst:action=block',
3652
+ });
3653
+ }
3654
+ }
3655
+ }
3656
+ } catch { /* optional */ }
3071
3657
  } catch { /* harness-selector is optional */ }
3072
3658
  config = loadGatesConfig(configPath, harnessPath);
3073
3659
  } catch {
@@ -3091,6 +3677,10 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3091
3677
  if (statefulHelperBypassGate) {
3092
3678
  return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
3093
3679
  }
3680
+ const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
3681
+ if (stealthMemoryInjectionGate) {
3682
+ return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
3683
+ }
3094
3684
  if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
3095
3685
  recordAuditEvent({
3096
3686
  toolName,
@@ -3104,6 +3694,22 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3104
3694
  return null;
3105
3695
  }
3106
3696
 
3697
+ const agentIdentityLifecycleGate = evaluateAgentIdentityLifecycleGate(toolName, toolInput);
3698
+ if (agentIdentityLifecycleGate) {
3699
+ recordStat(agentIdentityLifecycleGate.gate, 'block', null, { toolName, toolInput });
3700
+ const identityAuditRecord = recordAuditEvent({
3701
+ toolName,
3702
+ toolInput,
3703
+ decision: 'deny',
3704
+ gateId: agentIdentityLifecycleGate.gate,
3705
+ message: agentIdentityLifecycleGate.message,
3706
+ severity: agentIdentityLifecycleGate.severity,
3707
+ source: 'gates-engine',
3708
+ });
3709
+ auditToFeedback(identityAuditRecord);
3710
+ return agentIdentityLifecycleGate;
3711
+ }
3712
+
3107
3713
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
3108
3714
  if (pendingThreadResolutionGate) {
3109
3715
  recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
@@ -3181,13 +3787,45 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3181
3787
  const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
3182
3788
 
3183
3789
  if (gate.action === 'block') {
3790
+ const adminOverride = evaluateAdminOverride(gate, toolName, toolInput);
3791
+ if (adminOverride && adminOverride.authorized) {
3792
+ recordStat(gate.id, 'approve', gate, { toolName, toolInput });
3793
+ const auditRecord = recordAuditEvent({
3794
+ toolName,
3795
+ toolInput,
3796
+ decision: 'allow',
3797
+ gateId: gate.id,
3798
+ message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
3799
+ severity: gate.severity,
3800
+ source: 'gates-engine-admin-override',
3801
+ });
3802
+ auditToFeedback(auditRecord);
3803
+ return {
3804
+ decision: 'allow',
3805
+ gate: gate.id,
3806
+ message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
3807
+ severity: gate.severity,
3808
+ reasoning,
3809
+ adminOverride,
3810
+ };
3811
+ }
3184
3812
  // Expired leases report under their own gate id so neither the enforcement posture nor
3185
3813
  // the daily block cap can quietly turn this denial into a warning.
3186
3814
  const gateId = matchDetails && matchDetails.taskScopeViolation
3187
3815
  && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
3188
3816
  ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
3189
3817
  : gate.id;
3190
- const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
3818
+ const overrideMessage = adminOverride
3819
+ ? `${message} Approval request: ${adminOverride.escalationId}. Action digest: sha256:${adminOverride.approvalContextDigest}.${adminOverride.replayed ? ' The approved override was already consumed.' : ''}`
3820
+ : message;
3821
+ const denyResult = {
3822
+ decision: 'deny',
3823
+ gate: gateId,
3824
+ message: overrideMessage,
3825
+ severity: gate.severity,
3826
+ reasoning,
3827
+ ...(adminOverride ? { requiresAdminOverride: true, adminOverride } : {}),
3828
+ };
3191
3829
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
3192
3830
  const cappedResult = applyDailyBlockCap(denyResult);
3193
3831
  if (cappedResult) {
@@ -3197,7 +3835,7 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3197
3835
  return cappedResult;
3198
3836
  }
3199
3837
  recordStat(gate.id, 'block', gate, { toolName, toolInput });
3200
- const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
3838
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
3201
3839
  auditToFeedback(auditRecord);
3202
3840
  return denyResult;
3203
3841
  }
@@ -3284,6 +3922,24 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3284
3922
  return sentinelResult;
3285
3923
  }
3286
3924
 
3925
+ // Broker-signed execution receipts: verify attached proof; optionally require
3926
+ // for high-risk provider side effects (THUMBGATE_BROKER_RECEIPT_MODE=enforce).
3927
+ const brokerReceiptResult = evaluateBrokerReceiptGate(toolName, toolInput);
3928
+ if (brokerReceiptResult && brokerReceiptResult.decision === 'deny') {
3929
+ recordStat(brokerReceiptResult.gate, 'block', null, { toolName, toolInput });
3930
+ const auditRecord = recordAuditEvent({
3931
+ toolName,
3932
+ toolInput,
3933
+ decision: 'deny',
3934
+ gateId: brokerReceiptResult.gate,
3935
+ message: brokerReceiptResult.message,
3936
+ severity: brokerReceiptResult.severity,
3937
+ source: 'broker-execution-receipts',
3938
+ });
3939
+ auditToFeedback(auditRecord);
3940
+ return brokerReceiptResult;
3941
+ }
3942
+
3287
3943
  // Audit trail: record allow
3288
3944
  recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
3289
3945
  return null;
@@ -3447,6 +4103,7 @@ function evaluateUnconditionalHardFloor(input = {}, options = {}) {
3447
4103
 
3448
4104
  function evaluateFinancialHardFloor(input = {}, consumeReservation = false, options = {}) {
3449
4105
  const toolName = input.tool_name || input.toolName || 'unknown';
4106
+ if (isRemedyToolName(toolName)) return null;
3450
4107
  const rawToolInput = input.tool_input ?? input.toolInput;
3451
4108
  const toolInput = rawToolInput && typeof rawToolInput === 'object'
3452
4109
  ? rawToolInput
@@ -3799,7 +4456,11 @@ async function buildRelevantLessonContextAsync(toolName, toolInput) {
3799
4456
  * negative lesson present is relevant enough to surface.
3800
4457
  */
3801
4458
  function formatNegativeLessonContext(lessons) {
3802
- const negative = (lessons || []).filter((l) => l.signal === 'negative');
4459
+ const negative = (lessons || []).filter((l) => {
4460
+ if (l.signal !== 'negative') return false;
4461
+ const score = Number(l.rerankedScore ?? l.relevanceScore ?? 0);
4462
+ return score >= MIN_LESSON_INJECTION_RELEVANCE;
4463
+ });
3803
4464
  if (negative.length === 0) return null;
3804
4465
 
3805
4466
  const formatted = negative.map((l) => {
@@ -3824,8 +4485,10 @@ function isKnowledgeConflictHardBlockAction(toolName, toolInput = {}) {
3824
4485
  }
3825
4486
 
3826
4487
  function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
3827
- const lessonContext = formatNegativeLessonContext(lessons);
3828
- 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.`;
4488
+ // Issue #3689: high entropy means the scorer disagrees with itself. Do NOT
4489
+ // inject a disclaimer + noisy lessons into unrelated tool calls — suppress.
4490
+ // Strict mode may still hard-block destructive/external side effects.
4491
+ const message = `Knowledge conflict: retrieved lessons disagree for this action (entropy ${entropy}).`;
3829
4492
 
3830
4493
  if (isKnowledgeConflictHardBlockAction(toolName, toolInput)) {
3831
4494
  recordStat('retrieval_entropy_high', 'block', null, { toolName, toolInput });
@@ -3837,8 +4500,9 @@ function buildKnowledgeConflictContext(toolName, toolInput, lessons, entropy) {
3837
4500
  };
3838
4501
  }
3839
4502
 
3840
- recordStat('retrieval_entropy_high', 'warn', null, { toolName, toolInput });
3841
- return mergeContextStrings(`[ThumbGate] ${message}`, lessonContext);
4503
+ // Count as log (not warn): we intentionally did not inject context.
4504
+ recordStat('retrieval_entropy_high', 'log', null, { toolName, toolInput });
4505
+ return null;
3842
4506
  }
3843
4507
 
3844
4508
  function extractActionContext(toolName, toolInput) {
@@ -4286,7 +4950,87 @@ function verifyClaimEvidence(claimText, options = {}) {
4286
4950
  // Exports
4287
4951
  // ---------------------------------------------------------------------------
4288
4952
 
4953
+ // ---------------------------------------------------------------------------
4954
+ // Agent identity plane (Okta AI-identity checklist: shadow AI + lifecycle)
4955
+ // ---------------------------------------------------------------------------
4956
+
4957
+ // Warn-dedup per process: one shadow/lifecycle warning per agent id, so the
4958
+ // audit log records the finding without spamming every tool call.
4959
+ const AGENT_IDENTITY_WARNED = new Set();
4960
+
4961
+ function resolveActingAgentId() {
4962
+ return process.env.THUMBGATE_SESSION_AGENT
4963
+ || process.env.THUMBGATE_AGENT_ID
4964
+ || null;
4965
+ }
4966
+
4967
+ /**
4968
+ * Identity gate for the enforced evaluation path. Every attributed tool call
4969
+ * is recorded as an observation (the producer side of shadow-AI detection).
4970
+ * An observed-but-unregistered agent is a shadow agent: warn-only on every
4971
+ * mode — a strict-mode deny here would brick sessions on repos where nothing
4972
+ * registers agents yet. A registry-retired or disabled agent that keeps
4973
+ * acting is denied under THUMBGATE_STRICT_ENFORCEMENT=1 and warned otherwise:
4974
+ * retirement is an explicit operator action, so enforcing it cannot surprise
4975
+ * a healthy session. Fails open on any registry error — identity tracking
4976
+ * must never break the hook path.
4977
+ */
4978
+ function evaluateAgentIdentityLifecycleGate(toolName, toolInput) {
4979
+ try {
4980
+ const agentId = resolveActingAgentId();
4981
+ if (!agentId) return null;
4982
+ const identityStore = require('./audit-trail');
4983
+ try {
4984
+ identityStore.recordObservedAgent(agentId);
4985
+ } catch {
4986
+ // Observation is best-effort.
4987
+ }
4988
+ const registryRow = identityStore.loadAgentRegistry()
4989
+ .filter((agent) => agent && agent.id === agentId)
4990
+ .pop();
4991
+ const strict = process.env.THUMBGATE_STRICT_ENFORCEMENT === '1';
4992
+ const lifecycleStatus = registryRow?.metadata?.lifecycleStatus;
4993
+ if (registryRow && (lifecycleStatus === 'retired' || lifecycleStatus === 'disabled')) {
4994
+ const message = `Agent identity "${agentId}" is ${lifecycleStatus} in the agent registry but is still acting. `
4995
+ + 'Re-activate it via registerAgent with lifecycleStatus "active", or stop the agent.';
4996
+ if (strict) {
4997
+ return { gate: 'agent-identity-lifecycle', decision: 'deny', message, severity: 'critical' };
4998
+ }
4999
+ if (!AGENT_IDENTITY_WARNED.has(`retired:${agentId}`)) {
5000
+ AGENT_IDENTITY_WARNED.add(`retired:${agentId}`);
5001
+ recordAuditEvent({
5002
+ toolName,
5003
+ toolInput,
5004
+ decision: 'warn',
5005
+ gateId: 'agent-identity-lifecycle',
5006
+ message,
5007
+ severity: 'high',
5008
+ source: 'gates-engine',
5009
+ });
5010
+ }
5011
+ return null;
5012
+ }
5013
+ if (!registryRow && !AGENT_IDENTITY_WARNED.has(`shadow:${agentId}`)) {
5014
+ AGENT_IDENTITY_WARNED.add(`shadow:${agentId}`);
5015
+ recordAuditEvent({
5016
+ toolName,
5017
+ toolInput,
5018
+ decision: 'warn',
5019
+ gateId: 'agent-identity-shadow',
5020
+ message: `Shadow agent: "${agentId}" is acting but has never been registered in the agent registry. `
5021
+ + 'Register it via registerAgent so identity, lifecycle, and audit attribution are explicit.',
5022
+ severity: 'medium',
5023
+ source: 'gates-engine',
5024
+ });
5025
+ }
5026
+ return null;
5027
+ } catch {
5028
+ return null;
5029
+ }
5030
+ }
5031
+
4289
5032
  module.exports = {
5033
+ evaluateAgentIdentityLifecycleGate,
4290
5034
  loadGatesConfig,
4291
5035
  loadState,
4292
5036
  saveState,
@@ -4299,6 +5043,8 @@ module.exports = {
4299
5043
  isTaskScopeExpired,
4300
5044
  TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
4301
5045
  applyEnforcementPosture,
5046
+ resolveGovernanceMode,
5047
+ alignmentLayerForResult,
4302
5048
  buildTaskScopeViolation,
4303
5049
  setBranchGovernance,
4304
5050
  approveProtectedAction,
@@ -4317,8 +5063,17 @@ module.exports = {
4317
5063
  matchesGate,
4318
5064
  evaluateGates,
4319
5065
  evaluateGatesAsync,
5066
+ actionApprovalDigest,
4320
5067
  buildMatchSurfaces,
4321
5068
  extractAffectedFiles,
5069
+ extractGitMinusCPaths,
5070
+ resolveRepoRoot,
5071
+ governanceStatePath,
5072
+ currentScopeSessionId,
5073
+ isRemedyToolName,
5074
+ isCommandPositionPermissionChange,
5075
+ isGhApiPrCreateCommand,
5076
+ helperBypassActionKey,
4322
5077
  parseGitPathspec,
4323
5078
  canonicalizeGitCommand,
4324
5079
  canonicalizeCommandForGates,
@@ -4376,6 +5131,7 @@ module.exports = {
4376
5131
  evaluateLocalOnlyRemoteSideEffectGate,
4377
5132
  recordHelperScriptWrite,
4378
5133
  evaluateStatefulHelperBypassGate,
5134
+ evaluateStealthMemoryInjection,
4379
5135
  isAgentHookSettingsFile,
4380
5136
  isBreakGlassSettingsRecoveryAction,
4381
5137
  PR_THREAD_RESOLUTION_ACTION,
@@ -4384,6 +5140,8 @@ module.exports = {
4384
5140
  applyDailyBlockCap,
4385
5141
  getTodayBlockCount,
4386
5142
  incrementTodayBlockCount,
5143
+ effectiveCommandCwd,
5144
+ resolveRepoRoot,
4387
5145
  };
4388
5146
 
4389
5147
  // ---------------------------------------------------------------------------