fix(uninstall): exclude project from still-installed global hooks - #414
Conversation
`doberman uninstall` was project-scoped only: if a global (--global) Claude Code hook, or a Codex user-scope hook, was still installed, it kept firing in the "uninstalled" project and silently recreated .doberman/ there the next time any decision needed recording. uninstall now detects an active global/Codex-user hook and adds the project to a device-wide exclusion list (~/.doberman/excluded_projects.json) that every host adapter checks first, before anything else, so an excluded project gets a true no-op instead. The list is written only by this already possession-factor-gated flow; reading it is a pure, side-effect-free check that fails closed. Re-running `install-hooks` clears the exclusion (no gate needed - a strengthen). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Reviewed, @QY-25123. The regression test proving the exclusion file already falls under the repo-root confinement rule is the right way to ship a control-plane-adjacent change, checked rather than asserted. One thing before it merges: |
land #414: fix(uninstall): exclude project from still-installed global hooks
|
Merged, thanks @QY-25123! Proving the exclusion file is already covered by the outside-root confinement, with a test instead of a new rule, is the right way to add a bypass surface. I landed it through a landing branch in our repo rather than pushing to your fork: #477 restructured |
Pull Request
Slice
doberman uninstallcloses the global-hook gapWhat this PR does
doberman uninstallwas deliberately project-scoped only. If a global (--global) Claude Codehook, or a Codex
user-scope hook, was still installed on the machine, it kept firing in the"uninstalled" project — and any decision that needed recording (an AUTH/BLOCK, a monitor-softened
PASS, or the PostToolUse output scan) called
record_decision->open_db, whosemkdirsilentlyrecreated
.doberman/right back in the project the user had just tried to opt out of.This PR closes that gap:
doberman.storage.exclusionsmodule: a device-wide, JSON-backed list of excluded projectpaths at
~/.doberman/excluded_projects.json(sameDOBERMAN_HOMEisolation pattern asstorage.device_metrics).is_excluded()is a pure read — it never creates.doberman/or theexclusion file itself, and fails closed (protection stays on) on any read failure.
hosthooks.spine.is_excluded(cwd)is the shared chokepoint; all three host adapters(
claude_code.py,codex.py,openclaw.py) check it as the very first thing they do with ahook payload, before any other logic — so an excluded project gets a true no-op.
doberman uninstallnow automatically detects an active global (or Codex user-scope) hook andadds the project to the exclusion list as part of the same already-gated flow (typed project-name
confirm + possession factor) — no new flag, no new gate.
doberman install-hooks(any scope, any host) automatically clears an existing exclusion forthat project on success — an ungated strengthen, mirroring the enforcement dial's "turning it
back up re-arms automatically, with no gate" precedent.
doberman statusreports whether the current project is excluded.Tests added (run in CI)
tests/unit/test_project_exclusion.py(new):storage.exclusionsround-trip, canonicalization(relative path, trailing slash, subdirectory matching), fail-closed on a missing/malformed file,
DOBERMAN_HOMEisolation — plus the regression proof: a pre-excluded project'sPreToolUse/PostToolUsehook (Claude Code, Codex, and OpenClaw) fully abstains on an action that wouldotherwise BLOCK, and no
.doberman/is ever created. Also confirms the exclusion list fileitself stays protected by the existing outside-repo-root path confinement rule (no new rule
needed).
tests/unit/test_cli_uninstall.py: auto-exclusion on a successful uninstall when a global hookis present; no exclusion when none is present; no exclusion on a denied gate, wrong factor, or
--dry-run;install-hooksclears an existing exclusion.tests/unit/test_cli_status.py:excluded_from_globalin both the JSON and text views.Public-release safety (doberman-core only)
Security checklist
is_excluded()treats any read failure as "not excluded" — protection stays on)uninstallflow, never by a mediated agent or on the hot hook path; re-enabling protection viainstall-hooksneeds no gate since it's a strengthenEdge cases covered / Deviations from plan / Risks introduced
canonicalize the same as absolute ones; a malformed or missing exclusion-list file fails closed
to "not excluded" rather than crashing or (worse) excluding everything.
an active global/Codex-user hook and excludes without a flag) rather than an opt-in
--exclude-globalflag, and the reversal path is re-runninginstall-hooksrather than a newdedicated command — both were explicit choices made with the user during planning.
tests/unit/test_hosthook_codex.pyand
tests/unit/test_hosthook_taint_floor.py::test_codex_taint_floor_multistep_exfil_deniedhangin the author's local sandbox because their AUTH-challenge path falls through to a real GUI/TTY
prompter with no display attached to answer it. Confirmed via
git stashthat this reproducesidentically on unmodified
main, so it predates this change. Every other test in the suite (therest of the two affected files' tests, plus the full remaining suite run with
--ignoreon justthose two files) passes locally, along with
ruff check,ruff format --check, andlint-imports. CI on this PR (which doesn't have the same GUI-blocking issue) is the real signalhere.
🤖 Generated with Claude Code