Skip to content
Open
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
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
173 changes: 173 additions & 0 deletions hooks/socket-gate.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
Loading