feat(net): hold permissions.net for traffic the app layer cannot see - #187
Merged
RatulMaharaj merged 1 commit intoAug 1, 2026
Merged
Conversation
RatulMaharaj
force-pushed
the
feat/171-subprocess-egress
branch
from
August 1, 2026 18:34
da6fd15 to
7c8ed98
Compare
http_request goes through the permission engine. A subprocess does not: gh, curl, a stdio MCP server, anything a permissions.run grant spawns opens its own sockets and the engine never sees them. The Deno sandbox cannot help, because an agent that spawns anything gets a broad --allow-net by design. That left the container, and a container with a default route reaches the whole internet. Adds a filtering forward proxy and the network split that makes it the only way out. af init now emits an internal: true network carrying the agent, and a proxy service on both that one and the bridge, running `af egress` over the same host set hermetic mode computes. The agent also gets HTTP_PROXY/HTTPS_PROXY/NO_PROXY. Deno's fetch honours them (verified, not assumed) and so do gh, curl and git over HTTPS, so the agent's own traffic and its subprocesses take the same path through the same allowlist. The env vars are the polite path; the network split is what holds. A binary that ignores them has nowhere to send packets. TLS is not terminated. A hostname allowlist only needs the CONNECT line, and terminating would hand this process every credential the agent sends. Both CONNECT and absolute-URI requests are checked, since CONNECT is not the only way through a forward proxy. A refused client gets a 403 whose body names the host and the exact permissions.net line to add, because the alternative is an operator staring at a connection error with no idea which allowlist refused it. Refs #171. Two acceptance-criteria gaps are documented rather than papered over: SSH does not traverse a CONNECT proxy, so git over ssh fails outright on the internal network, and the refusal lands in the proxy's logs rather than the agent's audit table, which needs a channel between containers that does not exist yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CPftW3F8JTuivRhFmoY1wC
RatulMaharaj
force-pushed
the
feat/171-subprocess-egress
branch
from
August 1, 2026 18:44
7c8ed98 to
be33123
Compare
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.
Stacked on #186. Last of the six.
Refs #171. Two of the acceptance criteria are met; one is not, and it is called out below rather than fudged.
The gap
http_requestgoes through the permission engine, sopermissions.netholds for it. A subprocess does not.gh,curl, a stdio MCP server, anything apermissions.rungrant spawns opens its own sockets and the engine never sees them.The Deno sandbox cannot help: an agent that spawns anything gets a broad
--allow-net, because the subprocess is outside the sandbox by design. That leaves the container, and a container with a default route reaches the whole internet. Sopermissions.netwas true for the agent's own HTTP and decorative for everything it spawned.What changed
A filtering forward proxy (
packages/core/net/egress_proxy.ts), and the network split that makes it the only way out.af initnow emits:The agent is on the internal network only. The proxy service is on both, running
af egressover the same host set hermetic mode already computes - the agent'spermissions.net, plus the model endpoint, trigger hosts and HTTP MCP servers.The agent also gets
HTTP_PROXY/HTTPS_PROXY/NO_PROXY. The env vars are the polite path; the network split is what holds. A binary that ignores them has nowhere to send packets.One thing I checked rather than assumed
The design only works if Deno's own
fetchroutes through the proxy, otherwise the agent's model calls die the moment it goes on an internal network. I tested it before building anything:It does, and there is a test in the suite pinning it (
Deno's own fetch is routed and refused like any other client). The agent's traffic and its subprocesses take the same path through the same allowlist, with no second code path.Decisions from the issue
net:list compiled into--allow-netand now also has the proxy. Three layers, as before.Both CONNECT and absolute-URI requests are checked, since CONNECT is not the only way through a forward proxy and checking one would have been a hole.
The denial
The ergonomics cagent got right, copied:
Testing
505 pass,
deno task okclean. Nine new, and the proxy ones run against real sockets rather than mocks:fetchrefused through the proxyinit: the generated compose puts the agent on the internal network, the proxy on both, and points the env vars at itWhat this does not do
Being explicit, because the issue's acceptance criteria says "the refusal appears in the audit trail" and this does not do that:
run: [git]overgit@github.comfails outright on the internal network. Documented, with the HTTPS-remote workaround.resolved_private_address_does_not_match_allowlisted_hostnameis the prior art if we want to close that.af initgenerates from now on. Retrofitting an existing deployment means adding the two networks and the service by hand; the docs show the shape.