Skip to content

Commit 6e2862b

Browse files
authored
Merge pull request #328 from smart-mcp-proxy/032-tool-quarantine
feat: add tool-level quarantine with rug pull detection
2 parents e04bf28 + f00c04a commit 6e2862b

31 files changed

Lines changed: 2806 additions & 163 deletions

CLAUDE.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,8 @@ go test -race ./internal/... -v # Race detection
174174
mcpproxy upstream list # List all servers
175175
mcpproxy upstream logs <name> # View logs (--tail, --follow)
176176
mcpproxy upstream restart <name> # Restart server (supports --all)
177+
mcpproxy upstream inspect <name> # Inspect tool approval status (Spec 032)
178+
mcpproxy upstream approve <name> # Approve pending/changed tools (Spec 032)
177179
mcpproxy doctor # Run health checks
178180
```
179181

@@ -300,6 +302,7 @@ See [docs/configuration.md](docs/configuration.md) for complete reference.
300302
- **`call_tool_destructive`** - Proxy destructive tool calls to upstream servers (Spec 018)
301303
- **`code_execution`** - Execute JavaScript to orchestrate multiple tools (disabled by default)
302304
- **`upstream_servers`** - CRUD operations for server management
305+
- **`quarantine_security`** - Security quarantine management: list/inspect quarantined servers, inspect/approve/approve-all tools (Spec 032)
303306

304307
**Tool Format**: `<serverName>:<toolName>` (e.g., `github:create_issue`)
305308

@@ -333,6 +336,9 @@ See [docs/configuration.md](docs/configuration.md) for complete reference.
333336
| `GET /api/v1/tokens/{name}` | Get agent token details |
334337
| `DELETE /api/v1/tokens/{name}` | Revoke agent token |
335338
| `POST /api/v1/tokens/{name}/regenerate` | Regenerate agent token secret |
339+
| `POST /api/v1/servers/{id}/tools/approve` | Approve pending/changed tools (Spec 032) |
340+
| `GET /api/v1/servers/{id}/tools/{tool}/diff` | View tool description/schema changes (Spec 032) |
341+
| `GET /api/v1/servers/{id}/tools/export` | Export tool approval records (Spec 032) |
336342
| `GET /events` | SSE stream for live updates |
337343

338344
**Authentication**: Use `X-API-Key` header or `?apikey=` query parameter.
@@ -439,6 +445,7 @@ See `docs/code_execution/` for complete guides:
439445
- **`require_mcp_auth`**: When enabled, `/mcp` endpoint rejects unauthenticated requests (default: false for backward compatibility)
440446
- **Quarantine system**: New servers quarantined until manually approved
441447
- **Tool Poisoning Attack (TPA) protection**: Automatic detection of malicious descriptions
448+
- **Tool-level quarantine (Spec 032)**: SHA-256 hash-based change detection for individual tool descriptions/schemas. New tools start as "pending", changed tools marked as "changed" (rug pull detection). Configurable via `quarantine_enabled` (global) and `skip_quarantine` (per-server).
442449

443450
See [docs/features/agent-tokens.md](docs/features/agent-tokens.md) and [docs/features/security-quarantine.md](docs/features/security-quarantine.md) for details.
444451

@@ -562,6 +569,17 @@ Exponential backoff, separate contexts for app vs server lifecycle, state machin
562569
### Tool Indexing
563570
Full rebuild on server changes, hash-based change detection, background indexing.
564571

572+
### Tool-Level Quarantine (Spec 032)
573+
SHA-256 hash-based approval system for individual tools. Key files:
574+
- `internal/storage/models.go` - `ToolApprovalRecord` model and `ToolApprovalBucket`
575+
- `internal/storage/bbolt.go` - CRUD operations for tool approvals
576+
- `internal/runtime/tool_quarantine.go` - Hash calculation, approval checking, blocking logic
577+
- `internal/runtime/lifecycle.go` - Integration in `applyDifferentialToolUpdate()`
578+
- `internal/server/mcp.go` - Tool-level blocking in `handleCallToolVariant()` and MCP tool operations
579+
- `internal/httpapi/server.go` - REST API endpoints for inspection/approval
580+
- `internal/config/config.go` - `QuarantineEnabled` (global) and `SkipQuarantine` (per-server)
581+
- `frontend/src/views/ServerDetail.vue` - Web UI quarantine panel
582+
565583
### Signal Handling
566584
Graceful shutdown, context cancellation, Docker cleanup, double shutdown protection.
567585

cmd/mcpproxy/upstream_cmd.go

Lines changed: 235 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,33 @@ Examples:
121121
RunE: runUpstreamAddJSON,
122122
}
123123

124+
upstreamInspectCmd = &cobra.Command{
125+
Use: "inspect <server-name>",
126+
Short: "Inspect tool approval status for a server",
127+
Long: `Show tool-level quarantine status for all tools on a server.
128+
Displays approval status, hashes, and any detected description/schema changes.
129+
130+
Examples:
131+
mcpproxy upstream inspect github
132+
mcpproxy upstream inspect github --output=json
133+
mcpproxy upstream inspect github --tool create_issue`,
134+
Args: cobra.ExactArgs(1),
135+
RunE: runUpstreamInspect,
136+
}
137+
138+
upstreamApproveCmd = &cobra.Command{
139+
Use: "approve <server-name> [tool-names...]",
140+
Short: "Approve quarantined tools for a server",
141+
Long: `Approve pending or changed tools so they can be used by AI agents.
142+
Without specific tool names, approves all pending/changed tools.
143+
144+
Examples:
145+
mcpproxy upstream approve github # Approve all tools
146+
mcpproxy upstream approve github create_issue list_repos # Approve specific tools`,
147+
Args: cobra.MinimumNArgs(1),
148+
RunE: runUpstreamApprove,
149+
}
150+
124151
upstreamImportCmd = &cobra.Command{
125152
Use: "import <path>",
126153
Short: "Import servers from external configuration file",
@@ -176,6 +203,9 @@ Examples:
176203
upstreamRemoveYes bool
177204
upstreamRemoveIfExists bool
178205

206+
// Inspect command flags
207+
upstreamInspectTool string
208+
179209
// Import command flags
180210
upstreamImportServer string
181211
upstreamImportFormat string
@@ -200,6 +230,8 @@ func init() {
200230
upstreamCmd.AddCommand(upstreamAddCmd)
201231
upstreamCmd.AddCommand(upstreamRemoveCmd)
202232
upstreamCmd.AddCommand(upstreamAddJSONCmd)
233+
upstreamCmd.AddCommand(upstreamInspectCmd)
234+
upstreamCmd.AddCommand(upstreamApproveCmd)
203235
upstreamCmd.AddCommand(upstreamImportCmd)
204236

205237
// Define flags (note: output format handled by global --output/-o flag from root command)
@@ -237,6 +269,9 @@ func init() {
237269
upstreamRemoveCmd.Flags().BoolVarP(&upstreamRemoveYes, "y", "y", false, "Skip confirmation prompt (short form)")
238270
upstreamRemoveCmd.Flags().BoolVar(&upstreamRemoveIfExists, "if-exists", false, "Don't error if server doesn't exist")
239271

272+
// Inspect command flags
273+
upstreamInspectCmd.Flags().StringVar(&upstreamInspectTool, "tool", "", "Show details for a specific tool")
274+
240275
// Import command flags
241276
upstreamImportCmd.Flags().StringVarP(&upstreamImportServer, "server", "s", "", "Import only a specific server by name")
242277
upstreamImportCmd.Flags().StringVar(&upstreamImportFormat, "format", "", "Force format (claude-desktop, claude-code, cursor, codex, gemini)")
@@ -1430,13 +1465,13 @@ func parseImportFormat(format string) configimport.ConfigFormat {
14301465
func outputImportResultStructured(result *configimport.ImportResult, format string) error {
14311466
// Build output structure
14321467
output := map[string]interface{}{
1433-
"format": result.Format,
1434-
"format_name": result.FormatDisplayName,
1435-
"summary": result.Summary,
1436-
"imported": buildImportedServersOutput(result.Imported),
1437-
"skipped": result.Skipped,
1438-
"failed": result.Failed,
1439-
"warnings": result.Warnings,
1468+
"format": result.Format,
1469+
"format_name": result.FormatDisplayName,
1470+
"summary": result.Summary,
1471+
"imported": buildImportedServersOutput(result.Imported),
1472+
"skipped": result.Skipped,
1473+
"failed": result.Failed,
1474+
"warnings": result.Warnings,
14401475
}
14411476

14421477
formatter, err := GetOutputFormatter()
@@ -1620,6 +1655,199 @@ func applyImportedServersDaemonMode(ctx context.Context, dataDir string, importe
16201655
return nil
16211656
}
16221657

1658+
// runUpstreamInspect handles the 'upstream inspect' command (Spec 032)
1659+
func runUpstreamInspect(_ *cobra.Command, args []string) error {
1660+
serverName := args[0]
1661+
1662+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1663+
defer cancel()
1664+
1665+
globalConfig, err := loadUpstreamConfig()
1666+
if err != nil {
1667+
return outputError(output.NewStructuredError(output.ErrCodeConfigNotFound, err.Error()).
1668+
WithGuidance("Check that your config file exists and is valid").
1669+
WithRecoveryCommand("mcpproxy doctor"), output.ErrCodeConfigNotFound)
1670+
}
1671+
1672+
if !shouldUseUpstreamDaemon(globalConfig.DataDir) {
1673+
return fmt.Errorf("mcpproxy daemon is not running. Start it with: mcpproxy serve")
1674+
}
1675+
1676+
logger, err := createUpstreamLogger("warn")
1677+
if err != nil {
1678+
return outputError(err, output.ErrCodeOperationFailed)
1679+
}
1680+
1681+
socketPath := socket.DetectSocketPath(globalConfig.DataDir)
1682+
client := cliclient.NewClient(socketPath, logger.Sugar())
1683+
1684+
// If a specific tool is requested, show the diff
1685+
if upstreamInspectTool != "" {
1686+
record, err := client.GetToolDiff(ctx, serverName, upstreamInspectTool)
1687+
if err != nil {
1688+
return cliError("failed to get tool diff", err)
1689+
}
1690+
1691+
outputFormat := ResolveOutputFormat()
1692+
if outputFormat == "json" || outputFormat == "yaml" {
1693+
formatter, fmtErr := GetOutputFormatter()
1694+
if fmtErr != nil {
1695+
return fmtErr
1696+
}
1697+
result, fmtErr := formatter.Format(record)
1698+
if fmtErr != nil {
1699+
return fmtErr
1700+
}
1701+
fmt.Println(result)
1702+
return nil
1703+
}
1704+
1705+
// Table format: show detailed diff
1706+
fmt.Printf("Tool Diff: %s:%s\n", serverName, record.ToolName)
1707+
fmt.Printf("Status: %s\n\n", record.Status)
1708+
fmt.Printf("--- Previous Description ---\n%s\n\n", record.PreviousDescription)
1709+
fmt.Printf("+++ Current Description ---\n%s\n\n", record.CurrentDescription)
1710+
if record.PreviousSchema != "" || record.CurrentSchema != "" {
1711+
fmt.Printf("--- Previous Schema ---\n%s\n\n", record.PreviousSchema)
1712+
fmt.Printf("+++ Current Schema ---\n%s\n", record.CurrentSchema)
1713+
}
1714+
return nil
1715+
}
1716+
1717+
// List all tool approvals for this server
1718+
records, err := client.GetToolApprovals(ctx, serverName)
1719+
if err != nil {
1720+
return cliError("failed to get tool approvals", err)
1721+
}
1722+
1723+
if len(records) == 0 {
1724+
fmt.Printf("No tool approval records found for server '%s'\n", serverName)
1725+
return nil
1726+
}
1727+
1728+
outputFormat := ResolveOutputFormat()
1729+
if outputFormat == "json" || outputFormat == "yaml" {
1730+
formatter, fmtErr := GetOutputFormatter()
1731+
if fmtErr != nil {
1732+
return fmtErr
1733+
}
1734+
result, fmtErr := formatter.Format(records)
1735+
if fmtErr != nil {
1736+
return fmtErr
1737+
}
1738+
fmt.Println(result)
1739+
return nil
1740+
}
1741+
1742+
// Table format
1743+
headers := []string{"TOOL", "STATUS", "HASH", "DESCRIPTION"}
1744+
var rows [][]string
1745+
pendingCount, changedCount, approvedCount := 0, 0, 0
1746+
for _, r := range records {
1747+
status := r.Status
1748+
switch status {
1749+
case "pending":
1750+
pendingCount++
1751+
case "changed":
1752+
changedCount++
1753+
case "approved":
1754+
approvedCount++
1755+
}
1756+
1757+
desc := r.Description
1758+
if len(desc) > 60 {
1759+
desc = desc[:57] + "..."
1760+
}
1761+
1762+
hash := r.Hash
1763+
if len(hash) > 12 {
1764+
hash = hash[:12]
1765+
}
1766+
1767+
rows = append(rows, []string{r.ToolName, status, hash, desc})
1768+
}
1769+
1770+
formatter, fmtErr := GetOutputFormatter()
1771+
if fmtErr != nil {
1772+
return fmtErr
1773+
}
1774+
result, fmtErr := formatter.FormatTable(headers, rows)
1775+
if fmtErr != nil {
1776+
return fmtErr
1777+
}
1778+
fmt.Print(result)
1779+
fmt.Printf("\nSummary: %d approved, %d pending, %d changed (total: %d)\n", approvedCount, pendingCount, changedCount, len(records))
1780+
1781+
if pendingCount > 0 || changedCount > 0 {
1782+
fmt.Printf("\nTo approve all tools: mcpproxy upstream approve %s\n", serverName)
1783+
if changedCount > 0 {
1784+
fmt.Printf("To inspect changes: mcpproxy upstream inspect %s --tool <name>\n", serverName)
1785+
}
1786+
}
1787+
1788+
return nil
1789+
}
1790+
1791+
// runUpstreamApprove handles the 'upstream approve' command (Spec 032)
1792+
func runUpstreamApprove(_ *cobra.Command, args []string) error {
1793+
serverName := args[0]
1794+
toolNames := args[1:]
1795+
1796+
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
1797+
defer cancel()
1798+
1799+
globalConfig, err := loadUpstreamConfig()
1800+
if err != nil {
1801+
return outputError(output.NewStructuredError(output.ErrCodeConfigNotFound, err.Error()).
1802+
WithGuidance("Check that your config file exists and is valid").
1803+
WithRecoveryCommand("mcpproxy doctor"), output.ErrCodeConfigNotFound)
1804+
}
1805+
1806+
if !shouldUseUpstreamDaemon(globalConfig.DataDir) {
1807+
return fmt.Errorf("mcpproxy daemon is not running. Start it with: mcpproxy serve")
1808+
}
1809+
1810+
logger, err := createUpstreamLogger("warn")
1811+
if err != nil {
1812+
return outputError(err, output.ErrCodeOperationFailed)
1813+
}
1814+
1815+
socketPath := socket.DetectSocketPath(globalConfig.DataDir)
1816+
client := cliclient.NewClient(socketPath, logger.Sugar())
1817+
1818+
approveAll := len(toolNames) == 0
1819+
count, err := client.ApproveTools(ctx, serverName, toolNames, approveAll)
1820+
if err != nil {
1821+
return cliError("failed to approve tools", err)
1822+
}
1823+
1824+
outputFormat := ResolveOutputFormat()
1825+
if outputFormat == "json" || outputFormat == "yaml" {
1826+
formatter, fmtErr := GetOutputFormatter()
1827+
if fmtErr != nil {
1828+
return fmtErr
1829+
}
1830+
result, fmtErr := formatter.Format(map[string]interface{}{
1831+
"server_name": serverName,
1832+
"approved": count,
1833+
"tools": toolNames,
1834+
})
1835+
if fmtErr != nil {
1836+
return fmtErr
1837+
}
1838+
fmt.Println(result)
1839+
return nil
1840+
}
1841+
1842+
if approveAll {
1843+
fmt.Printf("Approved %d tools for server '%s'\n", count, serverName)
1844+
} else {
1845+
fmt.Printf("Approved %d tool(s) for server '%s': %s\n", count, serverName, strings.Join(toolNames, ", "))
1846+
}
1847+
1848+
return nil
1849+
}
1850+
16231851
// applyImportedServersConfigMode adds servers directly to the config file
16241852
func applyImportedServersConfigMode(imported []*configimport.ImportedServer, globalConfig *config.Config) error {
16251853
// Add all imported servers to config

frontend/src/services/api.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { APIResponse, Server, Tool, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RepositoryServer, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse } from '@/types'
1+
import type { APIResponse, Server, Tool, ToolApproval, SearchResult, StatusUpdate, SecretRef, MigrationAnalysis, ConfigSecretsResponse, GetToolCallsResponse, GetToolCallDetailResponse, GetServerToolCallsResponse, GetConfigResponse, ValidateConfigResponse, ConfigApplyResult, ServerTokenMetrics, GetRegistriesResponse, SearchRegistryServersResponse, RepositoryServer, GetSessionsResponse, GetSessionDetailResponse, InfoResponse, ActivityListResponse, ActivityDetailResponse, ActivitySummaryResponse, ImportResponse, AgentTokenInfo, CreateAgentTokenRequest, CreateAgentTokenResponse } from '@/types'
22

33
// Event types for API service
44
export interface APIAuthEvent {
@@ -273,6 +273,25 @@ class APIService {
273273
return this.request<{ tools: Tool[] }>(`/api/v1/servers/${encodeURIComponent(serverName)}/tools`)
274274
}
275275

276+
// Tool-level quarantine (Spec 032)
277+
async getToolApprovals(serverName: string): Promise<APIResponse<{ tools: ToolApproval[], count: number }>> {
278+
return this.request<{ tools: ToolApproval[], count: number }>(`/api/v1/servers/${encodeURIComponent(serverName)}/tools/export`)
279+
}
280+
281+
async getToolDiff(serverName: string, toolName: string): Promise<APIResponse<ToolApproval>> {
282+
return this.request<ToolApproval>(`/api/v1/servers/${encodeURIComponent(serverName)}/tools/${encodeURIComponent(toolName)}/diff`)
283+
}
284+
285+
async approveTools(serverName: string, tools?: string[]): Promise<APIResponse<{ approved: number }>> {
286+
const body = tools && tools.length > 0
287+
? { tools }
288+
: { approve_all: true }
289+
return this.request<{ approved: number }>(`/api/v1/servers/${encodeURIComponent(serverName)}/tools/approve`, {
290+
method: 'POST',
291+
body: JSON.stringify(body),
292+
})
293+
}
294+
276295
async getServerLogs(serverName: string, tail?: number): Promise<APIResponse<{ logs: string[] }>> {
277296
const params = tail ? `?tail=${tail}` : ''
278297
return this.request<{ logs: string[] }>(`/api/v1/servers/${encodeURIComponent(serverName)}/logs${params}`)

frontend/src/types/api.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,22 @@ export interface Tool {
8080
annotations?: ToolAnnotation
8181
}
8282

83+
// Tool approval types (Spec 032)
84+
export interface ToolApproval {
85+
server_name: string
86+
tool_name: string
87+
status: 'pending' | 'approved' | 'changed'
88+
hash: string
89+
description: string
90+
schema?: string
91+
approved_hash?: string
92+
current_hash?: string
93+
previous_description?: string
94+
current_description?: string
95+
previous_schema?: string
96+
current_schema?: string
97+
}
98+
8399
// Search result types
84100
export interface SearchResult {
85101
tool: {
@@ -453,4 +469,4 @@ export interface ImportResponse {
453469
skipped: SkippedServer[]
454470
failed: FailedServer[]
455471
warnings: string[]
456-
}
472+
}

0 commit comments

Comments
 (0)