Antigravity CLI の Issue 一覧を眺めていた昼下がり、ひとつの報告で手が止まりました。Remote Control で「読み取りのみ」にしておいた枠を、エージェントがローカルの settings.json を書き換えることで越えてしまう、という内容です(#1083・執筆時点で未解決)。
私自身、壁紙アプリの画像の仕分けを夜のあいだに無人で回しております。翌朝に結果を眺めるだけの運用で、途中で誰かが画面を見ているわけではありません。
そこで自分の設定を思い返してみると、仮にエージェントが設定ファイルを一行書き換えても、それに気づく仕組みがどこにもありませんでした。胃のあたりが少し重くなったのを覚えています。
内側の約束だけでは、外れたときに誰も気づきません
最初に考えたのは、エージェント側の設定をもっと厳しくすることでした。許可リストを絞り、フックで止め、プロンプトにも「設定を触らないこと」と書き足す——どれもエージェントの内側に置く約束です。
ところが同じ時期に、--dangerously-skip-permissions を付けると /plan モードの stop hook まで素通りになる、という報告も上がっていました(#1074)。内側の約束は、内側の別の仕組みでまとめて外れることがあるのです。
問題は「破られるかどうか」ではなく、「破られたときに翌朝の私が知らないまま次の夜を迎えること」でした。そう整理し直して、線を一本引いております。
止める仕組みより先に、書き換えに気づく仕組みを外側へ。
エージェントのプロセスの外で動き、実行の前後だけを見る小さな番人です。完璧な防壁ではありませんが、気づけない状態からは抜け出せます。
番人がやることは3つだけです
番人の仕事を、次の3つに絞りました。
| 段階 | やること | 狙い |
|---|---|---|
| 実行前 | 見張るファイルの sha256 を記録し、実物も控える | 比較の基準と、戻すための原本を残す |
| 実行中 | コマンドを打ち切り時間つきで走らせる | 終わらない夜を作らない |
| 実行後 | 指紋を取り直し、違っていれば退避して原本へ戻す | 翌朝に差分と証拠が残る |
打ち切り時間を入れたのには理由があります。CLI 1.2.6 で headless 実行(-p)の既定タイムアウトが5分から無制限へ変わりました。長い作業には助かる変更ですが、無人の夜に限っては、区切りを自分で持っておく方が安心です。
番人スクリプトの全体
Python の標準ライブラリだけで書いています。何を解決するかといえば、「エージェントが設定を書き換えたかどうかが、実行後に機械的に分かり、元に戻っている」状態を作ることです。
#!/usr/bin/env python3
"""無人実行の前後で設定ファイルの指紋を比べ、書き換えがあれば元に戻して止める番人。"""
import argparse
import hashlib
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
GUARD_HOME = Path(os.environ.get("AGENT_GUARD_HOME", Path.home() / ".agent-guard"))
TAMPER_EXIT = 42 # CLI 側の終了コード(構造化エラーの 3 など)と重ならない値
def fingerprint(path: Path):
"""ファイルなら中身の sha256、ディレクトリなら配下全体の sha256。無ければ None。"""
if not path.exists():
return None
h = hashlib.sha256()
if path.is_dir():
for p in sorted(path.rglob("*")):
if p.is_file():
h.update(str(p.relative_to(path)).encode())
h.update(p.read_bytes())
return "dir:" + h.hexdigest()
h.update(path.read_bytes())
return h.hexdigest()
def load_watch(list_file: str):
paths = []
for line in Path(list_file).read_text(encoding="utf-8").splitlines():
line = line.strip()
if line and not line.startswith("#"):
paths.append(Path(os.path.expanduser(line)).resolve())
return paths
def snapshot(paths, run_dir: Path):
"""指紋を記録し、元に戻せるよう実物も控えておく。"""
state = {}
for i, p in enumerate(paths):
state[str(p)] = fingerprint(p)
dest = run_dir / "before" / str(i)
if p.is_dir():
shutil.copytree(p, dest)
elif p.exists():
dest.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(p, dest)
(run_dir / "state.json").write_text(json.dumps(state, indent=2, ensure_ascii=False))
return state
def restore(paths, state, run_dir: Path, changed):
"""書き換えられた版は quarantine へ退避し、実行前の版を戻す(削除はしない)。"""
for i, p in enumerate(paths):
if str(p) not in changed:
continue
if p.exists():
q = run_dir / "quarantine" / str(i)
q.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(p), str(q))
src = run_dir / "before" / str(i)
if state[str(p)] is None:
continue # 実行前は存在しなかった → 退避だけで元どおり
if src.is_dir():
shutil.copytree(src, p)
else:
shutil.copy2(src, p)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--watch", required=True, help="見張るパスを1行1つ書いたファイル")
ap.add_argument("--timeout", type=int, default=3600, help="秒。headless の既定は無制限なので必ず区切る")
ap.add_argument("cmd", nargs=argparse.REMAINDER, help="-- の後に実行するコマンド")
a = ap.parse_args()
cmd = a.cmd[1:] if a.cmd[:1] == ["--"] else a.cmd
if not cmd:
ap.error("実行するコマンドがありません")
paths = load_watch(a.watch)
run_dir = GUARD_HOME / f"{time.strftime('%Y%m%d-%H%M%S')}-{os.getpid()}"
run_dir.mkdir(parents=True)
state = snapshot(paths, run_dir)
try:
rc = subprocess.run(cmd, timeout=a.timeout).returncode
except subprocess.TimeoutExpired:
print(f"[guard] {a.timeout} 秒で打ち切りました", file=sys.stderr)
rc = 124
changed = {p: {"before": b, "after": fingerprint(Path(p))}
for p, b in state.items() if fingerprint(Path(p)) != b}
report = {"command": cmd, "exit": rc, "changed": changed}
(run_dir / "report.json").write_text(json.dumps(report, indent=2, ensure_ascii=False))
if changed:
restore(paths, state, run_dir, changed)
for p in changed:
print(f"[guard] 書き換えを検知し、実行前の版へ戻しました: {p}", file=sys.stderr)
print(f"[guard] 詳細: {run_dir / 'report.json'}", file=sys.stderr)
sys.exit(TAMPER_EXIT)
sys.exit(rc)
if __name__ == "__main__":
main()なぜこう書いたのか、要点を3つだけ添えます。
- 書き換えられた版を消さず、quarantine へ移しています。 何を書き込もうとしたのかは、翌朝いちばん知りたい証拠だからです。
- 実行前に無かったファイルも見張れます。 新しく作られたフックの置き場のように「増えた」ことも、指紋が
Noneから変わることで拾えます。 - 終了コードを3種類に分けています。 書き換えは 42、打ち切りは 124、それ以外はコマンド自身の値をそのまま返します。CLI の構造化エラー(終了コード 3)と混ざらないようにするためです。
同じ秒に二度走らせたら、番人のほうが落ちました
最初の版では、記録用のフォルダ名を「年月日-時分秒」だけにしておりました。手元で試験を続けて流したところ、同じ秒に2回目が始まり、FileExistsError で番人自身が止まっています。
見張る側が先に倒れては、見張りになりません。フォルダ名の末尾にプロセス ID を足して、ぶつからないようにしました。夜間のジョブを分割して並べるようになると、同じ秒の起動は案外すぐに起きるのだと思います。
試験では、中身を書き換えるだけの偽のコマンドを走らせて、次の4通りを確かめました。
| 試したこと | 終了コード | 結果 |
|---|---|---|
| 何も触らないコマンド | 0 | そのまま通過 |
| 設定ファイルを書き換え、フックの置き場を新設 | 42 | 両方を検知。原本へ戻し、書き換えられた版は quarantine へ |
| 打ち切り時間 1 秒で 3 秒かかるコマンド | 124 | 打ち切りを記録 |
| 終了コード 3 で終わるコマンド | 3 | 番人の値と混ざらずに返る |
夜のジョブに組み込む
見張るパスは、1行1つのテキストに書きます。パスは CLI の版で変わりえますので、お使いの版のドキュメントで、プロジェクト側・ユーザー側の設定、フック、MCP の設定がどこに置かれるかを確かめてから書いてください。
# ~/.agent-guard/watch.txt
# プロジェクト側の設定ディレクトリ(実際のパスに置き換える)
~/work/wallpaper-batch/<プロジェクト設定ディレクトリ>
# ユーザー側の設定ファイル(実際のパスに置き換える)
~/<ユーザー設定の置き場>/settings.json
# 夜のジョブを起動するスクリプト自身も見張る
~/bin/run-nightly.sh起動スクリプトは、番人の終了コードで分岐させるだけです。
#!/bin/bash
# ~/bin/run-nightly.sh — cron や launchd から呼ぶ入口
export AGENT_GUARD_HOME="$HOME/.agent-guard"
PROMPT="$(cat "$HOME/jobs/wallpaper-tagging.md")"
python3 "$HOME/bin/agent_guard.py" \
--watch "$AGENT_GUARD_HOME/watch.txt" --timeout 2700 -- \
agy -p "$PROMPT"
rc=$?
case $rc in
0) ;;
42) echo "$(date '+%F %T') 設定の書き換えを検知 → 次回の実行を止める" >> "$AGENT_GUARD_HOME/alerts.log"
touch "$AGENT_GUARD_HOME/HOLD" ;;
124) echo "$(date '+%F %T') 打ち切り" >> "$AGENT_GUARD_HOME/alerts.log" ;;
*) echo "$(date '+%F %T') exit $rc" >> "$AGENT_GUARD_HOME/alerts.log" ;;
esac書き換えを検知したら、HOLD という空ファイルを置いています。cron 側で「HOLD があれば起動しない」と一行足しておけば、私が差分を読むまで次の夜は走りません。戻したから大丈夫、ではなく、なぜ書き換えようとしたのかを読むまでは再開しない——ここが番人のいちばん大事な仕事だと考えています。
番人も万能ではありません
正直にお伝えしておきたい限界が2つあります。
ひとつは、番人とエージェントが同じユーザー権限で動いていることです。記録置き場を作業ディレクトリの外に置いても、エージェントがそこまで手を伸ばせば書き換えられます。macOS の chflags uchg で設定ファイルを変更不可にする手もありますが、所有者なら外せる印ですので、これも絶対ではありません。
もうひとつは、実行の「途中」は見ていないことです。書き換えて、使って、元に戻す——という動きは、前後の比較では拾えません。
それでも私は、この番人を外しません。完全に縛る仕組みを目指して何も置かないより、気づける仕組みを一枚置いて翌朝の私に判断を渡すほうが、無人運用の現実に合っていると感じています。エージェント側の権限をどう切り分けるかは、Antigravity Agent に書き込み権限を安全に渡す Permission Boundary の本番設計で、外側の番人と対になる内側の設計として詳しく書き残しています。
まずは今夜のジョブで見張りたいファイルを3つだけ watch.txt に書き、何も触らないコマンドで番人が 0 を返すことを確かめるところから始めていただければと思います。