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.
@@ -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
- return `[ThumbGate] Past mistakes relevant to this action — read before proceeding:\n${formatted.join('\n')}`;
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 = buildRelevantLessonContext(toolName, toolInput);
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
- if (!WRITE_TOOLS.has(toolName)) {
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
- const codeResult = scanCode(content, filePath);
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
- const allFindings = [...codeResult.findings, ...supplyChainResult.findings];
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
- `Scanned ${content.length} bytes of content being written to ${filePath || 'unknown file'}`,
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 via the
306
- * Marsaglia-Tsang (2000) gamma ratio method. No external library needed.
305
+ * Draw one sample from the Beta posterior for each category.
306
+ * Supports temperature scaling to adjust exploitation vs exploration.
307
307
  *
308
- * betaSample(alpha, beta) = gammaSample(alpha) / (gammaSample(alpha) + gammaSample(beta))
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
- * This is the JS equivalent of Python's random.betavariate(alpha, beta).
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
- samples[cat] = betaSample(
320
- Math.max(params.alpha, 0.01),
321
- Math.max(params.beta, 0.01),
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
  }