From 0e945062b3b8c5138ee14b108150e20092448252 Mon Sep 17 00:00:00 2001 From: james-crowley <42618816+james-crowley@users.noreply.github.com> Date: Mon, 19 Oct 2026 16:38:37 -0400 Subject: [PATCH] Hold profile-pinned agent tokens to their own pin on GET /api/v1/servers A token created with --profile takes its scope from the profile, so its AllowedServers is usually "*" and the #1166 filter (visibleServers) listed every configured server to it, while /tools, preflight and the MCP surface were already limited to the pin. Apply the caller's own pin to a bare GET /servers, the same way GET /servers?profile= does: rows outside the pin disappear, tool_count counts only tools the pin admits, and stats narrow with the list. Admins and legacy-scoped tokens are unchanged. Fixes #1682 --- internal/httpapi/server.go | 6 ++++ .../servers_profile_caller_pin_test.go | 30 +++++++++++++++++++ internal/httpapi/view_as.go | 26 ++++++++++++++++ 3 files changed, 62 insertions(+) diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 3438cc74c..a2613bd6c 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -2012,6 +2012,9 @@ func (s *Server) handleGetServers(w http.ResponseWriter, r *http.Request) { if viewAsEval != nil { serverValues = s.limitServersToProfile(r.Context(), serverValues, viewAs, viewAsEval) statsValue = statsForServers(serverValues) + } else if limited, ok := s.limitServersToCallerPin(r.Context(), serverValues); ok { + serverValues = limited + statsValue = statsForServers(serverValues) } response := contracts.GetServersResponse{ @@ -2050,6 +2053,9 @@ func (s *Server) handleGetServers(w http.ResponseWriter, r *http.Request) { if viewAsEval != nil { servers = s.limitServersToProfile(r.Context(), servers, viewAs, viewAsEval) stats = statsForServers(servers) + } else if limited, ok := s.limitServersToCallerPin(r.Context(), servers); ok { + servers = limited + stats = statsForServers(servers) } response := contracts.GetServersResponse{ diff --git a/internal/httpapi/servers_profile_caller_pin_test.go b/internal/httpapi/servers_profile_caller_pin_test.go index 3e2133a10..ab2f2f64a 100644 --- a/internal/httpapi/servers_profile_caller_pin_test.go +++ b/internal/httpapi/servers_profile_caller_pin_test.go @@ -185,3 +185,33 @@ func TestServersProfileCallerPin(t *testing.T) { }) } } + +// Issue #1682: a bare GET /servers (no ?profile=) by a profile-pinned token must +// be held to the caller's own pin, as GET /tools is. Before the fix the token's +// AllowedServers ("*") was the only gate, so every configured server listed. +func TestServersBareListHeldToCallerPin(t *testing.T) { + for _, branch := range []struct { + name string + withManagement bool + }{{"management service", true}, {"legacy fallback", false}} { + t.Run(branch.name, func(t *testing.T) { + srv, token := callerPinServer(t, branch.withManagement) + + data := callerPinGet(t, srv, "/api/v1/servers", token) + assert.Equal(t, map[string]float64{"github": 1}, serverRows(data), + "notion and filesystem are outside the caller's pin; github counts only read_* tools") + + stats := data["stats"].(map[string]interface{}) + assert.Equal(t, float64(1), stats["total_servers"], "stats must narrow with the list") + assert.Equal(t, float64(1), stats["total_tools"]) + + // Bare listing equals the listing the same caller gets for its own pin. + own := serverRows(callerPinGet(t, srv, "/api/v1/servers?profile=work-readonly", token)) + assert.Equal(t, own, serverRows(data)) + + // The administrator path is unchanged. + admin := serverRows(callerPinGet(t, srv, "/api/v1/servers", scopeAdminAPIKey)) + assert.Len(t, admin, 3, "an administrator still lists every configured server") + }) + } +} diff --git a/internal/httpapi/view_as.go b/internal/httpapi/view_as.go index 26628d276..152329454 100644 --- a/internal/httpapi/view_as.go +++ b/internal/httpapi/view_as.go @@ -233,6 +233,32 @@ func (s *Server) limitServersToProfile(ctx context.Context, servers []contracts. return out } +// pinOnlyEvaluator admits every tool: it lets limitServersToProfile apply only +// the caller's own pin (via filterProfileToolRows) when no profile was asked for. +type pinOnlyEvaluator struct{} + +func (pinOnlyEvaluator) Evaluate(_, _ string) profile.AccessVerdict { + return profile.AccessVerdict{Visible: true, Callable: true} +} + +// limitServersToCallerPin holds a profile-pinned agent token to its own pin on a +// bare GET /servers (no ?profile=), the same way GET /tools already does. A +// pinned token takes its scope from the profile, so its AllowedServers is +// usually "*" and visibleServers alone would list every configured server. The +// result equals GET /servers?profile=. ok is false (and +// servers is untouched) for callers without a pin, so admins and legacy-scoped +// tokens behave exactly as before. +func (s *Server) limitServersToCallerPin(ctx context.Context, servers []contracts.Server) (limited []contracts.Server, ok bool) { + if !callerHasProfilePin(ctx) { + return servers, false + } + req := &viewAsRequest{subject: profile.AccessSubject{ + Kind: profile.AccessSubjectProfile, + Profile: auth.AuthContextFromContext(ctx).ProfilePin, + }} + return s.limitServersToProfile(ctx, servers, req, pinOnlyEvaluator{}), true +} + // callerHasProfilePin reports whether the request's caller is a // non-administrator bound to a profile (an agent token with a pin). func callerHasProfilePin(ctx context.Context) bool {