Managed Agents がパブリックプレビューになって最初に試したのは、いつもの記事生成パイプラインの一部をこのサンドボックスに載せ替えられるか、でした。Googleホストの隔離Linux環境でエージェントが計画・コード実行・ファイル操作まで完結してくれるのは、自前でコンテナを回している身にとって確かに魅力があります。
ところが動かしてみると、すぐに一番地味で一番危ない問題に突き当たりました。サンドボックスの中で作った成果物を、どうやって自分の管理下(GitHubリポジトリやオブジェクトストレージ)へ安全に戻すか 、という点です。ここを雑に作ると、Googleがホストする隔離環境の内側に、自分のインフラ全体を触れる鍵を置くことになります。隔離の意味が反転してしまいます。
ここでは持ち出し(egress)の一点に絞り、私がDolice Labsの自動処理で実際に採用した設計を共有します。6/19から制限なしAPIキーのリクエストが拒否されるようになった流れとも、実は綺麗につながる話です。
なぜ「成果物の持ち出し」だけを切り出して設計するのか
Managed Agents のサンドボックスは、実行のたびに立ち上がり、終われば消えるステートレスな環境として扱うのが基本です。計画も推論もコード実行もこの中で完結します。便利な反面、成果物(生成したMDX、ビルド済みJSON、画像など)はサンドボックスの寿命と一緒に消えます。だからどこかへ吐き出す必要があります。
素朴にやると、こうなります。エージェントに「GitHubへpushして」と頼み、そのために GITHUB_TOKEN をまるごと環境変数で渡す。動きます。でも、その瞬間にサンドボックス内のコード(自分が完全には制御していない、モデルが書いたコードを含む)が、そのトークンで到達できる全リポジトリを触れる状態になります。個人開発で複数サイトを同じアカウント下に置いている私の場合、1本の広すぎるトークンが漏れれば4サイト全部が射程に入ります。
ここでの原則はひとつです。サンドボックスには「今回の成果物を、決められた1箇所へ、書き込むだけ」の権限しか渡さない。 読み取りも、他リポジトリへの到達も、削除も要りません。egressをこの粒度まで絞ると、仮にサンドボックス内のコードが暴走しても、被害は「今回の出力先が汚れる」ところで止まります。
悪い例と良い例:クレデンシャルの渡し方
まず、やってはいけない形です。
# ❌ 悪い例: 長期・広範囲のトークンをサンドボックスに丸ごと渡す
agent = client.agents.create(
model = "gemini-flash-latest" ,
environment = {
"env_vars" : {
# このPATは全リポジトリにread/write可能。TTLも実質無期限
"GITHUB_TOKEN" : "ghp_LONG_LIVED_BROAD_SCOPE_TOKEN" ,
# おまけにデプロイ鍵まで渡してしまう
"CF_API_TOKEN" : "cloudflare_account_wide_token" ,
}
},
)
この形の何が問題かというと、権限・期限・到達範囲のすべてが「広い」ことです。トークンは長生きで、全リポジトリに書けて、ついでにデプロイまで触れます。サンドボックスの中で走るコードを一行ずつ検証できない以上、渡す鍵はそのコードにやってほしいことちょうどぶんに削るべきです。
良い形は、そもそも汎用トークンを渡さないことです。呼び出し側(自分の管理サーバー)で今回の1オブジェクトにだけ有効な、短命の署名付きアップロードURL を先に発行し、それだけをサンドボックスに渡します。
# ✅ 良い例: 呼び出し側で「今回の成果物1個」に限定した書き込み用URLを発行
import datetime
from google.cloud import storage
def issue_upload_url (site: str , run_id: str ) -> str :
bucket = storage.Client().bucket( "dolice-agent-artifacts" )
# 出力先を run 単位のプレフィックスに固定する(他runの領域には触れない)
blob = bucket.blob( f "incoming/ { site } / { run_id } /output.tar.gz" )
return blob.generate_signed_url(
version = "v4" ,
expiration = datetime.timedelta( minutes = 15 ), # 15分で失効
method = "PUT" , # PUT のみ。GET も DELETE も不可
content_type = "application/gzip" ,
)
signed_url = issue_upload_url( "gemilab" , run_id = "20260701-01" )
agent = client.agents.create(
model = "gemini-flash-latest" ,
environment = {
# サンドボックスに渡すのは「この1個を15分だけ書ける」URLのみ
"env_vars" : { "ARTIFACT_UPLOAD_URL" : signed_url},
},
)
差は明確です。悪い例では鍵の到達範囲が「アカウント全体」でしたが、良い例では「incoming/gemilab/20260701-01/output.tar.gz という1オブジェクトへ、PUTで、15分だけ」です。この署名付きURLが仮にログに残っても、盗んだ側にできるのは「今回の出力先を上書きする」ことだけで、しかも15分後には無効になります。
サンドボックス側は「1個をPUTする」だけに保つ
サンドボックス内のコードは、成果物をまとめて、渡されたURLへ投げるだけにします。ここに追加の権限判断を持ち込まないのが肝心です。
# サンドボックス内で走るegressコード(これ以上の権限を持たない)
import os, tarfile, io, urllib.request
def egress_artifacts (src_dir: str = "/workspace/out" ):
url = os.environ[ "ARTIFACT_UPLOAD_URL" ] # PUT専用・15分TTL
buf = io.BytesIO()
with tarfile.open( fileobj = buf, mode = "w:gz" ) as tar:
tar.add(src_dir, arcname = "out" )
buf.seek( 0 )
req = urllib.request.Request(
url, data = buf.read(), method = "PUT" ,
headers = { "Content-Type" : "application/gzip" },
)
with urllib.request.urlopen(req, timeout = 30 ) as resp:
# 200/201 以外は失敗として即座に落とす(部分成功を残さない)
if resp.status not in ( 200 , 201 ):
raise RuntimeError ( f "egress failed: { resp.status } " )
このコードは、他のバケットも、他のrunのプレフィックスも、GitHubも知りません。知らないものは漏らせません。GitHubへの反映は、この後に自分の管理サーバー側で行います。**「サンドボックスはストレージへ書くところまで、リポジトリへの反映は信頼できる自分の環境で」**という2段構えが、egressを最小権限に保つコツです。
取り込み側の検疫:署名付きURLを信じすぎない
成果物がバケットに届いたら、自分のサーバーが取りに行って、検証してからリポジトリへ反映します。ここを省くと、サンドボックスが吐いた壊れた出力(空ファイル、途中で切れたtar、想定外のパス)がそのまま本番に流れます。私自身、初回にこの検疫を省いて、中身が空のMDXがバケットに載ったまま反映一歩手前まで進み、肝を冷やしました。この落とし穴は、検疫を1段挟むだけで確実に回避できます。
# 自分の管理サーバー側(GitHub反映の権限はここだけが持つ)
def ingest (site: str , run_id: str ):
blob = storage.Client().bucket( "dolice-agent-artifacts" ) \
.blob( f "incoming/ { site } / { run_id } /output.tar.gz" )
if not blob.exists():
raise FileNotFoundError ( "artifact not delivered" )
data = blob.download_as_bytes()
# 検疫1: サイズが極端に小さい=失敗出力とみなす
if len (data) < 512 :
raise ValueError ( f "artifact too small: { len (data) } bytes" )
members = _safe_extract(data) # 検疫2: パストラバーサル(../)を弾く
# 検疫3: 期待する構成(ja/en 両方のMDX)が揃っているか
ja = [m for m in members if m.startswith( "out/ja/" ) and m.endswith( ".mdx" )]
en = [m for m in members if m.startswith( "out/en/" ) and m.endswith( ".mdx" )]
if len (ja) != len (en) or not ja:
raise ValueError ( f "JA/EN mismatch: ja= { len (ja) } en= { len (en) } " )
return ja, en
ここでのポイントは、GitHubへ書ける権限を持つのは自分の管理サーバーだけ という点です。サンドボックスはストレージに置くところまでしか到達できないので、検疫を通らなかった成果物は、そもそもリポジトリに触れることなく捨てられます。日英の本数一致チェックをこの段でも噛ませておくと、Dolice Labsで死守している「日本語版と英語版は必ずセット」という不変条件を、egressの経路上でもう一度守れます。
6/19「制限なしキー拒否」を、Managed Agents向けキーの規律に使う
6/19から、制限(restriction)のかかっていないAPIキーのリクエストが拒否されるようになりました。不正利用と課金の暴発を防ぐための変更ですが、これはManaged Agentsのegress設計とも噛み合います。サンドボックスに渡すGemini APIキー自体も、egressと同じ発想で締めておくべきだからです。
私は、Managed Agents用のGemini APIキーを次の3点で絞っています。
用途の限定 :エージェント実行に必要なGemini APIのみを許可し、他のGoogle APIは無効にします。キー1本の到達範囲を狭めるほど、漏れたときの被害が小さくなります。
プロジェクトの分離 :自動処理用のGCPプロジェクトを本番と分け、Project Spend Capsで月額の上限を張ります。仮にサンドボックス内のコードがループで呼び続けても、月の課金は天井で止まります。
短命キーへの寄せ :長期キーを常駐させず、実行の直前に発行して実行後に失効させる運用へ寄せます。egressの署名付きURLと発想は同じで、「今回の実行に必要なぶんだけ、必要な間だけ」に揃えます。
制限なしキーの拒否は、放置されていた広すぎるキーをあぶり出す健康診断のようなものだと受け止めています。Managed Agentsを個人運用に取り込むなら、この機会にキーの締め方も一緒に見直しておくと、後から効いてきます。
サンドボックスが消える前提でegressを冪等にする
Managed Agents は実行が終わればサンドボックスごと消えます。ネットワークの瞬断やタイムアウトでegressが途中で失敗したとき、サンドボックスはもう再試行してくれません。ここで大事なのが、出力先をrun_idで固定して冪等にしておく ことです。
先の実装では出力先を incoming/{site}/{run_id}/output.tar.gz に固定していました。これにより、同じrunを呼び直しても書き込み先は同じ1オブジェクトになり、二重反映が起きません。取り込み側は「そのrun_idのオブジェクトが検疫を通ったら、まだ反映していなければ反映する」という形にしておきます。私の環境では、この冪等キーをそのまま反映ログのキーにも使い、「どのrunが、いつ、どのslugを出したか」を1行で追えるようにしています。
冪等でない設計だと、egress失敗後の手動リトライで同じ記事が2本入るような事故が起きます。サンドボックスが自己修復してくれない以上、リトライの安全性は呼び出し側と取り込み側で担保するしかありません。
私がこの構成に落ち着いた理由
自前でエージェントループを回す構成と、Managed Agentsに載せる構成の両方を触ってみて、egressの設計思想は結局同じところへ収束しました。信頼できない実行環境には、最小権限・短命・単一の到達先だけを渡す。 隔離環境の内側に広い鍵を置いた瞬間、隔離のメリットはほぼ消えます。個人的には、egressは署名付きURLの一択を推奨します。長期トークンを渡す設計は、どれだけ運用で気をつけても本番運用では事故の芽が残るためです。
Managed Agentsは「環境構築なしでエージェントを回せる」点が確かに軽くて、検証コストを下げてくれます。ただしその軽さは、成果物の持ち出しを雑に作ってよい理由にはなりません。むしろGoogleホストの環境に自分の資産へ届く鍵を置くことになるぶん、egressの締め方はセルフホスト時よりも慎重にしたい、というのが私の実感です。
同じように複数プロジェクトの自動処理をエージェントに載せ替えようとしている方の、設計の出発点になれば幸いです。まずは「今回の1個を、決められた場所へ、短い時間だけ書ける」URLを1本用意するところから始めてみてください。