Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
18 changes: 18 additions & 0 deletions docs/features/security-quarantine.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,24 @@ built-in `tpa-descriptions` scanner. Its findings appear in the scan report
`threat_level`, `confidence`, and the contributing check `signals`. See
[Tool Scanner](/features/tool-scanner) for the full rule reference.

## Prompt rug-pull baseline (spec 100)

When upstream **prompt aggregation** is enabled (`aggregate_upstream_prompts: true`, off by default), mcpproxy keeps a per-prompt approval baseline that mirrors the tool rug-pull machinery. A trusted server that ships a benign prompt and later mutates its **advertised metadata** (name, description, or argument descriptions) has that changed prompt **withheld from `prompts/list`** until it is approved — closing the gap where a subtler injection that passes the poison scanner could still slip in via a later edit.

- **Scope — metadata only.** The baseline hashes the prompt's advertised metadata, not its `prompts/get` message content (which is materialised fresh per call and has no list-time artifact to baseline). Content is defended separately by output sanitisation and size caps. This is an inherent limit, not a shortcut.
- **Enforcement is by withholding.** A held prompt is simply not registered, so it is absent from `prompts/list` and `prompts/get` on it fails natively. There is no separate runtime gate.
- **Trust parity.** A server with `trust_mode: auto` (or `auto_approve_tool_changes: true`) auto-approves its prompt changes; `manual` holds them. Disabling quarantine globally (`quarantine_enabled: false`) auto-approves.

Manage held prompts with the `quarantine_security` MCP tool:

```jsonc
// see what is held (all servers, or one via "name")
{ "operation": "inspect_prompts", "name": "github" }
// approve one held prompt, or all for a server
{ "operation": "approve_prompt", "name": "github", "prompt_name": "summarize_pr" }
{ "operation": "approve_all_prompts", "name": "github" }
```

## Managing Quarantine

### View Quarantined Servers
Expand Down
41 changes: 38 additions & 3 deletions internal/server/mcp.go
Original file line number Diff line number Diff line change
Expand Up @@ -1152,6 +1152,9 @@ func (p *MCPProxyServer) buildManagementTools() []mcpserver.ServerTool {
mcp.Description("Per-server trust tier governing new-server admission AND tool-change approval (spec 086): 'auto' = approve without scanning; 'scan' = auto-approve only when the fast offline TPA scan is green, else hold for review; 'manual' = human reviews every change. Empty → manual (secure default). Used with add/update/patch."),
mcp.Enum("auto", "scan", "manual"),
),
mcp.WithBoolean("expose_prompts",
mcp.Description("Per-server prompt-aggregation override (F9): true = include this server's MCP prompts in mcpproxy's aggregated prompts/list; false = exclude them regardless of capability. Omit to leave unchanged (patch) / inherit the default (aggregate if advertised). Only meaningful when aggregate_upstream_prompts is enabled globally. Used with add/update/patch."),
),
)
tools = append(tools, mcpserver.ServerTool{Tool: upstreamServersTool, Handler: p.handleUpstreamServers})
}
Expand All @@ -1166,15 +1169,18 @@ func (p *MCPProxyServer) buildManagementTools() []mcpserver.ServerTool {
mcp.WithOpenWorldHintAnnotation(false),
mcp.WithString("operation",
mcp.Required(),
mcp.Description("Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled."),
mcp.Enum("list_quarantined", "inspect_quarantined", "quarantine_server", "inspect_tools", "approve_tool", "approve_all_tools", "block_tool", "block_all_tools", "enable_tool", "disable_tool"),
mcp.Description("Security operation: list_quarantined, inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, enable_tool, disable_tool, inspect_prompts, approve_prompt, approve_all_prompts. 'block_tool'/'block_all_tools' atomically approve AND disable a tool (acknowledge it but keep it hidden) — all-or-nothing so a tool is never left approved+enabled. The prompt operations (spec 100) manage aggregated upstream prompts held by the metadata rug-pull baseline: a prompt whose advertised metadata changed since approval is withheld from prompts/list until approved."),
mcp.Enum("list_quarantined", "inspect_quarantined", "quarantine_server", "inspect_tools", "approve_tool", "approve_all_tools", "block_tool", "block_all_tools", "enable_tool", "disable_tool", "inspect_prompts", "approve_prompt", "approve_all_prompts"),
),
mcp.WithString("name",
mcp.Description("Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools)"),
mcp.Description("Server name (required for inspect_quarantined, quarantine_server, inspect_tools, approve_tool, approve_all_tools, block_tool, block_all_tools, approve_prompt, approve_all_prompts)"),
),
mcp.WithString("tool_name",
mcp.Description("Tool name (required for approve_tool and block_tool operations)"),
),
mcp.WithString("prompt_name",
mcp.Description("Prompt name (required for approve_prompt; spec 100)"),
),
)
tools = append(tools, mcpserver.ServerTool{Tool: quarantineSecurityTool, Handler: p.handleQuarantineSecurity})
}
Expand Down Expand Up @@ -3410,6 +3416,12 @@ func (p *MCPProxyServer) handleQuarantineSecurity(ctx context.Context, request m
result, opErr = p.handleSetToolEnabledByName(request, true)
case "disable_tool":
result, opErr = p.handleSetToolEnabledByName(request, false)
case "inspect_prompts":
result, opErr = p.handleInspectPromptApprovals(request)
case "approve_prompt":
result, opErr = p.handleApprovePromptByName(request)
case "approve_all_prompts":
result, opErr = p.handleApproveAllPromptsByServer(request)
default:
p.emitActivityInternalToolCall("quarantine_security", "", "", "", sessionID, requestID, "error", fmt.Sprintf("Unknown quarantine operation: %s", operation), time.Since(startTime).Milliseconds(), args, nil, nil, "")
return mcp.NewToolResultError(fmt.Sprintf("Unknown quarantine operation: %s", operation)), nil
Expand Down Expand Up @@ -4603,6 +4615,16 @@ func (p *MCPProxyServer) handleAddUpstream(ctx context.Context, request mcp.Call
TrustMode: trustMode, // spec 086: carry the per-server trust_mode through on create
}

// F9: optional per-server expose_prompts override on add. GetBool can't tell
// absent from false, so probe the raw args for presence.
if rawArgs := request.GetArguments(); rawArgs != nil {
if raw, ok := rawArgs["expose_prompts"]; ok {
if b, ok := raw.(bool); ok {
serverConfig.ExposePrompts = &b
}
}
}

// Save to storage
if err := p.storage.SaveUpstreamServer(serverConfig); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to add upstream: %v", err)), nil
Expand Down Expand Up @@ -5119,6 +5141,19 @@ func (p *MCPProxyServer) buildPatchConfigFromRequest(request mcp.CallToolRequest
patch.InitTimeout = &v
}

// F9: per-server expose_prompts override. GetBool collapses absent→false, so
// detect key presence in the raw args and only set the pointer when the caller
// actually provided it (nil = leave unchanged for MergeServerConfig).
if args := request.GetArguments(); args != nil {
if raw, ok := args["expose_prompts"]; ok {
b, ok := raw.(bool)
if !ok {
return nil, opts, fmt.Errorf("invalid expose_prompts: must be a boolean, got %T", raw)
}
patch.ExposePrompts = &b
}
}

// Handle oauth JSON string - deep merge for nested config
if oauthJSON := request.GetString("oauth_json", ""); oauthJSON != "" {
// Check for explicit null removal
Expand Down
77 changes: 77 additions & 0 deletions internal/server/prompt_quarantine.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,3 +338,80 @@ func (p *MCPProxyServer) ApproveAllPrompts(serverName, approvedBy string) (int,
}
return approved, nil
}

// --- MCP quarantine_security prompt operations (spec 100 FR-7) ---

// handleInspectPromptApprovals returns the prompt approval records for a server
// (all servers when 'name' is omitted), with pending/changed counts so an agent
// can see which prompts the rug-pull baseline is withholding.
func (p *MCPProxyServer) handleInspectPromptApprovals(request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
if p.storage == nil {
return mcp.NewToolResultError("storage unavailable"), nil
}
serverName := request.GetString("name", "")
recs, err := p.storage.ListPromptApprovals(serverName)
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to list prompt approvals: %v", err)), nil
}
type promptView struct {
Server string `json:"server"`
Prompt string `json:"prompt"`
Status string `json:"status"`
ChangedFrom string `json:"changed_from,omitempty"`
}
var pending, changed int
views := make([]promptView, 0, len(recs))
for _, r := range recs {
switch r.Status {
case promptStatusPending:
pending++
case promptStatusChanged:
changed++
}
v := promptView{Server: r.ServerName, Prompt: r.PromptName, Status: r.Status}
if r.Status == promptStatusChanged && r.PreviousDescription != "" {
v.ChangedFrom = r.PreviousDescription
}
views = append(views, v)
}
payload := map[string]interface{}{
"prompts": views,
"pending_count": pending,
"changed_count": changed,
"action_required": pending + changed,
}
b, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to encode: %v", err)), nil
}
return mcp.NewToolResultText(string(b)), nil
}

// handleApprovePromptByName approves one held prompt (re-baselines it).
func (p *MCPProxyServer) handleApprovePromptByName(request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
serverName := request.GetString("name", "")
if serverName == "" {
return mcp.NewToolResultError("Missing required parameter 'name' (server name)"), nil
}
promptName := request.GetString("prompt_name", "")
if promptName == "" {
return mcp.NewToolResultError("Missing required parameter 'prompt_name'"), nil
}
if err := p.ApprovePrompt(serverName, promptName, "mcp"); err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to approve prompt '%s': %v", promptName, err)), nil
}
return mcp.NewToolResultText(fmt.Sprintf("Prompt '%s' on server '%s' has been approved.", promptName, serverName)), nil
}

// handleApproveAllPromptsByServer approves every held prompt for a server.
func (p *MCPProxyServer) handleApproveAllPromptsByServer(request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
serverName := request.GetString("name", "")
if serverName == "" {
return mcp.NewToolResultError("Missing required parameter 'name' (server name)"), nil
}
n, err := p.ApproveAllPrompts(serverName, "mcp")
if err != nil {
return mcp.NewToolResultError(fmt.Sprintf("Failed to approve prompts for '%s': %v", serverName, err)), nil
}
return mcp.NewToolResultText(fmt.Sprintf("Approved %d prompt(s) on server '%s'.", n, serverName)), nil
}
44 changes: 44 additions & 0 deletions internal/server/prompt_quarantine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,3 +162,47 @@ func TestApproveAllPrompts(t *testing.T) {

// Guard: the storage record type is what we expect (compile-time contract).
var _ = storage.PromptApprovalRecord{}

// --- MCP quarantine_security prompt op handlers ---

func TestHandlePromptApprovalOps(t *testing.T) {
proxy := manualTrustProxy(t)
proxy.checkPromptApprovals([]mcp.Prompt{{Name: "srv:a", Description: "1"}, {Name: "srv:b", Description: "2"}})

// inspect_prompts reports the two held prompts.
insp, err := proxy.handleInspectPromptApprovals(mcp.CallToolRequest{Params: mcp.CallToolParams{
Arguments: map[string]interface{}{"name": "srv"},
}})
require.NoError(t, err)
require.False(t, insp.IsError)
body := insp.Content[0].(mcp.TextContent).Text
assert.Contains(t, body, "\"pending_count\": 2")
assert.Contains(t, body, "\"action_required\": 2")

// approve_prompt approves one.
ap, err := proxy.handleApprovePromptByName(mcp.CallToolRequest{Params: mcp.CallToolParams{
Arguments: map[string]interface{}{"name": "srv", "prompt_name": "a"},
}})
require.NoError(t, err)
assert.False(t, ap.IsError)
rec, _ := proxy.storage.GetPromptApproval("srv", "a")
assert.Equal(t, promptStatusApproved, rec.Status)

// approve_all_prompts approves the rest.
aa, err := proxy.handleApproveAllPromptsByServer(mcp.CallToolRequest{Params: mcp.CallToolParams{
Arguments: map[string]interface{}{"name": "srv"},
}})
require.NoError(t, err)
assert.Contains(t, aa.Content[0].(mcp.TextContent).Text, "Approved 1 prompt")
recB, _ := proxy.storage.GetPromptApproval("srv", "b")
assert.Equal(t, promptStatusApproved, recB.Status)
}

func TestHandleApprovePromptByName_MissingArgs(t *testing.T) {
proxy := manualTrustProxy(t)
res, err := proxy.handleApprovePromptByName(mcp.CallToolRequest{Params: mcp.CallToolParams{
Arguments: map[string]interface{}{"name": "srv"},
}})
require.NoError(t, err)
assert.True(t, res.IsError, "missing prompt_name is an error result")
}
Loading
Loading