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..dfa54d9 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,49 @@ 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 + // 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() } + + 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.