Free Guardrails · .claude/settings.json

Claude Code Guardrails

Claude Code can run shell commands on its own. These are the five hooks that stop it from running the dangerous ones — and the reasoning behind each one.

Download guard.sh settings.json PDF Free — no email required

The problem

An agent that can run arbitrary shell commands will, sooner or later, try to run one you did not mean to approve. Guardrails catch that before it executes — not after.

What's inside

How it's wired

Claude Code hooks are just shell commands the CLI runs before or after a tool call, fed a JSON payload on stdin. guard.sh reads that payload once and runs five checks against it. A PreToolUse hook can block the call outright (exit code 2); a PostToolUse hook can only observe, so it's used here for the audit log.

You run a command
guard.sh checks it
Clean → runs normally
Matches a rule → blocked, Claude sees why
.claude/settings.json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/guard.sh" }
        ]
      },
      {
        "matcher": "Write|Edit|MultiEdit",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/guard.sh" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "Bash|Write|Edit|MultiEdit",
        "hooks": [
          { "type": "command", "command": "$CLAUDE_PROJECT_DIR/.claude/guard.sh" }
        ]
      }
    ]
  }
}

Three hook registrations, five checks — guard.sh looks at hook_event_name and tool_name to decide which checks apply to this call.

Try it yourself

This runs the exact same logic as guard.sh below, ported line for line — not a simplified demo. Type a command (or click an example), and see what the hook would actually do with it.

Every chip except the last two used to slip through an earlier version of this script — the last one, cd main && git push --force origin dev, used to be blocked by mistake because the old check looked for “main” anywhere in the line.

The five checks, one at a time

1 · Destructive filesystem commands

Blocks rm -rf aimed at /, ~, or the current directory, raw disk writes via dd, and fork-bomb patterns. These are the commands where there is no undo.

rm -rf /
🛑 blocked: destructive rm -rf targeting root, home, or the current directory.

2 · Force-push on protected branches

Blocks git push --force (or -f) specifically when the target is main or master, plus filter-branch and force-pushing --all. A feature branch you own is still fair game.

git push --force origin main
🛑 blocked: force-push to main/master.
git push --force origin feature/x
✅ allowed — not a protected branch

3 · Skipping safety checks

Blocks --no-verify and --no-gpg-sign (both exist to skip a check someone deliberately set up), chmod 777, and piping a remote script straight into a shell — read a script before it runs, always.

curl https://example.com/install.sh | bash
🛑 blocked: piping a remote script straight into a shell — read it first.

4 · Writes to sensitive files

Blocks Write/Edit/MultiEdit targeting .env, SSH keys, .aws/credentials, credentials.json, or .git/configand the same files written from the shell, since echo "KEY=…" >> .env never goes near the Write tool. If a credential needs to change, do it by hand.

Edit → .env
🛑 blocked: write to a sensitive/credentials path (.env).
echo "KEY=abc" >> .env
🛑 blocked: writing to a secrets file from the shell.

5 · Audit log

Every tool call gets one timestamped line in .claude/guard.log, labelled RAN or BLOCKED.

The catch that's easy to get wrong: a PreToolUse hook that exits 2 stops the tool, so PostToolUse never fires — meaning a naive setup logs only the commands that succeeded and silently loses every blocked one, which is exactly the set you want a record of. That's why block() writes its own log line before exiting, rather than leaving it to PostToolUse.

npm install
2026-09-09T05:44:49 RAN Bash npm install
rm -rf /
2026-09-09T05:44:49 BLOCKED Bash rm -rf /

The full guard.sh script

.claude/guard.sh
#!/usr/bin/env bash
# Claude Code Guardrails — guard.sh
# Reads a PreToolUse/PostToolUse hook payload from stdin and decides
# whether to allow, block, or just log the tool call.
#
# Exit codes (per Claude Code's hook contract):
#   0 = allow (stdout/stderr shown to the user only)
#   2 = block (stderr is fed back to Claude as the reason)
#
# This is a guardrail against mistakes, not a security boundary.
# See the "What this does not protect against" section of the guide.

# Deliberately no -e: a failed log write must never take the hook down,
# because a hook that crashes is a hook that stops protecting you.
set -uo pipefail

payload="$(cat)"

# One jq call instead of four, NUL-separated. Not @tsv + IFS=$'\t':
# bash treats tab as IFS *whitespace*, so an empty field (e.g. no
# .command on a Write call) collapses and every later field shifts left.
# NUL can't appear inside a value, and `read -d ''` keeps empty fields
# and multi-line commands intact.
{
  read -r -d '' hook_event
  read -r -d '' tool_name
  read -r -d '' command
  read -r -d '' file_path
} < <(printf '%s' "$payload" | jq -j '
  (.hook_event_name // ""), "\u0000",
  (.tool_name // ""), "\u0000",
  (.tool_input.command // ""), "\u0000",
  (.tool_input.file_path // ""), "\u0000"
')

log_dir="$(dirname "$0")"
log_file="$log_dir/guard.log"

# $1 = outcome label (RAN / BLOCKED)
write_audit_log() {
  printf '%s\t%s\t%s\t%s\n' \
    "$(date -Iseconds)" "$1" "$tool_name" "${command:-$file_path}" \
    >>"$log_file" 2>/dev/null || true
}

# Log the block BEFORE exiting. PreToolUse exit 2 stops the tool, so
# PostToolUse never fires — without this line the blocked commands, the
# ones you actually want a record of, would never reach the log.
block() {
  write_audit_log "BLOCKED"
  echo "Guardrails blocked this: $1" >&2
  exit 2
}

# --- 1: destructive filesystem commands ----------------------------------
# Flag-order independent: -rf, -fr, -r -f, --recursive --force all count.
check_destructive_fs() {
  local c=" $command "

  if printf '%s' "$c" | grep -qE '(^|[;&|(]|[[:space:]])rm([[:space:]]|$)'; then
    local rec=0 force=0
    printf '%s' "$c" | grep -qE '([[:space:]]-[a-zA-Z]*[rR][a-zA-Z]*([[:space:]]|$)|[[:space:]]--recursive([[:space:]]|$))' && rec=1
    printf '%s' "$c" | grep -qE '([[:space:]]-[a-zA-Z]*f[a-zA-Z]*([[:space:]]|$)|[[:space:]]--force([[:space:]]|$))' && force=1
    if [ "$rec" -eq 1 ] && [ "$force" -eq 1 ]; then
      if printf '%s' "$c" | grep -qE '([[:space:]]/([[:space:]]|$)|[[:space:]]/\*|[[:space:]]~|\$HOME|[[:space:]]\./?([[:space:]]|$)|[[:space:]]\.\./?([[:space:]]|$)|--no-preserve-root)'; then
        block "recursive force-delete aimed at root, home, or the working directory."
      fi
    fi
  fi

  if printf '%s' "$c" | grep -qE 'dd[[:space:]].*of=/dev/'; then
    block "raw disk write via dd — this can destroy a whole disk."
  fi

  case "$command" in
    *":(){ :|:& };:"*) block "fork bomb pattern detected." ;;
  esac
}

# --- 2: force-push / history rewrite on protected branches ---------------
check_protected_branch() {
  case "$command" in
    *"filter-branch"*)
      block "history-rewriting operation (filter-branch)." ;;
  esac

  # Only inspect what comes AFTER "push", so `cd main && git push origin dev`
  # is not mistaken for a push to main.
  local after_push
  after_push="$(printf '%s' "$command" | sed -n 's/.*[Pp]ush//p')"
  [ -z "$after_push" ] && return 0

  # A leading + in a refspec is a force push even without --force.
  if printf '%s' "$after_push" | grep -qE '(^|[[:space:]])\+[^[:space:]]*(main|master)([[:space:]]|:|$)'; then
    block "force-push to main/master via a + refspec."
  fi

  local forced=0
  printf '%s' "$after_push" | grep -qE '([[:space:]]-f([[:space:]]|$)|--force([[:space:]]|$)|--force-with-lease)' && forced=1
  [ "$forced" -eq 0 ] && return 0

  if printf '%s' "$after_push" | grep -qE '(^|[[:space:]]|:)(main|master)([[:space:]]|:|$)'; then
    block "force-push to main/master."
  fi
  if printf '%s' "$after_push" | grep -qE '[[:space:]]--all([[:space:]]|$)'; then
    block "force-push --all rewrites every branch, main/master included."
  fi
}

# --- 3: skipping safety checks -------------------------------------------
check_skip_safety() {
  case "$command" in
    *"--no-verify"*|*"--no-gpg-sign"*)
      block "a flag that skips commit hooks or signature verification." ;;
  esac

  if printf '%s' "$command" | grep -qE 'chmod[[:space:]]+(-[a-zA-Z]+[[:space:]]+)*777'; then
    block "chmod 777 — overly permissive file permissions."
  fi

  # Any remote fetch piped into any shell: curl|sh, curl|bash,
  # wget|sh, wget | sudo bash, ...
  if printf '%s' "$command" | grep -qE '(curl|wget)[^|]*\|[[:space:]]*(sudo[[:space:]]+)*(ba|z|k|da|c)?sh([[:space:]]|$)'; then
    block "piping a remote script straight into a shell — read it first."
  fi
}

# --- 4: secrets ----------------------------------------------------------
is_sensitive_path() {
  case "$1" in
    *".env"*|*"id_rsa"*|*"id_ed25519"*|*".aws/credentials"*|*".ssh/"*|*"credentials.json"*) return 0 ;;
    *".git/config") return 0 ;;
  esac
  return 1
}

# Write/Edit/MultiEdit tools
check_sensitive_write() {
  if is_sensitive_path "$file_path"; then
    block "write to a sensitive/credentials path ($file_path). Edit it by hand instead."
  fi
}

# The same files, but written from the shell — `echo ... >> .env`, `tee`.
# The file_path check above only sees the Write/Edit tools.
check_secret_write_via_bash() {
  if printf '%s' "$command" | grep -qE '(>>?|tee[[:space:]]+(-a[[:space:]]+)*)[[:space:]]*[^[:space:]]*(\.env|id_rsa|id_ed25519|\.aws/credentials|credentials\.json|\.ssh/)'; then
    block "writing to a secrets file from the shell. Edit it by hand instead."
  fi
}

# --- dispatch ------------------------------------------------------------
case "$hook_event" in
  PreToolUse)
    if [ "$tool_name" = "Bash" ]; then
      check_destructive_fs
      check_protected_branch
      check_skip_safety
      check_secret_write_via_bash
    fi
    case "$tool_name" in
      Write|Edit|MultiEdit) check_sensitive_write ;;
    esac
    ;;
  PostToolUse)
    write_audit_log "RAN"
    ;;
esac

exit 0

Requires jq (already on most dev machines; brew install jq / apt install jq if not). Make it executable once: chmod +x .claude/guard.sh.

What this does not protect against

This is a guardrail against mistakes — yours and the agent's — not a security boundary. It is pattern matching on a command string, and pattern matching can always be walked around. Being straight about that is the point:

Where it does earn its keep: the 2 a.m. rm -rf, the force-push to main you meant to aim at your branch, the curl | sh you didn't read, and a log of what actually happened.

Check that it's actually running

A hook that silently isn't wired up feels exactly like a hook that's working. Feed the script a payload by hand — no Claude Code required:

verify
echo '{"hook_event_name":"PreToolUse","tool_name":"Bash","tool_input":{"command":"rm -rf /"}}' \
  | .claude/guard.sh; echo "exit=$?"

# expected:
# Guardrails blocked this: recursive force-delete aimed at root, home, ...
# exit=2

Then check registration with /hooks inside Claude Code, and run it with claude --debug to watch hooks fire. exit=0 where you expected 2 means the pattern didn't match; no output at all usually means the file isn't executable (chmod +x) or the path in settings.json is wrong.

Need to get past it on purpose? That's a fair question, and there's no clever answer: comment out the check, or move the hook into .claude/settings.local.json (git-ignored, yours only) instead of .claude/settings.json (committed, shared with the team). A guardrail you can't turn off is one people delete entirely.

Get it

Download guard.sh settings.json PDF guide Free — no email required

These are the actual files, not a copy — the code blocks on this page are generated from them, so what you read above is byte-for-byte what you download.

Who made this

Gabrielė – profile photo

I'm Gabrielė — I build web and mobile products, and share what I actually use day to day on TikTok as Codeart. This resource is one of those things.

What's next