Skip to content
Draft
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
62 changes: 62 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,68 @@ 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 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).

The hook fails open on all errors, so it never blocks legitimate work.

### Hook Setup

**Prerequisites:**
- Node.js 22+
- [Socket CLI](https://www.npmjs.com/package/@socketsecurity/cli): `npm install -g @socketsecurity/cli`
- Run `socket login` to authenticate (one-time setup, no env vars needed)

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

| Alert Severity | Decision | Example |
|----------------|----------|---------|
| **Critical** | Block installation | `browserlist` (typosquat of `browserslist`) |
| **High** | Block installation | Packages with known supply chain risks |
| **Low/None** | Allow | `express`, `lodash`, `react` |
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
| **Low/None** | Allow | `express`, `lodash`, `react` |
| **Medium/Low** | Allow | `express`, `lodash`, `react` |

No such thing as "None" severity


### 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
113 changes: 113 additions & 0 deletions hooks/socket-gate.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
#!/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): string {
return execFileSync('node', ['--experimental-strip-types', hookPath], {
input,
encoding: 'utf-8',
timeout: 60_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 }
})
}

function socketCliAvailable (): boolean {
try {
execFileSync('which', ['socket'], { encoding: 'utf-8', timeout: 5_000 })
return true
} catch {
return false
}
}

const hasCli = socketCliAvailable()

test('socket-gate hook', async (t) => {
// ========================================
// Unit tests (no Socket CLI required)
// ========================================

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')
})

// ========================================
// Integration tests (require Socket CLI with `socket login`)
// ========================================

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

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

await t.test('blocks typosquat (browserlist)', { skip: !hasCli && 'Socket CLI not installed' }, () => {
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('socket.dev'), 'reason should include review link')
})

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

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

await t.test('handles bun add', { skip: !hasCli && 'Socket CLI not installed' }, () => {
const result = parseOutput(runHook(makeInput('bun add express')))
assert.strictEqual(result.decision, 'allow')
})
})
Loading
Loading