Blog · · by Rafael Asor, Attenu
A delegate cannot write, present, or invoke other delegates. I ran that sentence as authority.
Anthropic's commerce-agents states its delegate contract in a docstring: a delegate "cannot write, present, or invoke other delegates". I wrote the delegate that sentence warns about, put it in the same slot, and ran it twice. First run it staged a change, presented it to the operator, and called a second delegate. Second run, with one hook on the executor's dispatch point, all three were refused before any body ran, on authority the delegate was never given.
Their own delegate does none of that, and cannot: its surface is four read tools, so there is no write for it to reach. That is the point. The delegate in that slot is trusted because of its shape, and the shape belongs to the delegate rather than to the path every delegate shares. Put a different one in the slot and the docstring is what is left. What replaces it here is authority the delegate never held, decided where every tool call already arrives, plus one file that says afterwards what was allowed and what was refused, checkable by someone who does not have their code.
Runs on 2026-09-05. commerce-agents at fd4d592, attenu-guard main at 51097fd, Python 3.12.12, their own scripted model client, no API key and no network. The recipe is on main and no release carries it yet. It first landed in the commit straight after the 0.14.0 tag, and main is five commits past that tag now, so pip install attenu-guard gets you the library and not this example.
What this gives you
- A child authority computed from the tools the delegate's own surface declares, met against the parent's, decided at
BaseToolExecutor.dispatchbefore any handler runs. - A spend ceiling that lives on the node instead of the deployment, so a child's cap can be lower than its caller's and never higher.
- One file that verifies with commerce-agents uninstalled, and that fails on a grant widened by a scope the parent never held even when the hashes and the signature are good.
The delegate the contract warns about, twice
The contract is the module docstring of commerce_common/delegation.py, lines 4 to 6. Its first half is structural and holds: a delegate's run receives a DelegationContext (delegation.py:21-31) carrying the backend, the config, the session and the state, and no executor.
The second half is held inside one delegate, twice. Its surface is ANALYSIS_READ_TOOLS plus submit, progress and query (merchant_agent_runtime/analysis.py:117-139), four names whose comment says so: "read tools only, taken from the registry so their contracts match the orchestrator's" (merchant_agent/analysis.py:30-37). Its runner then tests the name before routing (merchant_agent_runtime/analysis.py:348-375), and a name outside the reads, the SQL tool and the progress tool, a stage_* among them, comes back as Unknown tool in the analysis context.
Neither check is on the shared path and neither travels. DelegationContext carries what MerchantToolExecutor.__init__ needs (executor.py:106-119), so a delegate can build the executor its own calls go through, which is what the shipped one does at analysis.py:332-339. That object holds the whole handler table, five staging tools plus apply and discard (executor.py:145-163), and the presentation components are a class attribute (executor.py:93). My delegate does the same construction and does not stop at reads:
-- nothing installed --
read get_pending_changes -> ran [{"change_id": "chg-0001", "kind": "listing_update", "status": "staged", "summary": "R
write stage_price_update -> ran (fenced payload)
present present_change_preview -> ran Displayed to the operator.
nested note_finding -> ran (fenced payload)
side effects: staged=['chg-0002'] presented=['chg-0001'] peer_delegate_ran=True
-- attenu-guard installed --
read get_pending_changes -> ran [{"change_id": "chg-0001", "kind": "listing_update", "status": "staged", "summary": "R
write stage_price_update -> HELD[authority] That call is outside this agent's authority: denied: scope_not_granted requested=prici
present present_change_preview -> HELD[authority] That call is outside this agent's authority: denied: scope_not_granted requested=prese
nested note_finding -> HELD[authority] That call is outside this agent's authority: denied: scope_not_granted requested=deleg
side effects: staged=none presented=none peer_delegate_ran=False
That is act 3, both halves, exact bytes and nothing cut. The right-hand text is clamped at 86 characters by the demo's own printer (demo.py:448), which is why the three refusal lines stop mid-scope.
side effects is the oracle and none of its three sources is a tool result: the store's pending list, a hook around the presentation component that fires only when that body is entered, and a flag the second delegate's own run sets first. A refused call that still ran its body would show in all three.
The refusal uses the repo's own shape, a ToolOutcome.held("authority", ...), so a host that renders their held calls renders this one without a new branch and the turn loop needs no new except.
Where the authority comes from
The hook is on BaseToolExecutor.dispatch (execution.py:225-243), where every tool call arrives and where components, extensions, delegates and handlers are each routed by name. The way in is the repo's own seam: executor_class is documented as "the seam for a deployment's own MerchantToolExecutor subclass" (orchestrator.py:86-87), and their test_every_path_takes_a_deployments_own_executor_class holds the Messages API orchestrator, the Agent SDK toolset and the MCP server to it. Only dispatch is overridden, so a deployment's own subclass can be the base and keeps its error wording.
A delegate's scopes are not typed by hand. They come from the tools its own surface declares, mapped through the same policy the executor authorizes against, and the operator decides only which to withhold. The child is then minted with the meet against the parent, so a grant asking for more than the parent holds yields the parent's. Act 2 runs the real AnalysisRunner on their scripted client with get_campaign_performance withheld: the parent node carries ten scopes, the child carries two, and the withheld read is refused mid-run while the delegate finishes its analysis without it.
Upstream has no per-delegate equivalent. The one lever is MerchantAgentConfig.enable_campaigns, and turning it off removes get_campaign_performance from the operator's surface at the same time (config.py:202-220). The config itself goes over whole: AnalysisRunner.__init__ stores it as received (analysis.py:112) and hands the same object to the executor it builds (analysis.py:335). The read-only delegate holds the deployment's limits and enabled systems and does nothing with them, because it has no tool that consumes them.
The ceiling, and who the store thinks spent it
max_campaign_budget is one number for the whole deployment (config.py:57, default 10,000), checked when a change is staged and again before it is applied (changes.py:101-107, :151, :191). Nothing reaches it from the shipped delegate, since stage_campaign is not on its surface. The drafter below is a child node the recipe mints, holding campaign.stage and a cap of 2,000.
config.max_campaign_budget = 10,000 (merchant_agent/config.py, checked at stage and again at apply)
the draft asks for = 5,000
operator stage_campaign -> ran (fenced payload)
drafter stage_campaign -> HELD[authority] That call is outside this agent's authority: denied: ceiling_exceeded constraint=max_s
One line is cut from the end, the run's own closing sentence, which says their check passed 5,000 for the operator and the chain held it for a child capped at 2,000. Same draft, same store, two answers, because the cap is on the node. A child asking for 50,000 gets the parent's 10,000, since the meet takes the lower one, and the root was minted with the deployment's own number on it (demo.py:150).
The store's own record cannot make that distinction. StagedChange carries created_by and created_by_kind (types.py:425-426), and ActorKind has two values (types.py:388-393) whose docstring says what they record:
Who drove an action: the operator directly, or the assistant on the operator's behalf. The principal recorded next to it is the operator either way.
That answers who is accountable. It does not answer which agent: of the eighteen fields on StagedChange, none names a delegate, so two delegates staging two changes leave the same row shape. The chain entry carries the node that asked.
The receipt, checked with their code absent
entries: 17 bundle: attenu-commerce-bundle.json
verify_bundle -> ok=True checks={'integrity': True, 'monotonicity': True, 'containment': True, 'anchor': 'verified', 'version': True, 'chain_id': True, 'root': True, 'expected_anchor': 'not checked', 'envelopes': 'not present'}
the chain, read back from the file alone:
commerce-demo:n0 merchant-turn allows=5 denies=0
commerce-demo:n1 analysis allows=1 denies=1 under commerce-demo:n0
commerce-demo:n2 report allows=2 denies=3 under commerce-demo:n0
commerce-demo:n3 campaign-drafter allows=0 denies=1 under commerce-demo:n0
every denial, from the file alone:
analysis get_campaign_performance campaign.read scope_not_granted
report stage_price_update pricing.stage scope_not_granted
report present_change_preview present.change_preview scope_not_granted
report note_finding delegate.note scope_not_granted
campaign-drafter stage_campaign campaign.stage ceiling_exceeded
Five denials on the ledger. The screen shows six. The extra one is a dispatch that resolves to no node at all. It is held, and with no node bound there is nothing to record it on.
That file then went to a virtualenv where commerce-agents is not installed:
$ pip list --format=freeze
attenu-guard==0.14.0
pip==25.3
$ python -c "import commerce_common, merchant_agent"
ModuleNotFoundError: No module named 'commerce_common'
$ attenu-guard verify ./attenu-commerce-bundle.json --hs256-key 64656d6f2d6b6579
integrity=True monotonicity=True containment=True anchor=verified nodes=4 actions_checked=8
OK
Only the last line of that traceback is shown. That environment holds attenu-guard and nothing else; their packages are absent, and so is the engine that writes the grants. The 0.14.0 is the version string on main at 51097fd, since the install there is the clone. The verifier rebuilds each node's authority from the root and spawn entries, re-checks the child against its parent, then checks every allow against the authority its node held.
Act 5 does the tamper itself: it rebuilds the same ledger with one child's grant widened by a scope the parent never held and re-signs it properly, which is what somebody holding the key could produce. I wrote that bundle to a file and put it through the CLI in the verifier-only venv:
$ attenu-guard verify ./widened-bundle.json --hs256-key 64656d6f2d6b6579
integrity=True monotonicity=False containment=True anchor=verified nodes=4 actions_checked=8
- monotonicity: commerce-demo:n1 not ⊆ parent commerce-demo:n0 (child scopes ['billing.refund', 'listing.read', 'metrics.read'] not held by parent)
FAILED
Both ran in the verifier-only environment, so the tamper and the detection both happen with their code absent. Writing the file was my own step; test_the_run_verifies_offline_and_a_widened_grant_does_not is where the same widening is pinned in the suite.
Two things about scope
Their README says, in the note under the title, that "Business rules, authorization, and compliance are the deployment's". This is not a gap in their code. It is the job they hand to whoever deploys it.
commerce-agents also takes no contributions. Its README says so in the licence section, at line 193: "This is a reference implementation; it is not maintained and does not accept contributions." Nothing here is a pull request or a claim that anyone at Anthropic has taken up or blessed anything.
What this does not do
An allow means the call was authorized, not that it ran. This chain records the decision. Their own gates run after the hook, so an allow can sit next to a call the deployment then blocked. test_an_allow_means_authorized_not_executed runs one turn through the real orchestrator, staging a price with no prior search, and asserts the host gets a tool_result whose status is blocked and whose reason is provenance, that backend.ledger.pending() is empty, and that the chain holds one decision, ("allow", "stage_price_update"). Read that ledger line as "this agent was allowed to ask". attenu-guard's shipped adapters carry the wiring that binds a decision to an outcome; this recipe runs the simpler chain.
One consumption path does not take executor_class. Every runtime path does. The shipped delegate's own reads do not, because AnalysisRunner._read names the concrete class inside the method (analysis.py:332-339) and keeps no reference outside it, which test_semantic_the_delegate_is_the_one_consumption_path_ignoring_executor_class asserts by reading that source. test_the_shipped_analysis_delegate_runs_under_the_child_node then runs their real delegate with install() active and lands its query_metrics on the child node as an allow and its get_campaign_performance as a deny. So the child node is minted either way, and its reads reach the chain only through a process-wide patch, because nobody can hand that executor a class. That patch is a monkeypatch, which the recipe README says where it offers it. No shipped test covers the negative case, so take the reads-are-absent half as what the cause implies.
Two more edges, one of them unmeasured. With the hosted code-execution flag on, _build_tools marks every read allowed_callers = [code_execution] and appends the sandbox tool (analysis.py:134-138), so those reads should be called server-side and never reach this process. I did not run that path. The other edge is a delegate that calls the backend directly, as their SQL path does, which is not going through dispatch at all, so guarding it means guarding the backend and that is a different recipe.
Run it yourself
git clone --depth 1 <https://github.com/anthropics/commerce-agents.git>
git clone <https://github.com/attenu-io/attenu-guard.git>
python3.12 -m venv .venv && . .venv/bin/activate
pip install -e ./commerce-agents/commerce-common \
-e ./commerce-agents/merchant-agent/core \
-e ./commerce-agents/merchant-agent/runtime-messages-api \
-e ./attenu-guard
python attenu-guard/examples/integrations/commerce-agents/demo.py
attenu-guard verify ./attenu-commerce-bundle.json --hs256-key 64656d6f2d6b6579
Five acts, then the file check. The demo writes the bundle to the working directory, so run the last line from the same place. attenu-guard has to come from the clone, not from PyPI, because the released wheel ships the library without the examples.
The tests want pytest on top of the four installs above, which the block does not bring in:
pip install pytest
python -m pytest -q attenu-guard/tests/integrations/test_commerce_agents.py
That is 45 passed, 1 skipped here. The skip needs the Agent SDK toolset package, which the install block leaves out. The suite skips rather than fails wherever commerce-agents is not importable, which is why this recipe is not in attenu-guard's CI matrix: every other integration installs its framework from PyPI, and commerce-agents is on no index.
The recipe, the adapter and the demo are in [examples/integrations/commerce-agents/](https://github.com/attenu-io/attenu-guard/tree/main/examples/integrations/commerce-agents), and the claims about upstream are re-checked against the installed package by test_commerce_agents.py, so a claim that goes stale fails a test instead of sitting in prose. The library is attenu-guard.
What a child gets to call when a parent hands it work, across five agent frameworks, is in the hub post.
Show me a run where a refused call reached its body, or where the bundle verifies something it should not, and I correct this and say where. The claims about upstream are pinned to fd4d592.
Rafael Asor is the founder of Attenu and the maintainer of attenu-guard and attenu-derive, open-source Python libraries for AI agent permissions across sub-agent handoffs. He is the author of the IETF Internet-Draft draft-asor-wimse-agent-delegation-chain and is based in Tel Aviv.