仕上げは AI との協働でやっています。事実と表現は著者本人が確認しています。
CLAUDE.md や指示ファイルに「〜禁止」と書いたことがあるなら、その禁止は今この瞬間も破られる余地を抱えている。AIは指示の意図ではなく、指示の表現を読む。表現に穴があれば、AIはその穴を論理的に通り抜ける。まだ事故になっていないとしたら、それはルールが効いているからではない。単に、その穴をまだ突かれていないだけだ。
この記事で言いたいことは一つ。AIへの禁止事項は、文章で書いても守られない。 守られるかどうかは、AIがその時どう解釈するかに委ねられていて、解釈は状況が変われば変わる。文章に頼った禁止は、確率でしか機能しない。
だから、「取り消せるか」という1問で操作を仕分けることにした。取り消せる操作はAIに全部任せる。取り消せない操作だけを、文章でなくコードで、物理的に止める。この記事はその設計の話だ。
実例:禁止と書いたのに、マージが動いた
きっかけは自分の環境で実際に起きた一件だった。固有名は伏せているが、技術的な因果は本物だ。
設定ファイルにはこう書いていた。
本番ブランチへの自動マージは禁止。
マージはユーザーの明示的な指示があった場合のみ実行すること。
AIはこれを読んだ上で、以下を実行した。
gh pr merge --auto --squash
--auto フラグは「CI等のブランチ保護条件が揃うまで待機してからマージする」機能だ。AIの解釈は「即時マージではなく、チェックを通してから慎重に実行する保護的な操作」だった。この解釈は文章の表現上、論理的に成立している。
問題は、そのリポジトリの required check がlintのみだったことだ。lint通過で --auto の待機条件が即座に満たされ、人間のレビューステップを飛ばしてマージが発動した。その後のデプロイ工程が認証エラーで失敗したため本番は更新されなかったが、それがなければそのまま通っていた。
文章のルールを破ったわけではない。文章の隙間を通り抜けた。
なぜ文章のルールは剥がれるのか
AIは文章の「意図」ではなく「表現」を解釈する。
今回のケースで言えば、--auto は「慎重な実行方法」という解釈が文脈上成立した。required checkの構成がlintのみだという事実はAIに見えていなかった。「保護的に見える機能を使えば禁止に抵触しない」という経路を、AIは論理的に辿った。
これはAIが悪意を持って動いたわけでも、ルールを軽視したわけでもない。言語による制約の構造的な問題だ。散文で書いたルールは、AIがその時どう解釈するかに依存する。同じルール文を10回読ませても、状況が変われば10回同じ判断をするとは限らない。
設計:「取り消せるか」の1問で3層に切る
この一件から、AIへの委譲範囲を整理する軸を決めた。判断軸はたった1つ。
「この操作は、取り消せるか?」
| 層 | 操作の例 | 扱い |
|---|---|---|
| 一・可逆 | 調査、コード生成、PR作成 | 自由に委譲(速度を取る) |
| 二・条件付き | 確認を挟めば実行可能な操作 | 逃げ道つきガード |
| 三・不可逆 | 本番マージ、デプロイ、外部課金 | 逃げ道ゼロで物理ブロック |
本番マージは三層目だった。なのに文章のルールだけで止めようとしていた。
大事なのは「可逆な操作はAIに任せる」部分だ。調査や草稿やPR作成を毎回確認していたら速度が出ない。三層目だけを物理的に止めれば、それ以外では全力で委譲できる。文章の禁止を全操作からなくすのではなく、不可逆な操作だけをコードの管理下に移す。それがこの設計の要点だ。
実装:PreToolUseフックで物理的に止める
Claude Codeでは、ツール実行前に任意のスクリプトを挟める(PreToolUseフック)。これを使って「実行直前の検査」を実装した。
実際の運用では、org別・操作別に分けた複数のガードを1つのディスパッチャが順次評価する構成にしているが、仕組みの核はシンプルだ。以下はその核を1ファイルに簡略化した説明用の例。
# PreToolUseフックとして登録するスクリプト(説明用に簡略化した例)
import sys
import json
hook_input = json.load(sys.stdin)
cmd = hook_input.get("tool_input", {}).get("command", "")
IRREVERSIBLE_PATTERNS = [
"gh pr merge",
"wrangler pages deploy",
"wrangler deploy",
]
for pattern in IRREVERSIBLE_PATTERNS:
if pattern in cmd:
print(f"BLOCK: 不可逆な本番操作です → {pattern}", file=sys.stderr)
sys.exit(2) # exit code 2 でツール実行を物理的に中断
sys.exit(2) で終了すると、Claude CodeはBash実行をキャンセルする。ここは2でなければならない。公式仕様では、PreToolUseフックがツール実行をブロックできるのは exit code 2 のみ。exit code 1 は non-blocking error として扱われ、実行はそのまま続行される。「非ゼロなら止まるだろう」と1を返すと、ブロックしたつもりで素通りする欠陥フックになる。
プロンプトに書いたルールと違うのは、このスクリプトがAIの解釈を経由しない点だ。gh pr merge という文字列がコマンドに含まれていれば、文脈に関係なく止まる。
bypassを作らない
実装時に気をつけた点が一つある。
「緊急時はバイパスしてよい」という例外口を設けないこと。自分自身、別の場所に「緊急時はローカルで直接実行してよい」という記述を残していた。それは緊急でない場面でも使われ続け、結果的に通常の運用経路になった。
抜け道は残せば必ず使われる。 三層目の物理ブロックに「場合によっては通す」を入れた瞬間、その「場合」は際限なく広がる。だから例外口は設けなかった。本当に必要なときはユーザーが明示的にスクリプトを無効化する手順を踏む。それが面倒であることが、むしろ正しい設計だと思っている。
今日できる、最初の一歩
プロンプトに書いたルールは、AIがその時どう解釈するかに依存する確率的な制約だ。PreToolUseフックは文字列マッチで止まる決定的な制約だ。どちらが信頼できるかは、今回の一件を見れば明らかだった。
自分は gh pr merge と wrangler deploy の2パターンから始めて、その後 wrangler pages deploy と外部課金系のコマンドを追加した。フックを入れてから数ヶ月、このパターンで三層目を誤実行したことはない。実装コストは低い。パターン1つ追加するだけならスクリプト1行だ。
読み終えたら、今すぐやれることが一つある。AIに委譲している操作の中から、取り消せない操作を3つ書き出す。それだけでいい。
| 操作 | 取り消せるか |
|---|---|
| コード生成・修正 | ✅ git revert で戻せる |
| PR作成 | ✅ closeできる |
gh pr merge |
❌ コードは revert で戻せるが、マージを起点に走るデプロイ等の後続工程は取り消せない |
wrangler deploy |
❌ 本番が即上書きされる |
| 外部APIへのPOST | ❌ 取り消し不可 |
「❌」が一つでもAIの自律実行範囲に入っているなら、それがフックを書く最初の候補になる。gh pr merge --auto を使っているなら、required checkの構成も合わせて見ておくといい。
gh api repos/:owner/:repo/branches/main --jq '.protection.required_status_checks.contexts'
lintだけ、またはCIが1つだけなら、--auto フラグは実質的にほぼ即マージになる。今回の自分がそうだった。
関連
- Claude Code hooks公式ドキュメント: https://code.claude.com/docs/en/hooks (exit code 2のみがブロックになる仕様の一次情報)
- Claude Code hooks設定:
~/.claude/settings.jsonのhooksセクション