Repository navigation
security(agents): an agent refused an action can get another agent to do it — no per-peer identity #16950
Description
Activity
Audit of every path where a request crosses from one agent to another. Two parts of the premise above do not hold on
main, one laundering path is real, and one escalation was suspected and refuted.Premise corrections
agents/scope_enforcement.pyis work-claim mutual exclusion, not authorization. "B holds S" means B holds a lease on S. B writing under its own lease is the design working. Authority lives elsewhere:- the approval gates (
requires_approval_before, enforced byenforce_work_item_approval) - the governed-identity boundary (
build_governed_identity→forbidden_work) auth_role- the A2A capabilities
- the approval gates (
- A2A has no refused capability to launder. Only
SUBMIT_TASKSis enforced (api/a2a.py:227); the other three are enforced nowhere. That is filed separately as security(a2a): three of four trust-matrix capabilities are enforced nowhere — a level that denies them denies nothing #16957. a2a-executorflattening does not break exclusion. Same-holder requires the same agent and the same task (autobot_shared/coordination/work_claims.py:307), so two peers' tasks still refuse each other. What is lost is attribution and authority, not exclusion.
The laundering that exists today: delegation drops the parent's approval gates
- What the child receives:
_handle_delegate_tool(chat_workflow/tool_handler.py:2316) passes it onlyparent_agent_id(used for a log line) andauth_role. - How the child is built:
_run_internal_subagentbuilds it withbuild_governed_identity({"agent_id": agent_type}, …)(chat_workflow/delegation.py:137). So itsrequires_approval_beforeis[], and the parent'swork_item_idis gone. - Effect: a parent whose
write_fileis held under the work item'swriting filesgate delegates the write, and the child'swrite_fileruns unapproved. - What is carried:
auth_role, deliberately (fix(mcp): the authenticated role never reaches MCPDispatcher — every tool call is evaluated as "user" #13821). The gates are not. - Exposure: behind
AUTOBOT_DELEGATION_ENABLED(off by default).
The failing test (AC4) is on the linked PR as a strict xfail. It reports XFAIL on
mainas proof of this gap, and the fix's XPASS fails the suite, so the marker has to come out in the fix commit.Every crossing path
Path Carries originator? Enforcement applied on the far side Note delegatetool, internal engineno; only parent_agent_idfor loggingthe child's own profile boundary; no approval gates, no work item the laundering above delegatetool,claude_codeengine (default)no only the child profile's --disallowedToolsout of process: the parent's gates cannot follow it at all today Internal peer channel: BaseAgent._handle_communication_request(agents/base_agent.py:419)no: the sender id is dropped none calls process_request, notexecute_with_tracking, so a peer request holds no work claimsA2A → coordinator.process_request→ distributed agents (agents/agent_orchestration/agent_execution.py:129)no: the peer id never reaches the orchestrator none also process_requestdirectly, so no claimsA2A → legacy agents ( agent_execution.py:202)no none the system-commands route only generates and validates ( agents/system_command_agent.py:838); it does not executeLLM-facing tools do not expose
send_agent_request. Its only callers areBaseAgent.send_message_to_agentand a demo inprotocols/agent_communication.py.Suspected escalation, checked and refuted
The question: can a peer-supplied
context["agent_id"]pin a registered unbounded profile (system_agent) through A2A?build_governed_identitytrustsagent_idfrom its source dict.It is not reached. Its only callers are
chat_workflow/manager.py:3598,chat_workflow/graph.py:415anddelegation.py:137. Neither orchestrator path enterschat_workflow:- Distributed: the registered distributed agents are only
ClassificationAgentandnpu_code_search(agents/agent_orchestration/coordinator.py:155-157). - Legacy:
agents/chat_agent.pydoes not importchat_workflow. - Indirectly: outside the package, the manager is used only by
api/chat.py,acp/runner.py(a server-side context), startup, andconversation.py, which has no importers.
"Not reached" is not "not vulnerable." Any future path that forwards a peer's context into
chat_workflowreopens this. The producer ofagent_idshould be trusted-only by design.Progress: #16958 (the strict-xfail negative control), then #16966, stacked on it.
#16966 delivers:
- The originator now survives relays.
MessageHeader.originatorandchainare stamped structurally, andAgentRequest.originatoris filled by the peer handler. - Delegation carries the parent's authority on both engines: its gates, its work item and its boundary. On the out-of-process
claude_codeengine, the parent's gated tools are refused, since that engine cannot ask for approval. - Only the trusted overlay can give a run an executor identity. This is the "not reached, but not structurally prevented" caveat, now closed.
Still open:
- The A2A half (AC1, AC2 and AC5 for A2A): the peer identity replacing
a2a-executor, and the peer's authority threaded into the orchestrator. It follows as a separate PR, together with security(a2a): three of four trust-matrix capabilities are enforced nowhere — a level that denies them denies nothing #16957. Re-keying trust on(jwt_sub, X-A2A-Agent-Id)would reset every existing peer toUNTRUSTED, so the migration is with the coordinator and the owner first. - AC3 ("a peer's message is never the human's approval"): no peer message reaches an approval path today. The property belongs with feat(agents): deliver peer messages at the recipient's next turn, not mid-task #16948's typed peer inbox entry. feat(agents): deliver peer messages at the recipient's next turn, not mid-task #16948 must not deliver a peer entry into the human approval path.
- F2's permission-set wiring: it lands with the first producer of a set that differs from the role, which is the Company OS hop (pending owner decision F3). Its checklist: the 8 tool-permission seams, which are 7 in
chat_workflow/tool_handler.pyplusbrowser_tool_handler.py:132.
- The originator now survives relays.
Owner decisions, 2026-09-18
Company OS autonomous bound — a table per org role. Org roles (
manager,coordinator,specialist,worker) currently carry no permission meaning anywhere in the code — they are used for the org chart, hiring and indexing only — so the earlier decision that an autonomous run is "bounded by its org role" bounded nothing. Each role now gets an explicit entry: theforbidden_workit may never do and the default approval gates it carries, enforced through the same gates as everything else. The table is drafted as a proposal for the owner to approve before it is enforced; until then, the Company OS hop takes its bound from one function, so approval changes a single place.A2A peer trust — keyed on
(credential, peer id); existing peers reset, and an admin re-grants. Trust was keyed onX-A2A-Agent-Idalone, a header the caller declares, so one admin credential could claim whichever peer id held the highest trust and inherit it — trust levels narrowed nothing. Keyed on the pair, a credential claiming another's peer id gets its own record and cannot borrow the other's trust.The migration consequence, accepted by the owner: every existing peer starts
UNTRUSTED, which cannot submit tasks, so live A2A integrations stop until an admin re-grants each peer. Of the three options this is the only one with no window in which one credential can inherit another's trust. The legacy header-keyed records are kept as the audit trail of who held what, not deleted.Engineering rulings on the common permission form
- The
Authorityvalue keeps the four native vocabularies side by side, each with its own meet — restrictions combine by union (approval categories,forbidden_work), grants by intersection (RBAC permission sets, A2A capabilities). A vocabulary absent from a hop is ⊤ for that hop, never ⊥. - RBAC carries the effective permission set, not a role name, because the roles are not a ladder:
operator,analystandeditorare mutually incomparable. The gate checks the set when present. Its fallback to the role must never be more permissive than the set. DISCOVERYis removed from the A2A matrix. The agent card is public by protocol design at/.well-known/agent.json, so gating the admin copy refused only a document anyone can fetch anonymously.DEFINE_AGENTSis removed too, having no operation behind it.SUBMIT_TASKSandQUERY_MEMORYremain, each enforced at exactly one site. Consequence:TRUSTEDnow grants nothing beyondSTANDARD, so the trust levels want revisiting.
Context for severity
Every A2A route is admin-gated at the router (
api/a2a.py:64-65). A2A is currently reachable only with admin credentials, and trust levels narrow an already-admin caller. That bounds today's exposure — and becomes a live hole the day A2A is opened to non-admin callers without the pair-keyed trust above.Scope of the two PRs
AC3 ("a peer's message is never the human's approval") is not touched by either PR: no peer message reaches an approval path today. It belongs with #16948's typed inbox entry.
- The
- added a commit that references this issue
on Sep 18, 2026 13 remaining items
- added 5 commits that reference this issue
on Sep 21, 2026 PR #17394 merged as
3f03aaf794. One criterion of five is delivered; this issue stays open. Evidence verified against mergedorigin/mainviagit show origin/main:<path>, not against the PR diff. The PR saidRefs, notCloses, for exactly this reason.AC Verdict Evidence A2A's single a2a-executoridentity is replaced by the admitted peer's ownmet a2a/task_executor.pycallsclaim_identity(peer_id, task_id);git grep '"a2a-executor"'on mergedmainreturns only comments and docstrings — no live assignment anywhere inautobot-backend/A request is authorised against its originator's permissions partial api/a2a.pykeys authorisation on the pair:peer_key = peer_trust_key(credential_subject(current_user), x_a2a_agent_id)thenrequire_capability(peer_key, Capability.SUBMIT_TASKS). So credential A presenting peer Y cannot inherit credential B's trust. Whether that closes "relaying cannot widen" for every relay path is not provable from this diffEvery peer message and every A2A task carries its originator's identity, end to end not verified A2A tasks now do. Peer messages are a separate path and I did not trace them. Ticking this from the task fix would be the broader claim resting on the narrower evidence A peer's message is never treated as the human user's approval not verified tests/security/test_approval_human_decider_17042.pyexists in mergedmainand is plausibly this criterion, delivered by #17042 rather than here. I have not read it, so I am not ticking it on the strength of a filenameA test in which agent A, refused scope S, asks agent B (which holds S) to act — and the action is refused not met chat_workflow/delegation_laundering_test.pycovers the parent/child subagent path — its cases aretest_the_parent_is_held_and_the_delegation_runsandtest_the_child_is_held_as_its_parent_is. Both are about holding, neither is the A-refused-asks-B refusal. #17394's tests cover two peers contending for one scope, which is a different shapeA correction this issue should carry, because the fix's first version was wrong about its own severity
The original commit claimed the flattened identity let one peer act on another's hold — laundering. It did not.
work_claims' Lua treats a holder as the same only whenagent_idandtask_idmatch, and task ids are server-minted per task, so two peers' tasks always conflicted. What the shared identity destroyed was attribution: a refusal nameda2a-executorinstead of the peer actually holding the scope, so an operator could not tell which peer to talk to, and any policy keyed onagent_idsaw every peer as one.That distinction is why AC5 is ticked and AC2 is only partial: this was an attribution fix, not an authorisation fix.
The suite shipped unbound, and a reader seeing green should know
At
c979af688this PR had nine passing tests, none attached to the change. Reverting the call site toagent_id="a2a-executor"left all nine green. Two independent reviewers found it; neither was the author.The fix added
test_the_executor_claims_as_the_peer, which spies on theagent_idhanded tohold_scopesand delegates to the real one. Re-measured on the rebased tree rather than trusting the earlier result, because base had moved three times:revert task_executor.py to agent_id="a2a-executor" -> 1 failed, 9 passed restored -> 10 passedFailing assertion is
['a2a-executor'] == ['a2a:peer-a']. I checked the failure reason, not just the red — an unrelated orchestrator-provider error logs on that path and could have produced a false bind.Why the original refusal test could not catch it:
ClaimConflict.requestedcarries the scope string and never the requester's identity, and the holder name the artifact does carry was seeded by the test's ownclaim_identity(...)call. Both halves were satisfiable without the call site participating.What closing this needs
AC1 and AC3 need someone to trace the peer-message path and read
test_approval_human_decider_17042.py. AC4 needs a test that does not exist yet — agent A refused scope S, asks agent B holding S, and the action is refused.Audit against
origin/main@7adaca8c29— there is a mechanism, and it is structural, not convention. 3 of 5 criteria met; AC2 is enforced on one of the two internal paths and AC4's test covers a different case than the one it names. Not closed.The question this answers: is anything actually enforced, or is this convention held by each agent's judgement? Neither. Three real mechanisms exist, two of them wired, and the gap is one specific path.
What exists
1. Identity propagation —
protocols/message_origin.py(102 lines).originatorset once and never overwritten by a relay;chainrecords every hop for audit and cycle detection. Relays are structural rather than per-agent discipline: an agent handling an inbound request runs insideacting_for(), andstamp()reads the inherited origin from aContextVar, so messages sent through helpers that never saw the inbound one still continue the chain (:18-22). Wired into the peer handler atagents/base_agent.py:427,:435,:443. Tested:protocols/message_origin_test.py.2. Authority algebra —
security/authority.py:56-62.meet()is the correct chain-of-principals semantics for refusing widening: restrictions union, grants intersect, withNoneas the unconstrained top. This is exactly what AC2 needs.3. Enforcement at the A2A routing choke point.
capability_requirements.refused_agentscalled fromagent_orchestration/agent_execution.py:154and:228; an unclassified agent is refused to an external originator, fail-closed (capability_requirements.py:15); the peer'sAuthoritycomes froma2a/trust_score.py:574, witha2a/task_executor.py:201returning an empty-capabilityAuthoritywhen there is none.The gap — AC2, on the internal peer-request path
Two internal paths coexist on
mainand they behave differently:Path A — the inbox (#16948): structurally safe.
protocols/peer_inbox.py:23-27states the ruling and implements it: a drained peer message is only context for what the recipient does next, it does not invoke a tool, and a tool call that content prompts still passes that kind's own sensitive-tool gate. Addressing is authorised by tenant-scoped presence (:29-34) —UNKNOWN_TENANTresolves to shared-only andEXTERNALidentities are never in presence, so they cannot be addressed at all. Drained atchat_workflow/tool_dispatch_guards.py:358(AI_STACKchat) andservices/agent_terminal/service.py:281(SESSION).Path B — the immediate handler: identity carried, never authorised. Still live and unchanged in shape:
protocols/agent_communication.py:489-491iteratesmessage_handlers[REQUEST]andawait handler(message)inline, andagents/base_agent.py:411still registers_handle_communication_requestfor it. That handler extracts the origin, stamps it on theAgentRequest— and then callsexecute_with_trackingwith noAuthority. Downstream,capability_requirements.py:70is explicit about what that means:def refused_agents(authority: Authority | None, agent_types: Iterable[str]) -> List[str]: """The agents in *agent_types* that *authority* may not reach. None, an internal caller, reaches all.""" if authority is None: return []
And nothing consumes the origin to decide:
git grep current_originoverautobot-backend/returns onlymessage_origin.pyitself and its test — no production reader. Nothing convertsAgentRequest.originatorinto anAuthority, andAuthority.meet()is never called on a chain.So on path B the laundering scenario still completes: A, refused scope S, asks B which holds S; B runs the work with B's own powers and A recorded as originator. What #16950's merged work changed is that the laundering is now attributable rather than invisible — a real improvement, and not the same thing as refused.
AC-by-AC
AC Verdict Evidence 1 identity carried end to end MET ✅ message_origin.py;base_agent.py:427/435/443;a2a/peer_identity.py; chain for audit + cycle detection2 authorised against the originator PARTIAL — not ticked enforced for A2A originators ( agent_execution.py:154/228); not enforced on path B (refused_agents(None, …) → [], no reader ofcurrent_origin)3 never treated as human approval MET ✅ peer_inbox.py:23-27— content becomes context, never an invocation; tool calls still hit the kind's sensitive-tool gate4 test: A refused S asks B which holds S NOT MET — not ticked what exists is chat_workflow/delegation_laundering_test.py::test_the_child_is_held_as_its_parent_is— the hierarchical case, where a child inherits its parent's hold. The peer case, where B holds more than A, is untested — and that is precisely the case path B does not refuse5 a2a-executorreplacedMET ✅ the literal survives only inside a2a/peer_claim_identity_16950_test.py, documenting the old behaviourTwo boundaries that belong in the record
Forgery is explicitly out of scope, by the module's own admission (
message_origin.py:24-28): nothing authenticatesoriginator/chain, so a process with write access to the agent bus's Redis can forge them until #16962 (per-agent keys + a MAC) lands — currently v0.10.0. This mechanism fixes the confused deputy among well-behaved agents, not raw-bus forgery. Worth stating plainly because the audit trail AC1 now satisfies is only as strong as that.#16948's coverage is the boundary of this issue's exposure. Path A cannot launder because a peer message is context, not an action. So the residual exposure is exactly what path A has not taken over:
base_agent's immediate REQUEST handler, plus AI_STACK roles beyondchat(#16997, which carries no milestone) and COMPANY_OS (#16992, v0.10.0). Finishing those shrinks the gap; closing path B's authorisation hole is what actually satisfies AC2.Smallest honest fix shape
Path B already holds everything needed: it has the originator, and
Authority.meet()already implements "every restriction of either, only the grants of both". What is missing is one step — resolve the originator to itsAuthorityand pass it into the path that already knows how to refuse (refused_agents), rather thanNone. Then AC4's test is the peer case, not the parent case.One seam, two issues — convert it once
#16948 and #16950 are different classes with the same residual cause. The unconverted immediate peer path is why an authority check does not run there (#16950 AC2) and why turn-boundary delivery does not apply there (#16948 AC1). Stating it on both so nobody builds it twice.
The seam, on
origin/main@7adaca8c29:# agents/base_agent.py:474-483 async def send_message_to_agent(self, recipient_id: str, message_data: Any, timeout: float = 30.0): from protocols.agent_communication import send_agent_request return await send_agent_request(self.agent_id, recipient_id, message_data, timeout) # protocols/agent_communication.py:622-627 — builds MessageType.REQUEST, awaits inline # protocols/agent_communication.py:489-491 — dispatches to the recipient's handler on arrival # agents/base_agent.py:411 — that handler is BaseAgent._handle_communication_request
Converting this one path satisfies parts of both issues:
Issue What the conversion gives #16948 AC1 (inbox, next turn boundary) and AC2 (addressed by presence name rather than the recipient_id: strthis signature takes) for theBaseAgentAPI the issue names in its first line#16950 AC2 (authorised against the originator) — the originator is already carried here ( base_agent.py:427/435/443); what is missing is resolving it to anAuthorityinstead of passingNone, wherecapability_requirements.py:70documents thatNone"reaches all"They do not become one issue. Delivery timing and authority are different properties with different tests, and either could be delivered without the other. But the edit is in one place, so whoever takes either should check the other first — and #16950's AC2 in particular is cheaper once this path already goes through a queue, because a queue has an enqueue point where an authority decision has somewhere to live.
Remaining kinds for #16948's side: #16997 (AI_STACK roles beyond
chat) and #16992 (COMPANY_OS). #16950's forgery boundary stays #16962.v0.9.1 triage (classification only — not a fix proposal)
Pre-filter — PRs. Every PR that targets this issue landed; none was abandoned. There is no OPEN PR.
PR State What it did Where the content landed #17394 MERGED A2A claims as its own peer 3f03aaf79#16966 CLOSED-unmerged originator survives relays; delegation inherits authority 771e060, 3d9f3ba, 8e8596a on main, via vehicle #17113 (CLOSED) → #17134 (MERGED) #16969 CLOSED-unmerged pair-keyed A2A trust 781a9b1, 38cb156 on main #16958 CLOSED-unmerged strict-xfail laundering test 4570656 on main, via #17047 (MERGED) #16974 CLOSED-unmerged Company OS org-role bound vehicle #17194 ( 245c1afb9)Mention only: #16996, #16978, #17134, and about 45 unrelated PRs.
Pre-filter — commits since filing
- On
api/a2a.py,a2a/task_executor.py,base_agent.pyandscope_enforcement.py: 3f03aaf, 109939a, 5ccfba8, 781a9b1, 38cb156, 771e060 (security(agents): an agent refused an action can get another agent to do it — no per-peer identity #16950/security(a2a): three of four trust-matrix capabilities are enforced nowhere — a level that denies them denies nothing #16957); 4c7edae (agents: the peer channel never delivers to the recipient — a request lands in the sender's own inbox and the sender answers itself #16986); 1efb9bd (merge). --grep '#16950'also finds 245c1af, 17ca3de, 89f82f4, c81684f, 3a5674e, 4f4558b, 5349f78 and 4570656.
Files
autobot-backend/agents/base_agent.py(opened)autobot-backend/protocols/agent_communication.py(opened)autobot-backend/agents/agent_orchestration/capability_requirements.py(opened,:66-76)autobot-backend/agents/agent_orchestration/agent_execution.py,autobot-backend/api/a2a.py,autobot-backend/a2a/task_executor.pyandautobot-backend/protocols/message_origin.py(grep only)autobot-backend/security/authority.pyandautobot-backend/chat_workflow/delegation_laundering_test.py(exist, not opened)
Premise check (code read on main 80b6200)
- Finding 1, "every task claims as
a2a-executor(task_executor.py:114)": stale, fixed.:122-124now callhold_scopes(..., agent_id=claim_identity(peer_id, task_id)).- The
a2a-executorliteral survives only in comments (task_executor.py:114,119,a2a/peer_identity.py:63). - Ingress is now keyed on the peer pair (
api/a2a.py:239-241).
- Finding 2, "the internal channel has no trust gate;
_handle_communication_request(:419) treats it as an ordinary request": partly stale.- The handler is now at
:421. It now carries the origin (:427,:435-436) and runs underacting_forplusexecute_with_tracking(:443-444), so work claims are taken. feat(agents): coordinate agents competing for shared resources — API limits, CPU time, queues #16951's comment that this path "calls process_request, holds no claims" is out of date. - Still true: there is no authority check.
execute_with_tracking(request)(:211) takes noAuthority.- Nothing reads
AgentRequest.originatorto make a decision; its only writer is:435. current_originhas no production reader (onlymessage_origin.pyand its test).refused_agents(None, …)returns[](capability_requirements.py:69-72). Only the A2A path passes anAuthority(agent_execution.py:154,:228).
- The handler is now at
- The shared seam with feat(agents): deliver peer messages at the recipient's next turn, not mid-task #16948 is verified:
base_agent.py:474-483→agent_communication.py:607, which builds a REQUEST at:622-627and awaitssend_request. The same path is feat(agents): deliver peer messages at the recipient's next turn, not mid-task #16948's residual. - New caveat: that sending path has no caller anywhere.
send_message_to_agenthas zero callers, andsend_agent_requestis called only from it.- So the laundering scenario on this path needs either a new in-repo caller or a raw Redis writer (the security(agents): authenticate agent-bus messages — a per-agent key issued at registration, HMAC on every message #16962 forgery boundary).
- Handlers are registered in production (
classification_agent.py:87,coordinator.py:181,distributed_management.py:188).
- AC4 test: no test found for the case where peer B holds more permissions than peer A. Grepping tests for
launderhits onlydelegation_laundering_test.py, which covers the parent/child case. Still open.
Blocked by: nothing visible in the issue. Adjacent: #16962 (forgery, v0.10.0), #16948 (the same seam), #16997 and #16992 (other kinds).
Read vs inferred: Read the issue body and its 9 comments;
base_agent.py:205-265and:400-500;agent_communication.py:600-655;capability_requirements.py:66-76; greps ofa2a.py,task_executor.pyandagent_execution.py. Inferred: that AC3's #17042 test is the right evidence; it was not opened, the same gap as the 09-25 comment.Undetermined:
- whether AC2 is meaningful, or even satisfiable, on a path with no in-repo sender (owner scoping);
- the content of
tests/security/test_approval_human_decider_17042.py(exists, not opened).
Collisions within v0.9.1 (same file, so one PR and one agent under the same-file rule):
autobot-backend/agents/base_agent.pywith feat(agents): deliver peer messages at the recipient's next turn, not mid-task #16948autobot-backend/protocols/agent_communication.pywith feat(agents): deliver peer messages at the recipient's next turn, not mid-task #16948
Generated by Claude Code
- On
B8 spec — one conversion, then this issue's criteria become assertions about the converted path
Batch B8 is
#16950AC2 +#16948+#16997on one seam. Full spec at~/b8-spec-2026-09-28.md.The seam:
base_agent.py:474-483→send_agent_request→agent_communication.py:622-627(buildsMessageType.REQUEST, awaits inline), dispatched at:489-491to the handler registered atbase_agent.py:411. The target shape already exists and is wired for two kinds —protocols/peer_inbox.py, drained at each kind's own turn boundary, addressed by presence, withlist_live(tenant_id)as the authorization check.Conversion, one sentence: route
send_message_to_agentthroughPeerInboxDirectory.send()instead ofsend_agent_request.Per-issue detail, mutations and test files are in the spec. The three points worth surfacing here:
-
security(agents): an agent refused an action can get another agent to do it — no per-peer identity #16950's AC2 is small because two of its three parts exist. The originator is already carried structurally (
message_origin.py,acting_for+ aContextVar), and the combination rule is implemented —security/authority.py:56-62meet(), restrictions union, grants intersect. What is missing is one step: resolve the originator to anAuthorityand pass it whereNonegoes today.git grep current_originhas no production reader, andcapability_requirements.py:70states the consequence in its own docstring — "None, an internal caller, reaches all." -
security(agents): an agent refused an action can get another agent to do it — no per-peer identity #16950's AC4 test must be the PEER case.
delegation_laundering_test.py::test_the_child_is_held_as_its_parent_isis the hierarchical case and passes because the child starts from less. The required assertion is A refused scope S asking B which holds S, and being refused. The mutation — pass the relayer's authority instead of the originator's — leaves the hierarchical test green and the peer test red. That divergence is why both must exist. -
AI_STACK peer-message drain only wired for the 'chat' role; 'rag'/'system_commands' silently lose messages #16997's widening must land with its drains.
peer_inbox.py:107_AI_STACK_ADDRESSABLE_NAMES = frozenset({"chat"})is enforced at:152-159by refusing an unaddressable name — a fail-closed default. Addingrag/system_commandsto the set without wiring each one's drain converts a loud refusal into a silent queue, which is worse than today and is the exact trade this cluster exists to undo.
Order: convert the seam → #16948's AC1/AC2/AC4 → #16950's AC2 + peer-case test → #16997's widening. And state in the PR that
message_origin.py:24-28puts forgery out of scope until #16962 adds per-agent keys and a MAC: this batch closes the confused deputy, not raw-bus forgery.-
Part of #16946. This is a present-day gap, not only a future-design requirement.
Two findings from auditing
main:api/a2a.py:227require_capability(x_a2a_agent_id, Capability.SUBMIT_TASKS), with anonymous callers hard-denied — but once admitted, every task claims its scopes asagent_id="a2a-executor"(a2a/task_executor.py:114). Downstream, nothing distinguishes which peer is acting; the peer id is used only for trust-score bookkeeping and PII-audit tags.BaseAgent._handle_communication_request(base_agent.py:419) treats the message as an ordinary request, indistinguishable from one made on the human's behalf.agents/scope_enforcement.pychecks the resource, not on whose authority the request arrived.Together: an agent refused a scope can ask another agent that holds it to do the work, and nothing refuses. That is permission laundering.
Acceptance criteria
a2a-executoridentity is replaced by the admitted peer's own