壁紙アプリの配信用ディレクトリを整理していた夜のことです。エージェントに「古いビルド成果物を消して」と頼み、画面に出てきたコマンドを確認しようとして、手が止まりました。対象のパスが、私の思っていた場所より一段浅かったのです。承認ボタンを押す前に気づけましたが、もし疲れた日の夜だったらと考えると、いまでも胃のあたりが冷えます。
人の目だけに頼る承認は、体調の悪い日に崩れます。そこで、危ないコマンドだけは実行の手前で機械に判定させるようにしました。Antigravity CLI の PreToolUse フックを使えば、それが 60 行ほどの Python で書けます。
最初にお伝えしたいのは、フックの難しさは設定ファイルではなく、コマンド文字列の読み方にあるということです。grep 一行で済ませた版が、私の手元でどう素通りしたかも、後半に書き残します。
作るもの
エージェントがシェルコマンドを実行しようとした瞬間に、次の 3 段階で返事をするフックです。
| 判定 | 対象 | 意味 |
|---|---|---|
| deny | ルートやホーム、絶対パスへの rm -rf/git push --force | 実行させない |
| ask | 相対パスへの rm -rf/git reset --hard | 毎回こちらに確認する |
| allow | それ以外 | 通常の承認ポリシーに任せる |
公式ドキュメントによると、フックの置き場所はワークスペース直下の .agents/hooks.json か、全体用の ~/.gemini/config/hooks.json です。~/.gemini/antigravity-cli/settings.json に書いたフックは読み込まれない、とも明記されています。
hooks.json
シェルコマンドを実行するツールの名前は run_command で、コマンド文字列は toolCall.args.CommandLine に入ります。matcher はツール名に対する正規表現です。
{
"safety-gate": {
"PreToolUse": [
{
"matcher": "run_command",
"hooks": [
{ "type": "command", "command": "python3 ./scripts/guard.py", "timeout": 10 }
]
}
]
}
}timeout の既定は 30 秒です。判定は一瞬で終わるので、10 秒に縮めておきました。固まったフックに待たされるほうが、ずっと高くつくからです。
guard.py
標準入力に JSON が届き、標準出力に decision を含む JSON を返します。PreToolUse では decision が必須で、deny が強制ブロック、ask が確認、allow が通常ポリシーへの委譲です。
#!/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()要点は 3 つです。
shlexでクォートを解釈してから、;、&&、||、|でコマンドを分けます。cd /tmp && rm -rf xの後半も見逃しません。- 先頭の
sudo、env、FOO=1を外してから、本体のコマンド名を見ます。 - フラグは
-rf、-fr、-r -f、--recursive --forceをすべて同じ集合に畳んで判定します。
手元で 16 通り試した結果
この環境では、実際のエージェントではなく、フックが受け取る形の JSON を標準入力へ流して判定を確かめました。
| 入力したコマンド | 判定 |
|---|---|
| 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(クォート閉じ忘れ) | ask |
grep 一行の版は、どこで抜けたか
最初に書いたのは、コマンド文字列に rm -rf が含まれていたら止める、という版でした。同じコマンド群に当てると、結果は次のとおりです。
rm -fr /var/wwwは素通りしました。フラグの順序を変えただけです。rm -r -f ./buildも素通りしました。フラグを分けただけです。git commit -m "rm -rf notes"は止まりました。コミットメッセージの文字列に反応したのです。
抜けるうえに、関係ないものまで止めてしまうのです。これが、文字列一致の版を捨てた理由だと、いまは思っています。コマンドは文字列ではなく、構造として読みます。 判定を書くたびに、この一文に戻るようにしています。
直感に反した点
cd /tmp && rm -rf /tmp/x は deny になりました。一時ディレクトリの後始末は無害なのに、絶対パスはすべて止める作りにしているためです。厳しすぎると感じる方は、DANGEROUS_TARGETS に完全一致するものと、/etc、/usr など保護したい接頭辞だけを deny にし、残りを ask へ落とすと、使い勝手が戻ります。私は最初のうち厳しいまま使い、確認の回数が増えすぎたら緩める、という順にしました。
もう一つ、この手の判定は迂回を完全には防げません。bash -c "rm -rf /" のように文字列の中へ包まれると、上の版は中身を解析しません。フックはあくまで「うっかり」を止める網で、悪意への防御ではない、という線引きで使っております。
確かめていないこと
この記事で実際に確かめたのは、guard.py の判定ロジックと hooks.json の構文までです。CLI 本体に載せて動かす部分は、公式ドキュメントの仕様に沿って書いたもので、このセッションの中では実行していません。
載せた後は、/hooks で読み込まれているかを見て、無害な rm -rf ./tmp-check を一度頼み、確認が出ることを目で見てください。フックが読み込まれても動かない報告もあるため、最初の一回の確認は省かないほうが安全です。
次の一歩
まずは guard.py の ask だけを有効にして、一週間使ってみていただければと思います。止まった回数を数えると、自分のリポジトリで本当に危ない操作がどれなのか、輪郭が見えてきます。