ブログ • 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 です。ポリシー、承認キュー、拒否の書き戻しです。カスタムツールと、コールバックで初めて見える呼び出しは、ここを通ります。

この記事で扱うこと:

先に押さえる点:ツールを allow に書くと、承認キューはその呼び出しを見ません。canUseTool は、すでに許可された呼び出しでは発火しません。以下では、接続点、ポリシー JSON、キュー、書き戻しの順に、自分で残す文書を分けます。

公式評価のどこに接続するか

Agent SDK と Claude Code の評価順は固定です。根拠は Agent SDK の権限 と Claude Code の権限 です。deny、ask、allow の順に見ます。より具体的な allow でも、すでに当たった deny は覆せません。

  1. 1
    deny が先に止める

    deny に当たると呼び出しは止まり、bypassPermissions でも同じです。Bash のようにツール名だけの deny は、この段の前にコンテキストから外されます。

  2. 2
    ask はコールバックへ

    ask に当たると canUseTool に渡ります。バイパスをオンにしていても同じです。dontAsk には確認が無く、当たった呼び出しは拒否され、コールバックは呼ばれません。

  3. 3
    allow は確認しない

    allow に当たるとそのまま実行され、canUseTool は呼ばれません。許可リストにツール名だけを書くのも同じです。別の ask ルールがコールバックへ戻す場合を除きます。

  4. 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 のリクエスト本体ではありません。

アプリ自身のポリシー。default は ask。後から書いた allow より deny が先
{
  "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. 1
    先に形を検証する

    そのツール自身の JSON Schema で input を検証します。失敗したらキューに入れず、MCP も呼ばず、すぐ deny にします。reason_code は schema_invalid です。

  2. 2
    次に deny、ask、allow の順で照合

    上の呼び出しは ask-pr-main に当たります。base が enum に入っているためです。ファイル順を見るのは同じ effect の中だけです。

  3. 3
    ツール名だけで allow にしない

    input 制約の無い allow は、base が main でも通します。引数の制約は権限の一部であり、Schema の写しではありません。

等価と enum だけを見る照合器。ルール文字列を shell に連結しません。
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 のときは、待つ前に保存します。待ちをメモリだけに置かないでください。プロセスが再起動すると、モデル側の呼び出しに結果が無くなります。

承認待ちの 1 件。tool_use_id は一意です。
{
  "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"
}

人の決定は別文書にします。キュー記録そのものへ上書きしません。

承認の決定。actor はログインセッションから取ります。
{
  "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 を付けます。

モデルへ書き戻す deny。ルール id と reason_code を入れ、同じ引数では再試行しないと書きます。
{
  "behavior": "deny",
  "message": "Denied by rule ask-pr-main (protected_branch). Do not retry this call."
}

message と deny_message はモデルに見えます。ルール id と reason_code を書きます。内部ホスト名、トークン、承認者の私的メモは書きません。

監査行は書き戻しとは別に保存します。書き戻しが成功してから wrote_back を true にします。
{
  "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 承認票と監査を残す製品
Claude Code の権限ルール。これは公式 settings の形であり、上のアプリ用ポリシーではありません。
{
  "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. 1
    ポリシーを整形する

    JSON 整形 で policy を開きます。Claude Code の settings も厳密な JSON です。コメントや末尾カンマは、次の起動でエラーになります。

  2. 2
    キュー記録を検証する

    下の Schema を JSON Schema ツールに貼り、承認記録を 1 件貼ります。tool_use_id が無い、status が列挙に無い、どちらも失敗します。

  3. 3
    変えたルールを比べる

    ルール変更の前後を JSON Diff で見て、ask が allow になっていないか確認します。この変化は文言より見る価値があります。

  4. 4
    書き戻しを検証する

    JSON 検証 で、書き戻しがまだ正しい JSON であり、tool_use_id が残っていることを確認します。

承認記録の JSON Schema。Schema ツールへそのまま貼れます。
{
  "$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 に渡します。

ブログへ戻る