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
@@ -0,0 +1,1016 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * MCP Session Handles — model-carried correlation with identity controls
5
+ *
6
+ * Context (InfoWorld 2026-08-13, Karunanithi / MCP July 2026 stateless core):
7
+ * MCP relocated protocol session correlation into ordinary tool arguments that
8
+ * the model must re-emit across turns. Handles are no longer hidden in a
9
+ * connection; they live in the context window. That makes them an identity
10
+ * surface, not a transport detail.
11
+ *
12
+ * High-ROI controls this module implements (article "What I'm doing about it"):
13
+ * 1. Authorize every handle against the authenticated principal on every request
14
+ * 2. Real entropy + short default TTL
15
+ * 3. Idempotency keys so a retry after a lost/swapped handle cannot mint a twin object
16
+ * 4. Multi-turn fidelity helpers so tests run at depth ~30, not turn one
17
+ *
18
+ * Design notes:
19
+ * - Handles are opaque tokens: `mcp_h_<id>.<hmac16>`
20
+ * - Server-side registry is the source of truth; the model only carries the token
21
+ * - Principal binding is mandatory at mint; authorize fails closed on mismatch
22
+ * - No exemption for "internal" tools — same path for all callers
23
+ */
24
+
25
+ const crypto = require('crypto');
26
+ const fs = require('fs');
27
+ const path = require('path');
28
+ const os = require('os');
29
+
30
+ const HANDLE_PREFIX = 'mcp_h_';
31
+ const DEFAULT_TTL_MS = 15 * 60 * 1000; // 15 minutes — short by design
32
+ const MIN_TTL_MS = 5 * 1000;
33
+ const MAX_TTL_MS = 60 * 60 * 1000; // 1 hour hard cap
34
+ const ENTROPY_BYTES = 18; // 144 bits
35
+ const SIG_HEX_LEN = 16;
36
+ // Explicit keys always win when present. Ambiguous keys (sessionId, etc.)
37
+ // are only treated as MCP handles when the value matches the mcp_h_ token
38
+ // shape — otherwise domain IDs (feedback sessions, Stripe, telemetry) would
39
+ // trip the gate with MISSING_PRINCIPAL / INVALID_SESSION_HANDLE_FORMAT.
40
+ const EXPLICIT_HANDLE_ARG_KEYS = [
41
+ 'sessionHandle',
42
+ 'session_handle',
43
+ 'mcpSessionHandle',
44
+ 'mcp_session_handle',
45
+ 'handleId',
46
+ ];
47
+ const AMBIGUOUS_HANDLE_ARG_KEYS = [
48
+ 'handle',
49
+ 'sessionId',
50
+ 'session_id',
51
+ 'taskScopeId',
52
+ 'basketId',
53
+ 'browserId',
54
+ 'correlationId',
55
+ ];
56
+ const HANDLE_ARG_KEYS = [...EXPLICIT_HANDLE_ARG_KEYS, ...AMBIGUOUS_HANDLE_ARG_KEYS];
57
+
58
+ /** @type {Map<string, object>} */
59
+ let registry = new Map();
60
+ /** @type {Map<string, object>} */
61
+ let idempotencyIndex = new Map();
62
+
63
+ let nowFn = () => Date.now();
64
+ let secretOverride = null;
65
+ let storePathOverride = null;
66
+ let diskLoaded = false;
67
+
68
+ function getSecret() {
69
+ if (secretOverride) return secretOverride;
70
+ const fromEnv = process.env.THUMBGATE_SESSION_SECRET
71
+ || process.env.THUMBGATE_MCP_HANDLE_SECRET
72
+ || '';
73
+ if (fromEnv && fromEnv.length >= 16) return fromEnv;
74
+ // Dev/test fallback — production should set THUMBGATE_SESSION_SECRET.
75
+ // Still high entropy so forged handles without the registry entry fail.
76
+ return 'thumbgate-dev-mcp-handle-secret-v1';
77
+ }
78
+
79
+ function clampTtlMs(ttlMs) {
80
+ if (ttlMs == null || ttlMs === '') return DEFAULT_TTL_MS;
81
+ const n = Number(ttlMs);
82
+ if (!Number.isFinite(n)) return DEFAULT_TTL_MS;
83
+ return Math.min(MAX_TTL_MS, Math.max(MIN_TTL_MS, Math.floor(n)));
84
+ }
85
+
86
+ function normalizePrincipal(principalId) {
87
+ const id = String(principalId || '').trim();
88
+ if (!id) {
89
+ const err = new Error('principalId is required to mint or authorize an MCP session handle');
90
+ err.code = 'MISSING_PRINCIPAL';
91
+ throw err;
92
+ }
93
+ return id;
94
+ }
95
+
96
+ function normalizeTenant(tenantId) {
97
+ const id = String(tenantId || 'default').trim();
98
+ return id || 'default';
99
+ }
100
+
101
+ function normalizeScope(scope) {
102
+ const s = String(scope || 'global').trim();
103
+ return s || 'global';
104
+ }
105
+
106
+ function signPayload(payload) {
107
+ return crypto
108
+ .createHmac('sha256', getSecret())
109
+ .update(payload)
110
+ .digest('hex')
111
+ .slice(0, SIG_HEX_LEN);
112
+ }
113
+
114
+ function buildHandleToken(handleId) {
115
+ const sig = signPayload(handleId);
116
+ return `${handleId}.${sig}`;
117
+ }
118
+
119
+ function parseHandleToken(token) {
120
+ const raw = String(token || '').trim();
121
+ if (!raw) return null;
122
+ const dot = raw.lastIndexOf('.');
123
+ if (dot <= 0 || dot === raw.length - 1) {
124
+ return { ok: false, code: 'INVALID_SESSION_HANDLE_FORMAT', reason: 'Handle missing signature segment' };
125
+ }
126
+ const handleId = raw.slice(0, dot);
127
+ const signature = raw.slice(dot + 1);
128
+ if (!handleId.startsWith(HANDLE_PREFIX)) {
129
+ return { ok: false, code: 'MALFORMED_SESSION_HANDLE', reason: 'Handle does not use mcp_h_ prefix' };
130
+ }
131
+ if (!/^[a-f0-9]+$/i.test(signature) || signature.length !== SIG_HEX_LEN) {
132
+ return { ok: false, code: 'INVALID_SESSION_HANDLE_FORMAT', reason: 'Handle signature is not a valid HMAC fragment' };
133
+ }
134
+ const expected = signPayload(handleId);
135
+ // timingSafeEqual requires equal length buffers
136
+ const a = Buffer.from(signature, 'utf8');
137
+ const b = Buffer.from(expected, 'utf8');
138
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
139
+ return { ok: false, code: 'INVALID_SESSION_HANDLE_SIGNATURE', reason: 'Handle HMAC verification failed' };
140
+ }
141
+ return { ok: true, handleId, signature, token: raw };
142
+ }
143
+
144
+ function defaultStorePath() {
145
+ if (storePathOverride) return storePathOverride;
146
+ if (process.env.THUMBGATE_MCP_HANDLE_STORE) {
147
+ return process.env.THUMBGATE_MCP_HANDLE_STORE;
148
+ }
149
+ // Prefer project-local runtime dir; fall back to tmp when unavailable.
150
+ const local = path.join(process.cwd(), '.thumbgate', 'mcp-session-handles.json');
151
+ try {
152
+ fs.mkdirSync(path.dirname(local), { recursive: true });
153
+ return local;
154
+ } catch {
155
+ return path.join(os.tmpdir(), 'thumbgate-mcp-session-handles.json');
156
+ }
157
+ }
158
+
159
+ function persist() {
160
+ // Optional durable spill for multi-process hosts. Failures are non-fatal —
161
+ // in-memory registry remains authoritative for this process.
162
+ try {
163
+ const storePath = defaultStorePath();
164
+ const payload = {
165
+ version: 1,
166
+ savedAt: new Date(nowFn()).toISOString(),
167
+ handles: [...registry.values()],
168
+ idempotency: [...idempotencyIndex.entries()].map(([key, value]) => ({ key, ...value })),
169
+ };
170
+ fs.mkdirSync(path.dirname(storePath), { recursive: true });
171
+ fs.writeFileSync(storePath, JSON.stringify(payload), 'utf8');
172
+ } catch {
173
+ // ignore disk errors
174
+ }
175
+ }
176
+
177
+ function loadFromDisk() {
178
+ try {
179
+ const storePath = defaultStorePath();
180
+ if (!fs.existsSync(storePath)) {
181
+ diskLoaded = true;
182
+ return;
183
+ }
184
+ const raw = JSON.parse(fs.readFileSync(storePath, 'utf8'));
185
+ if (!raw || typeof raw !== 'object') {
186
+ diskLoaded = true;
187
+ return;
188
+ }
189
+ registry = new Map();
190
+ for (const rec of raw.handles || []) {
191
+ if (rec && rec.handleId) registry.set(rec.handleId, rec);
192
+ }
193
+ idempotencyIndex = new Map();
194
+ for (const row of raw.idempotency || []) {
195
+ if (row && row.key) {
196
+ const { key, ...rest } = row;
197
+ idempotencyIndex.set(key, rest);
198
+ }
199
+ }
200
+ } catch {
201
+ // ignore corrupt store
202
+ }
203
+ diskLoaded = true;
204
+ }
205
+
206
+ function ensureDiskLoaded() {
207
+ if (!diskLoaded) loadFromDisk();
208
+ }
209
+
210
+ function purgeExpired(now = nowFn()) {
211
+ let removed = 0;
212
+ for (const [id, rec] of registry.entries()) {
213
+ if (rec.expiresAtMs <= now || rec.revokedAt) {
214
+ registry.delete(id);
215
+ removed += 1;
216
+ }
217
+ }
218
+ for (const [key, row] of idempotencyIndex.entries()) {
219
+ if (row.expiresAtMs && row.expiresAtMs <= now) {
220
+ idempotencyIndex.delete(key);
221
+ }
222
+ }
223
+ return removed;
224
+ }
225
+
226
+ /**
227
+ * Mint a cryptographically bound session handle for a principal.
228
+ * @param {object} input
229
+ * @param {string} input.principalId - authenticated caller (required)
230
+ * @param {string} [input.tenantId]
231
+ * @param {string} [input.scope]
232
+ * @param {string} [input.kind] - basket | browser | task | session | custom
233
+ * @param {number} [input.ttlMs]
234
+ * @param {object} [input.metadata]
235
+ * @param {string} [input.idempotencyKey] - if re-minted with same key, return existing
236
+ */
237
+ function mintHandle(input = {}) {
238
+ ensureDiskLoaded();
239
+ const principalId = normalizePrincipal(input.principalId);
240
+ const tenantId = normalizeTenant(input.tenantId);
241
+ const scope = normalizeScope(input.scope);
242
+ const kind = String(input.kind || 'session').trim() || 'session';
243
+ const ttlMs = clampTtlMs(input.ttlMs);
244
+ const now = nowFn();
245
+ const idempotencyKey = input.idempotencyKey != null
246
+ ? String(input.idempotencyKey).trim()
247
+ : '';
248
+
249
+ purgeExpired(now);
250
+
251
+ if (idempotencyKey) {
252
+ const idxKey = idemIndexKey({ principalId, tenantId, key: idempotencyKey, purpose: 'mint' });
253
+ const existing = idempotencyIndex.get(idxKey);
254
+ if (existing && existing.handleId && registry.has(existing.handleId)) {
255
+ const rec = registry.get(existing.handleId);
256
+ if (rec.expiresAtMs > now && !rec.revokedAt) {
257
+ return publicRecord(rec, { replayed: true });
258
+ }
259
+ }
260
+ }
261
+
262
+ const entropy = crypto.randomBytes(ENTROPY_BYTES).toString('hex');
263
+ const handleId = `${HANDLE_PREFIX}${entropy}`;
264
+ const token = buildHandleToken(handleId);
265
+ const record = {
266
+ handleId,
267
+ token,
268
+ principalId,
269
+ tenantId,
270
+ scope,
271
+ kind,
272
+ metadata: input.metadata && typeof input.metadata === 'object' ? { ...input.metadata } : {},
273
+ createdAtMs: now,
274
+ expiresAtMs: now + ttlMs,
275
+ ttlMs,
276
+ lastAuthorizedAtMs: null,
277
+ authorizeCount: 0,
278
+ revokedAt: null,
279
+ mintIdempotencyKey: idempotencyKey || null,
280
+ };
281
+
282
+ registry.set(handleId, record);
283
+
284
+ if (idempotencyKey) {
285
+ idempotencyIndex.set(
286
+ idemIndexKey({ principalId, tenantId, key: idempotencyKey, purpose: 'mint' }),
287
+ {
288
+ handleId,
289
+ principalId,
290
+ tenantId,
291
+ purpose: 'mint',
292
+ createdAtMs: now,
293
+ expiresAtMs: record.expiresAtMs,
294
+ }
295
+ );
296
+ }
297
+
298
+ persist();
299
+ return publicRecord(record, { replayed: false });
300
+ }
301
+
302
+ /**
303
+ * Backward-compatible mint used by early stub callers.
304
+ * Requires principal via second arg or env THUMBGATE_PRINCIPAL_ID.
305
+ */
306
+ function mintSessionHandle(tenantId = 'default', scope = 'global', opts = {}) {
307
+ const principalId = opts.principalId
308
+ || process.env.THUMBGATE_PRINCIPAL_ID
309
+ || process.env.THUMBGATE_AGENT_ID
310
+ || 'anonymous-dev';
311
+ const minted = mintHandle({
312
+ principalId,
313
+ tenantId,
314
+ scope,
315
+ kind: opts.kind || 'session',
316
+ ttlMs: opts.ttlMs,
317
+ metadata: opts.metadata,
318
+ idempotencyKey: opts.idempotencyKey,
319
+ });
320
+ return {
321
+ handleId: minted.token,
322
+ token: minted.token,
323
+ tenantId: minted.tenantId,
324
+ scope: minted.scope,
325
+ principalId: minted.principalId,
326
+ createdAt: minted.createdAtMs,
327
+ expiresAt: minted.expiresAtMs,
328
+ kind: minted.kind,
329
+ replayed: minted.replayed,
330
+ };
331
+ }
332
+
333
+ function publicRecord(rec, extra = {}) {
334
+ return {
335
+ token: rec.token,
336
+ handleId: rec.handleId,
337
+ principalId: rec.principalId,
338
+ tenantId: rec.tenantId,
339
+ scope: rec.scope,
340
+ kind: rec.kind,
341
+ createdAtMs: rec.createdAtMs,
342
+ expiresAtMs: rec.expiresAtMs,
343
+ ttlMs: rec.ttlMs,
344
+ authorizeCount: rec.authorizeCount,
345
+ metadata: { ...rec.metadata },
346
+ ...extra,
347
+ };
348
+ }
349
+
350
+ function idemIndexKey({ principalId, tenantId, key, purpose }) {
351
+ return `${purpose}::${tenantId}::${principalId}::${key}`;
352
+ }
353
+
354
+ /**
355
+ * True when value looks like a minted MCP handle token (prefix + signature).
356
+ * Domain session IDs (feedback, checkout, telemetry) must not match.
357
+ */
358
+ function looksLikeHandleToken(value) {
359
+ const v = String(value || '').trim();
360
+ if (!v.startsWith(HANDLE_PREFIX)) return false;
361
+ const dot = v.indexOf('.');
362
+ // mcp_h_<id>.<hmac hex>
363
+ return dot > HANDLE_PREFIX.length && /^[a-f0-9]+$/i.test(v.slice(dot + 1));
364
+ }
365
+
366
+ function candidateFromKey(args, key, prefix = '') {
367
+ if (args[key] == null) return null;
368
+ const value = String(args[key]).trim();
369
+ if (!value) return null;
370
+ const explicit = EXPLICIT_HANDLE_ARG_KEYS.includes(key);
371
+ if (!explicit && !looksLikeHandleToken(value)) return null;
372
+ return { key: prefix ? `${prefix}${key}` : key, value };
373
+ }
374
+
375
+ /**
376
+ * Extract a model-carried handle candidate from tool arguments.
377
+ * Ambiguous keys only match mcp_h_*.* tokens to avoid domain ID collisions.
378
+ */
379
+ function extractHandleFromArgs(args = {}) {
380
+ if (!args || typeof args !== 'object') return null;
381
+ for (const key of HANDLE_ARG_KEYS) {
382
+ const hit = candidateFromKey(args, key);
383
+ if (hit) return hit;
384
+ }
385
+ if (args.metadata && typeof args.metadata === 'object') {
386
+ for (const key of HANDLE_ARG_KEYS) {
387
+ const hit = candidateFromKey(args.metadata, key, 'metadata.');
388
+ if (hit) return hit;
389
+ }
390
+ }
391
+ return null;
392
+ }
393
+
394
+ /**
395
+ * Authorize a handle against the authenticated principal.
396
+ * Fail-closed: missing principal, wrong principal, wrong tenant, expired,
397
+ * revoked, or forged signature all deny.
398
+ *
399
+ * @param {object} input
400
+ * @param {string} [input.handle] - raw token from model args
401
+ * @param {object} [input.args] - full tool args (handle extracted if handle omitted)
402
+ * @param {string} input.principalId
403
+ * @param {string} [input.tenantId]
404
+ * @param {string} [input.toolName]
405
+ * @param {boolean} [input.required=true] - when false, missing handle is allowed
406
+ * @param {string} [input.expectedScope]
407
+ * @param {string} [input.expectedKind]
408
+ */
409
+ function authorizeHandle(input = {}) {
410
+ ensureDiskLoaded();
411
+ const now = nowFn();
412
+
413
+ let principalId;
414
+ try {
415
+ principalId = normalizePrincipal(input.principalId);
416
+ } catch (err) {
417
+ return deny('MISSING_PRINCIPAL', err.message, input.toolName);
418
+ }
419
+
420
+ // Always bind tenant (defaults to 'default'). Omitting tenant no longer
421
+ // skips the cross-tenant check — fail closed for multi-tenant hosts.
422
+ const tenantId = normalizeTenant(input.tenantId);
423
+ const required = input.required !== false;
424
+
425
+ const extracted = input.handle != null
426
+ ? { key: 'handle', value: String(input.handle) }
427
+ : extractHandleFromArgs(input.args || {});
428
+
429
+ if (!extracted) {
430
+ purgeExpired(now);
431
+ if (!required) {
432
+ return {
433
+ allowed: true,
434
+ code: 'STATELESS_UNSCOPED',
435
+ reason: 'No handle required and none provided',
436
+ toolName: input.toolName || null,
437
+ };
438
+ }
439
+ return deny(
440
+ 'MISSING_SESSION_HANDLE',
441
+ `Tool '${input.toolName || 'unknown'}' requires a valid session handle in model arguments, but the model dropped it.`,
442
+ input.toolName
443
+ );
444
+ }
445
+
446
+ const parsed = parseHandleToken(extracted.value);
447
+ if (!parsed.ok) {
448
+ purgeExpired(now);
449
+ return deny(parsed.code, parsed.reason, input.toolName, { argKey: extracted.key });
450
+ }
451
+
452
+ // Inspect the candidate BEFORE purge so expiry reports EXPIRED_SESSION_HANDLE
453
+ // rather than collapsing into UNKNOWN after deletion.
454
+ const rec = registry.get(parsed.handleId);
455
+ if (!rec) {
456
+ purgeExpired(now);
457
+ // Signature may be valid for the secret but the server has no record —
458
+ // treat as unknown / possible cross-process forgery without registry.
459
+ return deny(
460
+ 'UNKNOWN_SESSION_HANDLE',
461
+ 'Handle is not registered on this server (forged, expired, or never minted here).',
462
+ input.toolName,
463
+ { handleId: parsed.handleId, argKey: extracted.key }
464
+ );
465
+ }
466
+
467
+ if (rec.revokedAt) {
468
+ purgeExpired(now);
469
+ return deny('REVOKED_SESSION_HANDLE', 'Handle was revoked', input.toolName, {
470
+ handleId: rec.handleId,
471
+ revokedAt: rec.revokedAt,
472
+ });
473
+ }
474
+
475
+ if (rec.expiresAtMs <= now) {
476
+ registry.delete(rec.handleId);
477
+ purgeExpired(now);
478
+ return deny('EXPIRED_SESSION_HANDLE', `Handle expired at ${new Date(rec.expiresAtMs).toISOString()}`, input.toolName, {
479
+ handleId: rec.handleId,
480
+ expiresAtMs: rec.expiresAtMs,
481
+ });
482
+ }
483
+
484
+ purgeExpired(now);
485
+
486
+ if (rec.principalId !== principalId) {
487
+ return deny(
488
+ 'PRINCIPAL_MISMATCH',
489
+ `Handle bound to principal '${rec.principalId}' cannot be used by '${principalId}'.`,
490
+ input.toolName,
491
+ { handleId: rec.handleId, boundPrincipal: rec.principalId, callerPrincipal: principalId }
492
+ );
493
+ }
494
+
495
+ if (rec.tenantId !== tenantId) {
496
+ return deny(
497
+ 'CROSS_TENANT_SESSION_FORGERY',
498
+ `Session handle tenant '${rec.tenantId}' does not match active context tenant '${tenantId}'.`,
499
+ input.toolName,
500
+ { handleId: rec.handleId, boundTenant: rec.tenantId, callerTenant: tenantId }
501
+ );
502
+ }
503
+
504
+ if (input.expectedScope && rec.scope !== input.expectedScope) {
505
+ return deny(
506
+ 'SCOPE_MISMATCH',
507
+ `Handle scope '${rec.scope}' does not match expected '${input.expectedScope}'.`,
508
+ input.toolName,
509
+ { handleId: rec.handleId }
510
+ );
511
+ }
512
+
513
+ if (input.expectedKind && rec.kind !== input.expectedKind) {
514
+ return deny(
515
+ 'KIND_MISMATCH',
516
+ `Handle kind '${rec.kind}' does not match expected '${input.expectedKind}'.`,
517
+ input.toolName,
518
+ { handleId: rec.handleId }
519
+ );
520
+ }
521
+
522
+ rec.lastAuthorizedAtMs = now;
523
+ rec.authorizeCount = (rec.authorizeCount || 0) + 1;
524
+ persist();
525
+
526
+ return {
527
+ allowed: true,
528
+ code: 'SESSION_HANDLE_VERIFIED',
529
+ reason: 'Session handle authorized for principal and tenant.',
530
+ toolName: input.toolName || null,
531
+ handleId: rec.handleId,
532
+ token: rec.token,
533
+ principalId: rec.principalId,
534
+ tenantId: rec.tenantId,
535
+ scope: rec.scope,
536
+ kind: rec.kind,
537
+ authorizeCount: rec.authorizeCount,
538
+ expiresAtMs: rec.expiresAtMs,
539
+ argKey: extracted.key,
540
+ };
541
+ }
542
+
543
+ /**
544
+ * Stub-compatible verify API.
545
+ * @param {string} toolName
546
+ * @param {object} args
547
+ * @param {object|null} activeSessionContext - { required, tenantId, principalId, expectedScope, expectedKind }
548
+ */
549
+ function verifySessionHandle(toolName = '', args = {}, activeSessionContext = null) {
550
+ ensureDiskLoaded();
551
+ if (!args || typeof args !== 'object') {
552
+ return { allowed: true, reason: 'No arguments provided', code: 'NO_ARGS' };
553
+ }
554
+
555
+ const handleCandidate = extractHandleFromArgs(args);
556
+ const required = Boolean(activeSessionContext && activeSessionContext.required);
557
+
558
+ if (!activeSessionContext && !handleCandidate) {
559
+ return { allowed: true, reason: 'Stateless un-scoped tool call', code: 'STATELESS_UNSCOPED' };
560
+ }
561
+
562
+ // Carried handle without principal context = fail closed (no replay path).
563
+ if (!activeSessionContext && handleCandidate) {
564
+ return {
565
+ allowed: false,
566
+ code: 'MISSING_SESSION_CONTEXT',
567
+ reason: 'Session handle present but activeSessionContext with principalId is required to authorize.',
568
+ argKey: handleCandidate.key,
569
+ };
570
+ }
571
+
572
+ if (!activeSessionContext) {
573
+ return { allowed: true, reason: 'Stateless un-scoped tool call', code: 'STATELESS_UNSCOPED' };
574
+ }
575
+
576
+ const principalId = activeSessionContext.principalId
577
+ || process.env.THUMBGATE_PRINCIPAL_ID
578
+ || process.env.THUMBGATE_AGENT_ID
579
+ || null;
580
+
581
+ if (!principalId) {
582
+ return {
583
+ allowed: false,
584
+ code: 'MISSING_PRINCIPAL',
585
+ reason: 'activeSessionContext.principalId is required to authorize a session handle',
586
+ };
587
+ }
588
+
589
+ return authorizeHandle({
590
+ args,
591
+ principalId,
592
+ tenantId: activeSessionContext.tenantId,
593
+ toolName,
594
+ required,
595
+ expectedScope: activeSessionContext.expectedScope || activeSessionContext.scope,
596
+ expectedKind: activeSessionContext.expectedKind || activeSessionContext.kind,
597
+ });
598
+ }
599
+
600
+ /**
601
+ * Atomic claim for an idempotency key BEFORE side effects.
602
+ * Returns:
603
+ * - execute: first caller may run the side effect
604
+ * - replay: prior completed result (do not re-run)
605
+ * - in_flight: another caller holds a pending claim
606
+ * - denied: authorization failed
607
+ */
608
+ function claimIdempotency(input = {}) {
609
+ ensureDiskLoaded();
610
+ const principalId = normalizePrincipal(input.principalId);
611
+ const tenantId = normalizeTenant(input.tenantId);
612
+ const key = String(input.key || '').trim();
613
+ const operation = String(input.operation || 'default').trim() || 'default';
614
+ if (!key) {
615
+ const err = new Error('idempotency key is required');
616
+ err.code = 'MISSING_IDEMPOTENCY_KEY';
617
+ throw err;
618
+ }
619
+
620
+ const auth = authorizeHandle({
621
+ handle: input.handle,
622
+ args: input.args,
623
+ principalId,
624
+ tenantId,
625
+ toolName: input.toolName || operation,
626
+ required: true,
627
+ });
628
+ if (!auth.allowed) {
629
+ return { status: 'denied', authorization: auth };
630
+ }
631
+
632
+ const idxKey = idemIndexKey({
633
+ principalId,
634
+ tenantId,
635
+ key: `${operation}::${key}`,
636
+ purpose: 'op',
637
+ });
638
+ const existing = idempotencyIndex.get(idxKey);
639
+ if (existing && existing.status === 'completed' && existing.result !== undefined) {
640
+ return {
641
+ status: 'replay',
642
+ result: existing.result,
643
+ handleId: existing.handleId,
644
+ authorization: auth,
645
+ createdAtMs: existing.createdAtMs,
646
+ claimKey: idxKey,
647
+ };
648
+ }
649
+ if (existing && existing.status === 'pending') {
650
+ return {
651
+ status: 'in_flight',
652
+ handleId: existing.handleId,
653
+ authorization: auth,
654
+ claimKey: idxKey,
655
+ createdAtMs: existing.createdAtMs,
656
+ };
657
+ }
658
+
659
+ const now = nowFn();
660
+ const rec = registry.get(auth.handleId);
661
+ idempotencyIndex.set(idxKey, {
662
+ handleId: auth.handleId,
663
+ principalId,
664
+ tenantId,
665
+ operation,
666
+ key,
667
+ status: 'pending',
668
+ result: undefined,
669
+ createdAtMs: now,
670
+ expiresAtMs: rec ? rec.expiresAtMs : now + DEFAULT_TTL_MS,
671
+ });
672
+ persist();
673
+ return {
674
+ status: 'execute',
675
+ handleId: auth.handleId,
676
+ authorization: auth,
677
+ claimKey: idxKey,
678
+ createdAtMs: now,
679
+ };
680
+ }
681
+
682
+ /**
683
+ * Complete a prior claimIdempotency with the side-effect result.
684
+ * Call only after the effect succeeds (or failClosed with error).
685
+ */
686
+ function completeIdempotency(input = {}) {
687
+ ensureDiskLoaded();
688
+ const principalId = normalizePrincipal(input.principalId);
689
+ const tenantId = normalizeTenant(input.tenantId);
690
+ const key = String(input.key || '').trim();
691
+ const operation = String(input.operation || 'default').trim() || 'default';
692
+ const idxKey = input.claimKey || idemIndexKey({
693
+ principalId,
694
+ tenantId,
695
+ key: `${operation}::${key}`,
696
+ purpose: 'op',
697
+ });
698
+ const existing = idempotencyIndex.get(idxKey);
699
+ if (!existing) {
700
+ return { status: 'missing_claim' };
701
+ }
702
+ if (existing.status === 'completed' && existing.result !== undefined) {
703
+ return { status: 'replay', result: existing.result, handleId: existing.handleId };
704
+ }
705
+ existing.status = 'completed';
706
+ existing.result = input.result !== undefined ? input.result : { ok: true };
707
+ existing.completedAtMs = nowFn();
708
+ idempotencyIndex.set(idxKey, existing);
709
+ persist();
710
+ return {
711
+ status: 'stored',
712
+ result: existing.result,
713
+ handleId: existing.handleId,
714
+ createdAtMs: existing.createdAtMs,
715
+ };
716
+ }
717
+
718
+ /**
719
+ * Bind an idempotency key: claim → optional result store.
720
+ * Preferred production flow: claimIdempotency → side effect → completeIdempotency.
721
+ * This helper stores a completed result when `result` is provided (single-call path).
722
+ * @returns {{ status: 'stored'|'replay'|'execute'|'in_flight'|'denied', result?: any }}
723
+ */
724
+ function bindIdempotency(input = {}) {
725
+ if (input.result !== undefined && input.complete !== false) {
726
+ const claim = claimIdempotency(input);
727
+ if (claim.status === 'replay' || claim.status === 'denied' || claim.status === 'in_flight') {
728
+ return claim;
729
+ }
730
+ return completeIdempotency({
731
+ ...input,
732
+ claimKey: claim.claimKey,
733
+ result: input.result,
734
+ });
735
+ }
736
+ return claimIdempotency(input);
737
+ }
738
+
739
+ /**
740
+ * Gate MCP tool dispatch: when model carries a handle (or env requires one),
741
+ * authorize against principal/tenant from env or args metadata.
742
+ */
743
+ function authorizeMcpToolCall(toolName, args = {}, context = {}) {
744
+ ensureDiskLoaded();
745
+ const principalId = context.principalId
746
+ || args.principalId
747
+ || (args.metadata && args.metadata.principalId)
748
+ || process.env.THUMBGATE_PRINCIPAL_ID
749
+ || process.env.THUMBGATE_AGENT_ID
750
+ || null;
751
+ const tenantId = context.tenantId
752
+ || args.tenantId
753
+ || (args.metadata && args.metadata.tenantId)
754
+ || process.env.THUMBGATE_TENANT_ID
755
+ || 'default';
756
+ const handleCandidate = extractHandleFromArgs(args);
757
+ const forceRequired = context.required === true
758
+ || process.env.THUMBGATE_MCP_HANDLE_REQUIRED === '1';
759
+
760
+ if (!handleCandidate && !forceRequired) {
761
+ return {
762
+ allowed: true,
763
+ code: 'STATELESS_UNSCOPED',
764
+ reason: 'No model-carried handle on this tool call',
765
+ toolName,
766
+ };
767
+ }
768
+
769
+ if (!principalId) {
770
+ return deny(
771
+ 'MISSING_PRINCIPAL',
772
+ 'THUMBGATE_PRINCIPAL_ID (or args.principalId) is required when a session handle is present or required',
773
+ toolName
774
+ );
775
+ }
776
+
777
+ return authorizeHandle({
778
+ args,
779
+ principalId,
780
+ tenantId,
781
+ toolName,
782
+ required: true,
783
+ });
784
+ }
785
+
786
+ /**
787
+ * Resolve a previously bound idempotency result without re-executing side effects.
788
+ */
789
+ function resolveIdempotency(input = {}) {
790
+ ensureDiskLoaded();
791
+ const principalId = normalizePrincipal(input.principalId);
792
+ const tenantId = normalizeTenant(input.tenantId);
793
+ const key = String(input.key || '').trim();
794
+ const operation = String(input.operation || 'default').trim() || 'default';
795
+ if (!key) return { status: 'missing_key' };
796
+
797
+ const idxKey = idemIndexKey({
798
+ principalId,
799
+ tenantId,
800
+ key: `${operation}::${key}`,
801
+ purpose: 'op',
802
+ });
803
+ const existing = idempotencyIndex.get(idxKey);
804
+ if (!existing) return { status: 'miss' };
805
+ if (existing.expiresAtMs && existing.expiresAtMs <= nowFn()) {
806
+ idempotencyIndex.delete(idxKey);
807
+ return { status: 'expired' };
808
+ }
809
+ if (existing.status === 'pending') {
810
+ return { status: 'in_flight', handleId: existing.handleId, createdAtMs: existing.createdAtMs };
811
+ }
812
+ if (existing.result === undefined) return { status: 'miss' };
813
+ return {
814
+ status: 'hit',
815
+ result: existing.result,
816
+ handleId: existing.handleId,
817
+ createdAtMs: existing.createdAtMs,
818
+ };
819
+ }
820
+
821
+ function revokeHandle(tokenOrId, principalId) {
822
+ const principal = normalizePrincipal(principalId);
823
+ const parsed = parseHandleToken(tokenOrId);
824
+ const handleId = parsed.ok ? parsed.handleId : String(tokenOrId || '').replace(/\.[a-f0-9]+$/i, '');
825
+ const rec = registry.get(handleId);
826
+ if (!rec) return { revoked: false, code: 'UNKNOWN_SESSION_HANDLE' };
827
+ if (rec.principalId !== principal) {
828
+ return { revoked: false, code: 'PRINCIPAL_MISMATCH' };
829
+ }
830
+ rec.revokedAt = new Date(nowFn()).toISOString();
831
+ registry.delete(handleId);
832
+ persist();
833
+ return { revoked: true, handleId };
834
+ }
835
+
836
+ /**
837
+ * Simulate multi-turn model fidelity: at each turn the "model" re-emits a handle.
838
+ * mutateAtTurns: map turnIndex -> 'drop' | 'swap' | 'mutate' | 'forge'
839
+ * Returns per-turn authorize outcomes — used by depth-30 tests.
840
+ */
841
+ function simulateMultiTurnFidelity(options = {}) {
842
+ const turns = Math.max(1, Number(options.turns) || 30);
843
+ const principalId = normalizePrincipal(options.principalId || 'agent-primary');
844
+ const tenantId = normalizeTenant(options.tenantId || 'acme');
845
+ const mutateAtTurns = options.mutateAtTurns && typeof options.mutateAtTurns === 'object'
846
+ ? options.mutateAtTurns
847
+ : {};
848
+
849
+ const minted = mintHandle({
850
+ principalId,
851
+ tenantId,
852
+ scope: options.scope || 'production',
853
+ kind: options.kind || 'basket',
854
+ ttlMs: options.ttlMs || DEFAULT_TTL_MS,
855
+ idempotencyKey: options.idempotencyKey,
856
+ });
857
+
858
+ const results = [];
859
+ let carried = minted.token;
860
+
861
+ for (let turn = 1; turn <= turns; turn += 1) {
862
+ const mutation = mutateAtTurns[turn] || mutateAtTurns[String(turn)] || null;
863
+ let args = { basketId: carried, turn };
864
+
865
+ if (mutation === 'drop') {
866
+ args = { turn };
867
+ } else if (mutation === 'swap') {
868
+ const other = mintHandle({
869
+ principalId: options.swapPrincipalId || 'agent-other',
870
+ tenantId: options.swapTenantId || tenantId,
871
+ scope: 'production',
872
+ kind: 'basket',
873
+ });
874
+ args = { basketId: other.token, turn };
875
+ } else if (mutation === 'mutate') {
876
+ // Flip last hex nibble of id segment — breaks HMAC
877
+ const [id, sig] = carried.split('.');
878
+ const flipped = id.slice(0, -1) + (id.endsWith('a') ? 'b' : 'a');
879
+ args = { basketId: `${flipped}.${sig}`, turn };
880
+ } else if (mutation === 'forge') {
881
+ args = { basketId: `${HANDLE_PREFIX}${'f'.repeat(ENTROPY_BYTES * 2)}.${'0'.repeat(SIG_HEX_LEN)}`, turn };
882
+ } else if (mutation === 'cross_tenant') {
883
+ // Re-use same token but claim different tenant on authorize
884
+ args = { basketId: carried, turn };
885
+ }
886
+
887
+ const authTenant = mutation === 'cross_tenant'
888
+ ? (options.crossTenantId || 'other-tenant')
889
+ : tenantId;
890
+
891
+ const auth = authorizeHandle({
892
+ args,
893
+ principalId,
894
+ tenantId: authTenant,
895
+ toolName: `turn_${turn}_tool`,
896
+ required: true,
897
+ });
898
+
899
+ results.push({
900
+ turn,
901
+ mutation: mutation || 'faithful',
902
+ allowed: auth.allowed,
903
+ code: auth.code,
904
+ authorizeCount: auth.authorizeCount || null,
905
+ });
906
+
907
+ // Faithful model re-carries the same handle; dropped/forged paths keep prior carried
908
+ // only when authorize succeeded with a token.
909
+ if (auth.allowed && auth.token) {
910
+ carried = auth.token;
911
+ }
912
+ }
913
+
914
+ return {
915
+ token: minted.token,
916
+ handleId: minted.handleId,
917
+ principalId,
918
+ tenantId,
919
+ turns,
920
+ results,
921
+ successCount: results.filter((r) => r.allowed).length,
922
+ failureCount: results.filter((r) => !r.allowed).length,
923
+ };
924
+ }
925
+
926
+ function getHandleRecord(tokenOrId) {
927
+ const parsed = parseHandleToken(tokenOrId);
928
+ const handleId = parsed.ok
929
+ ? parsed.handleId
930
+ : String(tokenOrId || '').split('.')[0];
931
+ const rec = registry.get(handleId);
932
+ return rec ? publicRecord(rec) : null;
933
+ }
934
+
935
+ function listHandlesForPrincipal(principalId, tenantId) {
936
+ const principal = normalizePrincipal(principalId);
937
+ const tenant = tenantId != null ? normalizeTenant(tenantId) : null;
938
+ purgeExpired();
939
+ const out = [];
940
+ for (const rec of registry.values()) {
941
+ if (rec.principalId !== principal) continue;
942
+ if (tenant && rec.tenantId !== tenant) continue;
943
+ out.push(publicRecord(rec));
944
+ }
945
+ return out;
946
+ }
947
+
948
+ function deny(code, reason, toolName, extra = {}) {
949
+ return {
950
+ allowed: false,
951
+ code,
952
+ reason,
953
+ toolName: toolName || null,
954
+ ...extra,
955
+ };
956
+ }
957
+
958
+ /** Test / ops helpers */
959
+ function _resetForTests(options = {}) {
960
+ registry = new Map();
961
+ idempotencyIndex = new Map();
962
+ diskLoaded = false;
963
+ if (options.now != null) {
964
+ nowFn = typeof options.now === 'function' ? options.now : () => options.now;
965
+ } else {
966
+ nowFn = () => Date.now();
967
+ }
968
+ secretOverride = options.secret || null;
969
+ storePathOverride = options.storePath || null;
970
+ if (options.loadDisk) loadFromDisk();
971
+ else diskLoaded = true; // empty in-memory registry for isolated unit tests
972
+ }
973
+
974
+ function _setNow(msOrFn) {
975
+ nowFn = typeof msOrFn === 'function' ? msOrFn : () => msOrFn;
976
+ }
977
+
978
+ function _stats() {
979
+ purgeExpired();
980
+ return {
981
+ handleCount: registry.size,
982
+ idempotencyCount: idempotencyIndex.size,
983
+ };
984
+ }
985
+
986
+ // Load durable spill once at require-time for multi-worker hosts.
987
+ loadFromDisk();
988
+
989
+ module.exports = {
990
+ HANDLE_PREFIX,
991
+ HANDLE_ARG_KEYS,
992
+ DEFAULT_TTL_MS,
993
+ MIN_TTL_MS,
994
+ MAX_TTL_MS,
995
+ mintHandle,
996
+ mintSessionHandle,
997
+ authorizeHandle,
998
+ authorizeMcpToolCall,
999
+ verifySessionHandle,
1000
+ extractHandleFromArgs,
1001
+ claimIdempotency,
1002
+ completeIdempotency,
1003
+ bindIdempotency,
1004
+ resolveIdempotency,
1005
+ revokeHandle,
1006
+ getHandleRecord,
1007
+ listHandlesForPrincipal,
1008
+ simulateMultiTurnFidelity,
1009
+ parseHandleToken,
1010
+ purgeExpired,
1011
+ loadFromDisk,
1012
+ ensureDiskLoaded,
1013
+ _resetForTests,
1014
+ _setNow,
1015
+ _stats,
1016
+ };