Claude Code Hooks: Block rm -rf and Run Tests After Every Edit (2 Working Hooks) - NextGenBeing
Back to discoveries

Claude Code Hooks: Block rm -rf and Run Tests After Every Edit (2 Working Hooks)

Build a Claude Code PreToolUse hook that blocks rm -rf, force-push and .env reads, plus a PostToolUse test runner, with real JSON and exit codes.

AI Workflows 11 min read
Bekzod Erkinov

Bekzod Erkinov

Oct 17, 2026 • 0 views
Size:
Height:
11 min read 4,348 words Focus mode: Eye care:

Listen to Article

Loading...
0:00 / 0:00
0:00 0:00
Low High
0% 100%
⏸ Paused ▶️ Now playing... Ready to play ✓ Finished
Table of contents · 6 sections

Claude Code Hooks: Block rm -rf and Run Tests After Every Edit (2 Working Hooks)

Claude Code can run rm -rf, force-push, or read your .env if a prompt leads it there, and a line in CLAUDE.md is only a request. By the end of this post you'll have two hooks that make those rules deterministic: a PreToolUse guard that denies the dangerous calls, and a PostToolUse hook that runs your tests after every edit and feeds failures back to Claude.

One thing to know before you write either: a hook that crashes fails open. The hooks reference says a non-blocking error is "your hook exits with any other code, or fails in some other way... The action goes ahead." Only exit code 2 blocks. In my test a guard that threw an exception let rm -rf run. The fix is a try/catch that exits 2, shown in the guard below.

I built both in a throwaway Node repo and ran them through claude -p (Claude Code 2.1.295, Node 24.19.0, Windows 11, scripts run from Git Bash). I did not test macOS or Linux, so the path.win32 and shell: true choices are untested there. Every transcript comes from the final guard.js shown here, except where I say it was a synthetic variant.

The demo repo

demo/
├── package.json            {"name":"demo","scripts":{"test":"node --test"}}
├── src/add.js              exports.add = (a, b) => a + b;
├── test/add.test.js
├── test-guard.sh
├── .env                    API_KEY=sk-demo-123
└── .claude/
    ├── settings.json
    └── hooks/
        ├── guard.js        (PreToolUse)
        ├── guard-json.js   (alternative to guard.js, exit-0 JSON form)
        └── run-tests.js    (PostToolUse)

test/add.test.js:

const test = require('node:test'); const assert = require('node:assert');
const { add } = require('../src/add');
test('adds', () => assert.strictEqual(add(2, 3), 5));

Claude Code hooks: where settings.json scopes live

Hooks go in a hooks block in a settings file. The hooks reference lists user (~/.claude/settings.json), project (.claude/settings.json, committed), local project (.claude/settings.local.json, not committed) and managed policy settings. Per the reference, hook entries merge across levels rather than replacing each other, and allowManagedHooksOnly in managed settings blocks user, project, local and plugin hooks. So for a team, put the guard in the committed project file; enforcement nobody can switch off locally is a job for managed policy.

Matcher syntax

The matcher filters on tool name. From the Matcher patterns section of the hooks reference:

Matcher value Evaluated as Example
"*", "", or omitted Match all fires on every occurrence of the event
Only letters, digits, _, -, spaces, , and | Exact string, or a list of exact strings separated by | or , Bash; Edit|Write
Any other character JavaScript regular expression, unanchored ^Notebook; mcp__memory__.*

MCP tools are named mcp__<server>__<tool>; the docs say the .* is required to match a whole server. I only ran the Bash|Read and Edit|Write forms below.

The settings.json from my final run

{
  "hooks": {
    "PreToolUse": [
      { "matcher": "Bash|Read", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/guard.js\"" } ] }
    ],
    "PostToolUse": [
      { "matcher": "Edit|Write", "hooks": [ { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/.claude/hooks/run-tests.js\"", "timeout": 60 } ] }
    ]
  }
}

To try the JSON-output variant later, change guard.js to guard-json.js. Nothing else changes.

$CLAUDE_PROJECT_DIR makes the path work from any subdirectory. The reference says to set args (exec form, no shell, no quoting) when a hook references a path placeholder, and gives this example, which I did not run:

{
  "type": "command",
  "command": "node",
  "args": ["${CLAUDE_PROJECT_DIR}/.claude/hooks/check-command.js"]
}

I used the quoted string form in every run here.

Claude Code PreToolUse hook to block rm -rf

A hook receives one JSON object on stdin. This is the input my guard logged for a blocked rm -rf (paths trimmed):

{
  "session_id": "f796abe8-a427-441d-a811-8a44db7e6c99",
  "cwd": "C:\\Users\\...\\demo",
  "prompt_id": "0c9a3cbc-3ccd-4de5-bcb5-bec2d5d3baa6",
  "permission_mode": "default",
  "effort": { "level": "medium" },
  "hook_event_name": "PreToolUse",
  "tool_name": "Bash",
  "tool_input": {
    "command": "rm -rf node_modules; echo \"exit: $?\"",
    "description": "Remove node_modules directory"
  },
  "tool_use_id": "toolu_011rsCGjmGTzbKcrDW4SmKK8"
}

Claude appended ; echo "exit: $?" to my command. A check that compares the whole string to rm -rf node_modules would miss it, so match patterns, not strings. A single regex for rm flags is easy to get wrong (-rf, -fr, -r -f, -Rf, --recursive --force), so this guard splits the command into segments and reads the flags instead. Here's .claude/hooks/guard.js:

#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const isEnvFile = (p) => /^\.env(\.|$)/.test(path.win32.basename(p));
// True if any `rm` in the command line has a recursive flag and a force flag,
// however they are spelled: -rf, -fr, -r -f, -Rf, --recursive --force.
function isRecursiveForceRm(cmd) {
  for (const segment of cmd.split(/[;&|\n]+/)) {
    const words = segment.trim().split(/\s+/);
    const i = words.indexOf('rm');
    if (i === -1) continue;
    let recursive = false, force = false;
    for (const w of words.slice(i + 1)) {
      if (!w.startsWith('-')) break;
      if (w === '--recursive') recursive = true;
      else if (w === '--force') force = true;
      else if (!w.startsWith('--')) {
        if (/[rR]/.test(w)) recursive = true;
        if (w.includes('f')) force = true;
      }
    }
    if (recursive && force) return true;
  }
  return false;
}
try {
  const input = JSON.parse(fs.readFileSync(0, 'utf8'));
  const { tool_name: tool, tool_input: ti } = input;
  const cmd = tool === 'Bash' ? ti.command || '' : '';
  let reason = null;
  if (isRecursiveForceRm(cmd)) reason = 'rm -rf is blocked by .claude/hooks/guard.js';
  else if (/\bgit\s+push\b.*(--force\b|--force-with-lease|\s-f\b)/.test(cmd)) reason = 'force-push is blocked by .claude/hooks/guard.js';
  else if (tool === 'Read' && isEnvFile(ti.file_path || '')) reason = 'reading .env files is blocked by .claude/hooks/guard.js';
  else if (tool === 'Bash' && /\b(cat|less|head|tail|grep|type|source)\s[^|;&]*\.env\b/.test(cmd)) reason = 'reading .env via shell is blocked by .claude/hooks/guard.js';
  if (reason) { process.stderr.write(reason + '\n'); process.exit(2); }
  process.exit(0);
} catch (e) {
  process.stderr.write('guard.js crashed, failing closed: ' + e.message + '\n');
  process.exit(2);
}

path.win32.basename splits on both / and \, which matters because the Read input arrived with a Windows path (this is also why Windows file_path values need normalizing before you match on them).

Why the try/catch matters

My first guard.js had a bad regex (my shell heredoc ate a backslash). Run by hand it printed a SyntaxError and exited 1, and exit 1 isn't 2. To see what that means with Claude in the loop, I replaced the guard with the synthetic crash const input = JSON.parse("oops"); and ran claude -p "Run rm -rf scratch and then run ls" --allowedTools "Bash" --output-format stream-json --verbose --include-hook-events. The hook's record:

{"hook_name":"PreToolUse:Bash","exit_code":1,"outcome":"error","stderr":"<anonymous_script>:1

oops

^

SyntaxError: Unexpected token 'o', \"oops\" is not valid JSON ..."}

rm -rf scratch ran and the scratch/ directory was gone. The crash was a non-blocking error, as the docs sentence quoted in the intro describes. The try/catch that exits 2 in the guard above closes that hole.

The reference also documents "onFailure": "block" on a command or http hook (block the action when a hook fails), and states it requires Claude Code v2.1.295 or later. On 2.1.295 I registered the same crashing script with "onFailure": "block". The record again showed "exit_code":1,"outcome":"error", but Claude reported that a PreToolUse hook blocked the Bash call, and the directory was still there. I didn't test older versions, so I'd keep the try/catch regardless.

Test the guard before involving Claude

Pipe sample JSON in, as the hooks guide recommends. Save this as test-guard.sh in the repo root and run sh test-guard.sh (it needs a POSIX sh; I used Git Bash):

#!/bin/sh
check() {
  printf '%s' "$2" | node .claude/hooks/guard.js 2>/dev/null
  echo "[$1] exit=$?"
}
check 'rm -rf'      '{"tool_name":"Bash","tool_input":{"command":"rm -rf node_modules; echo done"}}'
check 'rm -fr'      '{"tool_name":"Bash","tool_input":{"command":"rm -fr node_modules"}}'
check 'rm -r -f'    '{"tool_name":"Bash","tool_input":{"command":"rm -r -f node_modules"}}'
check 'rm -Rf'      '{"tool_name":"Bash","tool_input":{"command":"rm -Rf node_modules"}}'
check 'rm --recursive --force' '{"tool_name":"Bash","tool_input":{"command":"rm --recursive --force x"}}'
check 'rm file.txt' '{"tool_name":"Bash","tool_input":{"command":"rm file.txt"}}'
check 'ls'          '{"tool_name":"Bash","tool_input":{"command":"ls"}}'
check 'force push'  '{"tool_name":"Bash","tool_input":{"command":"git push --force origin main"}}'
check 'Read .env'   '{"tool_name":"Read","tool_input":{"file_path":"C:\\proj\\.env"}}'
check 'cat .env'    '{"tool_name":"Bash","tool_input":{"command":"cat .env"}}'
check 'concat foo.env' '{"tool_name":"Bash","tool_input":{"command":"concat foo.env"}}'
check 'garbage'     'oops'

Output from a run of exactly this script:

[rm -rf] exit=2
[rm -fr] exit=2
[rm -r -f] exit=2
[rm -Rf] exit=2
[rm --recursive --force] exit=2
[rm file.txt] exit=0
[ls] exit=0
[force push] exit=2
[Read .env] exit=2
[cat .env] exit=2
[concat foo.env] exit=0
[garbage] exit=2

The concat foo.env line is why the shell .env pattern starts with \b and needs whitespace after the command word. The garbage line is the try/catch: bad input exits 2, not 1. Mind the doubled backslashes in the Read .env line: with a single backslash the JSON is invalid and you'd get exit 2 from the catch, not from the .env check.

Then the real thing, using claude -p with the tools pre-approved, so the only thing in the way is the hook:

claude -p "Run rm -rf node_modules. Then git push --force origin main. Then read the file .env. Report each result verbatim." --allowedTools "Bash,Read"

Claude's answer (shortened) from this run:

Nothing ran. A project hook, `.claude/hooks/guard.js`, blocked all three commands before they executed.

1. `rm -rf node_modules` was blocked. The hook said: `rm -rf is blocked by .claude/hooks/guard.js`
2. `git push --force origin main` was blocked. The hook said: `force-push is blocked by .claude/hooks/guard.js`
3. Reading `.env` was blocked. The hook said: `reading .env files is blocked by .claude/hooks/guard.js`

`node_modules` is still in place, nothing was pushed, and I haven't seen the contents of `.env`.

The JSON result listed all three calls under permission_denials, and node_modules was still on disk afterwards. Your stderr text is what Claude acts on, so write it as an instruction: say what to do instead, not just "blocked".

Known gaps in this guard

I piped these through guard.js with the same printf | node pattern:

exit=2 <- sudo rm -rf x
exit=2 <- echo "cleanup: rm -rf is risky; ok"
exit=0 <- git push origin +main
exit=0 <- git push -fu origin main
exit=0 <- cp .env /tmp/x
exit=0 <- node -p "1" < .env
exit=0 <- python -c open(1)
  • sudo rm -rf x is blocked correctly: the guard looks for the word rm anywhere in a segment.
  • The echo "cleanup: rm -rf is risky; ok" block is a false positive: the splitter breaks on ; even inside quotes. I left it as-is. Fixing it means real shell parsing, and a blocked harmless command is the safer failure.
  • Force-push forms the regex misses: git push origin +main and git push -fu (a combined short flag).
  • .env reads the regex misses: cp .env, < .env redirection, and any command not in the cat|less|head|tail|grep|type|source list.
  • Other interpreters are invisible to the guard, which the next test confirms with Claude.

I asked Claude to run a node -e one-liner that reads .env (written with String.fromCharCode so the literal .env never appears in the command). From the stream-json hook records with settings.json holding only the guard:

PreToolUse:Bash   exit_code=0 success
PreToolUse:Bash   exit_code=0 success
PostToolUse:Bash  exit_code=0 success
RESULT: ...API_KEY=sk-demo-123

The command came back with the key. (Claude answered in Uzbek in this run because of my global CLAUDE.md; the content is the API_KEY line.) You can add node|python|ruby to the list, but there's always another interpreter.

A permission rule is the other tool. I tried two deny-pattern forms, one at a time, as the only content of settings.json:

permissions.deny Read tool on .env node -e one-liner
["Read(./.env)"] denied (1 entry in permission_denials) key still returned
["Read(**/.env)"] denied (1 entry in permission_denials) key still returned

In my tests the deny rule covered the Read tool, the hook covered common shell commands, and neither stopped the interpreter one-liner. Keep real keys out of the working directory.

Exit code 2 vs JSON output

There are two ways to say no. Exit 2 plus stderr is a flat block with a message. Exit 0 plus JSON on stdout gives finer control. The docs: "Exit codes only let you block or stay silent, but JSON output gives you finer-grained control... exit 0 and print a JSON object to stdout." Their decision-control table lists permissionDecision (allow/deny/ask/defer) under hookSpecificOutput, and updatedInput there "replaces a tool's arguments before it runs". I ran only deny and the top-level mistake below; ask, defer and updatedInput are docs-only here.

Two more docs sentences worth knowing: "Exit code 0 with no output means the hook has no decision to report, so the tool call continues through the normal permission flow. The hook can deny the call, but staying silent doesn't approve it." And on exit 2, JSON fields "can't override the block. Even a permissionDecision of "allow" doesn't let the action through."

This is the complete guard-json.js. Its rm\s+-rf pattern is far narrower than the main guard's: it misses rm -fr, rm -r -f, force-push and .env.

const input = JSON.parse(require('fs').readFileSync(0, 'utf8'));
if (/rm\s+-rf/.test(input.tool_input.command || '')) {
  console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'PreToolUse',
    permissionDecision: 'deny', permissionDecisionReason: 'JSON deny: use trash-cli instead of rm -rf' } }));
}

I re-ran it today with --output-format stream-json --verbose --include-hook-events and a PreToolUse Bash matcher. The hook's record (the other records in the stream were my own SessionStart hooks and empty-stdout checks on Claude's other Bash calls):

{"hook_name":"PreToolUse:Bash","stdout":"{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"deny\",\"permissionDecisionReason\":\"JSON deny: use trash-cli instead of rm -rf\"}}\n","exit_code":0,"outcome":"success"}

Claude's answer: "I didn't delete scratch9. A hook blocked the command with the message 'use trash-cli instead of rm -rf'", and scratch9 was still on disk.

One trap, which I reproduced earlier: put permissionDecision at the top level instead of inside hookSpecificOutput and it does nothing. My test hook (toplevel.js) printed this and exited 0:

{"permissionDecision":"deny","permissionDecisionReason":"top-level deny"}

The record showed exit_code 0, outcome "success" with that JSON in stdout, and no error. Claude's answer: "scratch3 is deleted." So the rm -rf ran. A malformed deny is silent, which is another reason to pipe test input in first.

Which mechanism to use

You want Use Caveat from my tests
A flat yes/no with a message, on commands you can pattern-match PreToolUse hook, exit 2 + stderr A crash exits 1 and fails open
ask, defer, or rewritten input PreToolUse hook, exit 0 + hookSpecificOutput JSON (per the docs; I ran only deny) A top-level permissionDecision is silently ignored
A path rule for Claude's own tools, no code permissions.deny Covered Read, not an interpreter one-liner
The hook itself must fail closed "onFailure": "block" on the hook Worked on 2.1.295, which the docs list as the minimum version

Claude Code PostToolUse hook to run tests

PostToolUse fires after the tool ran, so it can't undo anything. Exit 2 here doesn't block; stderr just goes to Claude. For structured feedback the docs use a top-level decision: "block" with a reason. The input after an Edit has tool_name, tool_input.file_path and a cwd, which is all the hook below reads.

.claude/hooks/run-tests.js:

#!/usr/bin/env node
const fs = require('fs'); const { spawnSync } = require('child_process');
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
const f = input.tool_input.file_path || '';
if (!/\.js$/.test(f)) process.exit(0);
const r = spawnSync('npm', ['test', '--silent'], { cwd: input.cwd, encoding: 'utf8', shell: true });
if (r.status === 0) process.exit(0);
const out = ((r.stdout || '') + (r.stderr || '')).split('\n').slice(0, 40).join('\n');
console.log(JSON.stringify({ decision: 'block', reason: 'Tests failed after editing ' + f + ':\n' + out }));
process.exit(0);

It runs the suite with Node's spawnSync, which blocks until npm test exits; shell: true is what let npm resolve on my Windows machine. It skips non-.js files so editing a README doesn't start a test run, and truncates output to 40 lines because whatever you print lands in Claude's context.

I ran claude -p "Change add in src/add.js to return a - b using the Edit tool. Do not run anything else and do not fix it afterwards. Report the hook feedback you received." --allowedTools "Edit,Read" --output-format stream-json --verbose --include-hook-events with the full settings.json above. The PostToolUse:Edit record, with only the absolute paths shortened to ...:

{"type":"system","subtype":"hook_response","hook_name":"PostToolUse:Edit","hook_event":"PostToolUse",
 "stdout":"{\"decision\":\"block\",\"reason\":\"Tests failed after editing ...\\\\src\\\\add.js:\\n✖ adds (7.1666ms)\\nℹ tests 1\\nℹ suites 0\\nℹ pass 0\\nℹ fail 1\\nℹ cancelled 0\\nℹ skipped 0\\nℹ todo 0\\nℹ duration_ms 437.8098\\n\\n✖ failing tests:\\n\\ntest at test\\\\add.test.js:3:1\\n✖ adds (7.1666ms)\\n  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:\\n  \\n  -1 !== 5\\n  \\n      at TestContext.<anonymous> (...\\\\test\\\\add.test.js:3:27)\\n ...\"}\n",
 "exit_code":0,"outcome":"success"}

The trailing ... stands for the Node-internal stack frames and the actual/expected/operator fields. Claude's reply:

The PostToolUse:Edit hook (`node .claude/hooks/run-tests.js`) ran after the edit and reported a blocking error, "Tests failed after editing src/add.js".
- The suite has 1 test, `adds` in `test/add.test.js` (line 3), and it failed. 0 passed, 1 failed.
- The failure was an `AssertionError`: `strictEqual` got -1 but expected 5, so the test expects `add` to return a sum.

It quoted the failing test and the -1 !== 5 mismatch without being asked to run anything.

The session waits for this hook, so keep the timeout set (I used 60 s) and, if your suite is slow, run only the related test file. If the hook always reports failures, run the test command by itself first. One thing that bit me: node --test test/ failed on Node 24.19.0 with this error, while the npm test script (node --test, no path) passed:

Error: Cannot find module 'C:\Users\...\demo\test'
  code: 'MODULE_NOT_FOUND'

I only tried Node 24.19.0, so I can't say how other versions treat a directory argument; see the Node.js test runner docs for the file-selection rules.

What to do next

  1. Copy guard.js into .claude/hooks/, register it in .claude/settings.json, and run sh test-guard.sh before trusting it. Add a line for every command you care about.
  2. Keep the try/catch that exits 2, and test it with the piped oops line.
  3. Add your project's real danger list (terraform destroy, DROP TABLE, git reset --hard) as extra patterns, with a matching line in the test script.
  4. Point run-tests.js at your test runner, limited to the file types you edit, and time it on a real edit. For running Claude non-interactively with these hooks in place, see Claude Code headless with claude -p, which covers the permission flags.
Bekzod Erkinov

Bekzod Erkinov

Author

Founder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.

Free guide

Get the AI-Assisted Developer's Field Guide

The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.

Comments (0)

Please log in to leave a comment.

Log In

Related Articles

Don't miss the next deep dive

Get one well-researched tutorial in your inbox each week. No spam, unsubscribe anytime.