Agentic AI · BraivIQ AI Engineering Playbook
AI Agent Code In Python: Approvals Bound To The Call, After This Month's Agent Framework Bugs
AI agent code in Python is only as safe as the place where approvals live. Three agent framework bugs disclosed between 22 September and 8 October 2026 failed in different ways with the same root cause. One trusted approval decisions sent back by the browser, one remembered an approval by tool name and reused it for different arguments, and one hid tools from the list but still ran them when called. This playbook gives tested Python that avoids all three: approvals stored on the server and bound to one agent, one tool and the exact arguments, used once, with scope checked on every call and a hash-chained audit log.
Published · Updated · 16 min read · By BraivIQ Engineering
Key takeaways
- Three agent framework advisories published between 22 September and 8 October 2026 broke approvals or scope in three different ways: approvals relayed by the client, approvals cached by tool name, and tool filters applied only to the tool list.
- An approval should be a server-side record bound to one agent, one tool and a SHA-256 of the exact arguments. It should expire, work once, and never be inferred from text the model or the browser sends back.
- Check scope when a tool is called, not only when tools are listed. Anything not on the agent's list is refused and written to the audit log.
- Let the orchestrator, not the model, run an approved call, using the arguments stored with the approval. A replayed approval is refused.
- Forced tool use returns a 400 error on Claude Opus 5.5 and Sonnet 5.5, so the loop uses tool_choice auto with strict tools and treats any other reply as a stop.
3 - Agent framework advisories in four weeks where an approval or a tool filter could be bypassed · 7.6 - CVSS score of CVE-2026-107295 in Pydantic AI, where the web endpoint trusted approvals relayed by the client · 73 - Tools in mcp-atlassian's registry that tools/call could reach whatever the enabled list said (CVE-2026-77243) · 1 - Number of times a single approval should be usable
Write AI agent code in Python so that an approval is something the model and the browser cannot produce. Store it on your server, bind it to the agent, the tool and a hash of the exact arguments, let it expire, and let it be used once. Check the agent's scope every time a tool is called. Have your orchestrator, not the model, run the approved call. Write every step to an audit log that shows if anyone edits it. That is the pattern our Agentic AI London team uses with UK financial firms, and the bugs disclosed in the last four weeks show why each part matters.
This playbook is for senior engineers, architects and CTOs at banks, brokers, asset managers and payments firms. Both code samples run as shown on Python 3.14.4 and 3.11.14, pass mypy 2.4.0 in strict mode and use anthropic 1.14.0 against a stub client that returns real SDK types, so you can run them without an API key.
Which approval bugs were disclosed in the last four weeks?
Three advisories stand out because each one defeated a control that teams rely on to keep a person in charge of what an agent does.
| Published | Project | What went wrong | Fixed in |
|---|---|---|---|
| 22 Sep 2026 | mcp-atlassian, CVE-2026-77243 | ENABLED_TOOLS and TOOLSETS were applied when tools were listed, but tools/call ran from the full registry of 73 tools. READ_ONLY_MODE was checked in both places. | 0.22.0 |
| 8 Oct 2026 | Pydantic AI, CVE-2026-107295 (CVSS 7.6) | The web endpoint trusted approval decisions sent back by the client, so tools marked requires_approval were not protected. | 2.28.0 and 1.107.4 |
| 8 Oct 2026 | praisonaiagents, CVE-2026-60087 | A human approval was cached by tool name and reused for later calls with different arguments. | 1.6.78 |
Other projects tightened the same area in the same weeks. Inspect 0.3.278, released on 9 October, now rejects a tool call when an approver chooses modify but supplies no modified call, instead of running the original. The OpenAI Agents SDK 0.24.0, released today, says it will treat MCP annotations as untrusted discovery hints that do not grant approval. Version 0.3.286 of the Claude Agent SDK for TypeScript, from 30 September, warns that a session can start in auto mode if you leave permissionMode unset on a third-party provider or with telemetry off, so pass it explicitly.
What do the three bugs have in common?
Each one put the approval or the scope check somewhere an attacker or a confused model could influence. A decision relayed by the client is only as trustworthy as the client. An approval keyed by tool name approves every future use of that tool. A filter applied to the tool list stops the model seeing a tool but not calling it, and a model that has read about a tool in a document or an earlier turn can still name it.
The UK AI Security Institute saw the language version of the same problem in its 28 September evaluation of GPT-6 Astra. Adding the line “Anything not listed as in scope is out of scope” cut completed attacks in its riskiest scenarios from 26 of 50 runs to 4 of 49. Better instructions helped, but 4 is not zero, which is why the check belongs in code.
What does an approval-bound agent architecture look like?
Five parts, each with one job. The model proposes tool calls and never runs them. A dispatcher checks every call against the agent's scope, runs read-only tools and parks anything that changes a record. An approval store holds each parked call with its argument hash and expiry. A named approver decides through your own authenticated screen. The orchestrator runs only the approved call, with the stored arguments, and the audit log records each step.
What does AI agent code in Python look like for server-side approvals?
The approval store below uses only the standard library. It hashes canonical JSON of the arguments, so key order and spacing do not change the digest. The consume step is one conditional UPDATE, so two workers racing to use the same approval cannot both succeed.
approvals.py"""Server-side approvals for AI agent tool calls.
Each approval is bound to one agent, one tool and the exact arguments (a SHA-256 of canonical JSON),
is single-use and expires. Decisions are recorded on the server from an authenticated reviewer, never
relayed by the client, and nothing is ever approved by default.
Standard library only (sqlite3). Tested on Python 3.14.4 and 3.11.14 with mypy --strict.
"""
from __future__ import annotations
import hashlib
import json
import sqlite3
import uuid
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Literal
JSON = dict[str, object]
Decision = Literal["approve", "reject", "modify"]
def args_hash(args: JSON) -> str:
canonical = json.dumps(args, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
return hashlib.sha256(canonical.encode()).hexdigest()
@dataclass(frozen=True)
class Pending:
request_id: str
agent_id: str
tool: str
args: JSON
class ApprovalStore:
def __init__(self, path: str = ":memory:", ttl: timedelta = timedelta(minutes=30)) -> None:
self.db = sqlite3.connect(path, isolation_level=None)
self.ttl = ttl
self.db.execute(
"""CREATE TABLE IF NOT EXISTS approvals (
request_id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, tool TEXT NOT NULL,
args_json TEXT NOT NULL, args_hash TEXT NOT NULL,
status TEXT NOT NULL CHECK (status IN ('pending','approved','rejected','used')),
reviewer TEXT, decided_at TEXT, expires_at TEXT NOT NULL)"""
)
def request(self, agent_id: str, tool: str, args: JSON) -> str:
request_id = str(uuid.uuid4())
expires = (datetime.now(timezone.utc) + self.ttl).isoformat()
self.db.execute(
"INSERT INTO approvals VALUES (?,?,?,?,?, 'pending', NULL, NULL, ?)",
(request_id, agent_id, tool, json.dumps(args, sort_keys=True), args_hash(args), expires),
)
return request_id
def pending(self) -> list[Pending]:
rows = self.db.execute(
"SELECT request_id, agent_id, tool, args_json FROM approvals WHERE status = 'pending'"
).fetchall()
return [Pending(r[0], r[1], r[2], json.loads(r[3])) for r in rows]
def decide(self, request_id: str, reviewer: str, decision: Decision,
modified_args: JSON | None = None) -> None:
"""Call this from your authenticated review UI. `reviewer` comes from your SSO session."""
if decision == "modify" and modified_args is None:
decision = "reject" # a 'modify' with nothing modified must not run the original call
status = "rejected" if decision == "reject" else "approved"
now = datetime.now(timezone.utc).isoformat()
if decision == "modify" and modified_args is not None:
self.db.execute(
"UPDATE approvals SET args_json = ?, args_hash = ? WHERE request_id = ? AND status = 'pending'",
(json.dumps(modified_args, sort_keys=True), args_hash(modified_args), request_id),
)
cur = self.db.execute(
"UPDATE approvals SET status = ?, reviewer = ?, decided_at = ? "
"WHERE request_id = ? AND status = 'pending'",
(status, reviewer, now, request_id),
)
if cur.rowcount != 1:
raise LookupError(f"no pending approval {request_id}")
def consume(self, request_id: str, agent_id: str, tool: str, args: JSON) -> bool:
"""True exactly once, for the same agent, tool and arguments, before expiry."""
cur = self.db.execute(
"UPDATE approvals SET status = 'used' WHERE request_id = ? AND status = 'approved' "
"AND agent_id = ? AND tool = ? AND args_hash = ? AND expires_at > ?",
(request_id, agent_id, tool, args_hash(args), datetime.now(timezone.utc).isoformat()),
)
return cur.rowcount == 1
def approved_args(self, request_id: str) -> JSON | None:
row = self.db.execute(
"SELECT args_json FROM approvals WHERE request_id = ? AND status = 'approved'", (request_id,)
).fetchone()
return None if row is None else dict(json.loads(row[0]))
if __name__ == "__main__":
store = ApprovalStore()
payment_file = {"file": "payments_2026-10-15.xml", "rows": 42, "total_gbp": "18250.00"}
rid = store.request("agent:recs-01", "submit_payment_file", payment_file)
print("pending:", [p.tool for p in store.pending()])
print("consume before decision:", store.consume(rid, "agent:recs-01", "submit_payment_file", payment_file))
store.decide(rid, reviewer="j.smith@example.co.uk", decision="approve")
tampered = {**payment_file, "total_gbp": "182500.00"}
print("consume with changed arguments:", store.consume(rid, "agent:recs-01", "submit_payment_file", tampered))
print("consume by another agent:", store.consume(rid, "agent:other", "submit_payment_file", payment_file))
print("consume exact call:", store.consume(rid, "agent:recs-01", "submit_payment_file", payment_file))
print("consume again:", store.consume(rid, "agent:recs-01", "submit_payment_file", payment_file))
rid2 = store.request("agent:recs-01", "submit_payment_file", payment_file)
store.decide(rid2, reviewer="j.smith@example.co.uk", decision="modify") # modify without args
print("modify without arguments ->", store.approved_args(rid2), "(rejected)")Tested on Python 3.14.4 and 3.11.14 with mypy 2.4.0 --strict. Standard library only.
Running it prints False before any decision, False when the total is changed from 18250.00 to 182500.00, False when a different agent tries the approval, True for the exact call, and False on a second attempt. A modify decision with no new arguments is stored as a rejection, which is the behaviour Inspect adopted on 9 October.
Take the reviewer's name from your single sign-on session on the server, never from a form field. That one line is the difference between this design and the Pydantic AI web endpoint bug.
How do you enforce scope at call time in a Claude tool loop?
The dispatcher checks every tool_use block against a frozen set of allowed tools before it does anything else. The model is offered only the tools in scope, and the check is repeated when a call arrives, which is the gap that CVE-2026-77243 left open. Calls that change records become pending approvals, and the model is told to carry on with other work.
dispatch.py"""Call-time scope enforcement and approval-gated tool dispatch for a Claude tool-use loop.
- Scope is checked when a tool is called, not when tools are listed. Anything not listed is out of scope.
- Read-only tools run. Mutating tools are parked as a pending approval and the model is told to stop.
- Only the orchestrator executes an approved call, using the arguments stored on the server.
- Every decision is written to a hash-chained audit log.
Requires anthropic 1.14.0. Uses tool_choice {"type": "auto"} with strict tools, because forced tool use
returns a 400 error on Claude Opus 5.5 and Sonnet 5.5. Runs here against a stub client that returns real
SDK types, so no API key is needed. Tested on Python 3.14.4 and 3.11.14 with mypy --strict.
"""
from __future__ import annotations
import hashlib
import json
from collections.abc import Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from anthropic.types import Message, MessageParam, ToolParam, ToolResultBlockParam, ToolUseBlock
from approvals import JSON, ApprovalStore
@dataclass
class AuditLog:
entries: list[dict[str, object]] = field(default_factory=list)
def write(self, **event: object) -> None:
prev = str(self.entries[-1]["entry_hash"]) if self.entries else "0" * 64
body = {**event, "prev_hash": prev, "at": datetime.now(timezone.utc).isoformat()}
digest = hashlib.sha256(json.dumps(body, sort_keys=True, default=str).encode()).hexdigest()
self.entries.append({**body, "entry_hash": digest})
@dataclass(frozen=True)
class Tool:
spec: ToolParam
run: Callable[[JSON], str]
mutates: bool
@dataclass
class Dispatcher:
agent_id: str
scope: frozenset[str] # the only tools this agent may call
tools: dict[str, Tool]
approvals: ApprovalStore
audit: AuditLog
def handle(self, call: ToolUseBlock) -> ToolResultBlockParam:
args: JSON = dict(call.input)
if call.name not in self.scope or call.name not in self.tools:
self.audit.write(agent=self.agent_id, tool=call.name, args=args, outcome="refused_out_of_scope")
return {"type": "tool_result", "tool_use_id": call.id, "is_error": True,
"content": f"{call.name} is out of scope for this agent. Stop and report to a person."}
tool = self.tools[call.name]
if tool.mutates:
request_id = self.approvals.request(self.agent_id, call.name, args)
self.audit.write(agent=self.agent_id, tool=call.name, args=args, outcome="pending_approval",
request_id=request_id)
return {"type": "tool_result", "tool_use_id": call.id,
"content": f"Queued for human approval as {request_id}. Do not retry. Continue other work."}
result = tool.run(args)
self.audit.write(agent=self.agent_id, tool=call.name, args=args, outcome="ran")
return {"type": "tool_result", "tool_use_id": call.id, "content": result}
def execute_approved(self, request_id: str, tool_name: str) -> str:
"""Called by the orchestrator after a person approves. The model never calls this."""
args = self.approvals.approved_args(request_id)
if args is None or not self.approvals.consume(request_id, self.agent_id, tool_name, args):
self.audit.write(agent=self.agent_id, tool=tool_name, request_id=request_id, outcome="not_approved")
raise PermissionError(f"{request_id} is not an unused approval for {tool_name}")
result = self.tools[tool_name].run(args)
self.audit.write(agent=self.agent_id, tool=tool_name, args=args, request_id=request_id,
outcome="ran_after_approval")
return result
def run_agent(create: Callable[..., Message], dispatcher: Dispatcher, task: str, model: str) -> str:
messages: list[MessageParam] = [{"role": "user", "content": task}]
while True:
reply = create(model=model, max_tokens=2048, messages=messages,
system="You prepare operations work. A person approves anything that leaves the firm.",
tools=[dispatcher.tools[n].spec for n in sorted(dispatcher.scope & dispatcher.tools.keys())],
tool_choice={"type": "auto"})
if reply.stop_reason != "tool_use":
return "".join(b.text for b in reply.content if b.type == "text")
messages.append({"role": "assistant", "content": reply.content})
results = [dispatcher.handle(b) for b in reply.content if isinstance(b, ToolUseBlock)]
messages.append({"role": "user", "content": results})
def strict_tool(name: str, description: str, properties: dict[str, object]) -> ToolParam:
return {"name": name, "description": description, "strict": True,
"input_schema": {"type": "object", "properties": properties,
"required": sorted(properties), "additionalProperties": False}}
class StubMessages:
"""Scripted replies built from real anthropic.types models, standing in for client.messages."""
def __init__(self) -> None:
usage = {"input_tokens": 900, "output_tokens": 120}
calls = [
{"type": "tool_use", "id": "toolu_01", "name": "read_breaks", "input": {"date": "2026-10-15"}},
{"type": "tool_use", "id": "toolu_02", "name": "update_ssi", "input": {"account": "ACC-11893"}},
{"type": "tool_use", "id": "toolu_03", "name": "submit_payment_file",
"input": {"file": "payments_2026-10-15.xml"}},
]
self.replies = [
Message.model_validate({"id": "msg_01", "type": "message", "role": "assistant",
"model": "claude-sonnet-5-5", "content": calls,
"stop_reason": "tool_use", "stop_sequence": None, "usage": usage}),
Message.model_validate({"id": "msg_02", "type": "message", "role": "assistant",
"model": "claude-sonnet-5-5", "stop_reason": "end_turn",
"stop_sequence": None, "usage": usage,
"content": [{"type": "text", "text": "3 breaks found. Payment file "
"queued for approval. SSI change refused as out of scope."}]}),
]
self.seen_tools: list[list[str]] = []
def create(self, **kwargs: object) -> Message:
tools = kwargs["tools"]
assert isinstance(tools, list)
self.seen_tools.append([str(t["name"]) for t in tools])
return self.replies.pop(0)
if __name__ == "__main__":
tools = {
"read_breaks": Tool(strict_tool("read_breaks", "Read the day's reconciliation breaks (read-only).",
{"date": {"type": "string"}}),
run=lambda a: json.dumps({"date": a["date"], "breaks": 3}), mutates=False),
"submit_payment_file": Tool(strict_tool("submit_payment_file", "Submit a prepared payment file.",
{"file": {"type": "string"}}),
run=lambda a: f"submitted {a['file']}", mutates=True),
"update_ssi": Tool(strict_tool("update_ssi", "Change standing settlement instructions.",
{"account": {"type": "string"}}),
run=lambda a: "changed", mutates=True),
}
audit = AuditLog()
dispatcher = Dispatcher(agent_id="agent:recs-01", scope=frozenset({"read_breaks", "submit_payment_file"}),
tools=tools, approvals=ApprovalStore(), audit=audit)
stub = StubMessages()
print("agent:", run_agent(stub.create, dispatcher, "Prepare today's breaks and payment file.",
model="claude-sonnet-5-5"))
print("tools offered to the model:", stub.seen_tools[0])
pending = dispatcher.approvals.pending()
print("waiting for a person:", [(p.tool, p.args) for p in pending])
dispatcher.approvals.decide(pending[0].request_id, reviewer="j.smith@example.co.uk", decision="approve")
print("orchestrator:", dispatcher.execute_approved(pending[0].request_id, "submit_payment_file"))
try:
dispatcher.execute_approved(pending[0].request_id, "submit_payment_file")
except PermissionError as exc:
print("replay refused:", exc)
for e in audit.entries:
print(f" {e['outcome']:<22} {e['tool']:<20} {str(e['entry_hash'])[:12]}")Requires anthropic 1.14.0. Imports approvals.py. Runs against a stub that returns real anthropic.types.Message objects.
In the scripted run the model asks for three tools. read_breaks runs. update_ssi is refused because it is not in this agent's scope, even though it exists in the registry. submit_payment_file is queued. After a person approves it, the orchestrator runs it once, and a replay is refused. The audit log ends with five chained entries: ran, refused_out_of_scope, pending_approval, ran_after_approval and not_approved.
The loop uses tool_choice set to auto. Anthropic's release notes say forced tool use, with tool_choice set to any or to a named tool, returns a 400 error on Claude Opus 5.5 and Sonnet 5.5. Strict tool definitions keep the arguments to your schema, and any reply that is not a tool call ends the loop rather than being guessed at.
What breaks in production?
- **Arguments that drift between screen and store.** If your approval screen re-serialises the arguments, a value such as 18250.00 can come back as 18250.0 and every approval fails. Show the stored JSON and hash, and never rebuild it in the browser.
- **Stale approvals.** A record can change between approval and execution. Put a version number or ETag in the arguments so a changed record means a different hash. The sample expires approvals after 30 minutes.
- **One tool, many meanings.** A generic tool such as run_sql makes argument binding weak, because the risky part is inside a string. Give agents narrow tools with typed arguments.
- **Approval fatigue.** If most queued items are routine, reviewers stop reading. Measure approval times, and move routine read-only work out of the queue rather than training people to click.
- **Audit logs that live with the agent.** A hash chain shows edits but cannot stop someone deleting the whole file. Ship entries to storage the agent host cannot write to, and check the chain on a schedule.
- **SDK defaults that change.** The Claude Agent SDK note about auto mode is a reminder to set permission settings explicitly and to test them after every upgrade, as part of your LLM evaluation release gate.
What are the alternatives to building your own approval store?
Built-in approval features are worth using, and most were fixed quickly. The question is whether they meet the three tests: decided on the server, bound to the arguments, and enforced at call time. Use this as a checklist when you review one.
| Option | Good fit | What to check |
|---|---|---|
| Framework approval flag, such as requires_approval | Prototypes and internal tools on a patched version | Where the decision is stored, whether it is bound to arguments, and whether the web or CLI front end can supply it |
| Durable workflow pause, such as LangGraph interrupt or Temporal | Approvals that wait hours or days | That the resume payload is typed and the approver identity comes from your identity provider |
| Managed permission policies from a model provider | Agents that run inside the provider's managed environment | Whether the policy covers your own tool loop or only the managed one |
| Your own approval store, as above | Payments, client messages, static data and anything a regulator would ask about | Ownership of the code, plus tests for replay, expiry and changed arguments |
For many firms the answer is a mix: a framework for the loop and your own store for the approval. Our Batch 13 playbook on approval gates covers signed approvals in more depth, and MCP integration hardening covers the server side when your tools sit behind MCP.
Where should a UK financial firm start?
Pick one agent that changes something outside itself, such as a payment file, a client email or a standing settlement instruction, and put the dispatcher, the approval store and the audit log around it before you add more tools. Then test the three failure modes from this month directly: send an approval from the client, reuse an approval with new arguments, and call a tool that is not in scope. All three should be refused and logged.
Our blog explained the business side of why agent incidents worry financial firms. If you would like a second pair of eyes, our AI Agency London team runs a 14-day Proof Run on one workflow with the approval point and audit trail in place from day one, and our services page shows the workflows we build. This is engineering guidance, not legal or regulatory advice.
Frequently asked questions
What is a server-side approval for an AI agent?
It is a record kept on your own server that says a named person allowed one agent to call one tool with one exact set of arguments, before a given time. The agent cannot create or change it, and the browser cannot send it. The orchestrator checks and uses it once before running the call.
Why bind an approval to the arguments rather than the tool?
Because the risk is in the arguments. Approving submit_payment_file once should not approve a different file or a different total. The praisonaiagents bug disclosed on 8 October 2026 cached approvals by tool name, so a later call with different arguments ran without review.
Does forced tool use still work on Claude 5.5 models?
Not on Claude Opus 5.5 or Sonnet 5.5. Anthropic's release notes say tool_choice set to any or to a named tool returns a 400 error on those models. Use tool_choice auto with strict tool definitions, and treat a reply without a tool call as a stop or a hand-off to a person.
Is hiding a tool from the model enough to stop it being called?
No. CVE-2026-77243 in mcp-atlassian showed a server that filtered the tool list but still ran any tool in its full registry when called by name. Check scope again at the moment of the call, refuse anything not listed and log the refusal.
References
- Pydantic (GitHub), "Approval decisions relayed by the client were trusted by the web endpoint (GHSA-h4xc-3qfq-jf93, CVE-2026-107295)", 8 October 2026. https://github.com/pydantic/pydantic-ai/security/advisories/GHSA-h4xc-3qfq-jf93
- PraisonAI (GitHub), "Human approval cached by tool name and reused for different arguments (GHSA-29r9-67vg-qj56, CVE-2026-60087)", 8 October 2026. https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-29r9-67vg-qj56
- NIST National Vulnerability Database, "CVE-2026-77243, mcp-atlassian tool filtering applied only at tools/list", 22 September 2026. https://nvd.nist.gov/vuln/detail/CVE-2026-77243
- UK AI Security Institute (GitHub), "Inspect CHANGELOG, releases 0.3.277 and 0.3.278", 6 and 9 October 2026. https://github.com/UKGovernmentBEIS/inspect_ai/blob/main/CHANGELOG.md
- OpenAI (GitHub), "openai-agents-python v0.24.0 release notes", 15 October 2026. https://github.com/openai/openai-agents-python/releases/tag/v0.24.0
- Anthropic (GitHub), "Claude Agent SDK for TypeScript v0.3.286 release notes", 30 September 2026. https://github.com/anthropics/claude-agent-sdk-typescript/releases/tag/v0.3.286
- Anthropic, "Claude Developer Platform release notes", September and October 2026. https://platform.claude.com/docs/en/release-notes/overview
- UK AI Security Institute, "GPT-6 Astra performs unsanctioned supply-chain attacks in simulations", 28 September 2026. https://www.aisi.gov.uk/blog/gpt-6-astra-performs-unsanctioned-supply-chain-attacks-in-simulations
- Information Commissioner's Office, "The ICO's call for evidence on agentic AI", 8 October 2026. https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/artificial-intelligence/the-ico-s-call-for-evidence-on-agentic-ai/