@aictrl/hush 0.1.6 → 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.
Files changed (50) hide show
  1. package/.github/workflows/opencode-review.yml +52 -7
  2. package/.gitlab-ci.yml +59 -0
  3. package/README.md +150 -3
  4. package/dist/cli.js +30 -17
  5. package/dist/cli.js.map +1 -1
  6. package/dist/commands/init.d.ts +11 -0
  7. package/dist/commands/init.d.ts.map +1 -0
  8. package/dist/commands/init.js +135 -0
  9. package/dist/commands/init.js.map +1 -0
  10. package/dist/commands/redact-hook.d.ts +21 -0
  11. package/dist/commands/redact-hook.d.ts.map +1 -0
  12. package/dist/commands/redact-hook.js +225 -0
  13. package/dist/commands/redact-hook.js.map +1 -0
  14. package/dist/index.js +8 -2
  15. package/dist/index.js.map +1 -1
  16. package/dist/middleware/redactor.d.ts +5 -0
  17. package/dist/middleware/redactor.d.ts.map +1 -1
  18. package/dist/middleware/redactor.js +69 -0
  19. package/dist/middleware/redactor.js.map +1 -1
  20. package/dist/plugins/opencode-hush.d.ts +32 -0
  21. package/dist/plugins/opencode-hush.d.ts.map +1 -0
  22. package/dist/plugins/opencode-hush.js +58 -0
  23. package/dist/plugins/opencode-hush.js.map +1 -0
  24. package/dist/plugins/sensitive-patterns.d.ts +15 -0
  25. package/dist/plugins/sensitive-patterns.d.ts.map +1 -0
  26. package/dist/plugins/sensitive-patterns.js +69 -0
  27. package/dist/plugins/sensitive-patterns.js.map +1 -0
  28. package/dist/vault/token-vault.d.ts.map +1 -1
  29. package/dist/vault/token-vault.js +16 -3
  30. package/dist/vault/token-vault.js.map +1 -1
  31. package/examples/team-config/.claude/settings.json +41 -0
  32. package/examples/team-config/.codex/config.toml +4 -0
  33. package/examples/team-config/.gemini/settings.json +38 -0
  34. package/examples/team-config/.opencode/plugins/hush.ts +79 -0
  35. package/examples/team-config/opencode.json +10 -0
  36. package/package.json +11 -1
  37. package/scripts/e2e-plugin-block.sh +142 -0
  38. package/scripts/e2e-proxy-live.sh +185 -0
  39. package/src/cli.ts +28 -16
  40. package/src/commands/init.ts +186 -0
  41. package/src/commands/redact-hook.ts +297 -0
  42. package/src/index.ts +7 -2
  43. package/src/middleware/redactor.ts +75 -0
  44. package/src/plugins/opencode-hush.ts +70 -0
  45. package/src/plugins/sensitive-patterns.ts +71 -0
  46. package/src/vault/token-vault.ts +18 -4
  47. package/tests/init.test.ts +255 -0
  48. package/tests/opencode-plugin.test.ts +219 -0
  49. package/tests/redact-hook.test.ts +498 -0
  50. package/tests/redaction.test.ts +96 -0
@@ -0,0 +1,498 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { execFileSync } from 'child_process';
3
+ import { join } from 'path';
4
+
5
+ /**
6
+ * Integration tests for `hush redact-hook`.
7
+ * Spawns the CLI as a child process with piped stdin, matching real hook usage.
8
+ */
9
+ const CLI = join(__dirname, '..', 'dist', 'cli.js');
10
+
11
+ function runHook(input: string): { stdout: string; stderr: string; exitCode: number } {
12
+ try {
13
+ const stdout = execFileSync('node', [CLI, 'redact-hook'], {
14
+ input,
15
+ encoding: 'utf-8',
16
+ timeout: 5000,
17
+ });
18
+ return { stdout, stderr: '', exitCode: 0 };
19
+ } catch (err: any) {
20
+ return {
21
+ stdout: err.stdout ?? '',
22
+ stderr: err.stderr ?? '',
23
+ exitCode: err.status ?? 1,
24
+ };
25
+ }
26
+ }
27
+
28
+ describe('hush redact-hook', () => {
29
+ // ── PostToolUse built-in tools (existing tests) ──────────────────────
30
+
31
+ it('should redact email from Bash stdout', () => {
32
+ const payload = {
33
+ tool_name: 'Bash',
34
+ tool_response: { stdout: 'email: test@foo.com' },
35
+ };
36
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
37
+ expect(exitCode).toBe(0);
38
+
39
+ const result = JSON.parse(stdout);
40
+ expect(result.decision).toBe('block');
41
+ expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
42
+ expect(result.reason).not.toContain('test@foo.com');
43
+ });
44
+
45
+ it('should redact email from Read file.content', () => {
46
+ const payload = {
47
+ tool_name: 'Read',
48
+ tool_response: { file: { content: 'Contact: admin@internal.corp', filePath: '/app/config.json' } },
49
+ };
50
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
51
+ expect(exitCode).toBe(0);
52
+
53
+ const result = JSON.parse(stdout);
54
+ expect(result.decision).toBe('block');
55
+ expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
56
+ expect(result.reason).not.toContain('admin@internal.corp');
57
+ });
58
+
59
+ it('should redact IP address from Bash stderr', () => {
60
+ const payload = {
61
+ tool_name: 'Bash',
62
+ tool_response: { stderr: 'connection to 192.168.1.100 failed' },
63
+ };
64
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
65
+ expect(exitCode).toBe(0);
66
+
67
+ const result = JSON.parse(stdout);
68
+ expect(result.decision).toBe('block');
69
+ expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/);
70
+ });
71
+
72
+ it('should pass through clean output (no PII) with no output', () => {
73
+ const payload = {
74
+ tool_name: 'Bash',
75
+ tool_response: { stdout: 'hello world' },
76
+ };
77
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
78
+ expect(exitCode).toBe(0);
79
+ expect(stdout.trim()).toBe('');
80
+ });
81
+
82
+ it('should handle empty stdin gracefully', () => {
83
+ const { stdout, exitCode } = runHook('');
84
+ expect(exitCode).toBe(0);
85
+ expect(stdout.trim()).toBe('');
86
+ });
87
+
88
+ it('should exit 2 for invalid JSON', () => {
89
+ const { exitCode, stderr } = runHook('not json');
90
+ expect(exitCode).toBe(2);
91
+ expect(stderr).toContain('invalid JSON');
92
+ });
93
+
94
+ it('should handle payload with no tool_response', () => {
95
+ const payload = { tool_name: 'Bash' };
96
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
97
+ expect(exitCode).toBe(0);
98
+ expect(stdout.trim()).toBe('');
99
+ });
100
+
101
+ it('should combine stdout and stderr when both have PII', () => {
102
+ const payload = {
103
+ tool_name: 'Bash',
104
+ tool_response: {
105
+ stdout: 'user email: alice@example.com',
106
+ stderr: 'warning: 10.0.0.1 unreachable',
107
+ },
108
+ };
109
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
110
+ expect(exitCode).toBe(0);
111
+
112
+ const result = JSON.parse(stdout);
113
+ expect(result.decision).toBe('block');
114
+ expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
115
+ expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/);
116
+ });
117
+
118
+ it('should redact secrets from tool response', () => {
119
+ const payload = {
120
+ tool_name: 'Bash',
121
+ tool_response: { stdout: 'api_key=sk-1234567890abcdef1234' },
122
+ };
123
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
124
+ expect(exitCode).toBe(0);
125
+
126
+ const result = JSON.parse(stdout);
127
+ expect(result.decision).toBe('block');
128
+ expect(result.reason).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/);
129
+ });
130
+
131
+ it('should handle Grep tool with top-level content field', () => {
132
+ const payload = {
133
+ tool_name: 'Grep',
134
+ tool_response: { content: 'src/config.ts:3: email: "dev@internal.corp"' },
135
+ };
136
+ const { stdout, exitCode } = runHook(JSON.stringify(payload));
137
+ expect(exitCode).toBe(0);
138
+
139
+ const result = JSON.parse(stdout);
140
+ expect(result.decision).toBe('block');
141
+ expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/);
142
+ expect(result.reason).not.toContain('dev@internal.corp');
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
+ });
498
+ });
@@ -99,4 +99,100 @@ describe('Semantic Security Flow (Redaction + Rehydration)', () => {
99
99
  expect(hasRedacted).toBe(true);
100
100
  expect(content).toMatch(/^Call \[NETWORK_IP_[a-f0-9]{6}\]$/);
101
101
  });
102
+
103
+ describe('cloud provider key detection', () => {
104
+ it('should redact AWS access key IDs', () => {
105
+ const input = 'key: AKIAIOSFODNN7EXAMPLE';
106
+ const { content, hasRedacted } = redactor.redact(input);
107
+ expect(hasRedacted).toBe(true);
108
+ expect(content).toMatch(/\[AWS_KEY_[a-f0-9]{6}\]/);
109
+ expect(content).not.toContain('AKIAIOSFODNN7EXAMPLE');
110
+ });
111
+
112
+ it('should redact GCP API keys', () => {
113
+ const input = 'key: AIzaSyA1234567890abcdefghijklmnopqrstuv';
114
+ const { content, hasRedacted } = redactor.redact(input);
115
+ expect(hasRedacted).toBe(true);
116
+ expect(content).toMatch(/\[GCP_KEY_[a-f0-9]{6}\]/);
117
+ });
118
+
119
+ it('should redact GitHub PATs', () => {
120
+ const input = 'token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij';
121
+ const { content, hasRedacted } = redactor.redact(input);
122
+ expect(hasRedacted).toBe(true);
123
+ expect(content).toMatch(/\[GITHUB_PAT_[a-f0-9]{6}\]/);
124
+ });
125
+
126
+ it('should redact GitHub fine-grained PATs', () => {
127
+ const input = 'github_pat_1234567890abcdefghijkl_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456';
128
+ const { content, hasRedacted } = redactor.redact(input);
129
+ expect(hasRedacted).toBe(true);
130
+ expect(content).toMatch(/\[GITHUB_FINE_PAT_[a-f0-9]{6}\]/);
131
+ });
132
+
133
+ it('should redact GitLab PATs', () => {
134
+ const input = 'token: glpat-1234567890abcdefghij';
135
+ const { content, hasRedacted } = redactor.redact(input);
136
+ expect(hasRedacted).toBe(true);
137
+ expect(content).toMatch(/\[GITLAB_PAT_[a-f0-9]{6}\]/);
138
+ });
139
+
140
+ it('should redact Slack bot tokens', () => {
141
+ const input = 'xoxb-1234567890123-1234567890123-abc';
142
+ const { content, hasRedacted } = redactor.redact(input);
143
+ expect(hasRedacted).toBe(true);
144
+ expect(content).toMatch(/\[SLACK_BOT_[a-f0-9]{6}\]/);
145
+ });
146
+
147
+ it('should redact Stripe secret keys', () => {
148
+ // Concatenated to avoid GitHub push-protection false positive
149
+ const input = 'sk_live_' + '1234567890abcdefghijklmnop';
150
+ const { content, hasRedacted } = redactor.redact(input);
151
+ expect(hasRedacted).toBe(true);
152
+ expect(content).toMatch(/\[STRIPE_SECRET_[a-f0-9]{6}\]/);
153
+ });
154
+
155
+ it('should redact SendGrid API keys', () => {
156
+ // Concatenated to avoid GitHub push-protection false positive
157
+ const input = 'SG.' + 'abcdefghijklmnopqrstuv.yz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ01234';
158
+ const { content, hasRedacted } = redactor.redact(input);
159
+ expect(hasRedacted).toBe(true);
160
+ expect(content).toMatch(/\[SENDGRID_KEY_[a-f0-9]{6}\]/);
161
+ });
162
+
163
+ it('should redact npm tokens', () => {
164
+ const input = 'npm_abcdefghijklmnopqrstuvwxyz0123456789';
165
+ const { content, hasRedacted } = redactor.redact(input);
166
+ expect(hasRedacted).toBe(true);
167
+ expect(content).toMatch(/\[NPM_TOKEN_[a-f0-9]{6}\]/);
168
+ });
169
+
170
+ it('should redact Anthropic API keys', () => {
171
+ const input = 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789';
172
+ const { content, hasRedacted } = redactor.redact(input);
173
+ expect(hasRedacted).toBe(true);
174
+ expect(content).toMatch(/\[ANTHROPIC_KEY_[a-f0-9]{6}\]/);
175
+ });
176
+
177
+ it('should redact DigitalOcean PATs', () => {
178
+ const input = 'dop_v1_' + 'a'.repeat(64);
179
+ const { content, hasRedacted } = redactor.redact(input);
180
+ expect(hasRedacted).toBe(true);
181
+ expect(content).toMatch(/\[DIGITALOCEAN_TOKEN_[a-f0-9]{6}\]/);
182
+ });
183
+
184
+ it('should redact PEM private keys', () => {
185
+ const input = '-----BEGIN RSA PRIVATE KEY-----\n' + 'A'.repeat(100) + '\n-----END RSA PRIVATE KEY-----';
186
+ const { content, hasRedacted } = redactor.redact(input);
187
+ expect(hasRedacted).toBe(true);
188
+ expect(content).toMatch(/\[PRIVATE_KEY_[a-f0-9]{6}\]/);
189
+ expect(content).not.toContain('BEGIN RSA PRIVATE KEY');
190
+ });
191
+
192
+ it('should not false-positive on normal text', () => {
193
+ const input = 'The package.json file has scripts and dependencies.';
194
+ const { hasRedacted } = redactor.redact(input);
195
+ expect(hasRedacted).toBe(false);
196
+ });
197
+ });
102
198
  });