thumbgate 1.35.0 → 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 (196) 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 +336 -3
  24. package/bin/futureagi-bridge +9 -0
  25. package/config/gate-templates.json +581 -4
  26. package/config/gates/actor-critic-audit.json +34 -0
  27. package/config/gates/default.json +9 -3
  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 +6 -0
  34. package/config/model-candidates.json +312 -29
  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 +390 -148
  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/billing.js +1 -1
  89. package/scripts/broker-execution-receipts.js +719 -0
  90. package/scripts/budget-aware-gates-proof.js +423 -0
  91. package/scripts/claude-feedback-sync.js +29 -3
  92. package/scripts/claw-harness-production.js +237 -0
  93. package/scripts/cli-schema.js +163 -1
  94. package/scripts/codex-runbook-flywheel.js +318 -0
  95. package/scripts/context-footprint.js +186 -0
  96. package/scripts/contextfs.js +143 -61
  97. package/scripts/dashboard.js +251 -32
  98. package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
  99. package/scripts/docker-sandbox-planner.js +18 -0
  100. package/scripts/double-blind-eval-protocol.js +252 -0
  101. package/scripts/edotenv-rl-gateway.js +259 -0
  102. package/scripts/ensure-production-search-corpus.js +162 -0
  103. package/scripts/eval-holdout.js +311 -0
  104. package/scripts/feedback-aggregate.js +21 -2
  105. package/scripts/feedback-loop.js +87 -5
  106. package/scripts/feedback-quality.js +9 -0
  107. package/scripts/file-ledger-lock.js +4 -1
  108. package/scripts/financial-control-plane.js +41 -1
  109. package/scripts/find-dormant-requires.js +118 -0
  110. package/scripts/fs-utils.js +84 -8
  111. package/scripts/gates-engine.js +810 -63
  112. package/scripts/generate-case-study-outreach.js +24 -15
  113. package/scripts/git-at-scale.js +628 -0
  114. package/scripts/governance-conflict-audit.js +1650 -0
  115. package/scripts/governance-difficulty-curriculum.js +328 -0
  116. package/scripts/graphrag-retrieval.js +275 -0
  117. package/scripts/gurobi-optimizer.js +324 -0
  118. package/scripts/gurobi_optimizer.py +485 -0
  119. package/scripts/harness-selector.js +82 -1
  120. package/scripts/hidden-entry-points.js +284 -0
  121. package/scripts/human-escalation.js +199 -1
  122. package/scripts/hybrid-feedback-context.js +152 -19
  123. package/scripts/intent-governed-execution.js +602 -0
  124. package/scripts/intervention-policy.js +123 -20
  125. package/scripts/jit-harness-compose.js +628 -0
  126. package/scripts/jsonl-watcher.js +10 -0
  127. package/scripts/lesson-embedding-index.js +95 -12
  128. package/scripts/lesson-retrieval.js +105 -19
  129. package/scripts/local-model-profile.js +19 -2
  130. package/scripts/mailer/resend-mailer.js +1 -1
  131. package/scripts/matryoshka-embedding.js +235 -0
  132. package/scripts/mcp-oauth.js +42 -4
  133. package/scripts/mcp-session-handles.js +1016 -0
  134. package/scripts/mcp-wiring-doctor.js +314 -0
  135. package/scripts/memory-firewall.js +115 -2
  136. package/scripts/memory-scope-readiness.js +299 -0
  137. package/scripts/memory-vs-rag-route.js +161 -0
  138. package/scripts/model-tier-router.js +148 -21
  139. package/scripts/nvidia-specdecode-al-doctor.js +536 -0
  140. package/scripts/openui-catalog-compose-honesty.js +593 -0
  141. package/scripts/operational-integrity.js +19 -1
  142. package/scripts/override-audit.js +213 -0
  143. package/scripts/package-manager-honesty-doctor.js +458 -0
  144. package/scripts/pr-manager.js +63 -1
  145. package/scripts/prove-herdr-adapter.js +52 -0
  146. package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
  147. package/scripts/prove-workos.js +73 -0
  148. package/scripts/provider-attestation-conformance.js +192 -0
  149. package/scripts/provider-receipt-contract.js +136 -0
  150. package/scripts/qwen38-max-cost-optimizer.js +401 -0
  151. package/scripts/radware-threat-defense.js +280 -0
  152. package/scripts/rag-embedding-identity.js +221 -0
  153. package/scripts/rag-precision-guardrails.js +112 -2
  154. package/scripts/remote-feedback-capture.js +159 -0
  155. package/scripts/research-agent-harness.js +256 -0
  156. package/scripts/rsi-safety-hillclimb.js +200 -0
  157. package/scripts/rule-sprawl.js +188 -0
  158. package/scripts/schedule-manager.js +147 -0
  159. package/scripts/self-heal.js +8 -0
  160. package/scripts/session-lease.js +415 -0
  161. package/scripts/simatree-data-governance.js +347 -0
  162. package/scripts/slo-alert-engine.js +172 -7
  163. package/scripts/solver-parity.js +539 -0
  164. package/scripts/stealth-memory-injection-gate.js +333 -0
  165. package/scripts/switchyard-router.js +366 -0
  166. package/scripts/telemetry-analytics.js +84 -27
  167. package/scripts/temporal-decay-weighting.js +138 -0
  168. package/scripts/test-all.js +165 -0
  169. package/scripts/token-savings.js +42 -0
  170. package/scripts/tool-kpi-tracker.js +108 -5
  171. package/scripts/tool-registry.js +193 -5
  172. package/scripts/universal-claim-evaluator.js +14 -2
  173. package/scripts/vector-store.js +279 -9
  174. package/scripts/workflow-notebook.js +391 -0
  175. package/scripts/workflow-sentinel.js +111 -12
  176. package/scripts/workos-production-guard.js +260 -0
  177. package/scripts/workspace-search-route.js +515 -0
  178. package/server.json +2 -2
  179. package/src/agent-identity-boundary.js +76 -0
  180. package/src/agent-retrieval-cache.js +155 -0
  181. package/src/alert-noise-ledger.js +502 -0
  182. package/src/api/server.js +724 -153
  183. package/src/git-fast-cache.js +220 -0
  184. package/src/git-wal-sync.js +156 -0
  185. package/src/hash-anchored-edit.js +82 -0
  186. package/src/hermes-platform-protocol.js +475 -0
  187. package/src/hermes-sync-plane.js +241 -0
  188. package/src/index.js +30 -1
  189. package/src/iso42001-compliance-guard.js +97 -0
  190. package/src/latency-budget.js +244 -0
  191. package/src/mcp-writeguard.js +316 -0
  192. package/src/miminions-adapter.js +106 -0
  193. package/src/pipeline-compass.js +104 -0
  194. package/src/ppl-alert-pipeline.js +284 -0
  195. package/src/rendezvous-router.js +90 -0
  196. 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,8 +186,9 @@ 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',
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).
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).
173
192
  'outbound-email-send',
174
193
  TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
175
194
  ...SELF_PROTECT_HARD_FLOOR_GATE_IDS,
@@ -191,7 +210,7 @@ const CATASTROPHIC_DECLARATIVE_GATE_IDS = new Set([
191
210
  'git-clean-force',
192
211
  'rm-rf-home-or-root',
193
212
  'financial-control',
194
- // Never daily-cap discount agent email send (same class as force-push).
213
+ // Never daily-cap discount a denied autonomous email send (same class as force-push).
195
214
  'outbound-email-send',
196
215
  ]);
197
216
  const SELF_PROTECT_CONFIG_TARGET_PATTERN = /(?:^|\/)(?:config\/gates\/|config\/(?:budget|enforcement|mcp-allowlists)\.json$|\.thumbgate\/config\.json$|thumbgate\.json$)/i;
@@ -204,21 +223,76 @@ function isSelfProtectGate(gateId) {
204
223
  return SELF_PROTECT_HARD_FLOOR_GATE_IDS.has(gateId);
205
224
  }
206
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
+
207
255
  function applyEnforcementPosture(result) {
208
256
  if (!result || (result.decision !== 'deny' && result.decision !== 'approve')) return result;
257
+ const mode = resolveGovernanceMode();
258
+ const alignmentLayer = alignmentLayerForResult(result);
209
259
  // Defensive backstop: hard-floor results must never be posture-downgraded.
210
- 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
+ }
211
279
  // Full hard enforcement opt-in: keep every deny.
212
- 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
+ }
213
283
  // 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
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
216
288
  // with the warning surfaced instead of hard-blocked, so legitimate work is never blocked.
217
289
  return {
218
290
  ...result,
219
291
  decision: 'warn',
220
292
  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.`,
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.`,
222
296
  };
223
297
  }
224
298
  const BREAK_GLASS_CONDITION = 'thumbgate_break_glass';
@@ -261,19 +335,51 @@ function commandContainsSequence(words, sequence) {
261
335
  }
262
336
 
263
337
  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;
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
+ }
268
350
  }
269
- 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;
270
372
  }
271
373
 
272
374
  function isGhApiPrCreateCommand(command) {
273
375
  const words = commandWords(command);
274
376
  if (!commandContainsSequence(words, ['gh', 'api'])) return false;
275
- const hasPullsEndpoint = words.some((word) => word === '/pulls' || word.endsWith('/pulls'));
276
- 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;
277
383
  const fieldFlags = new Set(['-f', '--field', '--raw-field']);
278
384
  const hasFieldWrite = words.some((word) => (
279
385
  fieldFlags.has(word) ||
@@ -472,8 +578,39 @@ function isTaskScopeExpired(taskScope, nowMs = Date.now()) {
472
578
  return nowMs >= deadline;
473
579
  }
474
580
 
475
- function loadGovernanceState() {
476
- 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));
477
614
  const state = {
478
615
  taskScope: raw && raw.taskScope && typeof raw.taskScope === 'object' ? raw.taskScope : null,
479
616
  protectedApprovals: Array.isArray(raw && raw.protectedApprovals) ? raw.protectedApprovals : [],
@@ -494,35 +631,142 @@ function loadGovernanceState() {
494
631
  const activeApprovals = state.protectedApprovals.filter((entry) => {
495
632
  if (!entry || typeof entry !== 'object') return false;
496
633
  if (!entry.timestamp || !entry.expiresAt) return false;
497
- return now < entry.expiresAt;
634
+ return entry.expiresAt > now;
498
635
  });
499
- if (activeApprovals.length !== state.protectedApprovals.length) {
500
- state.protectedApprovals = activeApprovals;
501
- saveGovernanceState(state);
502
- }
636
+ state.protectedApprovals = activeApprovals;
503
637
  return state;
504
638
  }
505
639
 
506
- function saveGovernanceState(state) {
640
+ function saveGovernanceState(state, explicitSessionId = null) {
507
641
  const next = {
508
642
  taskScope: state && state.taskScope ? state.taskScope : null,
509
643
  protectedApprovals: Array.isArray(state && state.protectedApprovals) ? state.protectedApprovals : [],
510
644
  branchGovernance: state && state.branchGovernance ? state.branchGovernance : null,
511
645
  workflowContract: state && state.workflowContract ? state.workflowContract : null,
512
646
  };
513
- 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
+ };
514
757
  }
515
758
 
516
759
  function setTaskScope(scopeInput = {}) {
760
+ const explicitSessionId = scopeInput && scopeInput.sessionId ? String(scopeInput.sessionId).trim() : null;
517
761
  if (scopeInput && scopeInput.clear === true) {
518
- const currentState = loadGovernanceState();
762
+ const currentState = loadGovernanceState(explicitSessionId);
519
763
  const cleared = {
520
764
  taskScope: null,
521
765
  protectedApprovals: currentState.protectedApprovals,
522
766
  branchGovernance: currentState.branchGovernance,
523
767
  workflowContract: null,
524
768
  };
525
- saveGovernanceState(cleared);
769
+ saveGovernanceState(cleared, explicitSessionId);
526
770
  refreshLocalOnlyConstraint(cleared);
527
771
  return null;
528
772
  }
@@ -545,6 +789,7 @@ function setTaskScope(scopeInput = {}) {
545
789
  const leaseMs = scopeInput.ttlMs == null ? null : clampTtlMs(scopeInput.ttlMs, TASK_SCOPE_LEASE_MS);
546
790
  const taskScope = {
547
791
  taskId: String(scopeInput.taskId || '').trim() || null,
792
+ sessionId: currentScopeSessionId(explicitSessionId),
548
793
  summary: String(scopeInput.summary || '').trim() || null,
549
794
  allowedPaths,
550
795
  protectedPaths,
@@ -555,12 +800,12 @@ function setTaskScope(scopeInput = {}) {
555
800
  leaseMs,
556
801
  expiresAt: leaseMs == null ? null : scopeNow + leaseMs,
557
802
  };
558
- const state = loadGovernanceState();
803
+ const state = loadGovernanceState(explicitSessionId);
559
804
  state.taskScope = taskScope;
560
805
  state.workflowContract = scopeInput.workflowContract && typeof scopeInput.workflowContract === 'object'
561
806
  ? scopeInput.workflowContract
562
807
  : null;
563
- saveGovernanceState(state);
808
+ saveGovernanceState(state, explicitSessionId);
564
809
  if (taskScope.localOnly) {
565
810
  setConstraint('local_only', true);
566
811
  }
@@ -606,7 +851,14 @@ function breakGlassEmergency(input = {}) {
606
851
  const gates = ['pr_create_allowed', 'pr_threads_checked', BREAK_GLASS_CONDITION];
607
852
  const satisfied = {};
608
853
  for (const gateId of gates) {
609
- 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
+ });
610
862
  }
611
863
 
612
864
  const approval = approveProtectedAction({
@@ -665,8 +917,9 @@ function setBranchGovernance(input = {}) {
665
917
  return governance;
666
918
  }
667
919
 
668
- function getScopeState() {
669
- 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);
670
923
  }
671
924
 
672
925
  function getBranchGovernanceState() {
@@ -709,7 +962,7 @@ function isConditionSatisfied(conditionId) {
709
962
  return age < TTL_MS;
710
963
  }
711
964
 
712
- function satisfyCondition(conditionId, evidence, structuredReasoning) {
965
+ function satisfyCondition(conditionId, evidence, structuredReasoning, options = {}) {
713
966
  const state = loadState();
714
967
  const entry = {
715
968
  timestamp: Date.now(),
@@ -725,6 +978,28 @@ function satisfyCondition(conditionId, evidence, structuredReasoning) {
725
978
  }
726
979
  state[conditionId] = entry;
727
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
+
728
1003
  return entry;
729
1004
  }
730
1005
 
@@ -905,14 +1180,111 @@ function safeExecFileLines(binary, args, cwd) {
905
1180
  }
906
1181
  }
907
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
+
908
1252
  function resolveRepoRoot(toolInput = {}) {
909
- 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,
910
1278
  toolInput.repoPath,
911
1279
  toolInput.cwd,
912
1280
  process.cwd(),
913
- ]
914
- .filter(Boolean)
915
- .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
+ }
916
1288
 
917
1289
  for (const cwd of candidates) {
918
1290
  try {
@@ -1135,7 +1507,17 @@ function parseGitPathspec(command, subcommand, options = {}) {
1135
1507
  // null as "unknown" and fall back to broad.
1136
1508
  function effectiveCommandCwd(command, toolInput) {
1137
1509
  let cwd = String(toolInput?.cwd || toolInput?.repoPath || process.cwd());
1138
- 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|&&|\|\||[;|&]/);
1139
1521
  for (const segment of segments) {
1140
1522
  // Parsed without a regex: /^cd\s+(?:--\s+)?(.+)$/ has adjacent \s+ groups that backtrack
1141
1523
  // polynomially on input like `cd\t\t\t…` (js/polynomial-redos). The command comes
@@ -1149,8 +1531,12 @@ function effectiveCommandCwd(command, toolInput) {
1149
1531
  else if (argText.startsWith('--') && /^[ \t]/.test(argText.slice(2))) argText = argText.slice(2).trim();
1150
1532
  const target = tokenizeShellWords(argText)[0];
1151
1533
  if (!target) break; // bare `cd` -> home; leave scope resolution alone
1152
- if (/[*?$`]|^~/.test(target)) return null;
1153
- 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);
1154
1540
  }
1155
1541
  return cwd;
1156
1542
  }
@@ -1376,8 +1762,10 @@ function extractAffectedFiles(toolName, toolInput = {}) {
1376
1762
  }
1377
1763
 
1378
1764
  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));
1765
+ if (files.size === 0) {
1766
+ for (const filePath of getBranchDiffFiles(repoRoot)) {
1767
+ files.add(normalizePosix(filePath));
1768
+ }
1381
1769
  }
1382
1770
  }
1383
1771
  }
@@ -1691,9 +2079,51 @@ function isThreadResolutionSatisfied() {
1691
2079
  ));
1692
2080
  }
1693
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
+
1694
2124
  function isThreadResolutionEvidenceAction(toolName, toolInput = {}) {
1695
2125
  if (isGitCommitCommand(toolName, toolInput)) return true;
1696
- 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;
1697
2127
  if (toolName !== 'Bash') return false;
1698
2128
  const command = String(toolInput.command || '');
1699
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);
@@ -1733,7 +2163,9 @@ function getReadOnlyToolNames() {
1733
2163
  return names;
1734
2164
  }
1735
2165
  function isReadOnlyObservabilityTool(toolName) {
1736
- return Boolean(toolName) && getReadOnlyToolNames().has(toolName);
2166
+ if (!toolName) return false;
2167
+ const names = getReadOnlyToolNames();
2168
+ return names.has(toolName) || names.has(bareToolName(toolName));
1737
2169
  }
1738
2170
 
1739
2171
  function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
@@ -1777,7 +2209,7 @@ function evaluatePendingPrThreadResolutionGate(toolName, toolInput = {}) {
1777
2209
  severity: 'critical',
1778
2210
  reasoning: [
1779
2211
  `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',
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',
1781
2213
  ],
1782
2214
  };
1783
2215
  }
@@ -1872,10 +2304,15 @@ function recordHelperScriptWrite(toolName, toolInput = {}) {
1872
2304
  packageScriptTouched,
1873
2305
  reasons,
1874
2306
  };
1875
- trackAction(HELPER_BYPASS_ACTION, metadata);
2307
+ trackAction(helperBypassActionKey(), metadata);
1876
2308
  return metadata;
1877
2309
  }
1878
2310
 
2311
+ function helperBypassActionKey() {
2312
+ const sessionId = currentScopeSessionId();
2313
+ return sessionId ? `${HELPER_BYPASS_ACTION}:${sessionId}` : HELPER_BYPASS_ACTION;
2314
+ }
2315
+
1879
2316
  function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
1880
2317
  if (process.env.THUMBGATE_HELPER_BYPASS_GUARD === '0') return null;
1881
2318
  if (toolName !== 'Bash') {
@@ -1901,7 +2338,7 @@ function evaluateStatefulHelperBypassGate(toolName, toolInput = {}) {
1901
2338
 
1902
2339
  const writeMetadata = recordHelperScriptWrite(toolName, toolInput);
1903
2340
  const actions = listSessionActions();
1904
- const recentWrite = actions[HELPER_BYPASS_ACTION];
2341
+ const recentWrite = actions[helperBypassActionKey()];
1905
2342
  const recentMetadata = recentWrite && recentWrite.metadata && typeof recentWrite.metadata === 'object'
1906
2343
  ? recentWrite.metadata
1907
2344
  : null;
@@ -1974,6 +2411,10 @@ function evaluateCatastrophicDeclarativeGate(config, constraints, toolName, tool
1974
2411
  for (const gate of config.gates) {
1975
2412
  if (!CATASTROPHIC_DECLARATIVE_GATE_IDS.has(gate.id)) continue;
1976
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;
1977
2418
 
1978
2419
  const matchDetails = matchGate(gate, toolName, toolInput);
1979
2420
  if (!matchDetails.matched) continue;
@@ -2399,17 +2840,27 @@ function matchGate(gate, toolName, toolInput = {}) {
2399
2840
 
2400
2841
  if (gate.pattern) {
2401
2842
  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 };
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
+ }
2413
2864
  }
2414
2865
  if (isBreakGlassSettingsBypass(gate, affectedFiles)) {
2415
2866
  return { matched: false, matchText, affectedFiles };
@@ -2476,7 +2927,6 @@ function matchGate(gate, toolName, toolInput = {}) {
2476
2927
  matchText,
2477
2928
  affectedFiles,
2478
2929
  taskScopeViolation,
2479
- protectedApprovalViolation,
2480
2930
  branchGovernanceViolation,
2481
2931
  };
2482
2932
  }
@@ -2502,6 +2952,7 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
2502
2952
 
2503
2953
  const command = String(toolInput.command || '');
2504
2954
  let matchText = command;
2955
+
2505
2956
  if (gate.id === 'self-protect-config' || gate.id === 'self-protect-hooks-disable') {
2506
2957
  const targetPattern = gate.id === 'self-protect-config'
2507
2958
  ? SELF_PROTECT_CONFIG_TARGET_PATTERN
@@ -2513,7 +2964,28 @@ function matchSelfProtectHardFloor(gate, toolName, toolInput = {}) {
2513
2964
  const commandTargetPattern = gate.id === 'self-protect-config'
2514
2965
  ? SELF_PROTECT_CONFIG_COMMAND_PATTERN
2515
2966
  : SELF_PROTECT_HOOK_COMMAND_PATTERN;
2516
- 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
+ }
2517
2989
  } else {
2518
2990
  return null;
2519
2991
  }
@@ -2813,6 +3285,27 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2813
3285
  try {
2814
3286
  const { selectHarness } = require('./harness-selector');
2815
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 */ }
2816
3309
  } catch { /* harness-selector is optional */ }
2817
3310
  config = loadGatesConfig(configPath, harnessPath);
2818
3311
  } catch {
@@ -2835,6 +3328,10 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2835
3328
  if (statefulHelperBypassGate) {
2836
3329
  return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
2837
3330
  }
3331
+ const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
3332
+ if (stealthMemoryInjectionGate) {
3333
+ return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
3334
+ }
2838
3335
  if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
2839
3336
  recordAuditEvent({
2840
3337
  toolName,
@@ -2848,6 +3345,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2848
3345
  return null;
2849
3346
  }
2850
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
+
2851
3364
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
2852
3365
  if (pendingThreadResolutionGate) {
2853
3366
  recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
@@ -2952,13 +3465,45 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2952
3465
  });
2953
3466
 
2954
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
+ }
2955
3490
  // Expired leases report under their own gate id so neither the enforcement posture nor
2956
3491
  // the daily block cap can quietly turn this denial into a warning.
2957
3492
  const gateId = matchDetails && matchDetails.taskScopeViolation
2958
3493
  && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
2959
3494
  ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
2960
3495
  : gate.id;
2961
- 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
+ };
2962
3507
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
2963
3508
  const cappedResult = applyDailyBlockCap(denyResult);
2964
3509
  if (cappedResult) {
@@ -2968,7 +3513,7 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
2968
3513
  return cappedResult;
2969
3514
  }
2970
3515
  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' });
3516
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
2972
3517
  auditToFeedback(auditRecord);
2973
3518
  return denyResult;
2974
3519
  }
@@ -3056,6 +3601,22 @@ async function evaluateGatesAsyncInner(toolName, toolInput, configPath) {
3056
3601
  return sentinelResult;
3057
3602
  }
3058
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
+
3059
3620
  // Audit trail: record allow (no gate matched)
3060
3621
  recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
3061
3622
  return null;
@@ -3068,6 +3629,27 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3068
3629
  try {
3069
3630
  const { selectHarness } = require('./harness-selector');
3070
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 */ }
3071
3653
  } catch { /* harness-selector is optional */ }
3072
3654
  config = loadGatesConfig(configPath, harnessPath);
3073
3655
  } catch {
@@ -3091,6 +3673,10 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3091
3673
  if (statefulHelperBypassGate) {
3092
3674
  return recordStructuralGateBlock(toolName, toolInput, statefulHelperBypassGate);
3093
3675
  }
3676
+ const stealthMemoryInjectionGate = evaluateStealthMemoryInjection(toolName, toolInput);
3677
+ if (stealthMemoryInjectionGate) {
3678
+ return recordStructuralGateBlock(toolName, toolInput, stealthMemoryInjectionGate);
3679
+ }
3094
3680
  if (isBreakGlassSettingsRecoveryAction(toolName, toolInput)) {
3095
3681
  recordAuditEvent({
3096
3682
  toolName,
@@ -3104,6 +3690,22 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3104
3690
  return null;
3105
3691
  }
3106
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
+
3107
3709
  const pendingThreadResolutionGate = evaluatePendingPrThreadResolutionGate(toolName, toolInput);
3108
3710
  if (pendingThreadResolutionGate) {
3109
3711
  recordStat(pendingThreadResolutionGate.gate, 'block', null, { toolName, toolInput });
@@ -3181,13 +3783,45 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3181
3783
  const reasoning = buildReasoning(gate, toolName, toolInput, matchDetails);
3182
3784
 
3183
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
+ }
3184
3808
  // Expired leases report under their own gate id so neither the enforcement posture nor
3185
3809
  // the daily block cap can quietly turn this denial into a warning.
3186
3810
  const gateId = matchDetails && matchDetails.taskScopeViolation
3187
3811
  && matchDetails.taskScopeViolation.reasonCode === 'expired_task_scope'
3188
3812
  ? TASK_SCOPE_LEASE_EXPIRED_GATE_ID
3189
3813
  : gate.id;
3190
- 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
+ };
3191
3825
  // Free-tier daily block cap: after N blocks/day, deny → warn + upgrade CTA
3192
3826
  const cappedResult = applyDailyBlockCap(denyResult);
3193
3827
  if (cappedResult) {
@@ -3197,7 +3831,7 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3197
3831
  return cappedResult;
3198
3832
  }
3199
3833
  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' });
3834
+ const auditRecord = recordAuditEvent({ toolName, toolInput, decision: 'deny', gateId: gate.id, message: overrideMessage, severity: gate.severity, source: 'gates-engine' });
3201
3835
  auditToFeedback(auditRecord);
3202
3836
  return denyResult;
3203
3837
  }
@@ -3284,6 +3918,24 @@ function evaluateGatesInner(toolName, toolInput, configPath) {
3284
3918
  return sentinelResult;
3285
3919
  }
3286
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
+
3287
3939
  // Audit trail: record allow
3288
3940
  recordAuditEvent({ toolName, toolInput, decision: 'allow', source: 'gates-engine' });
3289
3941
  return null;
@@ -3447,6 +4099,7 @@ function evaluateUnconditionalHardFloor(input = {}, options = {}) {
3447
4099
 
3448
4100
  function evaluateFinancialHardFloor(input = {}, consumeReservation = false, options = {}) {
3449
4101
  const toolName = input.tool_name || input.toolName || 'unknown';
4102
+ if (isRemedyToolName(toolName)) return null;
3450
4103
  const rawToolInput = input.tool_input ?? input.toolInput;
3451
4104
  const toolInput = rawToolInput && typeof rawToolInput === 'object'
3452
4105
  ? rawToolInput
@@ -4286,7 +4939,87 @@ function verifyClaimEvidence(claimText, options = {}) {
4286
4939
  // Exports
4287
4940
  // ---------------------------------------------------------------------------
4288
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
+
4289
5021
  module.exports = {
5022
+ evaluateAgentIdentityLifecycleGate,
4290
5023
  loadGatesConfig,
4291
5024
  loadState,
4292
5025
  saveState,
@@ -4299,6 +5032,8 @@ module.exports = {
4299
5032
  isTaskScopeExpired,
4300
5033
  TASK_SCOPE_LEASE_EXPIRED_GATE_ID,
4301
5034
  applyEnforcementPosture,
5035
+ resolveGovernanceMode,
5036
+ alignmentLayerForResult,
4302
5037
  buildTaskScopeViolation,
4303
5038
  setBranchGovernance,
4304
5039
  approveProtectedAction,
@@ -4317,8 +5052,17 @@ module.exports = {
4317
5052
  matchesGate,
4318
5053
  evaluateGates,
4319
5054
  evaluateGatesAsync,
5055
+ actionApprovalDigest,
4320
5056
  buildMatchSurfaces,
4321
5057
  extractAffectedFiles,
5058
+ extractGitMinusCPaths,
5059
+ resolveRepoRoot,
5060
+ governanceStatePath,
5061
+ currentScopeSessionId,
5062
+ isRemedyToolName,
5063
+ isCommandPositionPermissionChange,
5064
+ isGhApiPrCreateCommand,
5065
+ helperBypassActionKey,
4322
5066
  parseGitPathspec,
4323
5067
  canonicalizeGitCommand,
4324
5068
  canonicalizeCommandForGates,
@@ -4376,6 +5120,7 @@ module.exports = {
4376
5120
  evaluateLocalOnlyRemoteSideEffectGate,
4377
5121
  recordHelperScriptWrite,
4378
5122
  evaluateStatefulHelperBypassGate,
5123
+ evaluateStealthMemoryInjection,
4379
5124
  isAgentHookSettingsFile,
4380
5125
  isBreakGlassSettingsRecoveryAction,
4381
5126
  PR_THREAD_RESOLUTION_ACTION,
@@ -4384,6 +5129,8 @@ module.exports = {
4384
5129
  applyDailyBlockCap,
4385
5130
  getTodayBlockCount,
4386
5131
  incrementTodayBlockCount,
5132
+ effectiveCommandCwd,
5133
+ resolveRepoRoot,
4387
5134
  };
4388
5135
 
4389
5136
  // ---------------------------------------------------------------------------