# Security One Rust binary, and a dependency list short enough to read in a sitting (see `Cargo.toml`). No runtime services, no telemetry. Every security boundary is kernel-enforced and tested. Every design decision is documented with the threat it mitigates and the prior art it builds on. **Our priorities, in order:** 1. **Correct.** Every claim is tested, and every edge case has a CVE or research reference. 2. **Transparent.** Read [SECURITY.md](../SECURITY.md). It hides nothing. 3. **Simple.** One binary, zero config required, sane defaults. 4. **Useful.** Get out of the way and let the agent do its job, safely. For the full security model, threat analysis, and test strategy, see **[SECURITY.md](../SECURITY.md)**. ## `~/.config/gh/hosts.yml` is readable cplt grants read-only access to gh's `hosts.yml` and `config.yml`, not the whole config directory. The same two files are granted at an absolute `GH_CONFIG_DIR` or `$XDG_CONFIG_HOME/gh` override. `hosts.yml` can contain a plaintext GitHub token, but gh can also keep the secret in the OS credential store. Copilot can receive an exported token, a host-retrieved gh token, a one-shot guard cache, or native Keychain access on macOS. The gh guard also has a separate exec-token cache for sandboxed gh. A retrieved token does not prove validity, Copilot entitlement or the account Copilot selected. See [GitHub token handling](../SECURITY.md#github-token-handling-per-agent). A compromised agent can read a granted plaintext token or a guard cache and use it wherever the network policy permits. `--deny-path ~/.config/gh`, or a deny on the relocated directory, blocks sandboxed file access where enforced. It does **not** prevent cplt from retrieving the token through gh on the host or handing it over through another channel, and does not force Copilot to use its Keychain login. For native-login diagnostics, see [known impacts](known-impacts.md#the-same-vault-prompt-on-macos). ## Outbound network is port-restricted This section describes the macOS Seatbelt profile; the Linux equivalents, and where they fall short, are in [Limitations](#linux) below. SBPL accepts only `*` or `localhost` as the host in a `remote ip` rule. There is no way to name an address or a range, and Copilot connects to CDN-backed endpoints whose IPs change (`api.business.githubcopilot.com`, `api.githubcopilot.com`, `proxy.business.githubcopilot.com`), so enumerating them was never an option either. What is left is the port. - Only port 443 is allowed. Every other outbound TCP port is blocked at the kernel level. The rule is TCP-only, so UDP/443 (QUIC/HTTP3) is blocked too and HTTP/3 clients fall back to TCP ([#815](https://github.com/navikt/cplt/issues/815)). - Localhost outbound is blocked on macOS, which keeps the agent away from local services such as databases and dev servers. Landlock has no localhost pinning, so this does not hold on Linux. - The SSH agent is blocked on macOS. Unix socket access is denied, so loaded SSH keys cannot be used. On Linux it is not gated by Landlock below kernel 7.1; bubblewrap's private `/tmp` hides the stock OpenSSH socket, but a desktop agent under `$XDG_RUNTIME_DIR` stays reachable. - Filesystem isolation blocks denied credential paths. Auth credentials deliberately handed to the agent remain readable and usable within the allowed network policy. - The proxy is on by default, and logs and filters every outbound connection from Copilot, `gh`, and `curl` alike. - `cplt config set allow.ports 8443` adds an outbound port to remote hosts. A local dev server or database needs `allow.localhost` instead: `allow.ports` does not open loopback. See [SECURITY.md](../SECURITY.md) for the full threat model and honest gaps. ## `--allow-browser` is a sandbox escape, and cannot be scoped `--allow-browser` (and `sandbox.allow_browser`) exists so an agent can hand an https URL to your browser during an OAuth sign-in. What it grants is Launch Services, which is a much larger thing. Launch Services starts the target through launchd, **outside** the Seatbelt profile, so with the flag on the agent can launch any application on the machine with no sandbox in front of it. `open -a Terminal script.sh` is the whole exploit. That is not a defect; it is what the grant is. The obvious repair — wrap `open`, accept `http://` and `https://`, reject file paths and `-a`/`-b` — was investigated for [#251](https://github.com/navikt/cplt/issues/251) and rejected, because two measured facts say it would guard nothing. **`lsopen` takes no filter.** SBPL has no URL, bundle-id or path predicate for the operation. Every profile under `/System/Library/Sandbox/Profiles` that uses it emits it bare, `(allow lsopen)`, exactly as cplt does — Apple has no narrower form to copy. It is all of Launch Services or none of it. **The grant does not go through the `open` binary.** A PATH wrapper, of the kind cplt installs for `gh` and `git`, only shadows a name. A program that calls `LSOpenCFURLRef()` reaches Launch Services directly and never runs `open` at all. Measured on macOS 15, with a ten-line C program linking CoreServices, compiled outside the sandbox and run inside a real cplt session against `file:///System/Applications/Calculator.app`: | run | `LSOpenCFURLRef` status | result | | --- | --- | --- | | without `--allow-browser` | `-54` (denied) | no application launched | | with `--allow-browser` | `0` | **Calculator running outside the sandbox** | No `open` process existed in either run. Separately, the profile's blanket `(allow process-exec)` means `/usr/bin/open` is executable by absolute path regardless, which a PATH shim never sees — so even the polite path has a one-token bypass (`/usr/bin/open` instead of `open`). Both bypasses are one-liners for a hostile agent. A shim would stop an agent that types `open` politely and nothing else, so cplt does not ship one and does not describe this flag as scopable. **What to do instead.** Leave it off, which is the default. Sign in outside cplt or use an agent's device-code flow and open the URL yourself. Copilot supports `copilot login --device-code`; its default desktop login can launch a browser. If you enable this grant for a flow that needs it, restore the prior setting afterwards. The setting is flagged dangerous: `cplt config set sandbox.allow_browser true` refuses it unless you add `--force`, and `detect` never proposes it. ## Limitations ### macOS - **`sandbox-exec` is deprecated.** Apple has not removed it, but may in a future macOS version. - **SBPL has no domain-based filtering.** The optional CONNECT proxy provides domain blocking. - **SBPL has no `lsopen` filtering either.** `--allow-browser` is therefore all of Launch Services or none of it, and with it on the agent launches applications outside the sandbox. It cannot be narrowed by a wrapper: see [`--allow-browser` is a sandbox escape](#--allow-browser-is-a-sandbox-escape-and-cannot-be-scoped). - **Keychain access is conditional.** Copilot's native login can use the macOS Keychain. By default cplt drops its broad Keychain grant when an exported or host-retrieved gh token substitutes for it; without a substitute, or on a detected account mismatch, the grant stays. Retrieval and a matching item do not prove the selected account or successful authentication. - **Proxy-forced mode is kernel-enforced for connections.** With `--proxy-forced` or `proxy.forced = true` ([#53](https://github.com/navikt/cplt/issues/53), opt-in, off by default), SBPL pins outbound egress to `localhost:`. Seatbelt can pin to localhost, so there is no residual for connections on macOS: no direct `:443` path exists, and neither raw sockets nor `env -u HTTPS_PROXY` can get around the proxy. DNS lookups through the system resolver remain possible. It fails closed if the proxy cannot start, and conflicts with `--no-proxy`. ### Linux - **Kernel 5.13+ is required.** Landlock LSM must be enabled (`cat /sys/kernel/security/lsm`). - **TCP port filtering needs kernel 6.7+.** Older kernels get filesystem-only enforcement, and network security comes from the proxy alone. - **Landlock network rules are TCP-only.** cplt handles `AccessNet::ConnectTcp` and nothing else, and Landlock's UDP support lands at ABI v10, above the v9 cplt requests. Outbound UDP to any host and port, and inbound UDP bind, are unrestricted at the kernel in every Linux mode **except `proxy.forced`**, where a seccomp rule permits only `SOCK_STREAM` with protocol 0 or `IPPROTO_TCP` for `AF_INET`/`AF_INET6` and so removes UDP, raw, SCTP and DCCP on any kernel. The CONNECT proxy carries TCP only, so UDP never reaches it: outside `proxy.forced`, DNS tunnelling, QUIC/HTTP-3 and plain UDP exfiltration have no cplt layer in front of them. macOS allows only TCP to remote hosts on 443 and `--allow-port` ports ([#815](https://github.com/navikt/cplt/issues/815)), so remote UDP is denied and HTTP/3 clients fall back to TCP; loopback UDP stays open under `--allow-localhost`. Direct TCP/443 still bypasses the proxy outside `proxy.forced`, so only there is the macOS proxy log a complete record of connections; DNS lookups through the system resolver do not appear in it. - **Pathname socket connections are unmediated on Landlock ABI 4–8.** Filesystem grants and denies do not gate `connect()`, and seccomp cannot dereference its address pointer. cplt already requests abstract-socket scoping from ABI v6 and `ResolveUnix` from ABI v9 (kernel 7.1). The latter mediates pathname connections, but broad writable-tree grants also allow them, including under `/tmp` and the project. `allow.socket` is not generally refused when ineffective. See [Linux limitations](../SECURITY.md#linux-specific-limitations). - **D-Bus can reach the session manager.** A reachable session bus lets `systemd-run --user ` request a unit **outside** the sandbox. Require `sandbox.use_bubblewrap = true` for the configured masks over selected known bus/systemd endpoints; default auto-detect can fall back and is not security-equivalent. Masks do not cover every custom pathname or abstract socket. The session-manager escape remains a code-reading conclusion, distinct from the private Secret Service reads below. - **Landlock network rules are port-based only** and cannot tell localhost from a remote host. When `--allow-localhost-any` is set, kernel TCP connect filtering is **disabled entirely on Linux**: Landlock cannot express "any localhost port but no remote host", so *every* TCP-connect rule is dropped and an agent can raw-socket to any remote `host:port` and exfiltrate directly, not merely reach localhost. macOS still pins `localhost:*`. cplt emits a prominent warning. Prefer `--proxy-forced`, which supersedes this flag, or scope to specific ports with `--allow-localhost ` to keep kernel connect-restriction on. - **Proxy-forced mode is only partial on Linux.** With `--proxy-forced` or `proxy.forced = true` ([#53](https://github.com/navikt/cplt/issues/53), opt-in, off by default), Landlock drops the `:443` rule and allows only the proxy port, which blocks direct TCP `:443` to any host and forces HTTPS through the CONNECT proxy. But Landlock is **port-based and cannot pin to localhost**, so a narrow `evil.com:` channel stays reachable if a remote host answers on that exact port. Landlock itself gates **TCP only**, but the seccomp rule above closes UDP, raw, SCTP and DCCP in this mode, so the residual is the proxy-port channel rather than open UDP. Read it as "no direct TCP `:443` bypass", not "no egress except the proxy". Closing the residual needs a network namespace, tracked in [#114](https://github.com/navikt/cplt/issues/114). macOS has no such residual, because SBPL pins to `localhost:`. It fails closed if the proxy cannot start, and conflicts with `--no-proxy`. - **Gradle and the JVM on Linux.** The Gradle daemon uses ephemeral localhost ports. Use `--allow-localhost-any` or `cplt config set sandbox.allow_localhost_any true` to let the Gradle client and daemon talk. If JVM startup itself fails, add `--allow-tmp-exec` as well, for native library loading from the temp dir. - **Landlock cannot deny subpaths within allowed paths.** Unlike macOS Seatbelt, it cannot deny `.env` reads or `.git/hooks` writes *inside* the project directory at the kernel level. For `.env`, the proxy blocks exfiltration as defense-in-depth. For `.git/hooks` and `.cplt.toml`, in the project directory and in every `allow.write` grant alike, **these stay writable on the Landlock-only path**. Env hardening (`GIT_CONFIG_NOSYSTEM`, injected `GIT_CONFIG_*`) only constrains git's config resolution for commands run *inside* the sandbox. It does **not** stop an agent planting a `.git/hooks/post-commit` that runs unsandboxed on the next `git` invocation. That direct file-planting vector is mitigated only when the Bubblewrap layer re-binds `.git/hooks` and `.cplt.toml` read-only. `.git/config` and `.gitmodules` stay writable even under bwrap, deliberately, since read-only binding them breaks legitimate `git config`, `remote`, and `submodule` operations and risks a stale `.git/config.lock`. So `core.hooksPath` remains a residual persistence path. - **mise's `shims/` and `installs/` are protected only by bubblewrap.** The PATH-resolved bin directories are write-denied on both platforms ([global tool installs](known-impacts.md#global-tool-installs)), but the two mise ones sit inside the mise data dir, which must stay writable for the rest of mise's state. Landlock cannot subtract from an allowed tree, so they ride the same read-only overlay as the git-persistence paths: they hold only when `bwrap` is installed, and only for directories that already exist. Without bubblewrap this is unenforced on Linux, and a binary dropped in `~/.local/share/mise/shims` runs unsandboxed on the user's next command. `~/.bun/bin`, `~/.deno/bin` and `$PNPM_HOME` are not affected — those are enforced by not granting write to the parent at all, which Landlock expresses natively. - **`map_exec` cannot be expressed, so three trees stay write+execute.** Landlock's `EXECUTE` right is checked on `execve()` alone — there is no `mmap(PROT_EXEC)` hook, and a dynamic loader gets there with `READ_FILE`. macOS's `file-map-executable` therefore has no narrower Landlock equivalent. Since [#243](https://github.com/navikt/cplt/issues/243) the execute bit follows `process_exec` alone, which took `.cargo/registry`, `.cargo/git`, `.gradle`, `.m2`, `.nuget`, `go/pkg` and the pnpm stores out of the write+execute class they had been in on Linux and nowhere else. `~/.gradle/jdks`, `~/.dotnet` and `~/.konan` keep `EXECUTE` — they really do run binaries out of themselves — and Landlock cannot subtract the parent's write grant, so those three remain write+execute on the Landlock-only path. The `EXEC_IN_WRITABLE` table in `sandbox_policy.rs` carries the reason for each. The `.dotnet` and `.konan` entries are unverified on Linux rather than decided, because no Linux host was available to run a real .NET or Kotlin/Native build against. On macOS a K/N build was run: it execs only from `~/.konan/dependencies` ([#323](https://github.com/navikt/cplt/issues/323)), so narrowing the Linux grant to that subtree is the likely next step. - **`--deny-path` needs active bubblewrap masks.** Landlock alone cannot subtract denied subpaths from allowed trees; below ABI v9, it also cannot mediate pathname socket connections. Active bubblewrap can enforce selected path masks, but these do not deny every custom or abstract socket. - **Some macOS flags do not apply.** `--allow-jvm-attach` and `--allow-cache-exec` emit warnings and are ignored on Linux. `--allow-docker` does apply now: it exempts the Docker and Podman daemon sockets from the bubblewrap mount masks and grants them in Landlock. It also grants `~/.docker` read-only, so CLI contexts and registry auth work. `--allow-rootless-docker` lifts the masks on the rootless sockets under `$XDG_RUNTIME_DIR` only (`docker.sock`, `podman/`), keeps the rootful ones masked, and grants no `~/.docker`. On macOS it has no effect. - **No audit logs.** `--show-denials` is macOS-only. Use `strace -f -e trace=file,network` for debugging. - **Secret Service credentials are not protected by keyring-file denies alone.** On Ubuntu 24.04 in Colima, kernel 6.8 reporting Landlock ABI 4, direct reads from a private D-Bus Secret Service succeeded under Landlock + seccomp in all 24 token-matrix cases, even with all token variables denied and the bus address stripped. `XDG_RUNTIME_DIR` or a known socket lets an agent rediscover the bus; env filtering is not a kernel boundary. Bubblewrap was untested because namespace creation was blocked in the container and VM. See [the measured Linux gap](../SECURITY.md#linux-specific-limitations) and [credential handover](known-impacts.md#linux-keyring-access-depends-on-the-backend-and-agent-version). - **The Bubblewrap layer is optional defense-in-depth.** When `bwrap` is installed, or `--use-bubblewrap` / `sandbox.use_bubblewrap` is set, the sandbox also gets PID, IPC, UTS, cgroup, and user namespaces plus a private `/tmp`. Landlock and seccomp are applied inside the namespaces by a fail-closed re-entry helper, so the agent can never run unsandboxed. It also re-binds a **narrow** set of pre-existing git-persistence paths **read-only**: `.git/hooks` and `.cplt.toml` under **every writable root**, meaning the project and each `allow.write` grant, plus the resolved shared `.git`'s `hooks` for a worktree, where `/.git` is a gitdir pointer file and the real hooks live in the common dir, or a bare repo, where hooks sit at `/hooks`. That mitigates the direct `.git/hooks` file-planting vector the Landlock-only path leaves open. `.git/config` and `.gitmodules` are deliberately left writable, since read-only would break legitimate git config, remote, and submodule operations and risk a stale `.git/config.lock`. Three residuals. `core.hooksPath` set through the writable `.git/config` can still redirect hooks. Only paths that already exist at launch can be protected, so a `.cplt.toml` that does not exist yet can still be created. And submodule hooks (`.git/modules//hooks`) are covered only when the submodule itself is granted via `allow.write`, because its `.git` gitlink then resolves into the parent's `modules/`, which gets the same protection. A submodule merely nested inside a granted parent is not covered, since only the grant root is resolved and never the subdirectories under it. Bubblewrap does **not** add a network namespace. The host network is shared so the filtering proxy keeps working, and kernel-level network isolation is tracked in [#114](https://github.com/navikt/cplt/issues/114). It does not hide the host filesystem either, which stays visible read-only with Landlock controlling access. Auto-detect falls back to Landlock-only with a warning when bwrap is missing or non-functional. ### Both platforms - **No TLS inspection.** The proxy sees domain names through CONNECT, but not request bodies. - **Parent-side git is hardened against repository-controlled program execution** ([#210](https://github.com/navikt/cplt/issues/210)). cplt runs `git` in the parent, *outside* the sandbox, with the working directory set to the project. The post-session audit, the `.cplt.toml` trust decision, trust identity, and sandbox path discovery all do this. On Linux `.git/config` stays writable to the agent by design, see the Linux note above, so a repository can name a program that parent `git` would otherwise execute unsandboxed. macOS denies that write in the profile — `(deny file-write* (literal "/config"))`, emitted after every user allow — but the same route was reachable there until the profile also pinned the gitdir itself against rename (GHSA-g79x-v9p4-jmxw: `mv .git .gitbak`, write the config, `mv` back sidestepped every path-shaped rule) and denied `/worktrees//config.worktree` and `/modules`. The hardening still earns its place on macOS: `.gitattributes` is an ordinary working-tree file no profile denies, and it can aim a `textconv` or content-filter driver defined in the user's own global config at a parent-side `git diff`. Every parent-side invocation now goes through `src/git.rs`, which clears inherited `GIT_*`, sets `GIT_CONFIG_NOSYSTEM`, `GIT_ATTR_NOSYSTEM`, `GIT_TERMINAL_PROMPT=0`, and `GIT_OPTIONAL_LOCKS=0`, and forces every executable config key it does not need to a safe value with `-c`: `core.fsmonitor`, `core.hooksPath`, `core.pager`, `core.editor`, `sequence.editor`, `core.sshCommand`, `core.gitProxy`, `core.alternateRefsCommand`, `credential.helper`, `uploadpack.packObjectsHook`, and `gpg.program`. `git diff` additionally gets `--no-textconv --no-ext-diff`. **Residual:** `filter..clean` and `.process` are named by an arbitrary subsection, so no fixed override can neutralize them. When the repository's own `local` or `worktree` scope config defines one, cplt **refuses** the working-tree queries instead, and the audit reports `incomplete` rather than a clean session it never verified. A committed `.cplt.toml` **still applies**, because it is read from the object store with `git cat-file`, which cannot reach a filter and is never refused. But its drift state reads as `Drifted` without the working tree having been compared, which also blocks the one-time `cplt trust accept`, since that approves only `Committed`. An agent can therefore force the audit to `incomplete` by writing `.git/config`, on Linux, where that file is writable to it. That is inherent to failing closed, since the only way to answer instead is to execute the filter. Filters in the user's `--global` config, `git lfs install` by default, are the user's own choice and do not trigger this. User-facing guidance is in [known-impacts.md](known-impacts.md#repo-local-git-content-filters-lfs-git-crypt). Global config is deliberately **not** blanked, because `cplt doctor` path discovery reads `core.hooksPath` from it and `detect` reads `commit.gpgsign`. For known attack vectors, out-of-scope threats, and prior art, see [SECURITY.md](../SECURITY.md).