
Sandbox for AI coding agents. Runs Copilot CLI, Claude Code, OpenCode, Gemini CLI, Antigravity, Pi, goose or a plain shell inside a kernel-level sandbox, with git and gh guards and sandbox policy committed to the repository.
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.
sandbox-exec
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:
.cplt.toml, committed to version control, so it is tamper-proof and auditableDetailed docs: Configuration · Proxy & domain filtering · gh command guard · git command guard · Known impacts · Security details · Security model
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 sandbox
Other 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 invocation
# 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 blocking
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 in project | 🔒 Kernel-blocked | Prevents secret exfiltration and destruction. --allow-env-files overrides |
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 |
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.
| 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.
| 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.
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:
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 full parent env (secrets pass through) |
| Credential dir protection | 15+ dirs denied by default | User must configure manually |
| DNS rebinding protection | ✅ Post-DNS IP checked against private ranges | ❌ Not implemented |
| Network proxy | HTTP CONNECT + domain allow/block | HTTP + SOCKS5 + experimental TLS MITM |
| SSH git | Blocked at kernel on macOS (agent socket denied); on Linux only SSH_AUTH_SOCK is withheld | Proxied via SOCKS5 |
| Package manager scripts | Blocked by default (npm_config_ignore_scripts) | Not blocked |
| Agent support | Copilot, OpenCode, Gemini, Antigravity, Pi, Claude Code, goose, DSH, Shell | Claude Code |
| Config | TOML (global + per-repo) | JSON (global only) + --control-fd live updates |
| Library API | ❌ Binary only | ✅ Embeddable TypeScript library |
cplt is more secure out of the box: env filtering, credential protection, DNS rebinding checks, 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.
Copilot CLI has shipped with a local sandbox since June 2026, included in the
standard seat. It runs shell commands through Microsoft MXC with restricted
filesystem, network and system access, on macOS, Linux and Windows.
/sandbox enable turns it on.
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 Intune or another MDM. 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 laptop the MDM 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, and inside a Git repository the agent is granted read and write
on .git by default. A sandboxed agent still has your gh token and 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.
brew install navikt/tap/cplt
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).
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 cplt
It 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, and upgrades ride
sudo apt upgrade from then on. cplt update refuses to touch an apt install
and points at sudo apt upgrade 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}".deb
For distributions that are not Debian derivatives, and for CI:
curl -fsSL https://raw.githubusercontent.com/navikt/cplt/main/install.sh | bash
Options:
# 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-brew
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 binary carries a build provenance attestation. Verify it:
gh attestation verify cplt -o navikt
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 install
mise 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.
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 below
Everything 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 doctor
Do 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.
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.
By default you get the sandbox by typing cplt. To make plain copilot run sandboxed too:
cplt --shell-install
That 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 others
Each 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.
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.
cplt [OPTIONS] [-- <AGENT_ARGS>...]
Everything after -- goes straight to the agent process (copilot, opencode, gemini, antigravity, pi, claude, goose, dsh, or shell).
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.
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-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) |
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 (OTEL_EXPORTER_OTLP_HEADERS may carry opt-in auth) |
| XDG dirs | XDG_CONFIG_HOME, XDG_DATA_HOME, XDG_STATE_HOME, XDG_CACHE_HOME | Explicit allowlist |
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 |
| 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 | 🧪 Experimental. Write the agent-facing sandbox brief to the scratch dir (CPLT_BRIEF.md). Off by default. Also sandbox.brief = true in config. Unstable, so it may change or be removed in a future release |
--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 |
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, 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, |
Run cplt doctor to see whether cplt will work here for your agent, and cplt doctor --verbose for everything it detected on your machine.
| 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. The configuration summary still prints, for auditability. Required when stdin is not a TTY, so CI and scripts need it |
-q, --quiet | Suppress the startup banner and non-essential messages. Errors and warnings still print. 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 |
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 and --name are Copilot-only. Pi and shell mode get no translation at all, so all four flags are dropped for them. 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 + prompt
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 |
| 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 and dsh are generic binary names that could collide with something else on your machine, and Claude Code has to be chosen on purpose.ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, OPENROUTER_API_KEY, ANTHROPIC_AUTH_TOKEN, CLAUDE_CODE_OAUTH_TOKEN and 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./connect device flow stores its token in ~/.local/share/opencode/auth.json, and Claude Code's OAuth token lives in ~/.claude (.credentials.json on Linux) or the macOS Keychain. Both are reachable inside the sandbox, so cplt does not nag about a missing API key for either.--allow-browser when 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.DISABLE_AUTOUPDATER=1. Claude Code has no --no-auto-update flag, self-updating inside the sandbox is a persistence vector, and it would fail against read-only install paths anyway.CLAUDE_CONFIG_DIR is honored. When it is set, cplt grants that directory instead of ~/.claude and passes the variable through, so a relocated config root keeps working./connect inside OpenCode.Per-agent config dirs, Keychain use, exec permissions, and env isolation are in SECURITY.md.
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 goose
Security notes for goose:
--agent goose or set sandbox.agent = "goose" in configANTHROPIC_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 reads GOOGLE_API_KEY, not GEMINI_API_KEY. Any provider outside this subset still works: name its variable with --pass-env--observe-domains capture, so its built-in allowlist is the shared package-registry base only. Add your provider's domain via allowed_domains before enabling --default-allowlistGOOSE_DISABLE_KEYRING=1 makes goose use a secrets.yaml in its config dir instead, and passing the key with --pass-env avoids stored secrets altogether. On Linux goose uses the D-Bus Secret Service, which the Keychain grant does not affect~/.config/goose/config.yaml declares extensions: entries whose cmd goose spawns on every session start, so a writable config dir is a host-persistence vector. Normal sessions do not write it; /mode changes and persisted tool permissions do not survive a sandboxed run. Reconfigure with goose configure outside cplt~/.local/share/goose/) and state (~/.local/state/goose/) dirs are writable, with exec denied. goose uses these XDG paths on macOS too, and honours the XDG_* overrides there--continue and bare --resume map to goose session --resume; --resume=ID to goose session --resume --session-id ID; --name X to goose session --name X. These are subcommand flags, so cplt injects the session subcommand with them. --remote is ignored (no goose equivalent)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 dsh
Security notes for DSH:
--agent dsh (aliases deepseek, deepseek-harness) or set sandbox.agent = "dsh". dsh is a short, generic command name that could belong to something else on your machinesandbox-exec calls (the same limitation that makes cplt turn Gradle's inner sandbox off, see Limitations), and bwrap builds its namespace with unshare, which cplt's seccomp filter denies. cplt is the enforcing boundary either way, so pick DSH's shipped danger-full-access permission preset for sandboxed sessions. Leave the inner runner on and tool calls fail with a sandbox runner error rather than a task error$DSH_HOME (~/.dsh by default). DSH_HOME is 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 veto CLAUDE_CONFIG_DIR goes through$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's cordis.yml include-root on every boot, so a per-profile cordis.patch.yml and installed plugins are a documented residual — do profile and dsh plugin edits outside cplt, and always launch dsh through cplt so anything planted still runs sandboxeddeepseek.com only. The shipped dsh-llm-deepseek adapter defaults to https://api.deepseek.com. Point DEEPSEEK_BASE_URL at a gateway and you have to add that gateway's domain via allowed_domains--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, so git push over HTTPS needs gh's token in hosts.yml or --pass-env GH_TOKENRun 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-profile
The 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'.
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.
# 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 ./blocked-domains.txt -- -p "refactor"
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 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 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"
# Inspect
cplt config show # effective config (file + defaults)
cplt config explain # every key with its description
cplt 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. 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.toml
It knows JVM (Gradle/Maven), Node.js, Docker, Python, Rust, Go, Playwright, Spring Boot, Ktor, TestContainers, Next.js, Vite, Flyway, Cypress, 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.pass_env, 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.
┌──────────────────────────────────┐
│ 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.
sandbox-execpre_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.
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:
.env files): kernel-blocked.git/hooks is write-denied at the kernel on macOS. On Linux, with Landlock and no Bubblewrap, it stays writable, and cplt's own parent-side git then runs with core.hooksPath=/dev/null so it never executes a planted hook, though a git you run yourself still willPNPM_HOME, ~/.deno/bin, ~/.bun/bin): write is granted there so pnpm add -g and friends work in-sandbox, so an agent can leave a binary behind that a later shell picks up off your PATHgit, bwrap, sandbox-exec, mise, and the gh it reads a token from) from fixed system directories rather than PATH, 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 proceedscplt doctor: its --version probes run each agent binary it finds on your PATH, 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. Its gh check is resolved from the trusted directories and its kernel-release read spawns nothing at allWhat cplt does not protect against:
sandbox.keychain_substitute can trade the grant away where an agent has another credentialOur 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
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 blocked-domains.txt -- -p "x" # block known-bad domains
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, block nothing
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"
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. Enforcement is full on macOS, which pins to localhost:<proxy_port>. 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 restricts UDP in default mode but does not route it either: remote ip "*:443" covers UDP, so QUIC/HTTP-3 on 443 leaves without touching the proxy there as well. Under proxy.forced the proxy log is a complete record of egress on macOS. 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. 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.
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. SECURITY.md has the full statement on block_auth_token.
Full behavior: docs/gh-guard.md · docs/git-guard.md
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 |
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 |
| 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 ⚠️ |
| SSH blocked | Use HTTPS remotes instead |
| GPG signing disabled | cplt config set sandbox.allow_gpg_signing true |
| 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 |
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.
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 "[email protected]:"), 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.
sandbox-exec is deprecated. Apple has not removed it, but may in a future macOS version.lsopen has no filter either, so --allow-browser is 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..env read/write/delete inside the project dir is not kernel-enforced. .git/hooks writes are blocked when Bubblewrap is active.--deny-path requires 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
Contributions are welcome.
git clone https://github.com/navikt/cplt.git && cd cplt
git config core.hooksPath hack # enables pre-commit fmt + clippy checks
mise run check # runs fmt, clippy, and tests
Open an issue before starting a large change. Every PR has to pass CI (fmt, clippy, tests).
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. Opt in to sandbox.keychain_substitute (EXPERIMENTAL, default off) to drop it on runs where the agent can authenticate without it — CLAUDE_CODE_OAUTH_TOKEN for Claude Code, an existing fallback token file for Antigravity. 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), ⚠️ env-only on Linux | Prevents signing git operations or SSH to hosts. Linux: unix socket connect() is not gated, so the withheld SSH_AUTH_SOCK is the only barrier and an agent that sets it itself can use the loaded keys. 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. Breaks yarn 1, 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, ~/.nav-pilot | 🔒 Kernel-blocked | Tool state that decides what the next launch may do. ~/.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 |
--deny-path <PATH> |
| Block a path that would otherwise be allowed. Deny always wins. Repeatable |
--allow-port <PORT> | Allow outbound traffic on an extra port. Only 443 by default. Repeatable. On macOS the rule is (remote ip "*:PORT"), which is family-agnostic and so carries UDP as well as TCP; Landlock gates TCP connect only. 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 |
--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 |
.miseMISE_* |
mise |