A caller with SEND but no TASK_READ gets a ticket it can never read — and the accept text tells it to poll #768

Open
opened 2026-10-05 11:26:34 +02:00 by ltms · 0 comments
Owner

Reported by the trinotes observer today and verified in the code.

What happens

fleet_send{sessionId: <another observer pane>, wait:false}
  -> "accepted — task delegated. Poll fleet_poll with ticket=task-2c8e2b-1"
fleet_poll{ticket: task-2c8e2b-1}
  -> "forbidden: observer:term_65d106559d7e43 may not TASK_READ"

The success message instructs the caller to call a tool the gate refuses it.

Why, in the code

Creating a ticket is gated by SEND alone. There is no TASK_CREATE capability: FleetMcp.sendAsync runs no role branch, it just calls messages.sendAsync(...) and returns the accept text.

Reading one needs TASK_READ, and auth/Authz.java:177 is:

case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect();

SEND is granted more widely — auth/Authz.java:143 includes caller.isObserver() && knownObserverTarget.test(targetSession), and a collaborator holds SEND too. So every caller that holds SEND but not TASK_READ can create a ticket it can never read: an observer, and a collaborator.

This is the inverse of #669's shape. There the grant was dropped at the second gate; here the first gate grants something the second gate makes useless.

It also contradicts the documented contract

The canonical block in CLAUDE.md says an observer is authorized

to SEND only to a target that resolves as an observer too — never to a lead, a collaborator, or a spawned member, and never a ticket.

The code does hand it a ticket. So the shipped instruction surface and the shipped behaviour disagree, and by this repo's own "the prompt is part of the product" rule one of the two has to move.

For a collaborator the doc is already consistent — it says a collaborator "cannot read a ticket, so you cannot collect a delegation's reply" — but it does not say the collaborator is handed one anyway.

Two defensible fixes — this needs a decision, not a patch

(a) Refuse the async branch. Make wait:false require TASK_READ, so an observer's and a collaborator's send is synchronous only. This matches the documented contract and the intent behind it: a ticket is a delegation artifact, and observer-to-observer traffic is coordination. The refusal must name the reason, not just the capability.

(b) Let the creator read its own ticket. The ownership mechanism already exists — sendAsync takes a creator Principal and MessageService.poll(String, String) "only hands the result back to the same caller". So this is a narrow grant: TASK_READ for a ticket whose recorded creator is you, and nothing else.

I lean to (a), and the reason is #705: letting an observer create and collect tickets makes it an orchestrator with no plan behind it, which is what that ticket guarded against. (b) is cheaper and friendlier but widens the role. Whoever takes this should read #705 first and record the decision on this ticket.

Related

A separate, smaller problem found in the same report: the SEND refusal message does not say the rule is target-dependent. Filed separately.

Note that today the async path is not merely useless to an observer — combined with #757 the send can also fail silently, because the sender is told accepted, the failure is logged where it cannot read, and the ticket is unreadable to it. Fixing this ticket removes one of those three blindfolds.

Reported by the `trinotes` observer today and verified in the code. ## What happens ``` fleet_send{sessionId: <another observer pane>, wait:false} -> "accepted — task delegated. Poll fleet_poll with ticket=task-2c8e2b-1" fleet_poll{ticket: task-2c8e2b-1} -> "forbidden: observer:term_65d106559d7e43 may not TASK_READ" ``` The success message instructs the caller to call a tool the gate refuses it. ## Why, in the code Creating a ticket is gated by `SEND` alone. There is no `TASK_CREATE` capability: `FleetMcp.sendAsync` runs no role branch, it just calls `messages.sendAsync(...)` and returns the accept text. Reading one needs `TASK_READ`, and `auth/Authz.java:177` is: ```java case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect(); ``` `SEND` is granted more widely — `auth/Authz.java:143` includes `caller.isObserver() && knownObserverTarget.test(targetSession)`, and a collaborator holds `SEND` too. So **every caller that holds `SEND` but not `TASK_READ` can create a ticket it can never read**: an observer, and a collaborator. This is the inverse of #669's shape. There the grant was dropped at the second gate; here the first gate grants something the second gate makes useless. ## It also contradicts the documented contract The canonical block in `CLAUDE.md` says an observer is authorized > to `SEND` only to a target that resolves as an observer too — never to a lead, a collaborator, or a spawned member, **and never a ticket**. The code does hand it a ticket. So the shipped instruction surface and the shipped behaviour disagree, and by this repo's own "the prompt is part of the product" rule one of the two has to move. For a collaborator the doc is already consistent — it says a collaborator "cannot read a ticket, so you cannot collect a delegation's reply" — but it does not say the collaborator is handed one anyway. ## Two defensible fixes — this needs a decision, not a patch **(a) Refuse the async branch.** Make `wait:false` require `TASK_READ`, so an observer's and a collaborator's send is synchronous only. This matches the documented contract and the intent behind it: a ticket is a *delegation* artifact, and observer-to-observer traffic is coordination. The refusal must name the reason, not just the capability. **(b) Let the creator read its own ticket.** The ownership mechanism already exists — `sendAsync` takes a `creator` `Principal` and `MessageService.poll(String, String)` "only hands the result back to the same caller". So this is a narrow grant: `TASK_READ` for a ticket whose recorded creator is you, and nothing else. I lean to **(a)**, and the reason is #705: letting an observer create and collect tickets makes it an orchestrator with no plan behind it, which is what that ticket guarded against. (b) is cheaper and friendlier but widens the role. Whoever takes this should read #705 first and record the decision on this ticket. ## Related A separate, smaller problem found in the same report: the `SEND` refusal message does not say the rule is target-dependent. Filed separately. Note that today the async path is not merely useless to an observer — combined with #757 the send can also fail silently, because the sender is told `accepted`, the failure is logged where it cannot read, and the ticket is unreadable to it. Fixing this ticket removes one of those three blindfolds.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#768