AIエージェントが問題を起こす前に停止させるためのガードレールフック
AIエージェントは実在するツールを実行しますが、時として間違ったツールを実行することがあります。ここでは、破壊的なアクションを後からではなく、実行レイヤーでキャッチする方法を説明します。
AIコーディングエージェントは、単にテキストを提案するだけではありません。ツールを実行します。シェルコマンドの実行、ファイルの書き込み、コミット、そして時にはデプロイも行います。ほとんどの場合、それは有能です。時には、あなたが決して承認しないようなことを試み、あなたがトランスクリプトを読む頃には、そのコマンドはすでに実行されてしまっています。「注意してください」というプロンプトではこれを止めることはできません。なぜなら、モデルはそれを無視できるからです。それを止めるのはフックです。エージェントのツールパス上にあるコードで、各呼び出しを検査し、実行前にそれを拒否することができます。この投稿では、これらのフックがどのように機能するのか、どこに配置するのか、そしてプロンプトだけで十分なのはどのような場合かについて説明します。
フックが対象とする失敗
デプロイする価値のあるエージェントとは、実際のシステムに触れさせることができるエージェントです。それはまた、エージェントを危険なものにする要因でもあります。モデルにシェルを与えると、抽象的には問題なくても、文脈によっては壊滅的な結果を招くコマンドを時折実行しようとします。
- 間違ったターゲットに対して実行される破壊的なコマンド。シンボリックリンクであったパスに対する
rm -rf、使い捨てではなかったデータベースに対するDROP。 - コミットされたシークレット。エージェントはすべてをステージングし、その中にはローカルに保持するつもりの
.envファイルも含まれます。 - 待つべきだったデプロイ。モデルには変更が完了したように見えたため、本番環境に直接プッシュしてしまいました。
- 早すぎる「完了」。テストがまだ失敗しているか、チェックリストの半分が未チェックの状態で、エージェントはタスクが完了したと宣言します。
これらのいずれも、悪いモデルが原因ではありません。これらは、不完全な全体像に基づいて行動する有能なモデルが、人間が決定と行動の間に介入する余地のない速度で動作することによって発生します。事後のレビューでは手遅れです。コマンドはすでに実行されてしまっています。
プロンプトがコントロールではない理由
明白な解決策は、ルールをシステムプロンプトに書き込むことです。「破壊的なコマンドは決して実行しないこと。シークレットは決してコミットしないこと。承認なしでデプロイはしないこと。」これは助けにはなりますし、そうすべきですが、コントロールではありません。それは、成功する確率が高い提案にすぎません。
プロンプトは、モデルが次に行うことに関する確率分布を形成します。ほとんどの場合、その分布は安全な経路を優先します。しかし、それは依然として確率分布であり、何千ものツール呼び出しの中でテールイベントが発生します。モデルがコンテキストを誤読し、3000トークン前の指示の重みが失われ、結果として安全でないコマンドが出力されてしまいます。確率を監査することはできません。次の呼び出しでプロンプトが有効であり続けることを証明することはできません。
フックは本質的に異なります。それはモデルへのアドバイスではなく、モデルの決定と実際の効果の間に位置するコードです。モデルがどれだけコマンドの実行を望んでも、フックが拒否を返せば、コマンドは実行されません。プロンプトは誘導します。フックは強制します。この2つは異なる役割を果たし、失敗のコストが高い場合には、後者だけが実際に信頼できるものです。
フックの存在する場所
エージェントランタイムは、ツール実行パスに独自のコードをアタッチできる少数のポイントを公開しています。その名前はフレームワークによって異なりますが、形式は一貫しています。2つのポイントが最も大きな役割を担います。
1つ目は、ツールが実行される前に発火します。ランタイムはフックにツール名とその引数を渡し、フックは決定を返します。それは、許可、変更付きで許可、または理由付きで拒否です。ここでの拒否は、ツールが決して実行されず、エージェントがフィードバックとしてその理由を受け取ることを意味します。
2つ目は、エージェントが自身のターンを終了しようとするときに発火します。ランタイムはフックに停止が許可されるかどうかを尋ねます。作業が実際に完了していない場合、フックは停止をブロックし、続行するための指示を返すことができます。
ツール実行前のフックは、おおよそ次のようになります。
def pre_tool_use(tool_name, tool_input):
if tool_name == "bash":
cmd = tool_input["command"]
if is_destructive(cmd):
return deny(
reason="This command can delete data. Use the "
"approved migration script, which takes a backup first."
)
if is_direct_deploy(cmd):
return deny(
reason="Direct deploys are blocked. Run ./scripts/deploy.sh, "
"which enforces the review gate."
)
return allow()
重要なのはパターンマッチングではなく、戻り値です。フックは警告して脇に退くわけではありません。フックが拒否すると、ランタイムはツール呼び出しを破棄します。コマンドは決して実行されなかったため、エージェントの次の観測はコマンドの出力ではなく、理由の文字列になります。
その価値が証明されている3つのフック
すべてのルールにフックが必要なわけではありません。ここで紹介する3つは、最も損害が大きく、誤検知が最も少ない失敗をカバーします。
破壊的コマンドと特権コマンドに対するツール実行前のガード。 コマンドの背後にある意図ではなく、コマンドの形式で照合します。再帰的な強制削除、データベースのdropやtruncate、保護されたブランチへの強制プッシュ、デプロイツールの直接呼び出しなどが該当します。拒否するだけでは仕事の半分しか終わっていません。返す理由には安全な代替手段を明記し、エージェントが同じことを再試行するのではなく、そちらに進めるようにすべきです。
未完了の作業に対する停止フック。 エージェントがターンを終了する前に、タスクの客観的な状態を確認します。チェックリストの項目はすべてチェック済みか?前回の実行でテストスイートはパスしたか?もしそうでなければ、停止をブロックします。
def on_stop(session):
if session.tests_last_status == "fail":
return block(reason="Tests are failing. Fix them before finishing.")
if session.checklist_has_open_items():
return block(reason="Checklist still has open items. Complete them.")
return allow_stop()
これは、早計な「完了」を防ぐためのフックです。客観的なシグナルが完了を示していない限り、エージェントは勝利を宣言できません。なぜなら、その宣言自体がフックによってインターセプトされるツール呼び出しだからです。
コミット前のシークレットスキャン。 コミットをインターセプトし、ステージングされている内容を検査します。.envファイル、秘密鍵、credentials.json、または既知のキーパターンに一致する行がセットに含まれている場合は、コミットを拒否します。
def pre_tool_use(tool_name, tool_input):
if is_commit(tool_name, tool_input):
for path in staged_files():
if looks_like_secret(path) or contains_key_material(path):
return deny(
reason=f"{path} looks like a secret. Unstage it and add "
f"it to .gitignore before committing."
)
return allow()
gitの履歴に含まれるシークレットを削除するにはコストがかかり、一度プッシュされると漏洩したものとして扱わなければなりません。このフックは安価ですが、それが防ぐ失敗の代償は安くありません。
フックの有用性を保つための設計原則
いくつかのルールによって、助けとなるガードレールと、ノイズにしかならないものとが区別されます。
フェイルクローズ (Fail closed)。 フックがアクションの安全性を判断できない場合、許可するのではなくブロックすべきです。ブロックされた安全なアクションは、1回のリトライとより明確な指示というコストを発生させます。許可された安全でないアクションは、インシデントというコストを発生させます。この非対称性こそがフックが存在する理由のすべてであるため、曖昧なケースは安全な側に倒すようにしましょう。
拒否をアクション可能にする。 「denied」(拒否) のみを返すフックは、エージェントに推測させることになり、エージェントはしばしば、同じ不適切なコマンドの亜種を推測してしまいます。理由を伝え、承認されたパスを提示するフックは、ブロックをリダイレクトに変えます。エージェントは理由を読み取り、承認されたルートをたどり、タスクを続行します。理由文字列はログ行ではなく、エージェントの次の入力です。
コストの高いアクションに集中する。 追加するすべてのルールは、メンテナンスが必要なルールであり、正当な何かをブロックする可能性を生みます。その予算は、間違いが取り返しがつかない、あるいは高コストになる箇所、すなわち本番データ、デプロイ、認証情報など、元に戻せないものに使いましょう。通常の編集や読み取りをゲートしないでください。過剰なブロックは、オペレーターにガードレールを信用しないように仕向け、エージェントのスループットを低下させます。これはエージェントを実行する意味を失わせます。
形状でマッチさせ、ある程度の偽陽性を許容する。 すべての危険なコマンドを列挙することはできません。構造的なシグナル、例えば再帰的な強制フラグ (recursive-force flag)、drop 動詞、保護されたブランチ名 (protected branch name)、シークレットのような形状のファイル名 (secret-shaped filename) などでマッチさせましょう。時には無害なものを捕捉してしまうこともあるでしょう。拒否の理由が十分に説明され、迅速に解決できる限りは、本物を見逃すという代替案を考えれば、それは正しいトレードオフです。
受け入れるトレードオフ
フックは無料ではありません。そして、無料であるかのように振る舞うと、誰も信頼しないガードレールが出来上がってしまいます。
フックはコードであるため、メンテナンスが必要です。3回に1回、正当なコマンドをブロックするような脆弱な正規表現は、苛立ったオペレーターによって無効にされ、無効化されたガードレールは何も保護しません。プロジェクトが変更されるにつれて、ルールは時代遅れになります。各フックは独自のバグを持つ小さなプログラムであり、ガードレールのバグは、それに頼っているがゆえに、ガードレールがないよりも悪いものです。
誤検知には、リトライ以上の実質的なコストが伴います。誤ったブロックはすべて、システム全体への信頼を損ないます。ある閾値を超えると、人々はガードレールを迂回し始め、フックが発火しないモードでエージェントを実行するようになります。これにより、抜け出そうとしていた無防備な状態に戻ってしまいます。ルールセットもまた問題を複雑にします。10個のフックが相互作用し、1つのフックで許可されたアクションが、別のフックが捕捉すべきだった問題の準備段階となる可能性があります。
正直なところ、これはリスクに対するコストという枠組みで考えるべきです。コストとは、メンテナンスと時折発生する誤ったブロックのことです。リスクとは、破壊的なコマンド、漏洩したシークレット、未レビューのデプロイのことです。重要度の低いサンドボックスでは、リスクは小さく、フックはオーバーヘッドになります。本番データ、ライブデプロイ、そして実際の認証情報を扱う場合、リスクが支配的となり、インシデントを引き起こしたであろうコマンドに対してフックが一度でも発火すれば、それだけで元が取れます。
プロンプトで十分な場合
すべてのルールにフックが必要なわけではなく、すべてのルールをフックとして扱うこと自体が失敗です。フックに手を伸ばすべきなのは、次の3つの条件が揃ったときです。アクションが高コストまたは不可逆であること、安全でないケースがツール呼び出しとその引数から認識できること、そしてルールが「通常」ではなく「毎回」守られる必要があることです。破壊的なコマンド、秘密のコミット、未レビューのデプロイはすべてこれに当てはまります。
単純なプロンプトの指示が適切なツールとなるのは、リスクが低い場合、まれな失敗がレビューで安価に発見できる場合、またはルールがコードで照合できる明確な境界線ではなく、スタイルや判断に関するものである場合です。「小さなコミットを推奨する」はプロンプトです。「main には決してフォースプッシュしない」はフックです。ツール呼び出しを見て許可または拒否を返す関数としてチェックを記述できない場合、それはおそらくプロンプトに属します。なぜなら、意図を推測しなければならないフックは、誤検知を繰り返した結果、無効にされてしまうからです。
この区別は、プロンプトかフックか、という対立ではありません。ガイダンスか保証か、という対立です。プロンプトを使用して、エージェントが一日中行う広範で曖昧な作業空間全体にわたって、優れたデフォルトに向かうように誘導します。フックを使用して、決して越えてはならないいくつかの厳格な境界線を引き、モデルが試みようと決定する層で単に思いとどまらせるのではなく、アクションが実際に発生する層でそれを越えることを不可能にします。