@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.
@@ -26,6 +26,8 @@ function runHook(input: string): { stdout: string; stderr: string; exitCode: num
26
26
  }
27
27
 
28
28
  describe('hush redact-hook', () => {
29
+ // ── PostToolUse built-in tools (existing tests) ──────────────────────
30
+
29
31
  it('should redact email from Bash stdout', () => {
30
32
  const payload = {
31
33
  tool_name: 'Bash',
@@ -139,4 +141,358 @@ describe('hush redact-hook', () => {
139
141
  expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
140
142
  expect(result.reason).not.toContain('dev@internal.corp');
141
143
  });
144
+
145
+ // ── PostToolUse built-in with explicit hook_event_name ───────────────
146
+
147
+ it('should use decision:block for PostToolUse built-in with explicit event name', () => {
148
+ const payload = {
149
+ hook_event_name: 'PostToolUse',
150
+ tool_name: 'Bash',
151
+ tool_response: { stdout: 'email: test@foo.com' },
152
+ };
153
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
154
+ expect(exitCode).toBe(0);
155
+
156
+ const result = JSON.parse(stdout);
157
+ expect(result.decision).toBe('block');
158
+ expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
159
+ });
160
+
161
+ // ── Backward compat: no hook_event_name ──────────────────────────────
162
+
163
+ it('should fall back to PostToolUse built-in when hook_event_name is absent', () => {
164
+ const payload = {
165
+ tool_name: 'Read',
166
+ tool_response: { file: { content: 'Contact: fallback@legacy.com' } },
167
+ };
168
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
169
+ expect(exitCode).toBe(0);
170
+
171
+ const result = JSON.parse(stdout);
172
+ expect(result.decision).toBe('block');
173
+ expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
174
+ expect(result.reason).not.toContain('fallback@legacy.com');
175
+ });
176
+
177
+ // ── PreToolUse (outbound MCP arg redaction) ──────────────────────────
178
+
179
+ describe('PreToolUse — outbound MCP arg redaction', () => {
180
+ it('should redact email in MCP tool input and return updatedInput', () => {
181
+ const payload = {
182
+ hook_event_name: 'PreToolUse',
183
+ tool_name: 'mcp__slack__send_message',
184
+ tool_input: {
185
+ channel: '#general',
186
+ text: 'Please contact admin@secret.corp for access',
187
+ },
188
+ };
189
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
190
+ expect(exitCode).toBe(0);
191
+
192
+ const result = JSON.parse(stdout);
193
+ expect(result.hookSpecificOutput).toBeDefined();
194
+ expect(result.hookSpecificOutput.hookEventName).toBe('PreToolUse');
195
+ expect(result.hookSpecificOutput.permissionDecision).toBe('allow');
196
+ expect(result.hookSpecificOutput.updatedInput.text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
197
+ expect(result.hookSpecificOutput.updatedInput.text).not.toContain('admin@secret.corp');
198
+ // Non-PII fields preserved
199
+ expect(result.hookSpecificOutput.updatedInput.channel).toBe('#general');
200
+ });
201
+
202
+ it('should pass through clean input with no output', () => {
203
+ const payload = {
204
+ hook_event_name: 'PreToolUse',
205
+ tool_name: 'mcp__miro__create_card',
206
+ tool_input: {
207
+ title: 'Sprint planning',
208
+ description: 'Weekly sync meeting notes',
209
+ },
210
+ };
211
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
212
+ expect(exitCode).toBe(0);
213
+ expect(stdout.trim()).toBe('');
214
+ });
215
+
216
+ it('should pass through when no tool_input is present', () => {
217
+ const payload = {
218
+ hook_event_name: 'PreToolUse',
219
+ tool_name: 'mcp__db__list_tables',
220
+ };
221
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
222
+ expect(exitCode).toBe(0);
223
+ expect(stdout.trim()).toBe('');
224
+ });
225
+
226
+ it('should redact nested PII in complex tool input', () => {
227
+ const payload = {
228
+ hook_event_name: 'PreToolUse',
229
+ tool_name: 'mcp__notion__create_page',
230
+ tool_input: {
231
+ title: 'User Report',
232
+ properties: {
233
+ email: 'user@private.org',
234
+ ip: 'Connected from 10.20.30.40',
235
+ },
236
+ },
237
+ };
238
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
239
+ expect(exitCode).toBe(0);
240
+
241
+ const result = JSON.parse(stdout);
242
+ const updated = result.hookSpecificOutput.updatedInput;
243
+ expect(updated.properties.email).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
244
+ expect(updated.properties.ip).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/);
245
+ expect(updated.title).toBe('User Report');
246
+ });
247
+ });
248
+
249
+ // ── PostToolUse MCP (inbound result redaction) ───────────────────────
250
+
251
+ describe('PostToolUse MCP — inbound result redaction', () => {
252
+ it('should redact email in MCP content array and return updatedMCPToolOutput', () => {
253
+ const payload = {
254
+ hook_event_name: 'PostToolUse',
255
+ tool_name: 'mcp__slack__read_channel',
256
+ tool_response: {
257
+ content: [
258
+ { type: 'text', text: 'Message from admin@company.io: hello team' },
259
+ ],
260
+ },
261
+ };
262
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
263
+ expect(exitCode).toBe(0);
264
+
265
+ const result = JSON.parse(stdout);
266
+ expect(result.updatedMCPToolOutput).toBeDefined();
267
+ expect(result.updatedMCPToolOutput.content).toHaveLength(1);
268
+ expect(result.updatedMCPToolOutput.content[0].type).toBe('text');
269
+ expect(result.updatedMCPToolOutput.content[0].text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
270
+ expect(result.updatedMCPToolOutput.content[0].text).not.toContain('admin@company.io');
271
+ });
272
+
273
+ it('should pass through clean MCP content with no output', () => {
274
+ const payload = {
275
+ hook_event_name: 'PostToolUse',
276
+ tool_name: 'mcp__miro__get_board',
277
+ tool_response: {
278
+ content: [
279
+ { type: 'text', text: 'Board "Sprint 42" has 15 cards' },
280
+ ],
281
+ },
282
+ };
283
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
284
+ expect(exitCode).toBe(0);
285
+ expect(stdout.trim()).toBe('');
286
+ });
287
+
288
+ it('should redact PII in multiple content blocks selectively', () => {
289
+ const payload = {
290
+ hook_event_name: 'PostToolUse',
291
+ tool_name: 'mcp__db__query',
292
+ tool_response: {
293
+ content: [
294
+ { type: 'text', text: 'Query results:' },
295
+ { type: 'text', text: 'Row 1: user@leaked.com, 192.168.0.1' },
296
+ { type: 'text', text: 'Row 2: no PII here' },
297
+ ],
298
+ },
299
+ };
300
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
301
+ expect(exitCode).toBe(0);
302
+
303
+ const result = JSON.parse(stdout);
304
+ const blocks = result.updatedMCPToolOutput.content;
305
+ expect(blocks).toHaveLength(3);
306
+ // First block — no PII, unchanged
307
+ expect(blocks[0].text).toBe('Query results:');
308
+ // Second block — both email and IP redacted
309
+ expect(blocks[1].text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
310
+ expect(blocks[1].text).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/);
311
+ expect(blocks[1].text).not.toContain('user@leaked.com');
312
+ // Third block — no PII, unchanged
313
+ expect(blocks[2].text).toBe('Row 2: no PII here');
314
+ });
315
+
316
+ it('should handle MCP PostToolUse with no content array', () => {
317
+ const payload = {
318
+ hook_event_name: 'PostToolUse',
319
+ tool_name: 'mcp__slack__ping',
320
+ tool_response: { status: 'ok' },
321
+ };
322
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
323
+ expect(exitCode).toBe(0);
324
+ expect(stdout.trim()).toBe('');
325
+ });
326
+
327
+ it('should handle MCP PostToolUse with no tool_response', () => {
328
+ const payload = {
329
+ hook_event_name: 'PostToolUse',
330
+ tool_name: 'mcp__slack__ping',
331
+ };
332
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
333
+ expect(exitCode).toBe(0);
334
+ expect(stdout.trim()).toBe('');
335
+ });
336
+ });
337
+
338
+ // ── Gemini CLI: BeforeTool (outbound MCP arg redaction) ───────────────
339
+
340
+ describe('BeforeTool — Gemini outbound MCP arg redaction', () => {
341
+ it('should redact email and return hookSpecificOutput.tool_input (no Claude fields)', () => {
342
+ const payload = {
343
+ hook_event_name: 'BeforeTool',
344
+ tool_name: 'mcp__slack__send_message',
345
+ tool_input: {
346
+ channel: '#general',
347
+ text: 'Please contact admin@secret.corp for access',
348
+ },
349
+ };
350
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
351
+ expect(exitCode).toBe(0);
352
+
353
+ const result = JSON.parse(stdout);
354
+ expect(result.hookSpecificOutput).toBeDefined();
355
+ expect(result.hookSpecificOutput.tool_input).toBeDefined();
356
+ expect(result.hookSpecificOutput.tool_input.text).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
357
+ expect(result.hookSpecificOutput.tool_input.text).not.toContain('admin@secret.corp');
358
+ expect(result.hookSpecificOutput.tool_input.channel).toBe('#general');
359
+ // Should NOT have Claude-specific fields
360
+ expect(result.hookSpecificOutput.hookEventName).toBeUndefined();
361
+ expect(result.hookSpecificOutput.permissionDecision).toBeUndefined();
362
+ expect(result.hookSpecificOutput.updatedInput).toBeUndefined();
363
+ });
364
+
365
+ it('should pass through clean input with no output', () => {
366
+ const payload = {
367
+ hook_event_name: 'BeforeTool',
368
+ tool_name: 'mcp__miro__create_card',
369
+ tool_input: {
370
+ title: 'Sprint planning',
371
+ description: 'Weekly sync meeting notes',
372
+ },
373
+ };
374
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
375
+ expect(exitCode).toBe(0);
376
+ expect(stdout.trim()).toBe('');
377
+ });
378
+
379
+ it('should pass through when no tool_input is present', () => {
380
+ const payload = {
381
+ hook_event_name: 'BeforeTool',
382
+ tool_name: 'mcp__db__list_tables',
383
+ };
384
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
385
+ expect(exitCode).toBe(0);
386
+ expect(stdout.trim()).toBe('');
387
+ });
388
+ });
389
+
390
+ // ── Gemini CLI: AfterTool built-in (inbound result redaction) ─────────
391
+
392
+ describe('AfterTool built-in — Gemini inbound result redaction', () => {
393
+ it('should redact email and return decision:"deny" (not "block")', () => {
394
+ const payload = {
395
+ hook_event_name: 'AfterTool',
396
+ tool_name: 'run_shell_command',
397
+ tool_response: { stdout: 'email: test@foo.com' },
398
+ };
399
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
400
+ expect(exitCode).toBe(0);
401
+
402
+ const result = JSON.parse(stdout);
403
+ expect(result.decision).toBe('deny');
404
+ expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
405
+ expect(result.reason).not.toContain('test@foo.com');
406
+ });
407
+
408
+ it('should pass through clean output with no output', () => {
409
+ const payload = {
410
+ hook_event_name: 'AfterTool',
411
+ tool_name: 'read_file',
412
+ tool_response: { stdout: 'hello world' },
413
+ };
414
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
415
+ expect(exitCode).toBe(0);
416
+ expect(stdout.trim()).toBe('');
417
+ });
418
+
419
+ it('should pass through when no tool_response', () => {
420
+ const payload = {
421
+ hook_event_name: 'AfterTool',
422
+ tool_name: 'read_file',
423
+ };
424
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
425
+ expect(exitCode).toBe(0);
426
+ expect(stdout.trim()).toBe('');
427
+ });
428
+ });
429
+
430
+ // ── Gemini CLI: AfterTool MCP (inbound MCP result redaction) ──────────
431
+
432
+ describe('AfterTool MCP — Gemini inbound MCP result redaction', () => {
433
+ it('should redact email in content array and return deny/reason with joined text', () => {
434
+ const payload = {
435
+ hook_event_name: 'AfterTool',
436
+ tool_name: 'mcp__slack__read_channel',
437
+ tool_response: {
438
+ content: [
439
+ { type: 'text', text: 'Message from admin@company.io: hello team' },
440
+ ],
441
+ },
442
+ };
443
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
444
+ expect(exitCode).toBe(0);
445
+
446
+ const result = JSON.parse(stdout);
447
+ expect(result.decision).toBe('deny');
448
+ expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
449
+ expect(result.reason).not.toContain('admin@company.io');
450
+ });
451
+
452
+ it('should join multiple content blocks into reason', () => {
453
+ const payload = {
454
+ hook_event_name: 'AfterTool',
455
+ tool_name: 'mcp__db__query',
456
+ tool_response: {
457
+ content: [
458
+ { type: 'text', text: 'Row 1: user@leaked.com' },
459
+ { type: 'text', text: 'Row 2: 192.168.0.1' },
460
+ ],
461
+ },
462
+ };
463
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
464
+ expect(exitCode).toBe(0);
465
+
466
+ const result = JSON.parse(stdout);
467
+ expect(result.decision).toBe('deny');
468
+ expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
469
+ expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/);
470
+ });
471
+
472
+ it('should pass through clean MCP content with no output', () => {
473
+ const payload = {
474
+ hook_event_name: 'AfterTool',
475
+ tool_name: 'mcp__miro__get_board',
476
+ tool_response: {
477
+ content: [
478
+ { type: 'text', text: 'Board "Sprint 42" has 15 cards' },
479
+ ],
480
+ },
481
+ };
482
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
483
+ expect(exitCode).toBe(0);
484
+ expect(stdout.trim()).toBe('');
485
+ });
486
+
487
+ it('should handle AfterTool MCP with no content array', () => {
488
+ const payload = {
489
+ hook_event_name: 'AfterTool',
490
+ tool_name: 'mcp__slack__ping',
491
+ tool_response: { status: 'ok' },
492
+ };
493
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
494
+ expect(exitCode).toBe(0);
495
+ expect(stdout.trim()).toBe('');
496
+ });
497
+ });
142
498
  });