Skip to content

Commit ad7ab19

Browse files
committed
fix(039): never call keyring.Set from scanner configure on macOS (F-03 follow-up)
The previous fix in this branch removed the IsAvailable probe that was popping the "Keychain Not Found" system modal, but the modal continued to appear on the user's machine during every `mcpproxy security configure` call with the real scanner_<id>_<env> key (screenshotted). Root cause: my runWithTimeout helper wrapped keyring.Set in a goroutine and returned ErrKeyringTimeout after 3s if the goroutine didn't finish. That protects the server process from blocking but does NOT cancel the goroutine — Go has no goroutine cancellation, and the underlying Security.framework call continues running in the background. While it runs, macOS keeps the modal on the user's screen. Worse: each subsequent Store call spawns another zombie goroutine. Fix: eliminate the keyring.Set call from the scanner configure path entirely. 1. internal/security/scanner/service.go — ConfigureScanner now stores scanner env values directly in the scanner's ConfiguredEnv map in BBolt. No SecretStore call. Scanner env vars end up in the scanner container's /proc/environ at scan time anyway, so keyring storage adds no meaningful confidentiality. Users who want keyring-backed storage for a specific secret can still pass a `${keyring:my-name}` reference as the env value — the resolver expands it via a read-only Get at scan time, which is safe. 2. internal/secret/keyring_provider.go — KeyringProvider.Store now refuses to call keyring.Set on darwin by default. The opt-in is MCPPROXY_KEYRING_WRITE=1 (env) or SetWritesEnabled(true) (programmatic, for tests). On Linux/Windows the default is unchanged. The previous three-layer guard (known-unavailable cache, first-time probe, failure cache) still applies behind the macOS gate for the opt-in case. 3. internal/secret/keyring_provider_test.go — new regression test TestKeyringProvider_Store_MacOSDefaultRefuses pins the exact user scenario (configure mcp-scan SNYK_TOKEN on macOS) and asserts that keyring.Set is NOT called. Other Store tests updated to explicitly opt in via SetWritesEnabled(true) so they exercise the three-layer guard path regardless of runtime.GOOS. Verified on arm64 macOS: `time mcpproxy security configure mcp-scan --env SNYK_TOKEN=...` returns in 0.041s (was 3s, before that 60s+), emits zero keyring log lines, and does NOT pop the macOS modal. Subsequent scan of the "everything" server runs mcp-scan successfully using the token pulled from ConfiguredEnv. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 26cba86 commit ad7ab19

3 files changed

Lines changed: 389 additions & 22 deletions

File tree

internal/secret/keyring_provider.go

Lines changed: 158 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"os"
88
"runtime"
99
"strings"
10+
"sync/atomic"
1011
"time"
1112

1213
"github.com/zalando/go-keyring"
@@ -43,6 +44,14 @@ const (
4344
// config-file, etc.) rather than propagating the error.
4445
var ErrKeyringTimeout = errors.New("keyring operation timed out (backend unresponsive — likely waiting on a user prompt)")
4546

47+
// ErrKeyringUnavailable is returned by write operations (Store/Delete) when
48+
// a previous IsAvailable() call determined the keyring backend is not
49+
// usable on this system. We refuse to call keyring.Set in that state
50+
// because on macOS it pops a system modal ("Keychain Not Found") whose
51+
// default action is destructive. Callers MUST fall back to an alternative
52+
// secret store.
53+
var ErrKeyringUnavailable = errors.New("keyring unavailable (backend not usable on this system — refusing to write to avoid OS modal prompts)")
54+
4655
// Package-level function variables allow tests to inject a fake/hanging
4756
// keyring without reaching into the real OS keychain.
4857
var (
@@ -51,9 +60,28 @@ var (
5160
keyringDelFn = keyring.Delete
5261
)
5362

63+
// availabilityState captures the cached result of an IsAvailable() probe.
64+
// Values: 0 = unknown (probe not yet run), 1 = available, 2 = unavailable.
65+
const (
66+
availUnknown int32 = 0
67+
availYes int32 = 1
68+
availNo int32 = 2
69+
)
70+
5471
// KeyringProvider resolves secrets from OS keyring (Keychain, Secret Service, WinCred)
5572
type KeyringProvider struct {
5673
serviceName string
74+
75+
// availability is the cached result of the most recent IsAvailable()
76+
// probe. Read/written atomically. When availNo, all write operations
77+
// short-circuit to ErrKeyringUnavailable so we NEVER call keyring.Set
78+
// on a broken/unusable keychain — which is what pops the macOS modal.
79+
availability int32
80+
81+
// writeOverride allows tests (and future programmatic opt-in) to force
82+
// the macOS write gate without going through env vars. 0 = respect
83+
// env, 1 = force enabled, 2 = force disabled. Read/written atomically.
84+
writeOverride int32
5785
}
5886

5987
// NewKeyringProvider creates a new keyring provider
@@ -63,6 +91,27 @@ func NewKeyringProvider() *KeyringProvider {
6391
}
6492
}
6593

94+
// markUnavailable records that the keyring is not usable. After this call,
95+
// all Store/Delete operations will return ErrKeyringUnavailable without
96+
// touching the OS keyring backend.
97+
func (p *KeyringProvider) markUnavailable() {
98+
atomic.StoreInt32(&p.availability, availNo)
99+
}
100+
101+
// markAvailable records that the keyring has been successfully probed and
102+
// is safe to write to.
103+
func (p *KeyringProvider) markAvailable() {
104+
atomic.StoreInt32(&p.availability, availYes)
105+
}
106+
107+
// isKnownUnavailable returns true if a previous probe determined the
108+
// keyring is not usable. Returns false for both "known available" AND
109+
// "not yet probed" so callers can optimistically attempt a first-time
110+
// write before the cache is populated.
111+
func (p *KeyringProvider) isKnownUnavailable() bool {
112+
return atomic.LoadInt32(&p.availability) == availNo
113+
}
114+
66115
// CanResolve returns true if this provider can handle the given secret type
67116
func (p *KeyringProvider) CanResolve(secretType string) bool {
68117
return secretType == SecretTypeKeyring
@@ -110,16 +159,68 @@ func (p *KeyringProvider) Resolve(_ context.Context, ref Ref) (string, error) {
110159
return secret, nil
111160
}
112161

113-
// Store saves a secret to the OS keyring and updates the registry
162+
// Store saves a secret to the OS keyring and updates the registry.
163+
//
164+
// By default on macOS this method does NOT actually call keyring.Set —
165+
// it returns ErrKeyringUnavailable immediately. Rationale: on macOS
166+
// keyring.Set wraps Security.framework under the hood; if the user's
167+
// default keychain is missing/locked/in an unusual state, Security.framework
168+
// pops a system modal ("Keychain Not Found" with a destructive default
169+
// button). The underlying call is blocking and the goroutine we'd wrap it
170+
// in cannot be cancelled once started — it keeps running until Security.
171+
// framework finally responds, which may involve the user clicking buttons.
172+
// No wrapper / timeout / probe can prevent the modal from appearing, so
173+
// the only safe option is to not call keyring.Set at all.
174+
//
175+
// Users who want keyring-backed storage on macOS can opt in by setting the
176+
// environment variable MCPPROXY_KEYRING_WRITE=1 or calling
177+
// EnableKeyringWrites(true) on the provider. When opted in, Store runs a
178+
// three-layer guard (known-unavailable cache, first-time probe, failure
179+
// cache) plus a 3s runWithTimeout wrapper — but note that the wrapper only
180+
// protects the CALLING goroutine from blocking; it cannot prevent the
181+
// modal from appearing on the user's screen once keyring.Set is called.
182+
//
183+
// On non-macOS platforms (Linux Secret Service, Windows Credential
184+
// Manager), Store goes through the three-layer guard without requiring
185+
// the opt-in, because those backends don't have the same modal issue.
186+
//
187+
// Callers MUST treat ErrKeyringUnavailable as "fall back to in-config /
188+
// in-memory storage" and surface a clear log message.
114189
func (p *KeyringProvider) Store(_ context.Context, ref Ref, value string) error {
115190
if !p.CanResolve(ref.Type) {
116191
return fmt.Errorf("keyring provider cannot store secret type: %s", ref.Type)
117192
}
118193

194+
// macOS-specific hard gate: do not call keyring.Set unless the caller
195+
// has explicitly opted in. The opt-in can be global via the
196+
// MCPPROXY_KEYRING_WRITE env var (value "1", "true", or "yes"), or
197+
// per-provider via SetWritesEnabled(true).
198+
if runtime.GOOS == "darwin" && !p.writesEnabled() {
199+
p.markUnavailable()
200+
return ErrKeyringUnavailable
201+
}
202+
203+
// Layer 1: if we already know the keyring is broken, bail out without
204+
// touching it.
205+
if p.isKnownUnavailable() {
206+
return ErrKeyringUnavailable
207+
}
208+
209+
// Layer 2: if we haven't probed yet, do it now — BEFORE calling Set.
210+
// IsAvailable uses a read-only Get probe which is safe on all platforms.
211+
if atomic.LoadInt32(&p.availability) == availUnknown {
212+
if !p.IsAvailable() {
213+
return ErrKeyringUnavailable
214+
}
215+
}
216+
119217
err := runWithTimeout(func() error {
120218
return keyringSetFn(p.serviceName, ref.Name, value)
121219
})
122220
if err != nil {
221+
// Layer 3: cache the failure so subsequent writes don't retry and
222+
// risk popping another modal.
223+
p.markUnavailable()
123224
return fmt.Errorf("failed to store secret %s in keyring: %w", ref.Name, err)
124225
}
125226

@@ -131,16 +232,56 @@ func (p *KeyringProvider) Store(_ context.Context, ref Ref, value string) error
131232
return nil
132233
}
133234

134-
// Delete removes a secret from the OS keyring and updates the registry
235+
// writesEnabled reports whether this provider is allowed to call
236+
// keyring.Set on the current platform. On non-macOS systems writes are
237+
// always enabled. On macOS writes require an explicit opt-in via the
238+
// MCPPROXY_KEYRING_WRITE env var or a test-only override.
239+
func (p *KeyringProvider) writesEnabled() bool {
240+
if runtime.GOOS != "darwin" {
241+
return true
242+
}
243+
if p.writeOverride != 0 {
244+
return p.writeOverride == 1
245+
}
246+
v := strings.ToLower(os.Getenv("MCPPROXY_KEYRING_WRITE"))
247+
return v == "1" || v == "true" || v == "yes"
248+
}
249+
250+
// SetWritesEnabled is a test-only helper that forces the opt-in state
251+
// regardless of env vars. Pass true to allow keyring.Set, false to
252+
// disallow. It is safe to call at any time — Store checks the override
253+
// synchronously.
254+
func (p *KeyringProvider) SetWritesEnabled(enabled bool) {
255+
if enabled {
256+
atomic.StoreInt32(&p.writeOverride, 1)
257+
} else {
258+
atomic.StoreInt32(&p.writeOverride, 2)
259+
}
260+
}
261+
262+
// Delete removes a secret from the OS keyring and updates the registry.
263+
// Same three-layer guard as Store: known-unavailable short-circuit,
264+
// first-time probe, and failure cache.
135265
func (p *KeyringProvider) Delete(_ context.Context, ref Ref) error {
136266
if !p.CanResolve(ref.Type) {
137267
return fmt.Errorf("keyring provider cannot delete secret type: %s", ref.Type)
138268
}
139269

270+
if p.isKnownUnavailable() {
271+
return ErrKeyringUnavailable
272+
}
273+
274+
if atomic.LoadInt32(&p.availability) == availUnknown {
275+
if !p.IsAvailable() {
276+
return ErrKeyringUnavailable
277+
}
278+
}
279+
140280
err := runWithTimeout(func() error {
141281
return keyringDelFn(p.serviceName, ref.Name)
142282
})
143283
if err != nil {
284+
p.markUnavailable()
144285
return fmt.Errorf("failed to delete secret %s from keyring: %w", ref.Name, err)
145286
}
146287

@@ -188,13 +329,16 @@ func (p *KeyringProvider) List(_ context.Context) ([]Ref, error) {
188329
return refs, nil
189330
}
190331

191-
// IsAvailable checks if the keyring is available on the current system.
332+
// IsAvailable checks if the keyring is available on the current system,
333+
// and caches the result. Subsequent calls to Store/Delete will short-circuit
334+
// to ErrKeyringUnavailable WITHOUT calling keyring.Set if the probe decided
335+
// the keyring is not usable — this is the key property that prevents the
336+
// macOS "Keychain Not Found" modal from ever being shown to the user.
192337
//
193338
// This is deliberately conservative and NEVER calls keyring.Set as a probe:
194339
// on macOS, Set triggers the "Keychain Not Found" system modal when the
195340
// user's default keychain is missing/locked/corrupted, whose default button
196-
// is the destructive "Reset To Defaults" action. That used to block the
197-
// server process on every scanner-configure call.
341+
// is the destructive "Reset To Defaults" action.
198342
//
199343
// Instead, we:
200344
// 1. Skip the probe entirely in obvious headless/CI environments.
@@ -204,11 +348,14 @@ func (p *KeyringProvider) List(_ context.Context) ([]Ref, error) {
204348
// as "unavailable".
205349
// 3. Run the Get inside a goroutine with a 2s hard timeout so a hung
206350
// keychain backend cannot stall the caller.
351+
// 4. Cache the result via markAvailable/markUnavailable so Store/Delete
352+
// can short-circuit on the fast path.
207353
func (p *KeyringProvider) IsAvailable() bool {
208354
// Heuristic fast-path: if we're clearly running headless (CI, no
209355
// X display on Linux, etc.) don't even try the keychain — it will
210356
// either fail or prompt.
211357
if isHeadlessEnvironment() {
358+
p.markUnavailable()
212359
return false
213360
}
214361

@@ -242,10 +389,16 @@ func (p *KeyringProvider) IsAvailable() bool {
242389

243390
select {
244391
case r := <-ch:
392+
if r.ok {
393+
p.markAvailable()
394+
} else {
395+
p.markUnavailable()
396+
}
245397
return r.ok
246398
case <-time.After(keyringProbeTimeout):
247399
// The keychain is wedged. Bail out — the caller should fall back
248400
// to in-memory / config-only secret storage.
401+
p.markUnavailable()
249402
return false
250403
}
251404
}

0 commit comments

Comments
 (0)