Skip to content

A policy DNS address stops taking new connections after the trusted resolver's TTL, one second when that TTL is 0 #4674

Open

Description

@Kevinbeltran123

User Story

In a personal project, I use OpenShell to let an agent build projects with cargo and Gradle. Sometimes a build fails on a host my policy allows and passes when I run it again; the supervisor's log shows transparent TCP mapping is expired. It looks like a network fault rather than a policy decision, and widening the policy does not help.

Problem Statement

Policy DNS answers a sandbox's lookup with a synthetic address and publishes a mapping from it to the resolved destination. The answer's TTL and the mapping's lifetime are both the trusted resolver's TTL, clamped to between 1 and 30 seconds (crates/openshell-supervisor-network/src/policy_dns/mod.rs: MIN_MAPPING_TTL, MAX_MAPPING_TTL, clamp_mapping_ttl). After that, a new TCP connection to the address is refused (transparent TCP mapping is expired) until something looks the name up again, which publishes the same address anew; a connection already open is not affected.

Clients keep a resolved address longer than that: libcurl's DNS cache defaults to 60 s (cargo and git use libcurl) and the JDK's address cache to 30 s. A new connection they open to the kept address is refused, on a host the policy allows. With a resolver that answers TTL 0 (the VM's resolver under Colima, for pypi.org and github.com, every time), every mapping lives one second.

Neither the address's lifetime nor this denial is in the docs: the denial reasons in docs/observability/logging.mdx do not list transparent_tcp_mapping_denied.

Impact / Why This Matters

Builds fail now and then on hosts the policy allows, with what looks like a network fault. In two cargo builds on 0.1.4, cargo's two new connections to index.crates.io, 5.4 and 5.6 s after its lookup, were refused (it retried and passed); on 0.1.3, four cargo builds in a row met the same denial and two of them failed, and a Gradle build failed resolving its test classpath when the JVM connected 14.5 s after the lookup.

The client gets no response and the denial names no policy, so an operator cannot tell it from a missing rule without the supervisor's log, and widening the policy does not help. The design (#2712) fails closed on stale synthetic addresses, which is right; a one-second floor makes ordinary client caching stale.

Acceptance Criteria

  • A client that opens a new connection to an address the sandbox's resolver handed out, within a common client cache lifetime (60 s, for instance) and while the policy still allows the name, is not refused, whatever TTL the trusted resolver gives.
  • The docs say how long a synthetic address takes new connections, and list transparent_tcp_mapping_denied among the denial reasons.

Reproduction Steps

Uses only an OpenShell sandbox and the curl in its image; no third-party installs. Any image with curl and bash works. pypi.org's own TTL is 86400 s.

  1. Save policy.yaml:

    version: 1
    filesystem_policy:
      include_workdir: true
      read_only: [/usr, /lib, /etc]
      read_write: [/tmp, /dev/null]
    landlock:
      compatibility: hard_requirement
    process:
      run_as_user: "1000"
      run_as_group: "1000"
    network_policies:
      probe:
        endpoints:
          - host: pypi.org
            port: 443
            protocol: rest
            enforcement: enforce
            rules:
              - allow:
                  method: GET
                  path: /simple/pip/
        binaries:
          - path: /usr/bin/curl
  2. openshell sandbox create --name a5 --from <image-with-curl> --policy policy.yaml

  3. In the sandbox (--resolve keeps the address, as a client's DNS cache does). The 31 s wait is past the 30 s ceiling, so this fails whatever TTL the trusted resolver gives:

    ip=$(curl -sS -o /dev/null -w '%{remote_ip}' https://pypi.org/simple/pip/)
    echo "address: $ip"
    sleep 31
    curl -sS -o /dev/null -w 'after 31 s: %{http_code}\n' --resolve "pypi.org:443:$ip" https://pypi.org/simple/pip/
    echo "curl exit: $?"
    curl -sS -o /dev/null -w 'new lookup: %{http_code} via %{remote_ip}\n' https://pypi.org/simple/pip/

    → address: 198.18.0.2, after 31 s: 000, curl exit: 7, new lookup: 200 via 198.18.0.2 (the same address, published again).

  4. With a trusted resolver that answers TTL 0 (Colima's VM resolver here), the same happens after sleep 2, and even for a second request made right after the first one, once the first transfer took over a second.

  5. Control: two transfers in one curl invocation reuse their connection (num_connects 1, then 0) and both get 200; only new connections are refused.

Suggested UX (if applicable)

Not applicable to configuration or CLI. Two possible directions, for maintainers to judge: a floor on the mapping's lifetime separate from the answer's TTL, or an expired mapping refreshed on connect while the policy still allows the name (a synthetic address is not reassigned within a sandbox's life, #2712).

Environment

  • OpenShell: v0.1.4 (gateway, supervisor and sandbox images 0.1.4; also seen on v0.1.3 and v0.1.2). main has the same bounds; the only change to policy_dns/ since the tag is a type refactor (feat(sandbox): publish and activate sandbox backend protocol #3898).
  • OS: macOS 26.6.2 (Darwin 25.6.0)
  • Deployment: local gateway, Docker driver; Docker 29.5.2 via Colima 0.10.3 (the supervisor's /etc/resolv.conf points at the VM's resolver, which answers TTL 0)

Logs

CONFIG:PUBLISHED [INFO] Policy DNS mapped pypi.org resolved=151.101.0.223,151.101.64.223,151.101.128.223,151.101.192.223 synthetic=198.18.0.2 ports=443
WARN openshell_supervisor_network::proxy: Denied staged transparent connection destination=198.18.0.2:443 error=transparent destination mapping is unavailable: transparent TCP mapping is expired
NET:OPEN [MED] DENIED /usr/bin/curl(0) -> 198.18.0.2:443 [reason:transparent_tcp_mapping_denied]
CONFIG:PUBLISHED [INFO] Policy DNS mapped pypi.org resolved=151.101.0.223,151.101.64.223,151.101.128.223,151.101.192.223 synthetic=198.18.0.2 ports=443

curl: (7) Failed to connect to pypi.org port 443 after 0 ms: Could not connect to server

Activity

  1. juancarlosm commented on Nov 6, 2026

    @juancarlosm

    Another reproduction, on Kubernetes with v0.1.7, where it affects every in-cluster
    destination.

    CoreDNS gives Service names a TTL of 5 s by default (the kubernetes plugin's ttl), so
    every mapping for a *.svc.cluster.local endpoint lives 5 s. Bun (opencode 1.4.4) caches
    DNS answers for 30 s regardless of TTL, so an agent's new connection 5-30 s after its lookup
    is refused:

    OCSF NET:OPEN [MED] DENIED /opt/opencode/.../opencode.exe(0) -> 198.18.0.2:8081 [reason:transparent_tcp_mapping_denied]
    OCSF CONFIG:PUBLISHED Policy DNS mapped agent-x.ns.svc.cluster.local resolved=10.5.6.193 synthetic=198.18.0.2 ports=8081 mapping_id=<new>
    OCSF NET:OPEN [INFO] ALLOWED ... -> agent-x.ns.svc.cluster.local:8081
    

    The client sees EACCES on connect. In one agent run, 3 of 38 tool calls failed this way.
    Our workaround is client-side (BUN_CONFIG_DNS_TIME_TO_LIVE_SECONDS=0); raising CoreDNS's TTL
    cluster-wide would work too but changes every pod's behaviour.

    +1 to refreshing an expired mapping on connect while the policy still allows the name: the
    synthetic address is never reassigned to another name, so the refresh does not weaken the
    fail-closed design.

  2. karlis-vagalis commented on Nov 7, 2026

    @karlis-vagalis

    This also affects a long-running agent sandbox on Kubernetes. Gateway, supervisor and sandbox runtime are all on 0.1.7.

    The workload maintains long-lived outbound TLS connections and makes ordinary HTTPS requests through an explicitly allowed in-cluster forward proxy. We repeatedly observe transparent_tcp_mapping_denied for both synthetic external-service addresses and the synthetic proxy address, despite the corresponding destinations remaining allowed. Earlier occurrences included the explicit transparent TCP mapping is expired diagnostic; the retained logs for the latest recurrence expose only the mapping-denial category.

    The sandbox remains Ready while application connections fail or repeatedly reconnect. Fresh requests can subsequently succeed without a policy change, and stopping/starting the sandbox has restored connectivity, but the failure recurs during continued operation. This affects interactive availability as well as individual requests; scheduled restarts are only a disruptive mitigation, not a fix.

    The Kubernetes service-TTL/client-cache mismatch described above is particularly relevant to the proxy path. Is there a supported per-sandbox mitigation that preserves fail-closed enforcement, or a planned fix to revalidate/refresh an expired mapping on connect while the destination is still authorized?

  3. leHirsch2000 commented on Nov 11, 2026

    @leHirsch2000

    Another reproduction, on macOS (Apple Silicon) with the Podman driver, OpenShell 0.1.3. There the TTL is always 0, so every mapping lives exactly MIN_MAPPING_TTL (1 s) and any client that caches DNS breaks.

    Why TTL 0 on Podman/macOS: the supervisor runs on the Podman machine's network and takes its trusted resolver from /etc/resolv.conf, which is gvproxy (192.168.127.1). gvproxy (containers/gvisor-tap-vsock, pkg/services/dns/dns.go) answers forwarded A/AAAA queries via Go's net.Resolver.LookupIPAddr and hard-codes Ttl: 0, including in current main. Neither gvproxy, Podman nor gateway.toml has a knob for this.

    Environment: macOS 26 arm64, Podman 6.1.4 (applehv, rootless), OpenShell gateway/supervisor/sandbox 0.1.3, Podman compute driver. I also diffed policy_dns and proxy up to v0.1.8: the expiry behaviour is unchanged.

    Repro (inside a sandbox whose policy allows pkg.example.com:443 for /usr/bin/curl):

    # Two transfers in one process; libcurl reuses its cached DNS answer (60 s) for the 2nd connection.
    curl -s -o /dev/null -o /dev/null -w '%{http_code} ' -H 'Connection: close' --http1.0 --rate 20/m \
      https://pkg.example.com/ https://pkg.example.com/; echo "exit $?"
    # -> 200 000 exit 7

    Sandbox log for the second connection:

    WARN openshell_supervisor_network::proxy Denied staged transparent connection destination=198.18.0.2:443 error=transparent destination mapping is unavailable: transparent TCP mapping is expired
    OCSF NET:OPEN [MED] DENIED /usr/bin/curl(0) -> 198.18.0.2:443 [reason:transparent_tcp_mapping_denied]
    

    A connect probe succeeds up to ~900 ms after the lookup and is refused from ~1000 ms.

    Clients we saw affected (all behave normally outside the sandbox):

    Client DNS cache Result
    npm 11 (@npmcli/agent/lib/dns.js) 5 min, not configurable via .npmrc npm install <new pkgs> without lockfile: 164 s and ~250 EACCES, vs 6 s with the cache disabled. EACCES is not in make-fetch-happen's retry list.
    libcurl (curl, git over HTTPS) 60 s 2nd connection in the same process refused
    JVM, default settings 30 s 2nd connection after 3 s refused; OK with -Dsun.net.inetaddr.ttl=0
    Node tls/net, uv (Rust/reqwest) none OK

    npm with an up-to-date lockfile mostly works, because it opens its connections within the first second and reuses them.

    Confirmation: a loop in the sandbox running getent hosts pkg.example.com every 0.5 s re-publishes the same synthetic address with a fresh expiry, and all failing cases above pass.

    Fix direction: +1 for a lifetime floor that is independent of the answer TTL. Policy-eligible mappings would stay valid for a fixed minimum while the policy generation is unchanged, so a policy change still revokes them immediately. The TTL returned to the client can stay as it is.

  4. Nikzz3 commented on Nov 11, 2026

    @Nikzz3

    +1. Im also facing this issue

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    state:triage-neededOpened without agent diagnostics and needs triage

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions