Skip to content

fix(security): a delegated agent inherits its parent's authority; the originator survives relays (#16950) - #16966

Closed
mrveiss wants to merge 11 commits into
issue-16950-delegation-laundering-testfrom
issue-16950-originator-authority
Closed

mrveiss wants to merge 11 commits into
issue-16950-delegation-laundering-testfrom
issue-16950-originator-authority

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 18, 2026 •

Copy link
Copy Markdown
Owner

Refs #16950

Stacked on #16958 (base issue-16950-delegation-laundering-test). #16958 is the strict-xfail negative control; this PR removes its marker in the fix commit. Merge #16958 first, then retarget this PR to main.

Thinking Path

This builds the parts of #16946's identity model (as corrected, with the owner's decisions) that do not depend on still-open forks.

  • The laundering on main: delegation dropped the parent's approval gates and work item. _handle_delegate_tool passed the child only parent_agent_id (used for a log line) and auth_role. The child was then built from its own profile alone. So a parent held from write_file could delegate the write, and the child ran it unapproved.
  • The originator was dropped on the peer channel. BaseAgent._handle_communication_request never read the header, so a peer's request was indistinguishable from the agent's own. And sender is restamped on every send, so after one relay the originator could not be recovered.
  • build_governed_identity trusted whatever agent_id it was handed. An executor id means no boundary at all. No peer context reaches it today (traced on security(agents): an agent refused an action can get another agent to do it — no per-peer identity #16950), but nothing structural stopped a future path from forwarding one.

The common form, per ruling F1: security/authority.py keeps the four authority surfaces side by side, each with its own meet.

  • Restrictions (approval gates, forbidden tools) combine by union.
  • Grants (RBAC permissions, A2A capabilities) combine by intersection.
  • A surface a hop does not use is top, never bottom. Otherwise a hop with nothing to do with A2A would zero out a chain it never touched. Both directions are tested.

What Changed

Commit Change
771e06032 MessageHeader.originator (set once, never overwritten by a relay) and chain. protocols/message_origin.py makes relays structural: a send made while handling a request continues that chain, even through helpers that never saw the inbound message. AgentRequest.originator/chain are filled by the handler. The if __name__ demo moves to protocols/agent_communication_demo.py to pay for the lines (805 → 750)
3d9f3ba9a Authority and its meet. chat_workflow/run_authority.py reads a run's authority and what a child inherits. Internal engine: the child carries the parent's work item, the union of both runs' gates, and the parent's authority, which the forbidden-work seam unions into the child's boundary. claude_code engine: it runs its own tool loop and cannot ask for approval, so the parent's gated tools and boundary are refused there (fail closed). Corrected in 3a5674e84: that claim was overstated as first written. Tokens with no claude_code tool (git_push, http_post, rotate_credentials, publish, …) were dropped by the mapping. A token the parent holds with no finer tool now takes Bash away; see the row below. Its only other reach, AutoBot's MCP server, exposes knowledge, memory and agent-list reads only (mcp_server/autobot_server.py:655-849). #16958's xfail marker is removed in this commit.
5349f789c The delegate handler passes the parent run, so the handler test now asserts the parent id on the run it receives. A new test checks that the GH#11266 dispatch log still names the parent
3a5674e84 Review fix. claude_tools_refusing(): the child's profile boundary is mapped as before, where dropping an unmappable token is the accepted behaviour. A token the parent holds with no finer claude tool disallows Bash, because a gate has no fallback. The gap was latent: every bounded profile forbids the shell group, so today's children already lose Bash. The tests model a Bash-keeping child and assert that every approval category takes Bash away, with a control
89f82f4d0, 17ca3de36 c0's review: a genuine multi-hop test. message_origin_test.py::test_two_real_round_trips_carry_the_originator_to_the_last_hop: A asks B, and B, while handling it, asks C; both replies come back. The real send_request, send_message/stamp, _handle_message, BaseAgent._handle_communication_request and send_response run on every hop, over the JSON wire format. Only the transport is a stand-in that delivers by recipient, because the real channels deliver a request to its sender, not its recipient (#16986). C sees originator A and chain [A, B] although B's onward request was built with no originator. Each delivery runs in a fresh context, as a receiver's poller would, so no origin leaks from the sender. The base branch is merged in (it was 6 commits behind)
8e8596aef apply_role marks the role it pins and removes a client-supplied marker, the same write-or-remove rule as apply_auth_role. build_governed_identity honours an executor id only when the overlay pinned it; an unpinned claim gets the default boundary. A client naming a bounded profile is still honoured: that is GH#11186's recorded self-restriction (test_apply_role_none_leaves_context_unchanged), kept. A guard pins the builder's three callers

Trust boundary (owner decision 4): the new header fields are not authenticated. Until #16962 lands, the agent bus's trust boundary is Redis write access: a process with it can forge sender and originator as any registered agent. This PR fixes the confused deputy (a well-behaved agent acting with its own authority on another's behalf), not raw-bus forgery.

Not in this PR:

Acceptance criteria

Refs, not Closes. Nothing is ticked; the merger verifies against merged code.

AC Delegation Peer channel A2A
AC1 (originator carried end to end) delivered delivered at the protocol level (two-hop round-trip test, routing stand-in per #16986) #16969
AC2 (authorised against the originator; relaying cannot widen) delivered (Authority.meet, laundering test) not delivered: #16998 #16969
AC3, AC5 untouched untouched untouched

Verification

  • The repo's pre-push hook ran and passed on each push: delegation_laundering_test.py (marker removed), message_origin_test.py, run_authority_test.py, security/authority_test.py, governed_identity_producer_test.py, delegation_test.py, and test_agent_registry_fail_closed.py. That includes run_authority_test.py's controls: without the inheritance, research_agent may call http_get and the claude_code child may Write/Edit.
  • CI is the evidence for the rest of the suite.
  • Size baselines: protocols/agent_communication.py 805 → 750 and chat_workflow/tool_handler.py 3729 → 3728. Both files are updated in both baselines.

Single-issue rationale

This is the core of #16950. The A2A half is split into #16969 (the coordinator approved the split) so that each stays one honest review. It closes nothing because #16950's A2A criteria, AC3 and the follow-ups are still open, and #16950 closes when those land.

Model Used

Claude Opus 5 (claude-opus-5)

#16950)

BaseAgent._handle_communication_request built its AgentRequest from the
payload and never read the header, so a peer's request was
indistinguishable from the agent's own -- the point where authorization
needs the originator is where it was dropped. And MessageHeader.sender is
restamped on every send, correctly, so once one relay happened the
originator was unrecoverable.

- MessageHeader gains originator (set once, never overwritten by a relay)
  and chain (every hop, originator first).
- protocols/message_origin.py: stamp() records each hop; acting_for() and
  a context variable make relays structural -- a message sent while
  handling a request continues that request's chain, even through helpers
  that never saw the inbound message. origin_of() reads an inbound header,
  treating a pre-field header's sender as its originator, never as nobody.
- AgentRequest gains originator and chain; the handler fills them and runs
  process_request inside acting_for.

Nothing authenticates these fields: until #16962, the bus's trust boundary
is Redis write access (#16946, owner decision 4). The if-main demo moves to
protocols/agent_communication_demo.py to pay for the lines in a file at
its size ceiling (805 -> 750).
…so it cannot do what the parent is held from (#16950)

_handle_delegate_tool passed the child only parent_agent_id (for a log
line) and auth_role; the child was built from its own profile alone, so
the parent's approval gates and work item were dropped. A parent held
from write_file by its work item's "writing files" gate could delegate
the write, and the child ran it unapproved.

security/authority.py is the common form ruled on #16950 (F1): the four
authority surfaces side by side, each with its own meet -- restrictions
(approval gates, forbidden tools) by union, grants (RBAC permissions, A2A
capabilities) by intersection, and a surface a hop does not use is top,
never bottom. chat_workflow/run_authority.py reads a run's authority and
what a child inherits from it.

- internal engine: the child ctx carries the parent's work item, the union
  of both runs' gates, and the parent's authority, which the forbidden-work
  seam now unions into the child's boundary;
- claude_code engine: it runs its own tool loop and cannot ask for
  approval, so the parent's gated tools and boundary are refused there
  (fail closed). Its only other reach, AutoBot's MCP server, exposes reads.

Removes #16958's strict-xfail marker in the same commit as the fix.
…#16950)

The delegate handler now passes the parent run (parent=ctx) so the child
can inherit its authority; run_delegated_subtask reads the parent's
agent_id from it for the GH#11266 dispatch log. The handler test asserted
the old parent_agent_id keyword, so it now asserts the id on the run it
receives. A new test checks that the log still names the parent when only
the parent run is passed, so the observability contract holds end to end.
…entity (#16950)

build_governed_identity trusted the agent_id in whatever dict it was
handed, and an executor (unbounded) id resolves to no boundary at all. No
peer's context reaches it today (traced on #16950), but nothing
structural stopped a future path from forwarding one.

- session_role.apply_role now marks the role it pins under
  PINNED_ROLE_CONTEXT_KEY, and removes a client-supplied marker, mirroring
  apply_auth_role's write-or-remove rule.
- build_governed_identity honours an executor id only when the overlay
  pinned it. An unpinned executor claim is renamed to an id no profile
  holds, so it resolves to the default boundary with a warning, as an
  unknown id does (GH#13588).
- A client naming a *bounded* profile is still honoured: that only
  restricts its own run, and GH#11186's self-restriction was a recorded
  decision (test_apply_role_none_leaves_context_unchanged), kept.
- A guard pins the builder's three callers, so a new producer has to show
  where its context came from.
@coderabbitai

coderabbitai Bot commented Sep 18, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • main
  • release

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Advanced

Run ID: 97786b3c-106d-4e56-8a47-0533b6f028e4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

Notice: 29 open PRs — past the runaway threshold (25)

There is no PR queue limit, and this is not a request to defer this PR. Work proceeds one issue at a time without a cap on open PRs; review capacity is the constraint.

This notice only means the count is high enough to be worth a glance for a runaway — something opening PRs in a loop, or a merge pipeline that has stalled so nothing is draining.

Currently open:

If the queue is draining normally, ignore this. Otherwise:

  1. Check whether CI is dispatching at all — see the ci-dispatch-watchdog status on these PRs
  2. Merge the ones whose CI has finished and review has passed: gh pr merge <number> --squash --delete-branch
  3. Look for a loop opening near-identical PRs

Warn-only runaway detector — .github/workflows/pr-queue-gate.yml. It never blocks a merge.

@mrveiss

mrveiss commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Review verdict: BLOCK — the claude_code engine's refusal is not fail-closed for most approval categories. Everything else in this PR is solid and should survive unchanged; this is one mapping gap, but it reopens exactly the laundering the PR exists to close.

🔴 Unmapped gated tokens are silently dropped, and Bash stays open

_run_claude_code_subagent computes the child's refused set as resolve_forbidden_tools(agent_type) | parent.forbidden_tools | gated_tools(parent.approval_gates) (delegation.py:120-121) and translates it with forbidden_to_claude_tools. That translation maps only write_file/edit_file to Write/Edit, and _BASH_TOKENS to Bash:

_BASH_TOKENS = frozenset(INFRA_AND_SHELL_TOOLS + TERMINAL_TOOLS + FILE_DELETE_TOOLS
                         + (FILE_WRITE_TOOLS minus write/edit))

def _claude_tool_for(token):
    if token in _FILE_TOOL_FOR: return _FILE_TOOL_FOR[token]
    return "Bash" if token in _BASH_TOKENS else None      # everything else -> None

def forbidden_to_claude_tools(forbidden):
    return sorted({t for t in (_claude_tool_for(f) for f in forbidden) if t})   # None filtered out

Resolved against tool_catalogue.py — every one of these maps to nothing, so it is dropped:

Token Gate it belongs to Result
git_push, git_commit, git_force_push pushing commits dropped
git_reset discarding local changes dropped
http_post, http_delete, send_request sending externally dropped
rotate_credentials rotating credentials dropped
publish publishing dropped

_build_cli_command is a pure blocklist, with Bash available by default. So a parent whose work item holds "pushing commits" delegates to a claude_code child whose --disallowedTools never contains Bash, and that child runs git push, git reset --hard or curl -X POST through Bash — doing exactly what the parent was held from doing.

The shipped profiles do not mask it: research_agent, documentation_agent and coordination_agent forbid only INFRA_AND_SHELL_TOOLS, which contains none of these tokens, so Bash stays open whatever the child's own profile says.

The internal engine is correct, which is why this is specific to one path. _run_internal_subagent puts the raw category into requires_approval_before, and enforce_work_item_approval matches the actually-dispatched tool name against it — no lossy translation, so every category holds.

Why a gate needs a different rule from a profile boundary

For a profile's own forbidden_work, dropping an unmapped token is a documented, accepted behaviour: the profile's other tokens still constrain the agent. A gate has no such fallback — it is the only thing standing between the child and the action. And the child cannot ask a human for approval, which is exactly why this PR refuses gated tools on this engine rather than holding them. A refusal that cannot be expressed must fall back to refusing the coarse tool, not to allowing everything.

Fix

On the gate path, deny by default: if any gated or forbidden token has no finer CLI mapping, add Bash to --disallowedTools. Leave the profile-boundary path's existing behaviour alone. Then add a run_authority_test.py case per category — pushing commits, discarding local changes, sending externally, rotating credentials, publishing — each asserting Bash is disallowed, mirroring test_a_gate_the_parent_holds_is_refused_to_the_child. Today only "writing files" and "destructive operations" are exercised, which is why CI is green.

The consequence is intended: a claude_code child of a parent held on pushing or external sending gets no Bash. If the work needs to push, the parent — with a human — does it.

This is separate from the owner's decision to accept an unenforceable Bash gap for Company OS org roles on claude_code. That concerns a role's standing permissions. This is a specific work item explicitly holding an action for a human, and a delegated child must not be able to take it.

Verified sound

  • The meet, in both directions. security/authority.py unions approval_gates and forbidden_tools and intersects permissions and capabilities, with None as ⊤, never ⊥. Tested on both sides: test_a_hop_without_capabilities_does_not_zero_a_chain_that_has_them, plus the ⊥-versus-⊤ contrast and commutativity. test_a_tool_the_parent_forbade_is_blocked_for_the_child has a control proving the block comes from inheritance.
  • Trusted producer. An executor agent_id is honoured only when apply_role pinned it; a forged pin does not survive the overlay; the three producers are locked by an AST test. The retained self-restriction exception can only narrow: an unpinned id becomes unpinned:<id> and falls to the default forbidden set.
  • test(security): a delegated subagent runs a write its parent is held from (#16950) #16958's xfail marker is removed, and the test now asserts the real enforce_work_item_approval holds the child's write.
  • Ratchets only lowered: tool_handler.py 3729 → 3728, and agent_communication.py 805 → 749, via a genuine extraction of the demo into its own module. Identical in both registries.
  • No swallowed errors, get_logger in every new module, functions within limits.

Labelled blocked until the gate path fails closed.

…ash when no finer tool covers it (#16950)

Review of #16966: forbidden_to_claude_tools drops any token with no
claude_code tool, and only write_file/edit_file and the shell group map.
git_push, git_commit, git_reset, http_post, send_request,
rotate_credentials and publish all mapped to None and were filtered out,
so a parent held on "pushing commits" would hand a Bash-keeping child
nothing that stops `git push`. For a profile boundary, dropping an
unmappable token is the accepted behaviour -- other tokens still bound the
agent. A gate has no such fallback.

claude_tools_refusing() keeps the two paths apart: the child's own profile
is mapped as before, and a token the parent holds (gated or forbidden)
with no finer mapping disallows Bash. The held action then happens in the
parent, with a human.

The gap was latent, not live: every bounded profile forbids the shell
group, which maps to Bash, so today's children already lose it. The tests
model a Bash-keeping child with an empty boundary and assert every
approval category takes Bash away, plus the control that a child with
nothing held keeps it.
@mrveiss

mrveiss commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Fixed in 3a5674e84, as proposed: on the held path only, a token with no finer claude_code tool disallows Bash. The profile-boundary path is unchanged.

Tests: run_authority_test.py::TestAHeldActionHasNoBashFallback covers every APPROVAL_CATEGORY_TOOLS category (parametrized, so a new category is covered automatically), plus the control that a child with nothing held keeps Bash. A unit test pins that an unmappable token in a profile still leaves Bash alone.

Correcting the scenario: the git push was not reachable on main with today's profiles. All four bounded profiles forbid INFRA_AND_SHELL_TOOLS (orchestration/agent_registry.py:38,59,103,167), which maps to Bash, and delegation refuses unbounded targets. So a delegated claude_code child already lost Bash through its own boundary.

  • The gap was latent: it opened for the first profile that keeps Bash.
  • Why CI stayed green: my own controls ran against a child whose boundary already removed Bash. That, as well as the missing categories, is why it went unnoticed.
  • How the new tests prove it: they model the Bash-keeping child with an empty boundary.

The description's claude_code claim is corrected in place, with the fix commit named.

…y file containing "test" (#16950)

Review of #16969 found `"test" not in name` in its guard's module filter;
the same filter was here. It skipped production modules too -- on this
tree testing_coverage_analyzer.py and testing_pattern_analyzer.py were
never scanned -- so a producer added to one would pass the guard
unexamined. The filter now matches pytest.ini's python_files naming
(test_*.py, *_test.py) and conftest.py, with a control that a production
name containing "test" is scanned.
@mrveiss

mrveiss commented Sep 18, 2026

Copy link
Copy Markdown
Owner Author

Code-review pass on c81684f0a86 (current head).

Positive: does NOT invent a competing agent-identity scheme. originator/chain land on the existing MessageHeader (autobot-backend/protocols/agent_communication.py:128-134) per #16946 §3, and the Authority/Inheritance value types (autobot-backend/security/authority.py, autobot-backend/chat_workflow/run_authority.py) implement the owner's "ruling F1" documented on #16950, not an ad-hoc scheme. AgentIdentity itself is correctly left untouched here — its kind/name/tenant_id extension belongs to #16947, which this PR doesn't depend on.

Two findings before merge:

  1. CI is red, root cause = stale base, not this PR's code. issue-16950-originator-authority (this PR's head) is 6 commits behind its base issue-16950-delegation-laundering-test — missing bc7c69177/813dd8c4a/b739198c7/1e261392c/0a55fa9be, which fixed the frontend-fragmentation and i18n-untranslated ratchet baselines. That's exactly why python-suite shards 4/12 and 6/12 fail (frontend_fragmentation_ratchet_test.py, i18n_untranslated_ratchet_test.py) — unrelated to the diff here, but still a required check failing. Needs a merge/rebase from the base branch (not gh pr update-branch, since main isn't the base) before this can go green.

  2. No genuine multi-hop relay test for "the originator survives relays." autobot-backend/protocols/message_origin_test.py::test_a_peer_request_carries_its_originator_into_the_agent (~L1254-1268) is a single real hop (A→B via _handle_communication_request); the "onward" B→C leg is a manually-called stamp(self.onward, "relay_b") inside the test double, never an actual second send_message/_handle_communication_request round trip to a third agent. Combined with TestStamp/TestOriginOf (unit-level, no live agents), correctness for N>1 hops rests on composition-by-construction, not on an executed chain. Worth a real A→B→C integration test before this claim is relied on elsewhere (e.g. security(agents): authenticate agent-bus messages — a per-agent key issued at registration, HMAC on every message #16962's threat model).

  3. AC2 claim vs. diff, for the peer channel specifically. The PR description says "AC1 and AC2 are delivered for delegation and the peer channel." AC1 (identity carried end to end) — yes. AC2 ("authorised against its originator's permissions") — for delegation, yes (Authority.meet narrows at tool_dispatch_guards.py:enforce_forbidden_work and the claude_code --disallowedTools path). For the peer channel, agents/base_agent.py:_handle_communication_request now populates AgentRequest.originator/chain but still calls self.process_request(agent_request) directly — no hold_scopes or any authority/scope check reads originator anywhere in this diff. The confused-deputy gap on the peer channel is therefore still open; only the prerequisite (recovering who the originator is) landed. Recommend the PR body be corrected to scope AC2's peer-channel claim to "plumbing only, enforcement pending" so downstream readers (and feat(agents): deliver peer messages at the recipient's next turn, not mid-task #16948/security(agents): authenticate agent-bus messages — a per-agent key issued at registration, HMAC on every message #16962) don't assume it's closed.

None of this is a regression — it's solid incremental work — but (1) blocks merge outright per house CI rules, and (2)/(3) should be resolved or explicitly re-scoped before this is relied on as proof of the security property.

@mrveiss

mrveiss commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Why this was closed: GitHub closed it automatically when its base branch was deleted. It was not a review decision and not a carry.

This PR was stacked on issue-16950-delegation-laundering-test, the branch of #16958. #16958 was carried by vehicle #17047 at c3d8b4782, and its branch was then deleted during the 2026-09-19 consolidation. Deleting a PR's base closes the PR. The check for stacked PRs came too late for this one.

Nothing is lost:

A sweep of every unmerged PR closed since 2026-09-18 whose base branch is gone found this as the only uncarried case.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant