Last active
September 7, 2025 20:24
-
-
Save HenryQW/29589ab608ce3e2e59e6df1359286862 to your computer and use it in GitHub Desktop.
Guradrails to prevent Claude Code from performing high-risk operations. Use `PreToolUse` hooks for activation. O(n) complexity with < 20ms execution time.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env python3 | |
# scripts/guardrails.py | |
"""Safety guardrails for shell commands executed by tools. | |
Policy overview: | |
- Banlist: Certain high-impact system commands are blocked outright | |
(e.g., diskutil, fdisk, parted, mkfs*). These are denied regardless of args. | |
- Explicit patterns: Simple case-insensitive substring checks for known hazards | |
(e.g., fork bomb, chmod -R 777, dd if=/dev/zero). | |
- Heuristics: Minimal, conservative checks for risky patterns like rm -rf on | |
root/home, root-level find -delete, piping remote scripts into a shell, and | |
writing to block devices via dd. | |
Keep this file small and readable; extend ban/heuristics carefully. | |
Usage: | |
Add to ~/.claude/settings.json: | |
"hooks": { | |
"PreToolUse": [ | |
{ | |
"matcher": ".*", | |
"hooks": [ | |
{ | |
"type": "command", | |
"command": "python /path/to/guardrails.py" | |
} | |
] | |
} | |
] | |
} | |
""" | |
import os | |
import sys | |
import shlex | |
from typing import List | |
# Keep this small and focused: fast checks first, then minimal heuristics. | |
# | |
# Policy: Some system-impacting commands are banned outright (banlist below), | |
# regardless of arguments. Heuristic checks then handle nuanced cases. | |
# Explicit hazardous substrings (checked case-insensitively) | |
DANGEROUS_SUBSTRINGS = ( | |
# Core explicit hazards (case-insensitive check) | |
"rm -rf /", | |
"rm -rf ~", | |
"sudo rm", | |
":(){:|:&};:", | |
"dd if=/dev/zero", | |
"chmod -r 777", # covers "-R" via lowercase normalization | |
) | |
# Commands that are ALWAYS blocked due to high system impact, regardless of args. | |
# Adjust this list conservatively; prefer explicit items to avoid surprises. | |
BAN_COMMANDS = { | |
# Disk and partition management | |
"diskutil", | |
"fdisk", | |
"sfdisk", | |
"parted", | |
"wipefs", | |
# LVM / RAID / crypto (high-impact system storage) | |
"lvremove", | |
"vgremove", | |
"pvremove", | |
"mdadm", | |
"cryptsetup", | |
# Mounting state changes | |
"mount", | |
"umount", | |
# Power and shutdown | |
"reboot", | |
"poweroff", | |
"halt", | |
"shutdown", | |
} | |
# Command name prefixes that are ALWAYS blocked (e.g., mkfs, mkfs.ext4, ...) | |
BAN_PREFIXES = ("mkfs",) # blocks mkfs and all mkfs.* variants | |
# Common wrapper commands that may prefix the real command | |
WRAPPER_COMMANDS = {"sudo", "env", "time", "nohup", "nice", "stdbuf", "ionice"} | |
# Well-known device nodes considered safe (not block devices) | |
SAFE_DEVICE_PATHS = ( | |
"/dev/null", | |
"/dev/zero", | |
"/dev/tty", | |
"/dev/random", | |
"/dev/urandom", | |
) | |
def _block(reason: str, command: str) -> None: | |
print(f"🛑 BLOCKED: {reason}", file=sys.stderr) | |
print(f"Command was: {command}", file=sys.stderr) | |
sys.exit(1) | |
def _tokenize(cmd: str) -> List[str]: | |
try: | |
return shlex.split(cmd, posix=True) | |
except Exception: | |
# Fallback to a naive split if quoting is malformed | |
return cmd.split() | |
def _parse_lower(cmd: str) -> tuple[str, List[str]]: | |
"""Return lowercased raw command and tokens for uniform checks.""" | |
lower_raw = cmd.lower() | |
tokens = _tokenize(cmd) | |
return lower_raw, [t.lower() for t in tokens] | |
def _rm_risky(tokens: List[str], raw_cmd: str) -> bool: | |
"""Consolidated rm hazards: many wildcards, no-preserve-root, root/home wipes. | |
tokens should be lowercased; raw_cmd is the original command (can be lowercased). | |
""" | |
if not tokens: | |
return False | |
try: | |
rm_idx = tokens.index("rm") | |
except ValueError: | |
return False | |
# Many wildcards in rm command (coarse but useful signal) | |
if raw_cmd.count("*") > 2: | |
return True | |
# Flags and targets | |
flags = "".join(t for t in tokens[rm_idx + 1 :] if t.startswith("-")) | |
targets = [t for t in tokens[rm_idx + 1 :] if not t.startswith("-")] | |
# --no-preserve-root is always risky | |
if any(t == "--no-preserve-root" for t in tokens[rm_idx + 1 :]): | |
return True | |
# Only care for recursive forceful deletes | |
rf = ("r" in flags) and ("f" in flags) | |
if not rf: | |
return False | |
if not targets: | |
return False | |
risky_literals = {"/", "~", "/*"} | |
for t in targets: | |
if t in risky_literals: | |
return True | |
if t.startswith("/*"): | |
return True | |
# very broad wildcard at top-level or under home | |
if t == "*" or (t.startswith("~/") and "*" in t): | |
return True | |
return False | |
def _base_command(tokens: List[str]) -> str: | |
"""Return the base command name (without path), lowercased. | |
Skips common wrappers (sudo, env VAR=val, time, etc.). | |
""" | |
if not tokens: | |
return "" | |
i = 0 | |
# Skip common wrappers and env assignments (e.g., VAR=val) | |
wrappers = WRAPPER_COMMANDS | |
while i < len(tokens): | |
t = tokens[i] | |
if t in wrappers or ("=" in t and not t.startswith("-")): | |
i += 1 | |
continue | |
break | |
if i >= len(tokens): | |
return "" | |
t = tokens[i] | |
# Strip leading path segments | |
if "/" in t: | |
t = t.rsplit("/", 1)[-1] | |
return t.lower() | |
def _is_banned_command(tokens: List[str]) -> str: | |
"""Return banned identifier if command is banned unconditionally, else ''.""" | |
base = _base_command(tokens) | |
if not base: | |
return "" | |
if base in BAN_COMMANDS: | |
return base | |
for pref in BAN_PREFIXES: | |
if base.startswith(pref): | |
return pref + "*" | |
return "" | |
def _is_block_device_path(p: str) -> bool: | |
if not p.startswith("/dev/"): | |
return False | |
# Allowlist of harmless device files | |
if p in SAFE_DEVICE_PATHS or p.startswith("/dev/fd/") or p.startswith("/dev/pts/"): | |
return False | |
return True | |
def _device_write_operation(tokens: List[str]) -> bool: | |
"""Detect potentially destructive writes to block devices via dd/shred or redirection.""" | |
base = _base_command(tokens) | |
if not base: | |
return False | |
# dd writing to block devices | |
if base == "dd": | |
for t in tokens: | |
if t.startswith("of="): | |
path = t[3:] | |
if _is_block_device_path(path): | |
return True | |
# Also catch redirection like '> /dev/sdX' via naive scan | |
for i, t in enumerate(tokens[:-1]): | |
if t in {">", ">>", "2>", "1>"} and _is_block_device_path(tokens[i + 1]): | |
return True | |
# shred applied to devices | |
if base == "shred": | |
return any(_is_block_device_path(t) for t in tokens if not t.startswith("-")) | |
return False | |
def _find_delete_root(tokens: List[str]) -> bool: | |
base = _base_command(tokens) | |
if base != "find": | |
return False | |
# Consider root-level search paths | |
has_root_path = any( | |
t.startswith("/") and not t.startswith("/tmp") | |
for t in tokens | |
if not t.startswith("-") | |
) | |
if not has_root_path: | |
return False | |
if "-delete" in tokens: | |
return True | |
# find ... -exec rm ... ; | |
if "-exec" in tokens and "rm" in tokens: | |
return True | |
return False | |
def _chmod_chown_recursive_root(tokens: List[str]) -> bool: | |
base = _base_command(tokens) | |
if base not in {"chmod", "chown", "chgrp"}: | |
return False | |
# Collect flag string | |
try: | |
base_idx = tokens.index(base) | |
except ValueError: | |
return False | |
flags = "".join(t for t in tokens[base_idx + 1 :] if t.startswith("-")) | |
recursive = "r" in flags.lower() | |
if not recursive: | |
return False | |
targets = [t for t in tokens[base_idx + 1 :] if not t.startswith("-")] | |
return any(t == "/" for t in targets) | |
def _pipe_remote_to_shell(tokens: List[str], raw: str) -> bool: | |
# Detect common patterns like: curl ... | sh | bash | |
if "|" in tokens: | |
try: | |
pipe_idx = tokens.index("|") | |
except ValueError: | |
pipe_idx = -1 | |
if pipe_idx > 0: | |
left = tokens[:pipe_idx] | |
right = tokens[pipe_idx + 1 :] | |
if any(x in left for x in ("curl", "wget", "fetch")) and any( | |
x in right for x in ("sh", "bash", "zsh") | |
): | |
return True | |
# Detect sh -c "curl ..." style | |
base = _base_command(tokens) | |
if ( | |
base in {"sh", "bash", "zsh"} | |
and "-c" in tokens | |
and ("curl" in raw or "wget" in raw) | |
): | |
return True | |
return False | |
def _system_power_command(tokens: List[str]) -> bool: | |
"""Detect power actions including systemctl reboot/poweroff/halt. | |
Direct power commands are already banned via BAN_COMMANDS; this extends | |
coverage to 'systemctl' invocations that perform the same actions. | |
""" | |
banned_direct = {"reboot", "poweroff", "halt", "shutdown"} | |
base = _base_command(tokens) | |
if base in banned_direct: | |
return True | |
if base == "systemctl" and any(x in tokens for x in banned_direct): | |
return True | |
return False | |
def check() -> None: | |
command = os.environ.get("TOOL_COMMAND", "") | |
cmd = command.strip() | |
if not cmd: | |
return # Nothing to check | |
cmd_lc, tokens_lc = _parse_lower(cmd) | |
# 1) Banlist: block certain system-impacting tools unconditionally | |
banned = _is_banned_command(tokens_lc) | |
if banned: | |
_block(f"Command '{banned}' is banned by safety policy.", command) | |
# 2) Fast explicit substring checks (case-insensitive) | |
for pat in DANGEROUS_SUBSTRINGS: | |
if pat in cmd_lc: | |
_block(f"Dangerous command detected: {pat}", command) | |
# 3) Heuristic: consolidated rm hazards | |
if _rm_risky(tokens_lc, cmd_lc): | |
_block("Risky 'rm' usage detected.", command) | |
# 4) Device writes (dd/shred to block devices) | |
if _device_write_operation(tokens_lc): | |
_block("Potentially destructive write to block device.", command) | |
# 5) Root-level find -delete / -exec rm | |
if _find_delete_root(tokens_lc): | |
_block("Root-level 'find' with delete/rm detected.", command) | |
# 6) Recursive chmod/chown/chgrp on root | |
if _chmod_chown_recursive_root(tokens_lc): | |
_block("Recursive permission/ownership change on '/'.", command) | |
# 7) Remote script piped into a shell | |
if _pipe_remote_to_shell(tokens_lc, cmd_lc): | |
_block("Piping remote script into a shell.", command) | |
# 8) System power commands (systemctl variants) | |
if _system_power_command(tokens_lc): | |
_block("System power/reboot command detected.", command) | |
if __name__ == "__main__": | |
check() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment