From 1221c381decf1deb2a4a1293784d2bb210c7f9b2 Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 11:44:04 +0000 Subject: [PATCH 1/7] feat: add PostToolUse hook for PII redaction in Claude Code Adds `hush redact-hook` command that runs as a Claude Code PostToolUse hook, redacting PII from tool outputs before Claude ever sees them. Works standalone or alongside the proxy for defense-in-depth. - `hush redact-hook`: stdin/stdout hook handler using existing Redactor - `hush init --hooks`: generates/merges hook config into settings.json - CLI subcommand routing with dynamic imports (no heavy deps for hooks) - 14 new tests (redact-hook + init integration tests) - README: Hooks Mode section with setup, diagram, comparison table - Team config example updated with defense-in-depth setup Co-Authored-By: Claude Opus 4.6 --- README.md | 41 +++++++ examples/team-config/.claude/settings.json | 14 +++ src/cli.ts | 44 +++++--- src/commands/init.ts | 107 ++++++++++++++++++ src/commands/redact-hook.ts | 123 ++++++++++++++++++++ tests/init.test.ts | 101 +++++++++++++++++ tests/redact-hook.test.ts | 124 +++++++++++++++++++++ 7 files changed, 538 insertions(+), 16 deletions(-) create mode 100644 src/commands/init.ts create mode 100644 src/commands/redact-hook.ts create mode 100644 tests/init.test.ts create mode 100644 tests/redact-hook.test.ts diff --git a/README.md b/README.md index 81c7090..b2c22eb 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,47 @@ CODE_ASSIST_ENDPOINT=http://127.0.0.1:4000 Each developer just needs `hush` running locally. All AI tools in the project will route through it automatically. +## Hooks Mode (Claude Code) + +Hush can also run as a **Claude Code hook** — redacting PII from tool outputs *before Claude ever sees them*. No proxy required. + +### Setup + +```bash +hush init --hooks +``` + +This adds a `PostToolUse` hook to `.claude/settings.json` that runs `hush redact-hook` after every `Bash`, `Read`, `Grep`, and `WebFetch` tool call. + +Use `--local` to write to `settings.local.json` instead (for personal overrides not committed to the repo). + +### How it works + +``` +Local files/commands → [Hook: redact before Claude sees] → Claude's context + ↓ + API request + ↓ + [Proxy: redact before cloud] + ↓ + LLM Provider +``` + +When a tool runs (e.g., `cat .env`), the hook intercepts the output, scans for PII, and replaces it with tokens before Claude processes it. Claude only ever sees `[USER_EMAIL_f22c5a]`, not `alice@company.com`. + +### Hooks vs Proxy + +| | Hooks Mode | Proxy Mode | +|---|---|---| +| **What's protected** | Tool outputs (before Claude sees them) | API requests (before they leave your machine) | +| **Setup** | `hush init --hooks` | `hush` + point `ANTHROPIC_BASE_URL` | +| **Works with** | Claude Code only | Any AI tool | +| **Defense-in-depth** | Use both for maximum coverage | Use both for maximum coverage | + +### Defense-in-depth + +For maximum protection, use both modes together. The team config example in [`examples/team-config/`](examples/team-config/) shows this setup — hooks redact tool outputs and the proxy redacts API requests. + ## How it Works 1. **Intercept** — Hush sits on your machine between your AI tool and the LLM provider. diff --git a/examples/team-config/.claude/settings.json b/examples/team-config/.claude/settings.json index fab86a5..1333b2c 100644 --- a/examples/team-config/.claude/settings.json +++ b/examples/team-config/.claude/settings.json @@ -1,5 +1,19 @@ { "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:4000" + }, + "hooks": { + "PostToolUse": [ + { + "matcher": "Bash|Read|Grep|WebFetch", + "hooks": [ + { + "type": "command", + "command": "hush redact-hook", + "timeout": 10 + } + ] + } + ] } } diff --git a/src/cli.ts b/src/cli.ts index 3f678fb..cb3757c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,20 +1,32 @@ #!/usr/bin/env node -import { app } from './index.js'; -import { createLogger } from './lib/logger.js'; -const log = createLogger('hush-cli'); -const PORT = process.env.PORT || 4000; +const subcommand = process.argv[2]; -const server = app.listen(PORT, () => { - log.info(`Hush Semantic Gateway is listening on http://localhost:${PORT}`); - log.info(`Routes: /v1/messages → Anthropic, /v1/chat/completions → OpenAI, /api/paas/v4/** → ZhipuAI, * → Google`); -}); +if (subcommand === 'redact-hook') { + const { run } = await import('./commands/redact-hook.js'); + await run(); +} else if (subcommand === 'init') { + const { run } = await import('./commands/init.js'); + run(process.argv.slice(3)); +} else { + // Default: start the proxy server + const { app } = await import('./index.js'); + const { createLogger } = await import('./lib/logger.js'); -server.on('error', (err: NodeJS.ErrnoException) => { - if (err.code === 'EADDRINUSE') { - log.error(`Port ${PORT} is already in use. Stop the other process or use PORT= hush`); - } else { - log.error({ err }, 'Failed to start server'); - } - process.exit(1); -}); + const log = createLogger('hush-cli'); + const PORT = process.env.PORT || 4000; + + const server = app.listen(PORT, () => { + log.info(`Hush Semantic Gateway is listening on http://localhost:${PORT}`); + log.info(`Routes: /v1/messages → Anthropic, /v1/chat/completions → OpenAI, /api/paas/v4/** → ZhipuAI, * → Google`); + }); + + server.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + log.error(`Port ${PORT} is already in use. Stop the other process or use PORT= hush`); + } else { + log.error({ err }, 'Failed to start server'); + } + process.exit(1); + }); +} diff --git a/src/commands/init.ts b/src/commands/init.ts new file mode 100644 index 0000000..7ae4efb --- /dev/null +++ b/src/commands/init.ts @@ -0,0 +1,107 @@ +/** + * hush init — Generate Claude Code hook configuration + * + * Usage: + * hush init --hooks Write to .claude/settings.json + * hush init --hooks --local Write to .claude/settings.local.json + */ + +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join } from 'path'; + +const HOOK_CONFIG = { + hooks: { + PostToolUse: [ + { + matcher: 'Bash|Read|Grep|WebFetch', + hooks: [ + { + type: 'command' as const, + command: 'hush redact-hook', + timeout: 10, + }, + ], + }, + ], + }, +}; + +interface SettingsJson { + hooks?: { + PostToolUse?: Array<{ + matcher: string; + hooks: Array<{ type: string; command: string; timeout?: number }>; + }>; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +function hasHushHook(settings: SettingsJson): boolean { + const postToolUse = settings.hooks?.PostToolUse; + if (!Array.isArray(postToolUse)) return false; + + return postToolUse.some((entry) => + entry.hooks?.some((h) => h.command?.includes('hush redact-hook')), + ); +} + +function mergeHooks(existing: SettingsJson): SettingsJson { + const merged = { ...existing }; + + if (!merged.hooks) { + merged.hooks = {}; + } + + if (!Array.isArray(merged.hooks.PostToolUse)) { + merged.hooks.PostToolUse = []; + } + + merged.hooks = { ...merged.hooks, PostToolUse: [...merged.hooks.PostToolUse, ...HOOK_CONFIG.hooks.PostToolUse] }; + + return merged; +} + +export function run(args: string[]): void { + const hasHooksFlag = args.includes('--hooks'); + const isLocal = args.includes('--local'); + + if (!hasHooksFlag) { + process.stderr.write('Usage: hush init --hooks [--local]\n'); + process.stderr.write('\n'); + process.stderr.write('Options:\n'); + process.stderr.write(' --hooks Generate Claude Code PostToolUse hook config\n'); + process.stderr.write(' --local Write to settings.local.json instead of settings.json\n'); + process.exit(1); + } + + const claudeDir = join(process.cwd(), '.claude'); + const filename = isLocal ? 'settings.local.json' : 'settings.json'; + const filePath = join(claudeDir, filename); + + // Ensure .claude/ exists + if (!existsSync(claudeDir)) { + mkdirSync(claudeDir, { recursive: true }); + } + + // Read existing settings or start fresh + let settings: SettingsJson = {}; + if (existsSync(filePath)) { + try { + const raw = readFileSync(filePath, 'utf-8'); + settings = JSON.parse(raw) as SettingsJson; + } catch { + process.stderr.write(`Warning: could not parse ${filePath}, starting fresh\n`); + } + } + + // Idempotency check + if (hasHushHook(settings)) { + process.stdout.write(`hush hooks already configured in ${filePath}\n`); + return; + } + + const merged = mergeHooks(settings); + writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n'); + process.stdout.write(`Wrote hush hooks config to ${filePath}\n`); +} diff --git a/src/commands/redact-hook.ts b/src/commands/redact-hook.ts new file mode 100644 index 0000000..950fd4e --- /dev/null +++ b/src/commands/redact-hook.ts @@ -0,0 +1,123 @@ +/** + * hush redact-hook — Claude Code PostToolUse hook handler + * + * Reads the hook payload from stdin, redacts PII from tool output, + * and prints a hookSpecificOutput override if anything was redacted. + * + * Exit codes: + * 0 — success (output may or may not contain override) + * 2 — malformed input (blocks the tool call per hooks spec) + */ + +import { Redactor } from '../middleware/redactor.js'; + +interface HookPayload { + tool_name?: string; + tool_input?: Record; + tool_output?: { + // Bash tool + stdout?: string; + stderr?: string; + // Read / Grep / WebFetch tools + content?: string; + // Generic fallback + output?: string; + [key: string]: unknown; + }; +} + +interface HookResponse { + hookSpecificOutput: { + hookEventName: 'PostToolUse'; + outputOverride: string; + }; +} + +/** Collect all text from a tool_output object. */ +function extractText(toolOutput: HookPayload['tool_output']): string | null { + if (!toolOutput || typeof toolOutput !== 'object') return null; + + const parts: string[] = []; + + if (typeof toolOutput.stdout === 'string' && toolOutput.stdout) { + parts.push(toolOutput.stdout); + } + if (typeof toolOutput.stderr === 'string' && toolOutput.stderr) { + parts.push(toolOutput.stderr); + } + if (typeof toolOutput.content === 'string' && toolOutput.content) { + parts.push(toolOutput.content); + } + if (typeof toolOutput.output === 'string' && toolOutput.output) { + parts.push(toolOutput.output); + } + + return parts.length > 0 ? parts.join('\n') : null; +} + +/** Build the redacted tool_output, preserving the original shape. */ +function redactToolOutput( + toolOutput: NonNullable, + redactor: Redactor, +): { text: string; hasRedacted: boolean } { + const text = extractText(toolOutput); + if (!text) return { text: '', hasRedacted: false }; + + const { content, hasRedacted } = redactor.redact(text); + return { text: content as string, hasRedacted }; +} + +function readStdin(): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk)); + process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8'))); + process.stdin.on('error', reject); + }); +} + +export async function run(): Promise { + let raw: string; + try { + raw = await readStdin(); + } catch { + process.stderr.write('hush redact-hook: failed to read stdin\n'); + process.exit(2); + } + + if (!raw.trim()) { + // Empty stdin — nothing to redact + process.exit(0); + } + + let payload: HookPayload; + try { + payload = JSON.parse(raw) as HookPayload; + } catch { + process.stderr.write('hush redact-hook: invalid JSON on stdin\n'); + process.exit(2); + } + + if (!payload.tool_output) { + // No tool_output to redact + process.exit(0); + } + + const redactor = new Redactor(); + const { text, hasRedacted } = redactToolOutput(payload.tool_output, redactor); + + if (!hasRedacted) { + // No PII found — let Claude Code keep the original output + process.exit(0); + } + + const response: HookResponse = { + hookSpecificOutput: { + hookEventName: 'PostToolUse', + outputOverride: text, + }, + }; + + process.stdout.write(JSON.stringify(response) + '\n'); + process.exit(0); +} diff --git a/tests/init.test.ts b/tests/init.test.ts new file mode 100644 index 0000000..f035b9a --- /dev/null +++ b/tests/init.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'fs'; +import { join } from 'path'; +import { execFileSync } from 'child_process'; +import { tmpdir } from 'os'; + +const CLI = join(__dirname, '..', 'dist', 'cli.js'); + +function runInit(cwd: string, ...extraArgs: string[]): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync('node', [CLI, 'init', '--hooks', ...extraArgs], { + encoding: 'utf-8', + cwd, + timeout: 5000, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (err: any) { + return { + stdout: err.stdout ?? '', + stderr: err.stderr ?? '', + exitCode: err.status ?? 1, + }; + } +} + +describe('hush init --hooks', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'hush-init-')); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should create .claude/settings.json from scratch', () => { + const { stdout, exitCode } = runInit(tmpDir); + expect(exitCode).toBe(0); + expect(stdout).toContain('Wrote hush hooks config'); + + const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8')); + expect(settings.hooks.PostToolUse).toHaveLength(1); + expect(settings.hooks.PostToolUse[0].matcher).toBe('Bash|Read|Grep|WebFetch'); + expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook'); + }); + + it('should merge into existing settings preserving other keys', () => { + const claudeDir = join(tmpDir, '.claude'); + mkdirSync(claudeDir, { recursive: true }); + writeFileSync( + join(claudeDir, 'settings.json'), + JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'http://127.0.0.1:4000' } }, null, 2), + ); + + const { exitCode } = runInit(tmpDir); + expect(exitCode).toBe(0); + + const settings = JSON.parse(readFileSync(join(claudeDir, 'settings.json'), 'utf-8')); + // Preserved existing env + expect(settings.env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:4000'); + // Added hooks + expect(settings.hooks.PostToolUse).toHaveLength(1); + expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook'); + }); + + it('should be idempotent on re-run', () => { + runInit(tmpDir); + const { stdout, exitCode } = runInit(tmpDir); + expect(exitCode).toBe(0); + expect(stdout).toContain('already configured'); + + const settings = JSON.parse(readFileSync(join(tmpDir, '.claude', 'settings.json'), 'utf-8')); + expect(settings.hooks.PostToolUse).toHaveLength(1); // Not duplicated + }); + + it('should write to settings.local.json with --local flag', () => { + const { stdout, exitCode } = runInit(tmpDir, '--local'); + expect(exitCode).toBe(0); + expect(stdout).toContain('settings.local.json'); + + const localPath = join(tmpDir, '.claude', 'settings.local.json'); + expect(existsSync(localPath)).toBe(true); + + const settings = JSON.parse(readFileSync(localPath, 'utf-8')); + expect(settings.hooks.PostToolUse[0].hooks[0].command).toBe('hush redact-hook'); + }); + + it('should show usage without --hooks flag', () => { + try { + execFileSync('node', [CLI, 'init'], { + encoding: 'utf-8', + cwd: tmpDir, + timeout: 5000, + }); + } catch (err: any) { + expect(err.status).toBe(1); + expect(err.stderr).toContain('Usage'); + } + }); +}); diff --git a/tests/redact-hook.test.ts b/tests/redact-hook.test.ts new file mode 100644 index 0000000..9f9b951 --- /dev/null +++ b/tests/redact-hook.test.ts @@ -0,0 +1,124 @@ +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'child_process'; +import { join } from 'path'; + +/** + * Integration tests for `hush redact-hook`. + * Spawns the CLI as a child process with piped stdin, matching real hook usage. + */ +const CLI = join(__dirname, '..', 'dist', 'cli.js'); + +function runHook(input: string): { stdout: string; stderr: string; exitCode: number } { + try { + const stdout = execFileSync('node', [CLI, 'redact-hook'], { + input, + encoding: 'utf-8', + timeout: 5000, + }); + return { stdout, stderr: '', exitCode: 0 }; + } catch (err: any) { + return { + stdout: err.stdout ?? '', + stderr: err.stderr ?? '', + exitCode: err.status ?? 1, + }; + } +} + +describe('hush redact-hook', () => { + it('should redact email from Bash stdout', () => { + const payload = { + tool_name: 'Bash', + tool_output: { stdout: 'email: test@foo.com' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput.hookEventName).toBe('PostToolUse'); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.hookSpecificOutput.outputOverride).not.toContain('test@foo.com'); + }); + + it('should redact email from Read content', () => { + const payload = { + tool_name: 'Read', + tool_output: { content: 'Contact: admin@internal.corp' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.hookSpecificOutput.outputOverride).not.toContain('admin@internal.corp'); + }); + + it('should redact IP address from Bash stderr', () => { + const payload = { + tool_name: 'Bash', + tool_output: { stderr: 'connection to 192.168.1.100 failed' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + }); + + it('should pass through clean output (no PII) with no output', () => { + const payload = { + tool_name: 'Bash', + tool_output: { stdout: 'hello world' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should handle empty stdin gracefully', () => { + const { stdout, exitCode } = runHook(''); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should exit 2 for invalid JSON', () => { + const { exitCode, stderr } = runHook('not json'); + expect(exitCode).toBe(2); + expect(stderr).toContain('invalid JSON'); + }); + + it('should handle payload with no tool_output', () => { + const payload = { tool_name: 'Bash' }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + expect(stdout.trim()).toBe(''); + }); + + it('should combine stdout and stderr when both have PII', () => { + const payload = { + tool_name: 'Bash', + tool_output: { + stdout: 'user email: alice@example.com', + stderr: 'warning: 10.0.0.1 unreachable', + }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + }); + + it('should redact secrets from tool output', () => { + const payload = { + tool_name: 'Bash', + tool_output: { stdout: 'api_key=sk-1234567890abcdef1234' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.hookSpecificOutput.outputOverride).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/); + }); +}); From 9a5dc219617af95d99b85ffffd4eb7699dd2507e Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 12:00:06 +0000 Subject: [PATCH 2/7] fix: align redact-hook with Claude Code hooks spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use `tool_response` field (not `tool_output`) matching actual payload - Use `decision: "block"` + `reason` output format (PostToolUse has no outputOverride — confirmed via spec and closed GitHub issues #4635, #18594) - Handle Read tool's nested `file.content` response shape - Add Grep content field test case (10 tests total) Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- src/commands/redact-hook.ts | 65 +++++++++++++++++++------------------ tests/redact-hook.test.ts | 54 ++++++++++++++++++++---------- 3 files changed, 70 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index b2c22eb..03730d3 100644 --- a/README.md +++ b/README.md @@ -153,7 +153,7 @@ Local files/commands → [Hook: redact before Claude sees] → Claude's context LLM Provider ``` -When a tool runs (e.g., `cat .env`), the hook intercepts the output, scans for PII, and replaces it with tokens before Claude processes it. Claude only ever sees `[USER_EMAIL_f22c5a]`, not `alice@company.com`. +When a tool runs (e.g., `cat .env`), the hook inspects the response for PII. If PII is found, the hook **blocks** the raw output and provides Claude with the redacted version instead. Claude only ever sees `[USER_EMAIL_f22c5a]`, not `alice@company.com`. ### Hooks vs Proxy diff --git a/src/commands/redact-hook.ts b/src/commands/redact-hook.ts index 950fd4e..c0e2d93 100644 --- a/src/commands/redact-hook.ts +++ b/src/commands/redact-hook.ts @@ -1,11 +1,11 @@ /** * hush redact-hook — Claude Code PostToolUse hook handler * - * Reads the hook payload from stdin, redacts PII from tool output, - * and prints a hookSpecificOutput override if anything was redacted. + * Reads the hook payload from stdin, redacts PII from the tool response, + * and blocks the output (replacing it with redacted text) if PII was found. * * Exit codes: - * 0 — success (output may or may not contain override) + * 0 — success (may or may not block) * 2 — malformed input (blocks the tool call per hooks spec) */ @@ -14,53 +14,56 @@ import { Redactor } from '../middleware/redactor.js'; interface HookPayload { tool_name?: string; tool_input?: Record; - tool_output?: { + tool_response?: { // Bash tool stdout?: string; stderr?: string; - // Read / Grep / WebFetch tools + // Read tool (nested under file) + file?: { content?: string; [key: string]: unknown }; + // Grep / WebFetch / generic content?: string; - // Generic fallback output?: string; [key: string]: unknown; }; } interface HookResponse { - hookSpecificOutput: { - hookEventName: 'PostToolUse'; - outputOverride: string; - }; + decision: 'block'; + reason: string; } -/** Collect all text from a tool_output object. */ -function extractText(toolOutput: HookPayload['tool_output']): string | null { - if (!toolOutput || typeof toolOutput !== 'object') return null; +/** Collect all text from a tool_response object. */ +function extractText(toolResponse: HookPayload['tool_response']): string | null { + if (!toolResponse || typeof toolResponse !== 'object') return null; const parts: string[] = []; - if (typeof toolOutput.stdout === 'string' && toolOutput.stdout) { - parts.push(toolOutput.stdout); + if (typeof toolResponse.stdout === 'string' && toolResponse.stdout) { + parts.push(toolResponse.stdout); + } + if (typeof toolResponse.stderr === 'string' && toolResponse.stderr) { + parts.push(toolResponse.stderr); } - if (typeof toolOutput.stderr === 'string' && toolOutput.stderr) { - parts.push(toolOutput.stderr); + // Read tool nests content under file.content + if (toolResponse.file && typeof toolResponse.file.content === 'string' && toolResponse.file.content) { + parts.push(toolResponse.file.content); } - if (typeof toolOutput.content === 'string' && toolOutput.content) { - parts.push(toolOutput.content); + if (typeof toolResponse.content === 'string' && toolResponse.content) { + parts.push(toolResponse.content); } - if (typeof toolOutput.output === 'string' && toolOutput.output) { - parts.push(toolOutput.output); + if (typeof toolResponse.output === 'string' && toolResponse.output) { + parts.push(toolResponse.output); } return parts.length > 0 ? parts.join('\n') : null; } -/** Build the redacted tool_output, preserving the original shape. */ -function redactToolOutput( - toolOutput: NonNullable, +/** Redact PII from the tool response text. */ +function redactToolResponse( + toolResponse: NonNullable, redactor: Redactor, ): { text: string; hasRedacted: boolean } { - const text = extractText(toolOutput); + const text = extractText(toolResponse); if (!text) return { text: '', hasRedacted: false }; const { content, hasRedacted } = redactor.redact(text); @@ -98,13 +101,13 @@ export async function run(): Promise { process.exit(2); } - if (!payload.tool_output) { - // No tool_output to redact + if (!payload.tool_response) { + // No tool_response to redact process.exit(0); } const redactor = new Redactor(); - const { text, hasRedacted } = redactToolOutput(payload.tool_output, redactor); + const { text, hasRedacted } = redactToolResponse(payload.tool_response, redactor); if (!hasRedacted) { // No PII found — let Claude Code keep the original output @@ -112,10 +115,8 @@ export async function run(): Promise { } const response: HookResponse = { - hookSpecificOutput: { - hookEventName: 'PostToolUse', - outputOverride: text, - }, + decision: 'block', + reason: text, }; process.stdout.write(JSON.stringify(response) + '\n'); diff --git a/tests/redact-hook.test.ts b/tests/redact-hook.test.ts index 9f9b951..7ea5aa2 100644 --- a/tests/redact-hook.test.ts +++ b/tests/redact-hook.test.ts @@ -29,46 +29,48 @@ describe('hush redact-hook', () => { it('should redact email from Bash stdout', () => { const payload = { tool_name: 'Bash', - tool_output: { stdout: 'email: test@foo.com' }, + tool_response: { stdout: 'email: test@foo.com' }, }; const { stdout, exitCode } = runHook(JSON.stringify(payload)); expect(exitCode).toBe(0); const result = JSON.parse(stdout); - expect(result.hookSpecificOutput.hookEventName).toBe('PostToolUse'); - expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); - expect(result.hookSpecificOutput.outputOverride).not.toContain('test@foo.com'); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).not.toContain('test@foo.com'); }); - it('should redact email from Read content', () => { + it('should redact email from Read file.content', () => { const payload = { tool_name: 'Read', - tool_output: { content: 'Contact: admin@internal.corp' }, + tool_response: { file: { content: 'Contact: admin@internal.corp', filePath: '/app/config.json' } }, }; const { stdout, exitCode } = runHook(JSON.stringify(payload)); expect(exitCode).toBe(0); const result = JSON.parse(stdout); - expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); - expect(result.hookSpecificOutput.outputOverride).not.toContain('admin@internal.corp'); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).not.toContain('admin@internal.corp'); }); it('should redact IP address from Bash stderr', () => { const payload = { tool_name: 'Bash', - tool_output: { stderr: 'connection to 192.168.1.100 failed' }, + tool_response: { stderr: 'connection to 192.168.1.100 failed' }, }; const { stdout, exitCode } = runHook(JSON.stringify(payload)); expect(exitCode).toBe(0); const result = JSON.parse(stdout); - expect(result.hookSpecificOutput.outputOverride).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); }); it('should pass through clean output (no PII) with no output', () => { const payload = { tool_name: 'Bash', - tool_output: { stdout: 'hello world' }, + tool_response: { stdout: 'hello world' }, }; const { stdout, exitCode } = runHook(JSON.stringify(payload)); expect(exitCode).toBe(0); @@ -87,7 +89,7 @@ describe('hush redact-hook', () => { expect(stderr).toContain('invalid JSON'); }); - it('should handle payload with no tool_output', () => { + it('should handle payload with no tool_response', () => { const payload = { tool_name: 'Bash' }; const { stdout, exitCode } = runHook(JSON.stringify(payload)); expect(exitCode).toBe(0); @@ -97,7 +99,7 @@ describe('hush redact-hook', () => { it('should combine stdout and stderr when both have PII', () => { const payload = { tool_name: 'Bash', - tool_output: { + tool_response: { stdout: 'user email: alice@example.com', stderr: 'warning: 10.0.0.1 unreachable', }, @@ -106,19 +108,35 @@ describe('hush redact-hook', () => { expect(exitCode).toBe(0); const result = JSON.parse(stdout); - expect(result.hookSpecificOutput.outputOverride).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); - expect(result.hookSpecificOutput.outputOverride).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).toMatch(/\[NETWORK_IP_[a-f0-9]{6}\]/); }); - it('should redact secrets from tool output', () => { + it('should redact secrets from tool response', () => { const payload = { tool_name: 'Bash', - tool_output: { stdout: 'api_key=sk-1234567890abcdef1234' }, + tool_response: { stdout: 'api_key=sk-1234567890abcdef1234' }, }; const { stdout, exitCode } = runHook(JSON.stringify(payload)); expect(exitCode).toBe(0); const result = JSON.parse(stdout); - expect(result.hookSpecificOutput.outputOverride).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[SENSITIVE_SECRET_[a-f0-9]{6}\]/); + }); + + it('should handle Grep tool with top-level content field', () => { + const payload = { + tool_name: 'Grep', + tool_response: { content: 'src/config.ts:3: email: "dev@internal.corp"' }, + }; + const { stdout, exitCode } = runHook(JSON.stringify(payload)); + expect(exitCode).toBe(0); + + const result = JSON.parse(stdout); + expect(result.decision).toBe('block'); + expect(result.reason).toMatch(/\[USER_EMAIL_[a-f0-9]{6}\]/); + expect(result.reason).not.toContain('dev@internal.corp'); }); }); From 14ad65d1839cf1fcc0e94754230ea686c114015c Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 12:25:11 +0000 Subject: [PATCH 3/7] feat: add OpenCode hush plugin and GitLab E2E pipeline Add tool.execute.before plugin for OpenCode that blocks reads of sensitive files (.env, *.pem, credentials.*, id_rsa, etc.) before the AI model sees them. Includes GitLab CI pipeline with two E2E scenarios: plugin blocks .env read, and proxy redacts PII in normal files. Co-Authored-By: Claude Opus 4.6 --- .gitlab-ci.yml | 40 ++++ README.md | 52 +++++ .../team-config/.opencode/plugins/hush.ts | 61 ++++++ examples/team-config/opencode.json | 3 +- package.json | 10 + scripts/e2e-plugin-block.sh | 142 ++++++++++++++ scripts/e2e-proxy-live.sh | 185 ++++++++++++++++++ src/plugins/opencode-hush.ts | 30 +++ src/plugins/sensitive-patterns.ts | 52 +++++ tests/opencode-plugin.test.ts | 133 +++++++++++++ 10 files changed, 707 insertions(+), 1 deletion(-) create mode 100644 .gitlab-ci.yml create mode 100644 examples/team-config/.opencode/plugins/hush.ts create mode 100755 scripts/e2e-plugin-block.sh create mode 100755 scripts/e2e-proxy-live.sh create mode 100644 src/plugins/opencode-hush.ts create mode 100644 src/plugins/sensitive-patterns.ts create mode 100644 tests/opencode-plugin.test.ts diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..56a509d --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,40 @@ +stages: + - build + - e2e + +build: + stage: build + image: node:22-slim + script: + - npm ci + - npm run build + - npm test + artifacts: + paths: + - dist/ + - node_modules/ + expire_in: 1 hour + +e2e-plugin-blocks-env: + stage: e2e + image: node:22-slim + needs: [build] + before_script: + - npm install -g opencode + script: + - chmod +x scripts/e2e-plugin-block.sh + - ./scripts/e2e-plugin-block.sh + variables: + ZHIPUAI_API_KEY: $ZHIPUAI_API_KEY + +e2e-proxy-redacts-pii: + stage: e2e + image: node:22-slim + needs: [build] + before_script: + - npm install -g opencode + script: + - chmod +x scripts/e2e-proxy-live.sh + - ./scripts/e2e-proxy-live.sh + variables: + ZHIPUAI_API_KEY: $ZHIPUAI_API_KEY diff --git a/README.md b/README.md index 03730d3..fc970fc 100644 --- a/README.md +++ b/README.md @@ -168,6 +168,58 @@ When a tool runs (e.g., `cat .env`), the hook inspects the response for PII. If For maximum protection, use both modes together. The team config example in [`examples/team-config/`](examples/team-config/) shows this setup — hooks redact tool outputs and the proxy redacts API requests. +## OpenCode Plugin + +Hush provides an **OpenCode plugin** that blocks reads of sensitive files (`.env`, `*.pem`, `credentials.*`, `id_rsa`, etc.) before the tool executes — the AI model never sees the contents. + +### Drop-in setup + +Copy the plugin file and update your `opencode.json`: + +``` +your-project/ +├── .opencode/plugins/hush.ts # plugin file +└── opencode.json # add "plugin" array +``` + +```json +{ + "provider": { + "zai-coding-plan": { + "options": { + "baseURL": "http://127.0.0.1:4000/api/coding/paas/v4" + } + } + }, + "plugin": [".opencode/plugins/hush.ts"] +} +``` + +Find the drop-in plugin at [`examples/team-config/.opencode/plugins/hush.ts`](examples/team-config/.opencode/plugins/hush.ts). + +### npm import + +```typescript +import { HushPlugin } from '@aictrl/hush/opencode-plugin' +``` + +### What it blocks + +| Tool | Blocked when | +|------|-------------| +| `read` | File path matches `.env*`, `*credentials*`, `*secret*`, `*.pem`, `*.key`, `id_rsa*`, `.netrc`, `.pgpass` | +| `bash` | Commands like `cat`, `head`, `tail`, `less`, `more`, `bat` target a sensitive file | + +### Plugin + Proxy = Defense-in-depth + +The plugin blocks reads of known-sensitive filenames. The proxy catches PII in files with normal names (e.g., `config.txt` containing an email). Together they provide two layers of protection: + +``` +Tool reads .env → [Plugin: BLOCKED] → model never sees it +Tool reads config.txt → [Plugin: allowed] → proxy redacts PII → model sees tokens + (not a sensitive filename) +``` + ## How it Works 1. **Intercept** — Hush sits on your machine between your AI tool and the LLM provider. diff --git a/examples/team-config/.opencode/plugins/hush.ts b/examples/team-config/.opencode/plugins/hush.ts new file mode 100644 index 0000000..fb2bc35 --- /dev/null +++ b/examples/team-config/.opencode/plugins/hush.ts @@ -0,0 +1,61 @@ +/** + * Hush PII Guard — OpenCode Plugin (drop-in copy) + * + * Blocks reads of sensitive files (.env, *.pem, credentials.*, etc.) + * before the tool executes — the AI model never sees the content. + * + * Usage: copy this file to `.opencode/plugins/hush.ts` in your project + * and add to `opencode.json`: + * { "plugin": [".opencode/plugins/hush.ts"] } + * + * Or install from npm: + * import { HushPlugin } from '@aictrl/hush/opencode-plugin' + */ + +const SENSITIVE_GLOBS = [ + /^\.env($|\..*)/, // .env, .env.local, .env.production, etc. + /credentials/i, + /secret/i, + /\.pem$/, + /\.key$/, + /^id_rsa/, + /^\.netrc$/, + /^\.pgpass$/, +]; + +function isSensitivePath(filePath: string): boolean { + const basename = filePath.split('/').pop() ?? ''; + return SENSITIVE_GLOBS.some((re) => re.test(basename)); +} + +const READ_COMMANDS = /\b(cat|head|tail|less|more|bat)\b/; + +function commandReadsSensitiveFile(cmd: string): boolean { + if (!READ_COMMANDS.test(cmd)) return false; + const parts = cmd.split(/[|;&]+/); + for (const part of parts) { + const tokens = part.trim().split(/\s+/); + const cmdIndex = tokens.findIndex((t) => READ_COMMANDS.test(t)); + if (cmdIndex === -1) continue; + for (let i = cmdIndex + 1; i < tokens.length; i++) { + const token = tokens[i]!; + if (token.startsWith('-')) continue; + if (isSensitivePath(token)) return true; + } + } + return false; +} + +export const HushPlugin = async () => ({ + 'tool.execute.before': async ( + input: { tool: string }, + output: { args: Record }, + ) => { + if (input.tool === 'read' && isSensitivePath(output.args['filePath'] ?? '')) { + throw new Error('[hush] Blocked: sensitive file'); + } + if (input.tool === 'bash' && commandReadsSensitiveFile(output.args['command'] ?? '')) { + throw new Error('[hush] Blocked: command reads sensitive file'); + } + }, +}); diff --git a/examples/team-config/opencode.json b/examples/team-config/opencode.json index 4776ecd..059a077 100644 --- a/examples/team-config/opencode.json +++ b/examples/team-config/opencode.json @@ -5,5 +5,6 @@ "baseURL": "http://127.0.0.1:4000/api/coding/paas/v4" } } - } + }, + "plugin": [".opencode/plugins/hush.ts"] } diff --git a/package.json b/package.json index 5c8f3de..42411b0 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,16 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./opencode-plugin": { + "import": "./dist/plugins/opencode-hush.js", + "types": "./dist/plugins/opencode-hush.d.ts" + } + }, "bin": { "hush": "dist/cli.js" }, diff --git a/scripts/e2e-plugin-block.sh b/scripts/e2e-plugin-block.sh new file mode 100755 index 0000000..aebee09 --- /dev/null +++ b/scripts/e2e-plugin-block.sh @@ -0,0 +1,142 @@ +#!/usr/bin/env bash +# +# E2E Scenario A: OpenCode hush plugin blocks .env read +# +# Verifies that the hush plugin's tool.execute.before hook prevents +# the AI model from ever reading sensitive files. The model should +# receive a "blocked" error instead of the file contents. +# +# Usage: ./scripts/e2e-plugin-block.sh +# Requirements: opencode CLI, node + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +PASS_COUNT=0 +FAIL_COUNT=0 +TMPDIR="" + +cleanup() { + echo "" + echo -e "${CYAN}Cleaning up...${NC}" + [ -n "$TMPDIR" ] && rm -rf "$TMPDIR" +} +trap cleanup EXIT + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + echo -e " ${GREEN}PASS${NC} $1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + echo -e " ${RED}FAIL${NC} $1" +} + +assert_contains() { + local haystack="$1" needle="$2" msg="$3" + if echo "$haystack" | grep -qiF "$needle"; then + pass "$msg" + else + fail "$msg (expected to find '$needle')" + fi +} + +assert_not_contains() { + local haystack="$1" needle="$2" msg="$3" + if echo "$haystack" | grep -qiF "$needle"; then + fail "$msg (found '$needle' which should have been blocked)" + else + pass "$msg" + fi +} + +echo -e "${CYAN}================================================${NC}" +echo -e "${CYAN} E2E Scenario A: Plugin Blocks .env Read ${NC}" +echo -e "${CYAN}================================================${NC}" +echo "" + +# --- Step 1: Create temp project with .env and hush plugin --- +echo -e "${YELLOW}[1/4] Creating temp project with .env and hush plugin...${NC}" + +TMPDIR=$(mktemp -d) +mkdir -p "$TMPDIR/.opencode/plugins" + +# Sensitive .env file with PII +cat > "$TMPDIR/.env" <<'ENVEOF' +DATABASE_URL=postgres://admin:supersecret@10.42.99.7:5432/prod +API_KEY=sk-live-a1b2c3d4e5f6g7h8i9j0k1l2m3n4 +ADMIN_EMAIL=alice@confidential-corp.com +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +ENVEOF + +# Copy the drop-in plugin +cp "$PROJECT_DIR/examples/team-config/.opencode/plugins/hush.ts" \ + "$TMPDIR/.opencode/plugins/hush.ts" + +# opencode.json — point at real provider + enable plugin +cat > "$TMPDIR/opencode.json" <&1) || true +echo -e " Output length: $(echo "$OUTPUT" | wc -c) bytes" + +# --- Step 3: Verify plugin blocked the read --- +echo "" +echo -e "${YELLOW}[3/4] Verifying plugin blocked .env read...${NC}" +echo "" + +# The output should mention blocking/error, not contain the actual PII +assert_contains "$OUTPUT" "block" "Output mentions blocking" + +# --- Step 4: Verify PII never appears in output --- +echo "" +echo -e "${YELLOW}[4/4] Verifying PII never appears in output...${NC}" +echo "" + +assert_not_contains "$OUTPUT" "alice@confidential-corp.com" "Email not in output" +assert_not_contains "$OUTPUT" "sk-live-a1b2c3d4e5f6g7h8i9j0k1l2m3n4" "API key not in output" +assert_not_contains "$OUTPUT" "supersecret" "DB password not in output" +assert_not_contains "$OUTPUT" "wJalrXUtnFEMI" "AWS secret not in output" + +# --- Summary --- +echo "" +echo -e "${CYAN}================================================${NC}" +TOTAL=$((PASS_COUNT + FAIL_COUNT)) +if [ "$FAIL_COUNT" -eq 0 ]; then + echo -e "${GREEN} ALL ${TOTAL} CHECKS PASSED${NC}" + echo "" + echo -e " ${GREEN}Plugin blocked .env read — PII never reached the model.${NC}" +else + echo -e "${RED} ${FAIL_COUNT}/${TOTAL} CHECKS FAILED${NC}" +fi +echo -e "${CYAN}================================================${NC}" + +exit "$FAIL_COUNT" diff --git a/scripts/e2e-proxy-live.sh b/scripts/e2e-proxy-live.sh new file mode 100755 index 0000000..27cc511 --- /dev/null +++ b/scripts/e2e-proxy-live.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# +# E2E Scenario B: Proxy redacts PII from normal file reads +# +# A non-sensitive filename (config.txt) containing PII gets through the +# plugin's filename check. The hush proxy intercepts the API request and +# redacts PII before it reaches the LLM provider. +# +# Usage: ./scripts/e2e-proxy-live.sh +# Requirements: opencode CLI, node, npm (dependencies installed + built) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +GATEWAY_PORT=4000 +GATEWAY_PID="" +PASS_COUNT=0 +FAIL_COUNT=0 +TMPDIR="" + +cleanup() { + echo "" + echo -e "${CYAN}Cleaning up...${NC}" + [ -n "$GATEWAY_PID" ] && kill "$GATEWAY_PID" 2>/dev/null || true + [ -n "$TMPDIR" ] && rm -rf "$TMPDIR" + wait 2>/dev/null || true +} +trap cleanup EXIT + +pass() { + PASS_COUNT=$((PASS_COUNT + 1)) + echo -e " ${GREEN}PASS${NC} $1" +} + +fail() { + FAIL_COUNT=$((FAIL_COUNT + 1)) + echo -e " ${RED}FAIL${NC} $1" +} + +assert_contains() { + local haystack="$1" needle="$2" msg="$3" + if echo "$haystack" | grep -qiF "$needle"; then + pass "$msg" + else + fail "$msg (expected to find '$needle')" + fi +} + +assert_not_contains() { + local haystack="$1" needle="$2" msg="$3" + if echo "$haystack" | grep -qiF "$needle"; then + fail "$msg (found '$needle' which should have been redacted)" + else + pass "$msg" + fi +} + +wait_for_port() { + local port=$1 label=$2 max_attempts=${3:-20} + for i in $(seq 1 "$max_attempts"); do + if curl -sf "http://127.0.0.1:${port}/health" > /dev/null 2>&1; then + return 0 + fi + sleep 0.5 + done + echo -e "${RED}${label} failed to start on :${port}${NC}" + return 1 +} + +echo -e "${CYAN}================================================${NC}" +echo -e "${CYAN} E2E Scenario B: Proxy Redacts PII in Normal ${NC}" +echo -e "${CYAN} File (Plugin Allows, Proxy Catches) ${NC}" +echo -e "${CYAN}================================================${NC}" +echo "" + +cd "$PROJECT_DIR" + +# --- Step 1: Start Hush gateway --- +echo -e "${YELLOW}[1/5] Starting Hush gateway on :${GATEWAY_PORT}...${NC}" + +DEBUG=true node dist/cli.js > /tmp/hush-e2e-proxy.log 2>&1 & +GATEWAY_PID=$! + +wait_for_port "$GATEWAY_PORT" "Gateway" || exit 1 +echo -e " Gateway PID: ${GATEWAY_PID}" + +# --- Step 2: Create temp project with config.txt containing PII --- +echo -e "${YELLOW}[2/5] Creating temp project with config.txt (PII in normal file)...${NC}" + +TMPDIR=$(mktemp -d) +mkdir -p "$TMPDIR/.opencode/plugins" + +# Normal filename — plugin won't block this +cat > "$TMPDIR/config.txt" <<'CFGEOF' +# Application Configuration +app_name: MyApp +admin_contact: alice@confidential-corp.com +server_ip: 10.42.99.7 +api_key=sk-live-a1b2c3d4e5f6g7h8i9j0k1l2m3n4 +log_level: info +CFGEOF + +# Copy the hush plugin (it won't block config.txt — not a sensitive filename) +cp "$PROJECT_DIR/examples/team-config/.opencode/plugins/hush.ts" \ + "$TMPDIR/.opencode/plugins/hush.ts" + +# Point OpenCode at hush proxy +cat > "$TMPDIR/opencode.json" </dev/null || echo "0") +echo -e " Vault size before: ${VAULT_BEFORE}" + +# --- Step 4: Run OpenCode to read config.txt --- +echo -e "${YELLOW}[4/5] Running OpenCode: 'read config.txt and summarize it'...${NC}" + +cd "$TMPDIR" +OUTPUT=$(opencode -p "read config.txt and summarize it" -q -f json 2>&1) || true +echo -e " Output length: $(echo "$OUTPUT" | wc -c) bytes" + +# --- Step 5: Verify proxy redacted PII --- +echo "" +echo -e "${YELLOW}[5/5] Verifying proxy intercepted PII...${NC}" +echo "" + +# Check vault has tokens +HEALTH_AFTER=$(curl -sf "http://127.0.0.1:${GATEWAY_PORT}/health") +VAULT_AFTER=$(echo "$HEALTH_AFTER" | python3 -c "import sys, json; print(json.load(sys.stdin).get('vaultSize', 0))" 2>/dev/null || echo "0") +echo -e " Vault size after: ${VAULT_AFTER}" + +if [ "$VAULT_AFTER" -gt 0 ]; then + pass "Vault contains ${VAULT_AFTER} token(s) — PII was intercepted by proxy" +else + fail "Vault is empty (expected > 0 tokens)" +fi + +# Check gateway logs for redaction +GATEWAY_LOG=$(cat /tmp/hush-e2e-proxy.log 2>/dev/null || echo "") +if echo "$GATEWAY_LOG" | grep -qi "redact"; then + pass "Gateway logs show redaction activity" +else + fail "Gateway logs don't show redaction (may not be an error if log format changed)" +fi + +# --- Summary --- +echo "" +echo -e "${CYAN}================================================${NC}" +TOTAL=$((PASS_COUNT + FAIL_COUNT)) +if [ "$FAIL_COUNT" -eq 0 ]; then + echo -e "${GREEN} ALL ${TOTAL} CHECKS PASSED${NC}" + echo "" + echo -e " ${GREEN}Plugin allowed config.txt (not a sensitive filename).${NC}" + echo -e " ${GREEN}Proxy caught PII in the API request and redacted it.${NC}" + echo -e " ${GREEN}Defense-in-depth: plugin + proxy working together.${NC}" +else + echo -e "${RED} ${FAIL_COUNT}/${TOTAL} CHECKS FAILED${NC}" +fi +echo -e "${CYAN}================================================${NC}" + +exit "$FAIL_COUNT" diff --git a/src/plugins/opencode-hush.ts b/src/plugins/opencode-hush.ts new file mode 100644 index 0000000..8e99471 --- /dev/null +++ b/src/plugins/opencode-hush.ts @@ -0,0 +1,30 @@ +/** + * OpenCode Plugin: Hush PII Guard + * + * Blocks reads of sensitive files (`.env`, `*.pem`, `credentials.*`, etc.) + * before the tool executes — the AI model never sees the content. + * + * Defense-in-depth: works alongside the Hush proxy which redacts PII from + * API requests. The plugin prevents file reads; the proxy catches anything + * that slips through in normal files. + * + * Install: copy to `.opencode/plugins/hush.ts` and add to `opencode.json`: + * { "plugin": [".opencode/plugins/hush.ts"] } + */ + +import { isSensitivePath, commandReadsSensitiveFile } from './sensitive-patterns.js'; + +export const HushPlugin = async () => ({ + 'tool.execute.before': async ( + input: { tool: string }, + output: { args: Record }, + ) => { + if (input.tool === 'read' && isSensitivePath(output.args['filePath'] ?? '')) { + throw new Error('[hush] Blocked: sensitive file'); + } + + if (input.tool === 'bash' && commandReadsSensitiveFile(output.args['command'] ?? '')) { + throw new Error('[hush] Blocked: command reads sensitive file'); + } + }, +}); diff --git a/src/plugins/sensitive-patterns.ts b/src/plugins/sensitive-patterns.ts new file mode 100644 index 0000000..920849b --- /dev/null +++ b/src/plugins/sensitive-patterns.ts @@ -0,0 +1,52 @@ +/** + * Shared helpers for detecting sensitive file paths and commands. + * Used by the OpenCode hush plugin to block reads of secret files. + */ + +/** Glob-style patterns for files that should never be read by AI tools. */ +const SENSITIVE_GLOBS = [ + /^\.env($|\..*)/, // .env, .env.local, .env.production, etc. + /credentials/i, + /secret/i, + /\.pem$/, + /\.key$/, + /^id_rsa/, + /^\.netrc$/, + /^\.pgpass$/, +]; + +/** + * Check whether a file path points to a sensitive file. + * Matches against the basename only so absolute/relative paths both work. + */ +export function isSensitivePath(filePath: string): boolean { + const basename = filePath.split('/').pop() ?? ''; + return SENSITIVE_GLOBS.some((re) => re.test(basename)); +} + +/** Commands that read file contents. */ +const READ_COMMANDS = /\b(cat|head|tail|less|more|bat)\b/; + +/** + * Check whether a bash command reads a sensitive file. + * Looks for common read commands followed by a sensitive filename. + */ +export function commandReadsSensitiveFile(cmd: string): boolean { + if (!READ_COMMANDS.test(cmd)) return false; + + // Split on pipes/semicolons/&& to get individual commands + const parts = cmd.split(/[|;&]+/); + for (const part of parts) { + const tokens = part.trim().split(/\s+/); + const cmdIndex = tokens.findIndex((t) => READ_COMMANDS.test(t)); + if (cmdIndex === -1) continue; + + // Check all tokens after the command for sensitive paths (skip flags) + for (let i = cmdIndex + 1; i < tokens.length; i++) { + const token = tokens[i]!; + if (token.startsWith('-')) continue; // skip flags like -n, -5 + if (isSensitivePath(token)) return true; + } + } + return false; +} diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts new file mode 100644 index 0000000..0218f17 --- /dev/null +++ b/tests/opencode-plugin.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from 'vitest'; +import { isSensitivePath, commandReadsSensitiveFile } from '../src/plugins/sensitive-patterns.js'; +import { HushPlugin } from '../src/plugins/opencode-hush.js'; + +describe('isSensitivePath', () => { + it.each([ + '.env', + '.env.local', + '.env.production', + '.env.development.local', + 'credentials.json', + 'credentials.yaml', + 'db-credentials', + 'secret.txt', + 'secrets.yaml', + 'server.pem', + 'tls.key', + 'id_rsa', + 'id_rsa.pub', + '.netrc', + '.pgpass', + ])('blocks %s', (path) => { + expect(isSensitivePath(path)).toBe(true); + }); + + it.each([ + '.env', + '/home/user/project/.env.local', + '/etc/ssl/private/server.key', + 'config/credentials.json', + ])('blocks absolute/relative path %s', (path) => { + expect(isSensitivePath(path)).toBe(true); + }); + + it.each([ + 'package.json', + 'src/index.ts', + 'README.md', + 'tsconfig.json', + '.gitignore', + 'environment.ts', + 'docker-compose.yml', + ])('allows %s', (path) => { + expect(isSensitivePath(path)).toBe(false); + }); +}); + +describe('commandReadsSensitiveFile', () => { + it.each([ + 'cat .env', + 'cat /app/.env.local', + 'head -5 secrets.yaml', + 'tail -n 20 credentials.json', + 'less .env.production', + 'more secret.txt', + 'bat id_rsa', + 'cat .pgpass', + 'cat foo.txt && cat .env', + 'echo hello | cat .env', + ])('blocks: %s', (cmd) => { + expect(commandReadsSensitiveFile(cmd)).toBe(true); + }); + + it.each([ + 'cat README.md', + 'ls -la', + 'echo "hello"', + 'grep password src/config.ts', + 'head -5 package.json', + 'cat src/index.ts', + 'npm install', + 'node dist/cli.js', + ])('allows: %s', (cmd) => { + expect(commandReadsSensitiveFile(cmd)).toBe(false); + }); +}); + +describe('HushPlugin integration', () => { + it('exports a factory that returns a tool.execute.before hook', async () => { + const plugin = await HushPlugin(); + expect(plugin['tool.execute.before']).toBeTypeOf('function'); + }); + + it('throws when read targets a sensitive file', async () => { + const plugin = await HushPlugin(); + await expect( + plugin['tool.execute.before']( + { tool: 'read' }, + { args: { filePath: '/project/.env' } }, + ), + ).rejects.toThrow('[hush] Blocked: sensitive file'); + }); + + it('passes when read targets a normal file', async () => { + const plugin = await HushPlugin(); + await expect( + plugin['tool.execute.before']( + { tool: 'read' }, + { args: { filePath: 'src/index.ts' } }, + ), + ).resolves.toBeUndefined(); + }); + + it('throws when bash command reads a sensitive file', async () => { + const plugin = await HushPlugin(); + await expect( + plugin['tool.execute.before']( + { tool: 'bash' }, + { args: { command: 'cat .env' } }, + ), + ).rejects.toThrow('[hush] Blocked: command reads sensitive file'); + }); + + it('passes when bash command is harmless', async () => { + const plugin = await HushPlugin(); + await expect( + plugin['tool.execute.before']( + { tool: 'bash' }, + { args: { command: 'ls -la' } }, + ), + ).resolves.toBeUndefined(); + }); + + it('passes for unrelated tools', async () => { + const plugin = await HushPlugin(); + await expect( + plugin['tool.execute.before']( + { tool: 'write' }, + { args: { filePath: '.env', content: 'x' } }, + ), + ).resolves.toBeUndefined(); + }); +}); From b2d167f78da845c0355c109e73ec90e50a06cd3e Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 12:40:46 +0000 Subject: [PATCH 4/7] fix: add PKCS#12, PFX, JKS, keystore, and PGP key patterns to sensitive file detection Co-Authored-By: Claude Opus 4.6 --- README.md | 2 +- examples/team-config/.opencode/plugins/hush.ts | 5 +++++ src/plugins/sensitive-patterns.ts | 5 +++++ tests/opencode-plugin.test.ts | 5 +++++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fc970fc..5d79599 100644 --- a/README.md +++ b/README.md @@ -207,7 +207,7 @@ import { HushPlugin } from '@aictrl/hush/opencode-plugin' | Tool | Blocked when | |------|-------------| -| `read` | File path matches `.env*`, `*credentials*`, `*secret*`, `*.pem`, `*.key`, `id_rsa*`, `.netrc`, `.pgpass` | +| `read` | File path matches `.env*`, `*credentials*`, `*secret*`, `*.pem`, `*.key`, `*.p12`, `*.pfx`, `*.jks`, `*.keystore`, `*.asc`, `id_rsa*`, `.netrc`, `.pgpass` | | `bash` | Commands like `cat`, `head`, `tail`, `less`, `more`, `bat` target a sensitive file | ### Plugin + Proxy = Defense-in-depth diff --git a/examples/team-config/.opencode/plugins/hush.ts b/examples/team-config/.opencode/plugins/hush.ts index fb2bc35..a723da5 100644 --- a/examples/team-config/.opencode/plugins/hush.ts +++ b/examples/team-config/.opencode/plugins/hush.ts @@ -18,6 +18,11 @@ const SENSITIVE_GLOBS = [ /secret/i, /\.pem$/, /\.key$/, + /\.p12$/, + /\.pfx$/, + /\.jks$/, + /\.keystore$/, + /\.asc$/, /^id_rsa/, /^\.netrc$/, /^\.pgpass$/, diff --git a/src/plugins/sensitive-patterns.ts b/src/plugins/sensitive-patterns.ts index 920849b..c3cfe6a 100644 --- a/src/plugins/sensitive-patterns.ts +++ b/src/plugins/sensitive-patterns.ts @@ -10,6 +10,11 @@ const SENSITIVE_GLOBS = [ /secret/i, /\.pem$/, /\.key$/, + /\.p12$/, + /\.pfx$/, + /\.jks$/, + /\.keystore$/, + /\.asc$/, /^id_rsa/, /^\.netrc$/, /^\.pgpass$/, diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index 0218f17..b59bf4d 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -19,6 +19,11 @@ describe('isSensitivePath', () => { 'id_rsa.pub', '.netrc', '.pgpass', + 'keystore.p12', + 'cert.pfx', + 'truststore.jks', + 'app.keystore', + 'private.asc', ])('blocks %s', (path) => { expect(isSensitivePath(path)).toBe(true); }); From 2c6381ed2279b005bd40b201441ce802036ec638 Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 12:52:10 +0000 Subject: [PATCH 5/7] fix: add cloud provider key detection, fix token-vault edge cases, return 502 with upstream context - Add Tier 1 cloud provider key patterns (AWS, GCP, GitHub, GitLab, Slack, Stripe, SendGrid, npm, Anthropic, OpenAI, DigitalOcean, etc.) and PEM private key detection to redactor - Run cloud key patterns before generic SECRET/CREDIT_CARD to prevent partial matches eating prefixed keys - Fix streaming token-vault: partial token detection now checks for uppercase prefix after '[' to avoid false-positives on JSON arrays - Cap contentBuffers at 1MB per field to prevent unbounded memory growth - Return 502 (not 500) with upstream error message for debugging Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 3 +- src/middleware/redactor.ts | 75 +++++++++++++++++++++++++++++ src/vault/token-vault.ts | 18 +++++-- tests/redaction.test.ts | 96 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0d81279..b41a2b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -158,8 +158,9 @@ async function proxyRequest( res.status(response.status).json(rehydratedData); } catch (error) { + const message = error instanceof Error ? error.message : String(error); log.error({ err: error, path: req.path }, 'Failed to forward request'); - res.status(500).json({ error: 'Gateway forwarding failed' }); + res.status(502).json({ error: 'Gateway forwarding failed', upstream: message }); } } diff --git a/src/middleware/redactor.ts b/src/middleware/redactor.ts index 2508b08..0b6c8b3 100644 --- a/src/middleware/redactor.ts +++ b/src/middleware/redactor.ts @@ -46,6 +46,56 @@ export class Redactor { PHONE: /(?:^|[\s:;])(?:\+\d{1,3}[-. ]?)?\(?\d{2,4}\)?[-. ]\d{3,4}[-. ]\d{3,4}(?:\s*(?:ext|x)\s*\d+)?/g, }; + /** + * Cloud provider key patterns — Tier 1 only (unique prefixes, very low false-positive risk). + * Sources: GitHub secret scanning, gitleaks, trufflehog. + */ + private static readonly CLOUD_KEY_PATTERNS: Array<{ re: RegExp; label: string }> = [ + // AWS + { re: /\b((?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b/g, label: 'AWS_KEY' }, + // GCP / Firebase + { re: /\b(AIza[\w-]{35})\b/g, label: 'GCP_KEY' }, + { re: /\b(GOCSPX-[a-zA-Z0-9_-]{28})\b/g, label: 'GCP_OAUTH' }, + // GitHub + { re: /\b(ghp_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_PAT' }, + { re: /\b(gho_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_OAUTH' }, + { re: /\b(ghu_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_U2S' }, + { re: /\b(ghs_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_S2S' }, + { re: /\b(ghr_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_REFRESH' }, + { re: /\b(github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})\b/g, label: 'GITHUB_FINE_PAT' }, + // GitLab + { re: /\b(glpat-[\w-]{20})\b/g, label: 'GITLAB_PAT' }, + { re: /\b(glptt-[a-zA-Z0-9_-]{40})\b/g, label: 'GITLAB_TRIGGER' }, + // Slack + { re: /\b(xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*)\b/g, label: 'SLACK_BOT' }, + { re: /\b(xox[pe]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9-]+)\b/g, label: 'SLACK_TOKEN' }, + // Stripe + { re: /\b(sk_(?:live|test)_[a-zA-Z0-9]{10,99})\b/g, label: 'STRIPE_SECRET' }, + { re: /\b(rk_(?:live|test)_[a-zA-Z0-9]{10,99})\b/g, label: 'STRIPE_RESTRICTED' }, + { re: /\b(whsec_[a-zA-Z0-9]{24,})\b/g, label: 'STRIPE_WEBHOOK' }, + // SendGrid (SG. + base64url with internal dot separator) + { re: /\b(SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43})\b/g, label: 'SENDGRID_KEY' }, + // npm + { re: /\b(npm_[a-z0-9]{36})\b/gi, label: 'NPM_TOKEN' }, + // PyPI + { re: /\b(pypi-AgEIcHlwaS5vcmc[\w-]{50,})\b/g, label: 'PYPI_TOKEN' }, + // Docker Hub + { re: /\b(dckr_pat_[a-zA-Z0-9_-]{27,})\b/g, label: 'DOCKER_PAT' }, + // Anthropic + { re: /\b(sk-ant-[a-zA-Z0-9_-]{36,})\b/g, label: 'ANTHROPIC_KEY' }, + // OpenAI (with T3BlbkFJ marker) + { re: /\b(sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,})\b/g, label: 'OPENAI_KEY' }, + // DigitalOcean + { re: /\b(do[por]_v1_[a-f0-9]{64})\b/g, label: 'DIGITALOCEAN_TOKEN' }, + // HashiCorp Vault + { re: /\b(hvs\.[\w-]{90,})\b/g, label: 'VAULT_TOKEN' }, + { re: /\b(hvb\.[\w-]{90,})\b/g, label: 'VAULT_BATCH' }, + // Supabase + { re: /\b(sbp_[a-f0-9]{40})\b/g, label: 'SUPABASE_PAT' }, + { re: /\b(sb_secret_[a-zA-Z0-9_-]{20,})\b/g, label: 'SUPABASE_SECRET' }, + // PEM private keys (multiline — matched separately in redactPEMKeys) + ]; + /** * Redact sensitive information from a JSON object or string. * @@ -102,6 +152,31 @@ export class Redactor { return token; }); + // Redact cloud provider keys BEFORE generic patterns — specific prefixed + // keys must be matched first so they don't get partially eaten by SECRET + // or CREDIT_CARD patterns. + for (const { re, label } of Redactor.CLOUD_KEY_PATTERNS) { + re.lastIndex = 0; + text = text.replace(re, (match, p1: string) => { + hasRedacted = true; + const val = p1 || match; + const token = `[${label}_${tokenHash(val)}]`; + tokens.set(token, val); + return token; + }); + } + + // Redact PEM private keys + text = text.replace( + /-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY-----[\s\S]{64,}?-----END[ A-Z0-9_-]{0,100}PRIVATE KEY-----/g, + (match) => { + hasRedacted = true; + const token = `[PRIVATE_KEY_${tokenHash(match)}]`; + tokens.set(token, match); + return token; + }, + ); + // Redact Secrets in text (e.g. "api_key=...") text = text.replace(Redactor.PATTERNS.SECRET, (match, p1) => { hasRedacted = true; diff --git a/src/vault/token-vault.ts b/src/vault/token-vault.ts index e9dc4e2..be20c68 100644 --- a/src/vault/token-vault.ts +++ b/src/vault/token-vault.ts @@ -88,7 +88,9 @@ export class TokenVault { let buffer = ''; const maxTokenLen = Math.max(...[...this.vault.keys()].map(t => t.length), 0); - // Accumulate content fields across SSE events to reassemble split tokens + // Accumulate content fields across SSE events to reassemble split tokens. + // Cap buffer size to prevent unbounded memory growth on very long streams. + const MAX_BUFFER_SIZE = 1024 * 1024; // 1 MB per field const contentBuffers: Record = {}; const CONTENT_FIELDS = ['content', 'reasoning_content', 'partial_json']; @@ -186,11 +188,21 @@ export class TokenVault { const bufKey = actualField; contentBuffers[bufKey] = (contentBuffers[bufKey] || '') + target[actualField]; - const buf = contentBuffers[bufKey]; + // Cap buffer size: flush everything if it grows too large + if (contentBuffers[bufKey]!.length > MAX_BUFFER_SIZE) { + target[actualField] = flushField(bufKey); + modified = true; + continue; + } + + const buf = contentBuffers[bufKey]!; const lastBracket = buf.lastIndexOf('['); + // Only treat as partial token if the text after '[' looks like a + // token prefix (uppercase letter or underscore), not JSON array content. const hasPartialToken = maxTokenLen > 0 && lastBracket >= 0 && !buf.substring(lastBracket).includes(']') && - buf.length - lastBracket < maxTokenLen; + buf.length - lastBracket < maxTokenLen && + /^\[[A-Z_]/.test(buf.substring(lastBracket)); if (hasPartialToken) { const safe = buf.substring(0, lastBracket); diff --git a/tests/redaction.test.ts b/tests/redaction.test.ts index ff89b8e..3173696 100644 --- a/tests/redaction.test.ts +++ b/tests/redaction.test.ts @@ -99,4 +99,100 @@ describe('Semantic Security Flow (Redaction + Rehydration)', () => { expect(hasRedacted).toBe(true); expect(content).toMatch(/^Call \[NETWORK_IP_[a-f0-9]{6}\]$/); }); + + describe('cloud provider key detection', () => { + it('should redact AWS access key IDs', () => { + const input = 'key: AKIAIOSFODNN7EXAMPLE'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[AWS_KEY_[a-f0-9]{6}\]/); + expect(content).not.toContain('AKIAIOSFODNN7EXAMPLE'); + }); + + it('should redact GCP API keys', () => { + const input = 'key: AIzaSyA1234567890abcdefghijklmnopqrstuv'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[GCP_KEY_[a-f0-9]{6}\]/); + }); + + it('should redact GitHub PATs', () => { + const input = 'token: ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghij'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[GITHUB_PAT_[a-f0-9]{6}\]/); + }); + + it('should redact GitHub fine-grained PATs', () => { + const input = 'github_pat_1234567890abcdefghijkl_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[GITHUB_FINE_PAT_[a-f0-9]{6}\]/); + }); + + it('should redact GitLab PATs', () => { + const input = 'token: glpat-1234567890abcdefghij'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[GITLAB_PAT_[a-f0-9]{6}\]/); + }); + + it('should redact Slack bot tokens', () => { + const input = 'xoxb-1234567890123-1234567890123-abc'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[SLACK_BOT_[a-f0-9]{6}\]/); + }); + + it('should redact Stripe secret keys', () => { + // Concatenated to avoid GitHub push-protection false positive + const input = 'sk_live_' + '1234567890abcdefghijklmnop'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[STRIPE_SECRET_[a-f0-9]{6}\]/); + }); + + it('should redact SendGrid API keys', () => { + // Concatenated to avoid GitHub push-protection false positive + const input = 'SG.' + 'abcdefghijklmnopqrstuv.yz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ01234'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[SENDGRID_KEY_[a-f0-9]{6}\]/); + }); + + it('should redact npm tokens', () => { + const input = 'npm_abcdefghijklmnopqrstuvwxyz0123456789'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[NPM_TOKEN_[a-f0-9]{6}\]/); + }); + + it('should redact Anthropic API keys', () => { + const input = 'sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[ANTHROPIC_KEY_[a-f0-9]{6}\]/); + }); + + it('should redact DigitalOcean PATs', () => { + const input = 'dop_v1_' + 'a'.repeat(64); + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[DIGITALOCEAN_TOKEN_[a-f0-9]{6}\]/); + }); + + it('should redact PEM private keys', () => { + const input = '-----BEGIN RSA PRIVATE KEY-----\n' + 'A'.repeat(100) + '\n-----END RSA PRIVATE KEY-----'; + const { content, hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(true); + expect(content).toMatch(/\[PRIVATE_KEY_[a-f0-9]{6}\]/); + expect(content).not.toContain('BEGIN RSA PRIVATE KEY'); + }); + + it('should not false-positive on normal text', () => { + const input = 'The package.json file has scripts and dependencies.'; + const { hasRedacted } = redactor.redact(input); + expect(hasRedacted).toBe(false); + }); + }); }); From be6375a9c3f52e9c233de2adc80f66a94eedbdf8 Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 13:00:56 +0000 Subject: [PATCH 6/7] fix: detect shell variable paths and batcat in sensitive file commands - Expand $HOME/.env, ${VAR}/.env, and ~/.env before checking sensitive patterns so `cat $HOME/.env` is blocked - Add `batcat` (Ubuntu symlink for bat) to read command detection - 6 new test cases for shell expansion and batcat Co-Authored-By: Claude Opus 4.6 --- examples/team-config/.opencode/plugins/hush.ts | 5 +++-- src/plugins/sensitive-patterns.ts | 10 ++++++---- tests/opencode-plugin.test.ts | 6 ++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/examples/team-config/.opencode/plugins/hush.ts b/examples/team-config/.opencode/plugins/hush.ts index a723da5..61750d3 100644 --- a/examples/team-config/.opencode/plugins/hush.ts +++ b/examples/team-config/.opencode/plugins/hush.ts @@ -33,7 +33,7 @@ function isSensitivePath(filePath: string): boolean { return SENSITIVE_GLOBS.some((re) => re.test(basename)); } -const READ_COMMANDS = /\b(cat|head|tail|less|more|bat)\b/; +const READ_COMMANDS = /\b(cat|head|tail|less|more|bat|batcat)\b/; function commandReadsSensitiveFile(cmd: string): boolean { if (!READ_COMMANDS.test(cmd)) return false; @@ -45,7 +45,8 @@ function commandReadsSensitiveFile(cmd: string): boolean { for (let i = cmdIndex + 1; i < tokens.length; i++) { const token = tokens[i]!; if (token.startsWith('-')) continue; - if (isSensitivePath(token)) return true; + const expanded = token.replace(/^~\//, '/home/user/').replace(/\$\{?\w+\}?\//g, '/'); + if (isSensitivePath(expanded)) return true; } } return false; diff --git a/src/plugins/sensitive-patterns.ts b/src/plugins/sensitive-patterns.ts index c3cfe6a..0fbf471 100644 --- a/src/plugins/sensitive-patterns.ts +++ b/src/plugins/sensitive-patterns.ts @@ -29,8 +29,8 @@ export function isSensitivePath(filePath: string): boolean { return SENSITIVE_GLOBS.some((re) => re.test(basename)); } -/** Commands that read file contents. */ -const READ_COMMANDS = /\b(cat|head|tail|less|more|bat)\b/; +/** Commands that read file contents (includes batcat — Ubuntu symlink for bat). */ +const READ_COMMANDS = /\b(cat|head|tail|less|more|bat|batcat)\b/; /** * Check whether a bash command reads a sensitive file. @@ -46,11 +46,13 @@ export function commandReadsSensitiveFile(cmd: string): boolean { const cmdIndex = tokens.findIndex((t) => READ_COMMANDS.test(t)); if (cmdIndex === -1) continue; - // Check all tokens after the command for sensitive paths (skip flags) + // Check all tokens after the command for sensitive paths (skip flags). + // Expand shell variables/tilde so `cat $HOME/.env` and `cat ~/secrets/.env` are caught. for (let i = cmdIndex + 1; i < tokens.length; i++) { const token = tokens[i]!; if (token.startsWith('-')) continue; // skip flags like -n, -5 - if (isSensitivePath(token)) return true; + const expanded = token.replace(/^~\//, '/home/user/').replace(/\$\{?\w+\}?\//g, '/'); + if (isSensitivePath(expanded)) return true; } } return false; diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index b59bf4d..b7f4368 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -62,6 +62,12 @@ describe('commandReadsSensitiveFile', () => { 'cat .pgpass', 'cat foo.txt && cat .env', 'echo hello | cat .env', + 'cat $HOME/.env', + 'cat ${HOME}/.env', + 'cat ~/secrets/.env', + 'cat ~/.pgpass', + 'batcat .env', + 'batcat id_rsa', ])('blocks: %s', (cmd) => { expect(commandReadsSensitiveFile(cmd)).toBe(true); }); From fc55b04767c847e24a347021c4be55619b855de0 Mon Sep 17 00:00:00 2001 From: AICtrl Bot Date: Mon, 2 Mar 2026 13:28:41 +0000 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20address=20PR=20#23=20review=20?= =?UTF-8?q?=E2=80=94=20harden=20shell=20bypass,=20remove=20leaks,=20clean?= =?UTF-8?q?=20up?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Harden commandReadsSensitiveFile: split on <> redirects, strip shell metacharacters (backticks, quotes, $(), {}) before isSensitivePath, detect redirect patterns like `cat <.env` - Trim whitespace in isSensitivePath - Remove upstream message from 502 JSON response to avoid leaking internal infrastructure details (kept in server-side log) - Remove dead ~/​ and $HOME expansion code — isSensitivePath already uses basename-only matching - Hold back bare `[` at buffer boundary in token-vault (not just [A-Z_ prefixed) - Rename TMPDIR → WORK_DIR in E2E scripts to avoid POSIX collision - Wrap opencode calls with timeout 120 in both E2E scripts - Replace node_modules/ artifact with cache keyed on package-lock.json in GitLab CI - Set PORT=$GATEWAY_PORT explicitly in e2e-proxy-live.sh - Add tests for shell metacharacter bypass (subshells, backticks, redirects, quotes) Co-Authored-By: Claude Opus 4.6 --- .gitlab-ci.yml | 21 +++++++++++++++- .../team-config/.opencode/plugins/hush.ts | 17 +++++++++---- scripts/e2e-plugin-block.sh | 20 ++++++++-------- scripts/e2e-proxy-live.sh | 22 ++++++++--------- src/index.ts | 3 +-- src/plugins/sensitive-patterns.ts | 24 ++++++++++++++----- src/vault/token-vault.ts | 6 +++-- tests/opencode-plugin.test.ts | 4 ++++ 8 files changed, 81 insertions(+), 36 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 56a509d..ae634ab 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -12,13 +12,25 @@ build: artifacts: paths: - dist/ - - node_modules/ expire_in: 1 hour + cache: + key: + files: + - package-lock.json + paths: + - node_modules/ e2e-plugin-blocks-env: stage: e2e image: node:22-slim needs: [build] + cache: + key: + files: + - package-lock.json + paths: + - node_modules/ + policy: pull before_script: - npm install -g opencode script: @@ -31,6 +43,13 @@ e2e-proxy-redacts-pii: stage: e2e image: node:22-slim needs: [build] + cache: + key: + files: + - package-lock.json + paths: + - node_modules/ + policy: pull before_script: - npm install -g opencode script: diff --git a/examples/team-config/.opencode/plugins/hush.ts b/examples/team-config/.opencode/plugins/hush.ts index 61750d3..e1c3821 100644 --- a/examples/team-config/.opencode/plugins/hush.ts +++ b/examples/team-config/.opencode/plugins/hush.ts @@ -29,15 +29,24 @@ const SENSITIVE_GLOBS = [ ]; function isSensitivePath(filePath: string): boolean { - const basename = filePath.split('/').pop() ?? ''; + const basename = (filePath.split('/').pop() ?? '').trim(); return SENSITIVE_GLOBS.some((re) => re.test(basename)); } const READ_COMMANDS = /\b(cat|head|tail|less|more|bat|batcat)\b/; +function stripShellMeta(token: string): string { + return token.replace(/[`"'$(){}]/g, ''); +} + function commandReadsSensitiveFile(cmd: string): boolean { if (!READ_COMMANDS.test(cmd)) return false; - const parts = cmd.split(/[|;&]+/); + const redirectPattern = /<\s*([^\s|;&<>]+)/g; + let rMatch; + while ((rMatch = redirectPattern.exec(cmd)) !== null) { + if (isSensitivePath(stripShellMeta(rMatch[1]!))) return true; + } + const parts = cmd.split(/[|;&<>]+/); for (const part of parts) { const tokens = part.trim().split(/\s+/); const cmdIndex = tokens.findIndex((t) => READ_COMMANDS.test(t)); @@ -45,8 +54,8 @@ function commandReadsSensitiveFile(cmd: string): boolean { for (let i = cmdIndex + 1; i < tokens.length; i++) { const token = tokens[i]!; if (token.startsWith('-')) continue; - const expanded = token.replace(/^~\//, '/home/user/').replace(/\$\{?\w+\}?\//g, '/'); - if (isSensitivePath(expanded)) return true; + const cleaned = stripShellMeta(token); + if (isSensitivePath(cleaned)) return true; } } return false; diff --git a/scripts/e2e-plugin-block.sh b/scripts/e2e-plugin-block.sh index aebee09..1fdc927 100755 --- a/scripts/e2e-plugin-block.sh +++ b/scripts/e2e-plugin-block.sh @@ -23,12 +23,12 @@ NC='\033[0m' PASS_COUNT=0 FAIL_COUNT=0 -TMPDIR="" +WORK_DIR="" cleanup() { echo "" echo -e "${CYAN}Cleaning up...${NC}" - [ -n "$TMPDIR" ] && rm -rf "$TMPDIR" + [ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" } trap cleanup EXIT @@ -68,11 +68,11 @@ echo "" # --- Step 1: Create temp project with .env and hush plugin --- echo -e "${YELLOW}[1/4] Creating temp project with .env and hush plugin...${NC}" -TMPDIR=$(mktemp -d) -mkdir -p "$TMPDIR/.opencode/plugins" +WORK_DIR=$(mktemp -d) +mkdir -p "$WORK_DIR/.opencode/plugins" # Sensitive .env file with PII -cat > "$TMPDIR/.env" <<'ENVEOF' +cat > "$WORK_DIR/.env" <<'ENVEOF' DATABASE_URL=postgres://admin:supersecret@10.42.99.7:5432/prod API_KEY=sk-live-a1b2c3d4e5f6g7h8i9j0k1l2m3n4 ADMIN_EMAIL=alice@confidential-corp.com @@ -81,10 +81,10 @@ ENVEOF # Copy the drop-in plugin cp "$PROJECT_DIR/examples/team-config/.opencode/plugins/hush.ts" \ - "$TMPDIR/.opencode/plugins/hush.ts" + "$WORK_DIR/.opencode/plugins/hush.ts" # opencode.json — point at real provider + enable plugin -cat > "$TMPDIR/opencode.json" < "$WORK_DIR/opencode.json" < "$TMPDIR/opencode.json" <&1) || true +cd "$WORK_DIR" +OUTPUT=$(timeout 120 opencode -p "read the file .env and tell me what's in it" -q -f json 2>&1) || true echo -e " Output length: $(echo "$OUTPUT" | wc -c) bytes" # --- Step 3: Verify plugin blocked the read --- diff --git a/scripts/e2e-proxy-live.sh b/scripts/e2e-proxy-live.sh index 27cc511..1c37c4f 100755 --- a/scripts/e2e-proxy-live.sh +++ b/scripts/e2e-proxy-live.sh @@ -25,13 +25,13 @@ GATEWAY_PORT=4000 GATEWAY_PID="" PASS_COUNT=0 FAIL_COUNT=0 -TMPDIR="" +WORK_DIR="" cleanup() { echo "" echo -e "${CYAN}Cleaning up...${NC}" [ -n "$GATEWAY_PID" ] && kill "$GATEWAY_PID" 2>/dev/null || true - [ -n "$TMPDIR" ] && rm -rf "$TMPDIR" + [ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" wait 2>/dev/null || true } trap cleanup EXIT @@ -87,7 +87,7 @@ cd "$PROJECT_DIR" # --- Step 1: Start Hush gateway --- echo -e "${YELLOW}[1/5] Starting Hush gateway on :${GATEWAY_PORT}...${NC}" -DEBUG=true node dist/cli.js > /tmp/hush-e2e-proxy.log 2>&1 & +PORT=$GATEWAY_PORT DEBUG=true node dist/cli.js > /tmp/hush-e2e-proxy.log 2>&1 & GATEWAY_PID=$! wait_for_port "$GATEWAY_PORT" "Gateway" || exit 1 @@ -96,11 +96,11 @@ echo -e " Gateway PID: ${GATEWAY_PID}" # --- Step 2: Create temp project with config.txt containing PII --- echo -e "${YELLOW}[2/5] Creating temp project with config.txt (PII in normal file)...${NC}" -TMPDIR=$(mktemp -d) -mkdir -p "$TMPDIR/.opencode/plugins" +WORK_DIR=$(mktemp -d) +mkdir -p "$WORK_DIR/.opencode/plugins" # Normal filename — plugin won't block this -cat > "$TMPDIR/config.txt" <<'CFGEOF' +cat > "$WORK_DIR/config.txt" <<'CFGEOF' # Application Configuration app_name: MyApp admin_contact: alice@confidential-corp.com @@ -111,10 +111,10 @@ CFGEOF # Copy the hush plugin (it won't block config.txt — not a sensitive filename) cp "$PROJECT_DIR/examples/team-config/.opencode/plugins/hush.ts" \ - "$TMPDIR/.opencode/plugins/hush.ts" + "$WORK_DIR/.opencode/plugins/hush.ts" # Point OpenCode at hush proxy -cat > "$TMPDIR/opencode.json" < "$WORK_DIR/opencode.json" < "$TMPDIR/opencode.json" <&1) || true +cd "$WORK_DIR" +OUTPUT=$(timeout 120 opencode -p "read config.txt and summarize it" -q -f json 2>&1) || true echo -e " Output length: $(echo "$OUTPUT" | wc -c) bytes" # --- Step 5: Verify proxy redacted PII --- diff --git a/src/index.ts b/src/index.ts index b41a2b8..0f4cd24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -158,9 +158,8 @@ async function proxyRequest( res.status(response.status).json(rehydratedData); } catch (error) { - const message = error instanceof Error ? error.message : String(error); log.error({ err: error, path: req.path }, 'Failed to forward request'); - res.status(502).json({ error: 'Gateway forwarding failed', upstream: message }); + res.status(502).json({ error: 'Gateway forwarding failed' }); } } diff --git a/src/plugins/sensitive-patterns.ts b/src/plugins/sensitive-patterns.ts index 0fbf471..439c6d7 100644 --- a/src/plugins/sensitive-patterns.ts +++ b/src/plugins/sensitive-patterns.ts @@ -25,13 +25,18 @@ const SENSITIVE_GLOBS = [ * Matches against the basename only so absolute/relative paths both work. */ export function isSensitivePath(filePath: string): boolean { - const basename = filePath.split('/').pop() ?? ''; + const basename = (filePath.split('/').pop() ?? '').trim(); return SENSITIVE_GLOBS.some((re) => re.test(basename)); } /** Commands that read file contents (includes batcat — Ubuntu symlink for bat). */ const READ_COMMANDS = /\b(cat|head|tail|less|more|bat|batcat)\b/; +/** Strip shell metacharacters that could wrap a filename to bypass detection. */ +function stripShellMeta(token: string): string { + return token.replace(/[`"'$(){}]/g, ''); +} + /** * Check whether a bash command reads a sensitive file. * Looks for common read commands followed by a sensitive filename. @@ -39,20 +44,27 @@ const READ_COMMANDS = /\b(cat|head|tail|less|more|bat|batcat)\b/; export function commandReadsSensitiveFile(cmd: string): boolean { if (!READ_COMMANDS.test(cmd)) return false; - // Split on pipes/semicolons/&& to get individual commands - const parts = cmd.split(/[|;&]+/); + // Check input redirections: `cat <.env` or `cat < .env` + // The file after `<` is read by the preceding command. + const redirectPattern = /<\s*([^\s|;&<>]+)/g; + let rMatch; + while ((rMatch = redirectPattern.exec(cmd)) !== null) { + if (isSensitivePath(stripShellMeta(rMatch[1]!))) return true; + } + + // Split on pipes, semicolons, &&, and redirections to get individual commands + const parts = cmd.split(/[|;&<>]+/); for (const part of parts) { const tokens = part.trim().split(/\s+/); const cmdIndex = tokens.findIndex((t) => READ_COMMANDS.test(t)); if (cmdIndex === -1) continue; // Check all tokens after the command for sensitive paths (skip flags). - // Expand shell variables/tilde so `cat $HOME/.env` and `cat ~/secrets/.env` are caught. for (let i = cmdIndex + 1; i < tokens.length; i++) { const token = tokens[i]!; if (token.startsWith('-')) continue; // skip flags like -n, -5 - const expanded = token.replace(/^~\//, '/home/user/').replace(/\$\{?\w+\}?\//g, '/'); - if (isSensitivePath(expanded)) return true; + const cleaned = stripShellMeta(token); + if (isSensitivePath(cleaned)) return true; } } return false; diff --git a/src/vault/token-vault.ts b/src/vault/token-vault.ts index be20c68..73741a0 100644 --- a/src/vault/token-vault.ts +++ b/src/vault/token-vault.ts @@ -199,10 +199,12 @@ export class TokenVault { const lastBracket = buf.lastIndexOf('['); // Only treat as partial token if the text after '[' looks like a // token prefix (uppercase letter or underscore), not JSON array content. + // Also hold back a bare '[' at the end — not enough chars yet to decide. + const tail = lastBracket >= 0 ? buf.substring(lastBracket) : ''; const hasPartialToken = maxTokenLen > 0 && lastBracket >= 0 && - !buf.substring(lastBracket).includes(']') && + !tail.includes(']') && buf.length - lastBracket < maxTokenLen && - /^\[[A-Z_]/.test(buf.substring(lastBracket)); + (tail === '[' || /^\[[A-Z_]/.test(tail)); if (hasPartialToken) { const safe = buf.substring(0, lastBracket); diff --git a/tests/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts index b7f4368..e041ec5 100644 --- a/tests/opencode-plugin.test.ts +++ b/tests/opencode-plugin.test.ts @@ -68,6 +68,10 @@ describe('commandReadsSensitiveFile', () => { 'cat ~/.pgpass', 'batcat .env', 'batcat id_rsa', + 'cat "$(echo .env)"', + 'cat `.env`', + 'cat <.env', + "cat '.env'", ])('blocks: %s', (cmd) => { expect(commandReadsSensitiveFile(cmd)).toBe(true); });