@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,71 @@
1
+ /**
2
+ * Shared helpers for detecting sensitive file paths and commands.
3
+ * Used by the OpenCode hush plugin to block reads of secret files.
4
+ */
5
+
6
+ /** Glob-style patterns for files that should never be read by AI tools. */
7
+ const SENSITIVE_GLOBS = [
8
+ /^\.env($|\..*)/, // .env, .env.local, .env.production, etc.
9
+ /credentials/i,
10
+ /secret/i,
11
+ /\.pem$/,
12
+ /\.key$/,
13
+ /\.p12$/,
14
+ /\.pfx$/,
15
+ /\.jks$/,
16
+ /\.keystore$/,
17
+ /\.asc$/,
18
+ /^id_rsa/,
19
+ /^\.netrc$/,
20
+ /^\.pgpass$/,
21
+ ];
22
+
23
+ /**
24
+ * Check whether a file path points to a sensitive file.
25
+ * Matches against the basename only so absolute/relative paths both work.
26
+ */
27
+ export function isSensitivePath(filePath: string): boolean {
28
+ const basename = (filePath.split('/').pop() ?? '').trim();
29
+ return SENSITIVE_GLOBS.some((re) => re.test(basename));
30
+ }
31
+
32
+ /** Commands that read file contents (includes batcat — Ubuntu symlink for bat). */
33
+ const READ_COMMANDS = /\b(cat|head|tail|less|more|bat|batcat)\b/;
34
+
35
+ /** Strip shell metacharacters that could wrap a filename to bypass detection. */
36
+ function stripShellMeta(token: string): string {
37
+ return token.replace(/[`"'$(){}]/g, '');
38
+ }
39
+
40
+ /**
41
+ * Check whether a bash command reads a sensitive file.
42
+ * Looks for common read commands followed by a sensitive filename.
43
+ */
44
+ export function commandReadsSensitiveFile(cmd: string): boolean {
45
+ if (!READ_COMMANDS.test(cmd)) return false;
46
+
47
+ // Check input redirections: `cat <.env` or `cat < .env`
48
+ // The file after `<` is read by the preceding command.
49
+ const redirectPattern = /<\s*([^\s|;&<>]+)/g;
50
+ let rMatch;
51
+ while ((rMatch = redirectPattern.exec(cmd)) !== null) {
52
+ if (isSensitivePath(stripShellMeta(rMatch[1]!))) return true;
53
+ }
54
+
55
+ // Split on pipes, semicolons, &&, and redirections to get individual commands
56
+ const parts = cmd.split(/[|;&<>]+/);
57
+ for (const part of parts) {
58
+ const tokens = part.trim().split(/\s+/);
59
+ const cmdIndex = tokens.findIndex((t) => READ_COMMANDS.test(t));
60
+ if (cmdIndex === -1) continue;
61
+
62
+ // Check all tokens after the command for sensitive paths (skip flags).
63
+ for (let i = cmdIndex + 1; i < tokens.length; i++) {
64
+ const token = tokens[i]!;
65
+ if (token.startsWith('-')) continue; // skip flags like -n, -5
66
+ const cleaned = stripShellMeta(token);
67
+ if (isSensitivePath(cleaned)) return true;
68
+ }
69
+ }
70
+ return false;
71
+ }
@@ -88,7 +88,9 @@ export class TokenVault {
88
88
  let buffer = '';
89
89
  const maxTokenLen = Math.max(...[...this.vault.keys()].map(t => t.length), 0);
90
90
 
91
- // Accumulate content fields across SSE events to reassemble split tokens
91
+ // Accumulate content fields across SSE events to reassemble split tokens.
92
+ // Cap buffer size to prevent unbounded memory growth on very long streams.
93
+ const MAX_BUFFER_SIZE = 1024 * 1024; // 1 MB per field
92
94
  const contentBuffers: Record<string, string> = {};
93
95
  const CONTENT_FIELDS = ['content', 'reasoning_content', 'partial_json'];
94
96
 
@@ -186,11 +188,23 @@ export class TokenVault {
186
188
  const bufKey = actualField;
187
189
  contentBuffers[bufKey] = (contentBuffers[bufKey] || '') + target[actualField];
188
190
 
189
- const buf = contentBuffers[bufKey];
191
+ // Cap buffer size: flush everything if it grows too large
192
+ if (contentBuffers[bufKey]!.length > MAX_BUFFER_SIZE) {
193
+ target[actualField] = flushField(bufKey);
194
+ modified = true;
195
+ continue;
196
+ }
197
+
198
+ const buf = contentBuffers[bufKey]!;
190
199
  const lastBracket = buf.lastIndexOf('[');
200
+ // Only treat as partial token if the text after '[' looks like a
201
+ // token prefix (uppercase letter or underscore), not JSON array content.
202
+ // Also hold back a bare '[' at the end — not enough chars yet to decide.
203
+ const tail = lastBracket >= 0 ? buf.substring(lastBracket) : '';
191
204
  const hasPartialToken = maxTokenLen > 0 && lastBracket >= 0 &&
192
- !buf.substring(lastBracket).includes(']') &&
193
- buf.length - lastBracket < maxTokenLen;
205
+ !tail.includes(']') &&
206
+ buf.length - lastBracket < maxTokenLen &&
207
+ (tail === '[' || /^\[[A-Z_]/.test(tail));
194
208
 
195
209
  if (hasPartialToken) {
196
210
  const safe = buf.substring(0, lastBracket);
@@ -0,0 +1,255 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'fs';
3
+ import { join } from 'path';
4
+ import { execFileSync } from 'child_process';
5
+ import { tmpdir } from 'os';
6
+
7
+ const CLI = join(__dirname, '..', 'dist', 'cli.js');
8
+
9
+ function runInit(cwd: string, ...extraArgs: string[]): { stdout: string; stderr: string; exitCode: number } {
10
+ try {
11
+ const stdout = execFileSync('node', [CLI, 'init', '--hooks', ...extraArgs], {
12
+ encoding: 'utf-8',
13
+ cwd,
14
+ timeout: 5000,
15
+ });
16
+ return { stdout, stderr: '', exitCode: 0 };
17
+ } catch (err: any) {
18
+ return {
19
+ stdout: err.stdout ?? '',
20
+ stderr: err.stderr ?? '',
21
+ exitCode: err.status ?? 1,
22
+ };
23
+ }
24
+ }
25
+
26
+ describe('hush init --hooks', () => {
27
+ let tmpDir: string;
28
+
29
+ beforeEach(() => {
30
+ tmpDir = mkdtempSync(join(tmpdir(), 'hush-init-'));
31
+ });
32
+
33
+ afterEach(() => {
34
+ rmSync(tmpDir, { recursive: true, force: true });
35
+ });
36
+
37
+ it('should create .claude/settings.json with both PreToolUse and PostToolUse', () => {
38
+ const { stdout, exitCode } = runInit(tmpDir);
39
+ expect(exitCode).toBe(0);
40
+ expect(stdout).toContain('Wrote hush hooks config');
41
+
42
+ const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8'));
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);
51
+ expect(settings.hooks.PostToolUse[0].matcher).toBe('Bash|Read|Grep|WebFetch');
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');
55
+ });
56
+
57
+ it('should merge into existing settings preserving other keys', () => {
58
+ const claudeDir = join(tmpDir, '.claude');
59
+ mkdirSync(claudeDir, { recursive: true });
60
+ writeFileSync(
61
+ join(claudeDir, 'settings.json'),
62
+ JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:4000' } }, null, 2),
63
+ );
64
+
65
+ const { exitCode } = runInit(tmpDir);
66
+ expect(exitCode).toBe(0);
67
+
68
+ const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8'));
69
+ // Preserved existing env
70
+ expect(settings.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:4000');
71
+ // Added both hook types
72
+ expect(settings.hooks.PreToolUse).toHaveLength(1);
73
+ expect(settings.hooks.PostToolUse).toHaveLength(2);
74
+ expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook');
75
+ });
76
+
77
+ it('should be idempotent on re-run', () => {
78
+ runInit(tmpDir);
79
+ const { stdout, exitCode } = runInit(tmpDir);
80
+ expect(exitCode).toBe(0);
81
+ expect(stdout).toContain('already configured');
82
+
83
+ const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8'));
84
+ expect(settings.hooks.PreToolUse).toHaveLength(1); // Not duplicated
85
+ expect(settings.hooks.PostToolUse).toHaveLength(2); // Not duplicated
86
+ });
87
+
88
+ it('should write to settings.local.json with --local flag', () => {
89
+ const { stdout, exitCode } = runInit(tmpDir, '--local');
90
+ expect(exitCode).toBe(0);
91
+ expect(stdout).toContain('settings.local.json');
92
+
93
+ const localPath = join(tmpDir, '.claude', 'settings.local.json');
94
+ expect(existsSync(localPath)).toBe(true);
95
+
96
+ const settings = JSON.parse(readFileSync(localPath, 'utf-8'));
97
+ expect(settings.hooks.PreToolUse[0].hooks[0].command).toBe('hush redact-hook');
98
+ expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook');
99
+ });
100
+
101
+ it('should show usage without --hooks flag', () => {
102
+ try {
103
+ execFileSync('node', [CLI, 'init'], {
104
+ encoding: 'utf-8',
105
+ cwd: tmpDir,
106
+ timeout: 5000,
107
+ });
108
+ } catch (err: any) {
109
+ expect(err.status).toBe(1);
110
+ expect(err.stderr).toContain('Usage');
111
+ }
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
+ });
255
+ });
@@ -0,0 +1,219 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isSensitivePath, commandReadsSensitiveFile } from '../src/plugins/sensitive-patterns.js';
3
+ import { HushPlugin } from '../src/plugins/opencode-hush.js';
4
+
5
+ describe('isSensitivePath', () => {
6
+ it.each([
7
+ '.env',
8
+ '.env.local',
9
+ '.env.production',
10
+ '.env.development.local',
11
+ 'credentials.json',
12
+ 'credentials.yaml',
13
+ 'db-credentials',
14
+ 'secret.txt',
15
+ 'secrets.yaml',
16
+ 'server.pem',
17
+ 'tls.key',
18
+ 'id_rsa',
19
+ 'id_rsa.pub',
20
+ '.netrc',
21
+ '.pgpass',
22
+ 'keystore.p12',
23
+ 'cert.pfx',
24
+ 'truststore.jks',
25
+ 'app.keystore',
26
+ 'private.asc',
27
+ ])('blocks %s', (path) => {
28
+ expect(isSensitivePath(path)).toBe(true);
29
+ });
30
+
31
+ it.each([
32
+ '.env',
33
+ '/home/user/project/.env.local',
34
+ '/etc/ssl/private/server.key',
35
+ 'config/credentials.json',
36
+ ])('blocks absolute/relative path %s', (path) => {
37
+ expect(isSensitivePath(path)).toBe(true);
38
+ });
39
+
40
+ it.each([
41
+ 'package.json',
42
+ 'src/index.ts',
43
+ 'README.md',
44
+ 'tsconfig.json',
45
+ '.gitignore',
46
+ 'environment.ts',
47
+ 'docker-compose.yml',
48
+ ])('allows %s', (path) => {
49
+ expect(isSensitivePath(path)).toBe(false);
50
+ });
51
+ });
52
+
53
+ describe('commandReadsSensitiveFile', () => {
54
+ it.each([
55
+ 'cat .env',
56
+ 'cat /app/.env.local',
57
+ 'head -5 secrets.yaml',
58
+ 'tail -n 20 credentials.json',
59
+ 'less .env.production',
60
+ 'more secret.txt',
61
+ 'bat id_rsa',
62
+ 'cat .pgpass',
63
+ 'cat foo.txt && cat .env',
64
+ 'echo hello | cat .env',
65
+ 'cat $HOME/.env',
66
+ 'cat ${HOME}/.env',
67
+ 'cat ~/secrets/.env',
68
+ 'cat ~/.pgpass',
69
+ 'batcat .env',
70
+ 'batcat id_rsa',
71
+ 'cat "$(echo .env)"',
72
+ 'cat `.env`',
73
+ 'cat <.env',
74
+ "cat '.env'",
75
+ ])('blocks: %s', (cmd) => {
76
+ expect(commandReadsSensitiveFile(cmd)).toBe(true);
77
+ });
78
+
79
+ it.each([
80
+ 'cat README.md',
81
+ 'ls -la',
82
+ 'echo "hello"',
83
+ 'grep password src/config.ts',
84
+ 'head -5 package.json',
85
+ 'cat src/index.ts',
86
+ 'npm install',
87
+ 'node dist/cli.js',
88
+ ])('allows: %s', (cmd) => {
89
+ expect(commandReadsSensitiveFile(cmd)).toBe(false);
90
+ });
91
+ });
92
+
93
+ describe('HushPlugin integration', () => {
94
+ it('exports a factory that returns a tool.execute.before hook', async () => {
95
+ const plugin = await HushPlugin();
96
+ expect(plugin['tool.execute.before']).toBeTypeOf('function');
97
+ });
98
+
99
+ it('throws when read targets a sensitive file', async () => {
100
+ const plugin = await HushPlugin();
101
+ await expect(
102
+ plugin['tool.execute.before'](
103
+ { tool: 'read' },
104
+ { args: { filePath: '/project/.env' } },
105
+ ),
106
+ ).rejects.toThrow('[hush] Blocked: sensitive file');
107
+ });
108
+
109
+ it('passes when read targets a normal file', async () => {
110
+ const plugin = await HushPlugin();
111
+ await expect(
112
+ plugin['tool.execute.before'](
113
+ { tool: 'read' },
114
+ { args: { filePath: 'src/index.ts' } },
115
+ ),
116
+ ).resolves.toBeUndefined();
117
+ });
118
+
119
+ it('throws when bash command reads a sensitive file', async () => {
120
+ const plugin = await HushPlugin();
121
+ await expect(
122
+ plugin['tool.execute.before'](
123
+ { tool: 'bash' },
124
+ { args: { command: 'cat .env' } },
125
+ ),
126
+ ).rejects.toThrow('[hush] Blocked: command reads sensitive file');
127
+ });
128
+
129
+ it('passes when bash command is harmless', async () => {
130
+ const plugin = await HushPlugin();
131
+ await expect(
132
+ plugin['tool.execute.before'](
133
+ { tool: 'bash' },
134
+ { args: { command: 'ls -la' } },
135
+ ),
136
+ ).resolves.toBeUndefined();
137
+ });
138
+
139
+ it('passes for unrelated tools', async () => {
140
+ const plugin = await HushPlugin();
141
+ await expect(
142
+ plugin['tool.execute.before'](
143
+ { tool: 'write' },
144
+ { args: { filePath: '.env', content: 'x' } },
145
+ ),
146
+ ).resolves.toBeUndefined();
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
+ });
219
+ });