fix: tighten recursive delete policy matching - #3251
fix: tighten recursive delete policy matching#3251Arvid Peldan (Peldan) wants to merge 25 commits into
Conversation
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
Signed-off-by: arvidpeldan <peldans@gmail.com>
PR Review Summary
Verdict: AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims. |
🤖 AI Agent: contributor-guide — View details
Welcome, and thank you for your contribution! 🎉 Your detailed explanation of the problem and solution is excellent, and the added test coverage is thorough. Before merging, please ensure:
For guidance, please refer to CONTRIBUTING.md. |
|
The branch is split by surface so each package change is easy to review on its own. The first commits apply the recursive-delete fix and matching tests to OpenCode, Claude Code, Copilot CLI, and Antigravity CLI separately. There’s also a small examples commit to keep the Copilot CLI example policies in sync with the package configs. The final commit is a review follow-up across the affected parsers. It tightens the flag handling for longer Unix clusters like |
|
@microsoft-github-policy-service agree company="Regent" |
Imran Siddique (imran-siddique)
left a comment
There was a problem hiding this comment.
Reviewed across all four bundles (claude-code, opencode, copilot-cli, antigravity) plus the examples/copilot-cli-agt config mirror. The design is sound and fail-safe: the regex is reduced to a broad delete-command trigger (^|[\s;&|()])(?:rm|remove-item|...)\b and the actual deny decision is delegated to the new hasRecursiveForceDelete() parser. shouldBypassBlockedCommandRule now denies only when the command is genuinely recursive and force AND is not a safe-cleanup target.
I worked through the notable edge cases in getRmCommandDetails:
- Split / reordered / clustered short options (
rm -r -f,rm -fr,rm -rfv,rm target -rf), long options (--recursive --forcein either order), PowerShell-Recurse/-Forceand their unambiguous abbreviations (-r,-fo), and Windowsrd /s /qall resolve to recursive+force → deny. -falone is correctly gated out as force (the PowerShell force check requires length >= 2), andnode_modules/build/dist,rm -f,rm --recursivealone,-Confirm/-Filtercorrectly fall through as not-denied.
Importantly this does not introduce a fail-open: a destructive form the parser fails to classify reverts to the normal shell-tool review path rather than being auto-allowed, and the leading (^|[\s;&|()]) anchor prevents substring false-matches (charm, warm). The PR also correctly identifies the pre-existing \b-rf\b word-boundary bug that meant the canonical rm -rf target never fired in several bundles — this genuinely fixes that gap.
Tests are comprehensive (positive deny + negative allow lists) and mirrored per package; CI green. Careful, well-scoped security hardening. LGTM.
MohammadHaroonAbuomar
left a comment
There was a problem hiding this comment.
Regressions vs the previous denylist:
- Left-anchor:
(^|[\s;&|()])misses`rm -rf /`and{rm -rf /;}which the old\bcaught. Add`and{to the class in all 14 JSONs. - Recursive-without-force is now allowed on antigravity/copilot (a test asserts
rm --recursive important-datapasses). That is a loosening; restore the deny or own it explicitly in the title/body. isUnixRmShortOptionClusterfails open on unknown letters (rm -rfI fooslips through). Invert to single-dash-letters-only.- Restore the trailing newline in
agent-governance-opencode/lib/policy.mjs.
Follow-up to the recursive-delete hardening, addressing review feedback on PR microsoft#3251. Applied to all four bundles (claude-code, opencode, copilot-cli, antigravity) plus the examples config mirror. - Trigger anchor: add ` and { to the delete-command character class in all 14 policy JSONs so `rm -rf /` and {rm -rf /;} are matched, which the previous class dropped. Also add normalizeCommandNameToken() so the parser un-glues leading `, {, (, $ from the command token; without it a matched trigger still bypassed the deny (commandIndex === -1). - Recursive-without-force: restore the deny. hasRecursiveForceDelete -> hasRecursiveDelete now gates on recursive alone (force is still parsed but no longer required), so `rm --recursive`, `rm -r`, and `Remove-Item -Recurse` are denied again unless the target is a safe-cleanup dir. - Short-option cluster: invert isUnixRmShortOptionCluster from the allow-list /^-[dfiprvw]+$/ to letters-only /^-[a-z]+$/, so a cluster with an unanticipated letter (e.g. `rm -rfx foo`) no longer fails open and drops its r/f flags. - Restore the trailing newline in agent-governance-opencode/lib/policy.mjs. Tests updated per bundle: recursive-only and delimiter-wrapped forms moved to the deny lists, a genuine fail-open cluster (-rfx) added as a regression guard, and the artificial bash -Confirm negative replaced with `rm -i` (PowerShell -Confirm/-Filter negatives kept under their proper surface). All four suites pass (npm run check, exit 0). Signed-off-by: arvidpeldan <peldans@gmail.com>
🤖 AI Agent: test-generator — `agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs`
|
🤖 AI Agent: code-reviewer — View details
TL;DR: 0 blockers, 1 warning. The recursive delete policy improvement is solid but could benefit from additional test coverage for edge cases.
Action items: Add tests for edge cases like chained commands ( Warnings: fine as follow-up PRs. |
🤖 AI Agent: breaking-change-detector — API Compatibility
API Compatibility
|
🤖 AI Agent: docs-sync-checker — Docs Sync
Docs Sync
|
🤖 AI Agent: security-scanner — Security Review
Security Review
|
Thanks! All four addressed in
|
Signed-off-by: arvidpeldan <peldans@gmail.com>
Imran Siddique (imran-siddique)
left a comment
There was a problem hiding this comment.
The four requested changes are all present at head (backtick/brace anchors, recursive-without-force now denies, letter-only cluster parsing, trailing newline), and the parser-based approach is fail-closed. The blocking review is stale and should be re-reviewed or dismissed.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (5)
agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs:1171
parseWindowsSlashFlags()currently treats any token made only of/+ letters as a Windows switch bundle (e.g. it matches/srv/emptydir). When the delete command isrmdir/rd/del/erase, this can misread Unix absolute paths as flags (thesin/srvsetsrecursive: true), causing non-recursive deletes likermdir /srv/emptydirto be hard-denied.
Tighten the switch regex to cmd-style one-letter segments so typical paths like /usr, /srv, /sbin are not interpreted as flags.
if (!/^(?:\/[a-z]+)+$/i.test(value)) {
agent-governance-opencode/lib/policy.mjs:1069
parseWindowsSlashFlags()currently treats any token made only of/+ letters as a Windows switch bundle (e.g. it matches/srv/emptydir). When the delete command isrmdir/rd/del/erase, this can misread Unix absolute paths as flags (thesin/srvsetsrecursive: true), causing non-recursive deletes likermdir /srv/emptydirto be hard-denied.
Tighten the switch regex to cmd-style one-letter segments so typical paths like /usr, /srv, /sbin are not interpreted as flags.
if (!/^(?:\/[a-z]+)+$/i.test(value)) {
agent-governance-claude-code/lib/policy.mjs:1068
parseWindowsSlashFlags()currently treats any token made only of/+ letters as a Windows switch bundle (e.g. it matches/srv/emptydir). When the delete command isrmdir/rd/del/erase, this can misread Unix absolute paths as flags (thesin/srvsetsrecursive: true), causing non-recursive deletes likermdir /srv/emptydirto be hard-denied.
Tighten the switch regex to cmd-style one-letter segments so typical paths like /usr, /srv, /sbin are not interpreted as flags.
if (!/^(?:\/[a-z]+)+$/i.test(value)) {
agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs:1210
parseWindowsSlashFlags()currently treats any token made only of/+ letters as a Windows switch bundle (e.g. it matches/srv/emptydir). When the delete command isrmdir/rd/del/erase, this can misread Unix absolute paths as flags (thesin/srvsetsrecursive: true), causing non-recursive deletes likermdir /srv/emptydirto be hard-denied.
Tighten the switch regex to cmd-style one-letter segments so typical paths like /usr, /srv, /sbin are not interpreted as flags.
if (!/^(?:\/[a-z]+)+$/i.test(value)) {
examples/copilot-cli-agt/.github/extensions/agt-global-policy/lib/policy.mjs:1168
parseWindowsSlashFlags()currently treats any token made only of/+ letters as a Windows switch bundle (e.g. it matches/srv/emptydir). When the delete command isrmdir/rd/del/erase, this can misread Unix absolute paths as flags (thesin/srvsetsrecursive: true), causing non-recursive deletes likermdir /srv/emptydirto be hard-denied.
Tighten the switch regex to cmd-style one-letter segments so typical paths like /usr, /srv, /sbin are not interpreted as flags.
if (!/^(?:\/[a-z]+)+$/i.test(value)) {
| const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText)); | ||
| if (!matchedPattern) { | ||
| continue; | ||
| } | ||
| if (shouldBypassBlockedCommandRule(rule, commandText)) { | ||
| if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) { |
| const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText)); | ||
| if (!matchedPattern) { | ||
| continue; | ||
| } | ||
| if (shouldBypassBlockedCommandRule(rule, commandText)) { | ||
| if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) { |
| const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText)); | ||
| if (!matchedPattern) { | ||
| continue; | ||
| } | ||
| if (shouldBypassBlockedCommandRule(rule, commandText)) { | ||
| if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) { |
| const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText)); | ||
| if (!matchedPattern) { | ||
| continue; | ||
| } | ||
| if (shouldBypassBlockedCommandRule(rule, commandText)) { | ||
| if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) { |
| const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText)); | ||
| if (!matchedPattern) { | ||
| continue; | ||
| } | ||
| if (shouldBypassBlockedCommandRule(rule, commandText)) { | ||
| if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) { |
MohammadHaroonAbuomar
left a comment
There was a problem hiding this comment.
- lib/policy.mjs trigger regex anchor class: add
"'so quote-prefixed tokens deny ("rm" -rf /currently asks; verified a 2-character regex fix makes it deny: the engine already normalizes quoted tokens, no shell parser needed). - For the declined IFS/caret evasions: acceptable to defer ONLY with deny-on-parser-miss-when-trigger-matched, or a documented limitation in each policy README; today they degrade to review silently.
- New FP:
git rm -r/git rm --cached -rnow hard-deny; exempt the git subcommand form.
Minor:
- the 5-copy consistency and the resurrected dead rule (old \b-rf\b never matched, even rm -rf /) are verified good.
Let quoted command names reach the recursive-delete parser. Tighten Windows switch parsing so /srv/emptydir stays a path, and document the cases that fall back to review. Signed-off-by: arvidpeldan <peldans@gmail.com>
I fixed the quoted-command case. Both While I was there, I fixed the Windows switch grammar. For I haven't added a blanket Can you confirm whether anything else still blocks this PR? |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (5)
agent-governance-opencode/lib/policy.mjs:925
getRmCommandDetails()treats any-...token as an option. Forrm,--terminates option parsing; without handling it, a non-recursive delete likerm -- -rf(removing a file literally named-rf) is misclassified as recursive and hard-denied.
if (normalizedToken.startsWith("-")) {
const normalizedFlag = normalizedToken.toLowerCase();
if (
normalizedFlag === "--recursive" ||
isPowerShellRecursiveParameter(normalizedFlag)
agent-governance-claude-code/lib/policy.mjs:924
getRmCommandDetails()treats any-...token as an option. Forrm,--terminates option parsing; without handling it, a non-recursive delete likerm -- -rf(removing a file literally named-rf) is misclassified as recursive and hard-denied.
if (normalizedToken.startsWith("-")) {
const normalizedFlag = normalizedToken.toLowerCase();
if (
normalizedFlag === "--recursive" ||
isPowerShellRecursiveParameter(normalizedFlag)
examples/copilot-cli-agt/.github/extensions/agt-global-policy/lib/policy.mjs:1024
getRmCommandDetails()treats any-...token as an option. Forrm,--terminates option parsing; without handling it, a non-recursive delete likerm -- -rf(removing a file literally named-rf) is misclassified as recursive and hard-denied.
if (normalizedToken.startsWith("-")) {
const normalizedFlag = normalizedToken.toLowerCase();
if (
normalizedFlag === "--recursive" ||
isPowerShellRecursiveParameter(normalizedFlag)
agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs:1027
getRmCommandDetails()treats any-...token as an option. Forrm,--terminates option parsing; without handling it, a non-recursive delete likerm -- -rf(removing a file literally named-rf) is misclassified as recursive and hard-denied.
if (normalizedToken.startsWith("-")) {
const normalizedFlag = normalizedToken.toLowerCase();
if (
normalizedFlag === "--recursive" ||
isPowerShellRecursiveParameter(normalizedFlag)
agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs:1066
getRmCommandDetails()treats any-...token as an option. Forrm,--terminates option parsing; without handling it, a non-recursive delete likerm -- -rf(removing a file literally named-rf) is misclassified as recursive and hard-denied.
if (normalizedToken.startsWith("-")) {
const normalizedFlag = normalizedToken.toLowerCase();
if (
normalizedFlag === "--recursive" ||
isPowerShellRecursiveParameter(normalizedFlag)
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (4)
agent-governance-claude-code/lib/policy.mjs:1320
normalizeCommandPathToken()preserves trailing delimiters (e.g.,)/}/]) on target tokens. This makes safe-cleanup targets fail the allowlist in wrapper forms like$(rm -rf node_modules)(target token becomesnode_modules)), so a safe recursive delete can be incorrectly denied even though the PR description says safe targets should not be denied.
function normalizeCommandPathToken(token) {
const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, "");
if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) {
return "";
}
agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs:1504
normalizeCommandPathToken()preserves trailing delimiters (e.g.,)/}/]) on target tokens. This makes safe-cleanup targets fail the allowlist in wrapper forms like$(rm -rf node_modules)(target token becomesnode_modules)), so a safe recursive delete can be incorrectly denied even though the PR description says safe targets should not be denied.
function normalizeCommandPathToken(token) {
const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, "");
if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) {
return "";
}
agent-governance-opencode/lib/policy.mjs:1321
normalizeCommandPathToken()preserves trailing delimiters (e.g.,)/}/]) on target tokens. This makes safe-cleanup targets fail the allowlist in wrapper forms like$(rm -rf node_modules)(target token becomesnode_modules)), so a safe recursive delete can be incorrectly denied even though the PR description says safe targets should not be denied.
function normalizeCommandPathToken(token) {
const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, "");
if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) {
return "";
}
examples/copilot-cli-agt/.github/extensions/agt-global-policy/lib/policy.mjs:1434
normalizeCommandPathToken()preserves trailing delimiters (e.g.,)/}/]) on target tokens. This makes safe-cleanup targets fail the allowlist in wrapper forms like$(rm -rf node_modules)(target token becomesnode_modules)), so a safe recursive delete can be incorrectly denied even though the PR description says safe targets should not be denied.
function normalizeCommandPathToken(token) {
const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, "");
if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) {
return "";
}
MohammadHaroonAbuomar
left a comment
There was a problem hiding this comment.
- agent-governance-opencode/lib/policy.mjs: one item remains:
git rm --cached -r(index-only, touches no working-tree files) still hard-denies. Either exclude --cached from the destructive-rm classification or document it as a known false positive in the package READMEs like the IFS/caret limitation. (git rm -r without --cached staying deny is accepted per your argument.)
`git rm --cached -r <path>` only updates the index and leaves the working tree alone, but the recursive-delete matcher classifies it as destructive and denies it. Record it alongside the other matcher limitations in each bundle README rather than special-casing the parser. Signed-off-by: arvidpeldan <peldans@gmail.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (5)
agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs:1021
getRmCommandDetails()treats any non-flag token as a delete target. That includes common shell redirection tokens like>/dev/null,2>/dev/null, or2>&1, which will makeisSafeCleanupCommand()fail and incorrectly deny safe-cleanup deletes (e.g.,rm -rf node_modules >/dev/null 2>&1). Consider skipping redirection operators (and their following path token when separated by whitespace) when collecting candidate targets.
const candidateTargets = [];
for (const token of tokens.slice(commandIndex + 1)) {
const normalizedToken = stripCommandToken(token);
if (!normalizedToken) {
continue;
agent-governance-opencode/lib/policy.mjs:919
getRmCommandDetails()treats any non-flag token as a delete target. That includes common shell redirection tokens like>/dev/null,2>/dev/null, or2>&1, which will makeisSafeCleanupCommand()fail and incorrectly deny safe-cleanup deletes (e.g.,rm -rf node_modules >/dev/null 2>&1). Consider skipping redirection operators (and their following path token when separated by whitespace) when collecting candidate targets.
const candidateTargets = [];
for (const token of tokens.slice(commandIndex + 1)) {
const normalizedToken = stripCommandToken(token);
if (!normalizedToken) {
continue;
agent-governance-claude-code/lib/policy.mjs:918
getRmCommandDetails()treats any non-flag token as a delete target. That includes common shell redirection tokens like>/dev/null,2>/dev/null, or2>&1, which will makeisSafeCleanupCommand()fail and incorrectly deny safe-cleanup deletes (e.g.,rm -rf node_modules >/dev/null 2>&1). Consider skipping redirection operators (and their following path token when separated by whitespace) when collecting candidate targets.
const candidateTargets = [];
for (const token of tokens.slice(commandIndex + 1)) {
const normalizedToken = stripCommandToken(token);
if (!normalizedToken) {
continue;
agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs:1060
getRmCommandDetails()treats any non-flag token as a delete target. That includes common shell redirection tokens like>/dev/null,2>/dev/null, or2>&1, which will makeisSafeCleanupCommand()fail and incorrectly deny safe-cleanup deletes (e.g.,rm -rf node_modules >/dev/null 2>&1). Consider skipping redirection operators (and their following path token when separated by whitespace) when collecting candidate targets.
const candidateTargets = [];
for (const token of tokens.slice(commandIndex + 1)) {
const normalizedToken = stripCommandToken(token);
if (!normalizedToken) {
continue;
examples/copilot-cli-agt/.github/extensions/agt-global-policy/lib/policy.mjs:1018
getRmCommandDetails()treats any non-flag token as a delete target. That includes common shell redirection tokens like>/dev/null,2>/dev/null, or2>&1, which will makeisSafeCleanupCommand()fail and incorrectly deny safe-cleanup deletes (e.g.,rm -rf node_modules >/dev/null 2>&1). Consider skipping redirection operators (and their following path token when separated by whitespace) when collecting candidate targets.
const candidateTargets = [];
for (const token of tokens.slice(commandIndex + 1)) {
const normalizedToken = stripCommandToken(token);
if (!normalizedToken) {
continue;
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 28 out of 28 changed files in this pull request and generated no new comments.
Suppressed comments (5)
agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs:1504
normalizeCommandPathToken()currently treats shell redirection tokens as deletion targets (e.g.,>/dev/null,2>&1). That makesisSafeCleanupCommand()fail and can hard-deny safe cleanup deletes likerm -rf node_modules >/dev/nullbecause the redirect token is not in the safe-cleanup list.
function normalizeCommandPathToken(token) {
const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, "");
if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) {
return "";
}
agent-governance-opencode/lib/policy.mjs:1321
normalizeCommandPathToken()currently treats shell redirection tokens as deletion targets (e.g.,>/dev/null,2>&1). That makesisSafeCleanupCommand()fail and can hard-deny safe cleanup deletes likerm -rf node_modules >/dev/nullbecause the redirect token is not in the safe-cleanup list.
function normalizeCommandPathToken(token) {
const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, "");
if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) {
return "";
}
agent-governance-claude-code/lib/policy.mjs:1320
normalizeCommandPathToken()currently treats shell redirection tokens as deletion targets (e.g.,>/dev/null,2>&1). That makesisSafeCleanupCommand()fail and can hard-deny safe cleanup deletes likerm -rf node_modules >/dev/nullbecause the redirect token is not in the safe-cleanup list.
function normalizeCommandPathToken(token) {
const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, "");
if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) {
return "";
}
examples/copilot-cli-agt/.github/extensions/agt-global-policy/lib/policy.mjs:1434
normalizeCommandPathToken()currently treats shell redirection tokens as deletion targets (e.g.,>/dev/null,2>&1). That makesisSafeCleanupCommand()fail and can hard-deny safe cleanup deletes likerm -rf node_modules >/dev/nullbecause the redirect token is not in the safe-cleanup list.
function normalizeCommandPathToken(token) {
const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, "");
if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) {
return "";
}
agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs:1550
normalizeCommandPathToken()currently treats shell redirection tokens as deletion targets (e.g.,>/dev/null,2>&1). That makesisSafeCleanupCommand()fail and can hard-deny safe cleanup deletes likerm -rf node_modules >/dev/nullbecause the redirect token is not in the safe-cleanup list.
function normalizeCommandPathToken(token) {
const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, "");
if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) {
return "";
}
|
Coordination note: #3662 independently reports the OpenCode instance of the recursive-delete matching problem, together with a separate non-blocking Linking the work explicitly so maintainers can consolidate it or choose precedence without reviewing or merging duplicate OpenCode implementations. |
Description
The recursive-delete deny rule depended too much on narrow command regexes such as
rm -rf. Before this change, equivalent recursive deletes likerm -fr important-data,rm -r -f important-data,rm --recursive important-data,Remove-Item -Recurse important-data, andrd /s /q important-datacould miss the deny rule and fall through to the normal shell-tool review path instead.One root cause was that several old rules matched text shape rather than command semantics. In the Claude Code, OpenCode, and Copilot CLI bundles the Bash rule used patterns like
\brm\b[\s\S]*\b-rf\b; in JavaScript regex semantics\b-rf\bdoes not matchrm -rf target(the space and-are both non-word characters), so the intended deny never fired for the canonical form. Antigravity's old pattern matched compactrm -rf/rm -frbut missed split/reordered forms (rm -r -f,rm --force --recursive,rm target -rf).This PR reduces the regex to a broad delete-command trigger and delegates the deny decision to a
hasRecursiveDelete()parser that inspects the actual flags, so equivalent destructive forms are denied consistently while safe cleanup targets are not.Deny semantics: a delete is denied when it is recursive, regardless of whether a force flag is present, unless every target is a known safe-cleanup directory (
node_modules,dist,build, …). Force is still parsed but is not required to deny. Non-recursive deletes (rm -f,rm -i,rm --force) are not hard-denied by this rule.Notable cases covered:
rm -rf,rm -fr,rm -rfv, reorderedrm important-data -rfrm -r -f,rm --recursive,rm --recursive --force(either order)rm -r,rm --recursive,Remove-Item -RecurseRemove-Item -Recurse -Force, abbreviationsRemove-Item -r -fo,ri -r -ford /s /q,del … /s /q`rm -rf /`,{rm -rf /;},$(rm -rf /)rm -rfx foorm -rf node_modules,Remove-Item -Recurse -Force build), and non-recursive deletes (rm -f,rm -i)Type of Change
Package(s) Affected
Checklist
Attribution & Prior Art
AI Assistance
If AI tools materially shaped this change, briefly note what was used:
Codex helped add regression tests, find the same parser issue in sibling packages, and carry the reviewed fix across those packages. I reviewed the implementation and test coverage.
IP, Patents, and Licensing