feat(engine): escalate recursive delete of gitignored, uncommitted dirs (#198) - #653
Conversation
…rs (DobermanCore#198) AN-1's lexical gate classifies an unrecoverable *file* operand by basename. A directory operand carries no lexical signal at all -- `data/` and `build/` are indistinguishable as strings -- so that case was deliberately deferred. This picks the deferral up beside the lexical gate rather than inside it: a recursive delete (`rm -r`, `Remove-Item -Recurse`) whose target `git check-ignore` reports as ignored, and under which `git ls-files` finds nothing committed, now steps up to AUTH. git holds no copy of that directory, so the delete is genuinely irrecoverable. Raise-only by construction: the branch sits after every existing BLOCK and every existing AUTH, so the only transition it can produce is PASS -> AUTH. Every uncertainty -- no git binary, not a repository, a timeout, an OS or subprocess error, an operand escaping the repo root -- returns False and lands the segment on exactly today's plain bulk_threshold behaviour. Tool-owned build/cache directories (node_modules, build, dist, .venv, ...) are carved out: deleting them is routine and cheap, and an auth prompt nobody reads protects nothing. The list is deliberately narrow -- ambiguous names someone might keep un-backed-up work in (bin, out, vendor, data, tmp) are left out so the escalation fires for them. Cost is bounded: four cheap local gates (recursive flag -> not carved out -> canonicalizes inside the root -> is a real directory, not a symlink) before any probe, then at most two subprocesses per segment, each with a 2s timeout. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…obermanCore#653) Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks @harshitagrawal2O, this is close and the structure is right: the gate sits dead last in both _segment_verdict and _windows_delete_verdict, after every existing BLOCK and AUTH, so raise-only holds by construction. Hoisting it above the bulk-threshold check turns test_bulk_threshold_keeps_precedence red, which is the test I wanted to see. Three things need your call before I merge:
Smaller: test_operand_escaping_the_repo_root_does_not_escalate stays green with the guard deleted (relposix is already "" whenever escapes_root is set), so assert on _delete_dir_candidates directly; it becomes load-bearing once 1 is fixed. And add Closes #198 to the body. Ping me when it's pushed and I'll re-run the same checks. |
|
Thanks @harshitagrawal2O, holding this one on what I think is a one-word fix. |
Pull Request
Slice
Implements the approach @fu351 confirmed on the issue (option 2: a bounded, timeout-guarded
git check-ignore -q, falling back to today's plainbulk_thresholdon any git or subprocess error, with a short timeout so a hung git never stalls a decision).What this PR does
AN-1's lexical gate (
_rm_targets_unrecoverable_data) classifies an unrecoverable file operand by basename. A directory operand carries no lexical signal at all —data/andbuild/are indistinguishable as strings, yet deleting the first can be irrecoverable while deleting the second costs a rebuild. That is why the case was deferred, and it is what this picks up.A recursive delete (
rm -r, and the Windows delete verbs) whose targetgit check-ignorereports as ignored, and under whichgit ls-filesfinds nothing committed, now steps up toAUTH (destructive_command). git holds no copy of that directory, so the delete is genuinely unrecoverable.Built beside the AN-1 gate, not over it.
_rm_targets_unrecoverable_datais byte-identical and still runs first; the new branch is a separate function reached only after it declines.Acceptance criteria
rmtargeting paths inside a git-ignored, never-committed directory escalates toAUTH— always, not at a lowered operand count. Documented choice, see below.gitunavailable, or the target outside a git repo → falls back to today's plainbulk_thresholdcheck. No crash, no silent PASS.explanation.ruff check .,ruff format --check .,lint-importspass.pytestpasses.The documented choice: always AUTH, with a narrow carve-out
The issue offered "lower the threshold or always AUTH — contributor's documented choice."
Lowering the threshold does not reach the case in the title.
rm -rf data/is a single operand, so no threshold above 1 catches it. Any lowered-threshold implementation would have closed the issue without fixing the scenario it describes. So this forcesAUTHregardless of operand count.That alone would also escalate
rm -rf node_modulesandrm -rf build, which are routine — and an auth prompt people learn to click through protects nothing, so the noise would be a real safety cost, not just an annoyance. There is therefore a carve-out for tool-owned build/cache directories (node_modules,build,dist,target,.venv,__pycache__,.next, …).The carve-out is deliberately conservative. Ambiguous names someone might legitimately keep un-backed-up work in —
bin,out,vendor,data,tmp— are not on it, so the escalation fires for those. And because the whole gate is additive, the list can only ever make the new escalation quieter; it has no reach over any verdict that exists today. Happy to drop it for the simpler, noisier rule if you'd rather.Safety constraints
Raise-only by construction. The branch sits after every existing
BLOCKand every existingAUTHin both_segment_verdictand_windows_delete_verdict, so the only transition it can produce is "would have returnedNone(PASS)" →AUTH. It cannot lower, reorder, or mask a verdict that fires today. Tested:rm -rf /still BLOCKs inside a repo,.dobermanstill BLOCKs, and a 30-operand ignored delete still reportsbulk_operationrather than being shadowed by the new reason code.Fails toward today, not toward PASS. No git binary, not a repository, a timeout, an
OSError, aSubprocessError, an operand escaping the repo root, or git reporting "ignored" while unable to say what is committed — every one returnsFalseand lands on exactly today's plainbulk_thresholdbehaviour. Each is covered by its own test.No raw path in the explanation. One shared category-only string; a test asserts a distinctively-named directory and its contents never reach
result.explanation.Cost
The git probe sits behind four cheap local gates — recursive flag → not a regenerable build directory → canonicalizes inside the root (via the shared
canonicalize, not an ad-hoc join) → is a real directory, not a symlink — and then runs at most two subprocesses per segment, whatever the operand count, each with a 2s timeout.--stdin -zkeeps the operand count off the command line and makes a path containing a newline unable to desynchronize the parse.Measured against this repo on Windows: carve-out hits short-circuit in 0.1 ms; a probe that reaches git costs ~45 ms for one call, ~98 ms on the escalation path. A non-recursive
rmnever probes at all.Tests added (run in CI)
tests/unit/test_rule_commands_gitignored_delete.py— 38 tests against real throwaway git repositories undertmp_path(deterministic: no network, no clock, fixed committer identity, fresh repo per test; skipped if no git binary):rm -rf data,data/,rm -r,rm -Rf ./data; one qualifying operand hidden among tracked ones;Remove-Item -Recurse; survives a chained segment.rm; a nonexistent target; a symlink to an ignored directory; an unexpanded glob operand; an operand escaping the repo root.TimeoutExpired/OSError/SubprocessError/ValueError;ls-filesunable to answer; the 2s timeout actually reachingsubprocess.runwithshellnot set.bulk_operationreason code; AN-1 still owns the file case with its own wording.Changelog
changelog.d/<PR>.added.mdfragment addedPublic-release safety (doberman-core only)
Security checklist
destructive_command+ a one-line explanation, documented indocs/REASON_CODES.mdEdge cases covered / Deviations from plan / Risks introduced
Two deviations, both deliberate — please push back on either.
Mirrored onto the Windows delete verbs. The issue scoped this to
rm/_segment_verdict. But an agent on Windows spells the same irrecoverable deleteRemove-Item -Recurse data, and_windows_delete_verdictalready mirrors every other rung of thermladder (including AN-1's own_any_operand_unrecoverable). Leaving it POSIX-only would have shipped the fix with a documented bypass. It is the last check in that function, so it can only turn itsNoneintoAUTH. This required adding arootparameter to_windows_delete_verdict— a private helper with a single call site.git ls-filesis a backstop here, not the load-bearing layer. The issue suggestedls-files --error-unmatchto rule out "ignored but already tracked" false positives. Measuring on git 2.47,check-ignorealready consults the index and will not call a directory with tracked contents ignored — so the first probe catches that case on its own. I kept the second pass anyway, because that index-awareness is a default (it is what--no-indexturns off) rather than a documented guarantee, and because it can only ever make the escalation quieter, never louder. It runs only oncecheck-ignorehas said "ignored", so it costs nothing on the common path. Both the behaviour and the backstop are tested independently so neither can rot unnoticed.One small refactor:
_rm_is_catastrophic's inline flag parse is extracted to_rm_recursive_and_forceso the new gate asks the same question rather than keeping a second, drifting copy. Pure extraction, behaviour byte-identical, covered by the existing catastrophic-delete tests.Risks / limits, stated plainly:
subprocesscall inengine/rules/. It is bounded and non-shell, but it is a genuine change in what the objective decision path is allowed to do, per your confirmation on the issue. The AN-1ponytailnote has been updated to point at the new gate so the next reader is not misled about scope.rm -rf $(cat target)) or still carrying a glob is not classified — same limit as every other operand-shaped check in this file.rm -rf data/wheredata/is ignored but the user does have it backed up elsewhere will now prompt. That is the intended trade: the rule can see git, not the user's backups.🤖 Generated with Claude Code