Skip to content

fix: tighten recursive delete policy matching - #3251

Open
Arvid Peldan (Peldan) wants to merge 25 commits into
microsoft:mainfrom
Peldan:fix/recursive-delete-policy
Open

fix: tighten recursive delete policy matching#3251
Arvid Peldan (Peldan) wants to merge 25 commits into
microsoft:mainfrom
Peldan:fix/recursive-delete-policy

Conversation

@Peldan

@Peldan Arvid Peldan (Peldan) commented Jul 3, 2026

Copy link
Copy Markdown

Description

The recursive-delete deny rule depended too much on narrow command regexes such as rm -rf. Before this change, equivalent recursive deletes like rm -fr important-data, rm -r -f important-data, rm --recursive important-data, Remove-Item -Recurse important-data, and rd /s /q important-data could 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\b does not match rm -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 compact rm -rf/rm -fr but 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, reordered rm important-data -rf
  • split/long forms: rm -r -f, rm --recursive, rm --recursive --force (either order)
  • recursive-without-force: rm -r, rm --recursive, Remove-Item -Recurse
  • PowerShell: Remove-Item -Recurse -Force, abbreviations Remove-Item -r -fo, ri -r -fo
  • Windows: rd /s /q, del … /s /q
  • delimiter-wrapped invocations: `rm -rf /`, {rm -rf /;}, $(rm -rf /)
  • short-option clusters with unanticipated letters: rm -rfx foo
  • not denied: safe targets (rm -rf node_modules, Remove-Item -Recurse -Force build), and non-recursive deletes (rm -f, rm -i)

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • Security fix

Package(s) Affected

  • agent-governance

Checklist

  • I have added tests that prove my fix/feature works

Attribution & Prior Art

  • This contribution does not contain code copied or derived from other projects without attribution
  • Any external projects that inspired this design are credited in code comments or documentation
  • If this PR implements functionality similar to an existing open-source project, I have listed it below

AI Assistance

  • I can explain every meaningful change in this PR: what it does, why, and what tradeoffs were considered
  • I have run tests and verification appropriate for this change
  • No part of this PR was autonomously submitted by an AI agent without my review
  • I have not used AI to generate review comments on others' PRs

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

  • This contribution does not implement patent-pending or patent-encumbered techniques
  • This contribution does not require an NDA or licensing agreement to understand or use
  • Any AI tools used have terms compatible with the MIT License

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>
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown

PR Review Summary

Check Status Details
🔍 Code Review ⚠️ Missing No current-run comment
🛡️ Security Scan ⚠️ Missing No current-run comment
🔄 Breaking Changes ⚠️ Missing No current-run comment
📝 Docs Sync ⚠️ Missing No current-run comment
🧪 Test Coverage ⚠️ Missing No current-run comment

Verdict: ⚠️ AI review incomplete; ready for human review

AI review comments are untrusted advisory output. The summary reports workflow-generated completion status only, not model-authored pass/fail claims.

@github-actions github-actions Bot added the size/XL Extra large PR (500+ lines) label Jul 3, 2026
@github-actions

github-actions Bot commented Jul 3, 2026

Copy link
Copy Markdown
🤖 AI Agent: contributor-guide — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

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:

  1. The updated regex patterns are tested against edge cases to avoid unintended matches or false positives.
  2. Verify that the new helper functions (e.g., getRmCommandDetails) are covered by unit tests for all expected scenarios.

For guidance, please refer to CONTRIBUTING.md.

@Peldan

Copy link
Copy Markdown
Author

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 rm -rfv, preserves PowerShell abbreviations like -r -fo, and avoids false positives for ordinary parameters such as -Confirm and -Filter.

@Peldan

Arvid Peldan (Peldan) commented Jul 4, 2026

Copy link
Copy Markdown
Author

@microsoft-github-policy-service agree company="Regent"

@Peldan
Arvid Peldan (Peldan) marked this pull request as ready for review July 4, 2026 08:41

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 --force in either order), PowerShell -Recurse/-Force and their unambiguous abbreviations (-r, -fo), and Windows rd /s /q all resolve to recursive+force → deny.
  • -f alone is correctly gated out as force (the PowerShell force check requires length >= 2), and node_modules/build/dist, rm -f, rm --recursive alone, -Confirm/-Filter correctly 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 MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regressions vs the previous denylist:

  • Left-anchor: (^|[\s;&|()]) misses `rm -rf /` and {rm -rf /;} which the old \b caught. Add ` and { to the class in all 14 JSONs.
  • Recursive-without-force is now allowed on antigravity/copilot (a test asserts rm --recursive important-data passes). That is a loosening; restore the deny or own it explicitly in the title/body.
  • isUnixRmShortOptionCluster fails open on unknown letters (rm -rfI foo slips 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>
@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: test-generator — `agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs`

AI-generated review output. Treat it as untrusted analysis and verify before acting.

agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs

  • hasRecursiveDelete_invalid_command_syntax -- Test for invalid command syntax that should not trigger a recursive delete detection.
  • getRmCommandDetails_unexpected_command_format -- Validate handling of unexpected or malformed command formats.
  • isSafeCleanupCommand_edge_cases -- Test edge cases for isSafeCleanupCommand with unusual but valid command structures.
  • splitCommandSegments_complex_chains -- Test splitting of command chains with complex combinations of operators and quoted strings.
  • parseWindowsSlashFlags_unexpected_flags -- Validate parsing of unexpected or malformed Windows-style slash flags.

agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/default-policy.json

  • commandPatterns_edge_cases -- Test for edge cases in the updated regex patterns for command matching.

agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json

  • commandPatterns_edge_cases -- Test for edge cases in the updated regex patterns for command matching.

agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json

  • commandPatterns_edge_cases -- Test for edge cases in the updated regex patterns for command matching.

agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/strict.json

  • commandPatterns_edge_cases -- Test for edge cases in the updated regex patterns for command matching.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: code-reviewer — View details

AI-generated review output. Treat it as untrusted analysis and verify before acting.

TL;DR: 0 blockers, 1 warning. The recursive delete policy improvement is solid but could benefit from additional test coverage for edge cases.

# Sev Issue Where
1 Warn Edge cases for recursive delete detection may lack test coverage (e.g., complex command chaining). Tests

Action items: Add tests for edge cases like chained commands (rm -rf && rm -r), mixed safe/unsafe targets, and ambiguous flag parsing.

Warnings: fine as follow-up PRs.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: breaking-change-detector — API Compatibility

AI-generated review output. Treat it as untrusted analysis and verify before acting.

API Compatibility

Severity Change Impact
High The function signature of shouldBypassBlockedCommandRule was changed from shouldBypassBlockedCommandRule(rule, commandText) to shouldBypassBlockedCommandRule(rule, commandText, toolName). This is a breaking change for any external code or modules that rely on the previous function signature.
High The function isSafeCleanupCommand now requires an additional toolName parameter. This is a breaking change for any external code that calls this function without the new parameter.
High The function hasUnsafeRecursiveDelete was introduced, and it calls hasRecursiveDelete and isSafeCleanupCommand. If external code previously relied on the absence of this function or its behavior, this could be a breaking change depending on its usage.
High The function hasRecursiveDelete was introduced and is now used in the deny decision logic. This could alter the behavior of existing policies, potentially causing previously allowed commands to be denied.
High The function getRmCommandDetails was introduced and replaces parts of the logic in isSafeCleanupCommand. This could change the behavior of existing functionality, leading to potential breaking changes in how recursive delete commands are identified and handled.

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: docs-sync-checker — Docs Sync

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Docs Sync

  • hasRecursiveDelete() in policy.mjs -- missing docstring
  • README.md -- section on "Command Deny Rules" needs update to reflect changes in recursive delete policy
  • CHANGELOG.md -- missing entry for changes to recursive delete policy logic

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
🤖 AI Agent: security-scanner — Security Review

AI-generated review output. Treat it as untrusted analysis and verify before acting.

Security Review

Severity Finding Fix
High The updated regex pattern in commandPatterns may overmatch and unintentionally block commands that are not intended to be denied. The new pattern `(^ [\s;&
Medium The getRmCommandDetails function parses command tokens but may not handle all edge cases of command-line input, such as malformed commands or unexpected input formats. This could lead to bypasses or false positives/negatives. Add comprehensive test cases for edge cases, including malformed commands and unexpected input formats, to ensure robust parsing.
Medium The splitCommandSegments function may not handle all edge cases of command chaining and quoting, potentially allowing malicious commands to bypass detection. Enhance the parsing logic to handle complex command chaining and quoting scenarios more robustly. Add test cases for various edge cases.
Medium The isPowerShellTool function relies on a regex match for tool names, which may not cover all possible PowerShell aliases or variations. This could lead to incorrect parsing of PowerShell commands. Expand the regex to include all known PowerShell aliases and variations, or use a more robust method to identify PowerShell tools.
Medium The parseWindowsSlashFlags function assumes that any token starting with / and containing only letters is a valid flag. This could lead to misinterpretation of valid file paths as flags. Add stricter validation to distinguish between valid Windows flags and file paths that start with a slash.
Medium The isUnixRmShortOptionCluster function matches any single-dash short-option cluster with letters, which could lead to false positives or misinterpretation of valid commands. Implement a more precise validation for Unix short-option clusters, possibly by maintaining an allow-list of known valid flags.

@Peldan

Copy link
Copy Markdown
Author

Regressions vs the previous denylist:

  • Left-anchor: (^|[\s;&|()]) misses `rm -rf /` and {rm -rf /;} which the old \b caught. Add ` and { to the class in all 14 JSONs.
  • Recursive-without-force is now allowed on antigravity/copilot (a test asserts rm --recursive important-data passes). That is a loosening; restore the deny or own it explicitly in the title/body.
  • isUnixRmShortOptionCluster fails open on unknown letters (rm -rfI foo slips through). Invert to single-dash-letters-only.
  • Restore the trailing newline in agent-governance-opencode/lib/policy.mjs.

Thanks! All four addressed in bb6f6c8:

  1. Left-anchor: Added ` and { to the trigger class in all 14 JSONs. This alone wasn't enough, though: the parser's tokenizer kept `rm/{rm/$(rm as one glued token, so a matched trigger still bypassed the deny (commandIndex === -1). Added normalizeCommandNameToken() to strip leading ` { ( $, so `rm -rf /`, {rm -rf /;}, and $(rm -rf /) are now actually denied.
  2. Recursive-without-force: Restored the deny. hasRecursiveForceDeletehasRecursiveDelete gates on recursive alone now, so rm --recursive / rm -r / Remove-Item -Recurse are denied unless the target is a safe-cleanup dir. Applied to all four bundles for consistency, and the PR description now states this explicitly.
  3. Cluster fails open: Inverted /^-[dfiprvw]+$//^-[a-z]+$/. Small note: -rfI was actually already caught (i is in the old set), so the real fail-open is a letter outside it! The regression test uses rm -rfx foo, which slipped before and denies now.
  4. Trailing newline: Restored in agent-governance-opencode/lib/policy.mjs.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 is rmdir/rd/del/erase, this can misread Unix absolute paths as flags (the s in /srv sets recursive: true), causing non-recursive deletes like rmdir /srv/emptydir to 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 is rmdir/rd/del/erase, this can misread Unix absolute paths as flags (the s in /srv sets recursive: true), causing non-recursive deletes like rmdir /srv/emptydir to 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 is rmdir/rd/del/erase, this can misread Unix absolute paths as flags (the s in /srv sets recursive: true), causing non-recursive deletes like rmdir /srv/emptydir to 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 is rmdir/rd/del/erase, this can misread Unix absolute paths as flags (the s in /srv sets recursive: true), causing non-recursive deletes like rmdir /srv/emptydir to 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 is rmdir/rd/del/erase, this can misread Unix absolute paths as flags (the s in /srv sets recursive: true), causing non-recursive deletes like rmdir /srv/emptydir to 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)) {

Copilot AI review requested due to automatic review settings July 30, 2026 13:10

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated 5 comments.

Comment on lines 524 to +528
const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText));
if (!matchedPattern) {
continue;
}
if (shouldBypassBlockedCommandRule(rule, commandText)) {
if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) {
Comment on lines 402 to +406
const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText));
if (!matchedPattern) {
continue;
}
if (shouldBypassBlockedCommandRule(rule, commandText)) {
if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) {
Comment on lines 524 to +528
const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText));
if (!matchedPattern) {
continue;
}
if (shouldBypassBlockedCommandRule(rule, commandText)) {
if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) {
Comment on lines 401 to +405
const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText));
if (!matchedPattern) {
continue;
}
if (shouldBypassBlockedCommandRule(rule, commandText)) {
if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) {
Comment on lines 530 to +534
const matchedPattern = rule.commandPatterns.find((pattern) => pattern.regex.test(commandText));
if (!matchedPattern) {
continue;
}
if (shouldBypassBlockedCommandRule(rule, commandText)) {
if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) {

@MohammadHaroonAbuomar MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 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 -r now 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>
Copilot AI review requested due to automatic review settings August 1, 2026 17:16
@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 1, 2026
@Peldan

Copy link
Copy Markdown
Author
  • 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 -r now 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.

I fixed the quoted-command case. Both "rm" -rf / and 'rm' -rf / now reach the existing parser. The trigger change is in all 14 policy files, with regression tests in the four behavior suites.

While I was there, I fixed the Windows switch grammar. del /s/q and del/s still deny, but rmdir /srv/emptydir is no longer read as a bundle of /s flags.

For ${IFS}, caret escapes, and nested bash -c or cmd /c strings, I took the documentation option you suggested. Each package README now says that the matcher handles common literal forms and sends anything it cannot classify through the bundled policy's normal review path. Antigravity still maps that result to deny in enforce mode.

I haven't added a blanket git rm exemption. git rm -r really does remove tracked files from the working tree. git rm --cached -r only changes the index, so treating both as the same false positive would weaken the rule. If the index-only form needs special handling, I think that should be a separate, narrowly scoped change.

Can you confirm whether anything else still blocks this PR?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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. For rm, -- terminates option parsing; without handling it, a non-recursive delete like rm -- -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. For rm, -- terminates option parsing; without handling it, a non-recursive delete like rm -- -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. For rm, -- terminates option parsing; without handling it, a non-recursive delete like rm -- -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. For rm, -- terminates option parsing; without handling it, a non-recursive delete like rm -- -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. For rm, -- terminates option parsing; without handling it, a non-recursive delete like rm -- -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)

Copilot AI review requested due to automatic review settings August 1, 2026 17:22

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 becomes node_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 becomes node_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 becomes node_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 becomes node_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 MohammadHaroonAbuomar left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, or 2>&1, which will make isSafeCleanupCommand() 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, or 2>&1, which will make isSafeCleanupCommand() 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, or 2>&1, which will make isSafeCleanupCommand() 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, or 2>&1, which will make isSafeCleanupCommand() 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, or 2>&1, which will make isSafeCleanupCommand() 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;

Copilot AI review requested due to automatic review settings August 5, 2026 09:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 makes isSafeCleanupCommand() fail and can hard-deny safe cleanup deletes like rm -rf node_modules >/dev/null because 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 makes isSafeCleanupCommand() fail and can hard-deny safe cleanup deletes like rm -rf node_modules >/dev/null because 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 makes isSafeCleanupCommand() fail and can hard-deny safe cleanup deletes like rm -rf node_modules >/dev/null because 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 makes isSafeCleanupCommand() fail and can hard-deny safe cleanup deletes like rm -rf node_modules >/dev/null because 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 makes isSafeCleanupCommand() fail and can hard-deny safe cleanup deletes like rm -rf node_modules >/dev/null because 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 "";
  }

@kerberosmansour

Copy link
Copy Markdown
Contributor

Coordination note: #3662 independently reports the OpenCode instance of the recursive-delete matching problem, together with a separate non-blocking review concern. #3676 is a narrower OpenCode fix and overlaps this PR's OpenCode recursive-delete changes; it also changes enforce-mode review handling, which is outside this PR's stated scope.

Linking the work explicitly so maintainers can consolidate it or choose precedence without reviewing or merging duplicate OpenCode implementations.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/XL Extra large PR (500+ lines)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants