Skip to content

Commit 524e5ad

Browse files
committed
chore(sync): cascade fleet template@032a512
Auto-applied by socket-wheelhouse sync-scaffolding into cascade-socket-bin-54762. 32 file(s) touched: - .claude/hooks/no-blind-keychain-read-guard/README.md - .claude/hooks/no-blind-keychain-read-guard/index.mts - .claude/hooks/no-blind-keychain-read-guard/package.json - .claude/hooks/no-blind-keychain-read-guard/test/index.test.mts - .claude/hooks/no-blind-keychain-read-guard/tsconfig.json - .claude/hooks/path-guard/README.md - .claude/hooks/path-guard/segments.mts - .claude/settings.json - .claude/skills/cascading-fleet/lib/cascade-template.sh - .claude/skills/cascading-fleet/lib/fleet-repos.json - .claude/skills/cascading-fleet/lib/fleet-repos.txt - .config/.markdownlint-cli2.jsonc - .config/.prettierignore - .config/markdownlint-rules/_shared/wheelhouse-self-skip.mjs - .config/markdownlint-rules/socket-no-private-wheelhouse-leak.mjs - .config/markdownlint-rules/socket-no-relative-sibling-script.mjs - .config/markdownlint-rules/socket-readme-required-sections.mjs - .config/oxlint-plugin/rules/prefer-cached-for-loop.mts - .config/oxlint-plugin/rules/socket-api-token-env.mts - .config/oxlint-plugin/rules/sort-boolean-chains.mts ... and 12 more
1 parent 308dcd6 commit 524e5ad

32 files changed

Lines changed: 1073 additions & 121 deletions
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# no-blind-keychain-read-guard
2+
3+
`PreToolUse(Bash)` blocker that refuses direct keychain READ calls
4+
from Bash. The keychain APIs surface a UI auth prompt per call;
5+
reading three times costs three prompts. The fleet's canonical
6+
in-process resolver (`api-token.mts.findApiToken()`) caches the
7+
value module-scoped after the first hit, so subsequent code paths
8+
should never need to re-read the keychain.
9+
10+
## Detected reads
11+
12+
| Platform | Pattern |
13+
| --------------- | ------------------------------------------- |
14+
| macOS | `security find-{generic,internet}-password` |
15+
| Linux | `secret-tool lookup` / `secret-tool search` |
16+
| Windows | `Get-StoredCredential` |
17+
| Windows | `Get-Credential … \| ConvertFrom-SecureString` |
18+
| cross-platform | `keyring get` |
19+
20+
## Allowed (not flagged)
21+
22+
Writes and deletes — these only happen during operator-driven
23+
setup / rotation, never on hot paths:
24+
25+
- `security add-generic-password` / `security delete-generic-password`
26+
- `secret-tool store` / `secret-tool clear`
27+
- `New-StoredCredential` / `Remove-StoredCredential`
28+
- `keyring set` / `keyring del`
29+
30+
## Bypass
31+
32+
Type the canonical phrase verbatim in your next user turn:
33+
34+
```
35+
Allow blind-keychain-read bypass
36+
```
37+
38+
Use when you genuinely need a fresh keychain read — operator-invoked
39+
diagnostics, verifying an entry exists, etc.
40+
41+
## Why
42+
43+
`security find-generic-password` on macOS prompts the user every call
44+
unless the calling process is on the entry's ACL. Claude Code's Bash
45+
tool spawns a fresh process per call, so each `security` invocation
46+
re-prompts. The same shape exists on Linux (`secret-tool` against
47+
gnome-keyring / kwallet) and Windows (`Get-StoredCredential` against
48+
the CredentialManager UI).
49+
50+
The right answer is to read the cached value from process state:
51+
52+
```ts
53+
import { findApiToken } from '../setup-security-tools/lib/api-token.mts'
54+
const { token } = findApiToken() // module-cached after first call
55+
```
56+
57+
Or from a child process spawned by hooks:
58+
59+
```bash
60+
echo "$SOCKET_API_KEY" # populated by wheelhouse shell-rc bridge
61+
```
62+
63+
The bridge writes the token to `~/.zshenv` (or platform equivalent)
64+
so every new shell exports `SOCKET_API_KEY` + `SOCKET_API_TOKEN`
65+
without a keychain read.
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
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+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
{
2+
"name": "hook-no-blind-keychain-read-guard",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"scripts": {
10+
"test": "node --test test/*.test.mts"
11+
},
12+
"devDependencies": {
13+
"@types/node": "catalog:"
14+
}
15+
}

0 commit comments

Comments
 (0)