Gemini CLI の Hooks でシークレット混入を防いでみる

· Google

Gemini CLI には「Hooks」という機能があります。これは、特定のイベント(ツールの実行前後など)が発生したときに、あらかじめ設定しておいたスクリプトを自動的に実行してくれる仕組みです。

ここでは、書き込み系のツールが呼び出される直前にチェックを差し込んで、シークレットらしき文字列を含むファイルを作ろうとしたら止める、という Hook を作ってみたいと思います。

まず動かしてみる

理屈の話を並べる前に、いきなり結果から見てもらうのが早いかなと思います。

具体的には、.env ファイルに name="yoichiro" と書こうとしたときに、Gemini CLI 側で書き込みを拒否してくれる、という挙動を目指します。

> `.env`ファイルを作って、`name="yoichiro"`と記載してください。

⚠ Security scanner blocked operation
Security Policy: Potential secret detected in content.

ターミナルにこんな表示が出て、ファイルが作られない状態になります。「あれ、でも普段の Gemini CLI ってこんなふうに止まったりはしないよね?」と思った方、その通りです。これは Hooks の仕掛けで止めているからです。

ではどう仕掛けたのかを、これから順番に見ていこうと思います。

Hooks とは何か

Hooks は、Gemini CLI が動作する中で発生するイベントに対して、自分で書いたスクリプトを差し込めるようにする仕組みです。エージェントの「行動の前後」に小さなフィルタや監視を入れたい、というときに使います。

特に今回使うのは BeforeTool というイベントです。これは Gemini CLI が何かしらのツール(read_filewrite_file など)を呼び出そうとする「直前」に発火するイベントで、ここで Hook を仕込んでおくと、ツールが実際に動く前にスクリプト側で「OK」「NG」の判断ができます。

つまり、ツールの実行を 未然に止められる わけです。これがセキュリティ的に大きな意味を持ってきます。

ちなみに BeforeTool の他にもイベントはありますが、今回はシンプルに 1 つだけ使います。

block-secrets.js を書く

実体となる Hook スクリプトを .gemini/hooks/block-secrets.js に置きます。エージェント側からはこのファイルを起動して、その出力を見て「実行を許可するか/拒否するか」を判断してもらう、という流れになります。

中身はこんな感じです。

const fs = require("fs");
const path = require("path");

async function main() {
  let input = "";
  for await (const chunk of process.stdin) {
    input += chunk.toString();
  }
  try {
    const toolCall = JSON.parse(input);
    const content =
      toolCall.tool_input?.content || toolCall.tool_input?.new_string || "";
    const secretPattern = /yoichiro/i;
    if (secretPattern.test(content)) {
      console.error("Blocked potential secret");
      console.log(
        JSON.stringify({
          decision: "deny",
          reason: "Security Policy: Potential secret detected in content.",
          systemMessage: "Security scanner blocked operation",
        }),
      );
      process.exit(0);
    } else {
      console.log(JSON.stringify({ decision: "allow" }));
      process.exit(0);
    }
  } catch (e) {
    console.error(`Error processing hook input: ${e.message}`);
    console.log(JSON.stringify({
      decision: "deny",
      reason: "System Error.",
      systemMessage: e.message }));
    process.exit(0);
  }
}

main();

ポイントは 3 つかなと思います。

一つ目は、Hook スクリプトには ツール呼び出しの内容が標準入力 (stdin) で JSON として渡ってくる ということです。なので、process.stdin をぜんぶ読み込んで JSON.parse してあげる、というのが冒頭の処理です。

二つ目は、判定対象を tool_input.content または tool_input.new_string から取り出していることです。write_file ツールなら contentreplace ツールなら new_string というふうに、ツールごとにフィールドが微妙に違うので、両対応しています。今回は単純に「yoichiro という文字列が含まれていたら NG」としていますが、本当は GitHub Token や AWS Access Key を表す正規表現を並べておくのが現実的かなと思います。

三つ目は、判定結果を JSON で標準出力に書き出す ことです。decision: "deny" を返せば Gemini CLI 側がツール呼び出しを拒否してくれますし、decision: "allow" なら何事もなかったかのようにツールが実行されます。reasonsystemMessage も添えておくと、ユーザー側のターミナルに親切なメッセージが表示されます。

エラー時は deny にフォールバックしていますが、これは賛否両論あるかなと思います。

  • Hook 側がコケたせいで Gemini CLI 全体が動かなくなるのは避けたいので「実行する」に振る。
  • Hook 側がコケて「ヤバいかどうか」わからないので、保守的に「実行させない」に振る。

どちらも正解なこともあり不正解な事もあります。個人的には後者かなと思うので、上記のサンプルでは deny にしています。

settings.json で Hook を登録する

スクリプトを置いただけでは、Gemini CLI はそのファイルの存在を知りません。.gemini/settings.json に「このイベントが起きたら、このスクリプトを呼んでね」という登録をしてあげる必要があります。

{
  "hooks": {
    "BeforeTool": [
      {
        "matcher": "replace|write_file",
        "hooks": [
          {
            "name": "secret-scanner",
            "type": "command",
            "command": "node .gemini/hooks/block-secrets.js",
            "description": "Prevent committing secrets"
          }
        ]
      }
    ]
  }
}

設定の構造を整理すると、こんな感じです。

  • hooks.BeforeTool — どのイベントに対する Hook か(今回はツール実行の直前)
  • matcher — どのツール名にマッチさせるかを正規表現で書く
  • hooks — マッチしたときに実行する Hook の配列(複数登録できます)
  • command — Hook として実行するコマンド

matcher を使ってツールごとに別々の Hook を仕込めるのは、僕としてはかなり気に入っているポイントです。例えば read_file には軽量な監査ログ Hook、write_filereplace には今回みたいなシークレット検査 Hook、というふうに役割を分けて持たせられます。

この matcher のシンプルさが Hook の設計をすごく見通しよくしてくれているかなと思っています。

動作確認してみる

ここまでで .gemini/hooks/block-secrets.js.gemini/settings.json の 2 ファイルが揃ったので、Gemini CLI を起動し直します。

$ cd <プロジェクトディレクトリ>
$ gemini

起動できたら、こう依頼してみてください。

`.env`ファイルを作って、`name="yoichiro"`と記載してください。

すると、エージェントがファイル作成のために write_file ツールを呼ぼうとした瞬間、block-secrets.js が走り、yoichiro という文字列を検出して decision: "deny" を返します。Gemini CLI 側はそれを受け取って、「Security scanner blocked operation」のメッセージを出して書き込みを止めてくれます。

⚠ Security scanner blocked operation
Security Policy: Potential secret detected in content.

「ちゃんと止まった」と画面で確認できる瞬間は、なかなか達成感があるかなと思います。

ところが、何度か試しているうちに気づいたのですが、Gemini CLI は Hook で書き込みを拒否されたあとに、別の手段を使って何としても目的を達成しようとしてくることがあります。例えば、run_shell_command を使って echo 'name="yoichiro"' > .env のようなコマンドで書き込もうとしたり、内容を分割して何回かに分けて流し込もうとしたり、といった「迂回路」を試しに行きます。「あ、これは思ったより根気強いな……」と感じました。

つまり、今回作った Hook は「write_filereplace の中身を見て止める」だけなので、別ツール経由の書き込みや分割書き込みには無力です。Hooks による安全装置は完璧ではない、というのがちょっと大事なポイントかなと思います。本格的に守りたいなら、matcherrun_shell_command にも広げたり、Hook の中身を「シークレットの正規表現を網羅して厳密に検査する」よう強化したり、といった重ね掛けで防御線を引いていくのが現実的かなと思っています。

Hook を外した世界

差を実感しておくと理解が深まるので、.gemini/settings.json をいったん消すか、リネームしてしまいましょう。そして Gemini CLI を再起動します。

$ gemini

同じ依頼を投げてみます。

`.env`ファイルを作って、`name="yoichiro"`と記載してください。

今度は何の問題もなく .env ファイルが作成され、name="yoichiro" がそのまま書き込まれます。

そう、Hook がなければ Gemini CLI は素直にツールを叩くだけなので、こちらが意図しない内容でもファイルは作られてしまうわけです。今回は単なるサンプル文字列でしたが、これが本物の API キーや Access Token だったらと考えると、ちょっと背筋が伸びるかなと思います。

まとめ

今回は、Gemini CLI の Hooks を使って「書き込み系ツールにシークレット検査を挟み込む」という小さな仕組みを作ってみました。

ポイントを振り返っておきます。

  • Hooks は Gemini CLI のイベントにスクリプトを差し込む機能
  • BeforeTool を使うと、ツール実行の直前に判定を入れられる
  • スクリプトは標準入力で JSON を受け取り、標準出力に decision を返すだけ
  • settings.jsonmatcher でツールごとに役割を分けられる

シークレット検査以外にも、コミットメッセージの言語チェック、フォーマッターの自動適用、操作ログの蓄積など、Hook の応用範囲はかなり広いかなと思います。「自分のプロジェクトにとって、AI エージェントが動く前後で必ずやってほしい仕事」を切り出して Hook として固定化しておくと、エージェントとの付き合い方がぐっと安全で気持ちよくなるかなと思っています。

本エントリが、Gemini CLI を自分なりにカスタマイズしていくための参考になれば幸いです。