Skip to content

feat(net): hold permissions.net for traffic the app layer cannot see - #187

Merged
RatulMaharaj merged 1 commit into
feat/176-drain-on-shutdownfrom
feat/171-subprocess-egress
Aug 1, 2026
Merged

feat(net): hold permissions.net for traffic the app layer cannot see#187
RatulMaharaj merged 1 commit into
feat/176-drain-on-shutdownfrom
feat/171-subprocess-egress

Conversation

@RatulMaharaj

Copy link
Copy Markdown
Member

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_request goes through the permission engine, so permissions.net holds for it. 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: 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. So permissions.net was 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 init now emits:

networks:
  my-bot-internal:
    internal: true    # no route off the host
  my-bot-egress:

The agent is on the internal network only. The proxy service is on both, running af egress over the same host set hermetic mode already computes - the agent's permissions.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 fetch routes through the proxy, otherwise the agent's model calls die the moment it goes on an internal network. I tested it before building anything:

proxy saw: ["CONNECT example.com:443 HTTP/1.1"]

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

  • Sidecar or in-container? Sidecar. In-container is simpler and weaker, because the subprocess shares the network namespace with the thing enforcing the policy. The whole point is a boundary a subprocess cannot walk around.
  • Terminate TLS? No. A hostname allowlist only needs the CONNECT line, and terminating would hand this process every credential the agent sends. The cost is that the check is per-host: a permitted host is reachable at any path.
  • Does this replace hermetic mode? No, it sits under it. An agent that spawns nothing still gets its net: list compiled into --allow-net and 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:

egress denied: "api.stripe.com" is not in this agent's permissions.net allowlist.
Add it to agent.yaml if the agent should reach it:

permissions:
  net: ["api.stripe.com"]

Testing

505 pass, deno task ok clean. Nine new, and the proxy ones run against real sockets rather than mocks:

  • host matching identical to the engine's, including the apex-is-not-a-subdomain rule
  • CONNECT to a denied host refused, with the fix in the body and the event recorded
  • an allowed host tunnelled end to end against a real upstream server
  • a plain-HTTP absolute-URI request to a denied host also refused
  • a relative-path request rejected as "not a forward proxy request"
  • malformed CONNECT authorities (no port, non-numeric port, out-of-range port) rejected without ever reaching the allowlist
  • Deno's fetch refused through the proxy
  • init: the generated compose puts the agent on the internal network, the proxy on both, and points the env vars at it

What 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:

  • The refusal is in the proxy's logs, not the agent's audit table. They are different containers. A channel between them does not exist and inventing one belonged in its own change.
  • SSH does not traverse a CONNECT proxy. run: [git] over git@github.com fails outright on the internal network. Documented, with the HTTPS-remote workaround.
  • DNS still resolves normally. The proxy checks the name in the request, so a permitted hostname that starts pointing somewhere else resolves wherever it points. codex's resolved_private_address_does_not_match_allowlisted_hostname is the prior art if we want to close that.
  • Existing compose files are unchanged. This only affects what af init generates from now on. Retrofitting an existing deployment means adding the two networks and the service by hand; the docs show the shape.

@RatulMaharaj
RatulMaharaj force-pushed the feat/171-subprocess-egress branch from da6fd15 to 7c8ed98 Compare August 1, 2026 18:34
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
RatulMaharaj force-pushed the feat/171-subprocess-egress branch from 7c8ed98 to be33123 Compare August 1, 2026 18:44
@RatulMaharaj
RatulMaharaj merged commit d9c2c5e into main Aug 1, 2026
2 checks passed
@RatulMaharaj
RatulMaharaj deleted the feat/171-subprocess-egress branch August 1, 2026 18:49
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