…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
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 storedallowed_serversis normally"*". The REST surface's shared enumeration predicate (auth.CanEnumerateServer→canSeeServer/visibleServers) consulted only that grant, so the same token thatGET /servers(#1684) andGET /tools(#1437) held to its pin could still learn every out-of-pin server's name, state and counts fromGET /status, the/eventsstream,GET /review,GET /attention,GET /profilesandGET /annotations/coverage, and diff the counts across doors to learn how many servers were hidden.What changes
auth.AuthContext.BoundToPinReach(reach)returns a copy of the context whoseAllowedServersis grant ∩ reach (the pinned profile'sEffectiveServersunder 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) andnewSSECallerContextRefresher(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'sTokenServersandmanagement.Doctorread 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 itstool_countnarrowing and now admits exactly the predicate's set.[](lists empty, counts zero, subtree 404s). The reach is resolved per request and per frame, never cached (FR-003).profileReachablerefuses every other name on the same path as an unknown one, so/profileslists one profile (narrowed,switchable_tocleared), and/profiles/{name},/profiles/{name}/effective-tools,?profile=on/serversand/toolsanswer the non-disclosing 404 for anything else.GET /profiles/activenow withholds the slug from every non-administrator unless the profile is selectable by that caller — the one deliberate compatibility change (FR-004): an unpinned--serverstoken reads""for an active profile disjoint from its grant, where it used to read the slug.serversChangedDeliverabledrops aservers.changedframe 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.scope_subtree.goheader 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.mdevents 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.godrives the production route table with a*-scoped token pinned towork-readonly=[github]over agithub/notion/filesystemfleet (notion quarantined):/status(map keys + every scalar),/annotations/coverage,/review,/attention,/profiles(+{name},/effective-tools,/activefor pinned, legacy and admin),?profile=on/serversand/tools, the/servers/{id}subtree, the grant-as-filter seam through the real middleware,/diagnostics, the live/eventsstream (dropped audit event, dropped hidden-onlyservers.changed, narrowed coalesced frame), a deleted pin and a non-overlapping pin compared field-by-field against an empty-grant token, an empty pin,serversChangedDeliverablein isolation, the activity door through the productionActivityFilter.Matches, usage-cache key isolation (pinned*vs unpinned*vs deleted pin), hidden-server change counts withheld,active_profile.changedrendering, 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).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/-tags server, unit-tests.yml skip regex) onserveredition,httpapi,server./scripts/test-api-e2e.sh— 70/70golangci-lintwith.github/.golangci.yml, bare and--build-tags server: only the pre-existingTopKbaseline findinggithub-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.