セッションを止めずに /add-dir でもう一つのリポジトリを足したのは、iOS 側のビルド設定を並べて確認したかったからでした。
個人開発でアプリ側のリポジトリとサイト側のリポジトリを行き来していると、こういう横断はどうしても出てきます。
作業自体はすぐ終わりました。気になったのは、そのあとです。
普段どおり秘匿ファイルの棚卸しを走らせたところ、件数が変わっていませんでした。追加したルートには、設定ファイルが何本も入っているはずなのに。
数分かけて追い、原因は単純でした。棚卸しの起点が最初のルートのままだったのです。ルートを増やすという操作は、そこにぶら下がる前提まで一緒に運んではくれません。
セッションは正常。エラーもゼロ。ただ、検査の網だけが古いところに張られたままでした。
追加したルートは、既存ルートの子ではない
/add-dir や SDK の register_repo_root でルートを足すと、感覚としては作業範囲が「広がった」ように見えます。
実際に起きているのは、独立したルートがもう一本並んだ状態です。親子ではなく、並列。
この違いが効いてくるのが無視規則です。git の除外規則は、リポジトリの外側には適用されません。当たり前の仕様ですが、ルートが二本になった瞬間に、その当たり前が運用上の穴になります。
同じ構造を手元で作って確かめました。root_a には secrets/ を無視する .gitignore を置き、root_b には置いていません。中身はどちらも同じ secrets/prod.env です。
# root_a 側: .gitignore が効いている
$ git -C root_a check-ignore -v secrets/prod.env
.gitignore:1:secrets/ secrets/prod.env
# root_a から root_b のファイルを判定させようとすると、そもそも受け付けない
$ cd root_a && git check-ignore -v ../root_b/secrets/prod.env
fatal: ../root_b/secrets/prod.env: '../root_b/secrets/prod.env' is outside repository
# root_b 側では、同じパスが無視対象にならない
$ git -C root_b check-ignore -v secrets/prod.env
(出力なし・終了コード 1)
git status まで見ると、差は目に見える形で出ます。
$ git -C root_a status --porcelain
A .gitignore
A src/app.ts
$ git -C root_b status --porcelain
A secrets/prod.env ← 追加ルート側では staged になる
A src/app.ts
root_a では secrets/prod.env が一覧にすら現れず、root_b では素通しで staged まで進んでいます。
つまり、追加ルートに対する判定は、必ず追加ルートの中で git を回さないと正しい答えになりません。親ルートから相対パスで覗きにいく実装は、静かに間違った「安全」を返します。
deny パターンは、どの照合器で読まれるかで意味が変わる
無視規則の次に確認したのが permissions.deny です。
こちらは git とは別系統で、パターン文字列の照合になります。ここで前提が一つ崩れました。同じパターンでも、照合器が違えば結果が変わります。
代表的な三つ、fnmatch・Path.match・glob と同じ ** セマンティクスを並べて、同じパスに当ててみました。
import fnmatch, re
from pathlib import PurePosixPath
def m_glob(p, pat):
"""glob と同じ ** セマンティクス(0個以上のディレクトリ)を再現する"""
rx = (re.escape(pat)
.replace(r"\*\*/", "(?:[^/]+/)*")
.replace(r"\*\*", ".*")
.replace(r"\*", "[^/]*")
.replace(r"\?", "[^/]"))
return re.fullmatch(rx, p) is not None
for pat in ["*.env", "**/*.env", "config/*.env", "**/config/*.env"]:
for p in ["config/prod.env", "ios/Config/secrets.env",
".vscode/settings.json", "scripts/build.sh"]:
print(pat, p,
fnmatch.fnmatch(p, pat),
PurePosixPath(p).match(pat),
m_glob(p, pat))
実行して出た結果を表にしたものが以下です。
| パターン | パス | fnmatch | Path.match | glob(**) |
*.env | config/prod.env | True | True | False |
*.env | ios/Config/secrets.env | True | True | False |
**/*.env | config/prod.env | True | True | True |
**/*.env | ios/Config/secrets.env | True | True | True |
config/*.env | config/prod.env | True | True | True |
config/*.env | ios/Config/secrets.env | False | False | False |
**/config/*.env | config/prod.env | False | False | True |
**/config/*.env | ios/Config/secrets.env | False | False | False |
読み方が二か所あります。
一つ目は *.env の行です。fnmatch と Path.match では config/prod.env に当たっています。* がスラッシュを越えているのです。glob の感覚で「直下だけを指定したつもり」で書くと、意図より広く当たります。
二つ目は **/config/*.env の行です。glob では当たるのに、fnmatch と Path.match では外れます。**/ が「0個以上のディレクトリ」ではなく「何かしらのディレクトリが1段以上」として読まれるためで、ルート直下の config/ が抜け落ちます。
ここが、私が事前に予想していたのと逆でした。**/ を付けるほど網が広がるつもりでいたのに、照合器によっては直下を取りこぼす方向に働きます。しかも取りこぼす対象が、追加ルートの直下という、いちばん設定ファイルが集まる場所でした。
ルートが一本だった頃は、この差に気づく機会がありません。パスの階層構造が一つしかないので、どの書き方でも結果が揃ってしまうからです。ルートを足して階層の浅い木が増えたときに、初めて表面化します。
大小文字が一致しないだけで、deny は静かに外れる
もう一つ、追加ルートで踏みました。
ios/Config/ です。iOS のプロジェクトでは Config や Secrets を大文字始まりで置く習慣があり、**/config/** と小文字で書いた deny は当たりません。
厄介なのは、macOS のファイルシステムが既定で大小文字を区別しない点です。シェルで ls ios/config と打つと中身が見えます。人間の目には「あるじゃないか」と映るのに、パターン照合は区別するので外れます。見えているのに守られていない、という食い違いが起きます。
これも、ルートが一本のうちは命名規則が揃っているので問題になりません。別の文化で作られたリポジトリを足した瞬間に出てきます。
DirectoryAdded は、この測り直しを差し込める場所
ここまでの三つは、どれも「ルートが増えた直後」にしか起きません。
SessionStart では間に合いません。セッション開始時点では追加ルートがまだ存在しないからです。ツール実行のたびに走る PreToolUse に載せると、毎回コストを払うことになります。
DirectoryAdded は、/add-dir や SDK の register_repo_root でルートが登録された直後に発火します。測り直しを一回だけ走らせるのに、ちょうどよい位置です。
設定はフックの一般的な形に従います。
{
"hooks": {
"DirectoryAdded": [
{
"hooks": [
{
"type": "command",
"command": "python3 $CLAUDE_PROJECT_DIR/.claude/scripts/scope_delta_audit.py"
}
]
}
]
}
}
一点、実装で迷った箇所があります。追加されたパスがどのキー名で渡ってくるかです。
私は、そこを決め打ちにしないことにしました。stdin の JSON から候補キーを順に探し、見つからなければ argv にフォールバックする形です。フック側のペイロードは版によって増減しますし、鍵の名前を一つ間違えただけで監査そのものが無音で空振りするのは、いちばん避けたい失敗の形でした。
無音の空振りは、検査がないことより質が悪いと考えています。検査が「ある」と思い込んでいるぶん、確認しなくなるからです。
追加ルートの差分だけを出す監査スクリプト
全体をスキャンし直すのではなく、追加ルートについて「既存の前提が届いていない箇所」だけを出します。
出力はパスと理由のみで、ファイルの中身は一切表示しません。フックの出力はログに残るため、そこに秘匿値を流し込んでしまうと本末転倒になります。
#!/usr/bin/env python3
"""追加された作業ルートに対して、既存の前提が通用しない箇所だけを出す差分監査。
DirectoryAdded フックから呼ぶ。標準出力はパスと理由のみ。中身は出さない。"""
import json, re, subprocess, sys
from pathlib import Path
# 既存ルートで運用してきた deny パターン(settings.json の permissions.deny 相当)
DENY_PATTERNS = ["**/*.env", "**/*.pem", "**/secrets/**", "**/.npmrc"]
SECRET_HINT = re.compile(
r"(secret|credential|token|apikey|api_key|\.env$|\.pem$|\.p12$|\.keystore$)", re.I)
def glob_match(path: str, pat: str) -> bool:
"""glob と同じ ** セマンティクス(0個以上のディレクトリ)で判定する。
fnmatch は '*' がスラッシュを越えるため、deny 判定には使わない。"""
rx = (re.escape(pat)
.replace(r"\*\*/", "(?:[^/]+/)*")
.replace(r"\*\*", ".*")
.replace(r"\*", "[^/]*")
.replace(r"\?", "[^/]"))
return re.fullmatch(rx, path) is not None
def list_files(root: Path):
"""そのルート自身の .gitignore を適用したうえで列挙する。
親ルートの .gitignore は届かないので、必ず追加ルートの中で git を回す。"""
try:
out = subprocess.run(
["git", "-C", str(root), "ls-files",
"--cached", "--others", "--exclude-standard"],
capture_output=True, text=True, timeout=30)
if out.returncode == 0:
return [l for l in out.stdout.splitlines() if l]
except (OSError, subprocess.SubprocessError):
pass # git 管理下でないルートもあるので、素の走査に落とす
return [p.relative_to(root).as_posix() for p in root.rglob("*")
if p.is_file() and ".git/" not in p.as_posix()]
def audit(base: Path, added: Path):
findings = {"uncovered_secrets": [], "case_mismatch": [], "path_collisions": []}
added_files = list_files(added)
base_files = set(list_files(base))
for rel in added_files:
if SECRET_HINT.search(rel):
if not any(glob_match(rel, p) for p in DENY_PATTERNS):
if any(glob_match(rel.lower(), p) for p in DENY_PATTERNS):
findings["case_mismatch"].append(rel)
else:
findings["uncovered_secrets"].append(rel)
if rel in base_files:
findings["path_collisions"].append(rel)
return findings
def resolve_added_root() -> Path | None:
"""ペイロードのキー名を決め打ちにしない。見つからなければ argv に落とす。"""
try:
payload = json.loads(sys.stdin.read() or "{}")
except (json.JSONDecodeError, OSError):
payload = {}
for key in ("directory", "path", "added_directory", "root", "cwd"):
value = payload.get(key)
if isinstance(value, str) and Path(value).is_dir():
return Path(value)
return Path(sys.argv[2]) if len(sys.argv) > 2 else None
def main() -> int:
base = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else Path.cwd()
added = resolve_added_root()
if added is None or not added.is_dir():
print("added root を特定できませんでした(監査を実行していません)", file=sys.stderr)
return 2 # 無音で成功させない
added = added.resolve()
f = audit(base, added)
total = sum(len(v) for v in f.values())
print(f"# scope delta audit: {added}")
for label, key in (("deny 未カバーの秘匿候補", "uncovered_secrets"),
("大小文字違いで deny を外れた", "case_mismatch"),
("既存ルートとパスが衝突", "path_collisions")):
print(f"\n[{label}] {len(f[key])} 件")
for rel in sorted(f[key]):
print(f" - {rel}")
print(f"\ntotal findings: {total}")
return 1 if total else 0
if __name__ == "__main__":
sys.exit(main())
終了コードを三値にしてある点だけ補足させてください。
0 は差分なし、1 は要確認の差分あり、2 は監査を実行できなかった、です。2 を 0 に丸めないことが要点で、「ルートを特定できなかった」を成功として扱った瞬間に、先ほどの無音の空振りが戻ってきます。
実行して出た5件
先ほどの二本のルートに、iOS 側にありがちな構成を足して走らせました。ios/Config/ と、大文字始まりの Secrets/、そして両ルートに同名で存在する config/prod.env と src/app.ts です。
# scope delta audit: /home/.../lab_scope/root_b
[deny 未カバーの秘匿候補] 2 件
- ios/Config/Secrets.plist
- ios/Config/Secrets.swift
[大小文字違いで deny を外れた] 1 件
- Secrets/signing.txt
[既存ルートとパスが衝突] 2 件
- config/prod.env
- src/app.ts
total findings: 5
exit=1
ios/Config/Secrets.plist と Secrets.swift は、**/*.env にも **/secrets/** にも当たりません。拡張子が違い、ディレクトリ名も大文字始まりだからです。名前に Secrets と入っているのに、既存の deny をすべてすり抜けます。
Secrets/signing.txt は、小文字に落とせば **/secrets/** に当たります。だから「大小文字違い」として別枠に出しています。カバーの仕方が違うので、対処も違うからです。前者は新しいパターンを足す必要があり、後者は既存パターンを大小文字非依存にすれば済みます。
config/prod.env と src/app.ts の衝突は、秘匿の話ではありません。相対パスでファイルを指したときに、どちらのルートに解決されるかが曖昧になる箇所です。指示の中で src/app.ts と書いたときの意味が一意でなくなるので、以降はルート名を前置きするか、明示的に絶対パスで書く判断ができます。
フックに置いて重くならないか、先に測る
フックは操作の流れに割り込みます。監査が数百ミリ秒かかるなら、置き場所から考え直す必要がありました。
追加ルート側に 2,882 ファイル(Swift・TypeScript・JSON・plist などを混在させた木)を用意して、9回ずつ計測しました。
| 処理 | 中央値 | 最小 |
| 差分監査(追加ルートのみ) | 21.8 ms | 21.3 ms |
| 両ルートの再走査 | 21.6 ms | 21.3 ms |
フック1回あたり 21.8 ミリ秒であれば、体感には出ません。ここは想定どおりでした。
想定と違ったのは二行目です。差分監査と再走査で、所要時間の差が 1% 未満しかありませんでした。
理由を追うと単純で、どちらも git ls-files を追加ルートに対して1回ずつ走らせており、その1回が全体を支配していました。既存ルート側の再走査は誤差に沈みます。
つまり、差分監査を選ぶ理由は速度ではありません。出力に何が並ぶかです。全体を走査すれば、すでに deny で守られている 2,000 行の中に、守られていない2件が埋もれます。差分だけを出せば、読むべきものが2行になります。
速くしたつもりでいた設計が、実際には読みやすくしていただけでした。この取り違えは、測るまで気づけません。
公式ドキュメントに書かれていない、運用上の勘所
三点あります。
一つ目は、監査の実行を追加ルートの中で行うことです。親ルートから相対パスで覗くと git が拒否するか、あるいは無視規則が届かないまま素通りします。git -C <added> を必ず挟みます。
二つ目は、deny の照合器を自分の側で固定することです。ツールがどの照合器を使うかは版で変わり得ますが、監査スクリプトが使う照合器は自分で決められます。私は glob セマンティクスに寄せました。実際にファイルシステムを走査するときの直感と一致するからで、fnmatch 側の「* がスラッシュを越える」挙動を監査に持ち込むと、カバーされているのに未カバーと出る誤検出が増えます。
三つ目は、パターンを増やす前に大小文字を疑うことです。上の結果で言えば、追加すべき新パターンは実質1系統(**/*.plist と **/*.swift のうち秘匿を持つもの)で、残りは既存パターンの照合方法を変えるだけで片付きます。パターンの本数が増えるほど、次にルートを足したときの見通しは悪くなります。
ルートを足した直後にやること
自分用に手順として畳んでおきます。
- 追加ルートの中で
git -C <added> ls-files --cached --others --exclude-standard を回し、そのルート自身の無視規則を適用した一覧を取る
- その一覧を、glob セマンティクスで固定した照合器で既存の deny に当て、未カバーのものだけを出す
- 未カバーの中から、小文字に落とせば当たるものを分けて数える。ここが多い場合は、パターンを増やすのではなく既存パターンを大小文字非依存にする
- 既存ルートと同名の相対パスを一覧にし、以降その名前を単独で使わないと決める
3 番と 4 番を分けているのには理由があります。3 番は設定の直し方の話で、4 番は書き方の話です。混ぜると、パターンだけが増えて曖昧さは残ります。
広く書けば安全になる、という思い込み
この作業を通してはっきりしたのは、deny を広く書くことと、安全になることは同じではないという点でした。
**/ を頭に付けて回るのは、いちばん手軽な「広げ方」に見えます。実際には照合器によってルート直下を取りこぼし、しかも取りこぼした事実はどこにも表示されません。広げたつもりの操作が、静かに穴を作っていました。
効いたのは、パターンを広げることではなく、境界が動いた瞬間に測り直す仕組みを置くことでした。ルートが増えるのは年に数回かもしれませんが、増えたその日にしか気づけない類の差分があります。
複数リポジトリを並行して扱う流れそのものは、Claude Code の --add-dir で複数リポジトリを横断編集する実践パターン にまとめています。秘匿ファイルの読み取り面を絞る側の設計は サンドボックスはコードを動かせても、認証ファイルは読ませない — Claude Code の sandbox.credentials で秘密の露出面を絞る が近い話題です。
次に試すなら、ここから
まず、いま使っている deny パターンを1本選び、この記事の照合器比較のコードにそのまま渡してみてください。fnmatch と glob で結果が割れるパターンが混ざっていれば、その1本が最初に直すべき箇所です。
私自身、ルートを足すという操作をこれほど疑ったのは初めてでした。まだ測りきれていない前提も残っているはずで、見つけ次第また書き足していこうと思っております。お読みいただきありがとうございました。