Keychain: durable approvals, silent reads, and guarded token renewal - #99
Merged
Conversation
A Keychain item that exists but was deliberately not read by an automatic refresh was recorded as permissionDenied and surfaced as a permission warning, though nothing was denied and nothing is broken. Split the binary failure category into manualReadDeferred / permissionDenied / unreadable in the read coordinator and accessor, map the deferred state to a neutral connectRequired outcome in every Keychain-backed provider, and render it as a Connect affordance (muted key glyph, Connect button) instead of the amber warning treatment — on the Mac dashboard, the share card, the drag preview, and the synced iOS companion. Real ACL denials, expired tokens, and unreadable-keychain failures keep their warnings. Claude-Session: https://claude.ai/code/session_015aDG8WwK9nViSoqNmsg6Rt
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
A deferred Safe Storage read on the newest cookie ended the candidate scan, so an older plaintext cookie or a browser whose key was already connected this session never got tried and the card offered Connect for nothing. Deferrals only occur on the prompt-free automatic path, so scanning on cannot raise a dialog; the connect prompt now surfaces only after every candidate failed, and a real interactive denial still ends the scan so no second approval dialog follows a "no". Claude-Session: https://claude.ai/code/session_015aDG8WwK9nViSoqNmsg6Rt
An Always Allow granted to an ad-hoc-signed binary is pinned to that binary's cdhash, so it dies with the next rebuild — every freshly built `swift build` binary re-prompted for every keychain-backed provider, and each approval littered the item's ACL with a dead entry. Cert-backed signatures (the build script's Apple Development identity, release Developer ID) hold requirement-based grants that survive rebuilds and even worktree moves. InteractiveKeychainReadGate now checks the process signature once and refuses the turn outright when it is ad-hoc: no dialog opens, the item records the neutral manual-read deferral (Connect state, not a permission warning whose Always Allow advice the build cannot honor), and the log names the fix. All three prompt-capable paths — the accessor, Claude Desktop Safe Storage, and Sakana browser keys — share the gate, so they all inherit the refusal. The SwiftPM test host is itself ad-hoc signed, so the suites exercising real interactive reads pin the new seam to durable and the refusal path gets its own regression tests. Claude-Session: https://claude.ai/code/session_01RbrQY7hdj5JeZdk5cNiMcY
Once the breaker's revalidation interval elapsed, the next background read of a still-denied item observed only "exists, deferred" (it never requests the secret) and its .manualReadDeferred category overwrote the recorded .permissionDenied — softening the denial warning into the neutral Connect state on a 15-minute timer even though no approval succeeded. storeIfCurrent now preserves a recorded denial when a later unattended deferral lands for the same item, releasing it only on a successful interactive read (which stores no failure) or a proven fingerprint change (a rotated login is a new approval question). Applies to all three read paths, which share this store. Claude-Session: https://claude.ai/code/session_01RbrQY7hdj5JeZdk5cNiMcY
Every relaunch parked keychain-backed providers on the Connect state until a manual refresh, because automatic paths were metadata-only: there was no way to read a classic login-keychain item without risking a dialog — LAContext.interactionNotAllowed provably fails to suppress classic ACL dialogs. But the process-global switch does not: with SecKeychainSetUserInteractionAllowed(false), an unauthorized secret read fails errSecAuthFailed in milliseconds, dialog-free (verified on macOS 26.6), and it remains Apple's documented answer for keychain requests targeting another app's items (forums thread 693148). Background reads now attempt one secret read inside a "quiet turn" of the interactive gate with the switch off (restored before release): an approved login loads silently on any refresh — no per-session Connect click — while a read that would have prompted falls to the neutral deferral, so a dialog can still only ever appear from an explicit click. Quiet turns briefly wait behind other quiet turns (launch races) but skip immediately behind a dialog-capable holder, and the switch stays confined to one audited wrapper, enforced by the reshaped containment test. With this, a cold launch loads all 8 providers with zero clicks and zero dialogs (was 3 cards waiting on Connect). Claude-Session: https://claude.ai/code/session_01RbrQY7hdj5JeZdk5cNiMcY
Four confirmed defects from the high-effort review, plus the cleanup that prevents their recurrence: - A cancelled (contended) interactive read stored a blank untripped entry, wiping a cached user-approved value and erasing a recorded denial's category — the next quiet deferral then landed unopposed, re-opening the softening bug dd922de closed. storeIfCurrent now discards a contended read entirely: never reached securityd, no evidence, nothing stored. - withTurn waited without bound behind a quiet holder, so a securityd wedge inside an unattended quiet read would hang Connect forever. Dialog waiters still wait unbounded behind dialogs (user-attended), but bail out as a cancellation after quietHolderBailout (5s) behind a stuck quiet holder. - The ephemeral-signature refusal recorded a deferral and tripped each item's 15-minute breaker despite never reaching securityd — on ad-hoc builds one Refresh All would blank providers whose quiet reads were succeeding. The refusal now records contention, storing nothing. - The restore of the process-global UI switch was unchecked; a failure would silently suppress every future approval dialog process-wide. The restore is now checked and fails loudly to the log. The triplicated quiet-read block (gate turn + switch toggle + status classification) is extracted into QuietKeychainSecretRead, so the classification, the restore check, and the locked-keychain diagnosis can no longer drift between the accessor and the Safe Storage readers. Stale comments asserting pre-quiet-read invariants (gate header, the denial-persistence rationale) are rewritten to match the shipped design, including that Safe Storage denials clear only via a successful interactive read (externalRead records no fingerprints). Claude-Session: https://claude.ai/code/session_01RbrQY7hdj5JeZdk5cNiMcY
Claude Code's writer was observed resetting its credential item's partition list on rotation (2026-08-08), dropping the user-consented teamid: entry. The app's Always Allow entries survive in the ACL, but every in-process read then fails errSecAuthFailed — and repairing the list requires the user's keychain password, which macOS rightly never lets an app supply for them. The affected items are created by Apple's security tool, whose apple-tool: partition and ACL entry survive the reset, so reading through the helper is silent and needs no consent. When a quiet read classifies as needs-approval, the accessor now consults PartitionWallFallbackReader: it first proves from the item's own ACL metadata (decrypt entry naming /usr/bin/security with no passphrase selector, partition list containing apple-tool:) that the helper read cannot prompt, and only then runs one find-generic-password subprocess. Unproven items never launch the helper. The value flows as a normal hit — the user's approvals are honored, not bypassed, and the Connect state appears only for genuinely unapproved logins. This is the one audited exemption to the no-security-CLI rule (allowlisted in SecurityCLIUsageTests): the historical objection — a subprocess approval names the helper, never Runway — does not apply to a read that requests no approval at all. Verified live against the broken item: the Business Claude card recovered silently (8/8 providers), no password, no dialog. Claude-Session: https://claude.ai/code/session_01RbrQY7hdj5JeZdk5cNiMcY
Reinstates the token renewal the legacy edition shipped, redesigned around the two documented ways a second rotator breaks a login: refreshing while the token is still valid races the owner's proactive renewal (the refresh_token_reused class, issue robinebers#516), and refreshing without persisting the rotation strands the owner on a consumed refresh token (CodexBar issue robinebers#1161, daily forced re-logins). ClaudeTokenRenewal therefore renews only reactively — the stored token must already be expired by 10 minutes, past Claude Code's own 5-minute-early renewal horizon, proving no live session owns the chain — and only after verifying a write path to the credential store, BEFORE the refresh token is consumed. The store is re-read at the last moment: if it holds a different refresh token, another writer rotated first and its fresher credential is adopted instead of raced. The rotated blob is patched as raw JSON (unmodeled fields survive), minified (the legacy hex-newline lesson), and written back to the exact store it came from: in-process first, else the proven-silent security helper with the secret over stdin — argv is visible to the whole login session, so StdinProcessRunning feeds it via a pipe. invalid_grant is terminal (a real `claude` login owns recovery) and failed attempts back off for 15 minutes. Desktop and environment credentials are never rotated; the legacy service-wide item is skipped (unknown account). Kill switch: `defaults write com.mattstallone.runway runway.claude.disableTokenRefresh -bool true`. Verified live: the Personal card's day-old expired token was renewed and written back on the first eligible cycle (keychain item mdat matches the log line), and the usage fetch completed with the new token in the same cycle. Claude-Session: https://claude.ai/code/session_01RbrQY7hdj5JeZdk5cNiMcY
The renewal write-back's SecItemUpdate path caused a live incident: securityd rewrites an item's partition list to the WRITER's partition on every update, so Runway's in-process write stamped the personal Claude item teamid-only and evicted apple-tool: — locking Claude Code's own security-based tooling out of its own credential and trapping the user in keychain password dialogs. (This also explains the 2026-08-08 morning incident in reverse: Claude Code's write had evicted the user-consented teamid partition the same way.) Write-back now goes through the security helper exclusively, which leaves the item in its native apple-tool: state — Claude Code keeps silent access and Runway reads on through the partition-wall fallback. The fallback's partition parser also now accepts the plain-text description a teamid-stamping update leaves behind (previously it failed closed on that shape). Recovery for an already-stamped item: one keychain-password entry (the dialog itself, or security set-generic-password-partition-list with "apple-tool:,teamid:..."). Claude-Session: https://claude.ai/code/session_01RbrQY7hdj5JeZdk5cNiMcY
mstallone
added a commit
that referenced
this pull request
Aug 8, 2026
**TL;DR** — Records the v0.8.10 changelog ahead of tagging the release. ## What was happening - The v0.8.10 release is ready to cut (keychain overhaul #99, plus #98 and #97), and the repo records each release's notes in CHANGELOG.md via PR before the tag. ## What this changes - Prepends the approved v0.8.10 section to CHANGELOG.md. No code changes. ## Heads-up - The v0.8.10 tag goes on this PR's squash-merge commit; the release workflow then builds, signs, notarizes, and publishes. https://claude.ai/code/session_01RbrQY7hdj5JeZdk5cNiMcY
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
TL;DR — Ends the recurring keychain prompts for good: one Always Allow per login, ever. Approved credentials now load silently on every launch and refresh (no per-session Connect click), survive other apps resetting the item's sharing rules, and expired Claude tokens renew themselves under strict single-chain guards.
What was happening
swift buildoutput) could raise approval dialogs whose Always Allow died with the next rebuild — the source of the "prompts on every rebuild" storms, and of dead cdhash entries accumulating in item ACLs.LAContext.interactionNotAllowedprovably fails to suppress classic ACL dialogs, so no background secret read was safe.errSecAuthFailedwith intact ACL approvals — recoverable only by typing the keychain password.What this changes
InteractiveKeychainReadGaterefuses approval dialogs from ad-hoc-signed builds (neutral Connect state + loud log naming the fix) — an approval that can't outlive a rebuild is never requested.SecKeychainSetUserInteractionAllowed(false)— deprecated but still Apple's documented answer for foreign-item reads, and empirically the only mechanism that suppresses classic ACL dialogs). Approved logins load silently; a would-prompt read falls to the neutral deferral. Dialog-capable reads bail out behind a wedged quiet holder instead of hanging.PartitionWallFallbackReaderrecovers from partition-list resets by reading through/usr/bin/security— only after proving from the item's own ACL metadata that the helper is silently authorized, so it can never prompt.ClaudeTokenRenewalrenews an expired Claude token reactively (expired ≥10 min, past Claude Code's own proactive-renewal horizon), verifies a write path before consuming the refresh token, adopts a concurrent writer's fresher chain instead of racing it, and writes the rotated blob back to the exact store it came from — through the security helper only, with the secret over stdin.invalid_grantis terminal with backoff. Kill switch:runway.claude.disableTokenRefresh.Heads-up
security-CLI rule (enforced by the reshaped containment tests): the partition-wall fallback reader and the renewal write-back, both gated on proven-silent authorization.securityCLI usage and the UI-switch call are each confined to one file;SecurityCLIUsageTestsfails on any new call site.refreshing.md,dashboard.md,debugging.md,providers/claude.md.Tests
Full suite passes (unit coverage for the gate, quiet reads, denial persistence, partition fallback proof-before-launch, renewal guards/CAS/write-back). Verified live on a real machine: cold launch loads all 8 providers with zero clicks and zero dialogs; a partition-reset item recovered silently; a day-expired second-profile token renewed and wrote back on the first eligible cycle.
https://claude.ai/code/session_01RbrQY7hdj5JeZdk5cNiMcY