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).
| Setting | Value | Note | Source |
|---|---|---|---|
| Approval binding | User, tenant, tool, resource ID and version, every material argument, expiry | — | OWASP AI Agent Security Cheat Sheet |
| Approval use | Single use, consumed in the same transaction that creates the operation | — | ASVS v5.0.0-15.4.2 (check and act as one atomic operation) |
| Permission check | At execution time, against current state; revocation applies immediately | — | ASVS v5.0.0-8.3.2 |
| Provider idempotency key | The operation ID, reused on every retry of that operation | Reconcile ambiguous outcomes instead of retrying with a new key. | — |
| Model-supplied arguments | Closed schema; reject unknown fields and any user, tenant or role field | — | — |
- Replay the exact approved call twice and confirm one operation row and one provider charge.
- Change the amount, recipient or invoice version and confirm denial before any provider call.
- Revoke the user’s permission after approval and confirm execution is denied.
- Run eight workers against the same approval at once and confirm exactly one operation exists.
- 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
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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 practiceModel output crosses a decision boundary
- Model proposalTreat the suggested tool name and arguments as untrusted input.
- Policy gatewayResolve the real actor, permitted tool and exact resource scope.
- Bound approvalBind consent to canonical arguments; atomically claim it with a durable work intent.
- Execution and receiptRecheck revocation, use an idempotency key, and retain the result across retries.
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.
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.
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)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.
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.
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.
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.
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.
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.
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.
| Case | Expected result | What it establishes |
|---|---|---|
| Exact call by currently authorized U1 | One durable P7 operation | Required payment succeeds once |
| Amount changed to 9000 | Deny before provider call | Approval binds material values |
| Invoice version changes to 5 | Require fresh decision | Approval does not silently survive material state change |
| U1 permission revoked | Deny | Current policy controls execution |
| Two simultaneous exact calls | At most one P7 created | Approval 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
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. 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. Locate the concurrency defect
Explain why
consumed=trueat 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. 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. 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.
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.
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:
python3 demo.py
python3 -m unittest -v test_boundaries.py25 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.
- OWASP AI Agent Security Cheat Sheet
- OWASP Top 10 for LLM Applications 2026 (LLM03 Excessive Agency). 2026 edition (3 August 2026).
- OWASP LLM06:2025 Excessive Agency (2025 list; LLM03 in 2026). 2025 edition.
- OWASP Top 10 for Agentic Applications 2026 (ASI02, ASI03, ASI09). Version 2026 (December 2025).
- OWASP Agent Control Standard (v0.1.0). 1 September 2026.
- OWASP Authorization Cheat Sheet
Reference information
- SDLC phase
- DesignDevelopment
- ASVS 5.0.0 requirements
- v5.0.0-8.3.1 · v5.0.0-8.3.2 · v5.0.0-15.4.2