Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 43 additions & 10 deletions src/doberman/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
}
Expand Down Expand Up @@ -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:")
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1912,23 +1920,39 @@ 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`.
With neither enrolled, this fails closed and removes nothing. A destructive,
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:
Expand All @@ -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("")
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions src/doberman/hosthooks/claude_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/doberman/hosthooks/codex.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions src/doberman/hosthooks/openclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/doberman/hosthooks/spine.py
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
Loading
Loading