Skip to content

fix(security): scope set_profile responses to the caller's token - #1225

Merged
Dumbris merged 3 commits into
mainfrom
claude/xenodochial-lumiere-5abe75
Sep 8, 2026
Merged

fix(security): scope set_profile responses to the caller's token#1225
Dumbris merged 3 commits into
mainfrom
claude/xenodochial-lumiere-5abe75

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 8, 2026

Copy link
Copy Markdown
Member

Part of Spec 105 (agent-token scope hardening) — FR-003. Lineage: Spec 104 FR-016 / FR-016b.

Summary

The leak. set_profile 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, including servers the token can never reach.
  • The unknown-slug refusal listed every configured profile, so probing a slug confirmed whether the operator had configured it (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 CanAccessServer bound that retrieve_tools / describe_tool already enforce through serverInScope (Spec 028 / #1166).

The fix. The same predicate is threaded through set_profile:

  • callerVisibleServers filters every servers payload — clear, select, and pinned-clear — through auth.CanEnumerateServer. A non-admin context (agent token, server-edition user) sees only the intersection of the selection with its AllowedServers; "*" passes everything; an empty allowlist grants nothing (matching CanAccessServer, not the "unrestricted" reading preflight.ResolveScope uses); admin / socket / absent contexts are passed through 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. 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_profile tool description is unchanged, so the frozen tools/list goldens under internal/server/testdata pass unregenerated.

What changed

File Change
internal/server/profile_tool.go callerVisibleServers + selectableProfileNames; admission check + available: list use the selectable set; all three success payloads filtered.
internal/server/profile_tool_test.go 11 new handler tests (see below); pinned fixture now carries AllowedServers: ["*"] as REST minting does, since a pin-only token models one that can see nothing.
docs/features/profiles.md Documents the credential bound on servers and on the available: 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: empty servers, refusal names no profile.
  • ServerEditionUserScopedLikeVisibilityAuthTypeUser with 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, StalePinUnknownSlugDisclosesNoProfiles now seed a real prior selection and assert it survives the refusal (a clear-before-refuse mutant fails 4 tests; the original assertions passed it).
  • EmptyAllowlistTokenSeesNothing also 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 passes config.ValidateProfiles (the original fixture slug all is reserved and could never load).

Verification

Live: baseline vs branch, isolated instance

Isolated core on 127.0.0.1:18241 (own --data-dir per binary, own --config; Web UI, socket, quarantine and Docker isolation off). Two stdio fixture servers a and b (2 tools each, both connected:true before every run), two profiles p1 = [a], p2 = [b]. Baseline binary built from origin/main (c93f79423); branch binary from 0cc96f141.

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 /mcp initialize, then tools/call set_profile with Authorization: Bearer <token> (or X-API-Key for admin).

Baseline (origin/main), a-only token:

profile content[0].text
"" {"active_profile":"","servers":["a","b"]} — leaks b
"p1" {"active_profile":"p1","servers":["a"]}
"p2" {"active_profile":"p2","servers":["b"]} — disjoint profile selectable, leaks b
"nonexistent" unknown profile 'nonexistent' (available: p1, p2) (isError) — enumerates every profile

Same-session probe: set_profile "p2" then retrieve_tools {"query":"echo"}[] (session silently moved into a profile the token cannot use; control with ""["a:echo"]).

Branch (this PR), a-only token:

profile content[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)
  • The two refusal bodies are byte-identical after substituting the echoed slug (cmp).
  • No state change on refusal: set_profile "p2" (refused) then retrieve_tools in the same session → ["a:echo"]; same for "nonexistent".
  • Across the whole a-only transcript, b / p2 appear only as the caller's own echoed slug; every servers payload is ["a"]; available: names only p1 (the one profile intersecting the token — permitted by FR-003, which bounds naming to selectable profiles).

Positive controls (branch):

caller call response
wild token (["*"]) "p2" {"active_profile":"p2","servers":["b"]}
wild token "" {"active_profile":"","servers":["a","b"]}
wild token "nonexistent" unknown profile 'nonexistent' (available: p1, p2) — unrestricted caller keeps the full list
admin X-API-Key "" {"active_profile":"","servers":["a","b"]}
admin X-API-Key "p2" then retrieve_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-edition AuthTypeUser path were not exercised live; the latter is covered by the unit tests only.

Gates (at 6d21ce991)

  • go build ./cmd/mcpproxy and go 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.
  • Frozen tool-surface goldens under internal/server/testdata pass unregenerated (git status --short internal/server/testdata empty).
  • /opt/homebrew/bin/golangci-lint run --config .github/.golangci.yml ./internal/server/... — 0 issues. go vet ./internal/server/ clean.
  • Diff footprint vs origin/main: internal/server/profile_tool.go, internal/server/profile_tool_test.go, docs/features/profiles.md.

Cross-model review

Reviewer: opencode CLI with github-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)

id sev disposition
F1 set_profile payloads ignore the /mcp/p/<slug> URL scope P1 Declined — pre-existing (main returned match.EffectiveServers(cfg) with no ProfileScopeFromContext either) and not a credential leak: every reported server is still inside the token's AllowedServers. FR-003 URL-precedence clause → follow-up.
F2 pinned-token admission accepts a configured pin with zero reach; deleting the pin flips the reply to a refusal P2 Declined — the diff narrows this (on main a 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.
F3 pinned clear reports the pin as active_profile rather than the stored "" P2 Declined — pre-existing return that the diff only wraps; docs/features/profiles.md documents it. FR-003 active_profile = stored selection → follow-up.
F4 refusal tests start from an empty session, so a clear-before-refuse implementation still passes; no present-vs-absent differential P2 Applied (in-scope parts): prior-selection seeding in three refusal tests, PresentVsAbsentIdentical, existing-profile attempt in EmptyAllowlistTokenSeesNothing. Proven with a mutant (4 tests fail; originals passed it). URL-scope and empty-profile fixtures deferred to follow-ups.
F5 fixture slug all is in reservedProfileSlugs and cannot load P3 Applied — renamed to mixed, added SetProfileFixtureIsLoadable (watched it fail on all, pass on mixed).

Fix commit 33486ec1d (test-only).

Round 2 — verdict FINDINGS (1)

id sev disposition
F1 no test locks the unpinned wildcard agent being refused an empty profile while an admin selects it; a mutant admitting every profile for "*" tokens leaves all tests green P3 Applied — confirmed the vacuity with that mutant (all TestHandleSetProfile* passed), added WildcardTokenRefusesEmptyProfileAdminSelectsIt (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:

  • FR-003 "token restriction ∩ effective profile, with URL scope still governing where present" (and the Edge Cases URL-precedence bullet): handleSetProfile never consults profile.ProfileScopeFromContext, so on /mcp/p/<slug> the servers list reports the selected/cleared profile ∩ token instead of the URL-governed effective scope. Fix: report ProfileScopeFromContext(ctx) ∩ token while still storing the selection; add a URL-scoped-context test.
  • FR-003 "active_profile reports the stored session selection": the pinned-clear path stores "" but returns the pin name (also with a stale pin). Fix: return "", update PinnedTokenClearIntersectsAllowedServers and the docs/features/profiles.md line together.
  • FR-003 non-selectable-refusal rule for pinned tokens: selectableProfileNames admits a configured pin with empty/disjoint reach (disjoint AllowedServers, empty grant, or empty pinned profile), mutating the session, and deleting the profile flips the same request to a refusal — disclosing current existence. PinnedTokenSelectsDisjointPin documents 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.
  • Edge Cases "an empty profile is refused through set_profile" for a wildcard agent pinned to an empty profile: bypasses the refusal via the pin branch (same root as the item above); no dedicated test.
  • FR-003 regression coverage still missing: a test with a URL scope in context.
  • FR-004 (profile-URL endpoint applies the same selectable-profile predicate; identical status/body across missing / deleted / non-selectable / pin-mismatch for /mcp/p/<slug>, /mcp/p, /mcp/p/) — separate change.
  • Live coverage of the SSE and /mcp/p/<slug> transports and the server-edition AuthTypeUser path (unit-tested only here).

Notes

  • Both claude/eager-kirch-10f6c2 and this branch (claude/xenodochial-lumiere-5abe75) edit internal/server/profile_tool.go; whichever lands second should expect a merge conflict in handleSetProfile and the helper block below allServerNames.
  • 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 -race package run and passed in isolation, on re-run, and in every later uncached full run — test-ordering flake, not a regression from this diff.
  • A macOS linker warning (malformed LC_DYSYMTAB) appeared while linking the test binary; toolchain noise, unrelated to the change.

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.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 8, 2026

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

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

View logs

@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: claude/xenodochial-lumiere-5abe75

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (29 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (23 MB)
  • installer-dmg-darwin-arm64 (21 MB)

How to Download

Option 1: GitHub Web UI (easiest)

  1. Go to the workflow run page linked above
  2. Scroll to the bottom "Artifacts" section
  3. Click on the artifact you want to download

Option 2: GitHub CLI

gh run download 34223026667 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 93.10345% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/profile_tool.go 93.10% 1 Missing and 1 partial ⚠️

📢 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
Dumbris merged commit 484200a into main Sep 8, 2026
41 checks passed
@Dumbris
Dumbris deleted the claude/xenodochial-lumiere-5abe75 branch September 8, 2026 17:13
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants