Skip to content

fix(uninstall): exclude project from still-installed global hooks - #414

Merged
fu351 merged 1 commit into
DobermanCore:mainfrom
QY-25123:feat/project-exclusion/global-hook-exclude-list
Aug 27, 2026
Merged

fix(uninstall): exclude project from still-installed global hooks#414
fu351 merged 1 commit into
DobermanCore:mainfrom
QY-25123:feat/project-exclusion/global-hook-exclude-list

Conversation

@QY-25123

Copy link
Copy Markdown
Contributor

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)

  • 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 (is_excluded() treats any read failure as "not excluded" — protection stays on)
  • No secret, full file, or unredacted prompt logged or committed
  • 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
  • Every BLOCK/AUTH carries reason codes + a human explanation (unchanged — exclusion is a full abstain, not a new verdict path)
  • 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

`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>
@fu351

fu351 commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

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.py compares paths with its own Path.resolve() while every other matcher routes through doberman.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.

fu351 added a commit that referenced this pull request Aug 27, 2026
land #414: fix(uninstall): exclude project from still-installed global hooks
@fu351
fu351 merged commit ebb9067 into DobermanCore:main Aug 27, 2026
6 checks passed
@fu351

fu351 commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator

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.

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