From 1221c381decf1deb2a4a1293784d2bb210c7f9b2 Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 11:44:04 +0000 Subject: [PATCH 1/2] feat: add PostToolUse hook for PII redaction in Claude Code Adds `hush redact-hook` command that runs as a Claude Code PostToolUse hook, redacting PII from tool outputs before Claude ever sees them. Works standalone or alongside the proxy for defense-in-depth. - `hush redact-hook`: stdin/stdout hook handler using existing Redactor - `hush init --hooks`: generates/merges hook config into settings.json - CLI subcommand routing with dynamic imports (no heavy deps for hooks) - 14 new tests (redact-hook + init integration tests) - README: Hooks Mode section with setup, diagram, comparison table - Team config example updated with defense-in-depth setup Co-Authored-By: Claude Opus 4.6 --- README.md | 41 +++++++ examples/team-config/.claude/settings.json | 14 +++ src/cli.ts | 44 +++++--- src/commands/init.ts | 107 ++++++++++++++++++ src/commands/redact-hook.ts | 123 ++++++++++++++++++++ tests/init.test.ts | 101 +++++++++++++++++ tests/redact-hook.test.ts | 124 +++++++++++++++++++++ 7 files changed, 538 insertions(+), 16 deletions(-) create mode 100644 src/commands/init.ts create mode 100644 src/commands/redact-hook.ts create mode 100644 tests/init.test.ts create mode 100644 tests/redact-hook.test.ts diff --git a/README.md b/README.md index 81c7090..b2c22eb 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 intercepts the output, scans for PII, and replaces it with tokens before Claude processes it. 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..950fd4e --- /dev/null +++ b/src/commands/redact-hook.ts @@ -0,0 +1,123 @@ +/** + * hush redact-hook — Claude Code PostToolUse hook handler + * + * Reads the hook payload from stdin, redacts PII from tool output, + * and prints a hookSpecificOutput override if anything was redacted. + * + * Exit codes: + * 0 — success (output may or may not contain override) + * 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_output?: { + // Bash tool + stdout?: string; + stderr?: string; + // Read / Grep / WebFetch tools + content?: string; + // Generic fallback + output?: string; + [key: string]: unknown; + }; +} + +interface HookResponse { + hookSpecificOutput: { + hookEventName: 'PostToolUse'; + outputOverride: string; + }; +} + +/** Collect all text from a tool_output object. */ +function extractText(toolOutput: HookPayload['tool_output']): string | null { + if (!toolOutput || typeof toolOutput !== 'object') return null; + + const parts: string[] = []; + + if (typeof toolOutput.stdout === 'string' && toolOutput.stdout) { + parts.push(toolOutput.stdout); + } + if (typeof toolOutput.stderr === 'string' && toolOutput.stderr) { + parts.push(toolOutput.stderr); + } + if (typeof toolOutput.content === 'string' && toolOutput.content) { + parts.push(toolOutput.content); + } + if (typeof toolOutput.output === 'string' && toolOutput.output) { + parts.push(toolOutput.output); + } + + return parts.length > 0 ? parts.join('\n') : null; +} + +/** Build the redacted tool_output, preserving the original shape. */ +function redactToolOutput( + toolOutput: NonNullable, + redactor: Redactor, +): { text: string; hasRedacted: boolean } { + const text = extractText(toolOutput); + 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_output) { + // No tool_output to redact + process.exit(0); + } + + const redactor = new Redactor(); + const { text, hasRedacted } = redactToolOutput(payload.tool_output, redactor); + + if (!hasRedacted) { + // No PII found — let Claude Code keep the original output + process.exit(0); + } + + const response: HookResponse = { + hookSpecificOutput: { + hookEventName: 'PostToolUse', + outputOverride: 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..9f9b951 --- /dev/null +++ b/tests/redact-hook.test.ts @@ -0,0 +1,124 @@ +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_output: { stdout: 'email: test@foo.com' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput.hookEventName).toBe('PostToolUse'); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.hookSpecificOutput.outputOverride).not.toContain('test@foo.com'); + }); + + it('should redact email from Read content', () => { + const payload = { + tool_name: 'Read', + tool_output: { content: 'Contact: admin@internal.corp' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.hookSpecificOutput.outputOverride).not.toContain('admin@internal.corp'); + }); + + it('should redact IP address from Bash stderr', () => { + const payload = { + tool_name: 'Bash', + tool_output: { 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.hookSpecificOutput.outputOverride).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + }); + + it('should pass through clean output (no PII) with no output', () => { + const payload = { + tool_name: 'Bash', + tool_output: { 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_output', () => { + 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_output: { + 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.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + }); + + it('should redact secrets from tool output', () => { + const payload = { + tool_name: 'Bash', + tool_output: { stdout: 'api_key=sk-1234567890abcdef1234' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/); + }); +}); From 9a5dc219617af95d99b85ffffd4eb7699dd2507e Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 12:00:06 +0000 Subject: [PATCH 2/2] fix: align redact-hook with Claude Code hooks spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `tool_response` field (not `tool_output`) matching actual payload - Use `decision: "block"` + `reason` output format (PostToolUse has no outputOverride — confirmed via spec and closed GitHub issues #4635, #18594) - Handle Read tool's nested `file.content` response shape - Add Grep content field test case (10 tests total) Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- src/commands/redact-hook.ts | 65 +++++++++++++++++++------------------ tests/redact-hook.test.ts | 54 ++++++++++++++++++++---------- 3 files changed, 70 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index b2c22eb..03730d3 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Local files/commands → [Hook: redact before Claude sees] → Claude's context LLM Provider ``` -When a tool runs (e.g., `cat .env`), the hook intercepts the output, scans for PII, and replaces it with tokens before Claude processes it. Claude only ever sees `[USER_EMAIL_f22c5a]`, not `alice@company.com`. +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 diff --git a/src/commands/redact-hook.ts b/src/commands/redact-hook.ts index 950fd4e..c0e2d93 100644 --- a/src/commands/redact-hook.ts +++ b/src/commands/redact-hook.ts @@ -1,11 +1,11 @@ /** * hush redact-hook — Claude Code PostToolUse hook handler * - * Reads the hook payload from stdin, redacts PII from tool output, - * and prints a hookSpecificOutput override if anything was redacted. + * 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 (output may or may not contain override) + * 0 — success (may or may not block) * 2 — malformed input (blocks the tool call per hooks spec) */ @@ -14,53 +14,56 @@ import { Redactor } from '../middleware/redactor.js'; interface HookPayload { tool_name?: string; tool_input?: Record; - tool_output?: { + tool_response?: { // Bash tool stdout?: string; stderr?: string; - // Read / Grep / WebFetch tools + // Read tool (nested under file) + file?: { content?: string; [key: string]: unknown }; + // Grep / WebFetch / generic content?: string; - // Generic fallback output?: string; [key: string]: unknown; }; } interface HookResponse { - hookSpecificOutput: { - hookEventName: 'PostToolUse'; - outputOverride: string; - }; + decision: 'block'; + reason: string; } -/** Collect all text from a tool_output object. */ -function extractText(toolOutput: HookPayload['tool_output']): string | null { - if (!toolOutput || typeof toolOutput !== 'object') return null; +/** 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 toolOutput.stdout === 'string' && toolOutput.stdout) { - parts.push(toolOutput.stdout); + if (typeof toolResponse.stdout === 'string' && toolResponse.stdout) { + parts.push(toolResponse.stdout); + } + if (typeof toolResponse.stderr === 'string' && toolResponse.stderr) { + parts.push(toolResponse.stderr); } - if (typeof toolOutput.stderr === 'string' && toolOutput.stderr) { - parts.push(toolOutput.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 toolOutput.content === 'string' && toolOutput.content) { - parts.push(toolOutput.content); + if (typeof toolResponse.content === 'string' && toolResponse.content) { + parts.push(toolResponse.content); } - if (typeof toolOutput.output === 'string' && toolOutput.output) { - parts.push(toolOutput.output); + if (typeof toolResponse.output === 'string' && toolResponse.output) { + parts.push(toolResponse.output); } return parts.length > 0 ? parts.join('\n') : null; } -/** Build the redacted tool_output, preserving the original shape. */ -function redactToolOutput( - toolOutput: NonNullable, +/** Redact PII from the tool response text. */ +function redactToolResponse( + toolResponse: NonNullable, redactor: Redactor, ): { text: string; hasRedacted: boolean } { - const text = extractText(toolOutput); + const text = extractText(toolResponse); if (!text) return { text: '', hasRedacted: false }; const { content, hasRedacted } = redactor.redact(text); @@ -98,13 +101,13 @@ export async function run(): Promise { process.exit(2); } - if (!payload.tool_output) { - // No tool_output to redact + if (!payload.tool_response) { + // No tool_response to redact process.exit(0); } const redactor = new Redactor(); - const { text, hasRedacted } = redactToolOutput(payload.tool_output, redactor); + const { text, hasRedacted } = redactToolResponse(payload.tool_response, redactor); if (!hasRedacted) { // No PII found — let Claude Code keep the original output @@ -112,10 +115,8 @@ export async function run(): Promise { } const response: HookResponse = { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - outputOverride: text, - }, + decision: 'block', + reason: text, }; process.stdout.write(JSON.stringify(response) + '\n'); diff --git a/tests/redact-hook.test.ts b/tests/redact-hook.test.ts index 9f9b951..7ea5aa2 100644 --- a/tests/redact-hook.test.ts +++ b/tests/redact-hook.test.ts @@ -29,46 +29,48 @@ describe('hush redact-hook', () => { it('should redact email from Bash stdout', () => { const payload = { tool_name: 'Bash', - tool_output: { stdout: 'email: test@foo.com' }, + 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.hookSpecificOutput.hookEventName).toBe('PostToolUse'); - expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); - expect(result.hookSpecificOutput.outputOverride).not.toContain('test@foo.com'); + 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 content', () => { + it('should redact email from Read file.content', () => { const payload = { tool_name: 'Read', - tool_output: { content: 'Contact: admin@internal.corp' }, + 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.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); - expect(result.hookSpecificOutput.outputOverride).not.toContain('admin@internal.corp'); + 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_output: { stderr: 'connection to 192.168.1.100 failed' }, + 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.hookSpecificOutput.outputOverride).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + 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_output: { stdout: 'hello world' }, + tool_response: { stdout: 'hello world' }, }; const { stdout, exitCode } = runHook(JSON.stringify(payload)); expect(exitCode).toBe(0); @@ -87,7 +89,7 @@ describe('hush redact-hook', () => { expect(stderr).toContain('invalid JSON'); }); - it('should handle payload with no tool_output', () => { + it('should handle payload with no tool_response', () => { const payload = { tool_name: 'Bash' }; const { stdout, exitCode } = runHook(JSON.stringify(payload)); expect(exitCode).toBe(0); @@ -97,7 +99,7 @@ describe('hush redact-hook', () => { it('should combine stdout and stderr when both have PII', () => { const payload = { tool_name: 'Bash', - tool_output: { + tool_response: { stdout: 'user email: alice@example.com', stderr: 'warning: 10.0.0.1 unreachable', }, @@ -106,19 +108,35 @@ describe('hush redact-hook', () => { expect(exitCode).toBe(0); const result = JSON.parse(stdout); - expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); - expect(result.hookSpecificOutput.outputOverride).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + 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 output', () => { + it('should redact secrets from tool response', () => { const payload = { tool_name: 'Bash', - tool_output: { stdout: 'api_key=sk-1234567890abcdef1234' }, + 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.hookSpecificOutput.outputOverride).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/); + 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'); }); });