diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..ae634ab --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,59 @@ +stages: + - build + - e2e + +build: + stage: build + image: node:22-slim + script: + - npm ci + - npm run build + - npm test + artifacts: + paths: + - dist/ + 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: + - 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] + cache: + key: + files: + - package-lock.json + paths: + - node_modules/ + policy: pull + 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 81c7090..5d79599 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,99 @@ 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 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 + +| | 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. + +## 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`, `*.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 + +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/.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/examples/team-config/.opencode/plugins/hush.ts b/examples/team-config/.opencode/plugins/hush.ts new file mode 100644 index 0000000..e1c3821 --- /dev/null +++ b/examples/team-config/.opencode/plugins/hush.ts @@ -0,0 +1,76 @@ +/** + * 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$/, + /\.p12$/, + /\.pfx$/, + /\.jks$/, + /\.keystore$/, + /\.asc$/, + /^id_rsa/, + /^\.netrc$/, + /^\.pgpass$/, +]; + +function isSensitivePath(filePath: string): boolean { + 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 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)); + if (cmdIndex === -1) continue; + for (let i = cmdIndex + 1; i < tokens.length; i++) { + const token = tokens[i]!; + if (token.startsWith('-')) continue; + const cleaned = stripShellMeta(token); + if (isSensitivePath(cleaned)) 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..1fdc927 --- /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 +WORK_DIR="" + +cleanup() { + echo "" + echo -e "${CYAN}Cleaning up...${NC}" + [ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" +} +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}" + +WORK_DIR=$(mktemp -d) +mkdir -p "$WORK_DIR/.opencode/plugins" + +# Sensitive .env file with PII +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 +AWS_SECRET_ACCESS_KEY=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY +ENVEOF + +# Copy the drop-in plugin +cp "$PROJECT_DIR/examples/team-config/.opencode/plugins/hush.ts" \ + "$WORK_DIR/.opencode/plugins/hush.ts" + +# opencode.json — point at real provider + enable plugin +cat > "$WORK_DIR/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..1c37c4f --- /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 +WORK_DIR="" + +cleanup() { + echo "" + echo -e "${CYAN}Cleaning up...${NC}" + [ -n "$GATEWAY_PID" ] && kill "$GATEWAY_PID" 2>/dev/null || true + [ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR" + 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}" + +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 +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}" + +WORK_DIR=$(mktemp -d) +mkdir -p "$WORK_DIR/.opencode/plugins" + +# Normal filename — plugin won't block this +cat > "$WORK_DIR/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" \ + "$WORK_DIR/.opencode/plugins/hush.ts" + +# Point OpenCode at hush proxy +cat > "$WORK_DIR/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 "$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 --- +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/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..c0e2d93 --- /dev/null +++ b/src/commands/redact-hook.ts @@ -0,0 +1,124 @@ +/** + * hush redact-hook — Claude Code PostToolUse hook handler + * + * 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 (may or may not block) + * 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_response?: { + // Bash tool + stdout?: string; + stderr?: string; + // Read tool (nested under file) + file?: { content?: string; [key: string]: unknown }; + // Grep / WebFetch / generic + content?: string; + output?: string; + [key: string]: unknown; + }; +} + +interface HookResponse { + decision: 'block'; + reason: string; +} + +/** 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 toolResponse.stdout === 'string' && toolResponse.stdout) { + parts.push(toolResponse.stdout); + } + if (typeof toolResponse.stderr === 'string' && toolResponse.stderr) { + parts.push(toolResponse.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 toolResponse.content === 'string' && toolResponse.content) { + parts.push(toolResponse.content); + } + if (typeof toolResponse.output === 'string' && toolResponse.output) { + parts.push(toolResponse.output); + } + + return parts.length > 0 ? parts.join('\n') : null; +} + +/** Redact PII from the tool response text. */ +function redactToolResponse( + toolResponse: NonNullable, + redactor: Redactor, +): { text: string; hasRedacted: boolean } { + const text = extractText(toolResponse); + 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_response) { + // No tool_response to redact + process.exit(0); + } + + const redactor = new Redactor(); + const { text, hasRedacted } = redactToolResponse(payload.tool_response, redactor); + + if (!hasRedacted) { + // No PII found — let Claude Code keep the original output + process.exit(0); + } + + const response: HookResponse = { + decision: 'block', + reason: text, + }; + + process.stdout.write(JSON.stringify(response) + '\n'); + process.exit(0); +} diff --git a/src/index.ts b/src/index.ts index 0d81279..0f4cd24 100644 --- a/src/index.ts +++ b/src/index.ts @@ -159,7 +159,7 @@ async function proxyRequest( } catch (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' }); } } 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/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..439c6d7 --- /dev/null +++ b/src/plugins/sensitive-patterns.ts @@ -0,0 +1,71 @@ +/** + * 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$/, + /\.p12$/, + /\.pfx$/, + /\.jks$/, + /\.keystore$/, + /\.asc$/, + /^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() ?? '').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. + */ +export function commandReadsSensitiveFile(cmd: string): boolean { + if (!READ_COMMANDS.test(cmd)) return false; + + // 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). + for (let i = cmdIndex + 1; i < tokens.length; i++) { + const token = tokens[i]!; + if (token.startsWith('-')) continue; // skip flags like -n, -5 + 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 e9dc4e2..73741a0 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,23 @@ 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. + // 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(']') && - buf.length - lastBracket < maxTokenLen; + !tail.includes(']') && + buf.length - lastBracket < maxTokenLen && + (tail === '[' || /^\[[A-Z_]/.test(tail)); if (hasPartialToken) { const safe = buf.substring(0, lastBracket); 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/opencode-plugin.test.ts b/tests/opencode-plugin.test.ts new file mode 100644 index 0000000..e041ec5 --- /dev/null +++ b/tests/opencode-plugin.test.ts @@ -0,0 +1,148 @@ +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', + 'keystore.p12', + 'cert.pfx', + 'truststore.jks', + 'app.keystore', + 'private.asc', + ])('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', + 'cat $HOME/.env', + 'cat ${HOME}/.env', + 'cat ~/secrets/.env', + 'cat ~/.pgpass', + 'batcat .env', + 'batcat id_rsa', + 'cat "$(echo .env)"', + 'cat `.env`', + 'cat <.env', + "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(); + }); +}); diff --git a/tests/redact-hook.test.ts b/tests/redact-hook.test.ts new file mode 100644 index 0000000..7ea5aa2 --- /dev/null +++ b/tests/redact-hook.test.ts @@ -0,0 +1,142 @@ +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_response: { stdout: 'email: test@foo.com' }, + }; + 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('test@foo.com'); + }); + + it('should redact email from Read file.content', () => { + const payload = { + tool_name: 'Read', + 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.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_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.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_response: { 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_response', () => { + 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_response: { + 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.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 response', () => { + const payload = { + tool_name: 'Bash', + 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.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'); + }); +}); 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); + }); + }); });