@socketsecurity/mcp 0.0.17 → 0.0.18

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.
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env node
2
+ export { buildSocketHeaders, getApiKeyInteractively, getForwardedHeaderValue, getRequestBaseUrl, getRequestHeaderValue, parseJsonObject, writeJson, writeOAuthError, } from './lib/http-helpers.ts';
3
+ export { createConfiguredServer } from './lib/server.ts';
4
+ export { authenticateRequest, buildProtectedResourceMetadata, getProtectedResourceMetadataUrl, loadOAuthMetadata, splitScopes, verifyAccessToken, } from './lib/oauth.ts';
@@ -0,0 +1,117 @@
1
+ # socket-gate
2
+
3
+ A Claude Code `PreToolUse` hook you can copy into your own setup to block
4
+ high-risk package installs before they run. When Claude Code is about to
5
+ execute a `Bash` install command, the hook queries the public Socket MCP server
6
+ at `https://mcp.socket.dev/` and denies the install when the package's supply
7
+ chain score is below `20` (known malware, typosquats, high-risk supply chain
8
+ signals).
9
+
10
+ No API key, no CLI, no registration. Copy the directory and wire it up.
11
+
12
+ This directory holds the hook source (`index.mts`) plus the bundled
13
+ `socket-gate.cjs` that a build produces. You copy the whole directory into
14
+ `~/.claude/hooks/` and point your settings at the `.cjs` (see
15
+ [Copy it in](#copy-it-in)).
16
+
17
+ ## Why
18
+
19
+ A package install is the moment a supply chain attack lands. Catching a
20
+ typosquat or known-malware package at the `PreToolUse` boundary, before the
21
+ install command runs, stops it from reaching disk or firing a postinstall
22
+ script. The hook is an advisory guardrail: it fails open, so a Socket outage
23
+ never blocks legitimate work.
24
+
25
+ ## What it catches
26
+
27
+ Install commands across six ecosystems, parsed out of the `Bash` tool's command
28
+ string:
29
+
30
+ | Ecosystem | Commands |
31
+ | --------- | ----------------------------------------------------------------------------------------- |
32
+ | npm | `npm install`, `npm i`, `npm add`, `yarn add`, `pnpm add`, `bun add` |
33
+ | PyPI | `pip install`, `pip3 install`, `uv add`, `uv pip install`, `poetry add`, `pipenv install` |
34
+ | Cargo | `cargo add`, `cargo install` |
35
+ | RubyGems | `gem install`, `bundle add` |
36
+ | Go | `go get`, `go install` |
37
+ | NuGet | `dotnet add package`, `nuget install` |
38
+
39
+ For a matched package the hook calls the Socket MCP `depscore` tool, parses the
40
+ `supplyChain` score, and denies when `supplyChain < 20`. Everything else
41
+ allows.
42
+
43
+ ## Copy it in
44
+
45
+ Copy the whole `socket-gate` directory into your hooks folder so the bundle and
46
+ its docs travel together, and so you own your copy to edit. The bundled
47
+ `socket-gate.cjs` is self-contained: rolldown inlines its one dependency
48
+ (`@socketsecurity/lib-stable`), so it runs without a `package.json` or
49
+ `node_modules` beside it.
50
+
51
+ From an installed `@socketsecurity/mcp` package:
52
+
53
+ ```bash
54
+ mkdir -p ~/.claude/hooks
55
+ cp -R node_modules/@socketsecurity/mcp/hooks/socket-gate ~/.claude/hooks/
56
+ ```
57
+
58
+ From a checkout of this repo, run `pnpm run build` first to produce
59
+ `socket-gate.cjs`, then copy:
60
+
61
+ ```bash
62
+ pnpm run build
63
+ cp -R hooks/socket-gate ~/.claude/hooks/
64
+ ```
65
+
66
+ ## Wire it up
67
+
68
+ Add to `~/.claude/settings.json`:
69
+
70
+ ```json
71
+ {
72
+ "hooks": {
73
+ "PreToolUse": [
74
+ {
75
+ "matcher": "Bash",
76
+ "hooks": [
77
+ {
78
+ "type": "command",
79
+ "command": "node ~/.claude/hooks/socket-gate/socket-gate.cjs"
80
+ }
81
+ ]
82
+ }
83
+ ]
84
+ }
85
+ }
86
+ ```
87
+
88
+ ## How it works
89
+
90
+ `supplyChain < 20` denies, anything else allows — e.g. `express` / `lodash` /
91
+ `react` (75–97) allow, `browserlist` (typosquat of `browserslist`, 15) and
92
+ confirmed malware (0) block. Network, timeout, and parse errors all fail open
93
+ (decision `allow`), with the cause written to stderr; stdout stays the
94
+ allow/deny IPC channel Claude Code parses.
95
+
96
+ ## Limitations
97
+
98
+ A best-effort guardrail, not a complete defense. Known gaps:
99
+
100
+ - **Manifest edits + lockfile installs.** Editing a manifest (`package.json`,
101
+ `requirements.txt`, `Cargo.toml`, `Gemfile`, `go.mod`, `*.csproj`) then
102
+ running a bare install (`npm install`, `pip install -r requirements.txt`,
103
+ `cargo build`, `bundle install`, `go mod tidy`, `dotnet restore`) leaves no
104
+ package name on the command line to check.
105
+ - **Package-manager invocations only.** Direct downloads (`curl | sh`,
106
+ `wget`), postinstall scripts of already-accepted packages, and transitive
107
+ dependencies are not re-checked.
108
+ - **Indirect Claude paths.** Sub-agents, MCP tools that shell out, and
109
+ non-`Bash` tool calls are not covered unless the `matcher` is broadened.
110
+
111
+ ## Test
112
+
113
+ The unit tests run against the `.mts` source (not the bundle):
114
+
115
+ ```bash
116
+ pnpm test
117
+ ```
@@ -0,0 +1,289 @@
1
+ /**
2
+ * Socket-gate — Claude Code PreToolUse hook (source for the bundled
3
+ * socket-gate.cjs).
4
+ *
5
+ * Intercepts package install commands across npm, PyPI, Cargo, RubyGems, Go,
6
+ * and NuGet, and checks the target package against the public Socket MCP
7
+ * server. Blocks installs when the supply chain score is below 20 (known
8
+ * malware, typosquats, high-risk supply chain signals).
9
+ *
10
+ * No API key, no CLI, no registration beyond copying the bundled directory.
11
+ *
12
+ * Setup (see README.md):
13
+ *
14
+ * 1. Copy the whole hooks/socket-gate/ directory to ~/.claude/hooks/ (from a
15
+ * published @socketsecurity/mcp install, or build it here with `pnpm run
16
+ * build`).
17
+ * 2. Point a PreToolUse Bash hook at ~/.claude/hooks/socket-gate/socket-gate.cjs
18
+ * in ~/.claude/settings.json.
19
+ *
20
+ * This source imports @socketsecurity/lib-stable; rolldown inlines it into the
21
+ * bundled socket-gate.cjs so the copied-out hook stays self-contained (a Claude
22
+ * Code hook has no package.json and no node_modules — it can only run what is
23
+ * physically present in the file).
24
+ *
25
+ * Fails open on network, parse, and timeout errors so a Socket outage does not
26
+ * block legitimate work.
27
+ */
28
+
29
+ import { readFileSync } from 'node:fs'
30
+ import { argv } from 'node:process'
31
+ import { fileURLToPath } from 'node:url'
32
+
33
+ import { errorMessage } from '@socketsecurity/lib-stable/errors'
34
+
35
+ const MCP_URL = 'https://mcp.socket.dev/'
36
+ const SUPPLY_CHAIN_THRESHOLD = 20
37
+ const REQUEST_TIMEOUT_MS = 10_000
38
+
39
+ type Ecosystem = 'npm' | 'pypi' | 'cargo' | 'gem' | 'golang' | 'nuget'
40
+
41
+ interface HookInput {
42
+ session_id: string
43
+ tool_name: string
44
+ tool_input: Record<string, unknown> | string
45
+ }
46
+
47
+ const INSTALL_PATTERNS: Array<{ ecosystem: Ecosystem; pattern: RegExp }> = [
48
+ { ecosystem: 'npm', pattern: /\bnpm\s+(?:add|i|install)\s+([^\s-][^\s]*)/i },
49
+ { ecosystem: 'npm', pattern: /\byarn\s+add\s+([^\s-][^\s]*)/i },
50
+ { ecosystem: 'npm', pattern: /\bpnpm\s+add\s+([^\s-][^\s]*)/i },
51
+ { ecosystem: 'npm', pattern: /\bbun\s+add\s+([^\s-][^\s]*)/i },
52
+ {
53
+ ecosystem: 'pypi',
54
+ pattern: /(?:\bpython3?\s+-m\s+)?\bpip3?\s+install\s+([^\s-][^\s]*)/i,
55
+ },
56
+ { ecosystem: 'pypi', pattern: /\buv\s+add\s+([^\s-][^\s]*)/i },
57
+ { ecosystem: 'pypi', pattern: /\buv\s+pip\s+install\s+([^\s-][^\s]*)/i },
58
+ { ecosystem: 'pypi', pattern: /\bpoetry\s+add\s+([^\s-][^\s]*)/i },
59
+ { ecosystem: 'pypi', pattern: /\bpipenv\s+install\s+([^\s-][^\s]*)/i },
60
+ {
61
+ ecosystem: 'cargo',
62
+ pattern: /\bcargo\s+(?:add|install)\s+([^\s-][^\s]*)/i,
63
+ },
64
+ { ecosystem: 'gem', pattern: /\bgem\s+install\s+([^\s-][^\s]*)/i },
65
+ { ecosystem: 'gem', pattern: /\bbundle\s+add\s+([^\s-][^\s]*)/i },
66
+ { ecosystem: 'golang', pattern: /\bgo\s+(?:get|install)\s+([^\s-][^\s]*)/i },
67
+ { ecosystem: 'nuget', pattern: /\bdotnet\s+add\s+package\s+([^\s-][^\s]*)/i },
68
+ { ecosystem: 'nuget', pattern: /\bnuget\s+install\s+([^\s-][^\s]*)/i },
69
+ ]
70
+
71
+ export async function checkPackage(
72
+ ecosystem: Ecosystem,
73
+ packageName: string,
74
+ fetchImpl: typeof fetch = fetch,
75
+ ): Promise<{ decision: 'allow' | 'deny'; reason: string }> {
76
+ const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS)
77
+ const commonHeaders = {
78
+ 'Content-Type': 'application/json',
79
+ Accept: 'application/json, text/event-stream',
80
+ }
81
+
82
+ const initRes = await fetchImpl(MCP_URL, {
83
+ method: 'POST',
84
+ headers: commonHeaders,
85
+ body: JSON.stringify({
86
+ jsonrpc: '2.0',
87
+ id: 1,
88
+ method: 'initialize',
89
+ params: {
90
+ protocolVersion: '2024-11-05',
91
+ capabilities: {},
92
+ clientInfo: { name: 'socket-gate', version: '1.0' },
93
+ },
94
+ }),
95
+ signal,
96
+ })
97
+
98
+ if (!initRes.ok) {
99
+ throw new Error(`Socket MCP initialize returned ${initRes.status}`)
100
+ }
101
+
102
+ const sessionId = initRes.headers.get('mcp-session-id')
103
+ if (!sessionId) {
104
+ throw new Error('Socket MCP did not return a session id')
105
+ }
106
+ await initRes.text()
107
+
108
+ const callRes = await fetchImpl(MCP_URL, {
109
+ method: 'POST',
110
+ headers: { ...commonHeaders, 'Mcp-Session-Id': sessionId },
111
+ body: JSON.stringify({
112
+ jsonrpc: '2.0',
113
+ id: 2,
114
+ method: 'tools/call',
115
+ params: {
116
+ name: 'depscore',
117
+ arguments: {
118
+ packages: [{ ecosystem, depname: packageName }],
119
+ },
120
+ },
121
+ }),
122
+ signal,
123
+ })
124
+
125
+ if (!callRes.ok) {
126
+ throw new Error(`Socket MCP depscore returned ${callRes.status}`)
127
+ }
128
+
129
+ const payload = (await callRes.json()) as {
130
+ result?:
131
+ | {
132
+ content?: Array<{ type: string; text: string }> | undefined
133
+ isError?: boolean | undefined
134
+ }
135
+ | undefined
136
+ }
137
+
138
+ if (payload.result?.isError) {
139
+ throw new Error(
140
+ 'Socket MCP reported a tool error (package likely not found)',
141
+ )
142
+ }
143
+
144
+ const text = payload.result?.content?.[0]?.text ?? ''
145
+ const score = parseSupplyChainScore(text)
146
+
147
+ if (score === undefined) {
148
+ throw new Error('Could not parse supplyChain score from MCP response')
149
+ }
150
+
151
+ if (score < SUPPLY_CHAIN_THRESHOLD) {
152
+ return {
153
+ decision: 'deny',
154
+ reason: `Socket blocked "${packageName}" (${ecosystem}): supply chain score is ${score} (threshold ${SUPPLY_CHAIN_THRESHOLD}).\n\nReview: https://socket.dev/${ecosystem}/package/${packageName}`,
155
+ }
156
+ }
157
+
158
+ return { decision: 'allow', reason: '' }
159
+ }
160
+
161
+ export function extractPackage(
162
+ command: string,
163
+ ): { ecosystem: Ecosystem; name: string } | undefined {
164
+ for (const { ecosystem, pattern } of INSTALL_PATTERNS) {
165
+ const match = command.match(pattern)
166
+ if (match?.[1]) {
167
+ const name = stripVersion(match[1], ecosystem)
168
+ if (!name) {
169
+ continue
170
+ }
171
+ return { ecosystem, name }
172
+ }
173
+ }
174
+ return undefined
175
+ }
176
+
177
+ // stdout is the Claude Code hook IPC channel — the harness parses this exact
178
+ // JSON as the permission decision, so these must be raw writes (a logger would
179
+ // add formatting/timestamps and break the protocol).
180
+ export function outputAllow(): void {
181
+ const payload = JSON.stringify({
182
+ hookSpecificOutput: {
183
+ hookEventName: 'PreToolUse',
184
+ permissionDecision: 'allow',
185
+ },
186
+ })
187
+ process.stdout.write(payload) // socket-hook: allow console
188
+ }
189
+
190
+ export function outputDeny(reason: string): void {
191
+ const payload = JSON.stringify({
192
+ hookSpecificOutput: {
193
+ hookEventName: 'PreToolUse',
194
+ permissionDecision: 'deny',
195
+ permissionDecisionReason: reason,
196
+ },
197
+ })
198
+ process.stdout.write(payload) // socket-hook: allow console
199
+ }
200
+
201
+ export function parseSupplyChainScore(text: string): number | undefined {
202
+ const match = text.match(/supplyChain:\s*(\d+(?:\.\d+)?)/i)
203
+ return match ? Number(match[1]) : undefined
204
+ }
205
+
206
+ export function stripVersion(pkg: string, ecosystem: Ecosystem): string {
207
+ switch (ecosystem) {
208
+ case 'npm':
209
+ return pkg.replace(/@[\d^~].*/u, '').replace(/@latest$/u, '')
210
+ case 'pypi':
211
+ return pkg.split(/[=<>!~[]/)[0] ?? pkg
212
+ case 'cargo':
213
+ case 'golang':
214
+ case 'nuget':
215
+ return pkg.replace(/@.*$/u, '')
216
+ default:
217
+ return pkg
218
+ }
219
+ }
220
+
221
+ async function main(): Promise<void> {
222
+ let raw: string
223
+ try {
224
+ raw = readFileSync(0, 'utf-8')
225
+ } catch {
226
+ outputAllow()
227
+ return
228
+ }
229
+
230
+ if (!raw.trim()) {
231
+ outputAllow()
232
+ return
233
+ }
234
+
235
+ let input: HookInput
236
+ try {
237
+ input = JSON.parse(raw)
238
+ } catch {
239
+ outputAllow()
240
+ return
241
+ }
242
+
243
+ if (input.tool_name !== 'Bash') {
244
+ outputAllow()
245
+ return
246
+ }
247
+
248
+ const command =
249
+ typeof input.tool_input === 'string'
250
+ ? input.tool_input
251
+ : (input.tool_input?.['command'] as string) || ''
252
+
253
+ if (!command) {
254
+ outputAllow()
255
+ return
256
+ }
257
+
258
+ const target = extractPackage(command)
259
+ if (!target) {
260
+ outputAllow()
261
+ return
262
+ }
263
+
264
+ try {
265
+ const result = await checkPackage(target.ecosystem, target.name)
266
+ if (result.decision === 'deny') {
267
+ outputDeny(result.reason)
268
+ } else {
269
+ outputAllow()
270
+ }
271
+ } catch (e) {
272
+ // Deliberate fail-OPEN: a Socket outage / network error / parse failure
273
+ // must not block legitimate installs (this hook is an advisory guardrail,
274
+ // not a hard gate — see the file header). Surface the error on stderr so
275
+ // the failure is observable; stdout stays the allow/deny IPC channel.
276
+ const errLine = `socket-gate: check failed for ${target.ecosystem}/${target.name}, failing open: ${errorMessage(e)}\n`
277
+ process.stderr.write(errLine) // socket-hook: allow console
278
+ outputAllow()
279
+ }
280
+ }
281
+
282
+ const isMainModule =
283
+ argv[1] !== undefined && fileURLToPath(import.meta.url) === argv[1]
284
+
285
+ if (isMainModule) {
286
+ main().catch(() => {
287
+ outputAllow()
288
+ })
289
+ }