# Known impacts The sandbox is kernel-enforced, so **all restrictions apply to every process spawned inside it**: dev servers, test runners, build tools, package managers. That is deliberate, since a sandboxed agent could otherwise escape by spawning a child process. It does affect some workflows. ## Copilot's VS Code launcher and Keychain substitution On macOS, an editor-provided Copilot launcher can exit with `ERROR: SecItemCopyMatching failed -25300` even when `GH_TOKEN` is supplied (#753). cplt refuses that launcher when the Keychain substitute is active. It does not restore Keychain access automatically. Install standalone Copilot outside cplt with `brew install --cask copilot-cli` or `npm install -g @github/copilot`, and put it in PATH. cplt prefers the standalone install over editor launchers, even when the editor appears first in PATH. Check the selected path with `cplt doctor --verbose`, then launch `cplt`. Keep `sandbox.keychain_substitute` enabled. ## Branch tracking is silently dropped `.git/config` is write-denied on macOS (it is a `core.hooksPath` and `url.*.insteadOf` vector), and on Linux when `sandbox.allow_git_config_write = false`, which new configs set. On Linux the error reads `Device or resource busy` instead of `Operation not permitted`. A Linux config without the key keeps the file writable, and branch tracking is recorded normally there. The commands that record branch tracking write to that file, and git treats the failure as non-fatal: it prints the error, prints a success line that is **false**, and exits 0. ``` error: could not write config file .git/config: Operation not permitted branch 'feat' set up to track 'origin/main'. ``` An agent reading the summary line, or checking the exit code, is told the tracking was recorded. It was not. | Command | What happens | | --- | --- | | `git push -u origin ` | With the git guard on: cplt runs the push without `-u` and says so in one line, so git prints no config error. The push succeeds, and cplt records the upstream after the session if the push qualifies (see below). With the guard off: push succeeds, git prints the error and a false tracking line, exit 0 | | `git checkout -b origin/` | Branch created, tracking **not** recorded, exit 0 | | `git branch --set-upstream-to=origin/` | Records nothing, exit 0 | | `git config user.name ` | Fails loudly, exit 4 | | `git switch -c ` (no tracking) | Clean, nothing to record | For `git push`, cplt removes `-u` and `--set-upstream` before the push runs. The flag does nothing except write that config, so the push itself is unchanged, and git has no failed write to report. The one line cplt prints instead: ``cplt: pushing without -u, because .git/config is read-only in the sandbox. cplt records the upstream when the session ends, if the push qualifies; otherwise it prints the `git branch -u` line to run. Until then, name the branch on later pushes: `git push origin HEAD:`.`` When the session ends, cplt sets `branch..remote` and `branch..merge` itself, outside the sandbox, and prints one line per branch. It does this only when all of these hold: - the command was exactly `git push -u ` or `git push -u HEAD`, with no other flags and no `src:dst` refspec. `HEAD` is the branch checked out when the push ran, not at session end - the repository is the launch repository or one named with `--repo-dir`, not a clone nested inside one - the branch exists locally, the remote is one `git remote` lists, and the git guard would allow `git push ` under the session's policy - the audit reports that the session settled: no process from it was still running. With `--no-audit` there is no such report and nothing is recorded - the session had a scratch dir (not `--no-scratch-dir`) Otherwise cplt prints the line to run yourself: `git branch -u / `. A branch pushed with `-u` more than once gets the remote of the last push. Under `--quiet`, which `cplt exec` defaults to, nothing is recorded and no line is printed at session end. The in-session note says so instead: ``cplt: pushing without -u, because .git/config is read-only in the sandbox. No upstream is recorded, so name the branch on later pushes: `git push origin HEAD:`, or run `git branch -u origin/` outside the sandbox.`` A bundled flag such as `-uf` is left alone and gets the note below. A `-u` that is the value of an option (`-o -u`) is not the flag and stays. For the other commands, cplt says so when it can see the request coming: a `branch`, `checkout` or `switch` carrying `-u`, `--set-upstream-to`, `-t` or `--track` gets a note before the command runs, saying the success line will be false. Both need the git guard on, since the wrapper they run in is the guard's. The implicit form, `git checkout -b /`, is named in the note rather than detected: it sets tracking from a positional that only git can tell from a pathspec. **The shape that works**, and what to tell an agent to do: push with an explicit refspec and open the PR with an explicit head, so nothing needs local config. To record the upstream anyway, run `git branch -u origin/` outside the sandbox, or rely on the session-end step above for `git push -u`. ```bash git push origin HEAD:my-branch gh pr create -R / --head my-branch ``` On macOS this applies to every writable root, the launch repository included, not only to repositories named with `--repo-dir`. On Linux it applies with `sandbox.allow_git_config_write = false`, to the same roots, but a nested repository is covered only if the bounded scan finds it (three levels down, 20 000 directories in total). Tracked in [#402](https://github.com/navikt/cplt/issues/402), which also covers whether the guard should intercept these forms and fail loudly instead. ## Linux: keyring access depends on the backend and agent version `gnome-keyring`, `kwallet` and anything else behind libsecret are reached over D-Bus. Removing `DBUS_SESSION_BUS_ADDRESS` only affects discovery, and denying keyring files does not block the Secret Service API. On Ubuntu 24.04 in Colima, kernel 6.8 reporting Landlock ABI 4, a private Secret Service remained readable under Landlock + seccomp using `XDG_RUNTIME_DIR` or a known socket, including with all token variables denied. Bubblewrap was untested because namespace creation was blocked. See [the measured Linux gap](../SECURITY.md#linux-specific-limitations). Native login may work or fail depending on the backend and agent version. An agent may report: ``` ! System vault not available The recommended secure storage (keychain, keyring, or credential manager) could not be found or accessed. Store token in plain text config file? ``` That prompt alone does not establish whether the sandbox blocked the bus or why authentication failed. **Copilot's own keyring login.** Copilot 1.0.101 and later fail inside cplt with `Saved sign-in could not be read from credential storage` when the login is in the Secret Service and the sandbox masks the bus. With `sandbox.keychain_substitute` on (the default for Copilot), cplt reads that login on the host before launch and passes it to Copilot as `COPILOT_GITHUB_TOKEN`. It takes the account from `lastLoggedInUser` in `~/.copilot/config.json` and runs one `secret-tool lookup service copilot-cli username :`, capped at one second. cplt skips the lookup when a token variable is already exported, when `deny.env` strips `COPILOT_GITHUB_TOKEN`, when the config has no `lastLoggedInUser` and lists several accounts, and when the account is not gh's github.com login (or gh is not logged in). The config file is writable from inside the sandbox, so it must not be able to pick another stored login. `secret-tool` comes from the `libsecret-tools` package and must be in a trusted bin directory such as `/usr/bin` (not `PATH`). With the keyring locked, the lookup can open an unlock dialog at every launch; cplt kills it after one second and prints a warning. `copilot logout` does not exist in current Copilot and does not help. This handover needs no `--force`, unlike `gh_guard.inject_token` below. The token is Copilot's own login, handed to the agent that already owns it, the same trade as the macOS Keychain substitute. It still sits in the agent's environment, so every process in the sandbox can read it through `/proc//environ`. That is less than the alternatives: `inject_token` puts gh's token there, which is a separate credential the agent would not otherwise hold, and reaching the Secret Service directly would expose every item in the keyring. Set `sandbox.keychain_substitute = false` to turn the lookup off. If no lookup runs or it finds nothing, `cplt doctor` warns and says which case applies. The fallback for Copilot is to authenticate on the host and let cplt hand over gh's credential: ```bash gh auth login --hostname github.com # outside cplt, once cplt config set gh_guard.inject_token true --force # needs gh_guard.enabled ``` `--force` is required because putting the token in the environment is a real weakening: it is inherited by every process in the sandbox and readable from any of them, through `/proc//environ` on Linux or `ps -E` on macOS. See [gh-guard.md](gh-guard.md). A manually supplied, session-only token environment is another handover option. Neither method proves that native keyring access is blocked. Prefer these to accepting plaintext token storage in the agent's config directory. ### The same vault prompt on macOS Run `cplt doctor --agent copilot` outside the agent. With `keychain: denied` and `auth: gh auth token (passed as GH_TOKEN)`, cplt deliberately removed the Keychain grant because it retrieved a replacement credential. This reports handover, not token validity, Copilot authorization or the selected account. An explicit `/login` can still try to store a new credential in the denied Keychain. Cancel the plaintext-storage offer while investigating. Check the gh account and compare ordinary startup with a one-shot request: ```bash ( unset COPILOT_GITHUB_TOKEN GH_TOKEN GITHUB_TOKEN gh auth status --hostname github.com gh api --hostname github.com user --jq .login cplt --agent copilot -- -p "Reply with OK only" cplt --agent copilot ) ``` Token retrieval does not prove validity or Copilot authorization. Record the error before the vault prompt and whether it appeared automatically or after `/login`. If both requests fail, compare the same token with the standalone Copilot executable outside cplt. Use the absolute path reported by `cplt doctor --agent copilot --verbose` to bypass shell aliases and PATH shims. Clear all three exported token variables in that comparison too, then assign the stored gh token as `GH_TOKEN` for the standalone command. Otherwise an exported credential may override the stored one. Use a subshell to preserve the calling shell's environment. Do not print tokens or run token assignment with shell tracing enabled. For Copilot's own stored login, sign in with the standalone executable outside cplt and verify secure storage. cplt does not extract that credential today. To test that login on macOS: 1. Record the prior values and whether each key was explicitly set, including any repository-local override. Run the following from the same repository and config scope used for the launch. These commands write global config; add `--local` when changing a repository-local override: ```bash cplt config set sandbox.keychain_substitute false cplt config set gh_guard.inject_token false ``` Substitution off restores broad Keychain read/write access, not access to just Copilot's item. Injection must also be off: otherwise cplt can still put gh's stored token in Copilot's environment. 2. Restart with the exported tokens cleared in a subshell: ```bash ( unset COPILOT_GITHUB_TOKEN GH_TOKEN GITHUB_TOKEN cplt --agent copilot ) ``` This tests native-login availability, not isolation from every gh source. Copilot can still invoke gh, and readable `hosts.yml` or a guard cache can supply a token. A successful request alone does not identify the source. 3. Restore each key to its actual prior value in the scope you changed. Use `cplt config set --unset` only if that key was originally unset there; otherwise restore its explicit `true` or `false` value. The subshell preserves the calling shell's exported tokens. Maintainers: [authentication acceptance tests](copilot-auth-testing.md) separate automated handover/enforcement checks from live onboarding and renewal evidence. ## `.env` file blocking **macOS only.** Landlock cannot deny a file inside a granted directory, so on Linux these files stay readable and writable (the proxy's domain filtering limits where a read secret can be sent; nothing denies the read; see [security.md](security.md)). `.env` and `.env.*` files are **blocked from reading and writing** by default, and so are files named exactly `.pem`, `.key`, `.p12`, `.pfx` or `.jks`, and SSH private keys named `id_rsa`, `id_dsa`, `id_ecdsa` or `id_ed25519`. A key file with a longer name, such as `server.pem`, is blocked only with [`sandbox.deny_key_files_by_extension`](configuration.md#denying-key-files-by-extension-sandboxdeny_key_files_by_extension). This stops a rogue agent exfiltrating secrets, but it has side effects. **The rule is a pattern on the file name, not a location**, so it applies everywhere the sandbox can reach. The two extracted dependency stores are carved back out for *reading* (#477), because a `.env` there is package content. A `--deny-path` or `deny.paths` entry inside or above one of those stores still wins over the carve-out (#597). Everywhere else the name is enough to deny it: | Operation | Impact | Why | | ------------------------------ | ---------- | --------------------------------------------------------------------- | | `npm install` | ✅ Works | Does not read `.env` files | | `cargo build`, `go build` | ✅ Works | Does not read `.env` files | | `next build` / `next dev` | ⚠️ May fail | Next.js auto-loads `.env`, `.env.local`, `.env.production` at startup | | `npm run dev` (Node.js) | ⚠️ May fail | Apps using `dotenv` to load config will get `undefined` env vars | | `npm test` / `vitest` | ⚠️ May fail | Tests that depend on `.env` for config won't find the values | | TLS dev servers (`.pem` certs) | ✅ Works | `server.pem`, `localhost.key` stay readable by default. Blocked with `sandbox.deny_key_files_by_extension`, which also blocks a project virtualenv's `certifi/cacert.pem` | | SSH key test fixtures | ⚠️ Blocked | A file named `id_rsa`, `id_dsa`, `id_ecdsa` or `id_ed25519` (such as `testdata/id_rsa`) is denied anywhere, including inside `node_modules`, a virtualenv or a vendored package (only the Go and Cargo stores are exempt), so a test that reads it fails. `.pub` files stay readable. Rename the fixture (`id_rsa.fixture`) or use `--allow-env-files` | | `.env.example` | ⚠️ Blocked | Matches the `.env.*` pattern; use `--allow-env-files` if needed | | Writing `.env` files | ❌ Blocked | Write and delete are denied as well as read, so an agent cannot create a `.env` from a template or remove one; use `--allow-env-files` if needed | | `go mod verify`, `cargo` over an extracted crate | ✅ Works | Read is re-allowed under `~/go/pkg/mod` and `~/.cargo/registry`: a `.env` there is a library's test fixture (`gotenv` ships one), not your secret, and the content is checksum-verified and came from a registry. Write stays denied, and so does read under a path you deny | | A `.env` in another dependency store | ⚠️ Blocked | The carve-out is a short explicit list, not a heuristic — only trees that are content-addressed, verified and registry-sourced | **Fix:** ```bash cplt config set sandbox.allow_env_files true ``` Or for a single run: `cplt --allow-env-files -- -p "start the dev server"` ### A tracked protected file breaks git The table above lists reading and writing. There is a second effect that looks like a git bug rather than a sandbox decision: if one of these files is **tracked by git** and has been modified, git commands that hash the worktree fail outright. ``` error: open(".env.local"): Operation not permitted fatal: cannot hash .env.local ``` `git diff`, `git add`, `git stash` and `git commit -a` all abort — not skip the file, abort — because git hashes worktree files and the read is denied. `git status` still works, since it only stats. An **untracked** file affects only `git add -A`, and a **gitignored** one affects nothing, because git never hashes it. The fix is to stop tracking it, which is worth doing on its own — a file matching these patterns is secret-shaped, and it is in the repository: ```bash git rm --cached .env.local echo ".env.local" >> .gitignore ``` `--allow-env-files` also works, but it unblocks every `.env*`, `.pem`, `.key`, `.p12`, `.pfx` and `.jks` in the project rather than the one file. A targeted `allow.read` on the path does **not** lift the deny; there is no per-file exemption. `cplt check` reports any tracked file matching these patterns, so you can find out before an agent session does. ## Relative paths in `.cplt.toml` now bite (behaviour change) A relative path in a repo `.cplt.toml`, say `deny.paths = ["target"]` or `propose.allow.read = ["vendor"]`, used to be **silently unenforced**. macOS compiled it into the profile as a rule that matched nothing, and Linux dropped it. It is now resolved against the repository root and enforced for real. If your repo already ships one, it starts working on upgrade, and a deny entry that was previously inert will now block a directory that actually exists: | Entry that was inert | What it does now | | ----------------------------------- | -------------------------------------- | | `deny.paths = ["target"]` | blocks the Rust build dir, so builds fail | | `deny.paths = [".git"]` | blocks git entirely | | `deny.paths = ["node_modules"]` | blocks installs and `node` resolution | **Check before upgrading:** ```bash cplt --print-profile | grep 'deny file-read' ``` **Fix:** remove the entry from `.cplt.toml`, or narrow it to the path you actually meant. Paths that do not exist yet are kept (macOS enforces them once created), so an entry naming a future directory is not an error. ## Toolchain homes inside a writable cache (behaviour change) The agent can no longer run programs from a toolchain directory that lands inside a writable cache. This covers two setups: - a toolchain home moved into a cache, such as `CARGO_HOME=~/.cache/cargo` or `GOPATH=~/.cache/go`; - two tool directories whose symlinks point into the same tree, such as `~/.cargo/bin` and `~/.cargo/registry` pointing at one directory. At launch cplt prints a warning naming both directories, then drops the executable one (`~/.cargo/bin`, `go/bin`). The writable cache stays granted. **Why:** when a directory is both writable and executable, an agent can write a program there and run it later. macOS already blocked running programs from such a tree, because the cache's exec deny comes last in the profile. On Linux, Landlock combined the two grants, so the directory really was both writable and executable. **Fix:** move the toolchain home outside `~/.cache` (for example `CARGO_HOME=~/.local/share/cargo`), or point the symlinks at separate trees. ## Go builds in a git worktree A Go build in a git worktree can fail under cplt with: ``` error obtaining VCS status: exit status 128 Use -buildvcs=false to disable VCS stamping. ``` **Why:** Go only treats a `.git` directory as a repository root. A worktree has a `.git` file, so Go keeps walking up. If `$HOME` is a git repo (a dotfiles repo, say), Go runs git there, and the sandbox does not let the agent read `$HOME`. Outside cplt the build passes because Go stamps the dotfiles repo instead. **Fix:** turn off VCS stamping. Set `GOFLAGS=-buildvcs=false` in your shell or in mise's `[env]`; cplt passes `GOFLAGS` through to the sandbox. For a single build, use `go build -buildvcs=false`. ## Lifecycle scripts (postinstall hooks) npm/yarn/pnpm lifecycle scripts are **blocked by default** via `npm_config_ignore_scripts=true` (npm, pnpm 10), `pnpm_config_ignore_scripts=true` (pnpm 11 and later, which no longer read `npm_config_*`), `YARN_IGNORE_SCRIPTS=true` (yarn 1) and `YARN_ENABLE_SCRIPTS=false` (yarn 2+). Each tool reads only its own variable. Two limits: - yarn 2+ blocks only the dependencies' scripts. The project's own `postinstall` still runs, by yarn's design. - yarn 2+ rejects `YARN_IGNORE_SCRIPTS` as an unknown setting (`Unrecognized or legacy configuration settings found: ignoreScripts`), so cplt sets it only when it sees yarn 1 and no yarn 2+. It looks at the directory you start cplt in and its parents, and the nearest one with a `.yarnrc.yml`, a `packageManager` naming yarn, or a `yarn.lock` decides. If none has one, it looks one level down, and every yarn project there must be yarn 1. A yarn 1 project without a `yarn.lock` or `packageManager`, or deeper than one level, still runs its scripts. Commit its `yarn.lock`, or start cplt in its directory. This stops supply chain attacks through postinstall hooks, but it may break packages that need a post-install step: | Operation | Impact | Why | | -------------------------------- | ----------- | -------------------------------------------------------------- | | `npm install` (download only) | ✅ Works | Packages are downloaded and extracted normally | | `npm install` (with native deps) | ⚠️ May fail | Packages like `node-gyp`, `sharp`, `bcrypt` need postinstall | | `npm run build` / `npm test` | ✅ Works | Explicit scripts are not blocked, only lifecycle hooks | | `yarn install` (Yarn Berry) | ⚠️ May fail | If packages have install scripts | **Fix:** ```bash cplt config set sandbox.allow_lifecycle_scripts true --force ``` Or for a single run: `cplt --allow-lifecycle-scripts -- -p "install dependencies"` ## Temp dir execution (go test, mise, node-gyp) Tools that compile then execute from `$TMPDIR` are **blocked by default**, because the sandbox denies `process-exec` and `file-map-executable` from `/private/tmp` and `/private/var/folders`. This affects: | Tool | Impact | Why | | ------------------------- | ----------- | --------------------------------------------------------------------- | | `go test` | ❌ Blocked | Compiles test binaries to `$TMPDIR`, then executes them | | `go run` | ❌ Blocked | Compiles to `$TMPDIR` then executes, same as `go test` | | `go generate` | ❌ Blocked | If the generator is a Go binary compiled to `$TMPDIR` | | `mise run` (inline tasks) | ❌ Blocked | Writes script to temp file, then executes it | | `node-gyp` (native addons)| ❌ Blocked | Compiles C/C++ to temp, then loads via dlopen | | `go build` | ✅ Works | Output binary goes to project dir or `$GOBIN`, not `$TMPDIR` | | `cargo test` | ✅ Works | Rust builds in `target/`, not `$TMPDIR` | | `npm test` / `vitest` | ✅ Works | JavaScript runs via interpreter, not compiled to temp | **Fix:** the scratch dir is **on by default**. cplt creates `~/Library/Caches/cplt/tmp/{session-id}/` with `rwx` permissions, redirects `TMPDIR`, `TMP`, `TEMP`, and `GOTMPDIR` there, and cleans up on exit. Stale directories older than 24 hours are garbage-collected on startup. **JVM note:** on macOS the JVM ignores `TMPDIR`. It reads `java.io.tmpdir` from `confstr(_CS_DARWIN_USER_TEMP_DIR)`, which always returns `/var/folders/...`. So cplt injects `-Djava.io.tmpdir= -Djansi.tmpdir= -Djava.rmi.server.hostname=localhost -Djava.net.preferIPv4Stack=true` via `JAVA_TOOL_OPTIONS`, which puts Maven Surefire forks, the Kotlin compiler daemon, and Jansi native lib extraction on the scratch dir. The RMI hostname flag keeps the Kotlin daemon's Java RMI traffic on `localhost`. The `preferIPv4Stack` flag forces pure IPv4 sockets, because Java's dual-stack IPv6 sockets produce IPv4-mapped addresses that SBPL cannot match, which breaks the sandbox's localhost filtering. Override with `--pass-env JAVA_TOOL_OPTIONS` if you need custom JVM flags. For inline mocking (MockK, Mockito, ByteBuddy), also add `--allow-jvm-attach`. See [JVM Attach API](#jvm-attach-api). **Gradle/JVM still failing?** Some JVM native libraries, `libjli.dylib` and JNI libs among them, call `dlopen` on the system temp dir *before* `JAVA_TOOL_OPTIONS` takes effect. If you see "Operation not permitted" during JVM startup itself rather than during the Gradle build, add `--allow-tmp-exec`: ```bash # Recommended for Gradle projects (localhost + tmp exec + JVM attach): cplt --allow-localhost-any --allow-tmp-exec --allow-jvm-attach -- -p "run tests" # Or set permanently: cplt config set sandbox.allow_localhost_any true cplt config set sandbox.allow_tmp_exec true --force cplt config set sandbox.allow_jvm_attach true ``` **Kotlin daemon on Linux:** the Kotlin compiler daemon writes marker files to `~/.local/share/kotlin/daemon/` and talks over localhost. If you see `AccessDeniedException: .../kotlin-daemon-client-tsmarker*.tmp`, cplt grants write access to `~/.local/share/kotlin/` automatically. If the daemon still cannot connect and falls back to non-daemon compilation with garbled Unicode paths, set `--allow-localhost-any`, because the daemon uses ephemeral ports: ```bash # Recommended for Kotlin/Gradle on Linux: cplt config set sandbox.allow_localhost_any true cplt config set sandbox.allow_jvm_attach true # If you need additional write paths (e.g. custom Kotlin data dir): cplt config set allow.write "~/.local/share/kotlin" ``` If you still see this error, check that you have not set `scratch_dir = false` in your config: ```bash cplt config explain sandbox.scratch_dir ``` ## Embedded Kafka zero-copy on macOS Kafka sends log records through Java `FileChannel.transferTo()`, which uses Darwin `sendfile()`. Older cplt profiles can permit the localhost connection and ordinary writes but deny `sendfile()`, producing `Operation not permitted` in `FileDispatcherImpl.transferTo0`, followed by consumer disconnects or EOF. Updated profiles support this with `sandbox.allow_localhost_any = true`, outside `proxy.forced`. The IPv4-stack JVM setting is still required on macOS; do not clear the `JAVA_TOOL_OPTIONS` cplt injects. Changing `java.io.tmpdir` or enabling JVM attach does not fix this socket check. Explicit per-port localhost grants do not enable this zero-copy allowance. It also enables zero-copy on established non-loopback sockets; new connections still use the existing destination rules. ## Cache exec (Playwright, pnpm dlx, etc.) Some tools unpack and execute binaries straight out of `~/Library/Caches` (macOS) or `~/.cache` (Linux), which is exec-blocked by default: | Tool | Cache path | Fix | |---|---|---| | Playwright Chromium | `~/Library/Caches/ms-playwright/` · `~/.cache/ms-playwright/` | Allow cache exec and disable Chromium's nested sandbox; see below | | Cypress | `~/Library/Caches/Cypress/` · `~/.cache/Cypress/` | Personal `allow_cache_exec`; repository proposal for `allow_localhost_any` | | pnpm dlx | `~/Library/Caches/pnpm/dlx/` · `~/.cache/pnpm/dlx/` | `--allow-cache-exec pnpm/dlx` | | npx / `npm exec` | `~/.npm/_npx/` | Install the package in the project; see [npx](#npx) | **Fix:** ```bash cplt config set sandbox.allow_cache_exec ms-playwright cplt config set sandbox.allow_cache_exec Cypress cplt config set sandbox.allow_cache_exec pnpm/dlx ``` Or for a single run, repeat the flag as needed: `cplt --allow-cache-exec ms-playwright --allow-cache-exec Cypress --allow-cache-exec pnpm/dlx` `--allow-cache-exec-any` opens exec for the entire cache tree (`~/Library/Caches` on macOS, `~/.cache` on Linux). Last resort only. ### npx and `npm exec` A package that is not installed in the project is fetched into `~/.npm/_npx` and run from there. `~/.npm` is writable and deliberately not executable, so the run fails with a message that names neither cplt nor the rule: ``` sh: /Users/you/.npm/_npx/8f497369b2d6166e/node_modules/.bin/cowsay: /usr/bin/env: bad interpreter: Operation not permitted ``` This is the same write-then-exec protection as for the caches above: npx downloads code and runs it. `cplt check exec npx` says so. `~/.npm/_npx` is not under the cache dir, so `allow_cache_exec` cannot name it, and `allow.exec ~/.npm/_npx` is refused because the directory is writable. **Fix:** install the package in the project, and npx runs it from `node_modules/.bin`, which is executable like the rest of the project: ```bash npm install -D cowsay npx cowsay hi ``` If you need npx for packages outside the project, move npm's cache into the cache dir and allow exec on its npx directory only. npm keeps the npx directory at `/_npx`. Put the variable in your shell profile (`~/.zshrc` or `~/.bashrc`), so every new shell has it; the `cplt config set` lines are one-time: ```bash # in ~/.zshrc or ~/.bashrc if [ "$(uname)" = Darwin ]; then export npm_config_cache="$HOME/Library/Caches/npm" else export npm_config_cache="$HOME/.cache/npm" fi ``` ```bash cplt config set sandbox.pass_env npm_config_cache cplt config set sandbox.allow_cache_exec npm/_npx ``` That starts a new npm cache, and every package npx fetches can then run inside the sandbox, as with `pnpm/dlx`. Every entry makes its directory both writable and executable, so an agent can write a binary there and run it. That code still runs inside the sandbox. This holds for `~/.cache/Cypress` and `~/Library/Caches/Cypress` exactly as it does for Playwright's browser cache. **Linux: the cache is always `~/.cache`.** cplt ignores `XDG_CACHE_HOME` for cache-exec grants. The variable comes from the launching environment, and a value such as `~/.config` would otherwise put a write+execute grant on cplt's own config and trust store. A tool that follows a relocated `XDG_CACHE_HOME` (Cypress and Playwright both do) gets no exec grant there. Cypress with a relocated cache (`XDG_CACHE_HOME` or `CYPRESS_CACHE_FOLDER`) is not supported; unset the variable for the run so the tool uses `~/.cache`. **Linux: a symlinked cache-exec entry is granted where it points, with a warning.** Landlock grants the directory a symlink resolves to, so for a link at `~/.cache/ms-playwright`, or above an entry (`~/.cache/pnpm` for `pnpm/dlx`), the agent gets read, write and execute on the target. cplt says so at every launch (`allow_cache_exec path … resolves through a symlink to …`). The sandbox can write `~/.cache`, so an earlier session could have planted the link. If you did not make it, remove it: `rm ~/.cache/ms-playwright && mkdir ~/.cache/ms-playwright`, then reinstall the browsers. `sandbox.refuse_cache_exec_links = true` makes every such link stop the launch; see [configuration](configuration.md#symlinked-cache-exec-entries-sandboxrefuse_cache_exec_links). cplt refuses the launch, with or without the key, when the target is: - exactly `/`, `/tmp`, `$HOME` or a parent of it, or one of the system roots such as `/usr` or `/etc` (the root itself; a path below one is caught by the checks that follow, or not at all); - inside a dot directory in `$HOME` other than `~/.cache`, or inside `~/bin`. These are where the host loads code from on its own: `~/.config/systemd/user`, `~/.config/autostart`, `~/.config/environment.d`, `~/.config/fish/conf.d`, `~/.bashrc.d`, `~/.local/bin`, editor and IDE config such as `~/.config/nvim` or `~/.vscode/extensions`, and agent config such as `~/.claude` or `~/.copilot`; - inside `/run`, `/var/tmp` or `/dev/shm`; - a credential directory, cplt's state, the project, or a path the sandbox already grants (a tool or application directory such as `~/.cache/pnpm`, a read-only config file, a tool prefix). The error reads `… and cplt will not grant write and execute there: `. A move to a data disk (`/data/pw`, `/mnt/…`) or to an ordinary directory in `$HOME` keeps working with the warning. cplt never creates a linked entry's target: if it does not exist, the launch says so and the entry gets no grant until you create it. `~/.cache` itself can be a symlink, since only you can make one there (the sandbox cannot write `$HOME`). Its target goes through the same checks (`Cache-exec root … resolves through a symlink to …, and cplt will not grant write and execute there`), and `sandbox.refuse_cache_exec_links` refuses it too. cplt's scratch directory, on by default, refuses a symlinked `~/.cache` on its own, so this only works with `--no-scratch-dir`. The grant goes on the target cplt checked. When the sandbox is applied, cplt opens it without following a symlink at any level, under bubblewrap as well, where it also checks that the directory in the namespace is the one it checked on the host. A link swapped in after the check therefore stops the launch (`Refusing to grant …: a symlink appeared in its path after cplt checked it`) instead of moving the grant. Run it again once nothing is changing the cache. If bubblewrap itself trips over the change, auto-detect falls back to Landlock-only with its usual warning, as for any bubblewrap start-up failure. The same open needs `openat2(2)`: a container whose seccomp profile refuses it cannot launch with cache exec (`… refuses openat2(2) …`). macOS does not refuse or warn: Seatbelt checks the resolved path against rules on the literal cache path, so a link there grants nothing extra. On macOS, the explicit `Cypress` entry grants Electron permission to register only `com.electron.cypress.MachPortRendezvousServer.` and open `RootDomainUserClient` through `IORegisterForSystemPower` for power monitoring. The `iokit-open-user-client` grant is limited to that class; other IOKit user clients remain denied. It grants read/write, but not execution, under `~/Library/Application Support/Cypress`, where Cypress keeps browser profiles and project state. Without it, `cypress verify` exits during bootstrap with `EPERM (1100)`, or a test run fails while opening its state. On Linux the same entry grants read/write, but not execution, under `${XDG_CONFIG_HOME:-~/.config}/Cypress`. On both platforms cplt refuses to launch if that state directory is a symlink, and on Linux it is opened without following symlinks when the sandbox is applied, like a cache-exec entry. Cypress uses separate ephemeral loopback ports for its HTTP/WebSocket server and the browser's debugging protocol. Prefer requesting this in the committed `.cplt.toml`, because it is a project requirement rather than a machine setting: ```toml [propose] allow_localhost_any = true ``` Each user approves the request with `cplt trust accept`. For a single run, use `--allow-localhost-any`. Setting `sandbox.allow_localhost_any` globally also works, but is not necessary. `proxy.forced` takes precedence and disables this broad localhost grant. To keep forced proxying, pin both Cypress ports and allow only those: ```bash CYPRESS_REMOTE_DEBUGGING_PORT=9333 cplt \ --pass-env CYPRESS_REMOTE_DEBUGGING_PORT \ --allow-localhost 9222 \ --allow-localhost 9333 \ --allow-cache-exec Cypress \ exec -- ./node_modules/.bin/cypress run --port 9222 ``` > **Playwright Chromium on macOS and Linux:** Chromium cannot run its own > nested sandbox in here. On macOS its helpers inherit cplt's profile and cannot > initialize a second Seatbelt sandbox (`forbidden-sandbox-reinit`). On Linux, > cplt's seccomp filter blocks the `unshare`/`setns` syscalls that sandbox > needs. cplt remains the enforcing kernel boundary on both platforms, but a > renderer compromised there receives the full cplt Playwright profile instead > of Chromium's narrower child profile. cplt still validates the cache-exec > subdir as traversal-free before Landlock grants execute access. See > [SECURITY.md](../SECURITY.md#out-of-scope). > > **Which setting applies where.** `playwright` the library already launches > Chromium with `--no-sandbox` unless you explicitly ask for `chromiumSandbox: > true`, so a plain `chromium.launch()` needs no change. `@playwright/mcp` is > the one that turns it back on: always on macOS, and on Linux for channels > other than `chromium` and `chrome-for-testing`. The `ms-playwright` opt-in > therefore sets `PLAYWRIGHT_MCP_SANDBOX=false` for the child, the same way cplt > turns off Gradle's nested sandbox. That keeps the workaround inside cplt > instead of in an MCP server configuration that editors also read on machines > without it. Override with `--pass-env PLAYWRIGHT_MCP_SANDBOX`. Any other > Chromium launcher still needs `--no-sandbox` of its own. Note that the MCP's > README documents an environment variable name the shipped code does not read. ### Puppeteer and mermaid-cli (`mmdc`) Puppeteer downloads Chrome to `~/.cache/puppeteer`. On macOS cplt never allows exec there, and `--allow-cache-exec` only covers `~/Library/Caches/`, so the launch fails with `Error: spawn EPERM`. (On Linux, `allow_cache_exec puppeteer` already covers `~/.cache/puppeteer`.) Puppeteer also talks to Chrome over a localhost WebSocket by default, which fails with `connect EPERM 127.0.0.1:` (`mmdc` only prints `[object Object]`). With `sandbox.allow_cache_exec ms-playwright` (or `ms-playwright/puppeteer`; a pinned browser dir such as `ms-playwright/chromium-` does not cover it) set, cplt sets `PUPPETEER_CACHE_DIR=~/Library/Caches/ms-playwright/puppeteer` for the agent on macOS, a directory that opt-in already makes executable (#742). Install Puppeteer's browser there once, inside cplt (Chrome's download host must be allowed by your network policy) or outside it with the same variable: ```bash PUPPETEER_CACHE_DIR=~/Library/Caches/ms-playwright/puppeteer npx puppeteer browsers install chrome-headless-shell ``` Then launch with `"headless": "shell"` (so Puppeteer uses the `chrome-headless-shell` you installed), `"args": ["--no-sandbox"]` and `"pipe": true`, and no `executablePath`. cplt leaves the variable alone when a Puppeteer config (`.puppeteerrc*`, `puppeteer.config.*`, or a `puppeteer` key in `package.json`) sits in the launch dir or above it, since that may pin its own `cacheDirectory`; `--pass-env PUPPETEER_CACHE_DIR` also keeps your own value, or Puppeteer's default when you have none. Run the install from the project that has Puppeteer (or mermaid-cli) installed, so it fetches the build that version expects. The older route still works: point Puppeteer at Playwright's browser. A `puppeteer-config.json` for `mmdc -p`: ```json { "executablePath": "/Users//Library/Caches/ms-playwright/chromium_headless_shell-/chrome-headless-shell-mac-arm64/chrome-headless-shell", "args": ["--no-sandbox"], "pipe": true } ``` Adjust the path to the `chrome-headless-shell` build you have. `--no-sandbox` is needed for the same reason as with Playwright above; without it the launch fails with `ConnectionClosedError: Connection closed`. Puppeteer only guarantees its own bundled browser, so a Playwright upgrade can bring a Chromium that this Puppeteer version does not support, which is why the cache route above is preferred. `--allow-localhost-any` instead of `"pipe": true` also works. `npx -y @mermaid-js/mermaid-cli` fails before any of this with `bad interpreter`; see [npx](#npx-and-npm-exec) above. ## Copilot CLI's own command sandbox (1.0.83+) Copilot CLI sandboxes shell commands itself since 1.0.83, and that sandbox cannot start inside cplt's. Two changelog entries in that release set the requirement: > Linux sandboxes now restrict network egress to the configured proxy; proxy > mode requires slirp4netns, util-linux 2.35+, iptables, and /dev/net/tun access > — 1.0.83-2 > Linux sandboxing now needs slirp4netns, nsenter, iptables, ip6tables, > iptables-restore and ip6tables-restore on PATH — 1.0.83-5 cplt sets `HTTP_PROXY` and `HTTPS_PROXY` for the child, so Copilot takes the proxy path whether or not the user asked for it. That path builds a network namespace, and cplt's seccomp filter denies `unshare` and `setns` with `EPERM` — the same wall Chromium's sandbox, the Gradle daemon and DSH's inner runner hit. macOS refuses a nested `sandbox-exec` for the same reason it refuses Chromium's. **cplt therefore sets `COPILOT_CLI_SANDBOX_SUPPORT_OVERRIDE=unsupported` for the child.** That is Copilot's own escape hatch, read in its native runtime before the host-support probe runs. Copilot disables its sandbox for the session and says so on stderr: > Sandboxing is disabled for this session because this host does not support it. > Shell commands and sandboxed services will run unsandboxed. Your > `sandbox.enabled` setting remains unchanged. Nothing is written to your `settings.json`: the next Copilot session outside cplt sandboxes exactly as it did before. Skipping the probe is worth something on its own — on Linux the probe spawns `bwrap` from inside cplt's sandbox. **cplt is then the only boundary.** That is the same trade the Playwright and DSH entries above make, and here it is not really a trade: the inner sandbox cannot start, so the alternative is not two boundaries but a launch that hangs. Hand the choice back with `--pass-env COPILOT_CLI_SANDBOX_SUPPORT_OVERRIDE`, and set the variable yourself, if you have the Linux prerequisites installed and want Copilot's sandbox inside cplt's. **One case this does not cover.** An enterprise managed policy can require the sandbox. Copilot then floors the setting on, refuses `--no-sandbox`, and no environment variable lifts the floor. That includes the fail-closed case where the policy could not be *read* — Copilot turns the sandbox on and says "Sandboxing is enabled because your organization's managed policy couldn't be read". If a session under cplt hangs right after the startup banner and your organization manages Copilot, that is the first thing to check. ## Setuid and setgid binaries (`ps`, `top`, `sudo`) **macOS:** a sandboxed process cannot exec a setuid or setgid binary. The kernel refuses it for any Seatbelt-sandboxed process, whatever the profile allows, so there is no cplt setting that lifts it. On macOS that includes `/bin/ps`, `/usr/bin/top`, `/usr/bin/sudo`, `/usr/bin/su` and `/usr/bin/login`: ``` $ cplt exec -- /bin/ps sandbox-exec: execvp() of '/bin/ps' failed: Operation not permitted ``` Scripts that shell out to `ps` fail the same way, for example Go's `fork/exec /bin/ps: operation not permitted`. Run them outside cplt. **Linux:** Landlock sets `no_new_privs`, so the binary runs but the setuid bit is ignored. A tool that only reads what any user may read works; one that needs the elevated uid, such as `sudo`, fails. `cplt check exec /bin/ps` reports this on macOS: BLOCKED, with the setuid reason. On Linux a binary that carries the setuid/setgid bit is ALLOWED with a note that it runs without elevated privileges; `/bin/ps` there usually has no such bit and is plain ALLOWED ([#620](https://github.com/navikt/cplt/issues/620)). ## Localhost blocking Localhost outbound is blocked by default on macOS, so sandboxed processes cannot connect to local services. On Linux, Landlock rules are port numbers only and cannot pin to localhost, so a local service on an allowed port is reachable; use `--with-proxy` for SSRF protection, and see [Linux limitations](../SECURITY.md#linux-specific-limitations). | Operation | Impact | Why | | ------------------------------ | ----------------- | ---------------------------------------------------- | | `npm install` (registry) | ✅ Works | Uses HTTPS to `registry.npmjs.org:443` | | `gradle build` (Maven Central) | ✅ Works | Uses HTTPS to `repo1.maven.org:443` | | Gradle daemon (ephemeral port) | ❌ Blocked | Use `--allow-localhost-any` (daemon uses random ports) | | Gradle/JVM startup (native libs)| ❌ Blocked | Use scratch dir (default) or `--allow-tmp-exec`, see [JVM note](#temp-dir-execution-go-test-mise-node-gyp) | | Local PostgreSQL (`:5432`) | ❌ Blocked | Use `--allow-localhost 5432` | | Local Redis (`:6379`) | ❌ Blocked | Use `--allow-localhost 6379` | | Local Kafka (`:9092`) | ❌ Blocked | Use `--allow-localhost 9092` | | MCP servers | ❌ Blocked | Use `--allow-localhost 3000` | | Local API/dev server | ❌ Blocked | Use `--allow-localhost 8080` | | Spring Boot (`:8080`) | ❌ Blocked | Use `--allow-localhost 8080` | | Next.js/Turbopack build | ❌ Workers blocked | Use `--allow-localhost-any` (random ephemeral ports) | **Fix:** use `cplt config set allow.localhost ` for specific services, or `cplt config set sandbox.allow_localhost_any true` for build tools that pick random ports (Next.js, Vite, esbuild). ## App/build can't reach the network under cplt When the proxy is on (the default), cplt injects the standard `HTTP(S)_PROXY` env vars so agent traffic can be filtered and logged, and **every** process inherits them: your app, your build, your tests, not just the agent. So when something reaches the network fine outside cplt but times out under it, the cause is usually the HTTP client mishandling those vars, or a domain or port that really is blocked, not cplt refusing all traffic. Some client versions have shipped proxy-handling regressions, so check for a newer patch release. cplt does not restrict outbound *domains* by default, but the proxy still enforces the port policy, the private-IP and localhost safeguards, and any configured allowlist or blocklist. To see what happened, run with the proxy log (`--proxy-log ` or `--proxy-log-level all`) and look for `BLOCKED` lines. If nothing was blocked, the request left cplt and the fault is client-side. `BLOCKED-*` lines mean a port or a configured list stopped it, a policy question rather than a client bug. ## DNS lockdown (`proxy.deny_dns`, macOS) Off by default. With `proxy.forced` on, `cplt config set proxy.deny_dns true` takes the system resolver away from the agent: the profile drops the `/private/var/run/mDNSResponder` socket and denies the mDNSResponder Mach services (`com.apple.dnssd.service`, `com.apple.mDNSResponder.*`). A DNS query can then no longer carry data out past the proxy ([#653](https://github.com/navikt/cplt/issues/653)). The agent's own processes can then only reach a name through the proxy, which resolves the host name in the `CONNECT` request, so the allowlist and the proxy log see those lookups. Without `proxy.forced` the key does nothing and cplt says so at launch. `proxy.forced` on its own, with or without this key, also denies `com.apple.nsurlsessiond`. That daemon runs background `URLSession` transfers outside the sandbox and the proxy, so macOS tools that hand downloads or uploads to a background session (some Swift and Xcode tooling, `softwareupdate`-style downloaders) fail under `proxy.forced`. Checked with cplt on macOS 26 ([#653](https://github.com/navikt/cplt/issues/653)): | Tool | With `deny_dns` | Why | | --- | --- | --- | | `curl`, `git` over HTTPS, `gh`, Node.js `fetch` (cplt sets `NODE_USE_ENV_PROXY`), Python `urllib` | ✅ works | sends the host name to the proxy, resolves nothing itself | | `getaddrinfo` callers: Node.js `dns.lookup`, Python `socket.getaddrinfo`, `dscacheutil` | ❌ "not known" / `ENOTFOUND` | they ask the system resolver, which is closed | | `ssh` (also `git@github.com:`) | ❌ "Could not resolve hostname" | port 22 is closed under `proxy.forced` anyway | | NSURLSession, Swift `URLSession`, `nscurl` | ❌ NSURLError -1003 | ignores `HTTPS_PROXY` and resolves through `com.apple.dnssd.service`; under `proxy.forced` alone it already fails at connect | | A client that resolves first, then connects through the proxy by IP | ❌ | resolve before the proxy, so it gets no address. Check its proxy settings for a "remote DNS" or `socks5h`-style option | Expected to work but not run in this check: the Copilot CLI, OpenCode and Claude Code, `npm`, `pip` and Go programs, all of which hand the host name to `HTTPS_PROXY`. A tool that fails only with `deny_dns` resolves names itself; turn the key off for that run's config, or run the tool outside cplt. What stays open: - System daemons that do network work for the agent resolve outside the sandbox. `trustd` is needed for TLS: when the agent has a crafted certificate verified, trustd looks up and fetches the certificate's AIA, OCSP or CRL URLs, so a name the agent picks still reaches DNS. - Loopback UDP to a port opened with `allow.localhost` (`--allow-localhost `) reaches a local resolver if one listens there. **Linux:** the key has no effect, and cplt says so at launch. `proxy.forced` already blocks direct UDP DNS there. Lookups through a local Unix-socket resolver (nss-resolve, nscd) below kernel 7.1 are not closed and not tested; a full fix needs the network namespace tracked in [#114](https://github.com/navikt/cplt/issues/114). ## Tools that ignore `HTTPS_PROXY` under `proxy.forced` `proxy.forced` is on in configs created by `cplt config init` ([#822](https://github.com/navikt/cplt/issues/822)), except on Linux below Landlock ABI 4. Without the key it stays off: an existing config keeps its setting, and `cplt doctor` suggests turning it on. Under `proxy.forced` the proxy is the only way out, so a tool that does not use `HTTP(S)_PROXY` cannot reach the network at all: | What | Under `proxy.forced` | Fix | |---|---|---| | Node.js `fetch()` below 24.5 (undici ignores `HTTPS_PROXY`) | ❌ `EAI_AGAIN` on Linux, `connect EPERM` on macOS | Use Node 24.5 or newer, which honours the `NODE_USE_ENV_PROXY=1` cplt sets, or a proxy-aware client | | `curl --noproxy '*'`, or anything that resolves names itself (Go/Rust binaries without proxy support) | ❌ `Could not resolve host` (curl exit code 6) on Linux | Drop `--noproxy`, or make the tool read `HTTPS_PROXY` | | QUIC / HTTP/3 | ❌ UDP is blocked | Clients fall back to TCP through the proxy | | JVM builds (Gradle, Maven) | ❌ see [Localhost blocking](#localhost-blocking) and [Gradle plugin workers](#gradle-plugin-workers-and-java_tool_options) | macOS: pin the ports. Linux: `--no-proxy-forced`, because the JDK's UDP socket gets `EPERM` whatever the ports | | curl, npm, pip and Python `urllib` through the proxy | ✅ Works | | On Linux, DNS inside the sandbox does not work under `proxy.forced`: a seccomp rule allows only TCP sockets, so `getaddrinfo` cannot open its UDP socket (see [UDP is unrestricted outside proxy-forced](#udp-is-unrestricted-outside-proxy-forced-linux)). Proxy-aware tools are unaffected, because the proxy resolves names. A DNS error inside the sandbox means the tool bypassed the proxy, not that the network is down. cplt says so once per project at launch, and in the agent brief when `sandbox.brief` is on. **Recovery:** `--no-proxy-forced` for one run, or `cplt config set proxy.forced false` to keep it off. ## Docker and Testcontainers Docker is **intentionally blocked**. `~/.docker` is denied, and on macOS the Docker socket is not reachable, since `(deny default)` covers `network-outbound` to unix sockets. Docker gives near-root access to the host system, which defeats the purpose of sandboxing. **On Linux it depends on bubblewrap.** Unix socket `connect()` is not gated by Landlock before ABI v9 (kernel 7.1), and a read-only bwrap bind does not stop a `connect()` either, so the only thing that removes the daemon socket on a current kernel is bubblewrap's mount mask over `/run/docker.sock`, `/var/run/docker.sock`, `/run/user//docker.sock`, `/run/user//podman` and `/run/podman`. Without bubblewrap an agent that can speak HTTP over the socket has near-root access to the host. `--allow-docker` lifts those masks, grants the sockets in Landlock, and grants `~/.docker` read-only (no write), so CLI contexts and registry auth work as they do on macOS. One difference: macOS carves `~/.docker/trust/private` back out; Landlock cannot deny a subpath of a granted directory, so on Linux the flag exposes Docker Content Trust keys too. See [Linux limitations](../SECURITY.md#linux-specific-limitations). - Docker commands, `docker compose`, and Testcontainers will fail - Local databases via Docker Compose need `--allow-localhost ` for the exposed port (the database container runs outside the sandbox) - Consider running database/Kafka containers before starting cplt, then use `--allow-localhost` for the ports `cplt init` proposes `allow_docker` only for a repo that runs containers during development: one with a compose file, or a Testcontainers dependency. A bare `Dockerfile` gets a diagnostic instead, since the image is usually built in CI and editing the file does not need the daemon. **Opting in (⚠️ dangerous):** if you understand the risks (container mounts bypass the sandbox entirely), you can allow Docker access: ```bash cplt config set sandbox.allow_docker true --force # or per-session: cplt --allow-docker ``` **Rootless Podman or Docker on Linux (⚠️ dangerous):** `sandbox.allow_rootless_docker` (`--allow-rootless-docker`) grants only the sockets under `$XDG_RUNTIME_DIR` (`docker.sock` and `podman/`). `/run/docker.sock`, `/var/run/docker.sock`, `/run/podman`, Docker Desktop and Colima stay masked, and `~/.docker` stays denied. Point the client at the socket with `DOCKER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock`. A rootless container still runs as you, outside the sandbox, and can mount your home directory. The rootful sockets are only masked when bubblewrap is active or Landlock is ABI v9 (kernel 7.1+); the launch banner says which. `allow_docker` wins if both are set. On macOS the key has no effect and warns. ```bash cplt config set sandbox.allow_rootless_docker true --force # or per-session: cplt --allow-rootless-docker ``` For Podman, use the remote client: `podman --remote` with `CONTAINER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock` (or the `docker` CLI with `DOCKER_HOST`). Both variables pass through to the agent. Local `podman` cannot work inside the sandbox: it needs user namespaces, and the seccomp filter blocks them. Both docker keys also grant `$XDG_RUNTIME_DIR/libpod` read-write, where Podman writes its lock and tmp files even in remote mode. ```bash export CONTAINER_HOST=unix://$XDG_RUNTIME_DIR/podman/podman.sock podman --remote images ``` **`/proc/self` for child processes:** under bubblewrap the agent's whole `/proc` is readable, because bubblewrap mounts a fresh `/proc` for a new pid namespace that lists only sandbox processes. Without bubblewrap only the agent's first process can read its own `/proc/self/cmdline`, `stat` and `status`; anything it starts gets `EACCES`, and tools that read their own command line (Podman: `cannot retrieve cmd line`) fail. cplt does not grant `/proc` there, since it would expose the command line of every host process, and says so at launch. Install bubblewrap, or pass `--allow-read /proc` if that exposure is acceptable. ## SSH agent blocking SSH agent access is blocked on macOS (the agent socket is not reachable — the profile's `(deny default)` covers `network-outbound` to unix sockets) and `SSH_AUTH_SOCK` is stripped from the environment on both platforms. `~/.ssh` is denied as well, so there is no key file to fall back on. Unless the agent reaches a loaded agent socket (possible on Linux, see the caveat below), this means: - `git clone` over SSH fails, because ssh has no key to authenticate with. Use HTTPS clones instead - `ssh` commands spawned by the agent fail to authenticate - `gh` CLI uses HTTPS by default and is unaffected **What is blocked is the key material, not SSH as a transport.** SSH to port 22 is closed by default, because outbound TCP is limited to port 443. But SSH servers that listen on 443 are reachable like any other host on 443. GitHub runs one at `ssh.github.com:443`, so `ssh -p 443 git@ssh.github.com` opens a connection with no configuration change. Without a reachable key it then fails at host-key verification or authentication, not for lack of a route. See [SSH over port 443](#ssh-over-port-443) below. **Linux caveat — this is not kernel-enforced below kernel 7.1.** Landlock gains the unix-socket `connect()` right only at ABI v9, and the SSH agent socket is not in the set bubblewrap masks (see [Linux limitations](../SECURITY.md#linux-specific-limitations)), so the withheld `SSH_AUTH_SOCK` is the whole barrier: without bubblewrap `/tmp` is readable, so `ls /tmp/ssh-*/agent.*` finds a stock OpenSSH socket and `SSH_AUTH_SOCK=... ssh-add -l` uses the loaded keys. bubblewrap's private `/tmp` hides that one, but not a gnome-keyring or systemd agent at a fixed `$XDG_RUNTIME_DIR` path. Bubblewrap's private `/tmp` helps only for that stock layout. The desktop agents put their socket under `$XDG_RUNTIME_DIR` — `gcr/ssh` (gnome-keyring), `keyring/ssh`, `openssh_agent` (systemd) — a fixed path under `/run/user/` that bwrap's read-only bind of `/` leaves visible and that needs no enumeration. On GNOME or KDE, bwrap adds nothing here. If your keys must be unusable by a compromised agent on Linux, unload them (`ssh-add -D`) before starting the session. ### If you do need SSH inside the sandbox For a remote on the default port 22, three things have to line up, and the same configuration works on both platforms: ```bash cplt config set allow.ports 22 # outbound is 443-only by default cplt config set allow.read "~/.ssh/id_ed25519" # the key, by name cplt config set allow.write "~/.ssh/known_hosts" # ssh appends on a first connection ``` Port 22 is needed only for the default `github.com:22` endpoint. It is not a gate on SSH in general: the key grant alone is enough to reach GitHub over `ssh.github.com:443` (see below). `~/.ssh/config` needs its own `allow.read` if you have one. `known_hosts` needs **write**, not read — under the default `StrictHostKeyChecking` a first connection to a host appends to it, and a read-only grant fails the connection. **The directory itself cannot be granted.** `allow.read = "~/.ssh"` (or `~/.aws`, `~/.gnupg`, any of the credential directories) is refused at startup with an error naming the per-path route inside it. macOS never honoured such a grant — the blanket subpath deny beats it whatever the config says — while Linux granted it for real, so the same config file opened every key on one platform and nothing on the other. Refusing is the only answer both backends give alike. One gap remains on Linux, unchanged and the same limitation described under [private registries](#private-registries): a grant on a *parent* — `$HOME` itself — still exposes everything under it, because Landlock cannot deny a subpath inside an allowed directory. Granting the key file hands the agent the key itself: a file the agent can read, it can also copy out over 443. Treat the grant as exposing that key, not as a hardening step. ### SSH over port 443 cplt never inspects what travels over an allowed connection. Two paths reach an SSH server on 443: - **Default mode.** The sandbox allows outbound TCP to any remote host on port 443 (`(allow network-outbound (remote tcp "*:443"))` on macOS, a port-443 Landlock rule on Linux with kernel 6.7+; older Linux kernels have no kernel port rule at all). `ssh` does not honour `HTTPS_PROXY`, so it connects directly and the proxy never sees it. Domain allowlists and blocklists do not apply to it. - **Proxy-forced mode** (`proxy.forced`, also set by `--preset strict`). Direct `:443` is closed in the kernel and `allow.ports` opens no kernel port either, so `ssh -p 22` and a direct `ssh -p 443` both fail. An ssh `ProxyCommand` that speaks HTTP CONNECT to cplt's proxy still gets through: the proxy accepts port 443, and its domain matching covers subdomains, so a `github.com` allowlist entry, which the Copilot default allowlist has, also allows `ssh.github.com`. The proxy forwards bytes without looking at them, so an SSH session inside the tunnel looks like any other. So neither port rules nor proxy-forced mode stop SSH to `ssh.github.com:443`. What stops it from being useful is that the agent has no key. On Linux below kernel 7.1 that is not guaranteed either: an agent that finds a loaded SSH agent socket (see above) has a key, and needs no configuration change to reach `ssh.github.com:443` with it. This is not specific to SSH: any protocol can run over an allowed 443 connection. **To block it deliberately,** turn on proxy-forced mode, so every connection goes through the proxy, and put `ssh.github.com` in the file `proxy.blocked_domains` points at (see [Blocklist](proxy.md#blocklist)). The file must live outside the project and any `allow.write` tree. The blocklist is checked after the allowlist, so it wins over the `github.com` entry. This blocks that one host. It does not block SSH to another server listening on 443 under a domain you allow. **To allow it deliberately,** grant the key file as above. Port 22 is not needed if you point the remote at `ssh://git@ssh.github.com:443/org/repo.git`. Under proxy-forced mode you also need a `ProxyCommand` that tunnels through cplt's proxy. ## D-Bus and systemd (Linux) — the session manager is reachable `XDG_RUNTIME_DIR` is on the environment allowlist, and Landlock cannot restrict `connect()` to a pathname unix socket before ABI v9 (kernel 7.1), so below that ABI `$XDG_RUNTIME_DIR/bus` is reachable from inside the sandbox unless bubblewrap masks it. Private Secret Service reads were demonstrated on ABI 4 as [described above](#linux-keyring-access-depends-on-the-backend-and-agent-version); the session-manager escape remains a code-reading conclusion. `systemd-run --user ` hands the command to the user's systemd instance, which starts it as a new unit **outside** Landlock, seccomp and the bubblewrap namespaces. The same applies to any other D-Bus service on the session bus that can start a process. - **Require `sandbox.use_bubblewrap = true`** for the configured masks over `$XDG_RUNTIME_DIR/bus`, `$XDG_RUNTIME_DIR/systemd` and `/run/dbus/system_bus_socket`. They cover selected known endpoints, not every custom pathname or abstract socket, and were unverified in the measured environment - **Without it** — not installed, `--no-bubblewrap`, or an auto-detect fallback at spawn time — nothing closes it below kernel 7.1 - From kernel 7.1 (Landlock ABI v9), cplt's existing `ResolveUnix` rules mediate pathname connections; writable-tree grants, including `/tmp` and the project, also allow them. ABI v6 abstract-socket scoping does not close the ABI 4–8 pathname gap - If your threat model needs it closed, verify the masks in the actual run, or use a container or VM without the host bus exposed ## UDP is unrestricted outside proxy-forced (Linux) Landlock gates UDP only at ABI v10 and cplt handles TCP connect alone, so outbound UDP to any host and port — and inbound UDP bind — is unrestricted at the kernel in every Linux mode except `proxy.forced`. The CONNECT proxy carries TCP only, so UDP traffic never reaches it and never appears in the proxy log. - Under **`proxy.forced`** a seccomp rule permits only `SOCK_STREAM` with protocol 0 or `IPPROTO_TCP` for `AF_INET`/`AF_INET6`, which removes UDP, raw, SCTP and DCCP on any kernel. Anything that opens such a socket gets `EPERM` from `socket()`, not just code that sends UDP: the JDK enumerates interfaces via `socket(AF_INET, SOCK_DGRAM, 0)` + `SIOCGIFCONF` and throws `SocketException`, which Gradle propagates (already moot under `proxy.forced`, where the port lock breaks Gradle anyway). seccomp cannot see the destination address at `socket(2)`, so no loopback exemption is possible - Outside `proxy.forced` the rule is deliberately not applied — denying `SOCK_DGRAM` would break `getaddrinfo(3)`, and so all DNS, for every non-proxied tool - DNS tunnelling, QUIC/HTTP-3, and plain UDP exfiltration are not covered by any cplt layer on Linux outside `proxy.forced` - The proxy log is therefore not a complete record of what left the machine on Linux - macOS allows only TCP on 443 and on `--allow-port` ports ([#815](https://github.com/navikt/cplt/issues/815)), so UDP to remote hosts is denied in every mode and HTTP/3 clients fall back to TCP. Loopback UDP stays open under `--allow-localhost`. Direct TCP/443 still bypasses the proxy outside `proxy.forced`, so only there is the macOS proxy log complete ## macOS protected folders (Desktop, Documents) macOS TCC (Transparency, Consent, and Control) protects certain folders at the kernel level. Without Full Disk Access, Copilot CLI cannot reach `~/Desktop` or `~/Documents` **with or without cplt**. That is a macOS restriction, not a sandbox limitation, and the cplt sandbox stays fully active either way. | Path | Without FDA | With FDA | Notes | | ---- | :---: | :---: | --- | | `~/Desktop` | ❌ | ✅ | TCC-protected | | `~/Documents` | ❌ | ✅ | TCC-protected | | `~/Downloads` | ✅ | ✅ | Less restrictive TCC policy | | Dragged screenshots | ❌ | ✅ | `TemporaryItems/NSIRD_*` are per-process isolated | **Fix: grant Full Disk Access to your terminal** (recommended): 1. Open **System Settings → Privacy & Security → Full Disk Access** 2. Enable your terminal app (Terminal.app, iTerm2, Ghostty, etc.) 3. **Restart the terminal.** TCC grants only take effect for new processes This lifts TCC restrictions for all child processes, while the cplt sandbox keeps enforcing its own deny-by-default rules (write protection, network filtering, dotfile access, etc.). **Alternatives** (if you prefer not to grant FDA): 1. **Copy files into your project**: ```bash cp ~/Desktop/screenshot.png . ``` 2. **Use a non-protected folder** for screenshots: ```bash defaults write com.apple.screencapture location ~/Screenshots mkdir -p ~/Screenshots ``` Then add to config: ```toml [sandbox] allow_read = ["~/Screenshots"] ``` ## Git workflow (commit & push) Git commit works for every agent. Whether `git push` works over HTTPS depends on which agent is running, because the credential channel differs per agent. **The git guard refuses a push to the default branch by default.** A feature-branch push goes through; `git push origin main` is blocked, and so is any force push. That is `git_guard.protect_default_branch_only`, on out of the box. `--preset strict` widens it to every push; `git_guard.mode = "warn"` goes back to observing. **Prerequisites:** 1. **Use HTTPS remotes** (not SSH). Check with `git remote -v`: ```bash # If you see git@github.com:org/repo.git, switch to HTTPS: git remote set-url origin https://github.com/org/repo.git ``` Or rewrite globally for all repos (no remote changes needed): ```bash git config --global url."https://github.com/".insteadOf "git@github.com:" ``` Git then uses HTTPS transparently even when remotes are configured as SSH. The rewrite is read from `~/.gitconfig`, which is readable inside the sandbox. 2. **Authenticate with `gh`** outside the sandbox: ```bash gh auth login # one-time setup ``` 3. **Configure the git credential helper** (not needed for `github.com` with the gh guard on): ```bash gh auth setup-git ``` `gh auth setup-git` writes the helper with an absolute path (for example `!/opt/homebrew/bin/gh auth git-credential`). With the gh guard on, cplt adds `!gh auth git-credential` as a last helper for `https://github.com` inside the sandbox, so git goes through the gh guard and gets the exec token ([how](gh-guard.md#how-gh-itself-authenticates)). Your own `github.com` helpers run first and win when they return a credential. Your git config is not changed. The same helper means the agent can get the token itself; see [the limit](../SECURITY.md#github-token-handling-per-agent). **Credentials per agent (macOS).** The helper only produces a token if `gh` can reach one from inside the sandbox: | Agent | HTTPS push | Credential source | | ------------------------------------------- | ----------- | -------------------------------------------------------------------------- | | `copilot` | Token source depends on launch | On macOS substitution is on by default, but denies Keychain access only when a substitute resolves. `gh` reads surviving `GH_TOKEN`/`GITHUB_TOKEN`; it ignores `COPILOT_GITHUB_TOKEN`. The guard can supply an exec token when the grant was dropped and gh has no environment token. No substitute, or a detected account mismatch, keeps the Keychain grant | | `antigravity`, `claude`, `goose` | Token source depends on launch | Login Keychain is granted unless the agent's substitution applies. With the gh guard on, its exec token can authenticate gh without that grant | | `opencode`, `pi`, `dsh`, `cplt exec` | ✅ Works | Keychain is denied. The gh guard reads the token at launch and hands it to `gh` as the exec token when git asks for credentials ([how](gh-guard.md#how-gh-itself-authenticates)). With `gh_guard.enabled = false` these agents need a token in `hosts.yml` or `--pass-env GH_TOKEN` | Authentication still depends on a valid credential for the target host. A token stored in `hosts.yml` is normally readable by every agent; gh can instead keep the secret in the OS credential store. `--pass-env GH_TOKEN` forwards an exported token regardless of agent, unless `deny.env` removes it. The Keychain row has one caveat. When the Keychain substitute applies (`sandbox.keychain_substitute`: by default for Copilot and Claude Code, `true` for the others) the Keychain grant is dropped for an agent whose own credential already reaches it another way: `claude` with `CLAUDE_CODE_OAUTH_TOKEN` exported, `antigravity` with its `~/.gemini/antigravity-cli/antigravity-oauth-token` fallback file present. The drop takes `gh`'s Keychain token with it. With the gh guard on, the exec token covers push for those two agents as in the row below; with it off, push fails for them in that configuration. Copilot keeps a token `gh` can use when the one it was handed is `GH_TOKEN` or `GITHUB_TOKEN`, which covers the `gh auth token` handover. A token exported only as `COPILOT_GITHUB_TOKEN` is one `gh` does not read, so there the exec token is written and the gh guard covers push. Push under a dropped grant was not tested. goose, and Antigravity without its fallback file, have no substitute and keep the whole-Keychain grant; that gap is known. For Claude Code the drop also takes its MCP OAuth tokens, which it keeps in the Keychain on macOS. Each MCP server that uses OAuth asks for one fresh sign-in, and Claude Code then stores the token in plaintext in `~/.claude/.credentials.json`, which the agent can read and write ([credential management](https://code.claude.com/docs/en/authentication.md#credential-management)). Those MCP tokens are exposed instead of the Keychain items the grant reached. **On Linux there is no Keychain grant to drop.** Host-side gh can retrieve a stored token from the Secret Service or `hosts.yml`; the guard's exec cache can supply it to sandboxed gh. Direct access to the Secret Service inside the sandbox is not guaranteed. `hosts.yml` and `config.yml` are normally readable for every agent, including at an absolute `GH_CONFIG_DIR` or `$XDG_CONFIG_HOME/gh` override. A `--deny-path` on that directory blocks sandboxed file access where enforced. It does not prevent host-side gh extraction, environment handover or guard token caches; see [Honest gaps](../SECURITY.md#honest-gaps). Exporting a token in your own shell is not enough on its own: `GH_TOKEN`, `GITHUB_TOKEN` and `COPILOT_GITHUB_TOKEN` are stripped from the environment of every agent except Copilot. Passing one explicitly does work, because `--pass-env` is applied after that suppression: `cplt --pass-env GH_TOKEN exec …` forwards it, and `gh auth status` then reports `Logged in to github.com account … (GH_TOKEN)`. Note that `--pass-env` is a global flag and goes before the subcommand, not after it. A repo `deny.env` naming the variable strips it again. `gh auth token` through the cplt `gh` shim is served from a one-shot cache that cplt writes for Copilot only, so with `block_auth_token = true` (the default) every other agent gets "No cached token available". Turn the setting off and the shim stops intercepting, leaving `gh` to answer from whatever source it can reach. Either way this is unrelated to whether push works. **Push before `gh pr create`.** With no TTY, which is how agents normally run, `gh pr create` refuses to push for you and fails with "aborted: you must first push the current branch to a remote, or use the --head flag". Push the branch first. **Optional, signed commits:** add `--allow-gpg-signing` (see [GPG signing](#gpg-commit-signing)). > **Why is SSH blocked?** The SSH agent socket gives access to *all* loaded keys, which could authenticate to any host. HTTPS through the `gh` credential helper is scoped to GitHub only. See [SSH agent blocking](#ssh-agent-blocking). > **Tip:** the git guard already refuses default-branch and force pushes, but it is a command filter an agent can step around (see [SECURITY.md](../SECURITY.md)). Add [branch protection rules](https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-a-branch-protection-rule/about-branch-protection-rules) so the server refuses them too. Good practice regardless of cplt. ## Git restrictions Some git operations are blocked to prevent persistence attacks that would survive the sandbox session: | Operation | Impact | Why | | ---------------------------------- | ----------- | ----------------------------------------------------------------- | | `git add/commit/status/diff/log` | ✅ Works, unless a protected file is tracked | Local operations, no writes to protected paths — but git hashes worktree files, so a **tracked** `.env*`/`.pem`/`.key` that has been modified aborts `add`, `diff`, `stash` and `commit -a`. See [A tracked protected file breaks git](#a-tracked-protected-file-breaks-git) | | `git checkout/merge/rebase/branch` | ✅ Works | Branch operations work normally | | `git fetch/pull/push` (HTTPS) | ✅ Works, except a default-branch push | Port 443 allowed, `gh auth git-credential` provides credentials. The git guard refuses pushes to `main`/`master` and every force push, see [Git workflow](#git-workflow-commit--push) | | `git fetch/pull/push` (SSH) | ❌ No key on macOS | SSH agent socket and `~/.ssh` denied, so authentication fails; use HTTPS. The network path is open on 443 (`ssh.github.com:443`), see [SSH over port 443](#ssh-over-port-443). On Linux only `SSH_AUTH_SOCK` is withheld | | `git config` (local) | ❌ Blocked on macOS | `.git/config` is write-protected on macOS, which prevents `url.*.insteadOf` hijacking. Applies to the project, to every named repository (`--repo-dir`), to every `allow.write` grant, and to any repository nested under one. Blocked on Linux too with `sandbox.allow_git_config_write = false` (new configs, needs Bubblewrap) | | `git config --global` | ❌ Blocked | Git config and `~/.gitignore_global` are read-only | | `git remote set-url` | ❌ Blocked on macOS | Writes to `.git/config`; on Linux blocked only with `sandbox.allow_git_config_write = false` | | `git submodule add` | ❌ Blocked on macOS | `.gitmodules` is write-protected on macOS. Writable on Linux, same Landlock limit (supply chain vector) | | Creating git hooks | ❌ Blocked | `.git/hooks/` is write-protected in the project, in every named repository (`--repo-dir`), in every `allow.write` grant, and in any repository nested under one, hooks run unsandboxed. On Linux this needs the Bubblewrap layer, and nested repositories are found by a bounded scan: three levels down, 20 000 directories in total, symlinks not followed. A repository deeper than that, past the cap, bare, or a nested worktree/submodule stays writable there. See [security.md](security.md) | | `git init` / `git clone` | ❌ Blocked on macOS | Both write `.git/config` and `.git/hooks/`, which are protected anywhere under a writable root. Clone outside the sandbox, then point cplt at the result. They work on Linux, also with `sandbox.allow_git_config_write = false`: a bind covers only a `.git/config` that exists at launch, so a repository created in the session stays writable | | `git submodule update --init` | ❌ Blocked on macOS | Records each submodule's URL in `.git/config`. On Linux blocked with `sandbox.allow_git_config_write = false` | | `git sparse-checkout init/set` | ❌ Blocked on macOS | Writes `core.sparseCheckout` (and `config.worktree`) into the git config. On Linux blocked with `sandbox.allow_git_config_write = false` | | `gh pr checkout` (fork PR) | ❌ Blocked on macOS | Adds a remote and branch tracking for the fork to `.git/config`. A PR from the same repository works. On Linux blocked with `sandbox.allow_git_config_write = false`. Check out fork PRs outside the sandbox | | `mv` / `rm -rf` of `.git`, `.github`, `.agents`, `.claude`, `.opencode`, `.pi` | ❌ Blocked | A path deny only holds while the path still denotes what it protects. Moving one of these directories aside, writing `hooks/`, `plugins/` or `settings.json` under the new name and moving it back planted a file that runs on the host, unsandboxed, in a later session (GHSA-39xf-9j26-f82m). The directories themselves stay writable — only renaming and removing the directory is refused. macOS uses `file-write-unlink`; on Linux this needs the Bubblewrap layer, which makes each a mountpoint so `rename`/`rmdir` return `EBUSY` | | `mv` / `rm -rf` of `~/Library/Caches/copilot`, `~/.cache/opencode` | ❌ Blocked | Same shape, one tree over: both hold binaries the host executes later (`copilot/pkg`, `opencode/bin`), and both sit in a writable cache, so a rename would let an agent swap the contents out from under the write-deny that protects them (GHSA-8qmv-wxp3-526v, GHSA-7mcc-xg5v-hv8v). The caches stay fully writable — managed binaries still download, extract and update — only the directory name is pinned. macOS uses `file-write-unlink`; on Linux this needs the Bubblewrap layer, which makes each a mountpoint so `rename`/`rmdir` return `EBUSY` | | macOS: `mv` / `rm -rf` of a directory above a symlinked credential or git config target, e.g. `ssh/` and `git/` in a stow dotfiles repo (`~/.ssh -> ~/dotfiles/ssh/.ssh`) opened as the project | ❌ Blocked | The credential and git config denies are emitted at the link target, and a deny there holds only while the directories above it keep their names: `mv ssh ssh2` would move the key out from under it. Every directory between the outermost writable tree and the target is pinned with `file-write-unlink`; it stays writable. The writable tree root is pinned too, see the next row | | macOS: `mv` / `rmdir` of a pinned writable root: the project, a `--repo-dir` or `allow.write` root, an agent config root, `~/Library/Caches`, `~/.cache`, `~/.m2`, `~/.gradle`, `~/.nuget`, `~/.konan`, and the target of any of these that is a symlink | ❌ Blocked | The denies inside a writable root name paths under it, so moving the root aside and putting a symlink in its place would move every protected file out from under them. Each root is pinned with `file-write-unlink`; its contents stay writable. A grant that names a file (an `allow.write` file, `~/.claude.json`) is not pinned, so it can still be replaced by rename. `rm -rf ` empties the root and then exits 1 because the root itself cannot be removed. Run such moves outside the sandbox | | `git remote set-head`, write-form `git symbolic-ref refs/remotes//HEAD` | ❌ Blocked | That symref records which branch `protect_default_branch_only` protects, and the guard re-reads it at every launch — a rewrite that persists on disk retargets the guard on the next launch (GHSA-cm6f-3wjh-x9qx). Both are refused by the git gate in block mode, while push prevention is active. The filesystem also denies `.git/refs/remotes/*/HEAD` (a regex on macOS; a read-only bind per existing file on Linux), but that deny only covers the `files` ref backend — on a repository created with `--ref-format=reftable` the symref is not a file at all, it lives in the binary tables under `.git/reftable/`, so the gate block is the only control that reaches it. Reading the symref (`git symbolic-ref --short …`) still works | | Signed commits/tags | ❌ Disabled | `commit.gpgsign` and `tag.gpgsign` overridden to `false` via env; use `--allow-gpg-signing` to enable | | Linux: reading, or `mv` / `rm -rf` of, a credential directory's link target inside a grant, e.g. `ssh/.ssh` in a stow dotfiles repo (`~/.ssh -> ~/dotfiles/ssh/.ssh`) opened as the project | ❌ Blocked with Bubblewrap | The target is masked where it lies, so it reads as an empty directory (a file target is unreadable), and a grant that names it through `~/.ssh` or the target path (a file or a directory) is bound back; an `allow.*` path that reaches it through a symlink of its own is ignored with a warning (a link inside the entry, like `~/.ssh/config -> conf.d/work` or `~/.kube/config -> clusters/prod.yaml`, is not such a hop and is kept). Every directory between the outermost writable tree and the target is pinned, so `mv ssh ssh2` returns `EBUSY`; the writable tree root itself is not. Without Bubblewrap nothing is masked and the launch warns ([#551](https://github.com/navikt/cplt/issues/551)) | **Agent binary caches:** the one real workflow the `~/.cache/opencode` pin blocks is `opencode uninstall`, which does `rm -rf ~/.cache/opencode` — run it outside the sandbox, where it works normally. **Your dotfiles repo as the project:** when one of git's home config files (`~/.gitconfig`, `~/.gitconfig.local`, `~/.gitignore_global`, or `config`, `ignore` or `attributes` in `~/.config/git`) resolves through a symlink into the repo cplt is running in, a `--repo-dir` root or an `allow.write` grant, the linked file stays read-only there, because it is the config git on the host trusts (#524). A missing one cannot be created either: `config`, `ignore` and `attributes` in a linked `~/.config/git`, `git` itself when `~/.config` is the link and has no `git` directory, and the target of a dangling link to one of the files above. On Linux with Bubblewrap, cplt does that by creating the missing files empty at launch, because Bubblewrap can only bind a file that exists (#553). They stay afterwards and show up as untracked files in your dotfiles repo; cplt names them when it creates them, and `--print-profile` and `cplt check` only list them. Git reads an empty config, ignore or attributes file the same as a missing one, so delete them or commit them as you like. One thing changes: `git config --global` writes to `~/.config/git/config` when that exists and `~/.gitconfig` does not, so after cplt has created an empty `config` in a linked `~/.config/git`, `git config --global` on the host writes into your dotfiles repo instead of creating `~/.gitconfig`. cplt creates no directory other than `git` under a linked `~/.config`, nothing under the system temp dirs, and nothing a symlink inside the repo or a git directory would decide; it warns about what it skips. So `git checkout`, `git pull`, `git stash pop` and `git reset --hard` fail when they would change a linked file, and git stops at that file, which can leave the working tree half-updated. Editors that save by writing a temporary file and renaming it over the original fail on it too, and so does moving or deleting the directory that holds it. The error is `Operation not permitted` on macOS, and `Read-only file system` or `Device or resource busy` on Linux with Bubblewrap. Run those operations outside cplt. Other files in the repo, and the repo directory itself, are unaffected. See [SECURITY.md](../SECURITY.md#layer-0-environment-variable-sanitization). **Global git hooks:** if `core.hooksPath` is set in `~/.gitconfig`, cplt auto-detects the hooks directory and allows reading it so git operations succeed. Write access is explicitly denied, to stop persistence attacks. The hooks path must be under `$HOME` with at least 3 path components (`~/.config/git/hooks`, for instance) to keep the read grant from being overly broad. **A linked worktree of a repository outside `$HOME`:** cplt grants a worktree's shared git directory only when it is under `$HOME`. Otherwise git in the worktree fails inside the sandbox, and cplt says so at launch and in `cplt doctor`: `Not granting the shared git directory … (outside $HOME)`. Move the repository under `$HOME`, or grant the directory with `--allow-write ` (or `allow.write`), which keeps its `hooks/` and `config` denied. **Commit signing:** `~/.ssh` and `~/.gnupg` are blocked, so GPG/SSH signing would fail. Rather than open private key directories, cplt injects `GIT_CONFIG_COUNT`/`GIT_CONFIG_KEY_N`/`GIT_CONFIG_VALUE_N` env vars that disable `commit.gpgsign` and `tag.gpgsign` inside the sandbox. Commits made by Copilot are unsigned, which is expected since users typically re-sign on merge or squash. Use `--allow-gpg-signing` to override this (see [GPG signing](#gpg-commit-signing)). ## Linux: missing host-persistence directories appear empty With `sandbox.mask_missing_deny_paths = true` (set by `cplt config init` for new configs), an agent host-persistence directory that does not exist at launch, such as `~/.copilot/agents/` or Pi's `~/.pi/agent/extensions/`, is mounted as an empty read-only directory. Inside the sandbox: - The directory exists and is empty. A tool that treats "missing" and "empty" differently sees "empty". - `mkdir` on it fails with `File exists`, and creating anything inside fails with `Read-only file system`. Install extensions, plugins or custom agents from a shell outside cplt. - bubblewrap leaves the empty directory on disk after the session. Later launches bind it read-only like any existing directory. A directory whose parent is missing (cplt warns at launch), and a missing file such as `settings.json`, are not masked. Recovery: `cplt config set sandbox.mask_missing_deny_paths false`. ## Linux: fewer home config files are readable (behaviour change) Linux used to grant a longer list of home config files than macOS. Both platforms now grant the same list, and Linux lost these: | No longer readable on Linux | What breaks | | --- | --- | | `~/.zshrc`, `~/.bashrc`, `~/.profile`, `~/.bash_profile`, `~/.zprofile` | Only an interactive `cplt --agent shell` session, which now starts without your aliases, prompt or `PATH` additions. Commands agents run through `$SHELL -c` never read these files. zsh does read `~/.zshenv` for `-c`, but that file was never granted on either platform | | `~/.node_repl_history` | Nothing that matters: the `node` REPL starts with an empty history | | Everything in `~/.config/git/` except `config`, `ignore` and `attributes`, for example `allowed_signers`, or an `includeIf` target such as `~/.config/git/work` | git **aborts** on an include it cannot read, so a `[includeIf "gitdir:~/work/"] path = ~/.config/git/work` in your config fails every git command in a matching repository | The rc files often export tokens. The rest of `~/.config/git/` can hold `credentials`, git's cleartext credential store, which is now hard-denied on both platforms, like `~/.git-credentials`. macOS already behaved this way. **Symptom:** `fatal: unable to access '/home/you/.config/git/work': Permission denied` from any git command. **Fix:** grant the file you need by name: ```bash cplt config set allow.read "~/.config/git/work" ``` A grant on the whole `~/.config/git` directory works too, and `credentials` stays denied inside it on macOS. On Linux, Landlock cannot deny a file inside a granted directory, so the directory grant exposes `credentials` there. Grant individual files instead. ## GPG commit signing GPG commit and tag signing is **disabled by default** because `~/.gnupg` is blocked. Copilot commits are unsigned, and you re-sign on merge or squash. If you want Copilot commits signed, say because branch protection requires signatures: ```bash cplt config set sandbox.allow_gpg_signing true --force ``` Or for a single run: `cplt --allow-gpg-signing` **Setup checklist.** Before using this flag, verify GPG signing works outside the sandbox: ```bash # 1. Check your signing key is configured git config --get user.signingkey # should show your key ID # 2. Check gpg-agent is running gpg-connect-agent 'GETINFO version' /bye # should print version + OK # 3. Cache your passphrase (so signing doesn't hang) echo "test" | gpg --clearsign > /dev/null # triggers passphrase prompt # 4. Verify git signing works git commit --allow-empty -S -m "test signed commit" git log --show-signature -1 # should show "Good signature" git reset HEAD~1 # undo the test commit ``` If all of that works, `cplt --allow-gpg-signing` will work too. The `gpg-agent` runs **outside** the sandbox, so pinentry prompts appear normally. The sandbox only needs to reach the agent socket. > **Note:** signature *verification* (`git log --show-signature`) does not work inside the sandbox, because GPG opens `trustdb.gpg` for writing during verification. This is harmless. Signing works, and signatures can be verified outside the sandbox or in CI. **Troubleshooting:** | Symptom | Cause | Fix | |---|---|---| | `error: gpg failed to sign the data` | Agent not running or passphrase not cached | Run `gpg-connect-agent 'GETINFO version' /bye` and `echo test \| gpg --clearsign` outside cplt | | `signing failed: No secret key` | Wrong `user.signingkey` in git config | Run `gpg --list-secret-keys` and set `git config --global user.signingkey ` | | `signing failed: Operation not permitted` | Flag not set, or `--deny-path` overriding | Check `cplt doctor` output for GPG signing status | | Commits unsigned despite flag | `gpg.format=ssh` in git config | This flag is GPG-only; SSH signing is not supported | | `GNUPGHOME` set to non-default path | SBPL rules only cover `~/.gnupg` | Unset `GNUPGHOME` or symlink to `~/.gnupg` | | `git log --show-signature` shows `Fatal: can't open trustdb.gpg` | GPG opens `trustdb.gpg` for writing during *verification*, which the sandbox denies | Expected. **Signing works**, only verification is affected. Verify signatures outside the sandbox or in CI | **What this does:** | Resource | Access | Why | |---|---|---| | `~/.gnupg/pubring.kbx`, `pubring.gpg` | Read-only | Public key lookup | | `~/.gnupg/trustdb.gpg` | Read-only | Trust validation | | `~/.gnupg/gpg.conf`, `common.conf` | Read-only | GPG config | | `~/.gnupg/S.gpg-agent` | Read + socket connect | IPC to agent daemon | | `~/.gnupg/S.keyboxd` | Read + socket connect | IPC to keyboxd (GnuPG 2.4+ public key daemon) | | `~/.gnupg/private-keys-v1.d/` | **DENIED** | Private keys stay locked | | `~/.gnupg/secring.gpg` | **DENIED** | Legacy private keyring stays locked | | `~/.gnupg/*` (writes) | **DENIED** | No modifications | **Security notes:** - **Private keys are NOT exposed.** The GPG agent holds keys in memory, and the Assuan IPC protocol has no command to export private key material. The `private-keys-v1.d/` directory stays denied even with this flag. - **Risk: signature impersonation and decryption.** A compromised process with agent socket access can request signatures on arbitrary data, which adds a "Verified" badge, and can decrypt arbitrary ciphertext if an encryption subkey exists. This is the same level of impersonation Copilot already has for unsigned commits; signing just adds the badge. - **GPG-only.** This flag does not enable SSH signing (`gpg.format=ssh`). SSH keys and `SSH_AUTH_SOCK` stay blocked. - **`--deny-path` wins.** If you pass `--deny-path ~/.gnupg` alongside `--allow-gpg-signing`, the deny takes precedence and every GPG allow is suppressed. - **`GNUPGHOME`** is not supported yet. Only the default `~/.gnupg` location is allowed. ## Gradle plugin workers and `JAVA_TOOL_OPTIONS` cplt injects `-Djava.net.preferIPv4Stack=true` via `JAVA_TOOL_OPTIONS` (see the JVM note above) so JVM localhost connections match the sandbox's `localhost:*` rules. Most JVM children read `JAVA_TOOL_OPTIONS` at startup. **Gradle plugin workers** do not. WorkerExecutor process isolation, ktlint-gradle for example, forks JVMs with explicit `forkOptions {}` and does not reliably propagate the environment, so the flag is lost, the worker's dual-stack sockets produce IPv4-mapped `::ffff:127.0.0.1` addresses, and SBPL cannot match those. Symptom (even with `allow_localhost_any = true`): ``` org.gradle.internal.remote.internal.ConnectException: Could not connect to server [... port:NNNNN, addresses:[/127.0.0.1]] ``` **cplt mitigation (macOS, opt-in):** set `sandbox.gradle_init = true` and cplt installs a guarded init script at `$GRADLE_USER_HOME/init.d/cplt-sandbox.gradle` (or `~/.gradle/init.d/` when unset) on sandbox launch. It activates only inside the sandbox (`__CPLT_WRAPPED` guard) and applies `preferIPv4Stack` to the daemon plus `Test` and `JavaExec` forks. It is opt-in because cplt does not write to tool config dirs by default. `WorkerExecutor` forks have no public configuration hook, so for those the plugin has to propagate the environment itself: ```kotlin forkOptions { environment("JAVA_TOOL_OPTIONS", System.getenv("JAVA_TOOL_OPTIONS") ?: "") } ``` Known affected: `ktlint-gradle` ([JLLeitschuh/ktlint-gradle#1110](https://github.com/JLLeitschuh/ktlint-gradle/issues/1110)). SBPL itself cannot be fixed, because macOS sandbox profiles have no primitive for IPv4-mapped addresses. That is why cplt reaches for the `preferIPv4Stack` workaround at all. That last claim is not a guess. SBPL accepts only `*` or `localhost` as the host in a `remote ip` rule; a literal address is a parse error, and the `localhost` token does not match an IPv4-mapped connect: ``` (remote ip "*:*") connect succeeds (remote ip "localhost:*") PermissionError [Errno 1] Operation not permitted (remote ip "::ffff:127.0.0.1:*") sandbox-exec: host must be * or localhost in network address (remote ip "127.0.0.1:*") sandbox-exec: host must be * or localhost in network address ``` So the only rule that would match these workers is `*:*`, which allows every outbound address rather than loopback. `--allow-localhost-any` will not be widened to mean that. The same gap applies to the **proxy** settings cplt injects. The init script forwards `http(s).proxyHost`/`proxyPort`/`nonProxyHosts` from the daemon into `Test` and `JavaExec` forks alongside `preferIPv4Stack`, but `WorkerExecutor` forks are out of reach for the same reason: a worker that fetches anything connects directly and does not appear in the proxy log. One more case worth knowing: a **Gradle daemon started outside cplt and then reused inside** is not proxied either. `JAVA_TOOL_OPTIONS` is not part of Gradle's daemon-compatibility check, so an existing daemon can be reused without ever having seen the proxy properties. Use `--no-daemon`, or let the first daemon start inside cplt. **Workaround until the plugin propagates the environment:** run the affected tasks outside cplt, and keep the rest of the build inside. ```bash ./gradlew ktlintFormat ./gradlew ktlintCheck detektMain detektTest build ``` ## Gradle toolchain JDKs `~/.gradle` is a dependency store. The sandbox grants read, write, and `file-map-executable` there (for JNI libs) but **not** `process-exec`, so a rogue agent cannot drop a binary into the dependency cache and run it. Gradle's toolchain support auto-provisions JDKs into `~/.gradle/jdks`, inside that non-executable tree, so forking a toolchain `javac` or test JVM failed with `Operation not permitted` and no config key could grant exec. cplt now carves `~/.gradle/jdks` back out as executable, and makes it **read-only** to keep the write-then-exec hole closed. Consequence: with `org.gradle.java.installations.auto-download=true`, a toolchain that is not already on disk fails at provisioning time with a write error instead of at exec time. Provision it once outside cplt, or use a JDK outside `~/.gradle`: ```properties # ~/.gradle/gradle.properties org.gradle.java.installations.auto-download=false org.gradle.java.installations.paths=/Library/Java/JavaVirtualMachines/temurin-25.jdk/Contents/Home ``` The provisioning failure surfaces as `foojay (Unable to download toolchain ..., due to: java.io.IOException: Operation not permitted)`, which reads like a network problem. It is the write being denied, not the download. Check the proxy only after ruling this out. Two limits are worth knowing. Both rules match the *resolved* path, so if `~/.gradle/jdks` is a symlink to another volume they silently do not apply. The agent cannot create that symlink itself, since the same rules deny writing or `mkdir` at that path, so it takes a pre-existing relocation. A relocated `GRADLE_USER_HOME` misses the rules entirely, though that is pre-existing, because the whole `~/.gradle` grant is keyed to the default location. **Linux:** unaffected. Landlock has a single `EXECUTE` right covering both `execve` and executable mappings, so the map-exec grant on `~/.gradle` already implies exec there. The carve-out is macOS-only, and the read-only pairing cannot be expressed on Linux for the same reason [`DENIED_HOME_SUBPATHS`](#private-registries) cannot. ## Kotlin/Native toolchain `~/.konan` is a store like `~/.gradle`: writable, `file-map-executable`, no `process-exec`. Kotlin/Native downloads an LLVM toolchain into `~/.konan/dependencies` on first use and a native link execs `clang++` from it, so on macOS every `link*Executable*` task failed with `Cannot run program ".../.konan/dependencies/llvm-.../bin/clang++": Exec failed, error: 1 (Operation not permitted)` ([#323](https://github.com/navikt/cplt/issues/323)). cplt now carves `~/.konan/dependencies` out as executable and **read-only**, like `~/.gradle/jdks`. The consequence is the same too. A first K/N build inside cplt cannot download the toolchain and fails with a write error under `~/.konan/dependencies`. Run that first build (or any build after a Kotlin version bump) outside cplt once. The lock file K/N opens on every build, `~/.konan/dependencies/cache/.lock`, takes data writes and nothing else. A `KONAN_DATA_DIR` outside `~/.konan` misses these rules and the `~/.konan` grant itself. **Linux** still grants execute on the whole of `~/.konan`, as before. ## JVM Attach API JVM testing frameworks like **MockK** (inline mocking), **Mockito** (inline agents), and **ByteBuddy** use the JVM Attach API for runtime class instrumentation. That API creates a Unix domain socket at `/tmp/.java_pid`, which the sandbox blocks by default. Enable it: ```bash cplt config set sandbox.allow_jvm_attach true ``` Or for a single run: `cplt --allow-jvm-attach` **When to enable:** - Kotlin/Java projects using **MockK** with `mockk()` or `mockkStatic()` inline mocking - Projects using **Mockito** with `Mockito.mock()` on final classes (requires the ByteBuddy agent) - Any test suite that gets `"Could not self-attach to current VM using external process"` errors - JMX monitoring tools that attach to running JVMs **How it works:** the JVM creates a socket at `/tmp/.java_pid`, a hardcoded path that `java.io.tmpdir` does not affect. A helper JVM process connects to that socket to load an instrumentation agent. The sandbox rule uses a regex that only allows sockets matching `.java_pid`, so on macOS every other Unix socket in `/tmp` (SSH agent, tmux, PostgreSQL) stays blocked. On Linux no unix socket is gated in the first place, so the flag grants nothing that was not already reachable, see [Linux limitations](../SECURITY.md#linux-specific-limitations). **Security note:** this opens a narrow IPC channel for `.java_pid*`-named sockets only. SSH agent access (`SSH_AUTH_SOCK`) is NOT exposed. On macOS it lives at `/private/tmp/com.apple.launchd.*/Listeners`, which does not match the pattern. ## MSBuild worker-node IPC `dotnet build` forks out-of-proc **worker nodes** that talk back to the client over a Unix domain socket at `/tmp/MSBuild`, which the sandbox blocks by default. Enable it: ```bash cplt config set sandbox.allow_msbuild true ``` Or for a single run: `cplt --allow-msbuild` **When to enable:** - Any `dotnet build`/`dotnet test`/`dotnet run` invocation that fails to spin up MSBuild worker nodes inside the sandbox **How it works:** MSBuild names its out-of-proc worker-node pipe `MSBuild` (`NamedPipeUtil.GetPlatformSpecificPipeName`, prefixed with `/tmp/` on Unix). The sandbox rule uses a regex that only allows sockets matching that exact `MSBuild` form. This is a **different socket** from the persistent **MSBuild Server**, the opt-in `dotnet build` acceleration feature that keeps a compiler process alive between builds. That one names its pipe `MSBuildServer-` (see [MSBuild-Server.md](https://github.com/dotnet/msbuild/blob/main/documentation/MSBuild-Server.md#pipe-name-convention--handshake)), a name the `--allow-msbuild` regex does not match, so it stays blocked. To close that off structurally instead of leaning on the socket-path allowlist alone, cplt also sets `DOTNET_CLI_DO_NOT_USE_MSBUILD_SERVER=1` unconditionally inside the sandbox, so `dotnet build` never starts or reuses a persistent server, including one a process outside the sandbox already started. **Security note:** this opens a narrow IPC channel for `MSBuild`-named sockets only. On macOS, SSH agent access and all other Unix sockets in `/tmp`, the persistent MSBuild Server included, stay blocked. On Linux no unix socket is gated in the first place, so the flag grants nothing that was not already reachable, see [Linux limitations](../SECURITY.md#linux-specific-limitations). ## Port restriction Only port 443 is allowed by default. Services on other ports need explicit configuration: ```bash cplt config set allow.ports 8443 ``` - `npm install` from private registries on non-standard ports - API calls to services not on 443 - FTP, SMTP, or other protocol connections Under `proxy.forced` an extra port opens no direct socket ([#297](https://github.com/navikt/cplt/issues/297)). The proxy still tunnels it, so the first two work; a tool that speaks its own protocol on a raw socket, such as FTP or SMTP, does not. Run those without proxy-forced — see [the proxy docs](proxy.md#proxy-forced-mode). ## Internal Maven/Gradle repositories on private IPs **This is a behaviour change.** cplt now makes the JVM proxy-aware: it injects `http(s).proxyHost`/`proxyPort` into `JAVA_TOOL_OPTIONS`, so Gradle and Maven dependency resolution goes through cplt's CONNECT proxy and appears in the proxy log. It previously went straight out and was invisible. The proxy applies the same SSRF guard to the JVM that it has always applied to curl, npm and pip, which have had `HTTP_PROXY` set all along. An **in-house Nexus or Artifactory** — or a self-hosted Develocity — that resolves into private address space (RFC1918, CGNAT, link-local) is refused: ``` Could not resolve com.example:internal-lib:1.0. > Could not get resource 'https://nexus.intern.example.com/repository/maven-public/...' > Failed to CONNECT: 403 Forbidden — Private target blocked by cplt. ``` Two cases, and they do not have the same answer. **The repository has a DNS name** — add it to `proxy.allow_private_domains`. Suffix matching, so one entry covers subdomains: ```toml # ~/.config/cplt/config.toml, or .cplt.toml in the repo [proxy] allow_private_domains = ["intern.example.com"] ``` Or for a single run: `cplt --allow-private-domain intern.example.com` (singular — there is no `--allow-private-domains` flag). **The repository URL is a bare IP literal**, e.g. `https://10.20.30.40/repository/maven-public/` in a `build.gradle` or `settings.xml`. **This cannot be allowed.** Not by `allow_private_domains`, not by any other key. The private-target check for an IP literal is a pre-DNS gate: it fires on the literal before the allow list is ever consulted, and that list is only read by the post-DNS guard. There is no flag that reaches it. The fix is to give the host a DNS name and use that in the repository URL. If your infrastructure genuinely has no name for it, the repository cannot be reached from inside cplt with the proxy on. Verify either case without running a build: ```bash cplt check net nexus.intern.example.com # ALLOWED once the domain is listed cplt check net 10.20.30.40 # BLOCKED, and stays blocked ``` ## Private registries Registry credential files are **blocked by default**, because they typically hold passwords or tokens a rogue agent could exfiltrate: | File | Purpose | |------|---------| | `~/.npmrc` | npm registry auth | | `~/.m2/settings.xml` | Maven repository credentials | | `~/.m2/settings-security.xml` | Maven master password | | `~/.gradle/gradle.properties` | Gradle/Nexus/Artifactory credentials | | `~/.cargo/credentials` | Cargo crate registry tokens | | `~/.cargo/credentials.toml` | Cargo crate registry tokens (TOML format) | With [`sandbox.protect_pnpm_config`](configuration.md#protecting-pnpms-global-config-sandboxprotect_pnpm_config) on, pnpm's global token files are blocked too: | File | Purpose | |------|---------| | `~/.config/pnpm/auth.ini` | pnpm 11+ registry tokens (`pnpm login`, `pnpm config set --global`) | | `~/.config/pnpm/rc` | the same, pnpm 10 and older | The key is off by default, and then pnpm's config dir is readable and writable, tokens included. The path follows `$XDG_CONFIG_HOME` when it is set. **Fix:** all of these can be overridden with `--allow-read`. ```bash cplt config set allow.read "~/.m2/settings.xml" cplt config set allow.read "~/.gradle/gradle.properties" cplt config set allow.read "~/.npmrc" cplt config set allow.read "~/.config/pnpm/auth.ini" ``` Or for a single run: `cplt --allow-read ~/.m2/settings.xml` If a Node or JVM build needs the usual three together, `sandbox.allow_build_credentials = true` grants `~/.npmrc`, `~/.gradle/gradle.properties` and `~/.m2/settings.xml` read-only in one opt-in. It is off by default and marked dangerous: **turning it on exposes every token in those files to the agent**, not only the one the project uses. It narrows nothing compared with the three `allow.read` lines. A link from one of them into a credential directory is refused, and a `--deny-path` on one keeps the key from granting it. On Linux the Maven and Gradle files stay readable and writable regardless, and a deny on them holds only under Bubblewrap. See [Build-host credentials](configuration.md#build-host-credentials-sandboxallow_build_credentials) ([#463](https://github.com/navikt/cplt/issues/463)). > **Note:** these are the *overridable* credential denials (`DENIED_HOME_SUBPATHS`). A second list — `~/.netrc`, `~/.pypirc`, `~/.gem/credentials` and `~/.vault-token` — is hard-denied and cannot be re-opened on either platform. macOS emits the generic `allow.read` grants before the literal deny rules, so the deny wins; Linux, where Landlock is grant-only and those files are withheld by omission, filters them back out of the ruleset. Naming one in `allow.read`, `allow.write` or `allow.socket` fails at startup with an error rather than being dropped quietly, so a grant that cannot take effect is never mistaken for one that did. One gap remains on Linux: a grant on a *parent* directory (`~/.gem`, or `$HOME` itself) still exposes the file, because Landlock cannot deny a subpath inside an allowed directory — the same limitation described below. You do not need to allow `~/.npmrc` just to stop yarn 1 crashing on it — cplt redirects `NPM_CONFIG_USERCONFIG` for that, see [yarn 1 and unreadable home rc files](#yarn-1-and-unreadable-home-rc-files) below. If you would rather not hand `~/.npmrc` to the agent at all but do need a private registry, a project-level `.npmrc` is readable as part of the project directory and can take its token from an environment variable — **with npm and yarn, but not with pnpm**, which deliberately does not expand environment variables in a project `.npmrc`. pnpm therefore needs the token in `~/.npmrc`, and that file has to be granted with `allow.read`. That matters wherever a project maps a whole scope to an authenticated registry, such as `@navikt:registry=https://npm.pkg.github.com`: the install does not degrade, it returns 401. `cplt init` and `cplt doctor` warn about such a scope and name both grants. That includes a scope mapped to `npm.pkg.github.com` with no token line in the project `.npmrc`, the usual NAV setup: GitHub Packages refuses anonymous npm installs, so the token in `~/.npmrc` is needed either way. ### NAV: the GitHub Packages mirror and the internal Nexus Two NAV-run hosts show up in most `navikt` JVM builds, and they need different answers. `github-package-registry-mirror.gc.nav.no` is an unauthenticated, GCS-backed mirror of public GitHub Packages. Teams point Gradle at it precisely so they do not need a PAT — which is what makes `~/.gradle/gradle.properties` necessary in the first place, so allowing the mirror removes a credential grant rather than adding one. It is deliberately **not** in cplt's default allowlist: that base list stays vendor-neutral, and an organisation-specific host does not belong in it. Add it yourself: ```bash echo github-package-registry-mirror.gc.nav.no >> ~/.config/cplt/allowed-domains.txt cplt config set proxy.allowed_domains "~/.config/cplt/allowed-domains.txt" cplt config set proxy.default_allowlist true ``` (`proxy.allowed_domains` is a path to a domain list file, one host per line, not an inline array. Setting it turns the allowlist on, and the agent's own hosts are always included (since 2026.09.29-095137-e745d3a; Goose, Pi and Shell have none), but the package registries are not: without `proxy.default_allowlist`, Maven Central (`repo.maven.apache.org`) and `plugins.gradle.org` are blocked and the build still fails. With it on, the file is merged with the agent's built-in list, which includes those registries, so you add the mirror without re-listing them. The `strict` preset turns it on already.) `repo.adeo.no` is NAV's internal Nexus and resolves into private address space, so `allowed_domains` is not enough — it is exactly the case [`proxy.allow_private_domains`](#internal-mavengradle-repositories-on-private-ips) exists for. > **Linux limitation:** the denials for files *inside an allowed tool dir* are only enforced on macOS, via SBPL literal deny rules. On Linux, Landlock cannot deny individual files within an allowed directory, so the parent dirs (`.m2`, `.gradle`, `.cargo`) stay fully readable for dependency resolution. `~/.npmrc` is the exception: it sits at the top of `$HOME`, which is never granted, so it is withheld on both platforms. ## Repo-local git content filters (LFS, git-crypt) If a repository's **own** `.git/config` defines a content filter, `filter..clean` or `filter..process`, cplt refuses the git queries that read working-tree content. Two things change: ``` [cplt] audit: incomplete [cplt] .cplt.toml differs from the version committed in git HEAD. ``` The audit reports `incomplete` instead of listing changes, and the one-time `cplt trust accept` fails, because it approves only a `Committed` state and the drift check could not run. **This does not affect most LFS users.** `git lfs install` writes to your `--global` config by default, and a filter there is your choice rather than the repository's, so it does not trigger the refusal. Only a filter defined in the repo's `.git/config` or a worktree config does. **A committed `.cplt.toml` still applies.** cplt reads it from the object store with `git cat-file`, which cannot reach a filter and is never refused. Permissions you have already approved keep working. It is the one-time accept that is blocked. **Why it works this way.** Git runs a content filter's program when it reads working-tree files, and cplt runs `git` in the parent process, *outside* the sandbox. `.git/config` is write-denied on macOS but stays writable to the agent on Linux (see [Git restrictions](#git-restrictions)), and on either platform the file may already carry a filter from before the session, so a repository could otherwise pick a program that cplt executes unsandboxed ([#210](https://github.com/navikt/cplt/issues/210)). Unlike the other executable config keys, filters are named by an arbitrary subsection, so no fixed `-c` override can neutralise them. That leaves two options. Run the filter, which is the escape. Or refuse, and say so. An `incomplete` audit is a loud "could not verify", not a clean session cplt never checked. One consequence is worth knowing: on Linux, where `.git/config` is writable, an agent can deliberately force the audit to `incomplete` by writing a filter into it. That is inherent to failing closed. **Fix:** move the filter to your global config if it belongs there (`git config --global filter.lfs.clean …`), or accept the `incomplete` audit for that repository. If you need `cplt trust accept` to work, remove the repo-local filter for the one invocation. ## yarn 1 and unreadable home rc files **On macOS the `~/.npmrc` half of this is handled automatically.** yarn 1 reaches `~/.npmrc` two ways, and cplt closes both: - as the user config. cplt sets `NPM_CONFIG_USERCONFIG` to a path inside the session scratch dir that deliberately does not exist, so that read fails with `ENOENT`, which yarn 1 tolerates, instead of the denial errno, which it does not ([#180](https://github.com/navikt/cplt/issues/180)); - on its walk up from the project directory, which checks `.npmrc` in every parent directory up to `/`. A project anywhere under `$HOME` passes `~/.npmrc` on the way, and no variable turns that walk off. The macOS profile also hides the file's metadata, so yarn's existence check sees no file and skips it. Nothing is granted: `~/.npmrc` stays unreadable either way. `~/.yarnrc` is a separate loader, which cplt grants read-only; see below. **On Linux the walk is not covered.** Landlock does not restrict `stat` or `access`, so yarn 1 sees `~/.npmrc` as present on the walk and aborts on the `EACCES` when the project is under `$HOME`. Use one of the fixes below. The redirect still covers the user-config read. The injection is skipped in three cases: - you allowed `~/.npmrc` yourself (`allow.read`), so you asked for the real file and cplt does not redirect around it. yarn reads it, on both platforms; - you set `NPM_CONFIG_USERCONFIG` yourself — in any capitalisation, since npm and yarn lowercase the key — and your value wins. If it names `~/.npmrc`, macOS hides it and Linux fails as below. If it names another file the sandbox cannot read, yarn fails as below on both platforms, since only `~/.npmrc` has its metadata hidden; - the scratch dir is off (`--no-scratch-dir`), so there is no session-scoped writable location to point at. On macOS the hidden metadata keeps yarn away from `~/.npmrc`; on Linux yarn fails as below. Without it, `yarn install` fails outright under the default policy when an `~/.npmrc` exists on the host. Nothing is resolved and no `node_modules` is written: ``` yarn install v1.22.22 error Error: EACCES: permission denied, open '/home/you/.npmrc' ``` cplt releases before this fix failed the same way on macOS, as `EPERM: operation not permitted`, for any project under `$HOME`. **Fix, on Linux.** For a project outside `$HOME` with the redirect skipped, and only the public registry in use, point yarn at a different npmrc. The token stays outside the sandbox. For a project under `$HOME` this does not help, because the walk still reaches `~/.npmrc`: ```bash : > .npmrc.sandbox NPM_CONFIG_USERCONFIG="$PWD/.npmrc.sandbox" cplt exec -- yarn install ``` If you do need the registry auth in `~/.npmrc`, allow it instead: ```bash cplt config set allow.read "~/.npmrc" ``` Or for a single run: `cplt --allow-read ~/.npmrc`. Deleting an `~/.npmrc` you do not use works just as well. `~/.yarnrc` is granted separately. See below. **Why only yarn 1.** `~/.npmrc` is denied by default because it commonly holds a registry token. Every package manager reads it; only yarn 1 treats an *unreadable* one as fatal. Both of its config loaders, `NpmRegistry.getPossibleConfigLocations` (which is what reaches `~/.npmrc`) and `parseRcPaths` (which reads the `.yarnrc` family), tolerate a *missing* file and rethrow everything else: ```js } catch (error) { if (error.code === 'ENOENT' || error.code === 'EISDIR') { return {}; } else { throw error; // EACCES / EPERM reaches here } } ``` npm, pnpm and bun all carry on past a denied `~/.npmrc` and install normally from the public registry ([navikt/cplt#180](https://github.com/navikt/cplt/issues/180) measured npm 10.9.8, pnpm 11.22.0 and bun 1.4.0). The trigger is therefore the file's *existence*, not its contents. An `~/.npmrc` holding nothing but `save-exact=true` breaks yarn 1 exactly as a token-bearing one does. **It is not only `~/.npmrc`.** `parseRcPaths` fails the same way on `~/.yarnrc` when it exists, and on `~/.yarnrc.yml`, `~/.config/yarn/config` and the `~/.config/yarn` directory itself. `NPM_CONFIG_USERCONFIG` does not reach that loader. Before [#881](https://github.com/navikt/cplt/issues/881) this was the result: ``` error Error: EPERM: operation not permitted, open '/Users/you/.yarnrc' ``` cplt now grants those files read-only, and the `~/.config/yarn` directory entry (on Linux, the directory with its contents). They can hold a registry token, which the agent can then read. To keep one out, move the token to `~/.npmrc`, which stays denied, or add the file to `deny`. **Why `NPM_CONFIG_USERCONFIG` works.** It is on cplt's environment allowlist because it names a path rather than carrying a secret, and yarn 1's npmrc loader honours it: `mergeEnv('npm_config_')` lowercases the variable into `config.userconfig`, which replaces the `~/.npmrc` entry in `getPossibleConfigLocations`. That list is gated on an existence check before the read, so a path that does not exist is skipped rather than read. Because that lowercasing collapses every spelling into one entry, and an *empty* value is falsy enough to fall back to `~/.npmrc`, cplt also removes any other-cased `npm_config_userconfig` from the child environment when it injects — otherwise a stray empty one could win the merge and undo the redirect. This is why cplt injects the variable rather than granting the file. `allow.read` fixes the crash by handing the credential over, the redirect fixes it by removing the need. npm and pnpm honour the same variable and could not read `~/.npmrc` in the sandbox anyway, so nothing changes for them. It redirects only the npmrc loader; `~/.yarnrc` is covered by the grant above. The XDG variables, by contrast, do not help. yarn 1's `.yarnrc` scan builds its own path list with hardcoded `~/.yarnrc` entries and runs before the XDG-aware `getConfigDir()` is consulted, so neither `XDG_CONFIG_HOME` nor `XDG_DATA_HOME` removes those paths from the list. yarn 2+ (berry) uses a different config loader and is unaffected. One footgun with `allow.read`: the path must **exist** when cplt starts. A missing path is warned about and dropped, so `allow.read "~/.npmrc"` on a machine without one is silently inert. That is fine here, since yarn only fails when the file exists in the first place. **Why cplt does not make the *denial itself* look like ENOENT.** The redirect above sidesteps the errno for the one file that has an env-var escape hatch. It does not change what a denied `open` reports, so `~/.yarnrc` and every other withheld dotfile still fail with the platform's denial errno. The tempting general accommodation is to report these files as *absent* rather than *denied*, which every package manager tolerates. macOS can express that. SBPL accepts `(deny file-read* (literal …) (with errno 2))`, so the read fails with `No such file or directory`. Linux cannot. Landlock is grant-only with no control over the errno a denied `open` returns, and on Linux `~/.npmrc` is not denied by a rule at all. It is simply never granted. Shipping the macOS half would fix macOS, leave Linux (where this was reported) untouched, and split the two backends' denial semantics for every tool, not just yarn. One line of config is the better trade. The macOS metadata rule for `~/.npmrc` above is narrower: it hides one file from `stat` and `access` and leaves the errno of a denied `open` alone. The profile imports Apple's `bsd.sb`, which allows `file-read-metadata` everywhere, and that named operation outranks cplt's `file-read*` deny, so without the extra rule the denied file still stats as present. Bubblewrap could mask the file with an empty `/dev/null` bind, but it is opt-in, applies only where the wrapper is active, and inverts the existing deny-path masks, which deliberately use an unreadable placeholder so a masked read fails loudly instead of reading as empty. Adding `~/.npmrc` to the default read grant is not an option either. That hands the npm token to the sandboxed agent, which is the one thing the denial exists to prevent. ### Linux: a denied file still looks readable yarn 1 is one case of a wider Linux behaviour. Landlock does not mediate `stat(2)`, `access(2)` or `faccessat(2)`, so a file the sandbox denies still exists and `access(path, R_OK)` says it is readable. Only the `open(2)` fails, with `EACCES`. A tool that checks a file before reading it believes the check and then fails on the open. On macOS, Seatbelt denies the check as well, so the same tool treats the file as absent. This holds under Bubblewrap too for `~/.npmrc`, `~/.yarnrc` and any other path that is simply never granted, since Bubblewrap leaves an unmasked path alone. It does not hold for a `deny.paths`/`--deny-path` target or a masked credential-directory entry: Bubblewrap binds a mode-000 placeholder over those, so `stat` sees that empty, unreadable file and `access(R_OK)` correctly fails instead of lying. The fix here is the same as above: grant the file with `allow.read`, or point the tool at a path that does not exist. See [SECURITY.md](../SECURITY.md#platform-enforcement-comparison) and [#389](https://github.com/navikt/cplt/issues/389). ## vitest with jsdom fails on a package.json above the project On macOS, `vitest` with `environment: 'jsdom'` can pass every test and then exit 1: ``` Error: EPERM: operation not permitted, open '/Users/you/package.json' MISSING DEPENDENCY Cannot find dependency 'jsdom' ``` This only happens when a `package.json` exists in a directory above the project, often a stray `~/package.json`. vitest's install check resolves `jsdom` from the project's parent directory and walks up from there. cplt allows `stat` above the project but denies reading file contents there, so the `open` fails with `EPERM` and vitest reports it as a missing dependency. Without cplt the same thing happens with `EACCES` when an ancestor `package.json` is unreadable. It was seen with vitest 5.0.5; the upstream bug is [vitest#11804](https://github.com/vitest-dev/vitest/issues/11804). cplt does not allow these reads on purpose, since that would expose files outside the project. Use one of these: - remove the stray `package.json` above the project; - allow that one file: `cplt --allow-read /package.json`; - skip the check: `cplt exec -- env VITEST_SKIP_INSTALL_CHECKS=1 pnpm vitest run`. cplt filters the variable out of the environment it inherits, so set it inside the sandbox like this, or pass it with `--pass-env VITEST_SKIP_INSTALL_CHECKS`. ## Cloud credential directories The following directories are **entirely blocked**, with no read, write, or execute: | Directory | Purpose | |-----------|---------| | `~/.config/gcloud` | Google Cloud SDK config, ADC credentials, Python virtualenv | | `~/.aws` | AWS credentials and config | | `~/.azure` | Azure CLI credentials | | `~/.kube` | Kubernetes cluster credentials | | `~/.config/op` | 1Password CLI sessions | ### Reading individual files (e.g. Application Default Credentials) You can grant **read-only** access to specific files inside these directories with `allow.read`: ```bash # Per session cplt --allow-read ~/.config/gcloud/application_default_credentials.json -- -p "deploy" # Per repo (.cplt.toml) cplt config set --repo allow.read ~/.config/gcloud/application_default_credentials.json ``` This lets GCP SDKs authenticate using the ADC JSON file without exposing the entire directory. A grant may also name a **subdirectory** — `~/.aws/sso/cache`, `~/.kube/cache` — and it grants that subtree. It used to grant the subtree on Linux and only the directory entry itself on macOS, where the re-allow was emitted as an SBPL `literal`; the files underneath stayed denied and nothing said so. Linux's reading is now authoritative on both: the grant covers what the user named, and nothing else in the credential directory. ### Executing cloud CLIs (gcloud, aws, az) is blocked by default Neither the default sandbox nor `allow.read` lets the agent **execute** binaries inside these directories. `gcloud`, for instance, uses a Python virtualenv at `~/.config/gcloud/virtenv/` that needs execute permission the sandbox does not grant. **This is intentional.** Cloud CLIs have unrestricted access to your cloud infrastructure. An agent running `gcloud` could create or delete resources, read secrets, or modify IAM policies. The sandbox closes that escalation path. **Workarounds:** | Need | Solution | |------|----------| | GCP authentication for SDKs | `allow.read ~/.config/gcloud/application_default_credentials.json`, SDKs read the JSON directly | | AWS authentication for SDKs | `allow.read ~/.aws/credentials`, SDKs read credentials directly | | Running cloud CLI commands | Run them outside the sandbox in a regular terminal | | CI/CD with cloud access | Use project-level service account keys or workload identity, not user credentials | > **Design principle:** `allow.read` grants read access to credential *paths* so SDKs can authenticate. It does not grant execute permission, because executing cloud CLIs would bypass the sandbox's network and filesystem restrictions. `allow.exec` is the deliberate opt-out, and it is the only one. It names a path inside a credential directory and grants read plus execute on it — `~/.docker/cli-plugins` and `~/.terraform.d/plugins` are the cases that motivated it. It was honoured on Linux and silently dropped on macOS, where it was emitted before the blanket deny and lost to it; it is now honoured on both. The tree stays read-only either way: the exec-grant write deny is the last rule in the profile, and `sandbox::prepare` refuses an exec grant that overlaps any writable tree. ## Developer tooling telemetry Plenty of developer tools phone home with default-on usage analytics: build systems, framework CLIs, language toolchains. cplt stops them with two complementary layers. **Layer 1, env var opt-outs** (injected unconditionally via `HARDENING_ENV_VARS`): | Variable | Affects | |----------|---------| | `DO_NOT_TRACK=1` | Cross-tool standard signal ([consoledonottrack.com](https://consoledonottrack.com/)) | | `NEXT_TELEMETRY_DISABLED=1` | Next.js build telemetry | | `TURBO_TELEMETRY_DISABLED=1` | Turborepo usage telemetry | | `CHECKPOINT_DISABLE=1` | HashiCorp tools (terraform, vault, packer, nomad) | | `GATSBY_TELEMETRY_DISABLED=1` | Gatsby build telemetry | **Layer 2, proxy domain blocks** (in the built-in blocklist, `blocked-domains.txt`, which applies whenever the proxy runs): | Domain | Blocks | |--------|--------| | `checkpoint.hashicorp.com` | HashiCorp version/update pings | | `telemetry.nextjs.org` | Next.js telemetry fallback | | `mobile.events.data.microsoft.com` | VS Code + all Microsoft extensions (1DS SDK) | | `dc.services.visualstudio.com` | Older VS Code / App Insights | | `telemetry.go.dev` | Go toolchain upload endpoint | | `posthog.com` | AI agent analytics (PostHog, all subdomains) | Tools keep working normally. These are non-essential telemetry endpoints. ## Global tool installs The directories a package manager puts on your `PATH` are read-only inside the sandbox: | Directory | Written by | | --- | --- | | `~/.bun/bin` | `bun install -g` | | `~/.deno/bin` | `deno install`, `deno upgrade` | | `$PNPM_HOME` (`~/Library/pnpm`, `~/.local/share/pnpm`) | `pnpm add -g`, `pnpm setup`, `pnpm env use -g` | | `~/.local/share/mise/shims` | `mise install`, `mise use -g`, `mise reshim` | | `~/.local/share/mise/installs` (whole tree) | `mise install`, `mise use -g`, `mise upgrade` | `~/.cargo/bin` and `~/go/bin` have been read-only for the same reason since the start. These directories sit ahead of `/usr/bin` on your `PATH`, so a file the agent leaves in one of them is what your *next* shell command resolves — outside the sandbox, with your credentials. Six lines of shell is the whole exploit: write `$HOME/.bun/bin/git`, `chmod 700`, exit. **Those commands now fail inside cplt.** That is the point, not a bug. Run them outside, in a normal shell. **Project-local installs are unaffected.** `npm install`, `pnpm install`, `bun install`, `cargo build`, `go build` and `pip install` in a venv write to the project or to a per-project cache. The sibling package and cache trees stay writable — `~/.bun/install`, `$PNPM_HOME/store`, `~/.npm`, `~/.cargo/registry` — so installs keep working. A `--deny-path` or `deny.paths` entry covering a pnpm store (or `$PNPM_HOME`, or anything above it) still wins: cplt withholds that store's write allow and warns, instead of reopening writes under your deny (#597). **pnpm 11+ uses the shared store; cplt names it.** With no `storeDir` configured, pnpm tests whether it can link into `$PNPM_HOME` by creating a temporary directory there. `$PNPM_HOME` is read-only, so the test fails, and pnpm would put a `.pnpm-store` in the highest ancestor of the project it can write to: the repo, a parent directory, or `/private/tmp` for a project under `/tmp`. Every repo and worktree would then download its dependencies again. So when that store already exists (`$PNPM_HOME/store`, by default `~/Library/pnpm/store` on macOS and `~/.local/share/pnpm/store` on Linux), cplt sets `pnpm_config_store_dir` to it. That is the store pnpm picks outside the sandbox, and it was already writable. It does not set it if it sees a store setting of your own: a `*_config_store_dir` variable, `storeDir` in pnpm's global `config.yaml`, or `storeDir`/`store-dir` in a `pnpm-workspace.yaml` or `.npmrc` in the launch dir, above it, or one level below it. A setting deeper than that is overridden, because pnpm ranks the environment first; set the variable yourself and pass it with `--pass-env` to win. What this changes in practice: `node_modules` inside the sandbox now hard-links into the same store your host installs use, so an agent that edits a file in a dependency changes it for the next install outside too. The store was writable before, but the per-project store kept the two apart. To keep them apart, pass `pnpm_config_store_dir=/.pnpm-store` with `--pass-env`. pnpm 10 does not read `pnpm_config_*` and still uses the per-project store. For pnpm 10, or a store elsewhere: ```bash # per session; pnpm 10 reads npm_config_store_dir instead export pnpm_config_store_dir="$(dirname "$(pnpm store path)")" cplt --pass-env pnpm_config_store_dir # or permanently, in pnpm-workspace.yaml or pnpm's global config.yaml, # as an absolute path: storeDir: /absolute/path/to/pnpm/store ``` See [#637](https://github.com/navikt/cplt/issues/637). **The one that will bite you: mise bootstrap.** mise can no longer install or update *any* toolchain from inside cplt — not only the shimmed ones. The whole `installs/` tree is denied, not just each `//bin`, because mise creates a `bin/` only for tools that ship one: on a machine with 207 installed version directories, 55 did. The rest land flat at `installs///`, and in non-shim mode mise puts *that* directory on PATH, so a `bin`-anchored rule would have left the majority of tools as drop points. So a repo whose `mise.toml` pins a toolchain your host does not already have will not bootstrap inside cplt. `mise install` fails writing to `installs/`, and even a toolchain that installs cleanly cannot be reshimmed. Install it once outside: ```bash mise install # outside cplt cplt # then work inside ``` `cplt init` says this for every repo whose mise config has a `tools` table. `cplt doctor` says it only when `mise ls --missing` lists something, or when it cannot ask: no mise in the trusted bin dirs, or the command fails. `mise x`, `mise exec` and an already-activated toolchain keep working — reading and executing what is already on disk was never restricted. **Relocated homes are followed, `XDG_DATA_HOME` for pnpm is not.** A custom `$PNPM_HOME` gets the same split posture through the same mechanism that keeps `$CARGO_HOME/bin` exec-only: a read-only top level, writable non-executable `store/`, and writable executable `package-manager-store/`. If pnpm follows a custom `XDG_DATA_HOME`, set `PNPM_HOME` to that pnpm directory before launching cplt so all three rules move together: ```bash export PNPM_HOME="$XDG_DATA_HOME/pnpm" cplt ``` **pnpm has two distinct execution paths.** cplt grants existing PATH executables under `$PNPM_HOME` individually instead of making the whole directory executable. If the selected `pnpm` is a pnpm 11 launcher script, cplt follows it to the binary it execs. If that binary lies in the store or shares an inode with a file named `pnpm` anywhere under `$PNPM_HOME/store/v*/links/` (pnpm 11 nests it as `links/@pnpm/exe///node_modules/@pnpm/exe/pnpm`), cplt copies it to a per-session read-only shadow and uses that copy for PATH and direct invocations. The writable content-addressable store remains non-executable. Separately, pnpm 10 downloads and runs `packageManager`-pinned versions from `$PNPM_HOME/package-manager-store/` (pnpm 11 uses the store, see below); that cache is deliberately writable and executable on both backends. **pnpm's shell setup and the shadow.** The block `pnpm setup` writes to your shell rc prepends `$PNPM_HOME/bin` to PATH when it is not there yet. Its `bin/pnpm` launcher then runs ahead of the shadow and execs the store binary, and pnpm fails with `EPERM`, or `bad interpreter: Operation not permitted`. So while a shadow is active, cplt appends every existing `$PNPM_HOME/bin` to the child PATH after the shadow (and `$PNPM_HOME` itself for the pnpm 10 layout, when it holds a `pnpm`). The rc block sees it and leaves PATH alone. cplt adds both the canonical and the literal `$HOME` spelling, so a symlinked home matches too. A side effect: children that skip your rc, such as a non-interactive `sh -c`, can now find globally installed pnpm bins through the appended dir, though they still cannot run the ones backed by the store. An rc line that prepends unconditionally, such as a bare `export PATH="$PNPM_HOME/bin:$PATH"`, still puts the launcher first. Use the default block from `pnpm setup`, or a Homebrew pnpm. Do not grant `allow.exec` on `~/Library/pnpm/global` or the store to work around it: that makes binaries in a writable tree executable again. `cplt doctor` warns when `$PNPM_HOME/bin` is missing from the PATH you launch cplt from. **pnpm 11 runs a pinned version from the store.** When a project's `packageManager` names another exact pnpm version, pnpm 11 installs it under `store/v*/links/@pnpm/exe//` and spawns it from there, not from `package-manager-store/`. The store is not executable, so the switch fails with `EPERM` ([#890](https://github.com/navikt/cplt/issues/890)). If that version is already in the store, cplt shadows it instead of the PATH pnpm, and pnpm sees no version to switch to. This applies even when the PATH pnpm comes from Homebrew or npm rather than the store: it is replaced by the pinned store binary. If it is not, the first run downloads it and fails; the next cplt launch picks it up. Until then, `pnpm --pm-on-fail=warn` or `pmOnFail: warn` in `pnpm-workspace.yaml` skips the switch and runs the PATH pnpm. Once the pinned version is in the store, cplt always runs it and that setting has no effect. **Linux is weaker than macOS here.** For `~/.bun` and `~/.deno` it is not: those are enforced by *not granting* write to the parent, which Landlock expresses natively. mise is the other exception — its `shims/` and `installs/` sit inside a data dir that has to stay writable, and Landlock cannot deny a subpath inside an allowed tree. They are carried by the bubblewrap read-only overlay instead, so they hold only when `bwrap` is installed and only for directories that already exist. Without bubblewrap, the mise pair is unenforced on Linux. The two platforms deny the same tree: bwrap binds paths and takes no globs, which is where the whole-tree shape came from, and macOS now matches it. **There is no flag that reopens these.** The denies are emitted after every user `allow.write`, so `--allow-write ~/.bun` does not override them, the same way it does not override the agent config-dir denies below. ## Agent config-dir host-persistence denies Each agent's global config dir is mounted read/write, but the files in it that **auto-execute on the host** the next time you run that agent outside cplt are write-denied. A sandboxed agent never needs to write them mid-session, and anything it did write there would run unsandboxed on your next launch. | Agent | Denied | What breaks | | --- | --- | --- | | Pi | Write-denied: `settings.json`, `trust.json`, `extensions/`, `npm/`, `git/`, `skills/`. Separately, `~/.pi/agent` itself is read-only, with write granted per subdirectory (`sessions/`, `prompts/`, `themes/`, `tools/`, `tmp/`) so that neither `bin/` nor `skills/` is inside a writable tree. Named lock directories `trust.json.lock/`, `auth.json.lock/` and `settings.json.lock/` may be created, timestamped and removed. Pi takes these locks when reading its trust store and settings and refreshing auth availability | ⚠️ `pi install`, `/trust`, editing or installing a skill, and every in-session setting that persists to `settings.json`: `/model` (Ctrl+S), `/thinking`, `/settings`. Because the root is read-only, Pi cannot create any other **new** top-level entry under `~/.pi/agent`: a first-ever `pi auth login` (creates `auth.json`) and the managed-binary bootstrap (Pi downloads `fd`/`rg` into `bin/` when they are not on `PATH`) have to run outside cplt. Lock grants match literal directory names: a directory-wide create/remove grant is a rename grant (`mv bin bin.old; mv tmp bin`), which is exactly the escape the read-only root closes. Residual on macOS: a writable sibling can be renamed onto a lock name (`mv tmp trust.json.lock`), which only breaks Pi's own lock. **Linux: Landlock cannot scope creation to a name and same-directory rename needs only the rights a directory-wide grant carries, so the lock grant applies only when bubblewrap is active** (every sibling and `bin/` is then a mountpoint and rename is `EBUSY`). **Without bubblewrap the root stays read-only and Pi does not start**. `cplt doctor` reports why and what to install. Auth is unaffected once `auth.json` exists. Pi uses provider API keys via `--pass-env` | | Claude Code | `~/.claude/settings.json`, `statusline.sh`, `plugins/` | ⚠️ Editing `settings.json` (including `hooks`) from inside a session. `commands/`, `agents/` and `skills/` stay writable — and the model can invoke them too, not just the user, so a file planted there reaches a host shell the next time the model picks it (see SECURITY.md, [#366](https://github.com/navikt/cplt/issues/366)). Auth is unaffected — the OAuth token is in the Keychain or `.credentials.json`. **Hook and status-line scripts outside `~/.claude` ([#702](https://github.com/navikt/cplt/pull/702), [#705](https://github.com/navikt/cplt/issues/705)).** cplt reads the user-level `settings.json` (never `settings.local.json` or a project's `.claude/settings*.json`, which the agent can write) and grants read + execute on the one file each hook or `statusLine` command runs: the command itself, or the script an interpreter (`python3`, `node`, `bash`, `uv run`, ...) is given. Other arguments, such as a log file, are not granted. The granted file is write-protected inside the sandbox. Files in a credential directory or under the Claude config root are skipped. A script inside the project, a `--repo-dir` root, an `allow.write` path or another writable grant gets no extra grant either: it runs under the normal rules for that tree and stays editable. A script that imports sibling files needs `allow.read` on its directory. On Linux the grant applies only when Bubblewrap is active, because only its read-only bind keeps `settings.json` out of the agent's reach. With Landlock alone the whole Claude config root is writable, so cplt drops the grant, warns, and the hook may fail. On Linux, cplt creates a missing `~/.claude/settings.json` (or its `CLAUDE_CONFIG_DIR` equivalent) as `{}` before every launch, with or without Bubblewrap, so Bubblewrap can bind it read-only; an existing file is never overwritten. cplt refuses to start when `CLAUDE_CONFIG_DIR` points at or around a credential directory such as `~/.ssh`. On hosts that run Claude without Bubblewrap (Landlock only), the agent can edit `settings.json` during that session, and a later Bubblewrap launch trusts whatever hook paths it names; only scripts outside writable trees and credential directories get read + exec. | | Antigravity | `~/.gemini/config/hooks.json`, `config/mcp_config.json`, `antigravity-cli/bin/` | ⚠️ Editing hooks or MCP server config from inside a session. Auth is unaffected | | goose | the whole of `~/.config/goose` is read-only, not just named files | ⚠️ `/mode` and theme changes, the first-run telemetry prompt, and persisted "always allow" tool permissions do not survive a sandboxed session. Run `goose configure` outside cplt. Ordinary sessions are unaffected — goose does not rewrite `config.yaml` while running. Auth is unaffected: secrets come from the keyring or `--pass-env` | | DeepSeek Harness | `$DSH_HOME/cordis.patch.yml` (the home-level overlay; `$DSH_HOME` is `~/.dsh` unless set) | ⚠️ The home-level overlay is write-denied because the Loader reads it at boot. `$DSH_HOME/profiles/` itself stays writable — DSH rewrites each profile's constant `cordis.yml` include-root on every boot — so a per-profile `cordis.patch.yml` and installed plugins remain a residual: do profile and `dsh plugin` edits outside cplt, and always launch `dsh` through cplt so any planted plugin still runs sandboxed. DSH's own per-command sandbox does not nest inside cplt's — macOS refuses nested `sandbox-exec` and cplt's seccomp filter denies the `unshare` bwrap needs — so select DSH's shipped `danger-full-access` permission preset and let cplt be the boundary; left on, tool calls fail with a sandbox runner error. Sessions, settings and cache under `$DSH_HOME` stay writable. A relocated `$DSH_HOME` is granted in place of `~/.dsh`; cplt refuses a value that resolves to a system root or your home directory, or that points at or around a credential directory such as `~/.ssh`. Auth: pass the key with `--pass-env DEEPSEEK_API_KEY`, or store 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 | | Copilot | `~/.copilot/settings.json`, `hooks/`, `mcp-config.json`, `lsp-config.json`, `extensions/`, `installed-plugins/`, `pkg/`, `skills/` | ⚠️ `/settings`, `/config`, `/mcp add`, `/lsp`, and editing or planting a file in a skill directory from inside a session. `agents/` and `instructions/` stay writable. Auth, the session store, saved permissions and logs are untouched — Copilot keeps those in other files. `installed-plugins/` may cost plugin self-update at session start; whether it does is unresolved, see SECURITY.md. **Hook and MCP scripts outside `~/.copilot` (macOS only, [#704](https://github.com/navikt/cplt/issues/704)).** cplt reads `~/.copilot/settings.json`, `hooks/*.json` and `mcp-config.json` (never a project's hook or MCP files, which the agent can write) and grants read + execute on the one file each hook command (`bash`, `command`, or `exec` + `args`) or MCP server `command` + `args` runs: the command itself, or the script an interpreter is given. All three are write-denied inside the sandbox, so the agent cannot add entries. A symlinked hook file or `hooks/` directory is not read. The other rules match the Claude Code grant: no other arguments, the granted file is write-protected, and credential paths and paths under `~/.copilot` are skipped. A script inside the project or another writable grant (for example `proj/mcp/server.js`) gets no extra grant: it runs under the normal project rules and stays editable. Linux is unchanged for now. | | OpenCode | — | Nothing denied. Its config dir — where skills live, including a relocated `OPENCODE_CONFIG_DIR` — is granted read-only, so it needs no deny entry. That also means `opencode plugin `, `opencode mcp add` and `opencode agent create` (global) fail inside the sandbox; run them outside cplt. **`file://` plugins and local MCP commands outside the granted dirs (macOS only, [#703](https://github.com/navikt/cplt/issues/703)).** cplt reads the user config (`opencode.json`, `opencode.jsonc` or `config.json` in `~/.config/opencode`, comments allowed) and grants read + execute on each `file://` plugin and on the one file each `mcp..command` runs: the command itself, or the script an interpreter (`node`, `python3`, `bun`, ...) is given. Other arguments are not granted. The granted file is write-protected inside the sandbox. A project `opencode.json` and `OPENCODE_CONFIG` are not read. OpenCode v2 uses `OPENCODE_CONFIG_DIR` in place of the default dir, so cplt grants it read-only and reads its config the same way; cplt refuses to start when it points at or around a credential directory such as `~/.ssh`. Nothing is granted when the config dir, or a config file it links to, is in a writable tree. On v2, an `opencode.json` or `opencode.jsonc` in a parent of the launch directory is made readable when it is a regular file or a symlink to one (see [OpenCode v2](#opencode-v2-is-refused)). A directory plugin's entrypoint must resolve inside the plugin dir. Files in a credential directory, under the config dir or under OpenCode's data, state or cache dir are skipped. A plugin that imports sibling files needs `allow.read` on its directory. Linux is unchanged. | **Pi Linux runtime-fallback exception.** The table's read-only root guarantee applies when bubblewrap is unavailable or disabled during preparation. If bubblewrap passes preparation but fails at launch, the existing fallback to plain Landlock retains directory-creation and removal rights without mountpoint protection. That permits replacing `bin/` with a writable sibling, planting code for a later unsandboxed Pi run. The additional lock names do not change Linux rights or cause this gap. **There is no flag that reopens these.** The denies are emitted at the very end of the profile, after every user `allow.write`, so even `--allow-write ~/.claude/settings.json` does not override them. That is deliberate: before this, `allow.write = ["~/.claude"]` silently reopened the whole set. **Linux is weaker than macOS here.** Landlock cannot deny a subpath inside an allowed directory, so the denies are carried by the bubblewrap read-only overlay. They hold only when `bwrap` is installed, and only for paths that **already exist** — bubblewrap cannot bind a missing source, so a `extensions/` directory that does not exist yet is unprotected until something creates it. Without bubblewrap the guard does not apply on Linux at all. goose is the exception to the table's opening sentence: its config dir is granted read-only outright rather than read/write with named denies. A `write_files` carve-out would not work — goose rewrites `config.yaml`, `permission.yaml` and `permissions/tool_permissions.json` by creating a temp file in the directory and renaming over the target, which needs directory write and would hand back the very vector the deny exists to close. ## OpenCode v2 OpenCode v2 (the npm package `@opencode/cli`, which still installs a binary called `opencode`) runs every tool call in a background service, `opencode serve --service`. A service started outside cplt would run them unsandboxed, so each cplt session runs its own service inside the sandbox ([navikt/cplt#710](https://github.com/navikt/cplt/issues/710)). What follows is macOS; Linux differs where [the Linux section](#opencode-v2-on-linux) says: - The session gets a private `XDG_STATE_HOME`, so OpenCode does not find a service you run on the host, and an `XDG_CONFIG_HOME` that links every entry of your own config dir, and of its `opencode/` dir (or of `OPENCODE_CONFIG_DIR`, when set), except `service.json`. `OPENCODE_CONFIG_DIR` then points at the session copy. In its place cplt writes one with a free loopback port and a random password. The OpenCode client starts the service on that port, inside the sandbox. - The sandbox may connect to that port only. The default service port, 49374, stays blocked, and both of your `service.json` files (in the config and state dirs) are unreadable and cannot be moved by renaming a dir above them, also through a symlinked `opencode/` dir. - When the session ends, cplt removes these dirs, and the service shuts down within 5 seconds. If cplt itself is killed, its service keeps running, holding `opencode.db`, until the scratch dir is cleaned up: at the first cplt launch after 24 hours. - The links are made at launch. A new top-level entry in `~/.config` or `~/.config/opencode` is seen from the next session. Settings OpenCode writes through a rename, such as `cli.json`, land in the session's copy and are lost when it ends. The links also let the agent see the names of everything in `~/.config`, though not read what is behind them. - OpenCode reads the names in `$HOME`, in every parent of the launch directory and in the `.claude`, `.agents` and `.opencode` dirs there (also where those are symlinks). The sandbox allows listing those dirs; their files stay denied. - OpenCode also reads `opencode.json` and `opencode.jsonc` in every parent of the launch directory and `AGENTS.md` in every parent up to `$HOME` (or up to the repository root outside `$HOME`), and stops the session ("Instruction initialization blocked by unavailable sources") when an `AGENTS.md` it finds cannot be read. cplt makes each of these files readable, by its exact path only, when it is a regular file. A symlink to a regular file also works (dotfile managers such as stow, chezmoi and home-manager), and both the link and the file it points at at launch are made readable. Repointing the link during the session exposes nothing new. cplt refuses to start when one is a hard link, a directory, a FIFO, a dangling link or a link to a file the sandbox denies, and names the file. A file you deny yourself (`deny.paths`, `.env`) stays unreadable; OpenCode then stops the session. This means the agent can read your `~/AGENTS.md` and any `AGENTS.md` in a parent directory. Instruction files are written for the agent, so it is the intended reader; keep nothing else in them. - cplt sets `OPENCODE_DISABLE_MODELS_FETCH=1` and `OPENCODE_DISABLE_AUTOUPDATE=1`. - Needs the scratch dir: `--no-scratch-dir` refuses v2. A `deny.env` naming `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `OPENCODE_CONFIG_DIR` or one of the two variables above also refuses it. **Sessions share your database.** `$XDG_DATA_HOME/opencode/opencode.db` holds your sessions and logins and is shared with the host and with other cplt sessions. When an OpenCode v2 service opens it, it resumes every turn that was in flight when another service stopped. That includes a turn started in another repository's sandbox, which then runs with this service's permissions. A service on the host resumes such turns outside the sandbox. Let a turn finish before you stop a session, and do not run an OpenCode v2 service on the host while cplt sessions are open. The first v2 run creates `opencode.db`, so start that first session alone: two first runs at the same moment can fail with "database is locked" (an OpenCode race), and running again works. **On Linux** ([navikt/cplt#719](https://github.com/navikt/cplt/issues/719)) v2 needs Bubblewrap; without it cplt refuses v2. The session setup is the same, and Landlock inside Bubblewrap does the rest: - Landlock cannot deny a file inside a granted dir, so cplt grants `~/.config/opencode` and `~/.local/state/opencode` entry by entry, leaving out `service.json`. An entry OpenCode or you create there during the session is not visible until the next one. A symlinked `opencode/` dir is followed, but a symlink inside it that points out of it is not granted: Landlock would grant whatever it names. - Any other grant that covers a host `service.json`, such as `allow.read = ["~/.config"]`, refuses v2 with a message naming it. Narrow the grant to run v2. - The session port joins the Landlock connect allowlist, and loopback is exempt from the proxy (`NO_PROXY`) so the client reaches its service. Loopback requests from any tool that honours `NO_PROXY` then bypass the proxy, so they do not show in its log. Landlock filters by port on any host, so the session port is open on remote hosts too, as the proxy port already is. - Port filtering needs Landlock ABI 4 (kernel 6.7). On older kernels every loopback port is reachable; cplt warns and continues. A host service on 49374 still asks for its password. `--allow-localhost-any` leaves it reachable the same way. - `/proc` is readable: the service is a child of the client and does not get the client's `/proc/self` grant, and it stops at startup without `/proc`. Bubblewrap's pid namespace limits that `/proc` to the session's processes. Without it, `/proc` would show the command lines of all your processes, which is why v2 needs Bubblewrap. If Bubblewrap does not start, cplt stops instead of falling back to Landlock only. - `AGENTS.md`, `opencode.json` and `opencode.jsonc` in a parent of the launch directory are readable when each is a regular file, or a symlink to one outside the credential dirs; cplt refuses anything else. OpenCode stops when it cannot read an `AGENTS.md` it finds. - The service stops with the session. - Install Bubblewrap with your package manager (`apt install bubblewrap`) and do not pass `--no-bubblewrap`. Ubuntu 23.10 and later, including 24.04, block it through AppArmor by default (`kernel.apparmor_restrict_unprivileged_userns`); `cplt doctor` says what to change. GitHub Copilot hosts for the proxy allowlist come from the `credential` table in `opencode.db`, not from `auth.json` or a gh login. cplt opens it read-only with `/usr/bin/sqlite3` and reads only whether a `github-copilot` login exists, never the credential itself. The host gh token handover below does not apply to v2: cplt does not set `OPENCODE_AUTH_CONTENT` for it. cplt spots v2 from where the binary lives when it can: a path through an `@opencode` npm scope, or an `opencode/` directory (Homebrew, version managers). Anywhere else, such as `~/.opencode/bin` from the upstream installer, it runs `opencode --version` once and caches the answer in `~/.config/cplt/opencode-version` until the binary changes, so later launches spawn nothing extra. The agent cannot write that directory, so it cannot fake the answer. If `opencode --version` fails or times out, cplt assumes 1.x and launches. `--print-profile` does not check. ## OpenCode: GitHub Copilot login from the host When `gh` is logged in on the host and OpenCode's `auth.json` has no `github-copilot` entry, cplt logs OpenCode in to GitHub Copilot with the host's gh token, so `opencode run -m github-copilot/...` works without `/connect`. It does this through `OPENCODE_AUTH_CONTENT`, which OpenCode reads instead of `auth.json`; your other providers are copied in. An existing `github-copilot` login is left alone. Running `/connect` inside the sandbox writes the gh token into `auth.json` as the `github-copilot` entry. This needs OpenCode 1.4.7 or newer; older versions ignore `OPENCODE_AUTH_CONTENT` and behave as before. To turn it off, add `OPENCODE_AUTH_CONTENT` (or `GH_TOKEN`) to `deny.env`. Details in [SECURITY.md](../SECURITY.md#github-token-handling-per-agent). ## AI agent telemetry AI agents and their third-party packages often send usage analytics and crash reports to external services such as PostHog and Sentry. cplt blocks these at two layers: 1. **Env var injection.** cplt injects `OMO_DISABLE_POSTHOG=1` and `DO_NOT_TRACK=1` so agents opt out before making any network call. 2. **Proxy blocklist.** `posthog.com` is in the built-in blocklist (`blocked-domains.txt`) as a fallback for tools that ignore env vars. The built-in list is compiled into cplt and applies whenever the proxy runs. For the `oh-my-openagent` OpenCode plugin specifically, `OMO_DISABLE_POSTHOG=1` stops PostHog being initialised, so no network calls are made and no errors appear. > **oh-my-openagent transcripts:** the plugin's Claude Code hooks feature writes transcripts to `$CLAUDE_CONFIG_DIR/transcripts` (default `~/.claude`), which the sandbox denies for non-Claude agents. The write fails with `EACCES` and OpenCode aborts the prompt. cplt therefore injects `CLAUDE_CONFIG_DIR=/opencode/claude-config` for OpenCode sessions, falling back to `~/.local/state/opencode/claude-config` when `XDG_STATE_HOME` is unset, which lands it in the write-allowed OpenCode state dir. If you set `CLAUDE_CONFIG_DIR` yourself, cplt respects it; add that path to `allow.write` if it lives outside the OpenCode dirs. **If you use `allowed_domains` (allowlist mode):** telemetry hosts are blocked by the allowlist unless you list them, and the blocklist is still checked after it, so `posthog.com` stays blocked even if you add it. The env var injection still suppresses telemetry silently. **Impact:** none. Telemetry is non-essential and the agent works normally without it. **Why blocked?** Analytics events may include code context, prompt fragments, or usage patterns that amount to unintended data exfiltration from inside the sandbox. **If you want to allow telemetry** (not recommended): there is no setting for it. The opt-out variables are always injected, and the built-in blocklist always applies while the proxy runs; `proxy.blocked_domains` only adds to it. The blocklist is enforced by the proxy, so it does not stop a client that ignores `HTTPS_PROXY` and connects directly on 443 unless [proxy-forced mode](proxy.md#proxy-forced-mode) is on (`proxy.forced`, `--preset strict`). There is no per-host exemption. ## Terminal devices and allocating a PTY The sandbox allows writing to a short list of device nodes: `/dev/null`, `/dev/zero`, `/dev/random`, `/dev/urandom`, `/dev/tty`, `/dev/ptmx`, and the `/dev/fd` subtree (which covers `/dev/stdin`, `/dev/stdout`, and `/dev/stderr`). Reading under `/dev` stays open, with one exception: the numbered terminal devices, `/dev/ttysNNN` on macOS. On Linux the same rule removes every right on `/dev/pts`. Those numbered nodes each name one specific terminal, and on a normal desktop most of them belong to the user's *other* windows: - **Writing** one puts bytes straight into that window's display, so a sandboxed agent could forge output there or set the clipboard through an OSC 52 escape. - **Reading** one takes bytes out of that terminal's input queue. The agent captures what the user types in the other window, a passphrase or a pasted token included, and the program that was supposed to receive those keystrokes never sees them. `/dev/tty` stays readable and writable because it always resolves to the caller's own controlling terminal. Your own session is unaffected. cplt passes file descriptors 0, 1, and 2 straight through, and both Seatbelt and Landlock check the path when a device is opened, not on every read or write to a descriptor that is already open. Raw mode, colours, pagers, and window-size handling all keep working. Two things do change: | Operation | Impact | Why | | ------------------------------------ | ------------------ | ---------------------------------------------------------- | | Your session's terminal, raw mode | ✅ Works | fds 0/1/2 are inherited; `/dev/tty` is the caller's own | | `git log` pager, `less`, `node`, `stty`, `tput` | ✅ Works | they use the inherited terminal | | `script`, `script -q file cmd` | ❌ Blocked | calls `openpty()`, then opens the new slave by name | | `pty.spawn()`, `pexpect`, `node-pty` | ❌ Blocked | same call, same device | | `tmux`, `screen` | ❌ Blocked | one PTY per pane (`tmux` was already blocked by its socket in `$TMPDIR`) | | `stty -f "$(tty)"`, or anything that opens its own terminal by name | ❌ Blocked | the name is a numbered device; use the plain form, which works on fd 0 | ### `expect` fails without failing `expect` deserves its own warning. It cannot allocate a PTY, but it does not exit non-zero when that happens. It prints ``` The system has no more ptys. Ask your system administrator to create more. ``` on stderr and carries on to the next command, so `expect` returns 0 and a wrapper script around it looks like it passed. Nothing cplt can do changes that, it is `expect`'s own error handling. If a suite of yours drives `expect`, check its output rather than its exit code, or run it outside cplt. **Fix:** run the command that needs its own terminal outside cplt, or grant the device tree back for that one session: ```bash cplt --allow-write /dev -- -p "run the pexpect suite" ``` One flag is enough: an `--allow-write` grant also emits the matching read allow, and `openpty()` needs both because it opens the new slave read-write. Be clear about what that flag buys: it restores read and write on every device node, including the terminals of your other windows. For the length of that session a compromised agent can print whatever it likes into them, set your clipboard, and read what you type in them. Prefer the first option, and keep the second for a single run rather than putting it in `config.toml`. ## Stopping cplt from a script cplt ignores SIGINT while the agent runs, because Ctrl-C already reaches the agent through the terminal. To stop cplt from a script, send SIGTERM, which cplt passes on to the agent.