§ Writing12 min read

Approval is a capability, not a question

Human approval only counts if the agent can't talk its way past it. Four properties, a scorecard of October's framework changes, and a token bound to the call.

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

  1. 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.
  2. Give every tool an effect (read, reversible, irreversible) in a table the executor owns, and make a missing entry mean irreversible.
  3. 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.
  4. Take the approval from an authenticated human session, record the user ID, and reject it when approver and requester match.
  5. 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.
  6. 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.
  7. 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


  1. 11 min read

    The prompt said no internet: what four months of agent escapes teach builders

    Between July and October 2026, labs reported agents crossing boundaries that existed only as text. Each failure maps to a software control you can build.


  2. 12 min read

    Threat modelling a tool-using agent in one afternoon, before it ships

    A practical threat model for an LLM agent with tools and MCP servers: draw the boundaries, walk the threats, put each control in code, then red-team it.


  3. 14 min read

    A permission matrix for an invoice-matching agent

    How to decide, write down and enforce in code what a payment-matching agent may do, from reading invoices to never touching vendor bank details.