fix(keychain): close CGO dialog leak, lock misclassification, and enable side-effect (follow-up to #107) - #108
Conversation
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.
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.
…ux.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.
…teraction 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.
Greptile SummaryThis follow-up to #107 closes three residual bugs in the keychain-failure subsystem: it eliminates the CGO dialog/goroutine leak by disabling
Confidence Score: 5/5Safe to merge. All three targeted defects are correctly fixed, error classification paths are clean, and the CGO memory management is sound. The CGO rewrite replaces the goroutine+timeout with a synchronous, mutex-serialized SecItemCopyMatching that can never open a SecurityAgent dialog. NULL guards for CFStringCreateWithBytes and CFDictionaryCreate close the crash paths flagged in the previous review. The sentinel-based classification correctly distinguishes locked (transient) from missing-grant (permanent) even across multiple wrap layers. The pre-flight ordering change in enableCmuxLoop is minimal and correct, with a TOCTOU fallback preserved. Test coverage is targeted: regression guards on no-hang, locked short-circuit, classification wrap-through, and cmux.json immutability on abort. No files require special attention. Important Files Changed
|
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.
… ST1012) Error vars must use the errFoo form; the var became an error value when it started carrying the ErrKeychainNoGrant sentinel.
Follow-up to #107. That PR stopped the
cmux-sync enableprompt storm; this closes three residual defects the storm fix left in the same keychain-failure subsystem.What this fixes
CGO dialog/goroutine leak.
safeStoragePasswordViaKeybaseForranSecItemCopyMatchingin a goroutine abandoned on a 3s timeout; on a signed binary that lost its grant the abandoned call held a SecurityAgent dialog open and thesecurityCLI fallback opened a second one. The read now disables keychain user interaction (SecKeychainSetUserInteractionAllowed) around a directSecItemquery, so it returnserrSecInteractionNotAllowedimmediately instead of prompting — making the call synchronous, with no goroutine to abandon and no orphaned dialog. (kSecUseAuthenticationUIFailalone does not suppress this prompt: the Chrome Safe Storage dialog is a legacy keychain ACL confirmation, not a LocalAuthentication UI.)Lock vs missing-grant misclassification.
SafeStoragePasswordForwrapped every non-timeoutsecurity-CLI failure as "did you grant access?" andIsKeychainAccessErrorstring-matched that wrapper, so a transient locked keychain was misread as a permanent missing grant — and--watchexited 0 (no launchd retry), permanently stopping the sync. The raw--25308exclusion fix(cmux-sync): stop SecurityAgent prompt storm on go install builds #107 added never fired because the live path discards the raw error and re-wraps it. Now there are typed sentinels (ErrKeychainNoGrant/ErrKeychainLocked/ErrKeychainTimeout), classification happens at the source (including aSecKeychainGetStatuslock check before the CLI so a locked keychain can't hang it), and the predicates useerrors.Is.socketControlModeside-effect on aborted enable.enableCmuxLoopwrotesocketControlMode=allowAllbefore the Keychain pre-flight could abort, leavingcmux.jsonmutated on a failed enable. The pre-flight now runs before the write (gated by a stubbablecmux.jsonexistence check), so an aborted enable leavescmux.jsonuntouched.Testing
go build ./...,go vet, and the full suite pass;go test -race ./internal/chrome/ ./internal/cli/is clean.--watchexits non-zero on a locked keychain; aborted enable leavescmux.jsonunmutated.Manual check on a Developer-ID build whose grant was invalidated:
cmux-sync --oncefails fast with the no-grant remediation and zero SecurityAgent dialogs; with the login keychain locked,cmux-sync --watchexits non-zero (launchd retries) rather than exit 0.Post-Deploy Monitoring & Validation
~/.agentcookie/logs/for the cmux-sync agent. Healthy = silent sync; on a missing grant, a single "Keychain not accessible; exiting cleanly" line and no restart loop; on a locked keychain, non-zero exit with launchd retry that recovers after unlock.--watchagent that stops permanently after a sleep-wake. Mitigation:launchctl unload ~/Library/LaunchAgents/dev.agentcookie.cmux-sync.plistthenagentcookie wizard set-keychain-access; revert is a cleangit revertof this range (no migrations, no persisted state).cmux-sync enable+ one sleep-wake cycle.