@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.
- package/.github/workflows/opencode-review.yml +52 -7
- package/dist/commands/init.d.ts +3 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +80 -26
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/redact-hook.d.ts +13 -4
- package/dist/commands/redact-hook.d.ts.map +1 -1
- package/dist/commands/redact-hook.js +162 -26
- package/dist/commands/redact-hook.js.map +1 -1
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/plugins/opencode-hush.d.ts +15 -4
- package/dist/plugins/opencode-hush.d.ts.map +1 -1
- package/dist/plugins/opencode-hush.js +37 -4
- package/dist/plugins/opencode-hush.js.map +1 -1
- package/examples/team-config/.claude/settings.json +22 -0
- package/examples/team-config/.gemini/settings.json +38 -0
- package/examples/team-config/.opencode/plugins/hush.ts +9 -6
- package/package.json +1 -1
- package/src/commands/init.ts +110 -31
- package/src/commands/redact-hook.ts +206 -33
- package/src/index.ts +7 -2
- package/src/plugins/opencode-hush.ts +44 -4
- package/tests/init.test.ts +159 -5
- package/tests/opencode-plugin.test.ts +71 -0
- package/tests/redact-hook.test.ts +356 -0
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* hush redact-hook — Claude Code
|
|
2
|
+
* hush redact-hook — Hook handler for Claude Code and Gemini CLI
|
|
3
3
|
*
|
|
4
|
-
* Reads the hook payload from stdin, redacts PII
|
|
5
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
280
|
+
// Gemini CLI events
|
|
281
|
+
if (eventName === 'BeforeTool') {
|
|
282
|
+
handleBeforeTool(payload, redactor);
|
|
283
|
+
return;
|
|
115
284
|
}
|
|
116
285
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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(
|
|
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.
|
|
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
|
-
*
|
|
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
|
|
9
|
-
* that slips through
|
|
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
|
});
|
package/tests/init.test.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
63
|
-
expect(settings.hooks.
|
|
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.
|
|
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
|
});
|