作業ツリーの棚卸しをしていて、手が止まりました。
.env は deny に書いてあります。secrets/ も丸ごと書いてあります。それなのに tools/backup/.env.bak は、どのルールにも当たっていませんでした。
半年前の自分が、設定を差し替える前に取っておいたバックアップです。中身は当時のまま、有効なキーが入っています。
先に結論を書きます
permissions.deny の Read ルールは、書いたパターンに一致したものだけを拒否します。当たり前の話ですが、実際の作業ツリーでこれが何を意味するかというと、ルールを何行書いたかではなく、秘密ファイルが何件残っているかで測らないと安全性は分からない、ということです。
私の環境では、素朴に2行書いた状態から数え直したところ、8件が素通りしていました。ルール側を10行に組み直して0件になりました。
以下は、その数え方と、組み直したあとに残った判断の話です。
秘密とソースが同じツリーに同居している
個人開発では、リポジトリの綺麗な分離がそのまま成り立たないことがあります。
私の場合、iOS と Android のアプリ、それに複数のサイトが1つの同期フォルダの下に並んでいます。アプリ側には Google Play への提出に使う署名鍵や google-services.json があり、サイト側には Cloudflare 用の .dev.vars があります。ビルド設定や素材と一緒に置いておかないと日々の作業が回らないので、物理的に分けるという選択が現実的ではありませんでした。
つまり、エージェントに読ませたいファイルと、絶対に読ませたくないファイルが、同じディレクトリの数階層下に混ざっています。この状態で permissions.allow に作業ルートを足すと、拒否ルールの精度がそのまま安全性になります。
出発点の設定は、こうでした。
{
"permissions": {
"deny": [
"Read(./.env)",
"Read(./secrets/**)"
]
}
}
書いた当時は、これで足りていたはずでした。足りなくなったのは、ツリーが育ったからです。
届いている範囲を、目で確かめずに数える
問題は、ルールを読んでも「今このツリーの何件に当たっているのか」が分からないことです。.env と書いてあれば .env は守られます。では .env.production は。apps/ios/config/AuthKey_ABC123.p8 は。
一件ずつ思い出すのは無理があったので、設定ファイルから deny の Read ルールを読み出して、実ツリーに当てて件数を出す小さなスクリプトを書きました。
#!/usr/bin/env python3
"""settings.json の permissions.deny に書いた Read ルールが、
実際の作業ツリーのどのファイルに届いているかを列挙する。
届いていない「秘密らしいファイル」を残余として出すのが目的。"""
import json, re, sys
from pathlib import Path
# 秘密らしさの判定。自分の環境に合わせて足していく
SECRET_HINTS = [
"**/.env*", "**/*.pem", "**/*.p8", "**/*.key", "**/*keystore*",
"**/*credential*", "**/*token*", "**/*secret*", "**/.dev.vars",
"**/google-services.json", "**/Keys.plist",
]
def read_deny_patterns(settings_path: Path):
"""permissions.deny から Read(...) ルールだけを取り出す"""
if not settings_path.exists():
return []
data = json.loads(settings_path.read_text())
rules = data.get("permissions", {}).get("deny", [])
out = []
for r in rules:
m = re.fullmatch(r"Read\((.+)\)", r.strip())
if m:
out.append(m.group(1).strip())
return out
def expand(root: Path, pattern: str):
"""先頭の ./ を落として glob に渡す。
ディレクトリに当たった場合は中身も再帰的に展開する"""
pat = pattern[2:] if pattern.startswith("./") else pattern
hits = set()
for p in root.glob(pat):
if p.is_dir():
hits.update(q for q in p.rglob("*") if q.is_file())
elif p.is_file():
hits.add(p)
return hits
def main(root_str=".", settings_str=".claude/settings.json"):
root = Path(root_str).resolve()
patterns = read_deny_patterns(root / settings_str)
if not patterns:
print(f"deny の Read ルールが見つかりません: {settings_str}")
return 1
covered = set()
print("== deny ルールが実際に届いているファイル ==")
for pat in patterns:
hits = expand(root, pat)
covered |= hits
print(f" {pat} -> {len(hits)} 件")
for h in sorted(hits)[:3]:
print(f" {h.relative_to(root)}")
suspects = set()
for hint in SECRET_HINTS:
suspects |= expand(root, hint)
gap = sorted(suspects - covered)
print("\n== 秘密らしいのに deny に当たっていないファイル ==")
if not gap:
print(" なし")
for g in gap:
print(f" !! {g.relative_to(root)}")
# 残余があれば終了コード1。CI やフックから呼べるようにしておく
return 1 if gap else 0
if __name__ == "__main__":
sys.exit(main(*sys.argv[1:]))
ポイントは3つあります。
ひとつめは、expand() でディレクトリに当たったときに中身を rglob で展開していることです。Read(./secrets/**) のようなルールは、ディレクトリそのものと中身の両方を意味として含みます。ここを展開しておかないと、件数が実態より小さく出ます。
ふたつめは、SECRET_HINTS を deny ルールとは別に持っていることです。ルール側だけを見ていると「書いたものは守られている」という当たり前の結論しか出ません。守りたい対象を独立に定義して差集合を取ることで、初めて漏れが見えます。
みっつめは、残余があるときに終了コード 1 を返していることです。これで pre-commit フックや CI から呼べます。
2行の設定で、8件が素通りしていました
冒頭の設定のまま走らせた結果です。
== deny ルールが実際に届いているファイル ==
./.env -> 1 件
.env
./secrets/** -> 1 件
secrets/deploy_token.txt
== 秘密らしいのに deny に当たっていないファイル ==
!! .env.local
!! .env.production
!! apps/android/app/google-services.json
!! apps/ios/config/AuthKey_ABC123.p8
!! apps/ios/config/Keys.plist
!! sites/labs/.dev.vars
!! sites/labs/.github/tokens.md
!! tools/backup/.env.bak
ルールは2行で、届いていたのは2件。守りたかったのは10件でした。
数字にすると当たり前に見えますが、設定ファイルを眺めているあいだは「.env は書いてある」という感覚だけがあって、.env.local が別物だという意識は薄れていました。ルールを書いた日と、ファイルが増えた日が離れているほど、この感覚のずれは広がります。
リネームひとつで網から外れる
組み直す前に、もう一段いやなことに気づきました。
**/.env* というパターンは、名前が .env で始まるものを拾います。ところが同じフォルダにあった env.old は拾いません。実際に確かめると、こうなります。
**/.env* -> .env, .env.local, .env.production, tools/backup/.env.bak
**/env* -> tools/backup/env.old
**/*env* -> .env, .env.local, .env.production, tools/backup/.env.bak, env.old
先頭のドットを外しただけで、別のパターンの世界に移ります。
さらに厄介なのは、これが deny ルール側だけの問題ではなかったことです。私が書いた SECRET_HINTS 自体が env.old を見落としていました。つまり最初のスキャンで「8件」と出たあの数字も、実は9件だった可能性があります。検出する側の網を信用しきってはいけない、という当たり前のことを、自分の書いたスクリプトに教わりました。
拡張子を足す、先頭のドットを外す、日付を後ろに付ける。バックアップを取るときに人が無意識にやる操作は、だいたいパターンマッチの外に出ます。この落とし穴の厄介なところは、作業として正しいことをしたときほど踏みやすい点にあります。設定を差し替える前に控えを取るのは、本来ほめられる手順のはずでした。
| パターン | 拾うもの | 取りこぼしやすいもの |
Read(./.env) | ルート直下の .env のみ | サブディレクトリの同名ファイル全部 |
Read(./**/.env) | 全階層の .env | .env.local などの派生 |
Read(./**/.env.*) | .env.local / .env.bak | env.old(先頭ドットなし) |
Read(./**/*env*) | 名前に env を含むもの全部 | ファイル名から判別できない秘密 |
Read(./secrets/**) | そのフォルダ配下すべて | フォルダ外に置かれた同種のファイル |
最下段が本題です。名前で守るという方法には、名前から分からないものは守れないという限界がついてきます。
組み直した deny ルール
残余が 0 になるまで足していった結果です。
{
"permissions": {
"deny": [
"Read(./**/.env)",
"Read(./**/.env.*)",
"Read(./**/.dev.vars)",
"Read(./**/*.p8)",
"Read(./**/*.pem)",
"Read(./**/*.key)",
"Read(./**/google-services.json)",
"Read(./**/Keys.plist)",
"Read(./**/*token*)",
"Read(./secrets/**)"
]
}
}
再実行すると、残余なしで終了コード 0 になります。
./**/.env -> 1 件
./**/.env.* -> 3 件
./**/.dev.vars -> 1 件
./**/*.p8 -> 1 件
./**/*.pem -> 0 件
./**/*.key -> 0 件
./**/google-services.json -> 1 件
./**/Keys.plist -> 1 件
./**/*token* -> 2 件
./secrets/** -> 1 件
== 秘密らしいのに deny に当たっていないファイル ==
なし
ここで注目していただきたいのは ./**/*.pem と ./**/*.key が 0 件のまま残してあることです。
いま該当ファイルはありません。それでも消しませんでした。ルールの価値は「今当たっている件数」ではなく「明日ファイルが増えたときに勝手に当たること」にあります。0 件のルールは無駄ではなく、将来分の予約です。
逆に言うと、このスクリプトの出力で 0 件のルールを見つけたときに「不要だから消そう」と判断してはいけません。私は一度そう考えかけました。消す判断が要るのは、ルールが多すぎて実行時のマッチング負荷が問題になったときだけで、この場合は当たった件数ではなくマッチングの回数で測ることを推奨します。この観点は許可ルールを積み上げたセッションが毎ターン重くなる話で別途扱っています。
2026年8月の変更で、前提が1つ変わりました
ここまでの話には、実は前提が隠れていました。「拒否ルールは、許可した領域の外側を守るものだ」という思い込みです。
Claude Code の v2.1.236 で、macOS におけるワイルドカードの read-deny ルールの扱いが変わりました。**/.env のようなルールが、読み取りを許可した領域の内側にあっても優先されるようになっています。一致したディレクトリの中身も対象に含まれ、ファイル名を変えて回避する余地が狭まりました。
裏を返すと、それ以前は許可の内側が弱かったということです。私が tools/backup/.env.bak を見つけて肝を冷やしたのは、まさにこの領域の話でした。
変更後の環境で改めて自分の設定を見ると、印象が変わります。deny ルールは「例外的に閉じる扉」ではなく、許可した部屋の中に置く鍵付きの引き出しとして機能します。であれば、多少細かく書きすぎるくらいでちょうどよい、というのが今の私の判断です。
作業ルートを途中で追加したときに拒否ルールがどこまで追随するかは、また別の論点になります。そちらは作業ルートを途中で足したときの境界の話にまとめてあります。
読み取り拒否だけに寄りかからない
とはいえ、名前の一致だけで守り切ろうとするのは無理があります。棚卸しのあとに実際に手を入れたのは、設定ファイルより先にツリーの側でした。
- バックアップを作業ツリーの外に出す。
tools/backup/ のような場所に古い設定を置く習慣をやめ、値が要るときは保管庫から取り出す形に変えました。これが一番効きました。ルールを1行も書かずに対象そのものが消えます。
- 値をファイルではなくプロセス経由で渡す。ビルドスクリプトが直接ファイルを読むのをやめ、環境変数として注入する形に寄せました。読み取りが起きなければ拒否も要りません。
- 残ったものを deny に書く。どうしてもツリー内に置く必要があるファイルだけを対象にすると、ルールの本数が減り、意味も明快になります。
順番が大事だと感じています。deny を先に厚くすると、ツリーを整理する動機がなくなります。整理を先にやると、必要なルールが自然に絞り込まれます。私自身、この順番を逆にしたまま半年を過ごし、その結果として冒頭のバックアップを見落としていました。
無人で走らせるときに確かめること
スケジュール実行やバックグラウンドのセッションで動かす場合、設定が読まれていること自体を確認する手順が要ります。
対話セッションなら /permissions で現在の状態を開けますが、無人実行では誰も見ていません。私は棚卸しスクリプトをジョブの前段に置いて、残余があれば終了コード 1 で止めるようにしました。
# ジョブの先頭で棚卸しを走らせ、漏れがあれば作業を始めない
python3 tools/scan_deny.py . .claude/settings.json || {
echo "deny ルールに漏れがあります。処理を中止します"
exit 1
}
設定ファイル自体が壊れていて読み込まれないケースもあります。スクリプトはルールが 0 件のときも 1 を返すので、その場合もここで止まります。設定が壊れたときに Claude Code 側がどう振る舞うかは壊れた settings.json とセーフモードの話で扱っています。
次にやること
まず、いま作業しているツリーの一番上でこのスクリプトを走らせてみてください。所要時間は数秒です。
残余が 0 件なら、それは今日の時点での 0 件です。3ヶ月後にもう一度走らせると、おそらく何か増えています。私はそれを見て、ルールを足すより先に「なぜこのファイルがここにあるのか」を考えるようにしました。
守り方を厚くする作業は達成感がありますが、守る対象を減らす作業のほうが、あとから効いてきます。