From 9dea2899752ce580d64ccefad96fab7e6c432959 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Sat, 3 Oct 2026 22:12:13 +0200 Subject: [PATCH] fleetd #669 Unit A: split SEND and READ into their real call shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEND covered three different call shapes under one action (local sessionId delivery, the coordId cross-host broker route, and the turnId answer-a-blocked-worker form). READ covered both roster/ profile/identity observation and ticket-polling/session-status. Split each into its own Authz.Action — SEND/COORD_SEND/ANSWER and READ/TASK_READ — with every new action granted to exactly who held the combined action before, on both the MCP and REST entry paths. Also fixes fleetd #678's Authz.java comment: READ no longer claims "the roster carries no secrets" for ticket replies and pending questions, because those now live under TASK_READ. --- .../main/java/dev/ltms/fleet/auth/Authz.java | 35 +++++-- .../java/dev/ltms/fleet/mcp/FleetMcp.java | 64 ++++++++----- .../java/dev/ltms/fleet/rest/FleetApp.java | 58 ++++++++---- .../java/dev/ltms/fleet/auth/AuthzTest.java | 31 +++++++ .../dev/ltms/fleet/mcp/FleetMcpAuthzTest.java | 91 ++++++++++++++++--- .../dev/ltms/fleet/rest/FleetAppAuthTest.java | 33 ++++++- 6 files changed, 249 insertions(+), 63 deletions(-) diff --git a/fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java b/fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java index bc505b3..fd5a617 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java +++ b/fleetd/src/main/java/dev/ltms/fleet/auth/Authz.java @@ -20,16 +20,22 @@ public final class Authz { SPAWN, /** Tear a worker peer down. */ STOP, - /** Deliver a turn to a session (or answer a worker's question). */ + /** Deliver a turn to a local session, addressed by {@code sessionId}. */ SEND, + /** Resolve a worker's blocked question and resume its turn, addressed by {@code turnId}. */ + ANSWER, + /** Address a peer lead on another daemon over the coordination broker, by {@code coordId}. */ + COORD_SEND, /** A worker's terminal reply for its own turn. */ REPLY, /** A worker's mid-turn question to the primary. */ ASK, /** Collect held replies from a session's inbox. */ DRAIN, - /** Read-only observation: status, roster, profiles, task polling. */ + /** Read-only roster, profile, and identity observation: no ticket, task, or turn state. */ READ, + /** Poll a ticket, or read a session's status. */ + TASK_READ, /** * Read (never ack) this daemon's own held lead-to-lead coordination mail (fleetd #421). * @@ -71,11 +77,19 @@ public final class Authz { // escalating into the orchestrator role. case SPAWN, STOP, DRAIN, HANDOVER -> caller.isPrimary(); - // Delivering a turn is open to the primary and the architect: an architect delegates - // to workers (that is the role's point) but still has no lifecycle rights. A worker is - // excluded — sending would be it escalating. + // Delivering a turn to a local session is open to the primary and the architect: an + // architect delegates to workers (that is the role's point) but still has no lifecycle + // rights. A worker is excluded — sending would be it escalating. case SEND -> caller.isPrimary() || caller.isArchitect(); + // Same grant as SEND. Resolving a worker's blocked question is part of delegating to + // it, not a separate capability. + case ANSWER -> caller.isPrimary() || caller.isArchitect(); + + // Same grant as SEND. This leaves the daemon over the coordination broker rather than + // addressing a local session, but the caller who may do one may do the other. + case COORD_SEND -> caller.isPrimary() || caller.isArchitect(); + // The load-bearing rule: a caller acts only as the pane it occupies. CB-532 widened who // that can be — a lead answering another lead is replying for its OWN terminal, which // this already permits — while the rule itself is unchanged, and is what stops anyone @@ -84,10 +98,17 @@ public final class Authz { // unnamed primary (token/loopback, no pane) owns nothing and is still excluded. case REPLY, ASK -> caller.ownsSession(targetSession); - // Observation is open to every authenticated role: a worker legitimately polls its own - // status, and the roster carries no secrets. + // READ is roster, profile, and identity observation — fleet_list, fleet_profiles, and + // fleet_whoami — and carries no secrets: no ticket reply, no pending question, and no + // other session's turn state. Those live under TASK_READ. METRICS is the separate + // Prometheus scrape. Both stay open to every authenticated role. case READ, METRICS -> caller.isPrimary() || caller.isWorker() || caller.isArchitect(); + // Ticket polling and session status, open to every authenticated role the same as READ. + // Unlike READ, a holder may poll a ticket it did not create, or read another session's + // pending question and the turnId that answers it. + case TASK_READ -> caller.isPrimary() || caller.isWorker() || caller.isArchitect(); + // fleetd #421: reading held lead-to-lead mail is the primary's alone. An architect // holds READ today (CB-548), so "not primary" must mean not-architect here too — this // is coordination between leads, not observation of the roster. diff --git a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java index 8462046..ae59bf7 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -690,7 +690,7 @@ public final class FleetMcp { return null; // AuthorizationMode.UNENFORCED: authorization not enforced (fleetd #518) } if (Authz.permits(caller, action, target)) { - if (action != Authz.Action.READ) { + if (action != Authz.Action.READ && action != Authz.Action.TASK_READ) { AuditLog.allowed(caller, action, target); // reads would drown the trail } return null; @@ -1035,31 +1035,21 @@ public final class FleetMcp { } /** - * Which authorization action a {@code fleet_poll} call needs, decided by its arguments - * (fleetd #272, widened by fleetd #421). + * Which authorization action a {@code fleet_poll} call needs, decided by its arguments. * - *

{@code fleet_poll} is now three operations behind one tool name. With + *

{@code fleet_poll} is three operations behind one tool name. With * {@code ticket} it observes an async delegation and changes nothing, which is a {@link - * Authz.Action#READ}. With {@code target} it calls {@link MessageService#drainReplies} on that - * session -- the replies are removed from the inbox and a second call returns nothing -- so it - * is a {@link Authz.Action#DRAIN}, the same gate {@code fleet_ack} already uses for removing a - * single message, and the same one the REST path uses at {@code FleetApp.drainReplies}. With - * {@code coordId} it reads (never acks) this daemon's own held lead-to-lead mail, which is a - * {@link Authz.Action#COORD_READ} -- not {@code READ}, even though nothing is - * consumed: {@code READ}'s grant is open to every authenticated role on the premise that the - * roster carries no secrets, and a lead-to-lead body is not the roster. Mapping a non-destructive - * peer-mail read to {@code READ} would let any worker read every peer lead's mail in full. - * - *

Before this method existed (fleetd #272) the handler passed a constant {@code READ} for - * both of the original branches. {@code READ} is open to every authenticated role, so any - * worker could read a peer's id out of {@code fleet_list} and destroy the replies that peer had - * queued for the primary. The gate failed open, and it did so because the required action is a - * function of the arguments while the handler chose it before looking at them. + * Authz.Action#TASK_READ}. With {@code target} it calls {@link MessageService#drainReplies} on + * that session -- the replies are removed from the inbox and a second call returns nothing -- + * so it is a {@link Authz.Action#DRAIN}, the same gate {@code fleet_ack} already uses for + * removing a single message, and the same one the REST path uses at + * {@code FleetApp.drainReplies}. With {@code coordId} it reads (never acks) this daemon's own + * held lead-to-lead mail, which is a {@link Authz.Action#COORD_READ} -- not + * {@code TASK_READ} or {@code READ}: a lead-to-lead body is a different inbox from either, and + * folding it into either would let any worker or architect read every peer lead's mail in full. * *

The choice lives in this method, and not inline in the handler, so that a test can assert - * the mapping the handler actually uses. {@code FleetMcpAuthzTest} already checked every - * {@link Authz.Action} against every {@link Role} and passed throughout -- it tested the policy - * table, which was correct, while the defect was in which action the caller handed it. + * the mapping the handler actually uses. * *

Checked first, and exclusively of {@code target}: a call naming {@code coordId} is reading * a different inbox entirely (this daemon's own lead channel, never a worker's), so it takes @@ -1072,7 +1062,30 @@ public final class FleetMcp { if (!isBlank(coordId)) { return Authz.Action.COORD_READ; } - return isBlank(target) ? Authz.Action.READ : Authz.Action.DRAIN; + return isBlank(target) ? Authz.Action.TASK_READ : Authz.Action.DRAIN; + } + + /** + * Which authorization action a {@code fleet_send} call needs, decided by its arguments. + * + *

{@code fleet_send} is three call shapes behind one tool name, mirroring {@link + * #pollAction}. With {@code coordId} it addresses a peer lead on another daemon over the + * coordination broker, which is {@link Authz.Action#COORD_SEND}. With {@code turnId} it + * resolves a worker's blocked {@code fleet_ask} and resumes that turn, which is {@link + * Authz.Action#ANSWER}. Otherwise it delivers to a local session by {@code sessionId}, which is + * the plain {@link Authz.Action#SEND}. + * + *

Checked in the same order the handler branches: {@code coordId} first and exclusively of + * {@code turnId}, matching {@link #sendToLead}'s own mutual-exclusion check. + * + * @param coordId the {@code coordId} argument of the call, or {@code null}/blank when absent + * @param turnId the {@code turnId} argument of the call, or {@code null}/blank when absent + */ + static Authz.Action sendAction(String coordId, String turnId) { + if (!isBlank(coordId)) { + return Authz.Action.COORD_SEND; + } + return isBlank(turnId) ? Authz.Action.SEND : Authz.Action.ANSWER; } /** @@ -1097,10 +1110,11 @@ public final class FleetMcp { */ private static Authz.Action authzAction(FleetTool tool, Map arguments) { return switch (tool) { - case SEND -> Authz.Action.SEND; + case SEND -> sendAction(str(arguments, "coordId"), str(arguments, "turnId")); case REPLY -> Authz.Action.REPLY; case ASK -> Authz.Action.ASK; - case STATUS, LIST, PROFILES, WHOAMI -> Authz.Action.READ; + case STATUS -> Authz.Action.TASK_READ; + case LIST, PROFILES, WHOAMI -> Authz.Action.READ; case POLL -> pollAction(str(arguments, "target"), str(arguments, "coordId")); case ACK -> Authz.Action.DRAIN; case SPAWN -> Authz.Action.SPAWN; diff --git a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java index 245aa30..3a05fb4 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java @@ -49,18 +49,36 @@ import java.util.stream.Collectors; */ public final class FleetApp { - /** The authorization action the matching route handler hands to {@link #allow}. */ + /** + * The authorization action the matching route handler hands to {@link #allow}, for a route + * whose action does not depend on the request body. + */ static Authz.Action routeAction(String route) { + return routeAction(route, null); + } + + /** + * As above, plus the one route whose action depends on the body: {@code POST + * /sessions/{id}/message} carries a {@code turnId} (the answer-a-blocked-worker shape) or not + * (a plain delivery), mirroring {@code FleetMcp#sendAction}'s split of the same two call + * shapes over MCP. {@code turnId} is ignored by every other route. + * + * @param turnId the request body's {@code turnId}, or {@code null}/blank when absent or not + * applicable to this route + */ + static Authz.Action routeAction(String route, String turnId) { return switch (route) { case "GET /metrics" -> Authz.Action.METRICS; case "POST /members" -> Authz.Action.SPAWN; case "DELETE /members/{paneId}" -> Authz.Action.STOP; - case "POST /sessions/{id}/message" -> Authz.Action.SEND; + case "POST /sessions/{id}/message" -> turnId == null || turnId.isBlank() + ? Authz.Action.SEND : Authz.Action.ANSWER; case "POST /sessions/{id}/reply" -> Authz.Action.REPLY; case "GET /sessions/{id}/replies" -> Authz.Action.DRAIN; case "POST /sessions/{id}/ask" -> Authz.Action.ASK; case "GET /sessions", "GET /agents", "GET /members", "GET /profiles", - "GET /member-credentials", "GET /sessions/{id}/status", "GET /tasks/{ticket}" -> Authz.Action.READ; + "GET /member-credentials" -> Authz.Action.READ; + case "GET /sessions/{id}/status", "GET /tasks/{ticket}" -> Authz.Action.TASK_READ; default -> throw new IllegalArgumentException("route has no authorization gate: " + route); }; } @@ -250,7 +268,8 @@ public final class FleetApp { } Principal caller = ctx.attribute(CALLER); if (Authz.permits(caller, action, target)) { - if (action != Authz.Action.READ && action != Authz.Action.METRICS) { + if (action != Authz.Action.READ && action != Authz.Action.METRICS + && action != Authz.Action.TASK_READ) { AuditLog.allowed(caller, action, target); // reads would drown the trail } return true; @@ -603,26 +622,33 @@ public final class FleetApp { * status-gated injector and block until the worker returns a structured {@code fleet_reply}. * Times out with a typed 202 (working / queued / busy) rather than an error — the message may * still land. + * + *

Two call shapes share this route, exactly as {@code fleet_send} does over MCP (see + * {@code FleetMcp#sendAction}): a plain delivery to {@code id}, and -- when the body carries + * {@code turnId} -- resolving a worker's blocked question. The body is parsed before the + * authorization check so the right one of {@link Authz.Action#SEND}/{@link Authz.Action#ANSWER} + * reaches the gate; a body that fails to parse is treated as the plain shape for that check + * alone, and is rejected afterward exactly as before. */ private void sendMessage(Context ctx) { String id = ctx.pathParam("id"); - if (!allow(ctx, routeAction("POST /sessions/{id}/message"), id)) { + JsonNode body; + try { + body = mapper.readTree(ctx.body()); + } catch (Exception e) { + body = null; + } + String turnId = body == null ? null : body.path("turnId").asText(null); + if (!allow(ctx, routeAction("POST /sessions/{id}/message", turnId), id)) { return; } - String content; - String turnId; - long timeout; - boolean wait; - try { - JsonNode body = mapper.readTree(ctx.body()); - content = body.path("content").asText(""); - turnId = body.path("turnId").asText(null); - timeout = body.path("timeoutMs").asLong(DEFAULT_MESSAGE_TIMEOUT_MS); - wait = body.path("wait").asBoolean(true); // default: block for the reply (CB-104) - } catch (Exception e) { + if (body == null) { ctx.status(400).json(Map.of("error", "bad_request", "detail", "body must be JSON")); return; } + String content = body.path("content").asText(""); + long timeout = body.path("timeoutMs").asLong(DEFAULT_MESSAGE_TIMEOUT_MS); + boolean wait = body.path("wait").asBoolean(true); // default: block for the reply (CB-104) if (content.isBlank()) { ctx.status(400).json(Map.of("error", "bad_request", "detail", "content is required")); return; diff --git a/fleetd/src/test/java/dev/ltms/fleet/auth/AuthzTest.java b/fleetd/src/test/java/dev/ltms/fleet/auth/AuthzTest.java index 07bab44..72881b5 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/auth/AuthzTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/auth/AuthzTest.java @@ -38,6 +38,37 @@ class AuthzTest { } } + /** + * {@code fleet_send} is three call shapes behind one action name until {@code + * FleetMcp#sendAction} picks one: a plain local {@link Authz.Action#SEND}, the {@code coordId} + * route ({@link Authz.Action#COORD_SEND}), and the {@code turnId} answer form ({@link + * Authz.Action#ANSWER}). All three carry the same grant as the undivided action did — a worker + * is excluded from every one, exactly as it was excluded from the one combined action before. + */ + @Test + void theThreeSendShapesCarryTheSameGrantAsTheOldUndividedAction() { + for (Authz.Action a : new Authz.Action[]{SEND, COORD_SEND, ANSWER}) { + assertTrue(Authz.permits(PRIMARY, a, "term_a"), "the primary may " + a); + assertTrue(Authz.permits(ARCH_DESIGN, a, "term_a"), "an architect may " + a); + assertFalse(Authz.permits(WORKER_A, a, "term_a"), + "a worker performing " + a + " would be escalating into the orchestrator role"); + assertFalse(Authz.permits(ANON, a, "term_a")); + } + } + + /** + * {@code fleet_poll{ticket}} and {@code fleet_status} are {@link Authz.Action#TASK_READ}, split + * out of the roster-only {@link Authz.Action#READ} (fleetd #678). The grant is unchanged from + * what the undivided {@code READ} action gave every one of these callers. + */ + @Test + void taskReadCarriesTheSameGrantReadDidBeforeTheSplit() { + assertTrue(Authz.permits(PRIMARY, TASK_READ, null)); + assertTrue(Authz.permits(WORKER_A, TASK_READ, null)); + assertTrue(Authz.permits(ARCH_DESIGN, TASK_READ, null)); + assertFalse(Authz.permits(ANON, TASK_READ, null)); + } + @Test void aWorkerMayReplyAndAskOnlyAsItself() { assertTrue(Authz.permits(WORKER_A, REPLY, "term_a")); diff --git a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java index 633a025..9d7c7f9 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java @@ -156,6 +156,45 @@ class FleetMcpAuthzTest { } } + /** + * fleetd #669 Unit A: {@code SEND} is split into three actions ({@link Authz.Action#SEND}, + * {@link Authz.Action#COORD_SEND}, {@link Authz.Action#ANSWER}), each carrying the same grant + * the one undivided action gave. An architect holds all three, exactly as it held the one. + */ + @Test + void anArchitectMayUseAllThreeSendShapesOverMcp() { + FleetMcp m = mcp(true); + for (Authz.Action a : new Authz.Action[]{Authz.Action.SEND, Authz.Action.COORD_SEND, + Authz.Action.ANSWER}) { + assertNull(m.denyFor(ARCH_DESIGN, a, "term_a"), + a + " carries the same grant the undivided SEND action gave an architect"); + } + } + + /** The other half of the same split: a worker is excluded from all three, as it was from one. */ + @Test + void aWorkerMayNotUseAnySendShapeOverMcp() { + FleetMcp m = mcp(true); + for (Authz.Action a : new Authz.Action[]{Authz.Action.SEND, Authz.Action.COORD_SEND, + Authz.Action.ANSWER}) { + McpSchema.CallToolResult denied = m.denyFor(WORKER_A, a, "term_a"); + assertNotNull(denied, a + " must stay refused to a worker"); + assertTrue(denied.isError(), "a refusal is returned as an MCP tool error"); + } + } + + /** + * fleetd #669 Unit A / #678: {@code TASK_READ} (ticket polling, session status) is split out of + * the roster-only {@code READ}, carrying forward the grant the undivided action gave. A worker + * still has both — it never gained or lost anything by the split. + */ + @Test + void aWorkerKeepsBothReadActionsAfterTheSplit() { + FleetMcp m = mcp(true); + assertNull(m.denyFor(WORKER_A, Authz.Action.READ, null)); + assertNull(m.denyFor(WORKER_A, Authz.Action.TASK_READ, null)); + } + @Test void anArchitectMayReplyAndAskOnlyAsItsOwnPaneOverMcp() { FleetMcp m = mcp(true); @@ -297,35 +336,53 @@ class FleetMcpAuthzTest { * the whole time the defect was live -- the table was right, the action fed to it was wrong. */ @Test - void pollingByTargetIsADrainAndPollingByTicketIsARead() { + void pollingByTargetIsADrainAndPollingByTicketIsATaskRead() { assertEquals(Authz.Action.DRAIN, FleetMcp.pollAction("term_b", null), "poll by target removes the replies — that is a drain, not an observation"); - assertEquals(Authz.Action.READ, FleetMcp.pollAction(null, null), - "poll by ticket changes nothing"); - assertEquals(Authz.Action.READ, FleetMcp.pollAction(" ", null), + assertEquals(Authz.Action.TASK_READ, FleetMcp.pollAction(null, null), + "poll by ticket changes nothing, but is not the roster-only READ action"); + assertEquals(Authz.Action.TASK_READ, FleetMcp.pollAction(" ", null), "a blank target is an absent target"); } /** * fleetd #421: a coordId branch is a THIRD operation behind fleet_poll's one name, and it must - * map to {@link Authz.Action#COORD_READ} — never {@link Authz.Action#READ}, even though this - * branch also consumes nothing. READ's grant is open to every authenticated role on the premise - * that the roster carries no secrets; a lead-to-lead body is not the roster, so folding this - * branch into READ would let any worker read every peer lead's mail in full. coordId also takes - * priority over target when both happen to be present — it addresses a different inbox entirely. + * map to {@link Authz.Action#COORD_READ} — never {@link Authz.Action#READ} or {@link + * Authz.Action#TASK_READ}, even though this branch also consumes nothing. A lead-to-lead body + * is not the roster and not a ticket/status read, so folding this branch into either would let + * any worker or architect read every peer lead's mail in full. coordId also takes priority over + * target when both happen to be present — it addresses a different inbox entirely. */ @Test - void pollingByCoordIdIsACoordReadNeverAPlainRead() { + void pollingByCoordIdIsACoordReadNeverAPlainOrTaskRead() { assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(null, "mac-opus"), - "reading held peer mail must not be mapped to the everyone-readable READ action"); + "reading held peer mail must not be mapped to a widely-readable action"); assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction(" ", "mac-opus"), "a blank target must not fall through to READ/DRAIN when coordId is present"); - assertEquals(Authz.Action.READ, FleetMcp.pollAction(null, " "), + assertEquals(Authz.Action.TASK_READ, FleetMcp.pollAction(null, " "), "a blank coordId is an absent coordId, same as target/ticket"); assertEquals(Authz.Action.COORD_READ, FleetMcp.pollAction("term_b", "mac-opus"), "coordId takes priority over target — this is a different inbox, not a drain"); } + /** + * {@code fleet_send} is three call shapes behind one tool name, exactly as {@code fleet_poll} + * is (fleetd #669 Unit A). {@link FleetMcp#sendAction} picks the action from the arguments, not + * the handler, for the same reason {@link FleetMcp#pollAction} does: a test can assert the + * mapping the handler actually uses. + */ + @Test + void sendMapsToThreeDifferentActionsByItsArguments() { + assertEquals(Authz.Action.SEND, FleetMcp.sendAction(null, null), + "a plain delivery, with neither coordId nor turnId, is a local SEND"); + assertEquals(Authz.Action.COORD_SEND, FleetMcp.sendAction("mac-opus", null), + "coordId addresses a peer lead over the coordination broker"); + assertEquals(Authz.Action.ANSWER, FleetMcp.sendAction(null, "turn-1"), + "turnId resolves a worker's blocked question"); + assertEquals(Authz.Action.COORD_SEND, FleetMcp.sendAction("mac-opus", "turn-1"), + "coordId takes priority over turnId, mirroring sendToLead's own mutual-exclusion check"); + } + @Test void everyRegisteredToolHasItsHandlerActionPinned() { // fleetd #469: this used to scrape FleetMcp.java's tool("…") calls for the registered set — @@ -344,16 +401,22 @@ class FleetMcpAuthzTest { () -> tool + " is registered but has no pinned authorization action")); assertEquals(Authz.Action.SEND, FleetMcp.toolAction("fleet_send", Map.of())); + assertEquals(Authz.Action.SEND, + FleetMcp.toolAction("fleet_send", Map.of("sessionId", "term_a", "content", "hi"))); + assertEquals(Authz.Action.COORD_SEND, + FleetMcp.toolAction("fleet_send", Map.of("coordId", "mac-opus", "content", "hi"))); + assertEquals(Authz.Action.ANSWER, + FleetMcp.toolAction("fleet_send", Map.of("turnId", "turn-1", "content", "hi"))); assertEquals(Authz.Action.REPLY, FleetMcp.toolAction("fleet_reply", Map.of())); assertEquals(Authz.Action.ASK, FleetMcp.toolAction("fleet_ask", Map.of())); - assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_status", Map.of())); + assertEquals(Authz.Action.TASK_READ, FleetMcp.toolAction("fleet_status", Map.of())); assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_ack", Map.of())); assertEquals(Authz.Action.SPAWN, FleetMcp.toolAction("fleet_spawn", Map.of())); assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_list", Map.of())); assertEquals(Authz.Action.STOP, FleetMcp.toolAction("fleet_stop", Map.of())); assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_profiles", Map.of())); assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_whoami", Map.of())); - assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_poll", Map.of("ticket", "task"))); + assertEquals(Authz.Action.TASK_READ, FleetMcp.toolAction("fleet_poll", Map.of("ticket", "task"))); assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_poll", Map.of("target", "term_b"))); assertEquals(Authz.Action.COORD_READ, FleetMcp.toolAction("fleet_poll", Map.of("coordId", "mac-opus"))); diff --git a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java index b755a5c..7098578 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java @@ -122,12 +122,28 @@ class FleetAppAuthTest { assertEquals(Authz.Action.DRAIN, FleetApp.routeAction("GET /sessions/{id}/replies")); assertEquals(Authz.Action.ASK, FleetApp.routeAction("POST /sessions/{id}/ask")); for (String route : Set.of("GET /sessions", "GET /agents", "GET /members", "GET /profiles", - "GET /member-credentials", "GET /sessions/{id}/status", "GET /tasks/{ticket}")) { + "GET /member-credentials")) { assertEquals(Authz.Action.READ, FleetApp.routeAction(route), route); } + for (String route : Set.of("GET /sessions/{id}/status", "GET /tasks/{ticket}")) { + assertEquals(Authz.Action.TASK_READ, FleetApp.routeAction(route), route); + } assertThrows(IllegalArgumentException.class, () -> FleetApp.routeAction("GET /healthz")); } + /** + * fleetd #669 Unit A: {@code POST /sessions/{id}/message} is two call shapes behind one route, + * mirroring {@code fleet_send}'s MCP-side split into {@link Authz.Action#SEND} and {@link + * Authz.Action#ANSWER} ({@code FleetMcp#sendAction}). The route never carries a {@code coordId} + * shape — that peer-lead route is MCP-only — so only these two apply here. + */ + @Test + void theMessageRouteIsASendWithNoTurnIdAndAnAnswerWithOne() { + assertEquals(Authz.Action.SEND, FleetApp.routeAction("POST /sessions/{id}/message", null)); + assertEquals(Authz.Action.SEND, FleetApp.routeAction("POST /sessions/{id}/message", " ")); + assertEquals(Authz.Action.ANSWER, FleetApp.routeAction("POST /sessions/{id}/message", "turn-1")); + } + private static Set routesTheServerRegisters() { try { String source = Files.readString(REST_SOURCE).lines() @@ -192,6 +208,21 @@ class FleetAppAuthTest { "draining an inbox is the primary's collection step"); } + /** + * fleetd #669 Unit A: the {@code turnId} shape of {@code POST /sessions/{id}/message} maps to + * {@link Authz.Action#ANSWER}, not the plain {@link Authz.Action#SEND} the test above drives — + * a worker must stay refused on this shape too, exactly as it was refused on the one undivided + * action before the split. + */ + @Test + void aWorkerMayNotAnswerAnotherSessionsBlockedQuestionOverRest() throws Exception { + int port = start(FakeHerdr.WORKER_PID, false, null); + + assertEquals(403, send(port, "POST", "/sessions/term_b/message", + "{\"turnId\":\"turn-1\",\"content\":\"hi\"}", null).statusCode(), + "resolving another session's blocked question would be a worker escalating too"); + } + // --- token mode --------------------------------------------------------------------------- @Test