fix(hooks): resolve deletion targets in block-rm-rf#531
Conversation
block-rm-rf pattern-matched raw command text, so its destructive-path test
only fired on `/`, a bare `~`, or a single-segment absolute path. Everyday
ways to recursively delete critical directories were allowed: `rm -rf $HOME`,
`rm -rf ~/Documents`, `rm -rf /home/<user>`, `find / -delete`, and
`rm -rf $(echo /)`.
Replace the text match with target resolution. Each shell segment is checked
for a recursive delete — `rm` with both -r and -f (any spelling, including
/bin/rm), or `find` with -delete / -exec rm — and its path operands are
resolved as far as a shell would: quotes stripped, `~` / `~user` / `$HOME` /
`${HOME}` treated as a home root, trailing globs dropped. A target within two
segments of either root (`/` or home) is catastrophic; anything deeper, or
relative, or under /tmp, is ordinary work.
Fail safe: a target whose head is an expansion the policy cannot evaluate —
command substitution or a variable other than $HOME — could resolve to `/`,
so it is treated as catastrophic. Command substitution, aliases and arbitrary
indirection are not emulated; they are blocked instead of guessed at.
Resolving targets per segment also means an absolute path mentioned outside
the delete (`ls /etc && rm -rf ./dist`) no longer flags the command, and
allowPaths entries may now be written home-relative (`~/scratch`).
Closes FailproofAI#520
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Warning Review limit reached
Next review available in: 3 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Automated code review started - full review. Results will be posted here. |
|
⏳ Phase 0-1 complete. Read existing comments (2 bot comments, 0 human reviews, 0 review threads). Analyzing 2 files changed (+262/-46 lines in |
|
✅ Build & test complete.
Starting Phase 4: Posting detailed review. |
🔍 Automated Code Review📋 Executive SummaryThis PR replaces 📊 Change Architecturegraph TD
A["blockRmRf() — entry point"] --> B["shellSegments() — split on &&, ||, ;, |"]
B --> C["recursiveDeletionTargets() — per-segment parsing"]
C --> D["isCatastrophicTarget() — depth check"]
D --> E["deletionTargetIsAllowed() — allowPaths fallback"]
E --> F["VERDICT: deny/allow"]
C -->|"rm handler"| C1["RM_CMD_RE matches /bin/rm too"]
C -->|"find handler"| C2["parses -exec rm and -delete"]
D --> D1["HOME_PREFIX_RE: ~, $HOME, ${HOME}"]
D --> D2["CATASTROPHIC_DEPTH = 2"]
D --> D3["SCRATCH_ROOTS: /tmp, /var/tmp"]
style A fill:#87CEEB
style C fill:#90EE90
style D fill:#90EE90
style D1 fill:#90EE90
style D2 fill:#FFD700
style E fill:#87CEEB
Legend: 🟢 New | 🔵 Modified | 🟡 Configurable constant 🔴 Breaking Changes✅ No breaking changes detected. All pre-existing
|
hermes-exosphere
left a comment
There was a problem hiding this comment.
Automated review: Approved with suggestions. ✅ See the summary comment for details and 2 inline comments for minor improvements.
hermes-exosphere
left a comment
There was a problem hiding this comment.
Automated review: Approved. ✅
Review catch: `RM_CMD_RE` already accepted `/bin/rm`, but the find branch tested `t === "find"`, so `/usr/bin/find / -delete` walked straight through a policy that blocks `find / -delete`. Same command, same blast radius, different spelling. Adds FIND_CMD_RE with RM_CMD_RE's shape and two cases to the bypass table. Both fail without this change. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Both good catches — took the first, documented the second.
Both fail without the change and pass with it; full suite 461/461, tsc clean. Nested quote stripping — agreed it's single-level, leaving it. Your read matches mine: a mixed-quoted token gets its outer pair removed only, and the unresolvable-expansion check catches what survives, so it fails safe. I'd rather not add a quoting mini-parser to a security policy for a case that currently lands on "deny" anyway — more parser is more surface. The comment now says single-level explicitly so the next reader doesn't assume otherwise. Worth saying plainly: the boundary in the PR body is where the judgement lives, and I'd rather you overrule it than have it merged on my say-so. Particularly the |
|
Automated code review started - full review. Results will be posted here. |
|
Your PR is awaiting review by a moderator. Till then you can join the Discord for conversation: https://discord.gg/qaFM2uYFb |
|
✅ Resolved 2 previously-reported issues that have been addressed in this update:
|
🔍 Automated Code Review (Re-Review)📋 Executive SummaryPR #531 replaces 📊 Change Architecturegraph TD
A[blockRmRf entry point] --> B[shellSegments: split on && | ; \n]
B --> C[recursiveDeletionTargets: per segment]
C --> D{rm with -r and -f?}
C --> E{find with -delete/-exec rm?}
D -->|yes| F[extract path args]
E -->|yes| G[extract find path operands]
F --> H[isCatastrophicTarget: per target]
G --> H
H --> I{home-relative? expandHomePrefix}
H --> J{absolute? count segments}
H --> K{unresolvable $VAR/\$()/\`? deny}
J --> L{segments <= 2? catastrophic}
I --> L
L -->|yes| M{allowPaths cover?}
M -->|no| N[deny: Catastrophic deletion blocked]
M -->|yes| O[allow]
L -->|no| O
style C fill:#90EE90
style H fill:#90EE90
style E fill:#FFD700
style K fill:#FFD700
Legend: 🟢 New | 🔵 Modified | 🟡 Breaking Change Risk (boundary adjustment) 🔴 Breaking Changes✅ No breaking changes detected. All existing tests pass unchanged. The policy boundary has shifted slightly (relative paths are now never flagged, per-segment resolution means
|
| Check | Result |
|---|---|
| SQL injection / shell injection | ✅ Clean |
| Hardcoded secrets / credentials | ✅ Clean |
| Race conditions | ✅ N/A (synchronous policy hook) |
| Null/empty checks | ✅ Well-handled: raw === "" guard, ?? "" on expr[execIdx+1] |
| Resource leaks | ✅ N/A (pure function, no I/O) |
| Off-by-one errors | ✅ Segment counting verified (simulated all edge cases) |
| Infinite loops | ✅ None — while bounded by token array length |
| Variable shadowing | ✅ Clean |
| Wrong function calls | ✅ All correct |
| Copy-paste errors | ✅ No stale references to old variable names |
| Missing error handling | ✅ homedir() call is always safe; no other fallible calls |
Specific edge cases verified manually:
/tmpitself → catastrophic ✓/tmp/foo→ allowed (inside scratch root) ✓/var/tmpitself → catastrophic ✓$HOME→ catastrophic ✓~/dev/myapp/node_modules(3 segments) → allowed ✓$BUILD_DIR→ unresolvable, denied ✓`pwd`→ unresolvable, denied ✓$(echo /)→ unresolvable, denied ✓find / -name '*.log'(no -delete) → not a recursive delete ✓- Absolute path in unrelated segment (
ls /etc && rm -rf ./dist) → allowed ✓
🧪 Evidence — Build & Test Results
| Gate | Result |
|---|---|
bun run lint |
0 errors, 5 warnings (all pre-existing, none in touched files) |
bunx tsc --noEmit |
clean |
bun run test:run |
2101 passed, 121 files, 0 failures |
bun run build |
pass |
All 459 builtin-policies tests pass, including all 32 new tests covering bypass forms, ordinary deletes, fail-safe cases, and allowPaths expansions.
🔗 Issue Linkage
This PR closes #520 — "Bypass forms in block-rm-rf". The 15 bypass forms listed in the PR body map directly to the 16 deny-test entries in the test table (+1 for "absolute find binary with -exec rm" which was added as part of the prior review fix). Changes fully address the issue.
👥 Human Review Feedback
- @Nitjsefnie (PR author): Confirmed both previous bot review items were addressed — committed the
findabsolute-path fix and documented the quote-stripping limitation. Both resolved. - No external human reviewers yet.
💡 Suggestions
- 🟢 Minor: Consider adding a test for the pathological
find -D -delete /case (though extremely unlikely in practice). The risk is that-Dwithout an argument causes-deleteto be skipped as an expression token. - 🟢 Optional: The
FIND_GLOBAL_OPT_REregex could be extended to support-O0/-O1/-O2/-O3optimization flags (currently-O\d*only).-Olevelwithout a digit separator (e.g.,find -O2) is covered, but-Ofollowed by space+level is not a global option — it's an expression. So this is correct as-is.
🏆 Verdict
VERDICT: APPROVED
Automated code review · 2026-07-16 18:40:00 UTC
hermes-exosphere
left a comment
There was a problem hiding this comment.
Automated re-review: All 2101 tests pass, lint is clean, TypeScript compiles with no errors. Both previously-reported issues have been addressed (find absolute-path matching + quote-stripping documentation). The target-resolution logic is well-factored and correctly handles all documented bypass forms from #520. No blocking issues found. ✅
hermes-exosphere
left a comment
There was a problem hiding this comment.
Automated review: Approved. ✅
|
Your PR is awaiting review by a moderator. Till then you can join the Discord for conversation: https://discord.befailproof.ai |
|
Your PR is awaiting review by a reviewer. Till then you can join the Discord for conversation: https://discord.befailproof.ai |
blockRmRfdecided whether a command was catastrophic by pattern-matching the raw commandtext: a token was destructive only if it was exactly
/, exactly~, or a single-segmentabsolute path (
^/[A-Za-z_][\w.-]*$), and only the literal command wordrmwas known. Sothe shape of the text, not the target the shell would actually resolve, drove the verdict.
This replaces the text match with target resolution. For each shell segment we ask two
questions: is this a recursive delete? and what does it delete?
rmwith both-rand-f(any spelling/order, including/bin/rm), orfindwith-delete/-exec rm(findrecurses by design).stripped,
~/~user/$HOME/${HOME}recognised as a home root, trailing/*andslashes dropped, then the segments below the root counted.
Bypass forms from the issue — now blocked
rm -rf $HOME/rm -rf ${HOME}/rm -rf "$HOME"rm -rf $HOME/Documentsrm -rf ~/Documentsrm -rf ~chetanrm -rf /home/chetan/rm -rf /var/lib/find / -delete/find / -exec rm -rf {} \;/find $HOME -deletefind -L /home/chetan -delete/rm -rf $(echo /)/bin/rm -rf /home/chetan/The baselines the issue lists as already-correct (
rm -rf /,rm -rf /etc,rm -rf ~,rm -rf /*) stay denied, and every pre-existingblock-rm-rftest passes unchanged.The blocked-vs-allowed boundary
Blocked — a recursive delete whose resolved target is:
/or a home directory(
~,~user,$HOME,${HOME}). That covers/,/etc,/home/chetan,/var/lib,~,~/Documents,~/dev. These are what take out the machine or the user's data.$(…), backticks) or avariable other than
$HOME(rm -rf $BUILD_DIR). It could expand to/, we cannot know,so we deny. This is the deliberate fail-safe, and it is the one place the policy trades
false positives for safety (
$BUILD_DIRis the shape of the classicrm -rf "$STEAMROOT/"*bug).
allowPathsis the escape hatch.Allowed — ordinary work:
./dist,node_modules,build/,dist/$VERSION) — they resolve underthe working directory, not under a root;
/home/chetan/myapp/dist,~/dev/myapp/node_modules);/tmp/…,/var/tmp/…) — deleting/tmpitself isstill denied;
rm, andfindwithout a delete expression (find / -name '*.log').Two consequences worth flagging for review:
/home/chetan/project(3 segments) is allowed, but~/project(1 segment below home) is denied — the same directory. This is forced, and I think
correctly: the existing test
allows rm -rf /home/user/project (depth-2+ path is not flagged)fixes the first, and the issue explicitly requires the second. Treating~as aroot in its own right is what satisfies both. It also keeps the rule machine-independent —
we never compare a typed path against the actual
homedir()to decide destructiveness,so verdicts don't shift with
$HOME.dir") read literally would deny every
~/…path, including~/dev/myapp/node_modules.I narrowed it to the same 2-segment rule used for
/, on the grounds that a policy thatblocks
rm -rf ~/dev/myapp/node_modulesgets turned off. Happy to widen if you disagree.Side effects of resolving per segment rather than scanning the whole command:
ls /etc && rm -rf ./distis now allowed (previously the/etcin thelsflagged thecommand and the unrelated
rmwas denied).allowPathsentries may now be written home-relative (~/scratch); target and allowPathare expanded on both sides before comparison.
What is out of reach, and why
Not attempted — a policy hook cannot be a shell, and guessing here would be worse than
failing safe:
rm -rf $(cat target),`pwd`,$BUILD_DIR. Not evaluated (that would mean running the command). Denied instead.eval,sh -c "…"— the command word we see is not the commandthat runs.
eval $CMDis invisible to any static check.find / -type f | xargs rm -rfhas no target token on therm; the reach comes from stdin. Detecting this needs dataflow across pipeline stages.Not in the issue's list; happy to take it in a follow-up.
rm -rf ../../.., orcd /earlier in the chain. Verdicts woulddepend on the working directory, which the policy does not resolve.
rm -rf /usr/local/lib(3 segments) is allowed. Blocking it meanseither a curated list of system roots or dropping the depth rule, and the latter would
block
/home/chetan/myapp/dist. The 2-segment line is the tradeoff the existing testsalready encode.
/opt/foo/$Xis treated as its literal depth;$X=../..wouldescape. Only a token's head is checked for unresolvable expansion, otherwise every
rm -rf dist/$VERSIONwould be denied.Tests
__tests__/hooks/builtin-policies.test.ts, in the existing style: a table of the 15 bypassforms above (each asserted deny), two fail-safe cases (
$BUILD_DIR, backticks), 11ordinary-delete cases asserted allow (relative paths,
build/, a temp dir, deep home andabsolute paths,
findwithout-delete, non-recursiverm), and 5 newallowPathscasescovering home-relative allowlisting and
find. No existing test was relaxed, deleted orweakened. None of these commands are executed — the tests assert the policy's verdict on
command strings.
Gates
bun run lintbunx tsc --noEmitbun run test:runintegrations.test.ts > Pi integration > writeHookEntries…asserts a resolved package path containsfailproofai, which fails because my checkout directory is not namedfailproofai. Verified it fails identically on unmodifiedmain(git stash). All 459builtin-policiestests pass.bun run buildScope:
block-rm-rfonly —src/hooks/builtin-policies.ts+ its test file. No other policy'sbehaviour changed.
Closes #520
Prepared with AI assistance (Claude Opus 4.8), human-reviewed before submission.