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:
- Where official evaluation stops, and when
canUseToolactually runs - Fields, defaults, and match order in a Permission Policy JSON document
- How an MCP tool call passes Schema validation before it matches a rule
- Which fields an approval record stores, and what expiry does
- How allow and deny are written back so one call does not run twice
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
Deny stops the call
A matching deny blocks the call, including in
bypassPermissionsmode. A bare tool name such as Bash is removed from context before this step. -
2
Ask reaches the callback
A matching ask is handed to
canUseTool, even if a bypass mode is on. IndontAskthere is no prompt: the matching call is denied and the callback is not invoked. -
3
Allow skips the prompt
A matching allow runs the tool, and
canUseToolis 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
Nothing matched
If nothing earlier matched,
defaultmode sends the call tocanUseTool.dontAskdenies 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.
{
"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.
{
"type": "tool_use",
"id": "toolu_01AbC",
"name": "mcp__github__create_pull_request",
"input": {
"base": "main",
"title": "Retry failed checks",
"head": "fix/retry"
}
}
-
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
Then match deny, ask, allow
The call above hits
ask-pr-mainbecausebaseis in the enum. File order matters only inside one effect. -
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.
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.
{
"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_idis unique. If the same id arrives again, return the existing record and do not call MCP a second time.- status may move only from
pendingtoapproved,denied, orexpired. A terminal status does not change. - After
expires_at, mark itexpiredand write backdenywith reason_codeapproval_expired. Do not default to allow. - Store input as submitted so a reviewer can read it. Do not load the policy from input. The model cannot change rules through arguments.
Store the human decision as its own document. Do not overwrite the queue record with it.
{
"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.
{
"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.
{
"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 |
{
"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
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
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
Diff the edited rules
Before and after a rule change, use JSON Diff to see whether
askbecameallow. That change matters more than wording. -
4
Validate the write-back
Use JSON validate to confirm the write-back is still legal JSON and that
tool_use_idis still present.
{
"$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.