Skip to content

Commit a98ab6a

Browse files
authored
fix(frontend): key tool-quarantine banner off changed, suppress for baseline pending (#641)
The per-server Tool-Quarantine banner / "N tool(s) require approval" + per-tool Approve UI in ServerDetail.vue surfaced for every non-approved tool, including freshly-`pending` baseline tools. Under the confirmed trust model (parent MCP-2081), approving a *server* promotes its baseline `pending` tools to `approved`, so a baseline `pending` tool is not a reason to nag the operator. - Extract selectQuarantinedTools() (frontend/src/utils/toolQuarantine.ts): the banner keys off `status=changed` (rug-pull) plus any residual `pending` left once a change has surfaced, NOT all non-approved tools. - While the server-level Security Quarantine banner is showing (server.quarantined=true), suppress the Tool-Quarantine banner entirely — the operator approves the server first; the two banners never appear at once. - Add data-test hooks (security-quarantine-banner, tool-quarantine-banner, tool-quarantine-list) for Playwright. - Unit tests lock the selection logic; Playwright sweep (3 states) + HTML report under specs/041-quarantine-invariants/verification/mcp-2101/. - Docs: update docs/features/tool-quarantine.md Web UI section. Related MCP-2101, MCP-2081 (Spec 032)
1 parent e45042d commit a98ab6a

10 files changed

Lines changed: 358 additions & 5 deletions

File tree

docs/features/tool-quarantine.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -205,7 +205,17 @@ curl -H "X-API-Key: your-key" \
205205
1. Open the MCPProxy dashboard
206206
2. Click on a server in the server list
207207
3. Navigate to the **Tools** tab in the server detail view
208-
4. Review pending/changed tools and click **Approve** or **Approve All**
208+
4. Review changed (and any residual pending) tools and click **Approve** or **Approve All**
209+
210+
The server detail view's **Tool Quarantine** banner is shown only when a tool's
211+
status is `changed` (a rug-pull). Once a change has surfaced, any residual
212+
`pending` tools are listed alongside it so they can be cleared in the same pass.
213+
Freshly-`pending` baseline tools do **not** raise the banner on their own:
214+
approving the **server** (lifting the server-level Security Quarantine) promotes
215+
its baseline `pending` tools to `approved`. While the server-level **Security
216+
Quarantine** banner is showing, the Tool-Quarantine banner is suppressed
217+
entirely — the operator approves the server first, and the two banners never
218+
appear at once.
209219

210220
The server list page shows a quarantine badge with the count of pending/changed tools for each server.
211221

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import type { ToolApproval } from '@/types'
2+
3+
/**
4+
* Selects the tools that warrant the per-server Tool-Quarantine banner / list
5+
* (Spec 032, parent MCP-2081, MCP-2101).
6+
*
7+
* Trust model (confirmed on MCP-2081): when an operator approves a *server*
8+
* (lifting the server-level Security Quarantine), the backend promotes that
9+
* server's baseline `pending` tools to `approved`. A freshly-`pending` baseline
10+
* tool is therefore NOT a reason to nag the operator with a tool-level banner —
11+
* only a `changed` tool (a rug-pull) is.
12+
*
13+
* Rules:
14+
* - While the server is quarantined, suppress the tool banner entirely. The
15+
* server-level Security Quarantine banner already covers it and the operator
16+
* must approve the server first — never show two banners at once.
17+
* - Otherwise the banner keys off `status === 'changed'`. Only once a change
18+
* has surfaced do we also include any residual `pending` tools so the
19+
* operator can clear them in the same approval pass.
20+
*/
21+
export function selectQuarantinedTools(
22+
toolApprovals: ToolApproval[],
23+
serverQuarantined: boolean,
24+
): ToolApproval[] {
25+
if (serverQuarantined) return []
26+
const hasChanged = toolApprovals.some((t) => t.status === 'changed')
27+
if (!hasChanged) return []
28+
return toolApprovals.filter((t) => t.status === 'changed' || t.status === 'pending')
29+
}

frontend/src/views/ServerDetail.vue

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -202,7 +202,7 @@
202202
</div>
203203
</div>
204204

205-
<div v-if="server.quarantined" class="alert alert-warning">
205+
<div v-if="server.quarantined" data-test="security-quarantine-banner" class="alert alert-warning">
206206
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
207207
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z" />
208208
</svg>
@@ -331,7 +331,7 @@
331331

332332
<div v-else class="space-y-4">
333333
<!-- Tool Quarantine Panel (Spec 032) -->
334-
<div v-if="quarantinedTools.length > 0" class="alert alert-warning shadow-lg mb-4">
334+
<div v-if="quarantinedTools.length > 0" data-test="tool-quarantine-banner" class="alert alert-warning shadow-lg mb-4">
335335
<svg class="w-6 h-6 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
336336
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
337337
</svg>
@@ -352,7 +352,7 @@
352352
</div>
353353

354354
<!-- Quarantined Tools List -->
355-
<div v-if="quarantinedTools.length > 0" class="space-y-3 mb-6">
355+
<div v-if="quarantinedTools.length > 0" data-test="tool-quarantine-list" class="space-y-3 mb-6">
356356
<div
357357
v-for="tool in quarantinedTools"
358358
:key="'q-' + tool.tool_name"
@@ -1224,6 +1224,7 @@ import type { Server, Tool, ToolApproval, SecurityScanReport } from '@/types'
12241224
import api from '@/services/api'
12251225
import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus'
12261226
import { serverDisplayName } from '@/utils/serverRoute'
1227+
import { selectQuarantinedTools } from '@/utils/toolQuarantine'
12271228
import { oauthSignInState } from '@/utils/health'
12281229
import { computeToolDiffSections } from '@/utils/toolDiff'
12291230
@@ -1302,8 +1303,12 @@ const toolToggleLoading = ref<Record<string, boolean>>({})
13021303
// they're mutually exclusive with each other and with any per-tool toggle.
13031304
const bulkToolToggleLoading = ref(false)
13041305
1306+
// MCP-2101 (Spec 032): the Tool-Quarantine banner / list keys off `changed`
1307+
// (rug-pull) tools, NOT freshly-`pending` baseline tools, and is suppressed
1308+
// entirely while the server-level Security Quarantine banner is showing.
1309+
// See selectQuarantinedTools for the trust-model rationale.
13051310
const quarantinedTools = computed(() => {
1306-
return toolApprovals.value.filter(t => t.status === 'pending' || t.status === 'changed')
1311+
return selectQuarantinedTools(toolApprovals.value, server.value?.quarantined ?? false)
13071312
})
13081313
13091314
const blockedToolCount = computed(() => {
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { describe, it, expect } from 'vitest'
2+
import { selectQuarantinedTools } from '@/utils/toolQuarantine'
3+
import type { ToolApproval } from '@/types'
4+
5+
// MCP-2101 (Spec 032, parent MCP-2081): the per-server Tool-Quarantine banner
6+
// must stop nagging for freshly-`pending` baseline tools and must never show
7+
// alongside the server-level Security Quarantine banner.
8+
//
9+
// Trust model (confirmed on MCP-2081): approving a *server* promotes its
10+
// baseline `pending` tools to `approved` on the backend. So a baseline
11+
// `pending` tool is NOT a reason to surface a tool-level banner — only a
12+
// `changed` tool (a rug-pull) is.
13+
14+
function approval(over: Partial<ToolApproval>): ToolApproval {
15+
return {
16+
server_name: 's',
17+
tool_name: 't',
18+
status: 'approved',
19+
hash: 'h',
20+
description: 'd',
21+
...over,
22+
}
23+
}
24+
25+
describe('selectQuarantinedTools (MCP-2101)', () => {
26+
it('suppresses the tool banner entirely while the server is quarantined', () => {
27+
// Even a rug-pull `changed` tool must not surface a SECOND banner while the
28+
// server-level Security Quarantine banner is up — operator approves the
29+
// server first, then the backend promotes baseline pending→approved.
30+
const tools = [
31+
approval({ tool_name: 'a', status: 'changed' }),
32+
approval({ tool_name: 'b', status: 'pending' }),
33+
]
34+
expect(selectQuarantinedTools(tools, true)).toEqual([])
35+
})
36+
37+
it('does NOT surface freshly-pending baseline tools (the core fix)', () => {
38+
// Not quarantined, no changed tool → baseline `pending` tools alone must
39+
// not raise the banner. Pre-fix this returned the pending tools.
40+
const tools = [
41+
approval({ tool_name: 'a', status: 'pending' }),
42+
approval({ tool_name: 'b', status: 'pending' }),
43+
approval({ tool_name: 'c', status: 'approved' }),
44+
]
45+
expect(selectQuarantinedTools(tools, false)).toEqual([])
46+
})
47+
48+
it('surfaces a `changed` (rug-pull) tool when the server is not quarantined', () => {
49+
const changed = approval({ tool_name: 'rugpull', status: 'changed' })
50+
const tools = [changed, approval({ tool_name: 'fine', status: 'approved' })]
51+
expect(selectQuarantinedTools(tools, false)).toEqual([changed])
52+
})
53+
54+
it('once a change has surfaced, also includes residual pending tools', () => {
55+
const changed = approval({ tool_name: 'rugpull', status: 'changed' })
56+
const pending = approval({ tool_name: 'leftover', status: 'pending' })
57+
const tools = [changed, pending, approval({ tool_name: 'fine', status: 'approved' })]
58+
const result = selectQuarantinedTools(tools, false)
59+
expect(result).toContain(changed)
60+
expect(result).toContain(pending)
61+
expect(result).not.toContainEqual(approval({ tool_name: 'fine', status: 'approved' }))
62+
})
63+
64+
it('returns empty when every tool is approved', () => {
65+
const tools = [
66+
approval({ tool_name: 'a', status: 'approved' }),
67+
approval({ tool_name: 'b', status: 'approved' }),
68+
]
69+
expect(selectQuarantinedTools(tools, false)).toEqual([])
70+
})
71+
72+
it('returns empty for an empty approval list', () => {
73+
expect(selectQuarantinedTools([], false)).toEqual([])
74+
expect(selectQuarantinedTools([], true)).toEqual([])
75+
})
76+
})
99.6 KB
Loading
90.3 KB
Loading
117 KB
Loading
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
const BASE = 'http://127.0.0.1:18082';
4+
const KEY = 'uitest';
5+
const SERVER = 'rugpull';
6+
const STATE = process.env.STATE || 'B';
7+
const OUT = process.env.OUT || `/tmp/mcp-2101-uitest/pw/shot-${STATE}.png`;
8+
9+
async function api(request: any, method: 'GET' | 'POST', path: string) {
10+
const r = await request.fetch(`${BASE}${path}`, {
11+
method,
12+
headers: { 'X-API-Key': KEY, 'Content-Type': 'application/json' },
13+
data: method === 'POST' ? '{}' : undefined,
14+
});
15+
return r;
16+
}
17+
18+
test(`tool-quarantine banner — state ${STATE}`, async ({ page, request }) => {
19+
// Drive the server-level quarantine flag for the state under test.
20+
if (STATE === 'A') {
21+
await api(request, 'POST', `/api/v1/servers/${SERVER}/quarantine`);
22+
} else {
23+
await api(request, 'POST', `/api/v1/servers/${SERVER}/unquarantine`);
24+
}
25+
26+
await page.goto(`${BASE}/ui/servers/${SERVER}?apikey=${KEY}&tab=tools`);
27+
await page.waitForLoadState('domcontentloaded');
28+
// Give the view time to fetch server + tool-approval data and render.
29+
await page.waitForTimeout(2500);
30+
31+
const securityBanner = page.locator('[data-test="security-quarantine-banner"]');
32+
const toolBanner = page.locator('[data-test="tool-quarantine-banner"]');
33+
34+
if (STATE === 'A') {
35+
// Quarantined: ONLY the server-level Security Quarantine banner. The
36+
// tool-level banner must be suppressed even though tools are non-approved.
37+
await expect(securityBanner).toBeVisible();
38+
await expect(toolBanner).toHaveCount(0);
39+
} else if (STATE === 'B') {
40+
// Not quarantined, baseline pending tools, NO changed tool: neither banner.
41+
await expect(securityBanner).toHaveCount(0);
42+
await expect(toolBanner).toHaveCount(0);
43+
} else if (STATE === 'C') {
44+
// Not quarantined, a `changed` (rug-pull) tool exists: the tool-level
45+
// banner appears; the server-level Security banner does not.
46+
await expect(securityBanner).toHaveCount(0);
47+
await expect(toolBanner).toBeVisible();
48+
}
49+
50+
await page.screenshot({ path: OUT, fullPage: true });
51+
});
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
#!/usr/bin/env python3
2+
"""Build a self-contained HTML verification report for MCP-2101.
3+
4+
Base64-embeds each screenshot so the report opens offline with no external
5+
assets. Run from this directory: `python3 build-report.py`.
6+
"""
7+
import base64
8+
import os
9+
10+
HERE = os.path.dirname(os.path.abspath(__file__))
11+
12+
SCENARIOS = [
13+
{
14+
"id": "A",
15+
"title": "Server quarantined → only the Security-Quarantine banner",
16+
"img": "01-stateA-security-only.png",
17+
"expected": "Server has quarantined=true with two non-approved (pending) tools. "
18+
"Exactly ONE banner shows — the server-level 'Security Quarantine' — "
19+
"and the tool-level 'Tool Quarantine' banner is suppressed entirely "
20+
"(never two banners at once).",
21+
"observed": "Only the 'Security Quarantine' banner renders. No 'Tool Quarantine' "
22+
"banner despite both tools being pending. (data-test=tool-quarantine-banner "
23+
"asserted count 0; security-quarantine-banner visible.)",
24+
"pass": True,
25+
},
26+
{
27+
"id": "B",
28+
"title": "Not quarantined, baseline pending tools → NO tool banner (core fix)",
29+
"img": "02-stateB-no-tool-banner.png",
30+
"expected": "Server is Active (not quarantined) with only freshly-pending baseline "
31+
"tools and no changed tool. Neither banner shows — a freshly-pending "
32+
"baseline tool must not nag the operator. (Pre-fix this showed "
33+
"'2 tool(s) require approval'.)",
34+
"observed": "No banner of either kind. Tools list 'echo'/'get_time' with informational "
35+
"'new' badges only. (Both banner data-tests asserted count 0.)",
36+
"pass": True,
37+
},
38+
{
39+
"id": "C",
40+
"title": "A changed (rug-pull) tool exists → tool banner appears",
41+
"img": "03-stateC-tool-banner.png",
42+
"expected": "Server is Active (not quarantined); two tools flipped to 'changed' "
43+
"(rug-pull) plus a residual 'pending' new tool. The 'Tool Quarantine' "
44+
"banner appears keyed off the changed status, listing changed + residual "
45+
"pending tools with per-tool Approve UI and before/after diffs.",
46+
"observed": "'Tool Quarantine' banner shows '3 tool(s) require approval'. echo/get_time "
47+
"show 'changed' with before/after diffs; steal_data shows 'pending'. "
48+
"(tool-quarantine-banner visible; security-quarantine-banner count 0.)",
49+
"pass": True,
50+
},
51+
]
52+
53+
54+
def b64(path):
55+
with open(os.path.join(HERE, path), "rb") as f:
56+
return base64.b64encode(f.read()).decode("ascii")
57+
58+
59+
def main():
60+
passed = sum(1 for s in SCENARIOS if s["pass"])
61+
total = len(SCENARIOS)
62+
cards = []
63+
for s in SCENARIOS:
64+
badge = "PASS" if s["pass"] else "FAIL"
65+
color = "#16a34a" if s["pass"] else "#dc2626"
66+
cards.append(f"""
67+
<details open>
68+
<summary><span class="badge" style="background:{color}">{badge}</span>
69+
<strong>State {s['id']}.</strong> {s['title']}</summary>
70+
<div class="body">
71+
<p><span class="lbl">Expected</span> {s['expected']}</p>
72+
<p><span class="lbl">Observed</span> {s['observed']}</p>
73+
<img src="data:image/png;base64,{b64(s['img'])}" alt="State {s['id']}" />
74+
</div>
75+
</details>""")
76+
77+
html = f"""<!doctype html><html><head><meta charset="utf-8">
78+
<title>MCP-2101 — Tool-Quarantine banner verification</title>
79+
<style>
80+
body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
81+
margin: 0; background:#f8fafc; color:#0f172a; }}
82+
.wrap {{ max-width: 1100px; margin: 0 auto; padding: 32px 20px 80px; }}
83+
h1 {{ font-size: 22px; margin: 0 0 4px; }}
84+
.sub {{ color:#64748b; margin: 0 0 24px; }}
85+
.summary {{ background:#fff; border:1px solid #e2e8f0; border-radius:12px;
86+
padding:20px 24px; margin-bottom:24px; box-shadow:0 1px 2px rgba(0,0,0,.04); }}
87+
.count {{ font-size: 30px; font-weight: 700; color:#16a34a; }}
88+
details {{ background:#fff; border:1px solid #e2e8f0; border-radius:12px;
89+
margin-bottom:16px; overflow:hidden; box-shadow:0 1px 2px rgba(0,0,0,.04); }}
90+
summary {{ cursor:pointer; padding:16px 20px; font-size:15px; list-style:none; }}
91+
summary::-webkit-details-marker {{ display:none; }}
92+
.badge {{ color:#fff; font-size:11px; font-weight:700; padding:2px 8px;
93+
border-radius:999px; margin-right:10px; letter-spacing:.04em; }}
94+
.body {{ padding: 4px 20px 20px; }}
95+
.lbl {{ display:inline-block; min-width:78px; font-weight:600; color:#475569;
96+
font-size:12px; text-transform:uppercase; letter-spacing:.04em; }}
97+
img {{ width:100%; border:1px solid #e2e8f0; border-radius:8px; margin-top:12px; }}
98+
p {{ line-height:1.5; }}
99+
code {{ background:#f1f5f9; padding:1px 5px; border-radius:4px; font-size:13px; }}
100+
</style></head><body><div class="wrap">
101+
<h1>MCP-2101 — Suppress tool-quarantine banner for baseline pending tools</h1>
102+
<p class="sub">Spec 032 · parent MCP-2081 · <code>frontend/src/views/ServerDetail.vue</code></p>
103+
<div class="summary">
104+
<div class="count">{passed}/{total} scenarios pass</div>
105+
<p>The Tool-Quarantine banner now keys off <code>status=changed</code> (plus residual
106+
<code>pending</code> once a change has surfaced) and is suppressed entirely while the
107+
server-level Security-Quarantine banner is showing. Captured live against a fresh
108+
mcpproxy + the <code>echo-rugpull</code> test server via Playwright (pinned Chromium 1217).</p>
109+
</div>
110+
{''.join(cards)}
111+
</div></body></html>"""
112+
113+
out = os.path.join(HERE, "report.html")
114+
with open(out, "w") as f:
115+
f.write(html)
116+
print(f"wrote {out} ({passed}/{total} pass)")
117+
118+
119+
if __name__ == "__main__":
120+
main()

specs/041-quarantine-invariants/verification/mcp-2101/report.html

Lines changed: 62 additions & 0 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)