ハーネスに書く
このツリーの各ページの「そのまま貼る」を集めたもの。 加えて、どれをどこに置くかの判断基準を先に置く。
ここに載せているフックは、手元で実際に走らせて出力を確認してある (実測)。推測ではなく実際にどうなるか。
元の規範
静的解析。 このページの全体が、あの章の主張を AI エージェントに適用したものになっている。
ありふれた問題を自動で目立たせることで、レビュアーの時間を節約できる
自動的に修正できるものは、すべて自動的に修正されるべき
(出典: 開発者のワークフローに埋め込む)
どこに置くかの判断
判断はひとつの問いで決まる。守られなかったときに困るか。
困らない 文章で書く CLAUDE.md
困る 仕組みで止める permissions / hooks
文章で書いたルールは、守られることもあれば守られないこともある。 それでよいものだけを文章に置く。 静的解析が 「行動可能で、修正が容易である」を条件に挙げているのと同じ理由で、 守れないルールを大量に置くと、全体が読み飛ばされる方向に働く。
| 置き場所 | 効き方 | 向いているもの |
|---|---|---|
CLAUDE.md | 読まれる。守られないこともある | 判断の方針、書き方の好み、既存の規約への参照 |
permissions.deny | 必ず止まる | 触ってほしくないファイル、走らせたくないコマンド |
permissions.ask | 毎回確認が入る | 取り消しにくいが、必要なこともある操作 |
hooks (PreToolUse) | 条件を書いて止められる | パスや引数の中身で判定したいもの |
hooks (PostToolUse) | 後から知らせる | 止めるほどではないが見落としたくない変化 |
| CI | 出す前に落とせる | テスト、静的解析、脆弱性スキャン。元の規範のまま |
**CI は変えなくていい。**ここが重要で、 AI 駆動だからといって CI に足すものは基本的にない。 左への移動で 言えば、新しく増やすのは CI より左の段だけ。
スコープの対応
| ファイル | 範囲 | 共有 |
|---|---|---|
~/.claude/CLAUDE.md | 全プロジェクト | 個人 |
<repo>/CLAUDE.md | そのリポジトリ | コミットしてチームで共有する |
~/.claude/settings.json | 全プロジェクト | 個人 |
<repo>/.claude/settings.json | そのリポジトリ | コミットして共有する |
<repo>/.claude/settings.local.json | そのリポジトリ | 個人。git に入れない |
リポジトリ固有の規約は <repo>/CLAUDE.md に置いてコミットする。
個人の好み(言語、コミットの確認を省くかどうか)は ~/.claude/ 側。
混ぜると、他人の環境で意図しない挙動になる。
そのまま貼る: CLAUDE.md
このツリーの5ページ分を1つにまとめたもの。
リポジトリの CLAUDE.md に貼る。長いと読み飛ばされるので、
当てはまらない節は消して使う。
## 判断の原則
- **推測を書かない。** 動かしていないものを「動く」と書かない。
確かめたことと確かめていないことを、報告の中で区別する
- **実行できるものは実行してから報告する。** 実行したコマンドとその出力を報告に含める。
出力が無い主張は、未確認のものとして扱われる
- **実際のデータで確かめる。** 都合のよい入力を自分で作って通しても、確かめたことにならない
- **頼まれていないものを足さない。** 設定項目・抽象化の層・将来のための拡張点・
使われないエラー分岐を、依頼に無いのに足さない。足すべきだと判断したら、足す前にそう言う
- **シンボルを変えるときは、呼び出し元を全部探してから変える**
- **前提が食い違うと成果物が丸ごと無駄になる場合だけ、着手前に確認する。**
それ以外は進めてよい。判断のたびに止まらない
## 変更の出し方
- **1つの変更は1つのことだけをする。** 機能変更・バグ修正・リファクタリングを混ぜない
- **着手前に規模を見積もって伝える。** 200行を超えそうなら、分けられないか提案してから始める
- **変更の説明は、1行目に要約、空行、それ以降に「なぜこの方法が最良か」
「確かめていないこと」。** 「確かめていないこと」が空なら「なし」と書く
- **リンター・フォーマッター・型チェックが通る状態にしてから報告する**
- **既存のコードと同じ書き方に合わせる。** 周囲の命名・構造・コメントの密度に寄せる
- **指摘に同意できないときは、理由と代案を出す。** 黙って書き換えない
## テスト
- **テストを書いたら、実装を入れる前に落ちることを確認する。** 実行結果を報告に含める
- **挙動をテストする。実装詳細をテストしない。** public な入口から呼ぶ。
private メソッドをテストのために公開しない。内部の呼び出し回数・順序を検証しない
- **状態を見る。相互作用を見ない。** 「保存関数が呼ばれたこと」ではなく
「取得した結果が正しいこと」を確認する
- **依存は 本物 > フェイク > スタブ の順で選ぶ。モックは使わない。**
落としたときは理由を書く
- **1つのテストは1つの挙動だけを見る**
- **重複を恐れない。** ヘルパーに畳むのは「説明的になるとき」だけ。
「繰り返しが減るから」では畳まない
- **テストの期待値を変えたときは、変えた理由を報告に書く。**
実装のバグをテスト側で吸収しない
- **テストを削除・スキップしない。** 必要だと判断したら、実行せずに理由を報告する
- **境界と異常系を書く。** 空・0・負・最大・想定外の型・エラー応答
## 秘密情報と依存
- **秘密情報を読まない・書かない・出力しない。** 必要なら、値ではなく
その値を参照している変数名を使って進める
- **秘密の値を、説明・ログ・コミットメッセージ・テストの固定値に書かない**
- **依存を足す前に、実在と素性を確認する。** 公式のリポジトリか、更新されているか、
利用方法が明確か。確認できないものは提案するだけにして、install しない
- **依存を足す前に、足さずに書けないか考える**
- **取り消せない操作は、実行前に確認を取る。** 何をどうするかを具体的に述べてから訊く
## コミット
- **1コミット = 1つの関心事。** 機能変更・バグ修正・リファクタリング・整形を混ぜない
- **その変更だけを revert して、他を壊さずに戻せる状態を保つ**
- **テストが通った時点で刻む。** 全部終わってからまとめてコミットしない
- **コミットメッセージは、差分の要約ではなく理由を書く。**
1行目に何をしたか、空行、それ以降に「なぜこの方法にしたか」
- **既存の履歴の書式に合わせる。** `git log` を見てから書く
- **履歴を書き換えない。** rebase・amend・force push は、明示的に頼まれたときだけ
- **フォーマッターを既存ファイル全体にかけない。** 触った箇所だけにするそのまま貼る: settings.json
<repo>/.claude/settings.json に置く。
deny は必ず止まるので、ここに置くのは「例外なく止めてよいもの」だけ。
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./.env.*)",
"Read(./**/*.pem)",
"Read(./**/*.key)",
"Read(./**/id_rsa)",
"Read(./**/id_ed25519)",
"Bash(git push --force:*)",
"Bash(git push -f:*)",
"Bash(rm -rf /*)"
],
"ask": [
"Bash(git rebase:*)",
"Bash(git reset --hard:*)",
"Bash(git commit --amend:*)",
"Bash(npm install:*)",
"Bash(pip install:*)",
"Bash(go get:*)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Read|Edit|Write",
"hooks": [
{ "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/guard.py\"" }
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{ "type": "command", "command": "python3 \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/guard.py\"" }
]
}
]
}
}ask に入れている install 系が、セキュリティの
「提案された名前をそのまま install する」を止める場所。
post-install スクリプトがある以上、install は実行と同じ扱いになる。
deny と ask はパスのパターンでしか判定できない。
中身で判定したいものは、次のフックに置く。
そのまま貼る: フック本体
<repo>/.claude/hooks/guard.py に置く。1本で2つの仕事をする。
標準入力に JSON が来て、止める/知らせるときだけ標準出力に JSON を返す。
秘密ファイルを開かせない
permissions.deny と重複しているように見えるが、役割が違う。
deny はパスのパターン、こちらはファイル名の規則。
certs/prod/server.key のような、あらかじめ列挙できない場所を拾う。
テストを消させない
テストで挙げた、
差分の中で最も見落とされやすいのに最も危ない変化。
止めはせず、目に見えるようにする。
消すのが正しい場面もあるので、deny ではなく PostToolUse の通知にしてある。
#!/usr/bin/env python3
"""Claude Code のフック。秘密ファイルへの読み書きを止め、テストの無効化を知らせる。
PreToolUse (Read|Edit|Write) 秘密情報を含みやすいファイルなら止める
PostToolUse (Edit|Write) テストを無効化する記述が入ったら知らせる
止める/知らせるとき以外は何も出さない。
壊れた入力では黙って通す。これは関門ではなく検査なので、フックの不具合で作業を止めない。
"""
import json, os, re, sys
# .env.example のような「中身が空の見本」は、編集もコミットもする対象なので除く。
# ここを止めると、正しい操作が毎回拒否される=実質的誤検出になり、フック全体が信用されなくなる。
TEMPLATE = re.compile(r'\.(example|sample|template|dist|tpl)$', re.I)
SECRET = re.compile(
r'^(\.env(\..+)?|credentials|id_rsa|id_ed25519|.*\.(pem|key|p12|pfx|keystore))$', re.I)
TESTPATH = re.compile(r'(^|[/_.-])(tests?|spec|_test|\.test|\.spec)([/_.-]|$)', re.I)
SKIP = {
".skip": r'\.skip\b',
"xit / xdescribe": r'\bx(it|describe)\b',
"@pytest.mark.skip": r'@pytest\.mark\.skip',
"@unittest.skip": r'@unittest\.skip',
"t.Skip": r'\bt\.Skip\b',
"pending": r'\bpending\b',
}
def emit(obj):
print(json.dumps(obj, ensure_ascii=False))
def main():
data = json.load(sys.stdin)
event = data.get("hook_event_name", "")
ti = data.get("tool_input", {})
path = ti.get("file_path") or ""
name = os.path.basename(path)
if event == "PreToolUse" and SECRET.match(name) and not TEMPLATE.search(name):
emit({
"hookSpecificOutput": {"hookEventName": "PreToolUse",
"permissionDecision": "deny"},
"systemMessage": "秘密情報を含みうるファイルは開かない。値ではなく、"
"その値を参照している変数名を使って進めること: " + path,
})
return
if event == "PostToolUse" and TESTPATH.search(path.replace(os.sep, "/")):
added = "\n".join(str(ti.get(k, "")) for k in ("new_string", "content"))
hit = [n for n, pat in SKIP.items() if re.search(pat, added)]
if hit:
emit({"systemMessage":
"テストを無効化する記述が入った (" + ", ".join(hit) + "): " + path +
"\nテストは消さない・飛ばさない。必要だと判断したなら、"
"実行せずに理由を報告すること。"})
try:
main()
except Exception:
pass壊れた入力で黙って通すのは意図的。 フックは全ツール呼び出しの前後に挟まるので、 ここで落ちると作業そのものが止まる。 静的解析が 言うとおり、信頼を失った検査は結局無効化されるので、 検査の側が邪魔をしない設計にしておく。
実測
上のフックを、手元で16ケース流して確認した(2026-09-01)。
| 入力 | 結果 |
|---|---|
PreToolUse + .env | 止まる |
PreToolUse + .env.local | 止まる |
PreToolUse + certs/server.key | 止まる |
PreToolUse + src/main.go | 通る |
PreToolUse + environment.ts | 通る(.env と紛らわしいが別物として扱われる) |
PreToolUse + .env.example / .env.sample / .env.template | 通る(後述の誤検出を直した結果) |
PreToolUse + android/release.keystore | 止まる |
PreToolUse + android/local.properties | 通る |
PostToolUse + foo_test.py に @pytest.mark.skip | 知らせる |
PostToolUse + foo_test.py に @unittest.skip | 知らせる |
PostToolUse + tests/user.go に t.Skip | 知らせる |
PostToolUse + user.spec.js に xit(...) | 知らせる |
PostToolUse + foo_test.py(skip なし) | 通る |
PostToolUse + main.js に list.skip(1) | 通る(テストファイルではない) |
| 壊れた JSON | 通る。終了コード 0 |
| 空の入力 | 通る。終了コード 0 |
確認の途中で2件、落とし穴を踏んだので残しておく。
1. 標準入力が読めていなかった
最初はシェルスクリプトの中で python3 - <<'PY' と書いていたが、
ヒアドキュメントが標準入力を占有して、フックに渡された JSON が読めなかった。
検知したい入力が全部素通りするのに、終了コードは 0 なので、
動いているように見えて何もしていない状態になる。
フックは黙って通る設計にする以上、入れたら必ず1件は止まることを確かめる必要がある。
2. .env.example を止めてしまっていた
実際に手元のリポジトリへ入れようとして見つかった。
最初の SECRET は \.env(\..+)? だったので、
.env.example .env.sample .env.template まで止まっていた。
これらは中身が空の見本で、編集もコミットもする対象。 止めると正しい操作が毎回拒否される。 静的解析の 言う実質的誤検出そのもので、しかも最も悪い形で出る。
有用なシグナルが少なく、役に立つことが稀な解析は、結局無効化される。
秘密を守るために入れたフックが、無効化される方向に働く。
TEMPLATE の除外を足して直した。
教訓は、書いた側では見つからないということ。
13ケースは全部自分で考えた入力で、.env.example は思いつかなかった。
実際のリポジトリに当ててから3ファイル目で出た。
3. CLAUDE.md がリポジトリの検査に引っかかった
貼る側ではなく、貼られる側で起きる問題。
このリポジトリに CLAUDE.md を置いたところ、
scripts/check.py が
「frontmatter なし」で落ちた。.md を全部歩く検査なので、当然そうなる。
ハーネスの資材はリポジトリの規約の外側にあると思いがちだが、
置いた瞬間に中側に入る。 ここでは README.md や ROADMAP.md と同じく
frontmatter を付けて解決した(規約に合わせる側が筋で、検査に例外を足す側ではない)。
入れる前に、そのリポジトリの検査を一度通すこと。
4. フックのパスが相対で、静かに動かなくなっていた
(1) と同じ型を、同じページの中でもう一度やっていた。
"command": "python3 .claude/hooks/guard.py" と相対パスで書いていたので、
カレントディレクトリがサブディレクトリのときファイルが見つからない。
実際に stock-copilot/src から流すとこうなる。
can't open file '.../src/.claude/hooks/guard.py': [Errno 2] No such file or directory
exit=0
終了コードは 0。 検知したい入力が全部素通りして、 しかも「黙って通す」設計なので正常時と区別がつかない。
$CLAUDE_PROJECT_DIR(プロジェクト直下を指す)を使う形に直した。
未設定でも従来どおり動くよう既定値を置いてある。
python3 "${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/guard.py"
4件中2件が「静かに何もしない」だった。 フックは失敗しても手が止まらないので、壊れていることに気づく経路が無い。 上に書いた「黙って通す」設計は、この危うさと引き換えに選んでいる。 だから入れた直後に1件わざと止めて、止まることを目で見るのが、 この設計に付いてくる義務になる。