◉ANTIGRAVITY LABEN
記事一覧/Tips & 活用術
✦ Tips & 活用術/2026-10-27中級

エージェントの rm -rf を手前で止める PreToolUse フックを 60 行で書く

Antigravity CLI の PreToolUse フックで、rm -rf と git push --force を実行前に判定して止める手順です。grep では素通りする書き方を、手元で 16 通り試した結果と一緒にまとめました。

Antigravity401CLI8hooks5PreToolUse安全設計

壁紙アプリの配信用ディレクトリを整理していた夜のことです。エージェントに「古いビルド成果物を消して」と頼み、画面に出てきたコマンドを確認しようとして、手が止まりました。対象のパスが、私の思っていた場所より一段浅かったのです。承認ボタンを押す前に気づけましたが、もし疲れた日の夜だったらと考えると、いまでも胃のあたりが冷えます。

人の目だけに頼る承認は、体調の悪い日に崩れます。そこで、危ないコマンドだけは実行の手前で機械に判定させるようにしました。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 つです。

  1. shlex でクォートを解釈してから、;、&&、||、| でコマンドを分けます。cd /tmp && rm -rf x の後半も見逃しません。
  2. 先頭の sudo、env、FOO=1 を外してから、本体のコマンド名を見ます。
  3. フラグは -rf、-fr、-r -f、--recursive --force をすべて同じ集合に畳んで判定します。

手元で 16 通り試した結果

この環境では、実際のエージェントではなく、フックが受け取る形の JSON を標準入力へ流して判定を確かめました。

入力したコマンド判定
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(クォート閉じ忘れ)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 だけを有効にして、一週間使ってみていただければと思います。止まった回数を数えると、自分のリポジトリで本当に危ない操作がどれなのか、輪郭が見えてきます。

シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

✦ Tips & 活用術2026-10-19
URL を書き換えたら、あとから読み込む画像だけ 404 になりました。replaceState と相対パスの確かめ方
一覧から個別表示へ切り替えるとき history.replaceState で URL を書き換えたところ、まだ読み込んでいない画像だけが 404 になりました。loading=lazy も同じ挙動だった点、確認用の最小ページとスクリプト、直し方三種の比較までを書き残します。
✦ Tips & 活用術2026-10-17
検索の管理画面に「ページにリダイレクトがあります」と出たとき、直すものと放っておくものを分けた基準
Search Console の「ページにリダイレクトがあります」を、自分のサイトで仕分けた記録です。http→https・www・末尾スラッシュ・言語の振り分けは想定内、チェーン・ループ・転送先のエラーや noindex は要対応という線引きと、転送を一つずつ辿って分類する Python スクリプト、素の要求では見えない言語依存の転送を二通りの要求で見つける方法まで書き残します。
✦ Tips & 活用術2026-10-15
画像が壊れて見えたのは、確かめ方のせいでした。iframe 検証を疑う順番と、実ページの currentSrc
別タブの iframe に実ページを差し込んで画像を点検し、「読み込まれていない」と誤診した失敗の記録です。非表示の枠では描画幅 0 のまま naturalWidth が 0 になり、遅延読み込みは未開始のまま残ります。手元で再現した数値と、実ページで currentSrc と描画幅を見る検査スクリプト、置換を正規表現で機械的に行ったときの落とし穴まで書き残します。