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
25 changes: 25 additions & 0 deletions __tests__/hooks/builtin-policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -618,6 +618,31 @@ describe("hooks/builtin-policies", () => {
expect((await policy.fn(ctx)).decision).toBe("deny");
});

it.each([
"rm -rf $HOME",
"rm -rf ${HOME}",
"rm -rf ~/Documents",
"rm -rf /home/chetan",
"rm -rf /var/lib",
])("blocks critical recursive-delete target %s", async (command) => {
const ctx = makeCtx({ toolName: "Bash", toolInput: { command } });
expect((await policy.fn(ctx)).decision).toBe("deny");
});

it("blocks find / -delete", async () => {
const ctx = makeCtx({ toolName: "Bash", toolInput: { command: "find / -delete" } });
expect((await policy.fn(ctx)).decision).toBe("deny");
});

it("allows a configured home-relative path", async () => {
const ctx = makeCtx({
toolName: "Bash",
toolInput: { command: "rm -rf ~/scratch" },
params: { allowPaths: ["~/scratch"] },
});
expect((await policy.fn(ctx)).decision).toBe("allow");
});

it("allows rm -rf on specific directory", async () => {
const ctx = makeCtx({ toolName: "Bash", toolInput: { command: "rm -rf ./node_modules" } });
expect((await policy.fn(ctx)).decision).toBe("allow");
Expand Down
21 changes: 17 additions & 4 deletions src/hooks/builtin-policies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,13 +631,26 @@ function rmTargetIsAllowed(cmd: string, allowPaths: string[]): boolean {
return true;
}

function isCriticalRecursiveDeletePath(token: string): boolean {
const normalized = token.replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
return normalized === "/"
|| normalized === "~"
|| normalized === "$HOME"
|| normalized === "${HOME}"
|| normalized.startsWith("~/")
|| /^\/[A-Za-z_][\w.-]*$/.test(normalized)
|| /^\/(?:home|Users)\/[^/]+$/.test(normalized)
|| /^\/(?:var\/(?:lib|log)|usr\/(?:lib|local))$/.test(normalized);
}
Comment on lines +634 to +644

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Fix path normalization to prevent bypasses and include $HOME relative paths.

  1. Multiple slashes bypass: Paths like //usr, //var/lib, or /var//lib will bypass the exact regex matches because multiple consecutive slashes are not collapsed (e.g., //usr is perfectly valid in Bash but fails /^\/[A-Za-z_]/).
  2. Missing $HOME/ coverage: The logic correctly covers ~/ with normalized.startsWith("~/"), but misses $HOME/ and ${HOME}/. This leaves rm -rf $HOME/Documents exposed, contradicting the PR objective to protect home-relative paths.

Normalize paths by collapsing consecutive slashes, and add checks for $HOME/ and ${HOME}/.

🛡️ Proposed fix
 function isCriticalRecursiveDeletePath(token: string): boolean {
-  const normalized = token.replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
+  const normalized = token.replace(/\/{2,}/g, "/").replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
   return normalized === "/"
     || normalized === "~"
     || normalized === "$HOME"
     || normalized === "${HOME}"
     || normalized.startsWith("~/")
+    || normalized.startsWith("$HOME/")
+    || normalized.startsWith("${HOME}/")
     || /^\/[A-Za-z_][\w.-]*$/.test(normalized)
     || /^\/(?:home|Users)\/[^/]+$/.test(normalized)
     || /^\/(?:var\/(?:lib|log)|usr\/(?:lib|local))$/.test(normalized);
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function isCriticalRecursiveDeletePath(token: string): boolean {
const normalized = token.replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
return normalized === "/"
|| normalized === "~"
|| normalized === "$HOME"
|| normalized === "${HOME}"
|| normalized.startsWith("~/")
|| /^\/[A-Za-z_][\w.-]*$/.test(normalized)
|| /^\/(?:home|Users)\/[^/]+$/.test(normalized)
|| /^\/(?:var\/(?:lib|log)|usr\/(?:lib|local))$/.test(normalized);
}
function isCriticalRecursiveDeletePath(token: string): boolean {
const normalized = token.replace(/\/{2,}/g, "/").replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
return normalized === "/"
|| normalized === "~"
|| normalized === "$HOME"
|| normalized === "${HOME}"
|| normalized.startsWith("~/")
|| normalized.startsWith("$HOME/")
|| normalized.startsWith("${HOME}/")
|| /^\/[A-Za-z_][\w.-]*$/.test(normalized)
|| /^\/(?:home|Users)\/[^/]+$/.test(normalized)
|| /^\/(?:var\/(?:lib|log)|usr\/(?:lib|local))$/.test(normalized);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/builtin-policies.ts` around lines 634 - 644, Update
isCriticalRecursiveDeletePath to collapse consecutive slashes in the normalized
path before applying the protected-path checks, so inputs such as //usr and
/var//lib match the existing rules. Extend the home-relative checks to recognize
paths beginning with $HOME/ and ${HOME}/ in addition to ~/ while preserving the
existing exact home-token handling.


function blockRmRf(ctx: PolicyContext): PolicyResult {
if (ctx.toolName !== "Bash") return allow();
const cmd = getCommand(ctx);
const hasDestructivePath = parseArgvTokens(cmd).some((token) => {
const normalized = token.replace(/\/\*$/, "").replace(/\/+$/, "") || (token.startsWith("/") ? "/" : "");
return normalized === "/" || normalized === "~" || /^\/[A-Za-z_][\w.-]*$/.test(normalized);
});
const hasDestructivePath = parseArgvTokens(cmd).some(isCriticalRecursiveDeletePath);

if (/\bfind\s+(?:\/|~|\$HOME|\$\{HOME\})(?=\s|$)[\s\S]*?\s-delete\b/.test(cmd)) {
return deny("Catastrophic deletion blocked");
}
Comment on lines +649 to +653

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Refine find -delete detection to avoid false positives and bypasses.

The current regex for find operates on the raw cmd string:

  1. False positives: Commands like echo "find / -delete" or git commit -m "find / -delete" will incorrectly trigger a block because token boundaries and quotes are ignored.
  2. Bypasses: find // -delete bypasses the exact string match.
  3. Inconsistent coverage: Hardcoding (?:\/|~|\$HOME|\$\{HOME\}) misses other critical paths (like /var/lib) that are correctly protected against rm -rf.

You can fix all of these by leveraging the parsed tokens to verify that find and -delete are actual executable tokens, holding find target paths to the exact same standard as rm.

🛡️ Proposed fix
-  const hasDestructivePath = parseArgvTokens(cmd).some(isCriticalRecursiveDeletePath);
-
-  if (/\bfind\s+(?:\/|~|\$HOME|\$\{HOME\})(?=\s|$)[\s\S]*?\s-delete\b/.test(cmd)) {
-    return deny("Catastrophic deletion blocked");
-  }
+  const tokens = parseArgvTokens(cmd);
+  const hasDestructivePath = tokens.some(isCriticalRecursiveDeletePath);
+
+  const findIdx = tokens.indexOf("find");
+  const deleteIdx = tokens.indexOf("-delete");
+  if (findIdx !== -1 && deleteIdx > findIdx) {
+    const findArgs = tokens.slice(findIdx + 1, deleteIdx);
+    if (findArgs.some(isCriticalRecursiveDeletePath)) {
+      return deny("Catastrophic deletion blocked");
+    }
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const hasDestructivePath = parseArgvTokens(cmd).some(isCriticalRecursiveDeletePath);
if (/\bfind\s+(?:\/|~|\$HOME|\$\{HOME\})(?=\s|$)[\s\S]*?\s-delete\b/.test(cmd)) {
return deny("Catastrophic deletion blocked");
}
const tokens = parseArgvTokens(cmd);
const hasDestructivePath = tokens.some(isCriticalRecursiveDeletePath);
const findIdx = tokens.indexOf("find");
const deleteIdx = tokens.indexOf("-delete");
if (findIdx !== -1 && deleteIdx > findIdx) {
const findArgs = tokens.slice(findIdx + 1, deleteIdx);
if (findArgs.some(isCriticalRecursiveDeletePath)) {
return deny("Catastrophic deletion blocked");
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/builtin-policies.ts` around lines 649 - 653, Replace the
raw-command regex in the find deletion check with parsed-token analysis:
identify an actual find executable invocation, require a -delete argument, and
validate its target paths using the same critical-path logic as rm handling
(including paths such as // and /var/lib). Preserve the existing deny response
while avoiding matches inside quoted arguments or unrelated commands.


// Combined flags in one token: rm -rf /, rm -fr /
if (hasDestructivePath && (
Expand Down