# Claude Code hooks スターターキット

AIディア（https://aidia.pengi-n.co.jp）が実運用している hooks 一式を、
顧客名・実案件・認証情報・社内パスを除いて配布用に整理したものです。

そのままコピーして使えます。ライセンスは自由（帰属表示も不要）。

**前提**: Windows 11 / Python 3.11 / Claude Code。
Linux・macOS でも動きますが、`sys.stderr.reconfigure` 周りは不要になります。

---

## 0. 入れる順番

**上から順に入れてください。1番の効果が一番大きく、4番が一番小さいです。**

| 順 | 何を入れるか | 止まるもの | 費用 |
|---|---|---|---|
| 1 | 破壊的コマンドのブロック（PreToolUse / Bash） | 誤削除・誤アップロード | 小 |
| 2 | ディスパッチャ化（§2） | hook の遅延・Drive断での沈黙 | 小 |
| 3 | 回路ブレーカー（PostToolUse） | 直列作業の暴走 | 小 |
| 4 | 完了時のリマインダ（Stop） | 検証を飛ばした完了報告 | 中 |

**1と2は同時に入れてください。** 1だけ入れると、Bash 1回ごとに Python が起動して体感が落ちます。

---

## 1. settings.json（イベントごとに1本だけ登録する）

**hook を1つずつ登録しないでください。** イベントごとにディスパッチャを1本登録し、
中で複数の検査を回します。理由は §2 に書いています。

```json
{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "python ~/.claude/hooks/pre_bash.py" }
        ]
      }
    ],
    "PostToolUse": [
      {
        "matcher": "",
        "hooks": [
          { "type": "command", "command": "python ~/.claude/hooks/post_tool.py" }
        ]
      }
    ],
    "UserPromptSubmit": [
      {
        "hooks": [
          { "type": "command", "command": "python ~/.claude/hooks/user_prompt.py" }
        ]
      }
    ]
  }
}
```

`matcher` はツール名の**完全一致**です。`bash` ではなく `Bash`。
大文字小文字を間違えると、エラーも出ずに黙って発火しません。

---

## 2. ディスパッチャ（`hooks/_dispatch.py`）

**なぜ必要か**: 1イベントに hook を4本登録すると、Bash 1回ごとに Python が4プロセス起動します。
AIディアではスクリプト実体がクラウドドライブ上にあったため、
この構成で**全 Bash 呼び出しに 874ms のオーバーヘッド**が乗っていました（2026-08-30 実測）。
さらにドライブが未マウントだと、**全 hook が黙って死にます**（エラーも出ない）。

ディスパッチャは、イベントごとに1プロセスだけ起動して検査を順に回します。

```python
#!/usr/bin/env python3
"""hook ディスパッチャの共通実装。

規約:
  - どれか1つでも exit 2 を返したら、その時点で停止して 2 を返す（ブロック最優先）
  - exit 0 の check の stdout は結合して出す
  - 1つの check が例外を投げても他は動かす（hook 全体を落とさない）
"""
import importlib.util
import io
import sys
import traceback
from pathlib import Path

CHECKS_DIR = Path(__file__).parent / "checks"


def _load(name):
    path = CHECKS_DIR / name
    if not path.exists():
        return None
    spec = importlib.util.spec_from_file_location(
        "check_" + path.stem.replace("-", "_"), str(path)
    )
    mod = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(mod)
    return mod


def run(check_files):
    try:
        payload = sys.stdin.read()
    except Exception:
        return 0

    real_stdout, real_stderr = sys.stdout, sys.stderr
    out_parts, err_parts = [], []
    exit_code = 0

    for name in check_files:
        buf_out, buf_err = io.StringIO(), io.StringIO()
        try:
            mod = _load(name)
            if mod is None or not hasattr(mod, "main"):
                continue
            sys.stdin = io.StringIO(payload)
            sys.stdout, sys.stderr = buf_out, buf_err
            rc = mod.main()
        except SystemExit as e:
            rc = e.code if isinstance(e.code, int) else 0
        except Exception:
            buf_err.write("[dispatch] " + name + " で例外:\n" + traceback.format_exc(limit=3))
            rc = 0
        finally:
            sys.stdout, sys.stderr = real_stdout, real_stderr

        if buf_out.getvalue().strip():
            out_parts.append(buf_out.getvalue().rstrip())
        if buf_err.getvalue().strip():
            err_parts.append(buf_err.getvalue().rstrip())

        if rc == 2:
            exit_code = 2
            break

    if out_parts:
        print("\n".join(out_parts))
    if err_parts:
        print("\n".join(err_parts), file=sys.stderr)
    return exit_code
```

各イベントの入口はこれだけです（`hooks/pre_bash.py`）。

```python
#!/usr/bin/env python3
"""PreToolUse(Bash) ディスパッチャ"""
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from _dispatch import run

CHECKS = [
    "pre-bash-upload-guard.py",   # 事故コスト最大なので先に評価する
    "pre-bash-git-guard.py",
]

if __name__ == "__main__":
    sys.exit(run(CHECKS))
```

**検査の並び順に意味があります。** 事故コストが大きいものを先に置いてください。
1つでも exit 2 を返した時点で残りは評価されません。

---

## 3. アップロード・削除ガード（`checks/pre-bash-upload-guard.py`）

**ここが一番効きます。** 本番・STG への誤アップロードと誤削除だけを止めます。

### 3-1. すり抜けの話（これを読まずにコピーしないでください）

最初の実装は**コマンド文字列だけを正規表現で見ていました**。これはすり抜けます。

```bash
# コマンド文字列に ftp も upload も出てこないので素通りする
python scripts/publish.py
```

`publish.py` の中身が `upload_dir()` を呼んでいても、hook からは見えません。
**外部スクリプトを1枚かませるだけで、ガードは無効になります。**

対策は、コマンドから `.py` のパスを抜き出して**中身まで読む**ことです。
ただし単純に「`ftp_helpers` を import していたらブロック」にすると、
ダウンロード専用スクリプトまで止まります（DL も同じモジュールを import するため）。
判定は import ではなく**書き込み・削除の関数名**で行います。

読めなかった場合は**安全側に倒してブロック**します。

### 3-2. 実装

```python
#!/usr/bin/env python3
"""PreToolUse(Bash): アップロード / 削除の暴走を止める。

承認トークン '# upload-approved' がコマンドか description にあれば通す。
"""
import json
import re
import sys

try:
    sys.stdout.reconfigure(encoding="utf-8", errors="replace")
    sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except (AttributeError, OSError):
    pass

UPLOAD_OR_DELETE_PATTERNS = [
    r"\blftp\b.*\b(put|mput|rm\b|rmdir|mirror\s+-R)",
    r"\bsftp\b.*\b(put|rm\b)",
    r"\bscp\s+[^|]*\S+@\S+:",
    r"\bcurl\b.*(--upload-file|--ftp-create-dirs)",
    r"\bpscp\b.*\S+@\S+:",
    r"\.(?:storbinary|storlines|delete|rmd)\(",
    r"\brm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b",
    r"\bgit\s+push\s+.*--force\b",
]
UPLOAD_OR_DELETE_RE = re.compile("|".join(UPLOAD_OR_DELETE_PATTERNS), re.IGNORECASE)

# 名前に ftp / upload / deploy / sync を含む .py の実行は「要確認」の印。
# 上りか下りかは名前で判別できないので、実際の判定は中身で行う。
PY_FILENAME_RE = re.compile(
    r"\bpython\b(?:\s+-\w+)*\s+\S*(?:ftp|upload|deploy|sync|publish)\S*\.py\b",
    re.IGNORECASE,
)

# 中身に「実際の書き込み / 削除」があるか。import だけでは反応させない。
PY_WRITE_RE = re.compile(
    r"(?:upload_dir|upload_file|delete_dir|delete_file"
    r"|\.storbinary\(|\.storlines\(|\.delete\(|\.rmd\()",
    re.IGNORECASE,
)
APPROVAL_TOKEN = "# upload-approved"


def scan_py_files_in_cmd(cmd):
    """python ... script.py の中身を読み、書き込み / 削除があるか判定する。

    戻り値: (checked, write_found, unreadable)
    """
    py_paths = re.findall(
        r"\bpython\b(?:\s+-\w+)*\s+[\"']?([^\s\"']+\.py)[\"']?", cmd, re.IGNORECASE
    )
    checked = write_found = unreadable = False
    for path in py_paths:
        try:
            with open(path, "r", encoding="utf-8", errors="ignore") as f:
                content = f.read(20000)
        except Exception:
            unreadable = True
            continue
        checked = True
        if PY_WRITE_RE.search(content):
            write_found = True
    return checked, write_found, unreadable


def main():
    try:
        data = json.load(sys.stdin)
    except Exception:
        return 0
    if data.get("tool_name") != "Bash":
        return 0

    tool_input = data.get("tool_input") or {}
    cmd = tool_input.get("command") or ""
    desc = tool_input.get("description") or ""

    if APPROVAL_TOKEN in cmd or APPROVAL_TOKEN in desc:
        return 0

    hit = bool(UPLOAD_OR_DELETE_RE.search(cmd))
    if not hit:
        checked, write_found, unreadable = scan_py_files_in_cmd(cmd)
        if write_found:
            hit = True
        elif PY_FILENAME_RE.search(cmd):
            # 中身を確認できて書き込みが無ければ DL 専用とみなして通す。
            # 確認できなければ安全側に倒す。
            hit = (not checked) or unreadable

    if not hit:
        return 0

    print(
        "アップロード / 削除はユーザー確認必須（毎回）\n"
        "  包括承認の流用は禁止。\n"
        "  1) 対象環境（本番 / STG / dev）と対象パスを明示して確認を取る\n"
        "  2) OK後、コマンド末尾に '# upload-approved' を付けて再実行",
        file=sys.stderr,
    )
    return 2


if __name__ == "__main__":
    sys.exit(main())
```

### 3-3. ブロック理由の書き方

exit 2 の stderr は**そのまま AI へのフィードバックになります**。
「禁止」だけでなく「**どうすれば通るか**」まで書いてください。
書かないと、AI が別のコマンドで回避しようとします。

---

## 4. 回路ブレーカー（`checks/post-turn-breaker.py`）

「30ターンで方針確認」といったルールを文章で書いても実行されません。機械的に数えます。

**閾値は自分の実測から決めてください。** 他人の数字を借りても効きません。
AIディアは自分の作業ログ415ブロックを集計し、上位9%（25ツール）で警告、
上位2%（60ツール）で分割を促す設計にしました。

```python
#!/usr/bin/env python3
"""PostToolUse: 1指示あたりのツール呼び出し数で警告を出す。"""
import json
import sqlite3
import sys
from pathlib import Path

DB = Path.home() / ".claude" / "analytics" / "events.sqlite"
WARN, HARD = 25, 60

SCHEMA = """
CREATE TABLE IF NOT EXISTS block_state (
    session_id TEXT PRIMARY KEY,
    tools      INTEGER DEFAULT 0,
    fired      INTEGER DEFAULT 0
);
"""


def main():
    try:
        data = json.load(sys.stdin)
    except Exception:
        return 0
    sid = data.get("session_id")
    if not sid:
        return 0

    DB.parent.mkdir(parents=True, exist_ok=True)
    try:
        con = sqlite3.connect(str(DB), timeout=2.0)
        try:
            con.executescript(SCHEMA)
            row = con.execute(
                "SELECT tools, fired FROM block_state WHERE session_id=?", (sid,)
            ).fetchone()
            tools = (row[0] if row else 0) + 1
            fired = row[1] if row else 0
            level = 2 if tools >= HARD else (1 if tools >= WARN else 0)
            con.execute(
                "INSERT OR REPLACE INTO block_state(session_id,tools,fired) VALUES(?,?,?)",
                (sid, tools, max(fired, level)),
            )
            con.commit()
        finally:
            con.close()
    except Exception:
        return 0

    if level == 0 or level <= fired:
        return 0   # 同じ段階で二度は鳴らさない

    if level == 1:
        print("\n[一時停止] この指示で " + str(tools) + " 回ツールを使っています。\n"
              "  現状の要約と残タスクを出し、方針が合っているか確認してください。")
    else:
        print("\n[分割] この指示で " + str(tools) + " 回ツールを使っています。\n"
              "  直列で続けない。独立した部分は別エージェントへ並列に出してください。")
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

カウンタは `UserPromptSubmit` でリセットします（人間の指示1件 = 1ブロック）。

```python
#!/usr/bin/env python3
"""UserPromptSubmit: ブロック境界でカウンタを戻す。"""
import json
import sqlite3
import sys
from pathlib import Path

DB = Path.home() / ".claude" / "analytics" / "events.sqlite"


def main():
    try:
        data = json.load(sys.stdin)
    except Exception:
        return 0
    sid = data.get("session_id")
    if not sid or not DB.exists():
        return 0
    try:
        con = sqlite3.connect(str(DB), timeout=2.0)
        try:
            con.execute("UPDATE block_state SET tools=0, fired=0 WHERE session_id=?", (sid,))
            con.commit()
        finally:
            con.close()
    except Exception:
        return 0
    return 0


if __name__ == "__main__":
    sys.exit(main())
```

---

## 5. hooks が動かないときの診断（上から順に）

| 症状 | 最初に見るところ |
|---|---|
| 何も起きない | `matcher` の大文字小文字。`bash` ではなく `Bash` |
| 何も起きない | `settings.json` の末尾カンマ。JSON として壊れていると黙って無視される |
| 全ツールが止まる | hook スクリプトの構文エラー。シェルが exit 2 を返し、ブロック扱いになる |
| ときどき動かない | スクリプト実体がクラウドドライブ上にある。未マウント時に黙って死ぬ |
| 体感が遅い | 1イベントに hook を複数登録している。§2 のディスパッチャへ |
| 文字化けする | Windows。`sys.stderr.reconfigure(encoding="utf-8")` を入れる |

**動作確認は必ず「止まること」と「通ること」の両方を見てください。**
止まることだけ確認すると、全部止まっている状態に気づきません。

---

## 6. hooks で解決しないこと

- **正規表現ガードは万能ではありません。** パターン表に無い書き方は素通りします。
  権限設定（allowedTools）とバックアップを併用した多層防御の一層として使ってください
- **外部スクリプト経由のすり抜け**は §3-1 の対策を入れないと防げません
- **判断の質は上がりません。** hooks が止められるのは「やってはいけない操作」であって、
  「やる必要がなかった作業」ではありません

---

## 7. 何から入れるかの判断表

| 状況 | 最初に入れるもの |
|---|---|
| 本番・STG にデプロイする権限を AI に渡している | §3 アップロード・削除ガード |
| 削除・DB 操作を任せている | §3（パターン表に DROP / rm -rf を追加） |
| 長時間の自律実行をさせている | §4 回路ブレーカー |
| hook を既に3本以上登録している | §2 ディスパッチャ化 |
| どれも当てはまらない | 入れない。事故が起きてから、その事故のパターンを追加する |

**最後の行が本音です。** 起きていない事故を先回りしてパターン表に足すと、
表だけ育って誰も読まなくなります。踏んだ罠を1つずつ足すのが、続く運用でした。

---

作成: 株式会社PENGIN / AIディア編集部
出典: https://aidia.pengi-n.co.jp/articles/claude-code-hooks-guardrail/
実測環境: Windows 11 / Python 3.11 / Claude Code（2026-09-02 時点の運用構成）
