AI エージェントにコマンドを実行させるアプリを作っている。
実行前に必ず「何をするか」を出して許可を取る、という作りにしていた。
デモを撮っている最中に詰んだ。ダイアログのボタンが画面の外へ出て、
承認も拒否もできなくなった。
結論を先に書くと、「実行するものを全部見せる」ことと
「見せたうえで選べる」ことは別。 選べなくなった時点で、
承認という仕組みは成立していない。
何が起きたか
モデルが HTML ファイルを作ろうとして、こういうコマンドを組み立てた。
cat > index.html <<'EOF'
<!doctype html>
… 4,558 バイトぶんの HTML …
EOF
showMessageBox の detail に、このコマンド文字列がそのまま入る。
中身がまるごと承認画面の高さになる。 ダイアログは画面より高くなり、
下端のボタンが見えない位置へ行った。
detail はスクロールしない。
Escape は効くが、Enter は効かない
その場で試して分かったことがある。
- Escape では閉じられた
- Enter は効かなかった
defaultId と cancelId を両方 0(安全側)にしていたためだった。
macOS では、この 2 つが同じ番号を指すと Return の割り当てが外れる。
「既定のボタンを安全側にする」という判断は正しい。
Enter の連打で危険な操作が通るのを防げる。
だがその設定は、キーボードで承認する道を同時に塞ぐ。
片方だけ見て決めると、こうなる。
はみ出したときの逃げ道が Escape しかない、というのは
設計として細すぎる。
同じ収録で、もう 1 つ壊れていた
cd ~/work && claude "LP を作って" が通常の実行に流れ、
Claude Code が入力待ちのまま 60 秒で打ち切られた。
ターミナルも開かないので、利用者からは「何も起きなかった」ように
しか見えない。 引き渡し用のスクリプトが 1 本も作られていないことで
裏が取れた。
対話型のコマンドを、結果を待つ実行に流してはいけない。
直し方を 3 段構えにした
1. ファイル書き込みを、実行から分ける
承認する側が見たいものが、実行と書き込みで違う。
実行では「何が起きるか」を読むために全文が要る。
書き込みで知りたいのはどこに・何バイト・上書きかであって、
中身は必要なら別で見ればいい。
道具を分けたことで、ダイアログの大きさが中身の長さから
切り離された。
| 書き込む中身 | ダイアログ |
|---|---|
| 4,558 バイトの HTML | 16 行 |
| 34,889 バイト | 17 行 |
ついでに 3 つ決めた。
-
上書きは別扱い。
warningにして「中身は失われます」と出す -
~と相対パスは承認前に展開する。 展開前の文字列で承認を取ると、
承認したパスと書かれるパスが食い違いうる - 既定のボタンはどちらも「書き込まない」
2. 端末を占有するコマンドは、承認を求める前に断る
claude codex gemini vim ssh psql など 24 種。
&& || ; | で繋がったどの節も見る。
export function findInteractiveCommand(command: string): string | null {
for (const segment of command.split(/&&|\|\||[;|]/)) {
const words = segment.trim().split(/\s+/)
.filter((w) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(w)); // FOO=1 を飛ばす
const head = (words[0] ?? '').replace(/^.*\//, '').replace(/^["']|["']$/g, '');
if (INTERACTIVE_COMMANDS.includes(head)) return head;
}
return null;
}
押させてから失敗させるのは筋が悪い。 ダイアログを出す前に断って、
「ターミナルで開く方の道具を使え」とモデルに返す。
3. それでも長ければ、表示だけ切り詰める
14 行 / 900 字。全文は一時ファイルへ書き、その場所をダイアログに添える。
承認の対象そのものは切らない。 切るのは見せる文字列だけ。
ここを混ぜると、「短く見えたものを承認したのに、長いものが実行される」
という一番まずい形になる。
どうテストするか
「押せる状態か」は目で見ても分からない。大きさが中身に依存しないこと
を書けばいい。
it('中身の長さに関係なく一定の大きさに収まる', () => {
const short = planWrite('/t/a.html', '<p>hi</p>', '/', () => false);
const huge = planWrite('/t/a.html',
Array.from({ length: 2000 }, (_, i) => `<p>${i}</p>`).join('\n'), '/', () => false);
const a = String(buildWriteApproval(short, '理由', 'ja').options.detail);
const b = String(buildWriteApproval(huge, '理由', 'ja').options.detail);
expect(b.length).toBeLessThan(a.length + 200);
expect(b.length).toBeLessThan(900);
});
a.length + 200 の方が本体で、900 は上限の念押し。
**「長い方が短い方とほぼ同じ」**を書くと、道具を分けた意図がそのまま
検査になる。
対話型の判定も、ダイアログを出していないことまで書く。
it('承認を求める前に断る。ダイアログすら出さない', async () => {
const showMessageBox = vi.fn();
const executeCommand = vi.fn();
await runCommand('cd ~/x && claude "作って"', '理由', { showMessageBox, executeCommand, … });
expect(showMessageBox).not.toHaveBeenCalled();
expect(executeCommand).not.toHaveBeenCalled();
});
おまけ:渡され方で表示が変わっていた
同じ直しの流れで、もう 1 つ見つかった。
ファイルを開く承認が、モデルが渡した文字列をそのまま出していた。
1 回目は ~/Downloads/…、2 回目は /Users/<利用者名>/Downloads/…。
同じファイルを開こうとしているのに、表示が違う。
渡され方に表示が左右される時点で、承認の画面として成立していない。
開く先は絶対パスに解いてから開き、見せる文字列だけ ~ に畳む形にした。
しかも ~ 表記の方は Electron の shell.openPath が展開しないので、
そもそも開けなかった。承認を押しても何も起きない状態だったことになる。
まとめ
- 見せることと、選べることは別。 選べなくなったら承認ではない
-
defaultIdとcancelIdを揃えると、macOS では Enter が死ぬ - 承認する側が見たいものは、操作の種類ごとに違う。道具を分ける
- 弾くなら押させる前に弾く
- 承認の対象は切り詰めない。 切るのは見せる文字列だけ
- 「押せる状態か」は大きさが中身に依存しないこととして書ける
踏んだ落とし穴だけを書いています。 同じところで止まった人の時間が少しでも減れば十分です。
他の記事も同じ形で、実際に測った数字と、直した手順だけを置いています。
(この話が出てきたのは Wisp という個人開発のアプリです)