From ad3f52df01fceb438868de670cd9859580c3c4c3 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Wed, 22 Apr 2026 18:54:17 -0400 Subject: [PATCH 1/2] Add optional Claude Code hook for blocking malicious packages Adds a PreToolUse hook (hooks/socket-gate.ts) that intercepts package install commands across npm, PyPI, Cargo, RubyGems, Go, and NuGet, and queries the public Socket MCP server at https://mcp.socket.dev/ to block packages with a supply chain score below 20 (known malware, typosquats, high-risk supply chain signals). - No API key, CLI, or registration required - Fails open on network, parse, and timeout errors - 23 tests (8 unit + 15 integration), all passing Inspired by https://blog.jimmyvo.com/posts/claudes-dependency-hook/ --- README.md | 93 ++++++++++++++ hooks/socket-gate.test.ts | 173 ++++++++++++++++++++++++++ hooks/socket-gate.ts | 251 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 517 insertions(+) create mode 100644 hooks/socket-gate.test.ts create mode 100644 hooks/socket-gate.ts diff --git a/README.md b/README.md index 6f8e319..2020ad0 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,99 @@ 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 an install command, the hook queries the public Socket MCP server at `https://mcp.socket.dev/` and denies the install when the package's supply chain score is below `20` (known malware, typosquats, high-risk supply chain signals). + +Supported ecosystems and package managers: + +| Ecosystem | Commands | +|-----------|----------| +| npm | `npm install`, `npm i`, `npm add`, `yarn add`, `pnpm add`, `bun add` | +| PyPI | `pip install`, `pip3 install`, `uv add`, `uv pip install`, `poetry add`, `pipenv install` | +| Cargo | `cargo add`, `cargo install` | +| RubyGems | `gem install`, `bundle add` | +| Go | `go get`, `go install` | +| NuGet | `dotnet add package`, `nuget install` | + +No API key, no CLI, no registration. Just copy the file and wire it up. + +### Hook Setup + +**Prerequisites:** Node.js 22+. + +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 `supplyChain < 20`, allows it otherwise. Examples: + +| Package | `supplyChain` | Decision | +|---------|--------------|----------| +| `express`, `lodash`, `react` | 75–97 | Allow | +| `browserlist` (typosquat of `browserslist`) | 15 | Block | +| `electrn` (typosquat of `electron`) | 9 | Block | +| Confirmed malware | 0 | Block | + +Network, timeout, or parse errors all fail open so a Socket 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 a manifest file directly (`package.json`, `requirements.txt`, `Cargo.toml`, `Gemfile`, `go.mod`, `*.csproj`) and then runs a bare install command (`npm install`, `pip install -r requirements.txt`, `cargo build`, `bundle install`, `go mod tidy`, `dotnet restore`), there is no package name on the command line for the hook to extract, so no check is performed. +- **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 (npm 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 npm package) +echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"npm install express"}}' \ + | node --experimental-strip-types hooks/socket-gate.ts + +# Should allow (safe PyPI package) +echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"pip install requests"}}' \ + | node --experimental-strip-types hooks/socket-gate.ts + +# Should allow (safe cargo crate) +echo '{"session_id":"test","tool_name":"Bash","tool_input":{"command":"cargo add serde"}}' \ + | 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..71a4996 --- /dev/null +++ b/hooks/socket-gate.test.ts @@ -0,0 +1,173 @@ +#!/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' +import { extractPackage, parseSupplyChainScore } from './socket-gate.ts' + +const hookPath = join(import.meta.dirname, 'socket-gate.ts') + +function runHook (input: string): string { + return execFileSync('node', ['--experimental-strip-types', hookPath], { + input, + encoding: 'utf-8', + timeout: 30_000, + env: { ...process.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 } + }) +} + +test('extractPackage — npm ecosystem', () => { + assert.deepStrictEqual(extractPackage('npm install lodash'), { ecosystem: 'npm', name: 'lodash' }) + assert.deepStrictEqual(extractPackage('npm i express'), { ecosystem: 'npm', name: 'express' }) + assert.deepStrictEqual(extractPackage('npm add react'), { ecosystem: 'npm', name: 'react' }) + assert.deepStrictEqual(extractPackage('yarn add vue'), { ecosystem: 'npm', name: 'vue' }) + assert.deepStrictEqual(extractPackage('pnpm add svelte'), { ecosystem: 'npm', name: 'svelte' }) + assert.deepStrictEqual(extractPackage('bun add zod'), { ecosystem: 'npm', name: 'zod' }) + assert.deepStrictEqual(extractPackage('npm install express@4.18.2'), { ecosystem: 'npm', name: 'express' }) + assert.deepStrictEqual(extractPackage('yarn add @types/node'), { ecosystem: 'npm', name: '@types/node' }) +}) + +test('extractPackage — pypi ecosystem', () => { + assert.deepStrictEqual(extractPackage('pip install requests'), { ecosystem: 'pypi', name: 'requests' }) + assert.deepStrictEqual(extractPackage('pip3 install flask'), { ecosystem: 'pypi', name: 'flask' }) + assert.deepStrictEqual(extractPackage('python -m pip install numpy'), { ecosystem: 'pypi', name: 'numpy' }) + assert.deepStrictEqual(extractPackage('python3 -m pip install pandas'), { ecosystem: 'pypi', name: 'pandas' }) + assert.deepStrictEqual(extractPackage('uv add httpx'), { ecosystem: 'pypi', name: 'httpx' }) + assert.deepStrictEqual(extractPackage('uv pip install fastapi'), { ecosystem: 'pypi', name: 'fastapi' }) + assert.deepStrictEqual(extractPackage('poetry add pydantic'), { ecosystem: 'pypi', name: 'pydantic' }) + assert.deepStrictEqual(extractPackage('pipenv install django'), { ecosystem: 'pypi', name: 'django' }) + assert.deepStrictEqual(extractPackage('pip install requests==2.31.0'), { ecosystem: 'pypi', name: 'requests' }) + assert.deepStrictEqual(extractPackage('pip install flask>=2.0'), { ecosystem: 'pypi', name: 'flask' }) +}) + +test('extractPackage — cargo ecosystem', () => { + assert.deepStrictEqual(extractPackage('cargo add serde'), { ecosystem: 'cargo', name: 'serde' }) + assert.deepStrictEqual(extractPackage('cargo install ripgrep'), { ecosystem: 'cargo', name: 'ripgrep' }) + assert.deepStrictEqual(extractPackage('cargo add tokio@1.0'), { ecosystem: 'cargo', name: 'tokio' }) +}) + +test('extractPackage — gem ecosystem', () => { + assert.deepStrictEqual(extractPackage('gem install rails'), { ecosystem: 'gem', name: 'rails' }) + assert.deepStrictEqual(extractPackage('bundle add rspec'), { ecosystem: 'gem', name: 'rspec' }) +}) + +test('extractPackage — golang ecosystem', () => { + assert.deepStrictEqual(extractPackage('go get github.com/pkg/errors'), { ecosystem: 'golang', name: 'github.com/pkg/errors' }) + assert.deepStrictEqual(extractPackage('go install github.com/charmbracelet/gum@latest'), { ecosystem: 'golang', name: 'github.com/charmbracelet/gum' }) +}) + +test('extractPackage — nuget ecosystem', () => { + assert.deepStrictEqual(extractPackage('dotnet add package Newtonsoft.Json'), { ecosystem: 'nuget', name: 'Newtonsoft.Json' }) + assert.deepStrictEqual(extractPackage('nuget install Serilog'), { ecosystem: 'nuget', name: 'Serilog' }) +}) + +test('extractPackage — non-install commands return null', () => { + assert.strictEqual(extractPackage('ls -la'), null) + assert.strictEqual(extractPackage('npm install'), null) + assert.strictEqual(extractPackage('npm ci'), null) + assert.strictEqual(extractPackage('pip install'), null) + assert.strictEqual(extractPackage('cargo build'), null) + assert.strictEqual(extractPackage('bundle install'), null) + assert.strictEqual(extractPackage('go mod tidy'), null) +}) + +test('parseSupplyChainScore', () => { + assert.strictEqual(parseSupplyChainScore('supplyChain: 75'), 75) + assert.strictEqual(parseSupplyChainScore('supplyChain: 0'), 0) + assert.strictEqual(parseSupplyChainScore('supplyChain: 15.5'), 15.5) + assert.strictEqual(parseSupplyChainScore('no score here'), null) +}) + +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', 'bundle install', 'go mod tidy', 'cargo build']) { + 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('allows safe npm package (lodash)', () => { + const result = parseOutput(runHook(makeInput('npm install lodash'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('allows safe scoped package (@types/node)', () => { + const result = parseOutput(runHook(makeInput('yarn add @types/node'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('blocks typosquat (browserlist)', () => { + 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 npm install', () => { + const result = parseOutput(runHook(makeInput('npm install express@4.18.2'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('handles pnpm add', () => { + const result = parseOutput(runHook(makeInput('pnpm add express'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('handles bun add', () => { + const result = parseOutput(runHook(makeInput('bun add express'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('allows safe PyPI package (requests)', () => { + const result = parseOutput(runHook(makeInput('pip install requests'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('allows safe cargo crate (serde)', () => { + const result = parseOutput(runHook(makeInput('cargo add serde'))) + assert.strictEqual(result.decision, 'allow') + }) + + await t.test('allows safe gem (rails)', () => { + const result = parseOutput(runHook(makeInput('gem install rails'))) + assert.strictEqual(result.decision, 'allow') + }) +}) diff --git a/hooks/socket-gate.ts b/hooks/socket-gate.ts new file mode 100644 index 0000000..3414c9a --- /dev/null +++ b/hooks/socket-gate.ts @@ -0,0 +1,251 @@ +#!/usr/bin/env -S node --experimental-strip-types +/** + * socket-gate.ts — Claude Code PreToolUse hook + * + * Intercepts package install commands across npm, PyPI, Cargo, RubyGems, + * Go, and NuGet, and checks the target package against the public Socket + * MCP server. Blocks installs when the supply chain score is below 20 + * (known malware, typosquats, high-risk supply chain signals). + * + * No API key, no CLI, no registration beyond copying this file. + * + * Setup: + * 1. Copy this file to ~/.claude/hooks/socket-gate.ts + * 2. Add to ~/.claude/settings.json (see README) + * + * Fails open on network, parse, and timeout errors so a Socket outage + * does not block legitimate work. + */ + +import { readFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { argv } from 'node:process' + +const MCP_URL = 'https://mcp.socket.dev/' +const SUPPLY_CHAIN_THRESHOLD = 20 +const REQUEST_TIMEOUT_MS = 10_000 + +type Ecosystem = 'npm' | 'pypi' | 'cargo' | 'gem' | 'golang' | 'nuget' + +interface HookInput { + session_id: string + tool_name: string + tool_input: Record | string +} + +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: Array<{ ecosystem: Ecosystem, pattern: RegExp }> = [ + { ecosystem: 'npm', pattern: /\bnpm\s+(?:install|i|add)\s+([^\s-][^\s]*)/i }, + { ecosystem: 'npm', pattern: /\byarn\s+add\s+([^\s-][^\s]*)/i }, + { ecosystem: 'npm', pattern: /\bpnpm\s+add\s+([^\s-][^\s]*)/i }, + { ecosystem: 'npm', pattern: /\bbun\s+add\s+([^\s-][^\s]*)/i }, + { ecosystem: 'pypi', pattern: /(?:\bpython3?\s+-m\s+)?\bpip3?\s+install\s+([^\s-][^\s]*)/i }, + { ecosystem: 'pypi', pattern: /\buv\s+add\s+([^\s-][^\s]*)/i }, + { ecosystem: 'pypi', pattern: /\buv\s+pip\s+install\s+([^\s-][^\s]*)/i }, + { ecosystem: 'pypi', pattern: /\bpoetry\s+add\s+([^\s-][^\s]*)/i }, + { ecosystem: 'pypi', pattern: /\bpipenv\s+install\s+([^\s-][^\s]*)/i }, + { ecosystem: 'cargo', pattern: /\bcargo\s+(?:add|install)\s+([^\s-][^\s]*)/i }, + { ecosystem: 'gem', pattern: /\bgem\s+install\s+([^\s-][^\s]*)/i }, + { ecosystem: 'gem', pattern: /\bbundle\s+add\s+([^\s-][^\s]*)/i }, + { ecosystem: 'golang', pattern: /\bgo\s+(?:get|install)\s+([^\s-][^\s]*)/i }, + { ecosystem: 'nuget', pattern: /\bdotnet\s+add\s+package\s+([^\s-][^\s]*)/i }, + { ecosystem: 'nuget', pattern: /\bnuget\s+install\s+([^\s-][^\s]*)/i } +] + +function stripVersion (pkg: string, ecosystem: Ecosystem): string { + switch (ecosystem) { + case 'npm': + return pkg.replace(/@[\d^~].*/u, '').replace(/@latest$/u, '') + case 'pypi': + return pkg.split(/[=<>!~[]/)[0] ?? pkg + case 'cargo': + case 'golang': + case 'nuget': + return pkg.replace(/@.*$/u, '') + default: + return pkg + } +} + +export function extractPackage (command: string): { ecosystem: Ecosystem, name: string } | null { + for (const { ecosystem, pattern } of INSTALL_PATTERNS) { + const match = command.match(pattern) + if (match?.[1]) { + const name = stripVersion(match[1], ecosystem) + if (!name) continue + return { ecosystem, name } + } + } + return null +} + +export function parseSupplyChainScore (text: string): number | null { + const match = text.match(/supplyChain:\s*(\d+(?:\.\d+)?)/i) + return match ? Number(match[1]) : null +} + +export async function checkPackage ( + ecosystem: Ecosystem, + packageName: string, + fetchImpl: typeof fetch = fetch +): Promise<{ decision: 'allow' | 'deny', reason: string }> { + const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS) + const commonHeaders = { + 'Content-Type': 'application/json', + 'Accept': 'application/json, text/event-stream' + } + + const initRes = await fetchImpl(MCP_URL, { + method: 'POST', + headers: commonHeaders, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { + protocolVersion: '2024-11-05', + capabilities: {}, + clientInfo: { name: 'socket-gate', version: '1.0' } + } + }), + signal + }) + + if (!initRes.ok) { + throw new Error(`Socket MCP initialize returned ${initRes.status}`) + } + + const sessionId = initRes.headers.get('mcp-session-id') + if (!sessionId) { + throw new Error('Socket MCP did not return a session id') + } + await initRes.text() + + const callRes = await fetchImpl(MCP_URL, { + method: 'POST', + headers: { ...commonHeaders, 'Mcp-Session-Id': sessionId }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 2, + method: 'tools/call', + params: { + name: 'depscore', + arguments: { + packages: [{ ecosystem, depname: packageName }] + } + } + }), + signal + }) + + if (!callRes.ok) { + throw new Error(`Socket MCP depscore returned ${callRes.status}`) + } + + const payload = await callRes.json() as { + result?: { + content?: Array<{ type: string, text: string }> + isError?: boolean + } + } + + if (payload.result?.isError) { + throw new Error('Socket MCP reported a tool error (package likely not found)') + } + + const text = payload.result?.content?.[0]?.text ?? '' + const score = parseSupplyChainScore(text) + + if (score === null) { + throw new Error('Could not parse supplyChain score from MCP response') + } + + if (score < SUPPLY_CHAIN_THRESHOLD) { + return { + decision: 'deny', + reason: `Socket blocked "${packageName}" (${ecosystem}): supply chain score is ${score} (threshold ${SUPPLY_CHAIN_THRESHOLD}).\n\nReview: https://socket.dev/${ecosystem}/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 target = extractPackage(command) + if (!target) { + outputAllow() + return + } + + try { + const result = await checkPackage(target.ecosystem, target.name) + if (result.decision === 'deny') { + outputDeny(result.reason) + } else { + outputAllow() + } + } catch { + outputAllow() + } +} + +const isMainModule = argv[1] !== undefined && fileURLToPath(import.meta.url) === argv[1] + +if (isMainModule) { + main().catch(() => { + outputAllow() + }) +} From 52a1bc3533286837426b1c5b984cdf97df9e9514 Mon Sep 17 00:00:00 2001 From: David Larsen Date: Wed, 22 Apr 2026 19:03:09 -0400 Subject: [PATCH 2/2] Fix ESLint quote-props error on Accept header --- hooks/socket-gate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hooks/socket-gate.ts b/hooks/socket-gate.ts index 3414c9a..a1a14e6 100644 --- a/hooks/socket-gate.ts +++ b/hooks/socket-gate.ts @@ -110,7 +110,7 @@ export async function checkPackage ( const signal = AbortSignal.timeout(REQUEST_TIMEOUT_MS) const commonHeaders = { 'Content-Type': 'application/json', - 'Accept': 'application/json, text/event-stream' + Accept: 'application/json, text/event-stream' } const initRes = await fetchImpl(MCP_URL, {