Zerf home

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)