Menu

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

closed
nobody
None
2026-08-27
2026-08-19
Anonymous
No

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

Related

Tickets: #401
Tickets: #458
Tickets: #460
Tickets: #481

Discussion

  • Anonymous

    Anonymous - 2026-08-24

    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.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.

     
  • Anonymous

    Anonymous - 2026-08-27

    Ticket changed by: fu351

    • status: open --> closed
     
  • Anonymous

    Anonymous - 2026-08-27

    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


Log in to post a comment.