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.
- package/.agents/skills/cyberstrike-compare-not-clone/SKILL.md +36 -0
- package/.agents/skills/gitlab-sandbox-allowlist-not-trust/SKILL.md +77 -0
- package/.agents/skills/jit-harness-compare-not-clone/SKILL.md +34 -0
- package/.agents/skills/openui-catalog-compose-honesty/SKILL.md +64 -0
- package/.agents/skills/zvec-grep-compare-not-clone/SKILL.md +34 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/llms.txt +1 -0
- package/.well-known/mcp/server-card.json +1 -1
- package/CONTRIBUTING.md +95 -0
- package/README.md +195 -632
- package/THIRD_PARTY_NOTICES.md +89 -0
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/forge/forge.yaml +3 -3
- package/adapters/future-agi/.mcp.json +8 -0
- package/adapters/future-agi/FUTURE_AGI.md +23 -0
- package/adapters/future-agi/config.toml +3 -0
- package/adapters/future-agi/future-agi-bridge.js +9 -0
- package/adapters/future-agi/opencode.json +8 -0
- package/adapters/herdr/herdr-plugin.toml +18 -0
- package/adapters/mcp/server-stdio.js +238 -25
- package/adapters/opencode/opencode.json +1 -1
- package/adapters/workos/WORKOS.md +52 -0
- package/bin/cli.js +336 -3
- package/bin/futureagi-bridge +9 -0
- package/config/gate-templates.json +581 -4
- package/config/gates/actor-critic-audit.json +34 -0
- package/config/gates/default.json +9 -3
- package/config/gates/five-walls-governance.json +34 -0
- package/config/gates/future-agi-guardrails.json +34 -0
- package/config/gates/radware-threat-defense-2026.json +61 -0
- package/config/gates/simatree-data-governance.json +33 -0
- package/config/mcp-allowlists.json +4 -0
- package/config/merge-quality-checks.json +6 -0
- package/config/model-candidates.json +312 -29
- package/config/model-tiers.json +18 -0
- package/config/post-deploy-marketing-pages.json +10 -0
- package/config/progressive/01-wire-only.json +11 -0
- package/config/progressive/02-dashboard-empty-ok.json +10 -0
- package/config/progressive/03-one-lesson.json +10 -0
- package/config/progressive/04-warn-fires.json +11 -0
- package/config/progressive/05-strict-optional.json +11 -0
- package/config/progressive/README.md +15 -0
- package/config/schemas/broker-execution-receipt.schema.json +139 -0
- package/config/schemas/provider-execution-attestation-v1.schema.json +58 -0
- package/conformance/provider-attestation/vectors.json +320 -0
- package/docs/specs/provider-execution-attestation-v1.md +69 -0
- package/openapi/openapi.yaml +15 -0
- package/package.json +390 -148
- package/public/about.html +2 -2
- package/public/ai-malpractice-prevention.html +7 -7
- package/public/blog/a-10-dollar-vps-is-not-a-computer.html +143 -0
- package/public/blog/a-receipt-is-not-world-state.html +388 -0
- package/public/blog/git-at-agent-scale.html +374 -0
- package/public/blog/no-llm-in-the-gate.html +133 -0
- package/public/blog.html +80 -0
- package/public/case-studies.html +16 -1
- package/public/compare.html +28 -0
- package/public/diagnostic.html +216 -7
- package/public/docs/connectors.html +39 -0
- package/public/federal.html +2 -2
- package/public/founders.html +639 -0
- package/public/index.html +87 -9
- package/public/install.html +8 -8
- package/public/learn.html +39 -0
- package/public/numbers.html +2 -2
- package/public/peter.html +310 -0
- package/public/platform-partners.html +119 -0
- package/public/pricing.html +24 -3
- package/public/privacy.html +117 -0
- package/public/pro.html +17 -0
- package/public/support.html +62 -0
- package/public/terms.html +130 -0
- package/public/third-party-notices.html +95 -0
- package/public/yt.html +351 -0
- package/scripts/action-receipts.js +133 -3
- package/scripts/adaptive-governance-arena.js +349 -0
- package/scripts/admin-override.js +205 -0
- package/scripts/agent-action-inventory.js +869 -0
- package/scripts/agent-audit-trace.js +42 -2
- package/scripts/agent-egress-policy.js +1117 -0
- package/scripts/agent-memory-lifecycle.js +141 -2
- package/scripts/agent-operations-planner.js +441 -1
- package/scripts/agent-readiness.js +68 -0
- package/scripts/agent-security-central.js +647 -0
- package/scripts/allowlist-bridge-honesty.js +417 -0
- package/scripts/async-job-runner.js +102 -11
- package/scripts/audit-trail.js +212 -0
- package/scripts/billing.js +1 -1
- package/scripts/broker-execution-receipts.js +719 -0
- package/scripts/budget-aware-gates-proof.js +423 -0
- package/scripts/claude-feedback-sync.js +29 -3
- package/scripts/claw-harness-production.js +237 -0
- package/scripts/cli-schema.js +163 -1
- package/scripts/codex-runbook-flywheel.js +318 -0
- package/scripts/context-footprint.js +186 -0
- package/scripts/contextfs.js +143 -61
- package/scripts/dashboard.js +251 -32
- package/scripts/deepseek-v4-runtime-guardrails.js +72 -6
- package/scripts/docker-sandbox-planner.js +18 -0
- package/scripts/double-blind-eval-protocol.js +252 -0
- package/scripts/edotenv-rl-gateway.js +259 -0
- package/scripts/ensure-production-search-corpus.js +162 -0
- package/scripts/eval-holdout.js +311 -0
- package/scripts/feedback-aggregate.js +21 -2
- package/scripts/feedback-loop.js +87 -5
- package/scripts/feedback-quality.js +9 -0
- package/scripts/file-ledger-lock.js +4 -1
- package/scripts/financial-control-plane.js +41 -1
- package/scripts/find-dormant-requires.js +118 -0
- package/scripts/fs-utils.js +84 -8
- package/scripts/gates-engine.js +810 -63
- package/scripts/generate-case-study-outreach.js +24 -15
- package/scripts/git-at-scale.js +628 -0
- package/scripts/governance-conflict-audit.js +1650 -0
- package/scripts/governance-difficulty-curriculum.js +328 -0
- package/scripts/graphrag-retrieval.js +275 -0
- package/scripts/gurobi-optimizer.js +324 -0
- package/scripts/gurobi_optimizer.py +485 -0
- package/scripts/harness-selector.js +82 -1
- package/scripts/hidden-entry-points.js +284 -0
- package/scripts/human-escalation.js +199 -1
- package/scripts/hybrid-feedback-context.js +152 -19
- package/scripts/intent-governed-execution.js +602 -0
- package/scripts/intervention-policy.js +123 -20
- package/scripts/jit-harness-compose.js +628 -0
- package/scripts/jsonl-watcher.js +10 -0
- package/scripts/lesson-embedding-index.js +95 -12
- package/scripts/lesson-retrieval.js +105 -19
- package/scripts/local-model-profile.js +19 -2
- package/scripts/mailer/resend-mailer.js +1 -1
- package/scripts/matryoshka-embedding.js +235 -0
- package/scripts/mcp-oauth.js +42 -4
- package/scripts/mcp-session-handles.js +1016 -0
- package/scripts/mcp-wiring-doctor.js +314 -0
- package/scripts/memory-firewall.js +115 -2
- package/scripts/memory-scope-readiness.js +299 -0
- package/scripts/memory-vs-rag-route.js +161 -0
- package/scripts/model-tier-router.js +148 -21
- package/scripts/nvidia-specdecode-al-doctor.js +536 -0
- package/scripts/openui-catalog-compose-honesty.js +593 -0
- package/scripts/operational-integrity.js +19 -1
- package/scripts/override-audit.js +213 -0
- package/scripts/package-manager-honesty-doctor.js +458 -0
- package/scripts/pr-manager.js +63 -1
- package/scripts/prove-herdr-adapter.js +52 -0
- package/scripts/prove-memory-pyramid-and-symbolic-canvas.js +95 -0
- package/scripts/prove-workos.js +73 -0
- package/scripts/provider-attestation-conformance.js +192 -0
- package/scripts/provider-receipt-contract.js +136 -0
- package/scripts/qwen38-max-cost-optimizer.js +401 -0
- package/scripts/radware-threat-defense.js +280 -0
- package/scripts/rag-embedding-identity.js +221 -0
- package/scripts/rag-precision-guardrails.js +112 -2
- package/scripts/remote-feedback-capture.js +159 -0
- package/scripts/research-agent-harness.js +256 -0
- package/scripts/rsi-safety-hillclimb.js +200 -0
- package/scripts/rule-sprawl.js +188 -0
- package/scripts/schedule-manager.js +147 -0
- package/scripts/self-heal.js +8 -0
- package/scripts/session-lease.js +415 -0
- package/scripts/simatree-data-governance.js +347 -0
- package/scripts/slo-alert-engine.js +172 -7
- package/scripts/solver-parity.js +539 -0
- package/scripts/stealth-memory-injection-gate.js +333 -0
- package/scripts/switchyard-router.js +366 -0
- package/scripts/telemetry-analytics.js +84 -27
- package/scripts/temporal-decay-weighting.js +138 -0
- package/scripts/test-all.js +165 -0
- package/scripts/token-savings.js +42 -0
- package/scripts/tool-kpi-tracker.js +108 -5
- package/scripts/tool-registry.js +193 -5
- package/scripts/universal-claim-evaluator.js +14 -2
- package/scripts/vector-store.js +279 -9
- package/scripts/workflow-notebook.js +391 -0
- package/scripts/workflow-sentinel.js +111 -12
- package/scripts/workos-production-guard.js +260 -0
- package/scripts/workspace-search-route.js +515 -0
- package/server.json +2 -2
- package/src/agent-identity-boundary.js +76 -0
- package/src/agent-retrieval-cache.js +155 -0
- package/src/alert-noise-ledger.js +502 -0
- package/src/api/server.js +724 -153
- package/src/git-fast-cache.js +220 -0
- package/src/git-wal-sync.js +156 -0
- package/src/hash-anchored-edit.js +82 -0
- package/src/hermes-platform-protocol.js +475 -0
- package/src/hermes-sync-plane.js +241 -0
- package/src/index.js +30 -1
- package/src/iso42001-compliance-guard.js +97 -0
- package/src/latency-budget.js +244 -0
- package/src/mcp-writeguard.js +316 -0
- package/src/miminions-adapter.js +106 -0
- package/src/pipeline-compass.js +104 -0
- package/src/ppl-alert-pipeline.js +284 -0
- package/src/rendezvous-router.js +90 -0
- 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
|
+
};
|