thumbgate 1.34.3 → 1.37.1

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 (200) hide show
  1. package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
  2. package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
  3. package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
  4. package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
  5. package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
  6. package/.claude-plugin/plugin.json +1 -1
  7. package/.well-known/llms.txt +1 -0
  8. package/.well-known/mcp/server-card.json +1 -1
  9. package/CONTRIBUTING.md +95 -0
  10. package/README.md +195 -632
  11. package/THIRD_PARTY_NOTICES.md +89 -0
  12. package/adapters/claude/.mcp.json +2 -2
  13. package/adapters/forge/forge.yaml +3 -3
  14. package/adapters/future-agi/.mcp.json +8 -0
  15. package/adapters/future-agi/FUTURE_AGI.md +23 -0
  16. package/adapters/future-agi/config.toml +3 -0
  17. package/adapters/future-agi/future-agi-bridge.js +9 -0
  18. package/adapters/future-agi/opencode.json +8 -0
  19. package/adapters/herdr/herdr-plugin.toml +18 -0
  20. package/adapters/mcp/server-stdio.js +238 -25
  21. package/adapters/opencode/opencode.json +1 -1
  22. package/adapters/workos/WORKOS.md +52 -0
  23. package/bin/cli.js +373 -5
  24. package/bin/futureagi-bridge +9 -0
  25. package/config/gate-templates.json +653 -4
  26. package/config/gates/actor-critic-audit.json +34 -0
  27. package/config/gates/default.json +21 -2
  28. package/config/gates/five-walls-governance.json +34 -0
  29. package/config/gates/future-agi-guardrails.json +34 -0
  30. package/config/gates/radware-threat-defense-2026.json +61 -0
  31. package/config/gates/simatree-data-governance.json +33 -0
  32. package/config/mcp-allowlists.json +4 -0
  33. package/config/merge-quality-checks.json +10 -1
  34. package/config/model-candidates.json +382 -24
  35. package/config/model-tiers.json +18 -0
  36. package/config/post-deploy-marketing-pages.json +10 -0
  37. package/config/progressive/01-wire-only.json +11 -0
  38. package/config/progressive/02-dashboard-empty-ok.json +10 -0
  39. package/config/progressive/03-one-lesson.json +10 -0
  40. package/config/progressive/04-warn-fires.json +11 -0
  41. package/config/progressive/05-strict-optional.json +11 -0
  42. package/config/progressive/README.md +15 -0
  43. package/config/schemas/broker-execution-receipt.schema.json +139 -0
  44. package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
  45. package/conformance/provider-attestation/vectors.json +320 -0
  46. package/docs/specs/provider-execution-attestation-v1.md +69 -0
  47. package/openapi/openapi.yaml +15 -0
  48. package/package.json +401 -147
  49. package/public/about.html +2 -2
  50. package/public/ai-malpractice-prevention.html +7 -7
  51. package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
  52. package/public/blog/a-receipt-is-not-world-state.html +388 -0
  53. package/public/blog/git-at-agent-scale.html +374 -0
  54. package/public/blog/no-llm-in-the-gate.html +133 -0
  55. package/public/blog.html +80 -0
  56. package/public/case-studies.html +16 -1
  57. package/public/compare.html +28 -0
  58. package/public/diagnostic.html +216 -7
  59. package/public/docs/connectors.html +39 -0
  60. package/public/federal.html +2 -2
  61. package/public/founders.html +639 -0
  62. package/public/index.html +87 -9
  63. package/public/install.html +8 -8
  64. package/public/learn.html +39 -0
  65. package/public/numbers.html +2 -2
  66. package/public/peter.html +310 -0
  67. package/public/platform-partners.html +119 -0
  68. package/public/pricing.html +24 -3
  69. package/public/privacy.html +117 -0
  70. package/public/pro.html +17 -0
  71. package/public/support.html +62 -0
  72. package/public/terms.html +130 -0
  73. package/public/third-party-notices.html +95 -0
  74. package/public/yt.html +351 -0
  75. package/scripts/action-receipts.js +133 -3
  76. package/scripts/adaptive-governance-arena.js +349 -0
  77. package/scripts/admin-override.js +205 -0
  78. package/scripts/agent-action-inventory.js +869 -0
  79. package/scripts/agent-audit-trace.js +42 -2
  80. package/scripts/agent-egress-policy.js +1117 -0
  81. package/scripts/agent-memory-lifecycle.js +141 -2
  82. package/scripts/agent-operations-planner.js +441 -1
  83. package/scripts/agent-readiness.js +68 -0
  84. package/scripts/agent-security-central.js +647 -0
  85. package/scripts/allowlist-bridge-honesty.js +417 -0
  86. package/scripts/async-job-runner.js +102 -11
  87. package/scripts/audit-trail.js +212 -0
  88. package/scripts/auto-promote-gates.js +178 -27
  89. package/scripts/billing.js +1 -1
  90. package/scripts/broker-execution-receipts.js +719 -0
  91. package/scripts/budget-aware-gates-proof.js +423 -0
  92. package/scripts/claude-feedback-sync.js +29 -3
  93. package/scripts/claw-harness-production.js +237 -0
  94. package/scripts/cli-progress.js +111 -0
  95. package/scripts/cli-schema.js +163 -1
  96. package/scripts/codex-runbook-flywheel.js +318 -0
  97. package/scripts/context-footprint.js +186 -0
  98. package/scripts/contextfs.js +143 -61
  99. package/scripts/dashboard-limits.js +27 -0
  100. package/scripts/dashboard.js +279 -9
  101. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  102. package/scripts/docker-sandbox-planner.js +18 -0
  103. package/scripts/double-blind-eval-protocol.js +252 -0
  104. package/scripts/edotenv-rl-gateway.js +259 -0
  105. package/scripts/ensure-production-search-corpus.js +162 -0
  106. package/scripts/eval-holdout.js +311 -0
  107. package/scripts/feedback-aggregate.js +21 -2
  108. package/scripts/feedback-loop.js +87 -5
  109. package/scripts/feedback-quality.js +9 -0
  110. package/scripts/file-ledger-lock.js +4 -1
  111. package/scripts/financial-control-plane.js +41 -1
  112. package/scripts/find-dormant-requires.js +118 -0
  113. package/scripts/fs-utils.js +84 -8
  114. package/scripts/gate-stats.js +2 -2
  115. package/scripts/gates-engine.js +859 -58
  116. package/scripts/generate-case-study-outreach.js +24 -15
  117. package/scripts/git-at-scale.js +628 -0
  118. package/scripts/governance-conflict-audit.js +1650 -0
  119. package/scripts/governance-difficulty-curriculum.js +328 -0
  120. package/scripts/graphrag-retrieval.js +275 -0
  121. package/scripts/gurobi-optimizer.js +324 -0
  122. package/scripts/gurobi_optimizer.py +485 -0
  123. package/scripts/harness-selector.js +82 -1
  124. package/scripts/hidden-entry-points.js +284 -0
  125. package/scripts/human-escalation.js +199 -1
  126. package/scripts/hybrid-feedback-context.js +152 -19
  127. package/scripts/intent-governed-execution.js +602 -0
  128. package/scripts/intervention-policy.js +123 -20
  129. package/scripts/jit-harness-compose.js +628 -0
  130. package/scripts/jsonl-watcher.js +10 -0
  131. package/scripts/lesson-embedding-index.js +95 -12
  132. package/scripts/lesson-retrieval.js +105 -19
  133. package/scripts/local-model-profile.js +19 -2
  134. package/scripts/mailer/resend-mailer.js +1 -1
  135. package/scripts/matryoshka-embedding.js +235 -0
  136. package/scripts/mcp-oauth.js +42 -4
  137. package/scripts/mcp-session-handles.js +1016 -0
  138. package/scripts/mcp-wiring-doctor.js +314 -0
  139. package/scripts/memory-firewall.js +115 -2
  140. package/scripts/memory-scope-readiness.js +299 -0
  141. package/scripts/memory-vs-rag-route.js +161 -0
  142. package/scripts/model-tier-router.js +148 -21
  143. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  144. package/scripts/openui-catalog-compose-honesty.js +593 -0
  145. package/scripts/operational-integrity.js +19 -1
  146. package/scripts/override-audit.js +213 -0
  147. package/scripts/package-manager-honesty-doctor.js +458 -0
  148. package/scripts/pr-manager.js +63 -1
  149. package/scripts/prove-herdr-adapter.js +52 -0
  150. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  151. package/scripts/prove-workos.js +73 -0
  152. package/scripts/provider-attestation-conformance.js +192 -0
  153. package/scripts/provider-receipt-contract.js +136 -0
  154. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  155. package/scripts/radware-threat-defense.js +280 -0
  156. package/scripts/rag-embedding-identity.js +221 -0
  157. package/scripts/rag-precision-guardrails.js +112 -2
  158. package/scripts/remote-feedback-capture.js +159 -0
  159. package/scripts/research-agent-harness.js +256 -0
  160. package/scripts/rsi-safety-hillclimb.js +200 -0
  161. package/scripts/rule-sprawl.js +188 -0
  162. package/scripts/schedule-manager.js +147 -0
  163. package/scripts/self-heal.js +8 -0
  164. package/scripts/session-lease.js +415 -0
  165. package/scripts/simatree-data-governance.js +347 -0
  166. package/scripts/slo-alert-engine.js +172 -7
  167. package/scripts/solver-parity.js +539 -0
  168. package/scripts/stealth-memory-injection-gate.js +333 -0
  169. package/scripts/switchyard-router.js +366 -0
  170. package/scripts/telemetry-analytics.js +84 -27
  171. package/scripts/temporal-decay-weighting.js +138 -0
  172. package/scripts/test-all.js +165 -0
  173. package/scripts/token-savings.js +42 -0
  174. package/scripts/tool-kpi-tracker.js +108 -5
  175. package/scripts/tool-registry.js +193 -5
  176. package/scripts/universal-claim-evaluator.js +14 -2
  177. package/scripts/vector-store.js +279 -9
  178. package/scripts/workflow-notebook.js +391 -0
  179. package/scripts/workflow-sentinel.js +111 -12
  180. package/scripts/workos-production-guard.js +260 -0
  181. package/scripts/workspace-search-route.js +515 -0
  182. package/server.json +2 -2
  183. package/src/agent-identity-boundary.js +76 -0
  184. package/src/agent-retrieval-cache.js +155 -0
  185. package/src/alert-noise-ledger.js +502 -0
  186. package/src/api/server.js +802 -185
  187. package/src/git-fast-cache.js +220 -0
  188. package/src/git-wal-sync.js +156 -0
  189. package/src/hash-anchored-edit.js +82 -0
  190. package/src/hermes-platform-protocol.js +475 -0
  191. package/src/hermes-sync-plane.js +241 -0
  192. package/src/index.js +30 -1
  193. package/src/iso42001-compliance-guard.js +97 -0
  194. package/src/latency-budget.js +244 -0
  195. package/src/mcp-writeguard.js +316 -0
  196. package/src/miminions-adapter.js +106 -0
  197. package/src/pipeline-compass.js +104 -0
  198. package/src/ppl-alert-pipeline.js +284 -0
  199. package/src/rendezvous-router.js +90 -0
  200. 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',
@@ -168,6 +186,10 @@ const UNCONDITIONAL_HARD_FLOOR_GATE_IDS = new Set([
168
186
  // applyEnforcementPosture, never demote spend blocks to warn-by-default.
169
187
  // Apollo $588 incident class (2026-08).
170
188
  'financial-control',
189
+ // Outbound email: irreversible delivery. Keep the hard gate intact until a
190
+ // separately authenticated admin override bound to the exact action digest
191
+ // authorizes one retry (AGENT-259 / District Cyber 2026-08-04).
192
+ 'outbound-email-send',
171
193
  TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
172
194
  ...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
173
195
  ]);
@@ -188,6 +210,8 @@ const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
188
210
  'git-clean-force',
189
211
  'rm-rf-home-or-root',
190
212
  'financial-control',
213
+ // Never daily-cap discount a denied autonomous email send (same class as force-push).
214
+ 'outbound-email-send',
191
215
  ]);
192
216
  const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
193
217
  const SELF_PROTECT_HOOK_TARGET_PATTERN = /(?:^|\/)(?:\.claude\/settings(?:\.local)?\.json|\.codex\/config\.toml|scripts\/hook-[^/]+\.(?:js|sh))$/i;
@@ -199,21 +223,76 @@ function isSelfProtectGate(gateId) {
199
223
  return SELF_PROTECT_HARD_FLOOR_GATE_IDS.has(gateId);
200
224
  }
201
225
 
226
+ /**
227
+ * Name the existing warn-by-default vs strict postures in Trustwise Control Tower
228
+ * vocabulary (live / sidecar / simulation / batch) without cloning their product.
229
+ * Default remains sidecar (= warn-by-default). Hard floors never demote.
230
+ */
231
+ function resolveGovernanceMode(env = process.env) {
232
+ // Strict opt-in wins over inherited simulation/batch. Hermes CLI sets
233
+ // THUMBGATE_STRICT_ENFORCEMENT=1 internally; a leftover shadow mode in the
234
+ // process env must not demote those denials.
235
+ if (env.THUMBGATE_STRICT_ENFORCEMENT === '1') return 'live';
236
+ const raw = String(env.THUMBGATE_GOVERNANCE_MODE || '').trim().toLowerCase();
237
+ if (raw === 'simulation' || raw === 'batch') return raw;
238
+ if (raw === 'live') return 'live';
239
+ return 'sidecar';
240
+ }
241
+
242
+ function alignmentLayerForResult(result) {
243
+ const gate = String(result && result.gate || '');
244
+ if (
245
+ UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(gate)
246
+ || CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate)
247
+ || /secret|self-protect|exfil/i.test(gate)
248
+ ) {
249
+ return 'safety';
250
+ }
251
+ if (/task-scope|local-only|pr_thread|sla/i.test(gate)) return 'sla';
252
+ return 'business';
253
+ }
254
+
202
255
  function applyEnforcementPosture(result) {
203
256
  if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
257
+ const mode = resolveGovernanceMode();
258
+ const alignmentLayer = alignmentLayerForResult(result);
204
259
  // Defensive backstop: hard-floor results must never be posture-downgraded.
205
- if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate)) return result;
260
+ if (UNCONDITIONAL_HARD_FLOOR_GATE_IDS.has(result.gate)) {
261
+ return { ...result, governanceMode: mode, alignmentLayer };
262
+ }
263
+ // Simulation/batch shadow live traffic: record, do not block (except floors).
264
+ // Only denials are shadowed — allow/approve stay as-is.
265
+ if (mode === 'simulation' || mode === 'batch') {
266
+ if (result.decision === 'deny') {
267
+ return {
268
+ ...result,
269
+ decision: 'warn',
270
+ warnByDefault: true,
271
+ governanceMode: mode,
272
+ alignmentLayer,
273
+ simulated: true,
274
+ message: `${result.message}\n\n⚠️ ThumbGate governance mode=${mode} — flagged and logged, not blocked.`,
275
+ };
276
+ }
277
+ return { ...result, governanceMode: mode, alignmentLayer, simulated: true };
278
+ }
206
279
  // Full hard enforcement opt-in: keep every deny.
207
- if (process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') return result;
280
+ if (mode === 'live' || process.env.THUMBGATE_STRICT_ENFORCEMENT === '1') {
281
+ return { ...result, governanceMode: 'live', alignmentLayer };
282
+ }
208
283
  // Honor the explicit strict-knowledge-conflict opt-in for that gate.
209
- if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') return result;
210
- // Warn-by-default: the gate still fired and is recorded; the action is allowed through
284
+ if (process.env.THUMBGATE_STRICT_KNOWLEDGE_CONFLICT === '1' && result.gate === 'knowledge-conflict-gate') {
285
+ return { ...result, governanceMode: mode, alignmentLayer };
286
+ }
287
+ // Sidecar / warn-by-default: the gate still fired and is recorded; the action is allowed through
211
288
  // with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
212
289
  return {
213
290
  ...result,
214
291
  decision: 'warn',
215
292
  warnByDefault: true,
216
- 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.`,
293
+ governanceMode: 'sidecar',
294
+ alignmentLayer,
295
+ 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.`,
217
296
  };
218
297
  }
219
298
  const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
@@ -256,19 +335,51 @@ function commandContainsSequence(words, sequence) {
256
335
  }
257
336
 
258
337
  function commandHasPostMethod(words) {
259
- for (let i = 0; i < words.length; i += 1) {
260
- const word = words[i];
261
- if ((word === '-x' || word === '--method') && words[i + 1] === 'post') return true;
262
- if (word === '--method=post' || word === '-xpost') return true;
338
+ return ghApiHttpMethod(words) === 'post';
339
+ }
340
+
341
+ function ghApiHttpMethod(words) {
342
+ const list = Array.isArray(words) ? words : [];
343
+ for (let i = 0; i < list.length; i += 1) {
344
+ const word = list[i];
345
+ if ((word === '-x' || word === '--method') && list[i + 1]) return String(list[i + 1]).toLowerCase();
346
+ if (word.startsWith('--method=')) return word.slice('--method='.length).toLowerCase();
347
+ if (word.startsWith('-x') && word.length > 2 && !word.startsWith('-x=')) {
348
+ return word.slice(2).toLowerCase();
349
+ }
263
350
  }
264
- return false;
351
+ return null;
352
+ }
353
+
354
+ function ghApiEndpoint(words) {
355
+ const list = Array.isArray(words) ? words : [];
356
+ const apiIndex = list.findIndex((word, i) => word === 'api' && list[i - 1] === 'gh');
357
+ if (apiIndex < 0) return null;
358
+ const flagsWithValue = new Set([
359
+ '-x', '--method', '-f', '--field', '-F', '--raw-field',
360
+ '-h', '--header', '--hostname', '--jq', '--input', '--cache',
361
+ ]);
362
+ for (let i = apiIndex + 1; i < list.length; i += 1) {
363
+ const word = list[i];
364
+ if (word.startsWith('-')) {
365
+ if (word.includes('=')) continue;
366
+ if (flagsWithValue.has(word)) i += 1;
367
+ continue;
368
+ }
369
+ return word;
370
+ }
371
+ return null;
265
372
  }
266
373
 
267
374
  function isGhApiPrCreateCommand(command) {
268
375
  const words = commandWords(command);
269
376
  if (!commandContainsSequence(words, ['gh', 'api'])) return false;
270
- const hasPullsEndpoint = words.some((word) => word === '/pulls' || word.endsWith('/pulls'));
271
- if (!hasPullsEndpoint) return false;
377
+ const method = ghApiHttpMethod(words);
378
+ if (method && method !== 'post') return false;
379
+ const endpoint = ghApiEndpoint(words);
380
+ if (!endpoint) return false;
381
+ if (/\/pulls\/\d+/.test(endpoint)) return false;
382
+ if (!(endpoint === '/pulls' || /\/pulls$/.test(endpoint))) return false;
272
383
  const fieldFlags = new Set(['-f', '--field', '--raw-field']);
273
384
  const hasFieldWrite = words.some((word) => (
274
385
  fieldFlags.has(word) ||
@@ -467,8 +578,39 @@ function isTaskScopeExpired(taskScope, nowMs = Date.now()) {
467
578
  return nowMs >= deadline;
468
579
  }
469
580
 
470
- function loadGovernanceState() {
471
- const raw = loadJSON(module.exports.GOVERNANCE_STATE_PATH);
581
+ function currentScopeSessionId(explicitSessionId = null) {
582
+ if (explicitSessionId) return String(explicitSessionId).trim();
583
+ const raw = String(
584
+ process.env.THUMBGATE_SESSION_AGENT
585
+ || process.env.THUMBGATE_SESSION_ID
586
+ || process.env.CLAUDE_SESSION_ID
587
+ || ''
588
+ ).trim();
589
+ return raw || null;
590
+ }
591
+
592
+ function sanitizeScopeSessionId(sessionId) {
593
+ if (!sessionId) return '';
594
+ const hash = crypto.createHash('sha256').update(String(sessionId)).digest('hex').slice(0, 16);
595
+ const prefix = String(sessionId).replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 32);
596
+ return prefix ? `${prefix}-${hash}` : hash;
597
+ }
598
+
599
+ // Sibling agents share ~/.thumbgate/governance-state.json today, so one
600
+ // set_task_scope rebinds every other live session (#3522). When a session id
601
+ // is present, persist a per-session file next to the legacy slot.
602
+ function governanceStatePath(explicitSessionId = null) {
603
+ const base = module.exports.GOVERNANCE_STATE_PATH;
604
+ const sessionId = currentScopeSessionId(explicitSessionId);
605
+ if (!sessionId) return base;
606
+ const safe = sanitizeScopeSessionId(sessionId);
607
+ if (!safe) return base;
608
+ const parsed = path.parse(base);
609
+ return path.join(parsed.dir, `${parsed.name}.${safe}${parsed.ext}`);
610
+ }
611
+
612
+ function loadGovernanceState(explicitSessionId = null) {
613
+ const raw = loadJSON(governanceStatePath(explicitSessionId));
472
614
  const state = {
473
615
  taskScope: raw && raw.taskScope && typeof raw.taskScope === 'object' ? raw.taskScope : null,
474
616
  protectedApprovals: Array.isArray(raw && raw.protectedApprovals) ? raw.protectedApprovals : [],
@@ -489,35 +631,142 @@ function loadGovernanceState() {
489
631
  const activeApprovals = state.protectedApprovals.filter((entry) => {
490
632
  if (!entry || typeof entry !== 'object') return false;
491
633
  if (!entry.timestamp || !entry.expiresAt) return false;
492
- return now < entry.expiresAt;
634
+ return entry.expiresAt > now;
493
635
  });
494
- if (activeApprovals.length !== state.protectedApprovals.length) {
495
- state.protectedApprovals = activeApprovals;
496
- saveGovernanceState(state);
497
- }
636
+ state.protectedApprovals = activeApprovals;
498
637
  return state;
499
638
  }
500
639
 
501
- function saveGovernanceState(state) {
640
+ function saveGovernanceState(state, explicitSessionId = null) {
502
641
  const next = {
503
642
  taskScope: state && state.taskScope ? state.taskScope : null,
504
643
  protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
505
644
  branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
506
645
  workflowContract: state && state.workflowContract ? state.workflowContract : null,
507
646
  };
508
- saveJSON(module.exports.GOVERNANCE_STATE_PATH, next);
647
+ saveJSON(governanceStatePath(explicitSessionId), next);
648
+ }
649
+
650
+ function stableCanonicalStringify(value) {
651
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
652
+ if (Array.isArray(value)) return `[${value.map(stableCanonicalStringify).join(',')}]`;
653
+ return `{${Object.keys(value).sort().map((key) => (
654
+ `${JSON.stringify(key)}:${stableCanonicalStringify(value[key])}`
655
+ )).join(',')}}`;
656
+ }
657
+
658
+ function actionApprovalDigest(toolName, toolInput) {
659
+ return crypto.createHash('sha256').update(stableCanonicalStringify({
660
+ toolInput: toolInput && typeof toolInput === 'object' ? toolInput : {},
661
+ toolName: String(toolName || ''),
662
+ })).digest('hex');
663
+ }
664
+
665
+ function adminOverrideTaskId(gateId, digest) {
666
+ return `admin-override:${gateId}:${digest}`;
667
+ }
668
+
669
+ function selectAdminOverrideAttempt(baseTaskId) {
670
+ const attempts = listEscalations().filter((entry) => (
671
+ entry.taskId === baseTaskId || String(entry.taskId || '').startsWith(`${baseTaskId}:attempt:`)
672
+ ));
673
+ const latest = attempts[0] || null;
674
+ if (!latest) return baseTaskId;
675
+ const terminal = ['rejected', 'cancelled', 'expired'].includes(latest.status)
676
+ || latest.eventType === 'consumed'
677
+ || Date.parse(latest.expiresAt) <= Date.now();
678
+ if (!terminal) return latest.taskId;
679
+ const highestAttempt = attempts.reduce((highest, entry) => {
680
+ const match = String(entry.taskId || '').match(/:attempt:(\d+)$/);
681
+ return Math.max(highest, match ? Number(match[1]) : 1);
682
+ }, 1);
683
+ return `${baseTaskId}:attempt:${highestAttempt + 1}`;
684
+ }
685
+
686
+ function evaluateAdminOverride(gate, toolName, toolInput) {
687
+ const policy = gate && gate.adminOverride;
688
+ if (!policy || policy.required !== true) return null;
689
+
690
+ const approvalContextDigest = actionApprovalDigest(toolName, toolInput);
691
+ const baseTaskId = adminOverrideTaskId(gate.id, approvalContextDigest);
692
+ let taskId;
693
+ try {
694
+ taskId = selectAdminOverrideAttempt(baseTaskId);
695
+ } catch {
696
+ return {
697
+ authorized: false,
698
+ approvalContextDigest,
699
+ escalationId: null,
700
+ taskId: baseTaskId,
701
+ approvalUnavailable: true,
702
+ };
703
+ }
704
+ const ttlMs = Math.min(
705
+ 60 * 60 * 1000,
706
+ Math.max(60 * 1000, Number(policy.ttlMs) || DEFAULT_ADMIN_OVERRIDE_TTL_MS),
707
+ );
708
+ let escalation;
709
+ try {
710
+ escalation = requestEscalation({
711
+ taskId,
712
+ reason: `Admin override required for hard gate '${gate.id}'.`,
713
+ severity: gate.severity || 'critical',
714
+ requester: { id: 'thumbgate-gates-engine', kind: 'service' },
715
+ evidence: [`sha256:${approvalContextDigest}`],
716
+ ttlMs,
717
+ idempotencyKey: taskId,
718
+ approvalContextDigest,
719
+ requiredReviewerRole: 'admin',
720
+ });
721
+ } catch {
722
+ return {
723
+ authorized: false,
724
+ approvalContextDigest,
725
+ escalationId: null,
726
+ taskId,
727
+ approvalUnavailable: true,
728
+ };
729
+ }
730
+ const escalationId = escalation.escalation.escalationId;
731
+ let consumption = null;
732
+ try {
733
+ consumption = consumeVerifiedApproval(escalationId, {
734
+ consumer: { id: 'thumbgate-gates-engine', kind: 'service' },
735
+ });
736
+ } catch {
737
+ consumption = null;
738
+ }
739
+
740
+ if (!consumption || !consumption.consumed) {
741
+ return {
742
+ authorized: false,
743
+ approvalContextDigest,
744
+ escalationId,
745
+ taskId,
746
+ replayed: consumption?.replayed === true,
747
+ };
748
+ }
749
+ return {
750
+ authorized: true,
751
+ approvalContextDigest,
752
+ escalationId,
753
+ taskId,
754
+ approver: consumption.approval.actor,
755
+ consumptionReceipt: consumption.consumption.eventHash,
756
+ };
509
757
  }
510
758
 
511
759
  function setTaskScope(scopeInput = {}) {
760
+ const explicitSessionId = scopeInput && scopeInput.sessionId ? String(scopeInput.sessionId).trim() : null;
512
761
  if (scopeInput && scopeInput.clear === true) {
513
- const currentState = loadGovernanceState();
762
+ const currentState = loadGovernanceState(explicitSessionId);
514
763
  const cleared = {
515
764
  taskScope: null,
516
765
  protectedApprovals: currentState.protectedApprovals,
517
766
  branchGovernance: currentState.branchGovernance,
518
767
  workflowContract: null,
519
768
  };
520
- saveGovernanceState(cleared);
769
+ saveGovernanceState(cleared, explicitSessionId);
521
770
  refreshLocalOnlyConstraint(cleared);
522
771
  return null;
523
772
  }
@@ -540,6 +789,7 @@ function setTaskScope(scopeInput = {}) {
540
789
  const leaseMs = scopeInput.ttlMs == null ? null : clampTtlMs(scopeInput.ttlMs, TASK_SCOPE_LEASE_MS);
541
790
  const taskScope = {
542
791
  taskId: String(scopeInput.taskId || '').trim() || null,
792
+ sessionId: currentScopeSessionId(explicitSessionId),
543
793
  summary: String(scopeInput.summary || '').trim() || null,
544
794
  allowedPaths,
545
795
  protectedPaths,
@@ -550,12 +800,12 @@ function setTaskScope(scopeInput = {}) {
550
800
  leaseMs,
551
801
  expiresAt: leaseMs == null ? null : scopeNow + leaseMs,
552
802
  };
553
- const state = loadGovernanceState();
803
+ const state = loadGovernanceState(explicitSessionId);
554
804
  state.taskScope = taskScope;
555
805
  state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
556
806
  ? scopeInput.workflowContract
557
807
  : null;
558
- saveGovernanceState(state);
808
+ saveGovernanceState(state, explicitSessionId);
559
809
  if (taskScope.localOnly) {
560
810
  setConstraint('local_only', true);
561
811
  }
@@ -601,7 +851,14 @@ function breakGlassEmergency(input = {}) {
601
851
  const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
602
852
  const satisfied = {};
603
853
  for (const gateId of gates) {
604
- satisfied[gateId] = satisfyCondition(gateId, evidence);
854
+ // Break glass is the highest-scrutiny unlock there is. Without an explicit
855
+ // source these records defaulted to 'cli', hiding emergency overrides among
856
+ // ordinary ones — the exact case an auditor looks for first.
857
+ satisfied[gateId] = satisfyCondition(gateId, evidence, null, {
858
+ source: 'break-glass',
859
+ actor: input.actor || 'operator',
860
+ reason,
861
+ });
605
862
  }
606
863
 
607
864
  const approval = approveProtectedAction({
@@ -660,8 +917,9 @@ function setBranchGovernance(input = {}) {
660
917
  return governance;
661
918
  }
662
919
 
663
- function getScopeState() {
664
- return loadGovernanceState();
920
+ function getScopeState(options = {}) {
921
+ const sessionId = typeof options === 'string' ? options : (options?.sessionId || process.env.THUMBGATE_SESSION_AGENT || null);
922
+ return loadGovernanceState(sessionId);
665
923
  }
666
924
 
667
925
  function getBranchGovernanceState() {
@@ -704,7 +962,7 @@ function isConditionSatisfied(conditionId) {
704
962
  return age < TTL_MS;
705
963
  }
706
964
 
707
- function satisfyCondition(conditionId, evidence, structuredReasoning) {
965
+ function satisfyCondition(conditionId, evidence, structuredReasoning, options = {}) {
708
966
  const state = loadState();
709
967
  const entry = {
710
968
  timestamp: Date.now(),
@@ -720,6 +978,28 @@ function satisfyCondition(conditionId, evidence, structuredReasoning) {
720
978
  }
721
979
  state[conditionId] = entry;
722
980
  saveState(state);
981
+
982
+ // Satisfying a gate condition IS an override: it unlocks an action the gate
983
+ // had blocked. Previously this wrote only to the state store, so an override
984
+ // performed through the CLI left no trace in the audit trail at all — the
985
+ // least-supervised path was also the least-recorded. Record it explicitly.
986
+ // Never let an audit failure break the unlock the caller is relying on.
987
+ try {
988
+ // Lazy require: override-audit depends on audit-trail, which this module
989
+ // already loads; requiring at call time avoids any circular-init surprise.
990
+ const { recordOverride } = require('./override-audit');
991
+ recordOverride({
992
+ gateId: conditionId,
993
+ source: options.source || 'cli',
994
+ actor: options.actor,
995
+ reason: options.reason,
996
+ evidence: entry.evidence,
997
+ structuredReasoning: entry.structuredReasoning,
998
+ });
999
+ } catch {
1000
+ /* audit is best-effort; the gate state is authoritative */
1001
+ }
1002
+
723
1003
  return entry;
724
1004
  }
725
1005
 
@@ -900,14 +1180,111 @@ function safeExecFileLines(binary, args, cwd) {
900
1180
  }
901
1181
  }
902
1182
 
1183
+ function extractGitMinusCPaths(command) {
1184
+ const found = [];
1185
+ const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
1186
+ for (const segment of segments) {
1187
+ const tokens = tokenizeShellWords(segment);
1188
+ const gitIdx = tokens.findIndex((token) => token === 'git' || /(?:^|\/)git$/.test(token));
1189
+ if (gitIdx === -1) continue;
1190
+ for (let i = gitIdx + 1; i < tokens.length; i += 1) {
1191
+ const token = tokens[i];
1192
+ if (token === '-C' && tokens[i + 1]) {
1193
+ found.push(tokens[i + 1]);
1194
+ i += 1;
1195
+ continue;
1196
+ }
1197
+ if (token === '--work-tree' && tokens[i + 1]) {
1198
+ found.push(tokens[i + 1]);
1199
+ i += 1;
1200
+ continue;
1201
+ }
1202
+ if (token.startsWith('--work-tree=')) {
1203
+ found.push(token.slice('--work-tree='.length));
1204
+ continue;
1205
+ }
1206
+ if (!token.startsWith('-')) break;
1207
+ }
1208
+ }
1209
+ return found;
1210
+ }
1211
+
1212
+ function extractGitContextPair(command) {
1213
+ const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
1214
+ for (const segment of segments) {
1215
+ const tokens = tokenizeShellWords(segment);
1216
+ const gitIdx = tokens.findIndex((token) => token === 'git' || /(?:^|\/)git$/.test(token));
1217
+ if (gitIdx === -1) continue;
1218
+ let gitDir = null;
1219
+ let workTree = null;
1220
+ for (let i = gitIdx + 1; i < tokens.length; i += 1) {
1221
+ const token = tokens[i];
1222
+ if (token === '--git-dir' && tokens[i + 1]) {
1223
+ gitDir = tokens[i + 1];
1224
+ i += 1;
1225
+ continue;
1226
+ }
1227
+ if (token.startsWith('--git-dir=')) {
1228
+ gitDir = token.slice('--git-dir='.length);
1229
+ continue;
1230
+ }
1231
+ if (token === '--work-tree' && tokens[i + 1]) {
1232
+ workTree = tokens[i + 1];
1233
+ i += 1;
1234
+ continue;
1235
+ }
1236
+ if (token.startsWith('--work-tree=')) {
1237
+ workTree = token.slice('--work-tree='.length);
1238
+ continue;
1239
+ }
1240
+ if (token === '-C' && tokens[i + 1]) {
1241
+ workTree = tokens[i + 1];
1242
+ i += 1;
1243
+ continue;
1244
+ }
1245
+ if (!token.startsWith('-')) break;
1246
+ }
1247
+ if (gitDir || workTree) return { gitDir, workTree };
1248
+ }
1249
+ return null;
1250
+ }
1251
+
903
1252
  function resolveRepoRoot(toolInput = {}) {
904
- const candidates = [
1253
+ const command = String(toolInput.command || '');
1254
+ const baseCwd = toolInput.cwd ? path.resolve(String(toolInput.cwd)) : process.cwd();
1255
+ const paired = extractGitContextPair(command);
1256
+ if (paired && paired.gitDir && paired.workTree) {
1257
+ try {
1258
+ const resolvedGitDir = path.resolve(baseCwd, paired.gitDir);
1259
+ const resolvedWorkTree = path.resolve(baseCwd, paired.workTree);
1260
+ const root = execFileSync('git', ['--git-dir', resolvedGitDir, '--work-tree', resolvedWorkTree, 'rev-parse', '--show-toplevel'], {
1261
+ cwd: resolvedWorkTree,
1262
+ encoding: 'utf8',
1263
+ stdio: ['ignore', 'pipe', 'ignore'],
1264
+ }).trim();
1265
+ if (root) return root;
1266
+ } catch {
1267
+ // Fall through to standard candidate probing
1268
+ }
1269
+ }
1270
+
1271
+ const minusC = extractGitMinusCPaths(command).map((entry) => path.resolve(baseCwd, entry));
1272
+ const commandCwd = effectiveCommandCwd(command, toolInput);
1273
+ const candidates = [];
1274
+ const seen = new Set();
1275
+ for (const value of [
1276
+ ...minusC,
1277
+ commandCwd,
905
1278
  toolInput.repoPath,
906
1279
  toolInput.cwd,
907
1280
  process.cwd(),
908
- ]
909
- .filter(Boolean)
910
- .map((value) => path.resolve(String(value)));
1281
+ ]) {
1282
+ if (!value) continue;
1283
+ const resolved = path.resolve(String(value));
1284
+ if (seen.has(resolved)) continue;
1285
+ seen.add(resolved);
1286
+ candidates.push(resolved);
1287
+ }
911
1288
 
912
1289
  for (const cwd of candidates) {
913
1290
  try {
@@ -1130,7 +1507,17 @@ function parseGitPathspec(command, subcommand, options = {}) {
1130
1507
  // null as "unknown" and fall back to broad.
1131
1508
  function effectiveCommandCwd(command, toolInput) {
1132
1509
  let cwd = String(toolInput?.cwd || toolInput?.repoPath || process.cwd());
1133
- const segments = String(command || '').split(/\r?\n|&&|\|\||[;|&]/);
1510
+ const commandStr = String(command || '');
1511
+ const gitCMatch = commandStr.match(/(?:^|\s)git(?:\s+-[^\s]+)*\s+-C(?:\s+|=)(?:'([^']+)'|"([^"]+)"|([^\s'"]+))/i);
1512
+ const gitCDir = gitCMatch ? (gitCMatch[1] || gitCMatch[2] || gitCMatch[3] || '').trim() : '';
1513
+ if (gitCDir) {
1514
+ let target = gitCDir;
1515
+ if (target.startsWith('~/')) {
1516
+ target = path.join(os.homedir(), target.slice(2));
1517
+ }
1518
+ return path.resolve(cwd, target);
1519
+ }
1520
+ const segments = commandStr.split(/\r?\n|&&|\|\||[;|&]/);
1134
1521
  for (const segment of segments) {
1135
1522
  // Parsed without a regex: /^cd\s+(?:--\s+)?(.+)$/ has adjacent \s+ groups that backtrack
1136
1523
  // polynomially on input like `cd\t\t\t…` (js/polynomial-redos). The command comes
@@ -1144,8 +1531,12 @@ function effectiveCommandCwd(command, toolInput) {
1144
1531
  else if (argText.startsWith('--') && /^[ \t]/.test(argText.slice(2))) argText = argText.slice(2).trim();
1145
1532
  const target = tokenizeShellWords(argText)[0];
1146
1533
  if (!target) break; // bare `cd` -> home; leave scope resolution alone
1147
- if (/[*?$`]|^~/.test(target)) return null;
1148
- cwd = path.resolve(cwd, target);
1534
+ if (/[*?$`]/.test(target)) return null;
1535
+ let targetResolved = target;
1536
+ if (targetResolved.startsWith('~/')) {
1537
+ targetResolved = path.join(os.homedir(), targetResolved.slice(2));
1538
+ }
1539
+ cwd = path.resolve(cwd, targetResolved);
1149
1540
  }
1150
1541
  return cwd;
1151
1542
  }
@@ -1371,8 +1762,10 @@ function extractAffectedFiles(toolName, toolInput = {}) {
1371
1762
  }
1372
1763
 
1373
1764
  if (/\bgit\s+push\b/i.test(command) || /\bgh\s+pr\s+(?:create|merge)\b/i.test(command) || isGhApiPrCreateCommand(command)) {
1374
- for (const filePath of getBranchDiffFiles(repoRoot)) {
1375
- files.add(normalizePosix(filePath));
1765
+ if (files.size === 0) {
1766
+ for (const filePath of getBranchDiffFiles(repoRoot)) {
1767
+ files.add(normalizePosix(filePath));
1768
+ }
1376
1769
  }
1377
1770
  }
1378
1771
  }
@@ -1686,9 +2079,51 @@ function isThreadResolutionSatisfied() {
1686
2079
  ));
1687
2080
  }
1688
2081
 
2082
+ // Hook payloads name MCP tools `mcp__<server>__<tool>` while exemption lists
2083
+ // hold bare tool names. Exemptions must compare on the stripped name or they
2084
+ // never fire for real hook traffic — 2026-08-05: the pending-thread gate
2085
+ // blocked `mcp__thumbgate__satisfy_gate` itself, deadlocking its own
2086
+ // documented escape hatch until the session-actions TTL expired.
2087
+ function bareToolName(toolName) {
2088
+ return String(toolName || '').replace(/^mcp__.+?__/, '');
2089
+ }
2090
+
2091
+ function isRemedyToolName(toolName) {
2092
+ return REMEDY_TOOL_NAMES.has(bareToolName(toolName));
2093
+ }
2094
+
2095
+ // permission-change-approval used to regex the entire command string, so
2096
+ // quoting `chmod 755` inside `gh issue create --body` was itself a deny (#3523).
2097
+ function isCommandPositionPermissionChange(toolName, toolInput = {}) {
2098
+ if (toolName !== 'Bash') return false;
2099
+ const command = String(toolInput.command || '');
2100
+ if (!command) return false;
2101
+ const segments = canonicalizeCommandForGates(command).split('; ');
2102
+ for (const segment of segments) {
2103
+ const tokens = tokenizeShellWords(segment);
2104
+ if (tokens.length === 0) continue;
2105
+ const bin = tokens[0].toLowerCase();
2106
+ if (bin === 'chmod' || bin === 'chown' || bin === 'setfacl') return tokens.length >= 2;
2107
+ if (bin === 'busybox' && tokens[1] && ['chmod', 'chown', 'setfacl'].includes(tokens[1].toLowerCase())) {
2108
+ return tokens.length >= 3;
2109
+ }
2110
+ if (bin === 'grant' || bin === 'revoke') return tokens.length >= 2;
2111
+ const nextFew = tokens.slice(1, 6).map((token) => token.toLowerCase());
2112
+ if ((bin === 'pm' || bin === 'adb') && nextFew.includes('grant')) return true;
2113
+ if (
2114
+ (bin === 'aws' || bin === 'gcloud' || bin === 'az')
2115
+ && nextFew.some((token) => token === 'iam' || token === 'policy' || token === 'role'
2116
+ || token === 'grant' || token === 'revoke')
2117
+ ) {
2118
+ return true;
2119
+ }
2120
+ }
2121
+ return false;
2122
+ }
2123
+
1689
2124
  function isThreadResolutionEvidenceAction(toolName, toolInput = {}) {
1690
2125
  if (isGitCommitCommand(toolName, toolInput)) return true;
1691
- if (['recall', 'search_lessons', 'verify_claim', 'satisfy_gate', 'track_action'].includes(toolName)) return true;
2126
+ if (['recall', 'search_lessons', 'verify_claim', 'satisfy_gate', 'track_action'].includes(bareToolName(toolName))) return true;
1692
2127
  if (toolName !== 'Bash') return false;
1693
2128
  const command = String(toolInput.command || '');
1694
2129
  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);
@@ -1728,7 +2163,9 @@ function getReadOnlyToolNames() {
1728
2163
  return names;
1729
2164
  }
1730
2165
  function isReadOnlyObservabilityTool(toolName) {
1731
- return Boolean(toolName) && getReadOnlyToolNames().has(toolName);
2166
+ if (!toolName) return false;
2167
+ const names = getReadOnlyToolNames();
2168
+ return names.has(toolName) || names.has(bareToolName(toolName));
1732
2169
  }
1733
2170
 
1734
2171
  function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
@@ -1772,7 +2209,7 @@ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
1772
2209
  severity: 'critical',
1773
2210
  reasoning: [
1774
2211
  `Tracked action ${PR_THREAD_RESOLUTION_ACTION} is pending`,
1775
- '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',
2212
+ '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',
1776
2213
  ],
1777
2214
  };
1778
2215
  }
@@ -1867,10 +2304,15 @@ function recordHelperScriptWrite(toolName, toolInput = {}) {
1867
2304
  packageScriptTouched,
1868
2305
  reasons,
1869
2306
  };
1870
- trackAction(HELPER_BYPASS_ACTION, metadata);
2307
+ trackAction(helperBypassActionKey(), metadata);
1871
2308
  return metadata;
1872
2309
  }
1873
2310
 
2311
+ function helperBypassActionKey() {
2312
+ const sessionId = currentScopeSessionId();
2313
+ return sessionId ? `${HELPER_BYPASS_ACTION}:${sessionId}` : HELPER_BYPASS_ACTION;
2314
+ }
2315
+
1874
2316
  function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
1875
2317
  if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
1876
2318
  if (toolName !== 'Bash') {
@@ -1896,7 +2338,7 @@ function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
1896
2338
 
1897
2339
  const writeMetadata = recordHelperScriptWrite(toolName, toolInput);
1898
2340
  const actions = listSessionActions();
1899
- const recentWrite = actions[HELPER_BYPASS_ACTION];
2341
+ const recentWrite = actions[helperBypassActionKey()];
1900
2342
  const recentMetadata = recentWrite && recentWrite.metadata && typeof recentWrite.metadata === 'object'
1901
2343
  ? recentWrite.metadata
1902
2344
  : null;
@@ -1969,6 +2411,10 @@ function evaluateCatastrophicDeclarativeGate(config, constraints, toolName, tool
1969
2411
  for (const gate of config.gates) {
1970
2412
  if (!CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate.id)) continue;
1971
2413
  if (gate.action !== 'block' || gate.metrics) continue;
2414
+ // Override-capable hard gates must reach the ordinary loop so it can
2415
+ // authenticate and consume the exact-action admin authorization. They are
2416
+ // still unconditional hard floors and cannot be posture/cap downgraded.
2417
+ if (gate.adminOverride && gate.adminOverride.required === true) continue;
1972
2418
 
1973
2419
  const matchDetails = matchGate(gate, toolName, toolInput);
1974
2420
  if (!matchDetails.matched) continue;
@@ -2274,8 +2720,52 @@ function checkWhenClause(when, constraints) {
2274
2720
  return true;
2275
2721
  }
2276
2722
 
2723
+
2724
+ /**
2725
+ * Surfaces a gate pattern can match against.
2726
+ *
2727
+ * Historically only `toolInput.command|file_path|path` was considered. That
2728
+ * made every MCP / non-Bash tool call invisible to pattern gates — including
2729
+ * Gmail `send_message`, Apollo emailer send, and any auto-promoted rule that
2730
+ * mentioned a tool by name. Those gates rendered as "active" with
2731
+ * lastFiredAt:null forever (AGENT-259).
2732
+ *
2733
+ * Surfaces, in order:
2734
+ * 1. Bash/command text (preserves existing `^` anchor behavior)
2735
+ * 2. bare tool name (MCP tools)
2736
+ * 3. tool name + command
2737
+ * 4. common URL/endpoint/action fields (HTTP-shaped tool inputs)
2738
+ *
2739
+ * Body/content is intentionally excluded to avoid false blocks when an agent
2740
+ * *edits code that mentions* a send endpoint.
2741
+ */
2742
+ function buildMatchSurfaces(toolName, toolInput = {}) {
2743
+ const name = String(toolName || '').trim();
2744
+ const command = String(toolInput.command || '').trim();
2745
+ const filePath = String(toolInput.file_path || toolInput.path || '').trim();
2746
+ const surfaces = [];
2747
+ const push = (s) => {
2748
+ const v = String(s || '').trim();
2749
+ if (v && !surfaces.includes(v)) surfaces.push(v);
2750
+ };
2751
+ push(command);
2752
+ push(filePath);
2753
+ push(name);
2754
+ if (name && command) push(`${name} ${command}`);
2755
+ if (name && filePath) push(`${name} ${filePath}`);
2756
+ for (const key of ['url', 'endpoint', 'method', 'action', 'path']) {
2757
+ if (toolInput[key] == null) continue;
2758
+ const v = String(toolInput[key]).slice(0, 400);
2759
+ push(v);
2760
+ if (name) push(`${name} ${v}`);
2761
+ }
2762
+ return surfaces;
2763
+ }
2764
+
2277
2765
  function matchGate(gate, toolName, toolInput = {}) {
2278
- let matchText = toolInput.command || toolInput.file_path || toolInput.path || '';
2766
+ // Primary text for audit/reasoning: prefer command, then tool name (MCP).
2767
+ const matchSurfaces = buildMatchSurfaces(toolName, toolInput);
2768
+ let matchText = matchSurfaces[0] || String(toolName || '');
2279
2769
 
2280
2770
  // Claw/hybrid support: enrich matchText with claw metadata (for EnterpriseClaw/OpenShell/Perplexity hybrid agents)
2281
2771
  const clawCtx = toolInput.clawContext || toolInput._claw || (toolInput.agentId ? {
@@ -2309,6 +2799,7 @@ function matchGate(gate, toolName, toolInput = {}) {
2309
2799
  }
2310
2800
 
2311
2801
  matchText = parts.filter(Boolean).join(' | ');
2802
+ if (!matchSurfaces.includes(matchText)) matchSurfaces.push(matchText);
2312
2803
  }
2313
2804
 
2314
2805
  const affected = extractAffectedFiles(toolName, toolInput);
@@ -2349,14 +2840,27 @@ function matchGate(gate, toolName, toolInput = {}) {
2349
2840
 
2350
2841
  if (gate.pattern) {
2351
2842
  try {
2352
- const regex = new RegExp(gate.pattern);
2353
- // Match the original text or its git-canonical form, so `git -C <dir> push --force`
2354
- // is caught by the same pattern as `git push --force`.
2355
- if (!patternMatchesCommand(regex, matchText)) {
2356
- return { matched: false, matchText, affectedFiles };
2357
- }
2358
- if (gate.id === 'permission-change-approval' && isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
2359
- return { matched: false, matchText, affectedFiles };
2843
+ if (gate.id === 'permission-change-approval') {
2844
+ if (isRemedyToolName(toolName) || !isCommandPositionPermissionChange(toolName, toolInput)) {
2845
+ return { matched: false, matchText, affectedFiles };
2846
+ }
2847
+ if (isSafeLocalCredentialHardeningCommand(toolName, toolInput)) {
2848
+ return { matched: false, matchText, affectedFiles };
2849
+ }
2850
+ } else if (gate.id === 'gh-api-pr-create-restricted') {
2851
+ if (!isGhApiPrCreateCommand(String(toolInput.command || ''))) {
2852
+ return { matched: false, matchText, affectedFiles };
2853
+ }
2854
+ } else {
2855
+ const regex = new RegExp(gate.pattern);
2856
+ // Match command text, tool name, and light payload surfaces. MCP tools
2857
+ // (e.g. Gmail send_message) have no `command` field — without multi-surface
2858
+ // matching, every pattern gate against them is permanently inert.
2859
+ const surfaces = matchSurfaces.length > 0 ? matchSurfaces : [matchText];
2860
+ const anySurfaceMatch = surfaces.some((surface) => patternMatchesCommand(regex, surface));
2861
+ if (!anySurfaceMatch) {
2862
+ return { matched: false, matchText, affectedFiles };
2863
+ }
2360
2864
  }
2361
2865
  if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
2362
2866
  return { matched: false, matchText, affectedFiles };
@@ -2423,7 +2927,6 @@ function matchGate(gate, toolName, toolInput = {}) {
2423
2927
  matchText,
2424
2928
  affectedFiles,
2425
2929
  taskScopeViolation,
2426
- protectedApprovalViolation,
2427
2930
  branchGovernanceViolation,
2428
2931
  };
2429
2932
  }
@@ -2449,6 +2952,7 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
2449
2952
 
2450
2953
  const command = String(toolInput.command || '');
2451
2954
  let matchText = command;
2955
+
2452
2956
  if (gate.id === 'self-protect-config' || gate.id === 'self-protect-hooks-disable') {
2453
2957
  const targetPattern = gate.id === 'self-protect-config'
2454
2958
  ? SELF_PROTECT_CONFIG_TARGET_PATTERN
@@ -2460,7 +2964,28 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
2460
2964
  const commandTargetPattern = gate.id === 'self-protect-config'
2461
2965
  ? SELF_PROTECT_CONFIG_COMMAND_PATTERN
2462
2966
  : SELF_PROTECT_HOOK_COMMAND_PATTERN;
2463
- if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) return null;
2967
+
2968
+ // Inspect every shell redirection destination (optional fd, optional/no
2969
+ // spaces, attached forms like printf x>file, multiple redirects).
2970
+ const redirectPattern = /(?:^|[\s;&|]|[^\s;&|<>])(?:\d*)>{1,2}\s*([^\s;&|<>]+)/g;
2971
+ const redirectTargets = [];
2972
+ let redirectMatch = redirectPattern.exec(command);
2973
+ while (redirectMatch) {
2974
+ if (redirectMatch[1]) redirectTargets.push(redirectMatch[1]);
2975
+ redirectMatch = redirectPattern.exec(command);
2976
+ }
2977
+ if (redirectTargets.length > 0) {
2978
+ // Deny when any redirect destination is protected.
2979
+ if (redirectTargets.some((target) => commandTargetPattern.test(target))) {
2980
+ // fall through to deny
2981
+ } else if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) {
2982
+ // Benign redirects and no other protected mutation → allow.
2983
+ return null;
2984
+ }
2985
+ // Benign redirects but command still mutates a protected path another way → deny.
2986
+ } else {
2987
+ if (!SHELL_FILE_MUTATION_PATTERN.test(command) || !commandTargetPattern.test(command)) return null;
2988
+ }
2464
2989
  } else {
2465
2990
  return null;
2466
2991
  }
@@ -2760,6 +3285,27 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2760
3285
  try {
2761
3286
  const { selectHarness } = require('./harness-selector');
2762
3287
  harnessPath = selectHarness(toolName, toolInput);
3288
+ try {
3289
+ // Opt-in RateBurst only (THUMBGATE_RADWARE_RATE=1). Never persist in tests/CI.
3290
+ if (process.env.THUMBGATE_RADWARE_RATE === '1') {
3291
+ const radware = require('./radware-threat-defense.js');
3292
+ 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')));
3293
+ if (!inTest) {
3294
+ radware.persistCallTimestamp();
3295
+ const burst = radware.checkRateBurst(radware.loadCallTimestamps());
3296
+ if (burst.tripped) {
3297
+ return recordStructuralGateBlock(toolName, toolInput, {
3298
+ decision: 'deny',
3299
+ gate: 'algorithmic-token-drain-circuit-breaker',
3300
+ action: 'block',
3301
+ severity: 'high',
3302
+ message: burst.message,
3303
+ receipt: 'threat_defense_interdicted=true:type=RateBurst:action=block',
3304
+ });
3305
+ }
3306
+ }
3307
+ }
3308
+ } catch { /* optional */ }
2763
3309
  } catch { /* harness-selector is optional */ }
2764
3310
  config = loadGatesConfig(configPath, harnessPath);
2765
3311
  } catch {
@@ -2782,6 +3328,10 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2782
3328
  if (statefulHelperBypassGate) {
2783
3329
  return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
2784
3330
  }
3331
+ const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
3332
+ if (stealthMemoryInjectionGate) {
3333
+ return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
3334
+ }
2785
3335
  if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
2786
3336
  recordAuditEvent({
2787
3337
  toolName,
@@ -2795,6 +3345,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2795
3345
  return null;
2796
3346
  }
2797
3347
 
3348
+ const agentIdentityLifecycleGate = evaluateAgentIdentityLifecycleGate(toolName, toolInput);
3349
+ if (agentIdentityLifecycleGate) {
3350
+ recordStat(agentIdentityLifecycleGate.gate, 'block', null, { toolName, toolInput });
3351
+ const identityAuditRecord = recordAuditEvent({
3352
+ toolName,
3353
+ toolInput,
3354
+ decision: 'deny',
3355
+ gateId: agentIdentityLifecycleGate.gate,
3356
+ message: agentIdentityLifecycleGate.message,
3357
+ severity: agentIdentityLifecycleGate.severity,
3358
+ source: 'gates-engine',
3359
+ });
3360
+ auditToFeedback(identityAuditRecord);
3361
+ return agentIdentityLifecycleGate;
3362
+ }
3363
+
2798
3364
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
2799
3365
  if (pendingThreadResolutionGate) {
2800
3366
  recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
@@ -2899,13 +3465,45 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2899
3465
  });
2900
3466
 
2901
3467
  if (gate.action === 'block') {
3468
+ const adminOverride = evaluateAdminOverride(gate, toolName, toolInput);
3469
+ if (adminOverride && adminOverride.authorized) {
3470
+ recordStat(gate.id, 'approve', gate, { toolName, toolInput });
3471
+ const auditRecord = recordAuditEvent({
3472
+ toolName,
3473
+ toolInput,
3474
+ decision: 'allow',
3475
+ gateId: gate.id,
3476
+ message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
3477
+ severity: gate.severity,
3478
+ source: 'gates-engine-admin-override',
3479
+ });
3480
+ auditToFeedback(auditRecord);
3481
+ return {
3482
+ decision: 'allow',
3483
+ gate: gate.id,
3484
+ message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
3485
+ severity: gate.severity,
3486
+ reasoning,
3487
+ adminOverride,
3488
+ };
3489
+ }
2902
3490
  // Expired leases report under their own gate id so neither the enforcement posture nor
2903
3491
  // the daily block cap can quietly turn this denial into a warning.
2904
3492
  const gateId = matchDetails && matchDetails.taskScopeViolation
2905
3493
  && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
2906
3494
  ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
2907
3495
  : gate.id;
2908
- const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
3496
+ const overrideMessage = adminOverride
3497
+ ? `${message} Approval request: ${adminOverride.escalationId}. Action digest: sha256:${adminOverride.approvalContextDigest}.${adminOverride.replayed ? ' The approved override was already consumed.' : ''}`
3498
+ : message;
3499
+ const denyResult = {
3500
+ decision: 'deny',
3501
+ gate: gateId,
3502
+ message: overrideMessage,
3503
+ severity: gate.severity,
3504
+ reasoning,
3505
+ ...(adminOverride ? { requiresAdminOverride: true, adminOverride } : {}),
3506
+ };
2909
3507
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
2910
3508
  const cappedResult = applyDailyBlockCap(denyResult);
2911
3509
  if (cappedResult) {
@@ -2915,7 +3513,7 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2915
3513
  return cappedResult;
2916
3514
  }
2917
3515
  recordStat(gate.id, 'block', gate, { toolName, toolInput });
2918
- const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
3516
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
2919
3517
  auditToFeedback(auditRecord);
2920
3518
  return denyResult;
2921
3519
  }
@@ -3003,6 +3601,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
3003
3601
  return sentinelResult;
3004
3602
  }
3005
3603
 
3604
+ const brokerReceiptResultAsync = evaluateBrokerReceiptGate(toolName, toolInput);
3605
+ if (brokerReceiptResultAsync && brokerReceiptResultAsync.decision === 'deny') {
3606
+ recordStat(brokerReceiptResultAsync.gate, 'block', null, { toolName, toolInput });
3607
+ const auditRecord = recordAuditEvent({
3608
+ toolName,
3609
+ toolInput,
3610
+ decision: 'deny',
3611
+ gateId: brokerReceiptResultAsync.gate,
3612
+ message: brokerReceiptResultAsync.message,
3613
+ severity: brokerReceiptResultAsync.severity,
3614
+ source: 'broker-execution-receipts',
3615
+ });
3616
+ auditToFeedback(auditRecord);
3617
+ return brokerReceiptResultAsync;
3618
+ }
3619
+
3006
3620
  // Audit trail: record allow (no gate matched)
3007
3621
  recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
3008
3622
  return null;
@@ -3015,6 +3629,27 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3015
3629
  try {
3016
3630
  const { selectHarness } = require('./harness-selector');
3017
3631
  harnessPath = selectHarness(toolName, toolInput);
3632
+ try {
3633
+ // Opt-in RateBurst only (THUMBGATE_RADWARE_RATE=1). Never persist in tests/CI.
3634
+ if (process.env.THUMBGATE_RADWARE_RATE === '1') {
3635
+ const radware = require('./radware-threat-defense.js');
3636
+ 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')));
3637
+ if (!inTest) {
3638
+ radware.persistCallTimestamp();
3639
+ const burst = radware.checkRateBurst(radware.loadCallTimestamps());
3640
+ if (burst.tripped) {
3641
+ return recordStructuralGateBlock(toolName, toolInput, {
3642
+ decision: 'deny',
3643
+ gate: 'algorithmic-token-drain-circuit-breaker',
3644
+ action: 'block',
3645
+ severity: 'high',
3646
+ message: burst.message,
3647
+ receipt: 'threat_defense_interdicted=true:type=RateBurst:action=block',
3648
+ });
3649
+ }
3650
+ }
3651
+ }
3652
+ } catch { /* optional */ }
3018
3653
  } catch { /* harness-selector is optional */ }
3019
3654
  config = loadGatesConfig(configPath, harnessPath);
3020
3655
  } catch {
@@ -3038,6 +3673,10 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3038
3673
  if (statefulHelperBypassGate) {
3039
3674
  return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
3040
3675
  }
3676
+ const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
3677
+ if (stealthMemoryInjectionGate) {
3678
+ return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
3679
+ }
3041
3680
  if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
3042
3681
  recordAuditEvent({
3043
3682
  toolName,
@@ -3051,6 +3690,22 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3051
3690
  return null;
3052
3691
  }
3053
3692
 
3693
+ const agentIdentityLifecycleGate = evaluateAgentIdentityLifecycleGate(toolName, toolInput);
3694
+ if (agentIdentityLifecycleGate) {
3695
+ recordStat(agentIdentityLifecycleGate.gate, 'block', null, { toolName, toolInput });
3696
+ const identityAuditRecord = recordAuditEvent({
3697
+ toolName,
3698
+ toolInput,
3699
+ decision: 'deny',
3700
+ gateId: agentIdentityLifecycleGate.gate,
3701
+ message: agentIdentityLifecycleGate.message,
3702
+ severity: agentIdentityLifecycleGate.severity,
3703
+ source: 'gates-engine',
3704
+ });
3705
+ auditToFeedback(identityAuditRecord);
3706
+ return agentIdentityLifecycleGate;
3707
+ }
3708
+
3054
3709
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
3055
3710
  if (pendingThreadResolutionGate) {
3056
3711
  recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
@@ -3128,13 +3783,45 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3128
3783
  const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
3129
3784
 
3130
3785
  if (gate.action === 'block') {
3786
+ const adminOverride = evaluateAdminOverride(gate, toolName, toolInput);
3787
+ if (adminOverride && adminOverride.authorized) {
3788
+ recordStat(gate.id, 'approve', gate, { toolName, toolInput });
3789
+ const auditRecord = recordAuditEvent({
3790
+ toolName,
3791
+ toolInput,
3792
+ decision: 'allow',
3793
+ gateId: gate.id,
3794
+ message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
3795
+ severity: gate.severity,
3796
+ source: 'gates-engine-admin-override',
3797
+ });
3798
+ auditToFeedback(auditRecord);
3799
+ return {
3800
+ decision: 'allow',
3801
+ gate: gate.id,
3802
+ message: `Single-use admin override consumed for sha256:${adminOverride.approvalContextDigest}.`,
3803
+ severity: gate.severity,
3804
+ reasoning,
3805
+ adminOverride,
3806
+ };
3807
+ }
3131
3808
  // Expired leases report under their own gate id so neither the enforcement posture nor
3132
3809
  // the daily block cap can quietly turn this denial into a warning.
3133
3810
  const gateId = matchDetails && matchDetails.taskScopeViolation
3134
3811
  && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
3135
3812
  ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
3136
3813
  : gate.id;
3137
- const denyResult = { decision: 'deny', gate: gateId, message, severity: gate.severity, reasoning };
3814
+ const overrideMessage = adminOverride
3815
+ ? `${message} Approval request: ${adminOverride.escalationId}. Action digest: sha256:${adminOverride.approvalContextDigest}.${adminOverride.replayed ? ' The approved override was already consumed.' : ''}`
3816
+ : message;
3817
+ const denyResult = {
3818
+ decision: 'deny',
3819
+ gate: gateId,
3820
+ message: overrideMessage,
3821
+ severity: gate.severity,
3822
+ reasoning,
3823
+ ...(adminOverride ? { requiresAdminOverride: true, adminOverride } : {}),
3824
+ };
3138
3825
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
3139
3826
  const cappedResult = applyDailyBlockCap(denyResult);
3140
3827
  if (cappedResult) {
@@ -3144,7 +3831,7 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3144
3831
  return cappedResult;
3145
3832
  }
3146
3833
  recordStat(gate.id, 'block', gate, { toolName, toolInput });
3147
- const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message, severity: gate.severity, source: 'gates-engine' });
3834
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
3148
3835
  auditToFeedback(auditRecord);
3149
3836
  return denyResult;
3150
3837
  }
@@ -3231,6 +3918,24 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3231
3918
  return sentinelResult;
3232
3919
  }
3233
3920
 
3921
+ // Broker-signed execution receipts: verify attached proof; optionally require
3922
+ // for high-risk provider side effects (THUMBGATE_BROKER_RECEIPT_MODE=enforce).
3923
+ const brokerReceiptResult = evaluateBrokerReceiptGate(toolName, toolInput);
3924
+ if (brokerReceiptResult && brokerReceiptResult.decision === 'deny') {
3925
+ recordStat(brokerReceiptResult.gate, 'block', null, { toolName, toolInput });
3926
+ const auditRecord = recordAuditEvent({
3927
+ toolName,
3928
+ toolInput,
3929
+ decision: 'deny',
3930
+ gateId: brokerReceiptResult.gate,
3931
+ message: brokerReceiptResult.message,
3932
+ severity: brokerReceiptResult.severity,
3933
+ source: 'broker-execution-receipts',
3934
+ });
3935
+ auditToFeedback(auditRecord);
3936
+ return brokerReceiptResult;
3937
+ }
3938
+
3234
3939
  // Audit trail: record allow
3235
3940
  recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
3236
3941
  return null;
@@ -3394,6 +4099,7 @@ function evaluateUnconditionalHardFloor(input = {}, options = {}) {
3394
4099
 
3395
4100
  function evaluateFinancialHardFloor(input = {}, consumeReservation = false, options = {}) {
3396
4101
  const toolName = input.tool_name || input.toolName || 'unknown';
4102
+ if (isRemedyToolName(toolName)) return null;
3397
4103
  const rawToolInput = input.tool_input ?? input.toolInput;
3398
4104
  const toolInput = rawToolInput && typeof rawToolInput === 'object'
3399
4105
  ? rawToolInput
@@ -4233,7 +4939,87 @@ function verifyClaimEvidence(claimText, options = {}) {
4233
4939
  // Exports
4234
4940
  // ---------------------------------------------------------------------------
4235
4941
 
4942
+ // ---------------------------------------------------------------------------
4943
+ // Agent identity plane (Okta AI-identity checklist: shadow AI + lifecycle)
4944
+ // ---------------------------------------------------------------------------
4945
+
4946
+ // Warn-dedup per process: one shadow/lifecycle warning per agent id, so the
4947
+ // audit log records the finding without spamming every tool call.
4948
+ const AGENT_IDENTITY_WARNED = new Set();
4949
+
4950
+ function resolveActingAgentId() {
4951
+ return process.env.THUMBGATE_SESSION_AGENT
4952
+ || process.env.THUMBGATE_AGENT_ID
4953
+ || null;
4954
+ }
4955
+
4956
+ /**
4957
+ * Identity gate for the enforced evaluation path. Every attributed tool call
4958
+ * is recorded as an observation (the producer side of shadow-AI detection).
4959
+ * An observed-but-unregistered agent is a shadow agent: warn-only on every
4960
+ * mode — a strict-mode deny here would brick sessions on repos where nothing
4961
+ * registers agents yet. A registry-retired or disabled agent that keeps
4962
+ * acting is denied under THUMBGATE_STRICT_ENFORCEMENT=1 and warned otherwise:
4963
+ * retirement is an explicit operator action, so enforcing it cannot surprise
4964
+ * a healthy session. Fails open on any registry error — identity tracking
4965
+ * must never break the hook path.
4966
+ */
4967
+ function evaluateAgentIdentityLifecycleGate(toolName, toolInput) {
4968
+ try {
4969
+ const agentId = resolveActingAgentId();
4970
+ if (!agentId) return null;
4971
+ const identityStore = require('./audit-trail');
4972
+ try {
4973
+ identityStore.recordObservedAgent(agentId);
4974
+ } catch {
4975
+ // Observation is best-effort.
4976
+ }
4977
+ const registryRow = identityStore.loadAgentRegistry()
4978
+ .filter((agent) => agent && agent.id === agentId)
4979
+ .pop();
4980
+ const strict = process.env.THUMBGATE_STRICT_ENFORCEMENT === '1';
4981
+ const lifecycleStatus = registryRow?.metadata?.lifecycleStatus;
4982
+ if (registryRow && (lifecycleStatus === 'retired' || lifecycleStatus === 'disabled')) {
4983
+ const message = `Agent identity "${agentId}" is ${lifecycleStatus} in the agent registry but is still acting. `
4984
+ + 'Re-activate it via registerAgent with lifecycleStatus "active", or stop the agent.';
4985
+ if (strict) {
4986
+ return { gate: 'agent-identity-lifecycle', decision: 'deny', message, severity: 'critical' };
4987
+ }
4988
+ if (!AGENT_IDENTITY_WARNED.has(`retired:${agentId}`)) {
4989
+ AGENT_IDENTITY_WARNED.add(`retired:${agentId}`);
4990
+ recordAuditEvent({
4991
+ toolName,
4992
+ toolInput,
4993
+ decision: 'warn',
4994
+ gateId: 'agent-identity-lifecycle',
4995
+ message,
4996
+ severity: 'high',
4997
+ source: 'gates-engine',
4998
+ });
4999
+ }
5000
+ return null;
5001
+ }
5002
+ if (!registryRow && !AGENT_IDENTITY_WARNED.has(`shadow:${agentId}`)) {
5003
+ AGENT_IDENTITY_WARNED.add(`shadow:${agentId}`);
5004
+ recordAuditEvent({
5005
+ toolName,
5006
+ toolInput,
5007
+ decision: 'warn',
5008
+ gateId: 'agent-identity-shadow',
5009
+ message: `Shadow agent: "${agentId}" is acting but has never been registered in the agent registry. `
5010
+ + 'Register it via registerAgent so identity, lifecycle, and audit attribution are explicit.',
5011
+ severity: 'medium',
5012
+ source: 'gates-engine',
5013
+ });
5014
+ }
5015
+ return null;
5016
+ } catch {
5017
+ return null;
5018
+ }
5019
+ }
5020
+
4236
5021
  module.exports = {
5022
+ evaluateAgentIdentityLifecycleGate,
4237
5023
  loadGatesConfig,
4238
5024
  loadState,
4239
5025
  saveState,
@@ -4246,6 +5032,8 @@ module.exports = {
4246
5032
  isTaskScopeExpired,
4247
5033
  TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
4248
5034
  applyEnforcementPosture,
5035
+ resolveGovernanceMode,
5036
+ alignmentLayerForResult,
4249
5037
  buildTaskScopeViolation,
4250
5038
  setBranchGovernance,
4251
5039
  approveProtectedAction,
@@ -4264,7 +5052,17 @@ module.exports = {
4264
5052
  matchesGate,
4265
5053
  evaluateGates,
4266
5054
  evaluateGatesAsync,
5055
+ actionApprovalDigest,
5056
+ buildMatchSurfaces,
4267
5057
  extractAffectedFiles,
5058
+ extractGitMinusCPaths,
5059
+ resolveRepoRoot,
5060
+ governanceStatePath,
5061
+ currentScopeSessionId,
5062
+ isRemedyToolName,
5063
+ isCommandPositionPermissionChange,
5064
+ isGhApiPrCreateCommand,
5065
+ helperBypassActionKey,
4268
5066
  parseGitPathspec,
4269
5067
  canonicalizeGitCommand,
4270
5068
  canonicalizeCommandForGates,
@@ -4322,6 +5120,7 @@ module.exports = {
4322
5120
  evaluateLocalOnlyRemoteSideEffectGate,
4323
5121
  recordHelperScriptWrite,
4324
5122
  evaluateStatefulHelperBypassGate,
5123
+ evaluateStealthMemoryInjection,
4325
5124
  isAgentHookSettingsFile,
4326
5125
  isBreakGlassSettingsRecoveryAction,
4327
5126
  PR_THREAD_RESOLUTION_ACTION,
@@ -4330,6 +5129,8 @@ module.exports = {
4330
5129
  applyDailyBlockCap,
4331
5130
  getTodayBlockCount,
4332
5131
  incrementTodayBlockCount,
5132
+ effectiveCommandCwd,
5133
+ resolveRepoRoot,
4333
5134
  };
4334
5135
 
4335
5136
  // ---------------------------------------------------------------------------