thumbgate 1.23.2 → 1.25.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/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.well-known/mcp/server-card.json +1 -1
- package/README.md +15 -4
- package/adapters/claude/.mcp.json +2 -2
- package/adapters/mcp/server-stdio.js +1 -1
- package/adapters/opencode/opencode.json +1 -1
- package/package.json +16 -4
- package/public/ai-malpractice-prevention.html +62 -5
- package/public/index.html +2 -2
- package/public/numbers.html +2 -2
- package/scripts/context-manager.js +22 -4
- package/scripts/gates-engine.js +98 -13
- package/scripts/mcp-oauth.js +293 -0
- package/scripts/security-scanner.js +80 -10
- package/scripts/thompson-sampling.js +16 -10
- package/scripts/tool-registry.js +35 -1
- package/scripts/vector-store.js +1 -0
- package/src/api/server.js +204 -2
package/scripts/gates-engine.js
CHANGED
|
@@ -55,6 +55,9 @@ const {
|
|
|
55
55
|
const {
|
|
56
56
|
evaluateSecurityScan,
|
|
57
57
|
} = require('./security-scanner');
|
|
58
|
+
const { evaluateSequenceState } = loadOptionalModule('./sequence-guard', () => ({
|
|
59
|
+
evaluateSequenceState: () => null,
|
|
60
|
+
}));
|
|
58
61
|
const { getAutoGatesPath } = require('./auto-promote-gates');
|
|
59
62
|
const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
|
|
60
63
|
|
|
@@ -2132,8 +2135,9 @@ function buildRecentCorrectiveActionsContext(options = {}) {
|
|
|
2132
2135
|
function buildRelevantLessonContext(toolName, toolInput) {
|
|
2133
2136
|
if (!toolName) return null;
|
|
2134
2137
|
|
|
2135
|
-
const { retrieveRelevantLessons } = loadOptionalModule('./lesson-retrieval', () => ({
|
|
2138
|
+
const { retrieveRelevantLessons, calculateRetrievalEntropy } = loadOptionalModule('./lesson-retrieval', () => ({
|
|
2136
2139
|
retrieveRelevantLessons: () => [],
|
|
2140
|
+
calculateRetrievalEntropy: () => 0,
|
|
2137
2141
|
}));
|
|
2138
2142
|
|
|
2139
2143
|
// Extract a searchable action context from the tool input
|
|
@@ -2142,23 +2146,77 @@ function buildRelevantLessonContext(toolName, toolInput) {
|
|
|
2142
2146
|
|
|
2143
2147
|
try {
|
|
2144
2148
|
const lessons = retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
|
|
2145
|
-
// retrieveRelevantLessons already filters at relevanceScore > 0.1 internally;
|
|
2146
|
-
// any negative lesson that survives retrieval is relevant enough to surface.
|
|
2147
|
-
const negative = lessons.filter((l) => l.signal === 'negative');
|
|
2148
|
-
if (negative.length === 0) return null;
|
|
2149
|
-
|
|
2150
|
-
const formatted = negative.map((l) => {
|
|
2151
|
-
const title = (l.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
|
|
2152
|
-
const advice = extractAvoidanceAdvice(l.content);
|
|
2153
|
-
return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
|
|
2154
|
-
});
|
|
2155
2149
|
|
|
2156
|
-
|
|
2150
|
+
const entropy = calculateRetrievalEntropy(lessons);
|
|
2151
|
+
if (entropy > 0.7) {
|
|
2152
|
+
recordStat("retrieval_entropy_high", "block");
|
|
2153
|
+
return { decision: "deny", gate: "knowledge-conflict-gate", message: "✗ THUMBGATE: Action blocked due to high Knowledge Entropy (conflicting past lessons).", severity: "high" };
|
|
2154
|
+
}
|
|
2155
|
+
return formatNegativeLessonContext(lessons);
|
|
2157
2156
|
} catch {
|
|
2158
2157
|
return null;
|
|
2159
2158
|
}
|
|
2160
2159
|
}
|
|
2161
2160
|
|
|
2161
|
+
/**
|
|
2162
|
+
* Async counterpart of buildRelevantLessonContext: uses HYBRID (dense embeddings +
|
|
2163
|
+
* lexical) retrieval so the agent is warned about semantically-related past mistakes
|
|
2164
|
+
* even when they share no keywords with the current action. Wired into runAsync.
|
|
2165
|
+
* Degrades to the lexical result automatically when no embedder is available.
|
|
2166
|
+
*/
|
|
2167
|
+
async function buildRelevantLessonContextAsync(toolName, toolInput) {
|
|
2168
|
+
if (!toolName) return null;
|
|
2169
|
+
|
|
2170
|
+
const { retrieveRelevantLessonsAsync, retrieveRelevantLessons, calculateRetrievalEntropy } = loadOptionalModule(
|
|
2171
|
+
'./lesson-retrieval',
|
|
2172
|
+
() => ({ retrieveRelevantLessonsAsync: null, retrieveRelevantLessons: () => [], calculateRetrievalEntropy: () => 0 }),
|
|
2173
|
+
);
|
|
2174
|
+
|
|
2175
|
+
const actionContext = extractActionContext(toolName, toolInput);
|
|
2176
|
+
if (!actionContext) return null;
|
|
2177
|
+
|
|
2178
|
+
try {
|
|
2179
|
+
const lessons = retrieveRelevantLessonsAsync
|
|
2180
|
+
? await retrieveRelevantLessonsAsync(toolName, actionContext, { maxResults: 3 })
|
|
2181
|
+
: retrieveRelevantLessons(toolName, actionContext, { maxResults: 3 });
|
|
2182
|
+
|
|
2183
|
+
// Knowledge Conflict Detection: if retrieved lessons have high sentiment entropy,
|
|
2184
|
+
// it indicates conflicting past evidence. Block and require human disambiguation.
|
|
2185
|
+
const entropy = calculateRetrievalEntropy(lessons);
|
|
2186
|
+
if (entropy > 0.7) {
|
|
2187
|
+
recordStat('retrieval_entropy_high', 'block');
|
|
2188
|
+
return {
|
|
2189
|
+
decision: 'deny',
|
|
2190
|
+
gate: 'knowledge-conflict-gate',
|
|
2191
|
+
message: '✗ THUMBGATE: Action blocked due to high Knowledge Entropy (conflicting past lessons). Please disambiguate your instructions or verify the intended behavior manually.',
|
|
2192
|
+
severity: 'high',
|
|
2193
|
+
};
|
|
2194
|
+
}
|
|
2195
|
+
|
|
2196
|
+
return formatNegativeLessonContext(lessons);
|
|
2197
|
+
} catch {
|
|
2198
|
+
return null;
|
|
2199
|
+
}
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
/**
|
|
2203
|
+
* Shared formatter: render the negative (mistake) lessons that survived retrieval
|
|
2204
|
+
* into the PreToolUse warning block. Retrieval already filters by relevance, so any
|
|
2205
|
+
* negative lesson present is relevant enough to surface.
|
|
2206
|
+
*/
|
|
2207
|
+
function formatNegativeLessonContext(lessons) {
|
|
2208
|
+
const negative = (lessons || []).filter((l) => l.signal === 'negative');
|
|
2209
|
+
if (negative.length === 0) return null;
|
|
2210
|
+
|
|
2211
|
+
const formatted = negative.map((l) => {
|
|
2212
|
+
const title = (l.title || '').replace(/^MISTAKE:\s*/, '').slice(0, 140);
|
|
2213
|
+
const advice = extractAvoidanceAdvice(l.content);
|
|
2214
|
+
return advice ? ` • ${title}\n → ${advice}` : ` • ${title}`;
|
|
2215
|
+
});
|
|
2216
|
+
|
|
2217
|
+
return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
|
|
2218
|
+
}
|
|
2219
|
+
|
|
2162
2220
|
function extractActionContext(toolName, toolInput) {
|
|
2163
2221
|
if (!toolInput) return toolName;
|
|
2164
2222
|
const parts = [toolName];
|
|
@@ -2196,6 +2254,12 @@ async function runAsync(input) {
|
|
|
2196
2254
|
|
|
2197
2255
|
const toolName = input.tool_name || '';
|
|
2198
2256
|
const toolInput = input.tool_input || {};
|
|
2257
|
+
|
|
2258
|
+
const sequenceGuard = evaluateSequenceState(toolName, toolInput);
|
|
2259
|
+
if (sequenceGuard && sequenceGuard.decision === 'deny') {
|
|
2260
|
+
return formatOutput(sequenceGuard);
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2199
2263
|
const result = await evaluateGatesAsync(toolName, toolInput);
|
|
2200
2264
|
|
|
2201
2265
|
// Attach security warnings to allow/warn results
|
|
@@ -2208,11 +2272,18 @@ async function runAsync(input) {
|
|
|
2208
2272
|
}
|
|
2209
2273
|
}
|
|
2210
2274
|
|
|
2275
|
+
|
|
2211
2276
|
const behavioralContext = buildBehavioralContext();
|
|
2212
|
-
const lessonContext =
|
|
2277
|
+
const lessonContext = await buildRelevantLessonContextAsync(toolName, toolInput);
|
|
2278
|
+
|
|
2279
|
+
if (lessonContext && lessonContext.decision === "deny") {
|
|
2280
|
+
return formatOutput(lessonContext);
|
|
2281
|
+
}
|
|
2282
|
+
|
|
2213
2283
|
const recentContext = buildRecentCorrectiveActionsContext();
|
|
2214
2284
|
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
2215
2285
|
return formatOutput(result, combinedContext);
|
|
2286
|
+
|
|
2216
2287
|
}
|
|
2217
2288
|
|
|
2218
2289
|
function run(input) {
|
|
@@ -2229,6 +2300,12 @@ function run(input) {
|
|
|
2229
2300
|
|
|
2230
2301
|
const toolName = input.tool_name || '';
|
|
2231
2302
|
const toolInput = input.tool_input || {};
|
|
2303
|
+
|
|
2304
|
+
const sequenceGuard = evaluateSequenceState(toolName, toolInput);
|
|
2305
|
+
if (sequenceGuard && sequenceGuard.decision === 'deny') {
|
|
2306
|
+
return formatOutput(sequenceGuard);
|
|
2307
|
+
}
|
|
2308
|
+
|
|
2232
2309
|
const result = evaluateGates(toolName, toolInput);
|
|
2233
2310
|
|
|
2234
2311
|
// Attach security warnings to allow/warn results
|
|
@@ -2241,11 +2318,18 @@ function run(input) {
|
|
|
2241
2318
|
}
|
|
2242
2319
|
}
|
|
2243
2320
|
|
|
2321
|
+
|
|
2244
2322
|
const behavioralContext = buildBehavioralContext();
|
|
2245
2323
|
const lessonContext = buildRelevantLessonContext(toolName, toolInput);
|
|
2324
|
+
|
|
2325
|
+
if (lessonContext && lessonContext.decision === "deny") {
|
|
2326
|
+
return formatOutput(lessonContext);
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2246
2329
|
const recentContext = buildRecentCorrectiveActionsContext();
|
|
2247
2330
|
const combinedContext = mergeContextStrings(lessonContext, recentContext, behavioralContext);
|
|
2248
2331
|
return formatOutput(result, combinedContext);
|
|
2332
|
+
|
|
2249
2333
|
}
|
|
2250
2334
|
|
|
2251
2335
|
// ---------------------------------------------------------------------------
|
|
@@ -2562,6 +2646,7 @@ module.exports = {
|
|
|
2562
2646
|
buildBehavioralContext,
|
|
2563
2647
|
buildRecentCorrectiveActionsContext,
|
|
2564
2648
|
buildRelevantLessonContext,
|
|
2649
|
+
buildRelevantLessonContextAsync,
|
|
2565
2650
|
extractActionContext,
|
|
2566
2651
|
extractAvoidanceAdvice,
|
|
2567
2652
|
mergeContextStrings,
|
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* mcp-oauth.js — OAuth 2.1 (PKCE) authorization-server machinery for the remote
|
|
6
|
+
* ThumbGate MCP connector, required by the Claude Connectors Directory
|
|
7
|
+
* ("Use OAuth 2.0 for authenticated services").
|
|
8
|
+
*
|
|
9
|
+
* Pure, dependency-free, IO-free functions + an injectable store, so the full
|
|
10
|
+
* flow is unit-testable without a network. The HTTP wiring (in src/api/server.js)
|
|
11
|
+
* is a thin shell over these.
|
|
12
|
+
*
|
|
13
|
+
* Implements:
|
|
14
|
+
* - RFC 9728 protected-resource metadata
|
|
15
|
+
* - RFC 8414 authorization-server metadata
|
|
16
|
+
* - RFC 7591 dynamic client registration (open registration)
|
|
17
|
+
* - Authorization-code grant with mandatory PKCE S256 (RFC 7636)
|
|
18
|
+
* - Bearer access tokens bound to a ThumbGate API key (so existing gating is unchanged)
|
|
19
|
+
*
|
|
20
|
+
* SECURITY NOTES:
|
|
21
|
+
* - PKCE S256 is mandatory; the plain method is rejected.
|
|
22
|
+
* - Auth codes are single-use and short-lived (60s); access tokens TTL-bound.
|
|
23
|
+
* - redirect_uri is matched exactly against the registered set.
|
|
24
|
+
* - The token never exposes the bound ThumbGate key; it maps server-side only.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
|
|
29
|
+
const AUTH_CODE_TTL_MS = 60 * 1000; // 1 minute
|
|
30
|
+
const ACCESS_TOKEN_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
31
|
+
const DEFAULT_SCOPE = 'mcp:read mcp:write';
|
|
32
|
+
|
|
33
|
+
// Upper bounds on the in-memory store. The registration and authorization
|
|
34
|
+
// endpoints are reachable pre-auth, so without a cap a malicious caller could
|
|
35
|
+
// grow these Maps unboundedly and exhaust server memory. When a Map is full we
|
|
36
|
+
// evict the oldest entry (FIFO) rather than deny service to legitimate clients.
|
|
37
|
+
const MAX_CLIENTS = 10000;
|
|
38
|
+
const MAX_CODES = 10000;
|
|
39
|
+
const MAX_TOKENS = 50000;
|
|
40
|
+
|
|
41
|
+
function now() {
|
|
42
|
+
return Date.now();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function randomToken(bytes = 32) {
|
|
46
|
+
return crypto.randomBytes(bytes).toString('base64url');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function base64UrlSha256(input) {
|
|
50
|
+
return crypto.createHash('sha256').update(String(input)).digest('base64url');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Create a fresh in-memory store.
|
|
55
|
+
*
|
|
56
|
+
* DURABILITY (known limitation): this uses plain Maps, so a process restart or a
|
|
57
|
+
* multi-instance / load-balanced deployment will drop issued tokens and
|
|
58
|
+
* registered clients (clients see 401s, in-flight authorizations break). For
|
|
59
|
+
* single-instance use this is fine; production multi-tenancy needs a durable,
|
|
60
|
+
* shared backing (Redis/DB) — tracked as the per-user-data-scoping follow-up.
|
|
61
|
+
* Entry counts are bounded (see MAX_* and capInsert) to prevent memory
|
|
62
|
+
* exhaustion from anonymous calls to the registration/authorization endpoints.
|
|
63
|
+
*/
|
|
64
|
+
function createStore() {
|
|
65
|
+
return {
|
|
66
|
+
clients: new Map(),
|
|
67
|
+
codes: new Map(),
|
|
68
|
+
tokens: new Map(),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Insert into a Map, evicting the oldest entry (FIFO) once `max` is reached. */
|
|
73
|
+
function capInsert(map, key, value, max) {
|
|
74
|
+
if (map.size >= max && !map.has(key)) {
|
|
75
|
+
const oldest = map.keys().next().value;
|
|
76
|
+
if (oldest !== undefined) map.delete(oldest);
|
|
77
|
+
}
|
|
78
|
+
map.set(key, value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Metadata (RFC 9728 / RFC 8414)
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function trimSlash(u) {
|
|
86
|
+
// Non-regex trailing-slash strip (avoids a SonarCloud S5852 false-positive on a
|
|
87
|
+
// provably-linear pattern).
|
|
88
|
+
let s = String(u || '');
|
|
89
|
+
while (s.endsWith('/')) s = s.slice(0, -1);
|
|
90
|
+
return s;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function buildProtectedResourceMetadata(baseUrl) {
|
|
94
|
+
const base = trimSlash(baseUrl);
|
|
95
|
+
return {
|
|
96
|
+
resource: `${base}/mcp`,
|
|
97
|
+
authorization_servers: [base],
|
|
98
|
+
bearer_methods_supported: ['header'],
|
|
99
|
+
scopes_supported: ['mcp:read', 'mcp:write'],
|
|
100
|
+
resource_documentation: `${base}/docs/connectors`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function buildAuthServerMetadata(baseUrl) {
|
|
105
|
+
const base = trimSlash(baseUrl);
|
|
106
|
+
return {
|
|
107
|
+
issuer: base,
|
|
108
|
+
authorization_endpoint: `${base}/oauth/authorize`,
|
|
109
|
+
token_endpoint: `${base}/oauth/token`,
|
|
110
|
+
registration_endpoint: `${base}/oauth/register`,
|
|
111
|
+
scopes_supported: ['mcp:read', 'mcp:write'],
|
|
112
|
+
response_types_supported: ['code'],
|
|
113
|
+
grant_types_supported: ['authorization_code'],
|
|
114
|
+
code_challenge_methods_supported: ['S256'],
|
|
115
|
+
token_endpoint_auth_methods_supported: ['none'],
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// Dynamic client registration (RFC 7591)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
// The MCP authorization spec is explicit: "All redirect URIs MUST be either
|
|
124
|
+
// `localhost` or use HTTPS." We therefore accept only HTTPS and loopback
|
|
125
|
+
// (http://localhost | http://127.0.0.1) and reject every other scheme —
|
|
126
|
+
// including native-app custom schemes (myapp://, intent://, etc.), which the MCP
|
|
127
|
+
// profile does not sanction and the real client (Claude) does not use.
|
|
128
|
+
function isAllowedRedirectUri(uri) {
|
|
129
|
+
const u = String(uri || '');
|
|
130
|
+
if (/^https:\/\//i.test(u)) return true;
|
|
131
|
+
if (/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?(\/|$)/i.test(u)) return true;
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function registerClient(store, body = {}) {
|
|
136
|
+
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris.filter(Boolean) : [];
|
|
137
|
+
if (redirectUris.length === 0) {
|
|
138
|
+
return { error: 'invalid_redirect_uri', error_description: 'redirect_uris is required' };
|
|
139
|
+
}
|
|
140
|
+
for (const uri of redirectUris) {
|
|
141
|
+
if (!isAllowedRedirectUri(uri)) {
|
|
142
|
+
return { error: 'invalid_redirect_uri', error_description: `unsupported redirect_uri: ${uri}` };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const clientId = `tg_${randomToken(16)}`;
|
|
146
|
+
const record = {
|
|
147
|
+
client_id: clientId,
|
|
148
|
+
redirect_uris: redirectUris,
|
|
149
|
+
token_endpoint_auth_method: 'none',
|
|
150
|
+
grant_types: ['authorization_code'],
|
|
151
|
+
response_types: ['code'],
|
|
152
|
+
client_name: typeof body.client_name === 'string' ? body.client_name.slice(0, 200) : 'mcp-client',
|
|
153
|
+
created_at: now(),
|
|
154
|
+
};
|
|
155
|
+
capInsert(store.clients, clientId, record, MAX_CLIENTS);
|
|
156
|
+
return record;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getClient(store, clientId) {
|
|
160
|
+
return store.clients.get(clientId) || null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
// Authorization code (PKCE S256)
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Issue an auth code. `boundKey` is the ThumbGate API key the resulting access
|
|
169
|
+
* token will act as (resolved by the authorize step once the user consents).
|
|
170
|
+
*/
|
|
171
|
+
function createAuthorizationCode(store, {
|
|
172
|
+
clientId, redirectUri, codeChallenge, codeChallengeMethod, scope, boundKey, state, resource,
|
|
173
|
+
} = {}) {
|
|
174
|
+
const client = getClient(store, clientId);
|
|
175
|
+
if (!client) return { error: 'invalid_client' };
|
|
176
|
+
if (!client.redirect_uris.includes(redirectUri)) return { error: 'invalid_request', error_description: 'redirect_uri mismatch' };
|
|
177
|
+
if (codeChallengeMethod !== 'S256') return { error: 'invalid_request', error_description: 'code_challenge_method must be S256' };
|
|
178
|
+
if (!codeChallenge || String(codeChallenge).length < 16) return { error: 'invalid_request', error_description: 'code_challenge required' };
|
|
179
|
+
|
|
180
|
+
const code = randomToken(24);
|
|
181
|
+
capInsert(store.codes, code, {
|
|
182
|
+
clientId,
|
|
183
|
+
redirectUri,
|
|
184
|
+
codeChallenge,
|
|
185
|
+
scope: scope || DEFAULT_SCOPE,
|
|
186
|
+
boundKey: boundKey || '',
|
|
187
|
+
resource: resource || '', // RFC 8707 resource indicator (the MCP server URL)
|
|
188
|
+
expiresAt: now() + AUTH_CODE_TTL_MS,
|
|
189
|
+
used: false,
|
|
190
|
+
}, MAX_CODES);
|
|
191
|
+
return { code, state };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function verifyPkce(codeChallenge, codeVerifier) {
|
|
195
|
+
if (!codeChallenge || !codeVerifier) return false;
|
|
196
|
+
// RFC 7636: verifier 43–128 chars.
|
|
197
|
+
if (String(codeVerifier).length < 43 || String(codeVerifier).length > 128) return false;
|
|
198
|
+
try {
|
|
199
|
+
return crypto.timingSafeEqual(
|
|
200
|
+
Buffer.from(base64UrlSha256(codeVerifier)),
|
|
201
|
+
Buffer.from(String(codeChallenge)),
|
|
202
|
+
);
|
|
203
|
+
} catch {
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Token exchange + validation
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
function exchangeCode(store, { code, codeVerifier, clientId, redirectUri, resource } = {}) {
|
|
213
|
+
const entry = store.codes.get(code);
|
|
214
|
+
if (!entry) return { error: 'invalid_grant', error_description: 'unknown code' };
|
|
215
|
+
// Single-use + expiry: consume regardless of outcome.
|
|
216
|
+
store.codes.delete(code);
|
|
217
|
+
if (entry.used) return { error: 'invalid_grant', error_description: 'code already used' };
|
|
218
|
+
if (now() > entry.expiresAt) return { error: 'invalid_grant', error_description: 'code expired' };
|
|
219
|
+
if (entry.clientId !== clientId) return { error: 'invalid_grant', error_description: 'client mismatch' };
|
|
220
|
+
if (entry.redirectUri !== redirectUri) return { error: 'invalid_grant', error_description: 'redirect_uri mismatch' };
|
|
221
|
+
if (!verifyPkce(entry.codeChallenge, codeVerifier)) return { error: 'invalid_grant', error_description: 'PKCE verification failed' };
|
|
222
|
+
// RFC 8707: the resource at token time must match the one bound at authorize time.
|
|
223
|
+
if (entry.resource && resource && entry.resource !== resource) {
|
|
224
|
+
return { error: 'invalid_target', error_description: 'resource indicator mismatch' };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const accessToken = `tgat_${randomToken(32)}`;
|
|
228
|
+
capInsert(store.tokens, accessToken, {
|
|
229
|
+
boundKey: entry.boundKey,
|
|
230
|
+
scope: entry.scope,
|
|
231
|
+
clientId,
|
|
232
|
+
aud: entry.resource || resource || '',
|
|
233
|
+
expiresAt: now() + ACCESS_TOKEN_TTL_MS,
|
|
234
|
+
}, MAX_TOKENS);
|
|
235
|
+
return {
|
|
236
|
+
access_token: accessToken,
|
|
237
|
+
token_type: 'Bearer',
|
|
238
|
+
expires_in: Math.floor(ACCESS_TOKEN_TTL_MS / 1000),
|
|
239
|
+
scope: entry.scope,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Resolve an access token to its session, or null if invalid/expired. */
|
|
244
|
+
function resolveAccessToken(store, token) {
|
|
245
|
+
if (!token) return null;
|
|
246
|
+
const entry = store.tokens.get(token);
|
|
247
|
+
if (!entry) return null;
|
|
248
|
+
if (now() > entry.expiresAt) {
|
|
249
|
+
store.tokens.delete(token);
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
252
|
+
return { boundKey: entry.boundKey, scope: entry.scope, clientId: entry.clientId, aud: entry.aud };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* RFC 8707 audience validation: a token is valid for `expectedResource` only if it
|
|
257
|
+
* was issued for it (or carries no audience, for back-compat). MCP servers MUST
|
|
258
|
+
* reject tokens minted for a different resource.
|
|
259
|
+
*/
|
|
260
|
+
function tokenAudienceValid(session, expectedResource) {
|
|
261
|
+
if (!session) return false;
|
|
262
|
+
if (!session.aud) return true; // no audience recorded — accept (back-compat)
|
|
263
|
+
return session.aud === expectedResource;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Best-effort GC of expired codes/tokens (call opportunistically). */
|
|
267
|
+
function pruneExpired(store) {
|
|
268
|
+
const t = now();
|
|
269
|
+
for (const [k, v] of store.codes) if (t > v.expiresAt) store.codes.delete(k);
|
|
270
|
+
for (const [k, v] of store.tokens) if (t > v.expiresAt) store.tokens.delete(k);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
module.exports = {
|
|
274
|
+
createStore,
|
|
275
|
+
buildProtectedResourceMetadata,
|
|
276
|
+
buildAuthServerMetadata,
|
|
277
|
+
registerClient,
|
|
278
|
+
getClient,
|
|
279
|
+
createAuthorizationCode,
|
|
280
|
+
verifyPkce,
|
|
281
|
+
exchangeCode,
|
|
282
|
+
resolveAccessToken,
|
|
283
|
+
tokenAudienceValid,
|
|
284
|
+
pruneExpired,
|
|
285
|
+
isAllowedRedirectUri,
|
|
286
|
+
base64UrlSha256,
|
|
287
|
+
AUTH_CODE_TTL_MS,
|
|
288
|
+
ACCESS_TOKEN_TTL_MS,
|
|
289
|
+
DEFAULT_SCOPE,
|
|
290
|
+
MAX_CLIENTS,
|
|
291
|
+
MAX_CODES,
|
|
292
|
+
MAX_TOKENS,
|
|
293
|
+
};
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
'use strict';
|
|
3
3
|
|
|
4
|
+
const { loadOptionalModule } = require("./private-core-boundary");
|
|
5
|
+
|
|
6
|
+
|
|
4
7
|
/**
|
|
5
8
|
* Security Scanner — OWASP-aware static analysis for PreToolUse checks.
|
|
6
9
|
*
|
|
@@ -16,6 +19,10 @@
|
|
|
16
19
|
const fs = require('fs');
|
|
17
20
|
const path = require('path');
|
|
18
21
|
const { recordAuditEvent, auditToFeedback } = require('./audit-trail');
|
|
22
|
+
const { scanInstallCommand, detectSlopsquat } = loadOptionalModule('./slopsquat-guard', () => ({
|
|
23
|
+
scanInstallCommand: () => ({ detected: false, findings: [] }),
|
|
24
|
+
detectSlopsquat: () => null,
|
|
25
|
+
}));
|
|
19
26
|
|
|
20
27
|
// ---------------------------------------------------------------------------
|
|
21
28
|
// Vulnerability pattern definitions (OWASP Top 10 + supply chain)
|
|
@@ -277,6 +284,18 @@ function scanDependencyChange(oldContent, newContent) {
|
|
|
277
284
|
path: 'package.json',
|
|
278
285
|
});
|
|
279
286
|
}
|
|
287
|
+
|
|
288
|
+
// Tier 3: Slopsquat Guard — deterministic typosquat detection
|
|
289
|
+
const slopsquatFinding = detectSlopsquat(pkg, 'npm');
|
|
290
|
+
if (slopsquatFinding) {
|
|
291
|
+
findings.push({
|
|
292
|
+
id: slopsquatFinding.id,
|
|
293
|
+
category: 'supply-chain',
|
|
294
|
+
severity: slopsquatFinding.severity,
|
|
295
|
+
label: slopsquatFinding.label,
|
|
296
|
+
path: 'package.json',
|
|
297
|
+
});
|
|
298
|
+
}
|
|
280
299
|
}
|
|
281
300
|
}
|
|
282
301
|
}
|
|
@@ -313,27 +332,68 @@ function scanDependencyChange(oldContent, newContent) {
|
|
|
313
332
|
* @param {Object} input - Hook input { tool_name, tool_input }
|
|
314
333
|
* @returns {Object|null} Gate result or null if clean
|
|
315
334
|
*/
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Evaluate slopsquat guard for a Bash command.
|
|
338
|
+
* @param {string} toolName
|
|
339
|
+
* @param {Object} toolInput
|
|
340
|
+
* @returns {Object|null}
|
|
341
|
+
*/
|
|
342
|
+
function evaluateSlopsquatScan(toolName, toolInput) {
|
|
343
|
+
if (toolName !== "Bash") return null;
|
|
344
|
+
const command = toolInput.command || "";
|
|
345
|
+
if (!command) return null;
|
|
346
|
+
|
|
347
|
+
const { resolveMode, scanInstallCommand } = loadOptionalModule("./slopsquat-guard", () => ({
|
|
348
|
+
resolveMode: () => "block",
|
|
349
|
+
scanInstallCommand: () => ({ detected: false, findings: [] }),
|
|
350
|
+
}));
|
|
351
|
+
|
|
352
|
+
const mode = resolveMode();
|
|
353
|
+
if (mode === "off") return null;
|
|
354
|
+
|
|
355
|
+
const result = scanInstallCommand(command);
|
|
356
|
+
if (!result.detected) return null;
|
|
357
|
+
|
|
358
|
+
const hasCritical = result.findings.some(f => f.severity === "critical");
|
|
359
|
+
const decision = (mode === "block" && hasCritical) ? "deny" : "warn";
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
decision,
|
|
363
|
+
gate: "slopsquat-guard",
|
|
364
|
+
message: "✗ THUMBGATE: " + result.findings[0].label,
|
|
365
|
+
severity: hasCritical ? "critical" : "high",
|
|
366
|
+
reasoning: result.findings.map(f => f.label),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
|
|
316
370
|
function evaluateSecurityScan(input = {}) {
|
|
317
371
|
const toolName = input.tool_name || input.toolName || '';
|
|
318
372
|
const toolInput = input.tool_input || {};
|
|
319
373
|
|
|
320
|
-
// Only scan write-type operations
|
|
374
|
+
// Only scan write-type operations and Bash commands
|
|
321
375
|
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit']);
|
|
322
|
-
|
|
376
|
+
const IS_BASH = toolName === 'Bash';
|
|
377
|
+
|
|
378
|
+
if (!WRITE_TOOLS.has(toolName) && !IS_BASH) {
|
|
323
379
|
return null;
|
|
324
380
|
}
|
|
325
381
|
|
|
326
382
|
const filePath = toolInput.file_path || toolInput.path || '';
|
|
327
383
|
const content = toolInput.content || toolInput.new_string || '';
|
|
384
|
+
const command = toolInput.command || '';
|
|
328
385
|
|
|
329
|
-
if (!content) return null;
|
|
386
|
+
if (!content && !command) return null;
|
|
330
387
|
|
|
331
|
-
// Tier 1: Code vulnerability scan
|
|
332
|
-
|
|
388
|
+
// Tier 1: Code vulnerability scan (for Edits)
|
|
389
|
+
let codeResult = { detected: false, findings: [] };
|
|
390
|
+
if (content) {
|
|
391
|
+
codeResult = scanCode(content, filePath);
|
|
392
|
+
}
|
|
333
393
|
|
|
334
394
|
// Tier 2: Supply chain scan for package.json changes
|
|
335
395
|
let supplyChainResult = { detected: false, findings: [] };
|
|
336
|
-
if (filePath && path.basename(filePath) === 'package.json') {
|
|
396
|
+
if (filePath && path.basename(filePath) === 'package.json' && content) {
|
|
337
397
|
let oldContent = '';
|
|
338
398
|
try {
|
|
339
399
|
const absPath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
@@ -344,7 +404,14 @@ function evaluateSecurityScan(input = {}) {
|
|
|
344
404
|
supplyChainResult = scanDependencyChange(oldContent, content);
|
|
345
405
|
}
|
|
346
406
|
|
|
347
|
-
|
|
407
|
+
// Tier 3: Slopsquat Guard for Bash commands
|
|
408
|
+
let slopsquatResult = { detected: false, findings: [] };
|
|
409
|
+
if (IS_BASH && command) {
|
|
410
|
+
const slopsquatGate = evaluateSlopsquatScan(toolName, toolInput);
|
|
411
|
+
if (slopsquatGate) return slopsquatGate;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const allFindings = [...codeResult.findings, ...supplyChainResult.findings, ...slopsquatResult.findings];
|
|
348
415
|
if (allFindings.length === 0) return null;
|
|
349
416
|
|
|
350
417
|
// Determine overall severity
|
|
@@ -359,16 +426,18 @@ function evaluateSecurityScan(input = {}) {
|
|
|
359
426
|
`[${f.severity.toUpperCase()}] ${f.label}${f.line ? ` (line ${f.line})` : ''}`
|
|
360
427
|
).join('; ');
|
|
361
428
|
|
|
362
|
-
const message = `Security scan detected ${allFindings.length} issue(s) in ${filePath || 'code'}: ${summary}`;
|
|
429
|
+
const message = `Security scan detected ${allFindings.length} issue(s) in ${filePath || (IS_BASH ? 'command' : 'code')}: ${summary}`;
|
|
363
430
|
|
|
364
431
|
const reasoning = [
|
|
365
|
-
|
|
432
|
+
IS_BASH
|
|
433
|
+
? `Scanned Bash command for slopsquat/typosquat risk: "${command.slice(0, 100)}..."`
|
|
434
|
+
: `Scanned ${content.length} bytes of content being written to ${filePath || 'unknown file'}`,
|
|
366
435
|
...allFindings.map(f => `${f.category}/${f.id}: ${f.label}${f.match ? ` — matched: ${f.match.slice(0, 60)}` : ''}`),
|
|
367
436
|
];
|
|
368
437
|
|
|
369
438
|
recordAuditEvent({
|
|
370
439
|
toolName,
|
|
371
|
-
toolInput: { file_path: filePath, content_length: content.length },
|
|
440
|
+
toolInput: { file_path: filePath, content_length: content.length, command: IS_BASH ? command : undefined },
|
|
372
441
|
decision,
|
|
373
442
|
gateId,
|
|
374
443
|
message,
|
|
@@ -439,6 +508,7 @@ function scanGitDiff(diffContent) {
|
|
|
439
508
|
// ---------------------------------------------------------------------------
|
|
440
509
|
|
|
441
510
|
module.exports = {
|
|
511
|
+
evaluateSlopsquatScan,
|
|
442
512
|
VULN_PATTERNS,
|
|
443
513
|
SUPPLY_CHAIN_PATTERNS,
|
|
444
514
|
scanCode,
|
|
@@ -302,24 +302,30 @@ function getCalibration(model) {
|
|
|
302
302
|
// ---------------------------------------------------------------------------
|
|
303
303
|
|
|
304
304
|
/**
|
|
305
|
-
* Draw one sample from the Beta posterior for each category
|
|
306
|
-
*
|
|
305
|
+
* Draw one sample from the Beta posterior for each category.
|
|
306
|
+
* Supports temperature scaling to adjust exploitation vs exploration.
|
|
307
307
|
*
|
|
308
|
-
*
|
|
308
|
+
* Temperature (T):
|
|
309
|
+
* T = 1.0 (default) — Standard Thompson Sampling.
|
|
310
|
+
* T < 1.0 — Sharper distribution, favors high-reliability categories (exploit).
|
|
311
|
+
* T > 1.0 — Flatter distribution, increases uncertainty and exploration.
|
|
309
312
|
*
|
|
310
|
-
*
|
|
311
|
-
* Used for Thompson Sampling action selection (explore via uncertainty).
|
|
313
|
+
* Implementation: Scales alpha and beta by 1/T.
|
|
312
314
|
*
|
|
313
315
|
* @param {Object} model - Model object containing categories
|
|
316
|
+
* @param {number} temperature - Scaling factor (default 1.0)
|
|
314
317
|
* @returns {Object} Map of category → float sample in [0, 1]
|
|
315
318
|
*/
|
|
316
|
-
function samplePosteriors(model) {
|
|
319
|
+
function samplePosteriors(model, temperature = 1.0) {
|
|
317
320
|
const samples = {};
|
|
321
|
+
const T = Math.max(0.01, Number(temperature) || 1.0);
|
|
322
|
+
const invT = 1.0 / T;
|
|
323
|
+
|
|
318
324
|
for (const [cat, params] of Object.entries(model.categories || {})) {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
);
|
|
325
|
+
// Scale precision by inverse temperature
|
|
326
|
+
const alpha = Math.max(params.alpha * invT, 0.01);
|
|
327
|
+
const beta = Math.max(params.beta * invT, 0.01);
|
|
328
|
+
samples[cat] = betaSample(alpha, beta);
|
|
323
329
|
}
|
|
324
330
|
return samples;
|
|
325
331
|
}
|