Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -531,7 +531,7 @@ function createCommandPatternBackend(policy) {
if (!matchedPattern) {
continue;
}
if (shouldBypassBlockedCommandRule(rule, commandText)) {
if (shouldBypassBlockedCommandRule(rule, commandText, toolName)) {
continue;
}

Expand Down Expand Up @@ -1018,35 +1018,56 @@ function createMinimalFallbackPolicy() {
};
}

function shouldBypassBlockedCommandRule(rule, commandText) {
function shouldBypassBlockedCommandRule(rule, commandText, toolName) {
if (rule.id === "recursive-delete") {
return isSafeCleanupCommand(commandText);
return !hasRecursiveDelete(commandText, toolName) || isSafeCleanupCommand(commandText, toolName);
}
if (rule.id === "secret-read") {
return isSafeEnvTemplateReadCommand(commandText);
}
return false;
}

function isSafeCleanupCommand(commandText) {
if (containsCommandControlOperator(commandText)) {
return false;
}

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|rmdir|ri|rd|del)$/i.test(normalizeCommandNameToken(token)),
);
if (commandIndex === -1) {
return false;
return undefined;
}

const commandName = normalizeCommandNameToken(tokens[commandIndex]).toLowerCase();
const parsesUnixShortOptions = commandName === "rm" && !isPowerShellTool(toolName);
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" ||
isPowerShellRecursiveParameter(normalizedFlag)
) {
recursive = true;
} else if (normalizedFlag === "--force" || isPowerShellForceParameter(normalizedFlag)) {
force = true;
} else if (parsesUnixShortOptions && isUnixRmShortOptionCluster(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) {
Expand All @@ -1055,9 +1076,60 @@ function isSafeCleanupCommand(commandText) {
}
}

return {
force,
recursive,
targets: candidateTargets,
};
}

function isSafeCleanupCommand(commandText, toolName) {
if (containsCommandControlOperator(commandText)) {
return false;
}

const details = getRmCommandDetails(commandText, toolName);
const candidateTargets = details?.targets ?? [];
return candidateTargets.length > 0 && candidateTargets.every(isSafeCleanupTarget);
}

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);
}

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) {
// 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` 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) {
if (containsCommandControlOperator(commandText)) {
return false;
Expand Down Expand Up @@ -1348,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("*")) {
Expand Down
121 changes: 120 additions & 1 deletion agent-governance-antigravity-cli/test/policy-engine.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -320,6 +354,91 @@ 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 -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",
"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 },
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 -rfv node_modules",
"rm -rf build",
"rm -fr dist",
"rm -f important-data",
"rm --force important-data",
"rm -i important-data",
"Remove-Item -Filter *.tmp important-data",
"Remove-Item important-data -Confirm",
"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(`{
Expand Down
2 changes: 1 addition & 1 deletion agent-governance-claude-code/config/default-policy.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
Expand Down
Loading
Loading