Skip to content

Commit 3c4c29a

Browse files
committed
Add optional Claude Code hook for blocking malicious packages
Adds a PreToolUse hook (socket-gate.ts) that intercepts npm/yarn/bun/pnpm install commands and checks packages against the Socket API. Blocks packages with critical or high severity alerts (typosquats, malware, supply chain attacks). Fails open on all errors. Includes tests and README documentation.
1 parent e896525 commit 3c4c29a

File tree

3 files changed

+437
-0
lines changed

3 files changed

+437
-0
lines changed

README.md

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,67 @@ This approach automatically uses the latest version without requiring global ins
223223
}
224224
```
225225

226+
## Claude Code Hook (Optional)
227+
228+
The repo includes an optional [Claude Code hook](https://code.claude.com/docs/en/hooks) that automatically blocks malicious packages before installation. When Claude Code runs `npm install`, `yarn add`, `bun add`, or `pnpm add`, the hook checks the package against Socket and blocks it if critical or high severity alerts are found (typosquats, malware, supply chain attacks).
229+
230+
The hook fails open on all errors, so it never blocks legitimate work.
231+
232+
### Hook Setup
233+
234+
**Prerequisites:** Node.js 22+ and a [Socket API key](https://docs.socket.dev/reference/creating-and-managing-api-tokens) (`packages:list` scope).
235+
236+
1. Copy the hook script:
237+
238+
```bash
239+
mkdir -p ~/.claude/hooks
240+
cp hooks/socket-gate.ts ~/.claude/hooks/
241+
```
242+
243+
2. Add to `~/.claude/settings.json`:
244+
245+
```json
246+
{
247+
"hooks": {
248+
"PreToolUse": [
249+
{
250+
"matcher": "Bash",
251+
"hooks": [
252+
{
253+
"type": "command",
254+
"command": "SOCKET_API_KEY=your-api-key-here node --experimental-strip-types ~/.claude/hooks/socket-gate.ts"
255+
}
256+
]
257+
}
258+
]
259+
}
260+
}
261+
```
262+
263+
If `SOCKET_API_KEY` is already in your shell environment, you can omit it from the command.
264+
265+
### How it works
266+
267+
| Alert Severity | Decision | Example |
268+
|----------------|----------|---------|
269+
| **Critical** | Block installation | `browserlist` (typosquat of `browserslist`) |
270+
| **High** | Block installation | Packages with known supply chain risks |
271+
| **Low/None** | Allow | `express`, `lodash`, `react` |
272+
273+
### Testing the hook
274+
275+
```bash
276+
# Should block (typosquat)
277+
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+
280+
# Should allow (safe package)
281+
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+
```
284+
285+
Inspired by [Jimmy Vo's dependency hook](https://blog.jimmyvo.com/posts/claudes-dependency-hook/).
286+
226287
## Tools exposed by the Socket MCP Server
227288

228289
### depscore

hooks/socket-gate.test.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
#!/usr/bin/env node
2+
import { test } from 'node:test'
3+
import assert from 'node:assert'
4+
import { execFileSync } from 'node:child_process'
5+
import { join } from 'node:path'
6+
7+
const hookPath = join(import.meta.dirname, 'socket-gate.ts')
8+
9+
function runHook (input: string, env?: Record<string, string>): string {
10+
return execFileSync('node', ['--experimental-strip-types', hookPath], {
11+
input,
12+
encoding: 'utf-8',
13+
timeout: 30_000,
14+
env: {
15+
...process.env,
16+
...env
17+
}
18+
}).trim()
19+
}
20+
21+
function parseOutput (output: string): { decision: string, reason?: string } {
22+
const parsed = JSON.parse(output)
23+
return {
24+
decision: parsed.hookSpecificOutput.permissionDecision,
25+
reason: parsed.hookSpecificOutput.permissionDecisionReason
26+
}
27+
}
28+
29+
function makeInput (command: string): string {
30+
return JSON.stringify({
31+
session_id: 'test',
32+
tool_name: 'Bash',
33+
tool_input: { command }
34+
})
35+
}
36+
37+
test('socket-gate hook', async (t) => {
38+
const apiKey = process.env['SOCKET_API_KEY']
39+
40+
// ========================================
41+
// Unit tests (no API key required)
42+
// ========================================
43+
44+
await t.test('allows non-Bash tools', () => {
45+
const input = JSON.stringify({ session_id: 'test', tool_name: 'Read', tool_input: { path: '/tmp/foo' } })
46+
const result = parseOutput(runHook(input))
47+
assert.strictEqual(result.decision, 'allow')
48+
})
49+
50+
await t.test('allows non-install commands', () => {
51+
const result = parseOutput(runHook(makeInput('ls -la')))
52+
assert.strictEqual(result.decision, 'allow')
53+
})
54+
55+
await t.test('allows lockfile-only installs', () => {
56+
for (const cmd of ['npm install', 'npm i', 'npm ci', 'yarn', 'yarn install', 'bun install', 'pnpm install']) {
57+
const result = parseOutput(runHook(makeInput(cmd)))
58+
assert.strictEqual(result.decision, 'allow', `should allow: ${cmd}`)
59+
}
60+
})
61+
62+
await t.test('allows empty input', () => {
63+
const result = parseOutput(runHook(''))
64+
assert.strictEqual(result.decision, 'allow')
65+
})
66+
67+
await t.test('allows invalid JSON', () => {
68+
const result = parseOutput(runHook('not json'))
69+
assert.strictEqual(result.decision, 'allow')
70+
})
71+
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+
77+
// ========================================
78+
// Integration tests (require SOCKET_API_KEY)
79+
// ========================================
80+
81+
await t.test('allows safe package (lodash)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
82+
const result = parseOutput(runHook(makeInput('npm install lodash')))
83+
assert.strictEqual(result.decision, 'allow')
84+
})
85+
86+
await t.test('allows safe scoped package (@types/node)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
87+
const result = parseOutput(runHook(makeInput('yarn add @types/node')))
88+
assert.strictEqual(result.decision, 'allow')
89+
})
90+
91+
await t.test('blocks typosquat (browserlist)', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
92+
const result = parseOutput(runHook(makeInput('npm install browserlist')))
93+
assert.strictEqual(result.decision, 'deny')
94+
assert.ok(result.reason?.includes('browserlist'), 'reason should mention package name')
95+
assert.ok(result.reason?.includes('socket.dev'), 'reason should include review link')
96+
})
97+
98+
await t.test('handles versioned install', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
99+
const result = parseOutput(runHook(makeInput('npm install express@4.18.2')))
100+
assert.strictEqual(result.decision, 'allow')
101+
})
102+
103+
await t.test('handles pnpm add', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
104+
const result = parseOutput(runHook(makeInput('pnpm add express')))
105+
assert.strictEqual(result.decision, 'allow')
106+
})
107+
108+
await t.test('handles bun add', { skip: !apiKey && 'SOCKET_API_KEY not set' }, () => {
109+
const result = parseOutput(runHook(makeInput('bun add express')))
110+
assert.strictEqual(result.decision, 'allow')
111+
})
112+
})

0 commit comments

Comments
 (0)