feat: add rm -rf block hook script and fix stderr (#661)

This commit is contained in:
jnjpng
2026-01-23 17:26:50 -08:00
committed by GitHub
parent 7af73fe53e
commit 55524061ab
4 changed files with 46 additions and 18 deletions

20
hooks/block-rm-rf.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/bash
# Block dangerous rm -rf commands
input=$(cat)
tool_name=$(echo "$input" | jq -r '.tool_name')
# Only check Bash commands
if [ "$tool_name" != "Bash" ]; then
exit 0
fi
command=$(echo "$input" | jq -r '.tool_input.command')
# Check for rm -rf pattern (handles -rf, -fr, -rfi, etc.)
if echo "$command" | grep -qE 'rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)'; then
echo "Blocked: rm -rf commands must be ran manually, use rm and rmdir instead." >&2
exit 2
fi
exit 0

View File

@@ -275,11 +275,12 @@ export async function executeHooks(
const result = await executeHookCommand(hook, input, workingDirectory); const result = await executeHookCommand(hook, input, workingDirectory);
results.push(result); results.push(result);
// Collect feedback from stdout when hook blocks // Collect feedback from stderr when hook blocks
// Format: [command]: {stderr} per spec
if (result.exitCode === HookExitCode.BLOCK) { if (result.exitCode === HookExitCode.BLOCK) {
blocked = true; blocked = true;
if (result.stdout) { if (result.stderr) {
feedback.push(result.stdout); feedback.push(`[${hook.command}]: ${result.stderr}`);
} }
// Stop processing more hooks after a block // Stop processing more hooks after a block
break; break;
@@ -320,11 +321,17 @@ export async function executeHooksParallel(
let blocked = false; let blocked = false;
let errored = false; let errored = false;
for (const result of results) { // Zip hooks with results to access command for formatting
for (let i = 0; i < results.length; i++) {
const result = results[i];
const hook = hooks[i];
if (!result || !hook) continue;
// Format: [command]: {stderr} per spec
if (result.exitCode === HookExitCode.BLOCK) { if (result.exitCode === HookExitCode.BLOCK) {
blocked = true; blocked = true;
if (result.stdout) { if (result.stderr) {
feedback.push(result.stdout); feedback.push(`[${hook.command}]: ${result.stderr}`);
} }
} }
if (result.exitCode === HookExitCode.ERROR) { if (result.exitCode === HookExitCode.ERROR) {

View File

@@ -188,7 +188,7 @@ describe.skipIf(isWindows)("Hooks Executor", () => {
test("stops on first blocking hook", async () => { test("stops on first blocking hook", async () => {
const hooks: HookCommand[] = [ const hooks: HookCommand[] = [
{ type: "command", command: "echo 'allowed'" }, { type: "command", command: "echo 'allowed'" },
{ type: "command", command: "echo 'blocked' && exit 2" }, { type: "command", command: "echo 'blocked' >&2 && exit 2" },
{ type: "command", command: "echo 'should not run'" }, { type: "command", command: "echo 'should not run'" },
]; ];
@@ -203,7 +203,7 @@ describe.skipIf(isWindows)("Hooks Executor", () => {
expect(result.blocked).toBe(true); expect(result.blocked).toBe(true);
expect(result.results).toHaveLength(2); // Only first two ran expect(result.results).toHaveLength(2); // Only first two ran
expect(result.feedback).toContain("blocked"); expect(result.feedback[0]).toContain("blocked");
}); });
test("continues after error but tracks it", async () => { test("continues after error but tracks it", async () => {
@@ -247,7 +247,7 @@ describe.skipIf(isWindows)("Hooks Executor", () => {
const hooks: HookCommand[] = [ const hooks: HookCommand[] = [
{ {
type: "command", type: "command",
command: "echo 'Reason: file is dangerous' && exit 2", command: "echo 'Reason: file is dangerous' >&2 && exit 2",
}, },
]; ];
@@ -261,7 +261,7 @@ describe.skipIf(isWindows)("Hooks Executor", () => {
const result = await executeHooks(hooks, input, tempDir); const result = await executeHooks(hooks, input, tempDir);
expect(result.blocked).toBe(true); expect(result.blocked).toBe(true);
expect(result.feedback).toContain("Reason: file is dangerous"); expect(result.feedback[0]).toContain("Reason: file is dangerous");
}); });
test("collects error feedback from stderr", async () => { test("collects error feedback from stderr", async () => {

View File

@@ -110,7 +110,8 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
hooks: [ hooks: [
{ {
type: "command", type: "command",
command: "echo 'Blocked: write to sensitive file' && exit 2", command:
"echo 'Blocked: write to sensitive file' >&2 && exit 2",
}, },
], ],
}, },
@@ -125,7 +126,7 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
); );
expect(result.blocked).toBe(true); expect(result.blocked).toBe(true);
expect(result.feedback).toContain("Blocked: write to sensitive file"); expect(result.feedback[0]).toContain("Blocked: write to sensitive file");
}); });
test("matches by tool name pattern", async () => { test("matches by tool name pattern", async () => {
@@ -277,7 +278,7 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
hooks: [ hooks: [
{ {
type: "command", type: "command",
command: "echo 'Denied: dangerous command' && exit 2", command: "echo 'Denied: dangerous command' >&2 && exit 2",
}, },
], ],
}, },
@@ -293,7 +294,7 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
); );
expect(result.blocked).toBe(true); expect(result.blocked).toBe(true);
expect(result.feedback).toContain("Denied: dangerous command"); expect(result.feedback[0]).toContain("Denied: dangerous command");
}); });
test("receives permission type and scope in input", async () => { test("receives permission type and scope in input", async () => {
@@ -599,7 +600,7 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
hooks: [ hooks: [
{ {
type: "command", type: "command",
command: "echo 'Cannot compact now' && exit 2", command: "echo 'Cannot compact now' >&2 && exit 2",
}, },
], ],
}, },
@@ -615,7 +616,7 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
); );
expect(result.blocked).toBe(true); expect(result.blocked).toBe(true);
expect(result.feedback).toContain("Cannot compact now"); expect(result.feedback[0]).toContain("Cannot compact now");
}); });
test("receives context info in input", async () => { test("receives context info in input", async () => {
@@ -887,7 +888,7 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
matcher: "*", matcher: "*",
hooks: [ hooks: [
{ type: "command", command: "echo 'check 1'" }, { type: "command", command: "echo 'check 1'" },
{ type: "command", command: "echo 'BLOCKED' && exit 2" }, { type: "command", command: "echo 'BLOCKED' >&2 && exit 2" },
{ type: "command", command: "echo 'should not run'" }, { type: "command", command: "echo 'should not run'" },
], ],
}, },
@@ -898,7 +899,7 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => {
expect(result.blocked).toBe(true); expect(result.blocked).toBe(true);
expect(result.results).toHaveLength(2); expect(result.results).toHaveLength(2);
expect(result.feedback).toContain("BLOCKED"); expect(result.feedback[0]).toContain("BLOCKED");
}); });
test("error hooks do not block subsequent hooks", async () => { test("error hooks do not block subsequent hooks", async () => {