Skip to content
Merged
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
59 changes: 59 additions & 0 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
@@ -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
93 changes: 93 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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
}
]
}
]
}
}
76 changes: 76 additions & 0 deletions examples/team-config/.opencode/plugins/hush.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> },
) => {
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');
}
},
});
3 changes: 2 additions & 1 deletion examples/team-config/opencode.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
"baseURL": "http://127.0.0.1:4000/api/coding/paas/v4"
}
}
}
},
"plugin": [".opencode/plugins/hush.ts"]
}
10 changes: 10 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
Loading