無人で走らせているエージェントのログを、後から眺めていたときのことです。ある行に、外部 API のトークンがそのまま残っていました。エラーのリトライ時に、リクエスト全体をデバッグ目的で書き出していた——ただそれだけの設定が、平文の秘密をディスクに刻んでいました。
ログは消せます。けれども、すでに別の場所へ転送されたログや、バックアップに取り込まれたログは、こちらの手の届かないところへ広がっています。トークンをローテートすれば被害は止められますが、ログそのものが汚染された事実は消えません。だから対処は、流出してからではなく、書き込む手前で済ませておく必要があります。
ここで設計するのは、ログを書く直前に必ず通す「リダクション層」です。Antigravity 2.0 のエージェントは、ツール呼び出しやモデルへの入出力をそのまま記録すると役に立ちますが、その素直さが秘密も一緒に運んでしまいます。記録の便利さを保ったまま、秘密だけを落とす一点を作ります。
ログは「いつか誰かに見られる」前提で書く
最初に置いておきたいのは、運用上の前提です。ログは、書いた本人だけが読むものではありません。
転送先の監視サービス、共有のストレージ、エラー集約ツール、そして将来の自分。ログは思っているより遠くまで旅をします。「自分しか見ないから」という想定は、無人運用では特に崩れやすいものです。だからこそ、ログに秘密を「最初から入れない」ことを設計の前提に据えます。
入れてしまってから消すのではなく、入る経路を一つに絞って、そこで確実に落とす。発想としては、許可リストで権限を一点に集めるのと同じ形になります。
何が漏れるのか — 秘密の出どころを並べる
落とす対象を決めるには、どこから秘密が混ざるかを先に把握しておくと迷いません。
出どころ 混ざりやすい秘密 典型的な記録経路
外部APIへのリクエスト Bearerトークン, APIキー リトライ時のリクエスト全文ダンプ
環境変数の出力 シークレット, 接続文字列 起動時の設定ダンプ・例外のローカル変数
モデルへの入出力 貼り付けた認証情報 プロンプト・ツール引数の記録
スタックトレース URLに埋まったトークン 例外メッセージのそのままの記録
縦に出どころ、横に「何が」「どう」記録されるかを並べました。狙って書いていなくても、例外処理やリトライの「全部出す」挙動が秘密を運ぶ のが厄介なところです。攻撃ではなく、親切な設計がそのまま穴になります。
リダクションは書き込みの直前に一度だけ通す
リダクション層は、ログのフォーマッタとして実装するのが扱いやすい形です。アプリ側のどこで logging を呼んでも、最終的にこのフィルタを通ってからディスクへ届く、という一点を作ります。
import logging, re
# 形のはっきりした秘密はパターンで落とす
PATTERNS = [
(re.compile( r "Bearer \s + [ A-Za-z0-9._ \- ] + " ), "Bearer [REDACTED]" ),
(re.compile( r " (?i) ( api [ _- ] ? key \" ? \s * [ := ]\s * \" ? )[ A-Za-z0-9._ \- ] {12,} " ), r " \1 [ REDACTED ] " ),
(re.compile( r ":// [ ^ :/@ \s] + : [ ^ @ \s] + @" ), "://[REDACTED]@" ), # URL埋め込み資格情報
]
class RedactingFilter ( logging . Filter ):
def filter (self, record: logging.LogRecord) -> bool :
msg = record.getMessage()
for pat, repl in PATTERNS :
msg = pat.sub(repl, msg)
record.msg = msg
record.args = () # 後段での再展開で素の値が戻らないようにする
return True
logger = logging.getLogger( "agent" )
handler = logging.FileHandler( "agent.jsonl" )
handler.addFilter(RedactingFilter()) # 書き込み直前で必ず通る
logger.addHandler(handler)
record.args = () の一行が地味に効きます。フォーマット前のメッセージだけを書き換えても、引数が残っていれば後段で素の値に戻ってしまいます。マスク後は引数を空にして、戻り道を塞ぎます。
パターンだけでは漏れる — 既知の秘密を登録して消す
正規表現は、形の決まった秘密には強い反面、形のない秘密——たとえば自前で発行したランダムなトークンや、特定の接続文字列——は取りこぼします。そこで、起動時に「自分が知っている秘密の実値」を登録し、ログ中にそれが現れたら問答無用で落とす方式を併走させます。
class SecretRegistry :
def __init__ (self):
self ._secrets: list[ str ] = []
def register (self, value: str ):
if value and len (value) >= 8 : # 短すぎる値は誤爆するので登録しない
self ._secrets.append(value)
def scrub (self, text: str ) -> str :
for s in self ._secrets:
if s in text:
text = text.replace(s, "[REDACTED:known]" )
return text
# 起動時に環境変数・シークレットストアの実値を登録
registry = SecretRegistry()
import os
for key in ( "ADMOB_TOKEN" , "STORE_API_KEY" , "DB_PASSWORD" ):
registry.register(os.environ.get(key, "" ))
短い値を登録しない判断は、誤爆を避けるためです。8文字程度を境にしておくと、true や日付のような一般的な文字列が巻き添えでマスクされる事故を防げます。パターンで形を、レジストリで実値を——この二段で、取りこぼしはかなり減ります。
消しすぎると調査できない — 部分マスクとハッシュの使い分け
ここで一つ、本番運用ならではのジレンマに当たります。秘密を [REDACTED] で完全に潰すと、今度は障害調査のときに「どのトークンで失敗したのか」すら追えなくなります。
私が落ち着いたのは、用途で消し方を分ける方針です。
完全に潰してよいもの(パスワード・カード番号)は [REDACTED] で丸ごと消します。
識別だけしたいもの(どのトークンか)は、末尾4文字だけ残す部分マスクにします。
突き合わせたいもの(同じ値が複数行に出たか)は、安定したハッシュの先頭8桁に置き換えます。
import hashlib
def partial_mask (value: str ) -> str :
return "****" + value[ - 4 :] if len (value) > 4 else "****"
def stable_hash (value: str ) -> str :
return "h:" + hashlib.sha256(value.encode()).hexdigest()[: 8 ]
ハッシュにしておくと、素の値は復元できないまま「同じ秘密が別の行にも出ている」ことだけは分かります。調査に必要なのは値そのものではなく、たいていこの「同一性」のほうです。
運用の所感 — リダクションは止めない、検知も併走させる
最後に、運用してみての実感を二つだけ。
リダクション層は、性能を理由に外したくなる瞬間が来ます。大量のログにすべて正規表現をかけるのは、たしかに無料ではありません。それでも私は、ここを止めないことを強く推奨します。秘密が一度ディスクに落ちたら、取り返しはつきません。コストは「保険料」として割り切るのが、無人運用では結局いちばん安く付きます。
もう一つ、リダクションは「最後の壁」であって「唯一の壁」にしないことです。マスクが効いたということは、そもそも秘密がログ経路に流れ込んでいる証拠でもあります。[REDACTED] が出た回数を別に数えておき、急に増えたら「どこかで秘密を素で渡し始めた」と読む。消す仕組みと、気づく仕組みを並走させておくのが安心だと感じています。
個人開発で Dolice Labs の4サイトを無人で回していると、AdMob のレポート取得から App Store 向けの処理まで、秘密に触れるエージェントは少なくありません。そのすべてのログが、書き込みの手前で同じ一点を通る——そう決めておくだけで、夜のあいだ積み上がっていくログを、ずっと落ち着いて眺められるようになります。