外向きの通信を許可リストで締めた翌週、走り終えたジョブの作業ディレクトリを眺めていて手が止まりました。
プロジェクトの外にあるディレクトリに、いくつかファイルが増えていたのです。通信は絞ったのに、ファイルシステムは既定のまま開いていました。片側だけ締めて安心していたことになります。
個人開発で夜間に無人実行を回している以上、書き込みの届く範囲は本来まっさきに決めておくべき値でした。
Claude Code v2.1.216 で、ファイルシステムの分離をネットワーク側と切り離して指定できるようになりました。用途ごとに粒度を変えられる、という変更です。ただ粒度を変えるには、いま実際にどこまで触っているのかを先に知る必要があります。
推測で書いた許可リストは、締めた瞬間ではなく、三週間後の深夜に牙を剥きます。
この記事は、締める前の採取に絞った実務メモです。設定の書き方そのものより、何を測って、その数字から何を決めたかに紙幅を割きます。
片側だけ締めていた、という気づき
ネットワーク側については、sandbox.network.strictAllowlist を入れて許可リスト外のホストを拒む形にしていました。この作業の経緯はstrictAllowlist で Claude Code サンドボックスの外向き通信を締めるにまとめています。
そのときに得た教訓が一つあります。許可リストは推測で書くと必ず穴が開くということです。ホスト名を頭で数えて書いた許可リストは、実際に走らせると足りませんでした。
同じことがファイルシステム側でも起きるはずでした。むしろ、こちらのほうが厄介です。通信先のホストは多くても数十ですが、ジョブが触るパスは数百に及びます。頭で数えられる規模ではありません。
そこで、設定を書く前に採取から始めることにしました。
触ったパスを採る
採取には strace を使います。-e trace=file を付けると、パス名を引数に取るシステムコールだけを拾えます。-f で子プロセスも追跡します。
strace -f -e trace=file -o trace.log bash job.sh
これで生ログは取れますが、そのままでは読めません。1,589行の出力から、読み取り面と書き込み面を分けて集計する必要があります。分けたいのは、openat のフラグに O_WRONLY や O_CREAT が立っているかどうかです。
以下は実際に使ったスクリプトです。そのまま貼れば動きます。
#!/usr/bin/env python3
"""strace の出力からファイルアクセスを採取し、読み取り面と書き込み面に分ける。"""
import re, sys, os, json, collections
# openat(AT_FDCWD, "/path", FLAGS...) = fd
OPENAT = re.compile(r'openat\(([^,]+),\s*"([^"]*)",\s*([^)]*)\)\s*=\s*(-?\d+)')
# stat("/path", ...) / access("/path", ...) など
PATHCALL = re.compile(
r'\b(stat|lstat|newfstatat|access|readlink|unlink|unlinkat|mkdir'
r'|mkdirat|rename|renameat2?|statx|execve)\((?:[^,]+,\s*)?"([^"]*)"')
WRITE_FLAGS = ("O_WRONLY", "O_RDWR", "O_CREAT", "O_TRUNC", "O_APPEND")
MUTATORS = {"unlink", "unlinkat", "mkdir", "mkdirat",
"rename", "renameat", "renameat2"}
def classify(line):
m = OPENAT.search(line)
if m:
_, path, flags, ret = m.groups()
kind = "write" if any(f in flags for f in WRITE_FLAGS) else "read"
return path, kind, int(ret) >= 0
m = PATHCALL.search(line)
if m:
call, path = m.groups()
kind = "write" if call in MUTATORS else "read"
# ENOENT で終わったものは「探索の空振り」として区別する
ok = not re.search(r'=\s*-1\s+ENOENT', line)
return path, kind, ok
return None
def normalize(p, cwd):
if not p.startswith("/"):
p = os.path.join(cwd, p)
return os.path.normpath(p)
def compact(paths, depth=3):
"""パスを深さ depth の接頭辞に畳む。許可リストの行数はこの数で決まる。"""
out = collections.Counter()
for p in paths:
parts = [x for x in p.split("/") if x]
out["/" + "/".join(parts[:depth])] += 1
return out
def main(trace_path, cwd):
reads, writes, misses = set(), set(), set()
with open(trace_path, errors="replace") as fh:
for line in fh:
r = classify(line)
if not r:
continue
path, kind, ok = r
path = normalize(path, cwd)
(writes if kind == "write" else reads).add(path)
if not ok:
misses.add(path)
# 書き込んだパスは読み取り面から差し引く(二重計上を避ける)
reads -= writes
misses -= writes
print(json.dumps({
"read_paths": len(reads - misses),
"probe_misses": len(misses),
"write_paths": len(writes),
"read_prefixes_d2": len(compact(reads - misses, 2)),
"read_prefixes_d3": len(compact(reads - misses, 3)),
"write_prefixes_d3": len(compact(writes, 3)),
"write_prefix_list": sorted(compact(writes, 3)),
}, indent=2, ensure_ascii=False))
if __name__ == "__main__":
main(sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else os.getcwd())
計測対象にしたのは、実際の自動化に近い構成のジョブです。git init からコミットまで、途中で Node のスクリプトと Python の集計を挟みます。特別なことは何もしていない、ごく普通のパイプラインです。
読み取り175に対して、書き込みは接頭辞3行
集計結果がこちらです。
| 指標 | 件数 |
| 実在した読み取りパス | 175 |
| 存在しなかった探索パス | 101 |
| 書き込みパス | 73 |
| 読み取りの接頭辞(深さ2) | 15 |
| 読み取りの接頭辞(深さ3) | 40 |
| 書き込みの接頭辞(深さ3) | 3 |
最初に集計したときは、読み取りを276パスとして数えていました。その内訳を見て気づいたのが、101件が存在しないパスへの問い合わせだったことです。そこは後で戻ります。
目を引いたのは最後の行でした。書き込みパスは73件あるのに、深さ3の接頭辞に畳むと3行にしかなりません。中身はこうでした。
| 接頭辞 | 正体 |
| プロジェクトの作業ディレクトリ | 成果物・`.git` 配下・一時ファイル |
| `/dev/null` | 出力の捨て先 |
| `/dev/tty` | 端末への直接出力 |
つまりこのジョブは、プロジェクトディレクトリと2つのデバイスファイル以外には一切書いていませんでした。
読み取り面は深さ3で40接頭辞、深さ2まで畳んでも15行が必要です。Python の標準ライブラリ、ロケール、git-core の共有ファイル。当たり前ですが、道具を動かすためには道具の置き場所が読めなければなりません。
ここから導いた判断はこうです。読み取り面を締める費用対効果は悪く、書き込み面を締める費用対効果は極端に良い。読み取りを絞れば15〜40行の許可リストを保守し続けることになり、パッケージを一つ入れるたびに増えます。書き込みを絞るなら3行で済み、しかも「事故で外に書く」という最も避けたい事象を直接止められます。
秘密情報の読み取りだけは例外で、こちらは別の仕組みで絞るほうが素直です。認証ファイルの露出面についてはサンドボックスはコードを動かせても、認証ファイルは読ませないで扱っています。
パスの列挙は、原理的に安定しない
採取が一度うまくいくと、その結果をそのまま許可リストにしたくなります。私もそうしかけました。
念のため、同じジョブをもう2回走らせて集合を比べてみました。ここで予想が外れます。
| 実行 | 採れたパス数 |
| 1回目 | 333 |
| 2回目 | 349 |
| 3回目 | 349 |
| 和集合 | 396 |
| 3回すべてに現れたパス | 310 |
私自身、ここは一致するものだと思い込んでいました。同じスクリプトを、同じ環境で、続けて3回走らせただけです。それでも一致したのは310件で、和集合396件に対して安定率は78.3%でした。5回に1回以上の割合で、前回は見えなかったパスが出てくることになります。
差分を覗くと、正体はすぐ分かりました。
.git/objects/2d/tmp_obj_4hUIKO
.git/objects/2d/tmp_obj_MceYRO
.git/objects/35/e31f6e250b4e802832444fbd40eed2f3e90779
.git/objects/43/97f0b209eff77e061321192a24f6eddda9a57b
git がオブジェクトを書くときの一時ファイル名と、内容から決まるオブジェクトハッシュです。前者は毎回ランダムに変わり、後者はコミットのタイムスタンプが変われば変わります。一致するはずがありません。
そこで、同じ3回分を接頭辞に畳んで比べ直しました。
| 粒度 | 和集合 | 3回すべてに現れた数 | 安定率 |
| パスそのもの | 396 | 310 | 78.3% |
| 接頭辞(深さ2) | 23 | 23 | 100% |
| 接頭辞(深さ3) | 64 | 64 | 100% |
| 接頭辞(深さ4) | 114 | 114 | 100% |
深さ2でも4でも、接頭辞レベルでは3回とも完全に一致しました。
これは運が良かったのではなく、構造的な話だと考えています。ランダムな名前が生まれるのは常に葉のほうで、それを収めるディレクトリの位置は決まっています。個々のパスは揺れても、パスが属する場所は揺れない。
だから許可リストは接頭辞で書くしかありません。一回の観測から得たパス一覧をそのまま貼れば、二回目の実行で落ちます。私が採取を三回繰り返さなければ、この落とし穴には気づかないまま設定を書いていたはずです。
書き込み面から締める
以上を踏まえて、実際に締めたのは書き込み側だけでした。.claude/settings.json の permissions に、書き込みを禁じる規則を置き、作業ディレクトリだけを例外にします。
{
"permissions": {
"deny": [
"Write(//**)",
"Edit(//**)"
],
"allow": [
"Write(//home/runner/work/**)",
"Edit(//home/runner/work/**)",
"Write(//tmp/**)"
]
}
}
書き方の要点が三つあります。
- 拒否を先に広く置く。既定を「書けない」にしてから、必要な場所だけ開けます。逆順に組み立てると、書き漏らした場所が黙って開いたままになります。
- 許可は接頭辞で書く。前節の通り、葉のパスを列挙しても持ちません。
/** で閉じます。
/tmp を忘れない。採取結果に出てこなくても、多くのツールは条件次第で一時ディレクトリを使います。ここを開けずに締めると、普段は通るのに特定の入力サイズでだけ落ちる、という切り分けの難しい失敗になります。
規則を積み上げるとセッションごとのコストにも跳ね返ります。行数と評価コストの関係は許可ルールを積み上げたセッションが毎ターン重くなるで測っています。接頭辞に畳む理由は、安定性だけでなくここにもあります。
なお、サンドボックス側のファイルシステム分離に関する設定キーは版によって変わります。v2.1.216 で分離の指定がネットワークと独立したことは変更履歴に記載がありますが、実際に書くキー名は、お使いの版の変更履歴で必ず確認してください。私はこの記事の検証を v2.1.220 系で行っています。
「見つからない」と「読ませない」は別の失敗
先ほど脇に置いた101件に戻ります。
採取した読み取りアクセス276件のうち、101件は存在しないパスへの問い合わせでした。全体の約37%です。空振りの上位はこうなっていました。
| 接頭辞 | 空振り件数 | 正体 |
| 作業ディレクトリ配下 | 31 | 設定ファイルの候補探索 |
| `/usr/lib/python3.10` | 13 | モジュール解決の候補 |
| `/usr/lib/locale` | 12 | ロケール定義の候補 |
| `/usr/local/sbin`・`/usr/local/bin` | 12 | `PATH` の走査 |
これは異常ではなく、道具の正常な振る舞いです。Python は import のたびに候補パスを順に叩き、見つからなければ次へ進みます。シェルは PATH を頭から順に舐めます。空振りは設計の一部です。
ここに、締めるときの落とし穴があります。
見つからない場合、システムコールは ENOENT を返します。呼び出し側は「無い」と理解して次の候補へ進む。ところがポリシーで拒否された場合、返ってくるのは EACCES や EPERM です。同じ「開けなかった」でも、返る理由が違う。
候補探索のループを書くとき、ENOENT だけを想定して次へ進み、それ以外のエラーは致命的として投げ直す実装は珍しくありません。そういうツールは、拒否に当たった時点でフォールバックせずに止まります。
だから、採取したパスをそのまま許可リストにするのは二重に間違いです。存在しないパスを許可する意味はありませんし、そもそも空振りしていた場所を拒否対象に含めると、以前は素通りしていた探索が別の失敗に化けます。
私が採った方針はこうです。読み取りは締めない。締めるのは書き込みと、秘密情報という限定された読み取りだけ。読み取り全体を絞る設計は、探索の空振りという正常動作と正面から衝突します。
採取から設定までの手順
実際に回している順序を残しておきます。
- 代表的なジョブを一つ選ぶ。全部を測ろうとせず、最も広く触るものを一つ。多くの場合それは、クローンからビルドまでを含むパイプラインです。
- 3回以上走らせて採取する。1回では足りないことを前節で確認しました。3回で接頭辞は安定しました。
- 読み取り面と書き込み面を分ける。スクリプトの出力で
write_prefix_list だけを見ます。ここが短ければ、締める作業はほぼ終わっています。
- 書き込みの接頭辞を許可リストに写す。深さ2か3で畳んだものを使います。深さ4以上は保守が重くなるだけで、安全性はほとんど変わりませんでした。
- 拒否を先に、許可を後に置いた設定を書く。既定を閉じてから開けます。
- 同じジョブをもう一度、締めた状態で走らせる。ここで落ちれば、落ちた場所が採取漏れです。
- 失敗の理由を記録に残す。
EACCES で落ちたのか ENOENT で落ちたのかを区別できるログにしておくと、次に同じ場所で悩まずに済みます。
手順6を省きたくなる気持ちは分かります。私も一度省きました。締めた設定が実際に効いているかは、走らせてみるまで分からないというだけの話です。
残っている課題
この方法にも限界があります。
strace で採れるのは、その回に実際に通った経路だけです。エラー処理の分岐や、月末にだけ走るバックアップ処理は、たまたまその回に通らなければ採取されません。稀にしか通らない経路ほど、締めた後に落ちて気づくことになります。
いまのところ、代表的な異常系を意図的に起こしてもう一度採取する、という素朴な方法で埋めています。もっと良いやり方があれば知りたいところです。
もう一つ、この採取は Linux の strace に依存しています。macOS では同じことができません。手元の開発機で採って本番の設定を書く、という流れは取れないため、本番と同じ環境で採取する必要があります。
それでも、書き込み面が接頭辞3行に収まると分かってからは、ファイルシステムを締める作業に対する心理的な負荷が明らかに軽くなりました。読み取りまで含めて完璧に絞ろうとしていた頃は、いつまでも着手できずにいたのです。
締める範囲を狭く決め切ったことが、結果的に前に進めた理由でした。
同じところで足を止めている方の判断材料になれば嬉しく思います。