@aictrl/hush 0.1.6 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitlab-ci.yml +59 -0
- package/README.md +150 -3
- package/dist/cli.js +30 -17
- package/dist/cli.js.map +1 -1
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +81 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/redact-hook.d.ts +12 -0
- package/dist/commands/redact-hook.d.ts.map +1 -0
- package/dist/commands/redact-hook.js +89 -0
- package/dist/commands/redact-hook.js.map +1 -0
- package/dist/index.js +1 -1
- package/dist/middleware/redactor.d.ts +5 -0
- package/dist/middleware/redactor.d.ts.map +1 -1
- package/dist/middleware/redactor.js +69 -0
- package/dist/middleware/redactor.js.map +1 -1
- package/dist/plugins/opencode-hush.d.ts +21 -0
- package/dist/plugins/opencode-hush.d.ts.map +1 -0
- package/dist/plugins/opencode-hush.js +25 -0
- package/dist/plugins/opencode-hush.js.map +1 -0
- package/dist/plugins/sensitive-patterns.d.ts +15 -0
- package/dist/plugins/sensitive-patterns.d.ts.map +1 -0
- package/dist/plugins/sensitive-patterns.js +69 -0
- package/dist/plugins/sensitive-patterns.js.map +1 -0
- package/dist/vault/token-vault.d.ts.map +1 -1
- package/dist/vault/token-vault.js +16 -3
- package/dist/vault/token-vault.js.map +1 -1
- package/examples/team-config/.claude/settings.json +19 -0
- package/examples/team-config/.codex/config.toml +4 -0
- package/examples/team-config/.opencode/plugins/hush.ts +76 -0
- package/examples/team-config/opencode.json +10 -0
- package/package.json +11 -1
- package/scripts/e2e-plugin-block.sh +142 -0
- package/scripts/e2e-proxy-live.sh +185 -0
- package/src/cli.ts +28 -16
- package/src/commands/init.ts +107 -0
- package/src/commands/redact-hook.ts +124 -0
- package/src/index.ts +1 -1
- package/src/middleware/redactor.ts +75 -0
- package/src/plugins/opencode-hush.ts +30 -0
- package/src/plugins/sensitive-patterns.ts +71 -0
- package/src/vault/token-vault.ts +18 -4
- package/tests/init.test.ts +101 -0
- package/tests/opencode-plugin.test.ts +148 -0
- package/tests/redact-hook.test.ts +142 -0
- package/tests/redaction.test.ts +96 -0
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# E2E Scenario B: Proxy redacts PII from normal file reads
|
|
4
|
+
#
|
|
5
|
+
# A non-sensitive filename (config.txt) containing PII gets through the
|
|
6
|
+
# plugin's filename check. The hush proxy intercepts the API request and
|
|
7
|
+
# redacts PII before it reaches the LLM provider.
|
|
8
|
+
#
|
|
9
|
+
# Usage: ./scripts/e2e-proxy-live.sh
|
|
10
|
+
# Requirements: opencode CLI, node, npm (dependencies installed + built)
|
|
11
|
+
|
|
12
|
+
set -euo pipefail
|
|
13
|
+
|
|
14
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
|
|
16
|
+
|
|
17
|
+
# Colors
|
|
18
|
+
RED='\033[0;31m'
|
|
19
|
+
GREEN='\033[0;32m'
|
|
20
|
+
YELLOW='\033[1;33m'
|
|
21
|
+
CYAN='\033[0;36m'
|
|
22
|
+
NC='\033[0m'
|
|
23
|
+
|
|
24
|
+
GATEWAY_PORT=4000
|
|
25
|
+
GATEWAY_PID=""
|
|
26
|
+
PASS_COUNT=0
|
|
27
|
+
FAIL_COUNT=0
|
|
28
|
+
WORK_DIR=""
|
|
29
|
+
|
|
30
|
+
cleanup() {
|
|
31
|
+
echo ""
|
|
32
|
+
echo -e "${CYAN}Cleaning up...${NC}"
|
|
33
|
+
[ -n "$GATEWAY_PID" ] && kill "$GATEWAY_PID" 2>/dev/null || true
|
|
34
|
+
[ -n "$WORK_DIR" ] && rm -rf "$WORK_DIR"
|
|
35
|
+
wait 2>/dev/null || true
|
|
36
|
+
}
|
|
37
|
+
trap cleanup EXIT
|
|
38
|
+
|
|
39
|
+
pass() {
|
|
40
|
+
PASS_COUNT=$((PASS_COUNT + 1))
|
|
41
|
+
echo -e " ${GREEN}PASS${NC} $1"
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
fail() {
|
|
45
|
+
FAIL_COUNT=$((FAIL_COUNT + 1))
|
|
46
|
+
echo -e " ${RED}FAIL${NC} $1"
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
assert_contains() {
|
|
50
|
+
local haystack="$1" needle="$2" msg="$3"
|
|
51
|
+
if echo "$haystack" | grep -qiF "$needle"; then
|
|
52
|
+
pass "$msg"
|
|
53
|
+
else
|
|
54
|
+
fail "$msg (expected to find '$needle')"
|
|
55
|
+
fi
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
assert_not_contains() {
|
|
59
|
+
local haystack="$1" needle="$2" msg="$3"
|
|
60
|
+
if echo "$haystack" | grep -qiF "$needle"; then
|
|
61
|
+
fail "$msg (found '$needle' which should have been redacted)"
|
|
62
|
+
else
|
|
63
|
+
pass "$msg"
|
|
64
|
+
fi
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
wait_for_port() {
|
|
68
|
+
local port=$1 label=$2 max_attempts=${3:-20}
|
|
69
|
+
for i in $(seq 1 "$max_attempts"); do
|
|
70
|
+
if curl -sf "http://127.0.0.1:${port}/health" > /dev/null 2>&1; then
|
|
71
|
+
return 0
|
|
72
|
+
fi
|
|
73
|
+
sleep 0.5
|
|
74
|
+
done
|
|
75
|
+
echo -e "${RED}${label} failed to start on :${port}${NC}"
|
|
76
|
+
return 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
echo -e "${CYAN}================================================${NC}"
|
|
80
|
+
echo -e "${CYAN} E2E Scenario B: Proxy Redacts PII in Normal ${NC}"
|
|
81
|
+
echo -e "${CYAN} File (Plugin Allows, Proxy Catches) ${NC}"
|
|
82
|
+
echo -e "${CYAN}================================================${NC}"
|
|
83
|
+
echo ""
|
|
84
|
+
|
|
85
|
+
cd "$PROJECT_DIR"
|
|
86
|
+
|
|
87
|
+
# --- Step 1: Start Hush gateway ---
|
|
88
|
+
echo -e "${YELLOW}[1/5] Starting Hush gateway on :${GATEWAY_PORT}...${NC}"
|
|
89
|
+
|
|
90
|
+
PORT=$GATEWAY_PORT DEBUG=true node dist/cli.js > /tmp/hush-e2e-proxy.log 2>&1 &
|
|
91
|
+
GATEWAY_PID=$!
|
|
92
|
+
|
|
93
|
+
wait_for_port "$GATEWAY_PORT" "Gateway" || exit 1
|
|
94
|
+
echo -e " Gateway PID: ${GATEWAY_PID}"
|
|
95
|
+
|
|
96
|
+
# --- Step 2: Create temp project with config.txt containing PII ---
|
|
97
|
+
echo -e "${YELLOW}[2/5] Creating temp project with config.txt (PII in normal file)...${NC}"
|
|
98
|
+
|
|
99
|
+
WORK_DIR=$(mktemp -d)
|
|
100
|
+
mkdir -p "$WORK_DIR/.opencode/plugins"
|
|
101
|
+
|
|
102
|
+
# Normal filename — plugin won't block this
|
|
103
|
+
cat > "$WORK_DIR/config.txt" <<'CFGEOF'
|
|
104
|
+
# Application Configuration
|
|
105
|
+
app_name: MyApp
|
|
106
|
+
admin_contact: alice@confidential-corp.com
|
|
107
|
+
server_ip: 10.42.99.7
|
|
108
|
+
api_key=sk-live-a1b2c3d4e5f6g7h8i9j0k1l2m3n4
|
|
109
|
+
log_level: info
|
|
110
|
+
CFGEOF
|
|
111
|
+
|
|
112
|
+
# Copy the hush plugin (it won't block config.txt — not a sensitive filename)
|
|
113
|
+
cp "$PROJECT_DIR/examples/team-config/.opencode/plugins/hush.ts" \
|
|
114
|
+
"$WORK_DIR/.opencode/plugins/hush.ts"
|
|
115
|
+
|
|
116
|
+
# Point OpenCode at hush proxy
|
|
117
|
+
cat > "$WORK_DIR/opencode.json" <<OCEOF
|
|
118
|
+
{
|
|
119
|
+
"provider": {
|
|
120
|
+
"zai-coding-plan": {
|
|
121
|
+
"options": {
|
|
122
|
+
"baseURL": "http://127.0.0.1:${GATEWAY_PORT}/api/coding/paas/v4"
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
},
|
|
126
|
+
"plugin": [".opencode/plugins/hush.ts"]
|
|
127
|
+
}
|
|
128
|
+
OCEOF
|
|
129
|
+
|
|
130
|
+
echo -e " Temp project: ${WORK_DIR}"
|
|
131
|
+
|
|
132
|
+
# --- Step 3: Check vault is empty before test ---
|
|
133
|
+
echo -e "${YELLOW}[3/5] Checking gateway vault is empty before test...${NC}"
|
|
134
|
+
|
|
135
|
+
HEALTH_BEFORE=$(curl -sf "http://127.0.0.1:${GATEWAY_PORT}/health")
|
|
136
|
+
VAULT_BEFORE=$(echo "$HEALTH_BEFORE" | python3 -c "import sys, json; print(json.load(sys.stdin).get('vaultSize', 0))" 2>/dev/null || echo "0")
|
|
137
|
+
echo -e " Vault size before: ${VAULT_BEFORE}"
|
|
138
|
+
|
|
139
|
+
# --- Step 4: Run OpenCode to read config.txt ---
|
|
140
|
+
echo -e "${YELLOW}[4/5] Running OpenCode: 'read config.txt and summarize it'...${NC}"
|
|
141
|
+
|
|
142
|
+
cd "$WORK_DIR"
|
|
143
|
+
OUTPUT=$(timeout 120 opencode -p "read config.txt and summarize it" -q -f json 2>&1) || true
|
|
144
|
+
echo -e " Output length: $(echo "$OUTPUT" | wc -c) bytes"
|
|
145
|
+
|
|
146
|
+
# --- Step 5: Verify proxy redacted PII ---
|
|
147
|
+
echo ""
|
|
148
|
+
echo -e "${YELLOW}[5/5] Verifying proxy intercepted PII...${NC}"
|
|
149
|
+
echo ""
|
|
150
|
+
|
|
151
|
+
# Check vault has tokens
|
|
152
|
+
HEALTH_AFTER=$(curl -sf "http://127.0.0.1:${GATEWAY_PORT}/health")
|
|
153
|
+
VAULT_AFTER=$(echo "$HEALTH_AFTER" | python3 -c "import sys, json; print(json.load(sys.stdin).get('vaultSize', 0))" 2>/dev/null || echo "0")
|
|
154
|
+
echo -e " Vault size after: ${VAULT_AFTER}"
|
|
155
|
+
|
|
156
|
+
if [ "$VAULT_AFTER" -gt 0 ]; then
|
|
157
|
+
pass "Vault contains ${VAULT_AFTER} token(s) — PII was intercepted by proxy"
|
|
158
|
+
else
|
|
159
|
+
fail "Vault is empty (expected > 0 tokens)"
|
|
160
|
+
fi
|
|
161
|
+
|
|
162
|
+
# Check gateway logs for redaction
|
|
163
|
+
GATEWAY_LOG=$(cat /tmp/hush-e2e-proxy.log 2>/dev/null || echo "")
|
|
164
|
+
if echo "$GATEWAY_LOG" | grep -qi "redact"; then
|
|
165
|
+
pass "Gateway logs show redaction activity"
|
|
166
|
+
else
|
|
167
|
+
fail "Gateway logs don't show redaction (may not be an error if log format changed)"
|
|
168
|
+
fi
|
|
169
|
+
|
|
170
|
+
# --- Summary ---
|
|
171
|
+
echo ""
|
|
172
|
+
echo -e "${CYAN}================================================${NC}"
|
|
173
|
+
TOTAL=$((PASS_COUNT + FAIL_COUNT))
|
|
174
|
+
if [ "$FAIL_COUNT" -eq 0 ]; then
|
|
175
|
+
echo -e "${GREEN} ALL ${TOTAL} CHECKS PASSED${NC}"
|
|
176
|
+
echo ""
|
|
177
|
+
echo -e " ${GREEN}Plugin allowed config.txt (not a sensitive filename).${NC}"
|
|
178
|
+
echo -e " ${GREEN}Proxy caught PII in the API request and redacted it.${NC}"
|
|
179
|
+
echo -e " ${GREEN}Defense-in-depth: plugin + proxy working together.${NC}"
|
|
180
|
+
else
|
|
181
|
+
echo -e "${RED} ${FAIL_COUNT}/${TOTAL} CHECKS FAILED${NC}"
|
|
182
|
+
fi
|
|
183
|
+
echo -e "${CYAN}================================================${NC}"
|
|
184
|
+
|
|
185
|
+
exit "$FAIL_COUNT"
|
package/src/cli.ts
CHANGED
|
@@ -1,20 +1,32 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { app } from './index.js';
|
|
3
|
-
import { createLogger } from './lib/logger.js';
|
|
4
2
|
|
|
5
|
-
const
|
|
6
|
-
const PORT = process.env.PORT || 4000;
|
|
3
|
+
const subcommand = process.argv[2];
|
|
7
4
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
})
|
|
5
|
+
if (subcommand === 'redact-hook') {
|
|
6
|
+
const { run } = await import('./commands/redact-hook.js');
|
|
7
|
+
await run();
|
|
8
|
+
} else if (subcommand === 'init') {
|
|
9
|
+
const { run } = await import('./commands/init.js');
|
|
10
|
+
run(process.argv.slice(3));
|
|
11
|
+
} else {
|
|
12
|
+
// Default: start the proxy server
|
|
13
|
+
const { app } = await import('./index.js');
|
|
14
|
+
const { createLogger } = await import('./lib/logger.js');
|
|
12
15
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
log.
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
const log = createLogger('hush-cli');
|
|
17
|
+
const PORT = process.env.PORT || 4000;
|
|
18
|
+
|
|
19
|
+
const server = app.listen(PORT, () => {
|
|
20
|
+
log.info(`Hush Semantic Gateway is listening on http://localhost:${PORT}`);
|
|
21
|
+
log.info(`Routes: /v1/messages → Anthropic, /v1/chat/completions → OpenAI, /api/paas/v4/** → ZhipuAI, * → Google`);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
25
|
+
if (err.code === 'EADDRINUSE') {
|
|
26
|
+
log.error(`Port ${PORT} is already in use. Stop the other process or use PORT=<number> hush`);
|
|
27
|
+
} else {
|
|
28
|
+
log.error({ err }, 'Failed to start server');
|
|
29
|
+
}
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hush init — Generate Claude Code hook configuration
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* hush init --hooks Write to .claude/settings.json
|
|
6
|
+
* hush init --hooks --local Write to .claude/settings.local.json
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
|
|
12
|
+
const HOOK_CONFIG = {
|
|
13
|
+
hooks: {
|
|
14
|
+
PostToolUse: [
|
|
15
|
+
{
|
|
16
|
+
matcher: 'Bash|Read|Grep|WebFetch',
|
|
17
|
+
hooks: [
|
|
18
|
+
{
|
|
19
|
+
type: 'command' as const,
|
|
20
|
+
command: 'hush redact-hook',
|
|
21
|
+
timeout: 10,
|
|
22
|
+
},
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
interface SettingsJson {
|
|
30
|
+
hooks?: {
|
|
31
|
+
PostToolUse?: Array<{
|
|
32
|
+
matcher: string;
|
|
33
|
+
hooks: Array<{ type: string; command: string; timeout?: number }>;
|
|
34
|
+
}>;
|
|
35
|
+
[key: string]: unknown;
|
|
36
|
+
};
|
|
37
|
+
[key: string]: unknown;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasHushHook(settings: SettingsJson): boolean {
|
|
41
|
+
const postToolUse = settings.hooks?.PostToolUse;
|
|
42
|
+
if (!Array.isArray(postToolUse)) return false;
|
|
43
|
+
|
|
44
|
+
return postToolUse.some((entry) =>
|
|
45
|
+
entry.hooks?.some((h) => h.command?.includes('hush redact-hook')),
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function mergeHooks(existing: SettingsJson): SettingsJson {
|
|
50
|
+
const merged = { ...existing };
|
|
51
|
+
|
|
52
|
+
if (!merged.hooks) {
|
|
53
|
+
merged.hooks = {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (!Array.isArray(merged.hooks.PostToolUse)) {
|
|
57
|
+
merged.hooks.PostToolUse = [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
merged.hooks = { ...merged.hooks, PostToolUse: [...merged.hooks.PostToolUse, ...HOOK_CONFIG.hooks.PostToolUse] };
|
|
61
|
+
|
|
62
|
+
return merged;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function run(args: string[]): void {
|
|
66
|
+
const hasHooksFlag = args.includes('--hooks');
|
|
67
|
+
const isLocal = args.includes('--local');
|
|
68
|
+
|
|
69
|
+
if (!hasHooksFlag) {
|
|
70
|
+
process.stderr.write('Usage: hush init --hooks [--local]\n');
|
|
71
|
+
process.stderr.write('\n');
|
|
72
|
+
process.stderr.write('Options:\n');
|
|
73
|
+
process.stderr.write(' --hooks Generate Claude Code PostToolUse hook config\n');
|
|
74
|
+
process.stderr.write(' --local Write to settings.local.json instead of settings.json\n');
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const claudeDir = join(process.cwd(), '.claude');
|
|
79
|
+
const filename = isLocal ? 'settings.local.json' : 'settings.json';
|
|
80
|
+
const filePath = join(claudeDir, filename);
|
|
81
|
+
|
|
82
|
+
// Ensure .claude/ exists
|
|
83
|
+
if (!existsSync(claudeDir)) {
|
|
84
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Read existing settings or start fresh
|
|
88
|
+
let settings: SettingsJson = {};
|
|
89
|
+
if (existsSync(filePath)) {
|
|
90
|
+
try {
|
|
91
|
+
const raw = readFileSync(filePath, 'utf-8');
|
|
92
|
+
settings = JSON.parse(raw) as SettingsJson;
|
|
93
|
+
} catch {
|
|
94
|
+
process.stderr.write(`Warning: could not parse ${filePath}, starting fresh\n`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Idempotency check
|
|
99
|
+
if (hasHushHook(settings)) {
|
|
100
|
+
process.stdout.write(`hush hooks already configured in ${filePath}\n`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const merged = mergeHooks(settings);
|
|
105
|
+
writeFileSync(filePath, JSON.stringify(merged, null, 2) + '\n');
|
|
106
|
+
process.stdout.write(`Wrote hush hooks config to ${filePath}\n`);
|
|
107
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* hush redact-hook — Claude Code PostToolUse hook handler
|
|
3
|
+
*
|
|
4
|
+
* Reads the hook payload from stdin, redacts PII from the tool response,
|
|
5
|
+
* and blocks the output (replacing it with redacted text) if PII was found.
|
|
6
|
+
*
|
|
7
|
+
* Exit codes:
|
|
8
|
+
* 0 — success (may or may not block)
|
|
9
|
+
* 2 — malformed input (blocks the tool call per hooks spec)
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Redactor } from '../middleware/redactor.js';
|
|
13
|
+
|
|
14
|
+
interface HookPayload {
|
|
15
|
+
tool_name?: string;
|
|
16
|
+
tool_input?: Record<string, unknown>;
|
|
17
|
+
tool_response?: {
|
|
18
|
+
// Bash tool
|
|
19
|
+
stdout?: string;
|
|
20
|
+
stderr?: string;
|
|
21
|
+
// Read tool (nested under file)
|
|
22
|
+
file?: { content?: string; [key: string]: unknown };
|
|
23
|
+
// Grep / WebFetch / generic
|
|
24
|
+
content?: string;
|
|
25
|
+
output?: string;
|
|
26
|
+
[key: string]: unknown;
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface HookResponse {
|
|
31
|
+
decision: 'block';
|
|
32
|
+
reason: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Collect all text from a tool_response object. */
|
|
36
|
+
function extractText(toolResponse: HookPayload['tool_response']): string | null {
|
|
37
|
+
if (!toolResponse || typeof toolResponse !== 'object') return null;
|
|
38
|
+
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
|
|
41
|
+
if (typeof toolResponse.stdout === 'string' && toolResponse.stdout) {
|
|
42
|
+
parts.push(toolResponse.stdout);
|
|
43
|
+
}
|
|
44
|
+
if (typeof toolResponse.stderr === 'string' && toolResponse.stderr) {
|
|
45
|
+
parts.push(toolResponse.stderr);
|
|
46
|
+
}
|
|
47
|
+
// Read tool nests content under file.content
|
|
48
|
+
if (toolResponse.file && typeof toolResponse.file.content === 'string' && toolResponse.file.content) {
|
|
49
|
+
parts.push(toolResponse.file.content);
|
|
50
|
+
}
|
|
51
|
+
if (typeof toolResponse.content === 'string' && toolResponse.content) {
|
|
52
|
+
parts.push(toolResponse.content);
|
|
53
|
+
}
|
|
54
|
+
if (typeof toolResponse.output === 'string' && toolResponse.output) {
|
|
55
|
+
parts.push(toolResponse.output);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return parts.length > 0 ? parts.join('\n') : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Redact PII from the tool response text. */
|
|
62
|
+
function redactToolResponse(
|
|
63
|
+
toolResponse: NonNullable<HookPayload['tool_response']>,
|
|
64
|
+
redactor: Redactor,
|
|
65
|
+
): { text: string; hasRedacted: boolean } {
|
|
66
|
+
const text = extractText(toolResponse);
|
|
67
|
+
if (!text) return { text: '', hasRedacted: false };
|
|
68
|
+
|
|
69
|
+
const { content, hasRedacted } = redactor.redact(text);
|
|
70
|
+
return { text: content as string, hasRedacted };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function readStdin(): Promise<string> {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const chunks: Buffer[] = [];
|
|
76
|
+
process.stdin.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
77
|
+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
|
|
78
|
+
process.stdin.on('error', reject);
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function run(): Promise<void> {
|
|
83
|
+
let raw: string;
|
|
84
|
+
try {
|
|
85
|
+
raw = await readStdin();
|
|
86
|
+
} catch {
|
|
87
|
+
process.stderr.write('hush redact-hook: failed to read stdin\n');
|
|
88
|
+
process.exit(2);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!raw.trim()) {
|
|
92
|
+
// Empty stdin — nothing to redact
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let payload: HookPayload;
|
|
97
|
+
try {
|
|
98
|
+
payload = JSON.parse(raw) as HookPayload;
|
|
99
|
+
} catch {
|
|
100
|
+
process.stderr.write('hush redact-hook: invalid JSON on stdin\n');
|
|
101
|
+
process.exit(2);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!payload.tool_response) {
|
|
105
|
+
// No tool_response to redact
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const redactor = new Redactor();
|
|
110
|
+
const { text, hasRedacted } = redactToolResponse(payload.tool_response, redactor);
|
|
111
|
+
|
|
112
|
+
if (!hasRedacted) {
|
|
113
|
+
// No PII found — let Claude Code keep the original output
|
|
114
|
+
process.exit(0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const response: HookResponse = {
|
|
118
|
+
decision: 'block',
|
|
119
|
+
reason: text,
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
process.stdout.write(JSON.stringify(response) + '\n');
|
|
123
|
+
process.exit(0);
|
|
124
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -159,7 +159,7 @@ async function proxyRequest(
|
|
|
159
159
|
|
|
160
160
|
} catch (error) {
|
|
161
161
|
log.error({ err: error, path: req.path }, 'Failed to forward request');
|
|
162
|
-
res.status(
|
|
162
|
+
res.status(502).json({ error: 'Gateway forwarding failed' });
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
@@ -46,6 +46,56 @@ export class Redactor {
|
|
|
46
46
|
PHONE: /(?:^|[\s:;])(?:\+\d{1,3}[-. ]?)?\(?\d{2,4}\)?[-. ]\d{3,4}[-. ]\d{3,4}(?:\s*(?:ext|x)\s*\d+)?/g,
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Cloud provider key patterns — Tier 1 only (unique prefixes, very low false-positive risk).
|
|
51
|
+
* Sources: GitHub secret scanning, gitleaks, trufflehog.
|
|
52
|
+
*/
|
|
53
|
+
private static readonly CLOUD_KEY_PATTERNS: Array<{ re: RegExp; label: string }> = [
|
|
54
|
+
// AWS
|
|
55
|
+
{ re: /\b((?:AKIA|ASIA|ABIA|ACCA)[A-Z2-7]{16})\b/g, label: 'AWS_KEY' },
|
|
56
|
+
// GCP / Firebase
|
|
57
|
+
{ re: /\b(AIza[\w-]{35})\b/g, label: 'GCP_KEY' },
|
|
58
|
+
{ re: /\b(GOCSPX-[a-zA-Z0-9_-]{28})\b/g, label: 'GCP_OAUTH' },
|
|
59
|
+
// GitHub
|
|
60
|
+
{ re: /\b(ghp_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_PAT' },
|
|
61
|
+
{ re: /\b(gho_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_OAUTH' },
|
|
62
|
+
{ re: /\b(ghu_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_U2S' },
|
|
63
|
+
{ re: /\b(ghs_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_S2S' },
|
|
64
|
+
{ re: /\b(ghr_[0-9a-zA-Z]{36})\b/g, label: 'GITHUB_REFRESH' },
|
|
65
|
+
{ re: /\b(github_pat_[a-zA-Z0-9]{22}_[a-zA-Z0-9]{59})\b/g, label: 'GITHUB_FINE_PAT' },
|
|
66
|
+
// GitLab
|
|
67
|
+
{ re: /\b(glpat-[\w-]{20})\b/g, label: 'GITLAB_PAT' },
|
|
68
|
+
{ re: /\b(glptt-[a-zA-Z0-9_-]{40})\b/g, label: 'GITLAB_TRIGGER' },
|
|
69
|
+
// Slack
|
|
70
|
+
{ re: /\b(xoxb-[0-9]{10,13}-[0-9]{10,13}[a-zA-Z0-9-]*)\b/g, label: 'SLACK_BOT' },
|
|
71
|
+
{ re: /\b(xox[pe]-[0-9]{10,13}-[0-9]{10,13}-[a-zA-Z0-9-]+)\b/g, label: 'SLACK_TOKEN' },
|
|
72
|
+
// Stripe
|
|
73
|
+
{ re: /\b(sk_(?:live|test)_[a-zA-Z0-9]{10,99})\b/g, label: 'STRIPE_SECRET' },
|
|
74
|
+
{ re: /\b(rk_(?:live|test)_[a-zA-Z0-9]{10,99})\b/g, label: 'STRIPE_RESTRICTED' },
|
|
75
|
+
{ re: /\b(whsec_[a-zA-Z0-9]{24,})\b/g, label: 'STRIPE_WEBHOOK' },
|
|
76
|
+
// SendGrid (SG. + base64url with internal dot separator)
|
|
77
|
+
{ re: /\b(SG\.[a-zA-Z0-9_-]{22}\.[a-zA-Z0-9_-]{43})\b/g, label: 'SENDGRID_KEY' },
|
|
78
|
+
// npm
|
|
79
|
+
{ re: /\b(npm_[a-z0-9]{36})\b/gi, label: 'NPM_TOKEN' },
|
|
80
|
+
// PyPI
|
|
81
|
+
{ re: /\b(pypi-AgEIcHlwaS5vcmc[\w-]{50,})\b/g, label: 'PYPI_TOKEN' },
|
|
82
|
+
// Docker Hub
|
|
83
|
+
{ re: /\b(dckr_pat_[a-zA-Z0-9_-]{27,})\b/g, label: 'DOCKER_PAT' },
|
|
84
|
+
// Anthropic
|
|
85
|
+
{ re: /\b(sk-ant-[a-zA-Z0-9_-]{36,})\b/g, label: 'ANTHROPIC_KEY' },
|
|
86
|
+
// OpenAI (with T3BlbkFJ marker)
|
|
87
|
+
{ re: /\b(sk-(?:proj|svcacct|admin)-[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,})\b/g, label: 'OPENAI_KEY' },
|
|
88
|
+
// DigitalOcean
|
|
89
|
+
{ re: /\b(do[por]_v1_[a-f0-9]{64})\b/g, label: 'DIGITALOCEAN_TOKEN' },
|
|
90
|
+
// HashiCorp Vault
|
|
91
|
+
{ re: /\b(hvs\.[\w-]{90,})\b/g, label: 'VAULT_TOKEN' },
|
|
92
|
+
{ re: /\b(hvb\.[\w-]{90,})\b/g, label: 'VAULT_BATCH' },
|
|
93
|
+
// Supabase
|
|
94
|
+
{ re: /\b(sbp_[a-f0-9]{40})\b/g, label: 'SUPABASE_PAT' },
|
|
95
|
+
{ re: /\b(sb_secret_[a-zA-Z0-9_-]{20,})\b/g, label: 'SUPABASE_SECRET' },
|
|
96
|
+
// PEM private keys (multiline — matched separately in redactPEMKeys)
|
|
97
|
+
];
|
|
98
|
+
|
|
49
99
|
/**
|
|
50
100
|
* Redact sensitive information from a JSON object or string.
|
|
51
101
|
*
|
|
@@ -102,6 +152,31 @@ export class Redactor {
|
|
|
102
152
|
return token;
|
|
103
153
|
});
|
|
104
154
|
|
|
155
|
+
// Redact cloud provider keys BEFORE generic patterns — specific prefixed
|
|
156
|
+
// keys must be matched first so they don't get partially eaten by SECRET
|
|
157
|
+
// or CREDIT_CARD patterns.
|
|
158
|
+
for (const { re, label } of Redactor.CLOUD_KEY_PATTERNS) {
|
|
159
|
+
re.lastIndex = 0;
|
|
160
|
+
text = text.replace(re, (match, p1: string) => {
|
|
161
|
+
hasRedacted = true;
|
|
162
|
+
const val = p1 || match;
|
|
163
|
+
const token = `[${label}_${tokenHash(val)}]`;
|
|
164
|
+
tokens.set(token, val);
|
|
165
|
+
return token;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Redact PEM private keys
|
|
170
|
+
text = text.replace(
|
|
171
|
+
/-----BEGIN[ A-Z0-9_-]{0,100}PRIVATE KEY-----[\s\S]{64,}?-----END[ A-Z0-9_-]{0,100}PRIVATE KEY-----/g,
|
|
172
|
+
(match) => {
|
|
173
|
+
hasRedacted = true;
|
|
174
|
+
const token = `[PRIVATE_KEY_${tokenHash(match)}]`;
|
|
175
|
+
tokens.set(token, match);
|
|
176
|
+
return token;
|
|
177
|
+
},
|
|
178
|
+
);
|
|
179
|
+
|
|
105
180
|
// Redact Secrets in text (e.g. "api_key=...")
|
|
106
181
|
text = text.replace(Redactor.PATTERNS.SECRET, (match, p1) => {
|
|
107
182
|
hasRedacted = true;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Plugin: Hush PII Guard
|
|
3
|
+
*
|
|
4
|
+
* Blocks reads of sensitive files (`.env`, `*.pem`, `credentials.*`, etc.)
|
|
5
|
+
* before the tool executes — the AI model never sees the content.
|
|
6
|
+
*
|
|
7
|
+
* Defense-in-depth: works alongside the Hush proxy which redacts PII from
|
|
8
|
+
* API requests. The plugin prevents file reads; the proxy catches anything
|
|
9
|
+
* that slips through in normal files.
|
|
10
|
+
*
|
|
11
|
+
* Install: copy to `.opencode/plugins/hush.ts` and add to `opencode.json`:
|
|
12
|
+
* { "plugin": [".opencode/plugins/hush.ts"] }
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { isSensitivePath, commandReadsSensitiveFile } from './sensitive-patterns.js';
|
|
16
|
+
|
|
17
|
+
export const HushPlugin = async () => ({
|
|
18
|
+
'tool.execute.before': async (
|
|
19
|
+
input: { tool: string },
|
|
20
|
+
output: { args: Record<string, string> },
|
|
21
|
+
) => {
|
|
22
|
+
if (input.tool === 'read' && isSensitivePath(output.args['filePath'] ?? '')) {
|
|
23
|
+
throw new Error('[hush] Blocked: sensitive file');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (input.tool === 'bash' && commandReadsSensitiveFile(output.args['command'] ?? '')) {
|
|
27
|
+
throw new Error('[hush] Blocked: command reads sensitive file');
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for detecting sensitive file paths and commands.
|
|
3
|
+
* Used by the OpenCode hush plugin to block reads of secret files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Glob-style patterns for files that should never be read by AI tools. */
|
|
7
|
+
const SENSITIVE_GLOBS = [
|
|
8
|
+
/^\.env($|\..*)/, // .env, .env.local, .env.production, etc.
|
|
9
|
+
/credentials/i,
|
|
10
|
+
/secret/i,
|
|
11
|
+
/\.pem$/,
|
|
12
|
+
/\.key$/,
|
|
13
|
+
/\.p12$/,
|
|
14
|
+
/\.pfx$/,
|
|
15
|
+
/\.jks$/,
|
|
16
|
+
/\.keystore$/,
|
|
17
|
+
/\.asc$/,
|
|
18
|
+
/^id_rsa/,
|
|
19
|
+
/^\.netrc$/,
|
|
20
|
+
/^\.pgpass$/,
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Check whether a file path points to a sensitive file.
|
|
25
|
+
* Matches against the basename only so absolute/relative paths both work.
|
|
26
|
+
*/
|
|
27
|
+
export function isSensitivePath(filePath: string): boolean {
|
|
28
|
+
const basename = (filePath.split('/').pop() ?? '').trim();
|
|
29
|
+
return SENSITIVE_GLOBS.some((re) => re.test(basename));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Commands that read file contents (includes batcat — Ubuntu symlink for bat). */
|
|
33
|
+
const READ_COMMANDS = /\b(cat|head|tail|less|more|bat|batcat)\b/;
|
|
34
|
+
|
|
35
|
+
/** Strip shell metacharacters that could wrap a filename to bypass detection. */
|
|
36
|
+
function stripShellMeta(token: string): string {
|
|
37
|
+
return token.replace(/[`"'$(){}]/g, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Check whether a bash command reads a sensitive file.
|
|
42
|
+
* Looks for common read commands followed by a sensitive filename.
|
|
43
|
+
*/
|
|
44
|
+
export function commandReadsSensitiveFile(cmd: string): boolean {
|
|
45
|
+
if (!READ_COMMANDS.test(cmd)) return false;
|
|
46
|
+
|
|
47
|
+
// Check input redirections: `cat <.env` or `cat < .env`
|
|
48
|
+
// The file after `<` is read by the preceding command.
|
|
49
|
+
const redirectPattern = /<\s*([^\s|;&<>]+)/g;
|
|
50
|
+
let rMatch;
|
|
51
|
+
while ((rMatch = redirectPattern.exec(cmd)) !== null) {
|
|
52
|
+
if (isSensitivePath(stripShellMeta(rMatch[1]!))) return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Split on pipes, semicolons, &&, and redirections to get individual commands
|
|
56
|
+
const parts = cmd.split(/[|;&<>]+/);
|
|
57
|
+
for (const part of parts) {
|
|
58
|
+
const tokens = part.trim().split(/\s+/);
|
|
59
|
+
const cmdIndex = tokens.findIndex((t) => READ_COMMANDS.test(t));
|
|
60
|
+
if (cmdIndex === -1) continue;
|
|
61
|
+
|
|
62
|
+
// Check all tokens after the command for sensitive paths (skip flags).
|
|
63
|
+
for (let i = cmdIndex + 1; i < tokens.length; i++) {
|
|
64
|
+
const token = tokens[i]!;
|
|
65
|
+
if (token.startsWith('-')) continue; // skip flags like -n, -5
|
|
66
|
+
const cleaned = stripShellMeta(token);
|
|
67
|
+
if (isSensitivePath(cleaned)) return true;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return false;
|
|
71
|
+
}
|