Agent Authorisation

Tool Calls: The Verdict Is a Step in the Run

A Dapr sidecar will no longer believe a header that says who is calling. That is one of several moves in the first week of October to put the decision about a tool call inside the run: policy before every dispatch, three outcomes, and a verdict written before the effect.

Cloud X Ops TeamDevOps & AI Integration
October 10, 2026
16 min read

Until 9 October a Dapr application could tell the service it called who it was. The sidecar stamps dapr-caller-app-id and dapr-caller-namespace on service invocation, but the 1.18.6 release notes say “a calling app could send its own values for these headers, and they reached the target app alongside the values Dapr set”. The receiving sidecar now writes those headers itself and, with mTLS on, takes them from the calling sidecar's certificate.

That is the direction the first week of October took: the decision about what a tool call may do, and who is asking, moved inside the run. Oso Cloud was acquired by Tiny on 6 October and keeps running; most of the Oso team joined Temporal, whose post on the move says an approval that was granted “stays granted, and a rejection stays rejected, even after a crash or a retry”. OpenAI's and Microsoft's agent SDKs both tied an approval to the exact call it was given for, covered below. None is a proxy in front of the agent; each is a step inside the run that leaves a record.

A tool call is an identity, a decision and an effect

Every tool dispatch has three parts. Someone asks: the agent, acting for a user, a tenant or a schedule. Something decides whether this call, with these arguments, may run now. Then the effect lands. The deciding part has mostly lived in a wrapper, and Google's launch of 8 October kept the enforcement at the gateway. The Gemini at Work post routes all traffic in, out and between agents through Agent Gateway, “an AI network firewall enforcing your organization's policies in real time”, and gives every agent an identity “cryptographically attested and governed like an employee”.

Keep the gateway; it checks identity and holds the keys. But it sees one request at a time, and three things a tool call needs are not in a request. It cannot pause your run and resume it later with the same arguments. It cannot tell a retry from a first attempt, and an effect that ran just before a crash runs again. And it never saw what the model proposed, leaving no way to tie an approval to the exact call it was given for. All three live in the run's own history, which is why the week's changes landed in workflow engines and SDKs rather than in firewalls.

Nor can the decision live only in the model's instructions. Anthropic's 9 October report on unintended model actions in its evaluations and internal use lists “Claude working around a restriction to reach data that was gated by a token or a fee”. Anthropic says the impact was minimal, and it has turned off live internet access for all its internal evaluations while it checks that its monitoring catches such behaviour. A limit the model is asked to respect is a prompt; the one that matters is enforced by the runtime before the call runs.

Three outcomes, one policy file

Vercel's @ai-sdk/policy-opa shows the step in the fewest moving parts, and it is not new: on npm since June, with a 9 October release that was only a dependency bump. The README says the policy “is consulted before every tool dispatch” and maps onto the three outcomes the AI SDK's toolApproval already has (approved, denied and user-approval). A Rego rule returns allow, deny or requires-approval with an optional reason, which “gets surfaced back to the model (for deny) or to the human approver (for requires-approval)”. The model sees a denial as a structured result on its next step; write the reason as an instruction, naming what is unreachable and what to use instead.

policy.rego
package agent.call
import rego.v1

# no rule matched: fail closed
default decision := {
  "decision": "deny",
  "reason": "no rule for this call"
}

# ALLOW: reads, any arguments
reads := {"db.query", "git.log"}
decision := {
  "decision": "allow"
} if {
  input.tool.name in reads
}

# DENY: a reason the model reads
decision := {
  "decision": "deny",
  "reason": "private net, use proxy"
} if {
  input.tool.name == "http.post"
  h := input.args.host
  net.cidr_contains("10.0.0.0/8", h)
}

# PAUSE: a human, bound to this call
decision := {
  "decision": "requires-approval",
  "reason": "refund over 500"
} if {
  input.tool.name == "stripe.refund"
  input.args.amount > 500
}

Three details matter more than the rules you write. The default is a deny with a reason, because the README's own default is not-applicable, which the SDK treats as allow. Each rule is scoped by tool and by argument; the refund rule fires on the amount, and reads pass whatever they ask for. The pause, too, is a policy outcome, and the approver sees the reason the rule produced. Load the file as WASM in the agent process or from an OPA server; an error in either fails closed, and wrapMcpTools sends every discovered MCP tool the policy does not name to user approval by default.

AWS's Strands Box, in developer preview and Apple silicon only for now, takes the same step down to the operating system. It is an open source local sandbox where shell, Python, MCP calls and outbound requests all pass one layer of permit and forbid rules, denied by default. Its gateway attaches credentials the agent never holds.

Unconfigured is not denied

An OPA error fails closed in policy-opa, but an absent policy does not: optionalOpaPolicy returns undefined when no client is configured, which is no enforcement, and shadow mode approves every call while it logs what the policy would have said. Both exist for rollout. Make the build fail when the bundle is missing, and set enforce before the agent reaches a write tool.

Write the verdict before the effect, then bind it to the call

A policy step holds only if its result survives what happens next. Temporal's post names the failure: “If an agent can bypass an approval or replay a rejected action after a retry, the controls are broken.” Its answer is the engine's own history, and the rule that follows is about order. The approval, or the rejection, is written to the run history first. The activity that performs the effect is scheduled second. A worker that crashes in between replays from the record and neither asks again nor refunds twice. LangGraph's interrupt() is one framework's pause (see the LangGraph guide); the coding agent harness guide covers a tool call cut off mid flight.

The second half is binding. An approval is for one call: this tool, these arguments, this session. OpenAI's agents-core 0.19.0 (5 October) binds conditional approvals “to isolated normalized execution input” and re-evaluates policy when a durable run resumes; 0.20.0 counts post-approval model calls toward maxTurns. Microsoft's Agent Framework dotnet-1.24.0 (7 October) binds MCP approval headers to the approved invocation and, since python-1.20.0, fails closed when a binding cannot be resolved.

The record of a verdict therefore carries the session, the tool, a hash of the normalised arguments and who answered. A call whose fields match is read from the record; one that differs is a new question, and one whose binding cannot be resolved does not run.

Watch one approval meet two refunds

Press Run the turn below. The agent makes three tool calls under the policy above: a database read, a POST to 10.0.4.7 and a refund of 1,200, which the policy pauses. You answer as the approver, the verdict is written to the run history before anything executes, and the agent then asks for a second refund of 4,800. With the approval bound to the call, the runtime sees different arguments and asks you again. Switch the binding to the tool and watch the 4,800 go out on your earlier yes.

Tool Call Policy · policy.rego
call#1
db.query {sql: "select ..."}
awaiting policy
rule: reads
effect: pending
call#2
http.post {host: 10.0.4.7, path: /v1/orders}
awaiting policy
rule: http.post into 10/8
effect: pending
call#3
stripe.refund {charge: ch_8f21, amount: 1200}
awaiting policy
rule: refund over 500
effect: pending
approver: you · if paused
call#4
stripe.refund {charge: ch_8f21, amount: 4800}
awaiting policy
rule: refund over 500
effect: pending
approver: you · if paused
run historyidle
# s-41 · nothing recorded yet
plancall#1 db.query
plancall#2 http.post 10.0.4.7
plancall#3 stripe.refund 1,200
plancall#4 stripe.refund 4,800
# approvals bound to the call
# verdict first, then the effect
session s-41questions 0verdict records 0effects 0
$ awaiting run · press Run the turn

Three verdicts, one approval, two refunds: bound to the call, the 4,800 asks again; bound to the tool, it rides the first yes.

Identity is read from the certificate

None of this holds if the run cannot tell who is calling, and Dapr's 9 October releases show what goes wrong when identity is a header. Beyond the spoofable headers, the Scheduler let any app with a valid identity in the same namespace schedule, read, list or delete reminder jobs on another app's internal workflow actors. A percent-encoded /, \ or . in a method name still slipped past the service invocation access control policy, a gap April's fix for CVE-2026-41491 had left open. And with the WorkflowsClusteredDeployment preview feature on (off by default), another app in the namespace could complete a workflow's tasks. Those internal actors now accept calls only from a sidecar with the same app ID and namespace, and the workflow actor verifies who created an activity result reminder. The versions to run are 1.18.7, or 1.17.16 on the 1.17 line, which carries three of the four fixes (all but the clustered one). As of 10 October there was no GitHub advisory, and the 1.18.6 release notes warn about sidecars left on an old version: “With mTLS disabled there is no verified identity to fall back on: if the calling sidecar has not been upgraded, dapr-caller-namespace on gRPC proxied calls still reflects the value the calling app sent, so upgrade every sidecar to close the gap fully”.

The pattern generalises. Identity for a tool call comes from something the caller cannot write: an mTLS certificate, a workload identity token, an OAuth grant exchanged at the gateway. Google's post has every action “written to an audit trail and attributed to the agent rather than to a person”. Inngest's 6 October change applies the idea to keys: MCP, CLI and API keys now carry read or write permissions, and a call without the required one “returns 403 or insufficient_scope”. Log every verdict with the identity that produced it and the session it belongs to, and an auditor replaying your run sees who asked, what was decided and what then ran.

The organisation's rules load first and fail closed

The last move of the week was about who writes the policy. GitHub's local sandboxing for Copilot reached general availability on 7 October, powered by Microsoft eXecution Container, and enterprises can “require sandboxing and enforce policies that developers cannot weaken”. Copilot Studio's post the same day has hooks in preview, letting makers “inspect, modify, or block a tool call before it executes”. Its documentation is candid that a hook fails open: if the workflow “fails, times out, or returns something the agent can't read, the agent continues as though the hook returned nothing”, and “Don't rely on a hook as your only safeguard for a business-critical rule.” The organisation's rules have to load first, fail closed and stay out of reach from inside the run, and Microsoft's own page says which of the three its hooks miss.

Then treat your policy repository like code. Keep recorded tool calls as fixtures and assert all three outcomes in CI, and a change that turns a pause into an allow fails a test before it reaches an agent. Audit the overrides too. Find the runtime that still lets a user switch sandboxing off, the agent that still accepts an approval from outside its session, and the tool server that still trusts a header. Count the turns after an approval against the limit, as agents-core 0.20.0 now does, or an approved run can carry on past its turn budget.

A verdict lives as long as the record that holds it.

Takeaways

  • Caller identity comes from something the caller cannot write, the sidecar's mTLS certificate; run Dapr 1.18.7 or 1.17.16 on every sidecar, because with mTLS off a calling sidecar left on an old version still passes the caller's own namespace header through on gRPC proxied calls.
  • A denial reason is an instruction to the model, which reads it as a structured result on its next step.
  • Write the approval or rejection into the run history before scheduling the effect; a crashed or retried worker then reads the verdict instead of asking again or refunding twice.
  • An approval is for one call: record the session, the tool, a hash of the normalised arguments and who answered, read a matching call from the record, treat a differing one as a new question, and refuse one whose binding cannot be resolved.
  • The policy-opa README defaults to not-applicable, which the SDK treats as allow, so your policy file carries its own default deny.

Who decided your agent could make that call?

We move tool authorisation into the run: policy as code before every dispatch, the verdict recorded before the side effect, approvals bound to one call that fail closed, and identity from mTLS rather than headers.

Put policy in the run
policy.sh
SECURE
cloudxops@gateway:~$ ./replay-verdicts.sh ./run
# Replaying every tool call against the policy repo...
[OK] every verdict recorded before its effect
[INFO] caller identity from mTLS, no header trusted
[READY] approvals bound to one call, fail closed
$ ▋
Policy coverage