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.

Claude Managed Agents permission policy verdicts as four stamped cards, allow, deny, ask and unavailable, with only the ask card routed to a person's approval queue
Four verdicts come back from the vendor. Only the ask still waits for a person, and it waits until you give it a clock.

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.

auto is 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, configure always_ask on that tool.”

Claude Managed Agents permission policy reference page showing the always_allow, always_ask and auto policy types in a table, with the agent toolset defaulting to always_allow and MCP toolsets to always_ask 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.

Claude Code hooks reference section for PermissionDenied, stating it runs when auto mode denies a tool call and only fires in auto mode, not for manual denials, PreToolUse blocks or deny rules 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

Diagram of vendor permission verdict triage: a tool call reaches the vendor’s evaluation, allow and deny go to the evaluation log, ask goes to the approval queue with an expiry and then through the vendor’s confirm path, and unavailable goes to your own gate 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 stacked bar chart of who decided a week of tool calls on three lanes: a Managed Agents lane mostly decided by the vendor classifier, a Claude Code auto-mode lane mostly unavailable, and an in-house custom-tool app decided by your own policy and people 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:

  1. Irreversible effects. Pushes to protected branches, deploys, deletes, outbound messages, anything that moves money. These go on always_ask, or off the agent entirely.
  2. Tools reachable by relayed end-user text. What you post in user.message counts 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 is always_ask on 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.
  3. MCP toolsets. They default to always_ask. Moving one to auto is a decision with a name and a date on it.
  4. The agent toolset. It defaults to always_allow, which stamps allow on every call without judging any of them. Choose auto or always_ask per 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