From 214c42cdb3dd029f593ce60bf487fba41e41709d Mon Sep 17 00:00:00 2001 From: AgentSeal Date: Sun, 14 Jun 2026 10:24:15 +0200 Subject: [PATCH] fix(menubar): read Claude keychain via security CLI on silent refresh (#490) Background token refreshes re-read the "Claude Code-credentials" keychain item via the Security framework. On macOS Sierra+, access is governed by the item's partition list, not the legacy "Always Allow" ACL. Claude Code resets that partition list every time it rotates the credential, dropping our app from the allowed set, so the next read raises a fresh keychain password prompt. On a heavy usage day this fires dozens of times. The LAContext interactionNotAllowed flag we relied on does not suppress that prompt for a plain generic-password item. Route the silent path (proactive refresh and post-401 re-read) through /usr/bin/security instead. The Apple-signed security binary sits in the item's apple-tool: partition, so it reads the secret without prompting and without depending on the user's ACL grant. It is read-only and never spends the shared refresh token, preserving the existing invariant that the Claude CLI owns the grant. The user-initiated bootstrap keeps the framework read, where a single consent prompt is expected. Drops the now-unused LocalAuthentication import. --- .../Data/ClaudeCredentialStore.swift | 63 +++++++++++++------ 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift index df5b1a38..002e861e 100644 --- a/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift +++ b/mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift @@ -1,5 +1,4 @@ import Foundation -import LocalAuthentication import Security /// Owns the lifecycle of Claude OAuth credentials, mirroring CodexBar's pattern: @@ -201,18 +200,30 @@ enum ClaudeCredentialStore { /// entries under different account names — older versions used "agentseal" /// (a hardcoded company-style identifier) while Claude Code 2.1.x writes /// under `$USER` (NSUserName()). After a user re-runs `/login`, both - /// entries can coexist and `SecItemCopyMatching` with kSecMatchLimitOne - /// often returns the older stale one. We try the user-keyed entry first - /// (the modern format), then fall back to the unscoped query for older - /// installations. + /// entries can coexist and a service-only lookup often returns the older + /// stale one. We try the user-keyed entry first (the modern format), then + /// fall back to the unscoped query for older installations. + /// + /// Silent background reads go through the `security` CLI rather than the + /// Security framework. The Apple-signed `security` binary sits in the + /// keychain item's `apple-tool:` partition, so it never raises the + /// partition-list prompt. The framework API does — and re-prompts every + /// time Claude Code rotates its credential and resets the item's partition + /// list, dropping our app from the allowed set (issue #490). Only the + /// user-initiated bootstrap still reads through the framework, where a + /// single consent prompt is expected. private static func readClaudeKeychain(allowUI: Bool) throws -> CredentialRecord? { - if let record = try readClaudeKeychain(account: NSUserName(), allowUI: allowUI) { + if !allowUI { + return readClaudeKeychainSilently(account: NSUserName()) + ?? readClaudeKeychainSilently(account: nil) + } + if let record = try readClaudeKeychainPrompting(account: NSUserName()) { return record } - return try readClaudeKeychain(account: nil, allowUI: allowUI) + return try readClaudeKeychainPrompting(account: nil) } - private static func readClaudeKeychain(account: String?, allowUI: Bool) throws -> CredentialRecord? { + private static func readClaudeKeychainPrompting(account: String?) throws -> CredentialRecord? { var query: [String: Any] = [ kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: claudeKeychainService, @@ -220,26 +231,40 @@ enum ClaudeCredentialStore { kSecReturnData as String: true, ] if let account { query[kSecAttrAccount as String] = account } - if !allowUI { - // Background refresh cycles must never raise a keychain prompt. Fail - // the read instead. Relies on the user having granted "Always Allow" - // on the one-time bootstrap prompt. - let context = LAContext() - context.interactionNotAllowed = true - query[kSecUseAuthenticationContext as String] = context - } var result: CFTypeRef? let status = SecItemCopyMatching(query as CFDictionary, &result) if status == errSecItemNotFound { return nil } - // Silent read that would need interaction: treat as "no fresher token - // available", not an error. The caller falls back to the cached token. - if !allowUI, status == errSecInteractionNotAllowed { return nil } guard status == errSecSuccess, let data = result as? Data else { throw StoreError.keychainReadFailed(status) } return try parseClaudeBlob(data: sanitizeClaudeBlob(data)) } + /// Reads Claude's keychain entry via `/usr/bin/security`, which never raises + /// the partition-list prompt. Returns nil on any failure so the caller falls + /// back to the cached token. + private static func readClaudeKeychainSilently(account: String?) -> CredentialRecord? { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/security") + var args = ["find-generic-password", "-s", claudeKeychainService] + if let account { args += ["-a", account] } + args.append("-w") + process.arguments = args + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = FileHandle.nullDevice + do { + try process.run() + process.waitUntilExit() + guard process.terminationStatus == 0 else { return nil } + let data = pipe.fileHandleForReading.readDataToEndOfFile() + return try? parseClaudeBlob(data: sanitizeClaudeBlob(data)) + } catch { + return nil + } + } + /// Claude Code's keychain writer line-wraps long values (newline + leading /// spaces) mid-token, producing JSON with literal control chars inside string /// values. Strip those plus pretty-print indentation between fields so the