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.
| Decision | Matches | Meaning |
|---|---|---|
| deny | rm -rf or git push --force on root, home, or absolute paths | Never runs |
| ask | rm -rf on relative paths, git reset --hard | Always asks me |
| allow | Everything else | Defers 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.
shlexparses quoting first, then the line is split on;,&&,||, and|, so the second half ofcd /tmp && rm -rf xis still inspected.- Leading
sudo,env, andFOO=1are stripped before reading the real command name. -rf,-fr,-r -f, and--recursive --forceall 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 in | Decision |
|---|---|
| ls -la | allow |
| rm -rf / | deny |
| rm -fr /var/www | deny |
| rm -r -f ./build | ask |
| rm -rf node_modules | ask |
| sudo rm -rf / | deny |
| FOO=1 rm -rf * | deny |
| cd /tmp && rm -rf /tmp/x | deny |
| git commit -m "rm -rf notes" | allow |
| echo "rm -rf /" > note.txt | allow |
| git push --force origin main | deny |
| git push -f | deny |
| git push --force-with-lease origin main | allow |
| git reset --hard HEAD~1 | ask |
| 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/wwwslipped through. Only the flag order changed.rm -r -f ./buildslipped 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.