Skip to content

Commit 13b7158

Browse files
authored
fix(security): don't re-quarantine servers added while running (rc.3 release gate) (#1488)
# Pull Request ## Description Fixes the `v0.70.0-rc.3` release-gate failure (`matrix/oauth`: *server not ready, connected=true tools=0*). That failure was a regression from #1485 (RC-UPG-001). **Cause** - #1485 re-quarantines a server that is known to config.db but has no approved tool baseline. - `Server.AddServer` saves a new server to config.db **before** it publishes the config, and the admission gate runs on every publish. - So a server added at runtime looked "known but never approved" and was held before its tools could be discovered. - An OAuth server added with `quarantined: false` has no tools until sign-in, which is exactly the gate's oauth cell. **Fix** - The no-baseline rule now applies only to servers that config.db held the first time the gate read it in this process (`Runtime.bootKnownServers`). Those are the only records an older release could have left behind. - A `quarantined` value stated at add time is now recorded as an operator statement (`MarkQuarantineExplicitlySet`). It is written to `mcp_config.json` and obeyed after a restart. This covers: - REST add; - the `upstream_servers` MCP add; - CLI `upstream add --no-quarantine` without a running daemon; - imports with `skip_quarantine` (REST and CLI). The last two were found by an add-path completeness sweep during review. The CLI direct-file case is pre-existing. ## Testing - [x] I have tested these changes locally - [x] I have added/updated tests that prove my fix is effective or my feature works - [x] All existing tests pass | Check | rc.3 (`dc851c8f5`) | this branch | |---|---|---| | `release-gate matrix --cells oauth` (real gate driver, run locally) | fail, same error as CI | pass | | `release-gate matrix --cells stdio,http,sse` | — | pass | | v0.69 → upgrade replay (keyless server restarted under v0.69) | — | keyless server held, vetted server live | - **New tests:** - `TestConfigLoadAdmissionGate_ServerAddedWhileRunningIsNotRequarantined`; - an explicit-bit assertion in `TestImport_SkipQuarantine`. - **Suites:** the race suite with `-tags server` passes for runtime, httpapi, server and config. `configimport` and `cmd/mcpproxy` tests pass. - **Lint:** clean on the changed files. - **Review:** zcode (GLM) found nothing on the diff. The add-path sweep's two confirmed gaps are fixed here; a third, theoretical bootKnown edge was refuted on verification. After merge, the next RC will be `v0.70.0-rc.4`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent dc851c8 commit 13b7158

9 files changed

Lines changed: 81 additions & 1 deletion

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,8 @@ Releases follow [Semantic Versioning](https://semver.org/).
169169

170170
- **Upgrade no longer admits a server an older release held in quarantine (RC-UPG-001).** A v0.69 restart (including the baseline security scan shortly after startup) wrote a keyless server's un-gated config over its recorded quarantine, while v0.69 kept holding it in memory. On upgrade the config-load admission gate read that record as already admitted and auto-baseline approved all of its tools. The gate now also requires an approval baseline: a server recorded as unquarantined whose tools were never approved is quarantined again for review. Servers with approved tools are unaffected; vetted servers with no tool records (zero-tool servers, servers that never connected while live, servers last vetted before v0.21) are also held once — approve them, or set `"quarantined": false` before upgrading.
171171

172+
- **A server added while mcpproxy runs is no longer re-quarantined before its tools are discovered.** The RC-UPG-001 rule now applies only to servers `config.db` already held at startup; adding a server saves it to `config.db` before publishing its config, so an OAuth server added with `quarantined: false` was held again before sign-in (v0.70.0-rc.3 release gate). Adding a server with an explicit `quarantined` value — REST, the `upstream_servers` tool, `upstream add --no-quarantine` without a running daemon, or an import with `skip_quarantine` — now records it as an operator statement in `mcp_config.json`, so it is honoured after a restart.
173+
172174
- **security/quarantine:** a server added by editing `mcp_config.json` (no `quarantined` key,
173175
first seen) stayed held for review only until its first restart. Restarting it (by hand, after a
174176
secret change, or through the automatic baseline scan's connect) wrote the raw file entry over

‎cmd/mcpproxy/upstream_cmd.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1717,6 +1717,12 @@ func runUpstreamAddConfigMode(req *cliclient.AddServerRequest, globalConfig *con
17171717
Quarantined: quarantined,
17181718
TrustMode: req.TrustMode,
17191719
}
1720+
if req.Quarantined != nil {
1721+
// A stated --quarantine/--no-quarantine is written to the file as an
1722+
// operator statement; otherwise SaveConfig drops quarantined:false
1723+
// and the admission gate holds the server on the next start.
1724+
newServer.MarkQuarantineExplicitlySet(true)
1725+
}
17201726

17211727
// Add to config
17221728
globalConfig.Servers = append(globalConfig.Servers, newServer)

‎internal/configimport/import.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,11 @@ func Import(content []byte, opts *ImportOptions) (*ImportResult, error) {
149149
// Override quarantine if SkipQuarantine is set
150150
if opts.SkipQuarantine {
151151
serverConfig.Quarantined = false
152+
// The caller stated the value: keep it as an operator statement
153+
// so it is written to mcp_config.json and the admission gate
154+
// obeys it after a restart, instead of re-holding a server
155+
// whose tools were never discovered.
156+
serverConfig.MarkQuarantineExplicitlySet(true)
152157
}
153158

154159
// FR-040: classify env/header fields and build the second-line

‎internal/configimport/import_test.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,12 @@ func TestImport_SkipQuarantine(t *testing.T) {
292292
if s.Server.Quarantined {
293293
t.Errorf("server %s should NOT be quarantined when SkipQuarantine=true", s.Server.Name)
294294
}
295+
// RC-UPG-001 follow-up: the stated value must persist as an
296+
// operator statement, or the boot gate re-holds the server after
297+
// a restart when its tools were never discovered.
298+
if !s.Server.QuarantineExplicitlySet() {
299+
t.Errorf("server %s: SkipQuarantine must be recorded as an explicit quarantine statement", s.Server.Name)
300+
}
295301
}
296302
})
297303
}

‎internal/httpapi/server.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2513,6 +2513,12 @@ func (s *Server) handleAddServer(w http.ResponseWriter, r *http.Request) {
25132513
Enabled: enabled,
25142514
Quarantined: quarantined,
25152515
}
2516+
if req.Quarantined != nil {
2517+
// The caller stated the value: record it as an operator statement so
2518+
// the admission gate obeys it on later loads (it is written to
2519+
// mcp_config.json instead of being dropped as a default).
2520+
serverConfig.MarkQuarantineExplicitlySet(true)
2521+
}
25162522
if req.ReconnectOnUse != nil {
25172523
serverConfig.ReconnectOnUse = *req.ReconnectOnUse
25182524
}

‎internal/runtime/config_load_admission_gate.go‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@ import (
5151
// records: zero-tool (prompts/resources-only) servers, servers never
5252
// discovered while live (e.g. disabled since they were vetted), and servers
5353
// last vetted before tool approvals were stored (v0.21). Each is held once
54-
// on upgrade; one approval releases it and records the decision. When the
54+
// on upgrade; one approval releases it and records the decision. The rule
55+
// applies only to servers config.db already held when this process started
56+
// (bootKnownServers): AddServer saves a new server to config.db before it
57+
// publishes the config, so a server added while running must not be read as
58+
// "known but never approved" before its tools are even discovered. When the
5559
// approval records cannot be read the gate keeps the old rule: unknown is not
5660
// the same as never admitted.
5761
//
@@ -148,6 +152,13 @@ func (r *Runtime) applyConfigLoadAdmissionGate(cfg *config.Config, stored map[st
148152
return cfg, false
149153
}
150154

155+
r.bootKnownOnce.Do(func() {
156+
r.bootKnownServers = make(map[string]struct{}, len(stored))
157+
for name := range stored {
158+
r.bootKnownServers[name] = struct{}{}
159+
}
160+
})
161+
151162
decisions := make(map[int]admissionDecision)
152163
var preFix []string
153164

@@ -173,6 +184,13 @@ func (r *Runtime) applyConfigLoadAdmissionGate(cfg *config.Config, stored map[st
173184
// the fix looks like. Upgrade safety says leave it alone; honesty
174185
// says say so.
175186
if !sc.QuarantineExplicitlySet() && cfg.QuarantineDefaultForServer(sc) {
187+
_, bootKnown := r.bootKnownServers[sc.Name]
188+
if !bootKnown {
189+
// Added while this process runs: AddServer saves it to
190+
// config.db before publishing, so "known" here only means
191+
// "just added" — the add path already decided admission.
192+
continue
193+
}
176194
if vetted, ok := r.hasApprovalBaseline(sc.Name); ok && !vetted {
177195
decisions[i] = admissionDecision{
178196
server: sc.Name,

‎internal/runtime/config_load_admission_gate_upgrade_test.go‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,3 +126,27 @@ func TestConfigLoadAdmissionGate_RequarantineSurvivesRestart(t *testing.T) {
126126
require.NoError(t, rt.LoadConfiguredServers(reloaded))
127127
assert.True(t, storedServer(t, rt, "legacy-keyless").Quarantined)
128128
}
129+
130+
// v0.70.0-rc.3 release gate (matrix/oauth): AddServer saves a new server to
131+
// config.db BEFORE publishing the config, so the publish-time gate saw it as
132+
// "known, unquarantined, never approved" and re-held it before its tools
133+
// (an OAuth server has none until sign-in) could be discovered. The
134+
// no-baseline rule is for records an older release left behind, so it only
135+
// applies to servers config.db held when this process started.
136+
func TestConfigLoadAdmissionGate_ServerAddedWhileRunningIsNotRequarantined(t *testing.T) {
137+
rt, cfg, _ := gateEnvAt(t, []map[string]any{
138+
{"name": "boot", "command": "./x", "protocol": "stdio", "enabled": true, "quarantined": false},
139+
}, nil, zap.NewNop())
140+
// Boot: the first gate pass records what config.db held at start.
141+
require.NoError(t, rt.LoadConfiguredServers(cfg))
142+
143+
// Runtime add, in AddServer's order: storage first, then the config.
144+
added := &config.ServerConfig{Name: "added-later", URL: "http://127.0.0.1:1/mcp", Protocol: "http", Enabled: true}
145+
require.NoError(t, rt.storageManager.SaveUpstreamServer(added))
146+
next := *rt.Config()
147+
next.Servers = append(append([]*config.ServerConfig{}, rt.Config().Servers...), config.CopyServerConfig(added))
148+
require.NoError(t, rt.LoadConfiguredServers(&next))
149+
150+
assert.False(t, storedServer(t, rt, "added-later").Quarantined,
151+
"a server added while running was admitted by its add path; the upgrade rule must not re-hold it")
152+
}

‎internal/runtime/runtime.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,14 @@ type Runtime struct {
133133
preFixMu sync.Mutex
134134
preFixReported map[string]struct{}
135135

136+
// bootKnownServers is the set of servers config.db held the first time
137+
// the admission gate read it in this process (RC-UPG-001). Only those can
138+
// carry a stale record from an older release; a server added while this
139+
// process runs is saved to config.db before its config is published, so
140+
// the gate must not read it as "known but never approved".
141+
bootKnownOnce sync.Once
142+
bootKnownServers map[string]struct{}
143+
136144
statusMu sync.RWMutex
137145
status Status
138146
statusCh chan Status

‎internal/server/mcp.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5797,6 +5797,11 @@ func (p *MCPProxyServer) handleAddUpstream(ctx context.Context, request mcp.Call
57975797
// F9: optional per-server expose_prompts override on add. GetBool can't tell
57985798
// absent from false, so probe the raw args for presence.
57995799
if rawArgs := request.GetArguments(); rawArgs != nil {
5800+
// A stated `quarantined` is an operator statement the admission gate
5801+
// must obey on later loads, not a default to drop on save.
5802+
if _, stated := rawArgs["quarantined"]; stated {
5803+
serverConfig.MarkQuarantineExplicitlySet(true)
5804+
}
58005805
if raw, ok := rawArgs["expose_prompts"]; ok {
58015806
if b, ok := raw.(bool); ok {
58025807
serverConfig.ExposePrompts = &b

0 commit comments

Comments
 (0)