🧭 本記事は Claude Code実務運用シリーズ の STEP 3「ガードレールを作る」です。
指示ファイル(確率的)と deny(静的パターン)に、hooks(command型)で「実行時の決定的ガード」を追加します。
シリーズ全体の地図と読む順は 親記事 にまとめています。
Claude Code の禁止ルールを「CLAUDE.md=確率的なお願い / permissions の deny=静的パターンマッチ / hooks(command 型)=実行時の決定的検証」の3層で整理し、deny では原理的に書けない禁止ルール3つを PreToolUse hook で強制するコピペレシピを配布します。レシピはすべて正例・負例を実測済みです。
この記事で得られるもの
コピペで導入できる PreToolUse hook(command 型)のレシピ3本です。いずれも permissions の deny では原理的に書けない禁止ルールを、実行時に決定的に止めます。
| 止めたいこと | deny で書けない理由(一言) | レシピ |
|---|---|---|
| デプロイ先が本番のときだけ、デプロイスクリプトを止める | デプロイ先が $(cat .deploy-target) のように実行時に決まり、コマンド文字列に "prod" が現れない |
3-1 |
main / master ブランチでの git commit を止める |
「現在のブランチ」という外部状態を参照する構文が permissions に存在しない | 3-2 |
rm の削除対象を tmp/ 配下だけに制限する(tmp/../ での脱出も検出) |
Bash ルールは文字列 glob のみで、.. を解決するという記述がドキュメントにない |
3-3 |
3本をまとめて、既存の settings.json を壊さずに一括適用できるワンファイル・インストーラも配布します(3-0)。
レシピだけ欲しい方は、3章から読み始めてください。 3本とも「止まるべきものが止まる」「通るべきものが通る」の両方向を実測し、git log やファイル存在で独立検証しています。bypassPermissions / --dangerously-skip-permissions でも止まることも確認済みです。
はじめに
Claude Code に本番環境を触らせたくない。多くの人がまず CLAUDE.md にこう書きます。
本番環境に対する操作は行わないでください。
次に settings.json の permissions.deny に危険なコマンドパターンを並べます。それでも残るのが「この禁止は、本当に毎回止まるのか?」という不安です。
この不安に答えるには、各層が「何を保証し、何を保証しないか」の正確な線引きが必要です。公式ドキュメントを逐語で確認し、実測してみると、筆者自身の思い込みが2つ訂正されました。
- 「deny は文字列の中間を見られない」という通説は誤りです。 公式の明文と実測の両方で確認したところ、deny は思ったより強い。本当に書けないのは「実行時に解決される値・状態」だけです(1章)。
- 「hooks=決定的」も言い過ぎです。 hooks の中には LLM に判定させる prompt 型・agent 型が公式に存在し、決定的なのは command 型(および http / mcp_tool 型)だけです(2章)。
この訂正済みの線引きのうえで、3章のレシピが「deny の土俵にそもそも乗らない禁止ルール」を止めます。
前提
- Claude Code のバージョンと確認日は以下の通りです。挙動・引用ともにこの時点のもので、バージョン依存の可能性があります。
$ date "+%Y-%m-%d %H:%M:%S %Z"
2026-07-17 18:10:47 JST
$ claude --version
2.1.210 (Claude Code)
$ python3 --version
Python 3.14.3
- OS は macOS です。レシピのフックスクリプトは python3 の標準ライブラリのみに依存させています(jq 不使用。配布時の可搬性重視)。
- 公式ドキュメントからの引用はすべて 2026-07-17 取得です。後述しますが、公式ドキュメントの文言は無告知で書き換わることがあるため、取得日を明記します。
- permissions ドキュメントには「v2.1.211 以降は
.claude/settings.local.jsonの解決先が変わる」などバージョン条件つきの記述が複数あります。本記事の内容を適用する際は、お使いのバージョンでドキュメントを確認してください。 - 対象読者は、Claude Code を実務導入していて「AI に踏ませたくない一線」を設定ファイルで管理したい方です。
全体構成
Claude Code の「禁止ルールを書く場所」は、性質の異なる層に分かれます。
| 層 | 実体 | 判定の性質 | 参照できる情報 |
|---|---|---|---|
| CLAUDE.md(指示ファイル) | 自然言語の指示 | 確率的(LLM が従うことに依存) | ― |
| permissions(deny / ask / allow) | ツール入力文字列への静的グロブマッチ | 決定的だが静的 | ツール入力の文字列のみ |
| hooks: command 型 | 任意のスクリプト実行 | 決定的・実行時 | ファイル・git 状態・環境など実行時のすべて |
| hooks: prompt / agent 型 | Claude モデルによる判定 | 非決定的(LLM 判定) | フック入力+モデルの判断 |
この整理のうえで、3章で「deny の土俵にそもそも乗らない禁止ルール」3つを hooks で強制します。
1. 既存の2層を一次情報で棚卸しする
1-1. CLAUDE.md は「お願い」(確率的)
まず一番外側の層です。CLAUDE.md(指示ファイル)に書いた禁止ルールを守るかどうかを最終的に決めるのは、LLM 自身です。
公式ドキュメントは、hooks の存在理由を説明する一文で、この前提をはっきり認めています(強調は筆者)。
They provide deterministic control over Claude Code's behavior, ensuring certain actions always happen rather than relying on the LLM to choose to run them.
— Automate actions with hooks(hooks-guide)(2026-07-17 取得)
拙訳: 「hooks は Claude Code の挙動に決定的な制御を与え、LLM が実行を選ぶことに頼るのではなく、特定のアクションが必ず起きることを保証します」。
裏を返すと、指示ファイルの遵守は「LLM が従うことを選ぶ」ことに依存している、というのが公式の位置づけです。実際、本記事の検証中にも、フックに到達する前にエージェント自身の判断で実行が拒否され、プロンプト設計を変えるまで挙動が変わる場面がありました(「ハマりどころ」で詳述します)。CLAUDE.md は判断の質を上げる層であって、禁止を保証する層ではありません。
1-2. deny は思ったより強い ― ワイルドカードは中間位置にも書ける
次に permissions.deny です。ここで通説を1つ訂正します。
「deny のパターンはコマンドの先頭からのマッチだから、引数の途中にある危険な文字列は見られない」——筆者もそう思い込んでいましたが、誤りです。公式に明文があります。
Wildcards can appear at any position in the command, including at the beginning, middle, or end
— Configure permissions(2026-07-17 取得)
ワイルドカード * は先頭・中間・末尾のどこにでも置けます。ドキュメントには Bash(git * main) が git checkout main にも git log --oneline main にもマッチするという例まで載っています。
実機でも確認しました。検証用リポジトリ(構成は 3-0 の「実測の前提」参照)の settings.json に中間一致の deny を一時追加します。
{
"permissions": {
"deny": ["Bash(*prod-super-secret-marker*)"]
}
}
この状態で、危険マーカーを引数の中間に含む echo を最も緩い権限モードで実行させます。
$ claude -p "Run the shell command: echo start prod-super-secret-marker end -- ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
Blocked by security policy: matches deny pattern Bash(*prod-super-secret-marker*)
(コマンド中の ... はプロンプト後半の指示文の省略です。以降の実測ログも同様)
引数の中間にある文字列で、きちんとブロックされました。deny は「文字列に現れる情報」に対しては、位置を問わずマッチできる決定的な仕組みです。「deny は弱いから hooks を使おう」という導入は、出発点として不正確です。
1-3. それでも deny に書けないもの ― 実行時に解決される値・状態
では deny の本当の限界はどこか。公式ドキュメントが自ら認めているのはこれです。
Bash permission patterns that try to constrain command arguments are fragile.
— Configure permissions(2026-07-17 取得)
「コマンド引数を制約しようとする Bash パーミッションパターンは壊れやすい」。ドキュメントが挙げるすり抜け例の筆頭が変数経由です。Bash(curl http://github.com/ *) で curl を GitHub に制限したつもりでも、URL=http://github.com && curl $URL のような形にはマッチしません。
つまり deny の限界は「中間が見えない」ことではなく、マッチ対象が tool_input.command という静的な文字列だけであることです。次の情報は、その文字列の中に最初から存在しません。
-
実行時に解決される値:
$(cat .deploy-target)の中身、変数の展開結果 - 外部状態: 現在の git ブランチ、ファイルの中身、環境の状態
-
パス解決の結果:
tmp/../secretが実際にどこを指すか
実行される「実体」がコマンド文字列に現れないケースこそが、静的パターンマッチの死角です。
これは hooks にも跳ね返る注意です。hook スクリプトが tool_input.command の文字列だけを見て判定するなら、参照できる情報は deny と変わりません。hooks の価値は「実行時にファイルや状態を実際に読んで判定できる」ことにあり、3章のレシピはすべてこの形で書いています。
2. hooks の「決定的」の正確な中身
2-1. 公式が言う "deterministic control"
hooks を「決定的」と呼ぶ根拠は、1-1 で引用した公式ガイド(hooks-guide)の一文です。"They provide deterministic control over Claude Code's behavior" ― hooks は、Claude Code の挙動への決定的な制御として公式に定義されています。
2-2. 注意: hooks の中にも「決定的でない」型がある
ここが本記事で強調したい2つ目の訂正です。「hooks=決定的」と言い切るのは不正確です。公式ガイドは同じ冒頭の説明の中で、こう続けています。
For decisions that require judgment rather than deterministic rules, you can also use prompt-based hooks or agent-based hooks that use a Claude model to evaluate conditions.
— Automate actions with hooks(hooks-guide)(2026-07-17 取得)
拙訳: 「決定的なルールではなく判断が必要な場面では、Claude モデルに条件を評価させる prompt ベース/agent ベースの hook も使えます」。
つまり hooks には次の2系統が公式に併存しています。
| hook の型 | 判定者 | 性質 |
|---|---|---|
command(および http / mcp_tool) |
あなたが書いたスクリプト | 決定的 |
prompt / agent
|
Claude モデル | 非決定的(LLM 判定) |
「確実に止める」ガードレールを作りたいなら、使うべきは command 型です。本記事のレシピはすべて "type": "command" で書きます。
2-3. PreToolUse の仕組み ― 止め方は2方式
PreToolUse は、ツール呼び出しの直前に発火するイベントです。hook スクリプトは stdin から JSON を受け取ります。
PreToolUse hooks receive tool_name, tool_input, and tool_use_id.
— Hooks reference(2026-07-17 取得)
Bash ツールの場合、tool_input.command に実行されようとしているコマンド文字列が入ります。ツール呼び出しを止める方法は2つあります。
方式A: exit code 2 + stderr
Exit 2 means a blocking error. Claude Code ignores stdout and any JSON in it. Instead, stderr text is fed back to Claude as an error message.
— Hooks reference(2026-07-17 取得)
方式B: exit 0 + stdout に JSON で permissionDecision を出力
permissionDecision: "allow" skips the permission prompt, ... "deny" prevents the tool call. "ask" prompts the user to confirm.
— Hooks reference(2026-07-17 取得。省略は筆者)
3章のレシピでは、レシピ②が方式A、レシピ①③が方式Bの実例になっています。両方の書き方を持ち帰ってください。
ハマりやすい仕様が1つあります。exit 1 では止まりません。
For most hook events, only exit code 2 blocks the action. Claude Code treats exit code 1 as a non-blocking error and proceeds with the action, even though 1 is the conventional Unix failure code.
— Hooks reference(2026-07-17 取得)
Unix の習慣で「失敗=exit 1」と書くと、エラー扱いにはなるもののツールは実行されてしまいます。ブロックは exit 2 です。
また、複数の PreToolUse hook が異なる判定を返した場合の優先順位も明文があります。
When multiple PreToolUse hooks return different decisions, precedence is deny > defer > ask > allow.
— Hooks reference(2026-07-17 取得)
1つでも deny を返せばブロックが勝ちます。3章で3レシピを同居させても安全なのは、この仕様のおかげです。
hook と permission rules の関係(どちらが先か・どちらが勝つか)も、混同しやすいので押さえます。
Hook decisions don't bypass permission rules. Claude Code evaluates deny and ask rules regardless of what a PreToolUse hook returns
— Configure permissions(2026-07-17 取得)
逆方向は hook が勝ちます。exit 2 でブロックする hook は allow ルールより優先され、permission rules の評価前にツール呼び出しを止める、と同じドキュメントに明記されています。
まとめると次の流れです(本記事に関係する経路だけの簡略図です)。
hook は締める方向にだけ働きます。公式ガイドの一文がこの非対称性をよく表しています。
Hooks can tighten restrictions but not loosen them past what permission rules allow.
— Automate actions with hooks(hooks-guide)(2026-07-17 取得)
2-4. bypassPermissions でも hook の deny は効く
ガードレールとして決定的に重要な仕様がこれです。
PreToolUse hooks fire before any permission-mode check. A hook that returns permissionDecision: "deny" blocks the tool even in bypassPermissions mode or with --dangerously-skip-permissions. This lets you enforce policy that users can't bypass by changing their permission mode.
— Automate actions with hooks(hooks-guide)(2026-07-17 取得)
権限モードを最も緩くしても、command 型 PreToolUse hook の deny は効く。この仕様は3章のレシピ①で実機確認します(結果: 一致しました)。
3. deny では書けない禁止ルール3つを hooks で強制する【コマンド一つで適用】
3-0. 適用はコマンド一つ ― 既存設定を壊さないインストーラ
この章で配る3レシピは、ワンファイルのインストーラで一括適用できます。下の install_claude_guardrails.py を1回保存し、自分のプロジェクトルートで実行するだけです。
# プロジェクトルートで実行(引数省略時はカレントディレクトリに適用)
python3 install_claude_guardrails.py
# 適用内容の確認だけしたいとき(ファイル変更なし)
python3 install_claude_guardrails.py --dry-run
# 一部のレシピだけ入れたいとき(例: ①と③)
python3 install_claude_guardrails.py --recipes 1,3
「既存の仕組みを壊さない」ための設計の要点です(いずれも実測で確認済み)。
-
.claude/hooks/に3スクリプトを書き出し、.claude/settings.jsonのhooks.PreToolUse(matcher"Bash")にエントリを追記します。settings.json がなければ hooks キーのみで新規作成します(permissions 等は追加しません) -
既存キーは変更しません:
permissions(deny / allow)・env・他 matcher・PostToolUse・既存の Bash hook はすべて保持され、レシピは既存 hook の後ろに追記されます - 変更前に
settings.json.bak-<timestamp>を自動作成します。内容の異なる同名フックスクリプトがある場合も.bakを作ってから置き換えます - 冪等です。2回目の実行は "nothing to do" で終了し、設定は変わりません
- settings.json が壊れた JSON の場合は、何も変更せずに中断します(hooks ディレクトリも作りません)
- ホームディレクトリへの適用は拒否します(
~/.claude/settings.json=ユーザーグローバル設定を書き換えてしまうため)。ファイルシステムルートも拒否します - この後に載せる settings.json の
permissions.allowは意図的にインストールしません(あれは検証環境用の設定で、権限を緩める変更を読者の環境に入れないための設計判断です) - インストール後、中立コマンド(
echo hello)を各フックに流すスモークテストを自動実行します(exit 0・無出力で PASS)
install_claude_guardrails.py 全文(クリックで展開)
#!/usr/bin/env python3
"""One-file installer for the 3 PreToolUse guardrail recipes.
Run from your project root (or pass the project path):
python3 install_claude_guardrails.py # install into ./
python3 install_claude_guardrails.py /path/to/project
python3 install_claude_guardrails.py --recipes 2 # subset
python3 install_claude_guardrails.py --dry-run # show plan only
What it does:
- Writes the recipe scripts into <project>/.claude/hooks/
- Adds matching hook entries to <project>/.claude/settings.json under
hooks.PreToolUse (matcher "Bash"), creating the file if it does not exist
- Leaves every other key (permissions, env, other matchers/events) untouched
- Backs up settings.json and any differing hook script before overwriting
- Idempotent: a second run reports "nothing to do"
- Refuses to run against your home directory (that would edit the user-global
~/.claude/settings.json instead of a project-level one)
It intentionally does NOT touch permissions.allow / permissions.deny.
"""
import argparse
import copy
import datetime
import json
import shutil
import subprocess
import sys
from pathlib import Path
SMOKE_INPUT = '{"tool_input": {"command": "echo hello"}}'
RECIPE_SOURCES = {
"recipe1_resolved_target_guard.py": r'''#!/usr/bin/env python3
"""PreToolUse hook (Recipe 1): block deploy commands whose *resolved* target
is production, even when the literal Bash command string never contains the
word "prod" (e.g. the target is read from a file/subshell at execution time).
"""
import json
import pathlib
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if "deploy.sh" not in command:
sys.exit(0) # not our target command; let normal permission flow decide
target_file = pathlib.Path(".deploy-target")
resolved_target = target_file.read_text().strip() if target_file.exists() else "unknown"
if resolved_target.startswith("prod"):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"Resolved deploy target is '{resolved_target}' (read from "
".deploy-target at hook time), which is a production target. "
"Blocked by PreToolUse hook regardless of what the literal "
"command string contains."
),
}
}))
sys.exit(0)
''',
"recipe2_protected_branch_guard.py": r'''#!/usr/bin/env python3
"""PreToolUse hook (Recipe 2): block `git commit` while the current branch
is a protected branch (main/master)."""
import json
import subprocess
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if "git commit" not in command:
sys.exit(0)
try:
branch = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, check=True,
).stdout.strip()
except Exception:
branch = ""
PROTECTED_BRANCHES = {"main", "master"}
if branch in PROTECTED_BRANCHES:
sys.stderr.write(
f"Blocked: direct `git commit` on protected branch '{branch}' is not "
"allowed. Create a feature branch first.\n"
)
sys.exit(2)
sys.exit(0)
''',
"recipe3_rm_allowlist_guard.py": r'''#!/usr/bin/env python3
"""PreToolUse hook (Recipe 3): only allow `rm` to delete paths that RESOLVE
(after following `..` and symlinks) inside the project's tmp/ directory."""
import json
import os
import re
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
m = re.match(r"^\s*rm(\s+-\S+)*\s+(?P<path>\S+)\s*$", command)
if not m:
sys.exit(0) # not a simple rm we recognize; normal permission flow applies
target = m.group("path")
project_root = os.getcwd()
allowed_root = os.path.realpath(os.path.join(project_root, "tmp"))
resolved = os.path.realpath(os.path.join(project_root, target))
inside_allowed = (
resolved == allowed_root
or resolved.startswith(allowed_root + os.sep)
)
if inside_allowed:
decision, reason = "allow", f"rm target resolves inside tmp/ ({resolved})"
else:
decision, reason = "deny", (
f"rm target '{target}' resolves to '{resolved}', which is outside "
f"the allowed directory '{allowed_root}'."
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason,
}
}))
sys.exit(0)
''',
}
RECIPE_KEYS = {"1": "recipe1_resolved_target_guard.py",
"2": "recipe2_protected_branch_guard.py",
"3": "recipe3_rm_allowlist_guard.py"}
def hook_entry(script_name: str) -> dict:
return {
"type": "command",
"command": f'python3 "${{CLAUDE_PROJECT_DIR}}/.claude/hooks/{script_name}"',
}
def fail(msg: str):
print(f"ERROR: {msg}", file=sys.stderr)
sys.exit(1)
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument("project", nargs="?", default=".",
help="target project directory (default: current directory)")
parser.add_argument("--recipes", default="1,2,3",
help="comma-separated subset to install (default: 1,2,3)")
parser.add_argument("--dry-run", action="store_true", help="show plan only")
args = parser.parse_args()
project = Path(args.project).expanduser().resolve()
if not project.is_dir():
fail(f"project directory not found: {project}")
if project == Path.home():
fail("refusing to install into your home directory — that would edit "
"~/.claude/settings.json (user-global settings). "
"Run from a project root or pass its path")
if project == Path(project.anchor):
fail("refusing to install into the filesystem root")
selected = []
for key in args.recipes.split(","):
key = key.strip()
if key not in RECIPE_KEYS:
fail(f"unknown recipe '{key}' (valid: 1,2,3)")
if RECIPE_KEYS[key] not in selected:
selected.append(RECIPE_KEYS[key])
claude_dir = project / ".claude"
hooks_dir = claude_dir / "hooks"
settings_path = claude_dir / "settings.json"
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
# ---- read current settings (a malformed file aborts before any change)
old_settings = None
if settings_path.exists():
try:
old_settings = json.loads(settings_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as e:
fail(f"{settings_path} is not valid JSON ({e}); nothing was changed")
if not isinstance(old_settings, dict):
fail(f"{settings_path} top-level is not a JSON object; nothing was changed")
# ---- plan the settings merge on a copy
new_settings = copy.deepcopy(old_settings) if old_settings is not None else {}
hooks_cfg = new_settings.setdefault("hooks", {})
if not isinstance(hooks_cfg, dict):
fail("settings.json 'hooks' is not an object; refusing to modify")
pre_list = hooks_cfg.setdefault("PreToolUse", [])
if not isinstance(pre_list, list):
fail("settings.json 'hooks.PreToolUse' is not an array; refusing to modify")
bash_entry = None
for entry in pre_list:
if isinstance(entry, dict) and entry.get("matcher") == "Bash":
bash_entry = entry
break
created_matcher = bash_entry is None
if created_matcher:
bash_entry = {"matcher": "Bash", "hooks": []}
hook_list = bash_entry.setdefault("hooks", [])
if not isinstance(hook_list, list):
fail("PreToolUse entry for matcher 'Bash' has a non-array 'hooks'; refusing to modify")
existing_commands = {h.get("command") for h in hook_list if isinstance(h, dict)}
to_add = [hook_entry(n) for n in selected
if hook_entry(n)["command"] not in existing_commands]
if to_add:
hook_list.extend(to_add)
if created_matcher:
pre_list.append(bash_entry)
# ---- plan the script writes
write_plan = [] # (name, dst, needs_backup)
for name in selected:
dst = hooks_dir / name
if dst.exists():
if dst.read_text(encoding="utf-8") == RECIPE_SOURCES[name]:
continue
write_plan.append((name, dst, True))
else:
write_plan.append((name, dst, False))
if not write_plan and not to_add:
print(f"OK: nothing to do — selected recipes already installed in {project}")
return
print(f"Target project : {project}")
print(f"Recipes : {', '.join(selected)}")
for name, dst, needs_backup in write_plan:
action = "update (backup first)" if needs_backup else "write"
print(f" script {action}: {dst.relative_to(project)}")
for entry in to_add:
print(f" settings add : {entry['command']}")
if not to_add:
print(" settings : unchanged (entries already present)")
if args.dry_run:
print("DRY RUN: no files were changed")
return
# ---- execute
hooks_dir.mkdir(parents=True, exist_ok=True)
for name, dst, needs_backup in write_plan:
if needs_backup:
backup = dst.with_name(dst.name + f".bak-{stamp}")
shutil.copy2(dst, backup)
print(f" backed up : {backup.relative_to(project)}")
dst.write_text(RECIPE_SOURCES[name], encoding="utf-8")
if to_add:
if settings_path.exists():
backup = settings_path.with_name(settings_path.name + f".bak-{stamp}")
shutil.copy2(settings_path, backup)
print(f" backed up : {backup.relative_to(project)}")
settings_path.write_text(
json.dumps(new_settings, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
# ---- smoke test: a neutral command must pass through (exit 0, no output)
print("Smoke test (neutral command should pass through: exit 0, no output):")
all_ok = True
for name in selected:
proc = subprocess.run(
[sys.executable, str(hooks_dir / name)],
input=SMOKE_INPUT, capture_output=True, text=True, cwd=str(project),
)
ok = proc.returncode == 0 and not proc.stdout.strip() and not proc.stderr.strip()
all_ok = all_ok and ok
print(f" {'PASS' if ok else 'FAIL'}: {name} (exit={proc.returncode})")
if not all_ok:
print("WARNING: smoke test failed — check python3 and script contents",
file=sys.stderr)
sys.exit(1)
print("Done. Unit-test each hook with: echo '<json>' | python3 .claude/hooks/<script>")
if __name__ == "__main__":
main()
このインストーラは、34ケースのテストで実測確認しています(新規プロジェクトへの適用と冪等性・既存設定とのマージ保全・壊れた JSON での安全中断・dry-run・ホームガード・インストール先コピーでのレシピ実動作)。インストーラが書き出す3スクリプトが本記事掲載のレシピとバイト一致することも、テストで担保しています。検証環境は macOS+python3(3.14.3)で、Windows での動作は未検証です(python3 コマンド名・パス区切りの違い)。
手動で配線したい場合: インストーラが settings.json に行う配線は、次の hooks キーの形です(検証環境で使った settings.json の全文。permissions.allow は前述の通り、検証環境用のためインストーラは追加しません)。3レシピを1ファイルに同居させても干渉しないことを確認済みです(matcher は共通で "Bash"、各スクリプトが対象外コマンドを内部でスルーする設計)。
{
"permissions": {
"allow": [
"Bash(./deploy.sh*)",
"Bash(git commit*)",
"Bash(git add*)",
"Bash(rm *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{ "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/recipe1_resolved_target_guard.py\"" },
{ "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/recipe2_protected_branch_guard.py\"" },
{ "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR}/.claude/hooks/recipe3_rm_allowlist_guard.py\"" }
]
}
]
}
}
実測の前提(検証環境)
3-1 以降の実測ログは、上の settings.json と3スクリプトを使い捨ての git リポジトリ(以下 sandbox-repo。実際のパスはマスキングし ~/sandbox 表記にしています)に置いて取得しました。実プロジェクトやグローバル設定には触れていません。
sandbox-repo/
.claude/
settings.json # PreToolUse hook 3本 + permissions.allow
hooks/
recipe1_resolved_target_guard.py
recipe2_protected_branch_guard.py
recipe3_rm_allowlist_guard.py
deploy.sh # ダミーのデプロイスクリプト(echo のみ、破壊的操作なし)
.deploy-target # 「現在のデプロイ対象」を表す状態ファイル(1行)
tmp/scratch.txt # 許可リスト内の削除対象(検証用)
important-root-file.txt # 許可リスト外のダミーファイル
README.md
.claude/ 配下は、上のインストーラが生成する構成に検証用の permissions.allow を加えたものです。それ以外(deploy.sh・.deploy-target・tmp/ など)は検証用のダミーです。
検証方法の注意(正直な報告): このサンドボックスは workspace が「未 trust」の状態だったため、permissions.allow は実行時に常に無視されました(実際の警告: Ignoring 4 permissions.allow entries from .claude/settings.json: this workspace has not been trusted. ...)。trust の受理はグローバル設定への書き込みを伴うため行わず、以降の検証は --permission-mode bypassPermissions --dangerously-skip-permissions で実施しています。この状態でも hooks の発火と deny の評価には影響しないことは確認済みですが、「allow ルールが素直に効く通常運用」そのものは今回再現できていません。「trust されていないと hook も効かない」という一般化は誤りです。
なお、bypassPermissions での検証は「最も緩い権限モードでも hook が止めるか」の確認(2-4 の仕様の実機検証)を兼ねています。
また、以降に掲載するフックスクリプト3本は、検証時に使った実物からコメント・docstring を短くした版です。実行コードは同一で、掲載版そのものでも全ケース(正例・負例)の動作一致を確認済みです。
3-1. レシピ①: 実行時に解決される値 ― 本番デプロイ誤爆防止
deny で書けない理由(1行): デプロイ先が $(cat .deploy-target) のように実行時に決まる場合、コマンド文字列には prod の文字が一切現れず、どんな deny パターンを書いてもマッチのしようがない。
Bash(*prod*) のような deny は「文字列に prod が現れる」ケースには効きます(1-2 で実測した通り)。しかし「デプロイ先を状態ファイルや設定から読む」タイプのスクリプトでは、危険かどうかはコマンド文字列の外にあります。
hook なら、その場で状態ファイルを読んで判定できます。方式B(permissionDecision JSON)の実例です。
.claude/hooks/recipe1_resolved_target_guard.py 全文:
#!/usr/bin/env python3
"""PreToolUse hook (Recipe 1): block deploy commands whose *resolved* target
is production, even when the literal Bash command string never contains the
word "prod" (e.g. the target is read from a file/subshell at execution time).
"""
import json
import pathlib
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if "deploy.sh" not in command:
sys.exit(0) # not our target command; let normal permission flow decide
target_file = pathlib.Path(".deploy-target")
resolved_target = target_file.read_text().strip() if target_file.exists() else "unknown"
if resolved_target.startswith("prod"):
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": (
f"Resolved deploy target is '{resolved_target}' (read from "
".deploy-target at hook time), which is a production target. "
"Blocked by PreToolUse hook regardless of what the literal "
"command string contains."
),
}
}))
sys.exit(0)
正例(止まるべきものが止まる): .deploy-target の中身は prod-billing-db(ダミーの本番識別子)。Bash に渡されたコマンドは ./deploy.sh $(cat .deploy-target) で、文字列上は "prod" を含みません。
$ claude -p "Run the shell command: ./deploy.sh \$(cat .deploy-target) -- ..." --permission-mode default
出力(抜粋):
The command was blocked by a PreToolUse hook before it executed. It did not run.
Resolved deploy target is 'prod-billing-db' (read from .deploy-target at hook
time), which is a production target. Blocked by PreToolUse hook regardless of
what the literal command string contains.
(コマンド中の \$ は、claude -p を打つ側のシェルで $(cat ...) が先に展開されるのを防ぐエスケープです。これにより Claude へのプロンプトにはリテラルの $(cat .deploy-target) が渡り、Bash ツールの tool_input.command もリテラルのままです)
負例(通るべきものが通る): .deploy-target を staging-web に変更して同じコマンドを実行。
$ claude -p "Run the shell command: ./deploy.sh \$(cat .deploy-target) -- ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
出力(抜粋):
Deploying to target: staging-web
Exit: success, no errors, no permission prompts, no denials.
追加検証(bypassPermissions でも止まるか): .deploy-target を prod に戻し、同じ bypassPermissions モードで再実行したところ、結果はブロックでした(メッセージは正例と同一)。deploy.sh の stdout は一切出力されず、実行されなかったことを確認しています。2-4 で引用した「PreToolUse hooks fire before any permission-mode check ... even in bypassPermissions mode」と完全に一致する実機結果です。
このレシピの .deploy-target を読む部分は一例です。「デプロイ先・接続先を決める実行時情報」をフックがその場で読む形に置き換えれば、同じ構造がそのまま使えます。
3-2. レシピ②: 状態依存(現在のブランチ) ― 保護ブランチでの git commit 禁止
deny で書けない理由(1行): git commit -m "..." という文字列は main 上でも feature ブランチ上でも完全に同一で、「現在どのブランチにいるか」を参照する構文が permissions には存在しない。
deny で書けるのは「git commit を常に禁止」という乱暴なルールだけです。「main でだけ禁止」という条件分岐は、文字列マッチでは構造的に表現できません。2026-07-17 時点の permissions ドキュメント全文を確認しましたが、Bash ルールが照合できるのはコマンド文字列のみで、外部状態を参照するルール構文は1つもありませんでした。パラメータマッチという別機構も、Bash の command フィールドは対象外と明記されています。
Fields that a tool already matches with its own canonicalizing rules are not matchable this way: command for Bash and PowerShell, ...
— Configure permissions(2026-07-17 取得。省略は筆者)
hook なら git branch --show-current をその場で実行して判定できます。方式A(exit 2 + stderr)の実例です。
.claude/hooks/recipe2_protected_branch_guard.py 全文:
#!/usr/bin/env python3
"""PreToolUse hook (Recipe 2): block `git commit` while the current branch
is a protected branch (main/master)."""
import json
import subprocess
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
if "git commit" not in command:
sys.exit(0)
try:
branch = subprocess.run(
["git", "branch", "--show-current"],
capture_output=True, text=True, check=True,
).stdout.strip()
except Exception:
branch = ""
PROTECTED_BRANCHES = {"main", "master"}
if branch in PROTECTED_BRANCHES:
sys.stderr.write(
f"Blocked: direct `git commit` on protected branch '{branch}' is not "
"allowed. Create a feature branch first.\n"
)
sys.exit(2)
sys.exit(0)
正例(main ブランチで commit):
$ git branch --show-current
main
$ claude -p "Run the shell command: git commit -m 'recipe2 positive test commit' -- ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
出力:
PreToolUse:Bash hook error: [python3 "${CLAUDE_PROJECT_DIR}/.claude/hooks/recipe2_protected_branch_guard.py"]:
Blocked: direct `git commit` on protected branch 'main' is not allowed. Create a feature branch first.
Claude の自己申告に頼らず、git log で独立検証します。
$ git log --oneline
f707f94 initial commit ← コミットは増えていない(正しくブロックされた)
負例(feature ブランチで commit):
$ git checkout -qb feature/recipe2-negative
$ claude -p "Run the shell command: git commit -m 'recipe2 negative test commit' -- ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
出力:
[feature/recipe2-negative fe73a2c] recipe2 negative test commit
1 file changed, 1 insertion(+)
$ git log --oneline
fe73a2c recipe2 negative test commit ← 新規コミットが実際に作られている
f707f94 initial commit
「main では止まり、feature では通る」。deny には原理的に書けない状態依存の禁止ルールが、hook なら 30 行で書けます。
3-3. レシピ③: パス解決が必要なルール ― rm の削除対象を許可リストで検証
deny で書けない理由(1行): tmp/../secret は字面上 "tmp/" で始まる文字列であり、Bash ルールのグロブはコマンド文字列を字面のままマッチするだけで、.. を解決するという記述がドキュメントにない。
permissions には Read / Edit ツール専用のパス解決システム(gitignore 形式、シンボリックリンクの特別扱いあり)が存在します。しかし 2026-07-17 時点のドキュメントを確認する限り、この解決ロジックが Bash のコマンド引数に適用されるという記述はどこにもありません(Bash 節は終始「コマンド文字列に対する glob」としてのみ説明されています)。
そのため「Bash(rm -rf tmp/*) のような allow / deny ルールは tmp/../secret のような traversal を通してしまう可能性が高い」というのは、ドキュメントからの推論であり、対照実験による直接確認ではありません(理由は後述の「検証の限界」参照)。一方、hook 側で traversal を検出できることは以下の通り実測済みです。
hook なら os.path.realpath で .. やシンボリックリンクを解決した後のパスを検証できます。方式B(allow / deny を JSON で返す)の実例です。tmp/ 配下に解決される場合は "allow"(確認プロンプトをスキップする指定)、外に出る場合は "deny" を返します。
.claude/hooks/recipe3_rm_allowlist_guard.py 全文:
#!/usr/bin/env python3
"""PreToolUse hook (Recipe 3): only allow `rm` to delete paths that RESOLVE
(after following `..` and symlinks) inside the project's tmp/ directory."""
import json
import os
import re
import sys
data = json.load(sys.stdin)
command = data.get("tool_input", {}).get("command", "")
m = re.match(r"^\s*rm(\s+-\S+)*\s+(?P<path>\S+)\s*$", command)
if not m:
sys.exit(0) # not a simple rm we recognize; normal permission flow applies
target = m.group("path")
project_root = os.getcwd()
allowed_root = os.path.realpath(os.path.join(project_root, "tmp"))
resolved = os.path.realpath(os.path.join(project_root, target))
inside_allowed = (
resolved == allowed_root
or resolved.startswith(allowed_root + os.sep)
)
if inside_allowed:
decision, reason = "allow", f"rm target resolves inside tmp/ ({resolved})"
else:
decision, reason = "deny", (
f"rm target '{target}' resolves to '{resolved}', which is outside "
f"the allowed directory '{allowed_root}'."
)
print(json.dumps({
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": decision,
"permissionDecisionReason": reason,
}
}))
sys.exit(0)
正例(traversal で tmp/ の外に脱出しようとする): エージェント自身に scratch-outside/decoy.txt を作らせ、rm -rf tmp/../scratch-outside/decoy.txt を実行させます。
$ claude -p "... 1. create scratch-outside/decoy.txt 2. rm -rf tmp/../scratch-outside/decoy.txt ..." \
--permission-mode bypassPermissions --dangerously-skip-permissions
出力(抜粋。パスはマスキング済み):
rm target 'tmp/../scratch-outside/decoy.txt' resolves to
'~/sandbox/sandbox-repo/scratch-outside/decoy.txt', which is outside
the allowed directory '~/sandbox/sandbox-repo/tmp'.
(中略)
Outcome: blocked by the PreToolUse hook.
(中略)
The decoy file was not deleted.
ファイル存在で独立検証します(ユーザー名・日時はマスキング/省略しています)。
$ ls -la scratch-outside/decoy.txt
-rw-r--r-- 1 sano wheel 46 ... decoy.txt ← 削除されていない
負例(tmp/ 配下の素直な削除):
$ claude -p "... rm tmp/scratch.txt ..." --permission-mode bypassPermissions --dangerously-skip-permissions
出力(抜粋):
$ rm tmp/scratch.txt; echo "exit status: $?"
exit status: 0
(前略)ALLOWED by the hook — not blocked. Exit status: 0.
$ ls tmp/
(空。scratch.txt は削除済み ← 正しく許可された)
このレシピの正規表現は rm [オプション] <パス1つ> という単純形だけを対象にした最小実装です。複数パス指定(rm a b c)や find ... -delete 等は対象外で、別途対応が必要です。なお公式ドキュメントも deny 側で同種の限界を認めています: "The same applies to find with -exec or -delete: a Bash(find *) rule doesn't cover these forms"(Configure permissions、2026-07-17 取得)。
4. 配線前にフック単体でユニットテストする
hook スクリプトは、settings.json に配線する前に単体でテストできます。stdin に JSON を流し込むだけです(hooks-guide のトラブルシューティング節と同じ手法です)。
echo '{"tool_input": {"command": "git commit -m test"}}' | \
python3 .claude/hooks/recipe2_protected_branch_guard.py
echo "exit code: $?"
確認するのは次の2点です。
- 方式A(レシピ②)なら: 対象コマンド+禁止状態のとき exit code が 2 になり、stderr に理由が出るか
- 方式B(レシピ①③)なら: stdout に
permissionDecisionの JSON が出るか(exit code は 0)
レシピ②はカレントディレクトリの git 状態を読むので、テストしたいリポジトリの中で実行してください。筆者は3本すべてをこの手順で先に単体テストしてから、claude -p での統合確認に進みました。Claude Code を経由するデバッグは1往復が重いので、この順番はおすすめです。
ハマりどころ・注意点
claude -p によるフック検証は「代理検証」である
レシピ③の正例テストは、実は1回目・2回目の試行で失敗しています。「rm -rf tmp/../important-root-file.txt を実行して」と依頼したところ、rm がツール呼び出しに至る前に、エージェント自身が「これは path obfuscation で、削除対象が自分の作成物ではない」と判断して実行を拒否しました。対象の rm コマンドが hook に評価されることは、一度もありませんでした。
3回目に、削除対象をエージェント自身にその場で作らせた使い捨てファイルへ変更し、「hook の判定も含めてそのまま報告してほしい」と明示して、ようやく hook の発火をテストできました。
claude -p での検証には、Claude 自身の安全判断という別レイヤーが混入します。「hook のテストのつもりが、モデルの拒否をテストしていた」という取り違えにご注意ください。
公式ドキュメントの文言は無告知で変わる
hooks と permission rules の関係を説明する一文は、筆者が別記事の調査で確認した 2026-07-06 時点では "Deny and ask rules are evaluated regardless of what a PreToolUse hook returns" という文言でしたが、2026-07-17 の再確認では本文 2-3 で引用した形にリライトされていました(意味は同じ)。逐語引用する場合は、公開直前に必ず再取得することをおすすめします。
hook スクリプト自体がフル権限で動く
公式リファレンスの注意書きは、レシピを配る記事として引用しておくべきものです。
Command hooks execute shell commands with your full user permissions. They can modify, delete, or access any files your user account can access. Review and test all hook commands before adding them to your configuration.
— Hooks reference(2026-07-17 取得)
本記事のレシピも、中身を読んで理解してから導入してください。
検証の限界(未検証事項)
本記事の実測がカバーしていない範囲を明示します。
-
検証はすべて
claude -p(ヘッドレス単発ターン)によるプロキシ実験です。 バックグラウンドエージェント(claude agents)経路での PreToolUse 発火は未検証です。 - deny / allow だけでレシピ③相当の許可リストを書いた場合に traversal が実際にすり抜けるかの対照実験は行っていません(未 trust 環境で allow が無視されるため、trust 受理=グローバル設定変更が必要になり断念)。ドキュメント記述からの推論に留まります。
- workspace trust を受理した通常運用下での allow ルールの挙動は未再現です。
-
hooks の
ifフィールドによる事前フィルタは未検証です。 本記事の3レシピはスクリプト内部で対象コマンドを判定する設計にしました。ドキュメント上はifで hook プロセスの起動自体を抑制でき、パフォーマンス上はそちらが有利なはずですが、実測比較はしていません。 - prompt 型・agent 型 hook の実機動作は未検証です(仕様の逐語確認のみ。本記事の主題は command 型のため)。
まとめ
3層の整理を再掲します。
- CLAUDE.md: 確率的。判断の質を上げる層。禁止の保証はできない
- permissions の deny: 静的だが決定的。文字列に現れる情報なら中間位置でも止められる(通説より強い)
- hooks(command 型): 実行時かつ決定的。実行時に解決される値・外部状態・パス解決という、deny の土俵に乗らない領域を止められる。prompt / agent 型は LLM 判定なのでここに数えない
持ち帰っていただくもの:
- 3-0 のワンファイル・インストーラ(既存設定を壊さず3レシピを一括適用。手動派向けの settings.json 全文つき)
- レシピ①: 実行時に解決される値で止める(方式B:
permissionDecision) - レシピ②: git の状態で止める(方式A: exit 2 + stderr)
- レシピ③: パス解決してから止める(方式B: allow / deny 両方向)
- 4章の「配線前ユニットテスト」手順
使い分けの基準はシンプルです。deny に書けるものは deny に(単純で速い)、文字列に現れないものだけを hooks に。 まずは自分のプロジェクトで「実行時にしか分からない禁止条件」を1つ探して、hook にしてみてください。
このシリーズの歩き方
Claude Code実務運用シリーズ ― 暴走させない、から仕組みにするまで。
- ◀ 前の記事: Claude Code公式プラグインで「セキュリティ担当のもう一人のClaude」を常駐させる
- ▶ 次の記事: 【第1弾】【初心者向け】VS CodeのClaude Codeで「iOSコードレビュー → 修正 → Xcodeビルド確認」を自動化する
- 🔍 あわせて読む: Claude Code auto modeを有効化する日、denyリストから消してよいものは1つもなかった【公式リストと突き合わせ】 ― denyの守備範囲を実測した回
- 🗺 シリーズ全記事の地図(親記事)