Summary
With network.allowLocalBinding: true, a sandboxed process can bind, listen on and connect to
loopback using pure 127.0.0.1 or pure ::1. A connect to the IPv4-mapped form
::ffff:127.0.0.1 is refused with EPERM.
That form is what a dual-stack socket (AF_INET6 with IPV6_V6ONLY=0) produces when it connects to
an IPv4 literal. gRPC uses dual-stack sockets, so any gRPC client cannot reach a server in its own
process inside the sandbox, even though every underlying primitive is permitted.
Reproduction
allowLocalBinding: true, otherwise the shipped defaults.
import errno, socket
srv = socket.socket(socket.AF_INET6, socket.SOCK_STREAM)
srv.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 0) # dual-stack, as gRPC does
srv.bind(("::", 0)); srv.listen(4)
port = srv.getsockname()[1]
def t(label, fam, addr):
s = socket.socket(fam, socket.SOCK_STREAM); s.settimeout(5)
try:
s.connect((addr, port)); print(f"{label:<32} ok")
except OSError as e:
print(f"{label:<32} FAIL errno={e.errno} {errno.errorcode.get(e.errno)}")
finally:
s.close()
t("AF_INET -> 127.0.0.1", socket.AF_INET, "127.0.0.1")
t("AF_INET6 -> ::1", socket.AF_INET6, "::1")
t("AF_INET6 -> ::ffff:127.0.0.1", socket.AF_INET6, "::ffff:127.0.0.1")
Observed under srt --settings <policy> python3 probe.py:
AF_INET -> 127.0.0.1 ok
AF_INET6 -> ::1 ok
AF_INET6 -> ::ffff:127.0.0.1 FAIL errno=1 EPERM
Unsandboxed, all three succeed.
Real-world impact
milvus-lite (used by the memsearch tool) starts its gRPC server in-thread and connects to it over
an ephemeral loopback port. Inside the sandbox that connect fails:
FAILED_PRECONDITION: ipv4:127.0.0.1:56075: connect failed:
addr: ipv4:127.0.0.1:56075 error: Operation not permitted
The server binds successfully (add_insecure_port returns a non-zero port), so this is purely the
client's connect being refused.
What is not the cause
Each ruled out by measurement inside the same policy:
- Filesystem permissions on the database (granted; failure unchanged)
- Listening on loopback (bind, listen and accept all succeed)
- Connect shape: blocking, non-blocking, pre-bound local end,
SO_REUSEADDR (all succeed with AF_INET)
- Non-blocking connect completion via both
select and kqueue (both complete)
- Socket options
TCP_NODELAY, SO_KEEPALIVE, SO_REUSEPORT, SO_SNDBUF, SO_NOSIGPIPE (all permitted)
- The SOCKS proxy environment: fails identically with
grpc_proxy, GRPC_PROXY, http_proxy,
https_proxy and all_proxy cleared, and no_proxy already lists 127.0.0.1
- gRPC resolver choice:
GRPC_DNS_RESOLVER=ares and =native behave the same
- Policy keys
enableWeakerNetworkIsolation (bool), allowAllUnixSockets (bool) and
allowMachLookup (string[]) — all three tried, all fail
Suggested fix
Have the loopback rule generated for allowLocalBinding also match ::ffff:127.0.0.1 (and the
IPv4-mapped range generally). One change covers every dual-stack client rather than each tool needing
its own workaround.
There is no client-side switch available for gRPC's C core, unlike the JVM's
-Djava.net.preferIPv4Stack=true used for the same problem elsewhere — see grpc/grpc-java#10797 for
the equivalent on the bind side.
Environment
- macOS 26 (Darwin 25.4.0), Apple silicon
@anthropic-ai/sandbox-runtime 0.0.71
- Python 3.11,
grpcio as shipped with pymilvus / milvus-lite
Summary
With
network.allowLocalBinding: true, a sandboxed process can bind, listen on and connect toloopback using pure
127.0.0.1or pure::1. A connect to the IPv4-mapped form::ffff:127.0.0.1is refused withEPERM.That form is what a dual-stack socket (
AF_INET6withIPV6_V6ONLY=0) produces when it connects toan IPv4 literal. gRPC uses dual-stack sockets, so any gRPC client cannot reach a server in its own
process inside the sandbox, even though every underlying primitive is permitted.
Reproduction
allowLocalBinding: true, otherwise the shipped defaults.Observed under
srt --settings <policy> python3 probe.py:Unsandboxed, all three succeed.
Real-world impact
milvus-lite(used by thememsearchtool) starts its gRPC server in-thread and connects to it overan ephemeral loopback port. Inside the sandbox that connect fails:
The server binds successfully (
add_insecure_portreturns a non-zero port), so this is purely theclient's connect being refused.
What is not the cause
Each ruled out by measurement inside the same policy:
SO_REUSEADDR(all succeed withAF_INET)selectandkqueue(both complete)TCP_NODELAY,SO_KEEPALIVE,SO_REUSEPORT,SO_SNDBUF,SO_NOSIGPIPE(all permitted)grpc_proxy,GRPC_PROXY,http_proxy,https_proxyandall_proxycleared, andno_proxyalready lists127.0.0.1GRPC_DNS_RESOLVER=aresand=nativebehave the sameenableWeakerNetworkIsolation(bool),allowAllUnixSockets(bool) andallowMachLookup(string[]) — all three tried, all failSuggested fix
Have the loopback rule generated for
allowLocalBindingalso match::ffff:127.0.0.1(and theIPv4-mapped range generally). One change covers every dual-stack client rather than each tool needing
its own workaround.
There is no client-side switch available for gRPC's C core, unlike the JVM's
-Djava.net.preferIPv4Stack=trueused for the same problem elsewhere — see grpc/grpc-java#10797 forthe equivalent on the bind side.
Environment
@anthropic-ai/sandbox-runtime0.0.71grpcioas shipped withpymilvus/milvus-lite