fix(auth): refuse GUI auth dialog off the main thread on macOS (#399) - #451
Merged
fu351 merged 1 commit intoAug 24, 2026
Conversation
…manCore#399) Every real caller of the auth-challenge prompter chain runs it on a background daemon thread: run_auth_challenge dispatches through _run_with_deadline (a spawned threading.Thread, so a wall-clock deadline can be enforced on a channel that might never return), and the MCP-proxy path does the equivalent via asyncio.to_thread. GuiPrompter therefore always constructs its Tk() root off the process's main thread. Cocoa's Tk backend requires its NSApplication event loop to start on the real OS main thread. Constructing Tk() off it is a documented hazard that does not reliably surface as a catchable TclError the way a missing $DISPLAY does -- it can silently fail to render, or abort the process -- either of which could leave an AUTH-tier decision recorded as approved with no human ever having seen a dialog: a fail-closed violation. _open_root() now refuses (raises PrompterUnavailableError) before ever importing tkinter when it detects sys.platform == "darwin" and the current thread is not the main thread. This is exactly the existing "channel unavailable" contract: FallbackPrompter falls through to the terminal, and if that is also unavailable, the provider denies. Windows and Linux are unaffected -- the guard is scoped to the one platform where this is a documented hazard, and only fires when actually off-main-thread. 9 new tests: the guard fires before tkinter is touched (mocked and via a real background thread), is inert on the real main thread, is inert on non-macOS platforms, is reachable through the public GuiPrompter API, and the full FallbackPrompter/LocalAuthProvider chain still denies (never approves) when both GUI and terminal are unavailable on macOS.
fu351
added a commit
that referenced
this pull request
Aug 24, 2026
land #451: refuse GUI auth dialog off the main thread on macOS
Collaborator
|
Merged, thanks @harshitagrawal2O! Raising the guard before |
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.
Pull Request
Slice
What this PR does
Traced the reported bug to its root cause and fixes it.
Root cause: Every real caller of the auth-challenge prompter chain runs it on a background daemon thread —
run_auth_challengedispatches through_run_with_deadline(src/doberman/auth/challenge.py), which spawns athreading.Threadspecifically so a wall-clock deadline can be enforced on a channel that might otherwise block forever; the MCP-proxy path does the equivalent viaasyncio.to_thread.GuiPromptertherefore always constructs itsTk()root off the process's main thread.Cocoa's Tk backend (macOS) requires its
NSApplicationevent loop to start on the real OS main thread. ConstructingTk()off it is a documented hazard that does not reliably surface as a catchableTclErrorthe way a missing$DISPLAYdoes on X11 — it can silently fail to render (no window ever appears, but the call also never raises) or abort the whole process. Either outcome could leave anAUTH-tier decision recorded as approved with no human ever having seen a dialog — exactly the fail-closed violation reported, and consistent with every piece of evidence in the issue:BLOCKverdicts never touch this code path at all (decision_payloadreturns synchronously, no Tk) — matching the report's control group, which worked every time.AUTHalways resolvedauth=executedwith zero denials/timeouts across weeks of history — consistent with a near-instant native-level failure rather than a slow hang that the 120s dialog timeout or 1200s outer deadline would otherwise catch and log astimeout.Fix:
_open_root()(src/doberman/auth/gui_prompter.py) now refuses — raising the existingPrompterUnavailableError— before ever importingtkinterwhen it detectssys.platform == "darwin"and the current thread is not the main thread. This slots into the already-correct existing contract:FallbackPrompterfalls through to the terminal prompter, and if that is also unavailable,resolve_auth/the provider denies with the existing, already-worded "approval dialog could not be shown" message. Windows and Linux are completely unaffected — the guard only fires on the one platform where this is a documented hazard, and only when genuinely off the main thread.I could not reproduce the live macOS symptom directly (no macOS hardware), but the fix removes the hazardous call path unconditionally regardless of exactly which native failure mode manifests, and is covered by tests proving the guard fires (including via a real background
threading.Thread, not just mocked thread identity) and that the rest of the fallback/deny chain behaves correctly once it does.Tests added (run in CI)
tests/unit/test_gui_prompter.py— 9 new tests:tkinter.Tk()is ever called (mocked thread identity, and via a real backgroundthreading.Thread)tkinter.Tk()is still attempted, proving the guard checks thread affinity, not platform alonewin32,linux) even off the main thread — zero behavior change for those usersGuiPrompter.confirm()API, not just_open_root()FallbackPrompter([GuiPrompter(), tty])falls through to the terminal when GUI is refusedLocalAuthProvider.authenticate()denies (never approves) when both GUI and terminal are unavailable under the exact [bug]: AUTH-tier decisions execute without human confirmation in the Claude Code host-hook path (fail-closed violation) #399 conditionsPublic-release safety (doberman-core only)
Security checklist
AUTHcould resolve as approved without a real answer; it never turns an existing approval path into a denial under normal (main-thread-safe) conditionsEdge cases covered / Deviations from plan / Risks introduced
TtyPrompter._open_tty()(src/doberman/auth/tty_prompter.py) raises a bareOSErrorrather thanPrompterUnavailableErrorwhen no controlling terminal is attached. This does not cause a fail-open —LocalAuthProvider.authenticate()'s own broadexcept Exceptionstill catches it and denies — but it meansTtyPrompterdoesn't participate inFallbackPrompter's "try the next channel" semantics the same wayGuiPrompterdoes. Low severity today (it's the last channel in the chain), but worth a follow-up if a channel is ever added after it.