自動化パイプラインの中で、承認フローを持つCLIツール(AIコーディングエージェント系ツールなど)をPowerShellから子プロセスとして非対話で起動する場面が増えています。今回、「編集系の操作は自動承認されるのに、Web系のツール呼び出しだけ毎回拒否される」という現象を踏んだので、原因の切り分けと対策をまとめます。
TL;DR
- 起動時に自動承認フラグ(例:
--permission-mode acceptEdits)を渡しても、ツール内部の承認ロジックが「対話端末(TTY)の有無」に暗黙的に依存していると、非対話サブプロセスでは一部のカテゴリの操作だけが「承認者不在」としてサイレントに拒否される。 - 原因は、フラグが表現しているスコープ(編集の自動承認)と、呼び出し元が期待しているスコープ(すべての自動承認)がズレていること。
- 対策は、承認カテゴリを列挙で明示的にオプトインする設計に変え、拒否理由をログに残すこと。
何が起きたか
構成は単純です。
- PowerShell側でCLIツールを子プロセスとして起動する。標準入力・標準出力・標準エラーをリダイレクトし、対話用のコンソールは渡さない。
- CLIツール側は起動時に
--permission-mode acceptEditsのようなフラグを受け取り、「確認なしで操作を進めてよい」と解釈させたいつもりだった。 - 実際に動かすと、ファイル編集系のタスクは通るのに、外部URL取得のようなWeb系タスクだけが
deniedになって落ちる。
ログを見ても「フラグは渡っているのにapprovedがfalse」としか分からず、最初はフラグの綴りミスかツールのバグを疑いました。実際の原因は、ツール側の承認ロジックがカテゴリごとに「対話端末がなければ拒否」という条件を隠し持っていたことでした。
原因: TTYの有無に暗黙的に依存した権限ゲート
再現用に、問題のあるバージョンの権限ゲートを最小構成で書き出したものが以下です。acceptEdits は「編集(edit)」カテゴリだけを自動承認する設計になっていて、「ネットワーク(network)」カテゴリは対話端末(process.stdin.isTTY)が無いと拒否する、という条件が埋め込まれています。
#!/usr/bin/env node
'use strict';
// 問題のあるバージョン: acceptEdits は edit カテゴリのみを自動承認する
const ALWAYS_INTERACTIVE_CATEGORIES = new Set(['network', 'shell-exec']);
function parseArgs(argv) {
const args = { permissionMode: 'default', task: null };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--permission-mode') {
args.permissionMode = argv[++i];
} else if (argv[i] === '--task') {
args.task = argv[++i];
}
}
return args;
}
function classifyTask(task) {
if (task === 'fetch-url') return 'network';
if (task === 'edit-file') return 'edit';
return 'unknown';
}
function canAutoApprove(category, permissionMode, isInteractive) {
if (permissionMode !== 'acceptEdits') return false;
if (category === 'edit') return true;
if (ALWAYS_INTERACTIVE_CATEGORIES.has(category)) {
// ここが罠: acceptEdits を渡していても、対話端末が無いと
// 「承認者に聞けない」ため network 系は自動承認されない
return isInteractive;
}
return false;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const category = classifyTask(args.task);
const isInteractive = Boolean(process.stdin.isTTY);
const approved = canAutoApprove(category, args.permissionMode, isInteractive);
console.log(JSON.stringify({
task: args.task,
category,
permissionMode: args.permissionMode,
isInteractive,
approved
}));
if (!approved) {
console.error(`denied: category="${category}" needs an interactive approver, but stdin is not a TTY`);
process.exit(1);
}
console.log(`ok: executed task "${args.task}"`);
}
main();
これを直接ターミナルから対話実行すると process.stdin.isTTY が true になるため通ります。
$ node agent-tool.js --permission-mode acceptEdits --task fetch-url
{"task":"fetch-url","category":"network","permissionMode":"acceptEdits","isInteractive":true,"approved":true}
ok: executed task "fetch-url"
同じコマンドを、標準入出力をリダイレクトしたPowerShellの子プロセスとして起動すると isTTY が false になり、拒否されます。
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "node"
$psi.Arguments = "agent-tool.js --permission-mode acceptEdits --task fetch-url"
$psi.UseShellExecute = $false
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.StandardInput.Close()
$stdout = $proc.StandardOutput.ReadToEnd()
$stderr = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
Write-Output $stdout
if ($proc.ExitCode -ne 0) {
Write-Error $stderr
}
{"task":"fetch-url","category":"network","permissionMode":"acceptEdits","isInteractive":false,"approved":false}
Write-Error: denied: category="network" needs an interactive approver, but stdin is not a TTY
acceptEdits を渡しているのに network カテゴリだけ落ちる、というのが最初に踏んだ現象そのものです。呼び出し側は「自動承認フラグを渡したのだから全部通る」と思い込んでいて、ツール側は「編集だけは自動承認するが、それ以外は対話端末で人に確認を取る前提」で作られていた、という認識のズレが根本原因でした。
対処: 承認カテゴリを明示的にオプトインする設計に変える
TTYの有無という暗黙の状態に承認判定を依存させず、「非対話実行でどのカテゴリを自動承認するか」を呼び出し側が明示的に渡す設計に変えます。
#!/usr/bin/env node
'use strict';
function parseArgs(argv) {
const args = { permissionMode: 'default', task: null, unattendedApprove: new Set() };
for (let i = 0; i < argv.length; i++) {
if (argv[i] === '--permission-mode') {
args.permissionMode = argv[++i];
} else if (argv[i] === '--task') {
args.task = argv[++i];
} else if (argv[i] === '--unattended-approve') {
const value = argv[++i] || '';
value.split(',').filter(Boolean).forEach((c) => args.unattendedApprove.add(c));
}
}
return args;
}
function classifyTask(task) {
if (task === 'fetch-url') return 'network';
if (task === 'edit-file') return 'edit';
return 'unknown';
}
function canAutoApprove(category, permissionMode, isInteractive, unattendedApprove) {
if (category === 'edit' && permissionMode === 'acceptEdits') return true;
if (unattendedApprove.has(category)) return true;
return isInteractive;
}
function main() {
const args = parseArgs(process.argv.slice(2));
const category = classifyTask(args.task);
const isInteractive = Boolean(process.stdin.isTTY);
const approved = canAutoApprove(category, args.permissionMode, isInteractive, args.unattendedApprove);
console.log(JSON.stringify({
task: args.task,
category,
permissionMode: args.permissionMode,
isInteractive,
unattendedApprove: Array.from(args.unattendedApprove),
approved
}));
if (!approved) {
console.error(`denied: category="${category}" has no approver (isInteractive=${isInteractive}, unattendedApprove=[${Array.from(args.unattendedApprove).join(',')}])`);
process.exit(1);
}
console.log(`ok: executed task "${args.task}"`);
}
main();
呼び出し側(PowerShell)は、非対話で許可したいカテゴリを明示的に列挙して渡します。
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.FileName = "node"
$psi.Arguments = "agent-tool.js --permission-mode acceptEdits --unattended-approve network --task fetch-url"
$psi.UseShellExecute = $false
$psi.RedirectStandardInput = $true
$psi.RedirectStandardOutput = $true
$psi.RedirectStandardError = $true
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.StandardInput.Close()
$stdout = $proc.StandardOutput.ReadToEnd()
$stderr = $proc.StandardError.ReadToEnd()
$proc.WaitForExit()
Write-Output $stdout
if ($proc.ExitCode -ne 0) {
Write-Error $stderr
}
{"task":"fetch-url","category":"network","permissionMode":"acceptEdits","isInteractive":false,"unattendedApprove":["network"],"approved":true}
ok: executed task "fetch-url"
ポイントは2つです。
- 承認判定を「TTYがあるかどうか」という間接的な状態ではなく、「呼び出し側が明示的に何を承認したか」という直接的な入力に基づかせる。
- 拒否した理由(カテゴリ・TTYの有無・オプトインしたカテゴリ一覧)を標準エラーに構造化して出す。「なぜ拒否されたか」がログだけで分かるようにしておくと、次に同種の問題が起きたときの調査時間が大きく減ります。
再発防止チェックリスト
- 自動承認フラグは「何を承認するか」をカテゴリ単位で列挙する設計にし、「全部OK」の一括フラグを避ける。一括フラグは、フラグの意味をツール内部の実装を読まないと正確に把握できなくなる。
- 非対話実行時は、承認可否の判定結果(対象カテゴリ・TTYの有無・approved/denied・理由)を必ずログに出す。サイレント拒否が一番デバッグコストが高い。
- 対話前提の承認フローを持つツールをサブプロセス化するときは、TTYの有無という間接的な状態に頼らず、「非対話モードであること」と「何を自動承認するか」を呼び出し側から明示的に渡せるインターフェースになっているか、導入前にドキュメントかソースで確認する。
- CIやバッチ実行に組み込む前に、対象タスクをドライラン相当で一通り流し、どのカテゴリが承認/拒否されるかを一覧化しておく。本番の定期実行で初めて拒否に気づく、という状況を避けられる。
まとめ
「自動承認フラグを渡したから全部通るはず」という思い込みと、「対話端末が無ければ一部の操作は人に聞く」というツール側の暗黙の設計がかみ合わないと、非対話自動化はカテゴリ単位でサイレントに壊れます。対策の本質はシンプルで、承認スコープを列挙で明示化し、拒否理由をログに残すことです。TTY判定のような間接的な状態に権限判断を依存させている箇所がないか、既存の自動化パイプラインも一度洗い出しておくと安全です。