@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.
- package/.github/workflows/opencode-review.yml +52 -7
- package/.gitlab-ci.yml +59 -0
- package/README.md +150 -3
- package/dist/cli.js +30 -17
- package/dist/cli.js.map +1 -1
- package/dist/commands/init.d.ts +11 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +135 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/redact-hook.d.ts +21 -0
- package/dist/commands/redact-hook.d.ts.map +1 -0
- package/dist/commands/redact-hook.js +225 -0
- package/dist/commands/redact-hook.js.map +1 -0
- package/dist/index.js +8 -2
- package/dist/index.js.map +1 -1
- package/dist/middleware/redactor.d.ts +5 -0
- package/dist/middleware/redactor.d.ts.map +1 -1
- package/dist/middleware/redactor.js +69 -0
- package/dist/middleware/redactor.js.map +1 -1
- package/dist/plugins/opencode-hush.d.ts +32 -0
- package/dist/plugins/opencode-hush.d.ts.map +1 -0
- package/dist/plugins/opencode-hush.js +58 -0
- package/dist/plugins/opencode-hush.js.map +1 -0
- package/dist/plugins/sensitive-patterns.d.ts +15 -0
- package/dist/plugins/sensitive-patterns.d.ts.map +1 -0
- package/dist/plugins/sensitive-patterns.js +69 -0
- package/dist/plugins/sensitive-patterns.js.map +1 -0
- package/dist/vault/token-vault.d.ts.map +1 -1
- package/dist/vault/token-vault.js +16 -3
- package/dist/vault/token-vault.js.map +1 -1
- package/examples/team-config/.claude/settings.json +41 -0
- package/examples/team-config/.codex/config.toml +4 -0
- package/examples/team-config/.gemini/settings.json +38 -0
- package/examples/team-config/.opencode/plugins/hush.ts +79 -0
- package/examples/team-config/opencode.json +10 -0
- package/package.json +11 -1
- package/scripts/e2e-plugin-block.sh +142 -0
- package/scripts/e2e-proxy-live.sh +185 -0
- package/src/cli.ts +28 -16
- package/src/commands/init.ts +186 -0
- package/src/commands/redact-hook.ts +297 -0
- package/src/index.ts +7 -2
- package/src/middleware/redactor.ts +75 -0
- package/src/plugins/opencode-hush.ts +70 -0
- package/src/plugins/sensitive-patterns.ts +71 -0
- package/src/vault/token-vault.ts +18 -4
- package/tests/init.test.ts +255 -0
- package/tests/opencode-plugin.test.ts +219 -0
- package/tests/redact-hook.test.ts +498 -0
- package/tests/redaction.test.ts +96 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hush init — Generate hook configuration for Claude Code or Gemini CLI
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* hush init --hooks Write to .claude/settings.json
|
|
6
|
+
* hush init --hooks --local Write to .claude/settings.local.json
|
|
7
|
+
* hush init --hooks --gemini Write to .gemini/settings.json
|
|
8
|
+
* hush init --hooks --gemini --local Write to .gemini/settings.local.json
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
12
|
+
import { join } from 'path';
|
|
13
|
+
|
|
14
|
+
const HUSH_HOOK = {
|
|
15
|
+
type: 'command' as const,
|
|
16
|
+
command: 'hush redact-hook',
|
|
17
|
+
timeout: 10,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const CLAUDE_HOOK_CONFIG = {
|
|
21
|
+
hooks: {
|
|
22
|
+
PreToolUse: [
|
|
23
|
+
{
|
|
24
|
+
matcher: 'mcp__.*',
|
|
25
|
+
hooks: [HUSH_HOOK],
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
PostToolUse: [
|
|
29
|
+
{
|
|
30
|
+
matcher: 'Bash|Read|Grep|WebFetch',
|
|
31
|
+
hooks: [HUSH_HOOK],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
matcher: 'mcp__.*',
|
|
35
|
+
hooks: [HUSH_HOOK],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const GEMINI_HOOK_CONFIG = {
|
|
42
|
+
hooks: {
|
|
43
|
+
BeforeTool: [
|
|
44
|
+
{
|
|
45
|
+
matcher: 'mcp__.*',
|
|
46
|
+
hooks: [HUSH_HOOK],
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
AfterTool: [
|
|
50
|
+
{
|
|
51
|
+
matcher: 'run_shell_command|read_file|read_many_files|search_file_content|web_fetch',
|
|
52
|
+
hooks: [HUSH_HOOK],
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
matcher: 'mcp__.*',
|
|
56
|
+
hooks: [HUSH_HOOK],
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
interface HookEntry {
|
|
63
|
+
matcher: string;
|
|
64
|
+
hooks: Array<{ type: string; command: string; timeout?: number }>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface SettingsJson {
|
|
68
|
+
hooks?: {
|
|
69
|
+
PreToolUse?: HookEntry[];
|
|
70
|
+
PostToolUse?: HookEntry[];
|
|
71
|
+
BeforeTool?: HookEntry[];
|
|
72
|
+
AfterTool?: HookEntry[];
|
|
73
|
+
[key: string]: unknown;
|
|
74
|
+
};
|
|
75
|
+
[key: string]: unknown;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface HookConfig {
|
|
79
|
+
hooks: Record<string, HookEntry[]>;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function hasHushHookInEntries(entries: HookEntry[] | undefined): boolean {
|
|
83
|
+
if (!Array.isArray(entries)) return false;
|
|
84
|
+
return entries.some((entry) =>
|
|
85
|
+
entry.hooks?.some((h) => h.command?.includes('hush redact-hook')),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function hasHushHookClaude(settings: SettingsJson): boolean {
|
|
90
|
+
return (
|
|
91
|
+
hasHushHookInEntries(settings.hooks?.PreToolUse) &&
|
|
92
|
+
hasHushHookInEntries(settings.hooks?.PostToolUse)
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function hasHushHookGemini(settings: SettingsJson): boolean {
|
|
97
|
+
return (
|
|
98
|
+
hasHushHookInEntries(settings.hooks?.BeforeTool) &&
|
|
99
|
+
hasHushHookInEntries(settings.hooks?.AfterTool)
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function mergeHookEntries(
|
|
104
|
+
existing: HookEntry[] | undefined,
|
|
105
|
+
newEntries: HookEntry[],
|
|
106
|
+
): HookEntry[] {
|
|
107
|
+
const merged = Array.isArray(existing) ? [...existing] : [];
|
|
108
|
+
|
|
109
|
+
for (const entry of newEntries) {
|
|
110
|
+
const alreadyHas = merged.some(
|
|
111
|
+
(e) =>
|
|
112
|
+
e.matcher === entry.matcher &&
|
|
113
|
+
e.hooks?.some((h) => h.command?.includes('hush redact-hook')),
|
|
114
|
+
);
|
|
115
|
+
if (!alreadyHas) {
|
|
116
|
+
merged.push(entry);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return merged;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function mergeHooks(existing: SettingsJson, hookConfig: HookConfig): SettingsJson {
|
|
124
|
+
const merged = { ...existing };
|
|
125
|
+
|
|
126
|
+
if (!merged.hooks) {
|
|
127
|
+
merged.hooks = {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
for (const [eventName, entries] of Object.entries(hookConfig.hooks)) {
|
|
131
|
+
const existingEntries = merged.hooks[eventName] as HookEntry[] | undefined;
|
|
132
|
+
merged.hooks[eventName] = mergeHookEntries(existingEntries, entries);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return merged;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function run(args: string[]): void {
|
|
139
|
+
const hasHooksFlag = args.includes('--hooks');
|
|
140
|
+
const isLocal = args.includes('--local');
|
|
141
|
+
const isGemini = args.includes('--gemini');
|
|
142
|
+
|
|
143
|
+
if (!hasHooksFlag) {
|
|
144
|
+
process.stderr.write('Usage: hush init --hooks [--local] [--gemini]\n');
|
|
145
|
+
process.stderr.write('\n');
|
|
146
|
+
process.stderr.write('Options:\n');
|
|
147
|
+
process.stderr.write(' --hooks Generate hook config (PreToolUse + PostToolUse or BeforeTool + AfterTool)\n');
|
|
148
|
+
process.stderr.write(' --local Write to settings.local.json instead of settings.json\n');
|
|
149
|
+
process.stderr.write(' --gemini Write Gemini CLI hooks instead of Claude Code hooks\n');
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const dirName = isGemini ? '.gemini' : '.claude';
|
|
154
|
+
const configDir = join(process.cwd(), dirName);
|
|
155
|
+
const filename = isLocal ? 'settings.local.json' : 'settings.json';
|
|
156
|
+
const filePath = join(configDir, filename);
|
|
157
|
+
|
|
158
|
+
// Ensure config dir exists
|
|
159
|
+
if (!existsSync(configDir)) {
|
|
160
|
+
mkdirSync(configDir, { recursive: true });
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Read existing settings or start fresh
|
|
164
|
+
let settings: SettingsJson = {};
|
|
165
|
+
if (existsSync(filePath)) {
|
|
166
|
+
try {
|
|
167
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
168
|
+
settings = JSON.parse(raw) as SettingsJson;
|
|
169
|
+
} catch {
|
|
170
|
+
process.stderr.write(`Warning: could not parse ${filePath}, starting fresh\n`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Idempotency check
|
|
175
|
+
const hookConfig = isGemini ? GEMINI_HOOK_CONFIG : CLAUDE_HOOK_CONFIG;
|
|
176
|
+
const hasHook = isGemini ? hasHushHookGemini : hasHushHookClaude;
|
|
177
|
+
|
|
178
|
+
if (hasHook(settings)) {
|
|
179
|
+
process.stdout.write(`hush hooks already configured in ${filePath}\n`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const merged = mergeHooks(settings, hookConfig);
|
|
184
|
+
writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n');
|
|
185
|
+
process.stdout.write(`Wrote hush hooks config to ${filePath}\n`);
|
|
186
|
+
}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hush redact-hook — Hook handler for Claude Code and Gemini CLI
|
|
3
|
+
*
|
|
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")
|
|
15
|
+
*
|
|
16
|
+
* Exit codes:
|
|
17
|
+
* 0 — success (may or may not redact)
|
|
18
|
+
* 2 — malformed input (blocks the tool call per hooks spec)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { Redactor } from '../middleware/redactor.js';
|
|
22
|
+
|
|
23
|
+
interface MCPContentBlock {
|
|
24
|
+
type: string;
|
|
25
|
+
text?: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface HookPayload {
|
|
30
|
+
hook_event_name?: 'PreToolUse' | 'PostToolUse' | 'BeforeTool' | 'AfterTool';
|
|
31
|
+
tool_name?: string;
|
|
32
|
+
tool_input?: Record<string, unknown>;
|
|
33
|
+
tool_response?: {
|
|
34
|
+
// Bash tool
|
|
35
|
+
stdout?: string;
|
|
36
|
+
stderr?: string;
|
|
37
|
+
// Read tool (nested under file)
|
|
38
|
+
file?: { content?: string; [key: string]: unknown };
|
|
39
|
+
// Grep / WebFetch / generic
|
|
40
|
+
content?: string | MCPContentBlock[];
|
|
41
|
+
output?: string;
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Collect all text from a built-in tool_response object. */
|
|
47
|
+
function extractText(toolResponse: HookPayload['tool_response']): string | null {
|
|
48
|
+
if (!toolResponse || typeof toolResponse !== 'object') return null;
|
|
49
|
+
|
|
50
|
+
const parts: string[] = [];
|
|
51
|
+
|
|
52
|
+
if (typeof toolResponse.stdout === 'string' && toolResponse.stdout) {
|
|
53
|
+
parts.push(toolResponse.stdout);
|
|
54
|
+
}
|
|
55
|
+
if (typeof toolResponse.stderr === 'string' && toolResponse.stderr) {
|
|
56
|
+
parts.push(toolResponse.stderr);
|
|
57
|
+
}
|
|
58
|
+
// Read tool nests content under file.content
|
|
59
|
+
if (toolResponse.file && typeof toolResponse.file.content === 'string' && toolResponse.file.content) {
|
|
60
|
+
parts.push(toolResponse.file.content);
|
|
61
|
+
}
|
|
62
|
+
if (typeof toolResponse.content === 'string' && toolResponse.content) {
|
|
63
|
+
parts.push(toolResponse.content);
|
|
64
|
+
}
|
|
65
|
+
if (typeof toolResponse.output === 'string' && toolResponse.output) {
|
|
66
|
+
parts.push(toolResponse.output);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
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,
|
|
80
|
+
redactor: Redactor,
|
|
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
|
+
}
|
|
115
|
+
|
|
116
|
+
const { content, hasRedacted } = redactor.redact(text);
|
|
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__');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function readStdin(): Promise<string> {
|
|
231
|
+
return new Promise((resolve, reject) => {
|
|
232
|
+
const chunks: Buffer[] = [];
|
|
233
|
+
process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
234
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
235
|
+
process.stdin.on('error', reject);
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Entry point ─────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
export async function run(): Promise<void> {
|
|
242
|
+
let raw: string;
|
|
243
|
+
try {
|
|
244
|
+
raw = await readStdin();
|
|
245
|
+
} catch {
|
|
246
|
+
process.stderr.write('hush redact-hook: failed to read stdin\n');
|
|
247
|
+
process.exit(2);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (!raw.trim()) {
|
|
251
|
+
process.exit(0);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
let payload: HookPayload;
|
|
255
|
+
try {
|
|
256
|
+
payload = JSON.parse(raw) as HookPayload;
|
|
257
|
+
} catch {
|
|
258
|
+
process.stderr.write('hush redact-hook: invalid JSON on stdin\n');
|
|
259
|
+
process.exit(2);
|
|
260
|
+
}
|
|
261
|
+
|
|
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;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (eventName === 'PostToolUse') {
|
|
272
|
+
if (isMCPTool(payload.tool_name)) {
|
|
273
|
+
handlePostToolUseMCP(payload, redactor);
|
|
274
|
+
} else {
|
|
275
|
+
handlePostToolUseBuiltin(payload, redactor);
|
|
276
|
+
}
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Gemini CLI events
|
|
281
|
+
if (eventName === 'BeforeTool') {
|
|
282
|
+
handleBeforeTool(payload, redactor);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (eventName === 'AfterTool') {
|
|
287
|
+
if (isMCPTool(payload.tool_name)) {
|
|
288
|
+
handleAfterToolMCP(payload, redactor);
|
|
289
|
+
} else {
|
|
290
|
+
handleAfterToolBuiltin(payload, redactor);
|
|
291
|
+
}
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Backward compat: no hook_event_name → treat as PostToolUse built-in
|
|
296
|
+
handlePostToolUseBuiltin(payload, redactor);
|
|
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
|
|
|
@@ -46,6 +46,56 @@ export class Redactor {
|
|
|
46
46
|
PHONE: /(?:^|[\s:;])(?:\+\d{1,3}[-. ]?)?\(?\d{2,4}\)?[-. ]\d{3,4}[-. ]\d{3,4}(?:\s*(?:ext|x)\s*\d+)?/g,
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Cloud provider key patterns — Tier 1 only (unique prefixes, very low false-positive risk).
|
|
51
|
+
* Sources: GitHub secret scanning, gitleaks, trufflehog.
|
|
52
|
+
*/
|
|
53
|
+
private static readonly CLOUD_KEY_PATTERNS: Array<{ re: RegExp; label: string }> = [
|
|
54
|
+
// AWS
|
|
55
|
+
{ re: /\b((?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b/g, label: 'AWS_KEY' },
|
|
56
|
+
// GCP / Firebase
|
|
57
|
+
{ re: /\b(AIza[\w-]{35})\b/g, label: 'GCP_KEY' },
|
|
58
|
+
{ re: /\b(GOCSPX-[a-zA-Z0-9_-]{28})\b/g, label: 'GCP_OAUTH' },
|
|
59
|
+
// GitHub
|
|
60
|
+
{ re: /\b(ghp_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_PAT' },
|
|
61
|
+
{ re: /\b(gho_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_OAUTH' },
|
|
62
|
+
{ re: /\b(ghu_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_U2S' },
|
|
63
|
+
{ re: /\b(ghs_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_S2S' },
|
|
64
|
+
{ re: /\b(ghr_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_REFRESH' },
|
|
65
|
+
{ re: /\b(github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})\b/g, label: 'GITHUB_FINE_PAT' },
|
|
66
|
+
// GitLab
|
|
67
|
+
{ re: /\b(glpat-[\w-]{20})\b/g, label: 'GITLAB_PAT' },
|
|
68
|
+
{ re: /\b(glptt-[a-zA-Z0-9_-]{40})\b/g, label: 'GITLAB_TRIGGER' },
|
|
69
|
+
// Slack
|
|
70
|
+
{ re: /\b(xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*)\b/g, label: 'SLACK_BOT' },
|
|
71
|
+
{ re: /\b(xox[pe]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9-]+)\b/g, label: 'SLACK_TOKEN' },
|
|
72
|
+
// Stripe
|
|
73
|
+
{ re: /\b(sk_(?:live|test)_[a-zA-Z0-9]{10,99})\b/g, label: 'STRIPE_SECRET' },
|
|
74
|
+
{ re: /\b(rk_(?:live|test)_[a-zA-Z0-9]{10,99})\b/g, label: 'STRIPE_RESTRICTED' },
|
|
75
|
+
{ re: /\b(whsec_[a-zA-Z0-9]{24,})\b/g, label: 'STRIPE_WEBHOOK' },
|
|
76
|
+
// SendGrid (SG. + base64url with internal dot separator)
|
|
77
|
+
{ re: /\b(SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43})\b/g, label: 'SENDGRID_KEY' },
|
|
78
|
+
// npm
|
|
79
|
+
{ re: /\b(npm_[a-z0-9]{36})\b/gi, label: 'NPM_TOKEN' },
|
|
80
|
+
// PyPI
|
|
81
|
+
{ re: /\b(pypi-AgEIcHlwaS5vcmc[\w-]{50,})\b/g, label: 'PYPI_TOKEN' },
|
|
82
|
+
// Docker Hub
|
|
83
|
+
{ re: /\b(dckr_pat_[a-zA-Z0-9_-]{27,})\b/g, label: 'DOCKER_PAT' },
|
|
84
|
+
// Anthropic
|
|
85
|
+
{ re: /\b(sk-ant-[a-zA-Z0-9_-]{36,})\b/g, label: 'ANTHROPIC_KEY' },
|
|
86
|
+
// OpenAI (with T3BlbkFJ marker)
|
|
87
|
+
{ re: /\b(sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,})\b/g, label: 'OPENAI_KEY' },
|
|
88
|
+
// DigitalOcean
|
|
89
|
+
{ re: /\b(do[por]_v1_[a-f0-9]{64})\b/g, label: 'DIGITALOCEAN_TOKEN' },
|
|
90
|
+
// HashiCorp Vault
|
|
91
|
+
{ re: /\b(hvs\.[\w-]{90,})\b/g, label: 'VAULT_TOKEN' },
|
|
92
|
+
{ re: /\b(hvb\.[\w-]{90,})\b/g, label: 'VAULT_BATCH' },
|
|
93
|
+
// Supabase
|
|
94
|
+
{ re: /\b(sbp_[a-f0-9]{40})\b/g, label: 'SUPABASE_PAT' },
|
|
95
|
+
{ re: /\b(sb_secret_[a-zA-Z0-9_-]{20,})\b/g, label: 'SUPABASE_SECRET' },
|
|
96
|
+
// PEM private keys (multiline — matched separately in redactPEMKeys)
|
|
97
|
+
];
|
|
98
|
+
|
|
49
99
|
/**
|
|
50
100
|
* Redact sensitive information from a JSON object or string.
|
|
51
101
|
*
|
|
@@ -102,6 +152,31 @@ export class Redactor {
|
|
|
102
152
|
return token;
|
|
103
153
|
});
|
|
104
154
|
|
|
155
|
+
// Redact cloud provider keys BEFORE generic patterns — specific prefixed
|
|
156
|
+
// keys must be matched first so they don't get partially eaten by SECRET
|
|
157
|
+
// or CREDIT_CARD patterns.
|
|
158
|
+
for (const { re, label } of Redactor.CLOUD_KEY_PATTERNS) {
|
|
159
|
+
re.lastIndex = 0;
|
|
160
|
+
text = text.replace(re, (match, p1: string) => {
|
|
161
|
+
hasRedacted = true;
|
|
162
|
+
const val = p1 || match;
|
|
163
|
+
const token = `[${label}_${tokenHash(val)}]`;
|
|
164
|
+
tokens.set(token, val);
|
|
165
|
+
return token;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Redact PEM private keys
|
|
170
|
+
text = text.replace(
|
|
171
|
+
/-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY-----[\s\S]{64,}?-----END[ A-Z0-9_-]{0,100}PRIVATE KEY-----/g,
|
|
172
|
+
(match) => {
|
|
173
|
+
hasRedacted = true;
|
|
174
|
+
const token = `[PRIVATE_KEY_${tokenHash(match)}]`;
|
|
175
|
+
tokens.set(token, match);
|
|
176
|
+
return token;
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
105
180
|
// Redact Secrets in text (e.g. "api_key=...")
|
|
106
181
|
text = text.replace(Redactor.PATTERNS.SECRET, (match, p1) => {
|
|
107
182
|
hasRedacted = true;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Plugin: Hush PII Guard
|
|
3
|
+
*
|
|
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.
|
|
8
|
+
*
|
|
9
|
+
* Defense-in-depth: works alongside the Hush proxy which redacts PII from
|
|
10
|
+
* API requests. The plugin prevents file reads and scrubs tool I/O;
|
|
11
|
+
* the proxy catches anything that slips through.
|
|
12
|
+
*
|
|
13
|
+
* Install: copy to `.opencode/plugins/hush.ts` and add to `opencode.json`:
|
|
14
|
+
* { "plugin": [".opencode/plugins/hush.ts"] }
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { isSensitivePath, commandReadsSensitiveFile } from './sensitive-patterns.js';
|
|
18
|
+
import { Redactor } from '../middleware/redactor.js';
|
|
19
|
+
|
|
20
|
+
const redactor = new Redactor();
|
|
21
|
+
|
|
22
|
+
export const HushPlugin = async () => ({
|
|
23
|
+
'tool.execute.before': async (
|
|
24
|
+
input: { tool: string },
|
|
25
|
+
output: { args: Record<string, string> },
|
|
26
|
+
) => {
|
|
27
|
+
// Block sensitive file reads first (hard block — throws)
|
|
28
|
+
if (input.tool === 'read' && isSensitivePath(output.args['filePath'] ?? '')) {
|
|
29
|
+
throw new Error('[hush] Blocked: sensitive file');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (input.tool === 'bash' && commandReadsSensitiveFile(output.args['command'] ?? '')) {
|
|
33
|
+
throw new Error('[hush] Blocked: command reads sensitive file');
|
|
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
|
+
}
|
|
69
|
+
},
|
|
70
|
+
});
|