Antigravity CLI 1.1.20 のリリースノートに、既定のレビューモードでワークスペース内の読み取りが自動的に許可されるようになった、という一行がありました。変更と外部アクセスの確認はこれまで通り残ります。読み取りと一覧の承認だけが静かになる、という変更です。
素直に助かる変更です。私自身、承認ダイアログを一日に何十回も押していて、押すこと自体が判断ではなく反射になっていました。
ただ、その一行を読んだ直後に手が止まりました。自分のワークスペースに何が入っているのか、即答できなかったからです。承認を1回ずつ押していた頃は、読み取り対象のパスが毎回目の前に出ていました。それが出なくなるということは、範囲の妥当性を事前に一度だけ決めておく必要がある、ということでもあります。
そこで、実際に手元の作業ツリーを2種類、機械的に数えてみました。結果は予想と違いました。
「ワークスペース内」は、git が見せている範囲ではありません
最初に確認したのは、ごく普通の Node プロジェクトです。npm init して依存を4本(express・typescript・axios・ws)入れ、.gitignore に node_modules/ と .env* と dist/ を書いた、どこにでもある構成にしました。
ディスク上のファイル数と、git が追跡しているファイル数を並べます。
| 指標 | 実測値 |
| ディスク上のファイル数 | 1,352 |
| git が追跡しているファイル数 | 3 |
| 展開された依存パッケージ数 | 79 |
node_modules の合計サイズ | 38 MB |
直接指定した依存は4本ですが、推移的な依存を含めて 79 パッケージが展開されました。追跡されているのは package.json・package-lock.json・.gitignore の3ファイルだけです。
ここが見落としやすい点でした。.gitignore はバージョン管理の対象を決める仕組みであって、ファイルシステムからの読み取りを止める仕組みではありません。git の視界から消えたファイルは、そのままディスクの上に残っています。読み取り自動承認が見ているのはディスクの側です。
「秘密は .gitignore に入れてあるから大丈夫」という感覚は、コミットの事故に対しては正しく、読み取りに対しては何の防御にもなっていません。
エージェントが書いた成果物が .gitignore に飲まれて消える現象についてはエージェントが書いたファイルが .gitignore に飲まれる問題の検出ゲートで扱いました。今回はその逆方向、つまり「git から見えないのに読める」側の話です。
読める範囲の 99.7% は、自分が書いていないファイルでした
同じツリーを、依存物・生成物のディレクトリ(node_modules・dist・.next・Pods・.venv など)とそれ以外に分けて数えました。
| 区分 | ファイル数 | 割合 |
| 自分が置いたファイル | 4 | 0.3% |
| 依存物・生成物 | 1,348 | 99.7% |
読み取り可能な面のほぼ全部が、自分が一行も書いていないファイルでした。内訳を拡張子で見ると .js が 441、.ts が 292、ソースマップが 193、Markdown が 144 です。node_modules の中の Markdown と LICENSE だけで 220 ファイルありました。
これは危険という話ではありません。依存物を読めること自体は開発上むしろ必要です。私が受け止めたのは、範囲の感覚が現実と大きくずれていた、という点です。「ワークスペース内の読み取り」と聞いて思い浮かべていたのは自分のソースコードでしたが、実際に承認なしで開ける対象は、その 300 倍以上ありました。
そしてもう一つ。依存物を除外すると、棚卸しで目を通すべき対象は 1,352 から 4 に減ります。棚卸しは全件を見る作業ではなく、まず依存物と自分の資産を切り分ける作業だと分かりました。
名前で拾うと、10件のうち9件が外れます
次に、資格情報らしいファイルを名前で探しました。.env 系・.pem・.p12・id_rsa・credentials.json・google-services.json・.mobileprovision、それに token・secret・password を含む名前、といった広めの網です。
結果は 10 件でした。中身を確かめると、本物は1件だけでした。
| 検出されたパス | 判定 |
.env.local | 本物(実際に値が入っている) |
node_modules/typescript/dist/enums/tokenFlags.*(8件) | 外れ(TypeScript の字句解析フラグ) |
node_modules/axios/lib/cancel/CancelToken.js | 外れ(リクエスト中断用のクラス) |
token という単語がライブラリのソースにありふれていることは、言われれば当たり前です。ただ、名前ベースの検査を一度回してみるまで、外れが9割になるとは思っていませんでした。この精度では、出力を見るのが面倒になって数回で使わなくなります。実際、私が最初に書いた検査スクリプトは、この時点で捨てる寸前でした。
外れを減らす方法は2つあります。依存物を対象から外すことと、名前で拾ったあとに中身の形で確かめることです。両方を入れた版が次の節のスクリプトです。
2段構えで棚卸しするスクリプト
第1段は名前で広く拾い、第2段でファイルの中身の「形」を見ます。形というのは、キー名らしい語に 20 文字以上の値が代入されている行か、PEM の秘密鍵ヘッダがあるか、という判定です。値そのものは出力しません。出力するのはパスと件数だけにしてあります。
#!/usr/bin/env python3
"""読み取り自動承認の対象になる範囲を、依存物と自分の資産に分けて数える。
ファイルの中身は一切出力しない(パスと件数のみ)。"""
import os, re, subprocess, sys, json
# 依存物・生成物: 読めてしまうが「自分が置いた資産」ではない領域
DEP_DIRS = {"node_modules", "vendor", "Pods", ".venv", "venv", "target",
".next", "dist", "build", ".gradle", "DerivedData", "__pycache__"}
# 第1段: 名前だけで拾う(外れを許容する広い網)
NAME_RE = re.compile(
r"""(^\.env($|\.)|^\.netrc$|^\.npmrc$|^\.pypirc$|^\.git-credentials$
|^id_(rsa|ed25519|ecdsa)$|\.pem$|\.p12$|\.p8$|\.keystore$|\.jks$
|^credentials(\.json)?$|^service[-_]account.*\.json$
|google-services\.json$|GoogleService-Info\.plist$|\.mobileprovision$
|token|secret|password)""", re.I | re.X)
# 第2段: 中身の形で確かめる(キー名 = 20文字以上の値、または PEM ヘッダ)
BODY_RE = re.compile(
rb"""((api[_-]?key|access[_-]?token|client[_-]?secret|password|private[_-]?key)
\s*[:=]\s*["']?[A-Za-z0-9_\-/+]{20,}
|-----BEGIN[ A-Z]*PRIVATE KEY-----)""", re.I | re.X)
def classify(root):
"""ツリーを歩き、依存物配下かどうかで2つに分ける。"""
own, dep = [], []
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = [d for d in dirnames if d != ".git"]
rel_dir = os.path.relpath(dirpath, root)
in_dep = bool(set(rel_dir.split(os.sep)) & DEP_DIRS)
for fn in filenames:
rel = os.path.normpath(os.path.join(rel_dir, fn))
(dep if in_dep else own).append((rel, fn))
return own, dep
def body_hit(path, limit=262144):
"""先頭 256KB だけ読む。巨大ファイルで走査が止まらないようにするため。"""
try:
with open(path, "rb") as f:
return bool(BODY_RE.search(f.read(limit)))
except OSError:
return False # 権限なし・壊れたシンボリックリンクは黙って飛ばす
def main(root):
own, dep = classify(root)
name_hits = [rel for rel, fn in own + dep if NAME_RE.search(fn)]
body_hits = [rel for rel in name_hits if body_hit(os.path.join(root, rel))]
try:
t = subprocess.run(["git", "-C", root, "ls-files", "-z"],
capture_output=True, timeout=60)
tracked = len([p for p in t.stdout.decode("utf-8", "replace").split("\0") if p]) \
if t.returncode == 0 else None
except Exception:
tracked = None # git 管理下でないディレクトリでも動かす
print(json.dumps({
"own_files": len(own),
"dependency_files": len(dep),
"tracked_by_git": tracked,
"stage1_name_hits": len(name_hits),
"stage2_confirmed": len(body_hits),
"confirmed_paths": sorted(body_hits)[:20],
}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main(sys.argv[1] if len(sys.argv) > 1 else ".")
先ほどの Node プロジェクトに対する出力です。
{
"own_files": 4,
"dependency_files": 1348,
"tracked_by_git": 3,
"stage1_name_hits": 10,
"stage2_confirmed": 1,
"confirmed_paths": [
".env.local"
]
}
10 件が1件になりました。所要時間は 0.04 秒です。走査コストが無視できる範囲に収まったので、ワークスペースを開く前の確認として現実的に回せます。
limit=262144 と except OSError は、あとから足した部分です。最初は全体を読んでいて、ビルド生成物の巨大なソースマップで詰まりました。権限のないファイルで例外が飛んで途中終了もしました。棚卸しのスクリプトが途中で止まると、止まった位置より先を見ていないことに気づけません。黙って飛ばして最後まで走り切るほうが、この用途では安全だと考えています。
もう一つ、os.walk は既定でシンボリックリンクを辿りません。ここは変えないほうが安全です。followlinks=True にすると、作業ツリーの中に置いたリンク1本で、外側のディレクトリまで数え始めます。棚卸しの対象が本人の知らないうちに広がる落とし穴なので、リンクの先も確認したいときは、そのパスを引数に指定して別途走らせる形で回避しています。
第2段でも残るもの
もう1つ、性質の違うツリーでも回しました。個人開発で運営しているサイトのリポジトリで、記事の Markdown が中心の 2,256 ファイルです。依存物は展開していない状態でした。
| 指標 | Node プロジェクト | 記事リポジトリ |
| ディスク上のファイル数 | 1,352 | 2,256 |
| 依存物・生成物 | 1,348 | 0 |
| 第1段(名前) | 10 | 27 |
| 第2段(中身) | 1 | 2 |
| 走査時間 | 0.04 秒 | 0.09 秒 |
第2段に残った2件を開くと、どちらも解説記事の本文でした。認証エラーの記事の中に、設定例として「キー名 = 長い値」の行が書いてあったためです。プレースホルダーですから当然、資格情報ではありません。
つまり第2段は、外れを 27 件から 2 件へ減らす仕組みであって、0 にする仕組みではありませんでした。ここを取り違えると、検出が0でないことを理由にスクリプトを直し続けることになります。この種の検査に求めるべきなのは、目視できる件数まで落とすことだと考えています。2件なら開いて確かめられます。27件だと開かなくなります。
棚卸しの結果を、どこに反映するか
数えたあとに実際に変えたのは、次の3点です。
1. 資格情報の置き場所を作業ツリーの外へ移す。 これが最も効きます。読み取り範囲の設定で守るのではなく、そもそも範囲の中に存在させない、という順序です。環境変数や OS のキーチェーンから読む構成についてはAntigravity で環境変数・シークレットを安全に管理する方法にまとめてあります。
2. ワークスペースの切り方を見直す。 複数のプロジェクトを1つの親ディレクトリで開いていると、読み取り自動承認の範囲がその親全体になります。私は6本のアプリと複数のサイトを1台で扱っていて、作業ツリーが親フォルダの下に並んでいました。関係のないプロジェクトまで範囲に入っていたので、開く単位をプロジェクトごとに分けました。複数のリポジトリを横断して直したい日もあります。この場合は、資格情報を持つプロジェクトだけを親ディレクトリの外へ出しておき、残りをまとめて開く形で折り合いをつけています。
3. .antigravityignore で除外を書き、書いたあとに効いていることを確かめる。 書いただけで安心すると、効いていないパターンに気づけません。除外が効かないときに疑う箇所は.antigravityignore が効いていないときに疑う4か所で整理しました。
順序が大事だと感じています。3から入ると、除外リストを育て続ける作業になります。1から入ると、そもそも書くべき除外が減ります。
実際の手順
- スクリプトを
read_surface.py として保存します。
- これから開こうとしているディレクトリを指定して実行します(
python3 read_surface.py ~/projects/my-app)。
dependency_files と own_files の比を見ます。自分の資産が極端に少なければ、開く単位が広すぎるサインです。
stage2_confirmed のパスを1件ずつ開き、本物か解説用の例かを判定します。
- 本物だったものを作業ツリーの外へ移し、参照方法を環境変数か OS のキーチェーンに切り替えます。
- 移せないもの(署名用のプロビジョニングファイルなど)を
.antigravityignore に書きます。
- もう一度実行し、
stage2_confirmed が説明のつく件数だけになったことを確認します。
- 依存を追加したときとプロジェクトを開く単位を変えたときに、2〜7を繰り返します。
8 が抜けやすい部分です。依存を1本足すと、読み取り面は数百ファイル単位で増えます。今回の計測でも、直接の依存4本が 79 パッケージになりました。
承認が減ったぶんを、どこで取り戻すか
承認プロンプトは、面倒な代わりに範囲の可視化を兼ねていました。それが自動化されたということは、可視化の役割を別のどこかに移す必要がある、ということだと理解しています。私はそれを「開く前に一度だけ数える」という形にしました。毎回の判断を、一度きりの判断に置き換えた、と言い換えてもいいかもしれません。
今日できることを1つ挙げるなら、いま開いているプロジェクトのルートでスクリプトを走らせて、own_files と dependency_files の比を見てみてください。その数字が想像通りなら、範囲の設計はすでに妥当です。想像と違ったなら、それが見直しの入口になります。
私自身、この計測をするまで自分の作業ツリーの形を誤解していました。同じところでつまずく方の手間が少し減れば嬉しく思います。お読みいただきありがとうございました。