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 bc505b3a..fd5a617a 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 84620468..ae59bf71 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 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 07bab44f..72881b5d 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 633a0250..9d7c7f96 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 b755a5c9..70985789 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