diff --git a/README.md b/README.md index 6f8e319..552004a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/hooks/socket-gate.test.ts b/hooks/socket-gate.test.ts new file mode 100644 index 0000000..68a28e6 --- /dev/null +++ b/hooks/socket-gate.test.ts @@ -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 { + 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') + }) +}) diff --git a/hooks/socket-gate.ts b/hooks/socket-gate.ts new file mode 100644 index 0000000..10d197d --- /dev/null +++ b/hooks/socket-gate.ts @@ -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 +} + +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 { + 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() +})