◉ANTIGRAVITY LABJP
Articles/Tips & Best Practices
✦ Tips & Best Practices/2026-10-27Intermediate

A 60-Line PreToolUse Hook That Stops an Agent's rm -rf Before It Runs

How to block rm -rf and git push --force before they execute, using an Antigravity CLI PreToolUse hook. Includes what a one-line grep missed across 16 test commands.

Antigravity401CLI8hooks6PreToolUsesafety3

I was tidying the delivery folder of one of my wallpaper apps late one evening and asked the agent to clear out old build output. The command on screen pointed one directory level higher than I had in mind. I caught it before pressing approve, but I still think about how that would have gone on a tired night.

Approval by eye alone fails on the days you are least able to afford it. So as an indie developer I moved the dangerous few commands behind a machine check that runs before execution. The Antigravity CLI PreToolUse hook makes that possible in about 60 lines of Python.

What I'd like to say first: the hard part is not the config file, it is how you read the command string. I'll also show how a one-line grep version slipped past my own tests.

What we're building

A hook that answers one of three ways when the agent is about to run a shell command.

DecisionMatchesMeaning
denyrm -rf or git push --force on root, home, or absolute pathsNever runs
askrm -rf on relative paths, git reset --hardAlways asks me
allowEverything elseDefers to the normal approval policy

According to the official docs, hooks live in .agents/hooks.json at the workspace root, or globally in ~/.gemini/config/hooks.json. The docs also state that hooks placed in ~/.gemini/antigravity-cli/settings.json are not loaded.

hooks.json

The shell tool is named run_command, and the command string arrives in toolCall.args.CommandLine. The matcher is a regex against the tool name.

{
  "safety-gate": {
    "PreToolUse": [
      {
        "matcher": "run_command",
        "hooks": [
          { "type": "command", "command": "python3 ./scripts/guard.py", "timeout": 10 }
        ]
      }
    ]
  }
}

The default timeout is 30 seconds. The check finishes instantly, so I dropped it to 10; a stuck hook costs far more than a fast one saves.

guard.py

JSON arrives on stdin and you answer with JSON containing decision on stdout. For PreToolUse the field is required: deny is a hard block, ask prompts, allow defers to the default policy.

#!/usr/bin/env python3
"""PreToolUse hook: run_command の破壊的な削除・強制 push を止める。"""
import json, re, shlex, sys
 
SEPARATORS = {";", "&&", "||", "|", "&"}
 
def split_commands(line):
    """クォートを尊重して ; && || | で区切り、コマンドごとの単語列にする。"""
    lex = shlex.shlex(line, posix=True, punctuation_chars=";&|")
    lex.whitespace_split = True
    cmds, cur = [], []
    for tok in lex:
        if tok in SEPARATORS or set(tok) <= set(";&|"):
            if cur:
                cmds.append(cur)
            cur = []
        else:
            cur.append(tok)
    if cur:
        cmds.append(cur)
    return cmds
 
def strip_prefix(words):
    """sudo / env / VAR=1 / command / nice を先頭から外す。"""
    i = 0
    while i < len(words):
        w = words[i]
        if w in ("sudo", "command", "nice", "env", "time") or re.fullmatch(r"[A-Za-z_]\w*=.*", w):
            i += 1
        else:
            break
    return words[i:]
 
def flags_of(words):
    short, long_ = set(), set()
    for w in words:
        if w.startswith("--"):
            long_.add(w)
        elif w.startswith("-") and len(w) > 1:
            short.update(w[1:])
    return short, long_
 
DANGEROUS_TARGETS = {"/", "~", "$HOME", "*", ".", ".."}
 
def judge(line):
    try:
        cmds = split_commands(line)
    except ValueError:
        return "ask", "コマンドを解析できませんでした(クォートの閉じ忘れ)"
    for words in cmds:
        words = strip_prefix(words)
        if not words:
            continue
        head, rest = words[0], words[1:]
        short, long_ = flags_of(rest)
        args = [w for w in rest if not w.startswith("-")]
        if head == "rm":
            recursive = "r" in short or "R" in short or "--recursive" in long_
            force = "f" in short or "--force" in long_
            if recursive and force:
                hit = [a for a in args if a in DANGEROUS_TARGETS or a.startswith("/")]
                if hit:
                    return "deny", f"rm -rf の対象が広すぎます: {hit[0]}"
                return "ask", "rm -rf は毎回確認します"
        if head == "git" and rest[:1] == ["push"]:
            if "--force" in long_ or "f" in short:
                return "deny", "git push --force は --force-with-lease を使ってください"
        if head == "git" and rest[:1] == ["reset"] and "--hard" in long_:
            return "ask", "git reset --hard は未コミットの変更を消します"
    return "allow", ""
 
def main():
    payload = json.load(sys.stdin)
    call = payload.get("toolCall", {})
    line = (call.get("args") or {}).get("CommandLine", "")
    decision, reason = judge(line)
    out = {"decision": decision}
    if reason:
        out["reason"] = reason
    print(json.dumps(out, ensure_ascii=False))
 
if __name__ == "__main__":
    main()

Three things matter here.

  1. shlex parses quoting first, then the line is split on ;, &&, ||, and |, so the second half of cd /tmp && rm -rf x is still inspected.
  2. Leading sudo, env, and FOO=1 are stripped before reading the real command name.
  3. -rf, -fr, -r -f, and --recursive --force all collapse into the same flag sets.

Sixteen commands, tested locally

I did not run a real agent here. I piped JSON shaped like the hook payload into the script and read the decisions.

Command fed inDecision
ls -laallow
rm -rf /deny
rm -fr /var/wwwdeny
rm -r -f ./buildask
rm -rf node_modulesask
sudo rm -rf /deny
FOO=1 rm -rf *deny
cd /tmp && rm -rf /tmp/xdeny
git commit -m "rm -rf notes"allow
echo "rm -rf /" > note.txtallow
git push --force origin maindeny
git push -fdeny
git push --force-with-lease origin mainallow
git reset --hard HEAD~1ask
rm -rf "unterminated (unclosed quote)ask

Where the one-line grep version failed

My first draft blocked any command containing the text rm -rf. Against the same commands:

  • rm -fr /var/www slipped through. Only the flag order changed.
  • rm -r -f ./build slipped through. The flags were merely split.
  • git commit -m "rm -rf notes" was blocked, because it reacted to text inside a commit message.

It missed real danger and blocked harmless work. That is why I dropped string matching. Read a command as structure, not as a string. I come back to that sentence every time I touch this file.

The part that surprised me

cd /tmp && rm -rf /tmp/x came back as deny. Cleaning a temp directory is harmless, but the guard rejects every absolute path. If that feels too strict, deny only exact matches in DANGEROUS_TARGETS plus prefixes you want protected, such as /etc or /usr, and downgrade the rest to ask. I ran strict first and loosened it only after the prompts piled up.

Also, this kind of check cannot stop every bypass. bash -c "rm -rf /" hides the command inside a string, and this version does not parse inside it. I treat the hook as a net for accidents, not a defense against intent.

What I have not verified

What I actually ran is the decision logic in guard.py and the JSON syntax of hooks.json. Loading it into the CLI follows the official docs, and I did not execute that part in this session.

Once it is loaded, check /hooks, then ask the agent to run a harmless rm -rf ./tmp-check and confirm that a prompt appears. There are reports of hooks loading but never firing, so that first check is worth keeping.

One next step

Enable only the ask branch for a week. Counting how often it stops you shows which operations in your own repository are truly risky.

Share

Thank You for Reading

Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

⟐ Editor View2026-08-13
2.6.0 Changed How Hooks Wait. A Triage Order for Turns That Never Finish
When an agent turn never finishes, the cause is often a hook you wrote rather than the model. Here is what changed in IDE 2.6.0, an audit script that finds hooks capable of stalling a turn, and how to give a hook its own cutoff.
✦ Tips2026-10-19
After I Rewrote the URL, Only the Not-Yet-Loaded Images Returned 404: Checking replaceState and Relative Paths
After rewriting the URL with history.replaceState when moving from a list to a detail view, only the images that had not loaded yet returned 404. Native loading=lazy broke too. Here are a minimal test page, a check script, and three fixes compared.
✦ Tips2026-10-17
When Search Console Says "Page with redirect": How I Sorted Which Redirects to Fix and Which to Leave Alone
A record of triaging Search Console's "Page with redirect" report on my own site. Normalising http to https, www, trailing slashes and language routing is expected; chains, loops and destinations that error or carry noindex need work. Includes a Python script that walks each hop and classifies it, and a way to catch language-dependent redirects that a plain request never sees.