|
| 1 | +#!/usr/bin/env node |
| 2 | +// Claude Code PreToolUse hook — no-blind-keychain-read-guard. |
| 3 | +// |
| 4 | +// Blocks Bash invocations that READ a credential from the OS |
| 5 | +// keychain. Reading via the platform CLI surfaces a per-call UI auth |
| 6 | +// prompt on the user's screen ("this app wants to access your |
| 7 | +// keychain"), and the prompt fires once per call — a hook chain that |
| 8 | +// reads the keychain three times costs three prompts. Tokens are |
| 9 | +// already cached in process memory after the first resolution; the |
| 10 | +// fleet's canonical resolver (`api-token.mts.findApiToken()`) hits |
| 11 | +// the cache, then env, then keychain, in that order. Bash callers |
| 12 | +// that go straight to `security find-generic-password` skip all of |
| 13 | +// that and re-prompt the user every time. |
| 14 | +// |
| 15 | +// Detects (case-sensitive, structural — not just substring): |
| 16 | +// |
| 17 | +// macOS: |
| 18 | +// security find-generic-password |
| 19 | +// security find-internet-password |
| 20 | +// |
| 21 | +// Linux: |
| 22 | +// secret-tool lookup |
| 23 | +// secret-tool search |
| 24 | +// |
| 25 | +// Windows (PowerShell): |
| 26 | +// Get-StoredCredential (CredentialManager module) |
| 27 | +// Get-Credential (when piping to ConvertFrom-SecureString) |
| 28 | +// |
| 29 | +// Cross-platform (Python keyring CLI): |
| 30 | +// keyring get |
| 31 | +// |
| 32 | +// Allowed (writes / deletes — necessary for operator-driven setup / |
| 33 | +// rotation, never on hot paths): |
| 34 | +// |
| 35 | +// security add-generic-password security delete-generic-password |
| 36 | +// secret-tool store secret-tool clear |
| 37 | +// New-StoredCredential Remove-StoredCredential |
| 38 | +// keyring set keyring del |
| 39 | +// |
| 40 | +// Bypass: `Allow blind-keychain-read bypass` in a recent user turn. |
| 41 | +// Use when you genuinely need to verify a keychain entry exists |
| 42 | +// (e.g. operator-invoked diagnostics). |
| 43 | +// |
| 44 | +// Exit codes: |
| 45 | +// 0 — pass. |
| 46 | +// 2 — block. |
| 47 | +// |
| 48 | +// Fails open on malformed payloads (exit 0 + stderr log) — the fleet's |
| 49 | +// hook contract. |
| 50 | + |
| 51 | +import process from 'node:process' |
| 52 | + |
| 53 | +import { bypassPhrasePresent } from '../_shared/transcript.mts' |
| 54 | + |
| 55 | +interface ToolInput { |
| 56 | + readonly tool_input?: |
| 57 | + | { |
| 58 | + readonly command?: string | undefined |
| 59 | + } |
| 60 | + | undefined |
| 61 | + readonly tool_name?: string | undefined |
| 62 | + readonly transcript_path?: string | undefined |
| 63 | +} |
| 64 | + |
| 65 | +interface Hit { |
| 66 | + readonly tool: string |
| 67 | + readonly platform: 'macos' | 'linux' | 'windows' | 'cross-platform' |
| 68 | + readonly snippet: string |
| 69 | +} |
| 70 | + |
| 71 | +const BYPASS_PHRASE = 'Allow blind-keychain-read bypass' |
| 72 | + |
| 73 | +// Token-bearing read patterns. Each entry: the literal verb that |
| 74 | +// surfaces a UI prompt + a label for the error message. Writes / |
| 75 | +// deletes are intentionally absent from this list. |
| 76 | +const READ_PATTERNS: ReadonlyArray<{ |
| 77 | + readonly re: RegExp |
| 78 | + readonly tool: string |
| 79 | + readonly platform: Hit['platform'] |
| 80 | +}> = [ |
| 81 | + // macOS — `security(1)`. The `-w` flag prints the password to |
| 82 | + // stdout, but even the metadata-only form triggers the ACL prompt. |
| 83 | + { |
| 84 | + re: /\bsecurity\s+(?:find-generic-password|find-internet-password)\b/, |
| 85 | + tool: 'security find-*-password', |
| 86 | + platform: 'macos', |
| 87 | + }, |
| 88 | + // Linux — `secret-tool`. `lookup` returns the password; `search` |
| 89 | + // lists matches (also surfaces the libsecret prompt). |
| 90 | + { |
| 91 | + re: /\bsecret-tool\s+(?:lookup|search)\b/, |
| 92 | + tool: 'secret-tool lookup/search', |
| 93 | + platform: 'linux', |
| 94 | + }, |
| 95 | + // Windows PowerShell — CredentialManager module. The |
| 96 | + // `Get-StoredCredential` cmdlet returns a PSCredential; reading |
| 97 | + // `.Password | ConvertFrom-SecureString` is the read pattern. |
| 98 | + { |
| 99 | + re: /\bGet-StoredCredential\b/, |
| 100 | + tool: 'Get-StoredCredential', |
| 101 | + platform: 'windows', |
| 102 | + }, |
| 103 | + // PowerShell `Get-Credential -Credential` piped to |
| 104 | + // `ConvertFrom-SecureString -AsPlainText` is the readback shape. |
| 105 | + // The bare `Get-Credential` (no pipe) is a fresh-prompt-the-user |
| 106 | + // flow and not the issue here — match only the readback pipe. |
| 107 | + { |
| 108 | + re: /\bGet-Credential\b[^|]*\|\s*ConvertFrom-SecureString\b/, |
| 109 | + tool: 'Get-Credential | ConvertFrom-SecureString', |
| 110 | + platform: 'windows', |
| 111 | + }, |
| 112 | + // Python `keyring` CLI — `keyring get <service> <username>`. |
| 113 | + { |
| 114 | + re: /\bkeyring\s+get\b/, |
| 115 | + tool: 'keyring get', |
| 116 | + platform: 'cross-platform', |
| 117 | + }, |
| 118 | +] |
| 119 | + |
| 120 | +/** |
| 121 | + * Scan a Bash command string for keychain READ patterns. Returns one hit per |
| 122 | + * matching subcommand so the error message can name them all (a `&&`-chained |
| 123 | + * command might have multiple). |
| 124 | + */ |
| 125 | +export function findKeychainReads(command: string): Hit[] { |
| 126 | + const hits: Hit[] = [] |
| 127 | + for (let i = 0, { length } = READ_PATTERNS; i < length; i += 1) { |
| 128 | + const entry = READ_PATTERNS[i]! |
| 129 | + const m = entry.re.exec(command) |
| 130 | + if (!m) { |
| 131 | + continue |
| 132 | + } |
| 133 | + // Pull a short snippet around the match (up to 80 chars) so the |
| 134 | + // operator can see the context. Centered on the match start. |
| 135 | + const start = Math.max(0, m.index - 10) |
| 136 | + const end = Math.min(command.length, m.index + m[0].length + 50) |
| 137 | + const snippet = command.slice(start, end) |
| 138 | + hits.push({ |
| 139 | + tool: entry.tool, |
| 140 | + platform: entry.platform, |
| 141 | + snippet: snippet.length < command.length ? `…${snippet}…` : snippet, |
| 142 | + }) |
| 143 | + } |
| 144 | + return hits |
| 145 | +} |
| 146 | + |
| 147 | +function handlePayload(payloadRaw: string): number { |
| 148 | + let payload: ToolInput |
| 149 | + try { |
| 150 | + payload = JSON.parse(payloadRaw) as ToolInput |
| 151 | + } catch { |
| 152 | + return 0 |
| 153 | + } |
| 154 | + if (payload.tool_name !== 'Bash') { |
| 155 | + return 0 |
| 156 | + } |
| 157 | + const command = payload.tool_input?.command ?? '' |
| 158 | + if (!command) { |
| 159 | + return 0 |
| 160 | + } |
| 161 | + const hits = findKeychainReads(command) |
| 162 | + if (hits.length === 0) { |
| 163 | + return 0 |
| 164 | + } |
| 165 | + if (bypassPhrasePresent(payload.transcript_path, BYPASS_PHRASE)) { |
| 166 | + return 0 |
| 167 | + } |
| 168 | + const lines: string[] = [] |
| 169 | + lines.push( |
| 170 | + '[no-blind-keychain-read-guard] Blocked: direct keychain READ from Bash.', |
| 171 | + ) |
| 172 | + lines.push('') |
| 173 | + for (let i = 0, { length } = hits; i < length; i += 1) { |
| 174 | + const h = hits[i]! |
| 175 | + lines.push(` ${h.platform.padEnd(15)} ${h.tool}`) |
| 176 | + lines.push(` Saw: ${h.snippet}`) |
| 177 | + } |
| 178 | + lines.push('') |
| 179 | + lines.push( |
| 180 | + ' Reading the keychain via the platform CLI surfaces a UI auth', |
| 181 | + ) |
| 182 | + lines.push( |
| 183 | + " prompt on the user's screen — and the prompt fires once per", |
| 184 | + ) |
| 185 | + lines.push( |
| 186 | + ' call. A hook chain that reads three times costs three prompts.', |
| 187 | + ) |
| 188 | + lines.push('') |
| 189 | + lines.push(' The token is almost certainly already available without a') |
| 190 | + lines.push(' keychain read:') |
| 191 | + lines.push('') |
| 192 | + lines.push( |
| 193 | + ' - In-process: call findApiToken() from setup-security-tools/', |
| 194 | + ) |
| 195 | + lines.push( |
| 196 | + ' lib/api-token.mts. It returns the module-cached value from', |
| 197 | + ) |
| 198 | + lines.push(' the first call onward, then env, then keychain.') |
| 199 | + lines.push('') |
| 200 | + lines.push(' - From Bash: read process.env.SOCKET_API_KEY or') |
| 201 | + lines.push( |
| 202 | + ' process.env.SOCKET_API_TOKEN. The wheelhouse shell-rc bridge', |
| 203 | + ) |
| 204 | + lines.push(' exports both for every new shell session.') |
| 205 | + lines.push('') |
| 206 | + lines.push( |
| 207 | + ' Writes / deletes (security add-generic-password / secret-tool', |
| 208 | + ) |
| 209 | + lines.push( |
| 210 | + ' store / New-StoredCredential / etc.) are allowed — they only', |
| 211 | + ) |
| 212 | + lines.push(' happen during operator-driven setup / rotation.') |
| 213 | + lines.push('') |
| 214 | + lines.push(' Bypass (e.g. operator-invoked diagnostics that need a fresh') |
| 215 | + lines.push(' keychain read):') |
| 216 | + lines.push(` Type "${BYPASS_PHRASE}" in your next message.`) |
| 217 | + process.stderr.write(lines.join('\n') + '\n') |
| 218 | + return 2 |
| 219 | +} |
| 220 | + |
| 221 | +export { handlePayload } |
| 222 | + |
| 223 | +// CLI entrypoint — only fires when this file is the main module. |
| 224 | +// During tests the importer pulls `findKeychainReads` without triggering |
| 225 | +// the stdin reader (which would never see an `end` event in test env |
| 226 | +// and hang the process). |
| 227 | +if (process.argv[1] && process.argv[1].endsWith('index.mts')) { |
| 228 | + let payloadRaw = '' |
| 229 | + process.stdin.setEncoding('utf8') |
| 230 | + process.stdin.on('data', chunk => { |
| 231 | + payloadRaw += chunk |
| 232 | + }) |
| 233 | + process.stdin.on('end', () => { |
| 234 | + try { |
| 235 | + process.exit(handlePayload(payloadRaw)) |
| 236 | + } catch (e) { |
| 237 | + process.stderr.write( |
| 238 | + `[no-blind-keychain-read-guard] hook error (allowing): ${e}\n`, |
| 239 | + ) |
| 240 | + process.exit(0) |
| 241 | + } |
| 242 | + }) |
| 243 | +} |
0 commit comments