Repository navigation
feat(scope): IdP-group server grants, one entitlement predicate, tenant session on core REST and Web UI (Spec 107 PR-C) - #1293
Merged
Merged
Conversation
…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
…nt (Spec 107 PR-A) Closes #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.
… rig (Spec 107 PR-B)
… binding and refusals (Spec 107 PR-B)
…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>
…edicate guard (Spec 107 PR-C)
…ex search and minting doors (Spec 107 PR-C)
…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>
…load and OAS wiring for PR-C
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.
Deploying mcpproxy-docs with
|
| 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 |
Contributor
📦 Build ArtifactsWorkflow Run: View Run Available Artifacts
How to DownloadOption 1: GitHub Web UI (easiest)
Option 2: GitHub CLI gh run download 35348419546 --repo smart-mcp-proxy/mcpproxy-go
|
|
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
…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
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>
2 of 3 tasks
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
entitledServerNamesFor(user, isAdmin)+ wrapperentitledServerNames(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.Sharedreader 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 aGET /user/activitydisclosure bug (it was ignoring the entitlement predicate entirely, trusting a not-yet-populatedAuthContext.AllowedServers).storage.SetAgentTokenOwnerResolverreplacing the two old callbacks): oneGetUsercall per token authentication;AllowedServersnarrowed live through the entitlement predicate ("*"materialises to the entitled set for a tenant, stays literal for an administrator — FR-009);Email/Provider/Rolecarried as new non-persisted (json:"-") fields on the validated token, never written to BBolt.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-limitof the filtered ranking, so a hidden high-ranking result can never displace an entitled one (internal/index/bleve.goSearchToolsScoped).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.principalKind;/profiles, SSE re-resolution per frame (including the heartbeat branch, closed in round 2), andGET /user/activityall 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)
Absent
access= unchangedShared-only behaviour (every shared server, like today). A present block — even{}— is deny-all for any group/default that doesn't match:default_serverscovers 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.go vetboth tags@latestfallback)internal/server), 64 packagesinternal/serverrace (CI-skip regex)-tags server -racego test ./cmd/...,./tests/oauthserver/...make swagger-verifyvitest(1298 tests) +npm run buildgen-roadmap.py --checktest-api-e2e.sh, two runs"*"token materialisation, hot reload narrows a live session/token with no restart or rotation, JWT can't self-renew, admin parityserver-edition-tenant.spec.ts) against the riggit diffdoes not touchrouter/index.ts)