Vendor Permission Verdicts: Which Ones Still Need Your Approval?
Claude Managed Agents permission policy verdicts, normalized: log allows and denials as evidence, queue only real asks, and mark unseen verdicts unavailable.
Go deeper. Build your own.
A Claude Managed Agents session on the auto permission policy can stamp evaluated_permission: "allow" on a shell call that pushes a branch and run it in the same moment. No prompt, no queue entry, no person. That is the Claude Managed Agents permission policy working as designed: the verdict you used to give now arrives as a field on an event, after the call has happened.
Two Anthropic products moved permission decisions onto Anthropic’s servers in the nine days to Sep 19. The fleet question is no longer whether to approve a call. It is which of the vendor’s verdicts still need you at all, and where the rest belong.
By Tuesday you can answer that for every lane. Each vendor’s evaluation lands in one schema. Allows and denials go to an evaluation log as evidence. Only calls the vendor actually paused reach your approval queue, each with an expiry you set. Tools a person must see first move to the vendor’s native ask. And every call whose verdict you cannot see is written down as unavailable instead of assumed.
Chatbots suggest; agents act. The approval queue used to double as the record of what was decided. Now most decisions happen on the vendor’s side of the wire, and the queue sees only the leftovers.
Sep 10 and Sep 19: the Claude Managed Agents permission policy gets auto, and Claude Code goes server-side
On Sep 10, 2026, Anthropic’s platform release notes added auto to the Managed Agents permission policies, next to always_allow and always_ask. Under auto the server evaluates each agent or MCP tool call and runs it, denies it, or pauses it for your approval when it reaches no determination. The agent toolset defaults to always_allow, MCP toolsets to always_ask, and nothing uses auto until you set it. The same notes shipped version 1.32.0 of the ant CLI with ant beta:sessions connect, which attaches a terminal to a running session so you can follow it and allow or deny waiting calls.
The verdict rides on the event. Under any policy, every agent.tool_use and agent.mcp_tool_use event carries evaluated_permission with a value of allow, ask or deny, and most events also carry an evaluation object. Under auto, a pause carries the reason_code indeterminate and a denial carries high_risk. A denial is final: “The session keeps running, and your client cannot override the denial.” The docs are just as blunt about the other direction.
“
autois not a human checkpoint. If the server determines that a call is safe, the call runs before anyone sees it, and its effects might not be reversible. If a person must review a tool’s calls before they run, configurealways_askon that tool.”
Screenshot: Claude Platform Docs, “Permission policies - Claude Platform Docs” (undated), captured Sep 21, 2026.
Nine days later, Claude Code 2.1.278 (Sep 19) switched auto mode for Claude API and Enterprise users, and on Bedrock, Vertex, Foundry and gateways, to a server-side classifier “which does not charge for classifier overhead”. The opt-out, CLAUDE_CODE_AUTO_MODE_SERVER=0, applies on Bedrock, Vertex, Foundry and gateways, and the permission-modes doc says the variable isn’t read on a direct connection to the Anthropic API. Four days earlier, 2.1.273 had set those three clouds to the local classifier by default. How your own classifier stacks against Claude Code’s is covered in the Jev confidence-gate runbook. What matters here is what Claude Code tells you afterwards: its denials, and never which allows its classifier made.
A third source, GitHub Copilot’s enterprise-managed ask, comes with its own rules about what can’t satisfy it. The cross-harness override test owns that one.
Human-in-the-loop queues that don’t become rubber stamps already folds your CLIs into one tiered queue with expiry and budgets. This runbook sits upstream of it. It decides which vendor verdicts get into that queue in the first place.
Step 1: Write one verdict schema every lane reports into
Keep one row per tool call per verdict source, append-only. A call that pauses and is later answered gets two rows: the vendor’s ask, then the resolution. Use the same fields on every lane, whichever vendor made the call.
| Field | What goes in it |
|---|---|
lane, session_id |
Your lane name; the vendor’s session ID |
event_id, tool_use_id |
The vendor’s IDs for the event and the call; these are your join keys |
tool, call_ref, call_sha256 |
Tool name; a pointer to the exact input in an access-controlled store; its hash as an integrity check |
verdict |
allow · ask · deny · unavailable |
decided_by |
static_policy · vendor_classifier · human · unavailable |
vendor_raw |
The documented fields exactly as received, never renamed |
source |
Which stream produced the row: event stream, hook or telemetry |
policy_version, harness_version, classifier_location |
Your agent or settings version; the CLI or API version; server, local or unknown |
static_policy covers anything you configured and versioned, including hooks and a classifier gate of your own. vendor_classifier is a verdict the vendor’s model made and told you about. human is a person answering a pause.
unavailable is a first-class value in both columns, and the reason is the rest of this runbook. A verdict you never received is not an allow. The habit of writing one evidence line per gate decision comes from pre-action gates; this schema extends it to decisions somebody else’s server made.
{"ts":"2026-09-21T14:02:07Z","lane":"billing-api","session_id":"<vendor session id>","event_id":"<vendor event id>","tool_use_id":"<vendor call id>","tool":"bash","call_ref":"calls/2026-09-21/billing-api/0412.json","call_sha256":"9f2c…","verdict":"allow","decided_by":"vendor_classifier","vendor_raw":{"evaluated_permission":"allow"},"source":"managed-agents:events","policy_version":"agent-def@14","harness_version":"managed-agents-2026-04-01","classifier_location":"server"}
The row is illustrative. Store the exact call input where your transcripts already live, under the same access rules: tool inputs carry file contents and sometimes secrets, and a hash alone can’t tell a reviewer what ran.
Step 2: Map Claude Managed Agents permission policy fields, and no others
Your normalizer reads three event types. On agent.tool_use and agent.mcp_tool_use, evaluated_permission is the verdict. decided_by comes from the policy you configured for that tool, which is why policy_version sits in the row: an allow under always_allow came from your configuration, and an allow under auto came from the server’s judgment.
| What the event carries | verdict |
decided_by |
Destination |
|---|---|---|---|
evaluated_permission: "allow", tool on always_allow |
allow |
static_policy |
Evaluation log |
evaluated_permission: "allow", tool on auto |
allow |
vendor_classifier |
Evaluation log; it already ran |
evaluated_permission: "deny", reason_code high_risk |
deny |
vendor_classifier |
Evaluation log; not overridable |
evaluated_permission: "ask", tool on always_ask |
ask |
static_policy |
Approval queue |
evaluated_permission: "ask", reason_code indeterminate |
ask |
vendor_classifier |
Approval queue |
evaluated_permission present, no evaluation object |
as stamped | unavailable |
Evaluation log, flagged |
No evaluated_permission (older events) |
unavailable |
unavailable |
Evaluation log, flagged |
Unrecognized evaluation.type or reason_code |
as stamped | unavailable |
Evaluation log, flagged for a map update |
agent.custom_tool_use |
unavailable |
unavailable |
Your app’s gate writes the real row (Step 8) |
Missing never means allow. The evaluation object is absent when the agent names a tool the session doesn’t enable (a deny with no evaluation) and on events recorded before the field existed. Unknown values stay raw: the docs tell clients to tolerate an evaluation.type or reason_code they don’t recognize, so keep it in vendor_raw and leave decided_by at unavailable until you update the map. Custom tool events carry neither field, because permission policies don’t govern custom tools.
An answered pause gets its second row from the confirmation you send (Step 5): same event_id, allow or deny, decided_by: human.
Step 3: Map Claude Code honestly, which mostly means denials
Claude Code gives you two documented sources, and neither can name a classifier allow.
The PermissionDenied hook runs when auto mode denies a tool call, including a denial made without a classifier verdict. It receives tool_name, tool_input, tool_use_id and a reason (plus mcp_server for MCP tools). “This hook only fires in auto mode”: a denial you click, a PreToolUse block or a matching deny rule never reaches it. Its only output is a request to retry, and exit codes are ignored, so it is a logging point, never a gate.
The OTel tool_decision event in the monitoring doc records a decision of accept or reject and a source of config, hook, user_permanent, user_temporary, user_abort or user_reject. config lumps together settings, managed policy, flags and the active permission mode, and the doc is plain about it: “The event doesn’t indicate which of these sources matched.” No documented field or hook distinguishes a classifier allow from a rule allow, so no row of yours should claim one.
Screenshot: Claude Code Docs, “Hooks reference - Claude Code Docs” (undated), captured Sep 21, 2026.
| Claude Code signal | verdict |
decided_by |
|---|---|---|
PermissionDenied, reason names a verdict such as [Data Exfiltration] |
deny |
vendor_classifier |
PermissionDenied, reason starts Auto mode could not evaluate this action and is blocking it for safety, or reads Classifier unavailable |
deny |
unavailable (failed closed) |
tool_decision, source: "hook" |
from decision |
static_policy |
tool_decision, any user_* source |
from decision |
human |
tool_decision, source: "config", decision: "accept" |
allow |
unavailable |
tool_decision, source: "config", decision: "reject" |
deny |
from the PermissionDenied row on the same tool_use_id, else unavailable |
| No telemetry exported and no denial | unavailable |
unavailable |
The logger for the first two rows records and cannot block.
# log_denial.py: illustrative PermissionDenied command hook; a logger, not a gate
# LANE and LANE_CLI_VERSION are set by your runner, not by Claude Code
import hashlib, json, os, sys, time
e = json.load(sys.stdin)
reason = e.get("reason", "")
no_verdict = reason.startswith("Auto mode could not evaluate") or reason == "Classifier unavailable"
row = {
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"lane": os.environ.get("LANE", "unknown"),
"session_id": e.get("session_id"),
"tool_use_id": e["tool_use_id"],
"tool": e["tool_name"],
"call_sha256": hashlib.sha256(json.dumps(e["tool_input"], sort_keys=True).encode()).hexdigest(),
"verdict": "deny",
"decided_by": "unavailable" if no_verdict else "vendor_classifier",
"vendor_raw": {"reason": reason, "mcp_server": e.get("mcp_server")},
"source": "claude-code:PermissionDenied",
"harness_version": os.environ.get("LANE_CLI_VERSION", "unknown"),
}
with open(os.path.expanduser("~/.fleet/verdicts.jsonl"), "a") as f:
f.write(json.dumps(row) + "\n")
Then record where the classifier ran, per session. Per the classifier billing doc, /status has an “Auto mode server” row that reads Enabled while the server decides and Disabled after Claude Code falls back to its own billed classifier requests, which happens when a gateway or proxy strips or rewrites headers, drops unrecognized request fields or edits responses. Headless lanes get the fallback notice on stderr under -p and as a system warning in stream-json; capture it and set classifier_location to local. Pro, Max and Team plans never see that notice, so its absence proves nothing there: leave those sessions at unknown. Keep harness_version on every row, because Bedrock, Vertex and Foundry sessions on 2.1.273 through 2.1.277 default to the local classifier and 2.1.278 onward to the server.
One trap: /permissions lists auto-mode denials under Recently denied, but per the auto mode configuration doc not the no-verdict ones. Build the denial log from the hook.
Any lane whose harness documents no verdict field gets unavailable in both columns until it does. Don’t reconstruct a vendor verdict from timing, from a missing prompt, or from a transcript line that reads like approval.
Step 4: Split the stream: evidence for allow and deny, the queue for ask
This split is the answer to the headline. Of the four verdicts, exactly one still needs a person.
verdict |
Where it goes | What a person can still do |
|---|---|---|
allow |
Evaluation log | Nothing before the fact. Under auto the call has run; review a weekly sample afterwards |
deny |
Evaluation log | Nothing through the client. Change the policy or the task, then let the agent retry |
ask |
Approval queue, one item per blocking event | Answer it through the vendor’s confirm path (Step 5) before the expiry (Step 6) |
unavailable |
Evaluation log, flagged | Fix the lane: add a verdict source, or put your own gate in front of it |
Allow and deny are evidence. Ask is the only verdict that still waits for a person. Unavailable is a gap you close with your own gate.
Allows and denials are evidence, and evidence has a retention clock you don’t control on a vendor-hosted session. Export the evaluation log on a schedule, the same way you’d export the agent audit trail before access changes. The unavailable share per lane is a coverage gap, and it belongs on the weekly page from the three oversight numbers, printed rather than hidden.
Illustrative: modeled shares, not measured data. On a Claude Code auto-mode lane most allows land in
unavailable, because no documented field names the classifier.
Don’t try to fix a deny by sending a confirmation. The API refuses it: “If you send a user.tool_confirmation for an event whose evaluated_permission is not ask, the API rejects it with a 400 error.” A spike of 400s in your confirm path means your queue is admitting events it shouldn’t.
Step 5: Answer each pause through the vendor’s own confirm path
A Managed Agents pause emits session.status_idle with stop_reason.type: requires_action and the blocking IDs in stop_reason.event_ids. Build one queue item per blocking event and carry the lane, the call pointer, the vendor’s reason_code and the session ID on it. The approver’s answer goes back as a user.tool_confirmation with tool_use_id, a result of allow or deny, and an optional deny_message.
{"type":"user.tool_confirmation","tool_use_id":"<id of the paused call>","result":"deny","deny_message":"Pushes to main need a release ticket. Open one and retry."}
Illustrative body; the field names are from the docs, and the transport is whatever your client already uses. For a person at a terminal, ant beta:sessions connect covers the same ground, and its --web flag serves the Console’s session viewer locally.
Answer only through that path; a call your runner executes outside the session has no vendor verdict and no place in the event stream. Answer each blocking event separately, so one approval can’t cover a different call that paused in the same idle. Write the resolution row the moment the confirmation is accepted, with the approver’s identity beside decided_by: human.
On Claude Code lanes the confirm path is the session’s own permission prompt. Your record of it arrives as a tool_decision with a user_* source.
Step 6: Put your own expiry on every native pause
“The session waits indefinitely for a response.” That line describes the vendor’s behaviour, and nothing obliges you to copy it. The docs describe no timeout on a pause, so the clock is yours.
Set expiry per consequence tier, using the tiers your approval queue already runs. When a pause expires, send result: "deny" with a deny_message naming the expiry, then write the row as deny, decided_by: static_policy, with the expiry rule’s version in policy_version. A reviewer who clicks later should hit a closed item. Don’t count on the vendor to reject a second confirmation for the same call; close the item on your side.
Count expiry denials per lane each week. A lane whose pauses keep expiring has the wrong tier, a thin reviewer rota, or a tool on the wrong policy.
Step 7: Move anything a person must see first to always_ask
An auto allow runs before anyone sees it, and an auto deny can’t be overridden. That leaves a person exactly one lever under auto: the pauses the server chooses to raise. Anything a person must see before it runs goes on always_ask, which pauses every call to that tool.
Walk each lane’s tool list with this checklist:
- Irreversible effects. Pushes to protected branches, deploys, deletes, outbound messages, anything that moves money. These go on
always_ask, or off the agent entirely. - Tools reachable by relayed end-user text. What you post in
user.messagecounts as your intent and can lead the server to allow a call it would otherwise deny. Relay untrusted end-user input there and the server reads it as your intent too; Anthropic’s advice isalways_askon tools that user must not run unreviewed. The server doesn’t take intent from tool results, fetched pages, MCP responses or messages between session threads, so the risk is the relay you built. - MCP toolsets. They default to
always_ask. Moving one toautois a decision with a name and a date on it. - The agent toolset. It defaults to
always_allow, which stampsallowon every call without judging any of them. Chooseautooralways_askper tool on purpose rather than inheriting the default.
The normalizer, the log and the queue from Steps 1 to 6 are records and routing. They don’t enforce anything. If your pipeline drops an event, the call it described still ran or still stopped exactly as the vendor decided. The walls are the vendor-side policy (always_ask, disabled tools), your harness deny rules and the sandbox under the agent. Watch the pipeline itself: compare event counts per session in the vendor stream against rows in your log, and alert when they diverge.
Step 8: Gate custom tools in the app that runs them
agent.custom_tool_use events carry neither evaluated_permission nor evaluation. Your application executes custom tools, so your application is the gate, and the vendor verdict for those calls is permanently unavailable.
Write your gate’s decision into the same schema with decided_by set to static_policy or human, never vendor_classifier. Fail closed: if the gate errors or times out, the row reads deny, the error goes in vendor_raw, and the tool doesn’t run.
Four ways a verdict pipeline misleads you, and the signal for each
Absent read as allow. A mapper defaults a missing evaluation or evaluated_permission to allow. Signal: allow rows with an empty vendor_raw. Fix: map absence to unavailable, and fail the mapper’s tests on any default.
Relayed text talks the server into an allow. An app forwards customer messages as user.message and the server reads them as your intent. Signal: auto allows on sensitive tools in sessions whose user.message came from a relay. Fix: always_ask on those tools, then re-read Step 7.
The classifier moved and nobody noticed. A CLI upgrade or a header-rewriting proxy changes who decided. Signal: classifier_location flips between server and local within a lane, or the billed-fallback notice appears in headless stderr. Fix: record it per session and treat an unexpected flip as a change event.
The record goes quiet. A hook stops firing or telemetry export breaks, and the log looks calm. Signal: rows per session fall against the vendor’s event count, or a lane shows zero denials for a week. Fix: alert on the divergence, and remember the enforcement never lived in the log.
One verdict schema is how a fleet keeps its own record
Every vendor will keep shipping its own permission dialect: a server-side policy here, a hook that fires only on denials there, a managed ask with its own override rules somewhere else. None of them will normalize the others for you. The schema, the split and the expiry belong in the layer that runs the whole fleet, beside the kill switch and the session inventory, which is the job of a multi-agent command center rather than any one vendor’s console.
Run the split for a week. Most rows will be evidence. A few will be real asks with a clock on them. The unavailable count is the honest number, and it is the one to shrink.
FAQ
Does Claude Managed Agents auto mode replace human approval?
No. Anthropic’s docs say auto is not a human checkpoint: a call the server judges safe runs before anyone looks, and your client cannot override a denial. Only calls the server can’t decide pause for you. Put always_ask on any tool a person must review before it runs, and give those pauses your own expiry.
Can I see which tool calls Claude Code’s auto mode classifier allowed?
Not from any documented field. The PermissionDenied hook fires on auto-mode denials only, and the OTel tool_decision event reports permission-mode decisions under source: "config" without saying which source matched. Log those allows with decided_by: unavailable rather than claiming a classifier verdict you never received.
Sources
- Claude Platform Docs: Permission policies — policy types, verdict fields, confirmations, custom tools, intent
- Claude Platform release notes — Sep 10, 2026:
auto; Ant CLI 1.32.0 - Claude Code changelog — 2.1.278 (Sep 19, 2026) and 2.1.273 (Sep 15, 2026)
- Claude Code hooks reference —
PermissionDenied - Claude Code monitoring — OTel
tool_decision - Claude Code permission modes — server classifier default and opt-out scope
- Claude Code auto mode classifier billing — fallback, notice,
/statusrow - Claude Code auto mode configuration — Recently denied
