Skip to content

fix(cmux-sync): stop SecurityAgent prompt storm on go install builds - #107

Merged
mvanhorn merged 2 commits into
mvanhorn:mainfrom
henkyermontero:fix/keychain-prompt-storm
Jun 23, 2026
Merged

fix(cmux-sync): stop SecurityAgent prompt storm on go install builds#107
mvanhorn merged 2 commits into
mvanhorn:mainfrom
henkyermontero:fix/keychain-prompt-storm

Conversation

@henkyermontero

Copy link
Copy Markdown
Contributor

Closes #106

What this fixes

Three compounding bugs caused agentcookie cmux-sync enable to trigger 10+ SecurityAgent Keychain prompts in rapid succession on go install builds (ad-hoc signed, no team ID). See #106 for the full repro and root-cause analysis.

Changes

  • internal/chrome/keychain.go (U1): SafeStoragePasswordFor now checks BinaryTeamID(os.Executable()) before the CGO path. When it returns "" (ad-hoc/unsigned binary), it skips safeStoragePasswordViaKeybaseFor (SecItemCopyMatching) entirely and goes straight to the security CLI. Unsigned binaries always prompt on the CGO path; skipping it avoids both the initial SecurityAgent dialog and the goroutine leaked after the 3s timeout. Signed binaries are unchanged. Adds IsKeychainAccessError to classify Keychain-grant failures vs. operational failures. The CGO call is now behind a safeStorageViaKeybaseRunner seam for testability.

  • internal/cli/cmux_sync.go (U2): In --watch mode, when SafeStoragePasswordFor returns a Keychain access error, the agent prints the remediation and exits 0 instead of returning the error — so launchd's KeepAlive does not restart it into a prompt loop. Operational errors (cmux down, Chrome DB locked, etc.) still exit non-zero so launchd retries them as before. --once mode is unchanged (still returns the error).

  • internal/cli/cmux_sync_enable.go (U3): enableCmuxLoop runs a Keychain pre-flight before installing the persistent launch agent and aborts with wizard set-keychain-access remediation if it fails — so the restart loop never starts. The pre-flight runs after the cmux-not-found no-op, so an absent cmux never triggers a prompt. With U1 in place, on a granted machine the pre-flight and the agent's own read both go through the security CLI with no prompt; on an ungranted machine the pre-flight aborts and the agent is never installed.

Tests

  • internal/chrome/keychain_test.go: CGO path is skipped for unsigned binaries, used for signed binaries, and a BinaryTeamID error is treated as unsigned; IsKeychainAccessError boundary cases.
  • internal/cli/cmux_sync_test.go: --watch exits 0 on Keychain access error, returns error on non-Keychain errors, and --once returns the error (no exit-0).
  • internal/cli/cmux_sync_enable_test.go: enable aborts (no mode change, no agent install) when pre-flight fails, proceeds when it passes, and skips the pre-flight entirely when cmux is absent.

go test ./... passes.

Notes / scope

  • BinaryTeamID shells out to codesign; it runs once per process in these call paths, so the added latency is negligible (not cached). Worth a sync.Once only if SafeStoragePasswordFor ever becomes hot.
  • This targets the ad-hoc-signed (go install) case, which is the storm in cmux-sync enable triggers 10+ SecurityAgent Keychain prompts in rapid succession (go install builds) #106. A Developer-ID-signed binary that loses its grant (e.g., after a macOS upgrade) would still take the CGO path; the underlying goroutine-abandonment in safeStoragePasswordViaKeybaseFor is out of scope here and could be hardened separately.

🤖 Generated with Claude Code

go install produces ad-hoc signed binaries with no team ID. On those, the
CGO SecItemCopyMatching path always triggers a blocking GUI SecurityAgent
dialog, and three compounding bugs turned a single `cmux-sync enable` into
10+ prompts in rapid succession:

1. safeStoragePasswordViaKeybaseFor spawns a goroutine for SecItemCopyMatching,
   times out after 3s and abandons it — the dialog stays open while the
   security-CLI fallback opens a second one. Two prompts per Keychain read.
2. The launch agent's KeepAlive.SuccessfulExit:false restarts cmux-sync every
   10s on the non-zero exit a Keychain denial produces — more prompts forever.
3. cmux-sync enable installs the persistent agent with no Keychain pre-flight,
   so the restart loop starts before the user can grant access.

Fixes:
- U1: SafeStoragePasswordFor checks BinaryTeamID(os.Executable()); when empty
  (ad-hoc/unsigned), skip the CGO path entirely and go straight to the
  security CLI, avoiding both the initial prompt and the leaked goroutine.
- U2: in --watch mode, exit 0 (not non-zero) when SafeStoragePasswordFor
  returns a Keychain access error, so launchd's KeepAlive does not restart
  the agent into a prompt loop. Operational errors still exit non-zero.
  Adds chrome.IsKeychainAccessError to classify the boundary.
- U3: enableCmuxLoop runs a Keychain pre-flight before installing the agent
  and aborts with remediation if it fails, so the loop never starts. The
  pre-flight runs after the cmux-not-found no-op, so absent cmux never prompts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-apps Bot commented Jun 22, 2026

Copy link
Copy Markdown

Greptile Summary

Three compounding bugs caused repeated SecurityAgent Keychain prompts on go install builds; this PR addresses all three. On unsigned/ad-hoc binaries the CGO (SecItemCopyMatching) path is now skipped entirely, --watch mode exits 0 (not non-zero) on a missing-grant error so launchd's KeepAlive doesn't restart into a loop, and a Keychain pre-flight in cmux-sync enable aborts before the persistent launch agent is ever installed.

  • keychain.go: BinaryTeamID gates the CGO path; unsigned binaries go straight to the security CLI. IsKeychainAccessError classifies missing-grant failures and explicitly excludes transient -25308 locked-keychain errors (which should still exit non-zero so launchd retries).
  • cmux_sync.go: --watch calls cmuxExitFunc(0) on IsKeychainAccessError; non-Keychain errors and --once mode are unchanged.
  • cmux_sync_enable.go: Keychain pre-flight is placed after the ErrNotFound early-return, so a never-launched cmux still produces the "launch cmux once" hint rather than a spurious Keychain message.

Confidence Score: 4/5

Safe to merge for the core prompt-storm fix; one ordering issue in the enable flow leaves cmux.json in allowAll on a failed pre-flight.

The fix for unsigned binaries, the --watch exit-0 path, and IsKeychainAccessError are all correctly implemented and well-tested. The one real issue is that cmuxSyncSetMode writes allowAll to cmux.json before the Keychain pre-flight can abort, leaving a partially-mutated config when the pre-flight fails — no agent is installed and the user isn't told to restart cmux, but the file change is persistent and the disable command doesn't revert it.

internal/cli/cmux_sync_enable.go — the ordering of cmuxSyncSetMode vs the Keychain pre-flight.

Important Files Changed

Filename Overview
internal/chrome/keychain.go Adds safeStorageViaKeybaseRunner seam and gates the CGO path behind a BinaryTeamID check; unsigned/ad-hoc binaries skip straight to the security CLI. Adds IsKeychainAccessError that correctly excludes transient -25308 locked-keychain errors.
internal/cli/cmux_sync.go Adds cmuxSyncPasswordFor and cmuxExitFunc seams; --watch mode exits 0 on IsKeychainAccessError so launchd's KeepAlive does not restart into a prompt loop.
internal/cli/cmux_sync_enable.go Adds cmuxSyncKeychainCheck seam and Keychain pre-flight before agent install; correctly placed after the ErrNotFound early-return so a never-launched cmux still shows the "launch cmux once" hint. Pre-flight runs after cmuxSyncSetMode has already written allowAll to cmux.json.
internal/chrome/keychain_test.go Adds table-driven tests for IsKeychainAccessError (including explicit -25308 exclusion) and three behavioural tests for the unsigned/signed/error-treated-as-unsigned paths of SafeStoragePasswordFor.
internal/cli/cmux_sync_enable_test.go Extends enable tests with four new cases: pre-flight absent when cmux.json is missing, aborts (no agent install) on failure, proceeds on success, and no pre-flight when cmux is absent.
internal/cli/cmux_sync_test.go Adds three tests for the new --watch exit-0 path: exits 0 on Keychain access error in watch mode, returns error on non-Keychain errors in watch mode, returns error in --once mode.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[SafeStoragePasswordFor called] --> B{os.Executable + BinaryTeamID}
    B -- teamID != empty signed binary --> C[safeStorageViaKeybaseRunner CGO path]
    C -- success --> D[return password]
    C -- error --> E[fall through to CLI]
    B -- teamID == empty unsigned/ad-hoc --> E
    E[security find-generic-password CLI] --> F{outcome}
    F -- timeout DeadlineExceeded --> G[return: not yet in Safe Storage partition]
    F -- non-zero exit --> H[return: did you grant access?]
    F -- success --> I[return password]

    subgraph watch[--watch mode runCmuxSync]
    J[cmuxSyncPasswordFor] --> K{IsKeychainAccessError?}
    K -- yes --> L[cmuxExitFunc 0 - no launchd restart]
    K -- no --> M[return err - launchd retries]
    end

    subgraph enable[enableCmuxLoop]
    N[cmuxSyncSetMode allowAll] --> O{ErrNotFound?}
    O -- yes --> P[no-op: launch cmux once]
    O -- no --> Q[Keychain pre-flight cmuxSyncKeychainCheck]
    Q -- fail --> R[abort: run wizard set-keychain-access]
    Q -- pass --> S[cmuxSyncInstallAgent --watch agent]
    end

    G --> K
    H --> K
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[SafeStoragePasswordFor called] --> B{os.Executable + BinaryTeamID}
    B -- teamID != empty signed binary --> C[safeStorageViaKeybaseRunner CGO path]
    C -- success --> D[return password]
    C -- error --> E[fall through to CLI]
    B -- teamID == empty unsigned/ad-hoc --> E
    E[security find-generic-password CLI] --> F{outcome}
    F -- timeout DeadlineExceeded --> G[return: not yet in Safe Storage partition]
    F -- non-zero exit --> H[return: did you grant access?]
    F -- success --> I[return password]

    subgraph watch[--watch mode runCmuxSync]
    J[cmuxSyncPasswordFor] --> K{IsKeychainAccessError?}
    K -- yes --> L[cmuxExitFunc 0 - no launchd restart]
    K -- no --> M[return err - launchd retries]
    end

    subgraph enable[enableCmuxLoop]
    N[cmuxSyncSetMode allowAll] --> O{ErrNotFound?}
    O -- yes --> P[no-op: launch cmux once]
    O -- no --> Q[Keychain pre-flight cmuxSyncKeychainCheck]
    Q -- fail --> R[abort: run wizard set-keychain-access]
    Q -- pass --> S[cmuxSyncInstallAgent --watch agent]
    end

    G --> K
    H --> K
Loading

Comments Outside Diff (1)

  1. internal/cli/cmux_sync_enable.go, line 106-126 (link)

    P1 socketControlMode set to allowAll before pre-flight can abort

    cmuxSyncSetMode writes allowAll to cmux.json at line 106 before the Keychain pre-flight runs at line 122. When the pre-flight fails and enableCmuxLoop returns an error, the user's cmux.json is left permanently in allowAll mode even though no agent was installed and the user was never told to restart cmux. The mode doesn't take effect until the user restarts cmux, so the practical window of exposure is narrow — but re-running enable after fixing Keychain access will write allowAll a second time (creating another .bak), and running disable only removes the agent, not the allowAll mode (the disable path tells the user to revert it manually). Moving the pre-flight before cmuxSyncSetMode would prevent the partial-state write entirely, though that requires handling the ErrNotFound hint separately.

    Fix in Codex Fix in Claude Code Fix in Cursor Fix in Conductor

Fix All in Codex Fix All in Claude Code Fix All in Cursor Fix All in Conductor

Reviews (2): Last reviewed commit: "fix(cmux-sync): address review — locked-..." | Re-trigger Greptile

Comment thread internal/chrome/keychain.go
Comment thread internal/cli/cmux_sync_enable.go Outdated
…rdering

Two findings from PR review:

- IsKeychainAccessError no longer treats a locked keychain (-25308 "User
  interaction is not allowed") as a missing grant. That error is transient
  (screen-lock / sleep-wake while the login keychain relocks) and resolves on
  its own, so --watch must exit non-zero and let launchd's KeepAlive retry
  rather than exit 0 and stop the sync permanently. Only the genuine
  missing-grant strings now map to exit 0.

- Move the enable Keychain pre-flight to after the cmux.json ErrNotFound
  guard, immediately before installing the agent. A freshly installed but
  never-launched cmux now surfaces "launch cmux once" instead of a misleading
  "grant Keychain access" message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@mvanhorn
mvanhorn merged commit 6969534 into mvanhorn:main Jun 23, 2026
1 check passed
mvanhorn added a commit that referenced this pull request Jun 23, 2026
…ble side-effect (follow-up to #107) (#108)

* fix(chrome): suppress keychain ACL dialog and drop the leaking goroutine

The CGO Safe Storage read ran SecItemCopyMatching in a goroutine abandoned
on a 3s timeout; on a signed binary that lost its grant the abandoned call
held a SecurityAgent dialog open and the security CLI fallback opened a
second one. Disable keychain user interaction
(SecKeychainSetUserInteractionAllowed) around the call so it returns
errSecInteractionNotAllowed immediately instead of prompting, making the
call synchronous -- no goroutine to abandon, no orphaned dialog. Replaces
the keybase convenience wrapper with a direct SecItem query so the
interaction toggle and a no-prompt fast path can be expressed.

kSecUseAuthenticationUIFail alone does not suppress this prompt: the Chrome
Safe Storage dialog is a legacy keychain ACL confirmation, not a
LocalAuthentication UI, so the interaction toggle is what matters.

* fix(chrome): classify keychain failures by sentinel, not wrapper prose

SafeStoragePasswordFor wrapped every non-timeout security-CLI failure as
"did you grant access?" and IsKeychainAccessError string-matched that
wrapper, so a transient locked keychain was misread as a permanent missing
grant -- and --watch exited 0 (no launchd retry), permanently stopping the
sync. The raw-25308 exclusion PR #107 added never fired because the live
path discards the raw error and re-wraps it.

Introduce ErrKeychainNoGrant/ErrKeychainLocked/ErrKeychainTimeout, capture
the security CLI stderr, classify at the source, and wrap with %w.
IsKeychainAccessError is now pure errors.Is (locked errors are excluded
even when wrapped in grant-prose); IsKeychainLocked matches the sentinel
plus the legacy -25308 string so foreign callers (doctor, SSH reads) still
work. Regression test asserts a locked error wrapped in grant-prose is not
an access error and that the sentinel survives an extra wrap layer.

* fix(cli): run cmux-sync enable Keychain pre-flight before mutating cmux.json

enableCmuxLoop wrote socketControlMode=allowAll (via cmuxSyncSetMode)
before the Keychain pre-flight, so a failed enable left cmux.json mutated
with an orphaned allowAll and a stray .bak. Add a stubbable
cmuxSyncConfigExists seam to surface the cmux.json-missing no-op, then run
the pre-flight, then write -- so an aborted enable leaves cmux.json
untouched. The ErrNotFound branch stays as a TOCTOU guard. Tests assert no
socketControlMode write on pre-flight failure or missing config.

* fix(chrome): detect locked keychain before the security CLI; guard interaction flip

Adversarial review surfaced two issues in the hardening:

- A locked login keychain can make the security CLI hang on an unlock
  prompt until the timeout, which classified as ErrKeychainTimeout ->
  exit 0 -- recreating the permanent-stop bug through the timeout door
  (and naively retrying on timeout would instead stack unlock prompts).
  Add keychainDefaultLocked (SecKeychainGetStatus) and consult it before
  the CLI read: a locked keychain short-circuits to ErrKeychainLocked so
  --watch exits non-zero and launchd retries, while the up-front check
  keeps retries fast (no CLI hang, no prompt stacking). Unknown lock
  state (status error / non-cgo) falls through to the CLI unchanged.

- SecKeychainSetUserInteractionAllowed is process-global; serialize the
  flip with a package mutex so a concurrent in-process keychain caller
  never observes it half-flipped.

Also tighten keychainLockedSignal from "25308" to "-25308" to avoid
matching unrelated numerics in CLI stderr.

* fix(chrome): guard CF NULL returns and gofmt test alignment

Greptile: CFStringCreateWithBytes and CFDictionaryCreate return NULL on
allocation failure, and CFRelease(NULL) is undefined behavior -- guard
each before deferring the release. Also fix gofmt comment alignment in
keychain_test.go that failed go-lint.

* test(cli): rename keychainAccessErr to errKeychainAccess (staticcheck ST1012)

Error vars must use the errFoo form; the var became an error value when it
started carrying the ErrKeychainNoGrant sentinel.

---------

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
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.

cmux-sync enable triggers 10+ SecurityAgent Keychain prompts in rapid succession (go install builds)

2 participants