What happened?
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.
Confirmed NOT the cause
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.
- Stale/uncommitted code:
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.
- A rogue auth-provider plugin:
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.
- Not a total hook bypass:
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.
Historical evidence this isn't a one-off
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.
Steps to reproduce
- Install the Claude Code host hook (
doberman install-hooks or equivalent), confirm doberman enforcement reports enforce.
- From inside a Claude-Code-hooked session, write a file under a
DEFAULT_SENSITIVE_GLOBS path, e.g. migrations/anything.sql (any content). This should classify file_write -> sensitive_path_access -> local_auth tier.
- Observe: the write succeeds immediately, no dialog appears anywhere on screen, no terminal prompt appears,
doberman dash's pending-approval queue (if running) never shows an entry for it.
- Check
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.)
Suspected root cause (unconfirmed -- needs maintainer instrumentation to verify)
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:
- Add temporary logging inside
_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.
- Specifically check whether
tkinter.Tk() succeeds/fails/hangs when constructed off the main thread inside a short-lived hook subprocess on macOS.
- Check whether the hook subprocess as spawned by Claude Code has a controlling terminal at all (
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.
Impact
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.
Doberman version
0.17.1 (editable install)
Python version & OS
Python 3.12.7, macOS 15.7.7 (Darwin 24.6.0)
Relevant logs or output
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.
What happened?
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, nodoberman dashpending-approval entry. The gated tool call (e.g. aWriteto a path matched byDEFAULT_SENSITIVE_GLOBS, reason codesensitive_path_access, tierlocal_auth) simply executes immediately, and the decision log recordsAUTH ... auth=executed.Expected: a
local_auth-tierAUTHshould block the tool call untilrun_auth_challenge()gets a real answer fromGuiPrompter/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 enforcementstate.Confirmed NOT the cause
enforcementdial: reproduced identically undermonitorand underenforce(doberman enforcementconfirmedenforcewas active for the later repros).acted_verdict()'s monitor-softening (src/doberman/policy/drift.py, functionacted_verdict) only applies to_DISCRETIONARY_SOFTcodes undermonitor/off-- it does not apply underenforce, yet the behavior is the same.git statusclean, localmainHEAD matches bothorigin/mainandupstream/mainexactly (git diff upstream/mainempty). This is currentmain, not a stale branch.pip listshows onlydoberman-coreinstalled (editable, pointing at this exact checkout); itsentry_points.txtregisters only thedobermanconsole script, nodoberman.auth_providersentry point.discover_auth_providers()(src/doberman/engine/registry.py) therefore returns[]andactive_provider()(src/doberman/auth/provider.py) falls through to the built-inLOCAL_PROVIDER, not a third-party stub.BLOCKverdicts on the same integration path are enforced correctly and consistently -- e.g. writing to.env/.env.*(protected_path_blocked, onDEFAULT_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 theAUTHchallenge-resolution path.Historical evidence this isn't a one-off
doberman logacross the full local history (2026-07-14 through today) shows 21AUTHdecisions, 20 of which resolvedauth=executed(the remaining 2 lines, bothgit_op, are missing theauth=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.Steps to reproduce
doberman install-hooksor equivalent), confirmdoberman enforcementreportsenforce.DEFAULT_SENSITIVE_GLOBSpath, e.g.migrations/anything.sql(any content). This should classifyfile_write->sensitive_path_access->local_authtier.doberman dash's pending-approval queue (if running) never shows an entry for it.doberman log --last 5/doberman status: the decision is recorded asAUTH ... auth=executed.(Note:
doberman dash's pending-approval queue is a red herring for this specific integration --DashboardPrompteris only wired into thedoberman serveMCP-proxy chain, not into the Claude Code host-hook's prompter chain, which isFallbackPrompter([GuiPrompter(), TtyPrompter()])(src/doberman/hosthooks/hookio.py,_default_auth_prompter). So "nothing in the dashboard" alone doesn't prove the bug -- theauth=executedin the decision log, combined with no GUI/terminal prompt ever appearing, is what does.)Suspected root cause (unconfirmed -- needs maintainer instrumentation to verify)
run_auth_challenge()(src/doberman/auth/challenge.py) hands the actual channel work to_run_with_deadline(), which runsactive_provider().authenticate(...)on a spawned daemonthreading.Thread, not the process's main thread.GuiPrompter.confirm()->_open_root()(src/doberman/auth/gui_prompter.py) then callstkinter.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 aTk()root off the main thread is a known source of silent misbehavior on macOS specifically (not a guaranteed Python exception --_open_root()'sexcept Exceptiononly catchesTclError-shaped failures, not thread-affinity issues that might not raise at all). If the dialog fails to actually render/pump events because of this, andTtyPrompter's/dev/ttyfallback 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 presubprocess, 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) andGuiPrompter.confirm()/TtyPrompter.confirm()to see which channel (if either) is actually being reached, and what it returns, for a realdoberman hook preinvocation.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,TtyPromptershould be raising, and that path should also be traced to confirm it's actually being hit and actually raising.Impact
This is a fail-closed violation on the
local_auth(and, by the same code path, potentiallytwo_factor/role_elevation) tiers for the Claude Code host-hook integration specifically. Any rule that steps up toAUTHin this integration currently behaves as if it werePASS. The human-in-the-loop guarantee that Prime Directive 1 depends on is not actually being enforced end-to-end for this adapter, even thoughenforcementisenforceand even though the code at every layer read during investigation is written to fail closed.BLOCKverdicts are unaffected and continue to work correctly.Doberman version
0.17.1 (editable install)
Python version & OS
Python 3.12.7, macOS 15.7.7 (Darwin 24.6.0)
Relevant logs or output
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_commandin the reason-code list above isn't onmainyet; PR #398 introduces it. The other three codes reproduce onmaintoday, and the fail-open path under investigation doesn't depend on which reason code triggered theAUTH.