Authz READ says "the roster carries no secrets" — it is false today: READ walks every ticket reply and lifts any member's open question #678

Closed
opened 2026-10-03 21:02:21 +02:00 by ltms · 1 comment
Owner

Promised in the #669 adjudication (issue_comment-18175) and filed here. Verified by me in the main clone at 6f27522.

The comment

fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java:87-89:

// Observation is open to every authenticated role: a worker legitimately polls its own
// status, and the roster carries no secrets.
case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect();

The first clause is true. The second is false, and the comment is load-bearing: it is the stated reason the arm is as wide as it is. A reader deciding whether to add a role to that arm will read this sentence and conclude the blast radius is small.

What READ actually reaches

READ is not just the roster. FleetMcp.authzAction maps four tools onto it, and pollAction adds a fifth path:

case STATUS, LIST, PROFILES, WHOAMI -> Authz.Action.READ;
case POLL -> pollAction(str(arguments, "target"), str(arguments, "coordId"));
static Authz.Action pollAction(String target, String coordId) {
    if (!isBlank(coordId)) { return Authz.Action.COORD_READ; }
    return isBlank(target) ? Authz.Action.READ : Authz.Action.DRAIN;
}

So fleet_poll{ticket: "..."} — no target, no coordId — is READ, not DRAIN. Only the target-inbox form is gated at DRAIN.

1. Any holder of READ can read every delegation's reply

Ticket ids are a plain sequential counter, not a capability. MessageService.java:

:342   private final AtomicLong ticketSeq = new AtomicLong();
:1282  String ticket = "task-" + ticketSeq.incrementAndGet();

task-1, task-2, task-3, … There is no owner check on the ticket-poll path — the ticket id is the whole authorization. A worker that holds READ can walk task-1..N and read the completed reply of every unit the lead has delegated this daemon lifetime, including units in other workers' scopes.

I did not have to theorise the walk: I did it myself in this session while collecting my own tickets, and a poll of a ticket I did not create returned its content.

2. fleet_status hands over another member's open question and the token that answers it

FleetMcp appends this to a status reply when the subject has a pending fleet_ask:

return text(base + "\n\n[question — worker is waiting for your answer]\n" + ask.question()
        + "\n\nAnswer it by calling fleet_send again with turnId=\"" + ask.turnId()
        + "\" and content set to your answer; the worker resumes the same turn.");

fleet_status is READ. So a READ holder gets another member's question text and its turnId. The question text is content from inside a brief. The turnId is the handle that answers it.

Answering needs SEND, which a worker does not have — so a worker can read the question but not inject the answer. That is the current mitigation, and it is one arm away: #669 Unit A proposes splitting SEND, and an architect already holds SEND today. An architect therefore holds both halves right now.

Why this matters beyond the comment

This is not a request to reword a comment. The comment is the symptom; the real finding is that READ is one action covering three different exposures — the roster (genuinely harmless), ticket replies (cross-scope content), and pending-question state (content plus an actionable handle). They are lumped together, so there is no way to grant one without the others.

That split is exactly #669 Unit A, which I ruled must land first. This ticket is the evidence for why, written down so the reason survives if Unit A gets re-litigated.

Worth noting how this was found: one architect established the ticket-walk mechanism and used it to argue that DRAIN could safely stay denied to a new role. The same mechanism also shows READ is already too wide. One fact, two conclusions, and the narrower reading is the comfortable one.

What to do

Scope is deliberately small, because the structural fix is Unit A:

  1. Fix the comment so it stops asserting something false. Say what the arm covers, without the history or the ticket number — the comment describes the code as it is.
  2. Decide the ticket-poll owner check. Either a non-primary caller may poll only tickets it is the subject of, or the ticket-poll path moves off READ. Owner-scoping is the smaller change and does not wait for Unit A.
  3. Leave fleet_status's question leak to Unit A, which splits status out of READ. Record it here rather than patching the tool in isolation.

Acceptance criteria

  1. A worker polling a ticket whose subject is another session is refused. The same worker polling its own ticket still succeeds — both directions asserted, so the test is not satisfied by refusing everything.
  2. A primary polling any ticket still succeeds.
  3. Authz.java contains no claim about what the observable surface holds that is not true of the arm as written.

Not verified by me

Whether the metrics endpoint behind METRICS carries anything beyond counters. I read the authorization arm, not the metrics payload. If it carries session names or ticket content, that belongs in this ticket too.

Promised in the #669 adjudication (`issue_comment-18175`) and filed here. Verified by me in the main clone at `6f27522`. ## The comment `fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java:87-89`: ```java // Observation is open to every authenticated role: a worker legitimately polls its own // status, and the roster carries no secrets. case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect(); ``` The first clause is true. The second is false, and the comment is load-bearing: it is the stated reason the arm is as wide as it is. A reader deciding whether to add a role to that arm will read this sentence and conclude the blast radius is small. ## What `READ` actually reaches `READ` is not just the roster. `FleetMcp.authzAction` maps four tools onto it, and `pollAction` adds a fifth path: ```java case STATUS, LIST, PROFILES, WHOAMI -> Authz.Action.READ; case POLL -> pollAction(str(arguments, "target"), str(arguments, "coordId")); ``` ```java static Authz.Action pollAction(String target, String coordId) { if (!isBlank(coordId)) { return Authz.Action.COORD_READ; } return isBlank(target) ? Authz.Action.READ : Authz.Action.DRAIN; } ``` So `fleet_poll{ticket: "..."}` — no `target`, no `coordId` — is **`READ`**, not `DRAIN`. Only the target-inbox form is gated at `DRAIN`. ### 1. Any holder of READ can read every delegation's reply Ticket ids are a plain sequential counter, not a capability. `MessageService.java`: ``` :342 private final AtomicLong ticketSeq = new AtomicLong(); :1282 String ticket = "task-" + ticketSeq.incrementAndGet(); ``` `task-1`, `task-2`, `task-3`, … There is no owner check on the ticket-poll path — the ticket id is the whole authorization. A worker that holds `READ` can walk `task-1..N` and read the completed reply of every unit the lead has delegated this daemon lifetime, including units in other workers' scopes. I did not have to theorise the walk: I did it myself in this session while collecting my own tickets, and a poll of a ticket I did not create returned its content. ### 2. `fleet_status` hands over another member's open question and the token that answers it `FleetMcp` appends this to a status reply when the subject has a pending `fleet_ask`: ```java return text(base + "\n\n[question — worker is waiting for your answer]\n" + ask.question() + "\n\nAnswer it by calling fleet_send again with turnId=\"" + ask.turnId() + "\" and content set to your answer; the worker resumes the same turn."); ``` `fleet_status` is `READ`. So a `READ` holder gets another member's question text **and its `turnId`**. The question text is content from inside a brief. The `turnId` is the handle that answers it. Answering needs `SEND`, which a worker does not have — so a worker can read the question but not inject the answer. That is the current mitigation, and it is one arm away: #669 Unit A proposes splitting `SEND`, and an architect already holds `SEND` today. An architect therefore holds both halves right now. ## Why this matters beyond the comment This is not a request to reword a comment. The comment is the symptom; the real finding is that **`READ` is one action covering three different exposures** — the roster (genuinely harmless), ticket replies (cross-scope content), and pending-question state (content plus an actionable handle). They are lumped together, so there is no way to grant one without the others. That split is exactly #669 Unit A, which I ruled must land first. **This ticket is the evidence for why**, written down so the reason survives if Unit A gets re-litigated. Worth noting how this was found: one architect established the ticket-walk mechanism and used it to argue that `DRAIN` could safely stay denied to a new role. The same mechanism also shows `READ` is already too wide. One fact, two conclusions, and the narrower reading is the comfortable one. ## What to do Scope is deliberately small, because the structural fix is Unit A: 1. **Fix the comment** so it stops asserting something false. Say what the arm covers, without the history or the ticket number — the comment describes the code as it is. 2. **Decide the ticket-poll owner check.** Either a non-primary caller may poll only tickets it is the subject of, or the ticket-poll path moves off `READ`. Owner-scoping is the smaller change and does not wait for Unit A. 3. Leave `fleet_status`'s question leak to Unit A, which splits status out of `READ`. Record it here rather than patching the tool in isolation. ## Acceptance criteria 1. A worker polling a ticket whose subject is another session is refused. The same worker polling its own ticket still succeeds — both directions asserted, so the test is not satisfied by refusing everything. 2. A primary polling any ticket still succeeds. 3. `Authz.java` contains no claim about what the observable surface holds that is not true of the arm as written. ## Not verified by me Whether the metrics endpoint behind `METRICS` carries anything beyond counters. I read the authorization arm, not the metrics payload. If it carries session names or ticket content, that belongs in this ticket too.
ltms closed this issue 2026-10-03 22:27:56 +02:00
Author
Owner

Closed by PR #687 (#669 Unit A), merged as 7c458e8 on main (pushed; main is now 2eb2d61).

The false comment is gone, and the reason it was false is gone with it: READ no longer covers
ticket polling and session status. Those moved to a new TASK_READ action, so the narrowed READ
really does cover only the roster, profiles and identity.

TASK_READ was given exactly the grant READ carried before, so no role gained or lost anything.
A reviewer compared all 156 (role, action, target) pairs against origin/main and found 0
mismatches, and I confirmed the grants by reading Authz myself. Merged tree: 1942 tests, 0
failures, BUILD SUCCESS
.

Closing.

Closed by PR #687 (#669 Unit A), merged as `7c458e8` on `main` (pushed; `main` is now `2eb2d61`). The false comment is gone, and the reason it was false is gone with it: `READ` no longer covers ticket polling and session status. Those moved to a new `TASK_READ` action, so the narrowed `READ` really does cover only the roster, profiles and identity. `TASK_READ` was given exactly the grant `READ` carried before, so no role gained or lost anything. A reviewer compared all 156 (role, action, target) pairs against `origin/main` and found 0 mismatches, and I confirmed the grants by reading `Authz` myself. Merged tree: **1942 tests, 0 failures, BUILD SUCCESS**. Closing.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: fleet/fleetd#678