Skip to content

Stop steered guest messages and session setup inheriting the owner's trust - #19

Merged
QuicksilverSlick merged 1 commit into
mainfrom
fix/turn-authority-holes
Sep 11, 2026
Merged

QuicksilverSlick merged 1 commit into
mainfrom
fix/turn-authority-holes

Conversation

@QuicksilverSlick

Copy link
Copy Markdown
Owner

Two holes in how a turn's trust is decided

Both are latent today, because the shipped permission mode is bypassPermissions and every request is auto-approved anyway. Both would defeat the guest gate the moment it turns on. The two critics in the approval-loop plan found them independently, and both said to close them first, as their own small PR, before anything turns the gate on.

1. A guest's steered message ran with the owner's authority

Authority was decided once, at dispatch, from the batch that started the turn. MultipleEventHandling defaults to Steer, so when a guest posted while the owner's turn was running, steer_or_interrupt wrote the guest's words straight into that turn — which was still marked Owner. The agent's next tool request was then approved as though the owner had asked.

A guest only had to wait until the owner was mid-turn.

Fix. A steer now carries its author's authority. SteerRequest.authority is a required field, so every construction site must state it. The read loop lowers the turn's authority to that author's before the steer is written. The loop is single-threaded, so no permission request can be decided in between.

Within a turn, trust only ever goes down (TurnAuthority::weaker).

The steer's authority is taken from the event's signing key, not the derived "effective" author. That is the exact input dispatch uses, so an event can never be trusted more as a steer than it would be if it had started the turn. Dispatch and steering now share one comparison, authority_for_author, so they cannot drift apart.

Lower, not interrupt. The steer stays instant and the turn keeps running. The cost: the owner's own later tool calls in that turn are also decided as a guest. That's correct — the agent can't tell which parts of its plan came from whom. Interrupting and restarting would be equally safe, just more disruptive.

2. Session setup reused the previous turn's authority

current_turn_authority was written only at startup and at prompt dispatch, and never reset. read_until_response, the loop that runs session/new and set_config_option, decided any permission request with whatever the last turn left behind. After an owner turn, a guest batch that opened a new session ran its setup as Owner.

Fix. handle_permission_request now takes authority as a required parameter instead of reading the field. The setup loop passes Guest explicitly, because no turn is running there. The prompt loop passes the turn's current authority.

Also

The TurnAuthority doc said Owner covered "the owner, or one of the owner's own sibling agents." Dispatch has only ever compared against the owner's key, so siblings have always classified as Guest. The doc now says so.

Tests

Test Kind Proves
a_turn_is_only_as_trusted_as_its_least_trusted_author pure trust only goes down
only_the_owners_own_key_counts_as_the_owner pure case-insensitive; an unknown owner verifies nobody
a_guest_steer_lowers_the_turn_before_the_agent_can_ask wire the regression: after a guest's steer, the harness answers reject_once
an_owner_steer_leaves_an_owner_turn_trusted wire the control: steering itself doesn't lower trust
setup_decides_permissions_as_a_guest_even_after_an_owner_turn wire after an owner turn, setup still answers reject_once

The wire tests' fake agent blocks on read until the steer arrives, so the order is fixed — steer written, then the request, then the answer. There's no timing race.

The three wire tests are for Linux CI. On Windows, Rust resolves bash to WSL's launcher before PATH, and WSL can't see a C:/… path — the same reason the crate's other bash-script tests fail there.

Verification

buzz-acp (Windows)   903 passed, 32 failed
                     = 3 new wire tests + 29 pre-existing failures
those exact 29,      0 passed, 29 failed   <- identical failing set: no regressions
on unchanged main
cargo fmt            clean
clippy -D warnings   0 findings

The failing set was compared, not only the count — equal counts could hide one test flipping each way.

🤖 Generated with Claude Code

…wner trust

Two holes in how a turn's authority is decided. Both are latent today: the
shipped permission mode is bypassPermissions, so every request is auto-approved
anyway. Both would defeat the guest gate the moment it turns on. The
approval-loop plan's critics found them independently.

1. STEERED GUEST TEXT RAN WITH THE OWNER'S AUTHORITY.
   Authority was decided once, at dispatch, from the batch that started the
   turn. MultipleEventHandling defaults to Steer, so when a guest posted while
   the owner's turn was running, steer_or_interrupt wrote the guest's words
   straight into that turn, which was still marked Owner. The agent's next tool
   request was then approved as though the owner had asked. A guest only had
   to wait until the owner was mid-turn.

   A steer now carries its author's authority. SteerRequest has a required
   `authority` field, so every construction site must state it. The read loop
   lowers the turn's authority to that author's BEFORE the steer is written.
   The loop is single-threaded, so no permission request can be decided in
   between. Within a turn, trust can only go down, never up
   (TurnAuthority::weaker).

   The steer's authority comes from the event's signing key, not the derived
   "effective" author. That is the exact input dispatch uses, so an event can
   never be trusted more as a steer than it would be if it had started the
   turn. Dispatch and steering now share one comparison, authority_for_author,
   so the two cannot drift apart.

   Lowering was chosen over interrupting. The steer stays instant and the turn
   keeps running. The cost is that the owner's own later tool calls in that
   turn are also decided as a guest. That is correct: the agent cannot tell
   which parts of its plan came from whom.

2. SESSION SETUP REUSED THE PREVIOUS TURN'S AUTHORITY.
   current_turn_authority was written only at startup and at prompt dispatch,
   and never reset. read_until_response, the loop that runs session/new and
   set_config_option, decided any permission request with whatever the last
   turn had left behind. After an owner turn, a guest batch that opened a new
   session ran its setup as Owner.

   handle_permission_request now takes authority as a required parameter
   instead of reading the field. The setup loop passes Guest explicitly, because
   no turn is running there. The prompt loop passes the turn's current
   authority.

Also corrects the TurnAuthority doc. It said Owner covered "the owner, or one of
the owner's own sibling agents". Dispatch has only ever compared against the
owner's key, so sibling agents have always classified as Guest.

Tests, 5 new:
- a_turn_is_only_as_trusted_as_its_least_trusted_author: pure, runs on every
  platform.
- only_the_owners_own_key_counts_as_the_owner: pure. Case-insensitive, and an
  unknown owner verifies nobody.
- a_guest_steer_lowers_the_turn_before_the_agent_can_ask: over the wire. A fake
  agent blocks until the steer arrives, then asks; the harness must answer
  reject_once.
- an_owner_steer_leaves_an_owner_turn_trusted: the control. Steering itself does
  not lower trust; the owner's own steer is still approved.
- setup_decides_permissions_as_a_guest_even_after_an_owner_turn: over the wire.
  After an owner turn, setup must still answer reject_once.

The three wire tests are for Linux CI. On Windows, Rust resolves `bash` to WSL's
launcher before PATH, and WSL cannot see a C:/ path. That is the same reason the
crate's other bash-script tests fail there.

Verified on Windows: buzz-acp 903 passed, 32 failed. The 32 are the 3 new wire
tests plus 29 pre-existing failures. Run against unchanged main, those exact 29
give 0 passed, 29 failed. The failing set is identical, so nothing regressed.
cargo fmt is clean; clippy -D warnings reports 0 findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@QuicksilverSlick
QuicksilverSlick merged commit db30851 into main Sep 11, 2026
193 of 201 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant