feat(platform): plugin egress primitives — ctx.net (raw TCP) + $config.* in network.outbound#370
Open
MarvinVomberg wants to merge 2 commits into
Open
feat(platform): plugin egress primitives — ctx.net (raw TCP) + $config.* in network.outbound#370MarvinVomberg wants to merge 2 commits into
MarvinVomberg wants to merge 2 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Two additive, opt-in extensions to the plugin egress model:
ctx.net— a raw-TCP egress primitive for line protocolsctx.httpcannot speak (SMTP, IMAP, POP3, …), gated by a newpermissions.network.outbound_tcpmanifest block.$config.*resolution inpermissions.network.outboundso 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.outboundhosts 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:await import()— so a plugin could reach fornode:netdirectly, which is exactly the unsanctioned hole we want to close).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 })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:ctx.netrather than a half-open allow-list (fail closed).169.254.0.0/16(incl. the169.254.169.254cloud-metadata endpoint) and IPv6fe80::/10are refused — nothing legitimately runs a mail server there.tls: truereturns an implicitly-encrypted socket (SMTPS :465);tls: falsereturns a plain socket the caller may upgrade itself (STARTTLS :587).ctx.netis leftundefinedwhen no target resolves, so plugins guard withif (ctx.net)and tolerate an older core.$config.*innetwork.outboundpermissions.network.outboundentries 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 typecheckandeslintare clean across the changed files.Backward compatibility
Fully additive. New optional
readonly net?: NetAccessoronPluginContext; new manifest keys are ignored by the loader when absent; literalnetwork.outboundhosts 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.