Blog • AI / Agent

How Do You Implement Claude Agent Permissions? From Permission Policy and MCP Tool Calls to a JSON Approval Flow

Claude Agent permission control is a JSON decision sitting between the tool call the model proposes and the code that actually runs. The policy yields allow, deny, or ask. Ask becomes an approval record. The decision is written back on the same tool_use_id. Drop the id, or write the wrong one, and the session stalls or the same call runs twice.

How the platform decides on its own is in the previous article. There, auto means the server reads permission_policy and writes evaluated_permission. You do not change that evaluation. This article is the three JSON documents you attach: the policy, the approval queue, and the denial write-back. Custom tools, and calls that only show up inside the callback, go through here.

This article covers:

Hold this first: once a tool is an allow rule, the approval queue never sees it. canUseTool does not fire for a call that was already approved. The sections below split the documents you keep: attachment point, policy JSON, queue, write-back.

Where you attach to official evaluation

The Agent SDK and Claude Code evaluate rules in a fixed order. See Agent SDK permissions and Claude Code permissions. Deny is checked first, then ask, then allow. A more specific allow does not override a deny that already matched.

  1. 1
    Deny stops the call

    A matching deny blocks the call, including in bypassPermissions mode. A bare tool name such as Bash is removed from context before this step.

  2. 2
    Ask reaches the callback

    A matching ask is handed to canUseTool, even if a bypass mode is on. In dontAsk there is no prompt: the matching call is denied and the callback is not invoked.

  3. 3
    Allow skips the prompt

    A matching allow runs the tool, and canUseTool is not called. Listing only the tool name on the allow list does the same, unless an ask rule pulls that call back to the callback.

  4. 4
    Nothing matched

    If nothing earlier matched, default mode sends the call to canUseTool. dontAsk denies it and does not call the callback.

The approval queue therefore only receives calls that have not already been allowed. To record every call, use a PreToolUse hook. It runs before the rest of the checks and may allow, deny, or edit arguments, but it does not override a deny or ask rule that already matched.

All three attachment points share one queue record. Only the final official write-back shape changes.

Attachment JSON you store Shape written back to the model
Managed Agents hosted tool, paused on ask permission_policy user.tool_confirmation
Agent SDK callback Your policy, plus a queue record { behavior, updatedInput / message }
Custom tool Your policy, plus a queue record user.custom_tool_result

A custom tool arrives as agent.custom_tool_use and has no evaluated_permission. Platform docs: Managed Agents permission policies. When auto denies a hosted tool, the client cannot overturn that with a confirmation event. Do not make the queue a second channel that forces allow.

How to write the Permission Policy JSON

If you only run Claude Code, the allow / ask / deny strings in the official settings.json are enough. An agent inside a product also needs a document you can version, diff, and check with Schema. The document below is your application contract, not an Anthropic request body.

Application policy: default is ask, and deny wins over a later allow
{
  "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"
    }
  ]
}
Field Meaning
version Policy format version. Copy it onto the queue record so an old ticket is not interpreted with new rules.
default Effect when no rule matches. In a product, use ask or deny, not allow.
effect deny, ask, or allow
tool Full tool name. An MCP tool starts with mcp__, then the server name, then the tool name.
input Optional. When present, the arguments must match too. Omit it to match the tool name only.
reason_code Stable machine code. Put it on the write-back and the audit row. Do not use a sentence as the primary key.

Match order follows the official order: every deny, then ask, then allow. Within one effect, the earlier rule in the file wins.

When default is ask, a new MCP tool that no rule names goes to the queue instead of running. That matches a hosted MCP toolset defaulting to always_ask, except the document and the queue are yours.

How one MCP tool call matches a rule

A model call carries at least a type, an id, a tool name, and input. Field names follow the Messages API tool_use block. An MCP tool name is the server name joined to the tool name.

A call that opens a pull request against a protected branch
{
  "type": "tool_use",
  "id": "toolu_01AbC",
  "name": "mcp__github__create_pull_request",
  "input": {
    "base": "main",
    "title": "Retry failed checks",
    "head": "fix/retry"
  }
}
  1. 1
    Validate the shape first

    Validate input with that tool's own JSON Schema. On failure: do not enqueue, do not call MCP, deny immediately, and set reason_code to schema_invalid.

  2. 2
    Then match deny, ask, allow

    The call above hits ask-pr-main because base is in the enum. File order matters only inside one effect.

  3. 3
    Do not allow on the tool name alone

    An allow rule with no input constraint also releases base set to main. Argument constraints are part of permission, not a second copy of Schema.

A matcher for equality and enum only. Do not concatenate rule strings into a 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;
  });
}

Path prefixes need a separate checker that compares normalized paths. If the function above sees a condition it does not understand, it returns false: the rule misses, it does not allow.

JSON Schema Permission Policy
Question answered Is the argument shape valid? May this call run?
On failure schema_invalid, and do not enqueue deny / ask
Example base must be a string base is main

What the approval queue stores

When effect is ask, persist the record before you wait. Do not keep the wait only in memory. If the process restarts, the model's call has no result.

One pending approval. tool_use_id is unique.
{
  "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"
}

Store the human decision as its own document. Do not overwrite the queue record with it.

The approval decision. actor comes from the login session.
{
  "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 comes from your login session. Do not trust a user field inside input.

How deny and allow are written back

Once the decision is terminal, write it back on the same tool_use_id. If you skip that, the session stays waiting. The model cannot continue, and the next turn may propose the call again under a new id.

Your decision Agent SDK callback Hosted tool paused on ask Custom tool
allow { behavior: "allow", updatedInput } result: "allow" After you run it successfully, return a custom tool result
deny { behavior: "deny", message } result: "deny" + deny_message A custom tool result marked as an error, including reason_code

See the official user-input docs. An allow in TypeScript includes updatedInput, and in Python updated_input. Before Claude Code v2.1.207, omitting that field failed validation and denied the call. Always send the original input back.

A deny written back to the model. Include the rule id and reason_code, and say not to retry the same arguments.
{
  "behavior": "deny",
  "message": "Denied by rule ask-pr-main (protected_branch). Do not retry this call."
}

message and deny_message are shown to the model. Include the rule id and reason_code. Do not include internal host names, tokens, or a reviewer's private notes.

Store the audit row separately from the write-back. Set wrote_back to true only after the write-back succeeds.
{
  "tool_use_id": "toolu_01AbC",
  "effect": "deny",
  "rule_id": "ask-pr-main",
  "reason_code": "protected_branch",
  "wrote_back": true
}

Call MCP only after allow. deny and expired do not call it. The policy engine does not run the tool first and ask a person second.

How this differs from settings and auto

The three rule sets do not answer the same question. Do not keep three lists and hope they stay aligned.

Claude Code settings Managed Agents Your policy and queue
Rule shape A tool-plus-specifier string always_allow / always_ask / auto effect, plus tool name and an optional input constraint
Who evaluates Claude Code, in deny, ask, allow order The platform. auto evaluates this one call Your code, in the same order as the official flow
Where a person approves An interactive prompt, or canUseTool user.tool_confirmation Your queue, then mapped onto the two write-backs at left
Fits Local coding A hosted agent A product that must keep tickets and an audit trail
Claude Code permission rules. This is the official settings shape, not the application policy above.
{
  "permissions": {
    "allow": ["Bash(npm test *)"],
    "ask": ["Bash(git push *)"],
    "deny": ["Read(./.env)", "Read(./.env.*)"]
  }
}

Tools a person must see stay at effect ask. When auto judges a call safe, it runs before anyone sees it. Details are in the previous article. In a product, your policy is the source. Exporting a Claude Code deny string is only a projection. Approval state stays on the queue.

Check the three JSON documents in JSONNote

Before you ship, run the policy, the queue record, and the write-back through the workspace. All three can be formatted, validated, and diffed in the browser, with no live agent attached.

  1. 1
    Format the policy

    Open the policy in JSON format. Claude Code settings are strict JSON too. A comment or a trailing comma is reported as an error on the next start.

  2. 2
    Validate the queue record

    Paste the Schema below into the JSON Schema tool, then paste one approval record. A missing tool_use_id, or a status outside the enum, fails.

  3. 3
    Diff the edited rules

    Before and after a rule change, use JSON Diff to see whether ask became allow. That change matters more than wording.

  4. 4
    Validate the write-back

    Use JSON validate to confirm the write-back is still legal JSON and that tool_use_id is still present.

JSON Schema for an approval record. Paste it into the Schema tool as-is.
{
  "$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" }
  }
}

FAQ

Why does canUseTool never run?

An earlier allow rule, or a mode such as acceptEdits or bypassPermissions, already approved the call. The callback only sees calls that are not yet approved. To inspect every call, use a PreToolUse hook. It still cannot override deny.

Is JSON Schema validation enough?

No. Schema only proves the shape is legal, for example that base is a string. Whether base equal to main needs a person is a policy decision. A valid shape can still be deny or ask.

After a denial, should the model retry the same arguments?

No. The write-back should say not to retry the same arguments, and you should not delete the result to pretend the call never happened. A new attempt needs a new tool_use_id and a new pass through the queue.

Does an expired queue item default to allow?

No. Mark the record expired and write back a deny with reason_code approval_expired. Expiry is not approval.

Do you still need this queue if Managed Agents uses auto?

Yes, but only for always_ask and for custom tools. A call that auto marked deny cannot be overturned by a confirmation event. Do not turn the queue into a side path that forces allow.

Conclusion

Implementing permission control is three JSON documents: the policy you write, the queue record created for ask, and the decision written back when the status is terminal. Match order follows the official order, with deny first. Schema only checks shape. If a person has not decided, or the ticket has expired, do not call MCP.

Direct conclusion: default to ask or deny. An allow must name the tool and, when it matters, the arguments. Each tool_use_id runs at most once, and it always gets a write-back.

Who declares an MCP tool and who executes it is covered in MCP security. Why JSON sits in front of execution is covered in the security boundary.

Next: validate the approval record's shape, then hand the policy to the agent.

Back to the blog