@aictrl/hush 0.1.7 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,17 +1,33 @@
1
1
  /**
2
- * hush redact-hook — Claude Code PostToolUse hook handler
2
+ * hush redact-hook — Hook handler for Claude Code and Gemini CLI
3
3
  *
4
- * Reads the hook payload from stdin, redacts PII from the tool response,
5
- * and blocks the output (replacing it with redacted text) if PII was found.
4
+ * Reads the hook payload from stdin, redacts PII, and returns the
5
+ * appropriate response format depending on the hook event type:
6
+ *
7
+ * Claude Code:
8
+ * PreToolUse — redacts outbound MCP tool arguments (updatedInput)
9
+ * PostToolUse — redacts inbound MCP tool results (updatedMCPToolOutput)
10
+ * or blocks built-in tool output (decision: "block")
11
+ *
12
+ * Gemini CLI:
13
+ * BeforeTool — redacts outbound MCP tool arguments (hookSpecificOutput.tool_input)
14
+ * AfterTool — redacts inbound tool results (decision: "deny")
6
15
  *
7
16
  * Exit codes:
8
- * 0 — success (may or may not block)
17
+ * 0 — success (may or may not redact)
9
18
  * 2 — malformed input (blocks the tool call per hooks spec)
10
19
  */
11
20
 
12
21
  import { Redactor } from '../middleware/redactor.js';
13
22
 
23
+ interface MCPContentBlock {
24
+ type: string;
25
+ text?: string;
26
+ [key: string]: unknown;
27
+ }
28
+
14
29
  interface HookPayload {
30
+ hook_event_name?: 'PreToolUse' | 'PostToolUse' | 'BeforeTool' | 'AfterTool';
15
31
  tool_name?: string;
16
32
  tool_input?: Record<string, unknown>;
17
33
  tool_response?: {
@@ -21,18 +37,13 @@ interface HookPayload {
21
37
  // Read tool (nested under file)
22
38
  file?: { content?: string; [key: string]: unknown };
23
39
  // Grep / WebFetch / generic
24
- content?: string;
40
+ content?: string | MCPContentBlock[];
25
41
  output?: string;
26
42
  [key: string]: unknown;
27
43
  };
28
44
  }
29
45
 
30
- interface HookResponse {
31
- decision: 'block';
32
- reason: string;
33
- }
34
-
35
- /** Collect all text from a tool_response object. */
46
+ /** Collect all text from a built-in tool_response object. */
36
47
  function extractText(toolResponse: HookPayload['tool_response']): string | null {
37
48
  if (!toolResponse || typeof toolResponse !== 'object') return null;
38
49
 
@@ -58,16 +69,162 @@ function extractText(toolResponse: HookPayload['tool_response']): string | null
58
69
  return parts.length > 0 ? parts.join('\n') : null;
59
70
  }
60
71
 
61
- /** Redact PII from the tool response text. */
62
- function redactToolResponse(
63
- toolResponse: NonNullable<HookPayload['tool_response']>,
72
+ // ── Shared helpers ──────────────────────────────────────────────────────
73
+
74
+ /**
75
+ * Redact PII from tool_input and format the response.
76
+ * Shared by PreToolUse (Claude) and BeforeTool (Gemini).
77
+ */
78
+ function redactToolInput(
79
+ payload: HookPayload,
64
80
  redactor: Redactor,
65
- ): { text: string; hasRedacted: boolean } {
66
- const text = extractText(toolResponse);
67
- if (!text) return { text: '', hasRedacted: false };
81
+ formatResponse: (redactedInput: Record<string, unknown>) => object,
82
+ ): void {
83
+ if (!payload.tool_input || typeof payload.tool_input !== 'object') {
84
+ process.exit(0);
85
+ }
86
+
87
+ const { content, hasRedacted } = redactor.redact(payload.tool_input);
88
+
89
+ if (!hasRedacted) {
90
+ process.exit(0);
91
+ }
92
+
93
+ const response = formatResponse(content as Record<string, unknown>);
94
+ process.stdout.write(JSON.stringify(response) + '\n');
95
+ process.exit(0);
96
+ }
97
+
98
+ /**
99
+ * Redact PII from a built-in tool response and format the response.
100
+ * Shared by PostToolUse (Claude, decision:"block") and AfterTool (Gemini, decision:"deny").
101
+ */
102
+ function redactBuiltinResponse(
103
+ payload: HookPayload,
104
+ redactor: Redactor,
105
+ decision: 'block' | 'deny',
106
+ ): void {
107
+ if (!payload.tool_response) {
108
+ process.exit(0);
109
+ }
110
+
111
+ const text = extractText(payload.tool_response);
112
+ if (!text) {
113
+ process.exit(0);
114
+ }
68
115
 
69
116
  const { content, hasRedacted } = redactor.redact(text);
70
- return { text: content as string, hasRedacted };
117
+ if (!hasRedacted) {
118
+ process.exit(0);
119
+ }
120
+
121
+ const response = {
122
+ decision,
123
+ reason: content as string,
124
+ };
125
+
126
+ process.stdout.write(JSON.stringify(response) + '\n');
127
+ process.exit(0);
128
+ }
129
+
130
+ // ── Claude Code handlers ────────────────────────────────────────────────
131
+
132
+ /** Handle PreToolUse — redact outbound MCP tool arguments. */
133
+ function handlePreToolUse(payload: HookPayload, redactor: Redactor): void {
134
+ redactToolInput(payload, redactor, (redactedInput) => ({
135
+ hookSpecificOutput: {
136
+ hookEventName: 'PreToolUse',
137
+ permissionDecision: 'allow',
138
+ updatedInput: redactedInput,
139
+ },
140
+ }));
141
+ }
142
+
143
+ /** Handle PostToolUse for MCP tools — redact inbound content blocks. */
144
+ function handlePostToolUseMCP(payload: HookPayload, redactor: Redactor): void {
145
+ const toolResponse = payload.tool_response;
146
+ if (!toolResponse || typeof toolResponse !== 'object') {
147
+ process.exit(0);
148
+ }
149
+
150
+ const contentArray = toolResponse.content;
151
+ if (!Array.isArray(contentArray)) {
152
+ process.exit(0);
153
+ }
154
+
155
+ const { content: redactedArray, hasRedacted } = redactor.redact(contentArray);
156
+
157
+ if (!hasRedacted) {
158
+ process.exit(0);
159
+ }
160
+
161
+ const response = {
162
+ updatedMCPToolOutput: {
163
+ content: redactedArray,
164
+ },
165
+ };
166
+
167
+ process.stdout.write(JSON.stringify(response) + '\n');
168
+ process.exit(0);
169
+ }
170
+
171
+ /** Handle PostToolUse for built-in tools — decision: "block". */
172
+ function handlePostToolUseBuiltin(payload: HookPayload, redactor: Redactor): void {
173
+ redactBuiltinResponse(payload, redactor, 'block');
174
+ }
175
+
176
+ // ── Gemini CLI handlers ─────────────────────────────────────────────────
177
+
178
+ /** Handle BeforeTool — redact outbound MCP tool arguments (Gemini format). */
179
+ function handleBeforeTool(payload: HookPayload, redactor: Redactor): void {
180
+ redactToolInput(payload, redactor, (redactedInput) => ({
181
+ hookSpecificOutput: {
182
+ tool_input: redactedInput,
183
+ },
184
+ }));
185
+ }
186
+
187
+ /** Handle AfterTool for MCP tools — redact content array, flatten to deny/reason. */
188
+ function handleAfterToolMCP(payload: HookPayload, redactor: Redactor): void {
189
+ const toolResponse = payload.tool_response;
190
+ if (!toolResponse || typeof toolResponse !== 'object') {
191
+ process.exit(0);
192
+ }
193
+
194
+ const contentArray = toolResponse.content;
195
+ if (!Array.isArray(contentArray)) {
196
+ process.exit(0);
197
+ }
198
+
199
+ const { content: redactedArray, hasRedacted } = redactor.redact(contentArray);
200
+
201
+ if (!hasRedacted) {
202
+ process.exit(0);
203
+ }
204
+
205
+ // Flatten content blocks to a single text for Gemini's deny/reason format
206
+ const textParts = (redactedArray as MCPContentBlock[])
207
+ .filter((b) => typeof b.text === 'string')
208
+ .map((b) => b.text as string);
209
+
210
+ const response = {
211
+ decision: 'deny' as const,
212
+ reason: textParts.join('\n'),
213
+ };
214
+
215
+ process.stdout.write(JSON.stringify(response) + '\n');
216
+ process.exit(0);
217
+ }
218
+
219
+ /** Handle AfterTool for built-in tools — decision: "deny". */
220
+ function handleAfterToolBuiltin(payload: HookPayload, redactor: Redactor): void {
221
+ redactBuiltinResponse(payload, redactor, 'deny');
222
+ }
223
+
224
+ // ── Utilities ───────────────────────────────────────────────────────────
225
+
226
+ function isMCPTool(toolName?: string): boolean {
227
+ return typeof toolName === 'string' && toolName.startsWith('mcp__');
71
228
  }
72
229
 
73
230
  function readStdin(): Promise<string> {
@@ -79,6 +236,8 @@ function readStdin(): Promise<string> {
79
236
  });
80
237
  }
81
238
 
239
+ // ── Entry point ─────────────────────────────────────────────────────────
240
+
82
241
  export async function run(): Promise<void> {
83
242
  let raw: string;
84
243
  try {
@@ -89,7 +248,6 @@ export async function run(): Promise<void> {
89
248
  }
90
249
 
91
250
  if (!raw.trim()) {
92
- // Empty stdin — nothing to redact
93
251
  process.exit(0);
94
252
  }
95
253
 
@@ -101,24 +259,39 @@ export async function run(): Promise<void> {
101
259
  process.exit(2);
102
260
  }
103
261
 
104
- if (!payload.tool_response) {
105
- // No tool_response to redact
106
- process.exit(0);
262
+ const redactor = new Redactor();
263
+ const eventName = payload.hook_event_name;
264
+
265
+ // Claude Code events
266
+ if (eventName === 'PreToolUse') {
267
+ handlePreToolUse(payload, redactor);
268
+ return;
107
269
  }
108
270
 
109
- const redactor = new Redactor();
110
- const { text, hasRedacted } = redactToolResponse(payload.tool_response, redactor);
271
+ if (eventName === 'PostToolUse') {
272
+ if (isMCPTool(payload.tool_name)) {
273
+ handlePostToolUseMCP(payload, redactor);
274
+ } else {
275
+ handlePostToolUseBuiltin(payload, redactor);
276
+ }
277
+ return;
278
+ }
111
279
 
112
- if (!hasRedacted) {
113
- // No PII found — let Claude Code keep the original output
114
- process.exit(0);
280
+ // Gemini CLI events
281
+ if (eventName === 'BeforeTool') {
282
+ handleBeforeTool(payload, redactor);
283
+ return;
115
284
  }
116
285
 
117
- const response: HookResponse = {
118
- decision: 'block',
119
- reason: text,
120
- };
286
+ if (eventName === 'AfterTool') {
287
+ if (isMCPTool(payload.tool_name)) {
288
+ handleAfterToolMCP(payload, redactor);
289
+ } else {
290
+ handleAfterToolBuiltin(payload, redactor);
291
+ }
292
+ return;
293
+ }
121
294
 
122
- process.stdout.write(JSON.stringify(response) + '\n');
123
- process.exit(0);
295
+ // Backward compat: no hook_event_name → treat as PostToolUse built-in
296
+ handlePostToolUseBuiltin(payload, redactor);
124
297
  }
package/src/index.ts CHANGED
@@ -105,7 +105,7 @@ async function proxyRequest(
105
105
  method: req.method,
106
106
  headers: fetchHeaders,
107
107
  body: hasBody ? JSON.stringify(redactedBody) : undefined,
108
- signal: AbortSignal.timeout(30000), // 30s timeout
108
+ signal: AbortSignal.timeout(120000), // 120s timeout for long LLM responses
109
109
  });
110
110
 
111
111
  // Handle Upstream Errors (4xx, 5xx)
@@ -159,7 +159,12 @@ async function proxyRequest(
159
159
 
160
160
  } catch (error) {
161
161
  log.error({ err: error, path: req.path }, 'Failed to forward request');
162
- res.status(502).json({ error: 'Gateway forwarding failed' });
162
+ if (!res.headersSent) {
163
+ res.status(502).json({ error: 'Gateway forwarding failed' });
164
+ } else {
165
+ // Headers already sent (streaming in progress) — just end the response
166
+ res.end();
167
+ }
163
168
  }
164
169
  }
165
170
 
@@ -1,24 +1,30 @@
1
1
  /**
2
2
  * OpenCode Plugin: Hush PII Guard
3
3
  *
4
- * Blocks reads of sensitive files (`.env`, `*.pem`, `credentials.*`, etc.)
5
- * before the tool executes — the AI model never sees the content.
4
+ * 1. Blocks reads of sensitive files (`.env`, `*.pem`, `credentials.*`, etc.)
5
+ * before the tool executes — the AI model never sees the content.
6
+ * 2. Redacts PII (emails, IPs, secrets) from tool arguments before execution.
7
+ * 3. Redacts PII from tool outputs (built-in and MCP) after execution.
6
8
  *
7
9
  * Defense-in-depth: works alongside the Hush proxy which redacts PII from
8
- * API requests. The plugin prevents file reads; the proxy catches anything
9
- * that slips through in normal files.
10
+ * API requests. The plugin prevents file reads and scrubs tool I/O;
11
+ * the proxy catches anything that slips through.
10
12
  *
11
13
  * Install: copy to `.opencode/plugins/hush.ts` and add to `opencode.json`:
12
14
  * { "plugin": [".opencode/plugins/hush.ts"] }
13
15
  */
14
16
 
15
17
  import { isSensitivePath, commandReadsSensitiveFile } from './sensitive-patterns.js';
18
+ import { Redactor } from '../middleware/redactor.js';
19
+
20
+ const redactor = new Redactor();
16
21
 
17
22
  export const HushPlugin = async () => ({
18
23
  'tool.execute.before': async (
19
24
  input: { tool: string },
20
25
  output: { args: Record<string, string> },
21
26
  ) => {
27
+ // Block sensitive file reads first (hard block — throws)
22
28
  if (input.tool === 'read' && isSensitivePath(output.args['filePath'] ?? '')) {
23
29
  throw new Error('[hush] Blocked: sensitive file');
24
30
  }
@@ -26,5 +32,39 @@ export const HushPlugin = async () => ({
26
32
  if (input.tool === 'bash' && commandReadsSensitiveFile(output.args['command'] ?? '')) {
27
33
  throw new Error('[hush] Blocked: command reads sensitive file');
28
34
  }
35
+
36
+ // Redact PII from outbound tool arguments (in-place mutation)
37
+ const { content, hasRedacted } = redactor.redact(output.args);
38
+ if (hasRedacted) {
39
+ const redacted = content as Record<string, string>;
40
+ for (const key of Object.keys(redacted)) {
41
+ output.args[key] = redacted[key]!;
42
+ }
43
+ }
44
+ },
45
+
46
+ 'tool.execute.after': async (
47
+ input: { tool: string },
48
+ output: { output?: string; content?: Array<{ type: string; text?: string }> },
49
+ ) => {
50
+ // Built-in tools: output is a string at output.output
51
+ if (typeof output.output === 'string') {
52
+ const { content, hasRedacted } = redactor.redact(output.output);
53
+ if (hasRedacted) {
54
+ output.output = content as string;
55
+ }
56
+ }
57
+
58
+ // MCP tools: output is content blocks at output.content
59
+ if (Array.isArray(output.content)) {
60
+ for (const block of output.content) {
61
+ if (block.type === 'text' && typeof block.text === 'string') {
62
+ const { content, hasRedacted } = redactor.redact(block.text);
63
+ if (hasRedacted) {
64
+ block.text = content as string;
65
+ }
66
+ }
67
+ }
68
+ }
29
69
  },
30
70
  });
@@ -34,15 +34,24 @@ describe('hush init --hooks', () => {
34
34
  rmSync(tmpDir, { recursive: true, force: true });
35
35
  });
36
36
 
37
- it('should create .claude/settings.json from scratch', () => {
37
+ it('should create .claude/settings.json with both PreToolUse and PostToolUse', () => {
38
38
  const { stdout, exitCode } = runInit(tmpDir);
39
39
  expect(exitCode).toBe(0);
40
40
  expect(stdout).toContain('Wrote hush hooks config');
41
41
 
42
42
  const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8'));
43
- expect(settings.hooks.PostToolUse).toHaveLength(1);
43
+
44
+ // PreToolUse
45
+ expect(settings.hooks.PreToolUse).toHaveLength(1);
46
+ expect(settings.hooks.PreToolUse[0].matcher).toBe('mcp__.*');
47
+ expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe('hush redact-hook');
48
+
49
+ // PostToolUse
50
+ expect(settings.hooks.PostToolUse).toHaveLength(2);
44
51
  expect(settings.hooks.PostToolUse[0].matcher).toBe('Bash|Read|Grep|WebFetch');
45
52
  expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook');
53
+ expect(settings.hooks.PostToolUse[1].matcher).toBe('mcp__.*');
54
+ expect(settings.hooks.PostToolUse[1].hooks[0].command).toBe('hush redact-hook');
46
55
  });
47
56
 
48
57
  it('should merge into existing settings preserving other keys', () => {
@@ -59,8 +68,9 @@ describe('hush init --hooks', () => {
59
68
  const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
60
69
  // Preserved existing env
61
70
  expect(settings.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:4000');
62
- // Added hooks
63
- expect(settings.hooks.PostToolUse).toHaveLength(1);
71
+ // Added both hook types
72
+ expect(settings.hooks.PreToolUse).toHaveLength(1);
73
+ expect(settings.hooks.PostToolUse).toHaveLength(2);
64
74
  expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook');
65
75
  });
66
76
 
@@ -71,7 +81,8 @@ describe('hush init --hooks', () => {
71
81
  expect(stdout).toContain('already configured');
72
82
 
73
83
  const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8'));
74
- expect(settings.hooks.PostToolUse).toHaveLength(1); // Not duplicated
84
+ expect(settings.hooks.PreToolUse).toHaveLength(1); // Not duplicated
85
+ expect(settings.hooks.PostToolUse).toHaveLength(2); // Not duplicated
75
86
  });
76
87
 
77
88
  it('should write to settings.local.json with --local flag', () => {
@@ -83,6 +94,7 @@ describe('hush init --hooks', () => {
83
94
  expect(existsSync(localPath)).toBe(true);
84
95
 
85
96
  const settings = JSON.parse(readFileSync(localPath, 'utf-8'));
97
+ expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe('hush redact-hook');
86
98
  expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook');
87
99
  });
88
100
 
@@ -98,4 +110,146 @@ describe('hush init --hooks', () => {
98
110
  expect(err.stderr).toContain('Usage');
99
111
  }
100
112
  });
113
+
114
+ it('should upgrade old PostToolUse-only config by adding PreToolUse', () => {
115
+ const claudeDir = join(tmpDir, '.claude');
116
+ mkdirSync(claudeDir, { recursive: true });
117
+
118
+ // Simulate old config with only PostToolUse
119
+ const oldConfig = {
120
+ hooks: {
121
+ PostToolUse: [
122
+ {
123
+ matcher: 'Bash|Read|Grep|WebFetch',
124
+ hooks: [{ type: 'command', command: 'hush redact-hook', timeout: 10 }],
125
+ },
126
+ ],
127
+ },
128
+ };
129
+ writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify(oldConfig, null, 2));
130
+
131
+ const { stdout, exitCode } = runInit(tmpDir);
132
+ expect(exitCode).toBe(0);
133
+ expect(stdout).toContain('Wrote hush hooks config');
134
+
135
+ const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
136
+
137
+ // PreToolUse added
138
+ expect(settings.hooks.PreToolUse).toHaveLength(1);
139
+ expect(settings.hooks.PreToolUse[0].matcher).toBe('mcp__.*');
140
+
141
+ // PostToolUse: original entry preserved + new mcp entry added
142
+ expect(settings.hooks.PostToolUse).toHaveLength(2);
143
+ expect(settings.hooks.PostToolUse[0].matcher).toBe('Bash|Read|Grep|WebFetch');
144
+ expect(settings.hooks.PostToolUse[1].matcher).toBe('mcp__.*');
145
+ });
146
+
147
+ it('should not duplicate PostToolUse entries when upgrading', () => {
148
+ const claudeDir = join(tmpDir, '.claude');
149
+ mkdirSync(claudeDir, { recursive: true });
150
+
151
+ // Old config already has the built-in PostToolUse entry
152
+ const oldConfig = {
153
+ hooks: {
154
+ PostToolUse: [
155
+ {
156
+ matcher: 'Bash|Read|Grep|WebFetch',
157
+ hooks: [{ type: 'command', command: 'hush redact-hook', timeout: 10 }],
158
+ },
159
+ ],
160
+ },
161
+ };
162
+ writeFileSync(join(claudeDir, 'settings.json'), JSON.stringify(oldConfig, null, 2));
163
+
164
+ // Run init twice
165
+ runInit(tmpDir);
166
+ const { stdout, exitCode } = runInit(tmpDir);
167
+ expect(exitCode).toBe(0);
168
+ expect(stdout).toContain('already configured');
169
+
170
+ const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
171
+ // Should have exactly 1 PreToolUse and 2 PostToolUse (no duplicates)
172
+ expect(settings.hooks.PreToolUse).toHaveLength(1);
173
+ expect(settings.hooks.PostToolUse).toHaveLength(2);
174
+ });
175
+ });
176
+
177
+ // ── Gemini CLI init ───────────────────────────────────────────────────
178
+
179
+ function runInitGemini(cwd: string, ...extraArgs: string[]): { stdout: string; stderr: string; exitCode: number } {
180
+ try {
181
+ const stdout = execFileSync('node', [CLI, 'init', '--hooks', '--gemini', ...extraArgs], {
182
+ encoding: 'utf-8',
183
+ cwd,
184
+ timeout: 5000,
185
+ });
186
+ return { stdout, stderr: '', exitCode: 0 };
187
+ } catch (err: any) {
188
+ return {
189
+ stdout: err.stdout ?? '',
190
+ stderr: err.stderr ?? '',
191
+ exitCode: err.status ?? 1,
192
+ };
193
+ }
194
+ }
195
+
196
+ describe('hush init --hooks --gemini', () => {
197
+ let tmpDir: string;
198
+
199
+ beforeEach(() => {
200
+ tmpDir = mkdtempSync(join(tmpdir(), 'hush-init-gemini-'));
201
+ });
202
+
203
+ afterEach(() => {
204
+ rmSync(tmpDir, { recursive: true, force: true });
205
+ });
206
+
207
+ it('should create .gemini/settings.json with BeforeTool and AfterTool', () => {
208
+ const { stdout, exitCode } = runInitGemini(tmpDir);
209
+ expect(exitCode).toBe(0);
210
+ expect(stdout).toContain('Wrote hush hooks config');
211
+
212
+ const settings = JSON.parse(readFileSync(join(tmpDir, '.gemini', 'settings.json'), 'utf-8'));
213
+
214
+ // BeforeTool
215
+ expect(settings.hooks.BeforeTool).toHaveLength(1);
216
+ expect(settings.hooks.BeforeTool[0].matcher).toBe('mcp__.*');
217
+ expect(settings.hooks.BeforeTool[0].hooks[0].command).toBe('hush redact-hook');
218
+
219
+ // AfterTool
220
+ expect(settings.hooks.AfterTool).toHaveLength(2);
221
+ expect(settings.hooks.AfterTool[0].matcher).toBe('run_shell_command|read_file|read_many_files|search_file_content|web_fetch');
222
+ expect(settings.hooks.AfterTool[0].hooks[0].command).toBe('hush redact-hook');
223
+ expect(settings.hooks.AfterTool[1].matcher).toBe('mcp__.*');
224
+ expect(settings.hooks.AfterTool[1].hooks[0].command).toBe('hush redact-hook');
225
+ });
226
+
227
+ it('should not create .claude/ directory', () => {
228
+ runInitGemini(tmpDir);
229
+ expect(existsSync(join(tmpDir, '.claude'))).toBe(false);
230
+ });
231
+
232
+ it('should be idempotent on re-run', () => {
233
+ runInitGemini(tmpDir);
234
+ const { stdout, exitCode } = runInitGemini(tmpDir);
235
+ expect(exitCode).toBe(0);
236
+ expect(stdout).toContain('already configured');
237
+
238
+ const settings = JSON.parse(readFileSync(join(tmpDir, '.gemini', 'settings.json'), 'utf-8'));
239
+ expect(settings.hooks.BeforeTool).toHaveLength(1);
240
+ expect(settings.hooks.AfterTool).toHaveLength(2);
241
+ });
242
+
243
+ it('should write to settings.local.json with --local flag', () => {
244
+ const { stdout, exitCode } = runInitGemini(tmpDir, '--local');
245
+ expect(exitCode).toBe(0);
246
+ expect(stdout).toContain('settings.local.json');
247
+
248
+ const localPath = join(tmpDir, '.gemini', 'settings.local.json');
249
+ expect(existsSync(localPath)).toBe(true);
250
+
251
+ const settings = JSON.parse(readFileSync(localPath, 'utf-8'));
252
+ expect(settings.hooks.BeforeTool[0].hooks[0].command).toBe('hush redact-hook');
253
+ expect(settings.hooks.AfterTool[0].hooks[0].command).toBe('hush redact-hook');
254
+ });
101
255
  });
@@ -145,4 +145,75 @@ describe('HushPlugin integration', () => {
145
145
  ),
146
146
  ).resolves.toBeUndefined();
147
147
  });
148
+
149
+ it('redacts email in tool args (in-place mutation)', async () => {
150
+ const plugin = await HushPlugin();
151
+ const output = { args: { text: 'Contact admin@secret.corp for access', channel: '#general' } };
152
+ await plugin['tool.execute.before']({ tool: 'mcp_send' }, output);
153
+ expect(output.args.text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
154
+ expect(output.args.text).not.toContain('admin@secret.corp');
155
+ expect(output.args.channel).toBe('#general');
156
+ });
157
+
158
+ it('passes through clean args without mutation', async () => {
159
+ const plugin = await HushPlugin();
160
+ const output = { args: { text: 'hello world', channel: '#general' } };
161
+ await plugin['tool.execute.before']({ tool: 'mcp_send' }, output);
162
+ expect(output.args.text).toBe('hello world');
163
+ expect(output.args.channel).toBe('#general');
164
+ });
165
+
166
+ it('still blocks sensitive files before redacting args', async () => {
167
+ const plugin = await HushPlugin();
168
+ await expect(
169
+ plugin['tool.execute.before'](
170
+ { tool: 'read' },
171
+ { args: { filePath: '.env', extra: 'admin@secret.corp' } },
172
+ ),
173
+ ).rejects.toThrow('[hush] Blocked: sensitive file');
174
+ });
175
+ });
176
+
177
+ describe('HushPlugin tool.execute.after', () => {
178
+ it('exports a tool.execute.after hook', async () => {
179
+ const plugin = await HushPlugin();
180
+ expect(plugin['tool.execute.after']).toBeTypeOf('function');
181
+ });
182
+
183
+ it('redacts email in built-in tool output (output.output string)', async () => {
184
+ const plugin = await HushPlugin();
185
+ const output = { output: 'Contact admin@secret.corp for access' } as any;
186
+ await plugin['tool.execute.after']({ tool: 'bash' }, output);
187
+ expect(output.output).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
188
+ expect(output.output).not.toContain('admin@secret.corp');
189
+ });
190
+
191
+ it('redacts IP in MCP content blocks (output.content array)', async () => {
192
+ const plugin = await HushPlugin();
193
+ const output = {
194
+ content: [
195
+ { type: 'text', text: 'Server at 192.168.1.100' },
196
+ { type: 'text', text: 'No PII here' },
197
+ ],
198
+ } as any;
199
+ await plugin['tool.execute.after']({ tool: 'mcp_query' }, output);
200
+ expect(output.content[0].text).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/);
201
+ expect(output.content[0].text).not.toContain('192.168.1.100');
202
+ expect(output.content[1].text).toBe('No PII here');
203
+ });
204
+
205
+ it('passes through clean output unchanged', async () => {
206
+ const plugin = await HushPlugin();
207
+ const output = { output: 'hello world' } as any;
208
+ await plugin['tool.execute.after']({ tool: 'bash' }, output);
209
+ expect(output.output).toBe('hello world');
210
+ });
211
+
212
+ it('handles output with no relevant fields gracefully', async () => {
213
+ const plugin = await HushPlugin();
214
+ const output = {} as any;
215
+ await expect(
216
+ plugin['tool.execute.after']({ tool: 'bash' }, output),
217
+ ).resolves.toBeUndefined();
218
+ });
148
219
  });