From 06e12efabf765d1b2db05fdad51af1ef29bf109c Mon Sep 17 00:00:00 2001 From: QY-25123 Date: Wed, 19 Aug 2026 11:27:17 -0700 Subject: [PATCH] fix(uninstall): exclude project from still-installed global hooks `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 --- CHANGELOG.md | 11 ++ README.md | 2 + docs/SETUP.md | 7 + src/doberman/cli/main.py | 53 ++++-- src/doberman/hosthooks/claude_code.py | 6 + src/doberman/hosthooks/codex.py | 3 + src/doberman/hosthooks/openclaw.py | 3 + src/doberman/hosthooks/spine.py | 16 ++ src/doberman/storage/exclusions.py | 141 +++++++++++++++ tests/unit/test_cli_status.py | 16 ++ tests/unit/test_cli_uninstall.py | 91 ++++++++++ tests/unit/test_project_exclusion.py | 244 ++++++++++++++++++++++++++ 12 files changed, 583 insertions(+), 10 deletions(-) create mode 100644 src/doberman/storage/exclusions.py create mode 100644 tests/unit/test_project_exclusion.py diff --git a/CHANGELOG.md b/CHANGELOG.md index a2c792de..2a4d0dea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ Shipped history for Doberman. Planned work lives on the [roadmap](README.md#road ## Unreleased (merged since v0.18.1) +- **`doberman uninstall` now actually stops protection when hooks are global.** Previously, + `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 this and automatically adds the project to a device-wide exclusion list + (`~/.doberman/excluded_projects.json`) that the (unchanged) global hook checks — first, before + anything else — on every call, so an excluded project gets a true no-op instead. The list is + only ever written by the already possession-factor-gated `uninstall` flow; reading it is a pure, + side-effect-free check that fails closed. Run `doberman install-hooks` in that project again to + clear the exclusion (no gate needed — re-arming protection is a strengthen). `doberman status` + reports whether the current project is excluded. - **Plain-language auth messages — new `message_tone` setting:** the authorization prompt now speaks plainly by default — *"Your agent wants to run a command: … Approve this exact action?"* — instead of the terse `[RISK: …] role: … reason: …` block. `doberman message-tone human|technical` diff --git a/README.md b/README.md index 1801e384..0ccfb34f 100644 --- a/README.md +++ b/README.md @@ -300,6 +300,8 @@ Every AUTH prompt is already in the redacted decision log; `doberman tune` turns `doberman uninstall-hooks` only strips the hook entries: it never touches `.doberman/`, and needs no authentication, which means nothing stops a protected agent that reaches a shell from disabling its own security layer if it wanted to. `doberman uninstall` closes that gap: it removes both the project- and local-scope hooks *and* the project's `.doberman/` control plane (policy + decision database) in one step, gated the same way as `doberman taint clear` / `doberman memory reset`: an enrolled possession factor (TOTP if enrolled, otherwise the local password), with no confirm-only fallback and a hard fail-closed refusal if neither is enrolled. Because it's destructive and irreversible, it also asks you to type the project directory name back before proceeding (`--yes` skips that prompt; it never skips the factor check). It is deliberately **project-scoped only**: `--global` hooks and your device-wide password / 2FA / fingerprint key / `~/.doberman/metrics.db` are shared across every project Doberman protects on the machine and are never touched, even on success. `uninstall` is itself a control-plane-blocked subcommand, so a mediated agent can never shell out to run it. Same protection as `uninstall-hooks`. +That project-scoping used to leave a gap: if a global (`--global`) Claude Code hook, or a Codex `user`-scope hook, is still installed on the machine, it keeps firing for every project regardless of whether that project ever ran `uninstall` — there's no way to make the hook *file* itself skip one project (its matcher keys off tool name, not path). `doberman uninstall` now closes this too: when it detects a global/Codex-user hook is still active, it automatically adds the project to a device-wide exclusion list (`~/.doberman/excluded_projects.json`) that the global hook checks — first, before anything else — on every call, so an excluded project gets a true no-op instead of the hook silently recreating `.doberman/` there. The exclusion list itself is only ever written by this already-gated `uninstall` flow (never by a mediated agent, and never on the hot hook path), and reading it is a pure, side-effect-free check that fails closed (a missing or corrupt list means *not* excluded, i.e. protection stays on). To bring protection back, run `doberman install-hooks` in that project again — any scope, no possession factor needed, since re-arming protection is a strengthen, not a weaken. `doberman status` reports whether the current project is excluded. + ### Plain or technical wording, `doberman message-tone` The authorization prompt speaks plain English by default - *"Your agent wants to run a command: `git push --force main`. The command looked destructive. Approve this exact action?"* - so you can read a catch and decide in seconds without parsing reason codes. Prefer the detailed engineering view? `doberman message-tone technical` switches to the terse `[RISK: …] role: … reason: …` block, and `doberman message-tone human` switches back. It changes wording only: cosmetic, not possession-factor gated, and it never touches the decision, the reason codes, or what lands in the decision log. diff --git a/docs/SETUP.md b/docs/SETUP.md index b6477765..9b2cce4d 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -109,6 +109,13 @@ possession factor (2FA if set up, otherwise your Doberman password) and, since i also asks you to type the project directory name back to confirm (skippable with `--yes`; the factor check never is). With neither factor enrolled it fails closed and removes nothing. +If a global (or Codex `user`-scope) hook is still installed elsewhere on the machine, it would +otherwise keep firing here even after `.doberman/` is gone — `uninstall` detects that and also +adds this project to a device-wide exclusion list the global hook checks first on every call, so +it gets a true no-op instead of silently recreating `.doberman/`. Run `doberman install-hooks` +in this project again to clear the exclusion (no gate needed - re-enabling protection is always +allowed). + > **Order matters when removing Doberman.** `pip uninstall doberman-core` has no way to also > clean up the hook entries it wrote - pip doesn't support that. Always run > `doberman uninstall-hooks` *first*. If you already uninstalled the package and every tool call diff --git a/src/doberman/cli/main.py b/src/doberman/cli/main.py index 782703a4..1c4d4b87 100644 --- a/src/doberman/cli/main.py +++ b/src/doberman/cli/main.py @@ -56,6 +56,7 @@ from doberman.policy.preferences import DIMENSIONS, preset_name from doberman.render import verdict_label, verdict_label_str from doberman.storage.db import active_elevations, grant_elevation, revoke_elevation +from doberman.storage.exclusions import add_exclusion, is_excluded, remove_exclusion from doberman.storage.log import memory_summary, read_decisions from doberman.storage.memory import prune_stale_entities, reset_memory from doberman.storage.taint import clear_taint, entity_scope, read_taint @@ -697,6 +698,7 @@ def _status_payload(path: str) -> dict: {"scope": scope, "path": settings_path, "installed": installed} for scope, settings_path, installed in hook_states ], + "excluded_from_global": is_excluded(path), "recent_decisions": recent_decisions, "missed_challenges_24h": missed_challenges, } @@ -766,6 +768,8 @@ def _render_status_text(payload: dict) -> None: for hook in payload["hooks"]: state = "installed" if hook["installed"] else "not installed" typer.echo(f" {hook['scope']:<8} {hook['path']} [{state}]") + if payload.get("excluded_from_global"): + typer.echo(" excluded from global/Codex-user hooks (run `doberman install-hooks` to undo)") typer.echo("") typer.echo("Recent decisions:") @@ -1705,6 +1709,8 @@ def install_hooks( typer.echo(f"wrote {settings_path}") typer.echo("Doberman will now gate every tool call in this project.") typer.echo("The session dashboard will print at the start of every session.") + if remove_exclusion(path): + typer.echo("This project is no longer excluded from global hooks.") def _install_codex(*, global_: bool, local: bool, path: str, dry_run: bool) -> None: @@ -1740,6 +1746,8 @@ def _install_codex(*, global_: bool, local: bool, path: str, dry_run: bool) -> N write_settings(hooks_path, merged) typer.echo(f"wrote {hooks_path}") typer.echo("Doberman will now gate Codex's tool calls in this scope.") + if remove_exclusion(path): + typer.echo("This project is no longer excluded from global hooks.") typer.echo("") typer.echo("Codex requires you to TRUST this hook before it runs:") typer.echo(" run a Codex command and approve the hook when prompted, or launch with") @@ -1912,11 +1920,15 @@ def uninstall( ) -> None: """Fully remove Doberman from this project: host hooks + `.doberman/`. - Project-scoped only. This does **not** touch `--global` hooks (they protect - every project on this machine) or your device-wide password / 2FA / fingerprint - key / `~/.doberman/metrics.db` (all shared across every project Doberman - protects) — removing those is a separate, deliberate action, not a side effect - of cleaning up one project. + Project-scoped only. This does **not** touch `--global` hooks themselves (they + protect every project on this machine) or your device-wide password / 2FA / + fingerprint key / `~/.doberman/metrics.db` — removing those is a separate, + deliberate action, not a side effect of cleaning up one project. But a global + (or Codex user-scope) hook would otherwise keep firing here even after this + project's own hooks/`.doberman/` are gone, so when one is detected as still + installed, this project is also added to a device-wide exclusion list that the + global hook checks and skips — closing that gap without touching the hook file + itself. Run `doberman install-hooks` here again to clear the exclusion. Requires an enrolled possession factor (2FA if set up, otherwise your Doberman password) — the same gate as `doberman taint clear` / `doberman memory reset`. @@ -1924,11 +1936,23 @@ def uninstall( irreversible action, so it also asks you to type the project directory name back before proceeding (skippable with `--yes`; the factor check is not). """ + from doberman.hosthooks.install_codex import ( + codex_hook_install_states, + remove_codex_hooks, + resolve_codex_hooks_path, + ) + targets = _project_uninstall_targets(path) if not targets: typer.echo("Nothing to remove for this project.") return + global_hook_active = any( + scope == "global" and installed for scope, _, installed in _hook_install_states(path) + ) or any( + scope == "user" and installed for scope, _, installed in codex_hook_install_states(path) + ) + project_name = Path(path).resolve().name typer.echo(f"Doberman UNINSTALL requested for this project ({Path(path).resolve()}):") for description, target_path in targets: @@ -1938,6 +1962,12 @@ def uninstall( typer.echo(" - hooks installed with --global") typer.echo(" - your Doberman password / 2FA enrollment / fingerprint key") typer.echo(" - ~/.doberman/metrics.db (device metrics)") + if global_hook_active: + typer.echo("") + typer.echo( + "A global (or Codex user-scope) hook is still installed on this machine — this " + "project will also be added to the device-wide exclusion list, so it skips it too." + ) if dry_run: typer.echo("") @@ -1979,11 +2009,6 @@ def uninstall( resolve_settings_path, write_settings, ) - from doberman.hosthooks.install_codex import ( - codex_hook_install_states, - remove_codex_hooks, - resolve_codex_hooks_path, - ) errors: list[str] = [] for scope, settings_path, installed in _hook_install_states(path): @@ -2019,6 +2044,14 @@ def uninstall( typer.echo("\nDoberman removed from this project.") + if global_hook_active: + add_exclusion(path) + typer.echo( + "This project has been added to the device-wide exclusion list, so the global " + "(or Codex user-scope) hook will skip it too. Run `doberman install-hooks` here " + "to bring protection back." + ) + @app.command(rich_help_panel="Getting started") def setup( diff --git a/src/doberman/hosthooks/claude_code.py b/src/doberman/hosthooks/claude_code.py index 49b677ed..5d82b4bd 100644 --- a/src/doberman/hosthooks/claude_code.py +++ b/src/doberman/hosthooks/claude_code.py @@ -171,6 +171,9 @@ def evaluate_pre(payload: dict[str, Any]) -> dict[str, Any] | None: NEVER raises — any failure becomes a deny. """ try: + if spine.is_excluded(payload.get("cwd")): + return None # device-wide excluded project — full abstain, no I/O + tool_name = payload.get("tool_name") if not isinstance(tool_name, str) or not tool_name: return _deny() # no identifiable action -> refuse @@ -344,6 +347,9 @@ def evaluate_post(payload: dict[str, Any]) -> dict[str, Any] | None: Wrapped in a broad except so it can never affect the return value. """ try: + if spine.is_excluded(payload.get("cwd")): + return None # device-wide excluded project — full abstain, no I/O + tool_name = payload.get("tool_name") if not isinstance(tool_name, str) or not tool_name: # No identifiable tool — fail closed. diff --git a/src/doberman/hosthooks/codex.py b/src/doberman/hosthooks/codex.py index 9582c268..0624ad5c 100644 --- a/src/doberman/hosthooks/codex.py +++ b/src/doberman/hosthooks/codex.py @@ -136,6 +136,9 @@ def evaluate_pre(payload: dict[str, Any]) -> dict[str, Any] | None: NEVER raises — any failure becomes a deny. """ try: + if spine.is_excluded(payload.get("cwd")): + return None # device-wide excluded project — full abstain, no I/O + tool_name = payload.get("tool_name") if not isinstance(tool_name, str) or not tool_name: return hookio.deny(_EVENT) # no identifiable action -> refuse diff --git a/src/doberman/hosthooks/openclaw.py b/src/doberman/hosthooks/openclaw.py index 7fda015a..c9c5db3d 100644 --- a/src/doberman/hosthooks/openclaw.py +++ b/src/doberman/hosthooks/openclaw.py @@ -224,6 +224,9 @@ def evaluate_before_tool_call(payload: dict[str, Any]) -> dict[str, Any]: ``None``. NEVER raises - any failure becomes a ``block`` (fail closed). """ try: + if spine.is_excluded(payload.get("cwd")): + return _VERDICT_ALLOW # device-wide excluded project — full abstain, no I/O + tool_name = payload.get("tool_name") if not isinstance(tool_name, str) or not tool_name: return _verdict_block(_FAILSAFE_REASON) # no identifiable action -> refuse diff --git a/src/doberman/hosthooks/spine.py b/src/doberman/hosthooks/spine.py index 9128e972..99d167a8 100644 --- a/src/doberman/hosthooks/spine.py +++ b/src/doberman/hosthooks/spine.py @@ -63,6 +63,22 @@ def extract_session_id(raw: object) -> str | None: return raw if isinstance(raw, str) and raw else None +def is_excluded(cwd: object) -> bool: + """True if *cwd* resolves into a device-wide excluded project. + + Every host adapter must call this as the very first thing it does with a + hook payload — before any other check, including the "no identifiable + action -> fail-closed deny" logic. It is a pure read (see + :mod:`doberman.storage.exclusions`): no I/O side effect, so an excluded + project gets a true no-op, not softened enforcement. Any resolution + failure fails closed (returns ``False`` — protection stays on). + """ + from doberman.storage.exclusions import is_excluded as _is_excluded + + repo_root, _ = resolve_root_and_mode(cwd) + return _is_excluded(repo_root) + + def evaluate_action( canonical: str, args: dict[str, Any], diff --git a/src/doberman/storage/exclusions.py b/src/doberman/storage/exclusions.py new file mode 100644 index 00000000..190a8fee --- /dev/null +++ b/src/doberman/storage/exclusions.py @@ -0,0 +1,141 @@ +"""Device-wide, human-gated list of projects excluded from global hooks. + +A global (``--global``) Claude Code hook, or a Codex ``user``-scope hook, fires +for every project on the machine — there is no way to make the hook *file* +itself skip one project (Claude Code's hook ``matcher`` keys off tool name, not +cwd). ``doberman uninstall`` closes that gap: when it detects a global/user-scope +hook is still installed, it adds the project to this list so the (unchanged) +global hook abstains for it, the same as if it were never installed there. + +Storage: a small JSON file at ``~/.doberman/excluded_projects.json``, resolved +via the same ``DOBERMAN_HOME`` env-var override :mod:`doberman.storage.device_metrics` +already uses (tests get isolation for free via the existing autouse fixture). +Deliberately sync, no ``aiosqlite``/asyncio — :func:`is_excluded` sits on the hot +hook path (:func:`doberman.hosthooks.spine.is_excluded`), so it stays as light as +:mod:`doberman.storage.device_metrics`. + +Security model: this list is a real bypass surface — whoever can write to it can +silently disable Doberman for any project. :func:`add_exclusion` is therefore only +ever called from the already possession-factor-gated ``doberman uninstall`` CLI +path, never from the hot hook path. :func:`is_excluded` is a **pure read**: it +never creates ``.doberman/`` or this file, and any failure (missing file, +malformed JSON, bad permissions) is swallowed and treated as "not excluded" — +protection stays ON, since an exclusion-list failure must never grant a bypass. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from doberman.storage.device_metrics import HOME_ENV + +_EXCLUSIONS_FILE = "excluded_projects.json" + + +def excluded_projects_path(home: Path | None = None) -> Path: + """Resolve the exclusion-list file path, respecting ``DOBERMAN_HOME``.""" + base = home if home is not None else Path(os.environ.get(HOME_ENV) or Path.home()) + return base / ".doberman" / _EXCLUSIONS_FILE + + +def load_excluded_projects(*, home: Path | None = None) -> list[str]: + """The raw stored (already-canonical) excluded paths, or ``[]``. + + Read-only: never raises, never creates the file or its parent directory. A + missing, unreadable, or malformed file is treated as an empty list. + """ + path = excluded_projects_path(home) + try: + if not path.exists(): + return [] + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return [] + entries = data.get("excluded") + if not isinstance(entries, list): + return [] + return [entry for entry in entries if isinstance(entry, str) and entry] + except Exception: # noqa: BLE001 — fail closed: unreadable list = nothing excluded + return [] + + +def is_excluded(repo_root: str, *, home: Path | None = None) -> bool: + """True if *repo_root* equals, or is nested under, an excluded project. + + Pure read — never creates ``.doberman/`` or the exclusion file, so this is + safe to call on every hook invocation. Any resolution failure fails closed + (returns ``False`` = protection stays ON). + """ + try: + target = Path(repo_root).resolve() + except (OSError, ValueError): + return False + for entry in load_excluded_projects(home=home): + try: + excluded_root = Path(entry).resolve() + except (OSError, ValueError): + continue + if target == excluded_root or target.is_relative_to(excluded_root): + return True + return False + + +def _write(path: Path, entries: list[str]) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(json.dumps({"excluded": entries}, indent=2) + "\n", encoding="utf-8") + tmp.replace(path) + try: + os.chmod(path.parent, 0o700) + os.chmod(path, 0o600) + except OSError: + pass + + +def add_exclusion(repo_root: str, *, home: Path | None = None) -> None: + """Add *repo_root* (canonicalized) to the device-wide exclusion list. + + Idempotent (de-duped by resolved path). Only ever called from the + possession-factor-gated ``doberman uninstall`` CLI path. + """ + target = str(Path(repo_root).resolve()) + entries = load_excluded_projects(home=home) + existing = set() + for entry in entries: + try: + existing.add(str(Path(entry).resolve())) + except (OSError, ValueError): + continue + if target in existing: + return + _write(excluded_projects_path(home), [*entries, target]) + + +def remove_exclusion(repo_root: str, *, home: Path | None = None) -> bool: + """Remove *repo_root* from the exclusion list, if present. + + Returns whether anything changed. No gate: re-running ``install-hooks`` + against a project is an unambiguous "protect this project again" signal — + a strengthen, same as turning the enforcement dial back up. + """ + try: + target = Path(repo_root).resolve() + except (OSError, ValueError): + return False + entries = load_excluded_projects(home=home) + kept: list[str] = [] + changed = False + for entry in entries: + try: + same = Path(entry).resolve() == target + except (OSError, ValueError): + same = False + if same: + changed = True + else: + kept.append(entry) + if changed: + _write(excluded_projects_path(home), kept) + return changed diff --git a/tests/unit/test_cli_status.py b/tests/unit/test_cli_status.py index d5c1297c..6f5bbba7 100644 --- a/tests/unit/test_cli_status.py +++ b/tests/unit/test_cli_status.py @@ -182,6 +182,7 @@ def test_status_shows_no_taint_by_default(tmp_path): "elevations", "taint", "hooks", + "excluded_from_global", "recent_decisions", "missed_challenges_24h", } @@ -202,11 +203,26 @@ def test_status_json_parses_with_expected_keys(tmp_path): assert isinstance(payload["twofa"], bool) assert isinstance(payload["password"], bool) assert isinstance(payload["missed_challenges_24h"], int) + assert isinstance(payload["excluded_from_global"], bool) # deterministic separators / keys (mirror scan --json) again = runner.invoke(app, ["status", "--path", str(tmp_path), "--json"]) assert again.stdout == result.stdout +def test_status_reports_excluded_from_global(tmp_path): + from doberman.storage.exclusions import add_exclusion + + add_exclusion(str(tmp_path)) + + text_result = runner.invoke(app, ["status", "--path", str(tmp_path)]) + assert text_result.exit_code == 0, text_result.output + assert "excluded from global" in text_result.output.lower() + + json_result = runner.invoke(app, ["status", "--path", str(tmp_path), "--json"]) + payload = json.loads(json_result.stdout) + assert payload["excluded_from_global"] is True + + def test_status_text_has_blank_line_section_breaks(tmp_path): result = runner.invoke(app, ["status", "--path", str(tmp_path)]) assert result.exit_code == 0 diff --git a/tests/unit/test_cli_uninstall.py b/tests/unit/test_cli_uninstall.py index 217b812d..a7cf13e7 100644 --- a/tests/unit/test_cli_uninstall.py +++ b/tests/unit/test_cli_uninstall.py @@ -25,6 +25,7 @@ from doberman.hosthooks.install import merge_doberman_hooks, resolve_settings_path, write_settings from doberman.hosthooks.install_codex import merge_codex_hooks, resolve_codex_hooks_path from doberman.policy.checklist import recommend_policy +from doberman.storage.exclusions import is_excluded runner = CliRunner() @@ -322,3 +323,93 @@ def test_denied_output_never_contains_the_password(tmp_path, monkeypatch): assert result.exit_code == 1 assert _PASSWORD not in result.output + + +# --------------------------------------------------------------------------- +# Global-hook exclusion: a still-installed global (or Codex user-scope) hook +# would otherwise keep firing here after this project's own state is gone, so +# a successful uninstall also adds the project to the device-wide exclusion +# list that the global hook checks (see doberman.storage.exclusions). +# --------------------------------------------------------------------------- + + +def _with_fake_global_claude_hooks(tmp_path, monkeypatch, root: str) -> None: + fake_home = tmp_path / "fake-home" + fake_home.mkdir(exist_ok=True) + monkeypatch.setattr(Path, "home", lambda: fake_home) + write_settings(resolve_settings_path("global", root), merge_doberman_hooks({})) + + +def test_uninstall_auto_excludes_project_when_global_hook_installed(tmp_path, monkeypatch): + root = str(tmp_path / "project") + Path(root).mkdir() + password.enroll(_PASSWORD) + _install_project_hooks(root) + _make_doberman_dir(root) + _with_fake_global_claude_hooks(tmp_path, monkeypatch, root) + _use_prompter(monkeypatch, lambda: _CorrectCode(_PASSWORD)) + + result = runner.invoke(app, ["uninstall", "--path", root, "--yes"]) + + assert result.exit_code == 0, result.output + assert "exclusion list" in result.output.lower() + assert is_excluded(root) is True + + +def test_uninstall_does_not_exclude_when_no_global_hook_installed(tmp_path, monkeypatch): + root = str(tmp_path) + password.enroll(_PASSWORD) + _install_project_hooks(root) + _make_doberman_dir(root) + _use_prompter(monkeypatch, lambda: _CorrectCode(_PASSWORD)) + + result = runner.invoke(app, ["uninstall", "--path", root, "--yes"]) + + assert result.exit_code == 0, result.output + assert "exclusion list" not in result.output.lower() + assert is_excluded(root) is False + + +def test_denied_uninstall_does_not_exclude_even_with_global_hook(tmp_path, monkeypatch): + root = str(tmp_path / "project") + Path(root).mkdir() + password.enroll(_PASSWORD) + _install_project_hooks(root) + _make_doberman_dir(root) + _with_fake_global_claude_hooks(tmp_path, monkeypatch, root) + _use_prompter(monkeypatch, lambda: _WrongCode()) + + result = runner.invoke(app, ["uninstall", "--path", root, "--yes"]) + + assert result.exit_code == 1 + assert is_excluded(root) is False + + +def test_dry_run_does_not_exclude_even_with_global_hook(tmp_path, monkeypatch): + root = str(tmp_path / "project") + Path(root).mkdir() + password.enroll(_PASSWORD) + _install_project_hooks(root) + _make_doberman_dir(root) + _with_fake_global_claude_hooks(tmp_path, monkeypatch, root) + _use_prompter(monkeypatch, lambda: _CorrectCode(_PASSWORD)) + + result = runner.invoke(app, ["uninstall", "--path", root, "--dry-run"]) + + assert result.exit_code == 0, result.output + assert "exclusion list" in result.output.lower() # mentioned, but not yet applied + assert is_excluded(root) is False + + +def test_install_hooks_clears_an_existing_exclusion(tmp_path): + root = str(tmp_path) + from doberman.storage.exclusions import add_exclusion + + add_exclusion(root) + assert is_excluded(root) is True + + result = runner.invoke(app, ["install-hooks", "--path", root]) + + assert result.exit_code == 0, result.output + assert "no longer excluded" in result.output.lower() + assert is_excluded(root) is False diff --git a/tests/unit/test_project_exclusion.py b/tests/unit/test_project_exclusion.py new file mode 100644 index 00000000..34d5d0e6 --- /dev/null +++ b/tests/unit/test_project_exclusion.py @@ -0,0 +1,244 @@ +"""Device-wide project exclusion list (fix for the global-hook uninstall gap). + +A global (``--global``) Claude Code hook, or a Codex ``user``-scope hook, fires +for every project on the machine, with no way to make the hook *file* itself +skip one project. Before this feature, ``doberman uninstall`` in one project +did not actually stop protection there when a global hook was still installed: +the hook kept firing and silently recreated ``.doberman/`` the next time any +decision needed recording (``storage/db.py``'s ``open_db`` -> ``mkdir``). + +Covers: the exclusion-list storage module in isolation (round-trip, +canonicalization, subdirectory matching, fail-closed on a malformed file); the +regression proof that an excluded project's hooks fully abstain with **no** +``.doberman/`` created, across all three host adapters; and that the exclusion +list file itself stays protected by the existing outside-repo-root path +confinement rule. +""" + +import json +from datetime import datetime, timezone +from pathlib import Path + +from doberman.engine.rules.paths import ProtectedPathRule +from doberman.hosthooks import claude_code, codex +from doberman.hosthooks import spine as spine_module +from doberman.hosthooks.openclaw import evaluate_before_tool_call +from doberman.models import ActionType, EvalContext, SecurityObject, Verdict +from doberman.storage.exclusions import ( + add_exclusion, + excluded_projects_path, + is_excluded, + load_excluded_projects, + remove_exclusion, +) + +# --------------------------------------------------------------------------- +# storage/exclusions.py in isolation +# --------------------------------------------------------------------------- + + +def test_add_then_is_excluded_round_trip(tmp_path): + project = tmp_path / "proj" + project.mkdir() + assert is_excluded(str(project)) is False + + add_exclusion(str(project)) + + assert is_excluded(str(project)) is True + assert str(project.resolve()) in load_excluded_projects() + + +def test_remove_exclusion_reverses_it(tmp_path): + project = tmp_path / "proj" + project.mkdir() + add_exclusion(str(project)) + assert is_excluded(str(project)) is True + + changed = remove_exclusion(str(project)) + + assert changed is True + assert is_excluded(str(project)) is False + + +def test_remove_exclusion_on_unlisted_project_is_a_noop(tmp_path): + project = tmp_path / "proj" + project.mkdir() + assert remove_exclusion(str(project)) is False + + +def test_add_exclusion_is_idempotent(tmp_path): + project = tmp_path / "proj" + project.mkdir() + add_exclusion(str(project)) + add_exclusion(str(project)) + entries = load_excluded_projects() + assert entries.count(str(project.resolve())) == 1 + + +def test_relative_and_trailing_slash_paths_canonicalize_the_same(tmp_path, monkeypatch): + project = tmp_path / "proj" + project.mkdir() + add_exclusion(str(project) + "/") + + monkeypatch.chdir(tmp_path) + assert is_excluded("proj") is True + + +def test_subdirectory_of_an_excluded_project_is_also_excluded(tmp_path): + project = tmp_path / "proj" + (project / "sub").mkdir(parents=True) + add_exclusion(str(project)) + + assert is_excluded(str(project / "sub")) is True + + +def test_sibling_project_is_not_excluded(tmp_path): + project = tmp_path / "proj" + project.mkdir() + sibling = tmp_path / "proj-other" + sibling.mkdir() + add_exclusion(str(project)) + + assert is_excluded(str(sibling)) is False + + +def test_missing_file_is_not_excluded_and_creates_nothing(): + assert load_excluded_projects() == [] + assert is_excluded("/anything") is False + assert not excluded_projects_path().exists() + + +def test_malformed_file_fails_closed_to_not_excluded(tmp_path): + project = tmp_path / "proj" + project.mkdir() + path = excluded_projects_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("not json{{{", encoding="utf-8") + + assert load_excluded_projects() == [] + assert is_excluded(str(project)) is False + + +def test_wrong_shaped_json_fails_closed_to_not_excluded(tmp_path): + path = excluded_projects_path() + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(["just", "a", "list"]), encoding="utf-8") + + assert load_excluded_projects() == [] + + +def test_dober_home_env_isolation_is_honored(tmp_path): + """Relies on the autouse ``isolated_device_metrics_home`` fixture (conftest.py) + pointing ``DOBERMAN_HOME`` at a per-test tmp dir — confirms exclusions.py reuses + that same isolation rather than touching the real ``~/.doberman/``.""" + project = tmp_path / "proj" + project.mkdir() + add_exclusion(str(project)) + real_home_file = Path.home() / ".doberman" / "excluded_projects.json" + assert not real_home_file.exists() + + +# --------------------------------------------------------------------------- +# Regression: an excluded project's hooks fully abstain, no .doberman/ created +# --------------------------------------------------------------------------- + + +def _doberman_dir(cwd: str) -> Path: + return Path(cwd) / ".doberman" + + +def test_claude_pre_abstains_and_creates_nothing_for_excluded_project(tmp_path): + project = tmp_path / "proj" + project.mkdir() + add_exclusion(str(project)) + + payload = { + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "rm -rf /"}, # would BLOCK if evaluated + "cwd": str(project), + } + assert claude_code.evaluate_pre(payload) is None + assert not _doberman_dir(str(project)).exists() + + +def test_claude_post_abstains_and_creates_nothing_for_excluded_project(tmp_path): + project = tmp_path / "proj" + project.mkdir() + add_exclusion(str(project)) + + payload = { + "hook_event_name": "PostToolUse", + "tool_name": "Bash", + "tool_input": {"command": "echo hi"}, + "tool_response": "AKIAABCDEFGHIJKLMNOP", # secret-shaped; would BLOCK if scanned + "cwd": str(project), + } + assert claude_code.evaluate_post(payload) is None + assert not _doberman_dir(str(project)).exists() + + +def test_codex_pre_abstains_and_creates_nothing_for_excluded_project(tmp_path): + project = tmp_path / "proj" + project.mkdir() + add_exclusion(str(project)) + + payload = { + "session_id": "00000000-0000-0000-0000-000000000001", + "cwd": str(project), + "hook_event_name": "PreToolUse", + "tool_name": "Bash", + "tool_input": {"command": "rm -rf /"}, + } + assert codex.evaluate_pre(payload) is None + assert not _doberman_dir(str(project)).exists() + + +def test_openclaw_abstains_and_creates_nothing_for_excluded_project(tmp_path): + project = tmp_path / "proj" + project.mkdir() + add_exclusion(str(project)) + + payload = { + "tool_name": "exec", + "params": {"command": "rm -rf /"}, + "cwd": str(project), + } + out = evaluate_before_tool_call(payload) + assert out == {"verdict": "allow"} + assert not _doberman_dir(str(project)).exists() + + +def test_spine_is_excluded_direct(tmp_path): + project = tmp_path / "proj" + project.mkdir() + assert spine_module.is_excluded(str(project)) is False + add_exclusion(str(project)) + assert spine_module.is_excluded(str(project)) is True + + +# --------------------------------------------------------------------------- +# Security: the exclusion list file itself stays protected by the existing +# outside-repo-root path confinement rule (no new rule needed). +# --------------------------------------------------------------------------- + + +def test_mediated_write_to_exclusion_file_is_blocked_by_existing_confinement(tmp_path): + repo_root = tmp_path / "some-repo" + repo_root.mkdir() + target = str(excluded_projects_path()) # e.g. ~/.doberman/excluded_projects.json + + action = SecurityObject( + id="exclusion-write-1", + ts=datetime(2026, 6, 7, tzinfo=timezone.utc), + agent_role="unknown", + action_type=ActionType.file_write, + tool_name="Write", + target=target, + metadata={}, + ) + ctx = EvalContext(metadata={"repo_root": str(repo_root)}) + + result = ProtectedPathRule().evaluate(action, ctx) + + assert result.verdict is Verdict.BLOCK