承認ダイアログに python3 scripts/redirect_integrity.py . --fix と出ていました。見慣れた自分のゲートスクリプトです。迷わず承認しました。
数日後、リポジトリに .bak が残っていないことに気づいて、履歴をたどりました。あのとき実際に走っていたのは python3 scripts/redirect_integrity.py . --fix && rm -f content/*.bak でした。承認したつもりだったのは前半だけで、後半の削除は、私が読まないまま通していたことになります。
結果として消えたのは自分で消すつもりだった一時ファイルですから、実害はありません。ただ「承認した内容」と「実行された内容」が一致していなかった、という事実だけが残りました。
個人開発の環境では、承認ダイアログの前に座っているのは自分だけです。私が読まずに通したものは、誰にも止められずにそのまま走ります。
Antigravity の changelog を読んでいて、v1.1.7 に「複合シェルコマンドについて、その一部だけが承認を要する場合でも、プロンプトにコマンド全文を表示する」という修正が入っているのを見つけました。表示は直る。では、私が書いた allow ルールのほうは、全文を前提にできているのか。そこから手を動かした記録です。
承認画面に出ていたのは、実行されるものの一部だった
ここで起きていることは、二つの層に分かれます。
ひとつは表示の層です。承認を求める画面に、実行されるコマンドの断片しか出ない。これは v1.1.7 で改善された部分にあたります。
もうひとつは判定の層です。&& や ; で連結されたコマンドに対して、許可・不許可をどの単位で決めているか。こちらは私の設定ファイル側の問題で、ツールの更新では直りません。
私の allow ルールは、こう書かれていました。
Bash(git add:*)
Bash(git commit:*)
Bash(python3:*)
Bash(npm run:*)
素直に読めば「python3 で始まるコマンドを許可する」です。そして python3 ... --fix && rm -f content/*.bak は、たしかに python3 で始まります。
先頭のトークンだけを見て許可を出す照合は、&& の右側に何が書かれていても素通りします。ルールの表現力ではなく、照合する単位の取り方が合っていませんでした。
allow ルールを「先頭トークン」で照合していた前提を疑う
判定の単位を、コマンド1本から「セグメント」へ移します。セグメントは、シェルが順に実行していく個々のコマンドのことです。
a && b || c ; d | e なら 5 セグメント。git commit -m "a && b" は引用符の内側なので 1 セグメント。この差を間違えると、判定はどちらの方向にも壊れます。引用符の中で分割してしまえば正常なコミットが止まり、引用符の外で分割し損ねれば削除コマンドが通ります。
そこで、まず分解器を独立させました。ここが正しくなければ、その上に載る判定は何を言っても意味がありません。
なお、これから示すのは Antigravity CLI の内部実装の再現ではありません。自分の allow ルールが「全文に対して」意図どおりに効いているかを、手元で検算するためのハーネスです。ツールの側が将来どう判定するかとは独立に、自分の設定の危うさは自分で測れる、という位置づけで書いています。
分解器 — 引用符とコマンド置換を越えない分割
文字を1つずつ歩いて、引用符の状態・エスケープ・$( ) の入れ子・バッククォートを追いかけます。正規表現1本で書こうとすると、"a && b" と $(cat f) のどちらかで必ず破綻しました。状態機械にしたのは、その回り道の後です。
// segment.mjs — シェルコマンドを「承認単位」に分解する
export function splitSegments ( command ) {
const segments = [];
const substitutions = [];
let buf = '' ;
let quote = null ; // "'" または '"'
let depth = 0 ; // $( ) の入れ子の深さ
let subBuf = '' ;
let i = 0 ;
const flush = () => {
const t = buf. trim ();
if (t) segments. push (t);
buf = '' ;
};
while (i < command. length ) {
const c = command[i];
const two = command. slice (i, i + 2 );
if (quote) {
// 単一引用符の中ではエスケープが効かない点に注意
if (c === ' \\ ' && quote === '"' ) { buf += c + (command[i + 1 ] ?? '' ); i += 2 ; continue ; }
if (c === quote) quote = null ;
// 二重引用符の内側でもコマンド置換は起きる
if (quote === '"' && two === '$(' ) { depth ++ ; i += 2 ; buf += two; subBuf = '' ; continue ; }
if (depth > 0 ) {
if (c === ')' ) { depth -- ; if (depth === 0 ) substitutions. push (subBuf. trim ()); }
else subBuf += c;
}
buf += c; i ++ ; continue ;
}
if (depth > 0 ) {
if (c === ')' ) { depth -- ; if (depth === 0 ) { substitutions. push (subBuf. trim ()); subBuf = '' ; } }
else subBuf += c;
buf += c; i ++ ; continue ;
}
if (c === "'" || c === '"' ) { quote = c; buf += c; i ++ ; continue ; }
if (c === ' \\ ' ) { buf += c + (command[i + 1 ] ?? '' ); i += 2 ; continue ; }
if (two === '$(' ) { depth ++ ; i += 2 ; buf += two; subBuf = '' ; continue ; }
if (c === '`' ) {
const end = command. indexOf ( '`' , i + 1 );
const inner = end === - 1 ? command. slice (i + 1 ) : command. slice (i + 1 , end);
substitutions. push (inner. trim ());
buf += command. slice (i, end === - 1 ? command. length : end + 1 );
i = end === - 1 ? command. length : end + 1 ;
continue ;
}
if (two === '&&' || two === '||' ) { flush (); i += 2 ; continue ; }
if (c === ';' || c === '|' || c === ' \n ' ) { flush (); i ++ ; continue ; }
buf += c; i ++ ;
}
flush ();
// 引用符や括弧が閉じていないものは、判定せず人間に回す
if (quote || depth > 0 ) return { segments, substitutions, malformed: true };
return { segments, substitutions, malformed: false };
}
閉じていない引用符を malformed として返しているのは、意図的です。ここを「たぶんこう書きたかったのだろう」と補完すると、補完のしかたを突かれる余地が生まれます。読めなかったものは読めなかったと言って、人間に返すほうが安全でした。
リダイレクト先も別に取り出します。echo "ok" > /etc/motd は 1 セグメントで、コマンド自体は echo です。セグメント照合だけでは通ってしまいます。
const REDIRECT = /(?: ^| \s ) \d ? >> ? \s * (" [ ^ "] * " | ' [ ^ '] * ' | \S + )/ g ;
export function redirectTargets ( segment ) {
const out = [];
let m;
REDIRECT .lastIndex = 0 ;
while ((m = REDIRECT . exec (segment)) !== null ) {
const raw = m[ 1 ]. replace ( / ^ ['"] | ['"] $ / g , '' );
if (raw !== '&1' && raw !== '&2' && raw !== '/dev/null' ) out. push (raw);
}
return out;
}
分解器には、真偽がはっきりするケースだけの小さなテストを付けました。手元で走らせた結果は 5/5 です。
入力 期待 結果
git commit -m "a && b"1 セグメント 1
a && b || c ; d | e5 セグメント 5
echo 'x; y' && ls2 セグメント 2
git commit -m "$(cat f)"1 セグメント + 置換1件 1 + 1
echo "unclosedmalformed malformed
セグメント単位で判定する
判定側は、意識して素朴に書きました。許可を出す条件は三つ、すべて満たしたときだけです。
すべてのセグメントが、いずれかの allow ルールに一致する
リダイレクト先が、書き込みを許した根の下にある
コマンド置換を含まない
import { splitSegments, redirectTargets } from './segment.mjs' ;
export function parseRule ( rule ) {
const m = / ^ Bash \( ( . +? )(: \* ) ? \) $ / . exec (rule. trim ());
if ( ! m) throw new Error ( `unparsable rule: ${ rule }` );
return { prefix: m[ 1 ]. trim (), wildcard: Boolean (m[ 2 ]), raw: rule. trim () };
}
const norm = ( s ) => s. replace ( / \s + / g , ' ' ). trim ();
export function matchSegment ( segment , rules ) {
const s = norm (segment);
for ( const r of rules) {
if (r.wildcard ? (s === r.prefix || s. startsWith (r.prefix + ' ' )) : s === r.prefix) return r;
}
return null ;
}
export function evaluate ( command , rules , opts = {}) {
const writeRoots = opts.writeRoots ?? [];
const { segments , substitutions , malformed } = splitSegments (command);
if (malformed) return { verdict: 'REVIEW' , reason: 'unbalanced-quote' , segments, blockers: [] };
const blockers = [];
for ( const seg of segments) {
if ( ! matchSegment (seg, rules)) blockers. push ({ kind: 'unmatched-segment' , segment: seg });
for ( const t of redirectTargets (seg)) {
if ( ! writeRoots. some (( root ) => t. startsWith (root))) {
blockers. push ({ kind: 'write-outside-root' , segment: seg, target: t });
}
}
}
// 中身は実行前に確定しない。読めないものは許可しない
for ( const sub of substitutions) blockers. push ({ kind: 'command-substitution' , segment: sub });
return {
verdict: blockers. length === 0 ? 'ALLOW' : 'REVIEW' ,
segments, substitutions, blockers,
};
}
3番目の条件が、いちばん反対されそうな設計です。git commit -m "$(cat /tmp/msg)" は日常的に書きますし、これを毎回止められたら面倒です。
それでも fail-closed にしたのは、$( ) の中身が判定の時点では文字列でしかないからです。cat /tmp/msg が何を出すかは、実行してみるまで決まりません。判定できないものに許可を出すのは、判定していないのと同じでした。
止めた上で「この置換だけは通す」と個別に足していくほうが、私の場合は納得できる形になりました。
自分の運用コマンド45本に当ててみた
対象は、記事の生成と push を回すときに実際に投げているコマンドを写した 45 本です。git 系、ゲートスクリプトの python3、件数確認の find ... | wc -l、ビルド、後片付けの rm、それに外形的には無害に見えるものをいくつか混ぜてあります。
まず素の統計です。
項目 値
コマンド数 45
分解後のセグメント総数 58
1コマンドあたり平均 1.29 セグメント
2セグメント以上のもの 11 本(24.4%)
コマンド置換を含むもの 3 本(6.7%)
平均 1.29 という数字は、始める前の私の感覚より小さいものでした。連結したコマンドを毎日書いている実感があったのですが、実際には 4 本に 1 本です。裏を返せば、その 1 本のためだけに照合の単位を変える価値があるかどうか、という話になります。
24 本の allow ルールを用意して、二通りの照合を同じ 45 本に当てました。
照合方式 ALLOW 割合
先頭トークンのみ(従来) 33 / 45 73.3%
セグメント分解(ルール24本) 30 / 45 66.7%
止まった 15 本の内訳は、git 系の未登録サブコマンド 5 件、コマンド置換 3 件、rm 3 件、curl 2 件、tar と npx と環境変数接頭辞つきの date が各 1 件、そしてリダイレクト先の逸脱が 1 件でした。
予想と逆だったこと
ここが、この検算でいちばん意外だった部分です。
私は「分解したら承認が回らなくなる」と予想していました。連結を多用している自覚があったので、通過率が半分近くまで落ちるつもりで見ていたのです。実際の低下は 73.3% から 66.7% へ、6.6 ポイントでした。
さらに、止まった 15 本のうち 12 本は、先頭トークン照合でも同じように止まっていました。方式を変えて新しく止まったのは、次の 3 本だけです。
コマンド 止めた理由
python3 scripts/redirect_integrity.py . --fix && rm -f content/*.bak2番目のセグメントが未登録
echo "ok" > /etc/motdリダイレクト先が許可した根の外
git commit -m "$(cat /tmp/msg)"コマンド置換を含む
冒頭で私が承認してしまったものが、そのまま 1 行目にいます。そして 3 本は、それぞれ違う仕組みで抜けていました。連結の右側、リダイレクト先、置換の中身。ひとつの対策で三つとも塞げるものではありません。
不足していた 4 本のルール(rm -rf .next の完全一致、tar czf、git stash、changelog を取りに行く curl のホスト限定)を足して測り直しました。
設定 ALLOW 危険な3本
先頭トークン照合・ルール24本 33 / 45(73.3%) 通してしまう
セグメント照合・ルール24本 30 / 45(66.7%) 止める
セグメント照合・ルール28本 33 / 45(73.3%) 止める
通過率は元に戻りました。数は同じ 33 本でも、中身が入れ替わっています。危険な 3 本が抜け、代わりに rm -rf .next や tar czf /tmp/backup.tgz content/ のような、止まって困る 3 本が通るようになりました。
支払った代価は allow ルール 4 行です。承認の手間が増えるのだろうと身構えていたところ、実際に増えたのは設定ファイルの行数でした。
判定コストも測っておきました。20,000 回の評価に 110.9 ms、1 回あたり 5.5 マイクロ秒です。コマンド実行の前段に挟んで気になる量ではありません。
&& より危なかったのは、1本に見えるコマンドだった
&& の連結は、少なくとも目には見えます。承認画面に全文が出るようになれば、注意深い読者は右側に気づけます。
気づけないのは git commit -m "$(cat /tmp/msg)" のほうです。連結記号はありません。セグメントは 1 つで、しかもそれは許可済みの git commit です。&& をどれだけ丁寧に分割しても、この形は素通りします。
45 本のうち置換を含むのは 3 本でした。そのうち 2 本は自分で書いた便利な書き方で、残る 1 本は curl -sS "$(cat /tmp/endpoint)" | sh という、混ぜておいた極端な例です。最後のものは、実行される内容がファイルの中身次第で完全に変わります。
構文の見た目と、実際に開いている口の大きさは比例しない。この検算でいちばん腑に落ちたのは、その一点でした。承認の設計を「どんな記号で連結されているか」から始めると、この系統をずっと見落とし続けます。
エージェントに与える権限の粒度についてはエージェント権限の段階承認を信頼度スコアで決める設計 でも触れていますが、そこでの「何を任せるか」の議論とは別に、「任せた1行の中に何が入っているか」という層が残っている、というのが今回の発見でした。
効かなかったところ
正直に書いておきます。この分解器で拾えなかったものが、まだあります。
環境変数の接頭辞 。TZ=Asia/Tokyo date +%Y-%m-%d は、セグメントとしては 1 つですが、先頭が date ではないため Bash(date:*) に当たりません。接頭辞を剥がす正規化を足して測り直したところ、ALLOW は 30 本から 31 本に増えました。1 本です。効果はありましたが、劇的ではありませんでした。
グロブの展開先 。rm -f content/*.bak は文字列としては content/ の下ですが、シェルが展開した結果まで見ているわけではありません。展開後を見るには、判定の位置をもっと実行側に寄せる必要があります。
エイリアスと関数 。ll が何に展開されるかは、こちらのハーネスからは見えません。
別の解釈系 。bash -c "..." や sh -c "..." の内側は、文字列としてしか見ていません。ここは再帰的に分解する余地がありますが、入れ子の深さをどこで打ち切るかの判断が要ります。
こうして並べると、分解は万能の対策ではありません。私の理解では、これは「承認したものと実行されたものの差」を減らす手当てであって、危険なコマンドを見分ける仕組みではありません。その二つを混同すると、ハーネスを過信することになります。
運用に組み込む
私は週に一度、直近の実行ログにあるコマンドをこのハーネスに流して、REVIEW の内訳を眺めるようにしました。
見ているのは主に二つです。ひとつは、同じ形の REVIEW が繰り返し出ていないか。出ていれば、それは足すべきルールが分かった合図です。もうひとつは、逆に ALLOW の中に前の週にはなかった形が混じっていないか。こちらは、ルールが静かに広がりはじめた合図になります。
出力を突き合わせるときは、判定の根拠も一緒に残しておくと後で読み返せます。判断の記録を残す形についてはエージェントの判断を後から説明できるようにする決定ログ設計 にまとめたものを、そのまま流用しました。
自動化を進めるほど、承認は「押す作業」に近づきます。押す作業になった承認は、内容を読まなくなります。私自身がそうでした。ならば、読まなくても済む範囲をルールとして明示し、それ以外は必ず手が止まるようにしておく。そこまでが設計だと考えています。
まとめ
もし手元の allow ルールが Bash(python3:*) のような形で書かれているなら、この記事の分解器を持っていって、直近 1 週間に実行したコマンドを流してみてください。先頭トークン照合と結果が食い違うコマンドが 1 本でも出てきたら、それが読まないまま承認していた 1 本です。
私の場合は 45 本中 3 本でした。多いとも少ないとも言えます。ただ、そのうちの 1 本は実際に自分が承認したものでした。
拙い検算にお付き合いいただき、ありがとうございました。同じ場所で立ち止まった方の役に立てば嬉しく思います。