AIに任せた作業の請求が、長く走らせた日だけ桁違いになる。公開したリポジトリや配布ZIPに、自分のユーザー名が入ったパスや秘密キーが残っていた。どちらも、気づくのは起きた後です。
まず30秒で確かめられます → https://zero-trace-ship-kit.zeus-lab-global.workers.dev/
公開前のフォルダやZIPを置くと、残っている痕跡をその場で一覧にします。ブラウザの中だけで動き、外部への通信はありません。
この記事で分かること
- AIエージェントの利用料が、履歴を毎手再送する計算モデルで、累積の入力コストがなぜ手数に対して二次的に増えるのか。式と計算値で示します。
- 「通知」では止まらない理由と、呼び出しの前に見積もって止める最小の実装。
- リポジトリや配布ZIPに残りやすい個人の痕跡(ユーザー名入りのパス、秘密キー、メールアドレス、写真のEXIFなど)を、commit / push の時点で機械的に止めるゲートの実装と、その検証結果。
記事中の金額はすべて式から求めた計算値で、実際の請求書の数字ではありません。単価は例として置いた値なので、お使いのサービスの現在の公式価格に置き換えて読んでください。
ブラウザだけで試せるデモを置いています(外部通信なし): https://zero-trace-ship-kit.zeus-lab-global.workers.dev/
1. 請求が膨らむ構造
エージェントは1手ごとに、それまでのやり取りと道具の実行結果をすべて入力として送り直します。記号を次のように置きます。
-
B: 最初から載っている量(指示文、道具の定義) -
A: 1手ごとに増える量(道具の結果と直前の応答) -
O: 1手ごとの出力 -
S: 手数
k 手目の入力は B + (k-1)·A トークンです。S 手までの入力の合計は
入力合計 = S·B + A·S·(S-1)/2
となり、第2項が S の2乗で効きます。手数が2倍になると、この項はおよそ4倍です。
B=6,000、A=2,500、O=500、入力 $3 / 100万トークン、出力 $15 / 100万トークン、再利用分(プロンプトキャッシュの読み出し)$0.30 / 100万トークンという例の値で計算すると次のとおりです。
| 手数 | 入力の合計(トークン) | 合計費用 | 再利用が毎回効いた場合 | 1手あたり平均 ÷ 最初の1手 |
|---|---|---|---|---|
| 10 | 172,500 | $0.59 | $0.20 | 2.3倍 |
| 20 | 595,000 | $1.94 | $0.47 | 3.8倍 |
| 40 | 2,190,000 | $6.87 | $1.24 | 6.7倍 |
| 80 | 8,380,000 | $25.74 | $3.66 | 12.6倍 |
| 160 | 32,760,000 | $99.48 | $12.12 | 24.4倍 |
手数が10から160へ16倍になると、費用は約169倍になります。「昨日まで数十円だった処理が、長く走らせた日だけ桁が変わる」のは、この形が原因です。
読み取れることは3つあります。
- 費用を決めるのは単価よりも手数と1手あたりの増分。 単価が半分になっても、手数が1.5倍になれば帳消しになります。
- 再利用(キャッシュ)は効くが、2乗の形は消えない。 上の表でも再利用ありの列は依然として加速しています。また、途中で文脈の先頭を書き換えると再利用が外れ、その手から通常単価に戻ります。再利用の書き込みに割増がある料金体系では、この列より高くなります。
-
道具の結果を小さくすることが最も効く。
Aは2乗の係数です。ファイル全文やログ全体を返す道具を、必要な行だけ返す形に直すと、合計に直接効きます。
2. 通知ではなく、実行前に止める
多くの管理画面にある「予算の通知」は、使った後に届きます。止めるには、呼び出しの前に最悪の場合の費用を見積もり、上限を超えるなら呼び出さない、という順序にする必要があります。
class BudgetExceeded(RuntimeError):
pass
class Guard:
def __init__(self, cap_usd, in_per_mtok, out_per_mtok):
self.cap, self.pin, self.pout, self.spent = cap_usd, in_per_mtok, out_per_mtok, 0.0
def authorize(self, input_tokens, max_output_tokens):
worst = (input_tokens * self.pin + max_output_tokens * self.pout) / 1_000_000
if self.spent + worst > self.cap:
raise BudgetExceeded(f"spent ${self.spent:.4f}, next call up to ${worst:.4f}")
def record(self, input_tokens, output_tokens):
self.spent += (input_tokens * self.pin + output_tokens * self.pout) / 1_000_000
要点は次の3つです。
-
authorizeは出力の上限いっぱいまで使われた場合で見積もる。実績ではなく最悪値で判定すると、上限を超える呼び出しは1回も実行されません。 -
recordには、応答に含まれる実際の使用量を渡す。 - 上限は「1回の実行」だけでなく「月」の単位でも持つ。複数の処理が同じ台帳ファイルを読むようにすると、合計で止められます。
先ほどの例では、上限を $2 に置くと21手目で止まります。そのまま40手走らせた場合の $6.87 との差が、1回の実行で守られる金額です。
同じ呼び出しを繰り返す状態(同じファイルを何度も読む、同じ検索を繰り返す)も費用が膨らむ典型です。道具名と引数をつないだ文字列を直前のものと比べ、連続で同じなら止める、という数行の見張りで防げます。
3. 公開物に残る「痕跡」
費用の次に事故になりやすいのが、公開したリポジトリや配布ZIPに残る個人の情報です。AIエージェントに作業を任せると、ログ、設定、エラー出力、試験結果に、作業した端末の情報がそのまま書き込まれることがあります。残りやすいものを挙げます。
| 種類 | 例 |
|---|---|
| ユーザー名入りの絶対パス |
/Users/<ログイン名>/...、/home/<ログイン名>/...、C:\Users\<ログイン名>\...
|
| 秘密キー・秘密鍵 | 各サービスのAPIキー、BEGIN ... PRIVATE KEY で始まる鍵 |
| メールアドレス | READMEの連絡先、コミットの名義 |
| 画像の付帯情報 | JPEGのEXIF(撮影位置を含むことがある)、PNGの文字情報 |
| 書類の作者欄 | PDFの Author、Office書類の作成者 |
| OSの残りファイル |
.DS_Store、__MACOSX/、Thumbs.db、.env
|
| Gitの名義 | 個人名とメールアドレスで作られたコミット |
ZIPは特に見落とします。作業フォルダは確認しても、配布用にまとめたZIPの中、さらにその中のZIPまでは開かないからです。
4. ゲートの実装
方針は「見つけたら commit と push を失敗させる」だけです。Pythonの標準機能のみで書きます。
4.1 形の決まったものは正規表現で拾う
HOME_PATH = re.compile(
r"(?<![\w.])(?:/Users/|/home/|[A-Za-z]:\\{1,2}Users\\{1,2})([A-Za-z0-9][A-Za-z0-9._-]*)"
)
先頭の (?<![\w.]) は、https://example.com/home/dashboard のようなURLの一部を誤って拾わないための条件です。取り出した名前が you や runner のような説明用・CI用の名前なら通します。
4.2 自分の名前は、ファイルに書かずに実行時に読む
検査機に本名を書き込めば、検査機そのものが漏れの原因になります。そこで、ログイン名、OSに登録された氏名、Gitの全体設定の名義を実行のたびに端末から読み取り、メモリの中だけで照合します。出力では必ず伏せます。
raw.add(getpass.getuser())
raw.add(Path.home().name)
full = run(["id", "-F"]) # macOS: 登録された氏名
文字列は UTF-8 と UTF-16 の両方の形で探します。Office書類や一部のバイナリは UTF-16 で名前を持つためです。
4.3 ZIPは中まで開く
def scan_archive(self, where, data, depth):
with zipfile.ZipFile(io.BytesIO(data)) as zf:
for info in zf.infolist():
self.scan_bytes(f"{where}!/{info.filename}", zf.read(info), depth + 1)
scan_bytes は中身がZIPならもう一度 scan_archive を呼ぶので、ZIPの中のZIPも同じ検査を通ります。.docx や .xlsx も実体はZIPなので、同じ経路で作成者欄まで読めます。
4.4 画像は区画を読むだけで足りる
EXIFの有無は、JPEGの区画を順に読み、APP1 区画が Exif で始まるかを見るだけで分かります。位置情報は、その中の目印 0x8825 があるかで判定します。外部ライブラリは要りません。
4.5 フックに入れる
# .git/hooks/pre-commit
exec python3 gatekeeper/zero_trace_check.py --staged --identity
# .git/hooks/pre-push
exec python3 gatekeeper/zero_trace_check.py --all --identity --history --remote --path dist
commit前は、これから記録される内容(ステージされた内容そのもの)を調べます。push前は、全ファイル、過去のコミットの名義とメッセージ、送り先のURL、そして .gitignore で外している配布ZIPの置き場も調べます。同じ検査をGitHub Actionsの最初の仕事としても走らせ、通った時だけ後続の配信が動くようにします。
5. 検証結果
検査機が自分で「合格」と言っても意味がないので、別のプログラムから確かめました。一時フォルダに「漏れを仕込んだファイル」と「紛らわしいが安全なファイル」を作り、検査機にかけます。仕込む値は実行時に組み立てるので、試験プログラム自体は検査に通ります。
| 項目 | 結果 |
|---|---|
| 漏れを仕込んだファイル | 26件中26件を検出 |
| 紛らわしい安全ファイル | 15件中、誤検出0件 |
| 所要時間 | 6.9ミリ秒(Python 3.14、Apple Siliconの Mac、1回の実行) |
仕込んだ26件の内訳は、暗号資産の復元フレーズ(12語・24語)、鍵として代入されたEVMの秘密鍵、Solanaの鍵ファイル、取引所のAPIシークレット、3つのOSの絶対パス、4種類のキーと秘密鍵、メールアドレス、直書きのパスワード、EXIFと位置情報を持つJPEG、文字情報を持つPNG、.DS_Store、.env、作者欄のあるPDF、パスを含むZIP、キーを含む「ZIPの中のZIP」、__MACOSX を含むZIP、途中で切れたZIP、作成者欄のある .docx、UTF-16で登録語を含むバイナリ、登録語を含むファイル名です。
安全側の15件には、SHA-256の値(鍵と同じ64桁の16進数)、単語表の語を含む普通の英文、/Users/you/ のような説明用のパス、actions/checkout@v4、logo@2x.png、example.com のアドレス、/home/ を含むURL、環境変数から読むだけのコードなどを入れています。
試験の結果はそのまま公開しています: https://zero-trace-ship-kit.zeus-lab-global.workers.dev/evidence/selftest.json
6. 限界
- 形の決まっていない情報は拾えません。文章の中の本名や住所は、端末から読み取れた語か、手元の登録語に入れた語だけが対象です。
- キーの検出は主要な形式に限ります。独自形式の認証情報は対象外です。
- 画像の中に写り込んだ文字は読みません。
- 「検出0件」は、この検査機が知っている形については残っていない、という意味であり、安全の証明ではありません。
まとめ
- エージェントの費用は
S·B + A·S·(S-1)/2の形で伸びる。効くのは手数と1手あたりの増分。 - 止めるなら、呼び出しの前に最悪値で見積もって拒む。通知は止めない。
- 個人の痕跡は、人の注意ではなく commit / push の関所で止める。ZIPは中まで開く。自分の名前は検査機に書かず、実行時に読む。
ブラウザで動くスキャナーと、文章ファイルを調べる軽量版は無料です: https://zero-trace-ship-kit.zeus-lab-global.workers.dev/
commit / push を止める完全版の検査機、クリーンな配布ZIPを作る道具、上限ガード、配信の自動化一式、商用ライセンスをまとめた完全版はこちらです: https://zeuslab.gumroad.com/l/zero-trace-ship-kit