Your Claude Code setup is untested

A CLAUDE.md that is wrong is merely unhelpful. A hook that is wrong keeps running, keeps reporting nothing, and stops protecting you — and nothing anywhere says so.

Last updated: 2026-09-21

The failure that has no symptom

Most of a .claude/ setup announces its own mistakes. Get a command wrong in CLAUDE.md and Claude runs it and it fails in front of you. Write a skill badly and you notice the output.

Hooks are different, because a hook’s job is to not happen. The signal that it works is an absence, so the signal that it has stopped working is also an absence. These three all look identical from the outside — a quiet session where nothing was blocked:

What happenedWhat you see
The hook worked and nothing dangerous came upnothing
The hook is misconfigured and never firesnothing
The hook fires, hits a missing dependency, and exits 0 on every inputnothing

The third is the one worth testing for. It is called failing open, and a security check that fails open is worse than no check at all — because you stopped watching for the thing yourself.

The whole test rig is a pipe

A command hook reads JSON on stdin and answers with an exit code. That is a testable interface, and you do not need Claude Code, an API key or a network to exercise it:

# must BLOCK -> 2
echo '{"tool_name":"Bash","tool_input":{"command":"npm test -- --passWithNoTests"}}' \
  | bash .claude/hooks/no-skip-tests.sh; echo "exit=$?"

# must ALLOW -> 0
echo '{"tool_name":"Bash","tool_input":{"command":"npm test"}}' \
  | bash .claude/hooks/no-skip-tests.sh; echo "exit=$?"

Two lines, and you now know more about your hook than most people know about theirs. Why 2 and not 1 is its own subject and worth five minutes if you have not read it.

Three classes of test, and the second is the one nobody writes

1. The blocks

One per thing you are refusing, in the spellings people actually use. rm -rf / is the one everybody tests; rm -rf ~/projects/* is the one that happens.

2. The near-misses — these matter more

For every block, a case that looks similar and must return 0:

Must blockMust allow
rm -rf ~, rm -rf /*rm -rf node_modules
cat .envcat .env.example
a write containing a live keya write containing process.env.STRIPE_SECRET_KEY
npm test -- --passWithNoTestsnpm test

A hook that returns 2 for everything passes a block-only suite perfectly. It also makes the tool unusable, and you delete it within the hour and go back to having nothing. The near-miss column is where the design actually lives, and it is the column that gets skipped because it feels like testing that nothing happens.

3. The degraded environments

Three, and all three have bitten real setups:

A script you can keep

Once there are more than two hooks, make it one command, so it is cheap enough to run after every edit:

#!/usr/bin/env bash
# .claude/test-hooks.sh — run me after touching anything in .claude/hooks/
pass=0; fail=0
check() { # label expected actual
  if [ "$2" = "$3" ]; then printf '  PASS  %-46s (exit %s)\n' "$1" "$3"; pass=$((pass+1))
  else printf '  FAIL  %-46s (exit %s, wanted %s)\n' "$1" "$3" "$2"; fail=$((fail+1)); fi
}
run() { echo "$2" | bash ".claude/hooks/$1" >/dev/null 2>&1; echo $?; }

B='{"tool_name":"Bash","tool_input":{"command":"%s"}}'
check 'blocks rm -rf on a home dir'  2 "$(run no-destructive-shell.sh "$(printf "$B" 'rm -rf ~/projects/*')")"
check 'allows rm -rf node_modules'   0 "$(run no-destructive-shell.sh "$(printf "$B" 'rm -rf node_modules')")"
check 'blocks a masked test run'     2 "$(run no-skip-tests.sh        "$(printf "$B" 'npm test -- --passWithNoTests')")"
check 'allows a real test run'       0 "$(run no-skip-tests.sh        "$(printf "$B" 'npm test')")"
check 'no jq: still blocks'          2 "$(JQ_BIN=/nonexistent run no-destructive-shell.sh "$(printf "$B" 'rm -rf ~')")"

echo; echo "$pass passed, $fail failed"; [ "$fail" -eq 0 ]

It exits non-zero when anything fails, so it drops straight into CI or a pre-push hook. The point is not the harness — it is that the near-miss lines are sitting in the file where the next person can see what the hook is allowed to do.

The rest of the setup, briefly

Skills

Two different things to prove, and people conflate them. That the skill loads: invoke it by name once and watch it work. That it fires on its own: describe the situation without naming it, and see whether Claude reaches for it. A skill that only ever works when you type its name does not have a skill problem, it has a description problem.

The permission allowlist

Run the commands you allowlisted and count the prompts. If you are still being asked, the entry does not match the real command — which is why allowlist entries should be copied out of a session transcript rather than guessed from your package manifest. If it is still asking about everything, start with the fix for that.

CLAUDE.md

Every command in it should have been run in the repo and passed before it was written down. That is the whole test, and it is the one most CLAUDE.md files fail: a lint command that was right two refactors ago sends Claude down a hole every session. When it is the test command that has gone stale, that failure has its own fix.

If you would rather start from a tested set

The Skills & Hooks Pack ships 10 skills, 8 hooks and 134 offline tests built exactly like the above: every block case, every allow case, behaviour outside a git repository, the no-jq path, and a second suite that proves every skill’s frontmatter actually parses so the skill loads at all. Run bash test/run-hooks-test.sh before you trust a single file — it takes about ten seconds and needs no network.

The tests are the reason to buy it rather than write it. Eight hooks is an afternoon; eight hooks whose near-miss lists have already been argued out is the part that takes a week of being annoyed.

Buy the Pack — $29 See the tests and two files first

Free option, genuinely: kit-claude-code-starter is MIT with a working .claude/ to copy. Want it built against your repo and handed back as a PR? Setup Sprint $99.

Questions

How do I test a Claude Code hook without running Claude Code?

A command hook reads JSON on stdin and communicates through its exit code, so a pipe is the whole test rig. Echo a payload shaped like the real event, pipe it into the hook, and check the exit status: 2 means it blocked and 0 means it allowed. No Claude Code session, no API key, no network.

Why do the allow cases matter more than the block cases?

Because a hook that returns 2 for everything passes a block-only test suite with a perfect score, and it makes the tool unusable within an hour. Every block case needs a near-miss beside it that must return 0 — rm -rf node_modules next to rm -rf ~, .env.example next to .env. The near-miss list is where the actual design work is, and it is the part that gets skipped.

What does it mean for a hook to fail open?

It means the hook runs, finds nothing to object to for the wrong reason, exits 0, and the dangerous command proceeds. The usual cause is a dependency: a hook built around jq produces an empty string on a machine without jq, so it has nothing to match against. Nothing errors and nothing is protected, which is worse than having no hook, because you stopped watching for the thing yourself.

Which environments should I test a hook in?

Three beyond the happy path: with the JSON parser missing, outside a git repository, and with an empty or malformed payload. A hook that crashes outside a git repo will fire on a scratch directory and stop the session; a hook that depends on a parser that is not installed stops protecting silently.

How do I know a skill is actually firing?

Invoke it by name once and watch it do the thing — that proves the file is found and parses. Auto-invocation is separate and depends on the description field, so test it by describing the situation rather than naming the skill and seeing whether Claude reaches for it. A skill that only ever works when you type its name has a description problem, not a skill problem.

How do I prove the permission allowlist is working?

Run the commands you allowlisted and count the prompts. The allowlist is only right when it matches the commands your repo actually runs, which is why the entries should be copied from a real session rather than guessed from the package manifest.