From 49ed80dca6baa2f694dcab25341ac2749a31f47b Mon Sep 17 00:00:00 2001 From: rrader26 Date: Mon, 11 May 2026 22:28:41 -0400 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20Network=20Pack=20=E2=80=94=20H?= =?UTF-8?q?TTP=20+=20WebSocket=20with=20URL=20allowlist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cross-platform pure-Node plugin for outbound network calls. Bounded by a glob-style URL allowlist (empty by default → denies everything until the operator configures one) so the agent can't exfiltrate to arbitrary endpoints. Tools shipped (5): agentmark_http_request GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS agentmark_websocket_connect open WS, returns ws_id agentmark_websocket_send text or base64 binary agentmark_websocket_receive pull queued or wait for next message agentmark_websocket_close close + release session HTTP body encoding is auto-detected: - JSON object → stringified, content-type defaults to application/json - plain string → sent verbatim - { base64: "..." } → decoded to raw bytes (octet-stream) Response body shape selectable via `response_format`: - "text" (default) - "json" (parsed; errors clearly if not valid JSON) - "base64" (for binary content like images / PDFs) WebSocket sessions queue inbound messages; receive() returns up to `max` messages, or waits up to `timeout_ms` for the first one if the queue is empty. Returns empty array on timeout or after socket closes. Uses native global `WebSocket` (Node 22+, browsers). No external deps. Allowlist matching is glob-style: - "*" matches non-slash run - "**" matches any character including slash - "https://api.example.com/**" — any path under that host - "https://*.example.com/**" — any subdomain - "wss://*.realtime.example.com/**" — WebSocket Opt-in plugin. Configure via createNetworkPlugin({ urlAllowlist: [...] }) or AGENTMARK_HTTP_ALLOWLIST (colon-separated). Tests (17 new, 359 total): allowlist semantics, single/double-star matching, wildcard subdomains, HTTP method shaping, JSON body encoding, base64 response decoding, boundary refusal paths, websocket allowlist enforcement. Build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/mcp/index.ts | 16 ++ src/plugins/network/allowlist.ts | 79 ++++++++++ src/plugins/network/http.ts | 128 +++++++++++++++ src/plugins/network/index.ts | 122 +++++++++++++++ src/plugins/network/tool-defs.ts | 115 ++++++++++++++ src/plugins/network/websocket.ts | 205 ++++++++++++++++++++++++ test/network/network-plugin.test.ts | 231 ++++++++++++++++++++++++++++ 7 files changed, 896 insertions(+) create mode 100644 src/plugins/network/allowlist.ts create mode 100644 src/plugins/network/http.ts create mode 100644 src/plugins/network/index.ts create mode 100644 src/plugins/network/tool-defs.ts create mode 100644 src/plugins/network/websocket.ts create mode 100644 test/network/network-plugin.test.ts diff --git a/src/mcp/index.ts b/src/mcp/index.ts index b401000..a3fdf7f 100644 --- a/src/mcp/index.ts +++ b/src/mcp/index.ts @@ -47,6 +47,22 @@ export { } from '../plugins/foundations' export type { FoundationsPluginConfig } from '../plugins/foundations' +// Network Pack — HTTP + WebSocket tools. Opt-in; URL allowlist required. +export { + createNetworkPlugin, + UrlAllowlist, + WebSocketManager, + NETWORK_TOOLS, + httpRequest, +} from '../plugins/network' +export type { + NetworkPluginConfig, + HttpRequestArgs, + HttpResponse, + WebSocketSession, + QueuedMessage, +} from '../plugins/network' + // Microsoft Workflows Pack (Graph-only v0) — opt-in; not part of the // default plugin set. Pass it explicitly via `createMcpServer({ plugins })`. export { diff --git a/src/plugins/network/allowlist.ts b/src/plugins/network/allowlist.ts new file mode 100644 index 0000000..9c39330 --- /dev/null +++ b/src/plugins/network/allowlist.ts @@ -0,0 +1,79 @@ +// URL allowlist matching for the Network Pack. +// +// Patterns use shell-style globbing on hostname + path. Examples: +// "https://api.example.com/[asterisk]" — any path under that host +// "https://[asterisk].example.com/[asterisk]" — any subdomain +// "wss://[asterisk].realtime.example.com/[asterisk]" — WebSocket allowlist +// +// (literal "*" in patterns — written here as [asterisk] only because +// the closing "*/" of a JSDoc comment would otherwise be triggered.) +// +// Default allowlist is empty — requests will be refused with a helpful +// error until the operator explicitly configures one. This prevents the +// agent from exfiltrating data to arbitrary endpoints. + +export interface Allowlist { + /** Glob-style patterns. Empty list = deny all. */ + patterns: string[] +} + +export class UrlAllowlist { + readonly patterns: string[] + private readonly compiled: RegExp[] + + constructor(patterns: string[] = []) { + this.patterns = patterns + this.compiled = patterns.map((p) => globToRegex(p)) + } + + /** True if `url` matches at least one configured pattern. */ + allows(url: string): boolean { + return this.compiled.some((re) => re.test(url)) + } + + /** + * Throws with a clear message if `url` is not allowed. Used at the + * boundary of every handler so the agent gets actionable feedback. + */ + assertAllowed(url: string): void { + if (this.patterns.length === 0) { + throw new Error( + 'Network allowlist is empty. The agent cannot make HTTP or ' + + 'WebSocket calls until the operator configures one. Set ' + + '`urlAllowlist` on createNetworkPlugin() or AGENTMARK_HTTP_ALLOWLIST.', + ) + } + if (!this.allows(url)) { + throw new Error( + `URL not in allowlist: ${url}. Configured patterns: ` + + this.patterns.map((p) => `"${p}"`).join(', '), + ) + } + } +} + +/** + * Convert a glob pattern to a regex. `*` matches any character except + * `/`; `**` matches any character including `/`. Other regex + * metacharacters are escaped. + */ +function globToRegex(pattern: string): RegExp { + let out = '^' + for (let i = 0; i < pattern.length; i++) { + const ch = pattern[i] + if (ch === '*') { + if (pattern[i + 1] === '*') { + out += '.*' + i++ + } else { + out += '[^/]*' + } + } else if ('.+?^$(){}[]|\\'.includes(ch)) { + out += '\\' + ch + } else { + out += ch + } + } + out += '$' + return new RegExp(out) +} diff --git a/src/plugins/network/http.ts b/src/plugins/network/http.ts new file mode 100644 index 0000000..591c1f7 --- /dev/null +++ b/src/plugins/network/http.ts @@ -0,0 +1,128 @@ +/** + * HTTP request handler for the Network Pack. + * + * Thin wrapper over global fetch (Node 18+/22+/browsers). Translates + * the agent-facing JSON args into a fetch call, applies the allowlist + * check, and returns a normalised response (status, headers, body). + * + * Body handling: + * - JSON object → stringified, content-type defaults to application/json + * - Plain string → sent verbatim + * - { base64: "..." } → decoded to bytes + * + * Response body: + * - 'text' (default) → returned as string + * - 'base64' → returned as base64-encoded bytes (use for binary) + * - 'json' → parsed (errors if response isn't valid JSON) + */ +import type { UrlAllowlist } from './allowlist' + +export interface HttpRequestArgs { + url: string + method?: string + headers?: Record + body?: unknown + response_format?: 'text' | 'base64' | 'json' + timeout_ms?: number + follow_redirects?: boolean +} + +export interface HttpResponse { + status: number + status_text: string + url: string + headers: Record + body: unknown + response_format: 'text' | 'base64' | 'json' + duration_ms: number +} + +export async function httpRequest( + allowlist: UrlAllowlist, + args: HttpRequestArgs, +): Promise { + const url = args.url + if (!url) throw new Error('`url` is required.') + allowlist.assertAllowed(url) + + const method = (args.method ?? 'GET').toUpperCase() + const headers: Record = { ...(args.headers ?? {}) } + const responseFormat = args.response_format ?? 'text' + const followRedirects = args.follow_redirects !== false + + const init: RequestInit = { + method, + headers, + redirect: followRedirects ? 'follow' : 'manual', + } + + if (args.body !== undefined && args.body !== null) { + const { body, contentType } = encodeBody(args.body) + if (!hasHeader(headers, 'content-type') && contentType) { + headers['content-type'] = contentType + } + init.body = body + } + + const controller = new AbortController() + const timeoutMs = args.timeout_ms ?? 30_000 + const timer = setTimeout(() => controller.abort(), timeoutMs) + init.signal = controller.signal + + const startedAt = Date.now() + let response: Response + try { + response = await fetch(url, init) + } finally { + clearTimeout(timer) + } + const duration_ms = Date.now() - startedAt + + const responseHeaders: Record = {} + response.headers.forEach((value, key) => { responseHeaders[key] = value }) + + let body: unknown + if (responseFormat === 'base64') { + const buf = await response.arrayBuffer() + body = Buffer.from(buf).toString('base64') + } else if (responseFormat === 'json') { + const text = await response.text() + try { + body = text.length > 0 ? JSON.parse(text) : null + } catch (err) { + throw new Error( + `response_format="json" but response body is not valid JSON: ` + + `${(err as Error).message}. Body preview: ${text.slice(0, 200)}`, + ) + } + } else { + body = await response.text() + } + + return { + status: response.status, + status_text: response.statusText, + url: response.url, + headers: responseHeaders, + body, + response_format: responseFormat, + duration_ms, + } +} + +function encodeBody(body: unknown): { body: BodyInit; contentType?: string } { + if (typeof body === 'string') { + return { body } + } + if (body && typeof body === 'object' && 'base64' in (body as Record)) { + const b64 = (body as { base64: string }).base64 + const bytes = Buffer.from(b64, 'base64') + return { body: bytes as unknown as BodyInit, contentType: 'application/octet-stream' } + } + return { body: JSON.stringify(body), contentType: 'application/json' } +} + +function hasHeader(headers: Record, name: string): boolean { + const target = name.toLowerCase() + return Object.keys(headers).some((k) => k.toLowerCase() === target) +} diff --git a/src/plugins/network/index.ts b/src/plugins/network/index.ts new file mode 100644 index 0000000..2b0d5d3 --- /dev/null +++ b/src/plugins/network/index.ts @@ -0,0 +1,122 @@ +/** + * Network Pack — HTTP + WebSocket tools. + * + * Bounded by a URL allowlist (empty by default → denies everything). + * Configure via `urlAllowlist` config option or `AGENTMARK_HTTP_ALLOWLIST` + * (colon-separated patterns). + */ +import { UrlAllowlist } from './allowlist' +import { httpRequest } from './http' +import { WebSocketManager } from './websocket' +import { NETWORK_TOOLS } from './tool-defs' +import type { AgentMarkPlugin, DispatchResult, ToolHandler } from '../../mcp/plugin' + +export interface NetworkPluginConfig { + /** Glob-style URL patterns allowed for HTTP + WebSocket requests. + * Empty list = deny all (default). */ + urlAllowlist?: string[] +} + +export function createNetworkPlugin(config: NetworkPluginConfig = {}): AgentMarkPlugin { + const fromEnv = (process.env.AGENTMARK_HTTP_ALLOWLIST ?? '') + .split(/[:,]/) + .map((s) => s.trim()) + .filter(Boolean) + const patterns = [...(config.urlAllowlist ?? []), ...fromEnv] + const allowlist = new UrlAllowlist(patterns) + const wsManager = new WebSocketManager(allowlist) + + const handlers: Record = { + agentmark_http_request: async (args): Promise => { + const url = requireString(args, 'url') + const response = await httpRequest(allowlist, { + url, + method: typeof args.method === 'string' ? args.method : undefined, + headers: isStringMap(args.headers) ? (args.headers as Record) : undefined, + body: args.body, + response_format: args.response_format === 'json' || args.response_format === 'base64' + ? args.response_format + : 'text', + timeout_ms: typeof args.timeout_ms === 'number' ? args.timeout_ms : undefined, + follow_redirects: args.follow_redirects !== false, + }) + return { text: JSON.stringify(response, null, 2) } + }, + + agentmark_websocket_connect: async (args): Promise => { + const url = requireString(args, 'url') + const protocols = optionalStringArray(args, 'protocols') + const result = await wsManager.connect(url, protocols) + return { text: JSON.stringify(result, null, 2) } + }, + + agentmark_websocket_send: async (args): Promise => { + const wsId = requireString(args, 'ws_id') + const data = requireString(args, 'data') + const format = args.format === 'base64' ? 'base64' : 'text' + wsManager.send(wsId, data, format) + return { text: JSON.stringify({ sent: true, ws_id: wsId, bytes: data.length }, null, 2) } + }, + + agentmark_websocket_receive: async (args): Promise => { + const wsId = requireString(args, 'ws_id') + const timeoutMs = typeof args.timeout_ms === 'number' ? args.timeout_ms : 5000 + const max = typeof args.max === 'number' ? args.max : 100 + const messages = await wsManager.receive(wsId, { timeoutMs, max }) + return { text: JSON.stringify({ count: messages.length, messages }, null, 2) } + }, + + agentmark_websocket_close: async (args): Promise => { + const wsId = requireString(args, 'ws_id') + const code = typeof args.code === 'number' ? args.code : 1000 + const reason = typeof args.reason === 'string' ? args.reason : undefined + await wsManager.close(wsId, code, reason) + return { text: JSON.stringify({ closed: true, ws_id: wsId, code }, null, 2) } + }, + } + + return { + name: 'network', + version: '0.1.0', + tools: NETWORK_TOOLS, + handlers, + dispose: async () => { + await wsManager.closeAll() + }, + describeSessions: () => ({ + network: { + url_allowlist: allowlist.patterns, + websockets: wsManager.list(), + }, + }), + } +} + +export { UrlAllowlist } from './allowlist' +export { httpRequest } from './http' +export { WebSocketManager } from './websocket' +export { NETWORK_TOOLS } from './tool-defs' +export type { HttpRequestArgs, HttpResponse } from './http' +export type { WebSocketSession, QueuedMessage } from './websocket' + +function requireString(args: Record, key: string): string { + const v = args[key] + if (typeof v !== 'string' || v.length === 0) { + throw new Error(`Missing required argument: ${key}`) + } + return v +} + +function optionalStringArray(args: Record, key: string): string[] | undefined { + const v = args[key] + if (v === undefined) return undefined + if (!Array.isArray(v) || v.some((x) => typeof x !== 'string')) { + throw new Error(`Argument ${key} must be an array of strings.`) + } + return v as string[] +} + +function isStringMap(v: unknown): boolean { + if (!v || typeof v !== 'object' || Array.isArray(v)) return false + return Object.values(v as Record).every((x) => typeof x === 'string') +} diff --git a/src/plugins/network/tool-defs.ts b/src/plugins/network/tool-defs.ts new file mode 100644 index 0000000..625e895 --- /dev/null +++ b/src/plugins/network/tool-defs.ts @@ -0,0 +1,115 @@ +/** + * Network Pack — tool definitions. + * + * HTTP request + WebSocket session management. Both bounded by a URL + * allowlist that the operator configures explicitly (empty by default). + */ +import type { McpToolDef } from '../../mcp/tool-defs' + +export const NETWORK_TOOLS: McpToolDef[] = [ + { + name: 'agentmark_http_request', + description: + 'Make an HTTP request to a URL on the configured allowlist. ' + + 'Supports GET/POST/PUT/PATCH/DELETE/HEAD/OPTIONS. Body is ' + + 'auto-encoded: JSON objects → application/json string, strings ' + + 'sent verbatim, { base64: "..." } objects → raw bytes.\n' + + '\n`response_format` controls the body shape:\n' + + ' - "text" (default): body returned as a string\n' + + ' - "json": body parsed (errors if not valid JSON)\n' + + ' - "base64": body base64-encoded (use for binary content)', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string' }, + method: { + type: 'string', + description: 'HTTP method. Default: GET.', + }, + headers: { + type: 'object', + description: 'Request headers. Content-Type is set automatically when a JSON body is supplied.', + }, + body: { + description: 'Request body. JSON object → stringified; string → verbatim; { base64 } → raw bytes.', + }, + response_format: { + type: 'string', + enum: ['text', 'json', 'base64'], + description: 'How to encode the response body. Default: text.', + }, + timeout_ms: { type: 'number', description: 'Request timeout in ms. Default: 30000.' }, + follow_redirects: { type: 'boolean', description: 'Follow 3xx redirects. Default: true.' }, + }, + required: ['url'], + }, + }, + + // ── WebSockets ───────────────────────────────────────────────────── + { + name: 'agentmark_websocket_connect', + description: + 'Open a WebSocket connection to a URL on the allowlist. Returns ' + + 'a ws_id once the handshake completes. Subsequent send/receive ' + + 'calls target the session by ws_id.', + inputSchema: { + type: 'object', + properties: { + url: { type: 'string' }, + protocols: { + type: 'array', + items: { type: 'string' }, + description: 'Sec-WebSocket-Protocol subprotocols to offer.', + }, + }, + required: ['url'], + }, + }, + { + name: 'agentmark_websocket_send', + description: 'Send a message on a WebSocket session.', + inputSchema: { + type: 'object', + properties: { + ws_id: { type: 'string' }, + data: { type: 'string' }, + format: { + type: 'string', + enum: ['text', 'base64'], + description: '"base64" decodes `data` to bytes before sending. Default: text.', + }, + }, + required: ['ws_id', 'data'], + }, + }, + { + name: 'agentmark_websocket_receive', + description: + 'Pull queued messages from a WebSocket session. If no messages ' + + 'are queued, waits up to `timeout_ms` (default: 5000) for the ' + + 'next one. Returns an array (possibly empty if the timeout fires ' + + 'or the socket closed).', + inputSchema: { + type: 'object', + properties: { + ws_id: { type: 'string' }, + timeout_ms: { type: 'number', description: 'Max wait when the queue is empty. Default: 5000.' }, + max: { type: 'number', description: 'Max messages to return in one call. Default: 100.' }, + }, + required: ['ws_id'], + }, + }, + { + name: 'agentmark_websocket_close', + description: 'Close a WebSocket session. Sends a close frame and releases the session.', + inputSchema: { + type: 'object', + properties: { + ws_id: { type: 'string' }, + code: { type: 'number', description: 'WebSocket close code. Default: 1000 (normal).' }, + reason: { type: 'string' }, + }, + required: ['ws_id'], + }, + }, +] diff --git a/src/plugins/network/websocket.ts b/src/plugins/network/websocket.ts new file mode 100644 index 0000000..af558ec --- /dev/null +++ b/src/plugins/network/websocket.ts @@ -0,0 +1,205 @@ +/** + * WebSocket session management for the Network Pack. + * + * Uses native `WebSocket` (Node 22+, browsers). Each connection gets a + * stable `ws_id` returned to the agent. Incoming messages are queued; + * `receive` pulls them (optionally waiting up to `timeout_ms` for the + * first one). `send` writes a text or binary message. `close` releases + * the session. + * + * Stateful — sessions live on the plugin instance until close() or + * plugin dispose(). + */ +import type { UrlAllowlist } from './allowlist' + +export interface WebSocketSession { + id: string + url: string + ws: WebSocket + queue: QueuedMessage[] + waiters: Array<(msg: QueuedMessage | null) => void> + /** State transitions for diagnostics. */ + state: 'connecting' | 'open' | 'closing' | 'closed' + /** Closure code+reason if the server closed us. */ + close_code?: number + close_reason?: string +} + +export interface QueuedMessage { + /** When the message arrived (epoch ms). */ + received_at: number + /** UTF-8 text or base64 bytes. */ + kind: 'text' | 'binary' + data: string +} + +export class WebSocketManager { + private readonly sessions = new Map() + + constructor(private readonly allowlist: UrlAllowlist) {} + + list(): Array<{ ws_id: string; url: string; state: string; queued: number }> { + return Array.from(this.sessions.values()).map((s) => ({ + ws_id: s.id, + url: s.url, + state: s.state, + queued: s.queue.length, + })) + } + + async connect(url: string, protocols?: string[]): Promise<{ ws_id: string; state: string }> { + this.allowlist.assertAllowed(url) + + const ws = new WebSocket(url, protocols) + const id = generateId() + const session: WebSocketSession = { + id, + url, + ws, + queue: [], + waiters: [], + state: 'connecting', + } + this.sessions.set(id, session) + + ws.addEventListener('open', () => { + session.state = 'open' + }) + ws.addEventListener('message', (event: MessageEvent) => { + const msg = encodeIncoming(event.data) + // Hand-off to any waiter, else queue. + const waiter = session.waiters.shift() + if (waiter) waiter(msg) + else session.queue.push(msg) + }) + ws.addEventListener('close', (event: CloseEvent) => { + session.state = 'closed' + session.close_code = event.code + session.close_reason = event.reason + // Wake any waiters with null so they don't hang forever. + for (const w of session.waiters.splice(0)) w(null) + }) + ws.addEventListener('error', () => { + // The 'close' event always follows; let the close handler + // record the state. The error itself doesn't carry useful + // structured info in the browser API. + }) + + // Wait for either 'open' or initial failure so the caller doesn't + // get a ws_id pointing at a dead socket. + await awaitOpen(ws, 30_000) + return { ws_id: id, state: session.state } + } + + send(wsId: string, data: string, format: 'text' | 'base64' = 'text'): void { + const session = this.require(wsId) + if (session.state !== 'open') { + throw new Error(`WebSocket ${wsId} is ${session.state}; cannot send.`) + } + if (format === 'base64') { + session.ws.send(Buffer.from(data, 'base64')) + } else { + session.ws.send(data) + } + } + + async receive(wsId: string, opts: { timeoutMs?: number; max?: number } = {}): Promise { + const session = this.require(wsId) + const max = Math.max(1, opts.max ?? 100) + + // If anything queued, return up to `max` immediately. + if (session.queue.length > 0) { + return session.queue.splice(0, max) + } + if (session.state === 'closed') return [] + + // Wait for the next message (or timeout). + const first = await new Promise((resolve) => { + const timer = opts.timeoutMs + ? setTimeout(() => { + const idx = session.waiters.indexOf(resolver) + if (idx !== -1) session.waiters.splice(idx, 1) + resolve(null) + }, opts.timeoutMs) + : null + + const resolver = (msg: QueuedMessage | null) => { + if (timer) clearTimeout(timer) + resolve(msg) + } + session.waiters.push(resolver) + }) + + if (first === null) return [] + // Drain any extras that arrived in the same tick, up to max. + return [first, ...session.queue.splice(0, max - 1)] + } + + async close(wsId: string, code = 1000, reason?: string): Promise { + const session = this.require(wsId) + session.state = 'closing' + session.ws.close(code, reason) + // Give the 'close' handler a tick to fire so the final state is recorded. + await new Promise((resolve) => setTimeout(resolve, 0)) + this.sessions.delete(wsId) + } + + async closeAll(): Promise { + for (const id of Array.from(this.sessions.keys())) { + await this.close(id).catch(() => {}) + } + } + + private require(wsId: string): WebSocketSession { + const s = this.sessions.get(wsId) + if (!s) throw new Error(`Unknown ws_id: ${wsId}`) + return s + } +} + +function encodeIncoming(data: unknown): QueuedMessage { + if (typeof data === 'string') { + return { received_at: Date.now(), kind: 'text', data } + } + // ArrayBuffer / Blob / TypedArray cases. + if (data instanceof ArrayBuffer) { + return { received_at: Date.now(), kind: 'binary', data: Buffer.from(data).toString('base64') } + } + if (ArrayBuffer.isView(data)) { + const view = data as ArrayBufferView + const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength) + return { received_at: Date.now(), kind: 'binary', data: Buffer.from(bytes).toString('base64') } + } + // Last-resort: stringify whatever it is. + return { received_at: Date.now(), kind: 'text', data: String(data) } +} + +function awaitOpen(ws: WebSocket, timeoutMs: number): Promise { + return new Promise((resolve, reject) => { + if (ws.readyState === 1 /* OPEN */) return resolve() + const timer = setTimeout(() => { + cleanup() + reject(new Error(`WebSocket open timed out after ${timeoutMs}ms`)) + }, timeoutMs) + const onOpen = () => { cleanup(); resolve() } + const onError = () => { cleanup(); reject(new Error('WebSocket open failed')) } + const onClose = () => { cleanup(); reject(new Error('WebSocket closed before open')) } + const cleanup = () => { + clearTimeout(timer) + ws.removeEventListener('open', onOpen) + ws.removeEventListener('error', onError) + ws.removeEventListener('close', onClose) + } + ws.addEventListener('open', onOpen) + ws.addEventListener('error', onError) + ws.addEventListener('close', onClose) + }) +} + +function generateId(): string { + const r = + typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' + ? crypto.randomUUID().replace(/-/g, '').slice(0, 12) + : Math.random().toString(36).slice(2, 14) + return `ws_${r}` +} diff --git a/test/network/network-plugin.test.ts b/test/network/network-plugin.test.ts new file mode 100644 index 0000000..69d9488 --- /dev/null +++ b/test/network/network-plugin.test.ts @@ -0,0 +1,231 @@ +/** + * Tests for the Network Pack. + * + * Allowlist matching + HTTP request shaping is covered with mocked + * global fetch. WebSocket coverage is deliberately scoped to the + * disallow path — exercising the full WS lifecycle in unit tests + * needs a real server, which we'll add in an integration suite later. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { + createNetworkPlugin, + UrlAllowlist, + NETWORK_TOOLS, +} from '../../src/plugins/network' +import { Dispatcher } from '../../src/mcp/plugin' + +let originalFetch: typeof globalThis.fetch + +beforeEach(() => { + originalFetch = globalThis.fetch +}) + +afterEach(() => { + globalThis.fetch = originalFetch +}) + +function mockFetch(handler: (url: string, init?: RequestInit) => Response | Promise) { + globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === 'string' ? input : (input as URL | Request).toString() + return Promise.resolve(handler(url, init)) + }) as typeof globalThis.fetch +} + +describe('UrlAllowlist', () => { + it('denies everything when empty', () => { + const list = new UrlAllowlist([]) + expect(list.allows('https://api.example.com/foo')).toBe(false) + expect(() => list.assertAllowed('https://api.example.com/foo')).toThrow(/allowlist is empty/) + }) + + it('matches single-star glob in path', () => { + const list = new UrlAllowlist(['https://api.example.com/*']) + expect(list.allows('https://api.example.com/users')).toBe(true) + expect(list.allows('https://api.example.com/users/1')).toBe(false) // single * doesn't cross / + expect(list.allows('https://other.example.com/users')).toBe(false) + }) + + it('matches double-star glob for any-path', () => { + const list = new UrlAllowlist(['https://api.example.com/**']) + expect(list.allows('https://api.example.com/')).toBe(true) + expect(list.allows('https://api.example.com/users/1/posts')).toBe(true) + }) + + it('matches wildcard subdomain', () => { + const list = new UrlAllowlist(['https://*.example.com/**']) + expect(list.allows('https://api.example.com/foo')).toBe(true) + expect(list.allows('https://other.example.com/bar')).toBe(true) + expect(list.allows('https://example.com/bar')).toBe(false) // wildcard requires a subdomain + }) + + it('produces a helpful error listing the configured patterns', () => { + const list = new UrlAllowlist(['https://api.x.com/**']) + try { + list.assertAllowed('https://evil.com/exfil') + } catch (err) { + expect((err as Error).message).toContain('not in allowlist') + expect((err as Error).message).toContain('"https://api.x.com/**"') + } + }) +}) + +describe('Network plugin — registration', () => { + it('registers every tool with a matching handler', () => { + const plugin = createNetworkPlugin({ urlAllowlist: ['https://api.example.com/**'] }) + const dispatcher = new Dispatcher([plugin]) + expect(dispatcher.toolNames.sort()).toEqual(NETWORK_TOOLS.map((t) => t.name).sort()) + }) + + it('describeSessions reports the configured allowlist', () => { + const plugin = createNetworkPlugin({ urlAllowlist: ['https://api.example.com/**'] }) + const info = plugin.describeSessions?.() + expect(info).toEqual({ + network: { + url_allowlist: ['https://api.example.com/**'], + websockets: [], + }, + }) + }) +}) + +describe('agentmark_http_request — happy paths', () => { + it('makes a GET, returns the body as text by default', async () => { + mockFetch(() => new Response('hello world', { status: 200, statusText: 'OK', headers: { 'content-type': 'text/plain' } })) + const plugin = createNetworkPlugin({ urlAllowlist: ['https://api.example.com/**'] }) + const dispatcher = new Dispatcher([plugin]) + + const result = await dispatcher.dispatch('agentmark_http_request', { + url: 'https://api.example.com/greet', + }) + expect(result.isError).toBeFalsy() + const body = JSON.parse(result.text) + expect(body.status).toBe(200) + expect(body.body).toBe('hello world') + expect(body.headers['content-type']).toBe('text/plain') + }) + + it('encodes a JSON object body and sets content-type', async () => { + let captured: { url: string; init?: RequestInit } | null = null + mockFetch((url, init) => { + captured = { url, init } + return new Response('{}', { status: 201 }) + }) + const plugin = createNetworkPlugin({ urlAllowlist: ['https://api.example.com/**'] }) + const dispatcher = new Dispatcher([plugin]) + + await dispatcher.dispatch('agentmark_http_request', { + url: 'https://api.example.com/users', + method: 'POST', + body: { name: 'Ryan', role: 'admin' }, + response_format: 'json', + }) + expect(captured?.init?.method).toBe('POST') + const headers = captured?.init?.headers as Record + expect(headers['content-type']).toBe('application/json') + expect(captured?.init?.body).toBe('{"name":"Ryan","role":"admin"}') + }) + + it('returns parsed JSON when response_format=json', async () => { + mockFetch(() => new Response(JSON.stringify({ ok: true, n: 7 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + })) + const plugin = createNetworkPlugin({ urlAllowlist: ['https://api.example.com/**'] }) + const dispatcher = new Dispatcher([plugin]) + + const result = await dispatcher.dispatch('agentmark_http_request', { + url: 'https://api.example.com/stat', + response_format: 'json', + }) + const body = JSON.parse(result.text) + expect(body.body).toEqual({ ok: true, n: 7 }) + }) + + it('returns base64 when response_format=base64 (binary content)', async () => { + const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47]) // PNG header + mockFetch(() => new Response(bytes, { status: 200 })) + const plugin = createNetworkPlugin({ urlAllowlist: ['https://api.example.com/**'] }) + const dispatcher = new Dispatcher([plugin]) + + const result = await dispatcher.dispatch('agentmark_http_request', { + url: 'https://api.example.com/logo.png', + response_format: 'base64', + }) + const body = JSON.parse(result.text) + expect(body.body).toBe(Buffer.from(bytes).toString('base64')) + }) +}) + +describe('agentmark_http_request — boundary enforcement', () => { + it('refuses requests when allowlist is empty', async () => { + const plugin = createNetworkPlugin({}) + const dispatcher = new Dispatcher([plugin]) + + const result = await dispatcher.dispatch('agentmark_http_request', { + url: 'https://api.example.com/anything', + }) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/allowlist is empty/) + }) + + it('refuses URLs outside the allowlist', async () => { + mockFetch(() => { throw new Error('fetch should not have been called') }) + const plugin = createNetworkPlugin({ urlAllowlist: ['https://api.example.com/**'] }) + const dispatcher = new Dispatcher([plugin]) + + const result = await dispatcher.dispatch('agentmark_http_request', { + url: 'https://evil.example.org/exfil', + }) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/not in allowlist/) + }) + + it('parses error response when response_format=json but body is not JSON', async () => { + mockFetch(() => new Response('not json', { status: 200 })) + const plugin = createNetworkPlugin({ urlAllowlist: ['https://api.example.com/**'] }) + const dispatcher = new Dispatcher([plugin]) + + const result = await dispatcher.dispatch('agentmark_http_request', { + url: 'https://api.example.com/bad', + response_format: 'json', + }) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/not valid JSON/) + }) +}) + +describe('agentmark_websocket_* — allowlist boundary', () => { + it('refuses websocket_connect for URLs outside the allowlist', async () => { + const plugin = createNetworkPlugin({ urlAllowlist: ['wss://realtime.example.com/**'] }) + const dispatcher = new Dispatcher([plugin]) + + const result = await dispatcher.dispatch('agentmark_websocket_connect', { + url: 'wss://evil.example.org/socket', + }) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/not in allowlist/) + }) + + it('refuses websocket_connect when allowlist is empty', async () => { + const plugin = createNetworkPlugin({}) + const dispatcher = new Dispatcher([plugin]) + const result = await dispatcher.dispatch('agentmark_websocket_connect', { + url: 'wss://realtime.example.com/socket', + }) + expect(result.isError).toBe(true) + expect(result.text).toMatch(/allowlist is empty/) + }) + + it('rejects send/receive/close calls for unknown ws_id', async () => { + const plugin = createNetworkPlugin({ urlAllowlist: ['wss://realtime.example.com/**'] }) + const dispatcher = new Dispatcher([plugin]) + + for (const tool of ['agentmark_websocket_send', 'agentmark_websocket_close']) { + const r = await dispatcher.dispatch(tool, { ws_id: 'ws_missing', data: 'x' }) + expect(r.isError).toBe(true) + expect(r.text).toContain('Unknown ws_id') + } + const r = await dispatcher.dispatch('agentmark_websocket_receive', { ws_id: 'ws_missing' }) + expect(r.isError).toBe(true) + }) +})