こんにちは!Martimです!
今回は、Terraform×Notion管理の構成にClaude Codeを組み込んでいく中で、CLAUDE.mdとsettings.jsonの設計についてハマりながら分かったことを共有していきます!
目次
- CLAUDE.mdの階層設計
- CLAUDE.mdに「禁止」と書くだけでは効かない
- denyリストは一度で完成しなかった
- MCPサーバーはBashのdenyをすり抜ける
- settings.jsonの置き場所
- グローバルとローカルの2層で管理する
- おわりに
はじめに
皆さん、「CLAUDE.mdに禁止事項を書いておけば、Claude Codeはちゃんと守ってくれるはず」と思ったことはありませんか? 自分は最初、普通にそう思っていました。
AWS構築をTerraformで行い、設計書をNotionで管理している構成のなかで、Claude Codeをどう組み込むかを個人的に調べ・試してみたのですが、CLAUDE.mdの階層設計と、settings.jsonによる権限制御、この2つを分けて考える必要があると気づくまでに、地味に遠回りをしました。その過程をまとめます。
個人的な検証・調査の範囲であり、実用的なものとして書いているものではありません。
検証環境も限定的なので、「こういう考え方もある」くらいの温度感で読んでもらえるとありがたいです。
正直なところ、誰かに向けてというより自分自身の備忘録として残している内容です。
同じところでハマる人が万が一いれば、参考になれば嬉しいくらいの位置づけで読んでもらえればと思います。
CLAUDE.mdの階層設計
Terraformでインフラを構築する場合、ディレクトリはだいたい以下のような階層になります。
プロジェクト名/
└── dev/
├── CLAUDE.md # ルート側
├── 01_iam/
│ └── CLAUDE.md # 下層側
├── 02_network/
├── 03_app/
Claude Codeは、起動したディレクトリとその上位階層にあるCLAUDE.mdはセッション開始時に必ず読み込み、下位階層(サブディレクトリ)のCLAUDE.mdはClaudeがそのディレクトリのファイルに実際にアクセスしたタイミングで読み込む、という仕組みになっています。
これを知らずに全部ルートのCLAUDE.mdに書こうとすると、すぐに肥大化します。なので自分は以下のように役割を分けました。
- ルート側のCLAUDE.md: 全体で統一すべきルール(命名規則、コーディング規約、各下層への導線など)を書く
- 各下層のCLAUDE.md: そのディレクトリ固有の内容(参照する設計書のURL、他スタックのOutput参照先など)を書く
→ つまり、全体ルールと個別ルールを物理的に分離することで、下層は「そのディレクトリで必要な情報だけ」を持てばよくなり、結果的にトークン消費も抑えられます。
読み取り先のNotionのURLなども、下層のCLAUDE.mdにそのディレクトリが使う分だけ書くようにすると、読み込み範囲が自動的に絞られるので相性が良いと感じました。一覧性がほしい場合は、ルート側に地図として全体のURL一覧を置いておく、という併用もできそうです。
CLAUDE.mdに「禁止」と書くだけでは効かない
ここが一番ハマったところです。
最初、「terraform applyは禁止」とCLAUDE.mdに書けば、それで止まるだろうと思っていました。初心者あるあるですね。
CLAUDE.mdはあくまでClaudeへの「意図の説明」であり、実行を強制する仕組みではありません。実行を強制力を持って止めたいなら、settings.jsonのpermissions設定側で制御する必要があります。
この2つの役割分担に気づいてからは、以下のように整理しました。
| ファイル | 役割 |
|---|---|
| CLAUDE.md | なぜその規約があるか、何を守ってほしいかの説明 |
| settings.json | 実際に強制力を持ってブロックする設定 |
settings.jsonのpermissionsはdeny→ask→allowの順に評価され、どこかの階層でdenyに該当したコマンドは、他の階層でallowしていても実行できません。 ここは検証していて素直に助かったポイントです。
一方で注意点もあります。Bashの権限パターンをコマンド引数だけで絞る書き方(例えば特定のオプションだけを狙って許可・拒否する形)は、公式ドキュメントでも「脆い」と明記されています。引数の書き方を少し変えられただけで意図した制御をすり抜ける可能性があるため、自分はterraform applyやterraform destroyのようにコマンド単位で広めにdenyする方針にしました。
あと、planモードで動かしている間は読み取り系のコマンドとファイル読み取りしか実行されない、という挙動も確認できたので、まず調査・確認だけさせたいフェーズではplanモードを使う、という使い分けもできそうです。
denyリストは一度で完成しなかった
権限まわりでもう一つ実感したのが、「stateを変更しうるコマンドを最初から全部洗い出すのは難しい」ということです。
自分は当初、applyやdestroyのような分かりやすいコマンドだけdenyしておけば十分だろうと考えていました。
ですが実際に検証していくと、terraform refresh(実環境の状態をstateに書き戻すコマンド)やterraform workspace deleteのような、一見地味なコマンドでもstateを変更・削除できてしまうことに気づきました。
これらはapplyのdeny設定とは文字列が一致しないため、素通りしてしまいます。
denyリストは最初に一括で決め切るのではなく、実際にコマンドを叩きながら「これも止めないといけない」を積み上げていく前提で考えた方がよさそうです。
AWS CLI側でも似たことがありました。
S3への書き込み系コマンドをaws s3 cpやaws s3 rmのように個別に列挙してdenyしていたのですが、aws s3api put-objectは入れていてもaws s3api put-bucket-policyのような別の書き込み系コマンドが漏れていました。
個別列挙だと際限なく漏れが出るので、aws s3api put-*のようにプレフィックスでまとめて塞ぐ方針に変えました。
逆に、denyを広げすぎて自分の首を絞めた例もあります。
Read(**/*.tfvars)のようにtfvarsの読み取りをdenyしようとしたところ、**Read側のdenyはEdit(Write)側の権限も一緒に止めてしまい、結果としてClaudeがterraform.tfvarsを新規作成できなくなりました。
** 読み取りだけを制限したつもりが、生成物そのものが作れなくなる、というのは検証してみて初めて気づいた挙動でした。
あと、terraform plan -lock=false(stateをロックしない差分確認)はallowに指定して、あえてclassifierの審査を通さないようにしています。
理由は、planが「インフラに変更を加えるコマンドだ」と誤って判定され、ブロックされることがあったためです。
ロックなしのplanと、ロックを取る通常のplanとで挙動を分けたい場合、明示的にallowを指定しないと意図通りに動かないケースがある、というのも実際に触ってみての気づきでした。
余談ですが、Terraform公式のドキュメントサイト(registry.terraform.io)はJavaScriptでレンダリングされているようで、Claude CodeのWebFetchでは本文を取得できませんでした。
プロバイダのドキュメントを参照させたい場合は、GitHub上のraw形式(hashicorp/terraform-provider-awsなど)で取得する形に切り替える必要があり、これは想定していなかった回り道でした。
denyの見落としはBash以外でも起きました。
gitのbranch -d/-Dのような、一覧には入れていたのに設定への反映を忘れていたコマンドが後から見つかったり、Notionへの書き込み系ツールを名指しでいくつかdenyしていたつもりが、管理側の設定で別途許可されている書き込み系ツールが漏れていたりしました。
「動くはずのdenyが実は効いていない」は、コマンド単位・ツール単位で個別に書けば書くほど起きやすくなるので、書き込み系はできるだけプレフィックスでまとめて括る、という方針に寄せています。
MCPサーバーはBashのdenyをすり抜ける
これは検証していて一番ひやっとした気づきです。
Bash(aws s3 rm:*)のようなコマンド文字列ベースのdenyは、あくまでBashで実行されるコマンドの文字列に対する制限です。
もし同じAWS操作をMCP経由のツールで行えるサーバーが接続されていた場合、Bash側のdenyはそのMCP経由の操作には一切効きません。
CLIの入り口だけ塞いでも、別の入り口が開いていれば意味がない、というごく当たり前のことですが、実際に自分の環境でNotion以外にも複数のMCPサーバーが(未認証ではあるものの)候補として存在しているのを確認したときは少し背筋が伸びました。
自分が取った対策は、実際に使っているNotion関連のツールだけを個別に許可・制限し、それ以外の未使用MCPサーバーはサーバー単位でまるごとdenyしておく、というものです。
理由は、組織側のデフォルト設定でMCPツールが広めに許可されていることがあり、今は未認証で使えないサーバーでも、誰かが認証した瞬間にそのまま使えるようになってしまう可能性があるためです。
使う予定のないサーバーは、個別のツール単位ではなくサーバー名だけを指定してコンテキストごと締め出しておく方が安全だと判断しました。
settings.jsonの置き場所
Terraformの実行は各下層ディレクトリ(02_networkなど)で行う前提だったので、最初は「作業者がどこでClaude Codeを起動するか分からない以上、各ディレクトリに.claude/settings.jsonを置く必要があるのでは」と考えていました。
ですが、これはやってみて無駄が多いと分かりました。
Claude Codeはセッション開始時にsettings.jsonを1つしか読み込まないため、下層ごとに置いても管理対象が増えるだけで、全体の統一という目的には合いません。
自分の場合はプロジェクト用に配布されたPCという前提だったので、ホームディレクトリの~/.claude/settings.jsonにまとめて置くことで解決しました。
ホームに置いておけば、そのマシン上のどのプロジェクト・どのディレクトリでClaude Codeを起動しても同じpermissionsが適用されます。
ここで一つ、実際に手を動かして確認した挙動があります。
ホーム直下にsettings.jsonとsettings.local.jsonを両方置いた状態で、settings.local.json側にはapplyを、settings.json側にはdestroyをそれぞれdenyしてみました。
その状態でterraform apply --helpとterraform destroy --helpを実行したところ、**applyは普通に実行され、destroyだけpermission errorになりました。
** つまり、settings.local.json側に書いたdenyは無視され、settings.json側の内容だけが反映されていた、ということです。
**ホームディレクトリ直下にsettings.jsonとsettings.local.jsonを並べて置いただけの状態では、settings.local.json側は評価されず、settings.json側だけが効きます。
** これが「ホームに置くとlocalが使えない」という体感の正体でした。
この時点では、制御を入れるならホーム側のsettings.json本体に直接書くしかない、と考えていました(この後、プロジェクト側にsettings.local.jsonを置く形で解決します)。
なお、事前の調査でVS Code拡張機能ではpermissionsが正しく反映されないという報告をいくつか見かけていたのですが、自分の環境(VS Code拡張としてClaude Codeを動かす構成)では、少なくとも今回のapply/destroyの検証範囲では、そうした事象は再現しませんでした。
バージョンや環境によって差がある可能性はあるので、あくまで自分の1ケースでの確認結果としてここに書いておきます。
グローバルとローカルの2層で管理する
前章で書いたとおり、検証用の一時denyをホーム側に持っていったら効かなくなった、という経験をしました。
そこから、権限は「どのプロジェクトでも共通で必要なもの」と「特定のプロジェクトだけで必要なもの」に分けて考えるべきだと気づき、以下の2層構成に落ち着きました。
-
グローバル:
~/.claude/settings.json。起動するディレクトリに関わらず常に読まれるので、どのプロジェクトでも共通して必要な制限(applyやdestroyのdeny、gitの操作制限など)をここに置く -
ローカル: 各プロジェクトのクローン配下の
.claude/settings.local.json。そのプロジェクトだけで必要な追加の制限をここに置く。.gitignoreされるので他メンバーへの影響もない
**denyは階層を問わず効き、ローカル側からグローバル側のdenyを緩める(許可し直す)ことはできません。
あくまで上乗せだけができる、という一方向の関係です。
** 同じルールをグローバルとローカルの両方に書くと、片方だけ直したときに不整合が起きるので、置き場所は必ずどちらか一方に決めるようにしています。
Windows環境で試したときに気づいたのですが、settings.local.jsonは起動したディレクトリを基準に読み込まれるようです。
ホームで一括管理しようとして~/.claude/settings.local.jsonに置いても、実際に作業しているディレクトリ側でないと読まれない、というのは最初戸惑ったポイントでした。
もう一つ、権限の指定方法自体もシンプルにしました。
当初はdeny/ask/allow/指定なし、と4種類で考えていましたが、defaultModeをdefault(確認を挟むモード)にしておけば、「指定なしの操作」にも自動で確認が入ります。
なので個別にaskを書く必要はなく、**「絶対に通したくないものはdeny、確認なしで即実行してよい読み取り専用のものだけallow、それ以外は書かない」**という3分類まで単純化できました。
本番運用に入ったあとの制限については、stateファイルなど破壊的な操作につながるEdit権限を落とすことで、意図しない変更を防げるのではないかと考えています。ただしこれはまだ自分の中での設計判断の段階で、実際に長期運用したうえでの検証結果ではありません。
おわりに
CLAUDE.mdの階層設計と、settings.jsonによる権限制御は、似ているようで役割がまったく違う、というのが今回の一番の気づきでした。
「書けば守ってくれる」と思っていたところから、「強制したいなら仕組み側で縛る」という発想に切り替えるまでに、地味に遠回りをした実感があります。
それでは!よいAWSライフを!