From eaa6741fd8f3f2c8b8d5245a57a7a869b3348d85 Mon Sep 17 00:00:00 2001 From: arvidpeldan Date: Fri, 3 Jul 2026 14:33:14 +0200 Subject: [PATCH 1/8] fix(opencode): deny recursive force deletes Signed-off-by: arvidpeldan --- .../config/default-policy.json | 2 +- agent-governance-opencode/lib/policy.mjs | 56 ++++++++++++++++--- .../test/policy.test.mjs | 56 +++++++++++++++++++ 3 files changed, 104 insertions(+), 10 deletions(-) diff --git a/agent-governance-opencode/config/default-policy.json b/agent-governance-opencode/config/default-policy.json index f04bb034f..01a5b3503 100644 --- a/agent-governance-opencode/config/default-policy.json +++ b/agent-governance-opencode/config/default-policy.json @@ -40,7 +40,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-opencode/lib/policy.mjs b/agent-governance-opencode/lib/policy.mjs index 8523add64..0f8f16cd2 100644 --- a/agent-governance-opencode/lib/policy.mjs +++ b/agent-governance-opencode/lib/policy.mjs @@ -879,7 +879,7 @@ function createMinimalFallbackPolicy() { function shouldBypassBlockedCommandRule(rule, commandText) { if (rule.id === "recursive-delete") { - return isSafeCleanupCommand(commandText); + return !hasRecursiveForceDelete(commandText) || isSafeCleanupCommand(commandText); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -887,25 +887,45 @@ function shouldBypassBlockedCommandRule(rule, commandText) { return false; } -function isSafeCleanupCommand(commandText) { - if (containsCommandControlOperator(commandText)) { - return false; - } - +function getRmCommandDetails(commandText) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), ); if (commandIndex === -1) { - return false; + return undefined; } + let recursive = false; + let force = false; const candidateTargets = []; for (const token of tokens.slice(commandIndex + 1)) { const normalizedToken = stripCommandToken(token); - if (!normalizedToken || normalizedToken.startsWith("-")) { + if (!normalizedToken) { continue; } + if (normalizedToken.startsWith("-")) { + const normalizedFlag = normalizedToken.toLowerCase(); + if ( + normalizedFlag === "--recursive" || + normalizedFlag === "-recursive" || + normalizedFlag === "-recurse" + ) { + recursive = true; + } else if (normalizedFlag === "--force" || normalizedFlag === "-force") { + force = true; + } else if (/^-[a-z]{1,2}$/i.test(normalizedFlag)) { + recursive ||= /r/i.test(normalizedFlag); + force ||= /f/i.test(normalizedFlag); + } + continue; + } + if (/^\/[a-z]+$/i.test(normalizedToken)) { + recursive ||= /s/i.test(normalizedToken); + force ||= /[fq]/i.test(normalizedToken); + continue; + } + for (const part of normalizedToken.split(",")) { const cleaned = normalizeCommandPathToken(part); if (cleaned) { @@ -914,6 +934,25 @@ function isSafeCleanupCommand(commandText) { } } + return { + force, + recursive, + targets: candidateTargets, + }; +} + +function hasRecursiveForceDelete(commandText) { + const details = getRmCommandDetails(commandText); + return Boolean(details?.recursive && details?.force); +} + +function isSafeCleanupCommand(commandText) { + if (containsCommandControlOperator(commandText)) { + return false; + } + + const details = getRmCommandDetails(commandText); + const candidateTargets = details?.targets ?? []; return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget); } @@ -1360,4 +1399,3 @@ function redactSecretLikeContent(text, _findings) { function describeSecretFindings(findings) { return `matched ${findings.length} secret pattern(s): ${findings.join(", ")}`; } - diff --git a/agent-governance-opencode/test/policy.test.mjs b/agent-governance-opencode/test/policy.test.mjs index 76d5ada1b..6988ad389 100644 --- a/agent-governance-opencode/test/policy.test.mjs +++ b/agent-governance-opencode/test/policy.test.mjs @@ -81,6 +81,62 @@ test("evaluateOpenCodeTool denies dangerous bash bootstrap and reviews persisten await rm(root, { recursive: true, force: true }); }); +test("evaluateOpenCodeTool denies recursive force deletes", async () => { + const root = await mkdtemp(join(tmpdir(), "agt-opencode-rm-")); + const state = await loadPolicy({ auditPath: join(root, "audit.json") }); + + for (const command of [ + "rm -rf important-data", + "rm -fr important-data", + "rm -r -f important-data", + "rm --recursive --force important-data", + "rm --force --recursive important-data", + "rm important-data -rf", + "rm -rf /", + "rm -rf ~/.ssh", + "npm test && rm -rf important-data", + "Remove-Item -Recurse -Force important-data", + "rd /s /q important-data", + ]) { + const result = await evaluateOpenCodeTool(state, { + tool: "bash", + args: { command }, + sessionId: "rm-session", + cwd: root, + }); + + assert.equal(result.effect, "deny", command); + } + + await rm(root, { recursive: true, force: true }); +}); + +test("evaluateOpenCodeTool does not deny safe cleanup or non-recursive force deletes", async () => { + const root = await mkdtemp(join(tmpdir(), "agt-opencode-rm-safe-")); + const state = await loadPolicy({ auditPath: join(root, "audit.json") }); + + for (const command of [ + "rm -rf node_modules", + "rm -rf build", + "rm -fr dist", + "rm -f important-data", + "rm --force important-data", + "Remove-Item -Recurse -Force build", + "rd /s /q node_modules", + ]) { + const result = await evaluateOpenCodeTool(state, { + tool: "bash", + args: { command }, + sessionId: "rm-safe-session", + cwd: root, + }); + + assert.notEqual(result.effect, "deny", command); + } + + await rm(root, { recursive: true, force: true }); +}); + test("evaluateOpenCodeTool denies metadata URL fetches regardless of arg name", async () => { const root = await mkdtemp(join(tmpdir(), "agt-opencode-url-")); const state = await loadPolicy({ auditPath: join(root, "audit.json") }); From 7b9e86f67df18b0669d32ebf9525e538c2b64e35 Mon Sep 17 00:00:00 2001 From: arvidpeldan Date: Fri, 3 Jul 2026 14:38:06 +0200 Subject: [PATCH 2/8] fix(claude-code): deny recursive force deletes Signed-off-by: arvidpeldan --- .../config/default-policy.json | 2 +- agent-governance-claude-code/lib/policy.mjs | 55 +++++++++++++++--- .../test/policy.test.mjs | 58 +++++++++++++++++++ 3 files changed, 106 insertions(+), 9 deletions(-) diff --git a/agent-governance-claude-code/config/default-policy.json b/agent-governance-claude-code/config/default-policy.json index bb21c16b1..9809c76d7 100644 --- a/agent-governance-claude-code/config/default-policy.json +++ b/agent-governance-claude-code/config/default-policy.json @@ -38,7 +38,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-claude-code/lib/policy.mjs b/agent-governance-claude-code/lib/policy.mjs index f4f68e22e..5d0ef0a1a 100644 --- a/agent-governance-claude-code/lib/policy.mjs +++ b/agent-governance-claude-code/lib/policy.mjs @@ -878,7 +878,7 @@ function createMinimalFallbackPolicy() { function shouldBypassBlockedCommandRule(rule, commandText) { if (rule.id === "recursive-delete") { - return isSafeCleanupCommand(commandText); + return !hasRecursiveForceDelete(commandText) || isSafeCleanupCommand(commandText); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -886,25 +886,45 @@ function shouldBypassBlockedCommandRule(rule, commandText) { return false; } -function isSafeCleanupCommand(commandText) { - if (containsCommandControlOperator(commandText)) { - return false; - } - +function getRmCommandDetails(commandText) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), ); if (commandIndex === -1) { - return false; + return undefined; } + let recursive = false; + let force = false; const candidateTargets = []; for (const token of tokens.slice(commandIndex + 1)) { const normalizedToken = stripCommandToken(token); - if (!normalizedToken || normalizedToken.startsWith("-")) { + if (!normalizedToken) { continue; } + if (normalizedToken.startsWith("-")) { + const normalizedFlag = normalizedToken.toLowerCase(); + if ( + normalizedFlag === "--recursive" || + normalizedFlag === "-recursive" || + normalizedFlag === "-recurse" + ) { + recursive = true; + } else if (normalizedFlag === "--force" || normalizedFlag === "-force") { + force = true; + } else if (/^-[a-z]{1,2}$/i.test(normalizedFlag)) { + recursive ||= /r/i.test(normalizedFlag); + force ||= /f/i.test(normalizedFlag); + } + continue; + } + if (/^\/[a-z]+$/i.test(normalizedToken)) { + recursive ||= /s/i.test(normalizedToken); + force ||= /[fq]/i.test(normalizedToken); + continue; + } + for (const part of normalizedToken.split(",")) { const cleaned = normalizeCommandPathToken(part); if (cleaned) { @@ -913,6 +933,25 @@ function isSafeCleanupCommand(commandText) { } } + return { + force, + recursive, + targets: candidateTargets, + }; +} + +function hasRecursiveForceDelete(commandText) { + const details = getRmCommandDetails(commandText); + return Boolean(details?.recursive && details?.force); +} + +function isSafeCleanupCommand(commandText) { + if (containsCommandControlOperator(commandText)) { + return false; + } + + const details = getRmCommandDetails(commandText); + const candidateTargets = details?.targets ?? []; return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget); } diff --git a/agent-governance-claude-code/test/policy.test.mjs b/agent-governance-claude-code/test/policy.test.mjs index 0e82e510c..80c1fb58f 100644 --- a/agent-governance-claude-code/test/policy.test.mjs +++ b/agent-governance-claude-code/test/policy.test.mjs @@ -81,6 +81,64 @@ test("evaluatePreToolUse denies dangerous bootstrap and reviews persistence writ await rm(root, { recursive: true, force: true }); }); +test("evaluatePreToolUse denies recursive force deletes", async () => { + const root = await mkdtemp(join(tmpdir(), "agt-claude-rm-")); + const auditPath = join(root, "audit.json"); + const state = await loadPolicy({ auditPath }); + + for (const command of [ + "rm -rf important-data", + "rm -fr important-data", + "rm -r -f important-data", + "rm --recursive --force important-data", + "rm --force --recursive important-data", + "rm important-data -rf", + "rm -rf /", + "rm -rf ~/.ssh", + "npm test && rm -rf important-data", + "Remove-Item -Recurse -Force important-data", + "rd /s /q important-data", + ]) { + const result = await evaluatePreToolUse(state, { + tool_name: "Bash", + tool_input: { command }, + session_id: "rm-session", + cwd: root, + }); + + assert.equal(result.hookSpecificOutput.permissionDecision, "deny", command); + } + + await rm(root, { recursive: true, force: true }); +}); + +test("evaluatePreToolUse does not deny safe cleanup or non-recursive force deletes", async () => { + const root = await mkdtemp(join(tmpdir(), "agt-claude-rm-safe-")); + const auditPath = join(root, "audit.json"); + const state = await loadPolicy({ auditPath }); + + for (const command of [ + "rm -rf node_modules", + "rm -rf build", + "rm -fr dist", + "rm -f important-data", + "rm --force important-data", + "Remove-Item -Recurse -Force build", + "rd /s /q node_modules", + ]) { + const result = await evaluatePreToolUse(state, { + tool_name: "Bash", + tool_input: { command }, + session_id: "rm-safe-session", + cwd: root, + }); + + assert.notEqual(result.hookSpecificOutput.permissionDecision, "deny", command); + } + + await rm(root, { recursive: true, force: true }); +}); + test("evaluatePreToolUse denies Windows-style secret reads", async () => { const root = await mkdtemp(join(tmpdir(), "agt-claude-windows-secret-")); const auditPath = join(root, "audit.json"); From 628e36a8feedccb41de691f4b73fe9257440fc1d Mon Sep 17 00:00:00 2001 From: arvidpeldan Date: Fri, 3 Jul 2026 14:49:53 +0200 Subject: [PATCH 3/8] fix(copilot-cli): deny recursive force deletes Signed-off-by: arvidpeldan --- .../config/default-policy.json | 4 +- .../config/profiles/advisory.json | 4 +- .../config/profiles/balanced.json | 4 +- .../config/profiles/strict.json | 4 +- .../agt-global-policy/lib/policy.mjs | 55 +++++++++-- .../test/policy-engine.test.mjs | 93 ++++++++++++++++++- 6 files changed, 147 insertions(+), 17 deletions(-) diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/default-policy.json b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/default-policy.json index be106a186..64c9610c6 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/default-policy.json +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/default-policy.json @@ -29,7 +29,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(remove-item|rm|ri|rd|del)\\b[\\s\\S]*\\b(-recurse|-rf)\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -109,7 +109,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json index 92a9df000..3ce240ad6 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json @@ -30,7 +30,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(remove-item|rm|ri|rd|del)\\b[\\s\\S]*\\b(-recurse|-rf)\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -110,7 +110,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json index 1a97e1f3e..43f4e3c8b 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json @@ -49,7 +49,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(remove-item|rm|ri|rd|del)\\b[\\s\\S]*\\b(-recurse|-rf)\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -129,7 +129,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/strict.json b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/strict.json index 640410af5..4c53073a6 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/strict.json +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/strict.json @@ -30,7 +30,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(remove-item|rm|ri|rd|del)\\b[\\s\\S]*\\b(-recurse|-rf)\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -110,7 +110,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs index af70b8e72..019a31c01 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs @@ -981,7 +981,7 @@ function createMinimalFallbackPolicy() { function shouldBypassBlockedCommandRule(rule, commandText) { if (rule.id === "recursive-delete") { - return isSafeCleanupCommand(commandText); + return !hasRecursiveForceDelete(commandText) || isSafeCleanupCommand(commandText); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -989,25 +989,45 @@ function shouldBypassBlockedCommandRule(rule, commandText) { return false; } -function isSafeCleanupCommand(commandText) { - if (containsCommandControlOperator(commandText)) { - return false; - } - +function getRmCommandDetails(commandText) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), ); if (commandIndex === -1) { - return false; + return undefined; } + let recursive = false; + let force = false; const candidateTargets = []; for (const token of tokens.slice(commandIndex + 1)) { const normalizedToken = stripCommandToken(token); - if (!normalizedToken || normalizedToken.startsWith("-")) { + if (!normalizedToken) { continue; } + if (normalizedToken.startsWith("-")) { + const normalizedFlag = normalizedToken.toLowerCase(); + if ( + normalizedFlag === "--recursive" || + normalizedFlag === "-recursive" || + normalizedFlag === "-recurse" + ) { + recursive = true; + } else if (normalizedFlag === "--force" || normalizedFlag === "-force") { + force = true; + } else if (/^-[a-z]{1,2}$/i.test(normalizedFlag)) { + recursive ||= /r/i.test(normalizedFlag); + force ||= /f/i.test(normalizedFlag); + } + continue; + } + if (/^\/[a-z]+$/i.test(normalizedToken)) { + recursive ||= /s/i.test(normalizedToken); + force ||= /[fq]/i.test(normalizedToken); + continue; + } + for (const part of normalizedToken.split(",")) { const cleaned = normalizeCommandPathToken(part); if (cleaned) { @@ -1016,6 +1036,25 @@ function isSafeCleanupCommand(commandText) { } } + return { + force, + recursive, + targets: candidateTargets, + }; +} + +function hasRecursiveForceDelete(commandText) { + const details = getRmCommandDetails(commandText); + return Boolean(details?.recursive && details?.force); +} + +function isSafeCleanupCommand(commandText) { + if (containsCommandControlOperator(commandText)) { + return false; + } + + const details = getRmCommandDetails(commandText); + const candidateTargets = details?.targets ?? []; return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget); } diff --git a/agent-governance-copilot-cli/test/policy-engine.test.mjs b/agent-governance-copilot-cli/test/policy-engine.test.mjs index 44cdaf359..f02b9174b 100644 --- a/agent-governance-copilot-cli/test/policy-engine.test.mjs +++ b/agent-governance-copilot-cli/test/policy-engine.test.mjs @@ -2,8 +2,11 @@ // Licensed under the MIT License. import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { PromptDefenseEvaluator } from "@microsoft/agent-governance-sdk"; import { @@ -11,12 +14,54 @@ import { buildLegacyRules, checkArbitraryText, compilePolicy, + evaluatePreToolUse, evaluateDirectResourceAccess, extractCommandText, formatPolicySummary, getOutputHandlingMode, + loadPolicy, } from "../assets/extensions/agt-global-policy/lib/policy.mjs"; +async function withPackagedPolicyState(callback) { + const root = await mkdtemp(join(tmpdir(), "agt-copilot-policy-")); + const previousSdkEntry = process.env.AGT_COPILOT_SDK_ENTRY; + const previousUnsafeOverride = process.env.AGT_COPILOT_ALLOW_UNSAFE_SDK_OVERRIDE; + const previousAuditPath = process.env.AGT_COPILOT_AUDIT_PATH; + process.env.AGT_COPILOT_SDK_ENTRY = fileURLToPath( + new URL("../node_modules/@microsoft/agent-governance-sdk/dist/index.js", import.meta.url), + ); + process.env.AGT_COPILOT_ALLOW_UNSAFE_SDK_OVERRIDE = "true"; + process.env.AGT_COPILOT_AUDIT_PATH = join(root, "audit.json"); + + try { + const state = await loadPolicy({ + defaultPolicyPath: new URL( + "../assets/extensions/agt-global-policy/config/default-policy.json", + import.meta.url, + ), + homeDirectory: root, + }); + return await callback(state); + } finally { + if (previousSdkEntry === undefined) { + delete process.env.AGT_COPILOT_SDK_ENTRY; + } else { + process.env.AGT_COPILOT_SDK_ENTRY = previousSdkEntry; + } + if (previousUnsafeOverride === undefined) { + delete process.env.AGT_COPILOT_ALLOW_UNSAFE_SDK_OVERRIDE; + } else { + process.env.AGT_COPILOT_ALLOW_UNSAFE_SDK_OVERRIDE = previousUnsafeOverride; + } + if (previousAuditPath === undefined) { + delete process.env.AGT_COPILOT_AUDIT_PATH; + } else { + process.env.AGT_COPILOT_AUDIT_PATH = previousAuditPath; + } + await rm(root, { recursive: true, force: true }); + } +} + test("default packaged policy keeps the hardened developer-protection baseline", async () => { const rawPolicy = JSON.parse( await readFile( @@ -411,6 +456,52 @@ test("evaluateDirectResourceAccess denies secret reads, allows env templates, re ); }); +test("evaluatePreToolUse denies recursive force deletes", async () => { + await withPackagedPolicyState(async (state) => { + for (const [toolName, command] of [ + ["bash", "rm -rf important-data"], + ["bash", "rm -fr important-data"], + ["bash", "rm -r -f important-data"], + ["bash", "rm --recursive --force important-data"], + ["bash", "rm --force --recursive important-data"], + ["bash", "rm important-data -rf"], + ["bash", "rm -rf /"], + ["bash", "rm -rf ~/.ssh"], + ["bash", "npm test && rm -rf important-data"], + ["powershell", "Remove-Item -Recurse -Force important-data"], + ["powershell", "rd /s /q important-data"], + ]) { + const result = await evaluatePreToolUse(state, { + toolName, + toolArgs: { command }, + }); + + assert.equal(result?.permissionDecision, "deny", command); + } + }); +}); + +test("evaluatePreToolUse does not deny safe cleanup or non-recursive force deletes", async () => { + await withPackagedPolicyState(async (state) => { + for (const [toolName, command] of [ + ["bash", "rm -rf node_modules"], + ["bash", "rm -rf build"], + ["bash", "rm -fr dist"], + ["bash", "rm -f important-data"], + ["bash", "rm --force important-data"], + ["powershell", "Remove-Item -Recurse -Force build"], + ["powershell", "rd /s /q node_modules"], + ]) { + const result = await evaluatePreToolUse(state, { + toolName, + toolArgs: { command }, + }); + + assert.notEqual(result?.permissionDecision, "deny", command); + } + }); +}); + test("getOutputHandlingMode ignores unscanned tools", () => { const policy = compilePolicy({ blockedToolCalls: [], From b60f42907ba4282159724f7f39af98541ff1dc31 Mon Sep 17 00:00:00 2001 From: arvidpeldan Date: Fri, 3 Jul 2026 14:54:06 +0200 Subject: [PATCH 4/8] fix(antigravity-cli): deny recursive force deletes Signed-off-by: arvidpeldan --- .../config/default-policy.json | 2 +- .../config/profiles/advisory.json | 2 +- .../config/profiles/balanced.json | 2 +- .../config/profiles/strict.json | 2 +- .../agt-global-policy/lib/policy.mjs | 57 ++++++++-- .../test/policy-engine.test.mjs | 107 +++++++++++++++++- 6 files changed, 158 insertions(+), 14 deletions(-) diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/default-policy.json b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/default-policy.json index 414d9a7bd..6847e9f0d 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/default-policy.json +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/default-policy.json @@ -43,7 +43,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(?:rm|del|rmdir|remove-item)\\b[\\s\\S]*(?:-rf|-fr|--recursive|/s)", + "source": "(^|[\\s;&|()])(?:rm|remove-item|rmdir|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json index b2261b881..f010bf36c 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json @@ -44,7 +44,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(?:rm|del|rmdir|remove-item)\\b[\\s\\S]*(?:-rf|-fr|--recursive|/s)", + "source": "(^|[\\s;&|()])(?:rm|remove-item|rmdir|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json index 2b9f32841..e70122525 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json @@ -44,7 +44,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(?:rm|del|rmdir|remove-item)\\b[\\s\\S]*(?:-rf|-fr|--recursive|/s)", + "source": "(^|[\\s;&|()])(?:rm|remove-item|rmdir|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/strict.json b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/strict.json index 3eec52953..7357dc49f 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/strict.json +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/strict.json @@ -44,7 +44,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(?:rm|del|rmdir|remove-item)\\b[\\s\\S]*(?:-rf|-fr|--recursive|/s)", + "source": "(^|[\\s;&|()])(?:rm|remove-item|rmdir|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs index 7a38b7375..3b3bfa582 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs @@ -1020,7 +1020,7 @@ function createMinimalFallbackPolicy() { function shouldBypassBlockedCommandRule(rule, commandText) { if (rule.id === "recursive-delete") { - return isSafeCleanupCommand(commandText); + return !hasRecursiveForceDelete(commandText) || isSafeCleanupCommand(commandText); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -1028,25 +1028,45 @@ function shouldBypassBlockedCommandRule(rule, commandText) { return false; } -function isSafeCleanupCommand(commandText) { - if (containsCommandControlOperator(commandText)) { - return false; - } - +function getRmCommandDetails(commandText) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => - /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), + /^(rm|remove-item|rmdir|ri|rd|del)$/i.test(stripCommandToken(token)), ); if (commandIndex === -1) { - return false; + return undefined; } + let recursive = false; + let force = false; const candidateTargets = []; for (const token of tokens.slice(commandIndex + 1)) { const normalizedToken = stripCommandToken(token); - if (!normalizedToken || normalizedToken.startsWith("-")) { + if (!normalizedToken) { continue; } + if (normalizedToken.startsWith("-")) { + const normalizedFlag = normalizedToken.toLowerCase(); + if ( + normalizedFlag === "--recursive" || + normalizedFlag === "-recursive" || + normalizedFlag === "-recurse" + ) { + recursive = true; + } else if (normalizedFlag === "--force" || normalizedFlag === "-force") { + force = true; + } else if (/^-[a-z]{1,2}$/i.test(normalizedFlag)) { + recursive ||= /r/i.test(normalizedFlag); + force ||= /f/i.test(normalizedFlag); + } + continue; + } + if (/^\/[a-z]+$/i.test(normalizedToken)) { + recursive ||= /s/i.test(normalizedToken); + force ||= /[fq]/i.test(normalizedToken); + continue; + } + for (const part of normalizedToken.split(",")) { const cleaned = normalizeCommandPathToken(part); if (cleaned) { @@ -1055,6 +1075,25 @@ function isSafeCleanupCommand(commandText) { } } + return { + force, + recursive, + targets: candidateTargets, + }; +} + +function hasRecursiveForceDelete(commandText) { + const details = getRmCommandDetails(commandText); + return Boolean(details?.recursive && details?.force); +} + +function isSafeCleanupCommand(commandText) { + if (containsCommandControlOperator(commandText)) { + return false; + } + + const details = getRmCommandDetails(commandText); + const candidateTargets = details?.targets ?? []; return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget); } diff --git a/agent-governance-antigravity-cli/test/policy-engine.test.mjs b/agent-governance-antigravity-cli/test/policy-engine.test.mjs index 6ab6e705e..4a146cabd 100644 --- a/agent-governance-antigravity-cli/test/policy-engine.test.mjs +++ b/agent-governance-antigravity-cli/test/policy-engine.test.mjs @@ -2,10 +2,11 @@ // Licensed under the MIT License. import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; +import { fileURLToPath } from "node:url"; import { PromptDefenseEvaluator } from "@microsoft/agent-governance-sdk"; import { @@ -18,8 +19,41 @@ import { extractCommandText, formatPolicySummary, getOutputHandlingMode, + loadPolicy, } from "../assets/extensions/agt-global-policy/lib/policy.mjs"; +const DEFAULT_POLICY_URL = new URL( + "../assets/extensions/agt-global-policy/config/default-policy.json", + import.meta.url, +); + +async function withPackagedPolicyState(customizePolicy, callback) { + const root = await mkdtemp(join(tmpdir(), "agt-antigravity-policy-")); + const nodeModulesSource = fileURLToPath(new URL("../node_modules", import.meta.url)); + const nodeModulesTarget = join(root, "vendor", "agent-governance-sdk", "node_modules"); + await cp(nodeModulesSource, nodeModulesTarget, { recursive: true }); + + const rawPolicy = JSON.parse(await readFile(DEFAULT_POLICY_URL, "utf8")); + const policy = customizePolicy(rawPolicy); + const policyPath = join(root, "policy.json"); + await writeFile(policyPath, `${JSON.stringify(policy, null, 2)}\n`, "utf8"); + + try { + const state = await loadPolicy({ + defaultPolicyPath: DEFAULT_POLICY_URL, + extensionRoot: root, + homeDirectory: root, + policyPath, + env: { + AGT_ANTIGRAVITY_AUDIT_PATH: join(root, "audit.json"), + }, + }); + return await callback(state); + } finally { + await rm(root, { recursive: true, force: true }); + } +} + test("default packaged policy keeps the hardened Antigravity developer-protection baseline", async () => { const rawPolicy = JSON.parse( await readFile( @@ -320,6 +354,77 @@ test("evaluatePreToolUse denies review-only tools because Antigravity hooks cann assert.match(result.permissionDecisionReason, /cannot pause for manual approval/i); }); +test("evaluatePreToolUse denies recursive force deletes", async () => { + await withPackagedPolicyState( + (policy) => ({ + ...policy, + mode: "enforce", + toolPolicies: { + ...policy.toolPolicies, + allowedTools: ["run_shell_command"], + defaultEffect: "allow", + reviewTools: [], + }, + }), + async (state) => { + for (const command of [ + "rm -r -f important-data", + "rm -rf important-data", + "rm -fr important-data", + "rm --recursive --force important-data", + "rm --force --recursive important-data", + "rm important-data -rf", + "rm -rf /", + "rm -rf ~/.ssh", + "npm test && rm -rf important-data", + "Remove-Item -Recurse -Force important-data", + "rd /s /q important-data", + ]) { + const result = await evaluatePreToolUse(state, { + toolArgs: { command }, + toolName: "run_shell_command", + }); + + assert.equal(result?.permissionDecision, "deny", command); + } + }, + ); +}); + +test("evaluatePreToolUse does not deny safe cleanup or non-recursive force deletes", async () => { + await withPackagedPolicyState( + (policy) => ({ + ...policy, + mode: "enforce", + toolPolicies: { + ...policy.toolPolicies, + allowedTools: ["run_shell_command"], + defaultEffect: "allow", + reviewTools: [], + }, + }), + async (state) => { + for (const command of [ + "rm -rf node_modules", + "rm -rf build", + "rm -fr dist", + "rm -f important-data", + "rm --force important-data", + "rm --recursive important-data", + "Remove-Item -Recurse -Force build", + "rd /s /q node_modules", + ]) { + const result = await evaluatePreToolUse(state, { + toolArgs: { command }, + toolName: "run_shell_command", + }); + + assert.notEqual(result?.permissionDecision, "deny", command); + } + }, + ); +}); + test("evaluateDirectResourceAccess blocks metadata and secret-path reads", () => { const policy = compilePolicy( JSON.parse(`{ From d2b0409cb34e6e9eb7113895870714d0ce946668 Mon Sep 17 00:00:00 2001 From: arvidpeldan Date: Fri, 3 Jul 2026 14:55:13 +0200 Subject: [PATCH 5/8] fix(examples): update recursive delete policies Signed-off-by: arvidpeldan --- examples/copilot-cli-agt/config/default-policy.json | 4 ++-- examples/copilot-cli-agt/config/profiles/advisory.json | 4 ++-- examples/copilot-cli-agt/config/profiles/balanced.json | 4 ++-- examples/copilot-cli-agt/config/profiles/strict.json | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/copilot-cli-agt/config/default-policy.json b/examples/copilot-cli-agt/config/default-policy.json index be106a186..64c9610c6 100644 --- a/examples/copilot-cli-agt/config/default-policy.json +++ b/examples/copilot-cli-agt/config/default-policy.json @@ -29,7 +29,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(remove-item|rm|ri|rd|del)\\b[\\s\\S]*\\b(-recurse|-rf)\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -109,7 +109,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/examples/copilot-cli-agt/config/profiles/advisory.json b/examples/copilot-cli-agt/config/profiles/advisory.json index 92a9df000..3ce240ad6 100644 --- a/examples/copilot-cli-agt/config/profiles/advisory.json +++ b/examples/copilot-cli-agt/config/profiles/advisory.json @@ -30,7 +30,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(remove-item|rm|ri|rd|del)\\b[\\s\\S]*\\b(-recurse|-rf)\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -110,7 +110,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/examples/copilot-cli-agt/config/profiles/balanced.json b/examples/copilot-cli-agt/config/profiles/balanced.json index 1a97e1f3e..43f4e3c8b 100644 --- a/examples/copilot-cli-agt/config/profiles/balanced.json +++ b/examples/copilot-cli-agt/config/profiles/balanced.json @@ -49,7 +49,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(remove-item|rm|ri|rd|del)\\b[\\s\\S]*\\b(-recurse|-rf)\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -129,7 +129,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/examples/copilot-cli-agt/config/profiles/strict.json b/examples/copilot-cli-agt/config/profiles/strict.json index 640410af5..4c53073a6 100644 --- a/examples/copilot-cli-agt/config/profiles/strict.json +++ b/examples/copilot-cli-agt/config/profiles/strict.json @@ -30,7 +30,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\b(remove-item|rm|ri|rd|del)\\b[\\s\\S]*\\b(-recurse|-rf)\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -110,7 +110,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "\\brm\\b[\\s\\S]*\\b-rf\\b", + "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] From c57b937169032ebcff7d42d9f8c1df356827b521 Mon Sep 17 00:00:00 2001 From: arvidpeldan Date: Fri, 3 Jul 2026 15:27:29 +0200 Subject: [PATCH 6/8] fix: harden recursive delete flag parsing Signed-off-by: arvidpeldan --- .../agt-global-policy/lib/policy.mjs | 55 ++++++++++++++----- .../test/policy-engine.test.mjs | 9 +++ agent-governance-claude-code/lib/policy.mjs | 49 +++++++++++++---- .../test/policy.test.mjs | 9 +++ .../agt-global-policy/lib/policy.mjs | 49 +++++++++++++---- .../test/policy-engine.test.mjs | 9 +++ agent-governance-opencode/lib/policy.mjs | 49 +++++++++++++---- .../test/policy.test.mjs | 9 +++ 8 files changed, 187 insertions(+), 51 deletions(-) diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs index 3b3bfa582..d8ff5a72e 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs @@ -531,7 +531,7 @@ function createCommandPatternBackend(policy) { if (!matchedPattern) { continue; } - if (shouldBypassBlockedCommandRule(rule, commandText)) { + if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) { continue; } @@ -1018,9 +1018,9 @@ function createMinimalFallbackPolicy() { }; } -function shouldBypassBlockedCommandRule(rule, commandText) { +function shouldBypassBlockedCommandRule(rule, commandText, toolName) { if (rule.id === "recursive-delete") { - return !hasRecursiveForceDelete(commandText) || isSafeCleanupCommand(commandText); + return !hasRecursiveForceDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -1028,7 +1028,7 @@ function shouldBypassBlockedCommandRule(rule, commandText) { return false; } -function getRmCommandDetails(commandText) { +function getRmCommandDetails(commandText, toolName) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => /^(rm|remove-item|rmdir|ri|rd|del)$/i.test(stripCommandToken(token)), @@ -1037,6 +1037,8 @@ function getRmCommandDetails(commandText) { return undefined; } + const commandName = stripCommandToken(tokens[commandIndex]).toLowerCase(); + const parsesUnixShortOptions = commandName === "rm" && !isPowerShellTool(toolName); let recursive = false; let force = false; const candidateTargets = []; @@ -1049,13 +1051,12 @@ function getRmCommandDetails(commandText) { const normalizedFlag = normalizedToken.toLowerCase(); if ( normalizedFlag === "--recursive" || - normalizedFlag === "-recursive" || - normalizedFlag === "-recurse" + isPowerShellRecursiveParameter(normalizedFlag) ) { recursive = true; - } else if (normalizedFlag === "--force" || normalizedFlag === "-force") { + } else if (normalizedFlag === "--force" || isPowerShellForceParameter(normalizedFlag)) { force = true; - } else if (/^-[a-z]{1,2}$/i.test(normalizedFlag)) { + } else if (parsesUnixShortOptions && isUnixRmShortOptionCluster(normalizedFlag)) { recursive ||= /r/i.test(normalizedFlag); force ||= /f/i.test(normalizedFlag); } @@ -1082,21 +1083,45 @@ function getRmCommandDetails(commandText) { }; } -function hasRecursiveForceDelete(commandText) { - const details = getRmCommandDetails(commandText); - return Boolean(details?.recursive && details?.force); -} - -function isSafeCleanupCommand(commandText) { +function isSafeCleanupCommand(commandText, toolName) { if (containsCommandControlOperator(commandText)) { return false; } - const details = getRmCommandDetails(commandText); + const details = getRmCommandDetails(commandText, toolName); const candidateTargets = details?.targets ?? []; return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget); } +function hasRecursiveForceDelete(commandText, toolName) { + const details = getRmCommandDetails(commandText, toolName); + return Boolean(details?.recursive && details?.force); +} + +function isPowerShellTool(toolName) { + return /\b(?:powershell|pwsh)\b/i.test(String(toolName ?? "")); +} + +function isPowerShellRecursiveParameter(flag) { + if (!/^-[a-z]+$/i.test(flag) || flag.startsWith("--")) { + return false; + } + const parameterName = flag.slice(1).toLowerCase(); + return parameterName === "recursive" || "recurse".startsWith(parameterName); +} + +function isPowerShellForceParameter(flag) { + if (!/^-[a-z]+$/i.test(flag) || flag.startsWith("--")) { + return false; + } + const parameterName = flag.slice(1).toLowerCase(); + return parameterName.length >= 2 && "force".startsWith(parameterName); +} + +function isUnixRmShortOptionCluster(flag) { + return /^-[dfiprvw]+$/i.test(flag); +} + function isSafeEnvTemplateReadCommand(commandText) { if (containsCommandControlOperator(commandText)) { return false; diff --git a/agent-governance-antigravity-cli/test/policy-engine.test.mjs b/agent-governance-antigravity-cli/test/policy-engine.test.mjs index 4a146cabd..36e6abbf2 100644 --- a/agent-governance-antigravity-cli/test/policy-engine.test.mjs +++ b/agent-governance-antigravity-cli/test/policy-engine.test.mjs @@ -371,13 +371,18 @@ test("evaluatePreToolUse denies recursive force deletes", async () => { "rm -r -f important-data", "rm -rf important-data", "rm -fr important-data", + "rm -rfv important-data", + "rm -rvf important-data", "rm --recursive --force important-data", "rm --force --recursive important-data", "rm important-data -rf", "rm -rf /", "rm -rf ~/.ssh", "npm test && rm -rf important-data", + "rm -r -fo important-data", "Remove-Item -Recurse -Force important-data", + "Remove-Item -r -fo important-data", + "ri -r -fo important-data", "rd /s /q important-data", ]) { const result = await evaluatePreToolUse(state, { @@ -406,11 +411,15 @@ test("evaluatePreToolUse does not deny safe cleanup or non-recursive force delet async (state) => { for (const command of [ "rm -rf node_modules", + "rm -rfv node_modules", "rm -rf build", "rm -fr dist", "rm -f important-data", "rm --force important-data", "rm --recursive important-data", + "rm important-data -Confirm", + "Remove-Item -Filter *.tmp important-data", + "Remove-Item important-data -Confirm", "Remove-Item -Recurse -Force build", "rd /s /q node_modules", ]) { diff --git a/agent-governance-claude-code/lib/policy.mjs b/agent-governance-claude-code/lib/policy.mjs index 5d0ef0a1a..653b9397d 100644 --- a/agent-governance-claude-code/lib/policy.mjs +++ b/agent-governance-claude-code/lib/policy.mjs @@ -402,7 +402,7 @@ function createCommandPatternBackend(policy) { if (!matchedPattern) { continue; } - if (shouldBypassBlockedCommandRule(rule, commandText)) { + if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) { continue; } @@ -876,9 +876,9 @@ function createMinimalFallbackPolicy() { }; } -function shouldBypassBlockedCommandRule(rule, commandText) { +function shouldBypassBlockedCommandRule(rule, commandText, toolName) { if (rule.id === "recursive-delete") { - return !hasRecursiveForceDelete(commandText) || isSafeCleanupCommand(commandText); + return !hasRecursiveForceDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -886,7 +886,7 @@ function shouldBypassBlockedCommandRule(rule, commandText) { return false; } -function getRmCommandDetails(commandText) { +function getRmCommandDetails(commandText, toolName) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), @@ -895,6 +895,8 @@ function getRmCommandDetails(commandText) { return undefined; } + const commandName = stripCommandToken(tokens[commandIndex]).toLowerCase(); + const parsesUnixShortOptions = commandName === "rm" && !isPowerShellTool(toolName); let recursive = false; let force = false; const candidateTargets = []; @@ -907,13 +909,12 @@ function getRmCommandDetails(commandText) { const normalizedFlag = normalizedToken.toLowerCase(); if ( normalizedFlag === "--recursive" || - normalizedFlag === "-recursive" || - normalizedFlag === "-recurse" + isPowerShellRecursiveParameter(normalizedFlag) ) { recursive = true; - } else if (normalizedFlag === "--force" || normalizedFlag === "-force") { + } else if (normalizedFlag === "--force" || isPowerShellForceParameter(normalizedFlag)) { force = true; - } else if (/^-[a-z]{1,2}$/i.test(normalizedFlag)) { + } else if (parsesUnixShortOptions && isUnixRmShortOptionCluster(normalizedFlag)) { recursive ||= /r/i.test(normalizedFlag); force ||= /f/i.test(normalizedFlag); } @@ -940,21 +941,45 @@ function getRmCommandDetails(commandText) { }; } -function hasRecursiveForceDelete(commandText) { - const details = getRmCommandDetails(commandText); +function hasRecursiveForceDelete(commandText, toolName) { + const details = getRmCommandDetails(commandText, toolName); return Boolean(details?.recursive && details?.force); } -function isSafeCleanupCommand(commandText) { +function isSafeCleanupCommand(commandText, toolName) { if (containsCommandControlOperator(commandText)) { return false; } - const details = getRmCommandDetails(commandText); + const details = getRmCommandDetails(commandText, toolName); const candidateTargets = details?.targets ?? []; return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget); } +function isPowerShellTool(toolName) { + return /\b(?:powershell|pwsh)\b/i.test(String(toolName ?? "")); +} + +function isPowerShellRecursiveParameter(flag) { + if (!/^-[a-z]+$/i.test(flag) || flag.startsWith("--")) { + return false; + } + const parameterName = flag.slice(1).toLowerCase(); + return parameterName === "recursive" || "recurse".startsWith(parameterName); +} + +function isPowerShellForceParameter(flag) { + if (!/^-[a-z]+$/i.test(flag) || flag.startsWith("--")) { + return false; + } + const parameterName = flag.slice(1).toLowerCase(); + return parameterName.length >= 2 && "force".startsWith(parameterName); +} + +function isUnixRmShortOptionCluster(flag) { + return /^-[dfiprvw]+$/i.test(flag); +} + function isSafeEnvTemplateReadCommand(commandText) { if (containsCommandControlOperator(commandText)) { return false; diff --git a/agent-governance-claude-code/test/policy.test.mjs b/agent-governance-claude-code/test/policy.test.mjs index 80c1fb58f..10c2c8d02 100644 --- a/agent-governance-claude-code/test/policy.test.mjs +++ b/agent-governance-claude-code/test/policy.test.mjs @@ -89,6 +89,8 @@ test("evaluatePreToolUse denies recursive force deletes", async () => { for (const command of [ "rm -rf important-data", "rm -fr important-data", + "rm -rfv important-data", + "rm -rvf important-data", "rm -r -f important-data", "rm --recursive --force important-data", "rm --force --recursive important-data", @@ -96,7 +98,10 @@ test("evaluatePreToolUse denies recursive force deletes", async () => { "rm -rf /", "rm -rf ~/.ssh", "npm test && rm -rf important-data", + "rm -r -fo important-data", "Remove-Item -Recurse -Force important-data", + "Remove-Item -r -fo important-data", + "ri -r -fo important-data", "rd /s /q important-data", ]) { const result = await evaluatePreToolUse(state, { @@ -119,10 +124,14 @@ test("evaluatePreToolUse does not deny safe cleanup or non-recursive force delet for (const command of [ "rm -rf node_modules", + "rm -rfv node_modules", "rm -rf build", "rm -fr dist", "rm -f important-data", "rm --force important-data", + "rm important-data -Confirm", + "Remove-Item -Filter *.tmp important-data", + "Remove-Item important-data -Confirm", "Remove-Item -Recurse -Force build", "rd /s /q node_modules", ]) { diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs index 019a31c01..dfa2dc61f 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs @@ -525,7 +525,7 @@ function createCommandPatternBackend(policy) { if (!matchedPattern) { continue; } - if (shouldBypassBlockedCommandRule(rule, commandText)) { + if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) { continue; } @@ -979,9 +979,9 @@ function createMinimalFallbackPolicy() { }; } -function shouldBypassBlockedCommandRule(rule, commandText) { +function shouldBypassBlockedCommandRule(rule, commandText, toolName) { if (rule.id === "recursive-delete") { - return !hasRecursiveForceDelete(commandText) || isSafeCleanupCommand(commandText); + return !hasRecursiveForceDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -989,7 +989,7 @@ function shouldBypassBlockedCommandRule(rule, commandText) { return false; } -function getRmCommandDetails(commandText) { +function getRmCommandDetails(commandText, toolName) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), @@ -998,6 +998,8 @@ function getRmCommandDetails(commandText) { return undefined; } + const commandName = stripCommandToken(tokens[commandIndex]).toLowerCase(); + const parsesUnixShortOptions = commandName === "rm" && !isPowerShellTool(toolName); let recursive = false; let force = false; const candidateTargets = []; @@ -1010,13 +1012,12 @@ function getRmCommandDetails(commandText) { const normalizedFlag = normalizedToken.toLowerCase(); if ( normalizedFlag === "--recursive" || - normalizedFlag === "-recursive" || - normalizedFlag === "-recurse" + isPowerShellRecursiveParameter(normalizedFlag) ) { recursive = true; - } else if (normalizedFlag === "--force" || normalizedFlag === "-force") { + } else if (normalizedFlag === "--force" || isPowerShellForceParameter(normalizedFlag)) { force = true; - } else if (/^-[a-z]{1,2}$/i.test(normalizedFlag)) { + } else if (parsesUnixShortOptions && isUnixRmShortOptionCluster(normalizedFlag)) { recursive ||= /r/i.test(normalizedFlag); force ||= /f/i.test(normalizedFlag); } @@ -1043,21 +1044,45 @@ function getRmCommandDetails(commandText) { }; } -function hasRecursiveForceDelete(commandText) { - const details = getRmCommandDetails(commandText); +function hasRecursiveForceDelete(commandText, toolName) { + const details = getRmCommandDetails(commandText, toolName); return Boolean(details?.recursive && details?.force); } -function isSafeCleanupCommand(commandText) { +function isSafeCleanupCommand(commandText, toolName) { if (containsCommandControlOperator(commandText)) { return false; } - const details = getRmCommandDetails(commandText); + const details = getRmCommandDetails(commandText, toolName); const candidateTargets = details?.targets ?? []; return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget); } +function isPowerShellTool(toolName) { + return /\b(?:powershell|pwsh)\b/i.test(String(toolName ?? "")); +} + +function isPowerShellRecursiveParameter(flag) { + if (!/^-[a-z]+$/i.test(flag) || flag.startsWith("--")) { + return false; + } + const parameterName = flag.slice(1).toLowerCase(); + return parameterName === "recursive" || "recurse".startsWith(parameterName); +} + +function isPowerShellForceParameter(flag) { + if (!/^-[a-z]+$/i.test(flag) || flag.startsWith("--")) { + return false; + } + const parameterName = flag.slice(1).toLowerCase(); + return parameterName.length >= 2 && "force".startsWith(parameterName); +} + +function isUnixRmShortOptionCluster(flag) { + return /^-[dfiprvw]+$/i.test(flag); +} + function isSafeEnvTemplateReadCommand(commandText) { if (containsCommandControlOperator(commandText)) { return false; diff --git a/agent-governance-copilot-cli/test/policy-engine.test.mjs b/agent-governance-copilot-cli/test/policy-engine.test.mjs index f02b9174b..9cd9edf3c 100644 --- a/agent-governance-copilot-cli/test/policy-engine.test.mjs +++ b/agent-governance-copilot-cli/test/policy-engine.test.mjs @@ -461,6 +461,8 @@ test("evaluatePreToolUse denies recursive force deletes", async () => { for (const [toolName, command] of [ ["bash", "rm -rf important-data"], ["bash", "rm -fr important-data"], + ["bash", "rm -rfv important-data"], + ["bash", "rm -rvf important-data"], ["bash", "rm -r -f important-data"], ["bash", "rm --recursive --force important-data"], ["bash", "rm --force --recursive important-data"], @@ -468,7 +470,10 @@ test("evaluatePreToolUse denies recursive force deletes", async () => { ["bash", "rm -rf /"], ["bash", "rm -rf ~/.ssh"], ["bash", "npm test && rm -rf important-data"], + ["powershell", "rm -r -fo important-data"], ["powershell", "Remove-Item -Recurse -Force important-data"], + ["powershell", "Remove-Item -r -fo important-data"], + ["powershell", "ri -r -fo important-data"], ["powershell", "rd /s /q important-data"], ]) { const result = await evaluatePreToolUse(state, { @@ -485,10 +490,14 @@ test("evaluatePreToolUse does not deny safe cleanup or non-recursive force delet await withPackagedPolicyState(async (state) => { for (const [toolName, command] of [ ["bash", "rm -rf node_modules"], + ["bash", "rm -rfv node_modules"], ["bash", "rm -rf build"], ["bash", "rm -fr dist"], ["bash", "rm -f important-data"], ["bash", "rm --force important-data"], + ["powershell", "rm important-data -Confirm"], + ["powershell", "Remove-Item -Filter *.tmp important-data"], + ["powershell", "Remove-Item important-data -Confirm"], ["powershell", "Remove-Item -Recurse -Force build"], ["powershell", "rd /s /q node_modules"], ]) { diff --git a/agent-governance-opencode/lib/policy.mjs b/agent-governance-opencode/lib/policy.mjs index 0f8f16cd2..b0657b50e 100644 --- a/agent-governance-opencode/lib/policy.mjs +++ b/agent-governance-opencode/lib/policy.mjs @@ -403,7 +403,7 @@ function createCommandPatternBackend(policy) { if (!matchedPattern) { continue; } - if (shouldBypassBlockedCommandRule(rule, commandText)) { + if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) { continue; } @@ -877,9 +877,9 @@ function createMinimalFallbackPolicy() { }; } -function shouldBypassBlockedCommandRule(rule, commandText) { +function shouldBypassBlockedCommandRule(rule, commandText, toolName) { if (rule.id === "recursive-delete") { - return !hasRecursiveForceDelete(commandText) || isSafeCleanupCommand(commandText); + return !hasRecursiveForceDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -887,7 +887,7 @@ function shouldBypassBlockedCommandRule(rule, commandText) { return false; } -function getRmCommandDetails(commandText) { +function getRmCommandDetails(commandText, toolName) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), @@ -896,6 +896,8 @@ function getRmCommandDetails(commandText) { return undefined; } + const commandName = stripCommandToken(tokens[commandIndex]).toLowerCase(); + const parsesUnixShortOptions = commandName === "rm" && !isPowerShellTool(toolName); let recursive = false; let force = false; const candidateTargets = []; @@ -908,13 +910,12 @@ function getRmCommandDetails(commandText) { const normalizedFlag = normalizedToken.toLowerCase(); if ( normalizedFlag === "--recursive" || - normalizedFlag === "-recursive" || - normalizedFlag === "-recurse" + isPowerShellRecursiveParameter(normalizedFlag) ) { recursive = true; - } else if (normalizedFlag === "--force" || normalizedFlag === "-force") { + } else if (normalizedFlag === "--force" || isPowerShellForceParameter(normalizedFlag)) { force = true; - } else if (/^-[a-z]{1,2}$/i.test(normalizedFlag)) { + } else if (parsesUnixShortOptions && isUnixRmShortOptionCluster(normalizedFlag)) { recursive ||= /r/i.test(normalizedFlag); force ||= /f/i.test(normalizedFlag); } @@ -941,21 +942,45 @@ function getRmCommandDetails(commandText) { }; } -function hasRecursiveForceDelete(commandText) { - const details = getRmCommandDetails(commandText); +function hasRecursiveForceDelete(commandText, toolName) { + const details = getRmCommandDetails(commandText, toolName); return Boolean(details?.recursive && details?.force); } -function isSafeCleanupCommand(commandText) { +function isSafeCleanupCommand(commandText, toolName) { if (containsCommandControlOperator(commandText)) { return false; } - const details = getRmCommandDetails(commandText); + const details = getRmCommandDetails(commandText, toolName); const candidateTargets = details?.targets ?? []; return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget); } +function isPowerShellTool(toolName) { + return /\b(?:powershell|pwsh)\b/i.test(String(toolName ?? "")); +} + +function isPowerShellRecursiveParameter(flag) { + if (!/^-[a-z]+$/i.test(flag) || flag.startsWith("--")) { + return false; + } + const parameterName = flag.slice(1).toLowerCase(); + return parameterName === "recursive" || "recurse".startsWith(parameterName); +} + +function isPowerShellForceParameter(flag) { + if (!/^-[a-z]+$/i.test(flag) || flag.startsWith("--")) { + return false; + } + const parameterName = flag.slice(1).toLowerCase(); + return parameterName.length >= 2 && "force".startsWith(parameterName); +} + +function isUnixRmShortOptionCluster(flag) { + return /^-[dfiprvw]+$/i.test(flag); +} + function isSafeEnvTemplateReadCommand(commandText) { if (containsCommandControlOperator(commandText)) { return false; diff --git a/agent-governance-opencode/test/policy.test.mjs b/agent-governance-opencode/test/policy.test.mjs index 6988ad389..26dafbf29 100644 --- a/agent-governance-opencode/test/policy.test.mjs +++ b/agent-governance-opencode/test/policy.test.mjs @@ -88,6 +88,8 @@ test("evaluateOpenCodeTool denies recursive force deletes", async () => { for (const command of [ "rm -rf important-data", "rm -fr important-data", + "rm -rfv important-data", + "rm -rvf important-data", "rm -r -f important-data", "rm --recursive --force important-data", "rm --force --recursive important-data", @@ -95,7 +97,10 @@ test("evaluateOpenCodeTool denies recursive force deletes", async () => { "rm -rf /", "rm -rf ~/.ssh", "npm test && rm -rf important-data", + "rm -r -fo important-data", "Remove-Item -Recurse -Force important-data", + "Remove-Item -r -fo important-data", + "ri -r -fo important-data", "rd /s /q important-data", ]) { const result = await evaluateOpenCodeTool(state, { @@ -117,10 +122,14 @@ test("evaluateOpenCodeTool does not deny safe cleanup or non-recursive force del for (const command of [ "rm -rf node_modules", + "rm -rfv node_modules", "rm -rf build", "rm -fr dist", "rm -f important-data", "rm --force important-data", + "rm important-data -Confirm", + "Remove-Item -Filter *.tmp important-data", + "Remove-Item important-data -Confirm", "Remove-Item -Recurse -Force build", "rd /s /q node_modules", ]) { From bb6f6c85311da4420b77d22bda7b0838c01ec1b9 Mon Sep 17 00:00:00 2001 From: arvidpeldan Date: Tue, 7 Jul 2026 10:00:41 +0200 Subject: [PATCH 7/8] fix: address recursive-delete review regressions across bundles Follow-up to the recursive-delete hardening, addressing review feedback on PR #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 --- .../config/default-policy.json | 2 +- .../config/profiles/advisory.json | 2 +- .../config/profiles/balanced.json | 2 +- .../config/profiles/strict.json | 2 +- .../agt-global-policy/lib/policy.mjs | 27 ++++++++++++++---- .../test/policy-engine.test.mjs | 9 ++++-- .../config/default-policy.json | 2 +- agent-governance-claude-code/lib/policy.mjs | 27 ++++++++++++++---- .../test/policy.test.mjs | 8 +++++- .../config/default-policy.json | 4 +-- .../config/profiles/advisory.json | 4 +-- .../config/profiles/balanced.json | 4 +-- .../config/profiles/strict.json | 4 +-- .../agt-global-policy/lib/policy.mjs | 27 ++++++++++++++---- .../test/policy-engine.test.mjs | 7 +++++ .../config/default-policy.json | 2 +- agent-governance-opencode/lib/policy.mjs | 28 +++++++++++++++---- .../test/policy.test.mjs | 8 +++++- .../config/default-policy.json | 4 +-- .../config/profiles/advisory.json | 4 +-- .../config/profiles/balanced.json | 4 +-- .../config/profiles/strict.json | 4 +-- 22 files changed, 135 insertions(+), 50 deletions(-) diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/default-policy.json b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/default-policy.json index 6847e9f0d..df33e0eb0 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/default-policy.json +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/default-policy.json @@ -43,7 +43,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|rmdir|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|rmdir|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json index f010bf36c..3eba105f2 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json @@ -44,7 +44,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|rmdir|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|rmdir|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json index e70122525..0f85b5e63 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json @@ -44,7 +44,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|rmdir|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|rmdir|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/strict.json b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/strict.json index 7357dc49f..61ed00db1 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/strict.json +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/config/profiles/strict.json @@ -44,7 +44,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|rmdir|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|rmdir|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs index d8ff5a72e..1a0cb75e1 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs @@ -1020,7 +1020,7 @@ function createMinimalFallbackPolicy() { function shouldBypassBlockedCommandRule(rule, commandText, toolName) { if (rule.id === "recursive-delete") { - return !hasRecursiveForceDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); + return !hasRecursiveDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -1031,13 +1031,13 @@ function shouldBypassBlockedCommandRule(rule, commandText, toolName) { function getRmCommandDetails(commandText, toolName) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => - /^(rm|remove-item|rmdir|ri|rd|del)$/i.test(stripCommandToken(token)), + /^(rm|remove-item|rmdir|ri|rd|del)$/i.test(normalizeCommandNameToken(token)), ); if (commandIndex === -1) { return undefined; } - const commandName = stripCommandToken(tokens[commandIndex]).toLowerCase(); + const commandName = normalizeCommandNameToken(tokens[commandIndex]).toLowerCase(); const parsesUnixShortOptions = commandName === "rm" && !isPowerShellTool(toolName); let recursive = false; let force = false; @@ -1093,9 +1093,12 @@ function isSafeCleanupCommand(commandText, toolName) { return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget); } -function hasRecursiveForceDelete(commandText, toolName) { +function hasRecursiveDelete(commandText, toolName) { + // A recursive delete is destructive whether or not a force flag is present, so + // the deny decision intentionally does not require force. The `force` detail is + // still parsed for completeness and future messaging. const details = getRmCommandDetails(commandText, toolName); - return Boolean(details?.recursive && details?.force); + return Boolean(details?.recursive); } function isPowerShellTool(toolName) { @@ -1119,7 +1122,12 @@ function isPowerShellForceParameter(flag) { } function isUnixRmShortOptionCluster(flag) { - return /^-[dfiprvw]+$/i.test(flag); + // Match any single-dash short-option cluster (letters only) rather than an + // allow-list of known letters. An allow-list fails open: an unrecognized letter + // such as the `x` in `rm -rfx foo` (not in the old allow-list) would discard the + // whole cluster and hide the recursive/force flags it contains. Matching all + // letter clusters fails safe instead. + return /^-[a-z]+$/i.test(flag); } function isSafeEnvTemplateReadCommand(commandText) { @@ -1412,6 +1420,13 @@ function stripCommandToken(token) { return String(token ?? "").replace(/^['"]|['"]$/g, ""); } +function normalizeCommandNameToken(token) { + // Strip surrounding quotes and any leading shell grouping/substitution + // delimiters so a command glued to punctuation is still recognized, e.g. + // `rm ... ` (backtick substitution), {rm ...;} (brace group), $(rm ...). + return stripCommandToken(token).replace(/^[`({$]+/, ""); +} + function normalizeCommandPathToken(token) { const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, ""); if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) { diff --git a/agent-governance-antigravity-cli/test/policy-engine.test.mjs b/agent-governance-antigravity-cli/test/policy-engine.test.mjs index 36e6abbf2..d0446b569 100644 --- a/agent-governance-antigravity-cli/test/policy-engine.test.mjs +++ b/agent-governance-antigravity-cli/test/policy-engine.test.mjs @@ -384,6 +384,12 @@ test("evaluatePreToolUse denies recursive force deletes", async () => { "Remove-Item -r -fo important-data", "ri -r -fo important-data", "rd /s /q important-data", + "rm --recursive important-data", + "rm -r important-data", + "Remove-Item -Recurse important-data", + "rm -rfx important-data", + "`rm -rf /`", + "{rm -rf /;}", ]) { const result = await evaluatePreToolUse(state, { toolArgs: { command }, @@ -416,8 +422,7 @@ test("evaluatePreToolUse does not deny safe cleanup or non-recursive force delet "rm -fr dist", "rm -f important-data", "rm --force important-data", - "rm --recursive important-data", - "rm important-data -Confirm", + "rm -i important-data", "Remove-Item -Filter *.tmp important-data", "Remove-Item important-data -Confirm", "Remove-Item -Recurse -Force build", diff --git a/agent-governance-claude-code/config/default-policy.json b/agent-governance-claude-code/config/default-policy.json index 9809c76d7..589313e5f 100644 --- a/agent-governance-claude-code/config/default-policy.json +++ b/agent-governance-claude-code/config/default-policy.json @@ -38,7 +38,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-claude-code/lib/policy.mjs b/agent-governance-claude-code/lib/policy.mjs index 653b9397d..86df61a98 100644 --- a/agent-governance-claude-code/lib/policy.mjs +++ b/agent-governance-claude-code/lib/policy.mjs @@ -878,7 +878,7 @@ function createMinimalFallbackPolicy() { function shouldBypassBlockedCommandRule(rule, commandText, toolName) { if (rule.id === "recursive-delete") { - return !hasRecursiveForceDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); + return !hasRecursiveDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -889,13 +889,13 @@ function shouldBypassBlockedCommandRule(rule, commandText, toolName) { function getRmCommandDetails(commandText, toolName) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => - /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), + /^(rm|remove-item|ri|rd|del)$/i.test(normalizeCommandNameToken(token)), ); if (commandIndex === -1) { return undefined; } - const commandName = stripCommandToken(tokens[commandIndex]).toLowerCase(); + const commandName = normalizeCommandNameToken(tokens[commandIndex]).toLowerCase(); const parsesUnixShortOptions = commandName === "rm" && !isPowerShellTool(toolName); let recursive = false; let force = false; @@ -941,9 +941,12 @@ function getRmCommandDetails(commandText, toolName) { }; } -function hasRecursiveForceDelete(commandText, toolName) { +function hasRecursiveDelete(commandText, toolName) { + // A recursive delete is destructive whether or not a force flag is present, so + // the deny decision intentionally does not require force. The `force` detail is + // still parsed for completeness and future messaging. const details = getRmCommandDetails(commandText, toolName); - return Boolean(details?.recursive && details?.force); + return Boolean(details?.recursive); } function isSafeCleanupCommand(commandText, toolName) { @@ -977,7 +980,12 @@ function isPowerShellForceParameter(flag) { } function isUnixRmShortOptionCluster(flag) { - return /^-[dfiprvw]+$/i.test(flag); + // Match any single-dash short-option cluster (letters only) rather than an + // allow-list of known letters. An allow-list fails open: an unrecognized letter + // such as the `x` in `rm -rfx foo` (not in the old allow-list) would discard the + // whole cluster and hide the recursive/force flags it contains. Matching all + // letter clusters fails safe instead. + return /^-[a-z]+$/i.test(flag); } function isSafeEnvTemplateReadCommand(commandText) { @@ -1182,6 +1190,13 @@ function stripCommandToken(token) { return String(token ?? "").replace(/^['"]|['"]$/g, ""); } +function normalizeCommandNameToken(token) { + // Strip surrounding quotes and any leading shell grouping/substitution + // delimiters so a command glued to punctuation is still recognized, e.g. + // `rm ... ` (backtick substitution), {rm ...;} (brace group), $(rm ...). + return stripCommandToken(token).replace(/^[`({$]+/, ""); +} + function normalizeCommandPathToken(token) { const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, ""); if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) { diff --git a/agent-governance-claude-code/test/policy.test.mjs b/agent-governance-claude-code/test/policy.test.mjs index 10c2c8d02..0ec079c7c 100644 --- a/agent-governance-claude-code/test/policy.test.mjs +++ b/agent-governance-claude-code/test/policy.test.mjs @@ -103,6 +103,12 @@ test("evaluatePreToolUse denies recursive force deletes", async () => { "Remove-Item -r -fo important-data", "ri -r -fo important-data", "rd /s /q important-data", + "rm --recursive important-data", + "rm -r important-data", + "Remove-Item -Recurse important-data", + "rm -rfx important-data", + "`rm -rf /`", + "{rm -rf /;}", ]) { const result = await evaluatePreToolUse(state, { tool_name: "Bash", @@ -129,7 +135,7 @@ test("evaluatePreToolUse does not deny safe cleanup or non-recursive force delet "rm -fr dist", "rm -f important-data", "rm --force important-data", - "rm important-data -Confirm", + "rm -i important-data", "Remove-Item -Filter *.tmp important-data", "Remove-Item important-data -Confirm", "Remove-Item -Recurse -Force build", diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/default-policy.json b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/default-policy.json index 64c9610c6..aca1d77d0 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/default-policy.json +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/default-policy.json @@ -29,7 +29,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -109,7 +109,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json index 3ce240ad6..c689c7fcc 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/advisory.json @@ -30,7 +30,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -110,7 +110,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json index 43f4e3c8b..6e90ed830 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/balanced.json @@ -49,7 +49,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -129,7 +129,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/strict.json b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/strict.json index 4c53073a6..801b36c18 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/strict.json +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/config/profiles/strict.json @@ -30,7 +30,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -110,7 +110,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs index dfa2dc61f..42055050a 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs @@ -981,7 +981,7 @@ function createMinimalFallbackPolicy() { function shouldBypassBlockedCommandRule(rule, commandText, toolName) { if (rule.id === "recursive-delete") { - return !hasRecursiveForceDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); + return !hasRecursiveDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -992,13 +992,13 @@ function shouldBypassBlockedCommandRule(rule, commandText, toolName) { function getRmCommandDetails(commandText, toolName) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => - /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), + /^(rm|remove-item|ri|rd|del)$/i.test(normalizeCommandNameToken(token)), ); if (commandIndex === -1) { return undefined; } - const commandName = stripCommandToken(tokens[commandIndex]).toLowerCase(); + const commandName = normalizeCommandNameToken(tokens[commandIndex]).toLowerCase(); const parsesUnixShortOptions = commandName === "rm" && !isPowerShellTool(toolName); let recursive = false; let force = false; @@ -1044,9 +1044,12 @@ function getRmCommandDetails(commandText, toolName) { }; } -function hasRecursiveForceDelete(commandText, toolName) { +function hasRecursiveDelete(commandText, toolName) { + // A recursive delete is destructive whether or not a force flag is present, so + // the deny decision intentionally does not require force. The `force` detail is + // still parsed for completeness and future messaging. const details = getRmCommandDetails(commandText, toolName); - return Boolean(details?.recursive && details?.force); + return Boolean(details?.recursive); } function isSafeCleanupCommand(commandText, toolName) { @@ -1080,7 +1083,12 @@ function isPowerShellForceParameter(flag) { } function isUnixRmShortOptionCluster(flag) { - return /^-[dfiprvw]+$/i.test(flag); + // Match any single-dash short-option cluster (letters only) rather than an + // allow-list of known letters. An allow-list fails open: an unrecognized letter + // such as the `x` in `rm -rfx foo` (not in the old allow-list) would discard the + // whole cluster and hide the recursive/force flags it contains. Matching all + // letter clusters fails safe instead. + return /^-[a-z]+$/i.test(flag); } function isSafeEnvTemplateReadCommand(commandText) { @@ -1366,6 +1374,13 @@ function stripCommandToken(token) { return String(token ?? "").replace(/^['"]|['"]$/g, ""); } +function normalizeCommandNameToken(token) { + // Strip surrounding quotes and any leading shell grouping/substitution + // delimiters so a command glued to punctuation is still recognized, e.g. + // `rm ... ` (backtick substitution), {rm ...;} (brace group), $(rm ...). + return stripCommandToken(token).replace(/^[`({$]+/, ""); +} + function normalizeCommandPathToken(token) { const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, ""); if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) { diff --git a/agent-governance-copilot-cli/test/policy-engine.test.mjs b/agent-governance-copilot-cli/test/policy-engine.test.mjs index 9cd9edf3c..a7c74fc96 100644 --- a/agent-governance-copilot-cli/test/policy-engine.test.mjs +++ b/agent-governance-copilot-cli/test/policy-engine.test.mjs @@ -475,6 +475,12 @@ test("evaluatePreToolUse denies recursive force deletes", async () => { ["powershell", "Remove-Item -r -fo important-data"], ["powershell", "ri -r -fo important-data"], ["powershell", "rd /s /q important-data"], + ["bash", "rm --recursive important-data"], + ["bash", "rm -r important-data"], + ["powershell", "Remove-Item -Recurse important-data"], + ["bash", "rm -rfx important-data"], + ["bash", "`rm -rf /`"], + ["bash", "{rm -rf /;}"], ]) { const result = await evaluatePreToolUse(state, { toolName, @@ -495,6 +501,7 @@ test("evaluatePreToolUse does not deny safe cleanup or non-recursive force delet ["bash", "rm -fr dist"], ["bash", "rm -f important-data"], ["bash", "rm --force important-data"], + ["bash", "rm -i important-data"], ["powershell", "rm important-data -Confirm"], ["powershell", "Remove-Item -Filter *.tmp important-data"], ["powershell", "Remove-Item important-data -Confirm"], diff --git a/agent-governance-opencode/config/default-policy.json b/agent-governance-opencode/config/default-policy.json index 01a5b3503..b3d0698d2 100644 --- a/agent-governance-opencode/config/default-policy.json +++ b/agent-governance-opencode/config/default-policy.json @@ -40,7 +40,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/agent-governance-opencode/lib/policy.mjs b/agent-governance-opencode/lib/policy.mjs index b0657b50e..e3a363f38 100644 --- a/agent-governance-opencode/lib/policy.mjs +++ b/agent-governance-opencode/lib/policy.mjs @@ -879,7 +879,7 @@ function createMinimalFallbackPolicy() { function shouldBypassBlockedCommandRule(rule, commandText, toolName) { if (rule.id === "recursive-delete") { - return !hasRecursiveForceDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); + return !hasRecursiveDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName); } if (rule.id === "secret-read") { return isSafeEnvTemplateReadCommand(commandText); @@ -890,13 +890,13 @@ function shouldBypassBlockedCommandRule(rule, commandText, toolName) { function getRmCommandDetails(commandText, toolName) { const tokens = tokenizeCommand(commandText); const commandIndex = tokens.findIndex((token) => - /^(rm|remove-item|ri|rd|del)$/i.test(stripCommandToken(token)), + /^(rm|remove-item|ri|rd|del)$/i.test(normalizeCommandNameToken(token)), ); if (commandIndex === -1) { return undefined; } - const commandName = stripCommandToken(tokens[commandIndex]).toLowerCase(); + const commandName = normalizeCommandNameToken(tokens[commandIndex]).toLowerCase(); const parsesUnixShortOptions = commandName === "rm" && !isPowerShellTool(toolName); let recursive = false; let force = false; @@ -942,9 +942,12 @@ function getRmCommandDetails(commandText, toolName) { }; } -function hasRecursiveForceDelete(commandText, toolName) { +function hasRecursiveDelete(commandText, toolName) { + // A recursive delete is destructive whether or not a force flag is present, so + // the deny decision intentionally does not require force. The `force` detail is + // still parsed for completeness and future messaging. const details = getRmCommandDetails(commandText, toolName); - return Boolean(details?.recursive && details?.force); + return Boolean(details?.recursive); } function isSafeCleanupCommand(commandText, toolName) { @@ -978,7 +981,12 @@ function isPowerShellForceParameter(flag) { } function isUnixRmShortOptionCluster(flag) { - return /^-[dfiprvw]+$/i.test(flag); + // Match any single-dash short-option cluster (letters only) rather than an + // allow-list of known letters. An allow-list fails open: an unrecognized letter + // such as the `x` in `rm -rfx foo` (not in the old allow-list) would discard the + // whole cluster and hide the recursive/force flags it contains. Matching all + // letter clusters fails safe instead. + return /^-[a-z]+$/i.test(flag); } function isSafeEnvTemplateReadCommand(commandText) { @@ -1183,6 +1191,13 @@ function stripCommandToken(token) { return String(token ?? "").replace(/^['"]|['"]$/g, ""); } +function normalizeCommandNameToken(token) { + // Strip surrounding quotes and any leading shell grouping/substitution + // delimiters so a command glued to punctuation is still recognized, e.g. + // `rm ... ` (backtick substitution), {rm ...;} (brace group), $(rm ...). + return stripCommandToken(token).replace(/^[`({$]+/, ""); +} + function normalizeCommandPathToken(token) { const cleaned = stripCommandToken(token).replace(/[\\]+/g, "/").replace(/\/+$/, ""); if (!cleaned || /^[|&]/.test(cleaned) || cleaned.includes("*")) { @@ -1424,3 +1439,4 @@ function redactSecretLikeContent(text, _findings) { function describeSecretFindings(findings) { return `matched ${findings.length} secret pattern(s): ${findings.join(", ")}`; } + diff --git a/agent-governance-opencode/test/policy.test.mjs b/agent-governance-opencode/test/policy.test.mjs index 26dafbf29..f2fbfd6c6 100644 --- a/agent-governance-opencode/test/policy.test.mjs +++ b/agent-governance-opencode/test/policy.test.mjs @@ -102,6 +102,12 @@ test("evaluateOpenCodeTool denies recursive force deletes", async () => { "Remove-Item -r -fo important-data", "ri -r -fo important-data", "rd /s /q important-data", + "rm --recursive important-data", + "rm -r important-data", + "Remove-Item -Recurse important-data", + "rm -rfx important-data", + "`rm -rf /`", + "{rm -rf /;}", ]) { const result = await evaluateOpenCodeTool(state, { tool: "bash", @@ -127,7 +133,7 @@ test("evaluateOpenCodeTool does not deny safe cleanup or non-recursive force del "rm -fr dist", "rm -f important-data", "rm --force important-data", - "rm important-data -Confirm", + "rm -i important-data", "Remove-Item -Filter *.tmp important-data", "Remove-Item important-data -Confirm", "Remove-Item -Recurse -Force build", diff --git a/examples/copilot-cli-agt/config/default-policy.json b/examples/copilot-cli-agt/config/default-policy.json index 64c9610c6..aca1d77d0 100644 --- a/examples/copilot-cli-agt/config/default-policy.json +++ b/examples/copilot-cli-agt/config/default-policy.json @@ -29,7 +29,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -109,7 +109,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/examples/copilot-cli-agt/config/profiles/advisory.json b/examples/copilot-cli-agt/config/profiles/advisory.json index 3ce240ad6..c689c7fcc 100644 --- a/examples/copilot-cli-agt/config/profiles/advisory.json +++ b/examples/copilot-cli-agt/config/profiles/advisory.json @@ -30,7 +30,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -110,7 +110,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/examples/copilot-cli-agt/config/profiles/balanced.json b/examples/copilot-cli-agt/config/profiles/balanced.json index 43f4e3c8b..6e90ed830 100644 --- a/examples/copilot-cli-agt/config/profiles/balanced.json +++ b/examples/copilot-cli-agt/config/profiles/balanced.json @@ -49,7 +49,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -129,7 +129,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] diff --git a/examples/copilot-cli-agt/config/profiles/strict.json b/examples/copilot-cli-agt/config/profiles/strict.json index 4c53073a6..801b36c18 100644 --- a/examples/copilot-cli-agt/config/profiles/strict.json +++ b/examples/copilot-cli-agt/config/profiles/strict.json @@ -30,7 +30,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] @@ -110,7 +110,7 @@ "effect": "deny", "commandPatterns": [ { - "source": "(^|[\\s;&|()])(?:rm|remove-item|ri|rd|del)\\b", + "source": "(^|[\\s;&|(){`])(?:rm|remove-item|ri|rd|del)\\b", "flags": "i" } ] From 56085af58137403cd94cb513498e5715e6660020 Mon Sep 17 00:00:00 2001 From: arvidpeldan Date: Tue, 7 Jul 2026 13:18:40 +0200 Subject: [PATCH 8/8] docs: clean up code comments Signed-off-by: arvidpeldan --- .../assets/extensions/agt-global-policy/lib/policy.mjs | 2 +- agent-governance-claude-code/lib/policy.mjs | 2 +- .../assets/extensions/agt-global-policy/lib/policy.mjs | 2 +- agent-governance-opencode/lib/policy.mjs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs index 1a0cb75e1..8008b393e 100644 --- a/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs +++ b/agent-governance-antigravity-cli/assets/extensions/agt-global-policy/lib/policy.mjs @@ -1124,7 +1124,7 @@ function isPowerShellForceParameter(flag) { function isUnixRmShortOptionCluster(flag) { // Match any single-dash short-option cluster (letters only) rather than an // allow-list of known letters. An allow-list fails open: an unrecognized letter - // such as the `x` in `rm -rfx foo` (not in the old allow-list) would discard the + // such as the `x` in `rm -rfx foo` would discard the // whole cluster and hide the recursive/force flags it contains. Matching all // letter clusters fails safe instead. return /^-[a-z]+$/i.test(flag); diff --git a/agent-governance-claude-code/lib/policy.mjs b/agent-governance-claude-code/lib/policy.mjs index 86df61a98..f403cd1ca 100644 --- a/agent-governance-claude-code/lib/policy.mjs +++ b/agent-governance-claude-code/lib/policy.mjs @@ -982,7 +982,7 @@ function isPowerShellForceParameter(flag) { function isUnixRmShortOptionCluster(flag) { // Match any single-dash short-option cluster (letters only) rather than an // allow-list of known letters. An allow-list fails open: an unrecognized letter - // such as the `x` in `rm -rfx foo` (not in the old allow-list) would discard the + // such as the `x` in `rm -rfx foo` would discard the // whole cluster and hide the recursive/force flags it contains. Matching all // letter clusters fails safe instead. return /^-[a-z]+$/i.test(flag); diff --git a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs index 42055050a..dce4a1e98 100644 --- a/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs +++ b/agent-governance-copilot-cli/assets/extensions/agt-global-policy/lib/policy.mjs @@ -1085,7 +1085,7 @@ function isPowerShellForceParameter(flag) { function isUnixRmShortOptionCluster(flag) { // Match any single-dash short-option cluster (letters only) rather than an // allow-list of known letters. An allow-list fails open: an unrecognized letter - // such as the `x` in `rm -rfx foo` (not in the old allow-list) would discard the + // such as the `x` in `rm -rfx foo` would discard the // whole cluster and hide the recursive/force flags it contains. Matching all // letter clusters fails safe instead. return /^-[a-z]+$/i.test(flag); diff --git a/agent-governance-opencode/lib/policy.mjs b/agent-governance-opencode/lib/policy.mjs index e3a363f38..d49531c9d 100644 --- a/agent-governance-opencode/lib/policy.mjs +++ b/agent-governance-opencode/lib/policy.mjs @@ -983,7 +983,7 @@ function isPowerShellForceParameter(flag) { function isUnixRmShortOptionCluster(flag) { // Match any single-dash short-option cluster (letters only) rather than an // allow-list of known letters. An allow-list fails open: an unrecognized letter - // such as the `x` in `rm -rfx foo` (not in the old allow-list) would discard the + // such as the `x` in `rm -rfx foo` would discard the // whole cluster and hide the recursive/force flags it contains. Matching all // letter clusters fails safe instead. return /^-[a-z]+$/i.test(flag);