Skip to content

feat(hosthooks): recheck a delete's blast radius before execution (#649) - #665

Closed
harshitagrawal2O wants to merge 2 commits into
DobermanCore:mainfrom
harshitagrawal2O:feat/hosthooks/delete-recheck-before-execution
Closed

feat(hosthooks): recheck a delete's blast radius before execution (#649)#665
harshitagrawal2O wants to merge 2 commits into
DobermanCore:mainfrom
harshitagrawal2O:feat/hosthooks/delete-recheck-before-execution

Conversation

@harshitagrawal2O

Copy link
Copy Markdown
Contributor

Pull Request

Slice

What this PR does

The issue's own reproduction, before and after:

$ grep -rln effect_set_diverged src/doberman/
src/doberman/explain.py
src/doberman/hosthooks/hookio.py   # <- new
src/doberman/models.py
src/doberman/proxy/executor.py

It was bigger than "add a recheck." The host-hook path computed no preview at allgrep -rn "compute_delete_effects\|delete_class_operands" src/doberman/hosthooks/ came back empty. So there was nothing for a recheck to compare against, and the host-hook AUTH challenge was rendering no blast radius: the human was approving a delete with no idea of its size. Both halves are added here.

Both live in hookio.resolve_auth_result, which is where the issue points and where they belong — the one place all four adapters turn an approval into an execution, so no adapter can be left out by accident:

  1. Before the challenge — compute the bounded ADR 0094 effect set and attach it to the decision the prompters render.
  2. After the human answers — recompute the same operands and deny if the digest moved.

What denies, and what does not

situation outcome
files appear between approval and execution deny
files disappear between approval and execution deny
a known count degrades to unknown deny
the recheck itself raises deny — a guard that cannot verify must not report success
nothing changed allow
dynamic delete (rm -rf $(...)) — unknown at both ends allow — dynamism alone is not divergence
no repo_root, so no baseline allow — the guard stays out of the way rather than inventing a divergence
the human declined deny, on its own terms, with its own outcome — the recheck sits behind the approval branch

Drift in either direction denies: a shrunken effect set is still not the approved one.

Cost

Unchanged for everything that is not a delete. _delete_operands returns (None, False) immediately for any non-command-bearing action or non-delete command, so no filesystem work is ever reached on a non-delete AUTH — asserted by a test that makes compute_delete_effects raise if it is called. Both imports are lazy, so the PASS/BLOCK hot path this module's docstring protects still pays nothing at import time. The operand list is parsed once and reused for the recheck, matching the proxy's own M1 note.

Tests added (run in CI)

  • tests/unit/test_hosthook_delete_recheck.py — 12 tests:
    • The preview exists at all — a delete-class AUTH now reaches the prompter carrying an effect set with the right count (there was none before this PR); a non-delete AUTH walks nothing.
    • Divergence denies — files appearing, files disappearing, a known count becoming unknown, and a recheck that raises.
    • Agreement still allows — an unchanged delete, a dynamic delete, and a missing repo_root.
    • A denial is still a denial — a declined challenge denies without reaching the recheck.
    • Redaction — the divergence deny names the reason code and never the operand, the directory, or the root; and it is still valid, serializable hook output the harness can parse.

Mutation-checked. Forcing diverged = False turns six of the twelve red. Reverted before commit.

Changelog

  • changelog.d/<PR>.<type>.md fragment added

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 — a recheck that raises denies; a known count degrading to unknown denies. The one place it deliberately does not deny is where there was never a baseline (no repo_root, or a non-delete action), which is today's behaviour rather than a new hole.
  • No secret, full file, or unredacted prompt logged or committed — the divergence reason names the category and the reason code only; asserted against the operand, the directory name, and the absolute root.
  • Any guardrail/learning change is raise-only (no silent loosening) — this can only turn a previously-allowed approved delete into a deny. It cannot allow anything that is denied today.
  • Every BLOCK/AUTH carries reason codes + a human explanation — the deny carries effect_set_diverged plus a next step, matching how the proxy path's synthetic BLOCK is explained.
  • doberman-core does not import doberman_enterprise

Edge cases covered / Deviations from plan / Risks introduced

OpenClaw is deliberately not covered, and docs/LIMITATIONS.md now says so precisely. That adapter hands an AUTH to OpenClaw's own /approve flow, which resolves outside this process — as openclaw._record_history's existing docstring notes — so there is no moment inside the hook where a recheck could run. Claude Code, Codex, and Cursor all route through resolve_auth_result and are covered. The limitation entry's heading and closing paragraph were rewritten rather than deleted: the other half of that limitation (drift is only detectable when both counts are exact) is unchanged and still true.

Risks, stated plainly:

  • The preview is new on this path, so a delete-class host-hook AUTH now does a bounded filesystem walk it did not do before. It is the same walk the proxy has always done — capped at 1,000 entries and 0.25s — but it is new latency on that specific path. It is skipped entirely for every non-delete AUTH.
  • The recheck window is the human's think time, which is exactly the window the guard is for, but it means a slow approval on a busy repo is more likely to see legitimate drift and deny. That is the intended trade (deny and let them re-run), and the deny message says to re-run.
  • action.target is the command source here, where the proxy uses command_line_from_arguments(arguments). The hook has no raw-args dict at this point, and action.target is what the destructive-command rule itself falls back to for command-bearing action types, so this matches the rule's own view of the command. A tool whose command lives only in structured args and never reaches target would get no preview and no recheck — the same as today.
  • Conflicts with feat(storage): record the resolving auth path and whether a human approved (#505) #663 (feat(storage): record the resolving auth path...), which also touches hookio.py. Mechanical to resolve; happy to rebase once that one lands, or land this first and rebase that one — whichever you prefer.

🤖 Generated with Claude Code

harshitagrawal2O and others added 2 commits September 7, 2026 05:20
…bermanCore#649)

Before a risky delete runs, Doberman counts what it would touch, shows that to
the human, and recounts right before execution, blocking if the count moved
(ADR 0094's TOCTOU guard, reason code effect_set_diverged). That guard lived
only in proxy/executor.py, so none of the four supported hosts had it, even
though a host hook is how most agents actually reach a tool. `grep -rln
effect_set_diverged src/doberman/` now lists hosthooks/hookio.py.

The host-hook path computed no preview at all, so there was nothing a recheck
could compare against and the challenge rendered no blast radius. This adds
both halves in hookio.resolve_auth_result, the one place every adapter turns an
approval into an execution:

* before the challenge, compute the bounded effect set and attach it to the
  decision the prompters render, so the human approves a blast radius they can
  actually see;
* after they answer, recompute the same operands and deny if the digest moved.

Drift in either direction denies, and so does a known count degrading to
unknown. A dynamic delete is `unknown` at both ends and cannot re-deny on
dynamism alone. A recheck that raises denies too: a guard that cannot verify
must not report success.

Cost is unchanged for everything else. Both helpers early-out before any
filesystem work for a non-delete action, and both imports are lazy, so the
PASS/BLOCK hot path this module's docstring protects still pays nothing.

Mutation-checked: forcing `diverged = False` turns six of the new tests red.

OpenClaw is deliberately not covered and LIMITATIONS.md now says so: it defers
an AUTH to its own /approve flow, which resolves outside this process, so there
is no moment inside the hook where a recheck could run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fu351 pushed a commit that referenced this pull request Sep 8, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 82989e8)
@fu351

fu351 commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Merged via a landing branch, thanks @harshitagrawal2O! Putting the recheck in resolve_auth_result, the one chokepoint all three adapters already go through, means the next host inherits it for free, and a recheck that throws denying instead of passing is the fail-closed shape I want. Nothing changed in your code: I cherry-picked your commits onto main under your name because main now refuses merge commits and needs branches up to date, and put Closes #649 on the landing PR. Notes on #653 and #663 are coming today, both need a decision from you rather than a fix from me. Feel free to star the repo if it's been useful.

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