深夜 2 時に走らせているはずの片付けジョブが、朝になっても終わっていませんでした。ログの最後の行は「実行中」のまま。原因は単純で、auto モードがあるコマンドの実行前に確認を求め、無人のプロセスには答える相手がいなかったのです。エラーで落ちてくれれば再実行できます。けれど「止まったまま生きている」のが一番たちが悪い、と身にしみた夜でした。
個人開発で複数のサイトを運営していて、その更新は夜間にまとめて処理しています。ひとりで回している以上、止まったジョブに朝まで気づけない構造こそ避けたいのです。手元にいるときの auto モードは、危うい削除の前に一呼吸置いてくれる頼もしい存在です。ところが同じ挙動が、無人実行では「返事を待って固まる」に変わります。そこで要るのが、その確認要求を握りつぶさずに受け止め、自分のルールで自動判定する仕組みです。以下では permission-prompt-tool の組み方を、実際に動くコードで残しておきます。
なぜ auto モードの事前確認は無人実行と相性が悪いのか
auto モードは、文脈から解決できない変数を含む破壊的なコマンドや、判断の分かれる操作の前に確認を挟みます。対話セッションなら、そこで人がキーを押せば進みます。ところが claude -p(headless / print モード)で走らせている無人ジョブには、押す人がいません。
このとき起きるのは、次の 3 つのどれかです。第一に、権限が渡っていないツールを呼ぼうとして即座に拒否され、ターンが error で終わる。第二に、確認を求める仕組みが用意されていないまま「許可されていない操作」として静かにスキップされ、あなたが期待した副作用が起きない。第三に、確認の受け皿だけはあるのに応答が返らず、プロセスがぶら下がる。どれも「成功でも失敗でもない灰色」で、cron のログには残りにくいのが厄介です。
対話の許可ダイアログを無人実行で消す発想そのものは、Claude Code Skill を無人で動かす設計 で扱いました。そちらは「そもそも確認を発生させない」方向です。この記事はもう一歩踏み込んで、「確認は発生してよい。ただし人ではなくコードが、記録を残しながら答える」構成をつくります。
permission-prompt-tool が「確認の受け皿」になる
Claude Code には、権限判断を外部のツールに委ねる仕組みがあります。起動時に --permission-prompt-tool を指定すると、Claude がツールを実行してよいか迷ったときに、そのツールへ判断を問い合わせます。問い合わせを受け取るのは、あなたが用意した MCP サーバー上のツールです。
問い合わせには、これから実行しようとしているツール名(tool_name)と、その入力(input)が渡ってきます。あなたのツールは、次のどちらかの形の JSON を返します。
返す判断 形 意味
許可 { "behavior": "allow", "updatedInput": { ... } }実行を許可します。必要なら入力を書き換えて渡せます
拒否 { "behavior": "deny", "message": "理由" }実行を止めます。message は判断の記録に残します
つまり、確認ダイアログの「はい / いいえ」を、あなたのコードが返せるようになります。無人実行で沈黙していた部分に、機械的な意思決定を差し込めるということです。返り値の細かな形はバージョンで動くことがあるため、導入時にお使いの Claude Code で一度実際の問い合わせ内容を出力して確かめておくと安心です。
実装:deny-by-default の承認ツールを MCP サーバーで書く
判断の基本方針は、拒否から始めることです。許可リストに載っているものだけを通し、それ以外は理由をつけて拒否します。権限まわりの事故は「うっかり許可してしまった」側から起きるので、既定を拒否に倒しておくと安全側に寄せられます。この考え方は無人で動くエージェントに渡すMCPツールを絞り込む と同じ精神です。
まず、承認ツールを 1 本だけ持つ小さな MCP サーバーを書きます。Node の公式 SDK を使い、標準入出力でしゃべる stdio サーバーにします。
// approver.mjs — 権限判断を1本のツールで返す stdio MCP サーバー
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js" ;
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" ;
import { z } from "zod" ;
import { appendFileSync } from "node:fs" ;
const LEDGER = process.env. APPROVAL_LEDGER ?? "/var/log/claude/approvals.jsonl" ;
// 許可ポリシー:ツール名ごとに「入力を見て通すか」を関数で定義する
const POLICY = {
// 読み取り系は無条件で許可
Read : () => ({ ok: true }),
Grep : () => ({ ok: true }),
Glob : () => ({ ok: true }),
// 書き込みは、決められた作業ディレクトリ配下だけ許可
Write : ( input ) => within (input?.file_path, "/srv/build/" ),
Edit : ( input ) => within (input?.file_path, "/srv/build/" ),
// Bash は許可コマンドの前方一致だけ。rm や curl は既定で拒否
Bash : ( input ) => bashAllow (input?.command ?? "" ),
};
const BASH_ALLOW = [ "git " , "node " , "npm run " , "python3 " , "ls " , "cat " ];
function within ( p , root ) {
if ( typeof p !== "string" || ! p. startsWith (root) || p. includes ( ".." ))
return { ok: false , why: `パスが作業ディレクトリ外です: ${ p }` };
return { ok: true };
}
function bashAllow ( cmd ) {
if ( / \b rm \s |\b curl \s |\b sudo \s | > \s * \/ etc/ . test (cmd))
return { ok: false , why: `危険なコマンドを含みます: ${ cmd . slice ( 0 , 60 ) }` };
if ( BASH_ALLOW . some (( p ) => cmd. startsWith (p))) return { ok: true };
return { ok: false , why: `許可リスト外のコマンドです: ${ cmd . slice ( 0 , 60 ) }` };
}
function record ( entry ) {
try {
appendFileSync ( LEDGER , JSON . stringify ({ ts: new Date (). toISOString (), ... entry }) + " \n " );
} catch {
// 台帳への書き込み失敗で判断そのものを止めない(fail-open は避け、判断は継続)
}
}
const server = new McpServer ({ name: "unattended-approver" , version: "1.0.0" });
server. tool (
"approve" ,
"無人実行での権限判断を deny-by-default で返す" ,
{ tool_name: z. string (), input: z. record (z. any ()). optional () },
async ({ tool_name , input }) => {
const rule = POLICY [tool_name];
const verdict = rule ? rule (input ?? {}) : { ok: false , why: `未登録のツール: ${ tool_name }` };
const decision = verdict.ok
? { behavior: "allow" , updatedInput: input ?? {} }
: { behavior: "deny" , message: verdict.why };
record ({ tool_name, allowed: verdict.ok, reason: verdict.why ?? "policy match" , input });
// permission-prompt-tool は JSON 文字列を text で返す契約
return { content: [{ type: "text" , text: JSON . stringify (decision) }] };
}
);
await server. connect ( new StdioServerTransport ());
ポイントは 3 つです。ひとつ、既定は拒否で、POLICY に無いツールは理由つきで止まります。ふたつ、判断のたびに record() で台帳へ追記するので、後から「なぜ止まったか」を追えます。みっつ、台帳の書き込みに失敗しても判断は継続します。ここで例外を投げると、監査のための記録が本来の処理を巻き添えにしてしまうためです。
allowlist を「ツール名+入力条件」の二段で判定する
ツール名だけで許可を決めると、粒度が粗すぎます。Bash を一律で通せば rm -rf まで通り、Write を一律で通せば設定ファイルの上書きまで通ってしまいます。そこで、上のコードでは 2 段で判定しています。第一段はツール名、第二段はその入力の中身です。
Bash なら、コマンド文字列の前方一致で許可リストを当てつつ、rm や curl、sudo、/etc への追記を含むものは先に弾きます。Write や Edit なら、書き込み先が作業ディレクトリ配下かどうか、そして .. による親ディレクトリへの脱出がないかを見ます。判断基準を「名前」から「名前+条件」に上げるだけで、通したいものと止めたいものの線が現実的な位置に引けます。
入力例 判定 理由
Bash: git push origin mainallow 許可リストの git に前方一致
Bash: rm -rf $DIR/cachedeny 危険パターン rm を含む
Write: /srv/build/out.htmlallow 作業ディレクトリ配下
Write: /etc/hostsdeny 作業ディレクトリ外
静的な allowedTools や settings.json でも粗い制御はできます(Claude Code のツール権限設定 で扱いました)。permission-prompt-tool の価値は、入力の中身を見て実行時に判断でき、しかもその判断を記録として残せる点にあります。
実行ラッパー:stream-json の result を見て終了コードと通知を分ける
承認ツールができたら、無人ジョブ本体を書きます。--mcp-config で承認サーバーを登録し、--permission-prompt-tool にその approve ツールを指定します。出力は stream-json で受け、最後に届く result イベントで成否を判定します。
#!/usr/bin/env bash
# nightly.sh — 承認ツール経由で無人実行し、result を見て通知を分ける
set -euo pipefail
export APPROVAL_LEDGER = "/var/log/claude/approvals.jsonl"
CONFIG = '{"mcpServers":{"approver":{"command":"node","args":["/opt/claude/approver.mjs"]}}}'
OUT = "$( mktemp )"
claude -p "content/ 以下のビルド成果物を再生成してコミットする" \
--output-format stream-json --verbose \
--mcp-config " $CONFIG " \
--permission-prompt-tool "mcp__approver__approve" \
> " $OUT " || true
# 最後の result イベントだけを取り出して評価する
RESULT = "$( grep '"type":"result"' " $OUT " | tail -1 )"
IS_ERROR = "$( printf '%s' " $RESULT " | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{console.log(JSON.parse(s).is_error?1:0)}catch{console.log(1)}})')"
DENIED = "$( grep -c '"allowed":false' " $APPROVAL_LEDGER " || true )"
if [ " $IS_ERROR " = "1" ]; then
notify "run failed: result is_error" # ここは自分の通知(Slack/メール等)に差し替える
exit 1
elif [ "${ DENIED :- 0 }" -gt 0 ]; then
notify "run finished but ${ DENIED } action(s) were denied — 台帳を確認"
exit 2 # 完了はしたが要確認、を専用コードで表す
fi
echo "clean run"
大事なのは、終了コードを 3 種類に分けている点です。0 は素直な完了、1 は実行そのものの失敗、2 は「完了はしたが拒否が発生した」という要確認状態です。auto モードの確認が拒否として現れたとき、それを success に混ぜてしまうと、あなたは翌朝「成功」のログだけを見て、実は片付けの半分が実行されていなかったことに気づけません。灰色の状態に専用の色を割り当てておくことが、無人運用では効いてきます。
Before / After:確認ダイアログ頼みから、構成で捌く運用へ
以前の私は、無人ジョブでも --dangerously-skip-permissions に相当する「全許可」で押し切ろうとしていました。確かに止まりはしません。けれど、止まらないことと安全であることは違います。空になった変数が広範囲の削除に化ける瞬間に、受け止める網がひとつもない状態でした。
観点 Before(全許可で押し切る) After(承認ツールで捌く)
止まらなさ 止まらない 止まらない
危険操作 そのまま通る 入力条件で拒否できる
記録 残らない 台帳に理由つきで残る
翌朝の判断 成功ログしか見えない 終了コード 2 で要確認が分かる
両者とも「止まらない」のは同じです。違うのは、After には拒否と記録という受け皿があることです。安全側に倒したうえで動き続けられる、という状態を私は選びたいと思うようになりました。
つまずきやすい点:優先順位・updatedInput・バージョン差
いくつか、実際に手を焼いた箇所を残しておきます。ひとつめは優先順位です。allowedTools などの静的な許可が先に効くと、permission-prompt-tool まで問い合わせが届かないことがあります。承認ツールで判断させたいツールは、静的側で先に通しきらないように整理してください。
ふたつめは updatedInput の扱いです。許可時にこのフィールドを返し忘れると、実行される入力が空になったり、意図と食い違ったりすることがあります。基本は受け取った input をそのまま返し、書き換えたいときだけ差し替えるのが安全です。
みっつめはバージョン差です。フラグ名や返り値の形は、Claude Code の更新でならされていくことがあります。導入時には、承認ツールの中で受け取った tool_name と input を一度そのまま台帳へ書き出し、実際にどんな問い合わせが来るのかを自分の目で確かめてから本運用に載せるのをおすすめします。
小さく始めるための一歩
いきなり全ツールのポリシーを書き切ろうとしなくて大丈夫です。まずは承認ツールを「すべて許可して、ただし tool_name と input を台帳に書き出すだけ」の観測モードで一晩動かしてみてください。翌朝その台帳を眺めると、あなたの無人ジョブが実際にどのツールをどんな入力で呼んでいるかが見えてきます。そこから、通してよいものを一つずつ allow に移していけば、無理なく deny-by-default に寄せられます。
私自身、この観測モードから始めた最初の一晩で、台帳には 200 行を超える判断が積もり、そのうち想定外の Bash が 30 件ほど混じっていたことに気づきました。個人開発だと、この手の「見えていなかった呼び出し」を一人で抱えがちです。止める設計は、まず「見える化」から。実装の参考になれば幸いです。お読みいただきありがとうございました。