Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 40 additions & 17 deletions InferenceMeter/Core/Keychain.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion docs/APPLE_DISTRIBUTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
Loading