タグ
白いカードにテラコッタ色のClaudeの星形ロゴと黒のロゴタイプ

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

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

目次

はじめに

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

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

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

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

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

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

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

.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.jsonEdit(.claude/**) のような項目を置いても、上の表のモードごとの結果は変わらない。

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

保護対象パスの判定は権限ルールより先に走る2 つの書き込みが同じ 2 段階のパイプラインに入る。1 段目が保護対象パスの判定、2 段目が permissions.allow の権限ルール。.claude/ 配下への書き込みは 1 段目で止まり、常に承認ダイアログを出す。✗ を付けた破線が示すとおり 2 段目には到達しないため、どの権限ルールも適用されない。docs/ 配下への書き込みは 1 段目を通過して 2 段目に届き、自動承認できる。.claude/ 配下への書き込みdocs/ 配下への書き込み1保護対象パスの判定常に承認ダイアログ2permissions.allow の権限ルール参照されない自動承認が可能
.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 を実行すると移動する」*と書いているからです。相対パスは何かがディレクトリを変えるまでは正しく、変数は常に正しいままです。

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

移動前と移動後の用語集2 つの列を 3 行で比較する。移動前は用語集が .claude/skills/blog/glossary.md にあり、skill は相対リンク glossary.md で参照し、書き込むたびに承認ダイアログが出る。移動後は docs/glossary.md にあり、skill は ${CLAUDE_PROJECT_DIR}/docs/glossary.md で参照し、書き込みはダイアログなしで通る。移動前置き場所.claude/skills/blog/glossary.mdskill からの参照[glossary.md](glossary.md)書き込むと毎回ダイアログ移動後置き場所docs/glossary.mdskill からの参照${CLAUDE_PROJECT_DIR}/docs/glossary.md書き込むとそのまま通る
ファイルはディレクトリを変え、スキルは指し方を変えました。書き戻しのルールは何も変わっていません。

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

.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 つ隣のファイルで繰り返されました。エージェントが書いたパーサーは、列見出しからロケールへの対応表を宣言していたのです。

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

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

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

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

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

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 で届くものは、隣のルールによってすでに事前承認されていたからです。結論は正しく、理由は誤りでした。それが次の節です。

片方の設定ファイルの広いルールが、もう片方の狭いルールを押し広げる2 つの設定ファイルが左右に並ぶ。コミット対象の settings.json には狭いルール Bash(pnpm exec astro *) があり、gitignore 対象の settings.local.json には広いルール Bash(pnpm exec *) がある。権限ルールは設定ファイルをまたいで統合されるため、両方が統合の帯に流れ込む。この読み方では、統合結果が pnpm exec で届くものをすべて自動承認するので狭いほうのルールは何もしておらず、しかもどちらのファイルを単体で見てもそれは分からない。記事はこのあと、コミット側のルールが効いていなかったより根本的な理由を見つける。settings.jsonコミット対象Bash(pnpm exec astro *)狭いsettings.local.jsongitignore 対象Bash(pnpm exec *)広い権限ルールは設定ファイルをまたいで統合されるpnpm exec で届くものはすべて自動承認狭いほうのルールは何もしていない
どちらのファイルを単体で見てもこれは分かりません。だから絞ったルールが効いているように見えていました。

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

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

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

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

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

このリポジトリは一度も信頼されていませんでした。~/.claude.json の中で hasTrustDialogAcceptedfalse だったのです。つまり**コミットされたファイルの許可ルールはすべて保留されていました。**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 をコミットして関門を回避しようとしないでください。そのファイルまで同じ関門の後ろに回るだけです。
  • **ファイルの中身を比べても、何が効いているかは分かりません。**別のファイルに存在するルールがそれで有効になるわけではなく、その違いはファイルだけを読む検査からは見えません。

参考リンク

この記事をシェア