thumbgate 1.23.1 → 1.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/.claude-plugin/marketplace.json +5 -5
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/.well-known/llms.txt +26 -11
  4. package/.well-known/mcp/server-card.json +8 -8
  5. package/README.md +84 -38
  6. package/adapters/claude/.mcp.json +2 -2
  7. package/adapters/mcp/server-stdio.js +1 -1
  8. package/adapters/opencode/opencode.json +1 -1
  9. package/bin/cli.js +39 -16
  10. package/bin/postinstall.js +11 -22
  11. package/config/gate-templates.json +72 -0
  12. package/config/github-about.json +1 -1
  13. package/config/post-deploy-marketing-pages.json +6 -1
  14. package/package.json +20 -8
  15. package/public/agent-manager.html +3 -3
  16. package/public/agents-cost-savings.html +3 -3
  17. package/public/ai-malpractice-prevention.html +335 -7
  18. package/public/blog.html +3 -3
  19. package/public/codex-enterprise.html +3 -3
  20. package/public/codex-plugin.html +4 -4
  21. package/public/compare.html +6 -6
  22. package/public/dashboard.html +211 -126
  23. package/public/guide.html +5 -5
  24. package/public/index.html +156 -47
  25. package/public/learn.html +24 -10
  26. package/public/lessons.html +2 -2
  27. package/public/numbers.html +6 -6
  28. package/public/pricing.html +6 -5
  29. package/public/pro.html +1 -0
  30. package/scripts/billing.js +17 -0
  31. package/scripts/commercial-offer.js +4 -1
  32. package/scripts/dashboard.js +53 -1
  33. package/scripts/gates-engine.js +101 -16
  34. package/scripts/mcp-oauth.js +293 -0
  35. package/scripts/plausible-server-events.js +2 -1
  36. package/scripts/rate-limiter.js +16 -12
  37. package/scripts/security-scanner.js +80 -10
  38. package/scripts/seo-gsd.js +167 -1
  39. package/scripts/telemetry-analytics.js +310 -0
  40. package/scripts/tool-registry.js +35 -1
  41. package/scripts/vector-store.js +1 -0
  42. package/scripts/visitor-journey.js +172 -0
  43. package/src/api/server.js +226 -31
  44. package/adapters/chatgpt/openapi.yaml +0 -1705
@@ -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
+ };
@@ -25,7 +25,7 @@
25
25
 
26
26
  const https = require('node:https');
27
27
 
28
- const DEFAULT_PLAUSIBLE_DOMAIN = 'thumbgate-production.up.railway.app';
28
+ const DEFAULT_PLAUSIBLE_DOMAIN = 'thumbgate.ai';
29
29
  const PLAUSIBLE_ENDPOINT = 'https://plausible.io/api/event';
30
30
  const REQUEST_TIMEOUT_MS = 2_000;
31
31
 
@@ -142,6 +142,7 @@ const CHECKOUT_EVENT_NAMES = Object.freeze({
142
142
  view: 'Checkout Pro Viewed',
143
143
  emailSubmitted: 'Checkout Pro Email Submitted',
144
144
  stripeRedirect: 'Checkout Pro Stripe Redirect Started',
145
+ purchase: 'Checkout Pro Purchase Completed',
145
146
  });
146
147
 
147
148
  function recordCheckoutFunnelEvent(stage, options = {}) {
@@ -12,35 +12,37 @@ const {
12
12
  const USAGE_FILE = path.join(process.env.HOME || '/tmp', '.thumbgate', 'usage-limits.json');
13
13
 
14
14
  // ──────────────────────────────────────────────────────────
15
- // Free tier: generous on captures (habit formation) and rules
16
- // (5 active gates), gated on Pro-only features (recall, search,
17
- // exports). Dashboard, exports, and unlimited rules drive Pro.
15
+ // Free tier: tight enough to create upgrade pressure after
16
+ // real usage. Captures and rules are capped so heavy users
17
+ // hit the wall within the first week, not the first quarter.
18
18
  // ──────────────────────────────────────────────────────────
19
19
  const FREE_TIER_LIMITS = {
20
- capture_feedback: { daily: Infinity, lifetime: Infinity, label: 'feedback captures' },
21
- prevention_rules: { daily: Infinity, lifetime: Infinity, label: 'prevention rules generated' },
20
+ capture_feedback: { daily: 5, lifetime: 25, label: 'feedback captures (5/day, 25 total on free)' },
21
+ prevention_rules: { daily: 2, lifetime: 6, label: 'prevention rules generated (2/day on free)' },
22
22
  recall: { daily: 0, lifetime: 0, label: 'recall queries (Pro only)' },
23
23
  search_lessons: { daily: 0, lifetime: 0, label: 'lesson searches (Pro only)' },
24
24
  search_thumbgate: { daily: 0, lifetime: 0, label: 'ThumbGate searches (Pro only)' },
25
25
  commerce_recall: { daily: 0, lifetime: 0, label: 'commerce recalls (Pro only)' },
26
26
  export_dpo: { daily: 0, lifetime: 0, label: 'DPO exports (Pro only)' },
27
27
  export_databricks: { daily: 0, lifetime: 0, label: 'Databricks exports (Pro only)' },
28
- construct_context_pack: { daily: Infinity, lifetime: Infinity, label: 'context packs' },
28
+ construct_context_pack: { daily: 3, lifetime: Infinity, label: 'context packs (3/day on free)' },
29
29
  };
30
30
 
31
- const FREE_TIER_MAX_GATES = 5; // 5 active prevention rules on free; Pro is unlimited
32
- const FREE_TIER_DAILY_BLOCKS = 10; // 10 gate blocks/day on free; after limit, deny → warn + upgrade CTA
31
+ const FREE_TIER_MAX_GATES = 3; // 3 active prevention rules on free; Pro is unlimited
32
+ const FREE_TIER_DAILY_BLOCKS = 3; // 3 gate blocks/day on free; after limit, deny → warn + upgrade CTA
33
33
 
34
34
  const UPGRADE_MESSAGE = `Pro: ${PRO_PRICE_LABEL} — unlimited rules, recall, lesson search, dashboard, and exports: ${PRO_MONTHLY_PAYMENT_LINK}\n Team: ${TEAM_PRICE_LABEL} after workflow qualification.`;
35
35
 
36
36
  const PAYWALL_MESSAGES = {
37
- prevention_rules: 'Free tier includes 5 active prevention rules. Promote more or unlock unlimited rules with Pro.',
37
+ capture_feedback: 'Free tier: 5 captures/day (25 total). Your feedback is stored locally upgrade to capture unlimited.',
38
+ prevention_rules: 'Free tier includes 3 active prevention rules and 2 rule generations/day. Upgrade to Pro for unlimited rules.',
38
39
  recall: 'Recall is a Pro feature. Your past feedback is stored locally — upgrade to search and reuse it.',
39
40
  search_lessons: 'Lesson search is a Pro feature. Upgrade to find patterns in your agent\'s mistakes.',
41
+ construct_context_pack: 'Free tier: 3 context packs/day. Upgrade to Pro for unlimited.',
40
42
  default: 'This feature requires Pro. Start Pro — card required; billed today.',
41
43
  };
42
44
 
43
- const TRIAL_DAYS = 14;
45
+ const TRIAL_DAYS = 7;
44
46
 
45
47
  function getInstallAgeDays() {
46
48
  try {
@@ -91,7 +93,8 @@ function isProTier(authContext) {
91
93
  const { isProLicensed } = require('./license');
92
94
  if (isProLicensed()) return true;
93
95
  } catch (_) {}
94
- // 14-day reverse trial: new installs get full Pro access
96
+ // 7-day reverse trial: new installs get full Pro access, then hit a clear
97
+ // hosted-sync/unlimited-rules pay moment while the product is still fresh.
95
98
  if (isInTrialPeriod()) return true;
96
99
  return false;
97
100
  }
@@ -164,9 +167,10 @@ function checkLimit(action, authContext) {
164
167
 
165
168
  // Check daily limit
166
169
  if (dailyLimit !== Infinity && dailyCurrent >= dailyLimit) {
170
+ const paywallMsg = PAYWALL_MESSAGES[action] || PAYWALL_MESSAGES.default;
167
171
  return {
168
172
  allowed: false,
169
- message: `Daily limit reached. ${UPGRADE_MESSAGE}`,
173
+ message: `Daily limit reached. ${paywallMsg}\n\n${UPGRADE_MESSAGE}`,
170
174
  used: dailyCurrent,
171
175
  limit: dailyLimit,
172
176
  limitType: 'daily',
@@ -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,