From abec67b9dff1a1b5867a3e6e0995f155a5ab02da Mon Sep 17 00:00:00 2001 From: Chris Osegueda Date: Mon, 2 Nov 2026 11:02:34 -0700 Subject: [PATCH 1/2] fix: stop repeated Keychain password prompts Read the Claude credential through /usr/bin/security instead of SecItemCopyMatching. Claude Code writes the item with that tool, so its partition list holds only "apple-tool:". A direct read fails that check and macOS asks for the login password even when the app is already on the access list, so "Always Allow" never stopped the prompts. The read is bounded by a five-second timeout, so a stuck read cannot stall the refresh loop the way an open dialog did. Claude-Session: https://claude.ai/code/session_01EFLAijFNmWssUbxYQDQR9N --- CHANGELOG.md | 4 +++ CLAUDE.md | 2 ++ InferenceMeter/Core/Keychain.swift | 56 +++++++++++++++++++++--------- README.md | 2 +- docs/APPLE_DISTRIBUTION.md | 2 +- 5 files changed, 47 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70f3bc9..e89f40b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to InferenceMeter are documented here. The project follows [ ## [Unreleased] +### Fixed + +- Stop the repeated macOS Keychain password prompts for `Claude Code-credentials`. The app now reads the credential through `/usr/bin/security`, as Claude Code does, instead of requesting direct access that **Always Allow** could not satisfy. + ## [0.1.4] - 2026-07-13 Codex quota-window restoration release after its five-hour limit proved to be temporarily absent rather than retired. diff --git a/CLAUDE.md b/CLAUDE.md index 4e2906d..0cd2f20 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,6 +44,8 @@ The engine polls every 60 seconds. Claude endpoint attempts are limited to every The CLIs own their OAuth credentials. InferenceMeter is strictly read-only: it must never perform a refresh-token exchange or write the Keychain credential or `~/.codex/auth.json`. Refresh tokens rotate, so using one independently can invalidate the CLI's copy and force a login. +Read the Claude credential only through `/usr/bin/security` (`Core/Keychain.swift`), never `SecItemCopyMatching`. Claude Code writes the item with that tool, so its partition list is just `apple-tool:`; a direct read makes `securityd` log `ACL partition mismatch` and ask for the login password even when the app is already on the access list, and **Always Allow** does not stop it. + Claude reauthentication may invoke `claude auth status --json` with all output discarded. This only asks the owner CLI to inspect or renew its own state; the provider then adopts a newer access token if the Keychain credential changed. Codex only adopts CLI-written state. Never print tokens, credentials, authorization headers, or raw owner payloads. Secret-safety tests capture stdout and stderr at the file-descriptor level; keep them green. Fixtures and issue attachments must be sanitized. diff --git a/InferenceMeter/Core/Keychain.swift b/InferenceMeter/Core/Keychain.swift index f1e8a70..fd431d0 100644 --- a/InferenceMeter/Core/Keychain.swift +++ b/InferenceMeter/Core/Keychain.swift @@ -20,7 +20,7 @@ struct Keychain: Sendable { private let readGenericPasswordHandler: @Sendable (_ service: String, _ account: String) throws -> Data init( - readGenericPassword: @escaping @Sendable (_ service: String, _ account: String) throws -> Data = Keychain.secItemReadGenericPassword + readGenericPassword: @escaping @Sendable (_ service: String, _ account: String) throws -> Data = Keychain.securityToolReadGenericPassword ) { readGenericPasswordHandler = readGenericPassword } @@ -31,26 +31,48 @@ struct Keychain: Sendable { } private extension Keychain { - static func secItemReadGenericPassword(service: String, account: String) throws -> Data { - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrService: service, - kSecAttrAccount: account, - kSecReturnData: kCFBooleanTrue as Any, - kSecMatchLimit: kSecMatchLimitOne - ] - - var result: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &result) - - guard status == errSecSuccess else { - throw KeychainError(status: status) + static let securityToolURL = URL(fileURLWithPath: "/usr/bin/security") + static let securityToolTimeout = DispatchTimeInterval.seconds(5) + // The tool exits with the low byte of its OSStatus; 44 is errSecItemNotFound. + static let securityToolItemNotFoundExitStatus: Int32 = 44 + + // Claude Code writes and reads its credential through /usr/bin/security, so that tool is + // always trusted on the item and the partition list holds only "apple-tool:". Reading with + // SecItemCopyMatching fails that partition check, and macOS asks for the login password + // however many times "Always Allow" was chosen. Reading the same way the owner does never + // prompts. The credential travels over a pipe; only the service and account are arguments. + static func securityToolReadGenericPassword(service: String, account: String) throws -> Data { + let process = Process() + let standardOutput = Pipe() + process.executableURL = securityToolURL + process.arguments = ["find-generic-password", "-a", account, "-w", "-s", service] + process.standardOutput = standardOutput + process.standardError = FileHandle.nullDevice + process.standardInput = FileHandle.nullDevice + + try process.run() + + let processIdentifier = process.processIdentifier + let timeout = DispatchWorkItem { kill(processIdentifier, SIGTERM) } + DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + securityToolTimeout, execute: timeout) + defer { timeout.cancel() } + + let output = try standardOutput.fileHandleForReading.readToEnd() ?? Data() + process.waitUntilExit() + + guard process.terminationStatus == 0 else { + throw process.terminationStatus == securityToolItemNotFoundExitStatus + ? KeychainError.itemNotFound + : KeychainError.unhandled(process.terminationStatus) } - guard let data = result as? Data else { + // The tool terminates the password with a newline that is not part of the item. + let data = output.last == UInt8(ascii: "\n") ? output.dropLast() : output[...] + + guard !data.isEmpty else { throw KeychainError.unexpectedItemData } - return data + return Data(data) } } diff --git a/README.md b/README.md index 70acf98..141cd79 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ The app never sends a refresh-token request and never writes OAuth credentials. Tokens, credentials, and authorization headers are not logged. The app contains no analytics, advertising, or update tracker. Claude network requests go directly to its provider endpoint. Codex network requests are delegated to the installed CLI so Codex remains the sole owner of its authentication lifecycle. -The first Claude refresh may trigger a macOS Keychain permission prompt. Choose **Always Allow** if you want future usage refreshes to occur without another prompt. +InferenceMeter reads the Claude credential through Apple's `/usr/bin/security` tool, the same way Claude Code reads it, so macOS does not show a Keychain password prompt. Claude Code's item already trusts that tool, which means any process running as your user can read the credential this way, with or without InferenceMeter installed. If you previously chose **Always Allow**, the leftover InferenceMeter entries on the item are harmless and no longer used. ## Data freshness and limitations diff --git a/docs/APPLE_DISTRIBUTION.md b/docs/APPLE_DISTRIBUTION.md index 920e62d..50d3152 100644 --- a/docs/APPLE_DISTRIBUTION.md +++ b/docs/APPLE_DISTRIBUTION.md @@ -188,7 +188,7 @@ Finally, extract the ZIP on a clean macOS account or separate Mac and verify tha - Gatekeeper opens the app without an unsigned-developer workaround. - The app displays the expected version in **About Inference Meter**. -- Claude Keychain access is requested only when needed. +- Claude usage loads without any Keychain password prompt. - Codex usage works without exposing or copying CLI credentials. - Launch at Login can be enabled and survives a restart. From a4d204e854a999b17a01ab24163069e67cc421e2 Mon Sep 17 00:00:00 2001 From: Chris Osegueda Date: Mon, 2 Nov 2026 11:06:27 -0700 Subject: [PATCH 2/2] fix: force-stop a Keychain read that outlives its timeout SIGTERM only bounded the read when the child honoured it. A child ignoring SIGTERM kept stdout open and blocked the read past the five-second limit. Claude-Session: https://claude.ai/code/session_01EFLAijFNmWssUbxYQDQR9N --- InferenceMeter/Core/Keychain.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/InferenceMeter/Core/Keychain.swift b/InferenceMeter/Core/Keychain.swift index fd431d0..dfa54d9 100644 --- a/InferenceMeter/Core/Keychain.swift +++ b/InferenceMeter/Core/Keychain.swift @@ -53,7 +53,8 @@ private extension Keychain { try process.run() let processIdentifier = process.processIdentifier - let timeout = DispatchWorkItem { kill(processIdentifier, SIGTERM) } + // SIGKILL because a child ignoring SIGTERM would keep the pipe open and block the read. + let timeout = DispatchWorkItem { kill(processIdentifier, SIGKILL) } DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + securityToolTimeout, execute: timeout) defer { timeout.cancel() }