Skip to content

feat(scope): IdP-group server grants, one entitlement predicate, tenant session on core REST and Web UI (Spec 107 PR-C) - #1293

Merged
Dumbris merged 51 commits into
mainfrom
107-c-group-allowlist
Sep 18, 2026
Merged

Dumbris merged 51 commits into
mainfrom
107-c-group-allowlist

Conversation

@Dumbris

@Dumbris Dumbris commented Sep 17, 2026

Copy link
Copy Markdown
Member

Summary

Stacked on #1292 → #1287 — merge in order.

Spec 107 PR-C (Phase C.1–C.4, T067–T095): one entitlement predicate with IdP-group server grants, owner resolution on every authentication, scoped index search, and a session-cookie tenant principal on core REST + the Web UI — so a tenant can use the product with nothing but their SSO session.

  • One entitlement predicate (entitledServerNamesFor(user, isAdmin) + wrapper entitledServerNames(userID, isAdmin), internal/serveredition/api/user_handlers.go): group grants (server_edition.access.group_servers/default_servers) read live; empty/absent access map = deny-all for non-administrators; administrators keep today's whole-configuration view byte-for-byte (SC-006). Seven previously-independent .Shared reader sites collapsed onto it; an AST guard (user_handlers_shared_guard_test.go) pins that collapse. Two cross-review rounds closed a hot-reload snapshot race (servers and the access block are now read from one live snapshot per request, entitledServerNamesForSnapshot/EntitlementSnapshotProvider) and a GET /user/activity disclosure bug (it was ignoring the entitlement predicate entirely, trusting a not-yet-populated AuthContext.AllowedServers).
  • Owner resolution on every authentication (storage.SetAgentTokenOwnerResolver replacing the two old callbacks): one GetUser call per token authentication; AllowedServers narrowed live through the entitlement predicate ("*" materialises to the entitled set for a tenant, stays literal for an administrator — FR-009); Email/Provider/Role carried as new non-persisted (json:"-") fields on the validated token, never written to BBolt.
  • Scoped index search (GET /api/v1/index/search): filters before the ranked cut, not after — an exhaustive paged scan of the same boolean query, cut to the top-limit of the filtered ranking, so a hidden high-ranking result can never displace an entitled one (internal/index/bleve.go SearchToolsScoped).
  • Session-cookie tenant principal on core REST (internal/httpapi/session_principal.go): one credential source decides per request (FR-001 precedence — a valid cookie never rescues a wrong API key or bearer token); a fixed, non-disclosing 403 body on every route not in the FR-045 tenant allowlist, enforced before body parsing; chi.Walk-exhaustive Go test (tenant_allowlist_walk_test.go) plus a browser-level Playwright smoke check over a representative subset.
  • Web UI on the SSO session alone (FR-041): every admin-only fetch on the dashboard, servers, activity, and settings views gated on principalKind; /profiles, SSE re-resolution per frame (including the heartbeat branch, closed in round 2), and GET /user/activity all give a tenant the same shape/masking as the core doors, scoped to their own records.

Two cross-review rounds (chunked by area — predicate/storage, httpapi hook/allowlist, SSE/profiles/activity, frontend) found and fixed 9 genuine defects total, most of them real information-disclosure bugs against a live reproduction (not just unit assertions): an encoded-path allowlist bypass exposing up to 100 cross-user tool-call records, two entitlement/config snapshot races, an activity-log door that ignored the predicate outright, and five frontend gating gaps that drew spurious or (for ModeSwitcher) genuinely reachable 403s for a tenant session. Details, live reproduction and the exact fix commit for each: verification.md → ## PR-C → ### Cross-review.

Access block (example)

"server_edition": {
  "access": {
    "group_servers": { "eng": ["a"], "ops": ["a", "b"] },
    "default_servers": []
  }
}

Absent access = unchanged Shared-only behaviour (every shared server, like today). A present block — even {} — is deny-all for any group/default that doesn't match: default_servers covers a user whose groups match no key.

Verification gate table (short)

Full command-by-command log: specs/107-server-edition-sso-hardening/verification.md → ## PR-C.

Gate Result
Both editions build, go vet both tags PASS
golangci-lint v2 (pinned binary refuses go1.26; @latest fallback) PASS — 0 findings in any PR-C file
Unit + race (excl. internal/server), 64 packages PASS
internal/server race (CI-skip regex) PASS (406.8s)
Server-edition job package list, -tags server -race PASS — 12 packages
go test ./cmd/..., ./tests/oauthserver/... PASS
make swagger-verify PASS
Frozen tool-surface goldens (FR-044, unregenerated) PASS
Administrator parity (SC-006), live + fixture PASS
Frontend vitest (1298 tests) + npm run build PASS
gen-roadmap.py --check PASS
Isolated test-api-e2e.sh, two runs 63/65 both — the 2 failures are a pre-existing spec-046 launcher-lifecycle fixture issue, outside every PR-C file
Real-instance rig (quickstart §5–§7): entitlement-filtered REST, non-disclosing by-name refusals, "*" token materialisation, hot reload narrows a live session/token with no restart or rotation, JWT can't self-renew, admin parity PASS, live
Playwright (server-edition-tenant.spec.ts) against the rig 4/5 — login/dashboard/servers/refused-routes/SameSite/admin-settings all pass; the 5th hit an unrelated pre-existing router deep-link/reload bug at the token-mint step (flagged separately, not a PR-C regression — git diff does not touch router/index.ts)

Dumbris and others added 30 commits September 16, 2026 10:45
…al IdPs

Generic OIDC provider, IdP-group → server allowlist, attributable JSONL
audit line, and a freeze/cut of the latent multiuser/credential-injection
code, split into four PRs (A freeze/cut, B OIDC + front door + telemetry,
C entitlement predicate + tenant session, D audit line).

Judge-panel drafted; cross-model reviewed over nine rounds (codex gpt-5.6-sol),
~130 findings resolved. Un-parks roadmap epic sso.
… and normalise removed config keys (Spec 107 PR-A)

Related #1177
Resolves the add/add conflict in .github/RELEASE_NOTICE.md by keeping the
branch copy, whose first section is byte-identical to main's Spec 105 notice
(#1279) and which appends the Spec 107 PR-A entries after it.
…ns, groups capture and subject binding (Spec 107 PR-B)

Related #1169
…e security and forced MCP auth (Spec 107 PR-B)
…ed proxies, cookie security policy, forced MCP auth, provider probe (Spec 107 PR-B)
… keys; telemetry v13 server-edition fields (Spec 107 PR-B)
T062/T063: real-instance verification of the OIDC front door (happy-path
login, groups, /auth/me, agent-token mint + /mcp initialize/tools-list,
ErrorMode tamper matrix, cookie Secure matrix behind a simulated trusted vs
untrusted proxy) and the telemetry v13 payload (server vs personal). T064:
full gate set re-run on HEAD after cross-review round 6 touched the config
load path, oauth_handler.go and telemetry.go — all green. T066: tick
completed PR-B tasks (T032-T066); ROADMAP.md regenerated.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…1288)

This branch diverged from main before #1288 landed, so PR-B's CI hit the
exact regression it fixed: handleAddFromRegistry's success path dereferenced
a nil cfg, chi's recoverer turned the panic into a bare 500, and the
recovered hardware fault corrupted the Go heap on windows/amd64 under Go
1.26 (golang/go#81238) — the internal/httpapi test binary crashed with
"found pointer to free object" and took the whole Windows unit-test job (and
the binaries job's httpapi/scanner test bundle) down with it. Cherry-picked
the same fix main already carries: the nil-config guard with a JSON 500 (and
its pinning test), plus the unrelated Pass-2 temp-dir teardown race fix in
the scanner test this same upstream commit bundled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dumbris and others added 15 commits September 17, 2026 03:27
…esolution on every authentication, scoped index search (Spec 107 PR-C)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
…oute allowlist and Web UI gating (Spec 107 PR-C)
…E/activity projections, Web UI on the SSO session alone (Spec 107 PR-C)

T083: SessionPrincipalResolver type in internal/httpapi/session_principal.go; rewrote
apiKeyAuthMiddleware with FR-001 precedence (X-API-Key > Authorization > ?apikey > session cookie).
Auth context now excludes session principals from CanRevealSecrets.

T084: Session principal resolver built in setup.go via userHandlers.ResolveAgentTokenOwner,
installed via Dependencies and wired in serveredition_wire.go.

T085: GET /profiles omits profiles whose entitlements don't intersect session scope;
GET /profiles/active returns empty when active profile is hidden. SSE and activity paths
re-resolve session principals before every frame for live-narrowing.

T086: Activity filtering and masking for tenant sessions; GET /user/activity added;
admin_user principals answer unconditionally with empty list (SC-006).

T088–T089: Frontend principal-kind gating for tenant sessions (onboarded to /login,
hidden config/routing/servers/stats/security/activity surfaces). AdminUsers adds Groups
column; AdminServers shows server-scoped group/grant chips. Playwright e2e test added
(quickstart.md spec).

Both editions build clean; all unit-tests.yml -tags server race suites pass.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Full plan.md gate set run against PR-C HEAD (1e379ff): all gates
pass, no PR-C code required fixing. golangci-lint's pinned v2 binary
refuses the go1.26 module (pre-existing tooling gap); the @latest
fallback found 16 issues, all pre-existing and outside the PR-C diff.
The isolated test-api-e2e.sh run's two failures (launcher-lifecycle
reconnect/log capture) are spec-046 fixture flakes unrelated to any
PR-C file, reproduced identically on two runs. Records the full
pass/fail table under '## PR-C / ### Automated checks' in
verification.md.
Four genuine defects found and fixed across three opencode
gpt-5.6-sol/terra review chunks (a fourth chunk was clean plus two
low-value findings rejected as pinned-by-design or non-security):

- tenantSessionAllowlist matched on the DECODED r.URL.Path, but chi
  routes on RawPath — a server name containing a literal "/" (nothing
  forbids one; only ":" is refused) reaches the real handler via its
  percent-encoded id while the allowlist Cuts the decoded path on the
  wrong boundary, bypassing the named must-refuse for
  /servers/{id}/tool-calls. Fixed by matching on RawPath when set.

- visibleSharedServers/visibleSharedServer independently re-read the
  live admin-config snapshot after the entitlement predicate had
  already read its own, opening a hot-reload race where a server
  could be entitled against one snapshot and disclosed from another.
  Fixed with tenantEntitledSnapshot, which fetches the snapshot once
  and threads it through both the predicate and the disclosure lookup.

- GET /user/activity read AllowedServers off the AuthContext, but the
  ServerEditionAuthMiddleware this door is actually mounted behind
  never populates that field (only the separate SessionPrincipalResolver
  path does) — so every tenant on this door saw every user's activity
  for every server, entitled or not. Fixed by resolving the entitlement
  set live through the one predicate instead.

- Dashboard.vue's loadActivitySummary was the one dashboard loader
  missing the tenant guard its four siblings already carry, drawing a
  spurious 403 against the now-allowlist-refused /activity/summary on
  every tenant page load and 30s refresh.

Also corrected RELEASE_NOTICE.md's inverted claim about the access
block's default (absent = Shared-only, NOT deny-all; only a present-
but-empty block is deny-all) and widened the Playwright refused-route
smoke list with several named must-refuse examples.

Two findings verified and rejected: the nil-vs-empty access-block
collapse in ServerEditionAccessProjection is deliberate, pinned by an
existing config_hotreload_serveredition_test.go case; rejecting an
empty group_servers key is defensive config validation, not a spec
violation.
Appends the round 1 entry (4 genuine findings fixed, 2 rejected with
reasons, full verification command list) under PR-C.
Cross-model review (opencode gpt-5.6-sol/terra) round 2 of PR-C:

- entitledServerNamesFor read the admin-config servers once (round 1's
  fix) but the access block through a separate, independent live call,
  so a hot reload landing between the two reads could splice a servers
  snapshot from one configuration version to an access snapshot from
  another. A new EntitlementSnapshotProvider derives both values from
  one liveConfig() read, wired in setup.go.
- The /events heartbeat branch never re-resolved the session principal,
  unlike its status/runtime-event siblings, so a disabled tenant's
  otherwise-idle stream never closed (FR-005).
- The tenant profile projection only omitted a profile when scoping
  narrowed a non-empty effective set to empty, missing the case where
  the effective set was already empty.
- Three tenant-dashboard call sites (Usage.vue's activity/usage poll,
  refreshSecurityScannerStatus, the ModeSwitcher control) and one
  Activity.vue fallback path issued or exposed admin-only core requests
  to a tenant session, contradicting FR-041; a doc paragraph misattributed
  an administrator-only capability to tenants.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
T079/T090: real-instance verification against the dev-server-edition
rig (quickstart §5-§7) - entitlement-filtered REST, non-disclosing
by-name refusals matching a nonexistent server, "*" token
materialisation to the entitled set, hot reload narrowing a live
session/token with no restart or rotation, JWT self-renewal refused,
live administrator parity. Web UI/Playwright run against the rig with
a properly embedded frontend (make build-server, since the rig
script's bare go build does not embed it): 4/5 pass; the 5th hit a
pre-existing router deep-link/reload bug (title updates, body does
not) unrelated to the PR-C diff, flagged as a follow-up rather than
fixed inline.

T093: full gate set re-run against HEAD (aac5053, after both
cross-review rounds), since review landed code changes after the
prior gate run. All 19 non-e2e gates green; isolated
test-api-e2e.sh run twice (63/65 both times, same pre-existing
launcher-lifecycle pair, unrelated to PR-C).

Ticks T067-T095 in tasks.md; ROADMAP.md regenerated.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 17, 2026 •

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 9985d4a
Status: ✅  Deploy successful!
Preview URL: https://e1442442.mcpproxy-docs.pages.dev
Branch Preview URL: https://107-c-group-allowlist.mcpproxy-docs.pages.dev

View logs

@github-actions

github-actions Bot commented Sep 17, 2026 •

Copy link
Copy Markdown
Contributor

📦 Build Artifacts

Workflow Run: View Run
Branch: 107-c-group-allowlist

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-go89G5YF.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 35348419546 --repo smart-mcp-proxy/mcpproxy-go

Note: Artifacts expire in 14 days.

@codecov-commenter

codecov-commenter commented Sep 17, 2026 •

Copy link
Copy Markdown

…ture (PR-C CI)

TestMCPGroupScope_TwoFixtureParity_GroupScopedToken failed in CI (not
locally) because newGroupScopeFixture wrote directly to storage/upstream
manager right after NewServer(), racing StartBackgroundInitialization's
async LoadConfiguredServers — which treats cfg.Servers as the source of
truth and unconditionally re-saves every entry (including the disabled
shared-server placeholders this fixture registers) before flipping the
runtime to PhaseReady. When that reconciliation write lands after the
fixture's own Enabled:true write, the server reverts to Enabled:false in
storage and isExactToolCallable drops its indexed tool from
retrieve_tools — exactly the observed empty-vs-populated mismatch.

Same race already fixed once in this codebase (see
newLogsTestServer/server_logs_missing_file_test.go); this applies the
same require.Eventually(PhaseReady) guard here.
# Conflicts:
#	.github/RELEASE_NOTICE.md
#	ROADMAP.md
#	docs/configuration/config-file.md
#	docs/development/server-edition-multiuser-auth.md
#	frontend/src/stores/auth.ts
#	internal/config/doctor_findings_serveredition.go
#	internal/config/server_edition_config.go
#	internal/config/serveredition_accessors.go
#	internal/config/serveredition_accessors_stub.go
#	internal/index/bleve.go
#	internal/runtime/config_hotreload.go
#	internal/runtime/config_hotreload_serveredition_test.go
#	internal/serveredition/api/credential_handlers.go
#	internal/serveredition/setup.go
#	roadmap.yaml
#	specs/107-server-edition-sso-hardening/tasks.md
#	specs/107-server-edition-sso-hardening/verification.md
@Dumbris
Dumbris merged commit 14801eb into main Sep 18, 2026
56 checks passed
@Dumbris
Dumbris deleted the 107-c-group-allowlist branch September 18, 2026 17:53
Dumbris added a commit that referenced this pull request Sep 18, 2026
Merge-driven fixes (Spec 107 PR-C, #1293, landed in main same day as this
PR's own commits):
- mintAgentToken (profile_integration_test.go) collided with an unrelated,
  same-named, server-tagged helper in mcp_auth_forced_test.go (Spec 107
  PR-C). Renamed this PR's own helper to mintProfileAgentToken — only used
  within its own file, no functional change.
- mcp_session_never_reaches_test.go (Spec 107 PR-C, server-tagged) called
  the pre-round-17 3-arg cacheAuthorizationWith signature; updated both
  call sites to the 4-arg (ctx, profileName, scope, idx) signature this
  PR's round 17 fix introduced.

Cross-model review round 1 (codex gpt-5.6-sol) findings, verified and fixed:
- cache_authz.go: round 17's caller-intersected ProfileServers stamp only
  special-cased AuthTypeAgent. Spec 107 PR-C's IdP-group grants give a
  plain OAuth AuthTypeUser its own restricted AllowedServers via the same
  CanAccessServer rule (nil/empty = deny-all), so a scoped server-edition
  user reopened round 17's exact cache side-channel (SC-005-class:
  removing a profile member outside a caller's own grant could revoke the
  caller's still-authorized cached access). Widened the intersection to
  every caller-bounded type (isAgent -> callerBounded).
- profile_tool.go effectiveServersForCandidate: the restricted-grant
  branch walked the profile's own declared list (O(profile size)) to
  reproduce "profile-declared order, duplicates kept", contradicting its
  own documented O(len(allowed)) invariant and reopening the exact
  SC-005 timing-disclosure class rounds 14-17 closed for every other
  admitted-read path. Fixed via a new declaredOccurrences precomputed
  index (server name -> ordered declared positions per profile, built
  once per snapshot alongside members/serverPos), so a restricted grant
  now costs O(len(allowed)) instead of O(profile size).
- handleSetProfile: SetActiveProfile (session write) and the later
  effective-scope render (session read, via resolveActiveProfileFromIndex's
  tier 3) were not atomic, so a concurrent set_profile on the SAME session
  could make one call's response report ITS OWN slug in active_profile
  with a DIFFERENT call's servers snapshot in the servers field, an FR-003
  consistency violation under concurrency. Added
  resolveEffectiveProfileForJustSetSlug, which resolves tier 3 from the
  slug this call itself just wrote instead of re-reading the session
  store, closing the race for this call site only.
- mcp_read_cache_authz_test.go: TestReadCache_..._OwnGrantMemberRemovalStillRevokes
  asserted the administrator-only "not readable with this credential"
  wording on a scoped (pinned agent) reader; readCacheRefusal's own
  documented non-disclosing contract remaps that case to the uniform
  "cache key not found" body for every non-administrator caller. Fixed
  the assertion; this was a pre-existing test bug, reproduced identically
  before this round's other changes, not a regression from them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Dumbris added a commit that referenced this pull request Sep 19, 2026
…th one non-disclosing refusal (Spec 105 PR D, FR-003/004) (#1283)

* test(scope): Spec 105 PR D red phase — selectable-profile predicate on /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>

* feat(scope): Spec 105 PR D — selectable-profile predicate on /mcp/p and 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>

* 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

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>

* fix(scope): PR D codex round 1 — constant-traversal selectable-profile 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>

* fix(scope): PR D codex round 2 — O(1)-in-the-fleet profile-URL gate

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>

* fix(scope): PR D codex round 3 — precomputed per-profile reach, O(1) 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>

* fix(scope): PR D review round 4 — reach costs the reader's grant, never 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>

* fix(scope): PR D review round 5 — index every snapshot before publication; 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>

* fix(scope): PR D review round 6 — requests take the profile index and 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>

* fix(scope): PR D review round 8 — request-visible profile index tracks 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>

* test(server): let background initialization settle before profile gate 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>

* test(server): measure the pin-outcome allocation parity without process-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>

* fix(scope): PR D review round 9 — set_profile decides with the URL gate'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>

* fix(scope): PR D review round 11 — acquire the profile-index pair atomically, 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>

* docs(scope): PR D review round 13 — refute the synchronous observer-cost 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>

* fix(scope): admitted scoped reads render servers from the profile index, 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>

* fix(scope): resolveActiveProfile decides straight from the admitted pair (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>

* fix(scope): cache producer/reader stamp intersects an agent token's own 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>

* fix(scope): merge-driven collisions + cross-model review round 1 fixes

Merge-driven fixes (Spec 107 PR-C, #1293, landed in main same day as this
PR's own commits):
- mintAgentToken (profile_integration_test.go) collided with an unrelated,
  same-named, server-tagged helper in mcp_auth_forced_test.go (Spec 107
  PR-C). Renamed this PR's own helper to mintProfileAgentToken — only used
  within its own file, no functional change.
- mcp_session_never_reaches_test.go (Spec 107 PR-C, server-tagged) called
  the pre-round-17 3-arg cacheAuthorizationWith signature; updated both
  call sites to the 4-arg (ctx, profileName, scope, idx) signature this
  PR's round 17 fix introduced.

Cross-model review round 1 (codex gpt-5.6-sol) findings, verified and fixed:
- cache_authz.go: round 17's caller-intersected ProfileServers stamp only
  special-cased AuthTypeAgent. Spec 107 PR-C's IdP-group grants give a
  plain OAuth AuthTypeUser its own restricted AllowedServers via the same
  CanAccessServer rule (nil/empty = deny-all), so a scoped server-edition
  user reopened round 17's exact cache side-channel (SC-005-class:
  removing a profile member outside a caller's own grant could revoke the
  caller's still-authorized cached access). Widened the intersection to
  every caller-bounded type (isAgent -> callerBounded).
- profile_tool.go effectiveServersForCandidate: the restricted-grant
  branch walked the profile's own declared list (O(profile size)) to
  reproduce "profile-declared order, duplicates kept", contradicting its
  own documented O(len(allowed)) invariant and reopening the exact
  SC-005 timing-disclosure class rounds 14-17 closed for every other
  admitted-read path. Fixed via a new declaredOccurrences precomputed
  index (server name -> ordered declared positions per profile, built
  once per snapshot alongside members/serverPos), so a restricted grant
  now costs O(len(allowed)) instead of O(profile size).
- handleSetProfile: SetActiveProfile (session write) and the later
  effective-scope render (session read, via resolveActiveProfileFromIndex's
  tier 3) were not atomic, so a concurrent set_profile on the SAME session
  could make one call's response report ITS OWN slug in active_profile
  with a DIFFERENT call's servers snapshot in the servers field, an FR-003
  consistency violation under concurrency. Added
  resolveEffectiveProfileForJustSetSlug, which resolves tier 3 from the
  slug this call itself just wrote instead of re-reading the session
  store, closing the race for this call site only.
- mcp_read_cache_authz_test.go: TestReadCache_..._OwnGrantMemberRemovalStillRevokes
  asserted the administrator-only "not readable with this credential"
  wording on a scoped (pinned agent) reader; readCacheRefusal's own
  documented non-disclosing contract remaps that case to the uniform
  "cache key not found" body for every non-administrator caller. Fixed
  the assertion; this was a pre-existing test bug, reproduced identically
  before this round's other changes, not a regression from them.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(scope): dedupe caller grant before expanding declaredOccurrences

Cross-model review round 2 finding: the round-1 fix for
effectiveServersForCandidate's restricted-grant timing oracle
(declaredOccurrences) multiplied a name's occurrences by BOTH the number
of times it appears in the profile's own declared list AND the number of
times it appears in the caller's own (unvalidated, possibly repeating)
AllowedServers — e.g. declared [A,B,A] + grant [A,A] produced [A,A,A,A]
instead of [A,A]. The prior grant-map-based walk deduped `allowed`
implicitly (Go map keys), so only declared's own duplicate count drove
the output; this restores that exact contract via a dedupeSeen guard.

Added TestProfileIndex_EffectiveServersForRestrictedGrant_DuplicatesFollowDeclaredOnly
pinning both halves: declared duplicates are kept, grant duplicates are not
multiplicative.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(scope): resolve only the profile name for set_profile's slug-race fix

Cross-model review round 2 findings on the resolveEffectiveProfileForJustSetSlug
fix (previous commit):
- Its only caller discards the *ProfileScope return value, but
  profileScopeFromIndex always resolves the WILDCARD-derived full profile
  membership to build it — O(profile size) work paid for a value nothing
  used. Changed the function to return only the name, using idx.position's
  O(1) existence check for the pin/slug tiers instead of building a scope.
- The stale-pin branch had dropped the operator warning log
  resolveActiveProfileFromIndex's own stale-pin branch emits, silently
  changing observability. Restored it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Dumbris added a commit that referenced this pull request Sep 20, 2026
…main (#1324)

CHANGELOG.md's Unreleased section was missing entries for 9 recent
behavior-changing commits, most notably the Spec 105 scope-hardening
security fixes (#1283/#1284/#1285) and Spec 107 SSO features
(#1293/#1296).

roadmap.yaml had drifted stale in both directions for two in-flight
specs:
- Spec 105 (agent-scope-hardening): 4 merged PRs (A #1279, B #1282,
  D #1283, E #1284) still showed status: todo, and PR H0 (#1285) had
  no roadmap task entry at all. Flipped to done; genuinely outstanding
  work (PR C/F/G/H1) is untouched.
- Spec 107 (sso): PR-D (#1296) is merged but the epic and its task
  still read in_review. Verified all 87 files PR-D touched exist on
  disk (internal/audit/*, docs/features/audit-log.md, config, docs,
  frontend) and that verification.md records a completed multi-round
  cross-model review and a passing SC-009 benchmark, then ticked the
  20 checkbox-lag tasks (T096-T115, T118-T119) in tasks.md and flipped
  both the task and epic status to done.

Regenerated ROADMAP.md via scripts/gen-roadmap.py.
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