# CLAUDE_PROJECT_DIR で用語集をスキルの外に出す — .claude/ への書き込みが必ず承認を求める理由

> Claude Code は .claude/ を保護対象パスとして扱うため、許可ルールでは事前承認できません。書き換わり続ける用語集を CLAUDE_PROJECT_DIR で外に出した記録です。

- Source: https://oharu121.com/ja/blog/claude-code-project-dir-skill-glossary-approval-prompts/
- Published: 2026-08-17T10:58:35+09:00
- Tags: Claude Code, 開発ツール, 自動化

---
## はじめに

このブログは、記事を日本語と繁体字中国語に翻訳する Claude Code のスキルで書かれています。その翻訳の一貫性を保っているのが用語集です。244 語、3 列、1 語につき 1 つの訳語だけを決めてあります。スキルには、翻訳者が判断を迫られた語はその変更の中で必ず追加する、というルールがあります。つまりパイプラインはほぼすべての記事でこのファイルに書き込みます。その書き込みは毎回止まって、私に承認を求めてきました。

割り込まれること自体は日々の苛立ちにすぎません。本当に気になったのは、置き場所が間違っていると気づいたことでした。このファイルは `.claude/skills/blog/` にあり、指示を書いたファイルの隣に並んでいますが、指示ではありません。**パイプラインが読み、そして追記していくデータ**であり、設定用のディレクトリで散文のふりをしていたのです。

解決策は権限ルールではありませんでした。どんな権限ルールでも解決できないからです。`.claude/` は**保護対象パス**であり、ファイルを外に出すことだけが取れる手でした。スキルからは `` `${CLAUDE_PROJECT_DIR}` `` 経由で参照します。スキルが自分の持ち物でないファイルを指すための、文書化された方法です。

本記事では、当たり前に見える解決策がなぜ成立しないのか、出荷前に私が止めた回り道、そして移動によって表に出た 2 つの重複した定数を整理します。

## スキルの中で唯一書き込まれるファイル

`blog` スキルは 10 個の markdown ファイルでできています。そのうち 9 個はモデルにふるまいを教えるものです。物語をどう組み立てるか、コードフェンスをどう書くか、各ロケールでどの見出しが必須か。残る 1 個は参照表です。

この違いはディレクトリの一覧には表れませんが、git の履歴にはすぐ表れます。`glossary.md` に触れた直近 10 コミットは、すべて記事公開のコミットでした。書き戻しの取り決めがスキルの 4 か所に書かれているからです。

```text title=".claude/skills/blog/SKILL.md"
6. **Terminology comes from …** Read it before any
   translate or review pass, and add terms you had to decide on.
```

実行のたびに追記されるファイルは、もうデータベースです。**残り 9 個は年に数回、人が意図して編集します。この 1 個は月に数回、機械が編集します。**同じディレクトリで、ライフサイクルは正反対です。

## 権限ルールでは保護対象パスを事前承認できない

まず思いつくのは、これを設定の問題として扱うことです。Claude Code には許可リストがあり、書き込む先も決まっているのだから、ファイル名を書いた項目を足せば承認ダイアログは止まるはずだ、という筋です。エージェントは書くべきルールを探しに行きました。

これは誤りで、しかも `settings.json` をいくら読んでも分からない種類の誤りです。ドキュメントは `.claude/` 配下への書き込みが*「bypassPermissions モード以外では決して自動承認されない」*と明言したうえで、迂回路も閉じています。

> 設定ファイル内の `permissions.allow` ルールは、保護対象パスへの書き込みを事前承認しない。安全判定は Claude Code が設定の許可ルールを評価する前に走るため、`~/.claude/settings.json` や `.claude/settings.json` に `Edit(.claude/**)` のような項目を置いても、上の表のモードごとの結果は変わらない。

**順序こそが仕組みです。**権限ルールは保護対象パスの判定に負けるのではなく、そもそも参照されません。この書き込みを対象にしたルールを書いても、正しそうな見た目のまま、何にも一致しないでファイルに残り続けます。

*Figure — PermissionOrder: `.claude/` 配下への書き込みは 1 段目で止まるため、それを対象にしたはずのルールには決して到達しません。*

### ほかに保護されているもの

このリストに載っているのは `.claude/` だけではありません。`.git`、`.vscode`、`.idea`、`.husky`、`.cargo`、`.devcontainer`、`.yarn`、`.mvn` もすべて保護対象です。唯一の例外は `.claude/worktrees` で、これは Claude Code が自分の git worktree を置く場所だからです。

**つまり書くべき設定は存在しませんでした。**ファイルを動かすしかなかったのです。

## CLAUDE_PROJECT_DIR が指すのはスキルのファイルではなくプロジェクトのファイル

エージェントが最初に書いた移動案は、参照をすべて `../../../docs/glossary.md` に書き換えるものでした。私はそこで手を止めさせ、思い込みではなく実際の作法を調べるよう頼みました。結果として、この判断は二重に正解でした。

調査が最初に見つけたのは、計画に*反する*材料でした。Anthropic のスキル作成ガイドは、補助ファイルがスキルのディレクトリの中、`reference/` や `scripts/` の下に置かれる前提で書かれています。同梱こそがスキルの可搬性と自己完結性を保つ、というのが一般的な指針です。リポジトリの別の場所にあるファイルは、あのページでは想定されていません。

2 つ目に見つかったのが、認められた例外でした。Claude Code はスキルに 3 つの変数を置換します。そのうちの 1 つが、まさにこの用途のために存在します。

| 変数 | 展開先 | 対応バージョン |
| --- | --- | --- |
| `` `${CLAUDE_SKILL_DIR}` `` | そのスキルの `SKILL.md` があるディレクトリ | 記載なし |
| `` `${CLAUDE_PROJECT_DIR}` `` | プロジェクトのルート | **v2.1.196** |
| `` `${CLAUDE_PLUGIN_ROOT}` `` | プラグインの導入先。プラグインのスキル専用 | 記載なし |

**真ん中の変数に手を伸ばす前にバージョンを確認してください。**3 つの中でいちばん新しく、それより古い環境では置換自体が起きません。`` `${CLAUDE_PROJECT_DIR}` `` という文字列のままモデルに届き、エラーも出ず、パスが解決しなかった理由を示すものも出力に残りません。自分のデータを静かに見つけられないスキルとして発覚するより、先に知っておく価値があります。

ドキュメントはこの変数を*「スキルがどこに導入されているかに依存せず、プロジェクト内のスクリプトやファイルを参照する」*ためのものと説明しています。まさに今回の用途ですし、エージェントが選ぼうとした相対パスより厳密に優れています。同じページが、作業ディレクトリは*「Claude が `cd` を実行すると移動する」*と書いているからです。相対パスは何かがディレクトリを変えるまでは正しく、変数は常に正しいままです。

**そもそも危うくなっていたのは可搬性ではありません。**用語集はこのリポジトリの用語であって、もともと持ち運べる中身ではありませんでした。スキルは可搬なままで、それが結び付くデータははっきりプロジェクト固有になりました。この変数はそのためにあります。

*Figure — GlossaryReference: ファイルはディレクトリを変え、スキルは指し方を変えました。書き戻しのルールは何も変わっていません。*

5 つのスキルファイルにまたがる 7 か所の参照が、1 つの書き方になりました。

```text title=".claude/skills/blog/SKILL.md"
6. **Terminology comes from `${CLAUDE_PROJECT_DIR}/docs/glossary.md`** — outside
   this skill on purpose, because the pipeline appends to it and `.claude/`
   writes always prompt.
```

## ファイルを移して見えたもの

ファイルの置き場所によってのみ静かに成立していたことが、2 つありました。

**節見出しのもう 1 つの写し。**3 つの見出しはロケールごとに固定文字列で、`scripts/prose-check.ts` はその 9 個すべての写しを自前で持っていました。しかも*「per the table in glossary.md」*というコメント付きでした。このコメントは重複を名指ししつつ、同時に理由も説明しています。スクリプトがスキルのディレクトリに手を伸ばす筋合いはなく、だから表そのものを読めなかったのです。2 つの写しが一致しているかを確かめるものはありませんでした。**実際には一致していましたが、それは運です。**用語集を直すことと検査側を直すことは別々の行為で、片方が飛ばされてももう片方は気づかないからです。`.claude/` の外に出た今、スクリプトは表を読み、2 つ目の写しは消えました。

**逆向きの CI の穴。**ワークフローは `.claude/**` を除外しているので、用語だけのコミットで CI が走ることはありませんでした。ファイルを外に出せば、バイト単位で同一のサイトを出荷するために、インストールと検査とビルドと本番デプロイが一式走り始めるところでした。私は同じ変更の中で `docs/**` を 2 つの `paths-ignore` ブロックに追加し、その項目が誤りになる条件をコメントに残しました。

そして重複の解消は、1 つ隣のファイルで繰り返されました。エージェントが書いたパーサーは、列見出しからロケールへの対応表を宣言していたのです。

```ts title="scripts/lib/glossary.ts"
const COLUMN_LOCALE: Record<string, Locale> = {
	English: 'en',
	日本語: 'ja',
	繁體中文: 'zh-tw',
};
```

この 3 つの自称語は `src/i18n/config.ts` に `LOCALE_LABEL` としてバイト単位で同じものが既にあり、しかも新しいファイルが**その 4 行上で import しているモジュール**の中にありました。重複した定数を消すことだけが目的の変更が、重複を 1 つ持ち込んだわけです。コードレビューがマージ前に捕まえ、今は既存のレコードから導出しています。ただ覚えておく価値があるのはこちらです。**重複の解消は、消すのではなく場所を移すことになりがちです。**そして移った写しがいちばん見つけにくいのは、それを不要にしたはずの import のすぐ隣に着地したときです。

## バージョン番号を信じずに置換を確かめる

`` `${CLAUDE_PROJECT_DIR}` `` には Claude Code v2.1.196 以降が必要です。当時の環境は 2.1.193 で、パッチが 3 つ足りませんでした。そのため最初の版では、参照のたびにリポジトリルートからのパスを併記するフォールバックを付けていました。

2.1.233 に上げたあと、バージョンの計算上は機能が使えるはずでした。しかしそれが示すのは機能が出荷されていることであって、スキルの markdown で実際に発火することではありません。そこでエージェントは、一時的なプロジェクトに 2 つの変数だけを書いた使い捨てのスキルを作り、呼び出してみました。

```text
PROJECT_DIR=…/scratchpad/probe-proj
SKILL_DIR=…/scratchpad/probe-proj/.claude/skills/probe
```

どちらも実在する絶対パスとして返ってきました。つまりフォールバックの文面は、もう起こりえない分岐を説明していたことになります。私はそれを削らせました。起こりえない状態を書いた指示はただではありません。`SKILL.md` は `/blog` を呼ぶたびに読み込まれますし、**起こりえない状況を説明した記述は、読み手が起こりうる記述まで読み飛ばすようになる原因です。**

この調査では想定していなかった副産物も出ました。本記事を書く際、エージェントはスキルを呼び出すときに `` `${CLAUDE_PROJECT_DIR}` `` をそのまま引数として渡したのですが、それは絶対パスに展開された状態で届いたのです。**置換はファイルに書かれた本文だけでなく、スキルの内容に差し込まれる引数にも適用されます。**

## 何もしていなかった 2 つ目の権限ルール

保護対象パスから分かったのは、ルールが設定ファイルに置かれたまま一度も参照されないことがある、という点です。それが頭に入ると、ほかにも同じものがないか見たくなります。

このリポジトリには設定ファイルが 2 つあります。コミットされる `settings.json` と、gitignore される `settings.local.json` です。local のほうは、コミットされる側とほぼ同じ内容にまで広がっていて、94 行の中に許可ルールを 84 個抱えていました。そのうち 2 つは、コミットされる側には存在しませんでした。

| local ファイルにだけあるルール | 実際にしていたこと |
| --- | --- |
| `Bash(pkill -f "astro preview")` | 何もしていない。実際のコマンドラインは `astro.mjs preview` だから |
| `Bash(pnpm exec *)` | コミット側が `Bash(pnpm exec astro *)` に絞ったルールを、黙って広げ直していた |

面白いのは 2 つ目です。**権限ルールは設定ファイルをまたいで統合されるため、片方のファイルでルールを絞っても、もう片方に広いままの写しが残っていれば何の効果もありません。**

この読み方に立てば、コミット側の `pnpm exec astro *` は何もしていなかったことになります。`pnpm exec` で届くものは、隣のルールによってすでに事前承認されていたからです。結論は正しく、理由は誤りでした。それが次の節です。

*Figure — RuleShadowing: どちらのファイルを単体で見てもこれは分かりません。だから絞ったルールが効いているように見えていました。*

そこで私は、local ファイルをそれだけが提供している `additionalDirectories` まで削らせました。エージェントは読むのではなく集合演算で検証しました。削除した 84 個のうち 82 個はコミット側にまだ存在し、存在しない 2 個はちょうど例の死んだルールと過剰な許可だけだ、というわけです。そして片付けは安全だと報告してきました。

## 検証が測っていたのは別の性質だった

次のブラウザ操作の呼び出しが承認を求めてきました。その次も同じでした。

`settings.local.json` を戻すと承認ダイアログは止まりました。その間 `settings.json` は触っていません。両方のファイルが同じルールを持っているなら、これは奇妙な結果です。答えは権限のドキュメントにありました。

> プロジェクトの `.claude/settings.json` にある `permissions.allow` ルールと `permissions.additionalDirectories` の項目は権限を与えるものなので、Claude Code はそのフォルダーのワークスペース信頼ダイアログを承認したあとにのみ適用する。

このリポジトリは一度も信頼されていませんでした。`~/.claude.json` の中で `hasTrustDialogAccepted` が `false` だったのです。つまり**コミットされたファイルの許可ルールはすべて保留されていました。**gitignore されたファイルが担っていたのは、両方に共通する 82 個です。コミット側にしかない残りは、どこでも効いていませんでした。local の写しがこの関門を抜けるのには明確な理由があります。Claude Code は git を実行して、あなた自身のファイルとリポジトリ由来のファイルを見分けます。そして local ファイルも追跡対象になった時点で、同じように保留されます。

### settings.local.json をコミットしても解決しない理由

この仕様が、いちばん綺麗に見える解決策を否定します。2 つのファイルがずれるのを止めようとして `settings.local.json` をコミットすると、それはリポジトリ由来のファイルに変わり、同じ関門の後ろに回されます。結果として両方のファイルが保留され、効いているものは何もなくなります。

**集合演算は正しく、そして役に立ちませんでした。**2 つのファイルの中身を比べ、片方に現れているからもう片方で消しても大丈夫だと結論しただけです。あるファイルのルールがそもそも適用されるかどうかは中身の性質ではないので、いくら中身を比べてもこれは捕まえられません。関門は非対称でもあり、覚えておく価値があるのはその点です。`deny` はどのスコープでも保留されません。信頼されていないリポジトリは、できることを狭められても、広げることはできないのです。

これに気づくまで承認ダイアログを 2 回はさんだのには理由があります。**承認を通したダイアログと、最初から事前承認された呼び出しは、エージェントの側からは見分けがつきません。**どちらも成功したツール結果を返すからです。エージェントは、私が直前に手で承認した呼び出しについて「ダイアログは出なかった」と報告しました。それを口に出して初めて訂正できました。

## まとめ

- **`.claude/` は保護対象パスです。**bypassPermissions モード以外では書き込みが自動承認されることはなく、安全判定は `permissions.allow` を読む*前*に走ります。`Edit(.claude/**)` のようなルールは、上書きされるのではなく一度も参照されません。
- **パイプラインが書き込むファイルは、スキルのディレクトリに置くものではありません。**年に数回だけ人が編集する 9 個の指示ファイルと、月に数回機械が編集する 1 個の表は、一覧の見た目が同じでもライフサイクルは正反対です。
- **`` `${CLAUDE_PROJECT_DIR}` `` が認められた出口です。**スキルの導入先に依存せずプロジェクトのファイルを指し、相対パスと違って作業ディレクトリの変更に耐えます。v2.1.196 以降が必要です。
- **バージョン番号から推測せず、置換そのものを確かめてください。**変数だけを書いた使い捨てのスキルなら、呼び出し 1 回で答えが出ます。
- **重複の解消は、確かめない限り場所を移すだけになります。**重複した定数の置き換えが、それを不要にした import の 4 行下で既存の定数を宣言し直していました。
- **権限ルールは統合されるので、絞ったルールが別の場所で黙って広げ直されることがあります。**どちらの設定ファイルも、単体では実効的な状態を示しません。
- **コミットされた `.claude/settings.json` は、フォルダを信頼するまで何も与えません。**`allow` は保留され、`deny` はどのスコープでも保留されません。ルールが効いていると結論する前に `hasTrustDialogAccepted` を確認してください。また `settings.local.json` をコミットして関門を回避しようとしないでください。そのファイルまで同じ関門の後ろに回るだけです。
- **ファイルの中身を比べても、何が効いているかは分かりません。**別のファイルに存在するルールがそれで有効になるわけではなく、その違いはファイルだけを読む検査からは見えません。

## 参考リンク

- [Claude Code permission modes, including the protected paths section stating that allow rules are evaluated after the safety check](https://code.claude.com/docs/en/permission-modes)
- [Claude Code skills reference, with the table of string substitutions and the v2.1.196 requirement for CLAUDE_PROJECT_DIR](https://code.claude.com/docs/en/skills)
- [Anthropic's skill authoring best practices, which assumes supporting files are bundled inside the skill directory](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices)
