Skip to content

security(agents): an agent refused an action can get another agent to do it — no per-peer identity #16950

Description

@mrveiss

Part of #16946. This is a present-day gap, not only a future-design requirement.

Two findings from auditing main:

  1. A2A flattens every admitted peer into one identity. The ingress gate is real and tested — api/a2a.py:227 require_capability(x_a2a_agent_id, Capability.SUBMIT_TASKS), with anonymous callers hard-denied — but once admitted, every task claims its scopes as agent_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.
  2. The internal channel has no trust gate at all. Any holder of the communication manager can address any registered agent, and 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.py checks 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

  • Every peer message and every A2A task carries the identity of the agent that actually originated it, end to end
  • A request is authorised against its originator's permissions — relaying it through another agent cannot widen them
  • A peer's message is never treated as the human user's approval
  • A test in which agent A, refused scope S, asks agent B (which holds S) to act — and the action is refused
  • A2A's single a2a-executor identity is replaced by the admitted peer's own

Activity

  1. added this to the v0.10.0 milestone on Sep 18, 2026
  2. modified the milestones: v0.10.0, v0.9.0 on Sep 18, 2026
  3. mrveiss commented on Sep 18, 2026

    @mrveiss
    OwnerAuthor

    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.py is 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 by enforce_work_item_approval)
      • the governed-identity boundary (build_governed_identity → forbidden_work)
      • auth_role
      • the A2A capabilities
    • A2A has no refused capability to launder. Only SUBMIT_TASKS is 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-executor flattening 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 only parent_agent_id (used for a log line) and auth_role.
    • How the child is built: _run_internal_subagent builds it with build_governed_identity({"agent_id": agent_type}, …) (chat_workflow/delegation.py:137). So its requires_approval_before is [], and the parent's work_item_id is gone.
    • Effect: a parent whose write_file is held under the work item's writing files gate delegates the write, and the child's write_file runs 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 main as 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
    delegate tool, internal engine no; only parent_agent_id for logging the child's own profile boundary; no approval gates, no work item the laundering above
    delegate tool, claude_code engine (default) no only the child profile's --disallowedTools out 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, not execute_with_tracking, so a peer request holds no work claims
    A2A → coordinator.process_request → distributed agents (agents/agent_orchestration/agent_execution.py:129) no: the peer id never reaches the orchestrator none also process_request directly, so no claims
    A2A → 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 execute

    LLM-facing tools do not expose send_agent_request. Its only callers are BaseAgent.send_message_to_agent and a demo in protocols/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_identity trusts agent_id from its source dict.

    It is not reached. Its only callers are chat_workflow/manager.py:3598, chat_workflow/graph.py:415 and delegation.py:137. Neither orchestrator path enters chat_workflow:

    • Distributed: the registered distributed agents are only ClassificationAgent and npu_code_search (agents/agent_orchestration/coordinator.py:155-157).
    • Legacy: agents/chat_agent.py does not import chat_workflow.
    • Indirectly: outside the package, the manager is used only by api/chat.py, acp/runner.py (a server-side context), startup, and conversation.py, which has no importers.

    "Not reached" is not "not vulnerable." Any future path that forwards a peer's context into chat_workflow reopens this. The producer of agent_id should be trusted-only by design.

  4. mrveiss commented on Sep 18, 2026

    @mrveiss
    OwnerAuthor

    Progress: #16958 (the strict-xfail negative control), then #16966, stacked on it.

    #16966 delivers:

    • The originator now survives relays. MessageHeader.originator and chain are stamped structurally, and AgentRequest.originator is 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_code engine, 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:

  5. mrveiss commented on Sep 18, 2026

    @mrveiss
    OwnerAuthor

    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: the forbidden_work it 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 on X-A2A-Agent-Id alone, 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 Authority value 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, analyst and editor are mutually incomparable. The gate checks the set when present. Its fallback to the role must never be more permissive than the set.
    • DISCOVERY is 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_AGENTS is removed too, having no operation behind it. SUBMIT_TASKS and QUERY_MEMORY remain, each enforced at exactly one site. Consequence: TRUSTED now grants nothing beyond STANDARD, 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.

  6. 13 remaining items

  7. mrveiss commented on Sep 25, 2026

    @mrveiss
    OwnerAuthor

    PR #17394 merged as 3f03aaf794. One criterion of five is delivered; this issue stays open. Evidence verified against merged origin/main via git show origin/main:<path>, not against the PR diff. The PR said Refs, not Closes, for exactly this reason.

    AC Verdict Evidence
    A2A's single a2a-executor identity is replaced by the admitted peer's own met a2a/task_executor.py calls claim_identity(peer_id, task_id); git grep '"a2a-executor"' on merged main returns only comments and docstrings — no live assignment anywhere in autobot-backend/
    A request is authorised against its originator's permissions partial api/a2a.py keys authorisation on the pair: peer_key = peer_trust_key(credential_subject(current_user), x_a2a_agent_id) then require_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 diff
    Every 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.py exists in merged main and 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 filename
    A 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.py covers the parent/child subagent path — its cases are test_the_parent_is_held_and_the_delegation_runs and test_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 shape

    A 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 when agent_id and task_id match, and task ids are server-minted per task, so two peers' tasks always conflicted. What the shared identity destroyed was attribution: a refusal named a2a-executor instead of the peer actually holding the scope, so an operator could not tell which peer to talk to, and any policy keyed on agent_id saw 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 c979af688 this PR had nine passing tests, none attached to the change. Reverting the call site to agent_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 the agent_id handed to hold_scopes and 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 passed
    

    Failing 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.requested carries the scope string and never the requester's identity, and the holder name the artifact does carry was seeded by the test's own claim_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.

  8. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    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). originator set once and never overwritten by a relay; chain records every hop for audit and cycle detection. Relays are structural rather than per-agent discipline: an agent handling an inbound request runs inside acting_for(), and stamp() reads the inherited origin from a ContextVar, so messages sent through helpers that never saw the inbound one still continue the chain (:18-22). Wired into the peer handler at agents/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, with None as the unconstrained top. This is exactly what AC2 needs.

    3. Enforcement at the A2A routing choke point. capability_requirements.refused_agents called from agent_orchestration/agent_execution.py:154 and :228; an unclassified agent is refused to an external originator, fail-closed (capability_requirements.py:15); the peer's Authority comes from a2a/trust_score.py:574, with a2a/task_executor.py:201 returning an empty-capability Authority when there is none.

    The gap — AC2, on the internal peer-request path

    Two internal paths coexist on main and they behave differently:

    Path A — the inbox (#16948): structurally safe. protocols/peer_inbox.py:23-27 states 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_TENANT resolves to shared-only and EXTERNAL identities are never in presence, so they cannot be addressed at all. Drained at chat_workflow/tool_dispatch_guards.py:358 (AI_STACK chat) and services/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-491 iterates message_handlers[REQUEST] and await handler(message) inline, and agents/base_agent.py:411 still registers _handle_communication_request for it. That handler extracts the origin, stamps it on the AgentRequest — and then calls execute_with_tracking with no Authority. Downstream, capability_requirements.py:70 is 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_origin over autobot-backend/ returns only message_origin.py itself and its test — no production reader. Nothing converts AgentRequest.originator into an Authority, and Authority.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 detection
    2 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 of current_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 gate
    4 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 refuse
    5 a2a-executor replaced MET ✅ the literal survives only inside a2a/peer_claim_identity_16950_test.py, documenting the old behaviour

    Two boundaries that belong in the record

    Forgery is explicitly out of scope, by the module's own admission (message_origin.py:24-28): nothing authenticates originator/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 beyond chat (#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 its Authority and pass it into the path that already knows how to refuse (refused_agents), rather than None. Then AC4's test is the peer case, not the parent case.

  9. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    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: str this signature takes) for the BaseAgent API 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 an Authority instead of passing None, where capability_requirements.py:70 documents that None "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.

  10. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    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

    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.py and autobot-backend/protocols/message_origin.py (grep only)
    • autobot-backend/security/authority.py and autobot-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-124 now call hold_scopes(..., agent_id=claim_identity(peer_id, task_id)).
      • The a2a-executor literal 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 under acting_for plus execute_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 no Authority.
        • Nothing reads AgentRequest.originator to make a decision; its only writer is :435.
        • current_origin has no production reader (only message_origin.py and its test).
        • refused_agents(None, …) returns [] (capability_requirements.py:69-72). Only the A2A path passes an Authority (agent_execution.py:154, :228).
    • 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-627 and awaits send_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_agent has zero callers, and send_agent_request is called only from it.
    • AC4 test: no test found for the case where peer B holds more permissions than peer A. Grepping tests for launder hits only delegation_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-265 and :400-500; agent_communication.py:600-655; capability_requirements.py:66-76; greps of a2a.py, task_executor.py and agent_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):


    Generated by Claude Code

  11. mrveiss commented on Sep 28, 2026

    @mrveiss
    OwnerAuthor

    B8 spec — one conversion, then this issue's criteria become assertions about the converted path

    Batch B8 is #16950 AC2 + #16948 + #16997 on 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 (builds MessageType.REQUEST, awaits inline), dispatched at :489-491 to the handler registered at base_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, with list_live(tenant_id) as the authorization check.

    Conversion, one sentence: route send_message_to_agent through PeerInboxDirectory.send() instead of send_agent_request.

    Per-issue detail, mutations and test files are in the spec. The three points worth surfacing here:

    1. 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 + a ContextVar), and the combination rule is implemented — security/authority.py:56-62 meet(), restrictions union, grants intersect. What is missing is one step: resolve the originator to an Authority and pass it where None goes today. git grep current_origin has no production reader, and capability_requirements.py:70 states the consequence in its own docstring — "None, an internal caller, reaches all."

    2. 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_is is 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.

    3. 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-159 by refusing an unaddressable name — a fail-closed default. Adding rag/system_commands to 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-28 puts forgery out of scope until #16962 adds per-agent keys and a MAC: this batch closes the confused deputy, not raw-bus forgery.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions