An assistant asks whether it may close a support ticket. The preview names ticket 42, explains why the issue appears resolved and offers an Approve button. You approve it. Before execution, another step replaces the target with ticket 99, or expands the operation to every ticket from the same customer.
Your click is genuine while the action that follows is different.
This is a fictional example, but it illustrates a precise failure to test in any agent that can change business records. A confirmation screen is useful only when the executor can establish which action the person authorized. A stored flag saying approved: true leaves that question unanswered.
The same check applies to publishing a draft, modifying a production setting or changing an account’s access. This lesson uses an invented support system so we can examine the permission decision without touching real records. We will bind one approval to one operation, attempt to change it, and check what happens when approval becomes stale or gets reused.
Suppose the agent’s service account is allowed to call close_ticket. That permission establishes a class of operations it can perform. It does not establish that the user asked to close every ticket the account can reach.
For our example, the task is narrower: close ticket 42 in workspace A, as part of run 17, provided the record is still the revision reviewed by the operator. Closing ticket 99 is outside that decision, even if the same API and credentials can do it.
OWASP’s AI Agent Security guidance recommends separating proposal from execution and binding high-impact approvals to the actor, tool, resource, parameters and expiry. Its transaction-authorization guidance also places a final authorization check immediately before execution. These are implementation requirements, rather than a request for the language model to interpret the user’s wishes more carefully. Sources: OWASP’s agent guidance and transaction authorization guidance.
A model can propose the operation and explain why it believes the ticket is ready. Trusted application code must decide whether the authenticated person has permission to authorize that operation, record the decision and enforce it at the point where the record changes.
An ordinary tool result is not an approval channel. A web page saying that the manager has already approved the request, or a peer agent volunteering to cover for an absent reviewer, supplies no authenticated decision from that manager. Treat such text as a claim to investigate.
For a low-impact task, a clear bounded policy may allow automatic execution. Where a human decision is required, render the preview from structured action data held by the application.
In our ticket example, a useful preview would say:
Action: Close one support ticketWorkspace: workspace-aTicket: TICKET-42Current revision: 7Resulting status: closedRequesting operator: operator-aWorkflow: run-17Approval expires: 60 seconds after issue
The 60-second period is an arbitrary teaching value, not a recommended default. Choose a lifetime that fits the operation and its exposure to change. Some workflows need a fresh review sooner; others can support longer-lived approval under a tightly defined policy.
The assistant’s explanation can appear alongside these fields. It should not replace them. A friendly label such as “finish customer cleanup” conceals whether the effect is one status update, a bulk delete or an external message.
Microsoft’s Agent Framework documentation shows approvals as responses to specific pending function calls, whose names and arguments can be displayed for review. Exact APIs vary by framework and release, but the useful integration question is stable: which stored call does this approval resume? Source: Microsoft Learn.
An approval digest, where used, helps identify exact bytes. It does not prove that the human understood the preview, that the display was honest, or that the target still belongs to the intended customer. Those are separate properties to verify. Display meaningful resource identifiers and resolve identity through trusted application data rather than an attacker-controlled label.
The following exercise needs Python 3.10 or later and uses only the standard library. It changes an in-memory ticket, makes no network requests and requires no model account.
The Action record is immutable. The approval store keeps that exact record. At execution, the gate compares the submitted action with the stored one, verifies the caller's context, checks current permission and record revision, and consumes the approval together with the local change.
The approve() method represents an already authenticated approval handler. It is deliberately called by the exercise, not exposed as a tool for the model. A real service still needs to establish who the approver is and whether that person may approve this change.
Save this as approval_demo.py:
"""Local approval-binding exercise. No network calls or real account changes."""from dataclasses import dataclassfrom secrets import token_urlsafefrom threading import Lock@dataclass(frozen=True)class Action: actor: str workspace: str workflow: str tool: str ticket: str expected_revision: int new_status: str@dataclassclass Approval: action: Action expires_at: int used: bool = Falseclass Gate: def __init__(self) -> None: self._approvals: dict[str, Approval] = {} self._lock = Lock() self.revision = 7 self.status = "open" self.permission_active = True def approve(self, action: Action, now: int) -> str: # In a service, only an authenticated approval handler may call this. # The model must not control this method or the stored approval record. if action.tool != "close_ticket" or action.new_status != "closed": raise PermissionError("Unsupported operation") if action.ticket != "TICKET-42" or action.expected_revision != self.revision: raise PermissionError("Resource is outside this exercise") token = token_urlsafe(24) with self._lock: self._approvals[token] = Approval(action, expires_at=now + 60) return token def execute(self, token: str, proposed: Action, *, actor: str, workspace: str, workflow: str, now: int) -> str: # Context arguments represent authenticated server-side context. # The lock covers validation, consuming approval, and the local effect. with self._lock: approval = self._approvals.get(token) if approval is None or approval.used or now >= approval.expires_at: raise PermissionError("Missing, consumed, or expired approval") if proposed != approval.action: raise PermissionError("Action changed after approval") action = approval.action if (actor, workspace, workflow) != ( action.actor, action.workspace, action.workflow ): raise PermissionError("Approval belongs to another context") if not self.permission_active: raise PermissionError("Current authorization has been revoked") if self.revision != action.expected_revision: raise PermissionError("Resource changed; review again") approval.used = True self.status = action.new_status self.revision += 1 return self.statusdef original() -> Action: return Action("operator-a", "workspace-a", "run-17", "close_ticket", "TICKET-42", 7, "closed")def main() -> None: from dataclasses import replace for label in ("unchanged action", "changed target", "wrong workspace", "expired approval", "stale resource", "revoked access", "replayed approval"): gate = Gate() action = original() token = gate.approve(action, now=100) args = dict(actor=action.actor, workspace=action.workspace, workflow=action.workflow, now=110) proposed = action if label == "changed target": proposed = replace(action, ticket="TICKET-99") elif label == "wrong workspace": args["workspace"] = "workspace-b" elif label == "expired approval": args["now"] = 160 elif label == "stale resource": gate.revision = 8 elif label == "revoked access": gate.permission_active = False elif label == "replayed approval": gate.execute(token, action, **args) try: gate.execute(token, proposed, **args) result = "ALLOW" except PermissionError: result = "BLOCK" expected = "ALLOW" if label == "unchanged action" else "BLOCK" if result != expected: raise AssertionError(f"Unexpected decision: {label}") print(f"{label}: {result}")if __name__ == "__main__": main()
Run it with:
python3 approval_demo.py
Expected output:
unchanged action: ALLOWchanged target: BLOCKwrong workspace: BLOCKexpired approval: BLOCKstale resource: BLOCKrevoked access: BLOCKreplayed approval: BLOCK
The gate allows the original call and rejects the altered conditions. The lock prevents two threads in this one process from both consuming the same approval. The included test file also submits 16 concurrent attempts and checks that only one changes the ticket.
This is a teaching model of one local boundary. It is not a production approval service, a distributed lock, a database transaction or an authentication mechanism. An agent with direct access to the real ticket API could bypass it. The gate must be on the only authorized execution path for the operation it protects.
A record may change while someone is deciding what to approve. In the example, revision 7 was reviewed. If a colleague adds a new unresolved issue and the record becomes revision 8, the old approval should not silently apply to the new state.
That decision is a deliberate policy for this exercise. Some real operations remain valid across particular updates. Define which changes matter and encode that rule; do not let the model decide after the fact that the difference is harmless.
OWASP describes this general class as a time-of-check/time-of-use problem and recommends invalidating authorization when relevant transaction data changes. Its guidance also requires limited validity and controls against reuse. Source: Transaction Authorization Cheat Sheet.
The pending operation itself can also change. Normalize and validate arguments before previewing them, then store the normalized operation. Avoid a flow that displays one interpretation but executes a separately parsed string. For example, approving “archive folder reports” should not leave the executor free to resolve a different path after the approval.
Workspace and workflow identifiers matter too. An approval belonging to one tenant should fail in another tenant, and the permission from a completed run should not become a reusable capability for future runs. The example checks both fields using trusted caller context. Letting the model supply that context as an unverified claim would remove the protection.
There are two different reasons the same operation may appear twice. Someone may reuse an old approval to cause another effect, or a legitimate client may retry because it never received the response to the first attempt.
Our exercise rejects every second use of the approval. That establishes one-time consumption, but it does not solve distributed retries. In a real system, the business update might complete just before the worker loses its connection. The client now has an uncertain outcome.
Use the receiving service’s idempotency mechanism where available. An idempotency key identifies a logical operation so repeated delivery can return the same result rather than performing the operation again. The server must handle reuse with conflicting arguments explicitly. AWS’s first-party guidance describes this distinction and the role of caller-provided request identifiers. Source: Making retries safe with idempotent APIs.
Keep an execution record for the logical operation: pending, dispatched, confirmed, failed or outcome unknown. After an ambiguous timeout, reconcile that record with the receiving system before issuing a new operation. A fresh approval token does not tell you whether the previous operation already happened.
For a database-backed workflow, authorization state, approval consumption and the protected local update need an appropriate transaction boundary. External side effects require additional delivery and reconciliation design. Do not describe a Python lock as providing exactly-once execution across a network.
Start with a disposable workspace containing two fictional tickets. Give the agent a task that names only the first ticket. Confirm that the ordinary approved path works and that a rejected request leaves both records unchanged.
Then vary one property at a time. Change the target after approval. Switch the workspace. Resume under another workflow. Advance the record’s revision. Revoke the operator’s permission. Allow approval to expire. Finally, send the same approval twice, including concurrently.
For each attempt, compare the action shown to the operator, the stored approval, the executor’s decision and the actual record change. A blocked tool call is useful evidence only when the business record did not change through another route.
Do not stop at the interface. Send the forbidden call directly to the test executor, without asking the model. This distinguishes model cooperation from application enforcement. If the model always declines the prompt but the executor accepts an altered approval, the control is still missing.
Inspect alternative tools as well. A shell, browser session or broadly privileged connector may reach the same backend. Your application should not protect close_ticket while leaving a generic request tool able to submit the equivalent update without a check.
Where approvals can be delegated, test the actual delegation record. Permission to assist with a workflow is not automatically permission to become its reviewer. Require the configured reviewer or an explicit authorized substitute, with the same action and resource binding.
The absence of a tool trace or an authoritative record view is a visibility gap. Label that result inconclusive. A reassuring answer in the chat cannot fill it.
Asking for approval before every harmless read can train an operator to dismiss prompts. Separate routine actions covered by a narrow policy from changes that need an explicit decision. The policy should name the resource scope, permitted operation and any limits, rather than granting a general “do whatever is necessary” exception.
Batch approval can still be precise. Present an immutable set of known actions, their targets and aggregate effect. A proposed action added later belongs to a new decision. If a batch can partially succeed, show which items completed and which require reconciliation before anyone retries the batch.
Use the platform’s existing authorization, approval and audit features where they fit. The small program above exists to expose the requirements, not to replace a supported identity provider or workflow engine. Check the installed release’s behavior with an integration test instead of relying on a feature name.
A useful regression test ends with an ordinary result: ticket 42 closes once, ticket 99 remains unchanged, and no approval from another workspace or earlier run can authorize either change. Keep that test beside the tool integration, so a later framework update has to preserve the boundary.
Beagle Lesson #3: How to Stop an AI Agent from Changing What You Approved was originally published in Towards AI on Medium, where people are continuing the conversation by highlighting and responding to this story.