Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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.

## How it Works

1. **Intercept** — Hush sits on your machine between your AI tool and the LLM provider.
Expand Down
14 changes: 14 additions & 0 deletions examples/team-config/.claude/settings.json
Original file line number Diff line number Diff line change
@@ -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
}
]
}
]
}
}
44 changes: 28 additions & 16 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -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=<number> 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=<number> hush`);
} else {
log.error({ err }, 'Failed to start server');
}
process.exit(1);
});
}
107 changes: 107 additions & 0 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
@@ -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`);
}
124 changes: 124 additions & 0 deletions src/commands/redact-hook.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<HookPayload['tool_response']>,
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<string> {
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<void> {
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);
}
Loading