Skip to content

Commit df90d32

Browse files
committed
Switch hook from direct API to Socket CLI (no API key needed)
Uses 'socket package score' instead of calling the /v0/purl endpoint directly. Auth is handled by the CLI's own config (socket login), so no SOCKET_API_KEY env var is required.
1 parent 3c4c29a commit df90d32

File tree

3 files changed

+69
-106
lines changed

3 files changed

+69
-106
lines changed

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,10 @@ The hook fails open on all errors, so it never blocks legitimate work.
231231

232232
### Hook Setup
233233

234-
**Prerequisites:** Node.js 22+ and a [Socket API key](https://docs.socket.dev/reference/creating-and-managing-api-tokens) (`packages:list` scope).
234+
**Prerequisites:**
235+
- Node.js 22+
236+
- [Socket CLI](https://www.npmjs.com/package/@socketsecurity/cli): `npm install -g @socketsecurity/cli`
237+
- Run `socket login` to authenticate (one-time setup, no env vars needed)
235238

236239
1. Copy the hook script:
237240

@@ -251,7 +254,7 @@ cp hooks/socket-gate.ts ~/.claude/hooks/
251254
"hooks": [
252255
{
253256
"type": "command",
254-
"command": "SOCKET_API_KEY=your-api-key-here node --experimental-strip-types ~/.claude/hooks/socket-gate.ts"
257+
"command": "node --experimental-strip-types ~/.claude/hooks/socket-gate.ts"
255258
}
256259
]
257260
}
@@ -260,8 +263,6 @@ cp hooks/socket-gate.ts ~/.claude/hooks/
260263
}
261264
```
262265

263-
If `SOCKET_API_KEY` is already in your shell environment, you can omit it from the command.
264-
265266
### How it works
266267

267268
| Alert Severity | Decision | Example |
@@ -275,11 +276,11 @@ If `SOCKET_API_KEY` is already in your shell environment, you can omit it from t
275276
```bash
276277
# Should block (typosquat)
277278
echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"npm install browserlist"}}' \
278-
| SOCKET_API_KEY=your-key node --experimental-strip-types hooks/socket-gate.ts
279+
| node --experimental-strip-types hooks/socket-gate.ts
279280

280281
# Should allow (safe package)
281282
echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"npm install express"}}' \
282-
| SOCKET_API_KEY=your-key node --experimental-strip-types hooks/socket-gate.ts
283+
| node --experimental-strip-types hooks/socket-gate.ts
283284
```
284285

285286
Inspired by [Jimmy Vo's dependency hook](https://blog.jimmyvo.com/posts/claudes-dependency-hook/).

hooks/socket-gate.test.ts

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,12 @@ import { join } from 'node:path'
66

77
const hookPath = join(import.meta.dirname, 'socket-gate.ts')
88

9-
function runHook (input: string, env?: Record<string, string>): string {
9+
function runHook (input: string): string {
1010
return execFileSync('node', ['--experimental-strip-types', hookPath], {
1111
input,
1212
encoding: 'utf-8',
13-
timeout: 30_000,
14-
env: {
15-
...process.env,
16-
...env
17-
}
13+
timeout: 60_000,
14+
env: { ...process.env }
1815
}).trim()
1916
}
2017

@@ -34,11 +31,20 @@ function makeInput (command: string): string {
3431
})
3532
}
3633

37-
test('socket-gate hook', async (t) => {
38-
const apiKey = process.env['SOCKET_API_KEY']
34+
function socketCliAvailable (): boolean {
35+
try {
36+
execFileSync('which', ['socket'], { encoding: 'utf-8', timeout: 5_000 })
37+
return true
38+
} catch {
39+
return false
40+
}
41+
}
42+
43+
const hasCli = socketCliAvailable()
3944

45+
test('socket-gate hook', async (t) => {
4046
// ========================================
41-
// Unit tests (no API key required)
47+
// Unit tests (no Socket CLI required)
4248
// ========================================
4349

4450
await t.test('allows non-Bash tools', () => {
@@ -69,43 +75,38 @@ test('socket-gate hook', async (t) => {
6975
assert.strictEqual(result.decision, 'allow')
7076
})
7177

72-
await t.test('allows when no API key is set', () => {
73-
const result = parseOutput(runHook(makeInput('npm install malicious-pkg'), { SOCKET_API_KEY: '' }))
74-
assert.strictEqual(result.decision, 'allow')
75-
})
76-
7778
// ========================================
78-
// Integration tests (require SOCKET_API_KEY)
79+
// Integration tests (require Socket CLI with `socket login`)
7980
// ========================================
8081

81-
await t.test('allows safe package (lodash)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
82+
await t.test('allows safe package (lodash)', { skip: !hasCli && 'Socket CLI not installed' }, () => {
8283
const result = parseOutput(runHook(makeInput('npm install lodash')))
8384
assert.strictEqual(result.decision, 'allow')
8485
})
8586

86-
await t.test('allows safe scoped package (@types/node)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
87+
await t.test('allows safe scoped package (@types/node)', { skip: !hasCli && 'Socket CLI not installed' }, () => {
8788
const result = parseOutput(runHook(makeInput('yarn add @types/node')))
8889
assert.strictEqual(result.decision, 'allow')
8990
})
9091

91-
await t.test('blocks typosquat (browserlist)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
92+
await t.test('blocks typosquat (browserlist)', { skip: !hasCli && 'Socket CLI not installed' }, () => {
9293
const result = parseOutput(runHook(makeInput('npm install browserlist')))
9394
assert.strictEqual(result.decision, 'deny')
9495
assert.ok(result.reason?.includes('browserlist'), 'reason should mention package name')
9596
assert.ok(result.reason?.includes('socket.dev'), 'reason should include review link')
9697
})
9798

98-
await t.test('handles versioned install', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
99+
await t.test('handles versioned install', { skip: !hasCli && 'Socket CLI not installed' }, () => {
99100
const result = parseOutput(runHook(makeInput('npm install express@4.18.2')))
100101
assert.strictEqual(result.decision, 'allow')
101102
})
102103

103-
await t.test('handles pnpm add', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
104+
await t.test('handles pnpm add', { skip: !hasCli && 'Socket CLI not installed' }, () => {
104105
const result = parseOutput(runHook(makeInput('pnpm add express')))
105106
assert.strictEqual(result.decision, 'allow')
106107
})
107108

108-
await t.test('handles bun add', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
109+
await t.test('handles bun add', { skip: !hasCli && 'Socket CLI not installed' }, () => {
109110
const result = parseOutput(runHook(makeInput('bun add express')))
110111
assert.strictEqual(result.decision, 'allow')
111112
})

hooks/socket-gate.ts

Lines changed: 40 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,23 @@
33
* socket-gate.ts — Claude Code PreToolUse hook
44
*
55
* Intercepts npm/yarn/bun/pnpm install commands and checks packages against
6-
* the Socket API. Blocks packages with critical alerts (malware, typosquats)
7-
* and warns on high severity supply chain risks.
6+
* Socket. Blocks packages with critical alerts (malware, typosquats)
7+
* and high severity supply chain risks.
8+
*
9+
* Uses the Socket CLI (`socket package score`) which handles its own auth
10+
* via `socket login`. No API key env var needed.
811
*
912
* Setup:
10-
* 1. Copy this file to ~/.claude/hooks/socket-gate.ts
11-
* 2. Add to ~/.claude/settings.json (see README)
12-
* 3. Set SOCKET_API_KEY env var
13+
* 1. Install Socket CLI: npm install -g @socketsecurity/cli && socket login
14+
* 2. Copy this file to ~/.claude/hooks/socket-gate.ts
15+
* 3. Add to ~/.claude/settings.json (see README)
1316
*
14-
* Fails open on all errors (network, auth, parse) so it never blocks
15-
* legitimate work.
17+
* Fails open on all errors (CLI missing, network timeout, parse failures)
18+
* so it never blocks legitimate work.
1619
*/
1720

1821
import { readFileSync } from 'node:fs'
22+
import { execFileSync } from 'node:child_process'
1923

2024
// ========================================
2125
// Types
@@ -28,21 +32,18 @@ interface HookInput {
2832
}
2933

3034
interface SocketAlert {
31-
type: string
35+
name: string
3236
severity: string
3337
category?: string
34-
props?: Record<string, unknown>
3538
}
3639

37-
interface PurlResponseLine {
38-
_type?: string
39-
score?: Record<string, unknown>
40-
alerts?: SocketAlert[]
41-
name?: string
42-
namespace?: string
43-
type?: string
44-
version?: string
45-
[key: string]: unknown
40+
interface SocketScoreResult {
41+
ok?: boolean
42+
data?: {
43+
self?: {
44+
alerts?: SocketAlert[]
45+
}
46+
}
4647
}
4748

4849
// ========================================
@@ -104,75 +105,34 @@ export function extractPackageName (command: string): string | null {
104105
}
105106

106107
// ========================================
107-
// PURL construction (npm only, inline)
108-
// ========================================
109-
110-
export function buildNpmPurl (packageName: string): string {
111-
if (packageName.startsWith('@') && packageName.includes('/')) {
112-
const slash = packageName.indexOf('/')
113-
const scope = encodeURIComponent(packageName.slice(0, slash))
114-
const name = packageName.slice(slash + 1)
115-
return `pkg:npm/${scope}/${name}`
116-
}
117-
return `pkg:npm/${packageName}`
118-
}
119-
120-
// ========================================
121-
// Socket API
108+
// Socket CLI
122109
// ========================================
123110

124-
const DEFAULT_SOCKET_API_URL = 'https://api.socket.dev/v0/purl'
125-
126-
function getSocketApiUrl (): string {
127-
if (process.env['SOCKET_API_URL']) {
128-
return process.env['SOCKET_API_URL']
111+
function isSocketInstalled (): boolean {
112+
try {
113+
execFileSync('which', ['socket'], { encoding: 'utf-8', timeout: 5_000 })
114+
return true
115+
} catch {
116+
return false
129117
}
130-
return `${DEFAULT_SOCKET_API_URL}?alerts=true&compact=false&fixable=false&licenseattrib=false&licensedetails=false`
131118
}
132119

133-
export async function checkPackage (packageName: string, apiKey: string): Promise<{ decision: 'allow' | 'deny', reason: string }> {
134-
const purl = buildNpmPurl(packageName)
135-
136-
const response = await fetch(getSocketApiUrl(), {
137-
method: 'POST',
138-
headers: {
139-
'user-agent': 'socket-mcp-hook/1.0',
140-
accept: 'application/x-ndjson',
141-
'content-type': 'application/json',
142-
authorization: `Bearer ${apiKey}`
143-
},
144-
body: JSON.stringify({ components: [{ purl }] }),
145-
signal: AbortSignal.timeout(15_000)
146-
})
147-
148-
if (!response.ok) {
149-
return { decision: 'allow', reason: '' }
150-
}
151-
152-
const text = await response.text()
153-
if (!text.trim()) {
154-
return { decision: 'allow', reason: '' }
155-
}
156-
157-
const lines: PurlResponseLine[] = text
158-
.split('\n')
159-
.filter(line => line.trim())
160-
.map(line => JSON.parse(line) as PurlResponseLine)
161-
.filter(obj => !obj._type)
162-
163-
if (lines.length === 0) {
164-
return { decision: 'allow', reason: '' }
165-
}
120+
export function checkPackage (packageName: string): { decision: 'allow' | 'deny', reason: string } {
121+
const result = execFileSync(
122+
'socket',
123+
['package', 'score', 'npm', packageName, '--json', '--no-banner'],
124+
{ encoding: 'utf-8', timeout: 30_000, maxBuffer: 10 * 1024 * 1024 }
125+
)
166126

167-
const pkg = lines[0]
168-
const alerts = pkg.alerts || []
127+
const parsed: SocketScoreResult = JSON.parse(result)
128+
const alerts = parsed.data?.self?.alerts || []
169129

170130
const critical = alerts.filter(a => a.severity === 'critical')
171131
const high = alerts.filter(a => a.severity === 'high')
172132

173133
if (critical.length > 0) {
174134
const details = critical
175-
.map(a => ` - ${a.type}: ${a.category || 'detected'}`)
135+
.map(a => ` - ${a.name}: ${a.category || 'detected'}`)
176136
.join('\n')
177137

178138
return {
@@ -183,7 +143,7 @@ export async function checkPackage (packageName: string, apiKey: string): Promis
183143

184144
if (high.length > 0) {
185145
const details = high
186-
.map(a => ` - ${a.type}: ${a.category || 'detected'}`)
146+
.map(a => ` - ${a.name}: ${a.category || 'detected'}`)
187147
.join('\n')
188148

189149
return {
@@ -241,20 +201,21 @@ async function main (): Promise<void> {
241201
return
242202
}
243203

244-
const apiKey = process.env['SOCKET_API_KEY']
245-
if (!apiKey) {
204+
if (!isSocketInstalled()) {
205+
// CLI not installed, fail open
246206
outputAllow()
247207
return
248208
}
249209

250210
try {
251-
const result = await checkPackage(packageName, apiKey)
211+
const result = checkPackage(packageName)
252212
if (result.decision === 'deny') {
253213
outputDeny(result.reason)
254214
} else {
255215
outputAllow()
256216
}
257217
} catch {
218+
// Fail open on any error
258219
outputAllow()
259220
}
260221
}

0 commit comments

Comments
 (0)