Skip to content

fix(auth): refuse GUI auth dialog off the main thread on macOS (#399) - #451

Merged
fu351 merged 1 commit into
DobermanCore:mainfrom
harshitagrawal2O:fix/auth/gui-prompter-macos-main-thread
Aug 24, 2026
Merged

fix(auth): refuse GUI auth dialog off the main thread on macOS (#399)#451
fu351 merged 1 commit into
DobermanCore:mainfrom
harshitagrawal2O:fix/auth/gui-prompter-macos-main-thread

Conversation

@harshitagrawal2O

Copy link
Copy Markdown
Contributor

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_challenge dispatches through _run_with_deadline (src/doberman/auth/challenge.py), which spawns a threading.Thread specifically so a wall-clock deadline can be enforced on a channel that might otherwise block forever; 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 (macOS) 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 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 an AUTH-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:

  • BLOCK verdicts never touch this code path at all (decision_payload returns synchronously, no Tk) — matching the report's control group, which worked every time.
  • AUTH always resolved auth=executed with 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 as timeout.
  • macOS-specific, matching the report's platform.

Fix: _open_root() (src/doberman/auth/gui_prompter.py) now refuses — raising the existing PrompterUnavailableErrorbefore ever importing tkinter when it detects sys.platform == "darwin" and the current thread is not the main thread. This slots into the already-correct existing contract: FallbackPrompter falls 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:
    • the guard refuses before tkinter.Tk() is ever called (mocked thread identity, and via a real background threading.Thread)
    • inert on the real main thread (macOS) — tkinter.Tk() is still attempted, proving the guard checks thread affinity, not platform alone
    • inert on non-macOS platforms (win32, linux) even off the main thread — zero behavior change for those users
    • reachable through the public GuiPrompter.confirm() API, not just _open_root()
    • full-chain: FallbackPrompter([GuiPrompter(), tty]) falls through to the terminal when GUI is refused
    • full-chain: LocalAuthProvider.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 conditions

Public-release safety (doberman-core only)

  • Contains nothing from the "not allowed" list: no enterprise/hosted code, no proprietary detection, no customer data, no secrets, no commercial-license code
  • Core still builds/tests/runs with NO enterprise package installed

Security checklist

  • Fails closed on error / uncertainty — the exact point of this fix: an unsafe channel now reports itself unavailable instead of risking undefined behavior
  • No secret, full file, or unredacted prompt logged or committed
  • Any guardrail/learning change is raise-only (no silent loosening) — this only removes a way an AUTH could resolve as approved without a real answer; it never turns an existing approval path into a denial under normal (main-thread-safe) conditions
  • Every BLOCK/AUTH carries reason codes + a human explanation — unchanged; this fix is upstream of verdict formation, in the challenge-resolution layer
  • doberman-core does not import doberman_enterprise

Edge cases covered / Deviations from plan / Risks introduced

  • Could not reproduce on macOS hardware — this fix is based on static tracing of the code path plus the well-documented Cocoa/Tk main-thread requirement, not a live repro. Flagging this explicitly per the issue's own request for maintainer instrumentation; happy to iterate further if a live repro surfaces a different or additional failure point.
  • Secondary observation, not fixed here (out of scope for this PR): TtyPrompter._open_tty() (src/doberman/auth/tty_prompter.py) raises a bare OSError rather than PrompterUnavailableError when no controlling terminal is attached. This does not cause a fail-open — LocalAuthProvider.authenticate()'s own broad except Exception still catches it and denies — but it means TtyPrompter doesn't participate in FallbackPrompter's "try the next channel" semantics the same way GuiPrompter does. Low severity today (it's the last channel in the chain), but worth a follow-up if a channel is ever added after it.
  • No behavior change for Windows/Linux, or for macOS on the main thread — verified by dedicated tests above.

…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
@fu351
fu351 merged commit 64f77e6 into DobermanCore:main Aug 24, 2026
6 checks passed
@fu351

fu351 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

Merged, thanks @harshitagrawal2O! Raising the guard before import tkinter is the part I want to call out, it turns a Cocoa-level crash we can't reliably catch into a plain Python exception at the seam, and the test that spins up a real background thread instead of mocking thread identity proves the detection actually works. One note on the mechanics: a dependency pin I merged put a changelog conflict on your branch, so I landed this through #453 rather than pushing anything to your fork, your commits are in exactly as you wrote them. I'm keeping #399 open: this closes the likeliest path, but the original symptom still needs confirming on real macOS hardware before I call the bug dead. #198 is assigned to you whenever you're ready.

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.

2 participants