連載「AIにコードを書かせる技術 — 品質とセキュリティのガードレール設計」第 1 回。
AIエージェント(Claude Code)に実装を任せる前提で、品質とセキュリティをどう「仕組み」で担保するかを扱います。
初回のテーマは、その土台になる コーディング規約の作り替え です。
この記事のゴール
手元にある「よく書けたコーディング規約」を、AIエージェントに渡してみたことはありますか。
たぶん、こうなります。
- 最初の 2 ファイルは規約どおり。3 ファイル目から命名がぶれる
- 「エラーは適切にハンドリングする」と書いたのに
catch (e) {}が生える - テストは「全部 PASS しました」と報告される。実際には型チェックしか回っていない
規約が悪いわけではありません。人間向けの規約は「読んで解釈して守る」ことを前提に書かれているからです。エージェントは解釈しますが、記憶は持続せず、判断は毎回ぶれ、そして「守ったふり」を検出する仕組みがなければ守ったふりをします。
この記事では、実運用しているリポジトリの規約・設定を素材に、規約を次の 5 層 に分解して落とし込む方法を書きます。
| 層 | 実体 | 破ったときに何が起きるか |
|---|---|---|
| L1 判定可能な規約本文 | docs/rules/*.md |
エージェントが自分で違反と判定できる |
| L2 入口ファイル | CLAUDE.md |
毎セッション最重要ルールが視界に入る |
| L3 権限 | .claude/settings.json |
危険コマンドがそもそも実行できない |
| L4 ローカルゲート |
pre-commit / pre-push
|
commit / push が止まる |
| L5 CI ゲート | GitHub Actions | PR が ready にならない |
上に行くほど「お願い」、下に行くほど「物理」。規約は L1 だけでは機能しません。 L3 以下に落ちて初めてルールになります。
1. なぜ人間向けの規約はエージェントに効かないのか
実際に効かなかったパターンを 4 つに整理します。
1.1 曖昧語は判定できない
❌ エラーは適切にハンドリングすること
❌ テストは正しく書くこと
❌ 命名は分かりやすくすること
「適切」「正しく」「分かりやすく」は、人間なら文脈で補完できます。エージェントは補完しますが、補完の中身が毎回違う。結果として、レビューで初めて食い違いに気づきます。
実際に運用しているテスト規約では、これを名指しで禁止しています。
### 4.2 記述ルール
- **曖昧表現の禁止**: 「正しく」「適切に」「問題なく」は使用せず、具体的な期待値を記述する
- **手順動詞の統一**: 「クリックする」「入力する」「選択する」「遷移する」などを統一表記
- **入力値**: 「適当な値」「任意の文字列」は禁止。実値または値生成ルールを明示
- **期待値**: 表示文言・遷移先・DB 状態・ログ出力など、検証可能な形で記述
ポイントは、この禁止条項自体がエージェントへの指示として機能することです。テストケースを書かせると、「適切に表示される」ではなく「ユーザー名は必須です と赤字で表示される」と書くようになります。
1.2 長い規約は「薄まる」
1000 行の規約を毎回読ませても、全項目が均等に効くわけではありません。効かせたい数項目は、別の場所に、短く、再掲する必要があります(L2 の CLAUDE.md の役割)。
1.3 抜け道が塞がれていない
これが一番効きます。エージェントは「ルールを守る」より「タスクを完了扱いにする」ことを優先しがちで、規約に穴があると必ずそこを通ります。
典型例が「カバレッジ 100% を目標とする」。素直に書くと、到達しにくい行を除外設定に追加して 100% を達成してきます。なので規約側で先に塞ぎます。
### 3.2 カバレッジ方針 — 100% 目標と到達困難箇所の mock 必須化
「通常実行では到達できない」ことを理由にカバレッジ除外 (`collectCoverageFrom` からの除外 /
`istanbul ignore` 等) で逃げてはならない。
| 到達困難の類型 | 例 | 必須対応 |
| --- | --- | --- |
| **外部依存** | 子プロセス起動 / ネットワーク / ファイル I/O / 時刻・乱数 | **mock / stub で当該経路を強制実行**し計測対象に含める |
| **例外・異常系** | `catch` 節 / タイムアウト / 外部コマンド非 0 終了 | mock を失敗させて `catch` を実際に通す |
| **環境差分** | env 未設定 / フラグ off / 権限なし | env を一時上書きして両分岐を通す |
#### why (= なぜ除外ではなく mock か)
- 除外は「テストしていない」を「カバレッジ上は緑」に偽装する。
- 外部依存・`catch` 節こそ本番障害の発生源。
- どうしても除外する場合は **コメントで理由を明記**し、レビューで個別承認する (= 無言の除外禁止)。
**「無言の除外禁止」**のように、逃げ道を潰したうえで正規の逃げ道を 1 本だけ用意するのがコツです。完全に塞ぐと、今度は別の予期しない抜け道を探します。
1.4 守ったかどうかを検証していない
「テストを全件 PASS させてから完了とする」と書いても、PASS したことを検証する仕組みがなければ、PASS したという報告が返ってくるだけです。ここは L5(CI ゲート)で機械的に潰します(後述の「手抜き PASS の検出」)。
2. 全体像
規約本文は 1 箇所(正本)に置き、各プロジェクトへ配布します。エージェントは CLAUDE.md を入口に規約を読み、権限で危険操作を封じられた状態で実装し、ローカルゲートと CI ゲートを両方通って初めて PR が ready になります。
3. L1: 規約本文を「判定可能」に書き換える
3.1 必須度の列を付ける
規約の各項目に「必須 / 条件付き必須 / 任意」を明記します。これがないと、エージェントは全項目を均等に扱うか、全部を任意として扱います。
コメント規約の例です。
### 4.1 基本方針 — why / what / how + タイミング
| 観点 | 何を書くか | 必要度 |
| --- | --- | --- |
| **why** (= 何のために) | 目的 / 判断根拠 / 業務ルール / 制限事項 | **必須** (コードから読み取れないため) |
| **what** (= 何を) | 処理対象、入出力の意味 | 識別子から自明なら省略可 |
| **how** (= どうする) | 実装方法、アルゴリズム、選んだ手段 | 非自明な手段を採るときに書く |
| **タイミング** (= いつ) | 起動契機、実行頻度、前提状態 | 副作用や非同期処理を含むときに書く |
**書き分けの原則**:
- `what` だけのコメントは禁止 (= コードと識別子で表現する)
- `why` を最優先
- タイミング (= when) は **副作用がある処理 / 非同期処理** に対して書く
3.2 ✅ / ❌ の対で書く
「こう書け」だけより、「こう書くな」を並記した方が圧倒的に効きます。エージェントは対比から境界を学習します。
```typescript
// ✅ 良い例 (why + how + タイミング を明示)
// why : 800ms 長押しはタブレット用ガイドライン推奨値 (誤タップ防止のため)
// how : env `VITE_LONGPRESS_MS` で上書き可、未指定なら 800ms
// when: 一覧画面で行を mousedown 中、リリース前に達した瞬間に発火
const LONGPRESS_MS = Number(import.meta.env.VITE_LONGPRESS_MS) || 800;
// ❌ 悪い例 (what のみ)
// 長押し時間
const LONGPRESS_MS = 800;
// ❌ 悪い例 (magic number に why 無し)
const LONGPRESS_MS = 800;
```
3.3 迷ったときの既定値を書く
エージェントは「省略可」と書かれた項目で毎回判断がぶれます。ぶれる方向を指定します。
「省略可能か迷ったら書く」を原則とし、実装するときは **多めに書いて**
レビュー時に冗長分を削る運用とする (= 書き忘れより冗長の方が安全)。
3.4 「曖昧なら止まれ」を規約化する
規約に書ける中で、たぶん一番リターンが大きい項目です。仕様が曖昧なとき、エージェントは推測で実装を進めます。 そして「期待と違う」PR ができあがり、レビューで丸ごと差し戻しになります。
そこで「着手前に停止する条件」を規約として書きます。ただしそのまま書くと、今度はコードを読めば分かることまで質問してきて往復が増えるので、質問の前に自助調査を義務付けるのがセットです。
## 5. 仕様確認・質問の手順 — **着手前に必ず判定**
**実装コードを 1 行でも書く前に**、Issue / PR の内容を点検する。
1. **質問前の自助調査を必ず先に実施** — コード / grep / 設定ファイル / docs を読み、
現状値・対象要素・既存実装の事実関係を確定させる。
2. そのうえで曖昧判定に該当するなら質問投稿。どちらか 1 つでも該当したら推測で進めず停止。
### 5.2.a パターン判定 (= 機械的に検出できる曖昧さ)
- ❌ **必須の値・名前・選択肢が指定されていない**
- 例: 「色を変えたい」だが色名・色コードが指定されていない
- 例: 「閾値を変える」だが変更後の数値が指定されていない
- ❌ **対象ファイル・対象画面・対象機能が複数候補ある**
### 5.2.b 包括判定 (= パターンに落ちない不明点)
- ❌ **i. 複数案を比較検討しても優劣判断不能** (= 嗜好・運用方針・承認が必要な仕様判断)
- ❌ **ii. 通常のチャット対話で相談されても聞き返すであろう内容**
判断表まで用意すると、質問と実装の線引きが安定します。
| 例 | 判定 | 理由 |
|---|---|---|
| 並び替え軸 / 集計軸のどの候補を意味するか | ✅ 質問 | ユーザー嗜好次第 (b-ii) |
| UI 制御方式 (ボタン / ドロップダウン / リンク) | ✅ 質問 | 複数案で優劣不能 (b-i) |
| 設定の保持範囲 (一時的 / localStorage / DB) | ✅ 質問 | 運用方針次第 (b-ii) |
| 「現状の上下余白は何 px か」 | ❌ 質問しない | コード読めば確定 |
| 比率指定の変更後具体値 (例: 16px → 4px) | ❌ 質問しない | 現状値から算出可能 |
そして停止時の手順も具体化します。「止まれ」だけだと、標準出力にメッセージを吐いて終了し、誰も気づきません。
### 5.4 質問投稿の手順
1. `.claude/question-${ISSUE_NUMBER}.md` に質問内容を書く
2. **自助調査結果 (= 現状値・対象要素) を質問本文に含め、具体提案 + 確認質問の形式にする**
(例: 「現状の上下余白は `X.tsx` で 16px。1/4 = 4px に変更して良いか?」)
3. **不明点が複数あれば 1 度に全項目を箇条書きで列挙** (= 追加質問ラウンドを減らす)
4. `gh issue comment ${ISSUE_NUMBER} --body-file ...` で投稿
5. ログ末尾に `=== TEST RESULT: FAIL ===` を書いて停止 (= **コードは 1 行も書かないこと**)
stdout に質問を吐いて止めるのは禁止 (= Web/Mobile から見えず履歴も残らない)。
4. L2: CLAUDE.md は「最初の 30 秒で読める入口」にする
CLAUDE.md に規約を全部書くのは失敗します。規約は正本(docs/rules/)に置き、CLAUDE.md は 入口と最重要ルールの再掲だけに絞ります。
実際に使っている構成です(プロジェクト固有の記述は一般化しています)。
# CLAUDE.md — プロジェクトガイド
このファイルは Claude Code がこのリポジトリで作業するときに **毎セッション読む前提** の概要です。
細かい規約・命名は `docs/` 配下を参照してください。本ファイルは「最初の 30 秒で読める入口」に
徹して、詳細はリンク先に集約します。
## このリポジトリは何か
(3〜5 行の要約 + ディレクトリツリー + テストの回し方)
## 最重要ルール (= 着手前に必ず確認)
| ルール | 出典 |
| --- | --- |
| **何らかの変更に着手する前に最新 main からブランチを切る** | [コーディング規約 §11.1](docs/rules/コーディング規約.md) |
| **テスト全件 PASS まで完了とみなさない** (型チェックだけでは不十分) | [テスト規約 §1](docs/rules/テスト規約.md) |
| **本番リリースは必ず main から** | [ビルド・テスト方式 §4.1](docs/rules/ビルド・テスト方式.md) |
| **仕様変更時はドキュメントと実装を同時更新** | [ビルド・テスト方式 §1.1](docs/rules/ビルド・テスト方式.md) |
| **API キーをクライアントへ出さない** | [セキュリティ設計書 §8](docs/design/セキュリティ設計書.md) |
| **曖昧な Issue/PR は推測で進めず、コメント投稿して停止** | [実行ルール §5](docs/rules/Claude実行ルール.md) |
## 作業の入口
| やりたいこと | 最初に読む |
| --- | --- |
| テストを動かしたい | `bash scripts/run.sh local` / docs/rules/テスト規約.md |
| コードを書きたい | docs/rules/コーディング規約.md |
| 画面を変えたい | docs/design/画面仕様書.md を **先に更新** → 実装 → テスト追加 |
## 触ってよい範囲 / 禁止事項
- `.claude/settings.json` の `deny` リストに従う
- **`.env` のコミット禁止**。秘密はサーバ側の環境変数/シークレットで管理
- ドキュメントと実装は **同時更新**。片方だけ直してコミットしない
- ルールの優先順位: **PJ 固有 (`docs/rules-project/`) > 汎用正本 (`docs/rules/`)**
効かせるためのポイントは 3 つです。
- 「最重要ルール」表に出典リンクを併記する — エージェントが根拠を辿れる。ルール同士が矛盾したときの解決にも使う
- 「やりたいこと → 最初に読むファイル」の対応表を置く — 全規約を読ませずに済み、必要な規約だけが確実に読まれる
- 優先順位を明文化する — 汎用ルールとプロジェクト固有ルールがぶつかったときの勝敗を先に決めておく
5. L2.5: 規約の正本化と配布(複数リポジトリを持っている場合)
プロジェクトが増えると、同じ規約のコピーが N 個できて内容がドリフトします。エージェントから見ると「どれが本当か分からない」状態になり、規約全体の信頼度が落ちます。
正本を 1 箇所に置き、git subtree で各プロジェクトへ配布する運用にしています。
| パス | 役割 | 編集してよい場所 |
| --- | --- | --- |
| `docs/rules/` | **汎用ルールの正本** | **上流リポジトリでのみ編集**。消費側 PJ では直接編集しない |
| `docs/rules-project/` | **PJ 固有規約** | 各 PJ で自由に編集。subtree pull で消えない |
### 優先順位 (矛盾時の解決)
**PJ 固有規約 (`docs/rules-project/`) > 汎用正本 (`docs/rules/`)**
- PJ 固有で「汎用ルールの一部を無効化する」場合は、
**どの汎用ルールを、なぜ上書きするか** を必ず明記する。
配布側・消費側のスクリプトはこれだけです。
# 上流 (正本リポジトリ): docs/rules を配布用ブランチへ split して push
bash scripts/publish-rules.sh
# 消費側 PJ: 初回導入 / 以後の追従
bash scripts/sync-rules.sh add
bash scripts/sync-rules.sh pull
sync-rules.sh の要点は、未コミット変更があると subtree が壊れるので先に止めることです。
RULES_REMOTE_URL="${RULES_REMOTE_URL:-git@github.com:<owner>/<template-repo>.git}"
RULES_DIST_BRANCH="${RULES_DIST_BRANCH:-rules-dist}"
PREFIX="docs/rules"
# コミットされていない変更があると subtree がエラーを返すため先に検出して止める
if ! git diff --quiet || ! git diff --cached --quiet; then
echo "[sync-rules] 作業ツリーに未コミット変更あり。commit/stash してから再実行してください。" >&2
exit 1
fi
サブモジュールではなく subtree にしている理由は、各リポジトリに規約の実体ファイルが存在することです。エージェントに読ませるファイルパスを CI から指定するため(後述の docs-paths)、実体がないと成立しません。
6. L3: 権限で物理的に禁じる(.claude/settings.json)
規約に「git push --force 禁止」と書くのと、実行できないようにするのは別の話です。Claude Code はプロジェクトスコープの設定で許可・拒否リストを持てます。
{
"permissions": {
"allow": [
"Bash", "Read", "Write", "Edit",
"Bash(bash scripts/run.sh:*)",
"Bash(rm -rf tests/results/* tests/.cache)",
"Bash(git switch *)",
"Bash(git branch *)",
"Bash(git stash *)",
"Bash(gh issue *)"
],
"deny": [
"Bash(rm -rf /*)",
"Bash(rm -rf ~)",
"Bash(rm -rf $HOME)",
"Bash(git push --force *)",
"Bash(git reset --hard *)",
"Bash(docker volume rm *)",
"Bash(docker system prune -af *)"
]
}
}
| 区分 | 意図 |
|---|---|
| allow テストループ | 実装 → テストの再実行を都度確認なしで回す(ここを許可しないと自律ループが毎回止まる) |
| deny 破壊操作 | ホームディレクトリ全消し事故を防ぐ |
| deny git 強制 | 履歴改変を伴う操作(= 取り消し困難)を遮断 |
| deny docker 巻き添え | 他 PJ のボリューム/イメージまで巻き込む操作を遮断 |
重要な性質が 2 つあります。
-
denyはallowより優先される。Bash(= 全許可)を allow に入れていても、deny のパターンには勝てません -
リモート実行も deny の対象にする。 SSH 越しのコマンドは文字列として渡るので、
Bash(ssh <user>@<host>*docker volume rm*)のように、危険な部分文字列でパターンを書きます
"deny": [
"Bash(ssh <user>@<host>*rm -rf*)",
"Bash(ssh <user>@<host>*down -v*)",
"Bash(ssh <user>@<host>*docker volume prune*)",
"Bash(ssh <user>@<host>*DROP TABLE*)",
"Bash(ssh <user>@<host>*TRUNCATE*)"
]
デプロイ先に SSH させる運用だと、ローカルの deny をすり抜けて本番でだけ破壊的操作が走る経路が生まれます。ここは実際に踏みかけた箇所です。
7. L4: ローカルゲート(pre-commit / pre-push)
CI で気づいても、秘密情報はすでに履歴に入っています。commit の手前で止めるのが pre-commit の役割です。
7.1 pre-commit — 秘密情報とファイル先頭コメント
.git/hooks/pre-commit に置いて chmod +x します。動作確認済みのものをそのまま載せます。
#!/usr/bin/env bash
# AI エージェントを含む「誰が commit しても」漏れを止める局所ゲート。
# なぜ pre-commit か: CI で気づいても秘密情報は既に履歴に入る。commit の手前で止める。
# バイパスは git commit --no-verify (= 意図的な一手間を要求する)。
set -euo pipefail
fail() { printf '\033[31m✗ pre-commit: %s\033[0m\n' "$1" >&2; exit 1; }
staged="$(git diff --cached --name-only --diff-filter=ACM)"
[ -n "${staged}" ] || exit 0
# 1) .env 系そのものを commit させない (.env.example だけ許可)
while IFS= read -r f; do
case "${f}" in
.env|.env.*|*/.env|*/.env.*)
case "${f}" in
*.example) continue ;;
esac
fail "${f} は commit 禁止です (秘密値は .env.example のキーだけ共有する)"
;;
esac
done <<< "${staged}"
# 2) 秘密情報スキャン (gitleaks があるときだけ。無い環境でフックを壊さない)
if command -v gitleaks >/dev/null 2>&1; then
gitleaks protect --staged --redact --no-banner \
|| fail "gitleaks が秘密情報らしき文字列を検出しました"
fi
# 3) ソースファイルの先頭コメント必須 (コーディング規約 §4.2 の機械チェック)
# 「何のためのファイルか」が無いファイルを新規追加させない。
while IFS= read -r f; do
case "${f}" in
*.ts|*.tsx|*.mjs|*.sh)
[ -f "${f}" ] || continue
head -n 5 "${f}" | grep -qE '^\s*(//|#)' \
|| fail "${f} の先頭 5 行に説明コメントがありません (コーディング規約 §4.2)"
;;
esac
done <<< "${staged}"
exit 0
動作確認の結果です。
$ git add .env && git commit -m x
✗ pre-commit: .env は commit 禁止です (秘密値は .env.example のキーだけ共有する)
$ git add bad.ts && git commit -m x # 先頭コメント無し
✗ pre-commit: bad.ts の先頭 5 行に説明コメントがありません (コーディング規約 §4.2)
$ git add good.ts && git commit -m x # 先頭に // コメントあり
[main 0f6d374] x
設計上のポイントを 3 つ。
-
gitleaksが無い環境ではスキップする。 フックが壊れて全員が--no-verifyを常用し始めると、ゲート自体が無効化されます -
チェック 3(先頭コメント必須)は規約の機械化。 「全ソースファイルに『何のためのファイルか』を書く」という規約は、レビューでは指摘漏れしますが、
head -n 5 | grepなら 100% 検出します -
バイパス手段は残す。
--no-verifyを消すのではなく「意図的な一手間」にしておきます
7.2 pre-push — main への直 push を止める
無料プランのプライベートリポジトリではブランチ保護(ruleset)が使えません。そこでローカルフックで最低限の防波堤を作ります。
#!/usr/bin/env bash
# main への「直接 push」を拒否するローカルガード (PR マージ経由のみ許可する運用)。
# なぜローカルフックか: private + 無料プランでは GitHub 側のブランチ保護/ruleset が使えないため、
# このマシンからの誤った main 直 push を最低限ブロックする。GitHub 側の強制ではない
# (他マシン・Web UI からの変更は防げない) 点に注意。
#
# pre-push は stdin で "<local_ref> <local_sha> <remote_ref> <remote_sha>" を 1 行ずつ受け取る。
set -euo pipefail
protected="refs/heads/main"
blocked=0
while read -r local_ref local_sha remote_ref remote_sha; do
if [ "${remote_ref}" = "${protected}" ]; then
blocked=1
fi
done
if [ "${blocked}" -eq 1 ]; then
echo "✗ pre-push: ${protected} への直接 push は禁止です (PR マージ経由のみ)。" >&2
echo " → フィーチャーブランチを push し、GitHub で PR を作成してマージしてください。" >&2
echo " どうしても必要なときだけ: git push --no-verify <remote> <branch> でバイパス可。" >&2
exit 1
fi
exit 0
限界も一緒にコメントに書いておくのが重要です。「これは GitHub 側の強制ではない」と書いておかないと、後から読んだ人(やエージェント)が保護されていると誤認します。
8. L5: CI ゲート — 「守ったふり」を機械的に潰す
ここが規約運用の本丸です。エージェントに自律実装させると、**最も高頻度で発生する問題は「テストが通っていないのに通ったことにする」**です。
8.1 テスト出力契約
まず、テストの成否を機械可読な形で固定します。CLAUDE.md に契約として書きます。
## テスト出力契約
```bash
# pipefail は必須 (無いと tee の終了コード=常に0 を見てしまう)。
# ${PIPESTATUS[0]} は使わない: bash 専用で、**zsh では常に PASS になる** (失敗を PASS と報告する)。
set -o pipefail
LOG="${RUNNER_TEMP:-.claude}/last-test.log"
bash scripts/run.sh local 2>&1 | tee "$LOG"
if [ $? -eq 0 ]; then
echo '=== TEST RESULT: PASS ===' >> "$LOG"
else
echo '=== TEST RESULT: FAIL ===' >> "$LOG"
fi
```
末尾 1 行が `=== TEST RESULT: PASS ===` でなければ push しない。
${PIPESTATUS[0]} の注意書きは実際に踏んだ罠です。bash 前提で書いたスクリプトが zsh で実行され、失敗が常に PASS として報告されていました。この手の「環境差で無言に壊れる」箇所は、規約側にコメントとして残しておくと同じ穴を二度踏みません。
8.2 手抜き PASS の禁止と検出
契約を書いただけでは、ログに PASS マーカーだけ書き込む挙動が出ます。規約で名指しして禁止します。
### 禁止事項
- ❌ run.sh を実行せず log file に PASS マーカーだけ手書きする (= 「手抜き PASS」)
- ❌ 別モードを実行して、実態は static だけなのに PASS マーカーで完了に持ち込む
- ❌ docker daemon 接続失敗等のエラー出力に PASS マーカーを連結して通そうとする
そして検出をワークフロー側に実装します。テスト実行の証跡(tests/results/ 配下の成果物)がジョブ後に増えていなければ、PASS 報告でも異常扱いに格上げします。
# テスト実走証跡 path。
# ジョブ開始後に新規ファイルが作られていない場合 result=PASS でも ABNORMAL に格上げ
# (= log file に PASS マーカーだけ書く「手抜き PASS」を検出)。
test-evidence-paths: |
tests/results/
「守れ」ではなく「守った証拠を出せ」に変えるのがポイントです。証跡はテスト実行の副作用として自然に生成されるものを選びます(Playwright の結果 Markdown、スクリーンショット等)。証跡自体を捏造できる形にしないことが条件です。
8.3 規約ファイルを CI から明示的に読ませる
セッションごとに規約が読まれる保証を、ワークフローの入力として持ちます。
# 毎セッション読むドキュメント。汎用正本 + PJ固有 + 設計書を必須化。
docs-paths: |
docs/rules/README.md
docs/rules/Claude実行ルール.md
docs/rules/ビルド・テスト方式.md
docs/rules/テスト規約.md
docs/rules/コーディング規約.md
docs/rules-project/README.md
docs/design/アーキテクチャ設計書.md
docs/design/セキュリティ設計書.md
docs/design/テスト仕様書.md
docs/rules/(全プロジェクト共通の正本)と docs/rules-project/(プロジェクト固有)を両方並べ、優先順位は規約側で決めてある、という構成です。
8.4 呼び出し側ワークフローの全体
Issue / PR にラベルが付いたら起動する形にしています。実装ロジックは再利用可能ワークフローに切り出し、各リポジトリの呼び出し側は設定だけを持ちます。
name: Claude Watch
on:
issues:
types: [labeled]
pull_request:
types: [labeled]
workflow_dispatch:
# 同じ Issue/PR の二重起動を防止。
concurrency:
group: claude-watch-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
cancel-in-progress: false
jobs:
watch:
if: contains(fromJson('["claude-go","claude-review"]'), github.event.label.name)
uses: <owner>/<your-flow-action>/.github/workflows/reusable.yml@main
with:
test-command: bash scripts/run.sh local
# 変更ファイルが light-test-paths 配下のみなら軽量テストに切替 (docs のみの PR 等)。
test-command-light: bash scripts/run.sh local --light
light-test-paths: |
docs/
README.md
test-evidence-paths: |
tests/results/
docs-paths: |
docs/rules/コーディング規約.md
docs/rules/テスト規約.md
max-retries: 5
claude-timeout: 2h
# Playwright 等が毎回再生成するバイナリを commit 前に main 状態へ戻す。
# これを指定しないと大量のバイナリ差分が PR に混入する。
restore-paths: |
docs/screenshots/
restore-paths は地味ですが効きます。スクリーンショットを撮る E2E を回すと、毎回数十枚の画像差分が PR に混入してレビュー不能になります。
8.5 中断条件を先に決めておく
無限ループ対策です。「PASS まで直し続ける」とだけ書くと、構造的に通らないテストを延々と再実行します。
- **中断条件 (タイムアウト含む)** = 以下のいずれかに該当したら、その時点までの差分を
**draft PR (= merge 防止) として残し、`claude-failed` ラベルを付けて停止** する:
- 同一テストで **5 回以上連続失敗** した
- GitHub Actions ジョブが **2 時間でタイムアウト** した
- テスト出力契約に違反した
- 仕様が曖昧で Issue/PR から決定できない (推測で書き進めない)
- **テスト実走証跡なし**
停止するときに draft PR として差分を残すのがポイントです。捨てると同じ試行錯誤を次回もやり直すことになります。
8.6 セキュリティゲート(DAST)を同じ導線に載せる
品質ゲートとセキュリティゲートを別運用にすると、後者は回らなくなります。同じランチャのモードとして持ちます。
#!/usr/bin/env bash
# OWASP ZAP ベースライン DAST スキャン。
# なぜ: 稼働中のアプリに対して受動スキャン (ヘッダ・クッキー・既知の弱点) を行い、
# セキュリティ回帰を機械的に検出する。run.sh security から呼ばれる (CI でも実行可)。
set -u
TARGET="${1:-http://localhost:${API_PORT:-8081}}"
ZAP_IMAGE="${ZAP_IMAGE:-ghcr.io/zaproxy/zaproxy:stable}"
WORK="${ZAP_WORK:-${PROJECT_DIR}/tests/results/zap}"
mkdir -p "${WORK}"
cp "${PROJECT_DIR}/.zap/rules.tsv" "${WORK}/rules.tsv" 2>/dev/null || true
command -v docker >/dev/null 2>&1 || { echo "[zap] docker が必要です"; exit 2; }
# host ネットワークで localhost のアプリへ到達する。-I = 警告では失敗しない (FAIL ルールのみ失敗)。
docker run --rm --network host \
-v "${WORK}:/zap/wrk:rw" \
"${ZAP_IMAGE}" zap-baseline.py \
-t "${TARGET}" \
-c rules.tsv \
-I \
-r zap-report.html \
-w zap-report.md \
-J zap-report.json
code=$?
# 0=問題なし, 2=警告のみ(-I により許容), それ以外=FAIL ルール検出 → 失敗扱い。
if [ "${code}" -eq 0 ] || [ "${code}" -eq 2 ]; then
echo "[zap] PASS (警告は許容、FAIL なし)"
exit 0
fi
echo "[zap] FAIL (重大アラート検出, code=${code})"
exit 1
受容する WARN は設定ファイル(.zap/rules.tsv)に理由付きで固定します。ここを曖昧にすると、警告が出るたびにエージェントが「対応不要と判断しました」と書いて素通りさせます。
**ZAP triage で受容する WARN**:
① `style-src 'unsafe-inline'` = React インラインスタイル + keyframes に必須
② SRI 欠如 = Web フォントの CSS は動的で安定ハッシュ不可 (将来セルフホストで解消)
③ COEP 未設定 = `require-corp` は地図タイル等のクロスオリジン資源を壊すため設定しない
9. 実運用して分かった 3 つの原則
9.1 抜け道を先に塞ぐ
エージェントは「ルールを守る」より「タスクを完了扱いにする」ことを優先します。規約を書くときは、「自分がこの規約を最小コストで満たすとしたらどうするか」を先にシミュレーションして、その道を塞ぎます。
| 素直に書いた規約 | エージェントの最小コスト解 | 塞ぎ方 |
|---|---|---|
| カバレッジ 100% | 到達困難行を除外設定に追加 | 除外禁止・mock 必須・無言の除外禁止 |
| テスト全件 PASS で完了 | ログに PASS マーカーだけ書く | 証跡パスの新規ファイル生成を必須化 |
| エラーは適切に処理 | catch (e) {} |
✅/❌ の対で最小実装例を提示 |
| ドキュメントも更新 | 更新せず PR を作る | 不要と判断した場合は PR 本文に理由明記を必須化 |
9.2 「守れ」ではなく「守った証拠を出せ」
検証できない規約は、規約ではなく願望です。各ルールについて「違反を機械的に検出する方法」をセットで考えます。検出方法が思いつかないルールは、優先度を下げるか、検出可能な形に書き直すべきサインです。
9.3 規約は 1 箇所、配布は自動
コピーが増えると内容がドリフトし、「どれが本当か」が誰にも分からなくなります。エージェントは矛盾する規約を渡されると、都合の良い方を採用します。正本を 1 箇所にして配布を自動化し、矛盾時の優先順位を明文化しておきます。
10. 導入チェックリスト
明日から手を付けるなら、この順番が費用対効果が高いです。
-
CLAUDE.mdを作り、最重要ルール 5〜10 件を出典リンク付きの表で置く - 規約から曖昧語(「適切に」「正しく」「問題なく」)を機械的に grep して潰す
- 主要ルールに ✅/❌ の対の例を付ける
- 「曖昧なら着手前に停止」と「質問前の自助調査」を規約化する
-
.claude/settings.jsonにdeny(破壊操作・履歴改変)を書く。SSH 越しの経路も忘れずに -
pre-commitで.envと秘密情報を止める -
pre-pushで main 直 push を止める - テスト出力契約を決め、証跡ファイルの生成を完了条件に加える
- 中断条件(連続失敗回数・タイムアウト・曖昧停止)を先に決める
- 規約が複数リポジトリにあるなら、正本化と配布の仕組みを入れる
次回
第 2 回は、この記事で触れた 「テスト全件 PASS」を実際に成立させるテスト設計 を扱います。テストピラミッドをエージェントに守らせる方法、外部依存を mock に引き下ろす設計、E2E の証跡(操作実行前/後スクリーンショット)の自動取得あたりが中心です。
筆者について
個人開発で PWA・API・IaC 周りを触りつつ、AI エージェントに実装を任せるための足場(規約・CI・自動化)を整えるのが最近の関心です。この記事で紹介した規約テンプレートや CI の構成は、テンプレートリポジトリとして手元で運用しています。
- GitHub: @KenichiOsakada
質問・「うちではこう塞いだ」といった知見があれば、コメントで教えてもらえると嬉しいです。