Skip to content

fix(auth): stale agent-token ProfilePin resolves to deny-all scope on the session path - #997

Merged
Dumbris merged 1 commit into
mainfrom
fix-stale-profile-pin-deny
Aug 15, 2026
Merged

fix(auth): stale agent-token ProfilePin resolves to deny-all scope on the session path#997
Dumbris merged 1 commit into
mainfrom
fix-stale-profile-pin-deny

Conversation

@Dumbris

@Dumbris Dumbris commented Aug 15, 2026

Copy link
Copy Markdown
Member

The bug: deleting a profile widened a pinned token's scope

An agent token can carry a profile_pin — a profile the operator binds the credential to so it can never operate outside that subset of upstream servers.

If the operator later deleted that profile, the live MCP session resolver (resolveActiveProfile, internal/server/profile_resolver.go) warn-skipped the pin and fell through to the next precedence tier — URL scope, session set_profile, then "none". "None" means no profile filtering, so the session ran with the token's own, wider server scope.

Deleting a profile — an operator action that reads as a restriction — therefore grew what the pinned token could see and call, and did so with only a warning in the log. Privilege widening was the bug; the pin is the restriction and it must fail closed.

The precedent: preflight already got this right

Spec 098 resolved exactly this on the preflight path. resolvePreflightScope (internal/server/preflight_glue.go) keeps an unresolvable pin's name as a restriction over an empty server set, which intersects to deny-all:

the pin is a narrowing the operator applied to that token, so losing the profile it names must never hand the token a wider view than it had yesterday.

This PR makes the session path match, so a preflight's not_found for a stale pin can never be contradicted by a live session that happily dispatches the same tool.

Behavior change (CHANGELOG-worthy)

A session whose token names a deleted profile now sees no upstream tools and can call none, where it previously saw everything in its token scope. The warn log is kept and now names the deny-all outcome. Recovery: re-create the profile, or re-mint the token against a live one.

Additional surfaces found while auditing

A pin is only a boundary if every dispatch surface resolves it. Three did not — for stale and live pins:

Surface Before After
code_execution sandbox read only the URL-injected scope (ProfileScopeFromContext), so a pinned token on the base /mcp ran with its full token scope resolves through resolveActiveProfile (applyProfileScopeToExecution), keeping RestrictToAllowed set so an empty allow-list denies instead of meaning "allow all"
direct-routing mode enforced allowed_servers + permissions but no profile at all, in both tools/list and dispatch filterDirectModeToolsForAuth and makeDirectModeHandler apply the active profile; dispatch emits the same profile_scope policy decision call_tool_* emits
set_profile("") answered with the full server list although the pin still bounded the session reports the pinned scope (empty when the pin is stale)

retrieve_tools additionally skips index.Manager.ForProfile for a deny-all scope: that call lazily creates an on-disk per-profile index, and a request allowed to see nothing must not leave one behind for a profile that may not exist. (Same reasoning as the deliberate ForProfile omission documented in preflight_glue.go.)

ProfileScopeFromContext is now documented as the URL tier only, and resolveActiveProfile is its single production caller.

Tests

  • TestResolveActiveProfile_StalePinDeniesAll — the resolver unit: stale pin → non-nil deny-all scope that keeps the removed profile's name, and neither a session selection nor a URL scope may re-widen it.
  • TestStaleTokenPinDeniesOnBothSessionAndPreflightPaths — session/preflight parity: both paths agree while the profile exists, and both deny after it is deleted.
  • internal/server/profile_pin_enforcement_test.go — per-surface coverage for the sandbox, direct mode and set_profile. Each case runs twice, with the pinned profile present (it narrows) and deleted (it denies), because a surface that ignores profiles entirely would otherwise pass the deny assertion for the wrong reason.

No existing test pinned the old fall-through behavior. Docs (agent-tokens, profiles, tools-preflight) and spec 028's warn-skip Q&A / FR-021 are updated, the spec with an explicit note on why the original answer changed.

Deliberately not done

The set_profile tool description still says clearing goes "back to all servers", which understates the pinned case. That string is snapshotted byte-for-byte by the spec-098 FR-015 tools/list goldens, whose value is that they were captured from the pre-098 merge base — rewording the MCP surface deserves its own change rather than riding a security fix. The gap is recorded in a comment on buildSetProfileTool.

Verification

  • CI="" go test -race ./internal/server/ — pass (full package)
  • CI="" go test ./internal/server/ ./internal/profile/ ./internal/preflight/ ./internal/jsruntime/ — pass
  • golangci-lint run --config .github/.golangci.yml ./internal/... — 0 issues
  • Cross-model review (opencode / gpt-5.6-sol), 3 rounds: round 1 found the sandbox + direct-mode + set_profile gaps (all fixed here), round 2 found two LOW items (nil-safety, stale wording — both fixed), round 3 found nothing blocking after the final comment corrections.

Related #969

… the session path

An agent token can carry a `profile_pin` — a profile the operator binds the
credential to so it can never operate outside that subset of upstream servers.
If the operator later DELETED that profile, the live MCP session resolver
(resolveActiveProfile) warn-skipped the pin and fell through to the next
precedence tier (URL scope, session set_profile, then "none"). "None" means no
profile filtering, so the session silently ran with the token's own — wider —
server scope. Deleting a profile, an act that reads as a restriction, therefore
GREW what the pinned token could see and call, and did so with only a debug-log
trace.

Spec 098 already resolved this for the preflight path: resolvePreflightScope
intersects an unresolvable pin against an empty server set, i.e. deny-all
(internal/server/preflight_glue.go). This makes the session path match, so a
preflight's `not_found` for a stale pin can never be contradicted by a live
session that happily dispatches the same tool.

BEHAVIOR CHANGE: a session whose token names a deleted profile now sees no
upstream tools and can call none, where it previously saw everything in its
token scope. The warn log is kept and now names the deny-all outcome. Recovery
is to re-create the profile or re-mint the token against a live one. This is
the security-correct reading: privilege widening was the bug, not the fix.

Auditing the other surfaces that resolve a profile turned up three more places
where a pin — stale or live — was not enforced at all:

* code_execution read only the URL-injected scope (ProfileScopeFromContext), so
  a pinned token on the base /mcp endpoint ran the sandbox under its full token
  scope. It now resolves through resolveActiveProfile
  (applyProfileScopeToExecution), keeping RestrictToAllowed set so an empty
  allow-list denies instead of meaning "allow all".
* direct-routing mode enforced allowed_servers and permissions but no profile
  at all, in both tools/list (filterDirectModeToolsForAuth) and dispatch
  (makeDirectModeHandler) — a pinned token could discover and call every server
  in its token scope. Both now apply the active profile; dispatch emits the
  same profile_scope policy decision call_tool_* emits.
* set_profile("") on a pinned token answered with the FULL server list even
  though the pin still bounded the session. It now reports the pinned scope
  (empty when the pin is stale), so the response cannot advertise a reach the
  resolver denies. The tool's own description text is left untouched on
  purpose: it is snapshotted by the spec-098 FR-015 tools/list goldens, whose
  value is that they were captured from the pre-098 merge base, so an
  MCP-surface reword belongs in its own change rather than in this one. The
  gap is recorded in a comment on buildSetProfileTool.

retrieve_tools additionally skips index.Manager.ForProfile for a deny-all scope:
that call lazily CREATES an on-disk per-profile index, and a request allowed to
see nothing must not leave one behind for a profile that may not exist.

Tests: resolver unit test for stale-pin deny-all; a session/preflight parity
test asserting both paths answer a deleted pin identically (and that they agree
while the profile still exists); per-surface tests for the sandbox, direct mode
and set_profile, each exercised with the profile present AND deleted so a
surface that ignores profiles cannot pass for the wrong reason.

Docs/spec updated: agent-tokens, profiles and tools-preflight now describe
deny-all and list every surface that resolves the pin; spec 028's warn-skip
answer is amended with the reason it changed.

Related #969
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: d41d657
Status: ✅  Deploy successful!
Preview URL: https://10804bc0.mcpproxy-docs.pages.dev
Branch Preview URL: https://fix-stale-profile-pin-deny.mcpproxy-docs.pages.dev

View logs

@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 88.09524% with 5 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/mcp_direct_scope.go 62.50% 2 Missing and 1 partial ⚠️
internal/profile/context.go 0.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

Copy link
Copy Markdown

📦 Build Artifacts

Workflow Run: View Run
Branch: fix-stale-profile-pin-deny

Available Artifacts

  • archive-darwin-amd64 (29 MB)
  • archive-darwin-arm64 (26 MB)
  • archive-linux-amd64 (17 MB)
  • archive-linux-arm64 (15 MB)
  • archive-windows-amd64 (28 MB)
  • archive-windows-arm64 (25 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (23 MB)
  • installer-dmg-darwin-arm64 (20 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 31913007548 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@Dumbris
Dumbris enabled auto-merge (squash) August 15, 2026 23:08

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving per cross-model review (opencode/gpt-5.6-sol, 3 rounds clean) and green CI (49 checks). Stale-pin deny-all + pin enforcement on code_execution/direct/set_profile surfaces.

@Dumbris
Dumbris merged commit 50fb802 into main Aug 15, 2026
50 checks passed
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