Skip to content

Commit 2378d37

Browse files
Dumbrisclaude
andauthored
feat(prompts): per-prompt rug-pull baseline MVP (spec 100) (#1010)
Implements the core of spec 100 (merged in #1009): the tool-quarantine (Spec 032) analogue for aggregated upstream prompts. A trusted server that passes admission with a benign prompt and later mutates its advertised metadata is now caught and the changed prompt is WITHHELD from prompts/list until approved — closing the last F2 gap from the PR #973 review. Scope (metadata only): the baseline hashes advertised LIST metadata (name + description + arguments); get-time prompts/get message content is inherently not baselineable here (spec Non-Goals) and stays defended by the existing F2 sanitisation + F12 caps. - storage: parallel PromptApprovalRecord + prompt_approvals bucket + CRUD (+ Manager wrappers), mirroring the tool ops 1:1 — NOT a ToolApprovalRecord overload (the server:tool / server:prompt key spaces would collide). - engine (internal/server/prompt_quarantine.go): calculatePromptApprovalHash (sha256(name|desc|normalizeJSON(args)), excludes Meta/Title), checkPromptApprovals with the pending/changed/approved state machine + baseline-pass + QuarantineEnabled kill-switch + TrustMode/AutoApproveToolChanges reuse, a fail-closed enforcePromptInvariant transition spine, filterBlockedPrompts, and the ApprovePrompt/ApproveAllPrompts mutators (which re-baseline + RefreshPrompts()). - hook: checkPromptApprovals + filterBlockedPrompts run in RefreshPrompts after the TPA scan-and-drop, before registration. Withholding IS the block — a prompt never passed to SetPrompts is absent from prompts/list and fails prompts/get natively, so there is no runtime get-time gate. Tests: hash stability/change, fail-closed invariant, first-seen→pending, approve→registered, rug-pull change→withheld→revert→re-approved, trust=auto auto-approve, ApproveAllPrompts, and a full-RefreshPrompts withholding integration test. Existing aggregation tests set quarantine off (they verify aggregation, not the baseline). Deferred to a follow-up: the quarantine_security MCP ops (inspect_prompts/approve_prompt/approve_all_prompts), REST twins, and the Vue review banner — the approve API (ApprovePrompt/ApproveAllPrompts) is ready to wire. Adding the MCP tool-schema op requires regenerating the 3 frozen tool-surface goldens, done deliberately in that PR. Claude-Session: https://claude.ai/code/session_01H7J8Yv5zr4tMQZaY3ot3Za Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b46505a commit 2378d37

7 files changed

Lines changed: 740 additions & 2 deletions

File tree

‎internal/server/mcp_routing.go‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -795,10 +795,19 @@ func (p *MCPProxyServer) RefreshPrompts() {
795795
// scanner before they are ever registered (parity with tool-description
796796
// poisoning detection).
797797
upstreamPrompts = p.scanAggregatedPrompts(upstreamPrompts)
798+
// Spec 100: rug-pull baseline. Detect pending/changed metadata vs the
799+
// approved baseline and WITHHOLD those prompts from registration (compose
800+
// in series after the TPA scan — scan detects poison, baseline detects
801+
// change). A withheld prompt is absent from prompts/list and fails
802+
// prompts/get natively; there is no runtime get-time gate.
803+
approval := p.checkPromptApprovals(upstreamPrompts)
804+
upstreamPrompts = filterBlockedPrompts(upstreamPrompts, approval.blocked)
798805
all = buildAggregatedServerPrompts(builtins, upstreamPrompts, p.getPromptAggregated, p.logger)
799806
p.logger.Info("refreshed prompts",
800807
zap.Int("upstream_prompt_count", len(upstreamPrompts)),
801-
zap.Int("total_prompt_count", len(all)))
808+
zap.Int("total_prompt_count", len(all)),
809+
zap.Int("withheld_pending", approval.pending),
810+
zap.Int("withheld_changed", approval.changed))
802811
} else {
803812
// nil upstreamPrompts: the aggregation loop never runs, so the nil
804813
// getPrompt is never invoked.

‎internal/server/mcp_routing_test.go‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,8 @@ func TestRefreshPrompts_AggregatesBuiltinsAndUpstream(t *testing.T) {
948948
proxy, _ := createTestProxyWithRuntime(t, nil)
949949
proxy.config.EnablePrompts = true
950950
proxy.config.AggregateUpstreamPrompts = true // opt in to upstream aggregation
951+
qOff := false
952+
proxy.config.QuarantineEnabled = &qOff // spec 100: these tests verify aggregation, not the rug-pull baseline
951953

952954
upstreamSrv := newTestRefreshPromptsUpstream(t)
953955
testServer := mcpserver.NewTestStreamableHTTPServer(upstreamSrv)
@@ -975,6 +977,46 @@ func TestRefreshPrompts_AggregatesBuiltinsAndUpstream(t *testing.T) {
975977
require.Contains(t, prompts, "server-a__greeting", "aggregated upstream prompt must be registered under its direct name")
976978
}
977979

980+
// TestRefreshPrompts_RugPullBaseline_WithholdsFirstSeen (spec 100) proves the
981+
// full RefreshPrompts path withholds a first-seen prompt on a quarantine-
982+
// enforced server, and that approving it registers it on the next refresh.
983+
func TestRefreshPrompts_RugPullBaseline_WithholdsFirstSeen(t *testing.T) {
984+
t.Setenv("MCPPROXY_DISABLE_OAUTH", "true")
985+
986+
proxy, _ := createTestProxyWithRuntime(t, []*config.ServerConfig{
987+
// Server present in config as manual trust + quarantine on (the default).
988+
{Name: "server-a", Protocol: "streamable-http", Enabled: true, TrustMode: string(config.TrustModeManual)},
989+
})
990+
proxy.config.EnablePrompts = true
991+
proxy.config.AggregateUpstreamPrompts = true
992+
993+
upstreamSrv := newTestRefreshPromptsUpstream(t)
994+
testServer := mcpserver.NewTestStreamableHTTPServer(upstreamSrv)
995+
t.Cleanup(testServer.Close)
996+
997+
um := upstream.NewManager(zap.NewNop(), proxy.config, nil, secret.NewResolver(), nil)
998+
t.Cleanup(func() { um.DisconnectAll() })
999+
require.NoError(t, um.AddServerConfig("srv-a", &config.ServerConfig{
1000+
Name: "server-a", Protocol: "streamable-http", URL: testServer.URL, Enabled: true,
1001+
}))
1002+
client, ok := um.GetClient("srv-a")
1003+
require.True(t, ok)
1004+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
1005+
defer cancel()
1006+
require.NoError(t, client.Connect(ctx))
1007+
proxy.upstreamManager = um
1008+
1009+
proxy.RefreshPrompts()
1010+
prompts := proxy.server.ListPrompts()
1011+
require.Contains(t, prompts, "setup-new-mcp-server", "built-ins are always registered")
1012+
require.NotContains(t, prompts, "server-a__greeting", "a first-seen prompt on a manual server is withheld (rug-pull baseline)")
1013+
1014+
// Approve it → it registers on the triggered refresh.
1015+
require.NoError(t, proxy.ApprovePrompt("server-a", "greeting", "tester"))
1016+
prompts = proxy.server.ListPrompts()
1017+
require.Contains(t, prompts, "server-a__greeting", "an approved prompt is registered")
1018+
}
1019+
9781020
// TestRefreshPrompts_AggregationDisabled_BuiltinsOnly verifies the default
9791021
// safe posture (PR #973 review): with EnablePrompts on but the opt-in
9801022
// aggregate_upstream_prompts flag off, RefreshPrompts serves ONLY the built-ins
@@ -1028,6 +1070,8 @@ func TestRefreshPrompts_ReadsLiveAggregateFlag(t *testing.T) {
10281070
live := rt.Config()
10291071
live.EnablePrompts = true
10301072
live.AggregateUpstreamPrompts = true
1073+
qOff := false
1074+
live.QuarantineEnabled = &qOff // spec 100: verify aggregation, not the rug-pull baseline
10311075

10321076
// Construction-time snapshot DISAGREES (aggregation off). If RefreshPrompts
10331077
// read p.config it would skip aggregation — the assertion below would fail.
@@ -1085,6 +1129,8 @@ func TestRefreshPrompts_PopulatesRoutingModeServers(t *testing.T) {
10851129
proxy, _ := createTestProxyWithRuntime(t, nil)
10861130
proxy.config.EnablePrompts = true
10871131
proxy.config.AggregateUpstreamPrompts = true // opt in to upstream aggregation
1132+
qOff := false
1133+
proxy.config.QuarantineEnabled = &qOff // spec 100: these tests verify aggregation, not the rug-pull baseline
10881134

10891135
upstreamSrv := newTestRefreshPromptsUpstream(t)
10901136
testServer := mcpserver.NewTestStreamableHTTPServer(upstreamSrv)

0 commit comments

Comments
 (0)