@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,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(30000), // 30s 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.status(500).json({ error: 'Gateway forwarding failed' });
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
+ });