Originally created by: QY-25123
Originally owned by: harshitagrawal2O
AUTH-tier decisions produced by the Claude Code host-hook integration (doberman hook pre, src/doberman/hosthooks/claude_code.py) appear to resolve as approved without any human ever answering a confirmation channel: no GUI dialog, no terminal prompt, no doberman dash pending-approval entry. The gated tool call (e.g. a Write to a path matched by DEFAULT_SENSITIVE_GLOBS, reason code sensitive_path_access, tier local_auth) simply executes immediately, and the decision log records AUTH ... auth=executed.
Expected: a local_auth-tier AUTH should block the tool call until run_auth_challenge() gets a real answer from GuiPrompter/TtyPrompter (src/doberman/auth/challenge.py, src/doberman/auth/gui_prompter.py, src/doberman/auth/tty_prompter.py), and every channel in that chain is written to fail closed (raise/deny) when it can't reach a human -- never to silently approve.
Got: the tool call always proceeds, with zero observable pause, regardless of doberman enforcement state.
enforcement dial: reproduced identically under monitor and under enforce (doberman enforcement confirmed enforce was active for the later repros). acted_verdict()'s monitor-softening (src/doberman/policy/drift.py, function acted_verdict) only applies to _DISCRETIONARY_SOFT codes under monitor/off -- it does not apply under enforce, yet the behavior is the same.git status clean, local main HEAD matches both origin/main and upstream/main exactly (git diff upstream/main empty). This is current main, not a stale branch.pip list shows only doberman-core installed (editable, pointing at this exact checkout); its entry_points.txt registers only the doberman console script, no doberman.auth_providers entry point. discover_auth_providers() (src/doberman/engine/registry.py) therefore returns [] and active_provider() (src/doberman/auth/provider.py) falls through to the built-in LOCAL_PROVIDER, not a third-party stub.BLOCK verdicts on the same integration path are enforced correctly and consistently -- e.g. writing to .env/.env.* (protected_path_blocked, on DEFAULT_BLOCKED_GLOBS) is hard-blocked every single time, both in this session and throughout the historical decision log. So the hook itself, and the objective-floor BLOCK path, are working; the bug is specifically scoped to the AUTH challenge-resolution path.doberman log across the full local history (2026-07-14 through today) shows 21 AUTH decisions, 20 of which resolved auth=executed (the remaining 2 lines, both git_op, are missing the auth= suffix entirely and may be a separate, unrelated logging gap). Zero ever resolved to a denial or a timeout, across multiple independent sessions/days and multiple reason codes (sensitive_path_access, environment_dump_command, possible_high_entropy_secret, egress_requires_auth). That consistency across days argues against a transient fluke (e.g. a display server hiccup) and for something structural in how the challenge resolves in this integration.
doberman install-hooks or equivalent), confirm doberman enforcement reports enforce.DEFAULT_SENSITIVE_GLOBS path, e.g. migrations/anything.sql (any content). This should classify file_write -> sensitive_path_access -> local_auth tier.doberman dash's pending-approval queue (if running) never shows an entry for it.doberman log --last 5 / doberman status: the decision is recorded as AUTH ... auth=executed.(Note: doberman dash's pending-approval queue is a red herring for this specific integration -- DashboardPrompter is only wired into the doberman serve MCP-proxy chain, not into the Claude Code host-hook's prompter chain, which is FallbackPrompter([GuiPrompter(), TtyPrompter()]) (src/doberman/hosthooks/hookio.py, _default_auth_prompter). So "nothing in the dashboard" alone doesn't prove the bug -- the auth=executed in the decision log, combined with no GUI/terminal prompt ever appearing, is what does.)
run_auth_challenge() (src/doberman/auth/challenge.py) hands the actual channel work to _run_with_deadline(), which runs active_provider().authenticate(...) on a spawned daemon threading.Thread, not the process's main thread.
GuiPrompter.confirm() -> _open_root() (src/doberman/auth/gui_prompter.py) then calls tkinter.Tk() from that background thread. Tk/Tcl on macOS (Cocoa) is documented to require the main thread for window/event-loop operations; creating or driving a Tk() root off the main thread is a known source of silent misbehavior on macOS specifically (not a guaranteed Python exception -- _open_root()'s except Exception only catches TclError-shaped failures, not thread-affinity issues that might not raise at all). If the dialog fails to actually render/pump events because of this, and TtyPrompter's /dev/tty fallback also can't reach a real controlling terminal from a hook subprocess spawned by the Claude Code harness, the intent of every layer is still "raise -> deny" -- but if the actual runtime fault doesn't surface as a catchable exception the way the code assumes, the failure mode is unverified beyond this point from static reading alone.
This is a hypothesis, not a confirmed root cause. I was not able to get further without attaching a debugger/instrumentation to the actual doberman hook pre subprocess, which isn't possible from inside the hooked session itself (the control-plane self-protection rule correctly blocks the agent from touching its own hook config/state, so this had to be investigated by reading source only). Reporting it as the most actionable lead for someone who can run the hook with logging/a debugger attached.
Suggested next steps for whoever picks this up:
_worker() (in _run_with_deadline) and GuiPrompter.confirm()/TtyPrompter.confirm() to see which channel (if either) is actually being reached, and what it returns, for a real doberman hook pre invocation.tkinter.Tk() succeeds/fails/hangs when constructed off the main thread inside a short-lived hook subprocess on macOS.os.ttyname / isatty()) -- if it does not, TtyPrompter should be raising, and that path should also be traced to confirm it's actually being hit and actually raising.This is a fail-closed violation on the local_auth (and, by the same code path, potentially two_factor/role_elevation) tiers for the Claude Code host-hook integration specifically. Any rule that steps up to AUTH in this integration currently behaves as if it were PASS. The human-in-the-loop guarantee that Prime Directive 1 depends on is not actually being enforced end-to-end for this adapter, even though enforcement is enforce and even though the code at every layer read during investigation is written to fail closed. BLOCK verdicts are unaffected and continue to work correctly.
0.17.1 (editable install)
Python 3.12.7, macOS 15.7.7 (Darwin 24.6.0)
2026-08-17T22:03:53 AUTH sensitive_path_access (file_write, migrations/*.sql) -> executed, no prompt observed
2026-08-17T21:52:34 AUTH sensitive_path_access (file_write, migrations/*.sql) -> executed, no prompt observed
2026-08-17T21:41:22 AUTH sensitive_path_access (file_write, migrations/*.sql) -> executed, no prompt observed
2026-08-17T21:36:20 AUTH sensitive_path_access (file_write, migrations/*.sql) -> executed, no prompt observed
# control group, same session, same integration -- BLOCK works correctly every time:
2026-08-17T21:51:22 BLOCK protected_path_blocked (doberman enforcement, control-plane self-protection) -> blocked
2026-08-17T21:39:58 BLOCK protected_path_blocked (.env write) -> blocked
2026-08-17T21:35:52 BLOCK possible_high_entropy_secret, protected_path_blocked (.env write, out-of-repo) -> blocked
2026-08-17T21:36:02 BLOCK protected_path_blocked (.env write, in-repo) -> blocked
# historical AUTH tally across the full local decision log (2026-07-14 -> 2026-08-17):
# 21 AUTH decisions total, 20 resolved "auth=executed", 0 resolved denied/timeout.
# (2 additional git_op lines lack an auth= suffix entirely -- possibly a separate logging gap.)
All paths/commands above are redacted or reconstructed from the local (already-redacted) decision log; no secret values are included.
Maintainer note (2026-08-20): environment_dump_command in the reason-code list above isn't on main yet; PR [#398] introduces it. The other three codes reproduce on main today, and the fail-open path under investigation doesn't depend on which reason code triggered the AUTH.
Originally posted by: harshitagrawal2O
Picking this one up — tracing the challenge/prompter chain now. Excellent repro writeup, thank you.
Originally posted by: benrrr56-wq
The striking thing is that the engine-level auth challenge logic is thoroughly proven, yet a single integration seam silently converts AUTH into auto-approved. That's the classic mistake: proving the core while the host-hook wiring is untested. Since you already saw Codex fail open (#335), this is the same class — any path where a human never answers must resolve to block, never to proceed. I'd add a per-decision field recording which path produced the verdict and whether a human actually confirmed; a config miswire then shows up as a reproducible mismatch you can grep instead of a silent downgrade. Recomputably verifiable evidence of the decision path turns "trust me it's gated" into something you can re-run and match. I build an opt-in checkpoint layer (allow/deny/require-human-approval) with replayable evidence — happy to compare notes.
Originally posted by: fu351
Thanks @benrrr56-wq, that's a real gap. Today the proxy path stores the auth method in auth_result but the host-hook path only stores executed or blocked, and nothing records whether a human actually answered versus an auto-approval. I'll open an issue for a per-decision field that records both. If you want to compare notes on the checkpoint layer, the Discord is the best place: https://discord.gg/Sfy5XGNqty
Opened it as [#505].
Related
Tickets: #505
Originally posted by: benrrr56-wq
That makes sense. Recording the human confirmed state separately from the auth method should make the host-hook mismatch much easier to detect and replay.
I’m working on AgentKey around a similar boundary: separating the authorization decision from what actually reached the executor, with the decision path preserved as evidence.
I’ll take a look at [#505] and join the Discord. Curious to compare how you’re modeling that field.
Related
Tickets: #505