Originally created by: QY-25123
Pull Request
Slice
- Repo: doberman-core
- Feature / Slice: global-hook-exclusion —
doberman uninstall closes the global-hook gap
- Plan reference: N/A (bug report + design discussion; no existing plan doc slice for this)
What this PR does
doberman uninstall was deliberately project-scoped only. If a global (--global) Claude Code
hook, 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, whose mkdir silently
recreated .doberman/ right back in the project the user had just tried to opt out of.
This PR closes that gap:
- New
doberman.storage.exclusions module: a device-wide, JSON-backed list of excluded project
paths at ~/.doberman/excluded_projects.json (same DOBERMAN_HOME isolation pattern as
storage.device_metrics). is_excluded() is a pure read — it never creates .doberman/ or the
exclusion 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 a
hook payload, before any other logic — so an excluded project gets a true no-op.
doberman uninstall now automatically detects an active global (or Codex user-scope) hook and
adds 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 for
that project on success — an ungated strengthen, mirroring the enforcement dial's "turning it
back up re-arms automatically, with no gate" precedent.
doberman status reports whether the current project is excluded.
- README.md, docs/SETUP.md, CHANGELOG.md updated to describe the new behavior.
Tests added (run in CI)
tests/unit/test_project_exclusion.py (new): storage.exclusions round-trip, canonicalization
(relative path, trailing slash, subdirectory matching), fail-closed on a missing/malformed file,
DOBERMAN_HOME isolation — plus the regression proof: a pre-excluded project's PreToolUse/
PostToolUse hook (Claude Code, Codex, and OpenClaw) fully abstains on an action that would
otherwise BLOCK, and no .doberman/ is ever created. Also confirms the exclusion list file
itself 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 hook
is present; no exclusion when none is present; no exclusion on a denied gate, wrong factor, or
--dry-run; install-hooks clears an existing exclusion.
tests/unit/test_cli_status.py: excluded_from_global in both the JSON and text views.
Public-release safety (doberman-core only)
- [x] Contains nothing from the "not allowed" list: no enterprise/hosted code, no proprietary detection, no customer data, no secrets, no commercial-license code
- [x] Core still builds/tests/runs with NO enterprise package installed
Security checklist
- [x] Fails closed on error / uncertainty (
is_excluded() treats any read failure as "not excluded" — protection stays on)
- [x] No secret, full file, or unredacted prompt logged or committed
- [x] Any guardrail/learning change is raise-only (no silent loosening) — the exclusion list is only ever written by the already possession-factor-gated
uninstall flow, never by a mediated agent or on the hot hook path; re-enabling protection via install-hooks needs no gate since it's a strengthen
- [x] Every BLOCK/AUTH carries reason codes + a human explanation (unchanged — exclusion is a full abstain, not a new verdict path)
- [x] doberman-core does not import doberman_enterprise
Edge cases covered / Deviations from plan / Risks introduced
- Edge cases: subdirectory of an excluded project is also excluded; relative/trailing-slash paths
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.
- Deviation from the originally-discussed design: the trigger is automatic (uninstall detects
an active global/Codex-user hook and excludes without a flag) rather than an opt-in
--exclude-global flag, and the reversal path is re-running install-hooks rather than a new
dedicated command — both were explicit choices made with the user during planning.
- Known local-environment limitation, unrelated to this change:
tests/unit/test_hosthook_codex.py
and tests/unit/test_hosthook_taint_floor.py::test_codex_taint_floor_multistep_exfil_denied hang
in 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 stash that this reproduces
identically on unmodified main, so it predates this change. Every other test in the suite (the
rest of the two affected files' tests, plus the full remaining suite run with --ignore on just
those two files) passes locally, along with ruff check, ruff format --check, and
lint-imports. CI on this PR (which doesn't have the same GUI-blocking issue) is the real signal
here.
🤖 Generated with Claude Code
Originally posted by: fu351
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:
exclusions.pycompares paths with its ownPath.resolve()while every other matcher routes throughdoberman.canonical.canonicalize(), which also strips the Windows trailing-dot quirk, so either converge on the shared helper or leave a comment saying why resolve() is enough here. Ignore the merge conflict on the branch, that one's mine and I'll land it from here once you've answered the canonicalize question.Ticket changed by: fu351
Originally posted by: fu351
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
uninstall()underneath you and the README and SETUP sections you edited had been rewritten, so I kept main's wording there and moved your exclusion paragraph into the uninstall section of docs/RECOVERY.md. The exclusion wiring is in the project-scoped path exactly as you wrote it, nothing else touched. If you want the next one in this area, [#239] (level-6) is the install-integrity guard for our own hook registration, the same "who can write to this" question you just answered for the exclusion list.Related
Tickets: #239
Tickets:
#477