# Proxy and domain filtering ## Proxy The proxy is enabled by default. cplt directs proxy-aware tools through a localhost CONNECT proxy with `HTTP_PROXY`/`HTTPS_PROXY` and `NODE_USE_ENV_PROXY=1`. It listens on an OS-assigned ephemeral port. The proxy observes only activity that reaches it. What that buys you: - **Connection logging.** CONNECT decisions made by the proxy, in real time. - **Domain blocking.** Known exfiltration infrastructure: paste sites, webhook services, and so on. - **Domain allowlisting.** Connections restricted to known-safe domains. - **Audit log.** A persistent file log of proxy decisions, for post-session review. - **Port enforcement.** The proxy applies the same port restrictions as the sandbox (443 plus `--allow-port`). Turn it off for one run, or permanently: ```bash cplt --no-proxy -- -p "fix the tests" # this run only cplt config set proxy.enabled false # permanently ``` Add filtering (recommended): ```bash cplt config set proxy.blocked_domains "~/.config/cplt/blocked-domains.txt" # or restrict to known-safe domains only: cplt config set proxy.allowed_domains "~/.config/cplt/allowed-domains.txt" # optional audit log: cplt config set proxy.log_file "~/.config/cplt/proxy.log" ``` The allowlist file must exist, or cplt refuses to start. The agent's own hosts are always allowed on top of it, for agents that have such a list (see [Allowlist](#allowlist) for the ones that do not), but package registries such as npm and Maven Central are not: add `cplt config set proxy.default_allowlist true` to get them. See [Allowlist](#allowlist).
CLI flags reference (override for a single run) | Flag | What it does | | --------------------------- | ------------------------------------------------------------------------------------------------ | | `--with-proxy` | Explicitly enable the proxy (no-op when proxy is already on by default). | | `--no-proxy` | Disable the proxy for this run. | | `--proxy-forced` / `--no-proxy-forced` | Make the proxy mandatory and restrict direct routing where the platform supports it (opt-in, default off). Conflicts with `--no-proxy`. See [Proxy-forced mode](#proxy-forced-mode) and [network snapshot coverage](#network-snapshot). | | `--proxy-port ` | Which port the proxy listens on (default: 0, OS-assigned ephemeral). | | `--blocked-domains ` | Domains to block, one per line, on top of the built-in list. Re-read every ~5s, so you can edit it live. | | `--allowed-domains ` | Domains to allow. Setting it turns the allowlist on, and only listed domains plus the agent's own hosts can connect — an empty file therefore blocks everything else, and a missing file is a startup error. Re-read every ~5s. | | `--default-allowlist` | Enable the agent's built-in default allowlist for this run (opt-in, default off): restrict egress to the agent's fail-closed domain set merged with `--allowed-domains`. See [Default allowlist](#default-allowlist-fail-closed-networking). | | `--allow-all-domains` | Escape hatch: disable the default allowlist for this run and allow all domains (blocklist still applies). Also ignores any `--allowed-domains` file. | | `--proxy-log ` | Append a line per connection to this file for post-session audit. | | `--proxy-log-level ` | Stderr verbosity: `none`, `error`, `blocked`, or `all`. Defaults to `none` without an allowlist and `blocked` with one. An explicit `none` silences proxy stderr. The audit log file always records everything. | | `--allow-private-domain ` | Allow connections to this domain even if it resolves to a private/internal IP. Use for corporate intranet services such as internal MCP servers. Suffix matching: `intern.nav.no` covers all subdomains. Can be repeated. |
> **Domain matching.** Blocklist, allowlist, and `upstream_no_proxy` all use the same rules. `example.com` matches the exact domain and all subdomains (`sub.example.com`, `deep.sub.example.com`). Matching is case-insensitive, and leading and trailing dots are stripped. > > **Localhost traffic** (MCP servers, dev servers) bypasses the proxy via `NO_PROXY` and will not appear in the audit log. > > **Quiet mode** (`-q` / `sandbox.quiet = true`) suppresses the startup banner. Proxy stderr output is controlled separately by `--proxy-log-level` / `proxy.log_level`. To silence it with an allowlist, set the level to `none`. To stop writing the separate audit file, run `cplt config set proxy.log_file --unset` (and omit `--proxy-log`). ## Network snapshot The parent includes a network snapshot in the post-session audit. Both agent execution and `cplt exec` use the same capture lifecycle. Use `--no-quiet` to display the audit with `cplt exec`, which defaults to quiet. `--no-audit`, `--quiet`, and `sandbox.audit = false` suppress the audit. Collection uses the existing proxy observer. It does not enable `--observe-domains`, change domain policy, or require a proxy log file. | Reported value | Meaning | | --- | --- | | Recorded CONNECT attempts | CONNECT verdict records committed before the snapshot cutoff. | | Retained hosts | Distinct normalized host keys retained by the collector. | | Hosts with blocked activity | Retained hosts with at least one blocked verdict. | | Pending proxy clients | Admitted clients without a completed classification at the cutoff. Some may not have sent CONNECT requests. | | Unretained observations | Records counted in the total but omitted from the host breakdown because of storage limits or malformed user information in the authority. | An allowed verdict means that cplt permitted an attempt. DNS, transport, or upstream proxy failure can still prevent connection establishment. A blocked verdict is sticky per host. Allowed, blocked, and allowed observations for one host produce three records and one host with blocked activity. The report does not derive blocked-attempt or successful-connection totals from those aggregates. A CONNECT tunnel can carry many application requests. Collection starts with the proxy, before child execution. The proxy remains available during the existing bounded descendant wait. The parent then stops admission and allows up to 500 milliseconds for pending classification. This drain budget also covers admission shutdown. Snapshot acquisition uses a short in-memory lock; scheduling and lock acquisition are not hard real-time guarantees. The snapshot cutoff freezes one consistent state. Classification can settle while established tunnels remain open. The audit does not wait for tunnel closure or terminate descendants. The existing descendant wait can add two seconds. Git inspection and scheduling add time beyond these wait budgets. The collector retains at most 1,024 hosts, with at most 1,024 bytes per stored key. It continues counting records when host storage reaches a limit. Host counts become lower bounds when records lack host entries. Collection failures are reported with the retained evidence. Unavailable collection is not reported as zero activity. Zero recorded attempts do not prove absence of networking. **Network visibility remains partial.** Routing facts are reported separately: - Without forced mode, direct traffic can bypass the proxy. - On macOS, forced mode restricts direct remote routing through the local proxy. Local and delegated service activity remains outside the observation scope. - On Linux, Landlock cannot restrict an allowed TCP port to localhost. Older kernels may lack TCP restriction support entirely. - Under current forced mode, `--allow-port` extends proxy policy without opening that port for direct remote access. - Corporate upstream and `upstream_no_proxy` paths remain observable when their CONNECT requests enter cplt's proxy. The snapshot does not record payloads, application requests, kernel-denied attempts, DNS, UDP, inbound traffic, or activity after its cutoff. Proxy clients are not authenticated by process identity. The report cannot establish exclusive attribution to the sandboxed child. The explicit `--observe-domains` diagnostic uses the same snapshot. Its terminal list displays at most 20 entries and discloses omitted entries. When the audit is suppressed (`--no-audit`, `--quiet`, or `sandbox.audit = false`), `--observe-domains` still waits for descendants before the snapshot cutoff. cplt waits up to two seconds after the direct child exits, then applies the separate 500-millisecond classification drain. If it cannot establish that the process tree finished, it warns. A descendant that closes the inherited descriptor is not detected. Traffic after the cutoff is not in the host list or `--observe-domains-out`. `--observe-domains-out` writes retained allowed hosts, including when collection is incomplete. Leading `# incomplete:` comments disclose collection failures, pending clients, and omitted observations. Invalid host entries, including escaped control characters, are omitted and counted in a comment. The allowlist parser ignores these comments. The parent opens the output destination before the session without following a destination symlink, then writes through that pinned handle. Replacing the path during the session cannot redirect the parent write. ## Proxy-forced mode By default the proxy is advisory at the kernel level. Outbound TCP to `*:443` is allowed by the sandbox, and traffic goes through the proxy only because cplt sets `HTTP_PROXY`/`HTTPS_PROXY`/`NODE_USE_ENV_PROXY=1`. A tool that opens a TCP socket directly, or an agent that runs `env -u HTTPS_PROXY -u HTTP_PROXY …`, reaches the internet on `:443` without passing through the proxy, so no domain filtering applies to it. UDP is wider still: the CONNECT proxy carries TCP only, and on Linux nothing restricts UDP outside proxy-forced, so DNS tunnelling, QUIC/HTTP-3 and plain `sendto()` exfiltration leave without appearing in any log. On macOS the remote allows are TCP-only ([#815](https://github.com/navikt/cplt/issues/815)), so UDP to remote hosts is denied and HTTP/3 clients fall back to TCP. Proxy-forced mode (`#53`) closes that bypass. It is opt-in and off by default; making it the default is tracked in [#71](https://github.com/navikt/cplt/issues/71). Enable it per run or in config: ```bash cplt --proxy-forced -- -p "fix the tests" ``` ```bash cplt config set proxy.forced true ``` ```toml [proxy] forced = true ``` **What it changes:** - **The proxy becomes mandatory.** It is forced on regardless of other proxy defaults. - **Kernel TCP egress is restricted to the proxy port.** Instead of allowing outbound `*:443`, the sandbox allows outbound only to the proxy's listening port, plus any configured localhost ports. Direct `:443` connections that skip the proxy are blocked in the kernel, so raw sockets and `env`-unset attempts can no longer reach the network that way. Traffic that does get out goes through the proxy, which applies domain allow/block filtering as usual. How complete this is depends on the platform. On Linux, Landlock restricts TCP only, so a seccomp rule in this mode permits just `SOCK_STREAM` with protocol 0 or `IPPROTO_TCP` for `AF_INET`/`AF_INET6`, closing UDP, raw, SCTP and DCCP; outside proxy-forced they stay unrestricted. **Platform asymmetry.** Enforcement is not equal on the two platforms: - **macOS (Seatbelt):** the profile pins egress to `localhost:`. There is no direct-network path for connections. DNS lookups through the system resolver (mDNSResponder) still leave the machine outside the proxy. - **Linux (Landlock):** Landlock is port-based and cannot pin to localhost. Proxy-forced drops the `:443` rule and allows only the proxy port, which blocks direct `:443` to any host, but a narrow `evil.com:` channel remains reachable if a remote host happens to answer on that exact port. Landlock itself gates **TCP only**, since its UDP support lands at ABI v10 and cplt handles TCP connect alone, but in this mode the seccomp rule above closes UDP, raw, SCTP and DCCP on any kernel, so the residual is the proxy-port channel rather than open UDP. `proxy.netns` closes that residual, see [Network namespace](#network-namespace-linux-proxynetns). Without it, treat Linux proxy-forced as "no direct TCP `:443` bypass and no non-TCP egress" rather than "no egress except the proxy". ### Network namespace (Linux, `proxy.netns`) Opt-in, Linux with Bubblewrap only ([#114](https://github.com/navikt/cplt/issues/114)): ```bash cplt config set proxy.netns true ``` The agent then runs in its own network namespace that holds only loopback. `HTTPS_PROXY` stays `http://127.0.0.1:`: a relay inside the namespace listens there and passes each connection to the proxy through a unix socket in the session's scratch dir. Nothing else has a route, so the kernel blocks direct TCP to any host and port, the host's loopback services, UDP and DNS, on top of what proxy-forced does. It works with or without `proxy.forced`. - **DNS goes through the proxy.** The proxy resolves the host name in each `CONNECT` request, with its SSRF guard. A tool that resolves names itself before connecting, or that ignores `HTTPS_PROXY`, has no network. - **Fails closed.** If Bubblewrap is missing or cannot create the namespace, the proxy is off, or `sandbox.scratch_dir` is off, cplt refuses to launch and says what to fix. It never runs on the shared network instead. - **Host loopback is out of reach.** `allow.localhost` (`--allow-localhost`) and `allow_localhost_any` have no effect: the agent's `127.0.0.1` is its own namespace. cplt warns when they are set. Turn `proxy.netns` off for a run that needs a host service. - **Needs the proxy.** With the proxy off there is no way out, so cplt refuses instead of starting an agent with no network. - **VM sockets are not covered.** `AF_VSOCK` is not a network namespace object. Inside a VM guest, the agent can still reach the hypervisor host over vsock. - **IPv4 only.** The relay listens on `127.0.0.1`; `[::1]` is not bridged. - **macOS:** no effect. Seatbelt already pins egress to `localhost:`. **Fail-closed behavior:** - If the mandatory proxy cannot bind or start, cplt refuses to launch the agent. It never falls back to open networking. - `--proxy-forced` conflicts with `--no-proxy` and with `proxy.enabled = false`. cplt errors out with a clear message rather than silently picking a side. **Interaction with allowed-domains.** Proxy-forced only changes kernel egress, meaning which port the sandbox permits. It does not replace domain filtering, because the proxy still enforces `allowed_domains` and `blocked_domains` on the traffic it carries. Use proxy-forced together with an allowlist to get "only these domains, and no way around the proxy". The per-agent default allowlist that pairs with this mode ships today, see [Default allowlist](#default-allowlist-fail-closed-networking), and `--preset strict` turns both on at once. **Raw-TCP tradeoff, by design.** Tools that are not proxy-aware, whether they ignore `HTTP_PROXY` or need a non-CONNECT or non-HTTPS protocol, lose direct network access under proxy-forced. That is the point of the mode: the only sanctioned path off the machine is the CONNECT proxy. **Proxy-forced constrains destinations, not protocols.** The proxy checks the host and port of a CONNECT request, then relays bytes without looking at them. A tool that tunnels through the proxy on its own, such as ssh with a CONNECT-speaking `ProxyCommand`, can carry any protocol to an allowed host. Allowlist entries match subdomains, so `github.com` also allows `ssh.github.com:443`. See [SSH over port 443](known-impacts.md#ssh-over-port-443). `--allow-port` is not a way around that. Outside proxy-forced it opens a direct kernel egress channel to any remote host on that port, unfiltered and absent from the proxy log; under proxy-forced that channel is not opened at all ([#297](https://github.com/navikt/cplt/issues/297)). The port is still added to the proxy's allowed-port policy, so `curl`, `npm` and anything else that honours `HTTPS_PROXY` reaches `remote:` through a CONNECT tunnel, logged and domain-filtered. Only a raw socket loses the path. cplt warns at startup when `allow.ports` is set under proxy-forced, so the narrowing is never silent. If a tool genuinely needs its own socket to a remote host, run it without proxy-forced. `allow_localhost_any` is ignored in this mode, with a startup warning. Named localhost ports (`--allow-localhost `) still work. See [Policy presets](configuration.md#policy-presets). ## Domain filtering There are **three** domain lists, and they answer different questions. Reading them as three flavours of the same thing is the usual source of confusion, because only two of them are about permission at all. | List | The question it answers | Default | Reload | | --- | --- | --- | --- | | **Blocklist** (`--blocked-domains`) | "Never connect here." Exfiltration sinks: paste sites, webhook capture, tunnels. | A curated list is built into cplt and always applies | Live, ~5s | | **Allowlist** (`--allowed-domains`, `--default-allowlist`) | "Connect *only* here." Everything not listed is refused. | Off | Live, ~5s | | **Private-domain waiver** (`proxy.allow_private_domains`) | "This name is a trusted internal service." Not about permission: it is the DNS-rebinding guard. | Empty | Live, ~5s | All three re-read their backing file on the same ~5 second TTL, so editing a list adds **and revokes** entries in a running session. One thing does not reload: whether the allowlist is *enforced at all* is decided at startup, so switching allowlisting on or off needs a new session even though its contents do not. Entries that came from the command line, or from a trust-approved `[propose.proxy]` block, are read once and survive every refresh. The third is the one people do not expect. cplt refuses any host that resolves to a **private IP**, whatever the lists say, because a public name pointing at `10.x` is how an agent reaches your internal network. An intranet service is therefore blocked with `403 ... Private target blocked by cplt` even with no allowlist in force and nothing on the blocklist — the fix is to name it here, not to touch the other two: ```bash cplt config set proxy.allow_private_domains dev-nais.cloud.nais.io ``` The blocklist and the allowlist can be used together; the allowlist is checked first, then the blocklist. ### How a domain entry matches **There is no wildcard syntax, and none is needed.** An entry matches the host itself and every subdomain under it, at any depth: | Entry | Matches | Does not match | | --- | --- | --- | | `cloud.nais.io` | `cloud.nais.io`, `foo.cloud.nais.io`, `a.b.cloud.nais.io` | `evilcloud.nais.io` | So write `cloud.nais.io`, not `*.cloud.nais.io`. A `*` is compared literally and matches nothing — and in an allowlist, where everything not listed is refused, that does not merely fail to help: it leaves the hosts you meant to permit blocked, with an entry on screen that looks right. cplt warns at startup about a `*` in a domain **file** (`--allowed-domains`, `--blocked-domains`, a subscription cache) rather than letting the session fail closed silently. Lists set as config arrays, such as `proxy.allow_private_domains`, are not checked yet. The same matching applies to the blocklist, `proxy.allow_private_domains` and `proxy.upstream_no_proxy`. ### Blocklist Block domains commonly used for data exfiltration. cplt has a built-in blocklist, built from real attack infrastructure observed in 2025 and 2026 supply chain incidents. It covers webhook capture services, paste sites, file sharing, tunneling services, IP recon endpoints and a few telemetry hosts. See [`blocked-domains.txt`](../blocked-domains.txt) for the full list with sources. The list is compiled into the binary, so it applies on every install (Homebrew, apt, a release tarball, a source build) whenever the proxy is running. It is checked after the allowlist, so a host on both lists is blocked. There is no setting to turn it off and no per-host exemption. The blocklist is enforced by the proxy, so it only covers traffic that goes through the proxy. Without [proxy-forced mode](#proxy-forced-mode) (`proxy.forced`, `--proxy-forced` or `--preset strict`), outbound TCP to `*:443` is open at the kernel level, and a client that ignores `HTTPS_PROXY` reaches a blocked host directly on 443. `--no-proxy` turns the proxy, and with it the blocklist, off entirely. `proxy.blocked_domains` (or `--blocked-domains`) adds your own file on top of the built-in list. It never replaces it. The file is re-read every ~5 seconds, so edits take effect mid-session. ### Subscribing to blocklists The threat landscape moves faster than cplt releases. A blocklist subscription keeps a local cache fresh from a maintained upstream list (issue #144, Phase 1). Cached subscription domains are UNIONed into the effective blocklist alongside the built-in list and your local `blocked_domains` file, so they only ever add blocks. Subscriptions are global-only (`~/.config/cplt/config.toml`). A repo `.cplt.toml` cannot add one, so a malicious repository can never point cplt at an attacker-controlled list. Configure them under `[proxy.subscriptions]`: ```toml [proxy.subscriptions] refresh = "manual" # "manual" (default), "daily", or "weekly" blocklists = [ # The cplt-maintained default list (opt-in, add it yourself): "https://raw.githubusercontent.com/navikt/cplt/main/blocked-domains.txt", # Pin a sha256 to reject tampered downloads (recommended): { url = "https://example.com/blocklist.txt", sha256 = "<64-hex-digest>" }, ] ``` Fetch and cache all configured lists explicitly: ```bash cplt update-lists ``` `cplt update-lists` reports a per-list result: how many domains were fetched, whether a pinned `sha256` verified, whether the last-good cache was kept on a fetch failure, or whether verification **FAILED** (tamper). Caches live under `~/.config/cplt/subscriptions/`, outside the agent's sandbox-writable paths, so a running agent cannot poison them. **Refresh.** With `refresh = "daily"` or `"weekly"`, cplt lazily re-fetches a stale cache before a run, bounded by a short timeout so it never hangs the run. With the default `"manual"`, only `cplt update-lists` refreshes. A cache that has never been fetched or is very old gets a one-line warning. **Tighten-only, fail-open semantics.** Blocklists can only add blocks, so the worst case of a bad, stale, or unreachable list is that a domain simply is not blocked, which is no worse than today. On a fetch failure cplt keeps and uses the last-good cache, or treats the list as empty if there is none, and never blocks the run on the network. When a subscription pins a `sha256`, a hash mismatch rejects the downloaded copy, keeps the last-good cache, and warns loudly about possible tampering. Pinning is encouraged rather than required, because an unverified blocklist cannot open an exfiltration channel. > **Allowlist subscriptions are a separate future feature.** An allowlist subscription would define what is permitted and is therefore cplt's security boundary. A tampered or MITM'd allowlist opens an exfiltration channel for every subscriber. That tier must be fail-closed and verification-required, and is deliberately not part of Phase 1. Only blocklist subscriptions exist today. ### Private-domain waiver Separate from both lists above, and the one most likely to be the cause when an internal service is refused. cplt blocks any hostname that resolves to a private or otherwise reserved address, because a public DNS name that resolves inward is the standard DNS-rebinding path into a corporate network. The set is wider than RFC 1918 — see `is_private_ip` for the authority: - IPv4: loopback `127/8`, private `10/8` `172.16/12` `192.168/16`, link-local `169.254/16` (which covers the cloud metadata endpoint), CGNAT `100.64/10` (Tailscale, VPN), benchmarking `198.18/15`, reserved `240/4`, protocol assignment `192.0.0/24`, unspecified `0.0.0.0`, broadcast `255.255.255.255` - IPv6: loopback `::1`, unspecified `::`, ULA `fc00::/7`, link-local `fe80::/10`, and v4-mapped forms of the above `proxy.allow_private_domains` waives that check for names you trust. Suffix matching, like every other list: `nav.cloud.nais.io` covers every subdomain, but does **not** cover `dev-nais.cloud.nais.io`, which is a different domain. ```bash cplt config set proxy.allow_private_domains intern.nav.no cplt --allow-private-domain intern.nav.no # this run only ``` An IP literal cannot be waived — give the host a name. A repository may propose entries in `[propose.proxy]`, which is the only proxy key `.cplt.toml` can propose, and they apply only after `cplt trust accept`. ### Where the list files may live A list file is re-read every few seconds, which is what makes an edit take effect without restarting the session. That is the right design for a person editing an allowlist and the wrong one for a file the sandboxed agent can reach — so cplt **refuses to launch** when `proxy.allowed_domains` or `proxy.blocked_domains` names a path inside the project directory or any `allow.write` grant, naming both the file and the tree: ``` [cplt] proxy.allowed_domains names allow.txt, which is inside ~/src/myproject — a tree this session can write. ``` An allowlist the agent can edit is not an allowlist. Keep the file somewhere the session cannot write; `~/.config/cplt/` is the obvious place, and grants inside cplt's own state directory are refused for the same reason. ### Adding hosts with `allow.domains` The allowlist file is the right shape for a long, shared or subscription-fed list. For the handful of hosts one person needs, `allow.domains` is a config list like every other: ```bash cplt config set allow.domains cloud.nais.io cplt config set allow.domains cloud.nais.io --unset ``` **It widens an allowlist that is in force, and never turns one on.** Every other `allow.*` key adds a permission without taking one away — `allow.read` does not mean "only read this" — so this one does not get to be the exception that switches a session to fail-closed the first time you add a host. With no allowlist active the entries sit there doing nothing, and `cplt config show` says so rather than letting them look effective. A `*` matches no host, because matching is exact host plus subdomains. Where cplt can catch that for you it does: `cplt config set` refuses it with the working form, and a `.cplt.toml` proposing one fails validation. A wildcard in an allowlist **file** is only warned about at launch, since nothing sees it until the file is read. A repository may propose entries in `[propose.allow] domains`, inert until `cplt trust accept` on each machine. An approved entry can only let an already filtering session reach one more host; it cannot open a session that was not filtering. ### Allowlist Restrict connections to specific domains. When the allowlist is set, the proxy blocks everything not in it, except the running agent's own hosts: the infrastructure part of its [default allowlist](#default-allowlist-fail-closed-networking) (for Copilot, `github.com`, `githubcopilot.com` and the rest of that list). Those are always merged into an active allowlist, so an allowlist cannot cut such an agent off from its own backend and break login. The shared package registries are not added unless `proxy.default_allowlist` is on. Not every agent has such a list. Goose, Pi and Shell have none: Goose is provider-agnostic and Pi's endpoints are not yet documented, so under an allowlist the model provider Goose or Pi uses must be in your file, or the agent cannot reach it. Shell is not an AI agent and has no backend of its own. OpenCode's own hosts are `opencode.ai` and `models.dev`. cplt adds the GitHub Copilot hosts only when OpenCode's `auth.json` has a `github-copilot` entry that is not Copilot Enterprise (see [below](#default-allowlist-fail-closed-networking)); for any other provider, list its hosts yourself. So the file only needs the hosts your project uses beyond the agent itself, for example: ``` registry.npmjs.org internal-artifacts.example.com ``` To keep the agent away from one of its own hosts anyway, put it on the blocklist, which is checked after the allowlist and wins. `cplt doctor` checks the agent's hosts against your policy, plus the package registries when an allowlist is active, without going to the network. One warning names up to three blocked hosts with their status (`BLOCKED` for a blocklist entry, `BLOCKED-ALLOWLIST` for a registry missing from a plain `allowed_domains`), counts the rest ("9 more"), and gives one fix per cause. > **Setting `allowed_domains` is what turns the allowlist on, not the file's contents.** The two ways to say "no allowlist" are not the same thing: > > - **No `allowed_domains` key** (and no `--allowed-domains`, no `default_allowlist`): no allowlist, every domain is allowed. This is the default. > - **`allowed_domains` pointing at an empty or all-comment file**: an allowlist with nothing on it, so every domain except the agent's own hosts is blocked. > > A configured `allowed_domains` file that does not exist is a startup error rather than a silent fallback to allow-all. Use `--allow-all-domains` to allow everything for a single run without editing config. > **Note:** Both the allowlist and blocklist are re-read from disk every ~5 seconds (TTL-cached), so you can edit them live mid-session and changes take effect within seconds without restarting cplt. If a file becomes unreadable or is deleted at runtime, the last-known-good list is kept (fail-safe); if it becomes *empty*, that is a real edit and the allowlist tightens to blocking everything but the agent's own hosts. At startup, an unreadable or missing allowlist makes cplt exit with an error (fail-closed). > > The `allow_private_domains` list in `config.toml` is also re-read every ~5 seconds. Domains from other sources, meaning `--allow-private-domain` CLI flags and trust-approved `[propose.proxy] allow_private_domains` entries from a repo `.cplt.toml`, are preserved for the whole session regardless of config changes. ### Default allowlist (fail-closed networking) By default the proxy is allow-all. It logs connections and blocks known-bad domains, but any HTTPS endpoint on the internet is reachable. Because port 443 is open at the kernel level, a compromised agent or a malicious dependency could exfiltrate source code to an arbitrary host. Network control is the only defense-in-depth layer that stops exfiltration after an agent is compromised (issue #52). Each agent ships with a built-in default allowlist, the set of domains it legitimately needs, on top of a shared package-registry base. For Copilot that is the GitHub Copilot infrastructure plus common package registries: ``` # GitHub Copilot infrastructure githubcopilot.com # covers *.githubcopilot.com api.github.com github.com copilot-proxy.githubusercontent.com actions.githubusercontent.com # covers *.actions.githubusercontent.com default.exp2.cds.s9ch.io # Package registries (shared by all agents) registry.npmjs.org registry.yarnpkg.com repo.maven.apache.org plugins.gradle.org crates.io static.crates.io pypi.org files.pythonhosted.org plugins-artifacts.gradle.org # where plugin-portal jars redirect services.gradle.org # Gradle wrapper distribution metadata packages.confluent.io # Kafka clients, Avro serdes jitpack.io # Maven artifacts built from git tags npm.pkg.github.com # GitHub Packages (npm) pkg-npm.githubusercontent.com # where its tarballs redirect maven.pkg.github.com # GitHub Packages (Maven) github-registry-files.githubusercontent.com # where its jars redirect proxy.golang.org sum.golang.org # Go modules and checksum database release-assets.githubusercontent.com # GitHub release asset downloads codeload.github.com # GitHub archive/tarball downloads raw.githubusercontent.com # Raw repository files nodejs.org # Node.js releases and distributions ghcr.io # GitHub Container Registry (OCI) pkg-containers.githubusercontent.com # GHCR layer/blob storage maven.google.com dl.google.com # Google Maven repository (Android) ``` The base covers `services.gradle.org` and `release-assets.githubusercontent.com`. For Copilot, the wrapper download succeeds out-of-the-box because `github.com` is on Copilot's infrastructure list. For non-Copilot agents, the intermediate redirect through `github.com` is still blocked by default (`github.com` is too broad to grant every agent by default); if `./gradlew` has to fetch a distribution inside the sandbox with those agents, add `github.com` to `allowed_domains`, or run the wrapper once outside cplt so the distribution is already in `~/.gradle/wrapper/dists`. `proxy.golang.org` serves most module zips itself, but some redirect to `storage.googleapis.com` ([golang/go#52908](https://github.com/golang/go/issues/52908)). That host is far too broad to grant by default. If `go mod download` fails on such a redirect, add `storage.googleapis.com` to `allowed_domains`, or run `go mod download` once outside cplt so the module cache is already filled. Each agent adds its own endpoints on top of that shared registry base: - **Copilot:** GitHub Copilot infrastructure, above. - **Antigravity:** Google Gemini API, Code Assist backend, and Google OAuth, plus `antigravity.google`. - **Claude:** the Anthropic API, sign-in (`claude.ai` and `claude.com`) and the claude.ai MCP connector proxy (`mcp-proxy.anthropic.com`). Verified 2026-09-04 against Anthropic's published network-access requirements and the hosts in the Claude Code 2.1.260 binary. `claude.com` is listed bare so its subdomain match also covers `platform.claude.com`, which OAuth token refresh needs for both Console and claude.ai accounts, and `code.claude.com`, the documentation host. Deliberately excluded from Claude's own infra: `storage.googleapis.com` (plugin metadata, too broad a host to grant by default), `formulae.brew.sh` (Homebrew update check), the Datadog telemetry intakes and `*.claudeusercontent.com` (Claude in Chrome and artifacts); `raw.githubusercontent.com` is now covered by the shared registry base. Add any of the excluded hosts via `allowed_domains` if you want them. Two inherited entries, `console.anthropic.com` and `statsig.anthropic.com`, are kept but could not be corroborated against either source — the current docs attribute Console authentication to `platform.claude.com` and feature-flag fetches to `api.anthropic.com`, and the binary carries neither host — so they are candidates for deletion once a run is observed. - **OpenCode:** only OpenCode's own infra (`opencode.ai`, `models.dev`). OpenCode is provider-agnostic, so you must add your chosen model provider's domain via `allowed_domains` (`api.anthropic.com`, `api.openai.com`, or `generativelanguage.googleapis.com`, for example) before its model traffic is permitted. One provider is detected for you: if OpenCode's credential store (`$XDG_DATA_HOME/opencode/auth.json`, default `~/.local/share/opencode/auth.json`) has a `github-copilot` entry from `/connect`, the Copilot hosts listed above are added too, under `default_allowlist` and under a plain `allowed_domains` alike. cplt reads that file once, before the sandbox starts; a project `opencode.json` is never consulted, so a repository cannot widen egress by naming a provider. GitHub Copilot Enterprise (an entry with `enterpriseUrl`) runs on your organisation's own domain, which cplt cannot know, so add those hosts via `allowed_domains`. - **Pi:** no infrastructure defaults yet. Add its endpoints via `allowed_domains` when enabling the allowlist. Contributions welcome. - **goose:** registry base only, and that is an observed result rather than a gap. A session captured with `--observe-domains` (goose 1.48.0, 2026-09-03) contacted no goose-owned host at all — only the configured model provider. goose is provider-agnostic, so add your provider's domain via `allowed_domains` the same way you would for OpenCode. Two hosts were seen but deliberately left out because neither is on a default path: `us.i.posthog.com` (telemetry, opt-in; adding it to `allowed_domains` does not help, because `posthog.com` is on the built-in blocklist, which is checked after the allowlist) and `github.com` (only reached by `goose update`, which is a self-update inside the sandbox). - **DeepSeek Harness:** the DeepSeek API, `deepseek.com`. Its shipped `dsh-llm-deepseek` adapter defaults to `https://api.deepseek.com`, so the bare domain covers the API host. A deployment that points `DEEPSEEK_BASE_URL` at a gateway must add that gateway's domain via `allowed_domains`. - **Shell:** registry base only, since it is not an AI agent. These infra lists are best-effort defaults for an opt-in feature. Anything missing shows up as `BLOCKED-ALLOWLIST`, so you can add it. `cplt config hosts --agent ` prints both lists for an agent, as the proxy would use them on this machine. Tools that build cplt config should read `--json` rather than keep a copy: ```bash cplt config hosts --agent copilot --json | jq .agent_hosts ``` The output is one JSON object with three fields: - `agent_hosts`: the agent's own hosts, always merged into an active allowlist. - `default_allowlist`: `agent_hosts` plus the package registries, the list `proxy.default_allowlist` turns on. - `version`: `1`. It goes up only if a field is removed or changes meaning; new fields may be added without a bump. An older cplt without the subcommand exits non-zero. Turn the allowlist on to make the proxy fail-closed, so only these domains and your own additions are allowed: ```bash cplt --default-allowlist -- -p "fix the tests" # for one run cplt config set proxy.default_allowlist true # permanently ``` - **Opt-in and off by default.** Enabling it changes no other behaviour, and the global default stays allow-all. Making it the default is tracked in issue #71. - **Effective allowlist = agent defaults ⊕ your `allowed_domains`.** When on, the agent's built-in list is merged with any file or config `allowed_domains`, so you add project registries or internal hosts without re-listing the base set. On startup cplt prints something like `Domain policy: 15 domains allowed (agent defaults + 1 configured)`. With it off and only `allowed_domains` set, the agent's own hosts are still merged in, but the package registries are not. - **Blocked attempts are visible.** Denied connections are logged as `BLOCKED-ALLOWLIST`, visible through `--proxy-log` or `--proxy-log-level blocked`, so you can see what to add. - **Escape hatch.** `--allow-all-domains` disables the allowlist for a single run and ignores any `--allowed-domains` file, putting you back to allow-all for debugging. It overrides both `--default-allowlist` and `proxy.default_allowlist`. - **Composes with the other proxy features.** The proxy enforces the domain allowlist regardless of `proxy.forced` (kernel egress restriction) or `proxy.upstream` (corporate-proxy forwarding). A no-proxy or upstream target still passes the same allowlist check, because the allowlist governs which domains, orthogonal to how they are routed. ### Verifying and generating an agent's allowlist Per-agent default allowlists should be observed empirically, not guessed. `--observe-domains` runs the session with the proxy in allow-all mode, captures bounded CONNECT evidence, and prints retained hosts for allowlist review. ```bash # Capture what the agent contacts while doing a representative task. cplt --agent copilot --observe-domains -- -p "add tests for the parser and run them" ``` `--observe-domains`: - **Forces the proxy on and in allow-all mode for this run.** It overrides `--preset strict`, `--default-allowlist`, `proxy.default_allowlist`, and any configured `allowed_domains`, while blocklists, port policy, and private-address restrictions continue to apply. A host on the built-in blocklist, your blocklist or a subscription is still blocked, so it never appears in the observed allowlist. cplt prints a warning that this run does not enforce an allowlist. Do not treat an observe run as a protected session. - **Counts committed CONNECT records** regardless of `--proxy-log` and `--proxy-log-level`. It displays up to 20 sorted retained hosts on stderr, even under `--quiet`: ``` [cplt] observe-domains: 14 proxy-observed retained CONNECT hosts [cplt] observe-domains: Collection: available. [cplt] observe-domains: Classification: settled. [cplt] observe-domains: network visibility: partial. # retained host evidence for review before updating allowed_domains: # bare hosts, exact-or-subdomain match; collapse subdomains to a parent by hand # e.g. api.githubcopilot.com + proxy.githubcopilot.com -> githubcopilot.com api.github.com api.githubcopilot.com github.com registry.npmjs.org ... ``` - **`--observe-domains-out `** writes retained allowed hosts to `FILE`. Incomplete snapshots include leading `# incomplete:` comments. Invalid host entries are omitted and disclosed. See [Network snapshot](#network-snapshot). - **Works for `cplt exec` too:** `cplt --agent claude --observe-domains exec -- npm test`. To make it representative, exercise the workflows you care about in one session: resume a chat, build, run tests, install a dependency. The emitted hosts are the retained proxy evidence, subject to the stated limits. Subdomains are not auto-collapsed, since that needs a public-suffix heuristic that risks over-broadening. Because the matcher is exact-or-subdomain, fold related subdomains to a parent by hand, turning `api.githubcopilot.com` and `proxy.githubcopilot.com` into `githubcopilot.com`. **Updating the allowlist.** Paste the observed hosts into either: - your own `allowed_domains` file or `proxy.allowed_domains` config, for one project, or - an agent's built-in defaults in [`src/agent.rs`](../src/agent.rs) (`default_allowed_domains` and the `*_DOMAINS` consts), to ship the change to everyone. When editing `src/agent.rs`, record provenance in a comment on the const, naming the agent, its version, and the date you captured the domains, so reviewers can tell whether the list is current. See the convention note above those consts. Do not fabricate provenance for lists you did not actually observe. ## Proxy operations ### Connection log At log level `all`, every connection attempt is printed to stderr in real time: ``` [proxy] 14:23:01 CONNECT api.githubcopilot.com:443 → CONNECTED [proxy] 14:23:04 CONNECT pastebin.com:443 → BLOCKED [proxy] 14:23:07 CONNECT mcp-onboarding.intern.nav.no:443 → BLOCKED-PRIVATE-RESOLVED ``` `proxy.log_file` (set above) additionally writes one line per connection to disk: ``` 2025-01-15T14:23:01Z CONNECT api.githubcopilot.com:443 CONNECTED 2025-01-15T14:23:04Z CONNECT pastebin.com:443 BLOCKED ``` The file is append-only. cplt never rotates or truncates it, and MCP broker lines go to the same file. Rotate it yourself (for example with `newsyslog` or `logrotate`) or delete it now and then. ### Status codes | Status | Meaning | Action | |---|---|---| | `CONNECTED` | Connection succeeded | none | | `CONNECTED (via ADDR ...)` | Connected to ADDR, but only after earlier addresses of the host failed (2 s per address while others remain, 10 s in total), or with blocked addresses in the DNS answer skipped (`skipped blocked ...`) | Often a broken IPv6 route or a mixed DNS answer; none needed | | `BLOCKED` | Domain matched blocklist | Check the built-in list (`blocked-domains.txt`), your `--blocked-domains` file and any subscription | | `BLOCKED-ALLOWLIST` | Domain not in allowlist | Add it to the file named by `proxy.allowed_domains` / `--allowed-domains` (re-read live), or `cplt config set allow.domains HOST` and restart. For a localhost target use `--allow-localhost `: the allowlist does not apply to loopback | | `BLOCKED-PORT` | Port not in allowed list | Add with `--allow-port `; for a localhost target use `--allow-localhost ` | | `BLOCKED-PRIVATE` | Pre-DNS private IP (`.local`, `127.*`, IP literals) | Use `--allow-localhost` for local ports | | `BLOCKED-PRIVATE-RESOLVED` | DNS resolved to a private IP (every address is checked; passing addresses are still used) | Use `--allow-private-domain ` | | `DNS-FAIL` | DNS resolution failed | Check domain spelling or network | | `CONNECT-FAIL:...` | TCP connection to every resolved address failed (each listed) | Target may be down | | `UNSUPPORTED` | Non-CONNECT HTTP method | Only CONNECT tunnels are supported | | `LIMIT` | 64 concurrent connections reached | Reduce parallelism | ### Troubleshooting **Tool blocked with `BLOCKED-PRIVATE-RESOLVED`.** A domain, typically corporate intranet, resolved to a private IP: ```bash cplt config set proxy.allow_private_domains intern.nav.no ``` Or for a single run: `cplt --allow-private-domain intern.nav.no` **MCP server on localhost blocked.** Use `allow.localhost`, not `allow_private_domains`: ```bash cplt config set allow.localhost 3000 ``` **A name that resolves to loopback is blocked** (`127.0.0.1.nip.io`, `lvh.me`). `allow.localhost` lifts the port and allowlist gates only for a name that is loopback as written: `localhost`, `*.localhost`, `127.0.0.1` or `[::1]`. Those gates run before DNS, and the proxy does not resolve a name to find out, because under a fail-closed allowlist that would let the agent trigger a DNS lookup for any name it likes. So `127.0.0.1.nip.io:3000` with only `--allow-localhost 3000` gets `BLOCKED-PORT`. Use `localhost:3000` instead. To keep the name, the target must also pass the gates a remote host passes: `--allow-port 3000` (which opens port 3000 to remote hosts as well) and, under an allowlist, the name in `allowed_domains`. The localhost opt-in is still needed, since without it the answer is refused after DNS with `BLOCKED-PRIVATE-RESOLVED`. `cplt check net HOST:PORT` resolves the name and gives this advice. **Tool needs a non-443 port.** Add it explicitly: ```bash cplt config set allow.ports 8443 ``` **Nothing connects.** Check that the proxy is running. On macOS: ```bash cplt --print-profile | grep localhost # shows the proxy port rule in the Seatbelt profile ``` This works on macOS only. On Linux `--print-profile` prints a Landlock summary, which has no localhost rule to find, because Landlock network rules are port-based and cannot pin to localhost. ### Corporate proxy environments cplt injects its own `HTTP_PROXY`/`HTTPS_PROXY` into the sandbox environment, replacing any corporate proxy you may have set. The sandbox environment is cleared by default, with sensitive env vars stripped, so your external `HTTP_PROXY` does not flow in. If you need to chain through a corporate proxy instead of using cplt's built-in proxy: ```bash cplt config set proxy.enabled false cplt config set sandbox.pass_env HTTP_PROXY cplt config set sandbox.pass_env HTTPS_PROXY ``` > **Note:** Disabling the proxy removes cplt's built-in domain filtering, connection logging, and port enforcement. `blocked_domains` and `allowed_domains` are features of the built-in proxy, so they have no effect when it is disabled. If your corporate proxy has its own domain filtering, rely on that instead. The sandbox still enforces filesystem and process isolation regardless of proxy settings. ### Chaining through an upstream (corporate) proxy Instead of disabling cplt's proxy, keep it and forward its approved CONNECT tunnels through your corporate proxy. Every hostname gate runs before the forward, so the upstream only ever receives a target whose *name* cplt's policy permits: the port policy, the fail-closed allowlist, the blocklist, and the pre-DNS private-hostname check that rejects private IP literals and `localhost` / `*.localhost` / `*.local`. Local targets are never forwarded. A loopback target you opted into with `--allow-localhost`, whether spelled `localhost`, `127.0.0.1` or a name that resolves only to loopback (such as `127.0.0.1.nip.io`), is connected directly, so it reaches your machine and not the upstream's. > [!IMPORTANT] > **Upstream mode delegates private-address defense to the upstream.** cplt forwards the target *hostname*, not an address, and the upstream resolves it itself. cplt's resolved-IP SSRF guard classifies the answer cplt's own resolver gives, which need not be the address the tunnel lands on: a split-horizon name that is public locally and private upstream passes the guard and still reaches the private host, and a name that does not resolve locally at all is forwarded with no address classification (that is how split-DNS names are meant to work here). This is deliberate — pinning the checked IP would break split-DNS and internal-name resolution, which is the reason upstream mode exists. In this mode the private-network boundary is the upstream proxy and its network segment, not cplt. ```bash cplt config set proxy.upstream "http://corporate-proxy.example.com:8080" # or for a single run: cplt --proxy-upstream http://corporate-proxy.example.com:8080 -- -p "fix the tests" ``` Optional basic-auth userinfo is supported (`http://user:pass@host:8080`), and only the `http` scheme is supported. The credentials are redacted in `config show` and startup output. > [!WARNING] > **Upstream credentials travel in cleartext.** There is no TLS-to-proxy path: an `https://` upstream is rejected rather than downgraded, so the connection to the upstream is plain TCP and the userinfo is sent on every CONNECT as `Proxy-Authorization: Basic `. Base64 is encoding, not encryption — anyone who can read the segment between cplt and the upstream reads the credential. Redaction covers logs and `config show`, not the wire. cplt does not refuse userinfo for a non-loopback upstream, because Basic auth to a corporate proxy over a trusted internal segment is ordinary. Where that segment is not trusted, point `proxy.upstream` at a loopback forwarder that holds the credential, or use a credential-free upstream that authenticates by source address. #### Bypassing the upstream for some hosts (`NO_PROXY`) Corporate setups almost always list internal hosts that must not go through the corporate proxy. Add them to `proxy.upstream_no_proxy` and cplt connects to a matching CONNECT target directly instead of forwarding it upstream, exactly like a `NO_PROXY` entry (`internal.example.com` covers its subdomains too): ```bash cplt config set proxy.upstream_no_proxy "internal.example.com" # repeatable CLI flag for a single run: cplt --proxy-upstream-no-proxy internal.example.com -- -p "..." ``` ```toml [proxy] upstream = "http://corporate-proxy.example.com:8080" upstream_no_proxy = ["internal.example.com", "corp.example"] ``` > [!IMPORTANT] > **Internal hosts also need `allow_private_domains`.** A no-proxy host takes cplt's direct path, which applies the resolved-IP SSRF guard. An internal host that resolves to a private IP, the usual `NO_PROXY` target, is blocked with `403 Resolved to private IP` unless you also allow it to resolve private: > ```toml > [proxy] > upstream = "http://corporate-proxy.example.com:8080" > upstream_no_proxy = ["internal.example.com"] # bypass the upstream > allow_private_domains = ["internal.example.com"] # AND permit its private IP > ``` - **Matching** follows the shared domain rules above, with one limit: CIDR and IP ranges are not honored. Only hostnames (exact plus subdomain) and exact IPs match, and a `10.0.0.0/8`-style entry is silently ignored, since it could only divert traffic to the already-filtered direct path anyway. - **`NO_PROXY` env is merged in, additively.** cplt reads the ambient `NO_PROXY`/`no_proxy` environment variable, comma- or whitespace-separated, and merges those hosts on top of the config and CLI list. There is no CLI way to subtract an ambient entry. Empty entries and a bare `*` wildcard are ignored, because cplt does not honor "bypass everything". - **No-op without an upstream.** The list is only consulted when `proxy.upstream` is set. - **Security is preserved.** A no-proxy host is not exempt from any filtering. Every gate (allow, block, port, SSRF resolved-IP check) runs before the upstream-versus-direct decision, and the direct path re-applies the resolved-IP guard. The host is simply connected directly by cplt, which runs outside the sandbox, rather than forwarded to the corporate proxy. Because cplt and not the agent makes that connection, this also works under `proxy.forced`. ## Copilot CLI network endpoints Copilot CLI 1.0.21 connects directly to these endpoints, empirically verified: | Endpoint | Purpose | | ---------------------------------- | ---------------------------------------- | | `api.github.com` | GitHub API (user info, token validation) | | `api.githubcopilot.com` | Copilot API | | `api.business.githubcopilot.com` | Copilot Business API (enterprise users) | | `proxy.business.githubcopilot.com` | Copilot Business proxy |