ブログ • AI / Agent
Claude Agent の権限制御はどう実装する?Permission Policy、MCP Tool Call から JSON 承認フローまで
Claude Agent の権限制御は、モデルが提案した Tool Call と実際の実行のあいだに置く JSON の判定です。ポリシーは allow、deny、ask のいずれかを返します。ask は承認記録になり、決定は同じ tool_use_id に書き戻します。id を落とすか書き間違えると、セッションが止るか、同じ呼び出しが二度実行されます。
プラットフォームが自分で判断する仕組みは前回の記事にあります。そこでの auto は、サーバーが permission_policy を読んで evaluated_permission を書く処理です。その判定手順は変えられません。この記事が扱うのは、あなたが接続する三つの JSON です。ポリシー、承認キュー、拒否の書き戻しです。カスタムツールと、コールバックで初めて見える呼び出しは、ここを通ります。
この記事で扱うこと:
- 公式の評価がどこまで進むと
canUseToolが呼ばれるか - Permission Policy JSON のフィールド、既定値、照合順
- MCP Tool Call が Schema を通ってからルールに当たるまで
- 承認キューに残すフィールドと、期限切れの扱い
- 許可と拒否をどう書き戻し、同じ呼び出しの二重実行を避けるか
先に押さえる点:ツールを allow に書くと、承認キューはその呼び出しを見ません。canUseTool は、すでに許可された呼び出しでは発火しません。以下では、接続点、ポリシー JSON、キュー、書き戻しの順に、自分で残す文書を分けます。
公式評価のどこに接続するか
Agent SDK と Claude Code の評価順は固定です。根拠は Agent SDK の権限 と Claude Code の権限 です。deny、ask、allow の順に見ます。より具体的な allow でも、すでに当たった deny は覆せません。
-
1
deny が先に止める
deny に当たると呼び出しは止まり、
bypassPermissionsでも同じです。Bash のようにツール名だけの deny は、この段の前にコンテキストから外されます。 -
2
ask はコールバックへ
ask に当たると
canUseToolに渡ります。バイパスをオンにしていても同じです。dontAskには確認が無く、当たった呼び出しは拒否され、コールバックは呼ばれません。 -
3
allow は確認しない
allow に当たるとそのまま実行され、
canUseToolは呼ばれません。許可リストにツール名だけを書くのも同じです。別の ask ルールがコールバックへ戻す場合を除きます。 -
4
どれにも当たらない
前段のどれにも当たらないとき、
defaultモードだけが呼び出しをcanUseToolに渡します。dontAskは拒否し、コールバックを呼びません。
したがって承認キューが受け取れるのは、まだ許可されていない呼び出しだけです。毎回記録するなら PreToolUse hook を使います。残りの検査より前に走り、許可、拒否、引数の変更ができます。ただし、すでに当たった deny や ask は覆せません。
三つの接続点は同じキュー記録を共有し、最後に公式の書き戻しの形へ写します。
| 接続点 | 保存する JSON | モデルへ書き戻す形 |
|---|---|---|
| Managed Agents のホストツールが ask で停止 | permission_policy |
user.tool_confirmation |
| Agent SDK のコールバック | 自分の policy とキュー記録 | { behavior, updatedInput / message } |
| カスタムツール | 自分の policy とキュー記録 | user.custom_tool_result |
カスタムツールのイベントは agent.custom_tool_use で、evaluated_permission はありません。プラットフォームの文書は Managed Agents の権限ポリシー です。ホストツールを auto が deny にした場合、クライアントは確認イベントで覆せません。キューを強制許可の第二経路にしないでください。
Permission Policy JSON の書き方
Claude Code だけなら、公式の settings.json にある allow / ask / deny の文字列で足ります。製品内の Agent には、版管理でき、diff でき、Schema で検証できる文書が別に要ります。下の文書はアプリケーションの契約であり、Anthropic のリクエスト本体ではありません。
{
"version": 1,
"default": "ask",
"rules": [
{
"id": "deny-env",
"effect": "deny",
"tool": "Read",
"input": { "file_path": { "const": ".env" } },
"reason_code": "secret_file"
},
{
"id": "ask-pr-main",
"effect": "ask",
"tool": "mcp__github__create_pull_request",
"input": { "base": { "enum": ["main", "master"] } },
"reason_code": "protected_branch"
},
{
"id": "allow-docs-search",
"effect": "allow",
"tool": "mcp__docs__search"
}
]
}
| フィールド | 意味 |
|---|---|
version |
ポリシー形式の版。キュー記録に写し、古い票を新しいルールで解釈しないようにします。 |
default |
どのルールにも当たらないときの効果。製品では ask か deny にし、allow にはしません。 |
effect |
deny、ask、または allow |
tool |
ツールの完全名。MCP ツールは mcp__ のあとにサーバー名、そのあとにツール名です。 |
input |
任意。書いたときは引数も一致して初めて当たります。省略するとツール名だけを見ます。 |
reason_code |
安定した機械コード。書き戻しと監査に入れ、自然文を主キーにしません。 |
照合順は公式と同じです。deny をすべて見てから ask、そのあと allow。同じ effect の中では、ファイルで前にあるルールが先です。
default を ask にすると、ルールに名前の無い新しい MCP ツールは実行されずキューに入ります。ホストされた MCP toolset の既定 always_ask と同じ考えですが、文書とキューは自分のものです。
1 回の MCP Tool Call をルールに当てる
モデルの呼び出しには、少なくとも type、id、ツール名、input があります。フィールド名は Messages API の tool_use に合わせます。MCP のツール名は、サーバー名とツール名を結んだ完全名です。
{
"type": "tool_use",
"id": "toolu_01AbC",
"name": "mcp__github__create_pull_request",
"input": {
"base": "main",
"title": "Retry failed checks",
"head": "fix/retry"
}
}
-
1
先に形を検証する
そのツール自身の JSON Schema で input を検証します。失敗したらキューに入れず、MCP も呼ばず、すぐ deny にします。reason_code は
schema_invalidです。 -
2
次に deny、ask、allow の順で照合
上の呼び出しは
ask-pr-mainに当たります。baseが enum に入っているためです。ファイル順を見るのは同じ effect の中だけです。 -
3
ツール名だけで allow にしない
input 制約の無い allow は、base が main でも通します。引数の制約は権限の一部であり、Schema の写しではありません。
function decide(policy, call) {
const order = ["deny", "ask", "allow"];
for (const effect of order) {
const hit = policy.rules.find(
(rule) => rule.effect === effect && matchRule(rule, call)
);
if (hit) {
return {
effect,
rule_id: hit.id,
reason_code: hit.reason_code || null
};
}
}
return { effect: policy.default, rule_id: null, reason_code: "default" };
}
function matchRule(rule, call) {
if (rule.tool !== call.name) return false;
if (!rule.input) return true;
return Object.entries(rule.input).every(([key, cond]) => {
const value = call.input?.[key];
if (Object.prototype.hasOwnProperty.call(cond, "const")) return value === cond.const;
if (Array.isArray(cond.enum)) return cond.enum.includes(value);
return false;
});
}
パスの接頭辞は、正規化したパスだけを比べる別の検査にします。上の関数が知らない条件を見たら false を返します。ルールは当たらず、許可にもなりません。
| JSON Schema | Permission Policy | |
|---|---|---|
| 答える問い | 引数の形は正しいか | この呼び出しを実行してよいか |
| 失敗したとき | schema_invalid。キューには入れない |
deny / ask |
| 例 | base は文字列でなければならない |
base が main |
承認キューに何を保存するか
effect が ask のときは、待つ前に保存します。待ちをメモリだけに置かないでください。プロセスが再起動すると、モデル側の呼び出しに結果が無くなります。
{
"id": "apr_01HZXK",
"session_id": "sess_01",
"tool_use_id": "toolu_01AbC",
"tool_name": "mcp__github__create_pull_request",
"input": {
"base": "main",
"title": "Retry failed checks",
"head": "fix/retry"
},
"matched_rule": "ask-pr-main",
"policy_version": 1,
"status": "pending",
"created_at": "2026-09-23T09:00:00Z",
"expires_at": "2026-09-23T09:15:00Z"
}
tool_use_idは一意です。同じ id が再び来たら既存の記録を返し、MCP を二度呼びません。- status は
pendingからapproved、denied、またはexpiredへしか進めません。終端状態は変えません。 expires_atを過ぎたらexpiredにし、denyを書き戻します。reason_code はapproval_expiredです。既定で許可しません。- input は提出されたまま保存し、承認者が読めるようにします。ポリシーを input から読まないでください。モデルは引数でルールを変えられません。
人の決定は別文書にします。キュー記録そのものへ上書きしません。
{
"approval_id": "apr_01HZXK",
"tool_use_id": "toolu_01AbC",
"decision": "deny",
"actor": "user:alex",
"reason_code": "protected_branch",
"decided_at": "2026-09-23T09:04:12Z"
}
actor はログインセッションから取ります。input の中のユーザー項目は信用しません。
拒否と許可の書き戻し
決定が終端になったら、同じ tool_use_id へ書き戻します。書き漏らすとセッションは待ちのままです。モデルは続けられず、次のターンで新しい id を付けて再提案するかもしれません。
| あなたの決定 | Agent SDK のコールバック | ask で止まったホストツール | カスタムツール |
|---|---|---|---|
allow |
{ behavior: "allow", updatedInput } |
result: "allow" |
実行に成功したあと、カスタムツールの結果を返す |
deny |
{ behavior: "deny", message } |
result: "deny" + deny_message |
エラー印の付いたカスタムツール結果。次を含める:reason_code |
公式のユーザー入力ドキュメント を参照してください。TypeScript の allow には updatedInput が、Python には updated_input が要ります。Claude Code v2.1.207 より前は、このフィールドを省くと検証に失敗して拒否されました。書き戻しには常に元の input を付けます。
{
"behavior": "deny",
"message": "Denied by rule ask-pr-main (protected_branch). Do not retry this call."
}
message と deny_message はモデルに見えます。ルール id と reason_code を書きます。内部ホスト名、トークン、承認者の私的メモは書きません。
{
"tool_use_id": "toolu_01AbC",
"effect": "deny",
"rule_id": "ask-pr-main",
"reason_code": "protected_branch",
"wrote_back": true
}
MCP を呼ぶのは allow のあとだけです。deny と expired では呼びません。ポリシーエンジンは、先に実行してから人に聞くものではありません。
settings と auto との分担
三種類のルールは同じ問いに答えていません。別々の名簿を書いて、勝手に揃うことを期待しないでください。
| Claude Code settings | Managed Agents | 自分の policy とキュー | |
|---|---|---|---|
| ルールの形 | ツール名に指定を足した文字列 | always_allow / always_ask / auto |
effect にツール名と、任意の input 制約 |
| 誰が評価するか | Claude Code。順は deny、ask、allow | プラットフォーム。auto はこの 1 回を評価する | 自分のコード。順は公式に合わせる |
| 人の承認はどこに残るか | 対話プロンプト、または canUseTool | user.tool_confirmation |
自分のキュー。そのあと左の二種類の書き戻しへ写す |
| 向いている用途 | ローカルでのコーディング | ホストされた Agent | 承認票と監査を残す製品 |
{
"permissions": {
"allow": ["Bash(npm test *)"],
"ask": ["Bash(git push *)"],
"deny": ["Read(./.env)", "Read(./.env.*)"]
}
}
人が必ず見るツールは effect を ask のままにします。auto が安全と判断すると、人が見る前に実行します。詳細は 前回の記事 です。製品では自分の policy を源にします。Claude Code の deny 文字列への書き出しは投影にすぎません。承認状態はキューが持ちます。
JSONNote で三つの JSON を確認する
出す前に、ポリシー、キュー記録、書き戻しをワークスペースで一度ずつ通します。三つともブラウザで整形、検証、差分が取れ、本番の Agent はまだ要りません。
-
1
ポリシーを整形する
JSON 整形 で policy を開きます。Claude Code の settings も厳密な JSON です。コメントや末尾カンマは、次の起動でエラーになります。
-
2
キュー記録を検証する
下の Schema を JSON Schema ツールに貼り、承認記録を 1 件貼ります。tool_use_id が無い、status が列挙に無い、どちらも失敗します。
-
3
変えたルールを比べる
ルール変更の前後を JSON Diff で見て、
askがallowになっていないか確認します。この変化は文言より見る価値があります。 -
4
書き戻しを検証する
JSON 検証 で、書き戻しがまだ正しい JSON であり、
tool_use_idが残っていることを確認します。
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"additionalProperties": false,
"required": [
"id",
"session_id",
"tool_use_id",
"tool_name",
"input",
"status",
"created_at",
"expires_at"
],
"properties": {
"id": { "type": "string", "minLength": 1 },
"session_id": { "type": "string", "minLength": 1 },
"tool_use_id": { "type": "string", "minLength": 1 },
"tool_name": { "type": "string", "minLength": 1 },
"input": { "type": "object" },
"matched_rule": { "type": ["string", "null"] },
"policy_version": { "type": "integer", "minimum": 1 },
"status": { "enum": ["pending", "approved", "denied", "expired"] },
"created_at": { "type": "string", "format": "date-time" },
"expires_at": { "type": "string", "format": "date-time" }
}
}
よくある質問
canUseTool が呼ばれないのはなぜですか?
より前の allow ルール、または acceptEdits や bypassPermissions がすでに許可しているためです。コールバックが見るのは、まだ承認されていない呼び出しだけです。毎回記録するなら PreToolUse を使います。それでも deny は覆せません。
JSON Schema の検証だけで十分ですか?
十分ではありません。Schema が証明するのは形だけです。たとえば base が文字列であることまでです。base が main のとき人の承認が要るかは policy の判断です。形が正しくても deny や ask になり得ます。
拒否したあと、同じ引数でモデルに再試行させてよいですか?
いけません。書き戻しに、同じ引数では再試行しないと書きます。結果を消して無かったことにもしません。新しい試みは新しい tool_use_id で、キューを最初から通します。
キューの期限が切れたら、既定で許可されますか?
されません。記録を expired にし、reason_code を approval_expired にして deny を書き戻します。期限切れは同意ではありません。
Managed Agents の auto を使う場合も、このキューは必要ですか?
必要です。ただし always_ask とカスタムツールだけです。auto が deny にした呼び出しは、確認イベントでは覆せません。キューを強制 allow の抜け道にしないでください。
まとめ
権限制御の実装は三つの JSON です。自分で書く policy、ask のときに残すキュー記録、終端になったときに書き戻す決定です。照合順は公式と同じで、deny が先です。Schema は形だけを見ます。人がまだ決めていない、または期限が切れているときは、MCP を呼びません。
結論:既定は ask か deny にします。allow にはツール名を書き、必要なら引数も書きます。各 tool_use_id は一度しか実行せず、必ず書き戻します。
MCP ツールを誰が宣言し、誰が実行するかは MCP セキュリティ を見てください。JSON を実行の前に置く理由は セキュリティ境界 にあります。
次にすること:承認記録の形を検証してから、ポリシーを Agent に渡します。