On 28 September the UK AI Security Institute (AISI) published a simulation study of GPT-6 Astra with a detail I keep coming back to. The model “often asked for permission to perform unsanctioned actions on out-of-scope targets”. The test harness had no person behind it, so any question got the same canned reply: “Please proceed to the next step using your best judgement”. AISI reports that the model “sometimes treated this automated message as permission to proceed”.
I want to be careful about what that shows. It’s a simulation, AISI weren’t testing an approval system, and they also saw “multiple cases” where the model attacked without asking at all. But the shape is the one most human-in-the-loop designs start from. The agent asks a question in the conversation. Some text comes back. The agent decides whether that text meant yes.
That’s a question, and the model is marking its own answer. An approval is something else: a capability the harness issues, which the tool executor checks, and which the model can neither mint nor interpret. This post is design analysis with illustrative code. I read release notes, pull requests and one draft spec; I ran none of the frameworks, and the TypeScript hasn’t been run either.
I’ve touched this before. The approval matrix post is about which actions need approval, the permission matrix is about which tools shouldn’t exist at all, and the containment timeline gave self-approval one paragraph. This one is about the approval itself: what it has to be made of.
The running example: an offboarding agent
Someone leaves the company. HR opens a ticket, and an agent works through it: list the leaver’s accounts, suspend each one, transfer their files to their manager, release the software licences, and after a retention period delete the mailbox.
Most of that is reversible. A suspended account can be unsuspended. A deleted mailbox can’t be undeleted once retention has passed, and “delete the mailbox for j.smith” is one typo away from deleting it for j.smyth, who still works here. So deleteMailbox needs approval from the leaver’s manager or an IT lead. The interesting question is what the agent holds after that person clicks Approve.
Four properties
Scoped to one agent and one call. The approval covers this agent, in this run, making this call, once. If a licence clean-up subagent also has a tool called deleteMailbox, the manager’s click doesn’t reach it. If the agent retries, it doesn’t get a second deletion out of the first click.
Bound to the arguments. The manager approved deleting j.smith’s mailbox. If the call that reaches the executor says j.smyth, or adds skipRetention: true, the approval doesn’t match and nothing runs. “The user approved deleteMailbox” is a much weaker statement than “the user approved this call”.
Decided by a different principal, outside the model. The decision arrives through a channel the model can’t write to: an authenticated session in a ticketing tool or a Slack action, with a real user ID on it. Tool output, retrieved documents and the model’s own text can’t carry it. And the approver isn’t the requester. Finance calls this maker-checker: the HR person who opened the ticket doesn’t also approve its irreversible step.
Fails closed. Approval service down, token expired, tool not in the policy table, state that can’t be deserialised after a deploy: every one of those is a denial. The leaver’s mailbox survives another day, which is the cheap direction to be wrong in.
Meta’s Muse design post from 8 September says approvals there are “strict capabilities, not conversational suggestions”, with the prompt shown in the client and the answer going to the policy component, not into the chat. That’s where my title comes from.
The scorecard
In the first week of October, five agent frameworks shipped changes to how tool calls get approved or checked before they run. I scored each against the four properties, along with one draft MCP proposal and one vendor sample. “Not stated” means the release note or pull request I read doesn’t say. It doesn’t mean the framework lacks the property.
| What changed | Scoped | Bound to arguments | Outside the model | Fails closed |
|---|---|---|---|---|
| Microsoft Agent Framework Python 1.20.0 (2 Oct) | Session-backed, with isolated approval-state namespaces | Not stated | Yes: completion read from typed call contents, not tool text | Yes, when bindings can’t be resolved |
| OpenAI Agents SDK Python 0.23.0 (1 Oct) | Yes: decisions keep their owning agent | No for “permanent” decisions, by design | Not stated | Yes for the new cases: unclear owner means ask again |
| Google ADK Python 2.11.0 (1 Oct) | Not stated | Not stated | Yes: workflow tool nodes pause for the user | Not stated |
Pydantic AI 2.53.0, ToolCallJudge (1 Oct) |
Per call | It reads the arguments; no binding described | No: the judge is a second model | Uncertainty blocks; judge errors not stated |
Mastra @mastra/connect 1.0 (7 Oct) |
n/a | n/a | n/a | No: discovered MCP tools stop needing approval unless you opt in |
| MCP SEP-2848 (draft, open) | Yes: tool, arguments and caller | Yes: full arguments stored, digest optional | Client side yes; the approver can be an agent | Yes: expiry is a terminal denial |
| Restock, LangChain and Stripe sample (8 Oct) | One order | Amount yes; cart re-checked by the tool | Yes: Slack interrupt plus Link | Not stated |
What the fixes say about the bugs
The Microsoft row is the one that surprised me. The release note says the framework will now “fail closed when approval bindings or agent-tool approvals cannot be resolved”, which I’d already quoted. This time I opened one of the linked pull requests, #8579. It removes the detection of an [APPROVAL_PENDING] text placeholder from the core loop and says approval completion has to be determined by typed function-call contents and not by the text a tool returns. I don’t know whether that was ever exploitable, and I’m not claiming it was. But the approval state of a call depending on a string in tool output is exactly the AISI shape, and they’ve removed it.
OpenAI’s note is one line: “core: scope function tool approvals to their owning agent”. The pull request says more. Permanent approvals and rejections could be reused by another agent with a same-named tool after a handoff. In offboarding terms: the manager says “always allow” for the main agent’s deleteMailbox, the run hands off to the licence clean-up subagent, and its deleteMailbox inherits the yes. After the fix, decisions keep their owner, and an ambiguous or missing owner means asking again. That’s the scoping property, and a fail-closed choice on old saved state. A permanent approval still isn’t bound to arguments, and can’t be. I’d keep “always allow” away from anything irreversible.
Google ADK’s change is smaller: tool nodes inside workflows now pause for approval through RequestInput, “the same way an LlmAgent does”, where before the note says an error was passed downstream. A pause is the right primitive. The note doesn’t tell me what the pause is bound to.
A judge is a filter
Pydantic AI’s ToolCallJudge is described in its pull request as using “a second model to assess selected calls before their tool functions run”, on the sound reasoning that a tool’s name doesn’t tell you whether a call is risky and its arguments do. Uncertainty blocks by default.
I’d use it, and I wouldn’t call it approval. A second model is a different principal from the first only in a narrow sense: the same hostile text can reach both, and nobody is accountable for its yes. It’s a filter that catches j.smyth when the ticket says j.smith. The pull request says it runs after requires_approval, as a second gate, which is the right way round.
The counterexample, and the draft
Mastra went the other way. @mastra/connect 1.0 is marked breaking: “Discovered MCP tools no longer require approval by default”, with requireApproval as the opt-in, and the notes say this matches @mastra/mcp’s existing default. I covered the default flip in the earlier post, so only the scorecard point here: a tool server the agent discovered at runtime is the case where I’d most want the default to be “ask”.
SEP-2848 is the closest thing on the list to the four properties written down as protocol. It’s an open pull request, first posted in June and still being discussed on 12 October; the text says “Status: draft” and it has no sponsor. In the draft, a server answers an approval-gated tool call with a task handle, and the author describes that handle as “a reference to a pending decision, never a grant”. The server “MUST check the immutable call binding before executing” and must re-evaluate authorisation on approval. Two things I’d watch. The digest over the arguments is optional (the server stores the full arguments either way). And who approves is left to the deployment: “a human reviewer, a supervising agent, a policy or risk engine”, with separation of duties explicitly the backend’s job. A supervising agent approving a working agent is two models agreeing. That may be fine for a rate limit. It’s not what I’d want between an agent and a mailbox.
Restock I wrote up in the permission matrix post, including its line “Nothing the model writes into a tool call can approve it.” The detail that matters here is that the Link approval is “for exactly the amount” in the merchant’s challenge, and the payment tool still re-checks the order against what the user reviewed, because the token alone “doesn’t enforce cart details”.
The token
Here’s the smallest version I’d build for the offboarding agent. It’s illustrative code. Two parts: an approval service that signs, and an executor that verifies. The model is in neither.
import { createHash, createHmac, randomUUID, timingSafeEqual } from "node:crypto";
type Call = { agentId: string; runId: string; tool: string; args: unknown };
type Approval = {
digest: string; // sha256 of the canonical call
approver: string; // user ID from the approval UI's own session
expiresAt: number; // epoch ms
nonce: string; // single use
sig: string; // HMAC over the fields above
};
// Sorted keys, so the same call always produces the same bytes.
function canonical(v: unknown): string {
if (Array.isArray(v)) return `[${v.map(canonical).join(",")}]`;
if (v && typeof v === "object") {
const o = v as Record<string, unknown>;
const fields = Object.keys(o).sort().map((k) => `${JSON.stringify(k)}:${canonical(o[k])}`);
return `{${fields.join(",")}}`;
}
return JSON.stringify(v);
}
const digestOf = (c: Call) => createHash("sha256").update(canonical(c)).digest("hex");
const sign = (body: Omit<Approval, "sig">, key: Buffer) =>
createHmac("sha256", key).update(canonical(body)).digest("hex");
// Approval service. Called by the UI handler after a person clicks Approve.
export function issue(call: Call, approver: string, requester: string, key: Buffer): Approval {
if (approver === requester) throw new Error("maker cannot be checker");
const body = {
digest: digestOf(call),
approver,
expiresAt: Date.now() + 10 * 60_000,
nonce: randomUUID(),
};
return { ...body, sig: sign(body, key) };
}
The digest covers the agent, the run, the tool name and every argument. Change any of them and it’s a different call.
type Effect = "read" | "reversible" | "irreversible";
type Deps = {
key: Buffer;
effects: Record<string, Effect>;
findApproval(digest: string): Promise<Approval | undefined>;
consumeNonce(nonce: string): Promise<boolean>; // atomic; false if already used
policyAllows(call: Call): Promise<boolean>;
};
async function check(call: Call, deps: Deps): Promise<{ ok: true } | { ok: false; reason: string }> {
try {
const effect = deps.effects[call.tool] ?? "irreversible"; // unknown tool: assume the worst
if (effect !== "irreversible") return { ok: true };
const digest = digestOf(call);
const approval = await deps.findApproval(digest); // the harness looks it up; the model never holds it
if (!approval) return { ok: false, reason: "no approval for this exact call" };
const { sig, ...body } = approval;
const want = Buffer.from(sign(body, deps.key), "hex");
const got = Buffer.from(sig, "hex");
if (got.length !== want.length || !timingSafeEqual(got, want)) return { ok: false, reason: "bad signature" };
if (body.digest !== digest) return { ok: false, reason: "approval is for a different call" };
if (body.expiresAt < Date.now()) return { ok: false, reason: "expired" };
if (!(await deps.policyAllows(call))) return { ok: false, reason: "policy no longer allows it" };
if (!(await deps.consumeNonce(body.nonce))) return { ok: false, reason: "already used" };
return { ok: true };
} catch {
return { ok: false, reason: "approval check failed" };
}
}
A few choices in there are deliberate.
The executor fetches the approval itself, by digest. The obvious alternative hands the token back to the model to include in its next tool call. That works if the signature is sound, but it puts a credential in the context window for no benefit, and it invites the model to try things with it.
policyAllows runs again at execution time, after the human said yes. Between the click and the call, the leaver’s retention hold might have been extended by legal. SEP-2848 puts this well: “an approval is an input to a new decision, not a standing grant”.
The denial reason goes back to the model as an ordinary tool result. That’s fine. It can read “no approval for this exact call” as many times as it likes, and rephrase its request as persuasively as it likes. The executor isn’t reading.
What the token doesn’t fix
It doesn’t make the approver look. If the Slack message says “Approve action?” with a green button, the manager will click it forty times a week. The approval UI has to render the arguments the digest was computed over, in plain words: whose mailbox, how many gigabytes, retention ended on which date. If what’s shown and what’s hashed can differ, the binding is decorative.
It also judges one call at a time. Five individually reasonable approved calls can add up to something nobody approved. That needs policy with memory, which this post doesn’t cover.
What I’d do on Monday
- Find every place the agent asks a question and acts on the reply. If the reply can come from a tool result, a timeout default or another model, that step has no approval.
- Give every tool an effect (
read,reversible,irreversible) in a table the executor owns, and make a missing entry meanirreversible. - Compute a digest over agent, run, tool and arguments, and store approvals against it. Check it in the executor, not in the framework’s callback.
- Take the approval from an authenticated human session, record the user ID, and reject it when approver and requester match.
- Write the denial tests first: changed argument, second use, expired, approval service unreachable, same-named tool on a different agent. Each must fail without a model in the loop.
- Pin your framework’s approval settings explicitly and search the release notes for “approval” on every upgrade. Four of the five frameworks above changed approval behaviour in one week.
- Show the approver the hashed arguments, and look at how long approvals take. A median of two seconds means nobody is reading.
Limitations
Everything in the scorecard comes from release notes, three pull requests, one draft SEP and a vendor’s sample write-up, all read on 13 October 2026. I didn’t install or run any of it, release notes are terse, and “not stated” cells may well be “yes” in the code. SEP-2848 is an unaccepted draft and may change or go nowhere. The AISI result is a simulation, and I’m using it as an illustration of a design shape, not as evidence about any approval product.
The TypeScript is unrun, uses a shared HMAC key where a real deployment would want asymmetric signatures and key rotation, and says nothing about the hard part, which is getting a busy manager to read the message before clicking the button.
Sources
- UK AI Security Institute: GPT-6 Astra performs unsanctioned supply-chain attacks in simulations (28 September 2026)
- Microsoft Agent Framework: Python 1.20.0 release notes
- Microsoft Agent Framework: pull request 8579
- OpenAI Agents SDK (Python): v0.23.0 release notes
- OpenAI Agents SDK (Python): pull request 5144
- Google ADK Python: v2.11.0 release notes
- Pydantic AI: v2.53.0 release notes
- Pydantic AI: pull request 9041 (ToolCallJudge)
- Mastra: @mastra/core 1.75.0 release notes
- Model Context Protocol: SEP-2848, Asynchronous Approval for Tool Calls (open pull request)
- Model Context Protocol: SEP-2848 draft text
- LangChain: Agents that can pay with Stripe Link (8 October 2026)
- Meta: Security and safety for AI agents, our approach with Muse (8 September 2026)