Skip to content

Instantly share code, notes, and snippets.

@danklammer
Forked from sgasser/README.md
Last active March 12, 2026 15:57
Show Gist options
  • Select an option

  • Save danklammer/246e568785f2b0b48a5cadc45369cdc9 to your computer and use it in GitHub Desktop.

Select an option

Save danklammer/246e568785f2b0b48a5cadc45369cdc9 to your computer and use it in GitHub Desktop.
Claude Code security hook - blocks dangerous commands, credential files, and bypass attempts
# Clauded - "I also like to live dangerously"
alias clauded="claude --dangerously-skip-permissions"

Claude Code Security Setup

Two-layer protection for sensitive files and destructive commands, while still allowing targeted deletes in your current git project.

Setup

  1. Copy settings.json to ~/.claude/settings.json
  2. Save security-validator.py to ~/.claude/hooks/security-validator.py
  3. Make executable: chmod +x ~/.claude/hooks/security-validator.py
  4. (Optional) Add .zshrc alias and reload shell: source ~/.zshrc

How it works

permissions.deny blocks sensitive file reads and high-risk commands.

permissions.allow explicitly allows rm and rmdir, but only so the hook can inspect and enforce safe behavior.

Hook (security-validator.py) enforces delete safety:

  • Allows rm/rmdir only when every target is inside a git repository
  • Blocks deletes outside git repos
  • Blocks deleting repo root
  • Blocks deleting inside .git
  • Blocks obvious high-risk targets (/, ~, ., ..)
  • Blocks --no-preserve-root

Why both?

  • permissions.deny is native to Claude Code and protects sensitive reads up front
  • The hook adds policy logic for shell delete commands, including in --dangerously-skip-permissions workflows
  • This combination keeps normal file cleanup possible inside project repos without allowing unsafe deletes elsewhere
#!/usr/bin/env python3
"""
Security validator hook for Claude Code.
Allows rm/rmdir only within a git repository and blocks dangerous deletion patterns.
"""
import glob
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
from typing import List, Optional, Sequence, Tuple
SHELL_SEPARATORS = {";", "|", "||", "&&"}
def split_shell_segments(command: str) -> List[List[str]]:
lexer = shlex.shlex(command, posix=True, punctuation_chars=";&|")
lexer.whitespace_split = True
lexer.commenters = ""
segments: List[List[str]] = []
current: List[str] = []
for token in lexer:
if token in SHELL_SEPARATORS:
if current:
segments.append(current)
current = []
continue
current.append(token)
if current:
segments.append(current)
return segments
def has_recursive_flag(args: Sequence[str]) -> bool:
parsing_flags = True
for arg in args:
if parsing_flags and arg == "--":
parsing_flags = False
continue
if parsing_flags and arg.startswith("-") and arg != "-":
if "r" in arg or "R" in arg:
return True
return False
def parse_rm_targets(args: Sequence[str]) -> List[str]:
targets: List[str] = []
parsing_flags = True
for arg in args:
if parsing_flags and arg == "--":
parsing_flags = False
continue
if parsing_flags and arg.startswith("-") and arg != "-":
continue
targets.append(arg)
return targets
def expand_targets(raw_target: str, cwd: Path) -> List[Path]:
expanded = os.path.expanduser(raw_target)
base = Path(expanded)
if not base.is_absolute():
base = cwd / base
if any(ch in raw_target for ch in "*?["):
matches = [Path(p).resolve(strict=False) for p in glob.glob(str(base), recursive=True)]
if matches:
return matches
return [base.resolve(strict=False)]
def is_within(path: Path, parent: Path) -> bool:
try:
path.relative_to(parent)
return True
except ValueError:
return False
def nearest_existing_dir(path: Path) -> Path:
candidate = path if path.is_dir() else path.parent
if candidate.exists():
return candidate
for parent in candidate.parents:
if parent.exists():
return parent
return Path.cwd()
def git_root_for_path(path: Path) -> Optional[Path]:
probe_dir = nearest_existing_dir(path)
for candidate in [probe_dir, *probe_dir.parents]:
result = subprocess.run(
["git", "-C", str(candidate), "rev-parse", "--show-toplevel"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
continue
root = result.stdout.strip()
if root:
return Path(root).resolve(strict=False)
return None
def validate_rm_segment(tokens: Sequence[str], cwd: Path) -> Tuple[bool, str]:
if not tokens:
return True, ""
exe = Path(tokens[0]).name
if exe not in {"rm", "rmdir"}:
return True, ""
args = list(tokens[1:])
recursive = has_recursive_flag(args)
targets = parse_rm_targets(args)
if not targets:
return False, "rm/rmdir requires explicit file or directory targets."
for raw_target in targets:
if raw_target in {"/", "~", "~root", ".", ".."}:
return False, f"Deleting '{raw_target}' is blocked."
for resolved_target in expand_targets(raw_target, cwd):
git_root = git_root_for_path(resolved_target)
if git_root is None:
return False, (
f"Deletion blocked for '{raw_target}'. rm/rmdir is only allowed inside a git repository."
)
if not is_within(resolved_target, git_root):
return False, f"Deletion blocked for '{raw_target}'. Target must be inside repo root {git_root}."
if resolved_target == git_root:
return False, f"Deletion of repository root is blocked: {git_root}"
dot_git = (git_root / ".git").resolve(strict=False)
if is_within(resolved_target, dot_git):
return False, "Deletion inside .git is blocked."
if recursive and resolved_target in {git_root, git_root.parent, Path.home(), Path("/")}:
return False, f"Recursive deletion of high-risk path blocked: {resolved_target}"
return True, ""
def check_bash_command(command: str, cwd: Path) -> tuple[bool, str]:
if "--no-preserve-root" in command:
return False, "Use of --no-preserve-root is blocked."
try:
segments = split_shell_segments(command)
except ValueError as exc:
return False, f"Could not parse shell command safely: {exc}"
for segment in segments:
is_allowed, msg = validate_rm_segment(segment, cwd)
if not is_allowed:
return is_allowed, msg
return True, ""
def main() -> None:
try:
input_data = json.load(sys.stdin)
except json.JSONDecodeError as e:
print(f"Error: Invalid JSON input: {e}", file=sys.stderr)
sys.exit(1)
if input_data.get("tool_name", "") != "Bash":
sys.exit(0)
tool_input = input_data.get("tool_input", {})
command = tool_input.get("command", "")
if not command:
sys.exit(0)
cwd_raw = tool_input.get("cwd") or os.getcwd()
cwd = Path(cwd_raw).resolve(strict=False)
is_allowed, error_msg = check_bash_command(command, cwd)
if not is_allowed:
print(f"SECURITY BLOCK: {error_msg}", file=sys.stderr)
sys.exit(2)
sys.exit(0)
if __name__ == "__main__":
main()
{
"permissions": {
"allow": [
"Write(*)",
"Update(*)",
"Bash(ls:*)",
"Bash(pwd:*)",
"Bash(cd:*)",
"Bash(echo:*)",
"Bash(printf:*)",
"Bash(cat:*)",
"Bash(less:*)",
"Bash(more:*)",
"Bash(head:*)",
"Bash(tail:*)",
"Bash(wc:*)",
"Bash(stat:*)",
"Bash(file:*)",
"Bash(mkdir:*)",
"Bash(touch:*)",
"Bash(cp:*)",
"Bash(mv:*)",
"Bash(chmod:*)",
"Bash(find:*)",
"Bash(grep:*)",
"Bash(rg:*)",
"Bash(sed:*)",
"Bash(awk:*)",
"Bash(xargs:*)",
"Bash(diff:*)",
"Bash(git:*)",
"Bash(curl:*)",
"Bash(wget:*)",
"Bash(make:*)",
"Bash(go:*)",
"Bash(python:*)",
"Bash(python3:*)",
"Bash(poetry:*)",
"Bash(node:*)",
"Bash(npm:*)",
"Bash(npx:*)",
"Bash(pnpm:*)",
"Bash(yarn:*)",
"Bash(sqlite3:*)",
"Bash(lsof:*)",
"Bash(rm:*)",
"Bash(rmdir:*)"
],
"deny": [
"Bash(dd:*)",
"Bash(mkfs:*)",
"Bash(sudo:*)",
"Bash(chown:*)",
"Bash(chattr:*)",
"Bash(mount:*)",
"Bash(umount:*)",
"Bash(shutdown:*)",
"Bash(reboot:*)",
"Read(**/.dev)",
"Read(**/.dev.*)",
"Read(**/.env)",
"Read(**/.env.*)",
"Read(**/.ssh/id_*)",
"Read(**/id_rsa)",
"Read(**/id_ed25519)",
"Read(**/*.pem)",
"Read(**/*.p12)",
"Read(**/*.pfx)",
"Read(**/*.ppk)",
"Read(**/*.gpg)",
"Read(**/*.pgp)",
"Read(**/*.asc)",
"Read(**/.aws/credentials)",
"Read(**/.azure/**)",
"Read(**/.config/gcloud/**)",
"Read(**/.kube/config)",
"Read(**/*vault_pass*)",
"Read(**/secrets.yml)",
"Read(**/secrets.yaml)",
"Read(**/secrets.json)",
"Read(**/credentials.json)",
"Read(**/.htpasswd)",
"Read(**/.netrc)",
"Read(**/.npmrc)",
"Read(**/.pypirc)",
"Read(**/application.properties)",
"Read(**/appsettings.json)",
"Read(**/*.tfstate)",
"Read(**/.git/config)",
"Read(**/.git-credentials)",
"Read(**/.bash_history)",
"Read(**/.zsh_history)",
"Read(**/.pgpass)",
"Read(**/.my.cnf)",
"Read(**/.docker/config.json)",
"Read(**/*.jks)",
"Read(**/*.keystore)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/security-validator.py"
}
]
}
]
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment