@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.
Files changed (47) hide show
  1. package/.gitlab-ci.yml +59 -0
  2. package/README.md +150 -3
  3. package/dist/cli.js +30 -17
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/init.d.ts +9 -0
  6. package/dist/commands/init.d.ts.map +1 -0
  7. package/dist/commands/init.js +81 -0
  8. package/dist/commands/init.js.map +1 -0
  9. package/dist/commands/redact-hook.d.ts +12 -0
  10. package/dist/commands/redact-hook.d.ts.map +1 -0
  11. package/dist/commands/redact-hook.js +89 -0
  12. package/dist/commands/redact-hook.js.map +1 -0
  13. package/dist/index.js +1 -1
  14. package/dist/middleware/redactor.d.ts +5 -0
  15. package/dist/middleware/redactor.d.ts.map +1 -1
  16. package/dist/middleware/redactor.js +69 -0
  17. package/dist/middleware/redactor.js.map +1 -1
  18. package/dist/plugins/opencode-hush.d.ts +21 -0
  19. package/dist/plugins/opencode-hush.d.ts.map +1 -0
  20. package/dist/plugins/opencode-hush.js +25 -0
  21. package/dist/plugins/opencode-hush.js.map +1 -0
  22. package/dist/plugins/sensitive-patterns.d.ts +15 -0
  23. package/dist/plugins/sensitive-patterns.d.ts.map +1 -0
  24. package/dist/plugins/sensitive-patterns.js +69 -0
  25. package/dist/plugins/sensitive-patterns.js.map +1 -0
  26. package/dist/vault/token-vault.d.ts.map +1 -1
  27. package/dist/vault/token-vault.js +16 -3
  28. package/dist/vault/token-vault.js.map +1 -1
  29. package/examples/team-config/.claude/settings.json +19 -0
  30. package/examples/team-config/.codex/config.toml +4 -0
  31. package/examples/team-config/.opencode/plugins/hush.ts +76 -0
  32. package/examples/team-config/opencode.json +10 -0
  33. package/package.json +11 -1
  34. package/scripts/e2e-plugin-block.sh +142 -0
  35. package/scripts/e2e-proxy-live.sh +185 -0
  36. package/src/cli.ts +28 -16
  37. package/src/commands/init.ts +107 -0
  38. package/src/commands/redact-hook.ts +124 -0
  39. package/src/index.ts +1 -1
  40. package/src/middleware/redactor.ts +75 -0
  41. package/src/plugins/opencode-hush.ts +30 -0
  42. package/src/plugins/sensitive-patterns.ts +71 -0
  43. package/src/vault/token-vault.ts +18 -4
  44. package/tests/init.test.ts +101 -0
  45. package/tests/opencode-plugin.test.ts +148 -0
  46. package/tests/redact-hook.test.ts +142 -0
  47. 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 log = createLogger('hush-cli');
6
- const PORT = process.env.PORT || 4000;
3
+ const subcommand = process.argv[2];
7
4
 
8
- const server = app.listen(PORT, () => {
9
- log.info(`Hush Semantic Gateway is listening on http://localhost:${PORT}`);
10
- log.info(`Routes: /v1/messages → Anthropic, /v1/chat/completions → OpenAI, /api/paas/v4/** → ZhipuAI, * → Google`);
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
- server.on('error', (err: NodeJS.ErrnoException) => {
14
- if (err.code === 'EADDRINUSE') {
15
- log.error(`Port ${PORT} is already in use. Stop the other process or use PORT=<number> hush`);
16
- } else {
17
- log.error({ err }, 'Failed to start server');
18
- }
19
- process.exit(1);
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(500).json({ error: 'Gateway forwarding failed' });
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
+ }