Skip to content

fix(scope): selectable-profile predicate on /mcp/p and set_profile with one non-disclosing refusal (Spec 105 PR D, FR-003/004) - #1283

Draft
Dumbris wants to merge 18 commits into
mainfrom
105-d-profiles
Draft

Dumbris wants to merge 18 commits into
mainfrom
105-d-profiles

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 16, 2026

Copy link
Copy Markdown
Member

Summary

Spec 105 (agent-token scope hardening), planning artifacts and PR A merged in #1279. This PR closes FR003-G1..G8 · roadmap scope-selectable-profile-predicate · research D1. Gap ids, evidence and test sketches: specs/105-agent-scope-hardening/gap-map.md §1; the refusal shapes it must satisfy: contracts/refusals.md.

Commits

  • c03e5685b fix(scope): PR D critique round 1 — admin set_profile parity, single-snapshot resolver, operator log on profile-URL refusals, fail-closed nil-config auth
  • c18eef4e3 feat(scope): Spec 105 PR D — selectable-profile predicate on /mcp/p and set_profile (FR-003/004, FR003-G1..G8, D1)
  • 553d6f0c3 test(scope): Spec 105 PR D red phase — selectable-profile predicate on /mcp/p and set_profile (FR-003/004, FR003-G1..G8)

Tests

Red-first per gap id (each confirmed failing on origin/main by assertion), pinned pre-105 tests inverted (never deleted), admin controls kept. Full list in the red-phase commit message and the test names above.

Verification

  • go build both editions · go test -race on the touched packages, internal/server (CI skip regex), -tags server serveredition · goldens byte-identical to main · gofmt/vet · ./scripts/test-api-e2e.sh 65/65.
  • golangci-lint v2 could not run locally (binary built with go1.25 vs the go 1.26 target) — CI covers it.
  • Internal adversarial review (security lens + SC-005/test-quality lens), every finding verified by probe before fixing; verdict table in the worktree's .review-tmp/critique-r1.md.
  • Cross-model review: pending — codex gpt-5.6-sol rounds run after this draft opens (Copilot quota for opencode astra is exhausted until 2026-10-01); the PR leaves draft when the reviewer returns CLEAN.

Follow-ups / Spec 105 gaps

See gap-map.md §8 (REST replay has no tool gate; UI active profile bounds no MCP session). Remaining Spec 105 PRs: C → F → G → H1 after this batch.

🤖 Generated with Claude Code

@cloudflare-workers-and-pages

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

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 4ba12d3
Status: ✅  Deploy successful!
Preview URL: https://5d117417.mcpproxy-docs.pages.dev
Branch Preview URL: https://105-d-profiles.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

codecov-commenter commented Sep 16, 2026

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.54005% with 25 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/server/profile_tool.go 93.80% 8 Missing and 7 partials ⚠️
internal/server/server.go 92.13% 5 Missing and 2 partials ⚠️
internal/server/profile_resolver.go 89.28% 1 Missing and 2 partials ⚠️

📢 Thoughts on this report? Let us know!

@github-actions

github-actions Bot commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: 105-d-profiles

Available Artifacts

  • archive-darwin-amd64 (30 MB)
  • archive-darwin-arm64 (27 MB)
  • archive-linux-amd64 (18 MB)
  • archive-linux-arm64 (16 MB)
  • archive-windows-amd64 (30 MB)
  • archive-windows-arm64 (26 MB)
  • frontend-dist-pr (0 MB)
  • installer-dmg-darwin-amd64 (24 MB)
  • installer-dmg-darwin-arm64 (21 MB)
  • smart-mcp-proxymcpproxy-goF2MQHN.dockerbuild (0 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 35305209675 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

Dumbris and others added 16 commits September 17, 2026 22:11
…n /mcp/p and set_profile (FR-003/004, FR003-G1..G8)

Failing tests for Phase 4 (T035-T040), all RED on HEAD by assertion:

- T035: generalise mintPinnedToken → mintAgentToken(t, env, name, allowed,
  perms, pin); mintPinnedToken stays as a thin wrapper for the pre-105 pin
  tests (H1 reuses the generalised minter).
- T036 (G1/G2/G5): TestProfile_ScopedUnpinnedRefusalUniform — research-srv-only
  unpinned token: /mcp/p/deploy (HEAD 200), /mcp/p/nonexistent, /mcp/p,
  /mcp/p/ and deleted /mcp/p/deploy (HEAD 404 + `available`) must share one
  404 status+body with no `available`; /mcp/p/research positive control.
- T037 (G3/G4): TestProfile_PinnedRefusalUniform (HEAD 403/403/404) and
  TestProfile_PinnedRefusalIndependentOfFleet (HEAD "no profiles configured"
  before the gate); anonymous no-profiles branch kept as admin control.
- T038 (G6): TestHandleSetProfile_URLScopeGovernsReportedServers — set_profile
  on a URL-scoped endpoint stores the selection but reports URL profile ∩ token.
- T039 (G7): TestSetProfileClearPinnedReportsEmptyActiveProfile — clearing a
  pinned selection reports active_profile == "".
- T040 (G8/D1): TestHandleSetProfile_PinnedZeroReachRefusedLikeDeletedPin
  (empty/ghost/disjoint pin refused with the deleted-pin body, no mutation)
  and TestProfile_PinnedZeroReachURLRefusedUniformly (/mcp/p/<zero-reach pin>
  uniform 404; HEAD 200).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nd set_profile (FR-003/004, FR003-G1..G8, D1)

profileMiddleware (T041): scoped callers (auth.IsScopedCaller — agent tokens
and server-edition users) are admitted through /mcp/p/<slug>, /mcp/p and
/mcp/p/ only when the slug is in selectableProfileNames; every other outcome
— missing, deleted, configured-but-unreachable, empty profile, pin mismatch,
zero-reach pin, slug-less path, empty fleet — is answered by ONE constructor
(profileNotSelectable: 404 {"error":"unknown profile '<slug>'"}, no
`available`, no pin). The no-profiles branch now runs after the gate; the
pre-105 403 pin branch is gone (a pinned agent is always a scoped caller).
Admin / anonymous callers keep the two pre-105 404 branches (SC-005).

handleSetProfile (T042): one selectable check for every non-empty slug;
`servers` is the effective scope after the update via resolveActiveProfile
(pin > URL > session) ∩ token — on a URL-scoped endpoint the URL profile,
not the stored selection; `active_profile` is the stored selection, so a
cleared pinned selection reports "". selectableProfileNames' pin branch now
requires reach (D1): an empty/ghost/disjoint pin is refused with the
deleted-pin body and the session is not mutated.

Docs (T043): docs/features/profiles.md set_profile payload semantics and the
scoped /mcp/p refusal; docs/features/agent-tokens.md pin section (404, not
403; zero-reach pin ≡ deleted pin).

Inverted pinned tests (T044), never deleted: TestProfile_PinnedTokenURLEnforcement
(403+pin text → uniform 404 without pin), TestSetProfileClearReportsPinnedScope
and TestHandleSetProfile_PinnedTokenClearIntersectsAllowedServers (active_profile
"" on clear), TestHandleSetProfile_PinnedTokenSelectsDisjointPin (→ refuses,
no mutation). Admin controls kept: TestProfile_404NoProfiles/404UnknownSlug,
TestHandleSetProfile_AdminUnchanged, the anonymous `available` control in
TestProfile_DeletedPinDoesNotEnumerateProfiles.

Verified: go test -race (CI -skip regex) ./internal/server/... ok;
go test -tags server -race ./internal/serveredition/... ok; both editions build;
gofmt/vet clean on touched files; goldens untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…snapshot resolver, operator log on profile-URL refusals, fail-closed nil-config auth

Findings confirmed and fixed (red-test-first; verdict table in .review-tmp/critique-r1.md):

- A1/A2 (SC-005): handleSetProfile rendered `servers` from ProfileScope's
  map-backed set — nondeterministic order, duplicates dropped — for every
  caller, and applied FR-003's URL-precedence reporting to administrators
  too. Administrators now short-circuit to the pre-105 payload (selected
  profile's EffectiveServers / allServerNames on clear, byte-for-byte);
  scoped callers render the effective scope in profile-declared order.
  Admin controls now require.Equal exact slices on a 5-server fixture and
  an admin URL-scope leg is added.
- S4/N7: resolveActiveProfileIn(ctx, cfg) / profileScopeForSlugIn(cfg,
  slug) — the handler admits, stores and reports from ONE config snapshot.
- S1: profileMiddleware logs every scoped refusal (agent_name, profile,
  remote_addr) — the gate answers before the logging handler, so this is
  the only trace a token probing /mcp/p/<slug> leaves.
- N5: mcpAuthMiddleware refuses (503) an agent token when no configuration
  is published instead of forwarding it with no AuthContext (absent
  context reads as administrator downstream).
- Tests: assertUniformProfileRefusal pins the error text; vacuous
  NotContains removed; duplicate pin-clear test folded into the inverted
  original.
- Docs: server-edition-user over-claim removed; deleted-pin warning
  attributed correctly; pin-mismatch message documented.

Deferred with rationale: resolver collapse of a zero-reach pin (PR C
fixture), pin-naming mismatch text (PR G), client-choosable session id
(Spec 058 US3).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e predicate

selectableProfileNames returned on the first reachable pin but walked the
whole profile slice for a deleted or zero-reach pin. profileMiddleware and
handleSetProfile consult it before issuing the uniform refusal, so a pinned
caller could tell "pin alive, slug mismatched" from "pin gone / zero reach"
by the work its own refusal cost — a timing-class oracle on pin existence
(spec Definitions: non-disclosing = status, body AND timing class).

The predicate is now forEachProfileSelectable: it visits every configured
profile in order, computes reach for each one whenever the caller is scoped
or pinned, applies the pin as a filter and never returns early;
selectableProfileNames accumulates through it into a pre-sized slice so even
the result allocation is constant. Administrator semantics are unchanged
(every configured profile, empty and ghost ones included — SC-005); the
orphaned profileNames helper is removed.

Tests: TestSelectableProfileNames_PinOutcomesDoSameWork (AllocsPerRun parity
across reachable / zero-reach / deleted pin on an equal-sized fleet — 2/1/0
before, equal after) and
TestForEachProfileSelectable_VisitsEveryProfileRegardlessOfOutcome (traversal
counter over eight caller kinds, picks == selectableProfileNames).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The uniform /mcp/p/<slug> refusal computed the whole selectable-profile
list (one reach pass per configured profile) before refusing, so a scoped
token's refusal cost zero iterations over an empty fleet and one per
profile over a populated one — same 404 body, work proportional to the
number of hidden profiles (FR-004 timing class; 1.7 µs vs 0.9 ms at
10 000 profiles through the middleware).

- profileIndex: immutable slug → profile map per config snapshot, cached
  by snapshot pointer (profileIndexCache, atomic.Pointer on Server).
- profileIndex.selectable evaluates the selectable rule for the requested
  slug (and the pin) only: same lookups and one allocation-free reach
  computation (profileHasReach, shared with forEachProfileSelectable) on
  every branch — absent, not selectable, deleted pin, zero-reach pin, pin
  mismatch, no profiles.
- profileMiddleware → serveProfileURL(w, r, cfg, next): the gate after the
  snapshot read, so fleet tests run on a bare Server (a live runtime over
  4 097 profiles spends the test building per-profile indexes). set_profile
  keeps selectableProfileNames; the URL gate never calls it.
- Tests: refusal allocation parity over 0/1/4 097-profile fleets; lookup-
  hook traversal seam (gate resolves ⊆ {slug, pin}, mutation-tested);
  wiring through the seam; pure-predicate zero-alloc and list-equivalence;
  cache-per-snapshot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…set_profile refusal, warmed index

Codex round 3 on Spec 105 PR D (FR-003/FR-004, research D1): three findings,
all confirmed by trace and throwaway timing probe, fixed red-test-first.

1. The warmed URL gate still told a deleted pin from an existing zero-reach
   one by work: profileHasReach scanned the candidate's declared list for
   every configured server (nil for a missing profile, the real list for an
   existing one) — 9.3 ms vs 3.2 µs over 4 096 servers. Reach is now
   precomputed per profile when the per-snapshot index is built (one bitset
   over the configured servers' positions, plus an all-zero placeholder for a
   slug the snapshot lacks); profileIndex.reach reads one bit and runs the
   credential check unconditionally per configured server, so a missing,
   deleted, empty or 4 096-server candidate costs the same (24.7 µs each).

2. set_profile decided the requested slug through the whole selectable list
   (one reach computation per configured profile) — 0.3 µs over one profile,
   63 µs over 4 097, byte-identical body. It now decides the requested slug
   alone through the same index (profileIndexFor → the main Server's cache in
   production); a scoped caller's refusal is the list-free
   `unknown profile '<slug>'`, administrators keep the pre-105 `available:`
   affordance (SC-005). Traversal-counter seam: every scoped refusal over a
   1- or 4 097-profile fleet resolves ⊆ {slug, pin}.

3. Prior item: the index was still built lazily by the first request after
   startup or a reload (one insertion per hidden profile). Server now warms
   it at construction and on every config.saved / config.reloaded /
   servers.changed event; the lazy build stays as the fallback for the
   event-delivery window.

Tests: TestProfileIndex_ReachIsPrecomputedAtBuild,
TestHandleSetProfile_ScopedRefusalTouchesOnlySlugAndPin,
TestHandleSetProfile_AdminUnknownSlugKeepsAvailableList,
TestProfileIndex_WarmedBeforeFirstRequest; the list-carrying scoped
assertions inverted per SC-007 (never deleted). Docs: profiles.md set_profile
bullet + §404 responses; tasks.md T041/T042; verdict table in
.review-tmp/critique-r1.md "Codex round 3".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…er the fleet

profileIndex.reach walked every configured server and ran the credential
check on each, so a scoped token's uniform refusal on /mcp/p/<slug> and on
set_profile cost one iteration over a one-server fleet and 4 096 over a
fleet with 4 095 hidden servers — same 404, same zero allocations (so the
allocation-parity guards were blind), 36 ns vs 25 µs: the operator's server
population was a timing oracle (FR-004; spec Definitions: non-disclosing =
status, body AND timing class; codex review round 4).

Reach is now O(|reader grant|): the index precomputes per snapshot a server
name → position map, the per-profile membership bitset and a per-profile
non-empty flag. A reader that may see every server (administrator, absent
context, wildcard entry) does one test — is the candidate's set non-empty;
a restricted reader walks its OWN allowed_servers and does one O(1)
membership test per entry against the candidate's precomputed set, never
returning early, so the count is the grant size whatever the fleet, the
candidate (present, absent, empty, 4 098-server) or the outcome. After:
29–31 ns over 1 and 4 096 hidden servers alike.

Traversal-counter seam profileIndex.reachHook (one call per membership
test). Red tests: TestProfileMiddleware_RefusalReachCostsTheGrantNotTheFleet
over the new "4096 hidden servers" fleet shape in profile_url_gate_test.go
(the round-2 allocation-parity and lookup-seam tests replay over it too),
TestProfileIndex_ReachCostsTheGrantNotTheFleet, and the set_profile leg
TestHandleSetProfile_ScopedRefusalReachCostsTheGrantNotTheFleet. Docs:
profiles.md (set_profile and profile-URL cost statements), tasks.md T041.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tion; two-slot cache no request can roll back

Prior item P (round 3/4 residual): the profile index was warmed from the
config EVENT, which configsvc delivers after snapshot.Store, so a request in
the publication-to-event window built the fleet-sized index inline — a window
a token with server-write permission can open itself. configsvc gains
AddPrePublishObserver, a read-only observer list distinct from the #937
admission gate's single SetPrePublishHook slot: observers run inside
updateLocked after the gate hook and before snapshot.Store, on the exact
*Config that will be published. NewServer registers the index build there;
the construction-time warm (the initial snapshot is stored without
observers) and the event warm (belt-and-braces) stay.

Finding 1: profileIndexCache.For stored whatever snapshot its caller
captured, so a stalled request holding an older snapshot evicted the warmed
index for the current one and the next request rebuilt 4 096 hidden
profiles. The cache is now two-slot: warm (written only by the warm path)
and lazy (For's fallback, which never touches warm); the previous warm
index is demoted into lazy so a request that captured its snapshot just
before a publication still finds it.

Tests: configsvc observer ordering (sees the gated config before Current()
moves and before subscribers are notified; nil-safe), zero lazy builds
across N reloads over a live runtime with an observer-order seam, and the
rollback regression. research.md D17 records the decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… its snapshot as one pair; observers may register observers

Codex round 6 on the Spec 105 PR D profile work (two findings).

1. MUST-FIX: the two-slot index cache still let a request build a
   fleet-sized profile index inline — a request that captured snapshot
   A and paused across two publications (warm C, lazy B) rebuilt A on
   resume, while the empty-fleet equivalent built nothing (FR-004
   timing class). Structural fix: the request path never captures a
   config snapshot of its own. profileIndexCache.Current() hands out
   the warm index, which carries the config it was built from
   (idx.cfg); profileMiddleware passes that index to serveProfileURL
   and handleSetProfile takes profileIndexCurrent() and decides over
   its cfg — admission, server list and effective scope alike. The
   previous-warm demotion is gone (a request holds the index it was
   handed); For stays as the bare-test-server build slot only, never
   writing the warm slot. Because the pre-publish observer warms the
   slot before snapshot.Store, a request may hold the index of the
   gated config about to be published, one publication ahead of
   runtime.Config() for microseconds — documented as acceptable.

2. SHOULD: runPrePublishObservers held observersMu.RLock across the
   callbacks under updateMu, so an observer registering another
   observer deadlocked. The list is now cloned under the read lock and
   released before observers run; AddPrePublishObserver documents that
   observers run under the update mutex and must not publish
   (Update / UpdateIfCurrent / ReloadFromFile re-enter by design).

Tests (red first): TestProfileRequests_ServeTheIndexAboutToBePublished
(URL gate and set_profile, from inside a publication, admit the
profile that exists only in the config being published; zero builds),
TestProfileIndexCache_PausedRequestKeepsTheIndexItWasHanded,
TestProfileRequests_NeverBuildTheIndexOverARuntime (lazyBuilds seam
stays 0 on both caches across every request entry and three reloads),
TestService_PrePublishObserverMayRegisterAnObserver. research.md D17
updated: the request path takes the index+snapshot pair; no request
builds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…s the published snapshot, not the one ahead of it

The warm (index, cfg) pair became request-visible the instant the
pre-publish observer ran, before configsvc stored the snapshot, so a
request could be admitted against the NEXT config while
resolveActiveProfile's pin tier — reading runtime.Config() independently
downstream — still answered the previous one: admission and the
effective scope could split across two snapshots within one request.

profileIndexCache now keeps the last two prepared pairs (warm, previous)
and Published(cfg) returns whichever matches the caller's own
runtime.Config() read, never the unconditional latest one; profileMiddleware
and profileIndexCurrent call it instead of Current(). serveProfileURL also
injects the admitted pair's cfg into the request context, and
resolveActiveProfile reads it in preference to a fresh live read, so a
reload landing between admission and a downstream pin resolution can't
split the two either. TestProfileRequests_ServeTheIndexAboutToBePublished
is rewritten (and renamed) to assert the opposite of its old behaviour;
TestProfileMiddleware_InjectsAdmittedSnapshotForDownstreamResolution is new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e tests run

The profile-gate fixture returned while the runtime's background
initialization was still writing index.bleve under the test's TempDir,
so shutdown and the TempDir cleanup raced it (shuffle lane: "directory
not empty"; Windows: "file is being used by another process"). Wait for
PhaseReady first, as server_logs_missing_file_test already does.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ss-wide noise

testing.AllocsPerRun counts every malloc in the process, so a goroutine
still winding down from an earlier test inflated one case (CI read 48
against 12). Build the index outside the window and take the minimum
over a few samples of the predicate alone — noise only ever adds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…te's admitted (index, snapshot) pair, and a pin mismatch refuses like any other non-selectable profile

MUST-FIX 1: handleSetProfile called profileIndexCurrent(), which read
runtime.Config() independently even on a /mcp/p/<slug> request whose
gate had already admitted a specific (index, cfg) pair and injected
only cfg into the context. A reload landing between admission and the
set_profile call could hand set_profile a different snapshot than the
one the URL gate used for the same request. serveProfileURL now
injects the whole admitted *profileIndex pair
(withProfileRequestIndex); profileIndexCurrent(ctx) and
resolveActiveProfile both prefer it outright — no build, no
Published()/runtime.Config() read — falling back to the existing
Published/For chain only for the base /mcp endpoint, which admits no
snapshot of its own.

MUST-FIX 2: a profile-pinned token asking for a different, otherwise
selectable profile was refused by an early branch
("agent token is pinned to profile '<pin>' and cannot switch to
'<slug>'") before any index lookup, distinguishable in body and work
from the uniform "unknown profile '<slug>'" a deleted, zero-reach or
disjoint profile receives. That branch let a caller confirm from the
wording alone that it is pinned, and to what. It is removed; a pin
mismatch now falls through to profileIndex.selectable, the same
predicate and body as every other non-selectable outcome
(contracts/refusals.md's `set_profile <not selectable>` row already
documented this as one format string).

Tests: TestProfileMiddleware_SetProfileDecidesWithTheAdmittedIndexNotAFreshRead
(new — a profile present only at admission stays selectable after a
reload removes it live, and a profile present only in the reload stays
refused, both within the one admitted request);
TestHandleSetProfile_PinnedRejectsOtherSlug and
TestHandleSetProfile_ScopedRefusalTouchesOnlySlugAndPin updated for the
uniform body; TestHandleSetProfile_ScopedRefusalReachCostsTheGrantNotTheFleet
extended with the pin-mismatch case. docs/features/profiles.md and
specs/105-agent-scope-hardening/research.md (D1, D17) updated to match.

Verification: gofmt clean; go build (both editions, -o /dev/null);
GOOS=linux/windows go vet ./internal/server/...; go test -race
-count=1 on ./internal/server/... (standard -skip regex, 400s),
./internal/runtime/... (5 packages) and
-tags server ./internal/serveredition/... (7 packages) — all green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…mically, never a stale read-then-match

profileMiddleware and set_profile's base-/mcp path (profileIndexCurrent)
each read runtime.Config()/currentConfig() once and only then called
Published(cfg) against that frozen read, falling back to For(cfg) on a
miss. A request paused across two publications between those two steps
missed both the warm and previous prepared pairs and fell back to a
fleet-sized inline build — the same timing-class disclosure D17 exists
to close, just relocated from the admission race (rounds 7-9) to the
acquisition race between a request's own config read and the cache
lookup.

profileIndexCache.Acquire(readCfg) closes it structurally: it loops a
bounded number of times (8), re-reading readCfg() and matching it via
Published on every iteration instead of freezing one read that may
already be stale by the time it is used. Each iteration is O(1), so
even a publication storm costs O(retries), never a build. A genuine
miss (nil) is left for the caller: serveProfileURL and
profileIndexCurrent refuse a scoped caller uniformly and O(1) on nil
(no lookup, no disclosure, no build) and fall back to a fresh For
build for an administrator-shaped caller, whose timing is not
contract-bound (SC-005). For/Current remain only the bare-test-server
fallback and the caches' own warm-path mechanics.

research.md D17 extended with the round-11 acquisition rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ost finding with a file:line trace and measurement

The round-13 reviewer finding claimed a write-capable agent's config
mutation (upstream_servers add) builds the profile index synchronously
inside the pre-publish observer, making its latency scale with hidden
profiles/servers — a D17/SC-005 non-disclosing-refusal concern.

Traced instead: Runtime.applyConfigLocked already runs
DetectConfigChanges (walks and JSON-diffs the whole Servers slice,
twice, sometimes three times), config.SaveConfig (marshals the entire
config to disk) and upstreamManager.SetGlobalConfig (walks every live
client) before configSvc.Update ever runs the pre-publish observer that
builds the profile index. The config-publishing path was already
O(fleet) end to end; the observer adds a bounded constant factor to a
timing class that predates this PR.

Measured at 4096 profiles/servers: newProfileIndex (the observer) =
437 µs/op vs SaveConfig = 19.42 ms/op and one DetectConfigChanges pass
= 9.81 ms/op (called 2-3x per apply) — well under 1.1% of the
pre-existing work, so the maintainer's move-the-bitset-out-of-the-
observer branch does not fire. Also confirmed the profiles×servers
bitset is not a droppable dead artifact: idx.reach reads it via an
O(1) bit test on every scoped call, the mechanism that keeps profile
selectability O(reader grant) rather than O(fleet) (D1) — so it stays.

No production code changed. research.md D17 records the refutation
verbatim; .review-tmp/critique-r1.md carries the full file:line trace
and benchmark; .review-tmp/pr-d-body.md carries the same "Retained
effects" text for the eventual PR body.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ex, never a fleet walk (Spec 105 PR D review round 14 — MUST-FIX)

After O(1) admission, successful scoped reads still materialized their
effective server set by rebuilding a fleet-sized "known servers" set on
every call: /mcp/p/<slug>, resolveActiveProfileIn's pin/session tiers
(every scoped retrieve_tools/describe_tool/call_tool_*/code_execution
call under a pinned or session profile), and set_profile's success
payload (including the cleared-session case, which additionally walked
every configured server a second time to apply the credential).

profileIndex.EffectiveServersFor(profileName, allowed) intersects the
reader's own grant against the index's precomputed serverPos/members
data instead: O(len(allowed)) for a restricted grant, never O(len(cfg.
Servers)). All four cited call sites now use it (or its int-keyed
sibling effectiveServersForCandidate, reusing an admission lookup the
gate already paid for); administrators keep the unchanged,
fleet-proportional path (SC-005). scopeServersIn/callerVisibleServers
are now dead and removed.

profileIndex.position also gained a bounds check against idx.cfg.
Profiles' CURRENT length: two existing tests mutate *config.Config in
place after an index is built/cached, which the new EffectiveServersFor
call sites exposed as an out-of-range panic on a stale cached position.
The check is a no-op for any snapshot that is never mutated in place.

research.md D17 records the round; golangci-lint (stuck on Go 1.25
since round 6) upgraded to 2.13.2 so the v2 lint pass could actually
run locally.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dumbris and others added 2 commits September 17, 2026 22:35
…air (Spec 105 PR D review round 15 — MUST-FIX)

resolveActiveProfile extracted only injected.cfg from the context-carried
(index, snapshot) pair and handed it to resolveActiveProfileIn, which
resolved the index a SECOND, independent time via profileIndexFor(cfg) — an
O(1) Published(cfg) match that falls back to a fleet-sized For(cfg) rebuild
on a miss. A request admitted with pair A that then paused while two more
publications landed would find A evicted from both the warm and previous
cache slots by the time resolveActiveProfile ran, rebuilding the whole
fleet inline for what must stay an O(1) decision — exactly the
pair-acquisition bypass rounds 11/13 closed on the admission path
(profileIndexCurrent/Acquire), reopened here on the downstream resolution
path a paused request reaches next.

resolveActiveProfile now calls resolveActiveProfileFromIndex directly with
the already-resolved pair when profileRequestIndexFromContext succeeds,
bypassing resolveActiveProfileIn/profileIndexFor(cfg) entirely on that
path; the profileIndexFor(cfg) route is reached only for the
no-injected-pair case (plain /mcp, no URL admission), unchanged.

A second round-12 finding argued resolveActiveProfileIn's pin/session
tiers should intersect the profile's declared membership with the
caller's own AllowedServers instead of a hardcoded wildcard, to bound the
per-call cost by the caller's grant rather than the profile's (possibly
much larger) declared population. Refuted: round 14 already tried and
reverted exactly this change (recorded in its own research.md D17 entry),
and reproducing it this round breaks the same tested contract
(TestResolveActiveProfile_PinHighestPrecedence, a pin context with no
AllowedServers asserting full pin-profile reach) — the reproduction and
the full downstream-consumer trace are recorded in research.md D17 and
.review-tmp/critique-r1.md under "Review round 15"; no production code
changed for that finding.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…wn grant with its profile (Spec 105 PR D review round 17 — MUST-FIX)

cacheAuthorizationWith stamped cache.Authorization.ProfileServers from
the resolver's wildcard-derived FULL profile membership, unintersected
with the caller's own AllowedServers. CouldHaveProduced/coversAll then
compares that stamped set on redemption, so a profile member entirely
outside an agent token's own grant — a server it never had, and never
will have, access to — entered the comparison: removing that hidden
member later revoked the SAME token's cache access to its own,
still-authorized server, turning the token's own cache hit/miss into
an observable side-channel for an unrelated server's continued
existence in the profile (SC-005-class disclosure through the cache
layer; round 16's trace of a consumer round 15's refutation did not
reach).

Fix: a new resolveActiveProfileWithIndex seam returns the (index,
snapshot) pair a (name, scope) pair was resolved against, so
cacheAuthorizationWith can stamp an agent caller's ProfileServers via
idx.EffectiveServersFor(profileName, ac.AllowedServers) — the same
O(len(caller-grant)) helper handleSetProfile's own scoped-visible path
already uses — instead of re-deriving a possibly different snapshot's
index or walking the profile's declared size. Non-agent scoped callers
(no AllowedServers of their own) keep the resolver's full-membership
semantic; resolveActiveProfileIn/resolveActiveProfileFromIndex's own
ProfileScope construction is untouched, per round 15's standing
verdict.

Red tests first: a reproduction of the reviewer's exact scenario
(token grant {github}, profile {github,weather}, narrowed to {github}
after caching — access must NOT be revoked) and a companion (the
token's own grant server removed from the profile — access must still
be revoked).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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