fix(security): scope set_profile responses to the caller's token - #1225
Merged
Conversation
The set_profile MCP tool answered every caller with unscoped data. An
agent token restricted to allowed_servers: ["research-srv"] that cleared
its selection ("") was handed the complete configured server list; one
that selected a profile was handed that profile's full server set; and
the unknown-slug error listed every configured profile, so probing a
name confirmed whether the operator had configured it. Each of those
disclosures contradicts the CanAccessServer bound that retrieve_tools /
describe_tool already enforce through serverInScope.
The fix threads the same predicate through set_profile:
- callerVisibleServers filters every `servers` payload (clear, select,
and pinned-clear) through auth.CanEnumerateServer, so a non-admin
context (agent token, server-edition user) only sees the intersection
of the selection with its AllowedServers; "*" passes everything, an
EMPTY allowlist grants nothing, and admin / socket / absent contexts
are untouched.
- selectableProfileNames is both the admission rule for a slug and the
`available:` list of the refusal: an unrestricted caller may select
any profile, a pinned token only its (still-configured) pin, a scoped
caller only the profiles overlapping the servers it can enumerate.
The set is computed before any session mutation, so a profile fully
outside the token's reach yields byte-for-byte the same refusal as a
nonexistent slug and never mutates the session.
Tests (internal/server/profile_tool_test.go) cover: scoped clear and
select report only allowed servers; disjoint vs nonexistent slug
produce the same error shape with no state change; an empty allowlist
sees nothing and is told no profile names; a server-edition user is
scoped like an agent token; a stale pin discloses no profiles; a pinned
token narrower than its pin sees the intersection and may still select
its own disjoint pin; admin and "*" wildcard behaviour is pinned as
unchanged. The frozen tools/list goldens are untouched (tool text is
not changed). docs/features/profiles.md documents the bound.
Acceptance contract: Spec 105 FR-003 (agent-token scope hardening, on
branch 105-agent-scope-hardening, not yet merged). Lineage: Spec 104
FR-016 / FR-016b, which states the invariant this change makes true.
Deploying mcpproxy-docs with
|
| Latest commit: |
6d21ce9
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://ee3b3719.mcpproxy-docs.pages.dev |
| Branch Preview URL: | https://claude-xenodochial-lumiere-5.mcpproxy-docs.pages.dev |
Contributor
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 34223026667 --repo smart-mcp-proxy/mcpproxy-go
|
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
…d a reserved fixture slug Cross-model review round 1 on the set_profile scoping fix. - The shared fixture named a profile "all", which config.ValidateProfiles rejects as a reserved slug; direct handler construction bypassed the check, so several tests exercised a configuration no deployment can load. Renamed to "mixed" and added TestSetProfileFixtureIsLoadable so the fixture is validated the way a real config load would. - Every refusal test started from an empty session, so the "no state change" assertions held for an implementation that cleared the session before refusing. Each refusal now starts from a real prior selection (verified: a clear-before-refuse mutant fails four tests that passed before). - Added the present-vs-absent differential: the same scoped token requesting the same disjoint slug gets a byte-identical refusal whether that profile is configured or removed, with no session mutation. - The empty-allowlist token now also attempts an existing profile and is refused exactly like an unknown one. No runtime change; internal/server/profile_tool.go is untouched. The URL-scope precedence, pinned-token admission and pinned-clear active_profile points raised in the same review are Spec 105 FR-003 contract items outside this fix and are tracked as follow-ups.
…_profile tests Round-2 cross-model review (R2-F1) showed a vacuity in the new set_profile suite: every profile in the shared fixture has a nonempty effective server set, so no test exercised Spec 105's "Unrestricted agent tokens" exception (1) — an unpinned "*" agent token must be refused an empty profile (declared empty, or emptied by warn-and-skip of an unknown server) while an administrator still selects it. A shortcut admitting every configured profile to wildcard tokens left all tests green (verified with that mutant: pre-existing tests pass, the new test fails). The new test extends the fixture locally with `empty` (no servers) and `ghost` (unknown server only), checks both load under ValidateProfiles with warnings only, and asserts for the wildcard agent a refusal byte-identical to an unknown slug whose `available:` list names neither profile and a preserved prior selection; for the administrator, a successful selection with an empty server list. No runtime change.
Dumbris
added a commit
that referenced
this pull request
Sep 9, 2026
Specs 104 (auto routing mode) and 105 (agent-token scope hardening) were written and cross-reviewed on 2026-09-07 but never committed; five merged PRs already cite 105 by name as their acceptance contract. This lands both and records them in roadmap.yaml so the DAG reflects what shipped and what is left. Spec 105 — agent-token scope hardening. The Spec 104 review verified the scoped-token promise against the code one surface at a time and found eight places where a legitimately narrow token could learn about or act on servers outside its grant. Five fix sessions ran from that review and merged on 2026-09-08: #1223 target tier, #1224 tail_log, #1225 set_profile, #1226 read_cache provenance, #1227 prompt owner and deleted-pin enumeration. Each was live-verified against a baseline binary and reviewed to a clean verdict. The epic records those five as done tasks and carries eight todo tasks, one per remaining FR group, taken from the "Follow-ups / Spec 105 gaps" checklists in those PR bodies: FR-005 retrieve_tools scoping, FR-008 direct publication identity, FR-010 refusal shapes, the FR-003/FR-004 selectable- profile predicate for unpinned tokens, FR-002 legacy cache invalidation, FR-007 log-record attribution, FR-009 producer-side identity, and the FR-011/013/014 regression suite. Spec 104 — auto routing mode. Measures the three candidate surfaces per session on the catalog that session will actually see and serves the richest rung under a token budget. It depends on 105: its FR-016 states the invariant those corrections make true. P1 is US1-4, P2 is US5-6. PR evidence sits on the child tasks rather than the epic, because gen-roadmap.py --check-github reads an all-merged epic pr: list as "this epic is done" and the scope-hardening epic is not. Also flips activity-storage-bounds to done: #1174 wired the 64KB write-path cap and #1214 bounded the per-server tool_calls buckets, stopped omitempty erasing configured zeros and added db compact, closing #1175 and #1176. The specs/README index rows are appended for both, though that table has been stale since 043 and does not list 044-103. Validation: gen-roadmap.py --check-github reports 0 errors. The one warning (mcp-2026-upgrade has no pr: link or child tasks) is pre-existing.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part of Spec 105 (agent-token scope hardening) — FR-003. Lineage: Spec 104 FR-016 / FR-016b.
Summary
The leak.
set_profileanswered every caller with unscoped data:allowed_servers: ["research-srv"]that cleared its selection ("") was handed the complete configured server list.unknown profile 'x' (available: research, deploy, ...)), and a profile entirely outside the token's reach was selectable (session mutated, success logged) even though the token could use nothing in it.Each of these contradicts the
CanAccessServerbound thatretrieve_tools/describe_toolalready enforce throughserverInScope(Spec 028 / #1166).The fix. The same predicate is threaded through
set_profile:callerVisibleServersfilters everyserverspayload — clear, select, and pinned-clear — throughauth.CanEnumerateServer. A non-admin context (agent token, server-edition user) sees only the intersection of the selection with itsAllowedServers;"*"passes everything; an empty allowlist grants nothing (matchingCanAccessServer, not the "unrestricted" readingpreflight.ResolveScopeuses); admin / socket / absent contexts are passed through untouched.selectableProfileNamesis both the admission rule for a slug and theavailable:list of the refusal: an unrestricted caller may select any profile; a pinned token only its still-configured pin; a scoped caller only the profiles overlapping the servers it can enumerate. It is computed before any session mutation, so a non-selectable profile yields byte-for-byte the same refusal as a nonexistent slug and never touches session state. No non-selectable profile is ever named.The
set_profiletool description is unchanged, so the frozentools/listgoldens underinternal/server/testdatapass unregenerated.What changed
internal/server/profile_tool.gocallerVisibleServers+selectableProfileNames; admission check +available:list use the selectable set; all three success payloads filtered.internal/server/profile_tool_test.goAllowedServers: ["*"]as REST minting does, since a pin-only token models one that can see nothing.docs/features/profiles.mdserversand on theavailable:list.Tests
internal/server/profile_tool_test.go:ScopedTokenClearReportsOnlyAllowedServers— clear reports only the token's servers.ScopedTokenSelectIntersectsAllowedServers— select returns profile ∩ allowed; selection is stored.ScopedTokenDisjointProfileIndistinguishableFromUnknown— disjoint vs nonexistent slug: identical error shape, no server names, no session mutation.EmptyAllowlistTokenSeesNothing— empty allowlist: emptyservers, refusal names no profile.ServerEditionUserScopedLikeVisibility—AuthTypeUserwith an allowlist is bounded the same way.ScopedTokenUnknownSlugDoesNotEnumerateAllProfiles— refusal names overlapping profiles only.StalePinUnknownSlugDisclosesNoProfiles— a removed pin discloses nothing and is not listed as available.PinnedTokenClearIntersectsAllowedServers— pin narrower than allowlist → intersection.PinnedTokenSelectsDisjointPin— a configured pin is always selectable by its own token (it already knows its pin); reach is then empty.AdminUnchanged,WildcardTokenUnchanged— API-key/socket and"*"tokens keep full listings.Added during cross-model review (test-only commits
33486ec1d,6d21ce991; no runtime code changed):ScopedTokenDisjointProfilePresentVsAbsentIdentical— same scoped token, same slug, one server with the profile configured and one with it deleted: byte-identical refusal, no session mutation on either.ScopedTokenDisjointProfileIndistinguishableFromUnknown,ScopedTokenUnknownSlugDoesNotEnumerateAllProfiles,StalePinUnknownSlugDisclosesNoProfilesnow seed a real prior selection and assert it survives the refusal (a clear-before-refuse mutant fails 4 tests; the original assertions passed it).EmptyAllowlistTokenSeesNothingalso attempts an existing profile and requires the unknown-slug refusal shape plus no mutation.WildcardTokenRefusesEmptyProfileAdminSelectsIt— an unpinned"*"agent selecting an empty (or unknown-server-only) profile is refused non-disclosingly with its prior selection preserved; an administrator selects it (servers: []). A mutant admitting every profile for wildcard tokens passed every earlier test.SetProfileFixtureIsLoadable— the shared fixture passesconfig.ValidateProfiles(the original fixture slugallis reserved and could never load).Verification
Live: baseline vs branch, isolated instance
Isolated core on
127.0.0.1:18241(own--data-dirper binary, own--config; Web UI, socket, quarantine and Docker isolation off). Two stdio fixture serversaandb(2 tools each, bothconnected:truebefore every run), two profilesp1 = [a],p2 = [b]. Baseline binary built fromorigin/main(c93f79423); branch binary from0cc96f141.Tokens minted with the CLI against the instance:
mcpproxy token create --name a-only --servers a --permissions read(allowed_servers: ["a"]) and--name wild --servers '*'(["*"]). Each MCP call is a fresh session:POST /mcpinitialize, thentools/callset_profilewithAuthorization: Bearer <token>(orX-API-Keyfor admin).Baseline (
origin/main),a-onlytoken:profilecontent[0].text""{"active_profile":"","servers":["a","b"]}— leaksb"p1"{"active_profile":"p1","servers":["a"]}"p2"{"active_profile":"p2","servers":["b"]}— disjoint profile selectable, leaksb"nonexistent"unknown profile 'nonexistent' (available: p1, p2)(isError) — enumerates every profileSame-session probe:
set_profile "p2"thenretrieve_tools {"query":"echo"}→[](session silently moved into a profile the token cannot use; control with""→["a:echo"]).Branch (this PR),
a-onlytoken:profilecontent[0].text""{"active_profile":"","servers":["a"]}"p1"{"active_profile":"p1","servers":["a"]}"p2"unknown profile 'p2' (available: p1)(isError)"nonexistent"unknown profile 'nonexistent' (available: p1)(isError)cmp).set_profile "p2"(refused) thenretrieve_toolsin the same session →["a:echo"]; same for"nonexistent".a-onlytranscript,b/p2appear only as the caller's own echoed slug; everyserverspayload is["a"];available:names onlyp1(the one profile intersecting the token — permitted by FR-003, which bounds naming to selectable profiles).Positive controls (branch):
wildtoken (["*"])"p2"{"active_profile":"p2","servers":["b"]}wildtoken""{"active_profile":"","servers":["a","b"]}wildtoken"nonexistent"unknown profile 'nonexistent' (available: p1, p2)— unrestricted caller keeps the full listX-API-Key""{"active_profile":"","servers":["a","b"]}X-API-Key"p2"thenretrieve_tools{"active_profile":"p2","servers":["b"]}→["b:echo"]Scope of the live run: personal edition on macOS via streamable-HTTP
/mcp. The SSE and/mcp/p/<slug>routing-mode surfaces and the server-editionAuthTypeUserpath were not exercised live; the latter is covered by the unit tests only.Gates (at
6d21ce991)go build ./cmd/mcpproxyandgo build -tags server -o /dev/null ./cmd/mcpproxy— OK.go test -race -skip 'E2E|Binary|MCPProtocol|TestInfoEndpoint|TestGracefulShutdownNoPanic|TestSocketInfoEndpoint' ./internal/server/...—ok internal/server (228.1s),ok internal/server/tokens, uncached.internal/server/testdatapass unregenerated (git status --short internal/server/testdataempty)./opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./internal/server/...— 0 issues.go vet ./internal/server/clean.origin/main:internal/server/profile_tool.go,internal/server/profile_tool_test.go,docs/features/profiles.md.Cross-model review
Reviewer:
opencodeCLI withgithub-copilot/gpt-6-astra(round 2 on opencode 1.18.29), briefed with the diff and the Spec 105 draft. Three rounds, each producing a verdict; two fix→re-review rounds. Every finding was verified against the diff before acting; no runtime code changed after round 0.Round 1 — verdict FINDINGS (5)
set_profilepayloads ignore the/mcp/p/<slug>URL scopemainreturnedmatch.EffectiveServers(cfg)with noProfileScopeFromContexteither) and not a credential leak: every reported server is still inside the token'sAllowedServers. FR-003 URL-precedence clause → follow-up.maina deleted pin enumerated every profile; now it names nothing). FR-003's predicate is stated for unpinned tokens and the pin was validated at mint time (validateProfilePin). Contract decision for Spec 105 → follow-up.active_profilerather than the stored""docs/features/profiles.mddocuments it. FR-003active_profile = stored selection→ follow-up.PresentVsAbsentIdentical, existing-profile attempt inEmptyAllowlistTokenSeesNothing. Proven with a mutant (4 tests fail; originals passed it). URL-scope and empty-profile fixtures deferred to follow-ups.allis inreservedProfileSlugsand cannot loadmixed, addedSetProfileFixtureIsLoadable(watched it fail onall, pass onmixed).Fix commit
33486ec1d(test-only).Round 2 — verdict FINDINGS (1)
"*"tokens leaves all tests greenTestHandleSetProfile*passed), addedWildcardTokenRefusesEmptyProfileAdminSelectsIt(fails under the mutant, passes on real code). Runtime already behaved correctly; no runtime change.Fix commit
6d21ce991(test-only). Round-1 F1/F3 re-listed by the reviewer as retained follow-ups; F2 re-listed and accepted as intended, no re-argument.Round 3 — verdict CLEAN. No findings. Reviewer re-confirmed the three previously-triaged spec gaps below as retained items with no new evidence.
Follow-ups / spec-105 gaps (not in this PR)
Reviewer-listed contract points the diff does not cover, carried as follow-ups:
handleSetProfilenever consultsprofile.ProfileScopeFromContext, so on/mcp/p/<slug>theserverslist reports the selected/cleared profile ∩ token instead of the URL-governed effective scope. Fix: reportProfileScopeFromContext(ctx) ∩ tokenwhile still storing the selection; add a URL-scoped-context test.active_profilereports the stored session selection": the pinned-clear path stores""but returns the pin name (also with a stale pin). Fix: return"", updatePinnedTokenClearIntersectsAllowedServersand thedocs/features/profiles.mdline together.selectableProfileNamesadmits a configured pin with empty/disjoint reach (disjointAllowedServers, empty grant, or empty pinned profile), mutating the session, and deleting the profile flips the same request to a refusal — disclosing current existence.PinnedTokenSelectsDisjointPindocuments today's choice. If a configured-pin exception is intended, the contract must state it; otherwise require a non-empty intersection in the pin branch./mcp/p/<slug>,/mcp/p,/mcp/p/) — separate change./mcp/p/<slug>transports and the server-editionAuthTypeUserpath (unit-tested only here).Notes
claude/eager-kirch-10f6c2and this branch (claude/xenodochial-lumiere-5abe75) editinternal/server/profile_tool.go; whichever lands second should expect a merge conflict inhandleSetProfileand the helper block belowallServerNames.TestGetServerLogs_MissingFileReturnsEmptyNotError(from fix(webui): stop the UI asserting states it was never told (v0.65.0 UX audit) #1216, untouched here) failed once in an early full-racepackage run and passed in isolation, on re-run, and in every later uncached full run — test-ordering flake, not a regression from this diff.malformed LC_DYSYMTAB) appeared while linking the test binary; toolchain noise, unrelated to the change.