Skip to content

Commit 1675a01

Browse files
committed
Fixes #4871: Add SchemaPin support to prevent supply chain attacks on tool schemas
- Add optional SchemaPin dependency for cryptographic schema verification - Implement SchemaPinService, SchemaPinValidator, and KeyPinningManager - Integrate schema verification into MCP tool loading process - Add VSCode configuration settings for SchemaPin options - Maintain backward compatibility - unsigned schemas still work - Add comprehensive test coverage for SchemaPin functionality - Support for .signed.schema.json files and public key pinning - Implement developer key discovery via .well-known endpoints - Add key revocation support for compromised developer keys This implementation provides protection against MCP Rug Pull attacks where malicious actors alter tool schemas after initial trust.
1 parent 2e2f83b commit 1675a01

File tree

10 files changed

+2628
-0
lines changed

10 files changed

+2628
-0
lines changed

pnpm-lock.yaml

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

roo-code-messages.log

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

src/package.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,44 @@
344344
"type": "boolean",
345345
"default": false,
346346
"description": "%settings.rooCodeCloudEnabled.description%"
347+
},
348+
"roo-cline.schemapin.enabled": {
349+
"type": "boolean",
350+
"default": true,
351+
"description": "Enable SchemaPin schema verification for MCP tools"
352+
},
353+
"roo-cline.schemapin.strictMode": {
354+
"type": "boolean",
355+
"default": false,
356+
"description": "Enable strict mode - reject tools without valid signatures"
357+
},
358+
"roo-cline.schemapin.autoPin": {
359+
"type": "boolean",
360+
"default": false,
361+
"description": "Automatically pin keys for new tools without prompting"
362+
},
363+
"roo-cline.schemapin.verificationTimeout": {
364+
"type": "number",
365+
"default": 5000,
366+
"minimum": 1000,
367+
"maximum": 30000,
368+
"description": "Timeout in milliseconds for schema verification operations"
369+
},
370+
"roo-cline.schemapin.trustedDomains": {
371+
"type": "array",
372+
"items": {
373+
"type": "string"
374+
},
375+
"default": [],
376+
"description": "List of trusted domains that bypass verification"
377+
},
378+
"roo-cline.schemapin.blockedDomains": {
379+
"type": "array",
380+
"items": {
381+
"type": "string"
382+
},
383+
"default": [],
384+
"description": "List of blocked domains that are never allowed"
347385
}
348386
}
349387
}
@@ -414,6 +452,7 @@
414452
"reconnecting-eventsource": "^1.6.4",
415453
"sanitize-filename": "^1.6.3",
416454
"say": "^0.16.0",
455+
"schemapin": "^1.0.0",
417456
"serialize-error": "^11.0.3",
418457
"simple-git": "^3.27.0",
419458
"sound-play": "^1.1.0",

src/services/mcp/McpHub.ts

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import {
3232
import { fileExistsAtPath } from "../../utils/fs"
3333
import { arePathsEqual } from "../../utils/path"
3434
import { injectVariables } from "../../utils/config"
35+
import { SchemaPinService } from "../schemapin"
3536

3637
export type McpConnection = {
3738
server: McpServer
@@ -133,15 +134,49 @@ export class McpHub {
133134
isConnecting: boolean = false
134135
private refCount: number = 0 // Reference counter for active clients
135136
private configChangeDebounceTimers: Map<string, NodeJS.Timeout> = new Map()
137+
private schemaPinService?: SchemaPinService
136138

137139
constructor(provider: ClineProvider) {
138140
this.providerRef = new WeakRef(provider)
141+
this.initializeSchemaPinService().catch(console.error)
139142
this.watchMcpSettingsFile()
140143
this.watchProjectMcpFile().catch(console.error)
141144
this.setupWorkspaceFoldersWatcher()
142145
this.initializeGlobalMcpServers()
143146
this.initializeProjectMcpServers()
144147
}
148+
149+
/**
150+
* Initialize SchemaPin service for schema verification
151+
*/
152+
private async initializeSchemaPinService(): Promise<void> {
153+
try {
154+
const provider = this.providerRef.deref()
155+
if (!provider) {
156+
console.warn("Provider not available for SchemaPin initialization")
157+
return
158+
}
159+
160+
// Get SchemaPin configuration from VSCode settings
161+
const config = vscode.workspace.getConfiguration("roo-cline.schemapin")
162+
const schemaPinConfig = {
163+
enabled: config.get<boolean>("enabled", true),
164+
autoPin: config.get<boolean>("autoPin", false),
165+
timeout: config.get<number>("verificationTimeout", 5000),
166+
verifyOnToolCall: !config.get<boolean>("strictMode", false), // In strict mode, we verify on tool call
167+
}
168+
169+
if (schemaPinConfig.enabled) {
170+
this.schemaPinService = new SchemaPinService(provider.context, schemaPinConfig)
171+
await this.schemaPinService.initialize()
172+
console.log("SchemaPin service initialized successfully")
173+
}
174+
} catch (error) {
175+
console.error("Failed to initialize SchemaPin service:", error)
176+
// Don't throw - SchemaPin is optional
177+
}
178+
}
179+
145180
/**
146181
* Registers a client (e.g., ClineProvider) using this hub.
147182
* Increments the reference count.
@@ -1457,6 +1492,17 @@ export class McpHub {
14571492
throw new Error(`Server "${serverName}" is disabled and cannot be used`)
14581493
}
14591494

1495+
// SchemaPin verification for tool calls
1496+
if (this.schemaPinService && this.schemaPinService.isEnabled()) {
1497+
try {
1498+
await this.verifyToolSchema(serverName, toolName, source)
1499+
} catch (error) {
1500+
console.warn(`SchemaPin verification failed for ${serverName}/${toolName}:`, error)
1501+
// In non-strict mode, we continue with the tool call
1502+
// In strict mode, this would throw an error
1503+
}
1504+
}
1505+
14601506
let timeout: number
14611507
try {
14621508
const parsedConfig = ServerConfigSchema.parse(JSON.parse(connection.server.config))
@@ -1563,6 +1609,124 @@ export class McpHub {
15631609
}
15641610
}
15651611

1612+
/**
1613+
* Verify tool schema using SchemaPin
1614+
*/
1615+
private async verifyToolSchema(serverName: string, toolName: string, source?: "global" | "project"): Promise<void> {
1616+
if (!this.schemaPinService) {
1617+
return
1618+
}
1619+
1620+
const connection = this.findConnection(serverName, source)
1621+
if (!connection) {
1622+
return
1623+
}
1624+
1625+
// Find the tool in the server's tools list
1626+
const tool = connection.server.tools?.find((t) => t.name === toolName)
1627+
if (!tool || !tool.inputSchema) {
1628+
return
1629+
}
1630+
1631+
// Check for signed schema file
1632+
const signedSchemaPath = await this.findSignedSchemaFile(serverName, toolName, source)
1633+
if (!signedSchemaPath) {
1634+
// No signed schema found - this is okay in non-strict mode
1635+
return
1636+
}
1637+
1638+
try {
1639+
const signedSchemaContent = await fs.readFile(signedSchemaPath, "utf-8")
1640+
const signedSchema = JSON.parse(signedSchemaContent)
1641+
1642+
if (!signedSchema.signature) {
1643+
console.warn(`No signature found in signed schema file for ${serverName}/${toolName}`)
1644+
return
1645+
}
1646+
1647+
// Perform SchemaPin verification
1648+
const verificationResult = await this.schemaPinService.verifyMcpTool({
1649+
serverName,
1650+
toolName,
1651+
schema: tool.inputSchema as Record<string, unknown>,
1652+
signature: signedSchema.signature,
1653+
domain: this.extractDomainFromServerName(serverName),
1654+
})
1655+
1656+
if (!verificationResult.valid) {
1657+
throw new Error(`Schema verification failed: ${verificationResult.error}`)
1658+
}
1659+
1660+
console.log(`SchemaPin verification successful for ${serverName}/${toolName}`)
1661+
} catch (error) {
1662+
console.error(`SchemaPin verification error for ${serverName}/${toolName}:`, error)
1663+
throw error
1664+
}
1665+
}
1666+
1667+
/**
1668+
* Find signed schema file for a tool
1669+
*/
1670+
private async findSignedSchemaFile(
1671+
serverName: string,
1672+
toolName: string,
1673+
source?: "global" | "project",
1674+
): Promise<string | null> {
1675+
// Look for .signed.schema.json files in common locations
1676+
const possiblePaths = [
1677+
// Next to the server executable
1678+
`${serverName}/${toolName}.signed.schema.json`,
1679+
`${serverName}.${toolName}.signed.schema.json`,
1680+
// In a schemas directory
1681+
`schemas/${serverName}/${toolName}.signed.schema.json`,
1682+
`schemas/${serverName}.${toolName}.signed.schema.json`,
1683+
]
1684+
1685+
for (const relativePath of possiblePaths) {
1686+
try {
1687+
// Check in project directory first if this is a project server
1688+
if (source === "project" && vscode.workspace.workspaceFolders?.length) {
1689+
const projectPath = path.join(vscode.workspace.workspaceFolders[0].uri.fsPath, relativePath)
1690+
if (await fileExistsAtPath(projectPath)) {
1691+
return projectPath
1692+
}
1693+
}
1694+
1695+
// Check in global MCP directory
1696+
const provider = this.providerRef.deref()
1697+
if (provider) {
1698+
const globalPath = path.join(await provider.ensureMcpServersDirectoryExists(), relativePath)
1699+
if (await fileExistsAtPath(globalPath)) {
1700+
return globalPath
1701+
}
1702+
}
1703+
} catch (error) {
1704+
// Continue checking other paths
1705+
}
1706+
}
1707+
1708+
return null
1709+
}
1710+
1711+
/**
1712+
* Extract domain from server name for SchemaPin
1713+
*/
1714+
private extractDomainFromServerName(serverName: string): string {
1715+
// Try to extract domain from server name
1716+
const urlMatch = serverName.match(/https?:\/\/([^\/]+)/)
1717+
if (urlMatch) {
1718+
return urlMatch[1]
1719+
}
1720+
1721+
// Check if it looks like a domain
1722+
if (serverName.includes(".") && !serverName.includes("/")) {
1723+
return serverName
1724+
}
1725+
1726+
// Fallback to using the server name as domain
1727+
return serverName
1728+
}
1729+
15661730
async dispose(): Promise<void> {
15671731
// Prevent multiple disposals
15681732
if (this.isDisposed) {
@@ -1578,6 +1742,11 @@ export class McpHub {
15781742
}
15791743
this.configChangeDebounceTimers.clear()
15801744

1745+
// Dispose SchemaPin service
1746+
if (this.schemaPinService) {
1747+
await this.schemaPinService.dispose()
1748+
}
1749+
15811750
this.removeAllFileWatchers()
15821751
for (const connection of this.connections) {
15831752
try {

0 commit comments

Comments
 (0)