深夜に回している自動投稿パイプラインが、理由も告げずに途中で止まっていました。
ログには接続タイムアウトらしき痕跡だけ。回線を疑い、再実行すると通ることもある。よくある「たまに失敗するネットワーク」に見えました。けれど原因は回線ではありませんでした。許可リストに載せ忘れた一つのホストへの通信が、静かに拒否されていただけだったのです。
Claude Code v2.1.219(2026-07-24)で追加された sandbox.network.strictAllowlist を入れた直後のことでした。この記事は、その設定を個人開発の自動化に組み込んだときの実務メモです。設定を有効にする手順そのものよりも、有効にしたあとで何が壊れ、どう洗い出したかに紙幅を割きます。
「拒否」は障害の顔をしてやってくる
strictAllowlist の考え方はごく単純です。サンドボックス内で実行するコマンドに対して、許可リストにないホストへの外向き通信を拒む。自動化を安全側の既定で回すための、ちょうどよい締め方です。
ただ運用してみて分かったのは、拒否そのものより「拒否の見え方」が厄介だということでした。
許可リストから漏れたホストへの接続は、アプリケーションから見れば単なる接続失敗です。多くのツールはそこで「ネットワークが不調」と解釈し、リトライやタイムアウトのメッセージを出します。つまりポリシーによる意図的な遮断が、偶発的な回線障害と区別できない顔で現れる 。ここが最初の落とし穴でした。この罠を回避するには、原因を推測する前に事実を観測することです。
そこで私が最初にやったのは、許可リストを書くことではなく、パイプラインが実際にどのホストへ出ていくのかを一度残らず観測することでした。
実際に触るホストを洗い出す
頭の中の「たぶんこのホストに繋ぐはず」は当てになりません。connect(2) をトレースして、宛先を事実として集めます。次のスクリプトは、任意のコマンドをラップして接続先ホスト名を吐き出します。
#!/usr/bin/env bash
# egress-recon.sh — コマンドが実際に接続するホストを洗い出す
# 使い方: ./egress-recon.sh <実行したいコマンド...>
# 例: ./egress-recon.sh bash deploy-pipeline.sh
set -euo pipefail
TRACE = "$( mktemp )"
trap 'rm -f "$TRACE"' EXIT
# connect(2) だけを追い、宛先アドレスを記録する
# -f: 子プロセスも追跡(git や npm はサブプロセスを大量に生む)
strace -f -qq -e trace=connect -o " $TRACE " " $@ " || true
# IPv4 の宛先を抽出(sin_addr=inet_addr("x.x.x.x"))
grep -oE 'inet_addr\("[0-9.]+"\)' " $TRACE " \
| grep -oE '[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+' \
| sort -u > " $TRACE .ip"
echo "# 接続先ホスト(逆引き済み・重複排除)"
while read -r ip ; do
# ローカル/リンクローカルは除外
case " $ip " in 127. *| 0.0.0.0 | 169.254. * ) continue ;; esac
host = "$( getent hosts " $ip " | awk '{print $2}')"
printf '%-24s %s\n' "${ host :- "(逆引き不可)"}" " $ip "
done < " $TRACE .ip" | sort -u
ポイントは -f です。git push も npm install も、実処理は子プロセスに委ねます。親プロセスだけ見ていると、肝心の接続を丸ごと取りこぼします。逆引きが効かないIPは、そのIP自体を控えておきます。後述する正引き辞書で名前に戻す手順を用意しました。
私の環境でこれを一度回したところ、接続先は11ホスト ありました。事前に想定していたのは4つ。想定のおよそ2.7倍です。
一つのホスト名では足りない
ここが、ドキュメントを眺めているだけでは掴めなかったところです。
私は素朴に「GitHub に push するなら github.com を、Anthropic の API を叩くなら api.anthropic.com を許可すれば足りる」と考えていました。ところが recon の結果は違いました。
論理的な操作 実際に接続したホスト
git push / fetch(HTTPS) github.com と、資産取得時の codeload.github.com
npm install registry.npmjs.org と、tarball 配信の別ホスト(CDN)
Claude API 呼び出し api.anthropic.com
名前解決 そもそも DNS が通らなければ全て止まる
一つの「操作」が、複数のホストへ扇状に広がっていました。とりわけ npm の tarball が別ホストから配られる点、git が状況によって codeload.github.com を掴む点は、実際に接続を観測するまで気づけませんでした。
つまり「サービス一つにつきホスト一つ」という前提そのものが誤り だったのです。この思い込みのまま許可リストを書いていたら、あの深夜の停止は延々と再現していたはずです。
ワイルドカードという誘惑に負けない
漏れが出るたびにホストを足すのは面倒です。そこで *.amazonaws.com のような広いワイルドカードで済ませたくなります。
一度はそう書きかけました。けれど手が止まりました。CDN の tarball が載っているクラウドを丸ごと許可することは、無関係な無数のバケットやエンドポイントへの通り道を同時に開くことでもあります。strictAllowlist をわざわざ入れた目的が、その一行で薄まってしまう。
結論として私は、広いワイルドカードよりもたまの取りこぼしを受け入れる 方を選びました。許可リストは具体的なホスト名で埋め、増えたら recon をもう一度回して差分を足す。安全側の既定という趣旨を、運用の楽さと引き換えにしない判断です。この方針を強く推奨します。
有効化までの三手順
慌てて設定を入れず、次の順で進めると事故が減りました。
パイプラインを一度、strictAllowlist なしで egress-recon.sh にかけて接続先を全て記録する
記録したホスト名を許可リストに落とし、strictAllowlist を有効化する
guard-run.sh で実行を監視し、想定外の接続が出たらはっきり失敗させる(回す頻度は後述の実測を踏まえて決めます)
この三手順を挟むだけで、「本番で静かに止まる」を「事前に一度だけ観測して潰す」に置き換えられます。
設定と、拒否を観測可能にする
洗い出したホストを設定に落とします。キー名はお使いのバージョンの設定スキーマに合わせて確認してください(strictAllowlist は新しい設定のため、細部は更新される可能性があります)。私はこの設定を .claude/settings.json に置いて運用しています。
// .claude/settings.json(設定例)
{
"sandbox" : {
"network" : {
"strictAllowlist" : true ,
"allow" : [
"api.anthropic.com" ,
"github.com" ,
"codeload.github.com" ,
"registry.npmjs.org"
]
}
}
}
そのうえで、拒否をネットワーク障害と取り違えないための観測を足します。パイプラインを一段ラップし、想定外のホストへ出ていないかを実行のたびに記録する薄い仕掛けです。
#!/usr/bin/env bash
# guard-run.sh — 実行中に触れたホストを許可リストと突き合わせる
set -euo pipefail
ALLOW_FILE = " ${1 :? allowlist ファイルを渡してください } " ; shift
SEEN = "$( mktemp )" ; trap 'rm -f "$SEEN"' EXIT
# egress-recon.sh を内側で使い、接続先を集める
./egress-recon.sh " $@ " | awk '{print $1}' | grep -vE '^\(' | sort -u > " $SEEN "
# 許可リストにない接続先だけを警告として出す
UNKNOWN = "$( comm -23 " $SEEN " <( sort -u " $ALLOW_FILE ") || true )"
if [ -n " $UNKNOWN " ]; then
echo "⚠️ 許可リスト外への接続を検知しました:" >&2
echo " $UNKNOWN " >&2
# 自動化では「静かに失敗」より「はっきり失敗」を選ぶ
exit 1
fi
echo "✅ 接続先はすべて許可リスト内でした"
これを入れてから、パイプラインの停止が「回線のせい」なのか「許可リストの漏れ」なのかを、ログの一行で言い切れるようになりました。原因のない再実行が消えたのが、いちばん体に効いた変化です。
失敗の速さが指紋になる
拒否が障害の顔で現れるのなら、顔ではなく速さを見ればよい。そう考え直して、失敗するまでの時間を実際に測りました。
#!/usr/bin/env python3
# probe-failure-shape.py — 失敗の「形」を測って原因を切り分ける
# 使い方: ./probe-failure-shape.py github.com api.anthropic.com 10.255.255.1
import socket, time, errno, statistics, sys
def probe (host, port = 443 , timeout = 3.0 ):
t0 = time.perf_counter()
try :
# 名前解決と接続を分けず、パイプラインと同じ経路で測る
addr = socket.getaddrinfo(host, port, socket. AF_INET , socket. SOCK_STREAM )[ 0 ][ 4 ]
s = socket.socket(socket. AF_INET , socket. SOCK_STREAM )
s.settimeout(timeout)
s.connect(addr)
s.close()
return "connected" , (time.perf_counter() - t0) * 1000
except socket.gaierror as e:
return f "DNS失敗 (gaierror { e.errno } )" , (time.perf_counter() - t0) * 1000
except socket.timeout:
# 上限に張り付いたか後で判るよう、経過時間も返す
return "タイムアウト" , (time.perf_counter() - t0) * 1000
except OSError as e:
return f "errno= { e.errno } ( { errno.errorcode.get(e.errno, '?' ) } )" , (time.perf_counter() - t0) * 1000
for host in sys.argv[ 1 :]:
runs = [probe(host) for _ in range ( 3 )]
ms = [r[ 1 ] for r in runs]
print ( f " { host }\t{ runs[ 0 ][ 0 ] }\t 中央値 { statistics.median(ms) :.2f } ms" )
タイムアウトを3秒に設定し、各ホスト3回ずつ測った結果です。数字は私の手元の環境の実測値で、絶対値は環境によって動きます。見てほしいのは桁の違いです。
事象 返ってくるもの 失敗までの中央値
許可済みホストへの正常な接続 connected 10.2 〜 14.1 ms
名前が解けない gaierror -2(EAI_NONAME) 1.22 ms
相手に届いた上で断られる errno=111(ECONNREFUSED) 0.02 ms
パケットが黙って捨てられる タイムアウト 3,003 ms(3秒上限そのもの)
並べて初めて腑に落ちました。失敗までの時間そのものが、原因の指紋になっている のです。
1ミリ秒を大きく下回るなら、相手までは届いていて即座に断られています。ポートが閉じているか、経路上で RST が返っている。1〜2ミリ秒で終わるなら、そもそも名前が解けていません。許可リストを疑う前に DNS の経路を確認すべき場面です。
問題は最後の行です。タイムアウトの上限にぴったり張り付く失敗。3秒設定なら3.00秒、10秒設定なら10.0秒。ばらつかない失敗は、機械の不調ではなく人が決めた規則の失敗 です。パケットが応答も返されずに捨てられている以上、待ち時間は上限で決まります。
深夜に止まっていたあのパイプラインは、まさにこの顔をしていました。本物の回線不調なら、失敗までの時間は上限より手前でばらつきます。毎回きれいに同じ秒数で落ちること自体が、不自然だったのです。気づいてしまえば、拒否と障害はもう似ていませんでした。
観測にも値札がある
とはいえ、常時トレースするのは無料ではありません。同じ HTTPS 取得を5回ずつ測ったところ、素の実行が中央値 88.0 ms、strace -f を通すと 240.9 ms でした。約2.7倍 です。
この数字を見て、私は当初の「毎回 guard-run で監視する」方針を改めました。新しいツールを入れた直後と、週に一度の定期実行。この頻度なら、遅さを飲み込みつつ差分は拾えます。安全のための仕掛けが遅さの理由になると、いつか外されてしまう。仕掛けは、外されない重さで置くのが長続きします。
逆引きは当てにならなかった
記事の前半で「逆引き済み」のスクリプトを載せました。正直に書けば、あれは私の想定が甘かった部分です。
recon で拾った IP を実際に逆引きしてみると、GitHub の 20.27.177.113 も Anthropic の 160.79.104.10 も、PTR レコードは返ってきませんでした。試した3件すべてが逆引き不可です。クラウド事業者の IP に PTR が整備されていないのは、むしろ普通のことでした。
さらに厄介な発見がありました。git ls-remote を一度トレースしただけで、接続先に 172.16.10.1 が混ざっていたのです。RFC1918 のプライベートアドレス。サービス本体ではなく、経路上のゲートウェイやプロキシです。
つまりrecon の出力には、許可リストに載せる対象ではない中継点が最初から混ざっている 。IP をそのまま許可リストに書き写す運用は、この時点で成り立ちません。
IP で書けない、もう一つの理由
念のため、主要ホストを20回ずつ名前解決してユニーク IP 数を数えました。
ホスト 20回の解決で現れたユニークIP
github.com 1件
codeload.github.com 1件
api.anthropic.com 1件
registry.npmjs.org 12件
CDN の背後にいる registry.npmjs.org だけが、たった20回の解決で12個の IP を返しました。ここを IP で固定した許可リストは、翌日には破綻します。許可リストはホスト名で書く。実測がその理由をはっきりさせてくれました。
そこで逆引きをやめ、許可リスト候補を正引きして辞書を作り、観測した IP をその辞書で名前に戻す 方向へ切り替えました。
#!/usr/bin/env python3
# resolve-map.py — 許可リスト候補を正引きし、観測IPを名前へ戻す
# 使い方: ./resolve-map.py allowlist.txt observed-ips.txt
import socket, sys
allow_file, observed_file = sys.argv[ 1 ], sys.argv[ 2 ]
names = [l.strip() for l in open (allow_file) if l.strip() and not l.startswith( "#" )]
ip2name = {}
for name in names:
# CDN は解決のたびにIPが変わるため、複数回引いて集合を育てる
for _ in range ( 8 ):
try :
for info in socket.getaddrinfo(name, 443 , socket. AF_INET , socket. SOCK_STREAM ):
ip2name.setdefault(info[ 4 ][ 0 ], set ()).add(name)
except socket.gaierror:
print ( f "# 名前が解決できません: { name } " , file = sys.stderr)
break
unknown = []
for line in open (observed_file):
ip = line.strip()
if not ip:
continue
if ip in ip2name:
print ( f " { ip }\t{ ',' .join( sorted (ip2name[ip])) } " )
else :
unknown.append(ip)
for ip in unknown:
# 中継点か、許可リストの取りこぼしか。ここを人が見て判断する
print ( f " { ip }\t (許可リストのどの名前にも一致しません)" )
sys.exit( 1 if unknown else 0 )
同じ IP が複数の名前に紐づくこともあります(CDN では珍しくありません)。だから辞書の値は集合にしてあります。一致しなかった IP だけが人の判断を必要とする残りで、私の環境ではその残りが 172.16.10.1 ひとつでした。中継点だと分かってしまえば、以後は無視してよい既知の顔になります。
観測を一度きりの調査で終わらせず、名前へ戻す道筋まで用意しておく。それだけで、許可リストの更新が「勘で足す」から「差分を足す」に変わりました。
運用で効いた判断
数週間回して残った、状況別の勘所を残しておきます。
場面 とった判断
新しいツールを導入する まず recon を回し、増える接続先を許可リストへ反映してから本番に載せる
取りこぼしが怖い ワイルドカードで広げず、guard-run で「はっきり失敗」させて差分を足す
逆引きできないIPが出た 正引き辞書(resolve-map.py)で名前に戻す。どの名前にも一致しなければ経路上の中継点を疑い、許可リストには載せない
DNS が怪しい 名前解決の経路が許可されているかを最初に確認する(ここが塞がると全滅する)
強い締め方ほど、失敗が静かになります。だからこそ、締める前に「実際に触るホストを観測する」ことと、締めた後に「拒否をはっきり見える化する」ことの二つが要になりました。設定を有効化する一行よりも、その前後の観測に手間をかける価値があります。
私自身まだ運用しながら学んでいる途中です。同じように自動化を安全側へ寄せたい方の、遠回りを一つ減らせたなら嬉しく思います。お読みいただきありがとうございました。