From 753f5360e4f04d14bb54cf3f840375a6c04f3127 Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 22:27:17 +0000 Subject: [PATCH 1/3] chore: bump version to 0.1.8 Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index e5a2d73..5fe663f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@aictrl/hush", - "version": "0.1.7", + "version": "0.1.8", "description": "Hush: A Semantic Security Gateway for AI Agents. Redacts PII from prompts and tool outputs locally before they hit the cloud.", "type": "module", "main": "dist/index.js", From a896eb718f027a23e77ceaa6c9d0a2a7771aab82 Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 23:26:46 +0000 Subject: [PATCH 2/3] feat: add MCP tool call redaction via PreToolUse and PostToolUse hooks Bidirectional PII redaction for MCP tools: PreToolUse redacts outbound arguments before they reach the MCP server, PostToolUse redacts inbound results before the LLM sees them. Built-in tool redaction unchanged. Co-Authored-By: Claude Opus 4.6 --- examples/team-config/.claude/settings.json | 22 +++ src/commands/init.ts | 82 ++++++--- src/commands/redact-hook.ts | 139 ++++++++++++--- tests/init.test.ts | 84 ++++++++- tests/redact-hook.test.ts | 195 +++++++++++++++++++++ 5 files changed, 466 insertions(+), 56 deletions(-) diff --git a/examples/team-config/.claude/settings.json b/examples/team-config/.claude/settings.json index 1333b2c..3480650 100644 --- a/examples/team-config/.claude/settings.json +++ b/examples/team-config/.claude/settings.json @@ -3,6 +3,18 @@ "ANTHROPIC_BASE_URL": "http://127.0.0.1:4000" }, "hooks": { + "PreToolUse": [ + { + "matcher": "mcp__.*", + "hooks": [ + { + "type": "command", + "command": "hush redact-hook", + "timeout": 10 + } + ] + } + ], "PostToolUse": [ { "matcher": "Bash|Read|Grep|WebFetch", @@ -13,6 +25,16 @@ "timeout": 10 } ] + }, + { + "matcher": "mcp__.*", + "hooks": [ + { + "type": "command", + "command": "hush redact-hook", + "timeout": 10 + } + ] } ] } diff --git a/src/commands/init.ts b/src/commands/init.ts index 7ae4efb..d484a0b 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -9,43 +9,81 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; import { join } from 'path'; +const HUSH_HOOK = { + type: 'command' as const, + command: 'hush redact-hook', + timeout: 10, +}; + const HOOK_CONFIG = { hooks: { + PreToolUse: [ + { + matcher: 'mcp__.*', + hooks: [HUSH_HOOK], + }, + ], PostToolUse: [ { matcher: 'Bash|Read|Grep|WebFetch', - hooks: [ - { - type: 'command' as const, - command: 'hush redact-hook', - timeout: 10, - }, - ], + hooks: [HUSH_HOOK], + }, + { + matcher: 'mcp__.*', + hooks: [HUSH_HOOK], }, ], }, }; +interface HookEntry { + matcher: string; + hooks: Array<{ type: string; command: string; timeout?: number }>; +} + interface SettingsJson { hooks?: { - PostToolUse?: Array<{ - matcher: string; - hooks: Array<{ type: string; command: string; timeout?: number }>; - }>; + PreToolUse?: HookEntry[]; + PostToolUse?: HookEntry[]; [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) => +function hasHushHookInEntries(entries: HookEntry[] | undefined): boolean { + if (!Array.isArray(entries)) return false; + return entries.some((entry) => entry.hooks?.some((h) => h.command?.includes('hush redact-hook')), ); } +function hasHushHook(settings: SettingsJson): boolean { + return ( + hasHushHookInEntries(settings.hooks?.PreToolUse) && + hasHushHookInEntries(settings.hooks?.PostToolUse) + ); +} + +function mergeHookEntries( + existing: HookEntry[] | undefined, + newEntries: HookEntry[], +): HookEntry[] { + const merged = Array.isArray(existing) ? [...existing] : []; + + for (const entry of newEntries) { + const alreadyHas = merged.some( + (e) => + e.matcher === entry.matcher && + e.hooks?.some((h) => h.command?.includes('hush redact-hook')), + ); + if (!alreadyHas) { + merged.push(entry); + } + } + + return merged; +} + function mergeHooks(existing: SettingsJson): SettingsJson { const merged = { ...existing }; @@ -53,11 +91,11 @@ function mergeHooks(existing: SettingsJson): SettingsJson { merged.hooks = {}; } - if (!Array.isArray(merged.hooks.PostToolUse)) { - merged.hooks.PostToolUse = []; - } - - merged.hooks = { ...merged.hooks, PostToolUse: [...merged.hooks.PostToolUse, ...HOOK_CONFIG.hooks.PostToolUse] }; + merged.hooks = { + ...merged.hooks, + PreToolUse: mergeHookEntries(merged.hooks.PreToolUse, HOOK_CONFIG.hooks.PreToolUse), + PostToolUse: mergeHookEntries(merged.hooks.PostToolUse, HOOK_CONFIG.hooks.PostToolUse), + }; return merged; } @@ -70,7 +108,7 @@ export function run(args: string[]): void { 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(' --hooks Generate Claude Code hook config (PreToolUse + PostToolUse)\n'); process.stderr.write(' --local Write to settings.local.json instead of settings.json\n'); process.exit(1); } diff --git a/src/commands/redact-hook.ts b/src/commands/redact-hook.ts index c0e2d93..43c434b 100644 --- a/src/commands/redact-hook.ts +++ b/src/commands/redact-hook.ts @@ -1,17 +1,28 @@ /** - * hush redact-hook — Claude Code PostToolUse hook handler + * hush redact-hook — Claude Code PreToolUse / 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. + * Reads the hook payload from stdin, redacts PII, and returns the + * appropriate response format depending on the hook event type: + * + * PreToolUse — redacts outbound MCP tool arguments (updatedInput) + * PostToolUse — redacts inbound MCP tool results (updatedMCPToolOutput) + * or blocks built-in tool output (decision: "block") * * Exit codes: - * 0 — success (may or may not block) + * 0 — success (may or may not redact) * 2 — malformed input (blocks the tool call per hooks spec) */ import { Redactor } from '../middleware/redactor.js'; +interface MCPContentBlock { + type: string; + text?: string; + [key: string]: unknown; +} + interface HookPayload { + hook_event_name?: 'PreToolUse' | 'PostToolUse'; tool_name?: string; tool_input?: Record; tool_response?: { @@ -21,18 +32,13 @@ interface HookPayload { // Read tool (nested under file) file?: { content?: string; [key: string]: unknown }; // Grep / WebFetch / generic - content?: string; + content?: string | MCPContentBlock[]; output?: string; [key: string]: unknown; }; } -interface HookResponse { - decision: 'block'; - reason: string; -} - -/** Collect all text from a tool_response object. */ +/** Collect all text from a built-in tool_response object. */ function extractText(toolResponse: HookPayload['tool_response']): string | null { if (!toolResponse || typeof toolResponse !== 'object') return null; @@ -58,8 +64,8 @@ function extractText(toolResponse: HookPayload['tool_response']): string | null return parts.length > 0 ? parts.join('\n') : null; } -/** Redact PII from the tool response text. */ -function redactToolResponse( +/** Redact PII from a built-in tool response text. */ +function redactBuiltinToolResponse( toolResponse: NonNullable, redactor: Redactor, ): { text: string; hasRedacted: boolean } { @@ -70,6 +76,83 @@ function redactToolResponse( return { text: content as string, hasRedacted }; } +/** Handle PreToolUse — redact outbound MCP tool arguments. */ +function handlePreToolUse(payload: HookPayload, redactor: Redactor): void { + if (!payload.tool_input || typeof payload.tool_input !== 'object') { + process.exit(0); + } + + const { content, hasRedacted } = redactor.redact(payload.tool_input); + + if (!hasRedacted) { + process.exit(0); + } + + const response = { + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + updatedInput: content, + }, + }; + + process.stdout.write(JSON.stringify(response) + '\n'); + process.exit(0); +} + +/** Handle PostToolUse for MCP tools — redact inbound content blocks. */ +function handlePostToolUseMCP(payload: HookPayload, redactor: Redactor): void { + const toolResponse = payload.tool_response; + if (!toolResponse || typeof toolResponse !== 'object') { + process.exit(0); + } + + const contentArray = toolResponse.content; + if (!Array.isArray(contentArray)) { + process.exit(0); + } + + const { content: redactedArray, hasRedacted } = redactor.redact(contentArray); + + if (!hasRedacted) { + process.exit(0); + } + + const response = { + updatedMCPToolOutput: { + content: redactedArray, + }, + }; + + process.stdout.write(JSON.stringify(response) + '\n'); + process.exit(0); +} + +/** Handle PostToolUse for built-in tools — existing block/reason flow. */ +function handlePostToolUseBuiltin(payload: HookPayload, redactor: Redactor): void { + if (!payload.tool_response) { + process.exit(0); + } + + const { text, hasRedacted } = redactBuiltinToolResponse(payload.tool_response, redactor); + + if (!hasRedacted) { + process.exit(0); + } + + const response = { + decision: 'block' as const, + reason: text, + }; + + process.stdout.write(JSON.stringify(response) + '\n'); + process.exit(0); +} + +function isMCPTool(toolName?: string): boolean { + return typeof toolName === 'string' && toolName.startsWith('mcp__'); +} + function readStdin(): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; @@ -89,7 +172,6 @@ export async function run(): Promise { } if (!raw.trim()) { - // Empty stdin — nothing to redact process.exit(0); } @@ -101,24 +183,23 @@ export async function run(): Promise { 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); + const eventName = payload.hook_event_name; - if (!hasRedacted) { - // No PII found — let Claude Code keep the original output - process.exit(0); + if (eventName === 'PreToolUse') { + handlePreToolUse(payload, redactor); + return; } - const response: HookResponse = { - decision: 'block', - reason: text, - }; + if (eventName === 'PostToolUse') { + if (isMCPTool(payload.tool_name)) { + handlePostToolUseMCP(payload, redactor); + } else { + handlePostToolUseBuiltin(payload, redactor); + } + return; + } - process.stdout.write(JSON.stringify(response) + '\n'); - process.exit(0); + // Backward compat: no hook_event_name → treat as PostToolUse built-in + handlePostToolUseBuiltin(payload, redactor); } diff --git a/tests/init.test.ts b/tests/init.test.ts index f035b9a..c92fb7d 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -34,15 +34,24 @@ describe('hush init --hooks', () => { rmSync(tmpDir, { recursive: true, force: true }); }); - it('should create .claude/settings.json from scratch', () => { + it('should create .claude/settings.json with both PreToolUse and PostToolUse', () => { 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); + + // PreToolUse + expect(settings.hooks.PreToolUse).toHaveLength(1); + expect(settings.hooks.PreToolUse[0].matcher).toBe('mcp__.*'); + expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe('hush redact-hook'); + + // PostToolUse + expect(settings.hooks.PostToolUse).toHaveLength(2); expect(settings.hooks.PostToolUse[0].matcher).toBe('Bash|Read|Grep|WebFetch'); expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook'); + expect(settings.hooks.PostToolUse[1].matcher).toBe('mcp__.*'); + expect(settings.hooks.PostToolUse[1].hooks[0].command).toBe('hush redact-hook'); }); it('should merge into existing settings preserving other keys', () => { @@ -59,8 +68,9 @@ describe('hush init --hooks', () => { 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); + // Added both hook types + expect(settings.hooks.PreToolUse).toHaveLength(1); + expect(settings.hooks.PostToolUse).toHaveLength(2); expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook'); }); @@ -71,7 +81,8 @@ describe('hush init --hooks', () => { 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 + expect(settings.hooks.PreToolUse).toHaveLength(1); // Not duplicated + expect(settings.hooks.PostToolUse).toHaveLength(2); // Not duplicated }); it('should write to settings.local.json with --local flag', () => { @@ -83,6 +94,7 @@ describe('hush init --hooks', () => { expect(existsSync(localPath)).toBe(true); const settings = JSON.parse(readFileSync(localPath, 'utf-8')); + expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe('hush redact-hook'); expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook'); }); @@ -98,4 +110,66 @@ describe('hush init --hooks', () => { expect(err.stderr).toContain('Usage'); } }); + + it('should upgrade old PostToolUse-only config by adding PreToolUse', () => { + const claudeDir = join(tmpDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + + // Simulate old config with only PostToolUse + const oldConfig = { + hooks: { + PostToolUse: [ + { + matcher: 'Bash|Read|Grep|WebFetch', + hooks: [{ type: 'command', command: 'hush redact-hook', timeout: 10 }], + }, + ], + }, + }; + writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify(oldConfig, null, 2)); + + const { stdout, exitCode } = runInit(tmpDir); + expect(exitCode).toBe(0); + expect(stdout).toContain('Wrote hush hooks config'); + + const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8')); + + // PreToolUse added + expect(settings.hooks.PreToolUse).toHaveLength(1); + expect(settings.hooks.PreToolUse[0].matcher).toBe('mcp__.*'); + + // PostToolUse: original entry preserved + new mcp entry added + expect(settings.hooks.PostToolUse).toHaveLength(2); + expect(settings.hooks.PostToolUse[0].matcher).toBe('Bash|Read|Grep|WebFetch'); + expect(settings.hooks.PostToolUse[1].matcher).toBe('mcp__.*'); + }); + + it('should not duplicate PostToolUse entries when upgrading', () => { + const claudeDir = join(tmpDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + + // Old config already has the built-in PostToolUse entry + const oldConfig = { + hooks: { + PostToolUse: [ + { + matcher: 'Bash|Read|Grep|WebFetch', + hooks: [{ type: 'command', command: 'hush redact-hook', timeout: 10 }], + }, + ], + }, + }; + writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify(oldConfig, null, 2)); + + // Run init twice + runInit(tmpDir); + const { stdout, exitCode } = runInit(tmpDir); + expect(exitCode).toBe(0); + expect(stdout).toContain('already configured'); + + const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8')); + // Should have exactly 1 PreToolUse and 2 PostToolUse (no duplicates) + expect(settings.hooks.PreToolUse).toHaveLength(1); + expect(settings.hooks.PostToolUse).toHaveLength(2); + }); }); diff --git a/tests/redact-hook.test.ts b/tests/redact-hook.test.ts index 7ea5aa2..508dd1b 100644 --- a/tests/redact-hook.test.ts +++ b/tests/redact-hook.test.ts @@ -26,6 +26,8 @@ function runHook(input: string): { stdout: string; stderr: string; exitCode: num } describe('hush redact-hook', () => { + // ── PostToolUse built-in tools (existing tests) ────────────────────── + it('should redact email from Bash stdout', () => { const payload = { tool_name: 'Bash', @@ -139,4 +141,197 @@ describe('hush redact-hook', () => { expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); expect(result.reason).not.toContain('dev@internal.corp'); }); + + // ── PostToolUse built-in with explicit hook_event_name ─────────────── + + it('should use decision:block for PostToolUse built-in with explicit event name', () => { + const payload = { + hook_event_name: 'PostToolUse', + 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}\]/); + }); + + // ── Backward compat: no hook_event_name ────────────────────────────── + + it('should fall back to PostToolUse built-in when hook_event_name is absent', () => { + const payload = { + tool_name: 'Read', + tool_response: { file: { content: 'Contact: fallback@legacy.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('fallback@legacy.com'); + }); + + // ── PreToolUse (outbound MCP arg redaction) ────────────────────────── + + describe('PreToolUse — outbound MCP arg redaction', () => { + it('should redact email in MCP tool input and return updatedInput', () => { + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'mcp__slack__send_message', + tool_input: { + channel: '#general', + text: 'Please contact admin@secret.corp for access', + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput).toBeDefined(); + expect(result.hookSpecificOutput.hookEventName).toBe('PreToolUse'); + expect(result.hookSpecificOutput.permissionDecision).toBe('allow'); + expect(result.hookSpecificOutput.updatedInput.text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.hookSpecificOutput.updatedInput.text).not.toContain('admin@secret.corp'); + // Non-PII fields preserved + expect(result.hookSpecificOutput.updatedInput.channel).toBe('#general'); + }); + + it('should pass through clean input with no output', () => { + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'mcp__miro__create_card', + tool_input: { + title: 'Sprint planning', + description: 'Weekly sync meeting notes', + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should pass through when no tool_input is present', () => { + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'mcp__db__list_tables', + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should redact nested PII in complex tool input', () => { + const payload = { + hook_event_name: 'PreToolUse', + tool_name: 'mcp__notion__create_page', + tool_input: { + title: 'User Report', + properties: { + email: 'user@private.org', + ip: 'Connected from 10.20.30.40', + }, + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + const updated = result.hookSpecificOutput.updatedInput; + expect(updated.properties.email).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(updated.properties.ip).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + expect(updated.title).toBe('User Report'); + }); + }); + + // ── PostToolUse MCP (inbound result redaction) ─────────────────────── + + describe('PostToolUse MCP — inbound result redaction', () => { + it('should redact email in MCP content array and return updatedMCPToolOutput', () => { + const payload = { + hook_event_name: 'PostToolUse', + tool_name: 'mcp__slack__read_channel', + tool_response: { + content: [ + { type: 'text', text: 'Message from admin@company.io: hello team' }, + ], + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.updatedMCPToolOutput).toBeDefined(); + expect(result.updatedMCPToolOutput.content).toHaveLength(1); + expect(result.updatedMCPToolOutput.content[0].type).toBe('text'); + expect(result.updatedMCPToolOutput.content[0].text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.updatedMCPToolOutput.content[0].text).not.toContain('admin@company.io'); + }); + + it('should pass through clean MCP content with no output', () => { + const payload = { + hook_event_name: 'PostToolUse', + tool_name: 'mcp__miro__get_board', + tool_response: { + content: [ + { type: 'text', text: 'Board "Sprint 42" has 15 cards' }, + ], + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should redact PII in multiple content blocks selectively', () => { + const payload = { + hook_event_name: 'PostToolUse', + tool_name: 'mcp__db__query', + tool_response: { + content: [ + { type: 'text', text: 'Query results:' }, + { type: 'text', text: 'Row 1: user@leaked.com, 192.168.0.1' }, + { type: 'text', text: 'Row 2: no PII here' }, + ], + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + const blocks = result.updatedMCPToolOutput.content; + expect(blocks).toHaveLength(3); + // First block — no PII, unchanged + expect(blocks[0].text).toBe('Query results:'); + // Second block — both email and IP redacted + expect(blocks[1].text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(blocks[1].text).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + expect(blocks[1].text).not.toContain('user@leaked.com'); + // Third block — no PII, unchanged + expect(blocks[2].text).toBe('Row 2: no PII here'); + }); + + it('should handle MCP PostToolUse with no content array', () => { + const payload = { + hook_event_name: 'PostToolUse', + tool_name: 'mcp__slack__ping', + tool_response: { status: 'ok' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should handle MCP PostToolUse with no tool_response', () => { + const payload = { + hook_event_name: 'PostToolUse', + tool_name: 'mcp__slack__ping', + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + }); }); From 33a9ad7ce9227f438808b26a4616bfce3ba4a447 Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Tue, 3 Mar 2026 07:08:51 +0000 Subject: [PATCH 3/3] feat: add MCP tool call redaction for OpenCode and Gemini CLI Add bidirectional PII redaction for two new AI coding clients: - OpenCode plugin: redact PII in tool args (before) and tool outputs (after) for both built-in tools and MCP content blocks via in-place mutation - Gemini CLI hooks: add BeforeTool/AfterTool event dispatch in redact-hook with Gemini-specific response format (deny/reason instead of block) - Init command: add --gemini flag to write .gemini/settings.json with BeforeTool/AfterTool hook configuration - Refactor redact-hook.ts to extract shared helpers, reducing duplication between Claude Code and Gemini event handlers Co-Authored-By: Claude Opus 4.6 --- examples/team-config/.gemini/settings.json | 38 +++++ .../team-config/.opencode/plugins/hush.ts | 15 +- src/commands/init.ts | 77 +++++++-- src/commands/redact-hook.ts | 146 +++++++++++++--- src/plugins/opencode-hush.ts | 48 +++++- tests/init.test.ts | 80 +++++++++ tests/opencode-plugin.test.ts | 71 ++++++++ tests/redact-hook.test.ts | 161 ++++++++++++++++++ 8 files changed, 581 insertions(+), 55 deletions(-) create mode 100644 examples/team-config/.gemini/settings.json diff --git a/examples/team-config/.gemini/settings.json b/examples/team-config/.gemini/settings.json new file mode 100644 index 0000000..ff071c0 --- /dev/null +++ b/examples/team-config/.gemini/settings.json @@ -0,0 +1,38 @@ +{ + "hooks": { + "BeforeTool": [ + { + "matcher": "mcp__.*", + "hooks": [ + { + "type": "command", + "command": "hush redact-hook", + "timeout": 10 + } + ] + } + ], + "AfterTool": [ + { + "matcher": "run_shell_command|read_file|read_many_files|search_file_content|web_fetch", + "hooks": [ + { + "type": "command", + "command": "hush redact-hook", + "timeout": 10 + } + ] + }, + { + "matcher": "mcp__.*", + "hooks": [ + { + "type": "command", + "command": "hush redact-hook", + "timeout": 10 + } + ] + } + ] + } +} diff --git a/examples/team-config/.opencode/plugins/hush.ts b/examples/team-config/.opencode/plugins/hush.ts index e1c3821..89cb180 100644 --- a/examples/team-config/.opencode/plugins/hush.ts +++ b/examples/team-config/.opencode/plugins/hush.ts @@ -1,15 +1,18 @@ /** * Hush PII Guard — OpenCode Plugin (drop-in copy) * - * Blocks reads of sensitive files (.env, *.pem, credentials.*, etc.) - * before the tool executes — the AI model never sees the content. + * This drop-in copy provides file-blocking only (sensitive file reads). + * For full bidirectional PII redaction (tool args + tool results), + * install from npm instead: * - * Usage: copy this file to `.opencode/plugins/hush.ts` in your project - * and add to `opencode.json`: - * { "plugin": [".opencode/plugins/hush.ts"] } + * npm install @aictrl/hush * - * Or install from npm: + * Then in your plugin entry point: * import { HushPlugin } from '@aictrl/hush/opencode-plugin' + * + * Usage (drop-in): copy this file to `.opencode/plugins/hush.ts` in your + * project and add to `opencode.json`: + * { "plugin": [".opencode/plugins/hush.ts"] } */ const SENSITIVE_GLOBS = [ diff --git a/src/commands/init.ts b/src/commands/init.ts index d484a0b..f5a0df5 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,9 +1,11 @@ /** - * hush init — Generate Claude Code hook configuration + * hush init — Generate hook configuration for Claude Code or Gemini CLI * * Usage: * hush init --hooks Write to .claude/settings.json * hush init --hooks --local Write to .claude/settings.local.json + * hush init --hooks --gemini Write to .gemini/settings.json + * hush init --hooks --gemini --local Write to .gemini/settings.local.json */ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; @@ -15,7 +17,7 @@ const HUSH_HOOK = { timeout: 10, }; -const HOOK_CONFIG = { +const CLAUDE_HOOK_CONFIG = { hooks: { PreToolUse: [ { @@ -36,6 +38,27 @@ const HOOK_CONFIG = { }, }; +const GEMINI_HOOK_CONFIG = { + hooks: { + BeforeTool: [ + { + matcher: 'mcp__.*', + hooks: [HUSH_HOOK], + }, + ], + AfterTool: [ + { + matcher: 'run_shell_command|read_file|read_many_files|search_file_content|web_fetch', + hooks: [HUSH_HOOK], + }, + { + matcher: 'mcp__.*', + hooks: [HUSH_HOOK], + }, + ], + }, +}; + interface HookEntry { matcher: string; hooks: Array<{ type: string; command: string; timeout?: number }>; @@ -45,11 +68,17 @@ interface SettingsJson { hooks?: { PreToolUse?: HookEntry[]; PostToolUse?: HookEntry[]; + BeforeTool?: HookEntry[]; + AfterTool?: HookEntry[]; [key: string]: unknown; }; [key: string]: unknown; } +interface HookConfig { + hooks: Record; +} + function hasHushHookInEntries(entries: HookEntry[] | undefined): boolean { if (!Array.isArray(entries)) return false; return entries.some((entry) => @@ -57,13 +86,20 @@ function hasHushHookInEntries(entries: HookEntry[] | undefined): boolean { ); } -function hasHushHook(settings: SettingsJson): boolean { +function hasHushHookClaude(settings: SettingsJson): boolean { return ( hasHushHookInEntries(settings.hooks?.PreToolUse) && hasHushHookInEntries(settings.hooks?.PostToolUse) ); } +function hasHushHookGemini(settings: SettingsJson): boolean { + return ( + hasHushHookInEntries(settings.hooks?.BeforeTool) && + hasHushHookInEntries(settings.hooks?.AfterTool) + ); +} + function mergeHookEntries( existing: HookEntry[] | undefined, newEntries: HookEntry[], @@ -84,18 +120,17 @@ function mergeHookEntries( return merged; } -function mergeHooks(existing: SettingsJson): SettingsJson { +function mergeHooks(existing: SettingsJson, hookConfig: HookConfig): SettingsJson { const merged = { ...existing }; if (!merged.hooks) { merged.hooks = {}; } - merged.hooks = { - ...merged.hooks, - PreToolUse: mergeHookEntries(merged.hooks.PreToolUse, HOOK_CONFIG.hooks.PreToolUse), - PostToolUse: mergeHookEntries(merged.hooks.PostToolUse, HOOK_CONFIG.hooks.PostToolUse), - }; + for (const [eventName, entries] of Object.entries(hookConfig.hooks)) { + const existingEntries = merged.hooks[eventName] as HookEntry[] | undefined; + merged.hooks[eventName] = mergeHookEntries(existingEntries, entries); + } return merged; } @@ -103,23 +138,26 @@ function mergeHooks(existing: SettingsJson): SettingsJson { export function run(args: string[]): void { const hasHooksFlag = args.includes('--hooks'); const isLocal = args.includes('--local'); + const isGemini = args.includes('--gemini'); if (!hasHooksFlag) { - process.stderr.write('Usage: hush init --hooks [--local]\n'); + process.stderr.write('Usage: hush init --hooks [--local] [--gemini]\n'); process.stderr.write('\n'); process.stderr.write('Options:\n'); - process.stderr.write(' --hooks Generate Claude Code hook config (PreToolUse + PostToolUse)\n'); + process.stderr.write(' --hooks Generate hook config (PreToolUse + PostToolUse or BeforeTool + AfterTool)\n'); process.stderr.write(' --local Write to settings.local.json instead of settings.json\n'); + process.stderr.write(' --gemini Write Gemini CLI hooks instead of Claude Code hooks\n'); process.exit(1); } - const claudeDir = join(process.cwd(), '.claude'); + const dirName = isGemini ? '.gemini' : '.claude'; + const configDir = join(process.cwd(), dirName); const filename = isLocal ? 'settings.local.json' : 'settings.json'; - const filePath = join(claudeDir, filename); + const filePath = join(configDir, filename); - // Ensure .claude/ exists - if (!existsSync(claudeDir)) { - mkdirSync(claudeDir, { recursive: true }); + // Ensure config dir exists + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); } // Read existing settings or start fresh @@ -134,12 +172,15 @@ export function run(args: string[]): void { } // Idempotency check - if (hasHushHook(settings)) { + const hookConfig = isGemini ? GEMINI_HOOK_CONFIG : CLAUDE_HOOK_CONFIG; + const hasHook = isGemini ? hasHushHookGemini : hasHushHookClaude; + + if (hasHook(settings)) { process.stdout.write(`hush hooks already configured in ${filePath}\n`); return; } - const merged = mergeHooks(settings); + const merged = mergeHooks(settings, hookConfig); 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 index 43c434b..54effa2 100644 --- a/src/commands/redact-hook.ts +++ b/src/commands/redact-hook.ts @@ -1,12 +1,17 @@ /** - * hush redact-hook — Claude Code PreToolUse / PostToolUse hook handler + * hush redact-hook — Hook handler for Claude Code and Gemini CLI * * Reads the hook payload from stdin, redacts PII, and returns the * appropriate response format depending on the hook event type: * - * PreToolUse — redacts outbound MCP tool arguments (updatedInput) - * PostToolUse — redacts inbound MCP tool results (updatedMCPToolOutput) - * or blocks built-in tool output (decision: "block") + * Claude Code: + * PreToolUse — redacts outbound MCP tool arguments (updatedInput) + * PostToolUse — redacts inbound MCP tool results (updatedMCPToolOutput) + * or blocks built-in tool output (decision: "block") + * + * Gemini CLI: + * BeforeTool — redacts outbound MCP tool arguments (hookSpecificOutput.tool_input) + * AfterTool — redacts inbound tool results (decision: "deny") * * Exit codes: * 0 — success (may or may not redact) @@ -22,7 +27,7 @@ interface MCPContentBlock { } interface HookPayload { - hook_event_name?: 'PreToolUse' | 'PostToolUse'; + hook_event_name?: 'PreToolUse' | 'PostToolUse' | 'BeforeTool' | 'AfterTool'; tool_name?: string; tool_input?: Record; tool_response?: { @@ -64,42 +69,77 @@ function extractText(toolResponse: HookPayload['tool_response']): string | null return parts.length > 0 ? parts.join('\n') : null; } -/** Redact PII from a built-in tool response text. */ -function redactBuiltinToolResponse( - toolResponse: NonNullable, +// ── Shared helpers ────────────────────────────────────────────────────── + +/** + * Redact PII from tool_input and format the response. + * Shared by PreToolUse (Claude) and BeforeTool (Gemini). + */ +function redactToolInput( + payload: HookPayload, redactor: Redactor, -): { text: string; hasRedacted: boolean } { - const text = extractText(toolResponse); - if (!text) return { text: '', hasRedacted: false }; + formatResponse: (redactedInput: Record) => object, +): void { + if (!payload.tool_input || typeof payload.tool_input !== 'object') { + process.exit(0); + } - const { content, hasRedacted } = redactor.redact(text); - return { text: content as string, hasRedacted }; + const { content, hasRedacted } = redactor.redact(payload.tool_input); + + if (!hasRedacted) { + process.exit(0); + } + + const response = formatResponse(content as Record); + process.stdout.write(JSON.stringify(response) + '\n'); + process.exit(0); } -/** Handle PreToolUse — redact outbound MCP tool arguments. */ -function handlePreToolUse(payload: HookPayload, redactor: Redactor): void { - if (!payload.tool_input || typeof payload.tool_input !== 'object') { +/** + * Redact PII from a built-in tool response and format the response. + * Shared by PostToolUse (Claude, decision:"block") and AfterTool (Gemini, decision:"deny"). + */ +function redactBuiltinResponse( + payload: HookPayload, + redactor: Redactor, + decision: 'block' | 'deny', +): void { + if (!payload.tool_response) { process.exit(0); } - const { content, hasRedacted } = redactor.redact(payload.tool_input); + const text = extractText(payload.tool_response); + if (!text) { + process.exit(0); + } + const { content, hasRedacted } = redactor.redact(text); if (!hasRedacted) { process.exit(0); } const response = { - hookSpecificOutput: { - hookEventName: 'PreToolUse', - permissionDecision: 'allow', - updatedInput: content, - }, + decision, + reason: content as string, }; process.stdout.write(JSON.stringify(response) + '\n'); process.exit(0); } +// ── Claude Code handlers ──────────────────────────────────────────────── + +/** Handle PreToolUse — redact outbound MCP tool arguments. */ +function handlePreToolUse(payload: HookPayload, redactor: Redactor): void { + redactToolInput(payload, redactor, (redactedInput) => ({ + hookSpecificOutput: { + hookEventName: 'PreToolUse', + permissionDecision: 'allow', + updatedInput: redactedInput, + }, + })); +} + /** Handle PostToolUse for MCP tools — redact inbound content blocks. */ function handlePostToolUseMCP(payload: HookPayload, redactor: Redactor): void { const toolResponse = payload.tool_response; @@ -128,27 +168,61 @@ function handlePostToolUseMCP(payload: HookPayload, redactor: Redactor): void { process.exit(0); } -/** Handle PostToolUse for built-in tools — existing block/reason flow. */ +/** Handle PostToolUse for built-in tools — decision: "block". */ function handlePostToolUseBuiltin(payload: HookPayload, redactor: Redactor): void { - if (!payload.tool_response) { + redactBuiltinResponse(payload, redactor, 'block'); +} + +// ── Gemini CLI handlers ───────────────────────────────────────────────── + +/** Handle BeforeTool — redact outbound MCP tool arguments (Gemini format). */ +function handleBeforeTool(payload: HookPayload, redactor: Redactor): void { + redactToolInput(payload, redactor, (redactedInput) => ({ + hookSpecificOutput: { + tool_input: redactedInput, + }, + })); +} + +/** Handle AfterTool for MCP tools — redact content array, flatten to deny/reason. */ +function handleAfterToolMCP(payload: HookPayload, redactor: Redactor): void { + const toolResponse = payload.tool_response; + if (!toolResponse || typeof toolResponse !== 'object') { process.exit(0); } - const { text, hasRedacted } = redactBuiltinToolResponse(payload.tool_response, redactor); + const contentArray = toolResponse.content; + if (!Array.isArray(contentArray)) { + process.exit(0); + } + + const { content: redactedArray, hasRedacted } = redactor.redact(contentArray); if (!hasRedacted) { process.exit(0); } + // Flatten content blocks to a single text for Gemini's deny/reason format + const textParts = (redactedArray as MCPContentBlock[]) + .filter((b) => typeof b.text === 'string') + .map((b) => b.text as string); + const response = { - decision: 'block' as const, - reason: text, + decision: 'deny' as const, + reason: textParts.join('\n'), }; process.stdout.write(JSON.stringify(response) + '\n'); process.exit(0); } +/** Handle AfterTool for built-in tools — decision: "deny". */ +function handleAfterToolBuiltin(payload: HookPayload, redactor: Redactor): void { + redactBuiltinResponse(payload, redactor, 'deny'); +} + +// ── Utilities ─────────────────────────────────────────────────────────── + function isMCPTool(toolName?: string): boolean { return typeof toolName === 'string' && toolName.startsWith('mcp__'); } @@ -162,6 +236,8 @@ function readStdin(): Promise { }); } +// ── Entry point ───────────────────────────────────────────────────────── + export async function run(): Promise { let raw: string; try { @@ -186,6 +262,7 @@ export async function run(): Promise { const redactor = new Redactor(); const eventName = payload.hook_event_name; + // Claude Code events if (eventName === 'PreToolUse') { handlePreToolUse(payload, redactor); return; @@ -200,6 +277,21 @@ export async function run(): Promise { return; } + // Gemini CLI events + if (eventName === 'BeforeTool') { + handleBeforeTool(payload, redactor); + return; + } + + if (eventName === 'AfterTool') { + if (isMCPTool(payload.tool_name)) { + handleAfterToolMCP(payload, redactor); + } else { + handleAfterToolBuiltin(payload, redactor); + } + return; + } + // Backward compat: no hook_event_name → treat as PostToolUse built-in handlePostToolUseBuiltin(payload, redactor); } diff --git a/src/plugins/opencode-hush.ts b/src/plugins/opencode-hush.ts index 8e99471..5be3e1c 100644 --- a/src/plugins/opencode-hush.ts +++ b/src/plugins/opencode-hush.ts @@ -1,24 +1,30 @@ /** * OpenCode Plugin: Hush PII Guard * - * Blocks reads of sensitive files (`.env`, `*.pem`, `credentials.*`, etc.) - * before the tool executes — the AI model never sees the content. + * 1. Blocks reads of sensitive files (`.env`, `*.pem`, `credentials.*`, etc.) + * before the tool executes — the AI model never sees the content. + * 2. Redacts PII (emails, IPs, secrets) from tool arguments before execution. + * 3. Redacts PII from tool outputs (built-in and MCP) after execution. * * Defense-in-depth: works alongside the Hush proxy which redacts PII from - * API requests. The plugin prevents file reads; the proxy catches anything - * that slips through in normal files. + * API requests. The plugin prevents file reads and scrubs tool I/O; + * the proxy catches anything that slips through. * * Install: copy to `.opencode/plugins/hush.ts` and add to `opencode.json`: * { "plugin": [".opencode/plugins/hush.ts"] } */ import { isSensitivePath, commandReadsSensitiveFile } from './sensitive-patterns.js'; +import { Redactor } from '../middleware/redactor.js'; + +const redactor = new Redactor(); export const HushPlugin = async () => ({ 'tool.execute.before': async ( input: { tool: string }, output: { args: Record }, ) => { + // Block sensitive file reads first (hard block — throws) if (input.tool === 'read' && isSensitivePath(output.args['filePath'] ?? '')) { throw new Error('[hush] Blocked: sensitive file'); } @@ -26,5 +32,39 @@ export const HushPlugin = async () => ({ if (input.tool === 'bash' && commandReadsSensitiveFile(output.args['command'] ?? '')) { throw new Error('[hush] Blocked: command reads sensitive file'); } + + // Redact PII from outbound tool arguments (in-place mutation) + const { content, hasRedacted } = redactor.redact(output.args); + if (hasRedacted) { + const redacted = content as Record; + for (const key of Object.keys(redacted)) { + output.args[key] = redacted[key]!; + } + } + }, + + 'tool.execute.after': async ( + input: { tool: string }, + output: { output?: string; content?: Array<{ type: string; text?: string }> }, + ) => { + // Built-in tools: output is a string at output.output + if (typeof output.output === 'string') { + const { content, hasRedacted } = redactor.redact(output.output); + if (hasRedacted) { + output.output = content as string; + } + } + + // MCP tools: output is content blocks at output.content + if (Array.isArray(output.content)) { + for (const block of output.content) { + if (block.type === 'text' && typeof block.text === 'string') { + const { content, hasRedacted } = redactor.redact(block.text); + if (hasRedacted) { + block.text = content as string; + } + } + } + } }, }); diff --git a/tests/init.test.ts b/tests/init.test.ts index c92fb7d..2f18e8f 100644 --- a/tests/init.test.ts +++ b/tests/init.test.ts @@ -173,3 +173,83 @@ describe('hush init --hooks', () => { expect(settings.hooks.PostToolUse).toHaveLength(2); }); }); + +// ── Gemini CLI init ─────────────────────────────────────────────────── + +function runInitGemini(cwd: string, ...extraArgs: string[]): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync('node', [CLI, 'init', '--hooks', '--gemini', ...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 --gemini', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'hush-init-gemini-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should create .gemini/settings.json with BeforeTool and AfterTool', () => { + const { stdout, exitCode } = runInitGemini(tmpDir); + expect(exitCode).toBe(0); + expect(stdout).toContain('Wrote hush hooks config'); + + const settings = JSON.parse(readFileSync(join(tmpDir, '.gemini', 'settings.json'), 'utf-8')); + + // BeforeTool + expect(settings.hooks.BeforeTool).toHaveLength(1); + expect(settings.hooks.BeforeTool[0].matcher).toBe('mcp__.*'); + expect(settings.hooks.BeforeTool[0].hooks[0].command).toBe('hush redact-hook'); + + // AfterTool + expect(settings.hooks.AfterTool).toHaveLength(2); + expect(settings.hooks.AfterTool[0].matcher).toBe('run_shell_command|read_file|read_many_files|search_file_content|web_fetch'); + expect(settings.hooks.AfterTool[0].hooks[0].command).toBe('hush redact-hook'); + expect(settings.hooks.AfterTool[1].matcher).toBe('mcp__.*'); + expect(settings.hooks.AfterTool[1].hooks[0].command).toBe('hush redact-hook'); + }); + + it('should not create .claude/ directory', () => { + runInitGemini(tmpDir); + expect(existsSync(join(tmpDir, '.claude'))).toBe(false); + }); + + it('should be idempotent on re-run', () => { + runInitGemini(tmpDir); + const { stdout, exitCode } = runInitGemini(tmpDir); + expect(exitCode).toBe(0); + expect(stdout).toContain('already configured'); + + const settings = JSON.parse(readFileSync(join(tmpDir, '.gemini', 'settings.json'), 'utf-8')); + expect(settings.hooks.BeforeTool).toHaveLength(1); + expect(settings.hooks.AfterTool).toHaveLength(2); + }); + + it('should write to settings.local.json with --local flag', () => { + const { stdout, exitCode } = runInitGemini(tmpDir, '--local'); + expect(exitCode).toBe(0); + expect(stdout).toContain('settings.local.json'); + + const localPath = join(tmpDir, '.gemini', 'settings.local.json'); + expect(existsSync(localPath)).toBe(true); + + const settings = JSON.parse(readFileSync(localPath, 'utf-8')); + expect(settings.hooks.BeforeTool[0].hooks[0].command).toBe('hush redact-hook'); + expect(settings.hooks.AfterTool[0].hooks[0].command).toBe('hush redact-hook'); + }); +}); diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index e041ec5..0b8885c 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -145,4 +145,75 @@ describe('HushPlugin integration', () => { ), ).resolves.toBeUndefined(); }); + + it('redacts email in tool args (in-place mutation)', async () => { + const plugin = await HushPlugin(); + const output = { args: { text: 'Contact admin@secret.corp for access', channel: '#general' } }; + await plugin['tool.execute.before']({ tool: 'mcp_send' }, output); + expect(output.args.text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(output.args.text).not.toContain('admin@secret.corp'); + expect(output.args.channel).toBe('#general'); + }); + + it('passes through clean args without mutation', async () => { + const plugin = await HushPlugin(); + const output = { args: { text: 'hello world', channel: '#general' } }; + await plugin['tool.execute.before']({ tool: 'mcp_send' }, output); + expect(output.args.text).toBe('hello world'); + expect(output.args.channel).toBe('#general'); + }); + + it('still blocks sensitive files before redacting args', async () => { + const plugin = await HushPlugin(); + await expect( + plugin['tool.execute.before']( + { tool: 'read' }, + { args: { filePath: '.env', extra: 'admin@secret.corp' } }, + ), + ).rejects.toThrow('[hush] Blocked: sensitive file'); + }); +}); + +describe('HushPlugin tool.execute.after', () => { + it('exports a tool.execute.after hook', async () => { + const plugin = await HushPlugin(); + expect(plugin['tool.execute.after']).toBeTypeOf('function'); + }); + + it('redacts email in built-in tool output (output.output string)', async () => { + const plugin = await HushPlugin(); + const output = { output: 'Contact admin@secret.corp for access' } as any; + await plugin['tool.execute.after']({ tool: 'bash' }, output); + expect(output.output).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(output.output).not.toContain('admin@secret.corp'); + }); + + it('redacts IP in MCP content blocks (output.content array)', async () => { + const plugin = await HushPlugin(); + const output = { + content: [ + { type: 'text', text: 'Server at 192.168.1.100' }, + { type: 'text', text: 'No PII here' }, + ], + } as any; + await plugin['tool.execute.after']({ tool: 'mcp_query' }, output); + expect(output.content[0].text).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + expect(output.content[0].text).not.toContain('192.168.1.100'); + expect(output.content[1].text).toBe('No PII here'); + }); + + it('passes through clean output unchanged', async () => { + const plugin = await HushPlugin(); + const output = { output: 'hello world' } as any; + await plugin['tool.execute.after']({ tool: 'bash' }, output); + expect(output.output).toBe('hello world'); + }); + + it('handles output with no relevant fields gracefully', async () => { + const plugin = await HushPlugin(); + const output = {} as any; + await expect( + plugin['tool.execute.after']({ tool: 'bash' }, output), + ).resolves.toBeUndefined(); + }); }); diff --git a/tests/redact-hook.test.ts b/tests/redact-hook.test.ts index 508dd1b..169b912 100644 --- a/tests/redact-hook.test.ts +++ b/tests/redact-hook.test.ts @@ -334,4 +334,165 @@ describe('hush redact-hook', () => { expect(stdout.trim()).toBe(''); }); }); + + // ── Gemini CLI: BeforeTool (outbound MCP arg redaction) ─────────────── + + describe('BeforeTool — Gemini outbound MCP arg redaction', () => { + it('should redact email and return hookSpecificOutput.tool_input (no Claude fields)', () => { + const payload = { + hook_event_name: 'BeforeTool', + tool_name: 'mcp__slack__send_message', + tool_input: { + channel: '#general', + text: 'Please contact admin@secret.corp for access', + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput).toBeDefined(); + expect(result.hookSpecificOutput.tool_input).toBeDefined(); + expect(result.hookSpecificOutput.tool_input.text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.hookSpecificOutput.tool_input.text).not.toContain('admin@secret.corp'); + expect(result.hookSpecificOutput.tool_input.channel).toBe('#general'); + // Should NOT have Claude-specific fields + expect(result.hookSpecificOutput.hookEventName).toBeUndefined(); + expect(result.hookSpecificOutput.permissionDecision).toBeUndefined(); + expect(result.hookSpecificOutput.updatedInput).toBeUndefined(); + }); + + it('should pass through clean input with no output', () => { + const payload = { + hook_event_name: 'BeforeTool', + tool_name: 'mcp__miro__create_card', + tool_input: { + title: 'Sprint planning', + description: 'Weekly sync meeting notes', + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should pass through when no tool_input is present', () => { + const payload = { + hook_event_name: 'BeforeTool', + tool_name: 'mcp__db__list_tables', + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + }); + + // ── Gemini CLI: AfterTool built-in (inbound result redaction) ───────── + + describe('AfterTool built-in — Gemini inbound result redaction', () => { + it('should redact email and return decision:"deny" (not "block")', () => { + const payload = { + hook_event_name: 'AfterTool', + tool_name: 'run_shell_command', + 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('deny'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).not.toContain('test@foo.com'); + }); + + it('should pass through clean output with no output', () => { + const payload = { + hook_event_name: 'AfterTool', + tool_name: 'read_file', + tool_response: { stdout: 'hello world' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should pass through when no tool_response', () => { + const payload = { + hook_event_name: 'AfterTool', + tool_name: 'read_file', + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + }); + + // ── Gemini CLI: AfterTool MCP (inbound MCP result redaction) ────────── + + describe('AfterTool MCP — Gemini inbound MCP result redaction', () => { + it('should redact email in content array and return deny/reason with joined text', () => { + const payload = { + hook_event_name: 'AfterTool', + tool_name: 'mcp__slack__read_channel', + tool_response: { + content: [ + { type: 'text', text: 'Message from admin@company.io: hello team' }, + ], + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.decision).toBe('deny'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).not.toContain('admin@company.io'); + }); + + it('should join multiple content blocks into reason', () => { + const payload = { + hook_event_name: 'AfterTool', + tool_name: 'mcp__db__query', + tool_response: { + content: [ + { type: 'text', text: 'Row 1: user@leaked.com' }, + { type: 'text', text: 'Row 2: 192.168.0.1' }, + ], + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.decision).toBe('deny'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + }); + + it('should pass through clean MCP content with no output', () => { + const payload = { + hook_event_name: 'AfterTool', + tool_name: 'mcp__miro__get_board', + tool_response: { + content: [ + { type: 'text', text: 'Board "Sprint 42" has 15 cards' }, + ], + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should handle AfterTool MCP with no content array', () => { + const payload = { + hook_event_name: 'AfterTool', + tool_name: 'mcp__slack__ping', + tool_response: { status: 'ok' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + }); });