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
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ your-project/
├── .claude/settings.json # Claude Code → hush
├── .codex/config.toml # Codex → hush
├── .gemini/.env # Gemini CLI → hush
├── .openclaw/ # OpenClaw skill workspace
└── opencode.json # OpenCode → hush
```

Expand Down Expand Up @@ -220,6 +221,30 @@ Tool reads config.txt → [Plugin: allowed] → proxy redacts PII →
(not a sensitive filename)
```

## OpenClaw Skill

Hush provides a **safety skill** for OpenClaw that blocks dangerous file reads and redacts PII from tool outputs *locally* before the model ever sees them.

### Setup

Copy the skill directory into your OpenClaw workspace:

```bash
mkdir -p ~/.openclaw/workspace/skills/hush
cp examples/team-config/.openclaw/skills/hush/* ~/.openclaw/workspace/skills/hush/
```

### What it protects

1. **Pre-execution Blocking**: Stops tools like `read` or `bash` if they target sensitive files (e.g., `.env`, `*.pem`, `id_rsa`).
2. **Post-execution Redaction**: Automatically scans `stdout`/`stderr` and file content for PII (emails, IPs, keys) and swaps them for tokens before returning the result to the model.

### npm import

```typescript
import { HushSkill } from '@aictrl/hush/openclaw-skill'
```

## How it Works

1. **Intercept** — Hush sits on your machine between your AI tool and the LLM provider.
Expand All @@ -235,6 +260,7 @@ Tool reads config.txt → [Plugin: allowed] → proxy redacts PII →
| Claude Code | `~/.claude/settings.json` | `/v1/messages` → Anthropic |
| Codex | `~/.codex/config.toml` | `/v1/chat/completions` → OpenAI |
| OpenCode | `opencode.json` | `/api/paas/v4/**` → ZhipuAI |
| OpenClaw | `~/.openclaw/openclaw.json` | `/*` (Proxy) + Skill |
| Gemini CLI | `.gemini/.env` | `/v1beta/models/**` → Google |
| Any tool | Point base URL at hush | `/*` catch-all with auto-detect |

Expand Down
27 changes: 27 additions & 0 deletions examples/team-config/.openclaw/skills/hush/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Hush PII Guard 🛡️

**Hush PII Guard** is a safety skill for OpenClaw that prevents sensitive data from being sent to AI models or leaking through tool outputs.

## 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 |

## What it redacts
The skill automatically scans the output of every tool (Bash stdout/stderr, file reads, etc.) for:
- 📧 Emails
- 🌐 IP Addresses
- 🔑 API Keys & Secrets
- 💳 Credit Card Numbers
- 📞 Phone Numbers

Sensitive data is replaced with deterministic tokens like `[USER_EMAIL_f22c5a]`.

## Setup
1. Copy this directory to `~/.openclaw/workspace/skills/hush/`.
2. Ensure `hush` is installed globally: `npm install -g @aictrl/hush`.
3. OpenClaw will automatically discover and load the skill from your workspace.

## Defense-in-depth
For maximum protection, use this skill alongside the **Hush Proxy**. The skill protects your local files, while the proxy redacts PII from API requests before they leave your machine.
11 changes: 11 additions & 0 deletions examples/team-config/.openclaw/skills/hush/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* OpenClaw Skill: Hush PII Guard
*
* This file is a wrapper that imports the implementation from the main package.
* In a real-world deployment, you would copy the contents of `src/plugins/openclaw-hush.ts`
* into this file.
*/

import { HushSkill } from '@aictrl/hush/openclaw-skill';

export default HushSkill;
11 changes: 9 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@
"./opencode-plugin": {
"import": "./dist/plugins/opencode-hush.js",
"types": "./dist/plugins/opencode-hush.d.ts"
},
"./openclaw-skill": {
"import": "./dist/plugins/openclaw-hush.js",
"types": "./dist/plugins/openclaw-hush.d.ts"
}
},
"bin": {
Expand All @@ -24,6 +28,7 @@
"dev": "tsx src/cli.ts",
"test": "vitest run --coverage",
"test:e2e": "./scripts/e2e-opencode.sh",
"test:openclaw": "tsx scripts/e2e-openclaw.ts",
"test:watch": "vitest",
"lint": "eslint src/**/*.ts",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\""
Expand All @@ -49,6 +54,7 @@
"cors": "^2.8.6",
"dotenv": "^17.3.1",
"express": "^5.2.1",
"openclaw": "^0.0.1",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3"
},
Expand Down
155 changes: 155 additions & 0 deletions scripts/e2e-openclaw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
/**
* Simulated E2E test for OpenClaw integration.
*
* This test simulates the two layers of protection:
* 1. The Skill (Local): Intercepts tool calls and redacts their outputs.
* 2. The Proxy (Cloud): Intercepts API requests from OpenClaw and redacts PII.
*/

import http from 'node:http';
import fs from 'node:fs';
import { HushSkill } from '../src/plugins/openclaw-hush.js';
import { Redactor } from '../src/middleware/redactor.js';
import { TokenVault } from '../src/vault/token-vault.js';

// Configuration
const MOCK_PORT = 4991;
const GATEWAY_PORT = 4992;
const CAPTURE_FILE = '/tmp/hush-openclaw-captured.json';

// --- Step 1: Mock Upstream (ZhipuAI/OpenAI) ---
const mockUpstream = http.createServer((req, res) => {
if (req.method === 'POST') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', () => {
fs.writeFileSync(CAPTURE_FILE, body);
const parsed = JSON.parse(body);
const lastMessage = parsed.messages?.[parsed.messages.length - 1]?.content || '';

res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
id: 'chatcmpl-e2e-mock-001',
choices: [{
message: { role: 'assistant', content: `Echo: ${lastMessage}` }
}]
}));
});
} else if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok' }));
}
});

// --- Step 2: Hush Gateway (Proxy) ---
const redactor = new Redactor();
const vault = new TokenVault();

const gateway = http.createServer(async (req, res) => {
if (req.method === 'POST') {
let body = '';
req.on('data', chunk => { body += chunk; });
req.on('end', async () => {
const bodyParsed = JSON.parse(body);
const { content: redactedBody, tokens, hasRedacted } = redactor.redact(bodyParsed);
if (hasRedacted) {
vault.saveTokens(tokens);
}

const upstreamRes = await fetch(`http://127.0.0.1:${MOCK_PORT}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(redactedBody)
});
const data = await upstreamRes.json();
const rehydrated = vault.rehydrate(data);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(rehydrated));
});
} else if (req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', vaultSize: vault.size }));
}
});

// --- Main Test Routine ---
async function runTest() {
console.log('--- OpenClaw Simulated E2E Test ---');

mockUpstream.listen(MOCK_PORT, '127.0.0.1');
gateway.listen(GATEWAY_PORT, '127.0.0.1');

try {
const skill = await HushSkill();

// 1. Test SKILL Blocking (Pre-execution)
console.log('[1/4] Testing Skill Blocking...');
const blockResult = await skill['before_tool_call']({
toolName: 'read',
params: { filePath: '.env' }
});
if (blockResult?.block === true) {
console.log(' PASS: Blocked sensitive file read');
} else {
throw new Error('Should have blocked .env read');
}

// 2. Test SKILL Redaction (Post-execution)
console.log('[2/4] Testing Skill Output Redaction...');
const event = {
toolName: 'bash',
params: {},
result: { stdout: 'My secret email is bulat@aictrl.dev' }
};
await skill['after_tool_call'](event);
if (event.result.stdout.includes('bulat@aictrl.dev')) {
throw new Error('Skill failed to redact output');
}
console.log(' PASS: Redacted tool output before AI sees it');
console.log(` Output: ${event.result.stdout}`);

// 3. Test PROXY Redaction (Outgoing API)
console.log('[3/4] Testing Proxy API Redaction...');
const apiRequest = {
model: 'glm-5',
messages: [{ role: 'user', content: `Contact another-email@example.com for help.` }]
};

const res = await fetch(`http://127.0.0.1:${GATEWAY_PORT}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(apiRequest)
});

const apiResponse = await res.json();
const captured = JSON.parse(fs.readFileSync(CAPTURE_FILE, 'utf8'));
const messageToUpstream = captured.messages[0].content;

if (messageToUpstream.includes('another-email@example.com')) {
throw new Error('Proxy failed to redact outgoing PII');
}
console.log(' PASS: Redacted PII in cloud request');

// 4. Test PROXY Rehydration (Incoming Response)
console.log('[4/4] Testing Proxy Response Rehydration...');
const assistantContent = apiResponse.choices[0].message.content;

if (!assistantContent.includes('another-email@example.com')) {
throw new Error(`Proxy failed to rehydrate response.`);
}
console.log(' PASS: Rehydrated PII in response to OpenClaw');
console.log(` Assistant: ${assistantContent}`);

console.log('\n--- ALL E2E CHECKS PASSED ---');
} catch (err) {
console.error('\n--- E2E TEST FAILED ---');
console.error(err);
process.exit(1);
} finally {
mockUpstream.close();
gateway.close();
if (fs.existsSync(CAPTURE_FILE)) fs.unlinkSync(CAPTURE_FILE);
}
}

runTest();
76 changes: 76 additions & 0 deletions src/plugins/openclaw-hush.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* OpenClaw Skill: Hush PII Guard
*
* 1. Blocks reads of sensitive files (`.env`, `*.pem`, `credentials.*`, etc.)
* before the tool executes — the AI model never sees the content.
* 2. Redacts PII (emails, IPs, secrets) from tool outputs (stdout/stderr)
* before the AI model sees the result.
*
* Defense-in-depth: works alongside the Hush proxy which redacts PII from
* API requests. The skill protects your local machine; the proxy protects the cloud.
*
* Install:
* Copy this file to `~/.openclaw/workspace/skills/hush/index.ts`
* and create a `SKILL.md` in the same directory.
*/

import { isSensitivePath, commandReadsSensitiveFile } from './sensitive-patterns.js';
import { Redactor } from '../middleware/redactor.js';

const redactor = new Redactor();

export const HushSkill = async () => ({
/**
* Pre-execution: Block dangerous file access.
*/
'before_tool_call': async (
event: { toolName: string; params: Record<string, any> },
) => {
// Block read tool if targeting sensitive files
if (event.toolName === 'read' && isSensitivePath(event.params['filePath'] ?? '')) {
return { block: true, blockReason: '[hush] Blocked: sensitive file' };
}

// Block bash tool if command reads sensitive files (cat .env, etc.)
if (event.toolName === 'bash' && commandReadsSensitiveFile(event.params['command'] ?? '')) {
return { block: true, blockReason: '[hush] Blocked: command reads sensitive file' };
}
},

/**
* Post-execution: Redact PII from tool outputs before OpenClaw sees them.
*/
'after_tool_call': async (
event: {
toolName: string;
params: Record<string, any>;
result?: any;
},
) => {
if (!event.result || typeof event.result !== 'object') return;

const output = event.result;

// 1. Scan stdout/stderr (Bash tool)
if (output.stdout) {
const { content: redacted } = redactor.redact(output.stdout);
output.stdout = redacted as string;
}
if (output.stderr) {
const { content: redacted } = redactor.redact(output.stderr);
output.stderr = redacted as string;
}

// 2. Scan file content (Read tool)
if (output.file && typeof output.file.content === 'string') {
const { content: redacted } = redactor.redact(output.file.content);
output.file.content = redacted as string;
}

// 3. Scan generic content/output
if (typeof output.content === 'string') {
const { content: redacted } = redactor.redact(output.content);
output.content = redacted as string;
}
},
});
Loading