From 719b79f89201b5c49003ac906742a5ad4b6ca414 Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 4 Sep 2026 10:39:58 +0700 Subject: [PATCH 1/2] wip: pin handler authorization actions --- .../java/dev/ltms/fleet/mcp/FleetMcp.java | 47 ++++++++++++---- .../java/dev/ltms/fleet/rest/FleetApp.java | 53 ++++++++++++++----- .../dev/ltms/fleet/mcp/FleetMcpAuthzTest.java | 24 +++++++++ .../dev/ltms/fleet/rest/FleetAppAuthTest.java | 25 +++++++++ 4 files changed, 124 insertions(+), 25 deletions(-) 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 fab8fbc..c406bc1 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -68,6 +68,12 @@ import org.slf4j.LoggerFactory; */ public final class FleetMcp { + /** The tool names registered below, paired with the action their handler passes to {@link #deny}. */ + static Set registeredToolNames() { + return Set.of("fleet_send", "fleet_reply", "fleet_ask", "fleet_status", "fleet_poll", + "fleet_ack", "fleet_spawn", "fleet_list", "fleet_stop", "fleet_profiles", "fleet_whoami"); + } + private static final Logger log = LoggerFactory.getLogger(FleetMcp.class); private static final long DEFAULT_TIMEOUT_MS = 25_000; @@ -257,7 +263,7 @@ public final class FleetMcp { // Each handler is built once and wired to its fleet_* tool below. BiFunction sendHandler = (exchange, req) -> { - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.SEND, + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_send", req.arguments()), str(req.arguments(), "sessionId")); if (denied != null) return denied; String caller = callerTerminal(exchange); @@ -299,7 +305,7 @@ public final class FleetMcp { BiFunction replyHandler = (exchange, req) -> { String self = callerTerminal(exchange); - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.REPLY, self); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_reply", req.arguments()), self); if (denied != null) return denied; return reply(messages, self, str(req.arguments(), "content")); }; @@ -307,13 +313,13 @@ public final class FleetMcp { BiFunction askHandler = (exchange, req) -> { String self = callerTerminal(exchange); - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.ASK, self); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_ask", req.arguments()), self); if (denied != null) return denied; return ask(messages, self, str(req.arguments(), "question"), timeoutMs(req.arguments())); }; BiFunction statusHandler = (exchange, req) -> { - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_status", req.arguments()), null); if (denied != null) return denied; return status(messages, str(req.arguments(), "sessionId")); }; @@ -322,7 +328,7 @@ public final class FleetMcp { Map a = req.arguments(); String target = str(a, "target"); // The action depends on the ARGUMENTS, not on the tool name -- see pollAction. - McpSchema.CallToolResult denied = deny(exchange, pollAction(target), target); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_poll", a), target); if (denied != null) return denied; return poll(messages, str(a, "ticket"), target); }; @@ -331,14 +337,14 @@ public final class FleetMcp { BiFunction ackHandler = (exchange, req) -> { Map a = req.arguments(); - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.DRAIN, str(a, "target")); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_ack", a), str(a, "target")); if (denied != null) return denied; return ack(messages, str(a, "target"), str(a, "msgId")); }; // Fleet management (CB-108): spawn/list/stop over ClaudeCodeLauncher. BiFunction spawnHandler = (exchange, req) -> { - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.SPAWN, null); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_spawn", req.arguments()), null); if (denied != null) return denied; String caller = callerTerminal(exchange); // SPAWN is already auth-gated to PRIMARY (architects can never call it), but @@ -355,7 +361,7 @@ public final class FleetMcp { }; BiFunction listHandler = (exchange, _) -> { - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_list", Map.of()), null); if (denied != null) return denied; return listFleet(workers, sessions, messages, capacity, healthCoverage, quarantine, outage, leadSeats, callers == null ? Map.of() : callers.leads(), @@ -365,19 +371,19 @@ public final class FleetMcp { BiFunction stopHandler = (exchange, req) -> { String paneId = str(req.arguments(), "paneId"); - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.STOP, paneId); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_stop", req.arguments()), paneId); if (denied != null) return denied; return stop(sessions, paneId); }; BiFunction profilesHandler = (exchange, _) -> { - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_profiles", Map.of()), null); if (denied != null) return denied; return profiles(workers, quarantine, outage); }; BiFunction whoamiHandler = (exchange, _) -> { - McpSchema.CallToolResult denied = deny(exchange, Authz.Action.READ, null); + McpSchema.CallToolResult denied = deny(exchange, toolAction("fleet_whoami", Map.of()), null); if (denied != null) return denied; return whoami(principal(exchange), sessions); }; @@ -754,6 +760,25 @@ public final class FleetMcp { return isBlank(target) ? Authz.Action.READ : Authz.Action.DRAIN; } + /** + * The action a registered tool handler actually hands to the authorization gate. + * Keeping this choice beside the registered-tool inventory makes a new tool fail the coverage + * test until its action is pinned. + */ + static Authz.Action toolAction(String toolName, Map arguments) { + return switch (toolName) { + case "fleet_send" -> Authz.Action.SEND; + case "fleet_reply" -> Authz.Action.REPLY; + case "fleet_ask" -> Authz.Action.ASK; + case "fleet_status", "fleet_list", "fleet_profiles", "fleet_whoami" -> Authz.Action.READ; + case "fleet_poll" -> pollAction(str(arguments, "target")); + case "fleet_ack" -> Authz.Action.DRAIN; + case "fleet_spawn" -> Authz.Action.SPAWN; + case "fleet_stop" -> Authz.Action.STOP; + default -> throw new IllegalArgumentException("unregistered tool: " + toolName); + }; + } + /** {@code fleet_poll}: check an async delegation by ticket, or drain a worker's inbox by target. */ static McpSchema.CallToolResult poll(MessageService messages, String ticket, String target) { if (!isBlank(target)) { 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 9c7c343..50c2d07 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; @@ -47,6 +48,30 @@ import java.util.stream.Collectors; */ public final class FleetApp { + /** Route paths registered by {@link #build()}, including the deliberately open liveness probe. */ + static Set registeredRoutePaths() { + return Set.of("GET /healthz", "GET /metrics", "GET /sessions", "GET /agents", "GET /members", + "GET /profiles", "GET /member-credentials", "POST /members", "DELETE /members/{paneId}", + "POST /sessions/{id}/message", "POST /sessions/{id}/reply", "GET /sessions/{id}/replies", + "POST /sessions/{id}/ask", "GET /sessions/{id}/status", "GET /tasks/{ticket}"); + } + + /** The authorization action the matching route handler hands to {@link #allow}. */ + static Authz.Action routeAction(String route) { + 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}/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; + default -> throw new IllegalArgumentException("route has no authorization gate: " + route); + }; + } + /** Default blocking window for a message; kept under typical HTTP idle timeouts. */ private static final long DEFAULT_MESSAGE_TIMEOUT_MS = 25_000; private static final long MAX_MESSAGE_TIMEOUT_MS = 120_000; @@ -222,7 +247,7 @@ public final class FleetApp { /** Prometheus scrape endpoint (CB-502). */ private void metrics(Context ctx) { - if (!allow(ctx, Authz.Action.METRICS, null)) { + if (!allow(ctx, routeAction("GET /metrics"), null)) { return; } ctx.status(200).contentType("text/plain; version=0.0.4; charset=utf-8").result(metrics.render()); @@ -294,7 +319,7 @@ public final class FleetApp { * member workspace (they live on the member daemon only). */ private void sessions(Context ctx) { - if (!allow(ctx, Authz.Action.READ, null)) { + if (!allow(ctx, routeAction("GET /sessions"), null)) { return; } List> out = new ArrayList<>(); @@ -319,7 +344,7 @@ public final class FleetApp { /** Discovery: every agent herdr tracks, keyed by its Claude session UUID. */ private void agents(Context ctx) { - if (!allow(ctx, Authz.Action.READ, null)) { + if (!allow(ctx, routeAction("GET /agents"), null)) { return; } ctx.status(200).json(Map.of("agents", @@ -328,7 +353,7 @@ public final class FleetApp { /** CB-304: bridge-owned roster merged with live herdr status by paneId. */ private void listMembers(Context ctx) { - if (!allow(ctx, Authz.Action.READ, null)) { + if (!allow(ctx, routeAction("GET /members"), null)) { return; } // CB-519: the registry key is a host-unique id, not the pane coordinate — join on terminal. @@ -359,7 +384,7 @@ public final class FleetApp { /** The configured worker profiles and which one a no-argument spawn uses. */ private void profiles(Context ctx) { - if (!allow(ctx, Authz.Action.READ, null)) { + if (!allow(ctx, routeAction("GET /profiles"), null)) { return; } ctx.status(200).json(Map.of( @@ -376,7 +401,7 @@ public final class FleetApp { * name list, which is exactly what let the list drift silently behind the real policy. */ private void memberCredentials(Context ctx) { - if (!allow(ctx, Authz.Action.READ, null)) { + if (!allow(ctx, routeAction("GET /member-credentials"), null)) { return; } MemberCredentialPolicyView view = memberCredentials.get(); @@ -396,7 +421,7 @@ public final class FleetApp { * the subscription boundary, 400 for an unknown profile. */ private void spawnMember(Context ctx) { - if (!allow(ctx, Authz.Action.SPAWN, null)) { + if (!allow(ctx, routeAction("POST /members"), null)) { return; } String role = ctx.queryParam("role"); @@ -467,7 +492,7 @@ public final class FleetApp { /** Tear a worker down by pane id. */ private void stopMember(Context ctx) { String paneId = ctx.pathParam("paneId"); - if (!allow(ctx, Authz.Action.STOP, paneId)) { + if (!allow(ctx, routeAction("DELETE /members/{paneId}"), paneId)) { return; } sessions.release(paneId); @@ -482,7 +507,7 @@ public final class FleetApp { */ private void sendMessage(Context ctx) { String id = ctx.pathParam("id"); - if (!allow(ctx, Authz.Action.SEND, id)) { + if (!allow(ctx, routeAction("POST /sessions/{id}/message"), id)) { return; } String content; @@ -569,7 +594,7 @@ public final class FleetApp { */ private void askMessage(Context ctx) { String id = ctx.pathParam("id"); - if (!allow(ctx, Authz.Action.ASK, id)) { + if (!allow(ctx, routeAction("POST /sessions/{id}/ask"), id)) { return; } String question; @@ -608,7 +633,7 @@ public final class FleetApp { // The rule that matters: a worker may reply only as itself. Over MCP this was already true // structurally (identity comes from the connection, never an argument); over REST the path // id was simply trusted, so this is where the invariant actually gets enforced. - if (!allow(ctx, Authz.Action.REPLY, id)) { + if (!allow(ctx, routeAction("POST /sessions/{id}/reply"), id)) { return; } String content; @@ -629,7 +654,7 @@ public final class FleetApp { */ private void drainReplies(Context ctx) { String id = ctx.pathParam("id"); - if (!allow(ctx, Authz.Action.DRAIN, id)) { + if (!allow(ctx, routeAction("GET /sessions/{id}/replies"), id)) { return; } var replies = messages.drainReplies(id); @@ -647,7 +672,7 @@ public final class FleetApp { */ private void sessionStatus(Context ctx) { String id = ctx.pathParam("id"); - if (!allow(ctx, Authz.Action.READ, id)) { + if (!allow(ctx, routeAction("GET /sessions/{id}/status"), id)) { return; } try { @@ -672,7 +697,7 @@ public final class FleetApp { /** Poll an async (wait:false) delegation by ticket. 404 for an unknown/expired ticket. */ private void taskStatus(Context ctx) { - if (!allow(ctx, Authz.Action.READ, null)) { + if (!allow(ctx, routeAction("GET /tasks/{ticket}"), null)) { return; } MessageService.TaskView v = messages.poll(ctx.pathParam("ticket")); 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 03082f0..f802187 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java @@ -201,6 +201,30 @@ class FleetMcpAuthzTest { "a blank target is an absent target"); } + @Test + void everyRegisteredToolHasItsHandlerActionPinned() { + Map expected = Map.ofEntries( + Map.entry("fleet_send", Authz.Action.SEND), + Map.entry("fleet_reply", Authz.Action.REPLY), + Map.entry("fleet_ask", Authz.Action.ASK), + Map.entry("fleet_status", Authz.Action.READ), + Map.entry("fleet_ack", Authz.Action.DRAIN), + Map.entry("fleet_spawn", Authz.Action.SPAWN), + Map.entry("fleet_list", Authz.Action.READ), + Map.entry("fleet_stop", Authz.Action.STOP), + Map.entry("fleet_profiles", Authz.Action.READ), + Map.entry("fleet_whoami", Authz.Action.READ)); + + Set expectedNames = Set.of("fleet_send", "fleet_reply", "fleet_ask", "fleet_status", + "fleet_poll", "fleet_ack", "fleet_spawn", "fleet_list", "fleet_stop", "fleet_profiles", + "fleet_whoami"); + assertEquals(expectedNames, FleetMcp.registeredToolNames(), + "a registered tool needs an action expectation before this test can pass"); + expected.forEach((tool, action) -> assertEquals(action, FleetMcp.toolAction(tool, Map.of()), tool)); + assertEquals(Authz.Action.READ, FleetMcp.toolAction("fleet_poll", Map.of("ticket", "task"))); + assertEquals(Authz.Action.DRAIN, FleetMcp.toolAction("fleet_poll", Map.of("target", "term_b"))); + } + @Test void aWorkerMayNotDrainAnotherSessionsInboxByPolling() { FleetMcp m = mcp(true); 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 28db15c..be7ab50 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java @@ -1,6 +1,7 @@ package dev.ltms.fleet.rest; import dev.ltms.fleet.auth.CallerResolver; +import dev.ltms.fleet.auth.Authz; import dev.ltms.fleet.auth.MemberRegistry; import dev.ltms.fleet.config.FleetConfig; import dev.ltms.fleet.guard.SubscriptionGuard; @@ -92,6 +93,30 @@ class FleetAppAuthTest { return http.send(b.build(), HttpResponse.BodyHandlers.ofString()); } + @Test + void everyRegisteredRouteHasItsHandlerActionPinned() { + Set expected = Set.of("GET /healthz", "GET /metrics", "GET /sessions", "GET /agents", + "GET /members", "GET /profiles", "GET /member-credentials", "POST /members", + "DELETE /members/{paneId}", "POST /sessions/{id}/message", "POST /sessions/{id}/reply", + "GET /sessions/{id}/replies", "POST /sessions/{id}/ask", "GET /sessions/{id}/status", + "GET /tasks/{ticket}"); + assertEquals(expected, FleetApp.registeredRoutePaths(), + "a registered route needs an action expectation before this test can pass"); + assertEquals(Authz.Action.METRICS, FleetApp.routeAction("GET /metrics")); + assertEquals(Authz.Action.SPAWN, FleetApp.routeAction("POST /members")); + assertEquals(Authz.Action.STOP, FleetApp.routeAction("DELETE /members/{paneId}")); + assertEquals(Authz.Action.SEND, FleetApp.routeAction("POST /sessions/{id}/message")); + assertEquals(Authz.Action.REPLY, FleetApp.routeAction("POST /sessions/{id}/reply")); + 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}")) { + assertEquals(Authz.Action.READ, FleetApp.routeAction(route), route); + } + assertThrows(IllegalArgumentException.class, () -> FleetApp.routeAction("GET /healthz"), + "healthz is deliberately open"); + } + // --- loopback-trust: the caller is the primary ------------------------------------------- @Test From 30e21adec7e5e65d3ae08eb928b2c0b492d0e07a Mon Sep 17 00:00:00 2001 From: Dai Ha Date: Fri, 4 Sep 2026 10:46:26 +0700 Subject: [PATCH 2/2] #281: cover registered authorization actions --- .../java/dev/ltms/fleet/mcp/FleetMcp.java | 6 --- .../java/dev/ltms/fleet/rest/FleetApp.java | 9 ---- .../dev/ltms/fleet/mcp/FleetMcpAuthzTest.java | 54 +++++++++++++------ .../dev/ltms/fleet/rest/FleetAppAuthTest.java | 48 +++++++++++++---- 4 files changed, 76 insertions(+), 41 deletions(-) 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 c406bc1..9a84002 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/mcp/FleetMcp.java @@ -68,12 +68,6 @@ import org.slf4j.LoggerFactory; */ public final class FleetMcp { - /** The tool names registered below, paired with the action their handler passes to {@link #deny}. */ - static Set registeredToolNames() { - return Set.of("fleet_send", "fleet_reply", "fleet_ask", "fleet_status", "fleet_poll", - "fleet_ack", "fleet_spawn", "fleet_list", "fleet_stop", "fleet_profiles", "fleet_whoami"); - } - private static final Logger log = LoggerFactory.getLogger(FleetMcp.class); private static final long DEFAULT_TIMEOUT_MS = 25_000; 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 50c2d07..891e575 100644 --- a/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java +++ b/fleetd/src/main/java/dev/ltms/fleet/rest/FleetApp.java @@ -31,7 +31,6 @@ import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.function.Function; import java.util.function.Predicate; import java.util.function.Supplier; @@ -48,14 +47,6 @@ import java.util.stream.Collectors; */ public final class FleetApp { - /** Route paths registered by {@link #build()}, including the deliberately open liveness probe. */ - static Set registeredRoutePaths() { - return Set.of("GET /healthz", "GET /metrics", "GET /sessions", "GET /agents", "GET /members", - "GET /profiles", "GET /member-credentials", "POST /members", "DELETE /members/{paneId}", - "POST /sessions/{id}/message", "POST /sessions/{id}/reply", "GET /sessions/{id}/replies", - "POST /sessions/{id}/ask", "GET /sessions/{id}/status", "GET /tasks/{ticket}"); - } - /** The authorization action the matching route handler hands to {@link #allow}. */ static Authz.Action routeAction(String route) { return switch (route) { 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 f802187..191f0a7 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/mcp/FleetMcpAuthzTest.java @@ -24,8 +24,13 @@ import io.modelcontextprotocol.spec.McpSchema; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import static org.junit.jupiter.api.Assertions.*; @@ -44,6 +49,9 @@ import static org.junit.jupiter.api.Assertions.*; */ class FleetMcpAuthzTest { + private static final Path MCP_SOURCE = Path.of("src/main/java/dev/ltms/fleet/mcp/FleetMcp.java"); + private static final Pattern TOOL_REGISTRATION = Pattern.compile("tool\\(\\\"(fleet_[a-z_]+)\\\""); + private final FakeHerdr herdr = new FakeHerdr(); private final AgentControl agents = new AgentControl(herdr); private Metrics metrics; @@ -203,28 +211,40 @@ class FleetMcpAuthzTest { @Test void everyRegisteredToolHasItsHandlerActionPinned() { - Map expected = Map.ofEntries( - Map.entry("fleet_send", Authz.Action.SEND), - Map.entry("fleet_reply", Authz.Action.REPLY), - Map.entry("fleet_ask", Authz.Action.ASK), - Map.entry("fleet_status", Authz.Action.READ), - Map.entry("fleet_ack", Authz.Action.DRAIN), - Map.entry("fleet_spawn", Authz.Action.SPAWN), - Map.entry("fleet_list", Authz.Action.READ), - Map.entry("fleet_stop", Authz.Action.STOP), - Map.entry("fleet_profiles", Authz.Action.READ), - Map.entry("fleet_whoami", Authz.Action.READ)); + Set registered = toolsTheServerRegisters(); + assertTrue(registered.size() >= 10, + "scraped only " + registered.size() + " tool registrations from FleetMcp (" + registered + + "); the server registers eleven, so the tool(\"…\") scrape has stopped matching"); + registered.forEach(tool -> assertDoesNotThrow(() -> FleetMcp.toolAction(tool, Map.of()), + () -> tool + " is registered but has no pinned authorization action")); - Set expectedNames = Set.of("fleet_send", "fleet_reply", "fleet_ask", "fleet_status", - "fleet_poll", "fleet_ack", "fleet_spawn", "fleet_list", "fleet_stop", "fleet_profiles", - "fleet_whoami"); - assertEquals(expectedNames, FleetMcp.registeredToolNames(), - "a registered tool needs an action expectation before this test can pass"); - expected.forEach((tool, action) -> assertEquals(action, FleetMcp.toolAction(tool, Map.of()), tool)); + assertEquals(Authz.Action.SEND, FleetMcp.toolAction("fleet_send", Map.of())); + 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.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.DRAIN, FleetMcp.toolAction("fleet_poll", Map.of("target", "term_b"))); } + private static Set toolsTheServerRegisters() { + try { + Matcher matcher = TOOL_REGISTRATION.matcher(Files.readString(MCP_SOURCE)); + Set tools = new LinkedHashSet<>(); + while (matcher.find()) { + tools.add(matcher.group(1)); + } + return tools; + } catch (Exception e) { + throw new AssertionError("could not scrape FleetMcp tool registrations", e); + } + } + @Test void aWorkerMayNotDrainAnotherSessionsInboxByPolling() { FleetMcp m = mcp(true); 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 be7ab50..b755a5c 100644 --- a/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java +++ b/fleetd/src/test/java/dev/ltms/fleet/rest/FleetAppAuthTest.java @@ -26,8 +26,15 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.LinkedHashSet; +import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.*; @@ -37,6 +44,10 @@ import static org.junit.jupiter.api.Assertions.*; */ class FleetAppAuthTest { + private static final Path REST_SOURCE = Path.of("src/main/java/dev/ltms/fleet/rest/FleetApp.java"); + private static final Pattern ROUTE_REGISTRATION = + Pattern.compile("app\\.(get|post|delete|put|patch)\\(\\s*\"([^\"]+)\""); + private final HttpClient http = HttpClient.newHttpClient(); private Javalin app; private Metrics metrics; @@ -95,13 +106,14 @@ class FleetAppAuthTest { @Test void everyRegisteredRouteHasItsHandlerActionPinned() { - Set expected = Set.of("GET /healthz", "GET /metrics", "GET /sessions", "GET /agents", - "GET /members", "GET /profiles", "GET /member-credentials", "POST /members", - "DELETE /members/{paneId}", "POST /sessions/{id}/message", "POST /sessions/{id}/reply", - "GET /sessions/{id}/replies", "POST /sessions/{id}/ask", "GET /sessions/{id}/status", - "GET /tasks/{ticket}"); - assertEquals(expected, FleetApp.registeredRoutePaths(), - "a registered route needs an action expectation before this test can pass"); + Set registered = routesTheServerRegisters(); + assertTrue(registered.size() >= 15, + "scraped only " + registered.size() + " route registrations from FleetApp (" + registered + + "); the app.(\"…\") scrape has stopped matching"); + // Liveness must work before credentials can be checked, so this route is deliberately open. + assertTrue(registered.remove("GET /healthz"), "GET /healthz must stay an explicit ungated exception"); + registered.forEach(route -> assertDoesNotThrow(() -> FleetApp.routeAction(route), + () -> route + " is registered but has no pinned authorization action")); assertEquals(Authz.Action.METRICS, FleetApp.routeAction("GET /metrics")); assertEquals(Authz.Action.SPAWN, FleetApp.routeAction("POST /members")); assertEquals(Authz.Action.STOP, FleetApp.routeAction("DELETE /members/{paneId}")); @@ -113,8 +125,26 @@ class FleetAppAuthTest { "GET /member-credentials", "GET /sessions/{id}/status", "GET /tasks/{ticket}")) { assertEquals(Authz.Action.READ, FleetApp.routeAction(route), route); } - assertThrows(IllegalArgumentException.class, () -> FleetApp.routeAction("GET /healthz"), - "healthz is deliberately open"); + assertThrows(IllegalArgumentException.class, () -> FleetApp.routeAction("GET /healthz")); + } + + private static Set routesTheServerRegisters() { + try { + String source = Files.readString(REST_SOURCE).lines() + .filter(line -> { + String stripped = line.stripLeading(); + return !(stripped.startsWith("//") || stripped.startsWith("*") || stripped.startsWith("/*")); + }) + .collect(Collectors.joining("\n")); + Matcher matcher = ROUTE_REGISTRATION.matcher(source); + Set routes = new LinkedHashSet<>(); + while (matcher.find()) { + routes.add(matcher.group(1).toUpperCase(Locale.ROOT) + " " + matcher.group(2)); + } + return routes; + } catch (Exception e) { + throw new AssertionError("could not scrape FleetApp route registrations", e); + } } // --- loopback-trust: the caller is the primary -------------------------------------------