Skip to content

fix(httpapi): intersect the profile pin into the shared enumeration predicate (Spec 118) - #1821

Merged
Dumbris merged 1 commit into
mainfrom
118-pin-visibility-boundary-impl
Nov 3, 2026
Merged

Dumbris merged 1 commit into
mainfrom
118-pin-visibility-boundary-impl

Conversation

@Dumbris

@Dumbris Dumbris commented Nov 3, 2026

Copy link
Copy Markdown
Member

Description

Related #1686 (Spec 118, specs/118-pin-visibility-boundary/spec.md, maintainer decision Option A).

A token minted with --profile <p> takes its grant from the profile, so its stored allowed_servers is normally "*". The REST surface's shared enumeration predicate (auth.CanEnumerateServer → canSeeServer / visibleServers) consulted only that grant, so the same token that GET /servers (#1684) and GET /tools (#1437) held to its pin could still learn every out-of-pin server's name, state and counts from GET /status, the /events stream, GET /review, GET /attention, GET /profiles and GET /annotations/coverage, and diff the counts across doors to learn how many servers were hidden.

What changes

  • One seam, one set. auth.AuthContext.BoundToPinReach(reach) returns a copy of the context whose AllowedServers is grant ∩ reach (the pinned profile's EffectiveServers under the live config). bindCallerToPin (internal/httpapi/pin_boundary.go) applies it at the two places that mint an agent token's context: handleAgentTokenAuth (every REST request) and newSSECallerContextRefresher (before every identity-bearing SSE frame). From there every read door, every recomputed count, scopeAllowedServers (activity / tool-call filters and the usage cache key), preflight's TokenServers and management.Doctor read the resolved set. No per-door pin filters were added; limitServersToCallerPin (fix(httpapi): hold profile-pinned agent tokens to their pin on GET /api/v1/servers #1684) stays for its tool_count narrowing and now admits exactly the predicate's set.
  • Fail closed (FR-002). A pin naming a deleted or empty profile, one whose servers are unconfigured, or one with no overlap with the grant binds to an empty non-nil set, so every door answers the same shape as a token granted [] (lists empty, counts zero, subtree 404s). The reach is resolved per request and per frame, never cached (FR-003).
  • Profile doors (FR-009, FR-012). A pinned caller may learn only its own pin: profileReachable refuses every other name on the same path as an unknown one, so /profiles lists one profile (narrowed, switchable_to cleared), and /profiles/{name}, /profiles/{name}/effective-tools, ?profile= on /servers and /tools answer the non-disclosing 404 for anything else. GET /profiles/active now withholds the slug from every non-administrator unless the profile is selectable by that caller — the one deliberate compatibility change (FR-004): an unpinned --servers token reads "" for an active profile disjoint from its grant, where it used to read the slug.
  • SSE (FR-007). serversChangedDeliverable drops a servers.changed frame only when every identity extra names a hidden server and the narrowed embed is empty or absent (the notify-only path, where the extra was the whole payload). Frames with a visible row, a visible identity or no identity still arrive with hidden extras withheld. The snapshot residual (a change to a hidden server alone while visible rows exist) is documented, not suppressed.
  • Docs. scope_subtree.go header rewritten (FR-016): the pin is part of the boundary; the two ergonomic filters are named. docs/features/agent-tokens.md "Server Scoping" + "Profile Pinning", docs/api/rest-api.md events table, three swagger descriptions (oas/ regenerated).

Scope note

Left for follow-ups, per spec: the MCP↔REST parity test (FR-013) and the CLI test with a pinned token (FR-015). Both surfaces already read the same set (MCP applies the pin as its first resolver tier; the CLI reads REST), so neither needs a code change here.

Testing

  • internal/httpapi/pin_boundary_test.go drives the production route table with a *-scoped token pinned to work-readonly=[github] over a github/notion/filesystem fleet (notion quarantined): /status (map keys + every scalar), /annotations/coverage, /review, /attention, /profiles (+ {name}, /effective-tools, /active for pinned, legacy and admin), ?profile= on /servers and /tools, the /servers/{id} subtree, the grant-as-filter seam through the real middleware, /diagnostics, the live /events stream (dropped audit event, dropped hidden-only servers.changed, narrowed coalesced frame), a deleted pin and a non-overlapping pin compared field-by-field against an empty-grant token, an empty pin, serversChangedDeliverable in isolation, the activity door through the production ActivityFilter.Matches, usage-cache key isolation (pinned * vs unpinned * vs deleted pin), hidden-server change counts withheld, active_profile.changed rendering, live status frames and a mid-stream pin deletion.
  • internal/auth/pin_boundary_test.go: the pure intersection (star/listed grant, nil/empty reach, dedup, admin/unpinned/nil pass-through, predicate wiring).
  • Pre-existing tests updated to the spec's rules: servers_profile_caller_pin_test.go (?profile=<wider> is now 404 for a pinned caller), preflight_test.go (fixture configures the pinned profile), sse_scope_reveal_test.go (hidden-only notify frame dropped; identity-less and visible ones delivered).
  • go test -race ./internal/httpapi/ ./internal/auth/ ./internal/management/ ./internal/profile/
  • CI server-tag race set (-tags server, unit-tests.yml skip regex) on serveredition, httpapi, server
  • ./scripts/test-api-e2e.sh — 70/70
  • golangci-lint with .github/.golangci.yml, bare and --build-tags server: only the pre-existing TopK baseline finding
  • Cross-model review: opencode github-copilot/gpt-6.1-sol, 3 rounds (round 1: 2 MUST-FIX + 4 SHOULD-FIX, all fixed; round 2: no production defects; round 3: NIT only). Summary in the first PR comment.

…redicate (Spec 118)

Related #1686

A profile-pinned agent token could still learn out-of-pin server names and
counts from /status, /events, /review, /attention, /profiles and
/annotations/coverage: its stored grant is "*" and the shared predicate read
the grant alone. The pin is now bound into the caller's AllowedServers at the
two seams that mint an agent token's context (REST auth, SSE per-frame
refresh) as grant ∩ the pinned profile's reach, failing closed to an empty
non-nil set for a deleted, empty or non-overlapping pin, so every door, every
derived count, the grant-as-filter seams and management.Doctor agree.

## Changes
- auth.AuthContext.BoundToPinReach: the pure intersection (copy, never in place)
- httpapi/pin_boundary.go: bindCallerToPin (live config, FR-020 seam), applied
  in handleAgentTokenAuth and newSSECallerContextRefresher
- profile doors: a pinned caller may learn only its own pin (profileReachable);
  /profiles/active withheld for every non-admin unless selectable (FR-004)
- SSE: a withheld servers.changed identity takes its sibling change counts with
  it; a pinned caller's frame is dropped only when nothing visible survives
  (FR-007); active_profile.changed rendered as /profiles/active answers
- scope_subtree.go header rewritten (FR-016); docs and swagger updated;
  specs/118 plan.md names the seam

## Testing
- new: internal/httpapi/pin_boundary_test.go, internal/auth/pin_boundary_test.go
- updated to the spec's rules: servers_profile_caller_pin_test.go,
  preflight_test.go, sse_scope_reveal_test.go, auth_profile_pin_test.go
- go test -race (httpapi, auth, management, profile; server-tag CI set),
  scripts/test-api-e2e.sh 70/70, golangci-lint (CI config, both tag sets)
- cross-model review: opencode gpt-6.1-sol, 3 rounds
@Dumbris

Dumbris commented Nov 3, 2026

Copy link
Copy Markdown
Member Author

Cross-model review log — opencode github-copilot/gpt-6.1-sol, 3 rounds

Round 1 (full diff + spec): 2 MUST-FIX, 4 SHOULD-FIX, verdict FINDINGS.

  1. MUST-FIX — hidden-server change counts (added/modified/removed, approved_count, changed, blocked_count) survived in a servers.changed frame whose server extra was withheld. Fixed: renderEventPayloadForCaller withholds every extra beside a withheld identity, keeping servers, stats, reason (every scoped caller; recorded as an FR-004 implementation note since it is a pre-existing REST server enumeration ignores agent-token scope: /api/v1/servers and /api/v1/config list every server regardless of allowed_servers #1166 oracle).
  2. MUST-FIX — the new drop rule also dropped frames for unpinned --servers tokens, against FR-004. Fixed: serversChangedDeliverable applies to pinned callers only; the REST server enumeration ignores agent-token scope: /api/v1/servers and /api/v1/config list every server regardless of allowed_servers #1166 notify-only test restored.
  3. SHOULD-FIX — active_profile.changed carried a slug /profiles/active would withhold. Fixed: renderActiveProfileChangedForCaller applies the same profileReachable rule.
  4. SHOULD-FIX — test gaps. Fixed in rounds 2–3: switchable_to fixture + assertion, status frames asserted, whole-envelope refusal comparison, own-pin /effective-tools gate, live mid-stream pin deletion, activity door, usage-cache key isolation, live status frames. Deferred with reasons in plan.md: FR-013 MCP parity, FR-015 CLI, /catalog/search end-to-end.
  5. SHOULD-FIX — auth_profile_pin_test.go fixture pinned to an unconfigured profile. Fixed: fixture configures the profile.
  6. SHOULD-FIX — plan.md absent (FR-020). Fixed: written, names the seam.

Round 2 (incremental): items 1, 2, 3, 5, 6 RESOLVED; item 4 partially. No new production defect. First-party consumers checked: Web UI and Swift tray use the embed/refetch, never the withheld extras; no scoped first-party SSE consumer exists. Per-frame GetConfig() read confirmed safe (RLock-protected pointer read, same accessor revealSecrets already uses per frame).

Round 3 (test-only delta): one NIT (the recorded deferrals); new assertions confirmed non-vacuous against the production keys and semantics; "mergeable as the boundary implementation".

@Dumbris
Dumbris enabled auto-merge (squash) November 3, 2026 17:33
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying mcpproxy-docs with  Cloudflare Pages  Cloudflare Pages

Latest commit: 72b4ea8
Status: ✅  Deploy successful!
Preview URL: https://a5f89190.mcpproxy-docs.pages.dev
Branch Preview URL: https://118-pin-visibility-boundary-g6ku.mcpproxy-docs.pages.dev

View logs

@codecov-commenter

Copy link
Copy Markdown

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

Codecov Report

❌ Patch coverage is 90.69767% with 8 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
internal/httpapi/pin_boundary.go 75.00% 2 Missing and 2 partials ⚠️
internal/auth/context.go 87.50% 1 Missing and 1 partial ⚠️
internal/httpapi/server.go 90.90% 1 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

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