block_destructive.py
hooks/block_destructive.py
#!/usr/bin/env python3
"""PreToolUse hook: nothing gets deleted or moved, and nobody writes into data/.
The permission rules already deny `rm`, but a delete can hide in other commands
(find -delete, unlink, mv, a one-line python). This hook looks at the command
itself, so those are blocked too. data/ is read only: only the scripts read it.
Exit code 2 blocks the call and shows stderr to the agent.
"""
import json
import re
import sys
DESTRUCTIVE = [
(r"(^|[\s;&|(])(rm|rmdir|unlink|shred|truncate|srm|trash)(\s|$)", "deletes files"),
(r"\bfind\b.*(-delete|-exec\s+(rm|unlink|mv)\b)", "deletes files with find"),
(r"(^|[\s;&|(])mv\s", "moves files away"),
(r"\bgit\s+(clean|reset\s+--hard|checkout\s+--\s)", "throws away files with git"),
(r"(os\.(remove|unlink|rmdir|removedirs)|shutil\.(rmtree|move)|Path\([^)]*\)\.unlink|\.unlink\(\))", "deletes files from code"),
(r">\s*\.?/?data/", "overwrites a file in data/"),
]
def main() -> int:
call = json.load(sys.stdin)
tool, args = call.get("tool_name", ""), call.get("tool_input", {})
if tool == "Bash":
cmd = args.get("command", "")
for pattern, what in DESTRUCTIVE:
if re.search(pattern, cmd):
print(f"Blocked: this command {what}. Nothing in this project gets deleted or moved by the agent. "
"Ask a person to do it.", file=sys.stderr)
return 2
if tool in ("Write", "Edit", "NotebookEdit"):
path = args.get("file_path") or args.get("notebook_path") or ""
if re.search(r"(^|/)data/", path):
print("Blocked: data/ is read only. Write to outputs/ instead.", file=sys.stderr)
return 2
return 0
if __name__ == "__main__":
try:
sys.exit(main())
except Exception as err: # fail closed: a broken hook must not let the call through
print(f"Hook error, call blocked: {err}", file=sys.stderr)
sys.exit(2)