diff --git a/README.md b/README.md index 81c7090..03730d3 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,47 @@ CODE_ASSIST_ENDPOINT=http://127.0.0.1:4000 Each developer just needs `hush` running locally. All AI tools in the project will route through it automatically. +## Hooks Mode (Claude Code) + +Hush can also run as a **Claude Code hook** — redacting PII from tool outputs *before Claude ever sees them*. No proxy required. + +### Setup + +```bash +hush init --hooks +``` + +This adds a `PostToolUse` hook to `.claude/settings.json` that runs `hush redact-hook` after every `Bash`, `Read`, `Grep`, and `WebFetch` tool call. + +Use `--local` to write to `settings.local.json` instead (for personal overrides not committed to the repo). + +### How it works + +``` +Local files/commands → [Hook: redact before Claude sees] → Claude's context + ↓ + API request + ↓ + [Proxy: redact before cloud] + ↓ + LLM Provider +``` + +When a tool runs (e.g., `cat .env`), the hook inspects the response for PII. If PII is found, the hook **blocks** the raw output and provides Claude with the redacted version instead. Claude only ever sees `[USER_EMAIL_f22c5a]`, not `alice@company.com`. + +### Hooks vs Proxy + +| | Hooks Mode | Proxy Mode | +|---|---|---| +| **What's protected** | Tool outputs (before Claude sees them) | API requests (before they leave your machine) | +| **Setup** | `hush init --hooks` | `hush` + point `ANTHROPIC_BASE_URL` | +| **Works with** | Claude Code only | Any AI tool | +| **Defense-in-depth** | Use both for maximum coverage | Use both for maximum coverage | + +### Defense-in-depth + +For maximum protection, use both modes together. The team config example in [`examples/team-config/`](examples/team-config/) shows this setup — hooks redact tool outputs and the proxy redacts API requests. + ## How it Works 1. **Intercept** — Hush sits on your machine between your AI tool and the LLM provider. diff --git a/examples/team-config/.claude/settings.json b/examples/team-config/.claude/settings.json index fab86a5..1333b2c 100644 --- a/examples/team-config/.claude/settings.json +++ b/examples/team-config/.claude/settings.json @@ -1,5 +1,19 @@ { "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:4000" + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash|Read|Grep|WebFetch", + "hooks": [ + { + "type": "command", + "command": "hush redact-hook", + "timeout": 10 + } + ] + } + ] } } diff --git a/src/cli.ts b/src/cli.ts index 3f678fb..cb3757c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,20 +1,32 @@ #!/usr/bin/env node -import { app } from './index.js'; -import { createLogger } from './lib/logger.js'; -const log = createLogger('hush-cli'); -const PORT = process.env.PORT || 4000; +const subcommand = process.argv[2]; -const server = app.listen(PORT, () => { - log.info(`Hush Semantic Gateway is listening on http://localhost:${PORT}`); - log.info(`Routes: /v1/messages → Anthropic, /v1/chat/completions → OpenAI, /api/paas/v4/** → ZhipuAI, * → Google`); -}); +if (subcommand === 'redact-hook') { + const { run } = await import('./commands/redact-hook.js'); + await run(); +} else if (subcommand === 'init') { + const { run } = await import('./commands/init.js'); + run(process.argv.slice(3)); +} else { + // Default: start the proxy server + const { app } = await import('./index.js'); + const { createLogger } = await import('./lib/logger.js'); -server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - log.error(`Port ${PORT} is already in use. Stop the other process or use PORT= hush`); - } else { - log.error({ err }, 'Failed to start server'); - } - process.exit(1); -}); + const log = createLogger('hush-cli'); + const PORT = process.env.PORT || 4000; + + const server = app.listen(PORT, () => { + log.info(`Hush Semantic Gateway is listening on http://localhost:${PORT}`); + log.info(`Routes: /v1/messages → Anthropic, /v1/chat/completions → OpenAI, /api/paas/v4/** → ZhipuAI, * → Google`); + }); + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + log.error(`Port ${PORT} is already in use. Stop the other process or use PORT= hush`); + } else { + log.error({ err }, 'Failed to start server'); + } + process.exit(1); + }); +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..7ae4efb --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,107 @@ +/** + * hush init — Generate Claude Code hook configuration + * + * Usage: + * hush init --hooks Write to .claude/settings.json + * hush init --hooks --local Write to .claude/settings.local.json + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; + +const HOOK_CONFIG = { + hooks: { + PostToolUse: [ + { + matcher: 'Bash|Read|Grep|WebFetch', + hooks: [ + { + type: 'command' as const, + command: 'hush redact-hook', + timeout: 10, + }, + ], + }, + ], + }, +}; + +interface SettingsJson { + hooks?: { + PostToolUse?: Array<{ + matcher: string; + hooks: Array<{ type: string; command: string; timeout?: number }>; + }>; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +function hasHushHook(settings: SettingsJson): boolean { + const postToolUse = settings.hooks?.PostToolUse; + if (!Array.isArray(postToolUse)) return false; + + return postToolUse.some((entry) => + entry.hooks?.some((h) => h.command?.includes('hush redact-hook')), + ); +} + +function mergeHooks(existing: SettingsJson): SettingsJson { + const merged = { ...existing }; + + if (!merged.hooks) { + merged.hooks = {}; + } + + if (!Array.isArray(merged.hooks.PostToolUse)) { + merged.hooks.PostToolUse = []; + } + + merged.hooks = { ...merged.hooks, PostToolUse: [...merged.hooks.PostToolUse, ...HOOK_CONFIG.hooks.PostToolUse] }; + + return merged; +} + +export function run(args: string[]): void { + const hasHooksFlag = args.includes('--hooks'); + const isLocal = args.includes('--local'); + + if (!hasHooksFlag) { + process.stderr.write('Usage: hush init --hooks [--local]\n'); + process.stderr.write('\n'); + process.stderr.write('Options:\n'); + process.stderr.write(' --hooks Generate Claude Code PostToolUse hook config\n'); + process.stderr.write(' --local Write to settings.local.json instead of settings.json\n'); + process.exit(1); + } + + const claudeDir = join(process.cwd(), '.claude'); + const filename = isLocal ? 'settings.local.json' : 'settings.json'; + const filePath = join(claudeDir, filename); + + // Ensure .claude/ exists + if (!existsSync(claudeDir)) { + mkdirSync(claudeDir, { recursive: true }); + } + + // Read existing settings or start fresh + let settings: SettingsJson = {}; + if (existsSync(filePath)) { + try { + const raw = readFileSync(filePath, 'utf-8'); + settings = JSON.parse(raw) as SettingsJson; + } catch { + process.stderr.write(`Warning: could not parse ${filePath}, starting fresh\n`); + } + } + + // Idempotency check + if (hasHushHook(settings)) { + process.stdout.write(`hush hooks already configured in ${filePath}\n`); + return; + } + + const merged = mergeHooks(settings); + writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n'); + process.stdout.write(`Wrote hush hooks config to ${filePath}\n`); +} diff --git a/src/commands/redact-hook.ts b/src/commands/redact-hook.ts new file mode 100644 index 0000000..c0e2d93 --- /dev/null +++ b/src/commands/redact-hook.ts @@ -0,0 +1,124 @@ +/** + * hush redact-hook — Claude Code PostToolUse hook handler + * + * Reads the hook payload from stdin, redacts PII from the tool response, + * and blocks the output (replacing it with redacted text) if PII was found. + * + * Exit codes: + * 0 — success (may or may not block) + * 2 — malformed input (blocks the tool call per hooks spec) + */ + +import { Redactor } from '../middleware/redactor.js'; + +interface HookPayload { + tool_name?: string; + tool_input?: Record; + tool_response?: { + // Bash tool + stdout?: string; + stderr?: string; + // Read tool (nested under file) + file?: { content?: string; [key: string]: unknown }; + // Grep / WebFetch / generic + content?: string; + output?: string; + [key: string]: unknown; + }; +} + +interface HookResponse { + decision: 'block'; + reason: string; +} + +/** Collect all text from a tool_response object. */ +function extractText(toolResponse: HookPayload['tool_response']): string | null { + if (!toolResponse || typeof toolResponse !== 'object') return null; + + const parts: string[] = []; + + if (typeof toolResponse.stdout === 'string' && toolResponse.stdout) { + parts.push(toolResponse.stdout); + } + if (typeof toolResponse.stderr === 'string' && toolResponse.stderr) { + parts.push(toolResponse.stderr); + } + // Read tool nests content under file.content + if (toolResponse.file && typeof toolResponse.file.content === 'string' && toolResponse.file.content) { + parts.push(toolResponse.file.content); + } + if (typeof toolResponse.content === 'string' && toolResponse.content) { + parts.push(toolResponse.content); + } + if (typeof toolResponse.output === 'string' && toolResponse.output) { + parts.push(toolResponse.output); + } + + return parts.length > 0 ? parts.join('\n') : null; +} + +/** Redact PII from the tool response text. */ +function redactToolResponse( + toolResponse: NonNullable, + redactor: Redactor, +): { text: string; hasRedacted: boolean } { + const text = extractText(toolResponse); + if (!text) return { text: '', hasRedacted: false }; + + const { content, hasRedacted } = redactor.redact(text); + return { text: content as string, hasRedacted }; +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk)); + process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + process.stdin.on('error', reject); + }); +} + +export async function run(): Promise { + let raw: string; + try { + raw = await readStdin(); + } catch { + process.stderr.write('hush redact-hook: failed to read stdin\n'); + process.exit(2); + } + + if (!raw.trim()) { + // Empty stdin — nothing to redact + process.exit(0); + } + + let payload: HookPayload; + try { + payload = JSON.parse(raw) as HookPayload; + } catch { + process.stderr.write('hush redact-hook: invalid JSON on stdin\n'); + process.exit(2); + } + + if (!payload.tool_response) { + // No tool_response to redact + process.exit(0); + } + + const redactor = new Redactor(); + const { text, hasRedacted } = redactToolResponse(payload.tool_response, redactor); + + if (!hasRedacted) { + // No PII found — let Claude Code keep the original output + process.exit(0); + } + + const response: HookResponse = { + decision: 'block', + reason: text, + }; + + process.stdout.write(JSON.stringify(response) + '\n'); + process.exit(0); +} diff --git a/tests/init.test.ts b/tests/init.test.ts new file mode 100644 index 0000000..f035b9a --- /dev/null +++ b/tests/init.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; +import { tmpdir } from 'os'; + +const CLI = join(__dirname, '..', 'dist', 'cli.js'); + +function runInit(cwd: string, ...extraArgs: string[]): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync('node', [CLI, 'init', '--hooks', ...extraArgs], { + encoding: 'utf-8', + cwd, + timeout: 5000, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (err: any) { + return { + stdout: err.stdout ?? '', + stderr: err.stderr ?? '', + exitCode: err.status ?? 1, + }; + } +} + +describe('hush init --hooks', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'hush-init-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should create .claude/settings.json from scratch', () => { + const { stdout, exitCode } = runInit(tmpDir); + expect(exitCode).toBe(0); + expect(stdout).toContain('Wrote hush hooks config'); + + const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8')); + expect(settings.hooks.PostToolUse).toHaveLength(1); + expect(settings.hooks.PostToolUse[0].matcher).toBe('Bash|Read|Grep|WebFetch'); + expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook'); + }); + + it('should merge into existing settings preserving other keys', () => { + const claudeDir = join(tmpDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.json'), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:4000' } }, null, 2), + ); + + const { exitCode } = runInit(tmpDir); + expect(exitCode).toBe(0); + + const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8')); + // Preserved existing env + expect(settings.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:4000'); + // Added hooks + expect(settings.hooks.PostToolUse).toHaveLength(1); + expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook'); + }); + + it('should be idempotent on re-run', () => { + runInit(tmpDir); + const { stdout, exitCode } = runInit(tmpDir); + expect(exitCode).toBe(0); + expect(stdout).toContain('already configured'); + + const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8')); + expect(settings.hooks.PostToolUse).toHaveLength(1); // Not duplicated + }); + + it('should write to settings.local.json with --local flag', () => { + const { stdout, exitCode } = runInit(tmpDir, '--local'); + expect(exitCode).toBe(0); + expect(stdout).toContain('settings.local.json'); + + const localPath = join(tmpDir, '.claude', 'settings.local.json'); + expect(existsSync(localPath)).toBe(true); + + const settings = JSON.parse(readFileSync(localPath, 'utf-8')); + expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook'); + }); + + it('should show usage without --hooks flag', () => { + try { + execFileSync('node', [CLI, 'init'], { + encoding: 'utf-8', + cwd: tmpDir, + timeout: 5000, + }); + } catch (err: any) { + expect(err.status).toBe(1); + expect(err.stderr).toContain('Usage'); + } + }); +}); diff --git a/tests/redact-hook.test.ts b/tests/redact-hook.test.ts new file mode 100644 index 0000000..7ea5aa2 --- /dev/null +++ b/tests/redact-hook.test.ts @@ -0,0 +1,142 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'child_process'; +import { join } from 'path'; + +/** + * Integration tests for `hush redact-hook`. + * Spawns the CLI as a child process with piped stdin, matching real hook usage. + */ +const CLI = join(__dirname, '..', 'dist', 'cli.js'); + +function runHook(input: string): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync('node', [CLI, 'redact-hook'], { + input, + encoding: 'utf-8', + timeout: 5000, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (err: any) { + return { + stdout: err.stdout ?? '', + stderr: err.stderr ?? '', + exitCode: err.status ?? 1, + }; + } +} + +describe('hush redact-hook', () => { + it('should redact email from Bash stdout', () => { + const payload = { + tool_name: 'Bash', + tool_response: { stdout: 'email: test@foo.com' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).not.toContain('test@foo.com'); + }); + + it('should redact email from Read file.content', () => { + const payload = { + tool_name: 'Read', + tool_response: { file: { content: 'Contact: admin@internal.corp', filePath: '/app/config.json' } }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).not.toContain('admin@internal.corp'); + }); + + it('should redact IP address from Bash stderr', () => { + const payload = { + tool_name: 'Bash', + tool_response: { stderr: 'connection to 192.168.1.100 failed' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + }); + + it('should pass through clean output (no PII) with no output', () => { + const payload = { + tool_name: 'Bash', + tool_response: { stdout: 'hello world' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should handle empty stdin gracefully', () => { + const { stdout, exitCode } = runHook(''); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should exit 2 for invalid JSON', () => { + const { exitCode, stderr } = runHook('not json'); + expect(exitCode).toBe(2); + expect(stderr).toContain('invalid JSON'); + }); + + it('should handle payload with no tool_response', () => { + const payload = { tool_name: 'Bash' }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should combine stdout and stderr when both have PII', () => { + const payload = { + tool_name: 'Bash', + tool_response: { + stdout: 'user email: alice@example.com', + stderr: 'warning: 10.0.0.1 unreachable', + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + }); + + it('should redact secrets from tool response', () => { + const payload = { + tool_name: 'Bash', + tool_response: { stdout: 'api_key=sk-1234567890abcdef1234' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/); + }); + + it('should handle Grep tool with top-level content field', () => { + const payload = { + tool_name: 'Grep', + tool_response: { content: 'src/config.ts:3: email: "dev@internal.corp"' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).not.toContain('dev@internal.corp'); + }); +});