Skip to content

Keychain: durable approvals, silent reads, and guarded token renewal - #99

Merged
mstallone merged 9 commits into
mainfrom
improve-keychain-warnings
Aug 8, 2026
Merged

Keychain: durable approvals, silent reads, and guarded token renewal#99
mstallone merged 9 commits into
mainfrom
improve-keychain-warnings

Conversation

@mstallone

@mstallone mstallone commented Aug 5, 2026

Copy link
Copy Markdown
Owner

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

  • Ad-hoc-signed dev builds (bare swift build output) 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.
  • Every app relaunch parked keychain-backed providers on the Connect state until a manual refresh, because automatic paths were metadata-only: LAContext.interactionNotAllowed provably fails to suppress classic ACL dialogs, so no background secret read was safe.
  • A recorded permission denial silently softened to the neutral Connect state after the 15-minute breaker revalidation, and a cancelled read erased it entirely.
  • Credential writers reset their keychain item's partition list on rotation (observed live with Claude Code), stranding Runway on errSecAuthFailed with intact ACL approvals — recoverable only by typing the keychain password.
  • Expired Claude logins (e.g. an idle second profile) flatlined to "login needs renewal" until the user manually used that profile, because Runway was strictly read-only after the refresh-token-reuse incidents (OpenAI Codex OAuth token incorrectly expires after 8 days despite valid authentication robinebers/openusage#516; CodexBar's Claude desync Add Nous Portal provider robinebers/openusage#1161).

What this changes

  • InteractiveKeychainReadGate refuses 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.
  • Background reads run one secret read inside a "quiet turn" with the process-global UI switch off (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.
  • Denials are durable: they survive unattended revalidation of an unchanged item and cancelled reads store no evidence; only a successful interactive read or a proven item change clears them.
  • PartitionWallFallbackReader recovers 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.
  • ClaudeTokenRenewal renews 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_grant is terminal with backoff. Kill switch: runway.claude.disableTokenRefresh.
  • Foreign keychain items are never written in-process: securityd rewrites the partition list to the writer's partition on every update, which locks the owning app's own tooling out (learned live, both directions).

Heads-up

  • Two audited exemptions to the no-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.
  • The security CLI usage and the UI-switch call are each confined to one file; SecurityCLIUsageTests fails on any new call site.
  • Claude Desktop and environment credentials remain strictly read-only; the legacy service-wide item is never written (unknown account).
  • Docs updated: 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

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
@chatgpt-codex-connector

Copy link
Copy Markdown

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 mstallone changed the title Show Keychain warnings only when the user must act Keychain: durable approvals, silent reads, and guarded token renewal Aug 8, 2026
@mstallone
mstallone merged commit cb85ad9 into main Aug 8, 2026
2 checks passed
@mstallone
mstallone deleted the improve-keychain-warnings branch August 8, 2026 19:42
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant