Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,87 @@ This approach automatically uses the latest version without requiring global ins
}
```

## Claude Code Hook (Optional)

The repo includes an optional [Claude Code hook](https://code.claude.com/docs/en/hooks) that blocks high-risk packages before installation. When Claude Code runs `npm install`, `yarn add`, `bun add`, or `pnpm add`, the hook calls the Socket `/v0/purl` API and denies the install when the package's supply chain score is below `0.2` (known malware, typosquats, high-risk supply chain signals).

### Hook Setup

**Prerequisites:**
- Node.js 22+
- A Socket API key with `packages:list` scope. See [creating API tokens](https://docs.socket.dev/reference/creating-and-managing-api-tokens). Export it in your shell:
```bash
export SOCKET_API_KEY=your-api-key-here
```
This is the same key used by the Socket MCP server.

1. Copy the hook script:

```bash
mkdir -p ~/.claude/hooks
cp hooks/socket-gate.ts ~/.claude/hooks/
```

2. Add to `~/.claude/settings.json`:

```json
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "node --experimental-strip-types ~/.claude/hooks/socket-gate.ts"
}
]
}
]
}
}
```

### How it works

The hook denies installation when `score.supplyChain < 0.2`, allows it otherwise. Examples:

| Package | `supplyChain` | Decision |
|---------|--------------|----------|
| `express`, `lodash`, `react` | ~0.97+ | Allow |
| `browserlist` (typosquat of `browserslist`) | 0.15 | Block |
| Confirmed malware | 0 | Block |

### Behavior on errors

- **`SOCKET_API_KEY` not set** — **deny**, with a setup message. The hook will not silently leave you unprotected.
- **Network, timeout, or parse errors** — **allow**. A Socket API outage will not block legitimate work.

### Limitations

This hook is a best-effort guardrail, not a complete defense. Known gaps:

- **Manifest edits + lockfile installs.** If Claude edits `package.json` directly and then runs bare `npm install` / `npm ci` / `yarn` / `pnpm install`, there is no package name on the command line for the hook to extract, so no check is performed.
- **JavaScript ecosystems only.** `pip`, `cargo`, `gem`, `go get`, etc. are not intercepted.
- **Package-manager invocations only.** Direct downloads (`curl | sh`, `wget`), post-install scripts of already-accepted packages, and transitive dependencies pulled in by an allowed package are not re-checked.
- **Indirect Claude paths.** Sub-agents, MCP tools that shell out, or non-`Bash` tool calls are not covered unless the `matcher` is broadened.

For defense in depth, pair this hook with the Socket MCP server (for AI-assisted review), [Socket CLI](https://docs.socket.dev/docs/socket-cli) scans in CI, and [Socket Firewall](https://docs.socket.dev/docs/socket-firewall-enterprise) at the registry boundary.

### Testing the hook

```bash
# Should block (typosquat)
echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"npm install browserlist"}}' \
| node --experimental-strip-types hooks/socket-gate.ts

# Should allow (safe package)
echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"npm install express"}}' \
| node --experimental-strip-types hooks/socket-gate.ts
```

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

## Tools exposed by the Socket MCP Server

### depscore
Expand Down
103 changes: 103 additions & 0 deletions hooks/socket-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
#!/usr/bin/env node
import { test } from 'node:test'
import assert from 'node:assert'
import { execFileSync } from 'node:child_process'
import { join } from 'node:path'

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

function runHook (input: string, env: Record<string, string | undefined> = {}): string {
return execFileSync('node', ['--experimental-strip-types', hookPath], {
input,
encoding: 'utf-8',
timeout: 30_000,
env: { ...process.env, ...env }
}).trim()
}

function parseOutput (output: string): { decision: string, reason?: string } {
const parsed = JSON.parse(output)
return {
decision: parsed.hookSpecificOutput.permissionDecision,
reason: parsed.hookSpecificOutput.permissionDecisionReason
}
}

function makeInput (command: string): string {
return JSON.stringify({
session_id: 'test',
tool_name: 'Bash',
tool_input: { command }
})
}

const hasApiKey = !!process.env.SOCKET_API_KEY

test('socket-gate hook', async (t) => {
await t.test('allows non-Bash tools', () => {
const input = JSON.stringify({ session_id: 'test', tool_name: 'Read', tool_input: { path: '/tmp/foo' } })
const result = parseOutput(runHook(input))
assert.strictEqual(result.decision, 'allow')
})

await t.test('allows non-install commands', () => {
const result = parseOutput(runHook(makeInput('ls -la')))
assert.strictEqual(result.decision, 'allow')
})

await t.test('allows lockfile-only installs', () => {
for (const cmd of ['npm install', 'npm i', 'npm ci', 'yarn', 'yarn install', 'bun install', 'pnpm install']) {
const result = parseOutput(runHook(makeInput(cmd)))
assert.strictEqual(result.decision, 'allow', `should allow: ${cmd}`)
}
})

await t.test('allows empty input', () => {
const result = parseOutput(runHook(''))
assert.strictEqual(result.decision, 'allow')
})

await t.test('allows invalid JSON', () => {
const result = parseOutput(runHook('not json'))
assert.strictEqual(result.decision, 'allow')
})

await t.test('denies when SOCKET_API_KEY is missing', () => {
const result = parseOutput(runHook(makeInput('npm install lodash'), { SOCKET_API_KEY: '' }))
assert.strictEqual(result.decision, 'deny')
assert.ok(result.reason?.includes('SOCKET_API_KEY'), 'reason should mention the env var')
})

await t.test('allows safe package (lodash)', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => {
const result = parseOutput(runHook(makeInput('npm install lodash')))
assert.strictEqual(result.decision, 'allow')
})

await t.test('allows safe scoped package (@types/node)', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => {
const result = parseOutput(runHook(makeInput('yarn add @types/node')))
assert.strictEqual(result.decision, 'allow')
})

await t.test('blocks typosquat (browserlist)', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => {
const result = parseOutput(runHook(makeInput('npm install browserlist')))
assert.strictEqual(result.decision, 'deny')
assert.ok(result.reason?.includes('browserlist'), 'reason should mention package name')
assert.ok(result.reason?.includes('supply chain score'), 'reason should mention the score')
assert.ok(result.reason?.includes('socket.dev'), 'reason should include review link')
})

await t.test('handles versioned install', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => {
const result = parseOutput(runHook(makeInput('npm install express@4.18.2')))
assert.strictEqual(result.decision, 'allow')
})

await t.test('handles pnpm add', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => {
const result = parseOutput(runHook(makeInput('pnpm add express')))
assert.strictEqual(result.decision, 'allow')
})

await t.test('handles bun add', { skip: !hasApiKey && 'SOCKET_API_KEY not set' }, () => {
const result = parseOutput(runHook(makeInput('bun add express')))
assert.strictEqual(result.decision, 'allow')
})
})
194 changes: 194 additions & 0 deletions hooks/socket-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
#!/usr/bin/env -S node --experimental-strip-types
/**
* socket-gate.ts — Claude Code PreToolUse hook
*
* Intercepts npm/yarn/bun/pnpm install commands and checks packages against
* the Socket API. Blocks packages with a supply chain score below 0.2
* (known malware, typosquats, high-risk supply chain signals).
*
* Setup:
* 1. export SOCKET_API_KEY=... (same key used by the MCP server)
* 2. Copy this file to ~/.claude/hooks/socket-gate.ts
* 3. Add to ~/.claude/settings.json (see README)
*
* Denies when SOCKET_API_KEY is missing so users are not silently
* unprotected. Fails open on network, parse, or timeout errors so a
* Socket outage does not block legitimate work.
*/

import { readFileSync } from 'node:fs'

const SOCKET_API_URL = 'https://api.socket.dev/v0/purl'
const SUPPLY_CHAIN_THRESHOLD = 0.2
const REQUEST_TIMEOUT_MS = 10_000

interface HookInput {
session_id: string
tool_name: string
tool_input: Record<string, unknown> | string
}

interface PurlResponse {
score?: {
supplyChain?: number
}
}

function outputAllow (): void {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'allow'
}
}))
}

function outputDeny (reason: string): void {
process.stdout.write(JSON.stringify({
hookSpecificOutput: {
hookEventName: 'PreToolUse',
permissionDecision: 'deny',
permissionDecisionReason: reason
}
}))
}

const INSTALL_PATTERNS = [
/npm\s+(?:install|i|add)\s+([^\s-][^\s]*)/i,
/yarn\s+add\s+([^\s-][^\s]*)/i,
/bun\s+add\s+([^\s-][^\s]*)/i,
/pnpm\s+add\s+([^\s-][^\s]*)/i
]

const LOCKFILE_PATTERNS = [
/^npm\s+(install|i|ci)\s*$/i,
/^yarn\s*(install)?\s*$/i,
/^bun\s+install\s*$/i,
/^pnpm\s+install\s*$/i
]

export function extractPackageName (command: string): string | null {
if (LOCKFILE_PATTERNS.some(p => p.test(command.trim()))) {
return null
}

for (const pattern of INSTALL_PATTERNS) {
const match = command.match(pattern)
if (match) {
const pkg = match[1]
return pkg.replace(/@[\d^~].*/u, '').replace(/@latest$/u, '')
}
}

return null
}

export async function checkPackage (
packageName: string,
apiKey: string,
fetchImpl: typeof fetch = fetch
): Promise<{ decision: 'allow' | 'deny', reason: string }> {
const auth = Buffer.from(`${apiKey}:`).toString('base64')

const res = await fetchImpl(SOCKET_API_URL, {
method: 'POST',
headers: {
'Authorization': `Basic ${auth}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
components: [{ purl: `pkg:npm/${packageName}` }]
}),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
})

if (!res.ok) {
throw new Error(`Socket API returned ${res.status}`)
}

const text = await res.text()
const line = text.split('\n').find(l => l.trim().length > 0)
if (!line) {
throw new Error('Empty response from Socket API')
}

const parsed: PurlResponse = JSON.parse(line)
const score = parsed.score?.supplyChain

if (typeof score !== 'number') {
throw new Error('Missing supplyChain score in response')
}

if (score < SUPPLY_CHAIN_THRESHOLD) {
return {
decision: 'deny',
reason: `Socket blocked "${packageName}": supply chain score is ${score.toFixed(2)} (threshold ${SUPPLY_CHAIN_THRESHOLD}).\n\nReview: https://socket.dev/npm/package/${packageName}`
}
}

return { decision: 'allow', reason: '' }
}

async function main (): Promise<void> {
let raw: string
try {
raw = readFileSync(0, 'utf-8')
} catch {
outputAllow()
return
}

if (!raw.trim()) {
outputAllow()
return
}

let input: HookInput
try {
input = JSON.parse(raw)
} catch {
outputAllow()
return
}

if (input.tool_name !== 'Bash') {
outputAllow()
return
}

const command = typeof input.tool_input === 'string'
? input.tool_input
: (input.tool_input?.command as string) || ''

if (!command) {
outputAllow()
return
}

const packageName = extractPackageName(command)
if (!packageName) {
outputAllow()
return
}

const apiKey = process.env.SOCKET_API_KEY
if (!apiKey) {
outputDeny('SOCKET_API_KEY is not set. Export it in your shell (same key used by the Socket MCP server) or remove the hook from ~/.claude/settings.json.')
return
}

try {
const result = await checkPackage(packageName, apiKey)
if (result.decision === 'deny') {
outputDeny(result.reason)
} else {
outputAllow()
}
} catch {
outputAllow()
}
}

main().catch(() => {
outputAllow()
})
Loading