From 300c54ae3ad85cd1a159c058ff013e0932a25adb Mon Sep 17 00:00:00 2001 From: MarvinVomberg <1999marvinvomberg@gmail.com> Date: Wed, 24 Jun 2026 22:31:11 +0200 Subject: [PATCH 1/2] feat(platform): raw-TCP egress primitive ctx.net for line protocols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins so far could only reach the network through ctx.http (HTTP/HTTPS, allow-listed). Line protocols like SMTP need a raw TCP/TLS socket, which the sandboxed plugin surface had no sanctioned way to open. Add `ctx.net.connect({host, port, tls})` — the line-protocol sibling of ctx.http — gated by a new `permissions.network.outbound_tcp` manifest block: permissions: network: outbound_tcp: - host: "$config.smtp_host" port: "$config.smtp_port" The `$config.*` references resolve against the plugin's operator config at runtime, so egress is pinned to exactly the host:port the operator entered. A generic mail/IMAP plugin can't know that target at authoring time, and the exact-match rule keeps internal relays (private IPs) reachable without opening a general SSRF surface. Enforcement mirrors httpAccessor: exact host+port match, per-minute connection budget, connect timeout. - packages/plugin-api: NetAccessor / NetConnectOptions types + NetForbiddenError / NetRateLimitError; `readonly net?` on PluginContext. - platform/netAccessor.ts: the guarded accessor. - platform/pluginContext.ts: resolveTcpTargets() + wiring (mirrors the http path). - test/netAccessor.test.ts: allow-list gating, port mismatch, rate limit, empty-list fail-closed (7 tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../packages/plugin-api/src/pluginContext.ts | 70 ++++++ middleware/src/platform/netAccessor.ts | 224 ++++++++++++++++++ middleware/src/platform/pluginContext.ts | 76 ++++++ middleware/test/netAccessor.test.ts | 148 ++++++++++++ 4 files changed, 518 insertions(+) create mode 100644 middleware/src/platform/netAccessor.ts create mode 100644 middleware/test/netAccessor.test.ts diff --git a/middleware/packages/plugin-api/src/pluginContext.ts b/middleware/packages/plugin-api/src/pluginContext.ts index c03d0f74..2223314d 100644 --- a/middleware/packages/plugin-api/src/pluginContext.ts +++ b/middleware/packages/plugin-api/src/pluginContext.ts @@ -15,6 +15,8 @@ * ask for another plugin's secrets — the boundary is structural. */ +import type { Socket } from 'node:net'; + import type { EntityCapturedTurnsHit, EntityCapturedTurnsOptions, @@ -75,6 +77,14 @@ export interface PluginContext { * relying on ctx.http means the plugin stays future-proof). */ readonly http?: HttpAccessor; + /** Raw-TCP egress for line protocols `ctx.http` cannot speak (SMTP, IMAP, + * …). Present only when the manifest declares + * `permissions.network.outbound_tcp` and the referenced operator config + * resolves to a concrete host:port. Every `connect` is pinned to that + * exact allow-listed target. Undefined otherwise — guard with `if + * (ctx.net)` so a Hub plugin tolerates an older core that lacks it. */ + readonly net?: NetAccessor; + /** Per-plugin memory store, scoped to `/memories/agents//`. * Paths passed to this accessor are relative — `notes.md` resolves to * `/memories/agents//notes.md` under the hood. Plugins cannot @@ -799,6 +809,66 @@ export class HttpRateLimitError extends Error { } } +/** + * Raw outbound TCP connection options for `ctx.net.connect`. + * + * Unlike `ctx.http` (HTTP/HTTPS only), `ctx.net` opens a raw TCP — or, with + * `tls: true`, an implicitly-encrypted — socket to a host:port the operator + * configured. It exists for line protocols the HTTP accessor cannot speak: + * SMTP/IMAP/POP3 and the like. The target is gated against + * `permissions.network.outbound_tcp` (see `NetAccessor`). + */ +export interface NetConnectOptions { + readonly host: string; + readonly port: number; + /** + * When true the kernel performs the TLS handshake and resolves with an + * already-encrypted socket (implicit TLS — e.g. SMTPS on :465). When false + * or omitted a plain TCP socket is returned and the caller may upgrade it + * itself (e.g. SMTP STARTTLS on :587, which nodemailer negotiates over the + * plain socket). Either way the connection only reaches the allow-listed + * host:port. + */ + readonly tls?: boolean; + /** TLS SNI servername; defaults to `host`. Ignored when `tls` is falsy. */ + readonly servername?: string; +} + +/** + * Raw-TCP egress accessor. Present only when the manifest declares + * `permissions.network.outbound_tcp` with at least one target the plugin's + * config resolves to a concrete host:port. Every `connect` is gated against + * that resolved allow-list (exact host + port match) and a per-minute + * connection budget — an unlisted target throws `NetForbiddenError`, an + * over-budget caller `NetRateLimitError`. + * + * The allow-list is resolved from operator config, NOT static manifest + * hostnames: a generic mail plugin does not know the SMTP host at authoring + * time, so the manifest references config fields (`host: "$config.smtp_host"`) + * and the kernel pins egress to exactly what the operator entered. That also + * means an internal relay on a private IP is reachable — the operator chose + * it — without opening a general SSRF hole. + */ +export interface NetAccessor { + connect(options: NetConnectOptions): Promise; +} + +export class NetForbiddenError extends Error { + constructor(agentId: string, target: string) { + super( + `plugin '${agentId}' is not permitted to open a TCP connection to '${target}' — missing from permissions.network.outbound_tcp (or its config-referenced host/port is unset)`, + ); + this.name = 'NetForbiddenError'; + } +} + +export class NetRateLimitError extends Error { + constructor(agentId: string) { + super(`plugin '${agentId}' exceeded its per-minute TCP connection budget`); + this.name = 'NetRateLimitError'; + } +} + /** * Memory accessor — per-plugin filesystem-backed key-value-ish store. * diff --git a/middleware/src/platform/netAccessor.ts b/middleware/src/platform/netAccessor.ts new file mode 100644 index 00000000..203ac99c --- /dev/null +++ b/middleware/src/platform/netAccessor.ts @@ -0,0 +1,224 @@ +import { lookup as dnsLookup } from 'node:dns/promises'; +import { connect as netConnect, isIP, type Socket } from 'node:net'; +import { connect as tlsConnect } from 'node:tls'; + +import { + NetForbiddenError, + NetRateLimitError, + type NetAccessor, + type NetConnectOptions, +} from '@omadia/plugin-api'; + +/** + * Per-plugin raw-TCP egress accessor — the line-protocol sibling of + * `httpAccessor`. Enforces: + * - An allow-list of concrete `{ host, port }` targets, resolved by the + * caller from the plugin's manifest (`permissions.network.outbound_tcp`) + * against its operator config. `connect` permits ONLY an exact host+port + * match — case-insensitive host, numeric port. + * - A per-minute rolling connection budget (token bucket), mirroring the + * HTTP accessor's 60/min default. + * + * Why exact-match against operator config rather than a static manifest + * hostname list (as `httpAccessor` uses): a generic mail plugin cannot know + * the SMTP host at authoring time — the operator enters it at install. Pinning + * egress to exactly that value keeps internal relays (private IPs) reachable + * without the SSRF surface a free-form raw-socket API would open. + * + * Returned to the caller seam as `undefined` when the resolved allow-list is + * empty (no `outbound_tcp` declared, or its config refs are unset) — in that + * case `ctx.net` is left unset, exactly like `ctx.http`. + */ + +const DEFAULT_RATE_LIMIT_PER_MINUTE = 60; +/** Hard ceiling on how long we wait for the socket to come up. SMTP servers + * answer in well under a second on the happy path; a stuck connect must not + * pin a tool handler open indefinitely. */ +const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; +/** Ceiling on simultaneously-open sockets per plugin — a backstop against a + * plugin holding many long-lived connections (slow-loris-style resource hold). + * SMTP sessions are short, so a handful is plenty. */ +const DEFAULT_MAX_CONCURRENT = 8; + +/** + * Egress addresses we refuse even though the design otherwise permits private + * IPs (operator-chosen internal relays are legitimate). The cloud-metadata + * service lives on the IPv4 link-local block `169.254.0.0/16` (the well-known + * `169.254.169.254`); nothing legitimately runs SMTP/IMAP there, and reaching + * it is a classic SSRF pivot. We also block IPv6 link-local (`fe80::/10`) and + * IPv4-mapped forms of the same. Loopback and RFC-1918 ranges stay reachable — + * an operator may well run a relay on localhost or an internal subnet. + */ +function isBlockedEgressIp(ip: string): boolean { + const v = ip.toLowerCase(); + // IPv4 link-local (covers 169.254.169.254 metadata), incl. v4-mapped IPv6. + const v4 = v.startsWith('::ffff:') ? v.slice('::ffff:'.length) : v; + if (/^169\.254\./.test(v4)) return true; + // IPv6 link-local fe80::/10 → fe80..febf. + if (/^fe[89ab][0-9a-f]:/.test(v)) return true; + return false; +} + +export interface NetTarget { + readonly host: string; + readonly port: number; +} + +export function createNetAccessor(opts: { + agentId: string; + /** Concrete, already-config-resolved targets the plugin may reach. */ + allowed: readonly NetTarget[]; + /** Override the default 60 connections/min cap. Tests only. */ + rateLimitPerMinute?: number; + /** Override the per-connection timeout. Tests only. */ + connectTimeoutMs?: number; + /** Override the max simultaneously-open sockets. Tests only. */ + maxConcurrent?: number; + /** Test seam — inject socket factories so a unit test need not bind a port. */ + connectFns?: { + net: typeof netConnect; + tls: typeof tlsConnect; + }; + /** Test seam — inject the DNS resolver so a unit test need not hit real DNS. */ + lookupFn?: (host: string) => Promise; +}): NetAccessor { + const { agentId, allowed } = opts; + const limit = opts.rateLimitPerMinute ?? DEFAULT_RATE_LIMIT_PER_MINUTE; + const connectTimeoutMs = opts.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS; + const maxConcurrent = opts.maxConcurrent ?? DEFAULT_MAX_CONCURRENT; + const openNet = opts.connectFns?.net ?? netConnect; + const openTls = opts.connectFns?.tls ?? tlsConnect; + const resolveHost = + opts.lookupFn ?? (async (host: string) => (await dnsLookup(host)).address); + + // Normalise the allow-list once: lower-cased host, integer port. + const allowSet = new Set( + allowed.map((t) => `${t.host.trim().toLowerCase()}:${t.port}`), + ); + + const bucket = new TokenBucket(limit, 60_000); + let openCount = 0; + + return { + async connect(options: NetConnectOptions): Promise { + const host = options.host.trim().toLowerCase(); + const port = Number(options.port); + const target = `${host}:${port}`; + + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new TypeError(`ctx.net: invalid port '${String(options.port)}'`); + } + if (!allowSet.has(target)) { + throw new NetForbiddenError(agentId, target); + } + if (openCount >= maxConcurrent) { + throw new NetRateLimitError(agentId); + } + if (!bucket.tryConsume()) { + throw new NetRateLimitError(agentId); + } + + // Resolve the hostname ONCE and dial the resolved IP literal. This closes + // the gap between the (string) allow-list check and the OS dial: the IP we + // classify is exactly the IP we connect to, so a DNS rebind between the + // two cannot slip a different address through (mirrors the http path's + // guarded dispatcher). The original hostname is kept as the TLS SNI / + // servername so certificate validation still matches. + const dialHost = options.host.trim(); + let address: string; + if (isIP(dialHost) !== 0) { + address = dialHost; + } else { + try { + address = await resolveHost(dialHost); + } catch { + throw new Error(`ctx.net: could not resolve host '${dialHost}'`); + } + } + if (isBlockedEgressIp(address)) { + // Link-local / cloud-metadata target — never a legitimate mail server. + throw new NetForbiddenError(agentId, `${target} (resolves to ${address})`); + } + + openCount += 1; + let settled = false; + try { + return await new Promise((resolve, reject) => { + const servername = options.servername ?? dialHost; + const socket = options.tls + ? openTls({ host: address, port, servername }) + : openNet({ host: address, port }); + + const release = (): void => { + if (!settled) { + settled = true; + openCount -= 1; + } + }; + const onReady = (): void => { + cleanup(); + socket.setTimeout(0); // hand a clean, un-timered socket to the caller + // Decrement the open counter when the socket finally closes, so the + // concurrency cap tracks live sockets rather than connect attempts. + socket.once('close', release); + resolve(socket); + }; + const onError = (err: Error): void => { + cleanup(); + release(); + socket.destroy(); + reject(err); + }; + const onTimeout = (): void => { + cleanup(); + release(); + socket.destroy(); + reject(new Error(`ctx.net: connection to '${target}' timed out`)); + }; + const cleanup = (): void => { + socket.removeListener('error', onError); + socket.removeListener('timeout', onTimeout); + socket.removeListener('connect', onReady); + socket.removeListener('secureConnect', onReady); + }; + + socket.setTimeout(connectTimeoutMs); + socket.once('error', onError); + socket.once('timeout', onTimeout); + // tls sockets fire 'secureConnect' once the handshake completes; plain + // sockets fire 'connect'. We listen for the relevant one. + socket.once(options.tls ? 'secureConnect' : 'connect', onReady); + }); + } catch (err) { + if (!settled) { + settled = true; + openCount -= 1; + } + throw err; + } + }, + }; +} + +/** Simple rolling-window token bucket — copied from httpAccessor to keep the + * two egress paths independent (a flood on one must not starve the other). */ +class TokenBucket { + private count = 0; + private windowStart = Date.now(); + + constructor( + private readonly capacity: number, + private readonly windowMs: number, + ) {} + + tryConsume(): boolean { + const now = Date.now(); + if (now - this.windowStart >= this.windowMs) { + this.count = 0; + this.windowStart = now; + } + if (this.count >= this.capacity) return false; + this.count++; + return true; + } +} diff --git a/middleware/src/platform/pluginContext.ts b/middleware/src/platform/pluginContext.ts index 91ae8702..52de741f 100644 --- a/middleware/src/platform/pluginContext.ts +++ b/middleware/src/platform/pluginContext.ts @@ -30,6 +30,7 @@ import { type MemoryAccessor, type MemoryStore, type MigrationContext, + type NetAccessor, type NotificationsAccessor, type OAuthTokensAccessor, OAuthTokenError, @@ -76,6 +77,7 @@ import type { PluginRouteRegistry } from './pluginRouteRegistry.js'; import type { NotificationRouter } from './notificationRouter.js'; import type { UiRouteCatalog } from './uiRouteCatalog.js'; import { createHttpAccessor, isAuditMode, type AuditMode } from './httpAccessor.js'; +import { createNetAccessor, type NetTarget } from './netAccessor.js'; import { signFlowState, verifyFlowState } from './flowState.js'; import type { PluginStatusRegistry } from './pluginStatusRegistry.js'; import { createMemoryAccessor } from './memoryAccessor.js'; @@ -376,6 +378,22 @@ export function createPluginContext( : {}), }; + // Net accessor: raw-TCP egress for line protocols ctx.http cannot speak + // (SMTP/IMAP/…). Present only when the manifest declares + // `permissions.network.outbound_tcp` AND every referenced config field + // resolves to a concrete host:port — a generic mail plugin references the + // operator-entered SMTP host/port (`$config.smtp_host`) rather than a static + // manifest hostname, so egress is pinned to exactly what the operator chose. + // Resolved here (not in extractOutboundAllowlist) because it needs the + // already-built `config` accessor to dereference those refs. + const tcpTargets = resolveTcpTargets(agentId, catalog, (key) => + config.get(key), + ); + const net: NetAccessor | undefined = + tcpTargets.length > 0 + ? createNetAccessor({ agentId, allowed: tcpTargets }) + : undefined; + // Tools accessor: funnel plugin-contributed tool registrations into the // kernel's NativeToolRegistry. Plugin captures the returned dispose handle // and calls it from its AgentHandle.close() for symmetric hot-unregister. @@ -689,6 +707,7 @@ export function createPluginContext( smokeMode: false, ...(scratch ? { scratch } : {}), ...(http ? { http } : {}), + ...(net ? { net } : {}), ...(memory ? { memory } : {}), ...(subAgent ? { subAgent } : {}), ...(knowledgeGraph ? { knowledgeGraph } : {}), @@ -1152,6 +1171,63 @@ function extractOutboundAllowlist( return outbound.filter((h): h is string => typeof h === 'string'); } +/** + * Resolve the raw-TCP egress allow-list from `permissions.network.outbound_tcp`. + * + * Each entry is `{ host, port }` where either value is a literal OR a + * `"$config."` reference resolved against the plugin's OWN config (the + * operator-entered install values). A reference shape is what makes a *generic* + * SMTP/IMAP plugin possible: the mail host is unknown at manifest-authoring + * time, so the manifest points at the config field and the kernel pins egress + * to whatever the operator saved. Entries whose host or port fail to resolve to + * a concrete, valid value are dropped (an unconfigured plugin simply gets no + * `ctx.net`), so a half-filled install never yields a half-open allow-list. + */ +function resolveTcpTargets( + agentId: string, + catalog: PluginCatalog, + configGet: (key: string) => unknown, +): NetTarget[] { + const entry = catalog.get(agentId); + if (!entry) return []; + const manifest = entry.manifest as Record | undefined; + const permissions = manifest?.['permissions'] as + | Record + | undefined; + const network = permissions?.['network'] as Record | undefined; + const raw = network?.['outbound_tcp']; + if (!Array.isArray(raw)) return []; + + const resolveRef = (value: unknown): string | undefined => { + if (typeof value === 'number') return String(value); + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + if (!trimmed.startsWith('$config.')) { + return trimmed.length > 0 ? trimmed : undefined; + } + const key = trimmed.slice('$config.'.length); + const resolved = configGet(key); + if (typeof resolved === 'number') return String(resolved); + if (typeof resolved === 'string' && resolved.trim().length > 0) { + return resolved.trim(); + } + return undefined; + }; + + const targets: NetTarget[] = []; + for (const item of raw) { + if (typeof item !== 'object' || item === null) continue; + const rec = item as Record; + const host = resolveRef(rec['host']); + const portStr = resolveRef(rec['port']); + if (host === undefined || portStr === undefined) continue; + const port = Number(portStr); + if (!Number.isInteger(port) || port < 1 || port > 65_535) continue; + targets.push({ host, port }); + } + return targets; +} + /** * #91 — resolve the operator-set audit config for a plugin: the selected * `audit_mode` and the union of every `host_list` setup field's value. Both diff --git a/middleware/test/netAccessor.test.ts b/middleware/test/netAccessor.test.ts new file mode 100644 index 00000000..e4e3df0d --- /dev/null +++ b/middleware/test/netAccessor.test.ts @@ -0,0 +1,148 @@ +import { strict as assert } from 'node:assert'; +import net from 'node:net'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +import { NetForbiddenError, NetRateLimitError } from '@omadia/plugin-api'; + +import { createNetAccessor } from '../src/platform/netAccessor.js'; + +// A throwaway loopback TCP server so the "allowed" path opens a REAL socket. +function listen(): Promise<{ port: number; close: () => void }> { + return new Promise((resolve) => { + const server = net.createServer((s) => s.end()); + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve({ port, close: () => server.close() }); + }); + }); +} + +describe('createNetAccessor — allow-list gating', () => { + let srv: { port: number; close: () => void }; + beforeEach(async () => { + srv = await listen(); + }); + afterEach(() => srv.close()); + + it('connects to an allow-listed host:port', async () => { + const net_ = createNetAccessor({ + agentId: 't', + allowed: [{ host: '127.0.0.1', port: srv.port }], + }); + const sock = await net_.connect({ host: '127.0.0.1', port: srv.port }); + assert.ok(sock.writable, 'socket should be writable'); + sock.destroy(); + }); + + it('host casing is ignored for matching', async () => { + const net_ = createNetAccessor({ + agentId: 't', + allowed: [{ host: '127.0.0.1', port: srv.port }], + }); + // an uppercased numeric host is identical, but exercise the lower-casing path + const sock = await net_.connect({ host: '127.0.0.1', port: srv.port }); + sock.destroy(); + assert.ok(true); + }); + + it('rejects an unlisted host', async () => { + const net_ = createNetAccessor({ + agentId: 't', + allowed: [{ host: '127.0.0.1', port: srv.port }], + }); + await assert.rejects( + () => net_.connect({ host: 'smtp.evil.com', port: srv.port }), + NetForbiddenError, + ); + }); + + it('rejects an allowed host on a different port', async () => { + const net_ = createNetAccessor({ + agentId: 't', + allowed: [{ host: '127.0.0.1', port: srv.port }], + }); + await assert.rejects( + () => net_.connect({ host: '127.0.0.1', port: srv.port + 1 }), + NetForbiddenError, + ); + }); + + it('rejects an invalid port before any dial', async () => { + const net_ = createNetAccessor({ + agentId: 't', + allowed: [{ host: '127.0.0.1', port: 70000 }], + }); + await assert.rejects( + () => net_.connect({ host: '127.0.0.1', port: 70000 }), + TypeError, + ); + }); +}); + +describe('createNetAccessor — rate limiting', () => { + it('enforces the per-minute connection budget', async () => { + const srv = await listen(); + const net_ = createNetAccessor({ + agentId: 't', + allowed: [{ host: '127.0.0.1', port: srv.port }], + rateLimitPerMinute: 1, + }); + const first = await net_.connect({ host: '127.0.0.1', port: srv.port }); + first.destroy(); + await assert.rejects( + () => net_.connect({ host: '127.0.0.1', port: srv.port }), + NetRateLimitError, + ); + srv.close(); + }); +}); + +describe('createNetAccessor — egress IP guard', () => { + it('blocks an allow-listed host that resolves to link-local / cloud metadata', async () => { + const net_ = createNetAccessor({ + agentId: 't', + allowed: [{ host: 'relay.attacker.com', port: 587 }], + lookupFn: async () => '169.254.169.254', + }); + await assert.rejects( + () => net_.connect({ host: 'relay.attacker.com', port: 587 }), + NetForbiddenError, + ); + }); + + it('blocks IPv6 link-local', async () => { + const net_ = createNetAccessor({ + agentId: 't', + allowed: [{ host: 'relay.attacker.com', port: 587 }], + lookupFn: async () => 'fe80::1', + }); + await assert.rejects( + () => net_.connect({ host: 'relay.attacker.com', port: 587 }), + NetForbiddenError, + ); + }); + + it('still allows a host that resolves to a private (loopback) relay', async () => { + const srv = await listen(); + const net_ = createNetAccessor({ + agentId: 't', + allowed: [{ host: 'relay.internal', port: srv.port }], + lookupFn: async () => '127.0.0.1', + }); + const sock = await net_.connect({ host: 'relay.internal', port: srv.port }); + assert.ok(sock.writable, 'private relay must remain reachable'); + sock.destroy(); + srv.close(); + }); +}); + +describe('createNetAccessor — empty allow-list', () => { + it('rejects every target when nothing is allowed', async () => { + const net_ = createNetAccessor({ agentId: 't', allowed: [] }); + await assert.rejects( + () => net_.connect({ host: '127.0.0.1', port: 25 }), + NetForbiddenError, + ); + }); +}); From fbc48dacdbc6ab12f45ba3b60bd376adcc10ff3d Mon Sep 17 00:00:00 2001 From: MarvinVomberg <1999marvinvomberg@gmail.com> Date: Thu, 25 Jun 2026 00:08:46 +0200 Subject: [PATCH 2/2] feat(platform): resolve $config.* in permissions.network.outbound The raw-TCP path (outbound_tcp) already dereferences `$config.` so a plugin can pin egress to an operator-entered host. Make the HTTP path symmetric: `permissions.network.outbound` entries of the form `$config.` now resolve against the plugin's install config, and a single field may hold several comma/whitespace-separated hosts. This lets a plugin expose an operator-configurable HTTP allow-list (e.g. an SMTP plugin's attachment-source hosts) instead of hard-coding hostnames in the manifest. Unset config contributes nothing, so ctx.http stays absent and egress fail-closed by default. Literal manifest hosts keep working unchanged. The accessor is now built after `config` is defined (it needs the config accessor to dereference the refs); no behavioural change for literal-host plugins. Co-Authored-By: Claude Opus 4.8 (1M context) --- middleware/src/platform/pluginContext.ts | 80 +++++++++++++++++------- 1 file changed, 56 insertions(+), 24 deletions(-) diff --git a/middleware/src/platform/pluginContext.ts b/middleware/src/platform/pluginContext.ts index 52de741f..ead5f324 100644 --- a/middleware/src/platform/pluginContext.ts +++ b/middleware/src/platform/pluginContext.ts @@ -238,29 +238,9 @@ export function createPluginContext( ? createScratchAccessor(agentId) : undefined; - // HTTP accessor: present when the manifest declares outbound hosts OR the - // plugin is a #91 web_scanner (audit/scanner plugins fetch user-supplied - // URLs and may declare no static hosts at all). The effective allow-list - // is resolved from the manifest plus the operator-selected audit mode + - // host_list config. Today the global `fetch` is still reachable — a future - // hardening pass will sandbox it; plugins should use ctx.http exclusively. - const outboundHosts = extractOutboundAllowlist(agentId, catalog); - const webScanner = - catalog.get(agentId)?.plugin.permissions_summary.network_web_scanner === - true; - const auditConfig = extractAuditConfig(agentId, catalog, registry); - const http: HttpAccessor | undefined = - outboundHosts.length > 0 || webScanner - ? createHttpAccessor({ - agentId, - outbound: outboundHosts, - webScanner, - extraHosts: auditConfig.extraHosts, - ...(auditConfig.auditMode - ? { auditMode: auditConfig.auditMode } - : {}), - }) - : undefined; + // (The HTTP accessor is built further down, AFTER `config` is defined, so its + // outbound allow-list can dereference `$config.*` entries — symmetric to the + // raw-TCP `outbound_tcp` path. See the `http`/`net` block below.) // Memory accessor: present when the manifest declares memory permissions // AND the memory provider plugin (`@omadia/memory`) has published its store @@ -378,6 +358,34 @@ export function createPluginContext( : {}), }; + // HTTP accessor: present when the manifest declares outbound hosts OR the + // plugin is a #91 web_scanner (audit/scanner plugins fetch user-supplied + // URLs and may declare no static hosts at all). The effective allow-list is + // resolved from the manifest (literal hosts AND `$config.*` references — so a + // plugin can let the OPERATOR configure an allowed host, e.g. an attachment + // source, rather than hard-coding it) plus the operator-selected audit mode + + // host_list config. Today the global `fetch` is still reachable — a future + // hardening pass will sandbox it; plugins should use ctx.http exclusively. + const outboundHosts = extractOutboundAllowlist(agentId, catalog, (key) => + config.get(key), + ); + const webScanner = + catalog.get(agentId)?.plugin.permissions_summary.network_web_scanner === + true; + const auditConfig = extractAuditConfig(agentId, catalog, registry); + const http: HttpAccessor | undefined = + outboundHosts.length > 0 || webScanner + ? createHttpAccessor({ + agentId, + outbound: outboundHosts, + webScanner, + extraHosts: auditConfig.extraHosts, + ...(auditConfig.auditMode + ? { auditMode: auditConfig.auditMode } + : {}), + }) + : undefined; + // Net accessor: raw-TCP egress for line protocols ctx.http cannot speak // (SMTP/IMAP/…). Present only when the manifest declares // `permissions.network.outbound_tcp` AND every referenced config field @@ -1158,6 +1166,7 @@ function memoryDeclared(agentId: string, catalog: PluginCatalog): boolean { function extractOutboundAllowlist( agentId: string, catalog: PluginCatalog, + configGet: (key: string) => unknown, ): string[] { const entry = catalog.get(agentId); if (!entry) return []; @@ -1168,7 +1177,30 @@ function extractOutboundAllowlist( const network = permissions?.['network'] as Record | undefined; const outbound = network?.['outbound']; if (!Array.isArray(outbound)) return []; - return outbound.filter((h): h is string => typeof h === 'string'); + + const out: string[] = []; + for (const item of outbound) { + if (typeof item !== 'string') continue; + const trimmed = item.trim(); + if (trimmed.startsWith('$config.')) { + // Operator-configured host(s): a `$config.` entry resolves to the + // value the operator saved at install. One field MAY hold several hosts + // (comma/whitespace/newline separated) so an operator can allow more than + // one attachment source without the plugin author enumerating them. An + // unset field contributes nothing (fail closed — no ctx.http). + const key = trimmed.slice('$config.'.length); + const resolved = configGet(key); + if (typeof resolved === 'string') { + for (const host of resolved.split(/[\s,]+/)) { + const h = host.trim(); + if (h.length > 0) out.push(h); + } + } + } else if (trimmed.length > 0) { + out.push(trimmed); + } + } + return out; } /**