{"slug": "beagle-lesson-3-how-to-stop-an-ai-agent-from-changing-what-you-approved", "title": "Beagle Lesson #3: How to Stop an AI Agent from Changing What You Approved", "summary": "A tutorial-style lesson argues that AI agents capable of changing business records must bind each human approval to a single specific operation — actor, tool, resource, parameters and expiry — rather than storing a generic approved: true flag, citing OWASP's AI Agent Security guidance that recommends separating proposal from execution and placing a final authorization check immediately before execution. Using an invented support-ticket system, the lesson shows how an approval for closing ticket 42 in workspace A under run 17 can be swapped for ticket 99 or expanded to every ticket from the same customer before execution, and recommends rendering previews from structured action data with fields including current revision, requesting operator and a 60-second approval expiry the lesson calls an arbitrary teaching value. It also notes Microsoft's Agent Framework documentation shows approvals as responses to specific pending function calls, and warns that tool results or peer-agent claims of approval are not authenticated decisions.", "body_md": "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.\n\nYour click is genuine while the action that follows is different.\n\nThis 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.\n\nThe 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.\n\nSuppose 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.\n\nFor 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.\n\nOWASP’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](https://cheatsheetseries.owasp.org/cheatsheets/AI_Agent_Security_Cheat_Sheet.html) and [transaction authorization guidance](https://cheatsheetseries.owasp.org/cheatsheets/Transaction_Authorization_Cheat_Sheet.html).\n\nA 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.\n\nAn 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.\n\nFor 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.\n\nIn our ticket example, a useful preview would say:\n\n```\nAction: Close one support ticketWorkspace: workspace-aTicket: TICKET-42Current revision: 7Resulting status: closedRequesting operator: operator-aWorkflow: run-17Approval expires: 60 seconds after issue\n```\n\nThe 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.\n\nThe 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.\n\nMicrosoft’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](https://learn.microsoft.com/en-us/agent-framework/agents/tools/tool-approval).\n\nAn 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.\n\nThe 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.\n\nThe 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.\n\nThe 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.\n\nSave this as approval_demo.py:\n\n```\n\"\"\"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()\n```\n\nRun it with:\n\n```\npython3 approval_demo.py\n```\n\nExpected output:\n\n```\nunchanged action: ALLOWchanged target: BLOCKwrong workspace: BLOCKexpired approval: BLOCKstale resource: BLOCKrevoked access: BLOCKreplayed approval: BLOCK\n```\n\nThe 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.\n\nThis 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.\n\nA 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.\n\nThat 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.\n\nOWASP 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](https://cheatsheetseries.owasp.org/cheatsheets/Transaction_Authorization_Cheat_Sheet.html).\n\nThe 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.\n\nWorkspace 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.\n\nThere 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.\n\nOur 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.\n\nUse 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](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/).\n\nKeep 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.\n\nFor 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.\n\nStart 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.\n\nThen 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.\n\nFor 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.\n\nDo 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.\n\nInspect 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.\n\nWhere 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.\n\nThe 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.\n\nAsking 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.\n\nBatch 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.\n\nUse 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.\n\nA 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.\n\n[Beagle Lesson #3: How to Stop an AI Agent from Changing What You Approved](https://pub.towardsai.net/beagle-lesson-3-how-to-stop-an-ai-agent-from-changing-what-you-approved-e79593f6fd28) was originally published in [Towards AI](https://pub.towardsai.net) on Medium, where people are continuing the conversation by highlighting and responding to this story.", "url": "https://wpnews.pro/news/beagle-lesson-3-how-to-stop-an-ai-agent-from-changing-what-you-approved", "canonical_source": "https://pub.towardsai.net/beagle-lesson-3-how-to-stop-an-ai-agent-from-changing-what-you-approved-e79593f6fd28?source=rss----98111c9905da---4", "published_at": "2026-11-09 08:31:18+00:00", "updated_at": "2026-11-09 08:53:05.789763+00:00", "lang": "en", "topics": ["ai-agents", "ai-safety", "ai-policy"], "entities": ["OWASP", "Microsoft", "Microsoft Agent Framework"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/beagle-lesson-3-how-to-stop-an-ai-agent-from-changing-what-you-approved", "markdown": "https://wpnews.pro/news/beagle-lesson-3-how-to-stop-an-ai-agent-from-changing-what-you-approved.md", "text": "https://wpnews.pro/news/beagle-lesson-3-how-to-stop-an-ai-agent-from-changing-what-you-approved.txt", "jsonld": "https://wpnews.pro/news/beagle-lesson-3-how-to-stop-an-ai-agent-from-changing-what-you-approved.jsonld"}}