Skip to content

Commit 1cb6d98

Browse files
fix(webui): surface tool-level quarantine on the Servers list (#519)
The Servers grid only renders the big orange "Server is quarantined" banner + Approve button when `server.quarantined === true`. Servers that are trusted at the server level but ship tools blocked by Spec 032 tool-level quarantine (pending / changed) had only a tiny `stat-desc` under the tool count — easy to miss, and shadowed entirely by the `X disabled` line when any tool was also disabled, because both lines shared a `v-if / v-else-if` chain. The asymmetry is confusing: the detail page DOES escalate tool-level quarantine to a yellow alert panel with an Approve-All button, so users who hit the "tool requires approval" error from the daemon find clear remediation on Details but nothing on the list view. They report the list as broken or inconsistent. This commit: * Adds a second `alert-warning` banner (`v-else-if="quarantineToolCount > 0"`) with a Review link that routes to `/servers/<name>?tab=tools`. Server and tool quarantine remain mutually exclusive on the card — the server-level banner already implies the tools won't run, so we don't stack two warnings. * Splits the stat-desc `v-if / v-else-if` chain so `X disabled` and `N pending approval` can render side-by-side. Token-size hint stays the "nothing wrong" fallback. * Computes a context-aware summary string: - "All N tools pending security approval" when every tool is pending - "N of M tools pending security approval" when partial - "N tools changed since approval — re-review needed" for rug-pull - mixed pending+changed when both apply No backend change: the `quarantine.pending_count` / `changed_count` / `blocked_count` block is already populated by `enrichServersWithQuarantineStats` (REST + SSE). Extends the existing ServerCard test with five new cases covering the partial / full / changed / mixed-with-server-level / mixed-with-disabled scenarios.
1 parent 5fb0cc2 commit 1cb6d98

2 files changed

Lines changed: 154 additions & 3 deletions

File tree

frontend/src/components/ServerCard.vue

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@
3535
</svg>
3636
{{ blockedToolCount }} disabled
3737
</div>
38-
<div v-else-if="quarantineToolCount > 0" class="stat-desc text-xs text-warning flex items-center gap-1">
38+
<div v-if="quarantineToolCount > 0" class="stat-desc text-xs text-warning flex items-center gap-1">
3939
<svg class="w-3 h-3 inline-block flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
4040
<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" />
4141
</svg>
4242
{{ quarantineToolCount }} pending approval
4343
</div>
44-
<div v-else-if="server.tool_list_token_size" class="stat-desc text-xs">
44+
<div v-if="blockedToolCount === 0 && quarantineToolCount === 0 && server.tool_list_token_size" class="stat-desc text-xs">
4545
{{ server.tool_list_token_size.toLocaleString() }} tokens
4646
</div>
4747
</div>
@@ -106,14 +106,35 @@
106106
<span class="text-xs">{{ server.last_error }}</span>
107107
</div>
108108

109-
<!-- Quarantine warning -->
109+
<!-- Server-level quarantine warning. Server is held back entirely until
110+
the user approves it. Drives the Approve button below via
111+
health.action='approve'. -->
110112
<div v-if="server.quarantined" class="alert alert-warning alert-sm mb-4">
111113
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
112114
<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" />
113115
</svg>
114116
<span class="text-xs">Server is quarantined</span>
115117
</div>
116118

119+
<!-- Tool-level quarantine warning (Spec 032). Independent of server
120+
quarantine: when a server is trusted at the server level but ships
121+
tools with descriptions/schemas that have not yet been approved
122+
(or that changed since last approval — rug-pull guard), they are
123+
silently blocked from agent use. Surface this on the list so users
124+
don't have to open Details to discover it. -->
125+
<div v-else-if="quarantineToolCount > 0" class="alert alert-warning alert-sm mb-4">
126+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
127+
<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" />
128+
</svg>
129+
<span class="text-xs flex-1">{{ toolQuarantineSummary }}</span>
130+
<router-link
131+
:to="`/servers/${server.name}?tab=tools`"
132+
class="btn btn-xs btn-warning"
133+
>
134+
Review
135+
</router-link>
136+
</div>
137+
117138
<!-- Actions - uses unified health.action when available -->
118139
<div class="card-actions justify-end space-x-2">
119140
<!-- Primary action button based on health.action -->
@@ -408,6 +429,34 @@ const blockedToolCount = computed(() => {
408429
return q.blocked_count ?? 0
409430
})
410431
432+
// Human-readable summary for the tool-quarantine banner. Differentiates
433+
// fully-quarantined (every tool needs approval) from partially-quarantined,
434+
// and surfaces "changed" tools separately because they indicate a rug-pull
435+
// rather than a first-time review.
436+
const toolQuarantineSummary = computed(() => {
437+
const q = props.server.quarantine
438+
if (!q) return ''
439+
const pending = q.pending_count ?? 0
440+
const changed = q.changed_count ?? 0
441+
const total = pending + changed
442+
if (total === 0) return ''
443+
const toolCount = props.server.tool_count ?? 0
444+
const noun = (n: number) => (n === 1 ? 'tool' : 'tools')
445+
if (changed > 0 && pending > 0) {
446+
return `${pending} ${noun(pending)} pending, ${changed} changed — approval needed`
447+
}
448+
if (changed > 0) {
449+
return `${changed} ${noun(changed)} changed since approval — re-review needed`
450+
}
451+
if (toolCount > 0 && pending === toolCount) {
452+
return `All ${pending} ${noun(pending)} pending security approval`
453+
}
454+
if (toolCount > 0) {
455+
return `${pending} of ${toolCount} ${noun(toolCount)} pending security approval`
456+
}
457+
return `${pending} ${noun(pending)} pending security approval`
458+
})
459+
411460
// Security scan badge (Spec 039)
412461
const securityScanStatus = computed(() => {
413462
return props.server.security_scan?.status || 'not_scanned'

frontend/src/components/__tests__/ServerCard.test.ts

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,4 +61,106 @@ describe('ServerCard', () => {
6161
expect(wrapper.text()).toContain('disabled-server')
6262
expect(wrapper.text()).toContain('Disabled')
6363
})
64+
65+
it('shows tool-quarantine banner with Review link when tools are pending and server is not quarantined', () => {
66+
const server = {
67+
name: 'partial-server',
68+
protocol: 'stdio' as const,
69+
enabled: true,
70+
connected: true,
71+
quarantined: false,
72+
tool_count: 10,
73+
quarantine: { pending_count: 3, changed_count: 0, blocked_count: 0 }
74+
}
75+
76+
const wrapper = mount(ServerCard, {
77+
props: { server },
78+
global: { plugins: [pinia, router] }
79+
})
80+
81+
expect(wrapper.text()).toContain('3 of 10 tools pending security approval')
82+
const review = wrapper.find('a.btn-warning')
83+
expect(review.exists()).toBe(true)
84+
expect(review.attributes('href')).toBe('/servers/partial-server?tab=tools')
85+
// The server-level "Server is quarantined" banner must NOT render here
86+
expect(wrapper.text()).not.toContain('Server is quarantined')
87+
})
88+
89+
it('says "All N pending" when every tool is pending', () => {
90+
const server = {
91+
name: 'fully-pending',
92+
protocol: 'stdio' as const,
93+
enabled: true,
94+
connected: true,
95+
quarantined: false,
96+
tool_count: 4,
97+
quarantine: { pending_count: 4, changed_count: 0, blocked_count: 0 }
98+
}
99+
100+
const wrapper = mount(ServerCard, {
101+
props: { server },
102+
global: { plugins: [pinia, router] }
103+
})
104+
105+
expect(wrapper.text()).toContain('All 4 tools pending security approval')
106+
})
107+
108+
it('flags rug-pull-style changed tools separately', () => {
109+
const server = {
110+
name: 'rugpull',
111+
protocol: 'stdio' as const,
112+
enabled: true,
113+
connected: true,
114+
quarantined: false,
115+
tool_count: 5,
116+
quarantine: { pending_count: 0, changed_count: 2, blocked_count: 0 }
117+
}
118+
119+
const wrapper = mount(ServerCard, {
120+
props: { server },
121+
global: { plugins: [pinia, router] }
122+
})
123+
124+
expect(wrapper.text()).toContain('2 tools changed since approval')
125+
})
126+
127+
it('does not double up: server-level banner wins over tool-level banner', () => {
128+
const server = {
129+
name: 'srv-quarantined',
130+
protocol: 'stdio' as const,
131+
enabled: true,
132+
connected: false,
133+
quarantined: true,
134+
tool_count: 4,
135+
quarantine: { pending_count: 4, changed_count: 0, blocked_count: 0 }
136+
}
137+
138+
const wrapper = mount(ServerCard, {
139+
props: { server },
140+
global: { plugins: [pinia, router] }
141+
})
142+
143+
expect(wrapper.text()).toContain('Server is quarantined')
144+
expect(wrapper.text()).not.toContain('pending security approval')
145+
})
146+
147+
it('shows both disabled and pending counts when both apply', () => {
148+
const server = {
149+
name: 'mixed',
150+
protocol: 'stdio' as const,
151+
enabled: true,
152+
connected: true,
153+
quarantined: false,
154+
tool_count: 10,
155+
quarantine: { pending_count: 2, changed_count: 0, blocked_count: 3 }
156+
}
157+
158+
const wrapper = mount(ServerCard, {
159+
props: { server },
160+
global: { plugins: [pinia, router] }
161+
})
162+
163+
expect(wrapper.text()).toContain('3 disabled')
164+
expect(wrapper.text()).toContain('2 pending approval')
165+
})
64166
})

0 commit comments

Comments
 (0)