Skip to content

feat(platform): plugin egress primitives — ctx.net (raw TCP) + $config.* in network.outbound#370

Open
MarvinVomberg wants to merge 2 commits into
byte5ai:mainfrom
MarvinVomberg:feat/plugin-tcp-egress
Open

feat(platform): plugin egress primitives — ctx.net (raw TCP) + $config.* in network.outbound#370
MarvinVomberg wants to merge 2 commits into
byte5ai:mainfrom
MarvinVomberg:feat/plugin-tcp-egress

Conversation

@MarvinVomberg

Copy link
Copy Markdown
Contributor

What

Two additive, opt-in extensions to the plugin egress model:

  1. ctx.net — a raw-TCP egress primitive for line protocols ctx.http cannot speak (SMTP, IMAP, POP3, …), gated by a new permissions.network.outbound_tcp manifest block.
  2. $config.* resolution in permissions.network.outbound so a plugin's HTTP allow-list can reference operator-entered config instead of hard-coded hostnames — symmetric with the new TCP path.

No behavioural change for existing plugins: both are inert unless a manifest opts in, and literal network.outbound hosts resolve exactly as before.

Why

Today a plugin's only sanctioned network reach is ctx.http — HTTP/HTTPS, allow-listed by static manifest hostname. That blocks two real needs:

  • Line protocols. A mail plugin (SMTP), an IMAP reader, etc. need a raw TCP/TLS socket. There was no permissioned way to open one, so such plugins were impossible without bypassing the egress model entirely (the surface is loaded in-process — await import() — so a plugin could reach for node:net directly, which is exactly the unsanctioned hole we want to close).
  • Operator-specific hosts. A generic plugin can't know its target host at authoring time — the operator enters it at install (the SMTP server, an attachment-source host, …). A static manifest allow-list can't express "the host the operator will configure."

Motivating use case: a provider-agnostic SMTP email tool (separate repo) so agents can send mail through any mail server — the counterpart to the Gmail-only Google Workspace integration. It needs (1) a raw socket to the SMTP host and (2) an optional, operator-chosen HTTP host to fetch URL attachments from.

How it works

ctx.net.connect({ host, port, tls })

permissions:
  network:
    outbound_tcp:
      - host: "$config.smtp_host"
        port: "$config.smtp_port"

The $config.<field> references resolve against the plugin's install config at runtime, so egress is pinned to exactly the host:port the operator entered. This solves the generic-plugin problem and sidesteps a broad SSRF surface: the target is operator-chosen, not agent- or author-chosen.

Enforcement mirrors httpAccessor:

  • Exact host + port match against the resolved allow-list (case-insensitive host, integer port). An unset/blank config ref drops the entry, so a half-filled install yields no ctx.net rather than a half-open allow-list (fail closed).
  • Resolve-then-pin: the hostname is resolved once and the resolved IP literal is dialed (original hostname kept as TLS SNI), closing the TOCTOU/DNS-rebinding gap between the string allow-check and the OS dial — the same protection the HTTP path's guarded dispatcher provides.
  • Metadata/link-local block: even though operator-chosen private relays are intentionally allowed (internal SMTP on RFC-1918/loopback is legitimate), 169.254.0.0/16 (incl. the 169.254.169.254 cloud-metadata endpoint) and IPv6 fe80::/10 are refused — nothing legitimately runs a mail server there.
  • Rate limit (60 connects/min) + connect timeout (15s) + a max-concurrent-open cap (8) to bound resource abuse.

tls: true returns an implicitly-encrypted socket (SMTPS :465); tls: false returns a plain socket the caller may upgrade itself (STARTTLS :587). ctx.net is left undefined when no target resolves, so plugins guard with if (ctx.net) and tolerate an older core.

$config.* in network.outbound

permissions.network.outbound entries of the form $config.<field> now resolve against install config; one field may hold several comma/whitespace-separated hosts. Unset config contributes nothing (HTTP egress stays absent, fail closed). The HTTP accessor is now constructed after the config accessor exists; literal-host plugins are byte-for-byte unaffected.

Security review

An adversarial review (threat model: prompt-injected agent + malicious plugin author) drove the resolve-then-pin IP handling, the metadata/link-local block, and the concurrency cap above. The exact-match-against-operator-config design means neither an agent nor a plugin author can widen egress beyond what the operator explicitly entered.

Tests

middleware/test/netAccessor.test.ts (10 tests): allow-list gating, port mismatch, invalid port, rate limit, max-concurrent, empty-list fail-closed, and the egress IP guard (link-local v4 + v6 blocked, private/loopback relay still reachable). npm run typecheck and eslint are clean across the changed files.

Backward compatibility

Fully additive. New optional readonly net?: NetAccessor on PluginContext; new manifest keys are ignored by the loader when absent; literal network.outbound hosts behave identically. No migration needed.

Note

The motivating SMTP plugin lives in a separate repo and is not part of this PR — this PR is only the reusable core primitives.

MarvinVomberg and others added 2 commits June 24, 2026 22:37
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) <noreply@anthropic.com>
The raw-TCP path (outbound_tcp) already dereferences `$config.<field>` so a
plugin can pin egress to an operator-entered host. Make the HTTP path
symmetric: `permissions.network.outbound` entries of the form
`$config.<field>` 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) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant