ある朝、前夜のスケジュール実行のログを追っていて、見慣れないホスト名で手が止まりました。依存パッケージの postinstall が、私の知らない集計エンドポイントへ静かに ping を投げていたのです。エージェント自身が悪さをしたわけではありません。エージェントに任せた npm install の、そのまた先が外へ出ていっただけです。
統一パーミッションでファイルの書き込みには承認をかけていました。けれど「どこへ通信するか」には、何の関門も置いていなかった。個人開発でエージェントを無人で回すようになってから、私が一番の急所だと感じているのはここです。書き換えられるものは差分を見れば戻せます。けれど一度外へ出ていったリクエストは、戻せません。
書き込み権限ばかり見ていた
エージェントに実作業を委ねるという話をするとき、議論はたいてい「何を変更させるか」に向かいます。ファイルを消す、コミットする、デプロイする。どれも承認ダイアログで止められますし、統一パーミッションはその一元管理をきれいにしてくれました。
けれど委譲の怖さの半分は「何に到達できるか」の側にあります。エージェントが走らせるビルド、テスト、パッケージインストール、ツール呼び出しは、どれも外向きの通信を伴います。その通信先は、承認ダイアログには出てきません。具体的には次のような通信が、私の環境では黙って通り抜けていました。
通信の出どころ 到達先の例 承認ダイアログに出るか
依存パッケージの postinstall ベンダーの集計ビーコン 出ない
ツールが同梱するテレメトリ 解析サービスの収集口 出ない
エージェントが書いた検証スクリプト 任意の外部 API 出ない
課金エンドポイントへの実呼び出し 広告・ストア・決済系 API 出ない
私が守りたかったのは、最後の行です。iOS/Android のアプリを個人で運用していると、エージェントの手元にはAdMob などの広告配信やストアの管理 API を叩ける鍵が、環境変数として置かれている場面があります。夜間の自動処理でその鍵を使ってほしい一方、想定外のホストへその鍵が向かうのだけは避けたい。境界を引く場所は、書き込みではなく到達先だと考え直しました。
到達先を deny-by-default で絞る
方針はひとつです。エージェント配下の通信を、すべて手元のゲートに通す。そのゲートは「許可リストに載っているホストだけ」を通し、それ以外は既定で拒否する。deny-by-default にするのがこの設計の肝で、allow-list を書き忘れたホストは通らない、という方向に倒します。逆(既定で許可し、危ないものだけ塞ぐ)にすると、知らないうちに増えた到達先を永遠に追いかけることになります。
実装の勘所は、TLS を割らないことです。中身を復号して覗く必要はありません。私が知りたいのは「どのホストに繋ごうとしたか」だけで、それは HTTPS の CONNECT 要求のホスト名を見れば分かります。中身には触れないので、正規の通信のプライバシーも保てます。
実装 — CONNECT を許可ホストだけ通す小さなゲート
標準ライブラリだけで書ける、80行ほどの転送プロキシです。CONNECT のホスト名を許可リストと照合し、載っていなければ 403 を返してログに残します。
# egress_gate.py — エージェントの外向き通信を許可ホストだけに絞る deny-by-default ゲート
import asyncio, fnmatch, time
def load_allow (path = "egress_allowlist.txt" ):
with open (path) as f:
return [ln.strip() for ln in f if ln.strip() and not ln.startswith( "#" )]
ALLOW = load_allow()
LOG = open ( "egress_denied.log" , "a" )
def allowed (host: str ) -> bool :
# 完全一致か、*.example.com のワイルドカードで判定する
return any (fnmatch.fnmatch(host, pat) for pat in ALLOW )
async def pipe (reader, writer):
try :
while data := await reader.read( 65536 ):
writer.write(data)
await writer.drain()
except Exception :
pass
finally :
writer.close()
async def handle (client_r, client_w):
try :
head = await client_r.readuntil( b " \r\n\r\n " )
except Exception :
client_w.close(); return
line = head.split( b " \r\n " , 1 )[ 0 ].decode( "latin1" )
parts = line.split( " " )
# 到達先を秘匿するため CONNECT(HTTPS トンネル)のみ扱う
if len (parts) < 2 or parts[ 0 ] != "CONNECT" :
client_w.write( b "HTTP/1.1 405 Method Not Allowed \r\n\r\n " )
await client_w.drain(); client_w.close(); return
host, _, port = parts[ 1 ].partition( ":" )
port = port or "443"
if not allowed(host):
LOG .write( f " { time.strftime( ' %F %T' ) }\t DENY \t{ host } : { port }\n " )
LOG .flush()
client_w.write( b "HTTP/1.1 403 Forbidden \r\n\r\n " )
await client_w.drain(); client_w.close(); return
try :
up_r, up_w = await asyncio.open_connection(host, int (port))
except Exception :
client_w.write( b "HTTP/1.1 502 Bad Gateway \r\n\r\n " )
await client_w.drain(); client_w.close(); return
client_w.write( b "HTTP/1.1 200 Connection Established \r\n\r\n " )
await client_w.drain()
await asyncio.gather(pipe(client_r, up_w), pipe(up_r, client_w))
async def main ():
server = await asyncio.start_server(handle, "127.0.0.1" , 8899 )
print ( "egress gate on 127.0.0.1:8899 / allow:" , ALLOW )
async with server:
await server.serve_forever()
asyncio.run(main())
エージェント側は、環境変数でこのゲートを経由させるだけです。Antigravity のタスク/ターミナル設定や、エージェントが起動するシェルに次を渡します。
export HTTPS_PROXY = "http://127.0.0.1:8899"
export https_proxy = "http://127.0.0.1:8899"
# HTTP を使うツール対策(多くは 443 に寄せたいので必要最小限に)
export HTTP_PROXY = "http://127.0.0.1:8899"
CONNECT は HTTPS トンネルの開始要求なので、ゲートはホスト名だけを見て通すか拒むかを決めます。通した後はそのまま双方向に中継するだけで、中身は一切見ません。ここが「割らない」設計の実体です。
プロジェクトごとに許可リストを空から育てる
allowlist は最初からたくさん書かない、というのが私のやり方です。ほぼ空の状態で1晩回し、egress_denied.log に出た拒否を翌朝ながめて、正当なものだけを1行ずつ足していく。この「拒否ログを起点に育てる」順番が、deny-by-default の恩恵を最大にします。
# egress_allowlist.txt — 到達を許すホストだけを1行ずつ
# コード取得・パッケージ
github.com
*.githubusercontent.com
registry.npmjs.org
# 使っている生成 API
generativelanguage.googleapis.com
# 自分のアプリ運用 API(鍵を持つ先だからこそ明示する)
androidpublisher.googleapis.com
手順としては次の4つに畳めます。私の場合、この順番を崩さないことを個人的に強く推奨します。
allowlist をコード取得系だけにして1晩回し、正常系の最小集合を知る
翌朝 DENY ログの相手を1件ずつ判断し、正当な到達先と想定外を仕分ける
正当なものだけを allowlist に追記し、許可を必要な分だけ広げる
想定外は拒否のまま観察を続け、増える到達先に気づける状態を保つ
順番 やること 狙い
1 allowlist をコード取得系だけにして1晩回す 正常系の最小集合を知る
2 翌朝 DENY ログの相手を1件ずつ判断する 正当な到達先と、想定外を仕分ける
3 正当なものだけ allowlist に追記する 許可を必要な分だけ広げる
4 想定外は放置=拒否のまま観察を続ける 増える到達先に気づける状態を保つ
大事なのは、想定外のホストを「とりあえず足す」誘惑に負けないことです。DENY のまま残しておけば、それは翌朝も目に入ります。私は3日ほど連続で同じホストが出て、初めて正体(あるツールのバージョン確認)が腑に落ちて足しました。
21晩回して分かったこと
自分の4アプリまわりの夜間自動処理で、このゲートを21晩挟んでみました。到達試行の総数に対し、拒否されたのは41件。ユニークな拒否ホストは9つでした。内訳が、方針の是非を一番よく語ってくれます。9ホスト中4つ(約44%)は依存パッケージ由来のテレメトリで、拒否したまま放置しても機能には影響しませんでした。
拒否ホストの分類 ユニーク数 私の判断
自分が足し忘れていた正当な先(CDN・ミラー) 3 allowlist に追記
依存パッケージ同梱のテレメトリ・集計 4 拒否のまま(機能に影響なし)
ツールのバージョン確認・更新チェック 2 1つは追記、1つは拒否のまま
数字にすると地味ですが、私にとっての収穫は「41件の中に、決済・広告系の鍵が想定外ホストへ向かった形跡はゼロだった」と、証拠つきで言えるようになったことです。以前は「たぶん大丈夫」でした。今は egress_denied.log を見れば済みます。足し忘れの3件で正常な処理が一時的に止まりましたが、翌朝5分の追記で解消し、無人実行そのものが破綻することはありませんでした。
この方法が守れない範囲
正直に線を引いておきます。ホスト名の粒度でしか絞れないので、同じホストの中の「叩いてよいパス」と「叩いてほしくないパス」は区別できません。TLS を割らない選択と引き換えの制約です。本番運用でこの粒度が足りないと感じた場面では、回避策として後述の二段構えを併用しています。
また、プロキシ環境変数を無視して直接 IP へ繋ぐツールや、独自にソケットを開くバイナリは、このゲートをすり抜けます。私はそこまで塞ぎたい対象(鍵を持つ重い処理)は、ネットワーク名前空間ごと分離するか、ホスト側のファイアウォールで 8899 以外の外向きを落とす二段構えにしています。DNS を許可リストのホストだけに限る手も併用できますが、個人開発の範囲では、まずこのゲートで「見えるようにする」だけでも判断の土台が変わりました。
まとめ
エージェントに実作業を委ねるとき、私たちは「何を変えられるか」に目を凝らしがちです。けれど鍵を預けた相手にとって、より静かで戻せない権限は「どこへ到達できるか」の側にあります。
次の一歩は小さくて構いません。ひとつのプロジェクトで、allowlist をコード取得系だけの数行にして、ゲートを一晩挟んでみてください。翌朝 egress_denied.log に並ぶ相手の名前が、あなたの環境で本当は何が外に出ていたのかを、たぶん一番正直に教えてくれます。私自身、この一覧を初めて見た晩の落ち着かなさを、まだよく覚えています。お読みいただきありがとうございました。