In short

Never let model output decide whether a tool runs. A gateway takes the user and tenant from the session, validates arguments against a closed schema and checks current permission on every call. For consequential actions, store an approval bound to the exact arguments and resource version, consume it and create the operation in one transaction, and send the operation ID to the provider as an idempotency key so a retry cannot repeat the effect.

  • Excessive Agency is LLM03:2026 and Tool Misuse and Exploitation is ASI02:2026: risk grows with every permission, tool and autonomous step an agent holds.
  • Schema validity is not permission. A well-formed invoice ID can still name another customer’s invoice.
  • An approval binds the user, tenant, tool, resource version and every material argument, plus an expiry; any change needs a new decision.
  • Reading a consumed flag and then updating it races. Consume the approval and create the operation in one atomic statement or transaction.
  • A unique local row stops duplicate dispatch, but only a provider idempotency key or reconciliation stops a duplicate external effect after a crash.
  • Approval screens must show the exact arguments the gateway binds; a model-written summary can hide a changed recipient (ASI09:2026).

Verify it

  1. Replay the exact approved call twice and confirm one operation row and one provider charge.
  2. Change the amount, recipient or invoice version and confirm denial before any provider call.
  3. Revoke the user’s permission after approval and confirm execution is denied.
  4. Run eight workers against the same approval at once and confirm exactly one operation exists.
  5. Stop the worker after the provider call but before the result is stored, restart it, and confirm the retry reuses the same idempotency key.
Applies to

Tool gateways and agent actions. A Boolean policy function does not implement atomic approval consumption or execution.

Part of
Learning path: Secure an AI application (step 4 of 11). Workflows: Fix a finding, Secure a feature, Review a design

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 18 minutes: 6 to read and 12 for the exercise

What you will learn

  • Bind approval to immutable action details and current permission
  • Find the race in a check-then-consume approval
  • Distinguish retry identity from a new authorized payment

Before you start

Application, platform and software security engineers

  • Distinguish model proposals from user authority, approved parameters and durable side effects.

Refresh first: Authorization models: RBAC, ABAC, ReBAC and policy as code you can test

Go to the guided practice

Model output crosses a decision boundary

Untrusted outputAuthority is enforced hereUserModelTool gatewayPayment servicePay invoice I71Proposed call: pay I7,5000, V11Resolve user,tenant, tool andscope2Show recipient V1 and 5000 cents3Approve A7 for these exact arguments3Claim A7 andcreate operationP7 atomically3Execute P7 with itsidempotency key4Receipt, kept forretries4Trust boundaryUntrusted input1Step, described below
  1. Model proposalTreat the suggested tool name and arguments as untrusted input.
  2. Policy gatewayResolve the real actor, permitted tool and exact resource scope.
  3. Bound approvalBind consent to canonical arguments; atomically claim it with a durable work intent.
  4. Execution and receiptRecheck revocation, use an idempotency key, and retain the result across retries.
The model cannot grant itself authority. The local agent project implements transaction and retry behavior that a boolean permission exercise cannot establish.

A proposed action is not a permission grant

A model can propose a tool call because of a user request, a retrieved document or an attacker-controlled response (see /domains/ai-emerging/llm-security/prompt-injection-boundaries/). The tool gateway must decide independently whether the authenticated user and agent may perform it. A system prompt saying “only act when authorized” cannot substitute for that decision. The OWASP Top 10 for LLM Applications 2026 ranks this Excessive Agency (LLM03:2026), and the Agentic Top 10 calls the same failure Tool Misuse and Exploitation (ASI02:2026).

List each capability by its actual effects: reading records, sending information, modifying state, spending money or executing code. Separate narrow operations instead of giving one generic shell or unrestricted HTTP tool enough authority to do all of them. The model must not choose its own authenticated principal or expand its credential scope.

Worked payment: approval is a record, not a sentence

The user approves payment P7 for invoice I7: 5000 cents to recipient V1, under invoice version 4. Store those details with the authenticated user, tenant and expiry. A later proposal for 9000 cents or recipient V2 is a different action. Comparing only the tool name or invoice ID would let a material change pass under the old approval.

Current authorization remains necessary. If the user loses payment authority after approval but before execution, the old approval must not override that revocation. The code below puts every condition in one UPDATE ... RETURNING statement inside a BEGIN IMMEDIATE transaction: the approval must be unconsumed and unexpired, every argument and the invoice version must match, and the user must still hold the pay permission. Only if that statement changes a row does the transaction insert the operation. Two workers that both read consumed=false separately could otherwise both execute.

The local transaction guarantees at most one authorized operation, but the external payment needs its own idempotency. A crash after the provider commits and before the worker stores the result is not solved by asking the model to avoid retries. Send the operation ID as the provider’s idempotency key, for example an Idempotency-Key header, and reconcile ambiguous outcomes by querying the provider with the same key. Approval, concurrency control and external idempotency solve related but different problems.

Keep the approval display trustworthy: it must show the same target and values the gateway binds. A model-generated summary saying “pay the approved invoice” can conceal a changed recipient. The execution service loads authoritative values instead of accepting a model-selected user or tenant field.

Fixedapprovals.pyPython
import sqlite3
import uuid

SCHEMA = """
CREATE TABLE permissions (
  user_id TEXT, tenant TEXT, action TEXT, PRIMARY KEY (user_id, tenant, action)
);
CREATE TABLE approvals (
  id TEXT PRIMARY KEY, user_id TEXT NOT NULL, tenant TEXT NOT NULL,
  invoice_id TEXT NOT NULL, invoice_version INTEGER NOT NULL,
  amount_cents INTEGER NOT NULL, recipient TEXT NOT NULL,
  expires_at INTEGER NOT NULL, consumed_at INTEGER
);
CREATE TABLE operations (
  id TEXT PRIMARY KEY, approval_id TEXT NOT NULL UNIQUE, status TEXT NOT NULL
);
"""


def claim_payment(db, call, now):
    """Consume approval and create the operation in one transaction.

    `call` holds the authenticated user and tenant from the session plus the
    proposed arguments.
    Returns the operation id, which is also the provider's idempotency key, or None.
    """
    db.execute("BEGIN IMMEDIATE")  # take the write lock before reading the approval
    try:
        row = db.execute(
            """UPDATE approvals SET consumed_at = :now
               WHERE id = :approval_id AND consumed_at IS NULL AND expires_at > :now
                 AND user_id = :user_id AND tenant = :tenant
                 AND invoice_id = :invoice_id AND invoice_version = :invoice_version
                 AND amount_cents = :amount_cents AND recipient = :recipient
                 AND EXISTS (SELECT 1 FROM permissions
                             WHERE user_id = :user_id AND tenant = :tenant
                               AND action = 'pay')
               RETURNING id""",
            {**call, "now": now},
        ).fetchone()
        if row is None:
            db.execute("ROLLBACK")
            return None  # changed, expired, revoked, foreign or already used: no effect
        operation_id = str(uuid.uuid4())
        db.execute(
            "INSERT INTO operations (id, approval_id, status) VALUES (?, ?, 'pending')",
            (operation_id, call["approval_id"]),
        )
        db.execute("COMMIT")
        return operation_id
    except BaseException:
        db.execute("ROLLBACK")
        raise


def connect(path):
    # isolation_level=None: the code above issues BEGIN, COMMIT and ROLLBACK itself.
    return sqlite3.connect(path, isolation_level=None, timeout=10)

Build a deterministic execution contract

Derive caller identity from the trusted request channel and validate tool arguments against a closed schema that rejects unknown fields. Then perform resource and action authorization against current application state. Schema validity establishes shape, not permission: a well-formed invoice identifier may still name another customer’s invoice.

Use scoped credentials and fixed destination policies in the execution service. For delegated tasks, preserve both user and workload limits. Do not let a tool response, downloaded file or agent-to-agent message supply a new authority grant. Record the proposed action, policy decision and resulting effect with sensitive fields redacted.

Bind consent to the action that will run

For operations requiring user approval, show the concrete target, arguments and consequences through a trustworthy interface. Store an approval bound to that action, authenticated user, relevant resource version and expiry. If arguments or material state change, obtain a new decision instead of reusing approval for the old proposal.

Address duplicate delivery and time-of-check/time-of-use changes. Use idempotency keys and transaction-level checks appropriate to the operation. A user approving one payment does not authorize repeated payments after retries, and an approval recorded before account access is revoked should not override the latest policy.

Make approvals hard to exploit

An approval is only as good as what the approver sees. People tend to trust fluent, confident agents, and the OWASP Top 10 for Agentic Applications 2026 lists the abuse of that trust as Human-Agent Trust Exploitation (ASI09): an attacker, or a misaligned agent, steers a person into approving an action or disclosing information they would refuse if they saw the real request, and the person's approval then hides the agent's part in the audit trail.

Render the approval from the stored action, not from the model's explanation: the exact recipient, amount, account, record, diff or command, and the data that will leave. Mark what the agent wrote as agent-generated and show where each input came from. Separate preview from effect, so nothing changes state while the person is still looking. For high-risk actions, ask for independent confirmation, such as re-authentication, a second approver or a check against a record the agent did not supply. Keep approvals rare enough to mean something: let low-risk actions run under policy and send only consequential ones to a person, because a stream of routine prompts trains people to click through. Give users a plain way to report an agent that pressured or misled them, log every request and decision where the agent cannot change it, and compare each agent's actions with its approved workflow so unusual detours stand out. These follow OWASP's ASI09 mitigations.

Test effects under adversarial proposals

Feed the gateway controlled tool-call fixtures without depending on model behavior. Test allowed actions, another tenant’s resource, changed approved arguments, expired approval, replay and a revoked user. Inspect the resulting ledger or datastore. A refusal sentence is irrelevant if the tool still executed, while a denial-only implementation is incomplete if it breaks every legitimate action. For an agent that keeps proposing calls after denials, the kill switches and run limits in /domains/ai-emerging/agent-security/agent-failure-containment/ stop it.

Why signed approvals and model-supplied tenants still fail

A signed approval token can establish integrity of its contents while still being replayable. Integrity, expiry, audience and one-time state are separate properties. Decide whether the operation permits repeated use; for this example it does not.

A gateway that checks tenant from the model’s JSON lets the caller choose the security context it is supposed to constrain. Derive identity and session authority from the trusted channel, and validate resource ownership against authoritative state. Reject extra fields rather than silently accepting a model-supplied principal override.

Place the gateway where agent standards expect it

The OWASP Agent Control Standard (specification v0.1.0, announced by OWASP on 1 September 2026) defines hook methods such as steps/toolCallRequest and steps/toolCallResult, where a separate policy component can allow, deny or modify a proposed call and inspect its result. It is an early specification: use it to place your checks consistently across agent frameworks, not as evidence that they are sufficient. Its reference Guardian proceeds when it cannot reach a decision unless you set ACS_ON_DECISION_FAILURE=deny, so configure it to fail closed.

The checks in this guide map to ASVS v5.0.0 as follows: authorization enforced at a trusted service layer (8.3.1), permission changes applied immediately (8.3.2) and check-and-act performed as one atomic operation (15.4.2). The agent tool gateway lab (/labs/agent-tool-gateway/) and the agent-tool-boundaries project on this page implement the same binding and crash-recovery behavior.

Check the result against concrete cases

Each row is one proposal against approval A7 and the ledger state that must follow it. The Agent tool gateway lab on this page has you repair a gateway until the same cases pass.

Check the result against concrete cases
CaseExpected resultWhat it establishes
Exact call by currently authorized U1One durable P7 operationRequired payment succeeds once
Amount changed to 9000Deny before provider callApproval binds material values
Invoice version changes to 5Require fresh decisionApproval does not silently survive material state change
U1 permission revokedDenyCurrent policy controls execution
Two simultaneous exact callsAt most one P7 createdApproval consumption is atomic

Guided practice

Evaluate the proposals and then interleave two workers. You can complete this exercise from the state record and timeline.

Supplied scenario material
Plain text
Approval A7: user U1, tenant North, action pay, invoice I7 version 4, 5000 cents to V1, operation P7, expires 15:00, consumed=false.
Policy: current payment permission required; exact argument and version match; one operation per approval. Time now 14:00.
C1 exact A7 arguments, U1 authorized.
C2 exact except recipient=V2.
C3 exact after permission revoked.
Race: W1 reads consumed=false; W2 reads consumed=false; W1 calls provider; W2 calls provider; both set consumed=true.
Proposed repair: transaction conditionally consumes A7 and inserts unique operation P7; worker dispatches P7 with stable provider key P7.
  1. 1. Evaluate the calls

    Decide C1–C3 and state which evidence the gateway must trust.

    Expected result and reasoning

    C1 can proceed if the transaction acquires the approval. C2 fails recipient binding. C3 fails current permission. Identity and authoritative invoice/permission state come from the application, not the proposal’s self-declared fields.

  2. 2. Locate the concurrency defect

    Explain why consumed=true at the end does not show one execution.

    Expected result and reasoning

    Both workers observed the same old false value and performed the effect before either update controlled the other. The final Boolean hides two calls. The condition and operation creation must be coordinated atomically.

  3. 3. Separate local and remote guarantees

    Explain what the proposed transaction fixes and what provider behavior is still needed.

    Expected result and reasoning

    It permits at most one local P7 operation under A7. A provider commit followed by worker crash still needs stable provider deduplication/query semantics and reconciliation; a local unique row alone cannot undo or uniquely identify an unrelated remote effect.

  4. 4. Choose assertions

    State what to inspect after exact, changed, revoked and duplicate proposals.

    Expected result and reasoning

    Inspect durable operations and provider test ledger. Exact authorized input creates one P7; changed and revoked inputs create none; duplicates resolve to the same P7 outcome without another logical charge. A refusal response is not the assertion.

Your deliverable: A verdict for C1–C3, an explanation of the race, and the database and provider assertions that would prove at most one payment.

The approval, workers and provider are synthetic. The exercise covers one approval and one provider; it does not test multi-step plans or delegated agents.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Repair the code

Excessive agency lab: Bind an agent’s action to the approved arguments

Run a local project

Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.

Python 3.11+ · standard library · 60–90 min

Enforce retrieval and tool boundaries across crashes

Repair a two-tenant retrieval application and SQLite approval/outbox workflow. Prevent cross-tenant context, bind exact approvals, revoke queued work and recover a committed side effect without duplication.

In the extracted folder, run:

Shell
python3 demo.py
python3 -m unittest -v test_boundaries.py

25 regression tests · The starter intentionally fails some cases.

Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.

Real SQLite transactions and independent durable provider receipts; deterministic ranker and trusted test identities. No model, MCP implementation or external effects.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. OWASP AI Agent Security Cheat Sheet
  2. OWASP Top 10 for LLM Applications 2026 (LLM03 Excessive Agency). 2026 edition (3 August 2026).
  3. OWASP LLM06:2025 Excessive Agency (2025 list; LLM03 in 2026). 2025 edition.
  4. OWASP Top 10 for Agentic Applications 2026 (ASI02, ASI03, ASI09). Version 2026 (December 2025).
  5. OWASP Agent Control Standard (v0.1.0). 1 September 2026.
  6. OWASP Authorization Cheat Sheet

Reference information

ASVS 5.0.0 requirements
v5.0.0-8.3.1 · v5.0.0-8.3.2 · v5.0.0-15.4.2

Download this guide as Markdown