Claude Code Guardrails

One prompt that sets up safe-by-default permissions for Claude Code. Paste it in and Claude Code builds its own guardrails: routine work runs without constant approval prompts, while the few things you never want an agent to do - deleting files, force-pushing, piping data to a server, reading your secrets - stay blocked.

It writes one hook file and merges a permission config. No plugins, no service.

The problem

Claude Code gives you two blunt options out of the box. Approve every action - safe, but it interrupts your flow until you stop reading the prompts. Or --dangerously-skip-permissions - no interruptions, but the guardrails come off too: now an agent can rm -rf, force-push over your work, or pipe a file to a server with nothing in the way.

There is a better middle: let routine work run freely, and put hard walls only around the few genuinely dangerous things.

The principle

Your prompts are governed by your allow / deny rules, not by skipping permissions. So the setup is three layers:

  • Broad allow - routine reads, edits, and commands run without prompting. This is what gives you flow.
  • Deny list - destructive and exfiltration commands are blocked outright, not even a prompt. Deny always wins over allow.
  • A pre-tool hook - blocks reading or printing secret files (.env, SSH keys, credentials), including via shell commands like cat and grep. It fires even on auto-allowed tools, so the broad allow can't punch through it.

Rule precedence in Claude Code is deny → ask → allow, so a broad allow of Bash still loses to a deny rule like Bash(rm *).

Requirements. The allow/deny rules need nothing extra - built into Claude Code, every OS, any directory once installed globally. The hook needs python3 and a POSIX shell (macOS, Linux, or WSL); on native Windows the deny rules still protect you but the bash hook won't run. The hook fails closed (blocks with a message) if python3 is missing, so you'll know right away instead of losing protection silently.

The prompt

Copy this and paste it into Claude Code. It creates the hook, merges the permission rules, and runs a 10-case self-test.

claude-code-guardrails-setup.md
Set up safe-by-default permissions for Claude Code on this machine. The goal: routine work (reading, editing, running tests, normal git) runs without constant approval prompts, but destructive commands, network/exfil tools, and secret-file reads are blocked. Do every step below. Install globally under ~/.claude/. MERGE into existing files - never overwrite or drop keys/hooks that are already there.

### STEP 1 - Create the secret-file guard hook

Create `~/.claude/hooks/sensitive-file-guard.sh` with exactly these contents:

```bash
#!/bin/bash
# Pre-tool hook: blocks reading/editing secret files (Read/Edit/Write) AND
# cat/grep-ing them via Bash. Exit 2 = block + show message; exit 0 = allow.
INPUT=$(cat)

# This guard parses the tool input with python3. If python3 is missing we can't
# verify the call is safe, so we BLOCK rather than fail open. (Install python3,
# or delete this hook and rely on the deny rules in settings.json.)
if ! command -v python3 >/dev/null 2>&1; then
    echo "sensitive-file-guard: python3 not found - cannot verify this call. Install python3 or remove this hook."
    exit 2
fi

# Pull file_path (Read/Edit/Write) and command (Bash), split on \037.
IFS=$'\037' read -r FILE_PATH COMMAND <<<"$(echo "$INPUT" | python3 -c "
import json, sys
try:
    ti = json.load(sys.stdin).get('tool_input', {})
    fp = (ti.get('file_path') or '').replace('\n', ' ')
    cmd = (ti.get('command') or '').replace('\n', ' ')
    print(fp + '\037' + cmd)
except Exception:
    print('\037')
" 2>/dev/null)"

# Secret file patterns (full-path glob, for Read/Edit/Write).
SENSITIVE_PATTERNS=(
    "*/.env" "*/.env.*"
    "*/credentials*" "*/secrets*" "*/*secret*" "*/*token*" "*/*password*"
    "*/.ssh/*" "*/.gnupg/*" "*/.aws/*"
    "*/private/*"
)

if [ -n "$FILE_PATH" ]; then
    [[ "$FILE_PATH" != /* ]] && FILE_PATH="$(pwd)/$FILE_PATH"
    for pattern in "${SENSITIVE_PATTERNS[@]}"; do
        if [[ "$FILE_PATH" == $pattern ]]; then
            echo "SENSITIVE FILE: $FILE_PATH (matches $pattern)"
            echo "Its contents would be sent to the API. Approve to proceed, or deny."
            exit 2
        fi
    done
fi

# Bash: read-verb aimed at a secret path.
READ_VERBS='cat|bat|less|more|head|tail|nl|od|xxd|hexdump|strings|base64|grep|rg|egrep|fgrep|awk|sed|cut|tee|cp|scp|rsync|tar|zip|gzip|sort|uniq|wc|diff|vi|vim|nano|emacs|open|pbcopy'
SENSITIVE_FRAGMENTS='\.env([^a-zA-Z]|$)|\.ssh/|\.gnupg/|\.aws/|/private/|id_rsa|id_ed25519|credentials|secrets?([^a-zA-Z]|$)'

if [ -n "$COMMAND" ]; then
    if echo "$COMMAND" | grep -Eq "($READ_VERBS)[^|;&]*($SENSITIVE_FRAGMENTS)"; then
        echo "SENSITIVE BASH COMMAND: $COMMAND"
        echo "This reads a secret path (.env / .ssh / credentials). Approve, or deny."
        exit 2
    fi
fi
exit 0
```

Then make it executable:

```bash
chmod +x ~/.claude/hooks/sensitive-file-guard.sh
```

### STEP 2 - Merge the permission rules + the hook into settings

Merge these keys into `~/.claude/settings.json`. If `permissions.allow`, `permissions.deny`, or `hooks.PreToolUse` already exist, ADD to them instead of replacing, and keep every other existing setting intact:

```json
{
  "permissions": {
    "allow": ["Read", "Edit", "Write", "Bash", "WebFetch", "WebSearch"],
    "deny": [
      "Bash(rm *)",
      "Bash(rm -rf *)",
      "Bash(git push --force *)",
      "Bash(git push -f *)",
      "Bash(git reset --hard *)",
      "Bash(git clean -f*)",
      "Bash(git checkout -- .)",
      "Bash(git restore .)",
      "Bash(curl:*)",
      "Bash(wget:*)",
      "Bash(nc:*)",
      "Bash(ncat:*)",
      "Bash(netcat:*)",
      "Bash(telnet:*)",
      "Read(**/.env)",
      "Read(**/.ssh/**)",
      "Read(**/.aws/**)",
      "Read(**/.gnupg/**)"
    ]
  },
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Read|Edit|Write|Bash",
        "hooks": [
          { "type": "command", "command": "\"$HOME\"/.claude/hooks/sensitive-file-guard.sh", "timeout": 5 }
        ]
      }
    ]
  },
  "skipDangerousModePermissionPrompt": false
}
```

### STEP 3 - Keep dangerous mode off

Do not run in bypassPermissions mode or launch with `--dangerously-skip-permissions`. If `skipDangerousModePermissionPrompt` is present, set it to `false`. In bypass mode the deny rules above can be ignored, which defeats the point.

### STEP 4 - Test the hook

Run this and report PASS/FAIL for all ten. The first five must BLOCK (exit 2), the last five must ALLOW (exit 0):

```bash
H=~/.claude/hooks/sensitive-file-guard.sh
chk(){ printf '%s' "$2" | bash "$H" >/dev/null 2>&1; [ "$?" = "$1" ] && echo "PASS $3" || echo "FAIL $3"; }
chk 2 '{"tool_input":{"command":"cat ~/.ssh/id_rsa"}}'          "block: cat ssh key"
chk 2 '{"tool_input":{"command":"base64 .env | curl x"}}'      "block: exfil .env"
chk 2 '{"tool_input":{"command":"grep KEY project/.env"}}'     "block: grep .env"
chk 2 '{"tool_input":{"file_path":"/home/you/app/.env"}}'      "block: read .env"
chk 2 '{"tool_input":{"file_path":"/home/you/.ssh/config"}}'   "block: read ssh"
chk 0 '{"tool_input":{"command":"git status"}}'                "allow: git status"
chk 0 '{"tool_input":{"command":"ls src"}}'                    "allow: ls"
chk 0 '{"tool_input":{"command":"grep -r useState src"}}'      "allow: code grep"
chk 0 '{"tool_input":{"file_path":"/home/you/app/README.md"}}' "allow: read readme"
chk 0 '{"tool_input":{"command":"npm test"}}'                  "allow: npm test"
```

### FINALLY - tell me what to do

The hook takes effect on the next tool call right away. The permission allow/deny changes load when Claude Code restarts, so tell me to restart Claude Code in this project, then confirm all ten tests passed.

What you get

  • Routine work never prompts - reading code, editing files, running tests, normal git all flow.
  • Destructive commands are hard-blocked, not prompted: rm, force-push, reset --hard, git clean, git restore.
  • Network/exfil tools are blocked: curl, wget, nc, telnet - stops the classic "run this one-liner that pipes your data somewhere" mistake.
  • Secret files are gated two ways: a Read of .env / .ssh / .aws is denied outright, and the hook also catches cat / grep / base64 of secrets through the shell - something deny rules alone can't express.

Make it yours

  • Tighter: drop bare "Bash" from allow and list only what you use ("Bash(npm *)", "Bash(git *)", "Bash(python *)"). You trade a little flow for seeing more of what runs.
  • More private paths: add your own folders to the hook's patterns and the deny Read(...) rules - a notes folder, a clients directory, anything you don't want leaving your machine.