cplt
Kernel-enforced sandbox for AI coding agents. cplt wraps GitHub Copilot CLI, OpenCode, Gemini CLI, Antigravity CLI, Pi, Claude Code, goose, DeepSeek Harness, or any shell, so the agent can write code but cannot steal credentials, push to main, merge PRs, or exfiltrate secrets.
- macOS: Apple Seatbelt/SBPL via
sandbox-exec - Linux: Landlock LSM + seccomp-BPF + optional Bubblewrap namespace isolation (kernel 5.13+, full network filtering on 6.7+)
- Windows: no native support. There is no Windows sandbox backend. Run cplt inside WSL2, where it is an ordinary Linux install and the Microsoft kernel ships Landlock. See Windows (WSL2) setup.

Why cplt?
AI agents execute arbitrary code. A compromised agent, whether through prompt injection, a supply chain attack, or a malicious MCP server, can read ~/.ssh, push to main, merge PRs, or exfiltrate your code, unless the OS itself says no.
cplt gives you kernel-level enforcement with team-configurable policy:
- Per-repo policy in
.cplt.toml, committed to version control, so it is tamper-proof and auditable - Deny by default for credentials, secrets, and sensitive files
- Command-level git and gh interception: pushes to the default branch, force pushes, merges, and releases are blocked, feature branches stay open
- Outbound network filtering with an audit log
- No Docker, no VMs. One binary that runs on a locked-down laptop
- Zero-config start for developers, with escape hatches when a build genuinely needs one
Table of contents
- Quick start
- What it blocks
- How cplt compares
- Install
- Usage
- Configuration
- Architecture
- Security
- Network and proxy
- Command guards
- Known impacts
- Limitations
- Contributing
- References
Detailed docs: Configuration · Proxy & domain filtering · gh command guard · git command guard · Known impacts · Security details · Security model
Quick start
brew install navikt/tap/cplt # macOS. On Debian or Ubuntu, see apt below
cplt --shell-install # make 'copilot' run sandboxed (persistent)
# --agent opencode for any other agent
cplt doctor # check your environment
cplt -- -p "fix the tests" # run Copilot in sandboxOther agents and sandbox commands:
cplt --agent opencode # OpenCode (Copilot subscription)
cplt --agent opencode --pass-env ANTHROPIC_API_KEY # third-party provider
cplt --agent shell # interactive sandboxed shell (no AI)
cplt exec -- npm install # sandbox any command directly
cplt exec -c "npm install && npm test" # compound commands in sandbox
alias npm="cplt exec -- npm" # sandboxed npm for every invocationTeam rollout
# 1. Generate per-repo policy
cplt init --write
# 2. Developers approve on first run
cplt trust accept --all
# 3. Tune the command guards (both block by default)
cplt config set git_guard.protect_default_branch_only false # block every push, not just main
cplt config set git_guard.mode warn # observe instead of blockingWhat it blocks
The sandbox blocks access to credentials and secrets in the kernel. Command guards block destructive operations. Every restriction applies to the agent and to every process it spawns.
| Resource | Status | Notes |
|---|---|---|
| Read/write project directory | ✅ Allowed | |
Read/write/delete .env*, .pem, .key, id_rsa in project | 🔒 Kernel-blocked | Prevents secret exfiltration and destruction. --allow-env-files overrides. Key files are matched by exact name (.pem), not extension (server.pem), unless sandbox.deny_key_files_by_extension is set |
Write .git/hooks, .git/config, .gitmodules | 🔒 Kernel-blocked (macOS), ⚠️ partial on Linux | Prevents persistence via git hooks, hooksPath redirect, submodule hijacking. Linux: Landlock cannot deny a subpath inside an allowed tree, so these stay writable on the Landlock-only path. bwrap re-binds .git/hooks read-only but deliberately leaves .git/config and .gitmodules writable, so core.hooksPath remains a persistence route, see Linux limitations. Applies to every writable root, the project and each allow.write grant, including a granted worktree or bare repo whose real hooks live outside <root>/.git |
Execute from /tmp, /var/folders | 🔒 Kernel-blocked | Prevents write-then-exec. The scratch dir redirects TMPDIR to a safe location, on by default |
Write PATH-resolved bin/shim dirs (~/.bun/bin, ~/.deno/bin, $PNPM_HOME, mise shims/ and all of installs/) | 🔒 Kernel-blocked (macOS), ⚠️ mise partial on Linux | Prevents trojaning a binary your next unsandboxed command resolves through PATH. Same reason ~/.cargo/bin and ~/go/bin have always been read-only. Breaks bun install -g, deno install, pnpm add -g, mise install, mise upgrade, mise use -g inside cplt, deliberately, and a repo pinning an uninstalled toolchain no longer bootstraps. Project-local installs are unaffected. Linux: mise's two ride the bwrap read-only overlay; the rest hold natively. See Global tool installs |
Execute from ~/Library/Caches | 🔒 Kernel-blocked by default | Prevents binary-drop staging. Copilot native modules are exempted via a carve-out. Add targeted exemptions with --allow-cache-exec <SUBDIR>, e.g. ms-playwright |
Modify .vscode/tasks.json, launch.json | ⚠️ Allowed, known risk | IDE trust boundary. See SECURITY.md for mitigations |
Read/write ~/.copilot (auth, settings) | ✅ Allowed | Includes file-map-executable for keytar.node, pty.node, computer.node |
Write ~/.copilot/pkg (native modules) | 🔒 Kernel-blocked | Prevents persistence via native module replacement |
| Environment variables | 🔒 Sanitized + hardened | Only a safe allowlist passes through. Lifecycle scripts blocked. --pass-env VAR adds one back |
Read ~/.config/gh/hosts.yml + config.yml | ✅ Allowed (read-only) | Only these two files. The rest of .config/gh is blocked |
Read ~/.config/mise | ✅ Allowed (read-only) | Tool versions and PATH, no secrets |
Read ~/.gitconfig, ~/.config/git/config | ✅ Allowed (read-only) | A dotfiles symlink is followed to its target, so a stowed ~/.gitconfig works |
Read ~/.git-credentials | 🔒 Kernel-blocked | credential.helper = store keeps cleartext tokens here. No --allow-read reopens it, like ~/.netrc. Linux: a grant on an ancestor ($HOME itself) still exposes it, because Landlock cannot deny a subpath inside an allowed tree |
Read global git hooks (core.hooksPath) | ✅ Allowed (read-only, write-denied) | Auto-detected. Must be under $HOME with depth ≥3. Writes are explicitly blocked |
Commit/tag signing (commit.gpgsign, tag.gpgsign) | 🔒 Disabled | Private keys in ~/.ssh and ~/.gnupg are blocked, so signing is disabled via an env var override |
Read ~/Library/Application Support/Microsoft | ✅ Allowed (read-only) | Device ID for telemetry |
| Access macOS Keychain | ⚠️ Allowed (read+write) for agents that store auth there | The grant cannot be scoped to one item, so it reaches every keychain entry the agent can unlock. The Keychain substitute (sandbox.keychain_substitute) drops it where the agent can authenticate another way: by default for Copilot (an exported COPILOT_GITHUB_TOKEN, GH_TOKEN or GITHUB_TOKEN, else gh's token passed as GH_TOKEN) and Claude Code (an exported CLAUDE_CODE_OAUTH_TOKEN), opt-in for Antigravity (its fallback token file). Without such a credential the grant stays. See SECURITY.md |
| Outbound network (port 443) | ✅ Allowed | Every other port is blocked. Add extras with --allow-port |
| Localhost outbound | 🔒 Kernel-blocked (macOS), ⚠️ port-based on Linux | Prevents local service access. Inbound still works for the proxy. Linux: Landlock rules are port numbers only and cannot tell localhost:443 from remote:443, so a local service on an allowed port is reachable and there is no localhost-specific deny. Use --with-proxy for SSRF protection, see Linux limitations |
| SSH agent (unix socket) | 🔒 Kernel-blocked (macOS), ⚠️ backend-dependent on Linux | Prevents using your loaded keys for git or SSH. It does not block the network path: SSH servers on port 443, such as ssh.github.com:443, are reachable, see SSH over port 443. Linux: below ABI v9, pathname connections are unmediated, so withholding SSH_AUTH_SOCK only affects discovery. ABI v9 mediates connections but also permits them under writable-tree grants. Active bwrap hides the stock OpenSSH socket under /tmp, but not a gnome-keyring/gcr or systemd agent under $XDG_RUNTIME_DIR. See Linux limitations |
Developer tools (~/.cargo, ~/.gradle, ~/.m2, ~/.sdkman, ~/.jenv, ~/.pyenv, ~/.konan, etc.) | ✅ Allowed (read+write for caches) | Only dirs that exist on disk. Tightened at runtime by what cplt doctor detects |
Registry credential files (~/.m2/settings.xml, ~/.gradle/gradle.properties, ~/.cargo/credentials) | 🔒 Kernel-blocked on macOS. On Linux the parent tool dir stays readable | Override with --allow-read. See Private registries |
Read ~/.npmrc | 🔒 Kernel-blocked (both platforms) | Override with --allow-read. Can break yarn 1 on Linux, see yarn 1 |
Go source code (~/go/src) | 🔒 Kernel-blocked | Only ~/go/bin and ~/go/pkg are readable |
Read ~/.ssh, ~/.gnupg, ~/.aws, ~/.azure | 🔒 Kernel-blocked | |
Read ~/.kube, ~/.docker, ~/.nais | 🔒 Kernel-blocked | |
Read ~/.password-store, ~/.terraform.d | 🔒 Kernel-blocked | |
Read ~/.config/gcloud, ~/.config/op | 🔒 Kernel-blocked | Individual files are overridable with --allow-read. See Cloud credentials |
Read or write ~/.config/cplt (and $XDG_CONFIG_HOME/cplt), ~/.nav-pilot | 🔒 Kernel-blocked | Tool state that decides what the next launch may do. config.toml, trust/, local/ and update/ stay in ~/.config/cplt; launch state and markers follow $XDG_CONFIG_HOME/cplt. ~/.config/cplt is un-overridable as a whole subtree; inside ~/.nav-pilot, a named path stays grantable so a pinned agentpakke payload can be read |
Read ~/.netrc, ~/.pypirc, ~/.vault-token | 🔒 Kernel-blocked | Un-overridable on both platforms. Naming one in allow.read is a startup error |
Read ~/.gem/credentials | 🔒 Kernel-blocked | Un-overridable on both platforms. Naming one in allow.read is a startup error |
gh CLI destructive operations (merge, delete, release) | 🔒 Command-gated (on by default) | Opt out with --no-gh-guard. See gh guard |
git push to the default branch | 🔒 Command-gated (on by default) | Blocks pushes to main/master; feature-branch pushes still work. protect_default_branch_only = false blocks every push, git_guard.mode = "warn" only warns, --no-git-guard opts out |
| Child process inheritance | ✅ All restrictions apply to subprocesses |
That table is a summary. The sandbox also allows access to system files (SSL certs, /etc/hosts), temp directories (read and write, no exec), and system tool paths (/usr/bin, /opt/homebrew). Run cplt --print-profile for the complete SBPL rules.
For the full security model, threat analysis, and test strategy, read SECURITY.md.
How cplt compares
Codex CLI's sandbox
| Area | cplt | Codex CLI sandbox |
|---|---|---|
| Outbound network control | CONNECT proxy with domain allow/block lists | No domain-level filtering |
| Environment handling | Allowlist plus hardening env injection | More basic pass-through model |
| Secret file protection | Deny patterns such as .env*, .pem, .key inside the repo | Primarily directory-scoped access |
| Repo policy | .cplt.toml with an explicit trust/approval flow | No repo-level policy file |
| Agent support | Copilot, OpenCode, Gemini CLI, Antigravity CLI, Pi, Claude Code, goose, DeepSeek Harness, or shell | Codex only |
cplt is not stronger everywhere. Codex CLI has Linux namespace isolation today, and it already exposes explicit sandbox modes such as read-only and workspace-write. cplt does not yet have that mode matrix.
Docker-based sandboxes
| Area | cplt | Docker-based sandbox |
|---|---|---|
| Startup time | Roughly instant for normal CLI use | Usually slower container startup |
| Network control | Per-request outbound filtering via proxy | Usually all-or-nothing network access |
| File controls | Per-path and per-pattern rules | Per-mount controls |
| Host requirements | Single binary | Docker daemon required |
| Corporate laptop fit | Works where Docker is unavailable or restricted | Often blocked by local policy |
Docker still gives you stronger isolation in some environments, especially if you want a fully separate filesystem and process namespace. cplt trades that for lighter setup and tighter integration with the machine you already develop on.
VS Code agent mode permissions
Tools such as VS Code agent mode rely mainly on UI permissions. cplt enforces its restrictions in the kernel, so the agent cannot talk its way around them with a prompt or a modified instruction. That matters most for CLI agents and credential exposure:
- cplt works outside the IDE
- env vars are filtered before the agent starts
- sensitive files can be blocked even when they live inside the repo
- the same restrictions apply to child processes
Claude Code's sandbox (Anthropic Sandbox Runtime)
Anthropic Sandbox Runtime (srt) is the sandboxing layer used by Claude Code. Same high-level approach as cplt, macOS Seatbelt plus kernel-level Linux enforcement plus an HTTP proxy, different implementation.
| Area | cplt | Anthropic srt |
|---|---|---|
| Language / delivery | Single Rust binary | Node.js + npm package + external deps |
| Linux backend | Landlock LSM (no deps, no namespaces) | bubblewrap (container via user namespaces) |
| Environment filtering | Strict allowlist + suffix-deny (_TOKEN, _SECRET) | Inherits the parent env on macOS and Linux. Variables listed under credentials.envVars can be withheld or masked |
| Credential dir protection | 15+ dirs denied by default | User must configure manually |
| Resolved-address checks | Post-DNS IP checked against loopback, link-local, RFC 1918, CGNAT and IPv6 ULA ranges by default | Post-DNS IP checked against loopback, link-local, cloud metadata and the host's own addresses. Private ranges only when listed in deniedResolvedAddresses |
| Network proxy | HTTP CONNECT + domain allow/block | HTTP + SOCKS5 + experimental TLS MITM |
| SSH git | No key: agent socket denied at kernel on macOS, on Linux only SSH_AUTH_SOCK is withheld. The transport is not blocked, ssh.github.com:443 is reachable | Proxied via SOCKS5 |
| Package manager scripts | Blocked by default (npm_config_ignore_scripts, pnpm_config_ignore_scripts, YARN_IGNORE_SCRIPTS, YARN_ENABLE_SCRIPTS). Yarn 2+ still runs the project's own scripts | Not blocked |
| Agent support | Copilot, OpenCode, Gemini, Antigravity, Pi, Claude Code, goose, DSH, Shell | Claude Code |
| Credential injection | Opt-in placeholder GitHub token, real token restored at the proxy (proxy.inject_github_token) | Opt-in masking and injection at the proxy for listed variables, AWS SigV4 re-signing (credentials.sigv4) |
| Config | TOML (global + per-repo) | JSON (~/.srt-settings.json or --settings) + --control-fd live updates |
| Library API | ❌ Binary only | ✅ Embeddable TypeScript library |
Both check where an allowed hostname resolves. They differ on private ranges: cplt blocks them by default, srt only when you list them. cplt has more on by default: env filtering, credential directory protection, lifecycle script blocking. srt is more flexible: SOCKS5, TLS inspection, per-request callbacks, library embedding. The Linux backend choice matters. bwrap needs workarounds on Ubuntu 24.04+ because of AppArmor userns restrictions, while Landlock requires kernel 5.13 or newer but has zero external dependencies.
GitHub Copilot CLI's own sandbox
Copilot CLI has a local sandbox, included in the standard seat and generally available since October 7, 2026. It runs shell commands through Microsoft Execution Containers
(MXC) with restricted filesystem, network and system access, on macOS, Linux
and recent Windows 11 builds. It is off by default. /sandbox enable or
--sandbox turns it on. With git and gh authentication enabled, sandboxed
tools get placeholders and a local proxy supplies the real credentials.
If that covers you, use it. It costs nothing extra, and it runs on Windows, which cplt does not.
Two things it does not do.
Policy lives with the administrator, not the repository. Enterprises set
sandbox policy through server-managed, MDM-managed or file-based managed
settings. Nothing sits next to the code, so a rule that matters for one
repository cannot follow it to a contributor, to CI, or to a machine the
administrator does not manage. In cplt the policy is
.cplt.toml in the repository. Reviewers see changes to it in the pull
request, and the file can tighten a developer's own configuration but never
loosen it.
It confines the process, not what the process does with credentials it
holds. The /sandbox tabs cover the filesystem, the network and system
capabilities. Masking keeps the token value out of the sandbox, but the
proxy still authenticates the agent's git and gh calls, so the agent keeps
your push access. Pushing a branch, merging a pull request and deleting a
repository are all well-formed API calls from an authorised client, and a
filesystem or network rule has no opinion about them. cplt wraps git and
gh instead. The agent commits, branches and rebases freely. gh pr merge,
gh repo delete and gh release create are blocked by default. So is
git push to main/master; feature-branch pushes still work, because
protect_default_branch_only is on. Set it to false to block every push, or
git_guard.mode = "warn" to only warn.
Running both is reasonable. MXC confines the process. The guards decide what the agent may do with the credentials it holds.
The user documentation is on ki-utvikling.nav.no.
Honest gaps
- macOS has the strongest file-level enforcement today. Linux coverage is improving but not identical.
- cplt does not yet offer simple read-only / workspace-write / full-access policy presets.
- If you want full container isolation, cplt is not trying to replace Docker.
Install
Homebrew (recommended)
brew install navikt/tap/cpltmise
mise use -g 'github:navikt/cplt@<version>'mise picks the right release asset for your platform and verifies its build provenance attestation.
Pin the version. Our version strings are not comparable semver — they carry
leading zeros and two hyphens — so mise latest can resolve to an older
release than the newest one (navikt/copilot#818).
apt (Debian/Ubuntu, recommended on Linux)
navikt/apt is a signed archive served over GitHub Pages, carrying cplt and nav-pilot for amd64 and arm64:
curl -fsSL https://navikt.github.io/apt/keyring/navikt-archive-keyring.gpg \
| sudo tee /usr/share/keyrings/navikt-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/navikt-archive-keyring.gpg] https://navikt.github.io/apt stable main" \
| sudo tee /etc/apt/sources.list.d/navikt.list
sudo apt update && sudo apt install cpltIt is a plain apt repository mirroring our releases, not a distribution package
with its own maintainer. Its publish job runs hourly and pulls the newest .deb
from each tool's latest release, so a release cut minutes ago takes up to an
hour to become installable that way.
The package puts the binary at /usr/bin/cplt. Upgrade it with
sudo apt update && sudo apt install --only-upgrade cplt. cplt update refuses
to touch an apt install and prints that command instead: replacing the binary behind dpkg's
back would be undone by the next apt run.
Without the archive, the same .deb is a release asset:
arch=$(dpkg --print-architecture) # amd64 or arm64
gh release download --repo navikt/cplt --pattern "*_${arch}.deb"
sudo apt install ./cplt_*_"${arch}".debcurl | bash
For distributions that are not Debian derivatives, and for CI:
curl -fsSL https://raw.githubusercontent.com/navikt/cplt/main/install.sh | bashOptions:
# Install a specific version
curl -fsSL ... | bash -s -- --version 2026.05.05-174753-75bae5b
# Install to a custom directory
curl -fsSL ... | bash -s -- --dir ~/.local/bin
# Skip Homebrew (force direct download)
curl -fsSL ... | bash -s -- --no-brewDownload from releases
Grab the latest build for your platform from GitHub Releases:
# macOS, Apple Silicon (M1/M2/M3/M4)
curl -fsSL https://github.com/navikt/cplt/releases/latest/download/cplt-aarch64-apple-darwin.tar.gz | tar xz
sudo mv cplt /usr/local/bin/
# macOS, Intel
curl -fsSL https://github.com/navikt/cplt/releases/latest/download/cplt-x86_64-apple-darwin.tar.gz | tar xz
sudo mv cplt /usr/local/bin/
# Linux, x86_64
curl -fsSL https://github.com/navikt/cplt/releases/latest/download/cplt-x86_64-unknown-linux-gnu.tar.gz | tar xz
sudo mv cplt /usr/local/bin/
# Linux, ARM64
curl -fsSL https://github.com/navikt/cplt/releases/latest/download/cplt-aarch64-unknown-linux-gnu.tar.gz | tar xz
sudo mv cplt /usr/local/bin/Every release tarball and .deb carries a build provenance attestation. The attestation covers the downloaded file, not the binary inside it, so download to a file and verify it before you extract or install. The pattern is the same for every release asset — download, then gh attestation verify <file> --repo navikt/cplt before you unpack or install it:
cplt-x86_64-apple-darwin.tar.gzcplt-aarch64-apple-darwin.tar.gzcplt-x86_64-unknown-linux-gnu.tar.gzcplt-aarch64-unknown-linux-gnu.tar.gzcplt_<version>_amd64.debcplt_<version>_arm64.deb
For example, on Linux x86_64:
curl -fsSLO https://github.com/navikt/cplt/releases/latest/download/cplt-x86_64-unknown-linux-gnu.tar.gz
gh attestation verify cplt-x86_64-unknown-linux-gnu.tar.gz --repo navikt/cplt
tar xzf cplt-x86_64-unknown-linux-gnu.tar.gzThe .deb carries a version in its name, so gh release download resolves it instead of a fixed URL:
arch=$(dpkg --print-architecture) # amd64 or arm64
gh release download --repo navikt/cplt --pattern "*_${arch}.deb"
gh attestation verify cplt_*_"${arch}".deb --repo navikt/cplt
sudo apt install ./cplt_*_"${arch}".debBuild from source
git clone https://github.com/navikt/cplt.git && cd cplt
cargo build --release
sudo cp target/release/cplt /usr/local/bin/Or with mise:
mise run installmise run install and manual builds put cplt in /usr/local/bin/cplt. If you also have the Homebrew build at /opt/homebrew/bin/cplt, put /usr/local/bin first in PATH so your development build wins:
# Check which cplt is active
which cplt
# If it shows /opt/homebrew/bin/cplt, reorder your PATH:
export PATH="/usr/local/bin:$PATH"Or just run /usr/local/bin/cplt explicitly and skip PATH resolution entirely.
Windows (WSL2)
The step-by-step guide for Nav developers is cplt on Windows (WSL2) on ki-utvikling.nav.no. This section is the reference behind it.
cplt has no Windows sandbox backend. Enforcement is Apple Seatbelt on macOS and Landlock LSM on Linux, so there is nothing to run natively on Windows. The supported route is WSL2, where cplt is an ordinary Linux install and the sandbox is kernel-enforced. Every Microsoft kernel branch builds CONFIG_SECURITY_LANDLOCK=y and lists landlock first in CONFIG_LSM (config-wsl), shipped since kernel 5.15.57.1, and WSL's default kernel command line sets no lsm= override.
In PowerShell, once:
wsl --install # WSL2 + the default distro (now Ubuntu 26.04 LTS), then reboot
wsl --install -d Ubuntu-24.04 # ...or pin an older release
wsl --update # keep the Microsoft kernel current, see the ABI note belowEverything below runs inside the distro (wsl, or the Ubuntu profile in Windows Terminal), not in PowerShell:
# 1. Node. Copilot CLI requires Node 22+
# Ubuntu 26.04 ships 22.x, so apt is enough:
sudo apt update && sudo apt install -y nodejs npm
# Ubuntu 24.04 ships Node 18, too old. Use nvm, fnm, or NodeSource there instead.
# 2. GitHub CLI, and log in. Ubuntu's universe package works but lags
# (2.45 on 24.04); add GitHub's apt repo if you want a current gh:
# https://github.com/cli/cli/blob/trunk/docs/install_linux.md
sudo apt install -y gh
gh auth login
# 3. The agent, installed in the distro, never on the Windows side
npm install -g @github/copilot
# 4. cplt, from the apt archive. The default distro is Ubuntu, so this is
# the same route as on any other Debian derivative.
curl -fsSL https://navikt.github.io/apt/keyring/navikt-archive-keyring.gpg \
| sudo tee /usr/share/keyrings/navikt-archive-keyring.gpg >/dev/null
echo "deb [signed-by=/usr/share/keyrings/navikt-archive-keyring.gpg] https://navikt.github.io/apt stable main" \
| sudo tee /etc/apt/sources.list.d/navikt.list
sudo apt update && sudo apt install cplt
# 5. Check the result
cplt doctorDo not install Copilot CLI on the Windows side. With interop on (the default), the Windows PATH is appended to the distro's, so a Windows-side npm install -g @github/copilot turns up inside the distro as /mnt/c/Users/<user>/AppData/Roaming/npm/copilot. That is a Windows install reached through interop. It cannot run in the Linux sandbox, and the npm shim execs a node that the distro will not have unless you installed one there too. The symptom used to be an unrelated runtime-extraction error. cplt now names the cause when it resolves an agent under /mnt/<drive>/ and it is running under WSL, and cplt doctor reports it as a failing check instead of passing (#188). WSL is detected from kernel-owned state, either /run/WSL or the kernel name in /proc/sys/kernel/osrelease and /proc/version, not from WSL_DISTRO_NAME, which is absent under sudo and in systemd units and which any process can set. On a plain Linux box /mnt/c is left alone. It is an ordinary mount point there.
That check has two limits, both deliberate. It keys on the default automount root, so if you have relocated it ([automount] root in /etc/wsl.conf) the Windows-side install is not recognised and you get the old, less helpful failure with the path in it. And turning interop off stops the Windows PATH from leaking in but does not unmount /mnt/c.
Kernel and Landlock ABI. Current WSL (2.7.x and later) ships Linux 6.18, which gives Landlock ABI 7 — everything cplt uses except the unix-socket connect() right, which needs ABI 9 (kernel 7.1). An install still on the 6.6 kernel line gets ABI 3: filesystem rules are enforced, but TCP port rules (ABI 4), ioctl restriction (ABI 5) and signal/abstract-socket scoping (ABI 6) are not available, and network filtering falls back to the CONNECT proxy. wsl --update moves you forward. cplt doctor prints the kernel version and the ABI it found, which is the check that matters on your machine.
Do not disable Landlock in
.wslconfig. A[wsl2] kernelCommandLinewith anlsm=list that omitslandlock, or a custom[wsl2] kernel=built withoutCONFIG_SECURITY_LANDLOCK, removes the kernel enforcement cplt depends on, andcplt doctorwill report Landlock as unavailable.
Keep the project in the Linux filesystem. Work in ~/src/... inside the distro rather than /mnt/c/Users/.... Microsoft's own guidance is that cross-OS file access is markedly slower, and /mnt/c is served over 9p by default as of WSL 2.9.x (virtiofs is opt-in via [wsl2] virtiofs=true). More to the point, we have not verified how Landlock enforces rules on that mount. The kernel documents no exclusion for network- or FUSE-backed filesystems, only pipes, sockets and nsfs, and Landlock's own test suite exercises 9p and FUSE, so we expect it to work. Nobody here has confirmed it. Treat a project under /mnt/c as unproven rather than supported.
Bubblewrap. Ubuntu 23.10+ blocks unprivileged user namespaces through kernel.apparmor_restrict_unprivileged_userns, which breaks bwrap. That sysctl comes from an Ubuntu kernel patch that is absent from the Microsoft kernel, so the optional Bubblewrap layer is expected to work on Ubuntu-under-WSL2. That is inference from the kernel source, not something we have run. If bwrap fails there, please say so in #189. cplt's own seccomp filter is a plain PR_SET_SECCOMP BPF program, which stacks on top of the filter WSL installs in every process.
Reporting a problem. Paste the output of cplt doctor. It shows the kernel, the Landlock ABI and whether Bubblewrap is active or why not, with your home directory written as ~ and a Windows profile as /mnt/<drive>/Users/~. cplt doctor --verbose adds the effective allow.read, allow.write and deny.paths grants and the tools cplt found, but prints absolute paths, so check it before you paste it anywhere public. It also warns about a project under /mnt/<drive>/ and about a grant on /dev without Bubblewrap, which re-exposes the terminals of your other windows (known impacts).
Not yet verified on a real WSL2 install. Verified from source: Landlock is compiled in and first in
CONFIG_LSMon the Microsoft kernel; the/mnt/<drive>/detection, the WSL signals it uses, and their error text; thatcplt doctorfails on such an agent and prints kernel + Landlock ABI; the 5.13+/6.7+ requirements; and thatinstall.shinstalls the Linux release binary. Still unverified by anyone here: how Landlock behaves on/mnt/c, whether Bubblewrap works under WSL2, the exact package versions your distro release ships, and the sequence above end to end. If you run it, please report what actually happened in #189.
Shell setup (recommended)
By default you get the sandbox by typing cplt. To make plain copilot run sandboxed too:
cplt --shell-installThat detects your shell, appends the alias to your rc file, and prints what it did. Run it as many times as you like, it will not add duplicates.
--agent picks which command gets the alias, and every agent cplt can launch is available:
cplt --shell-install --agent opencode # 'opencode' runs sandboxed
cplt --shell-install --agent claude # and 'claude', alongside the othersEach install adds to your rc file rather than replacing what is there, so you can sandbox as many agents as you use. Without --agent you get copilot, which is what the flag has always installed.
| Shell | File modified | What's added (for --agent opencode) |
|---|---|---|
| zsh (macOS default) | ~/.zshrc | eval "$(cplt --shell-setup --agent opencode)" |
| bash | ~/.bashrc | eval "$(cplt --shell-setup --agent opencode)" |
| fish | ~/.config/fish/conf.d/cplt.fish | alias opencode 'cplt --agent opencode' |
--agent antigravity installs aliases for both antigravity and agy, since either name starts the same agent.
Restart your shell or source the file to activate.
There is no alias for --agent shell: there is no shell binary to shadow. Type cplt --agent shell for a sandboxed shell, or cplt exec -- <command> for a single command.
Manual setup (alternative)
If you would rather not use --shell-install, add the line yourself:
# zsh / bash
eval "$(cplt --shell-setup --agent opencode)"
# fish
alias opencode 'cplt --agent opencode'Same pattern mise, direnv, and starship use.
Why each alias names its agent. alias opencode=cplt would not do what it looks like. Plain cplt picks its agent from --agent, then the config file, then whatever it finds in PATH — and PATH detection prefers copilot. Typing opencode would sandbox Copilot instead, with nothing on screen to say so. The alias passes --agent so the command you type is the agent you get.
Why an alias instead of a symlink? cplt and Copilot CLI install into the same Homebrew bin directory (/opt/homebrew/bin/), and only one file named copilot can live there, so a symlink would conflict. An alias sidesteps that. The real copilot binary stays in PATH where cplt can find and wrap it, and the alias redirects your command.
Note: cplt refuses to nest. If it detects that it is already running inside a sandbox (via the
__CPLT_WRAPPEDenvironment variable), it will not launch again. Read-only subcommands such as--print-profileandcplt doctorstill work inside an existing sandbox.
PATH shims (opt-in)
An alias only reaches an interactive shell that read your rc file. It does nothing for zsh -c, a script, command copilot, or an agent your IDE starts. PATH shims cover those:
cplt --shell-install --shims # opt in
cplt doctor # check that each shim comes first on PATH
cplt --shell-uninstall # undo all of itThe install does three things and prints each path it touches:
- It creates
~/.local/share/cplt/binand writes one small script there for each agent it finds installed (antigravityandagyboth get one). Each script runscplt --agent <name> -- "$@", so the name you type is the agent you get and your arguments pass through untouched. - It puts that directory first on PATH with a marked block (
# >>> cplt >>>…# <<< cplt <<<) in~/.zshenv,~/.zprofileand~/.profile, creating them if missing, and in~/.bash_profileand~/.bash_loginif they exist. It adds one alias line to~/.zshrc, to~/.bashrcif it exists, and to fish'sconf.d/cplt.fishif you use fish. An alias line an earliercplt --shell-installwrote is left where it is. Each line is skipped inside a cplt sandbox, so azsh -cthe agent runs finds the real agent. Before its first edit to a file that already existed, cplt copies it to<file>.cplt-backup. WithZDOTDIRset, the install stops and prints the line to add yourself. - From then on,
--shell-setupand everycpltlaunch add a shim for an agent you installed later. They never remove one, since the PATH they see may be an IDE's or a project's, and never run an agent binary.cplt doctoralso adds shims (except an unvettedgooseorpi), and--shell-install --shimsagain also removes the shims of agents that are gone. This never touches the network, and it does nothing at all until you have run the install.
Nothing changes for anyone who has not run --shell-install --shims: no directory, no rc edits, and the sandbox profile stays byte-identical.
Uninstall removes the blocks, the backups, the files and directories the install created and the shims (the install lists them in ~/.local/share/cplt/bin/.manifest), and leaves anything it did not write. A line you added inside a cplt block, or a file you put in the shim directory, makes it stop and tell you rather than delete it. Run it before you remove cplt itself; a shim left behind with no cplt on PATH refuses to start the agent (exit 127) instead of running it unsandboxed.
What the shims do not cover: an agent started by absolute path (/opt/homebrew/bin/copilot), a PATH that mise activate or another rc line reordered after the block, bash -c without BASH_ENV, and an app launched by macOS that never ran a shell. cplt doctor warns when something on PATH shadows a shim, and fails when the shim directory is not on PATH at all.
goose and pi share their names with unrelated tools (pressly/goose is a Go migration tool), so --shell-install --shims checks their --version output before shimming them, and remembers a binary whose output was not the agent (a timeout or error is not remembered). cplt doctor never runs the check: it lists an unvetted binary and tells you to run the install. If it still picks up the wrong binary, leave the agent out: cplt config set shell.skip goose.
Usage
cplt [OPTIONS] [-- <AGENT_ARGS>...]
Everything after -- goes straight to the agent process (copilot, opencode, gemini, antigravity, pi, claude, goose, dsh, or shell).
Policy presets
A preset sets a baseline for the five main sandbox toggles with one flag instead of a list of them. Individual flags still win over the preset, so --preset permissive --no-allow-tmp-exec does what it says. Also settable as [sandbox] preset = "..." in config.
| Flag | What it does |
|---|---|
--preset strict | Full network lockdown. All five toggles off, plus gh_guard, git_guard, proxy.forced (forced-proxy egress) and proxy.default_allowlist (fail-closed domain allowlist) on. Escape hatch: --allow-all-domains disables just the allowlist |
--preset standard | The current defaults. All five off, scratch dir stays on. Same as passing no preset |
--preset permissive | Turns on allow_localhost_any, allow_tmp_exec, and allow_lifecycle_scripts |
--preset full-trust | ⚠️ Dangerous. Turns on all five, adding allow_env_files and allow_docker |
Full preset matrix and resolution order: docs/configuration.md.
File access
The project directory is the writable workspace, plus a narrow allowlist needed for auth, runtime, and tooling (see the table above). The kernel blocks everything else, SSH keys and cloud credentials included.
| Flag | What it does |
|---|---|
-d, --project-dir <DIR> | Which directory Copilot can work in. Defaults to the current git repo root |
--allow-read <PATH> | Let Copilot read files outside the project, read-only. Repeatable |
--allow-write <PATH> | Let Copilot read and write outside the project. Use carefully. Repeatable. The tree is writable but not executable — a tree that is both is a binary-drop path, so an allow.write over ~/.cargo also stops ~/.cargo/bin running. Use --allow-exec on a separate, non-overlapping tree when you need both |
--allow-write-state <PATH> | Same grant as --allow-write, for a tool's own state (caches, session stores). The exit audit leaves these paths out of its "writable paths NOT audited" warning; --verbose lists them as tool state. Repeatable |
--allow-exec <PATH> | ⚠️ Dangerous. Let the agent execute binaries from a tree outside the default tool directories — a relocated Homebrew or toolchain prefix, say. Grants read and execute, never write. Repeatable. Refused for an unsafe root (/, /tmp, $HOME and its parents, the platform system directories) and for any tree that overlaps a writable one — the project directory, an --allow-write grant, a writable tool directory such as ~/.cache, a writable agent data directory (~/.claude, ~/.local/share/opencode, ~/.pi/agent and the like), the real .git of a worktree or bare repo, or a tree the backends make writable with no grant at all (/tmp and /dev/shm on Linux; /private/tmp and /private/var/folders on macOS): writable plus executable is a binary-drop path, and neither backend can subtract the write grant from the exec grant |
--allow-socket <PATH> | ⚠️ Dangerous. Allow a Unix domain socket path, for example a custom LSP daemon or a database socket. Repeatable. Whatever is on the other end runs outside the sandbox, so pointing this at docker.sock or an agent socket is equivalent to --allow-docker, and the only guard is that --deny-path overlaps are rejected. On Linux it does nothing below kernel 7.1, since unix socket connects are not gated by Landlock before ABI v9 (see Linux limitations) |
--deny-path <PATH> | Block a path that would otherwise be allowed. Deny always wins. Repeatable |
--deny-env <VAR> | Strip an environment variable, even one named in --pass-env or let through by --inherit-env. Same as deny.env in .cplt.toml. nav-pilot uses it to keep OpenAI, xAI and Google keys from OpenCode's voice mode. Repeatable |
--allow-port <PORT> | Allow outbound traffic on an extra port. Only 443 by default. Repeatable. On macOS the rule is (remote tcp "*:PORT"), so UDP to that port stays blocked (#815); on Linux Landlock gates TCP connect only and UDP is open outside proxy.forced. Under proxy.forced the port opens no direct socket at all — it is reachable through the proxy, so proxy-aware tools keep working |
--allow-localhost <PORT> | Allow outbound to localhost on one port. Localhost is blocked by default. Use for MCP servers or dev servers. Repeatable |
--allow-localhost-any | Allow outbound to localhost on all ports. Needed by build tools like Turbopack (Next.js) and Vite that use random ephemeral ports for IPC |
Environment variables
cplt sanitizes the child environment by default. Only safe variables pass through, and cloud credentials, database URLs, and package tokens are stripped. It also injects hardening variables that block npm/yarn/pnpm lifecycle scripts (postinstall hooks, the number one supply chain attack vector), disable git commit and tag signing (since ~/.ssh and ~/.gnupg are unreachable inside the sandbox), and opt out of developer tooling telemetry (DO_NOT_TRACK=1, NEXT_TELEMETRY_DISABLED=1, TURBO_TELEMETRY_DISABLED=1, CHECKPOINT_DISABLE=1, and others).
What passes through:
| Category | Examples | How |
|---|---|---|
| Core system | HOME, USER, PATH, SHELL, TMPDIR, LANG | Explicit allowlist |
| Terminal | TERM, COLORTERM, TERM_PROGRAM | Explicit allowlist |
| Editor | EDITOR, VISUAL, PAGER | Explicit allowlist |
| Auth tokens | GH_TOKEN, GITHUB_TOKEN, COPILOT_GITHUB_TOKEN | Passed only if you already set them. The gh guard uses a one-time file instead |
| Copilot config | COPILOT_DEBUG, COPILOT_* | Prefix allowlist |
| Language runtimes | NODE_*, GOPATH, CARGO_HOME, JAVA_HOME, VIRTUAL_ENV, PYTHONPATH | Explicit allowlist |
| Tool managers | NVM_*, FNM_*, PYENV_*, MISE_*, SDKMAN_*, COREPACK_*, YARN_* | Prefix allowlist |
| OpenTelemetry | OTEL_EXPORTER_OTLP_ENDPOINT, OTEL_SERVICE_NAME, OTEL_RESOURCE_ATTRIBUTES, OTEL_* | Prefix allowlist; the Authorization entry in OTEL_EXPORTER_OTLP_HEADERS (and the _TRACES/_METRICS/_LOGS variants) is removed unless named with --pass-env; with sandbox.strip_otlp_vendor_auth = true (written by cplt config init) so are x-honeycomb-team, api-key, x-api-key and dd-api-key; other headers pass. This applies in sanitized mode only, not with inherit_env |
| XDG dirs | XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME | Explicit allowlist |
| gh config | GH_CONFIG_DIR | Explicit allowlist. Its hosts.yml and config.yml are readable |
Prefix allowlist with secret-suffix protection. A variable matching an allowed prefix such as COPILOT_* or YARN_* still gets dropped if it ends in a secret-bearing suffix: _TOKEN, _AUTH, _SECRET, _SECRET_KEY, _KEY, _PASSWORD, or _CREDENTIALS. So COPILOT_DEBUG passes and COPILOT_API_KEY does not.
Always blocked: AWS_*, AZURE_*, NPM_TOKEN, DATABASE_URL, VAULT_TOKEN, SSH_AUTH_SOCK, Docker vars, CI tokens, and anything not in the allowlist.
| Flag | What it does |
|---|---|
--pass-env <VAR> | Pass one environment variable through to the agent. Repeatable |
--inherit-env | ⚠️ Dangerous. Inherit the full parent environment. Strips only NO_COLOR, FORCE_COLOR, SSH_AUTH_SOCK, SSH_AGENT_PID. Debugging only |
Sandbox toggles
| Flag | What it does |
|---|---|
--allow-lifecycle-scripts | Allow npm/yarn/pnpm lifecycle scripts (postinstall hooks) to run. Blocked by default. Use when npm install needs them |
--allow-gpg-signing | Allow GPG commit and tag signing inside the sandbox. Grants read-only access to the public keyring and the GPG agent socket. Private keys stay denied. See GPG signing |
--allow-jvm-attach | Allow JVM Attach API unix sockets in /tmp. Needed for MockK inline mocking, Mockito inline agents, ByteBuddy. See JVM Attach API |
--allow-msbuild | Allow MSBuild worker-node unix sockets in /tmp. Needed for dotnet build. Does not enable the persistent MSBuild Server. See MSBuild worker-node IPC |
--no-scratch-dir | Disable the per-session scratch directory, which is on by default. TMPDIR will not be redirected |
--scratch-dir | Explicitly enable the per-session scratch directory. Already the default, so this is for overriding scratch_dir = false in config |
--brief | Write the agent-facing sandbox brief to the scratch dir (CPLT_BRIEF.md) and load it into Copilot and OpenCode as instructions. On in configs written by cplt config init. Also sandbox.brief = true in config |
--no-brief | Turn the sandbox brief off for this run, overriding sandbox.brief = true in config. Also suppresses the AGENTS.md block, which is gated on the brief |
--agents-md | 🧪 Experimental. With --brief, also write the managed cplt block into the project's AGENTS.md. Off by default. Also sandbox.agents_md = true in config. No effect without --brief. Unstable, so it may change or be removed in a future release |
--no-agents-md | Turn the AGENTS.md block off for this run, overriding sandbox.agents_md = true in config. Leaves the scratch-dir brief alone |
--allow-tmp-exec | ⚠️ Dangerous. Allow exec from system temp dirs (/private/tmp, /private/var/folders). Prefer the scratch dir |
--allow-cache-exec <SUBDIR> | Allow exec from one ~/Library/Caches/<SUBDIR>. Repeatable. For tools that cache compiled binaries there, such as Playwright and pnpm dlx |
--allow-cache-exec-any | ⚠️ Dangerous. Allow exec from all of ~/Library/Caches. Prefer --allow-cache-exec <SUBDIR> |
--allow-browser | ⚠️ Dangerous. With this on, the agent can launch any application on your machine outside the sandbox. The grant is Launch Services, not a browser: launchd starts the target outside the Seatbelt profile, so open -a Terminal /tmp/x.sh runs unsandboxed. This cannot be scoped to URLs — SBPL's lsopen takes no filter, and the grant is reachable through LSOpenCFURLRef() without the open binary at all, so no wrapper can narrow it (#251, and docs/security.md). Only turn it on while a sign-in prompt is actually on screen (MCP server OAuth, re-auth), then turn it back off. Off by default |
--deny-clipboard | Block the agent from reading or writing the macOS clipboard (pbpaste/pbcopy) by denying the com.apple.pasteboard Mach service. Every other Mach service (Keychain, DNS, Security framework) is unaffected. On by default — this flag restates the default |
--allow-clipboard | Give the agent back the macOS clipboard, which cplt denies by default. Equivalent to sandbox.deny_clipboard = false |
--use-bubblewrap | Linux only. Require the bubblewrap namespace layer (PID, mount, IPC, UTS, cgroup, user namespaces plus a private /tmp) on top of Landlock and seccomp. Errors out if bwrap is missing. Auto-detected when neither flag is given |
--no-bubblewrap | Linux only. Never use bubblewrap, even when installed. Falls back to Landlock and seccomp. Use it when bwrap breaks a specific tool |
sandbox.allow_git_worktrees (config only, macOS) | Let sub-agents create worktrees with cplt worktree add <name> <ref> under a cplt-owned root for this repository. Git runs under the session read policy; protected files must match launch HEAD. Off by default. See Worktrees for sub-agents |
Supported runtimes
cplt auto-discovers installed tools and writes sandbox rules to match. Generally only directories that exist on disk get rules, so there are no phantom paths. On macOS, writable app directories are included when discovered even if they do not exist yet, so they can be created on first use. Linux cannot allow a write to a non-existent path, so creation has to happen outside the sandbox there.
| Runtime | Home dirs | Env vars / prefixes | Discovery |
|---|---|---|---|
| Node.js | .nvm, .local/share/fnm, .local/bin | NODE_*, NPM_*, NVM_*, FNM_* | node |
| Rust | .cargo, .rustup | CARGO_HOME, RUSTUP_HOME | cargo |
| Go | go/bin, go/pkg | GOPATH, GOROOT, GOCACHE, GOFLAGS, etc. | go |
| Java/Kotlin (JVM) | .sdkman, .jenv, .gradle, .m2 | JAVA_HOME, JAVA_TOOL_OPTIONS, GRADLE_*, MAVEN_*, SDKMAN_*, JENV_* | java, gradle |
| Kotlin Native | .konan | none | none |
| Python | .pyenv | VIRTUAL_ENV, PYTHONPATH, PYENV_ROOT, PYENV_* | python3 |
| Yarn Berry | .yarn | YARN_* (hardening overrides YARN_ENABLE_SCRIPTS) | yarn |
| pnpm | Library/pnpm, .local/share/pnpm | PNPM_HOME | pnpm |
| Corepack | none | COREPACK_* | none |
| mise | .local/share/mise, .mise | MISE_* | mise |
Run cplt doctor to see whether cplt will work here for your agent, and cplt doctor --verbose for everything it detected on your machine. Its guard: lines show whether the git and gh guards would let a feature-branch push, a push to main and gh pr merge through in this repository. The settings: line shows keychain_substitute, quiet, the guard modes, allow_cache_exec and the scratch dir as this launch resolves them. On macOS the keychain: line says whether the agent keeps the login Keychain grant, and why. The auth: line names where the agent's credential comes from, by variable name and never by value: an exported GH_TOKEN, GITHUB_TOKEN, COPILOT_GITHUB_TOKEN or CLAUDE_CODE_OAUTH_TOKEN, gh auth token, or the agent's own login. When Copilot gets an exported token in place of the Keychain, doctor warns: cplt passes the token on unchecked, so a stale one fails sign-in with no fallback. Unset it, run gh auth refresh, or set sandbox.keychain_substitute = false. It also reports settings the launch refuses or ignores: a cache-exec or Cypress path the launch would refuse, global-only keys in .cplt.toml, gh_guard.inject_token while the guard is off (or, on macOS, where it no longer has an effect), allow_localhost_any under proxy.forced, a token variable in pass_env that gh no longer needs, and a deny.env that strips every token variable the agent signs in with.
On Linux, cplt doctor --fix offers to fix two host problems: bubblewrap not installed (apt-get or dnf, picked from /etc/os-release), and Ubuntu's AppArmor block on bwrap's user namespaces (it writes /etc/apparmor.d/bwrap and loads it, or loads Ubuntu's own bwrap-userns-restrict profile when that exists). It prints the exact commands and file content, asks Fix now? [Y/n], runs them with sudo, then checks again. The same offer comes up when a launch refuses the MCP broker for one of these reasons. If you answer no there, later launches of the same cplt version only print a one-line hint. The commands are a fixed list in cplt; nothing from config or the environment is run. cplt only offers in an interactive terminal: with --yes, without a terminal, with CI set or inside a cplt sandbox it prints the commands for you to run.
Doctor also warns about settings that let the agent start but break things mid-session. With scratch_dir off, exec from the system temp dir is denied and the git and gh guards are not installed. On macOS with quiet on, cplt does not record the upstream after git push -u. With Copilot 1.0.83 or later and its own command sandbox turned on in ~/.copilot/settings.json, cplt turns that sandbox off for the session; if you pass COPILOT_CLI_SANDBOX_SUPPORT_OVERRIDE through, Copilot's sandbox fails to start instead. On Linux, doctor warns when Copilot may depend on a keyring login; native access depends on the backend and agent version, and the warning does not prove the Secret Service is blocked. See Linux keyring access. If Copilot asks for /login on Linux, see Copilot asks for /login on Linux. Doctor also warns about a repository under allow.write that is not a named root, so its scripts cannot run, and about a repository under a cache or temp dir where exec is denied.
In --verbose, the auth lines for token variables, the gh login and keytar show only for Copilot, and a tool reached through a mise shim shows as via mise.
The default output is written to be pasted into an issue. --verbose prints absolute paths and is not. --agent works on either side of doctor: cplt doctor --agent claude is the same as cplt --agent claude doctor.
Debugging
| Flag | What it does |
|---|---|
--doctor | Deprecated. Use the cplt doctor subcommand instead |
--print-profile | Print the generated sandbox profile (SBPL) and exit |
--show-denials | Stream macOS sandbox denial logs in real time |
--no-validate | Skip the startup check that verifies sandbox restrictions are active |
-y, --yes | Skip the interactive confirmation prompt. On a TTY cplt asks only on the first launch in a project and when the profile changed since the last confirmed launch; with --quiet it asks every time, since the table is hidden. Required when stdin is not a TTY, so CI and scripts need it. Also sandbox.yes = true in config (cplt config set sandbox.yes true), which skips the prompt on every launch; --no-yes brings it back for one run |
-q, --quiet | Suppress the startup banner, the exit audit and non-essential messages. Errors and warnings still print. Without --yes, cplt asks for confirmation every launch, since the table is hidden. Also sandbox.quiet = true in config |
--no-quiet | Override sandbox.quiet = true and show the startup summary anyway |
--no-audit | Skip the post-session change report. cplt normally diffs the working tree against a baseline commit pinned before the run and lists what the session touched, flagging sensitive paths. -q suppresses it too |
--init-config | Create a starter config file at ~/.config/cplt/config.toml and exit |
Session flags
These translate into the agent's own session flags, so you do not need a -- separator.
| Flag | What it does |
|---|---|
--resume[=SESSION] | Resume a previous session. Bare --resume picks interactively, --resume=NAME picks by name or ID |
--continue | Resume the most recent session in the current directory |
--remote | Enable remote control, so you can monitor and steer the session from GitHub.com or mobile |
--name SESSION | Name the session so --resume=NAME can find it later |
--continue and --resume are mapped for OpenCode, Antigravity, and Claude Code too:
| cplt flag | Copilot | OpenCode | Antigravity (agy) | Claude Code |
|---|---|---|---|---|
--continue | --continue | --continue | --continue | --continue |
--resume | --resume | --continue¹ | --continue¹ | --resume |
--resume=ID | --resume=ID | --session ID | --conversation ID | --resume ID |
--remote | --remote | ignored | ignored | ignored |
--name NAME | --name NAME | ignored | ignored | ignored |
¹ Neither OpenCode nor Antigravity has an interactive session picker, so a bare --resume means "continue last session". Claude Code has one, so it maps straight across.
--remote is Copilot-only, and --name reaches Copilot and goose only. Pi, DSH and shell mode get no translation at all, so all four flags are dropped for them. cplt warns about each flag it drops. Auto-resume is a separate mechanism: when you invoke cplt with no pass-through args and no session flags, it appends --resume for you, and that applies to Copilot only.
Combine them with sandbox flags and -- pass-through args:
cplt --resume=my-task # resume by name
cplt --remote --name my-task -- -p "fix tests" # remote + named + promptAgents
Pick one with --agent <name>, or make it the default with cplt config set sandbox.agent <name>. Copilot, OpenCode, and Antigravity are auto-detected from PATH in that order when you do not name one.
| Agent | --agent value | Auto-detected | Auth |
|---|---|---|---|
| GitHub Copilot CLI | copilot | yes, priority 1 | GitHub token, from the Keychain or gh |
| OpenCode | opencode | yes, priority 2 | Copilot subscription via /connect, or --pass-env ANTHROPIC_API_KEY; v2: own sandboxed service per session, needs Bubblewrap on Linux (#710) |
| Antigravity CLI | antigravity, aliases agy and agi | yes, priority 3 | Google OAuth in the browser |
| Pi | pi | no | --pass-env ANTHROPIC_API_KEY and friends |
| Claude Code | claude, aliases cc and claude-code | no | Subscription OAuth in ~/.claude or the Keychain, CLAUDE_CODE_OAUTH_TOKEN (drops the Keychain grant), or --pass-env ANTHROPIC_API_KEY |
| DeepSeek Harness | dsh, aliases deepseek and deepseek-harness | no | --pass-env DEEPSEEK_API_KEY, or $DSH_HOME/.env (~/.dsh/.env) |
| Your shell | shell | no | none |
- Pi, Claude Code, goose, and DeepSeek Harness are never auto-detected.
pianddshare generic binary names that could collide with something else on your machine, and Claude Code has to be chosen on purpose. - Third-party API keys are opt-in.
ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY,OPENROUTER_API_KEY,ANTHROPIC_AUTH_TOKEN,CLAUDE_CODE_OAUTH_TOKENand the Bedrock/Vertex routing vars (CLAUDE_CODE_USE_BEDROCK,AWS_BEARER_TOKEN_BEDROCK,CLAUDE_CODE_USE_VERTEX,ANTHROPIC_VERTEX_PROJECT_ID,GOOGLE_CLOUD_PROJECT) never pass through unless you name them with--pass-env. - Subscription auth needs no env var. OpenCode's
/connectdevice flow stores its token in~/.local/share/opencode/auth.json, and Claude Code's OAuth token lives in~/.claude(.credentials.jsonon Linux) or the macOS Keychain. Both are reachable inside the sandbox, so cplt does not nag about a missing API key for either. - OAuth browser flows need
--allow-browserwhen a sign-in prompt appears. That covers Antigravity; every other agent here uses a device flow that prints a code and a URL and needs no browser. The flag lets the agent launch any application outside the sandbox and cannot be narrowed to URLs, so turn it on for the sign-in and off again — see the flag table and docs/security.md. - Claude Code auto-update is disabled with
DISABLE_AUTOUPDATER=1. Claude Code has no--no-auto-updateflag, self-updating inside the sandbox is a persistence vector, and it would fail against read-only install paths anyway. CLAUDE_CONFIG_DIRis honored. When it is set, cplt grants that directory instead of~/.claudeand passes the variable through, so a relocated config root keeps working.- OpenCode is an officially supported Copilot client, so your existing Copilot subscription works with
/connectinside OpenCode.
Per-agent config dirs, Keychain use, exec permissions, and env isolation are in SECURITY.md.
goose support
cplt can sandbox goose, the open-source AI agent (binary goose). Verified against goose 1.48.0.
# Run goose (must be explicit — not auto-detected)
cplt --agent goose
# goose is provider-agnostic — pass your provider's API key
cplt --agent goose --pass-env ANTHROPIC_API_KEY
cplt --agent goose --pass-env OPENAI_API_KEY
# Skip the keyring entirely: keep the key in the environment
GOOSE_DISABLE_KEYRING=1 cplt --agent goose --pass-env OPENAI_API_KEY --pass-env GOOSE_DISABLE_KEYRING
# Set goose as your default agent
cplt config set sandbox.agent gooseSecurity notes for goose:
- Not auto-detected: select explicitly with
--agent gooseor setsandbox.agent = "goose"in config - Provider-agnostic: goose routes model traffic to a user-configured provider (Anthropic, OpenAI, Google, Databricks, OpenRouter, …). Common provider keys (
ANTHROPIC_API_KEY,OPENAI_API_KEY,AZURE_OPENAI_API_KEY,GOOGLE_API_KEY,DATABRICKS_HOST/DATABRICKS_TOKEN,GROQ_API_KEY,OPENROUTER_API_KEY,XAI_API_KEY,AWS_BEARER_TOKEN_BEDROCK) are recognized auth hints and must be passed via--pass-env. goose readsGOOGLE_API_KEY, notGEMINI_API_KEY. Any provider outside this subset still works: name its variable with--pass-env - No default domains: goose contacted no host of its own in an
--observe-domainscapture, so its built-in allowlist is the shared package-registry base only. Add your provider's domain viaallowed_domainsbefore enabling--default-allowlist - Keychain is granted, and you can avoid it: goose stores provider secrets in the macOS login Keychain by default, so cplt grants it — but that grant is broader than goose's own entry (#242).
GOOSE_DISABLE_KEYRING=1makes goose use asecrets.yamlin its config dir instead, and passing the key with--pass-envavoids stored secrets altogether. On Linux goose uses the D-Bus Secret Service, which the Keychain grant does not affect - Config dir is read-only:
~/.config/goose/config.yamldeclaresextensions:entries whosecmdgoose spawns on every session start, so a writable config dir is a host-persistence vector. Normal sessions do not write it;/modechanges and persisted tool permissions do not survive a sandboxed run. Reconfigure withgoose configureoutside cplt - goose's data (
~/.local/share/goose/) and state (~/.local/state/goose/) dirs are writable, with exec denied. goose uses these XDG paths on macOS too, and honours theXDG_*overrides there --continueand bare--resumemap togoose session --resume;--resume=IDtogoose session --resume --session-id ID;--name Xtogoose session --name X. These are subcommand flags, so cplt injects thesessionsubcommand with them.--remoteis ignored (no goose equivalent)
DeepSeek Harness support
cplt can sandbox DeepSeek Harness (binary dsh), the plugin-oriented agent harness from DeepSeek. Upstream ships it as a developer preview and its own SAFETY.md says not to rely on its controls as the only boundary, which is the case cplt exists for.
# Run DSH (must be explicit — not auto-detected)
cplt --agent dsh
# Pass the API key, or keep it in $DSH_HOME/.env
cplt --agent dsh --pass-env DEEPSEEK_API_KEY
# Set DSH as your default agent
cplt config set sandbox.agent dshSecurity notes for DSH:
- Not auto-detected: select it with
--agent dsh(aliasesdeepseek,deepseek-harness) or setsandbox.agent = "dsh".dshis a short, generic command name that could belong to something else on your machine - Turn DSH's own sandbox off inside cplt: DSH wraps every shell and file tool call in its own process sandbox — Seatbelt on macOS, bwrap or Landlock on Linux. Neither nests inside cplt. macOS does not support nested
sandbox-execcalls (the same limitation that makes cplt turn Gradle's inner sandbox off, see Limitations), and bwrap builds its namespace withunshare, which cplt's seccomp filter denies. cplt is the enforcing boundary either way, so pick DSH's shippeddanger-full-accesspermission preset for sandboxed sessions. Leave the inner runner on and tool calls fail with a sandbox runner error rather than a task error - One home root, and cplt follows the override: DSH keeps sessions, settings, cache and profiles under
$DSH_HOME(~/.dshby default).DSH_HOMEis on the env allowlist, so the child resolves the same root cplt grants. A value pointing at a system root or your home directory is refused before launch, the same vetoCLAUDE_CONFIG_DIRgoes through - Host-persistence guard:
$DSH_HOME/cordis.patch.yml, the home-level overlay the Loader reads at boot, is write-denied.$DSH_HOME/profiles/stays writable because DSH rewrites each profile'scordis.ymlinclude-root on every boot, so a per-profilecordis.patch.ymland installed plugins are a documented residual — do profile anddsh pluginedits outside cplt, and always launchdshthrough cplt so anything planted still runs sandboxed - Default domains:
deepseek.comonly. The shippeddsh-llm-deepseekadapter defaults tohttps://api.deepseek.com. PointDEEPSEEK_BASE_URLat a gateway and you have to add that gateway's domain viaallowed_domains - Auth: pass the key with
--pass-env DEEPSEEK_API_KEY, or keep it in$DSH_HOME/.env. A key saved through DSH's own models UI lands in$DSH_HOME/.credentials.yaml, inside the same writable root. The macOS Keychain is denied, sogit pushover HTTPS needsgh's token inhosts.ymlor--pass-env GH_TOKEN
Shell mode
Run a plain sandboxed shell with no AI agent and the same restrictions. Handy for testing build tools, debugging sandbox issues, or just working carefully by hand.
# Interactive sandboxed shell (uses $SHELL: fish, zsh, bash)
cplt --agent shell
# Inspect what's allowed without entering the shell
cplt --agent shell --print-profileThe same deny-by-default rules apply: filesystem isolation, network restrictions, env sanitization. Shell config directories (fish variables and history, zsh history) stay writable.
For a single command, cplt exec is cleaner than cplt --agent shell -- -c 'cmd'.
Exec mode
Run any command inside the sandbox without starting an agent. No startup banner, no confirmation prompt, so it suits scripts, pipes, and shell aliases.
# Sandbox a single command
cplt exec -- npm install
cplt exec -- make build
cplt exec -- go test ./...
# Compound commands via $SHELL -c
cplt exec -c "npm install && npm test"
# Pass sandbox flags as usual
cplt exec --allow-lifecycle-scripts -- npm install
cplt exec --project-dir /path/to/repo -- make build
cplt exec --with-proxy -- curl https://example.com
# Shell aliases for sandboxed tools
alias npm="cplt exec -- npm"
alias node="cplt exec -- node"
alias python="cplt exec -- python"Every top-level cplt flag applies: --project-dir, --allow-read, --deny-path, --with-proxy, --pass-env, and the rest. Add --no-quiet to see the full sandbox configuration summary before the command runs.
cplt finds the command on PATH the way a shell does and decides policy on the file a symlink points to. A tool reached through a symlink under another name, such as a mise shim, busybox or a rustup proxy, is started through that symlink, so it still sees the name it was called by. The sandbox must be able to read every link on the way to the target. cplt checks that against its read grants, --deny-path, and the built-in read denies (credential files, key files, .env files), and starts the target directly when a link fails the check.
The command starts in your current directory when that is inside the project or another granted repository root, so cd apps/web && cplt exec -- npm test runs in apps/web. The project root decides which paths are granted, not where the command runs. From a directory outside every grant, for example with --project-dir pointing elsewhere, or from under a --deny-path, the command starts in the project root.
Examples
# The common case: Copilot in the sandbox
cplt -- -p "fix the tests"
# Sessions
cplt --resume # pick one interactively
cplt --resume=my-refactor # by name
cplt --continue # most recent in this directory
cplt --remote --name my-task -- -p "fix tests" # named remote session
# Check the environment before the first run
cplt doctor
# Let Copilot read a shared library directory
cplt --allow-read ~/shared-libs -- -p "use shared-libs"
# Block a path you don't want Copilot to see
cplt --deny-path ~/.config/gh -- -p "refactor auth"
# Extra outbound port, e.g. an external API
cplt --allow-port 8443 -- -p "test the API"
# Localhost for MCP servers or dev servers
cplt --allow-localhost 3000 --allow-localhost 8080 -- -p "use the MCP server"
# All of localhost, needed by Next.js/Turbopack and Vite builds
cplt --allow-localhost-any -- -p "fix the build"
# Pass specific env vars through
cplt --pass-env MY_CUSTOM_VAR --pass-env ANOTHER_VAR -- -p "run with custom config"
# Inherit the full environment (dangerous, debugging only)
cplt --inherit-env -- -p "debug the build"
# Network
cplt --no-proxy -- -p "fix the tests" # proxy is on by default
cplt --blocked-domains ./my-blocklist.txt -- -p "refactor" # adds to the built-in list
cplt --allow-private-domain intern.nav.no -- -p "use mcp-onboarding"
# Non-interactive / CI (skip the confirmation prompt)
cplt --yes -- -p "fix the tests"
# Inspect and debug the sandbox itself
cplt --print-profile
cplt --show-denials -- -p "fix the tests"Configuration
Configuration happens at two levels: global, for developer preferences, and per-repo, for team policy.
# Browse and change settings interactively
cplt settings
# Set global preferences
cplt config set sandbox.quiet true
cplt config set sandbox.yes true # never ask "Proceed?", even when the profile changed; the summary still prints
cplt config set proxy.blocked_domains "~/.config/cplt/blocked-domains.txt"
cplt config set git_guard.mode warn # observe pushes instead of blocking them
cplt config set gh_guard.enabled false # opt out of the gh guard entirely
# Set per-repo policy (committed to .cplt.toml)
cplt config set --repo sandbox.allow_jvm_attach true
cplt config set --repo deny.paths "~/secrets"
cplt config set --repo allow.localhost 5432 8099 # array keys take several values
# Inspect
cplt config show # effective config (file + defaults)
cplt config explain # every key with its descriptioncplt settings is the interactive editor, with Effective, Global, and Repository views, search, staged changes, and an explicit confirmation before it saves anything security-sensitive. cplt config stays the stable non-interactive interface for scripts and CI. Repository proposals are still committed and approved separately with cplt trust. The editor never commits or auto-approves them.
Precedence runs CLI flags, then the global config file at ~/.config/cplt/config.toml, then built-in defaults. Per-repo config in .cplt.toml is a separate layer rather than a rung on that ladder: [deny] tightens unconditionally, and approved permissions are additive only, so a repo can enable a feature but can never switch off something set by a CLI flag or global config.
A .cplt.toml in the repository root carries team policy:
[deny] # Applied automatically, no opt-in needed
paths = ["~/secrets", "~/.vault-token"]
env = ["VAULT_TOKEN", "DATABASE_URL"]
[propose] # Requires developer approval (cplt trust accept)
gh_guard = true
git_push_prevention = true
allow_jvm_attach = true
allow_docker = true
[propose.allow]
ports = [5432]
localhost = [3000]
socket = ["/var/run/docker.sock"]cplt reads it from git HEAD, so the agent cannot tamper with its own policy mid-session, and trust approvals are pinned to the file's content. An uncommitted .cplt.toml grants nothing until it is committed, though its [deny] keys still apply. In CI and scripts, where nobody can answer a prompt, --accept-repo-config approves the committed file's proposals for that one run without persisting any trust. Tools that need the approval state read cplt trust show --json (format). cplt init writes one for you by detecting the project's tooling:
cplt init # preview detected permissions
cplt init --write # write .cplt.toml to disk
cplt init --quiet # output only TOML (pipe-friendly)
cplt init --global # generate a personal ~/.config/cplt/config.tomlIt knows JVM (Gradle/Maven), Node.js, Docker, Python, Rust, Go, Playwright, Spring Boot, Ktor, TestContainers, Next.js, Vite, Flyway, Cypress, mise, and environment secrets from .env.example. Dangerous permissions come out of the generator with a risk warning attached. --global looks at machine-level things instead: Playwright browsers, GPG signing, registry credentials, alternative agents.
Some keys are global-only and rejected from .cplt.toml because they are machine-specific or a local preference: sandbox.agent, sandbox.quiet, sandbox.yes, sandbox.validate, sandbox.scratch_dir, sandbox.inherit_env, sandbox.allow_cache_exec, sandbox.allow_cache_exec_any, proxy.enabled, proxy.port, proxy.log_file, proxy.log_level, proxy.blocked_domains, proxy.allowed_domains, and every [gh_guard] and [git_guard] key.
Full details, including the trust model, path expansion rules, and the complete config file reference: docs/configuration.md.
Architecture
┌──────────────────────────────────┐
│ cplt (Rust binary) │
│ ┌───────────┐ ┌─────────────┐ │
│ │ Policy │ │ CONNECT │ │
│ │ Generator │ │ Proxy │ │
│ └─────┬─────┘ │ (optional) │ │
│ │ └─────────────┘ │
│ ▼ │
│ ┌─────────────┬────────────┐ │
│ │ macOS │ Linux │ │
│ │ Seatbelt │ Landlock │ │
│ │ sandbox- │ + seccomp │ │
│ │ exec │ pre_exec │ │
│ └─────────────┴────────────┘ │
│ │ │
│ ▼ │
│ copilot (sandboxed) │
│ ├── All child processes │
│ ├── Cannot read ~/.ssh │
│ ├── Network port-restricted │
│ ├── SSH agent blocked │
│ └── Filesystem = primary ctrl │
└──────────────────────────────────┘
The security model is a deny-by-default filesystem with kernel enforcement. On macOS, and on Linux with kernel 6.7+ (Landlock ABI v4), the network is restricted to port 443 by default, with --allow-port for extras. On older Linux kernels the CONNECT proxy provides that restriction instead, which is why it is enabled by default. SSH agent access and localhost outbound are blocked in the kernel on macOS. On Linux neither is: port-based Landlock rules cannot tell localhost from a remote host, and unix socket connect() is not gated by Landlock below kernel 7.1, so apart from the sockets bubblewrap masks the withheld SSH_AUTH_SOCK is the only thing standing between the agent and your loaded keys. The profile generator discovers your environment (cplt doctor --verbose shows the same probe results) and emits rules only for tool directories that actually exist on disk. Fewer rules, tighter sandbox.
- macOS: a Seatbelt/SBPL profile is generated and handed to
sandbox-exec - Linux: Landlock LSM rules plus a seccomp-BPF filter, applied via
pre_exec(kernel 5.13+, TCP port filtering on 6.7+)
Internals and module layout: docs/architecture.md. Threat model, defense layers, and honest gaps: SECURITY.md.
Security
One binary, minimal dependencies, no runtime services, no telemetry. Three defense layers, with clear boundaries between them:
| Layer | Enforcement | Bypassable? | What it protects |
|---|---|---|---|
| 1. Kernel sandbox | macOS Seatbelt / Linux Landlock+seccomp | ❌ No | File access, exec, network ports |
| 2. Network proxy | CONNECT proxy, domain filtering | ❌ No (within the sandbox) | Outbound connections, exfiltration |
| 3. Command guard | PATH-based wrapper scripts | ⚠️ Soft barrier | Pushes, merges, releases, API writes |
What cplt protects against:
- Secret exfiltration (SSH keys, cloud credentials,
.envfiles): kernel-blocked - Unauthorized code execution from temp dirs: kernel-blocked
- Persistence via cache-dir binaries: kernel-blocked, since those dirs are denied exec
- Persistence via git hooks in the project:
.git/hooksis write-denied at the kernel on macOS. On Linux, with Landlock and no Bubblewrap, it stays writable, and cplt's own parent-sidegitthen runs withcore.hooksPath=/dev/nullso it never executes a planted hook, though agityou run yourself still will - Persistence via package-manager tool dirs that are both writable and executable (mise shims,
PNPM_HOME,~/.deno/bin,~/.bun/bin): write is granted there sopnpm add -gand friends work in-sandbox, so an agent can leave a binary behind that a later shell picks up off yourPATH - A planted binary hijacking the agent cplt launches: at launch and audit, cplt resolves the helpers it runs itself (
git,bwrap,sandbox-exec,mise, and theghit reads a token from) from fixed system directories rather thanPATH, but the agent binary itself runs from wherever it was discovered, which for an npm-global install is commonly under a writable mise or node tree. cplt cannot resolve it from a fixed directory — it legitimately lives where your version manager put it — so it checks the resolved path against the write rules the sandbox is about to apply and warns at launch, naming the binary and the writable tree, then proceeds cplt doctor: its--versionprobes run each agent binary it finds on yourPATH, in the parent, so a planted one executes there — the same discovered-path exposure as the launch above, which is why doctor is a report and not a boundary. Itsghcheck is resolved from the trusted directories and its kernel-release read spawns nothing at all- Data exfiltration to unauthorized domains: proxy-blocked
- Accidental pushes to main and PR merges without review: guard-blocked
What cplt does not protect against:
- Malicious code already inside the project directory. The agent has full read/write there
- Logic bugs the agent introduces. You still review the code
- A sophisticated adversary bypassing the command guard. Use server-side branch protection
- Network attacks on allowed domains. If github.com is allowed, the agent can read and write there
- macOS Keychain access, for agents that store auth there. Contents are password-protected, and
sandbox.keychain_substitutecan trade the grant away where an agent has another credential
The MCP host broker, which keeps a local MCP server's credential out of the sandbox, runs on macOS and on Linux under Bubblewrap. On Ubuntu 24.04 that takes a one-time AppArmor profile, which cplt prints and offers to install (cplt doctor --fix). Without Bubblewrap cplt refuses the broker. See the platform table.
Our priorities, in order: correct (every claim is tested, every edge case has a CVE or research reference), transparent (SECURITY.md hides nothing), simple (one binary, zero config required, sane defaults), and useful (get out of the way and let the agent work, safely).
More: docs/security.md · SECURITY.md
Network and proxy
The proxy is on by default. All outbound traffic from Copilot CLI, gh, and curl goes through a localhost CONNECT proxy via HTTP_PROXY/HTTPS_PROXY and NODE_USE_ENV_PROXY=1. It listens on an OS-assigned ephemeral port, so nothing collides. You get connection logging in real time, domain blocking, domain allowlisting, a persistent audit log, and the same port policy the sandbox enforces (443 plus anything in allow.ports).
cplt --proxy-forced -- -p "fix tests" # force all egress through the proxy
cplt --no-proxy -- -p "fix tests" # disable for one run
cplt --blocked-domains my-blocklist.txt -- -p "x" # block more domains (adds to the built-in list)
cplt --allowed-domains allowed-domains.txt -- -p "x" # allowlist mode
cplt --default-allowlist -- -p "x" # fail-closed: only the agent's own domains
cplt --observe-domains -- -p "x" # record what the agent contacts, no allowlist
cplt --proxy-upstream http://proxy.corp:8080 -- -p "x" # chain through a corporate proxy--observe-domains-out <FILE> writes the observed set one domain per line, and
--proxy-upstream-no-proxy <HOST> lists hosts to reach directly instead of through
the upstream.
cplt config set proxy.enabled false
cplt config set proxy.blocked_domains "~/.config/cplt/blocked-domains.txt"
cplt config set proxy.allowed_domains "~/.config/cplt/allowed-domains.txt"
cplt config set proxy.log_file "~/.config/cplt/proxy.log"An allowlist always includes the agent's own hosts, but not package registries; add cplt config set proxy.default_allowlist true for those. Goose and Pi have no built-in hosts, and OpenCode's are opencode.ai and models.dev, plus the GitHub Copilot hosts when OpenCode's auth.json has a (non-Enterprise) github-copilot entry. With those agents, any other model provider's hosts must be in your allowlist (docs/proxy.md).
Proxy-forced mode is opt-in. It restricts kernel egress to the proxy port so a socket opened directly, or an env -u HTTPS_PROXY, cannot slip past. On macOS the kernel pins connections to localhost:<proxy_port>; name lookups through the system resolver still work (see SECURITY.md, Honest gaps). On Linux it blocks direct TCP :443, and a seccomp rule permits only SOCK_STREAM with protocol 0 or IPPROTO_TCP for AF_INET/AF_INET6, so UDP, raw, SCTP and DCCP are closed too — at the cost of anything that opens such a socket, not only code that sends UDP. What remains is a port-based residual, evil.com:<proxy_port>, until #114.
Outside proxy-forced, Linux does not restrict UDP. Landlock's network rights are TCP-only until ABI v10, cplt handles AccessNet::ConnectTcp alone, and the seccomp rule above is deliberately not applied — denying SOCK_DGRAM there would break getaddrinfo(3), and so all DNS, for every non-proxied tool. Outbound UDP to any host, inbound UDP bind, DNS tunnelling and QUIC/HTTP-3 are therefore unmediated in default mode, and the CONNECT proxy carries TCP only, so none of it appears in the proxy log. macOS allows only TCP on 443 (#815), so UDP/443 is denied and HTTP/3 clients such as Bun fall back to TCP. That TCP goes through the proxy only if the client honours the proxy settings: outside proxy.forced, direct TCP/443 to any host is still allowed and bypasses the allowlist. Under proxy.forced the proxy log is a complete record of connections on macOS, though not of DNS lookups through the system resolver. On Linux it is complete except for the evil.com:<proxy_port> residual above, which does not traverse the proxy and so does not appear in its log.
Both lists match the same way: example.com covers the exact domain and every subdomain, matching is case-insensitive, and trailing dots are stripped. Blocklist and allowlist files are re-read every five seconds, so you can edit them live. Keep them outside every tree the sandbox can write, or the agent can edit them too. cplt refuses to launch when a list file is inside the project, a named repository, an allow.write grant or a temp directory. It warns at launch and in cplt check, without refusing, when a directory or symlink on the way to the file sits in any tree the sandbox makes writable, such as a tool cache under your home directory. Localhost traffic bypasses the proxy via NO_PROXY and never appears in the audit log. --proxy-timeout <SECONDS> bounds request and header reads (default 60) and does not tear down established CONNECT tunnels, which may idle for up to an hour.
Every proxy flag, domain-filtering detail, upstream corporate-proxy chaining, and the connection log format: docs/proxy.md.
Command guards
Enable them and cplt intercepts gh and git through wrapper scripts in $PATH:
| Command | Action |
|---|---|
gh pr merge, gh repo delete, gh release create | 🔒 Blocked |
git push origin main, git push --force | 🔒 Blocked |
gh api (write to other repos) | 🔒 Scope-checked |
gh pr list, gh issue list, git commit | ✅ Allowed |
git push origin feature-branch | ✅ Allowed with protect_default_branch_only |
This is Layer 3, a soft barrier. It stops a compliant agent from doing something destructive by accident. For a hard boundary, lean on the kernel sandbox and server-side branch protection.
With the gh guard on, cplt also caches the GitHub token at launch and serves it once through the gh auth token callback, then deletes the cache. That cuts accidental and environment-based leakage. It is not a boundary against a hostile agent, because the cache lives in the agent's own TMPDIR and an agent that reads it before the legitimate consumer still gets the token. It also writes the token to a second file, the exec token, and hands it to the real gh as GH_TOKEN on each approved call, so gh works without the Keychain while the agent's own environment stays without it (skipped for Copilot on macOS while it keeps the Keychain or holds GH_TOKEN/GITHUB_TOKEN). That file stays for the session and is just as readable to the agent. SECURITY.md has the full statement on block_auth_token.
Full behavior: docs/gh-guard.md · docs/git-guard.md
Known impacts
The sandbox blocks some workflows on purpose. The common ones and their fixes:
| Impact | Fix |
|---|---|
.env files blocked | cplt config set sandbox.allow_env_files true |
| npm postinstall hooks blocked | cplt config set sandbox.allow_lifecycle_scripts true --force |
go test / mise run blocked (temp exec) | The scratch dir is on by default. If you still need it, cplt config set sandbox.allow_tmp_exec true --force |
| Localhost connections blocked | cplt config set allow.localhost 3000, or cplt config set sandbox.allow_localhost_any true |
| Docker blocked | cplt config set sandbox.allow_docker true --force ⚠️ (Linux, rootless Podman/Docker only: sandbox.allow_rootless_docker) |
| SSH fails (no key) | Use HTTPS remotes instead |
| GPG signing disabled | cplt config set sandbox.allow_gpg_signing true --force |
| JVM MockK/Mockito fails | cplt config set sandbox.allow_jvm_attach true |
dotnet build MSBuild worker nodes blocked | cplt config set sandbox.allow_msbuild true |
| Private registry creds blocked | cplt config set allow.read "~/.m2/settings.xml" |
| Internal Maven/Nexus repo unreachable (Gradle/Maven) | cplt config set proxy.allow_private_domains "intern.example.com". An IP-literal repository URL cannot be allowed — give the host a DNS name; see below |
| Playwright Chromium won't launch | Allow cache exec, then disable Chromium's nested sandbox; see below |
Puppeteer / mmdc fails with spawn EPERM or [object Object] | With allow_cache_exec ms-playwright, install Puppeteer's browser into the cache cplt names, then use --no-sandbox and "pipe": true; see Puppeteer |
Playwright Chromium needs cplt config set sandbox.allow_cache_exec ms-playwright, and Chromium must run without its own nested sandbox. On macOS its helpers cannot initialize a second Seatbelt sandbox inside cplt (forbidden-sandbox-reinit); on Linux cplt's seccomp filter blocks the namespace syscalls that sandbox needs. Playwright as a library already launches with --no-sandbox, and that same opt-in sets PLAYWRIGHT_MCP_SANDBOX=false for Playwright MCP, which would otherwise turn it back on. Any other Chromium launcher needs --no-sandbox itself. cplt remains the enforcing kernel boundary, but a compromised renderer then receives the full cplt Playwright profile instead of Chromium's narrower child profile. See Cache exec and SECURITY.md.
Cypress needs cplt config set sandbox.allow_cache_exec Cypress. Cache
execution is machine-specific, so each user must set it in personal config or
pass --allow-cache-exec Cypress for a run. On macOS the entry grants Electron's
scoped Mach registration and RootDomainUserClient power-monitor access.
Cypress also needs ephemeral loopback ports. Prefer requesting those in the
repository's committed .cplt.toml, rather than enabling them globally:
[propose]
allow_localhost_any = trueEach user approves that request with cplt trust accept. proxy.forced
overrides allow_localhost_any; use fixed Cypress ports or disable forced
proxying for that run.
Git commit works for every agent; whether git push works over HTTPS depends on the agent. Three prerequisites: use HTTPS remotes rather than SSH (git remote set-url origin https://github.com/org/repo.git, or rewrite globally with git config --global url."https://github.com/".insteadOf "git@github.com:"), run gh auth login once outside the sandbox, and run gh auth setup-git if the credential helper is not configured yet. Push then runs gh auth git-credential, which needs a token gh can reach from inside the sandbox — that differs per agent, see Git workflow. Pushes to the default branch and all force pushes are refused by the git guard by default; push a feature branch. The SSH agent socket is blocked because it unlocks every loaded key and can authenticate to any host, while the gh credential helper is scoped to GitHub.
The JVM is proxy-aware, so an internal Maven repository on a private IP now needs allowing. cplt injects http(s).proxyHost/proxyPort into JAVA_TOOL_OPTIONS, so Gradle and Maven dependency resolution goes through the CONNECT proxy and shows up in the proxy log instead of bypassing it. The proxy's SSRF guard then refuses an in-house Nexus or Artifactory that resolves into private address space, exactly as it already does for curl, npm and pip. Add its DNS name to proxy.allow_private_domains. A repository URL written as a bare IP literal (https://10.20.30.40/repository/maven-public/) cannot be allowed by any key — that check runs before the allow list is consulted — so such a repository needs a DNS name. WorkerExecutor plugin forks, and a Gradle daemon started outside cplt and reused inside, are not proxied. See Internal Maven/Gradle repositories on private IPs.
Gradle 9+ runs its own nested sandbox, and cplt turns it off. Since Gradle 8.8 the daemon wraps itself in sandbox-exec (controlled by GRADLE_MACOS_SANDBOX, previously the org.gradle.daemon.sandbox property). macOS does not support nested sandbox-exec calls, so the inner sandbox fails with "Operation not permitted" on socket operations. cplt injects GRADLE_MACOS_SANDBOX=off, since it already provides kernel-level sandboxing. This is a known upstream issue that hits any tool wrapping Gradle in an outer sandbox. Override with --pass-env GRADLE_MACOS_SANDBOX if you really want Gradle's own sandbox.
Copilot CLI 1.0.83 runs its own nested sandbox, and cplt turns it off. On Linux that sandbox builds a network namespace — slirp4netns, iptables, /dev/net/tun — and cplt's seccomp filter denies the unshare it takes. cplt also sets HTTP_PROXY/HTTPS_PROXY, which in 1.0.83 puts a Linux sandbox on the proxy-egress path whether or not you asked for it, so the two collide on every launch. Symptom: [cplt] Starting Copilot in sandbox... and then nothing. cplt injects Copilot's own opt-out, COPILOT_CLI_SANDBOX_SUPPORT_OVERRIDE=unsupported; Copilot stands down for the session, says so, and leaves your saved sandbox.enabled alone. cplt is the boundary, as it is for Gradle and Chromium. Override with --pass-env COPILOT_CLI_SANDBOX_SUPPORT_OVERRIDE. An enterprise managed policy that requires the sandbox overrides all of this — see Copilot CLI's own command sandbox.
Every impact, with the per-tool tables, JVM and Kotlin daemon notes, GPG troubleshooting, and the private-registry platform differences: docs/known-impacts.md.
Limitations
macOS
sandbox-execis 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 instead.
- SBPL's
lsopenhas no filter either, so--allow-browseris all of Launch Services or none of it. With it on the agent can launch any application outside the sandbox, and no wrapper can narrow that — see docs/security.md.
Linux
- Kernel 5.13+ required, with Landlock LSM enabled.
- TCP port filtering needs kernel 6.7+. Older kernels get filesystem-only enforcement.
- Landlock cannot deny subpaths within allowed paths, so
.envread/write/delete inside the project dir is not kernel-enforced..git/hookswrites are blocked when Bubblewrap is active. --deny-pathrequires Bubblewrap. It is enforced through mount masks when bwrap is active. Without it, Landlock is allowlist-only and cplt warns about the deny instead of applying it.
More: docs/security.md
Contributing
Contributions are welcome.
see CONTRIBUTING.md for how to contribute
Open an issue before starting a large change. Every PR has to pass CI (fmt, clippy, tests).
References
- SECURITY.md, the full security model, threat analysis, test strategy, and prior art
- Apple sandbox-exec(1)
- Chromium Seatbelt V2 Design
- Landlock LSM documentation
- seccomp-BPF documentation
- OWASP SSRF Prevention Cheat Sheet
- michaelneale/agent-seatbelt-sandbox