Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
e49986d
test(scope): Spec 105 PR D red phase — selectable-profile predicate o…
Dumbris Sep 16, 2026
d3e436c
feat(scope): Spec 105 PR D — selectable-profile predicate on /mcp/p a…
Dumbris Sep 16, 2026
5a15b14
fix(scope): PR D critique round 1 — admin set_profile parity, single-…
Dumbris Sep 16, 2026
e5d2c53
fix(scope): PR D codex round 1 — constant-traversal selectable-profil…
Dumbris Sep 16, 2026
7ca51e4
fix(scope): PR D codex round 2 — O(1)-in-the-fleet profile-URL gate
Dumbris Sep 16, 2026
be86444
fix(scope): PR D codex round 3 — precomputed per-profile reach, O(1) …
Dumbris Sep 16, 2026
dbea6d7
fix(scope): PR D review round 4 — reach costs the reader's grant, nev…
Dumbris Sep 16, 2026
0a2f961
fix(scope): PR D review round 5 — index every snapshot before publica…
Dumbris Sep 16, 2026
d063d69
fix(scope): PR D review round 6 — requests take the profile index and…
Dumbris Sep 16, 2026
146efc9
fix(scope): PR D review round 8 — request-visible profile index track…
Dumbris Sep 16, 2026
2156549
test(server): let background initialization settle before profile gat…
Dumbris Sep 16, 2026
4f12b8b
test(server): measure the pin-outcome allocation parity without proce…
Dumbris Sep 16, 2026
d4ee963
fix(scope): PR D review round 9 — set_profile decides with the URL ga…
Dumbris Sep 16, 2026
c2739d9
fix(scope): PR D review round 11 — acquire the profile-index pair ato…
Dumbris Sep 17, 2026
5838e97
docs(scope): PR D review round 13 — refute the synchronous observer-c…
Dumbris Sep 17, 2026
c8d41d9
fix(scope): admitted scoped reads render servers from the profile ind…
Dumbris Sep 17, 2026
b23d7a7
fix(scope): resolveActiveProfile decides straight from the admitted p…
Dumbris Sep 17, 2026
4ba12d3
fix(scope): cache producer/reader stamp intersects an agent token's o…
Dumbris Sep 17, 2026
303f582
Merge origin/main into 105-d-profiles
Dumbris Sep 18, 2026
f693400
fix(scope): merge-driven collisions + cross-model review round 1 fixes
Dumbris Sep 18, 2026
65a31d8
fix(scope): dedupe caller grant before expanding declaredOccurrences
Dumbris Sep 18, 2026
6744a9c
fix(scope): resolve only the profile name for set_profile's slug-race…
Dumbris Sep 18, 2026
4fc1ce8
Merge origin/main into 105-d-profiles (second pass: #1296, #1285 landed)
Dumbris Sep 18, 2026
5b9851b
Merge origin/main into 105-d-profiles (third pass: #1284 and dependab…
Dumbris Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -888,7 +888,7 @@ graph LR
| Web UI + macOS app UX audit | In progress | P0 | — | | |
| Release qualification gate (auto-QA matrix blocks the tag) | In progress | P0 | — | [081-release-qa-gate](./specs/081-release-qa-gate/) | |
| Action log / transparency — info at a glance | In progress | P1 | — | | |
| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 31/109 (28%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | |
| Agent-token scope hardening: every MCP request authorized by its own scope (spec 105) | In progress | P1 | 42/109 (39%) | [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | |
| Token-efficiency benchmark: measured savings, published results | In progress | P1 | 62/64 (97%) | [103-token-bench](./specs/103-token-bench/) | |
| Telemetry identity & data quality (machine_id + CI-filter hardening) | In progress | P1 | — | | |
| Telemetry v7: honest funnel + churn instrumentation | In progress | P1 | — | [080-telemetry-v7-churn](./specs/080-telemetry-v7-churn/) | |
Expand Down Expand Up @@ -1034,6 +1034,6 @@ Legend: `shipped` ≥95% checked · `in-flight` 1–94% · `drafted` 0% · `—`
| [102-schema-deferred](./specs/102-schema-deferred/) | `shipped` | 89/89 (100%) |
| [103-token-bench](./specs/103-token-bench/) | `shipped` | 62/64 (97%) |
| [104-auto-routing-mode](./specs/104-auto-routing-mode/) | — | — |
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 31/109 (28%) |
| [105-agent-scope-hardening](./specs/105-agent-scope-hardening/) | `in-flight` | 42/109 (39%) |
| [106-security-residual-fixes](./specs/106-security-residual-fixes/) | `shipped` | 18/19 (95%) |
| [107-server-edition-sso-hardening](./specs/107-server-edition-sso-hardening/) | `in-flight` | 102/126 (81%) |
6 changes: 3 additions & 3 deletions docs/features/agent-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -534,8 +534,8 @@ mcpproxy token create \

Server-side enforcement (no client cooperation required):

- **`set_profile("other")` is rejected** — a pinned token cannot switch its session to a different profile (switching to its own pinned profile, or clearing, is allowed).
- **`/mcp/p/<other>` returns `403`** — connecting to any profile URL other than the pinned one is forbidden; the pinned profile's own URL works.
- **`set_profile("other")` is rejected** — a pinned token cannot switch its session to a different profile (switching to its own pinned profile while it still has reach, or clearing, is allowed; clearing reports `active_profile: ""` and the pin's servers).
- **`/mcp/p/<other>` returns `404`** — connecting to any profile URL other than the pinned one is refused with the same non-disclosing `unknown profile` body every other non-selectable slug produces (see [404 responses](./profiles.md#404-responses)); the pinned profile's own URL works while the pin has reach.
- **The pin is the highest-precedence resolver source**, above an explicit `/mcp/p/<slug>` URL scope and above a session `set_profile` selection.
- **Every dispatch surface resolves it** — `retrieve_tools`, `describe_tool`, `call_tool_*`, the `code_execution` sandbox, direct-routing mode (`server__tool`) and [preflight](./tools-preflight.md) all bound themselves by the pin, so no routing mode is a way around it.

Expand All @@ -548,7 +548,7 @@ Resolution precedence (highest wins):
4. none (no profile filtering — all allowed servers)
```

**Validation & config changes**: the pinned slug must name a configured profile at creation time (creation is rejected otherwise). If the profile is **later removed** from the configuration, the pin resolves to a **deny-all scope**: the token sees no upstream servers and no tools, on the MCP session path and in [preflight](./tools-preflight.md#disclosure-tiers) alike. The request is logged with a warning naming the removed profile, not hard-failed at the transport. The pin is a restriction the operator applied, so losing the profile it names must never hand the token a wider view than it had the day before — re-create the profile, or re-mint the token against a live one, to restore it. Pinning composes with server scoping and permission tiers: a request must satisfy **all** of them.
**Validation & config changes**: the pinned slug must name a configured profile at creation time (creation is rejected otherwise). If the profile is **later removed** from the configuration, the pin resolves to a **deny-all scope**: the token sees no upstream servers and no tools, on the MCP session path and in [preflight](./tools-preflight.md#disclosure-tiers) alike. A pin with **zero reach** — the profile still exists but is empty, names only unconfigured servers, or no longer overlaps the token's `allowed_servers` — is treated exactly like a deleted one on `set_profile` and `/mcp/p/<pin>`, so the token cannot tell whether its own pin still exists. A request under a **deleted** pin is logged with a warning naming the removed profile, not hard-failed at the transport; a refused `/mcp/p/<pin>` initialization (deleted or zero-reach alike) is logged as `profile URL refused for scoped caller`. The pin is a restriction the operator applied, so losing the profile it names must never hand the token a wider view than it had the day before — re-create the profile, or re-mint the token against a live one, to restore it. Pinning composes with server scoping and permission tiers: a request must satisfy **all** of them.

The pin is shown by `token list` (PROFILE PIN column) and `token show` (Profile Pin field), and is preserved across `token regenerate`.

Expand Down
10 changes: 7 additions & 3 deletions docs/features/profiles.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,9 +67,9 @@ The `set_profile` MCP tool switches the active profile **inside a live session**

- The selection is keyed by the MCP session id (stable per streamable-HTTP / SSE connection) and persists for the lifetime of that session.
- It applies to subsequent `retrieve_tools`, `call_tool_*`, `code_execution` and direct-mode (`server__tool`) calls on the base `/mcp` endpoint — `retrieve_tools` searches the profile's per-profile index directly.
- Passing an empty string (`""`) clears the selection and returns to all servers (the result lists every configured server). A token with a [`profile_pin`](./agent-tokens.md#profile-pinning) keeps its pin — the result then lists the pinned profile's servers, since that is what the session can still reach.
- The `servers` list is always bounded by the caller's credential, using the same rule that scopes `retrieve_tools`: for an [agent token](./agent-tokens.md) scoped to specific servers it is the intersection of the selection (all servers, the chosen profile, or the pin) with the token's `allowed_servers`, so a token restricted to one server is never told about the others. API-key and socket callers see the full lists.
- An unknown slug is rejected: `unknown profile '<slug>' (available: research, deploy)`. For an agent token the `available:` list names only the profiles that token may select — its pin, or the profiles overlapping its `allowed_servers` — not the whole catalogue, and a profile entirely outside the token's reach is rejected with that same error rather than confirmed as existing.
- Passing an empty string (`""`) clears the selection and returns to all servers. `active_profile` always reports the **stored session selection** — `""` after a clear, even for a token with a [`profile_pin`](./agent-tokens.md#profile-pinning) — while `servers` reports the **effective scope** the session can actually reach after the update: the pin's servers for a pinned token (nothing once the pinned profile has been deleted), the URL profile on a `/mcp/p/<slug>` endpoint, otherwise the selection or every configured server.
- The `servers` list is always bounded by the caller's credential, using the same rule that scopes `retrieve_tools`: for an [agent token](./agent-tokens.md) scoped to specific servers it is the intersection of the effective profile (resolved pin > URL > session, see [Resolution precedence](#resolution-precedence)) with the token's `allowed_servers`, so a token restricted to one server is never told about the others. On a `/mcp/p/<slug>` endpoint the URL still governs the request, so `set_profile("other")` there stores `other` as `active_profile` but reports `<slug> ∩ allowed_servers` in `servers`. API-key and socket callers see the full lists.
- An unknown slug is rejected. An administrator (API key, socket, anonymous back-compat) gets the discovery affordance: `unknown profile '<slug>' (available: research, deploy)`. An agent token gets `unknown profile '<slug>'` with no list at all: it may select only the profiles overlapping its `allowed_servers` (or its pin while the pin still has reach), and a profile entirely outside its reach (an empty profile, a profile whose servers are all outside `allowed_servers`, or the token's own pin once it no longer exists or no longer overlaps the token's servers) is rejected with that same error rather than confirmed as existing. A pinned token asking for any profile OTHER than its pin (see [profile pinning](./agent-tokens.md#profile-pinning)) is rejected with that same `unknown profile '<slug>'` error too — never a distinct "pinned to..." message, which would let the token confirm from the wording alone that it is pinned, and to what, from a refusal aimed at a different slug. The check looks only at the requested slug (and the token's pin) and tests the token's own `allowed_servers` against that profile's precomputed server set — its cost does not depend on how many other profiles are configured, on how many servers the requested profile declares or on how many servers are configured at all, only on the size of the token's own grant — so a token cannot learn which profiles or servers exist, or whether it is pinned, from `set_profile`, by body or by timing.
- Session state is cleared automatically on session close.

`set_profile` is available on the default `/mcp` server and the `call_tool` / `code_execution` routing-mode servers.
Expand Down Expand Up @@ -136,7 +136,11 @@ Profile changes take effect for new connections on the next config reload. In-fl

## 404 responses

For API-key, socket and (when `require_mcp_auth` is off) unauthenticated callers:

| Condition | Body |
|-----------|------|
| No profiles configured | `{"error":"no profiles configured"}` |
| Unknown slug | `{"error":"unknown profile '<slug>'","available":["research","deploy"]}` |

An [agent token](./agent-tokens.md) may initialize through `/mcp/p/<slug>` only when that profile is one it could select with `set_profile` — its servers overlap the token's `allowed_servers`, or it is the token's pin and the pin still has reach. Every other request — a missing or deleted slug, a configured profile outside the token's reach, an empty profile, a pin mismatch, the slug-less `/mcp/p` and `/mcp/p/`, and an empty fleet — receives one and the same `404 {"error":"unknown profile '<slug>'"}` with no `available` list, and the check itself looks only at the requested slug (and the token's pin), testing the token's own `allowed_servers` against that profile's precomputed server set — its cost does not depend on how many other profiles are configured, on how many servers the requested profile declares, on how many servers are configured at all, or on whether this is the first request after a reload (the profile index is rebuilt when the configuration changes, not on demand); it scales only with the size of the token's own grant — so a scoped caller cannot learn which profiles or servers exist from the profile URL, by body or by timing. The refusal is silent towards the agent only: each one is logged (`profile URL refused for scoped caller`, with the token name, the requested slug and the remote address) so an operator can spot a token probing the slug space.
49 changes: 49 additions & 0 deletions internal/runtime/configsvc/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package configsvc
import (
"context"
"fmt"
"slices"
"sync"
"sync/atomic"
"time"
Expand Down Expand Up @@ -54,6 +55,13 @@ type Service struct {
// #937 admission gate so a poisoned server can never be reconciled and
// indexed in the window between "config parsed" and "config gated".
prePublish atomic.Value // func(*config.Config) *config.Config

// prePublishObservers run after prePublish, on the exact *Config that is
// about to be stored — while nothing else can observe it. Read-only: they
// build state keyed on that pointer (Spec 105's profile index) so it is
// ready before the first reader can capture the snapshot.
observersMu sync.RWMutex
prePublishObservers []func(*config.Config)
}

// NewService creates a new configuration service with the given initial config.
Expand Down Expand Up @@ -106,6 +114,46 @@ func (s *Service) SetPrePublishHook(hook func(*config.Config) *config.Config) {
s.prePublish.Store(hook)
}

// AddPrePublishObserver registers a read-only observer of every configuration
// on its way into the snapshot. Observers run inside the update, after the
// pre-publish hook has produced the final config and before the snapshot is
// stored or any subscriber is notified — so they see the exact *Config that
// will be published, at a moment nothing else can. They must not mutate it,
// and because they run under the update mutex they must be cheap (an index
// build of one insertion per entry, not I/O). Distinct from the single
// SetPrePublishHook slot, which the #937 admission gate owns.
//
// Observers run with the update mutex held and MUST NOT publish: a call to
// Update, UpdateIfCurrent or ReloadFromFile from inside an observer is a
// re-entrant publish and deadlocks by design, exactly like the pre-publish
// hook. Registering a further observer from inside one is allowed — the
// list is not locked while observers run — and that observer first runs on
// the next publication.
//
// Nil observers and a nil service are ignored. Safe to call at any time,
// including from an observer.
func (s *Service) AddPrePublishObserver(observe func(*config.Config)) {
if s == nil || observe == nil {
return
}
s.observersMu.Lock()
defer s.observersMu.Unlock()
s.prePublishObservers = append(s.prePublishObservers, observe)
}

// runPrePublishObservers runs every observer registered when it is called on
// cfg (called with updateMu held, before the snapshot is stored). The list is
// copied under the read lock and released before any observer runs, so an
// observer may register another without deadlocking.
func (s *Service) runPrePublishObservers(cfg *config.Config) {
s.observersMu.RLock()
observers := slices.Clone(s.prePublishObservers)
s.observersMu.RUnlock()
for _, observe := range observers {
observe(cfg)
}
}

// runPrePublishHook applies the installed hook, if any.
func (s *Service) runPrePublishHook(cfg *config.Config) *config.Config {
v := s.prePublish.Load()
Expand Down Expand Up @@ -148,6 +196,7 @@ func (s *Service) UpdateIfCurrent(expected, newConfig *config.Config, updateType
func (s *Service) updateLocked(newConfig *config.Config, updateType UpdateType, source string) {

newConfig = s.runPrePublishHook(newConfig)
s.runPrePublishObservers(newConfig)

current := s.Current()
s.version++
Expand Down
107 changes: 107 additions & 0 deletions internal/runtime/configsvc/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -399,3 +399,110 @@ func TestService_Close(t *testing.T) {
t.Error("Subscriber channel not closed after service close")
}
}

// TestService_PrePublishObserverSeesTheConfigBeforePublication pins the
// contract AddPrePublishObserver offers a derived-index builder (Spec 105
// PR D, the profile index): the observer runs on the exact *config.Config
// about to be published — after the pre-publish hook has produced it —
// while Current() still answers the previous snapshot and before any
// subscriber has been notified. A builder keyed on that pointer therefore
// has its index ready before a single reader can capture the snapshot.
func TestService_PrePublishObserverSeesTheConfigBeforePublication(t *testing.T) {
initial := &config.Config{Listen: "127.0.0.1:8080"}
svc := NewService(initial, "/tmp/config.json", zap.NewNop())

// The #937 admission gate replaces the incoming config; the observer must
// see the gated one, never the caller's.
gated := &config.Config{Listen: "127.0.0.1:9090"}
svc.SetPrePublishHook(func(*config.Config) *config.Config { return gated })

ctx, cancel := context.WithCancel(context.Background())
defer cancel()
updates := svc.Subscribe(ctx)
<-updates // initial snapshot

type observation struct {
seen, current *config.Config
notified bool
}
observed := make(chan observation, 1)
svc.AddPrePublishObserver(func(cfg *config.Config) {
o := observation{seen: cfg, current: svc.Current().Config}
select {
case <-updates:
o.notified = true
default:
}
observed <- o
})

require.NoError(t, svc.Update(&config.Config{Listen: "incoming"}, UpdateTypeModify, "test"))

select {
case o := <-observed:
require.Same(t, gated, o.seen, "the observer must see the config the gate hook produced")
require.Same(t, initial, o.current, "the observer must run before Current() moves to the new snapshot")
require.False(t, o.notified, "the observer must run before subscribers are notified")
default:
t.Fatal("the observer must run synchronously inside Update")
}
require.Same(t, gated, svc.Current().Config)
select {
case u := <-updates:
require.Same(t, gated, u.Snapshot.Config)
case <-time.After(time.Second):
t.Fatal("subscribers must still be notified after the observer ran")
}
}

// TestService_PrePublishObserverNilSafe: a nil observer and a nil service
// are both ignored, and every registered observer runs on every update.
func TestService_PrePublishObserverNilSafe(t *testing.T) {
var none *Service
none.AddPrePublishObserver(func(*config.Config) {})

svc := NewService(&config.Config{Listen: "127.0.0.1:8080"}, "/tmp/config.json", zap.NewNop())
svc.AddPrePublishObserver(nil)
runs := 0
svc.AddPrePublishObserver(func(*config.Config) { runs++ })
svc.AddPrePublishObserver(func(*config.Config) { runs++ })

require.NoError(t, svc.Update(&config.Config{Listen: "a"}, UpdateTypeModify, "one"))
require.NoError(t, svc.Update(&config.Config{Listen: "b"}, UpdateTypeModify, "two"))
require.Equal(t, 4, runs, "each observer runs once per update; nil ones are skipped")
}

// TestService_PrePublishObserverMayRegisterAnObserver (Spec 105 PR D codex
// round 6, finding 2): observers run under the update mutex, so the observer
// list must not be held locked while they run — an observer that registers a
// follow-up observer deadlocked on observersMu. The list is copied under the
// read lock and released before any observer is invoked; an observer added
// during a publication first runs on the NEXT one.
func TestService_PrePublishObserverMayRegisterAnObserver(t *testing.T) {
svc := NewService(&config.Config{Listen: "127.0.0.1:8080"}, "/tmp/config.json", zap.NewNop())

var registered sync.Once
innerRuns := 0
svc.AddPrePublishObserver(func(*config.Config) {
registered.Do(func() {
svc.AddPrePublishObserver(func(*config.Config) { innerRuns++ })
})
})

update := func(listen string) {
t.Helper()
done := make(chan error, 1)
go func() { done <- svc.Update(&config.Config{Listen: listen}, UpdateTypeModify, "test") }()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("Update deadlocked: an observer registering another observer must not block publication")
}
}

update("one")
require.Equal(t, 0, innerRuns, "an observer registered during a publication runs from the next one on")
update("two")
require.Equal(t, 1, innerRuns, "the observer registered by another observer must run on the next publication")
}
Loading
Loading