0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

承認ダイアログが押せなくなった(全部見せることと、選べることは別)

0
Last updated at Posted at 2026-08-14

AI エージェントにコマンドを実行させるアプリを作っている。
実行前に必ず「何をするか」を出して許可を取る、という作りにしていた。

デモを撮っている最中に詰んだ。ダイアログのボタンが画面の外へ出て、
承認も拒否もできなくなった。

結論を先に書くと、「実行するものを全部見せる」ことと
「見せたうえで選べる」ことは別。
選べなくなった時点で、
承認という仕組みは成立していない。

何が起きたか

モデルが HTML ファイルを作ろうとして、こういうコマンドを組み立てた。

cat > index.html <<'EOF'
<!doctype html>
… 4,558 バイトぶんの HTML …
EOF

showMessageBoxdetail に、このコマンド文字列がそのまま入る。
中身がまるごと承認画面の高さになる。 ダイアログは画面より高くなり、
下端のボタンが見えない位置へ行った。

detail はスクロールしない。

Escape は効くが、Enter は効かない

その場で試して分かったことがある。

  • Escape では閉じられた
  • Enter は効かなかった

defaultIdcancelId を両方 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 が展開しないので、
そもそも開けなかった。承認を押しても何も起きない状態だったことになる。

まとめ

  • 見せることと、選べることは別。 選べなくなったら承認ではない
  • defaultIdcancelId を揃えると、macOS では Enter が死ぬ
  • 承認する側が見たいものは、操作の種類ごとに違う。道具を分ける
  • 弾くなら押させる前に弾く
  • 承認の対象は切り詰めない。 切るのは見せる文字列だけ
  • 「押せる状態か」は大きさが中身に依存しないこととして書ける

踏んだ落とし穴だけを書いています。 同じところで止まった人の時間が少しでも減れば十分です。
他の記事も同じ形で、実際に測った数字と、直した手順だけを置いています。

(この話が出てきたのは Wisp という個人開発のアプリです)

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?