深夜に回している定型処理のパイプラインが、理由も告げずに途中で止まっていました。
ログには接続タイムアウトらしき痕跡だけ。回線を疑い、再実行すると通ることもありました。よくある「たまに失敗するネットワーク」に見えたのです。けれど原因は回線ではありませんでした。許可リストに載せ忘れた一つのホストへの通信が、静かに拒否されていただけでした。
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 も、実処理は子プロセスに委ねます。親プロセスだけ見ていると、肝心の接続を丸ごと取りこぼします。
私の環境でこれを一度回したところ、接続先は11ホスト ありました。事前に想定していたのは4つでした。想定のおよそ2.7倍です。
一つのホスト名では足りない
ここが、ドキュメントを眺めているだけでは掴めなかったところです。
私は素朴に「GitHub に push するなら github.com を、Anthropic の API を叩くなら api.anthropic.com を許可すれば足りる」と考えていました。ところが recon の結果は違いました。
一つの「操作」が、複数のホストへ扇状に広がっていました。とりわけ 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回ずつ測った結果です。数字は私の手元の環境の実測値で、絶対値は環境によって動きます。見ていただきたいのは桁の違いです。
並べて初めて腑に落ちました。失敗までの時間そのものが、原因の指紋になっている のです。
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 数を数えました。
CDN の背後にいる registry.npmjs.org だけが、たった20回の解決で12個の IP を返しました。ここを IP で固定した許可リストは、翌日には破綻します。許可リストはホスト名で書く。実測がその理由をはっきりさせてくれました。
観測の仕掛けが、いちばん先に壊れていました
ここからが、この記事を書き直すきっかけになった部分です。
「逆引きは当てにならない」と分かった時点で、私は表の数字だけを直して満足していました。ところが上に載せた egress-recon.sh と guard-run.sh を、逆引きの効かない IP を渡して実際に走らせ直したところ、二つのスクリプトはどちらも意図した動きをしていませんでした。三つの欠陥が、重なって効いていたのです。
欠陥1: 逆引きが失敗した瞬間、出力が空になっていた
getent hosts は名前が引けないと終了コード 2 を返します。ところが egress-recon.sh の先頭には set -euo pipefail があります。host="$(getent hosts "$ip" | awk '{print $2}')" の代入は、pipefail のもとで getent の失敗をそのまま引き継ぎ、set -e がループごと打ち切ります。
掲載していた形のまま、逆引きできない3つの IP を渡して走らせた結果です。
$ ./egress-recon.sh # ← 掲載していた初版
# 接続先ホスト(逆引き済み・重複排除)
exit=2
ホスト行が一行も出ません 。見出しだけを出して終わっていました。先の節で確かめたとおり、クラウドの IP はまず逆引きできません。つまりこのスクリプトは、いちばん観測したい相手に対してだけ、無言で空を返していたのです。
|| true を足していなかった一箇所が、観測の入口を丸ごと閉じていました。エラーメッセージも出ないため、私は長いあいだ「接続先が少ない環境なのだ」と思い込んでおりました。
欠陥2: 見出しの # が、毎回「未知のホスト」として警告されていた
guard-run.sh は recon の出力を awk '{print $1}' に通します。見出し行 # 接続先ホスト(逆引き済み・重複排除) の第1フィールドは # です。次の grep -vE '^\(' は括弧で始まる行しか落としませんから、# はそのまま残ります。
recon 側の欠陥1を修正した状態で、掲載版の突き合わせ部分だけを動かした結果です。
--- SEEN(掲載版) ---
#
github.com
registry.npmjs.org
--- comm 結果(掲載版の UNKNOWN) ---
#
--- 終了判定 ---
exit 1 相当(警告あり)
接続先がすべて許可リストに載っていても、# が未知のホストとして残るため、guard-run.sh は毎回かならず exit 1 します。常に警告を出す監視は、いずれ誰も見なくなります。私自身、この出力を「まあ出るものだ」と流していた時期がありました。
欠陥3: いちばん見たい行を、自分でふるい落としていた
grep -vE '^\(' は (逆引き不可) の行を落とす意図で書いたものです。けれど記事の後半で確かめたとおり、逆引き不可はクラウドのホストでは例外ではなく既定です。
さきほどの実行結果を見返すと、SEEN に 160.79.104.10(api.anthropic.com)も 172.16.10.1(経路上の中継点)も含まれていません。人の判断がいちばん必要な行だけを、突き合わせの前に捨てていた ことになります。
三つを並べて、ようやく構図が見えました。締める設定より先に、締まったことを見る目のほうが壊れていました。 セキュリティの設定を入れた安心感が、その設定を検算する仕掛けの検算を後回しにさせていたのだと思います。
修正版
直し方はどれも小さなものでした。見出しは標準エラー出力へ回し、名前が引けなければ IP をそのまま名前欄に置き、突き合わせはタブ区切りの第1列だけを見ます。
#!/usr/bin/env bash
# egress-recon.sh(修正版)— 名前が引けなければ IP のまま出す
set -euo pipefail
# 見出しは stderr へ。stdout は機械が読む「名前 TAB IP」だけにする
echo "# 接続先(名前が引けなければ IP のまま)" >&2
while read -r ip ; do
case " $ip " in 127. *| 0.0.0.0 | 169.254. * ) continue ;; esac
# getent は名前が引けないと exit 2 を返す。pipefail で落ちないよう受け止める
host = "$( getent hosts " $ip " 2> /dev/null | awk '{print $2}')" || host = ""
printf '%s\t%s\n' "${ host :- $ip }" " $ip "
done < " $TRACE .ip" | sort -u
#!/usr/bin/env bash
# guard-run.sh(修正版)— 中継点は「既知」として別ファイルに逃がす
set -euo pipefail
ALLOW_FILE = " ${1 :? allowlist ファイルを渡してください } "
IGNORE_FILE = " ${2 :-/ dev / null } " # 経路上の中継点など、既知だが許可リストには載せないもの
SEEN = "$( mktemp )" ; KNOWN = "$( mktemp )" ; trap 'rm -f "$SEEN" "$KNOWN"' EXIT
# 見出しは stderr にあるので stdout には混ざらない。念のため # 始まりも弾く
./egress-recon.sh "${ @: 3 }" 2> /dev/null \
| awk -F '\t' 'NF && $1 !~ /^#/ {print $1}' | sort -u > " $SEEN "
cat " $ALLOW_FILE " " $IGNORE_FILE " 2> /dev/null \
| sed 's/#.*//' | awk 'NF{print $1}' | sort -u > " $KNOWN "
UNKNOWN = "$( comm -23 " $SEEN " " $KNOWN " || true )"
if [ -n " $UNKNOWN " ]; then
echo "⚠️ 許可リスト外への接続を検知しました:" >&2
echo " $UNKNOWN " >&2
exit 1
fi
echo "✅ 接続先はすべて許可リスト内でした"
修正版を、同じ入力で二通り走らせた実行結果です。
=== 修正版 recon の stdout ===
104.16.0.35 104.16.0.35
160.79.104.10 160.79.104.10
172.16.10.1 172.16.10.1
20.27.177.113 20.27.177.113
exit=0
=== 修正版 guard(許可リスト+中継点リストで全て既知) ===
✅ 接続先はすべて許可リスト内でした
exit=0
=== 修正版 guard(未知の 93.184.216.34 を混ぜた場合) ===
⚠️ 許可リスト外への接続を検知しました:
93.184.216.34
exit=1
逆引きできない IP も名前欄に残り、既知の中継点は known-transit.txt に逃がしてあるため警告が出ません。そして本当に見知らぬ宛先が現れたときだけ、はっきり落ちます。この三つの挙動が揃って、はじめて監視として意味を持つのだと感じています。
なお中継点リストの中身は、次の一行だけです。人の判断を一度だけ行い、その結論をファイルに残します。それが「勘で足す」を「差分を足す」に変える最後のひと押しでした。
# 経路上の中継点(サービス本体ではない)
172.16.10.1
名前へ戻す辞書は、いまも併用しています
逆引きをやめたあと、許可リスト候補を正引きして辞書を作り、観測した IP をその辞書で名前に戻す道具も用意しました。修正版 guard で未知として残った 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 ひとつでした。中継点だと分かってしまえば、以後は無視してよい既知の顔になります。
運用で効いた判断
数週間回して残った、状況別の勘所を残しておきます。
強い締め方ほど、失敗が目立たなくなります。だからこそ、締める前に「実際に触るホストを観測する」ことと、締めた後に「拒否をはっきり見える化する」ことの二つが要になりました。設定を有効化する一行よりも、その前後の観測に手間をかける価値があります。
そして今回いちばん応えたのは、その観測の側が黙って壊れていたという事実でした。監視の道具は、監視される側と同じだけ疑ってよいのだと思います。
まずは手元の観測スクリプトに、逆引きできない IP を一つだけ渡してみてください。何も出てこないようなら、私と同じ場所でつまずいています。お読みいただきありがとうございました。