Files
goodbuddy/src/main/capabilities/curated-mcp-launch.ts
T

277 lines
7.6 KiB
TypeScript

import { lstat, realpath, stat } from 'node:fs/promises'
import { isAbsolute } from 'node:path'
import {
buildDesktopHelperEnvironment,
type SecurePathMetadata,
type SessionEnvironmentOptions
} from '../linux-desktop/session-environment'
import { isPathInside } from '../workspace-file-access'
const MAX_ARGUMENTS = 64
const MAX_ARGUMENT_LENGTH = 4_096
const MAX_ENVIRONMENT_ENTRIES = 64
const MAX_ENVIRONMENT_VALUE_LENGTH = 8_192
const DESKTOP_ENVIRONMENT_NAMES = new Set([
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_RUNTIME_DIR',
'DBUS_SESSION_BUS_ADDRESS',
'XAUTHORITY',
'NO_AT_BRIDGE'
])
declare const curatedMcpLaunchBrand: unique symbol
export type CuratedMcpPathMetadata = Readonly<SecurePathMetadata>
export interface CuratedMcpFileSystem {
inspect(path: string): Promise<CuratedMcpPathMetadata>
}
export type CuratedMcpLaunchOptions = Readonly<{
executable: string
args?: readonly string[]
cwd: string
ownedRoots: readonly string[]
ownerUid: number
environment?: Readonly<Record<string, string>>
allowedEnvironmentNames?: readonly string[]
linuxDesktopEnvironment?: SessionEnvironmentOptions
}>
export type CuratedMcpLaunchDescriptor = Readonly<{
readonly [curatedMcpLaunchBrand]: true
readonly transport: 'curated-stdio'
readonly command: string
readonly args: readonly string[]
readonly cwd: string
readonly env: Readonly<Record<string, string>>
}>
export type CuratedMcpLaunchDependencies = Readonly<{
fileSystem?: CuratedMcpFileSystem
validateLinuxDesktopEnvironment?: (
options: SessionEnvironmentOptions
) => Promise<NodeJS.ProcessEnv>
}>
const descriptors = new WeakSet<object>()
const defaultFileSystem: CuratedMcpFileSystem = {
async inspect(path) {
const [linkMetadata, canonicalPath] = await Promise.all([
lstat(path),
realpath(path)
])
const metadata = await stat(canonicalPath)
return {
canonicalPath,
uid: metadata.uid,
mode: metadata.mode,
isDirectory: metadata.isDirectory(),
isFile: metadata.isFile(),
isSymbolicLink: linkMetadata.isSymbolicLink()
}
}
}
const hasControlCharacters = (value: string): boolean =>
[...value].some((character) => {
const code = character.charCodeAt(0)
return code < 32 || code === 127
})
const isSafePathText = (value: string): boolean =>
value.length >= 1 &&
value.length <= MAX_ENVIRONMENT_VALUE_LENGTH &&
!hasControlCharacters(value)
const assertOwnedCanonicalPath = (
requestedPath: string,
metadata: CuratedMcpPathMetadata,
ownerUid: number,
expectedKind: 'directory' | 'file'
): void => {
if (
!isAbsolute(requestedPath) ||
metadata.canonicalPath !== requestedPath ||
metadata.isSymbolicLink ||
metadata.uid !== ownerUid ||
(metadata.mode & 0o022) !== 0 ||
(expectedKind === 'directory'
? !metadata.isDirectory
: !metadata.isFile)
) {
throw new Error(`Unsafe curated MCP ${expectedKind} path`)
}
}
const isRejectedEnvironmentName = (name: string): boolean => {
const upperName = name.toUpperCase()
return (
upperName.startsWith('LD_') ||
upperName === 'NODE_OPTIONS' ||
upperName === 'GTK_MODULES' ||
upperName === 'QT_PLUGIN_PATH' ||
upperName.startsWith('ELECTRON_') ||
upperName.startsWith('CHROME_') ||
upperName.startsWith('CHROMIUM_') ||
upperName === 'HTTP_PROXY' ||
upperName === 'HTTPS_PROXY' ||
upperName === 'ALL_PROXY' ||
upperName === 'NO_PROXY' ||
/(?:CREDENTIAL|PASSWORD|PASSWD|SECRET|TOKEN|API_KEY|ACCESS_KEY|PRIVATE_KEY|AUTHORIZATION|COOKIE)/u.test(
upperName
)
)
}
const copyEnvironment = (
source: Readonly<Record<string, string>>,
allowedNames: ReadonlySet<string>,
target: Record<string, string>
): void => {
for (const [name, value] of Object.entries(source)) {
if (
!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name) ||
!allowedNames.has(name) ||
isRejectedEnvironmentName(name) ||
value.length > MAX_ENVIRONMENT_VALUE_LENGTH ||
hasControlCharacters(value)
) {
throw new Error(`Unsafe curated MCP environment variable: ${name}`)
}
target[name] = value
}
}
export async function createCuratedMcpLaunch(
options: CuratedMcpLaunchOptions,
dependencies: CuratedMcpLaunchDependencies = {}
): Promise<CuratedMcpLaunchDescriptor> {
if (
options.ownedRoots.length === 0 ||
options.ownedRoots.some(
(root) => !isAbsolute(root) || !isSafePathText(root)
)
) {
throw new Error('Curated MCP requires an absolute allowlisted root')
}
if (
!isAbsolute(options.executable) ||
!isAbsolute(options.cwd) ||
!isSafePathText(options.executable) ||
!isSafePathText(options.cwd) ||
(options.args?.length ?? 0) > MAX_ARGUMENTS ||
options.args?.some(
(argument) =>
argument.length > MAX_ARGUMENT_LENGTH ||
hasControlCharacters(argument)
)
) {
throw new Error('Invalid curated MCP launch parameters')
}
const fileSystem = dependencies.fileSystem ?? defaultFileSystem
const rootMetadata = await Promise.all(
options.ownedRoots.map((root) => fileSystem.inspect(root))
)
rootMetadata.forEach((metadata, index) => {
assertOwnedCanonicalPath(
options.ownedRoots[index]!,
metadata,
options.ownerUid,
'directory'
)
})
const [executableMetadata, cwdMetadata] = await Promise.all([
fileSystem.inspect(options.executable),
fileSystem.inspect(options.cwd)
])
assertOwnedCanonicalPath(
options.executable,
executableMetadata,
options.ownerUid,
'file'
)
assertOwnedCanonicalPath(
options.cwd,
cwdMetadata,
options.ownerUid,
'directory'
)
if (
(process.platform !== 'win32' &&
(executableMetadata.mode & 0o111) === 0) ||
!rootMetadata.some((root) =>
isPathInside(root.canonicalPath, executableMetadata.canonicalPath)
) ||
!rootMetadata.some((root) =>
isPathInside(root.canonicalPath, cwdMetadata.canonicalPath)
)
) {
throw new Error('Curated MCP paths are outside the owned allowlist')
}
const allowedNames = new Set(options.allowedEnvironmentNames ?? [])
if (
(options.allowedEnvironmentNames?.length ?? 0) >
MAX_ENVIRONMENT_ENTRIES ||
allowedNames.size > MAX_ENVIRONMENT_ENTRIES ||
[...allowedNames].some(
(name) =>
isRejectedEnvironmentName(name) ||
DESKTOP_ENVIRONMENT_NAMES.has(name.toUpperCase())
)
) {
throw new Error('Unsafe curated MCP environment allowlist')
}
const environment: Record<string, string> = {}
copyEnvironment(options.environment ?? {}, allowedNames, environment)
if (options.linuxDesktopEnvironment) {
const validate =
dependencies.validateLinuxDesktopEnvironment ??
buildDesktopHelperEnvironment
const desktopEnvironment = await validate(
options.linuxDesktopEnvironment
)
copyEnvironment(
desktopEnvironment as Record<string, string>,
new Set([
'PATH',
'LANG',
'LC_ALL',
'LC_CTYPE',
'DISPLAY',
'WAYLAND_DISPLAY',
'XDG_RUNTIME_DIR',
'DBUS_SESSION_BUS_ADDRESS',
'XAUTHORITY',
'NO_AT_BRIDGE'
]),
environment
)
}
const descriptor = Object.freeze({
transport: 'curated-stdio' as const,
command: executableMetadata.canonicalPath,
args: Object.freeze([...(options.args ?? [])]),
cwd: cwdMetadata.canonicalPath,
env: Object.freeze({ ...environment })
}) as CuratedMcpLaunchDescriptor
descriptors.add(descriptor)
return descriptor
}
export function isCuratedMcpLaunchDescriptor(
value: unknown
): value is CuratedMcpLaunchDescriptor {
return (
typeof value === 'object' &&
value !== null &&
descriptors.has(value)
)
}