Skip to content
Closed
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
45 changes: 45 additions & 0 deletions frontend/src/utils/toolQuarantine.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// MCP-2081 (Spec 032): decide which tools surface the tool-level Quarantine
// banner + per-tool Approve UI on the server-detail page.
//
// The trust model (confirmed): a freshly-discovered baseline tool starts
// `pending`. That is NOT a rug-pull — when the operator approves the server,
// the backend promotes those baseline `pending` records to `approved`. So
// baseline `pending` tools must never raise the tool-quarantine banner on their
// own, and the tool-level banner must never compete with the server-level
// Security Quarantine banner.
//
// The only thing that legitimately needs per-tool attention is a `changed`
// tool (its description / input schema / output schema hash drifted from the
// approved version — i.e. a rug-pull). Once a `changed` tool exists, any
// residual `pending` tools are surfaced alongside it so the operator can clear
// the whole batch in one pass.

import type { ToolApproval } from '@/types'

/**
* Select the tool-approval records that should drive the tool-quarantine
* banner and approval list.
*
* @param approvals the server's tool-approval records
* @param serverQuarantined whether the server itself is under Security Quarantine
* @returns the subset to surface (empty = no tool-quarantine banner)
*/
export function selectQuarantinedTools(
approvals: ToolApproval[] | null | undefined,
serverQuarantined: boolean | null | undefined
): ToolApproval[] {
if (!approvals || approvals.length === 0) return []

// Server-level Security Quarantine takes precedence: the operator approves
// the server first, and the backend then promotes baseline pending tools to
// approved. Suppress the competing tool-level banner entirely.
if (serverQuarantined) return []

// Baseline `pending` tools are a fresh-install artifact, not a rug-pull, so
// they must not raise the banner on their own. Only a `changed` tool triggers
// it; once it does, residual `pending` tools are included for batch approval.
const hasChanged = approvals.some((t) => t.status === 'changed')
if (!hasChanged) return []

return approvals.filter((t) => t.status === 'changed' || t.status === 'pending')
}
11 changes: 8 additions & 3 deletions frontend/src/views/ServerDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1226,6 +1226,7 @@ import { useSecurityScannerStatus } from '@/composables/useSecurityScannerStatus
import { serverDisplayName } from '@/utils/serverRoute'
import { oauthSignInState } from '@/utils/health'
import { computeToolDiffSections } from '@/utils/toolDiff'
import { selectQuarantinedTools } from '@/utils/toolQuarantine'

interface Props {
// MCP-1112: vue-router decodes the percent-encoded ':serverName' param, so
Expand Down Expand Up @@ -1302,9 +1303,13 @@ const toolToggleLoading = ref<Record<string, boolean>>({})
// they're mutually exclusive with each other and with any per-tool toggle.
const bulkToolToggleLoading = ref(false)

const quarantinedTools = computed(() => {
return toolApprovals.value.filter(t => t.status === 'pending' || t.status === 'changed')
})
// Which tools drive the tool-quarantine banner + per-tool Approve UI (MCP-2081):
// only `changed` tools (rug-pull), plus any residual `pending` left alongside a
// change. Baseline `pending` tools never alarm on their own, and the banner is
// suppressed entirely while the server-level Security Quarantine banner shows.
const quarantinedTools = computed(() =>
selectQuarantinedTools(toolApprovals.value, server.value?.quarantined)
)

const blockedToolCount = computed(() => {
const q = server.value?.quarantine
Expand Down
42 changes: 42 additions & 0 deletions frontend/tests/unit/tool-quarantine-banner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { describe, it, expect } from 'vitest'
import { selectQuarantinedTools } from '../../src/utils/toolQuarantine'
import type { ToolApproval } from '../../src/types'

// MCP-2081 (Spec 032): the tool-quarantine banner must key off `changed`
// (rug-pull) tools — NOT baseline `pending` tools — and must be suppressed
// entirely while the server-level Security Quarantine banner is showing.

function approval(tool_name: string, status: ToolApproval['status']): ToolApproval {
return { server_name: 'srv', tool_name, status, hash: `h-${tool_name}`, description: `${tool_name} desc` }
}

describe('selectQuarantinedTools (MCP-2081)', () => {
it('does NOT surface the banner for baseline pending tools alone', () => {
const approvals = [approval('a', 'pending'), approval('b', 'pending')]
expect(selectQuarantinedTools(approvals, false)).toEqual([])
})

it('surfaces only the changed tool when a rug-pull is detected', () => {
const approvals = [approval('a', 'approved'), approval('b', 'changed')]
const result = selectQuarantinedTools(approvals, false)
expect(result.map((t) => t.tool_name)).toEqual(['b'])
})

it('includes residual pending tools alongside a changed tool (batch clear)', () => {
const approvals = [approval('a', 'changed'), approval('b', 'pending'), approval('c', 'approved')]
const result = selectQuarantinedTools(approvals, false)
expect(result.map((t) => t.tool_name).sort()).toEqual(['a', 'b'])
})

it('suppresses the tool-quarantine banner entirely while the server is quarantined', () => {
// Even a changed tool is hidden: the operator approves the server first.
const approvals = [approval('a', 'changed'), approval('b', 'pending')]
expect(selectQuarantinedTools(approvals, true)).toEqual([])
})

it('returns empty for no approvals or all-approved tools', () => {
expect(selectQuarantinedTools([], false)).toEqual([])
expect(selectQuarantinedTools(null, false)).toEqual([])
expect(selectQuarantinedTools([approval('a', 'approved')], false)).toEqual([])
})
})
Loading