# Configuration ## Precedence cplt resolves each setting in this order, highest first: 1. CLI flags for the current run (`--with-proxy`, `--no-proxy`, `--proxy-port`, and so on) 2. This project's per-repo user config, `~/.config/cplt/local/.toml` (see [Per-repo user config](#per-repo-user-config-config-set---local)) 3. The config file, `~/.config/cplt/config.toml`, or whatever `CPLT_CONFIG` points at 4. The preset baseline (`--preset`, `[sandbox] preset`), then built-in defaults Per-repo config (`.cplt.toml`) is applied after those layers are merged, with its own rules. The launch repository's `[deny]` section, and any proposal in it that turns a guard on, always apply; a `--repo-dir` root contributes its `[deny]` only. Its other proposals do nothing until accepted with `cplt trust accept`. An accepted boolean takes effect only where none of layers 1 to 3 set the key, and accepted lists are appended. See [Per-repo configuration](#per-repo-configuration-cplttoml). Sessions that span several repositories are configured separately again — see [Working across several repositories](#working-across-several-repositories---repo-dir). List values merge instead of replacing, so repeated `cplt config set` commands accumulate for `allow.read`, `allow.write`, `allow.ports`, `allow.localhost`, `allow.domains`, and `deny.paths`. Point `CPLT_CONFIG` at another file to use it instead of the default location: ```bash CPLT_CONFIG=/path/to/custom.toml cplt -- --version ``` To isolate cplt in tests, scripts, and harnesses, set `CPLT_CONFIG` alongside scratch `HOME` and `XDG_*` paths; see [Isolating cplt in tests and harnesses](#isolating-cplt-in-tests-and-harnesses). `CPLT_CONFIG` replaces the whole config file, `[sandbox]` keys included, so it can widen the sandbox in ways a repo's `.cplt.toml` never can. Because a shell that auto-loads repository environment (direnv, mise) can set it just by you entering a directory, cplt keeps the substitution visible: - Pointing it anywhere other than `~/.config/cplt/` prints a warning naming the file. The warning is not suppressed by `--quiet`. - Pointing it **inside the project directory** is refused and cplt exits: that file is repository content, and it would replace your config with none of the review a `.cplt.toml` gets. Symlinks and `..` are resolved first, so a link from outside the repo back into it is refused too. Unset `CPLT_CONFIG` (check `.envrc` and mise config) to run. Paths in `[allow]` and `[deny]` support `~/` expansion and resolve relative to the config file's directory. `proxy.blocked_domains` supports `~/` expansion only. ## Per-repo user config (`config set --local`) Your own settings for one checkout, kept out of the repository: ```bash cplt config set --local proxy.port 8443 cplt config set --local allow.read ~/scratch/spleis cplt config path --local # where this project's file lives cplt config local list # every project with one, and the orphans ``` The file lives at `~/.config/cplt/local/.toml`, **outside** the repository — which is what lets it widen the sandbox the way `config.toml` does. Write access to `~/.config/cplt/` is denied inside the sandbox, so the agent cannot write its own grants there; a file in the working tree could make no such claim. Dangerous keys still need `--force`, and `config show` labels every scalar it supplies `(local)`, dangerous ones included. List keys union with the global layer, so they are shown merged and unattributed — except `sandbox.repo_dirs`, which only this layer can set. Two rules are specific to this layer: - **Absolute paths only.** A relative entry is refused at `config set --local` and again at load: there is no sensible directory for it to anchor to. - **A moved or replaced checkout stops applying.** The file records the `origin` it was written for; if a different repository now sits at that path, cplt warns (not suppressed by `--quiet`) and applies nothing. Re-adopt it by setting any key with `--local` again. A checkout that moved leaves an orphan file, which `cplt config local list` marks as "path no longer exists". ## Working across several repositories (`--repo-dir`) A session often spans more than one repository: a service and the library it pulls in, an app and its schema, a Gradle build with an included build beside it. By default only the launch repository has an identity — `gh` write commands are scoped to it, and a `gh pr create` aimed at the other one is refused. Name the other repository and it becomes a first-class repository of the session: ```bash # For one run cplt --repo-dir libs/sykepenger-model exec -- ./gradlew build # Persisted for this checkout, so every later launch picks it up with no flag cplt config set --local sandbox.repo_dirs ~/src/spleis/libs/sykepenger-model # Or name the repository and let cplt find and verify the checkout cplt link navikt/sykepenger-model ``` The startup summary then lists what is in scope, and where each entry came from: ``` Repositories: navikt/spleis ~/src/spleis launch repository navikt/sykepenger-model ~/src/spleis/libs/sykepenger-model local config ``` A flag *adds to* the persisted set rather than replacing it, so a one-off `--repo-dir` on top of a saved list is the union of both. ### What naming a repository does, and what it does not **For a repository nested inside the project, it declares identity, not access.** A nested checkout already sits inside the project directory, so the agent could always read, write and build there — the project directory is granted as one subtree. What `--repo-dir` adds is that: - **`gh` may target it:** `gh pr create -R navikt/sykepenger-model` is allowed, the gh scope set includes it, and `GH_REPO` pinning accounts for it. - **The agent indexes it (`--add-dir`):** Copilot CLI and Claude Code receive `--add-dir ` for each additional repository (with `CLAUDE_CODE_ADDITIONAL_DIRECTORIES_CLAUDE_MD=1` set for Claude Code), so `@` file autocomplete and agent instruction discovery (`AGENTS.md`, `CLAUDE.md`) work even when the nested directory is listed in the parent repository's `.gitignore`. - **`git push -u` upstreams are recorded at session exit (macOS):** On macOS, `.git/config` is read-only inside the sandbox, so when the git guard is active in a non-quiet session, `cplt` strips `-u` / `--set-upstream` inside the sandbox and applies `branch..remote` and `branch..merge` from the parent process when the session ends — for every named repository, not only the launch one. (On Linux, `git push -u` records tracking directly unless bubblewrap is active; quiet macOS sessions strip `-u` without deferring upstream recording.) When an interactive launch finds immediate child git repositories inside the project directory that are not yet linked, `cplt` prints a startup hint naming the `cplt config set --local sandbox.repo_dirs` (or `--repo-dir`) command to bring them into scope. Naming a repository also decides what the post-session audit measures. Each named repository is audited on its own lines, against its own baseline: ``` [cplt] Session ended (exit 0, 41s) [cplt] Project changes: 2 files (+18 -3) [cplt] src/main.rs +18 -3 [cplt] Changes in ~/src/spleis/libs/sykepenger-model: 1 file (+4 -0) [cplt] src/Vedtaksperiode.kt +4 -0 ``` That report is another reason to name a repository even when the agent could already write to it. A checkout keeps its own git history, so the launch repository's `git status` sees it as a single entry — or, when it is gitignored, as nothing at all. Before it had a report of its own, a session that edited only the nested repository printed `no project file changes`. **For a repository beside the launch one, it declares access as well.** A sibling checkout — `~/src/spleis` and `~/src/sykepenger-model` side by side — is not reachable at all unless you name it, and naming it grants read, write and execute on that tree: ```bash cd ~/src/spleis cplt --repo-dir ~/src/sykepenger-model ``` The startup summary distinguishes the two cases, because they are the same row otherwise: ``` Repositories: navikt/spleis ~/src/spleis launch repository navikt/sykepenger-model ~/src/sykepenger-model --repo-dir new read/write/exec tree ``` Execute is the part that needed a feature. `--allow-write` has always given a sibling read and write, but a write grant is deliberately **not** executable — a tree that is both is where an agent drops a binary and runs it. So this works for editing and fails for building: ```bash # Edit-only: files yes, build no, gh identity no cplt --allow-write ~/src/sykepenger-model exec -- $EDITOR ``` Use `--allow-write` when the sibling only needs to be edited, and `--repo-dir` when its own build, tests or pull requests have to run. If you have `allow.write` over a directory that holds checkouts, cplt says so at launch and names the repositories it found. That grant looks like "let the agent work in these" and is not: a script there fails with `bad interpreter: Operation not permitted`, which names neither cplt nor the grant, and reads like a problem with the project. Naming the repositories is the fix. A named root is a second writable-and-executable tree, with the same protected paths as the project: `.git/hooks`, `.git/config`, `.cplt.toml` and the rest stay unwritable in it, so a hook cannot be planted to run outside the sandbox later. **The git guard follows the set too.** A `git push` inside a named repository is judged by *that* repository's default branch, captured at launch — so a repository whose default branch is `trunk` protects `trunk`, not the launch repository's `main`. An `allow_push` rule naming a remote is pinned to that remote's URL in every repository in scope, and it authorizes a push only when the push also runs in one of them: a rule written for this session cannot authorize a push from a clone the agent made underneath the project, whose `origin` you were never thinking about ([#424](https://github.com/navikt/cplt/issues/424)). **A named repository's `[deny]` applies too.** `[deny]` can only tighten, so it needs no approval, no trust entry and no content hash — there is no grant a second repository could open with it. Paths anchor to the repository that wrote them, so each repository's deny applies inside itself. `deny.env` is the exception and is process-wide: a named repository stripping a variable strips it for the session, and cplt says so at launch. `[propose]` is launch-repository only. A named repository that proposes something gets one line saying it was not consulted; approving proposals per repository is a separate feature and is not built. A `.cplt.toml` in a named repository that cannot be parsed **warns and the launch continues**, which is what the launch repository's own unreadable config has always done. The warning says what it costs — that repository's `[deny]` is not applied, so the session is less restricted inside it than its config asks for. Only a tightening can go missing this way: a named repository's file grants nothing, because its `[propose]` is never consulted. To refuse the launch instead, set [`sandbox.refuse_invalid_repo_config`](#refusing-an-invalid-cplttoml-sandboxrefuse_invalid_repo_config). Launching from the parent (`--project-dir ~/src`) grants the whole tree and is a much wider grant than naming two repositories. It also does **not** let you name the checkouts: `--repo-dir` requires the launch directory to be a git repository toplevel, and a plain directory holding repositories is not one, so the combination is refused rather than quietly granting identity to everything under it. ### Which layer it can be set in `sandbox.repo_dirs` is settable **only** in the per-repo local config (`config set --local`), and this is deliberate: | Layer | Accepted | Why | | --- | --- | --- | | `--local` (`~/.config/cplt/local/.toml`) | **yes** | Outside the repository and unwritable from inside the sandbox, so it can name roots the way `config.toml` can widen the sandbox. Scoped to one checkout. | | Global (`~/.config/cplt/config.toml`) | no | A repository list in your global config would attach to every session, including repositories where those paths mean something else. | | `.cplt.toml` (`config set --repo`) | no | It is committed, so it would hand every cloner's agent a new project-grade root. Repo config can tighten, never grant paths. | Each refusal names the layer that does work, so you do not have to remember the table. `config set` takes `-g`, `-l` and `-r` for `--global`, `--local` and `--repo`. The long forms are used throughout this document because they say which file is being written. ### A project can name the repositories it spans A `.cplt.toml` may say which repositories a session usually needs: ```toml [propose] repos = ["navikt/sykepenger-model", "navikt/spleis-testdata"] ``` Identities, never paths. The repository states what the *project* is; your machine decides where those repositories live. A committed `.cplt.toml` cannot point cplt at a directory of its choosing — the most it can do is put a name in front of you. `cplt link --propose /` links the repository for you and adds it to `[propose] repos`, creating `.cplt.toml` if needed and keeping the rest of the file as it was. Commit the file to share it. **What approving grants is not small.** Each repository it links becomes a *named repository* — the same thing `--repo-dir` and `sandbox.repo_dirs` name, with the same grant: read, write **and execute** on that whole tree, the same posture as the project directory. A repository proposing five repositories is asking for five of those. Approving is therefore a separate, explicit step: ``` $ cplt trust accept repos [cplt] sandbox.repo_dirs = [/Users/you/src/sykepenger-model] [cplt] navikt/spleis-testdata: not linked No checkout of that repository was found. Looked at: ... ``` Each identity is resolved and origin-verified exactly as `cplt link` does, and one that is not on your machine is never fetched on the config's say-so. At a terminal cplt asks, per repository, `Clone owner/name into ? [Y/n]`, with `` beside this repository; the clone uses `gh` (or git over https) from the trusted system directories, and is origin-verified before it is linked. Without a terminal it prints the clone command and runs nothing. The summary says how many of the proposed repositories are linked. Approving again re-links anything missing, so a repository you clone later needs no re-approval dance. `cplt trust revoke repos` removes the roots recorded at approval time, matched by path — and only those. A root you added yourself with `cplt link` or `config set --local` stays, because it is yours: if a proposal names a repository you had already linked, approving says so and leaves it alone rather than taking ownership of it. `cplt trust` lists what an approval linked, which is what revoke acts on; a recorded root that is no longer in your config is reported rather than assumed gone, because revoke cannot tell removed from renamed. Three properties worth knowing: - **An unapproved proposal grants nothing.** It shows up in the launch summary naming the repositories, so you learn the link exists to be made, and that is all it does. - **`--accept-repo-config` cannot approve it.** That flag approves for one run and persists nothing; linking a repository is persistent. It is the one proposal the flag skips. - **An uncommitted `.cplt.toml` cannot propose repositories at all.** It is a grant, so it needs the audit trail a commit gives — see the repo-config security model above. The approval binds to the path it resolved, which is then re-validated on every launch like any other named root. cplt does not re-resolve the identity each time: a later directory rename would otherwise redirect a grant you approved for a specific tree. **"Origin verified" describes the moment you approved.** On Linux an agent can rewrite `.git/config` in any tree it was already granted, and Landlock cannot carve that file out of a writable root — so a tree a previous session could write can claim to be the repository a proposal names. If the real checkout is absent, that claim is what gets linked, and the verification line will say so. It grants nothing the previous session did not already have, but it can make you believe the real repository is in scope when it is not. This matters more here than for `cplt link`, because what is being verified was chosen by a committed file rather than typed by you. Limits worth knowing: a second checkout of the same repository cannot approve `repos` at all, because trust is stored per origin; a fork reads as "not found", since its `origin` is the fork; a repository with no `origin`, or with only `upstream`, is invisible; and a monorepo can only be named whole. ### Naming a repository instead of a path `cplt link` takes the repository, finds the checkout, and checks it really is that repository before writing the path. What it writes is a `sandbox.repo_dirs` entry, so what you get is a *named repository* in exactly the sense above — "linking" is how you name one without knowing where it lives: ```bash cplt link navikt/sykepenger-model # [cplt] navikt/sykepenger-model → /Users/you/src/sykepenger-model # (beside this repository, origin verified) ``` Where it looks, in order: beside the launch repository; then across the forge directory, so `nais/unleash` is findable from `navikt/cplt` in a `//` layout; then the origin of each directory beside the launch repository, capped, which finds a clone in a differently-named directory. A directory that resolves inside the launch repository is never a candidate — that tree is agent-writable, so a checkout planted there would be a repository of the agent's choosing wearing the right name. That check runs on the resolved path, so a symlinked sibling pointing back into the project does not get around it. Note the sibling directory itself is only as trustworthy as your machine: one `--project-dir ` launch makes every directory beside the repository writable from the sandbox. Every candidate has its `origin` read by the trusted git **outside** the sandbox and must match the name you asked for. A directory called `sykepenger-model` that is some other repository is not linked, and a directory you name yourself is checked too: ```bash cplt link navikt/sykepenger-model ~/elsewhere/model # verified, not assumed ``` If two equally good candidates verify, cplt refuses and names both rather than picking. A directory named after the repository beats one matched only by its origin, and the weaker matches are printed so you can link one of those instead. Linked worktrees of the chosen checkout are counted, not listed — they share one `.git`, so they all report the same origin. `cplt link / --unlink` removes it again, so a root can be dropped by the name it was added under. It matches on the recorded directory's origin where it can read one, and falls back to the directory's own name where it cannot — a deleted directory has no identity to read, and neither does one whose `.git/config` was rewritten since. Without that fallback the entries most worth removing would be the ones the command refused to find. If more than one entry answers, it names them and removes none; `cplt config set --local sandbox.repo_dirs --unset` is always available. What this writes is an ordinary `sandbox.repo_dirs` entry, so everything below applies to it unchanged. What it adds is the identity check. And note the limit: **"origin verified" describes the moment you ran the command.** On Linux an agent can rewrite `.git/config` in any tree it was granted, and Landlock cannot carve that file out of a writable root — which is why the link is stored and re-validated as a path, never re-resolved from the name. ### Relative paths are resolved when you set them `../sibling` is how people name the repository next door, so `config set` takes it and stores what it resolves to: ```bash cd ~/src/spleis cplt config set --local sandbox.repo_dirs ../sykepenger-model # [cplt] sandbox.repo_dirs = [/Users/you/src/sykepenger-model] ``` The file holds absolute paths only. A relative entry *in* the file has no stable anchor — it would mean different directories depending on where cplt was started — so what gets written is the resolved path, not what you typed. A `~/` entry is stored as typed: it is already anchor-independent, and expanding it would bake in one machine's home directory. The path has to exist when you set it. A relative entry cplt cannot resolve now is one the next launch would refuse anyway, and the error at write time names the directory you were standing in. Removing does not. `--unset ../sykepenger-model` still works after you have deleted the checkout, which is when you are most likely to be typing it. The two directions differ on purpose: a path that resolves to nothing cannot be written, but it can be removed. cplt resolves it as far as the filesystem still answers — the longest prefix that exists, so a symlinked parent still resolves to what was stored — and appends the rest. Nothing stored under it is a no-op, and says so. A stored entry with the same final component is named rather than removed on a guess — the same as `cplt link --unlink`: ``` [cplt] sandbox.repo_dirs: /Users/you/src/sykepenger-model is not set, nothing removed One entry has that name: /Users/you/work/sykepenger-model Remove that one: cplt config set --local sandbox.repo_dirs '/Users/you/work/sykepenger-model' --unset ``` When several share it, all of them are named and none is removed. ### Rules that apply on every launch, not only when written A persisted entry is re-validated at each launch, because the file may be weeks old and the tree it names is agent-writable. An entry is refused — the launch stops, it is not silently dropped — when it: - is not a git repository toplevel (a subdirectory of one is not a repository), - contains the launch repository (launch from it and name the inner one), - has a symlink as its **final** component (a symlink planted there last session would redirect the identity this launch pins; a symlinked *ancestor* is fine, it is just the path you took to get there), - contains a `..` component, which hides which directory is actually named, - is your home directory or another unsafe root. The launch repository itself is refused too: it is always in scope, and naming it again is a sign the entry means something else. Refusals name the source, so a persisted root says which file to fix rather than blaming a flag nobody passed: ``` [cplt] sandbox.repo_dirs entry ~/src/spleis/libs in ~/.config/cplt/local/3291….toml is not a git repository; for a plain directory use `--allow-write`. ``` ### Everyday recipes ```bash # What is in scope right now, without launching an agent cplt config get sandbox.repo_dirs cplt config show | grep repo_dirs # shown with its (local) label # Stop naming one, or clear the list cplt config set --local sandbox.repo_dirs ~/src/spleis/libs/sykepenger-model --unset cplt config set --local sandbox.repo_dirs --unset # Which checkouts have a local config at all cplt config local list ``` For a monorepo of many small repositories, name only the ones the agent will open pull requests against. Every named root widens what `gh` may write to, and the gh guard is the thing standing between an agent and the wrong repository. ## Writing `.git/config` on Linux (`sandbox.allow_git_config_write`) macOS always denies writes to `.git/config`. A written `.git/config` can set `core.hooksPath`, `core.fsmonitor` or a `filter.*.clean` command, and those run unsandboxed on your next `git` command. On Linux the key decides. `cplt config init` writes `allow_git_config_write = false`: cplt binds every `.git/config` under a writable root read-only, and in-session `git config` and `git remote` fail. This needs Bubblewrap. Without it, cplt refuses to launch and says why. A config without the key keeps the old behaviour (writable), and `cplt doctor` suggests setting it. To let the agent write the file: ```bash cplt config set sandbox.allow_git_config_write true ``` User config only; `.cplt.toml` cannot set it. The bind also covers a `.git` pointer file (worktrees, `--separate-git-dir`) and every `config.worktree`. Only files that exist at launch are bound, so a repository created in the session (`git init`, `git clone`) stays writable. Nested repositories are found by a bounded scan (three levels, 20 000 directories), as for `.git/hooks`. A hard link to `.git/config` from the writable tree fails with `EXDEV`, because the bound file is its own mount. With `extensions.worktreeConfig` on, a `config.worktree` that does not exist at launch is not bound, so the agent can create one. Linked worktree directories under `.git/worktrees` are pinned, so `git worktree remove` and `prune` fail in the session. ## Worktrees for sub-agents (`sandbox.allow_git_worktrees`) Off by default, macOS only, since 2026.09.24-164900-53d4462. When on, cplt gives the session one directory outside your checkout where sub-agents can create Git worktrees for parallel work: ```bash cplt config set sandbox.allow_git_worktrees true # every repository cplt config set --local sandbox.allow_git_worktrees true # this checkout only ``` Inside the sandbox, create a worktree with the managed command: ```bash cplt worktree add # on an existing local branch; prints the path cplt worktree add -b # on a new branch created at cplt worktree add # detached at ``` `cplt worktree add` checks out `` (a branch, tag or commit in the local repository). A local branch is checked out on that branch; like `git worktree add`, it refuses a branch that another worktree already has checked out. `-b` creates a new branch at `` and refuses a name that exists. A tag, commit or remote-tracking branch is checked out with HEAD detached, and an ambiguous name (a branch and a tag with the same name) is refused. A refused or failed request leaves no branch and no worktree behind. The broker runs Git under the session's read policy, checks the private checkout, and publishes it atomically. It: - never fetches from a remote and never runs a hook, a filter driver or a config value from the repository. A `filter.*` entry in the repository's or your global git config refuses the command because private checkout cannot reproduce filtered content. Private config, not this preflight check, prevents execution. Git attributes can still transform ordinary files; - preserves tracked protected files exactly as recorded in the launch checkout's HEAD, including `.cplt.toml`, `.github/hooks` and nested agent configuration. Adding, removing, changing or renaming any protected file refuses the request, as does changing its executable mode or checkout bytes. Prepare worktrees with different protected content outside cplt. Ordinary tracked files may differ; - refuses a symlink that leads out of the worktree, a nested repository or bare repository in the tree, and a name that already exists. An existing worktree is never replaced; - also refuses dangling symlinks and symlink targets with `..` after a named component, matching the launch/exit checks; - creates no branch, so a refused or failed request leaves no ref behind. `` is letters, digits, `.`, `_` and `-`, starting with a letter or digit, at most 64 characters. Plain `git worktree add` inside the sandbox still works as before, but git runs it directly and none of the checks above apply. The command works only in a session cplt started, on macOS. The worktrees it creates outlive the session, like any other in the root. The broker requires a launch HEAD commit whose protected bytes and executable modes match the checkout. Symlinked protected paths or ancestors, filesystem aliases of protected names, and protected files larger than 1 MiB are refused. Ordinary Unicode names remain allowed. Tree queries are capped at 1 MiB and 200,000 entries. Protected names and their declared ancestors must use the exact spelling in `PROTECTED_IN_ROOT`, even if a case or Unicode alias is tracked. The launch scan follows only tracked HEAD paths and their ancestors. Local or ignored protected config and dependency trees are not approved or copied, and do not disable the broker. Tracked protected files must still match disk, and tracked aliases are refused. Every entry of the staged requested checkout is checked before publication. An unavailable launch snapshot disables the broker with a warning and leaves `CPLT_WORKTREE_SOCKET` unset; the session and managed root remain available. Prepare worktrees outside cplt in that case. No partial protected-file snapshot is accepted. A global `filter.*` configuration, including Git LFS, can also make this convenience unavailable or refuse requests even when the requested tree does not use the filter. Requests share a serial queue. A client timeout does not cancel a queued request; check `$CPLT_WORKTREE_ROOT/` before retrying because it may still publish. Private staging cleanup runs after the 60-second operation budget and can extend shutdown time. What you get: - The root is `~/.cplt-worktrees/`, where `` is derived from the repository's canonical Git common directory. It is not configurable. Every linked worktree of one repository gets the same root. A separate clone of the same GitHub repository gets a different one. - The root is granted read, write and execute, like a `--repo-dir` repository. `~/.cplt-worktrees` itself and the roots of other repositories are not granted. - There is no isolation between sub-agents. All worktrees in a session share one grant, so a sub-agent can write the other sub-agents' worktrees, and every sub-agent can still write the launch checkout. Separate worktrees keep parallel work apart; they do not protect it from another agent. - The launch prints the root in the startup summary, exports it as `CPLT_WORKTREE_ROOT`, and names it in the sandbox brief. With the key off the variable is removed, even if your shell has one. - The same persistence denies apply as in the project, at any depth: the shared `.git/hooks` and `.git/config`, each worktree's `config.worktree`, and `.github/hooks`, `.claude/settings.json`, `.cplt.toml` and the rest of the per-root list inside every worktree. See [SECURITY.md](../SECURITY.md#managed-worktree-root-sandboxallow_git_worktrees). - On macOS the kernel refuses rewriting a `/worktrees//commondir` in place, refuses rewriting or deleting any `.git` below the root, and refuses creating a `.git` (in any letter case) at `/.git` or in any subdirectory of a worktree (`///.git`). It does not stop the agent from deleting and recreating a `commondir`, creating an admin directory, moving a worktree aside and writing a new `//.git`, or moving in a directory that already holds a `.git` (the create rule only sees the moved directory's own name). So cplt checks every admin directory and walks the whole root at every launch and at session end. It compares exact text and does not follow links, since a symlink that leads the right way today can be re-aimed later. It passes only what `git worktree add` writes: `commondir` reading exactly `../..`; the admin `gitdir` naming an existing regular `.git` file by a path with no symlink in it; and `//.git` as a regular file reading exactly `gitdir: /worktrees/`, whose admin `gitdir` reads exactly `//.git`. It also reports: - a `.git` anywhere else, in any letter case (on a case-insensitive volume Git opens `.GIT` for `.git`); - an admin directory whose `gitdir` does not name an existing regular `.git` file (a worktree deleted without `git worktree prune` counts); - a directory that Git would take for a bare repository: `HEAD` with `objects` and `refs`, or `HEAD` with a `commondir`, whatever their file type; - anything directly in the root that is not a directory, such as a symlink; - any symlink below the root, dangling or not, unless it stays inside its own worktree: its target, read as text, must name a path inside the worktree (a `..` only before the first name), and it must resolve there. A link that reaches the worktree through a symlink outside it is a finding, since that outside symlink can be re-aimed later. Links like npm's `node_modules/.bin/x -> ../pkg/bin/x` pass. Any finding prints an error at session end naming the directories not to run Git in, and makes the next launch with the key on refuse to start. With the key off, a session can still rewrite a worktree's `commondir` in place (the deny that stops it is part of the key-on profile only, so the key-off profile is unchanged from main). So whenever this repository's root exists, cplt runs the same check at launch and at session end with the key off too, and prints the same error, but the launch goes ahead. A root it cannot inspect, or a repository whose common directory it cannot establish, is reported rather than skipped. Submodules inside a managed worktree are refused for the same reason. So are worktrees written with `worktree.useRelativePaths`, since the check expects the absolute paths Git writes by default. - The walk visits at most `sandbox.worktree_walk_max_dirs` directories (default 100000; files do not count) and 64 levels. A root past either bound is a finding, so the launch refuses to start. The error lists how many directories the walk had counted in each worktree when it stopped. A few large JavaScript worktrees can pass 100000, so raise the key if the error names build or dependency output you want to keep: `cplt config set sandbox.worktree_walk_max_dirs 300000`. - The session-end check runs once the agent's process has exited: after the audit's wait for its child processes (two seconds at most) when the change audit or `--observe-domains` runs, and right away otherwise. A background process the agent left running can still change the root after the check, and nothing reports that until the next launch runs the check again. cplt does not kill leftover processes. - Worktrees and branches persist after the session. cplt never removes them. Clean up outside cplt with `git worktree remove` and `git branch -d` when you are done. Inside cplt `git worktree remove` fails: deleting a worktree's `.git` is denied, so it removes the files and the admin directory but leaves the pointer, and the next launch refuses to start until you delete that directory and run `git worktree prune`. - The end-of-session audit does not cover the worktrees' contents yet. It prints a line saying so. Review them with `git worktree list`. Two other layouts work too: - **Starting cplt inside an existing linked worktree.** The root is keyed on the repository's common Git directory, not on the checkout you start from, so a launch from a linked worktree gets the same root as a launch from the main checkout, and the sub-agents' worktrees land next to the ones created from there. - **A worktree whose shared `.git` lives in another repository or in a bare repository** (for example `git clone --bare` followed by `git worktree add`). The root is keyed on that shared directory. Independently of this key, cplt grants that directory to the session so Git works in the checkout, with or without `sandbox.allow_git_worktrees`. It grants it only when the shared directory is under `$HOME`, is not a system or temp root, sits where the gitdir says it should (`/worktrees/`), and has none of the characters `"`, `(`, `)`, `;`, `\`, a newline, a carriage return or a NUL byte in its path, since those cannot go into the sandbox profile safely. Otherwise the shared directory is not granted: cplt warns only about the refused characters, and in every case Git fails inside the sandbox. A shared directory that does not sit where the gitdir says (a steered `commondir`) fails the launch when the key is on. The launch fails, rather than running without the root, if it cannot be granted: the root or `~/.cplt-worktrees` is a symlink, is not a directory, or is owned by another user, the repository's `commondir` does not match the layout Git writes, or a worktree link fails the check above. It also fails on Linux, where nothing inside the root would be kernel-enforced; there `cplt config set sandbox.allow_git_worktrees true` is refused as well. Outside a Git repository there is no root to grant. With the key in your global config, cplt warns and runs without it. With the key in this checkout's local config, the launch fails. `cplt doctor` and `cplt check` report on the root without creating it. The key is accepted only in your own config, global or local. A `.cplt.toml` cannot set it or propose it. There it is an unknown key: it is reported and never applied. ## Quick setup `cplt settings` browses and changes settings interactively. It stages edits, shows their source and security impact, and writes validated TOML atomically. For scripts, CI, or a direct non-interactive edit, use `cplt config set`: ```bash cplt config set sandbox.quiet true cplt config set proxy.port 9090 cplt config set allow.read ~/Desktop cplt config set allow.ports 8080 cplt config set git_guard.mode warn # the git guard blocks by default cplt config set gh_guard.enabled false # opt out of the gh guard ``` When your startup command gets long, move the repeated flags into config. Your personal machine setup belongs in global config, so leave off `--repo`: ```bash cplt config set sandbox.allow_localhost_any true # tools that grab random localhost ports cplt config set sandbox.allow_docker true --force # Gradle, Testcontainers, Docker cplt config set sandbox.allow_rootless_docker true --force # Linux: rootless Podman/Docker only cplt config set sandbox.allow_jvm_attach true # Gradle daemon, MockK, Mockito inline mocking cplt config set allow.read ~/code/work/.gitconfig-nav # host files the agent should always read ``` That saves you from typing this every time: ```bash cplt --allow-localhost-any --allow-docker --allow-jvm-attach \ --allow-read ~/code/work/.gitconfig-nav ``` `cplt config explain` tells you what a key does and how to set it. ### GitHub Enterprise hosts The gh guard scopes only `github.com` remotes unless `gh_guard.hosts` (default `["github.com"]`) names more. Set it in the global config, with bare hostnames: ```bash cplt config set gh_guard.hosts github.com,example.ghe.com ``` It is global-only: `--local` and `.cplt.toml` cannot set it. The host must also be reachable through the proxy, so under strict networking add it to `allowed_domains` as well. See [gh guard](gh-guard.md#github-enterprise-hosts). ### Letting the agent merge its own pull requests The gh guard refuses `gh pr merge`. `gh_guard.allow_pr_merge` (default `false`) lifts that for one case: a pull request the agent can merge only because the repository would stop a bad merge anyway. ```bash cplt config set gh_guard.allow_pr_merge true --force ``` With the key on, the shim checks each `gh pr merge` when it runs and lets it through only when all of these hold: - the pull request is in the startup repository (or another repository in the scope set); - its author is the account `gh` is logged in as, so any approving review it carries came from someone else; - an active ruleset on its base branch requires at least one approving review, that account cannot bypass the ruleset, and the ruleset also sets "Dismiss stale pull request approvals when new commits are pushed" or "Require approval of the most recent reviewable push". Without one of those, the agent could get an approval, push unreviewed commits and merge. Otherwise the merge is refused with the reason. A failed or unreadable GitHub response also refuses. `--admin` is always refused, and so is any flag the shim does not recognise. `--auto`, `--squash`, `--delete-branch` and the other ordinary flags pass. A merge queue alone does not count, and neither does classic branch protection, which the check does not read. The merge that runs is the one that was checked. The shim rewrites it to `gh pr merge ... --match-head-commit ` with the pull request number and head commit the check read, so a bare `gh pr merge` cannot land on another pull request after a `git checkout`, and a push after the check makes GitHub reject the merge. A `--match-head-commit` naming another commit is refused. What the check cannot pin is the base branch: its rules, or the pull request's base, could change between the check and the merge. **An approval rule is required.** Required status checks do not count on their own: the pull request's author controls what CI reports, by editing the workflows in the pull request or by posting a status through `allow_api_write`. A branch with checks and no required approval, such as cplt's own `main`, refuses every merge. The key applies to every repository you sandbox. `cplt config set --local` limits it to one checkout. ## Policy presets A preset is a named security posture. One flag or key sets a baseline for the five sandbox toggles and for the safety features (`gh_guard`, `git_guard`, forced-proxy egress, fail-closed domain allowlist): ```bash cplt --preset strict # full lockdown: toggles off + guards + forced proxy + domain allowlist on cplt --preset standard # the current defaults (no-op baseline) cplt --preset permissive # localhost + tmp exec + lifecycle scripts on cplt --preset full-trust # everything on (docker, env files, tmp exec, localhost, lifecycle) ``` Or in config: ```toml [sandbox] preset = "standard" ``` | Preset | localhost (`allow_localhost_any`) | env files (`allow_env_files`) | tmp exec (`allow_tmp_exec`) | docker (`allow_docker`) | lifecycle (`allow_lifecycle_scripts`) | `gh_guard` | `git_guard` | `proxy.forced` | `proxy.default_allowlist` | |--------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| | `strict` | off | off | off | off | off | **on** | **on** | **on** | **on** | | `standard` | off | off | off¹ | off | off | **on** | **on** | off | off | | `permissive` | **on** | off | **on** | off | **on** | off | off | off | off | | `full-trust` | **on** | **on** | **on** | **on** | **on** | off | off | off | off | ¹ `standard` leaves the per-session scratch directory on, which is the default and what most tools need. `allow_tmp_exec` (raw `/tmp` exec) stays off. `standard` is identical to cplt's hardcoded defaults, so omitting `--preset` behaves exactly like `--preset standard`. `gh_guard` and `git_guard` are on under `standard` (the default posture), both in `block` mode; the git guard blocks pushes to the default branch only (`protect_default_branch_only`), so feature-branch pushes still work. `strict` additionally drops that relaxation — every push is blocked — and turns on `proxy.forced` (mandatory proxy, kernel egress locked to it) and `proxy.default_allowlist` (fail-closed domain filtering, where only the agent's built-in allowlist plus any `allowed_domains` resolve and everything else is blocked; a plain `allowed_domains` without it still always includes the agent's own hosts, just not the package registries). The last two are orthogonal and compose: the kernel pins egress to the proxy, then the proxy filters domains. `permissive` and `full-trust` turn both guards back off along with weakening the sandbox toggles. **`allow_localhost_any` has no effect under `proxy.forced`, and so none under `strict`.** cplt ignores the key there and warns at startup, wherever it was set: CLI, config or an approved `.cplt.toml`. On Linux, Landlock network rules match ports, not hosts, so allowing every localhost port means not restricting TCP connect at all, and direct egress to any `host:443` would open up again. macOS could pin the rule to localhost, but any local process that relays traffic would then be a path off the machine that skips the proxy. A named port still works under `proxy.forced`: `allow.localhost 3000` (`--allow-localhost 3000`) opens that one localhost port. A tool that picks a random port needs a fixed port or `proxy.forced` off. `cplt config show` marks the key `(no effect: proxy.forced is on)`, and `cplt check net localhost:` gives the fix that works in this mode. Because `strict` only enables safety features, `config set sandbox.preset strict` needs no `--force`, unlike `permissive` and `full-trust`, which weaken the sandbox. To take the strict baseline but keep the network open, add `--allow-all-domains` (or `proxy.default_allowlist = false`). That overrides just the allowlist, since an explicit off beats the baseline. ### Preset precedence A preset is only a baseline. An explicit flag or config value always overrides it, whichever source the preset came from: ```bash # permissive baseline, but keep tmp exec blocked: cplt --preset permissive --no-allow-tmp-exec # strict baseline, but allow docker just for this run: cplt --preset strict --allow-docker ``` Each toggle resolves in this order: explicit CLI flag, then explicit config value, then preset baseline, then the hardcoded default (off). The preset itself resolves CLI (`--preset`) over config (`[sandbox] preset`). Every preset-controlled toggle has a matching `--no-…` form (`--no-allow-localhost-any`, `--no-allow-env-files`, `--no-allow-tmp-exec`, `--no-allow-docker`, `--no-allow-lifecycle-scripts`), so you can opt a single toggle out of a permissive or full-trust baseline. ## Global-only settings Project-specific sandbox permissions belong in `.cplt.toml`, approved with `cplt trust`. That covers `sandbox.allow_jvm_attach`, `sandbox.allow_msbuild`, `sandbox.allow_docker`, `sandbox.allow_rootless_docker`, `sandbox.allow_localhost_any`, and `allow.ports`. Machine-specific paths such as `allow.read ~/code/work/.gitconfig-nav` go in `~/.config/cplt/config.toml`. `sandbox.pass_env` is the exception among the environment settings: a project can propose the variables its build needs (`NODE_ENV`, `TZ`, `SPRING_PROFILES_ACTIVE`), because it names them one at a time and a reviewer sees the list in the diff. The value still comes from whoever launches the agent, never from the file, and each developer approves once with `cplt trust`. `sandbox.inherit_env` stays refused — it passes the whole environment, so there is nothing for a reviewer to review. The settings below are machine-specific or local CLI preferences, so `.cplt.toml` does not support them at all. `cplt config set --repo ` rejects each one with an explanation. Set them globally instead. | Key | Why | |---|---| | `sandbox.preset` | composes several dangerous permissions into one baseline, so a repo must request individual keys and get each reviewed and trusted separately | | `sandbox.agent` | preferred agent depends on what's installed locally | | `sandbox.quiet` | local output preference | | `sandbox.yes` | local prompt-skip preference | | `sandbox.validate` | local launch behavior | | `sandbox.scratch_dir` | local temp handling | | `sandbox.brief` | local agent-context preference | | `sandbox.agents_md` | a repo must not be able to make cplt write into its own `AGENTS.md` | | `sandbox.use_bubblewrap` | depends on bwrap being installed on the machine | | `sandbox.audit` | local output preference, not project sandbox policy | | `sandbox.gradle_init` | writes to the machine's Gradle user home, not project policy | | `sandbox.deny_nested_git` | a staged hardening switch, see [Blocking new nested `.git` entries](#blocking-new-nested-git-entries-sandboxdeny_nested_git) | | `sandbox.refuse_unenforced` | depends on what the machine can enforce, see [Refusing deny paths this host cannot enforce](#refusing-deny-paths-this-host-cannot-enforce-sandboxrefuse_unenforced) | | `sandbox.refuse_cache_exec_links` | a staged hardening switch, see [Symlinked cache-exec entries](#symlinked-cache-exec-entries-sandboxrefuse_cache_exec_links) | | `sandbox.deny_key_files_by_extension` | a staged hardening switch, see [Denying key files by extension](#denying-key-files-by-extension-sandboxdeny_key_files_by_extension) | | `sandbox.protect_pnpm_config` | a staged hardening switch, see [Protecting pnpm's global config](#protecting-pnpms-global-config-sandboxprotect_pnpm_config) | | `sandbox.refuse_invalid_repo_config` | decides how a repo's own broken config is treated, so the repo cannot set it; see [Refusing an invalid `.cplt.toml`](#refusing-an-invalid-cplttoml-sandboxrefuse_invalid_repo_config) | | `sandbox.skip_untrusted_opencode_config` | decides what an untrusted repo may configure, so the repo cannot set it; see [OpenCode project config in untrusted repositories](#opencode-project-config-in-untrusted-repositories-sandboxskip_untrusted_opencode_config) | | `sandbox.deny_copilot_dir_exec` | a staged hardening switch, see [No execute on `~/.copilot`](#no-execute-on-copilot-sandboxdeny_copilot_dir_exec) | | `sandbox.allow_copilot_host_persistence` | decides what the sandbox may leave behind for host sessions, so the repo cannot set it; see [Copilot agents and saved approvals](#copilot-agents-and-saved-approvals-sandboxallow_copilot_host_persistence) | | `sandbox.mask_missing_deny_paths` | decides what the sandbox may leave behind for host sessions, so the repo cannot set it; see [Missing host-persistence directories on Linux](#missing-host-persistence-directories-on-linux-sandboxmask_missing_deny_paths) | | `sandbox.inherit_env` | too dangerous for repo config, it would affect every team member | | `sandbox.allow_build_credentials` | hands the agent the user's own registry tokens from `$HOME`, and a repo cannot grant home paths | | `allow.exec` | exec paths differ per machine, and a repo must not be able to make one of its own trees executable | | `sandbox.allow_cache_exec` | cache paths differ per machine | | `sandbox.allow_cache_exec_any` | too broad for repo policy | | `proxy.enabled` | local proxy preference | | `proxy.forced` | forcing all egress through the proxy is a local security preference, not project policy | | `proxy.deny_dns` | closing DNS is a local security preference that goes with `proxy.forced` | | `proxy.netns` | a network namespace depends on this host's Bubblewrap and kernel; see [Network namespace](proxy.md#network-namespace-linux-proxynetns) | | `proxy.port` | port conflicts are machine-specific | | `proxy.log_file` | local log path | | `proxy.log_level` | local verbosity preference | | `proxy.timeout` | local network condition preference | | `proxy.blocked_domains` | local path to a blocklist file | | `proxy.allowed_domains` | local path to an allowlist file | | `proxy.default_allowlist` | proxy settings are machine-specific, not project policy | | `proxy.upstream` | the corporate proxy is machine-specific and network-specific | | `proxy.upstream_no_proxy` | same, and it only means anything alongside `proxy.upstream` | | all `[proxy.subscriptions]` keys | subscription sources are security-sensitive, and a repo must not be able to add one | | all `[gh_guard]` keys | guard policy is configured globally, not per-repo | | all `[git_guard]` keys | guard policy is configured globally, not per-repo | | `shell.skip` | the PATH shims are shared by every repository on the machine; also refused in `config set --local` | ### `shell.skip` Agents the PATH shim sync leaves out, by `--agent` name or command name. It only matters after `cplt --shell-install --shims` (see the README's "PATH shims" section). Adding an agent removes its shim at the next sync; removing it from the list brings the shim back (for `goose` and `pi`, which need a version check, at the next `cplt --shell-install --shims`). ```bash cplt config set shell.skip goose # your goose is pressly/goose, not Block's agent ``` An unknown name is an error rather than a silent no-op, so a typo cannot leave the shim you meant to remove in place. ### Removing values ```bash cplt config set allow.read ~/Desktop --unset # remove one element cplt config set allow.read --unset # remove entire key cplt config set sandbox.quiet --unset # revert to default cplt config unset allow.read ~/Desktop # same as set ... --unset ``` ### Inspecting config ```bash cplt config show # show effective config (file + defaults) cplt config get sandbox.quiet # get a single value cplt config explain # list all keys with descriptions cplt config explain sandbox.pass_env # explain a specific key cplt config validate # check for syntax errors and unknown keys cplt config hosts --agent copilot --json # an agent's effective hosts (built-in plus hosts detected on this machine), see docs/proxy.md ``` ## Build-host credentials (`sandbox.allow_build_credentials`) Off by default. Marked dangerous. Config-only: there is no CLI flag, and `.cplt.toml` cannot set it. ```bash cplt config set sandbox.allow_build_credentials true --force ``` It grants read access to three files, and nothing else: - `~/.npmrc` - `~/.gradle/gradle.properties` - `~/.m2/settings.xml` A Node or JVM build that pulls from an authenticated registry, such as GitHub Packages or Nexus, needs one of these. Without it the build fails at dependency resolution even with the network open, typically as a 401 from `pnpm install` or Gradle. The failure comes from filesystem policy, not the proxy, so nothing in the error points at cplt. See [#463](https://github.com/navikt/cplt/issues/463). **Turning it on exposes those tokens to the agent.** It can read every token in the three files, not only the one for the registry the project uses. It can also send them anywhere the network policy allows. The key narrows nothing. It replaces three separate `allow.read` lines with one reviewed decision. What it does, exactly: - The files only. Neither backend gets a directory grant or a write grant from this key. On macOS the grant is read-only; on Linux it is read-only for `~/.npmrc` only (see the Linux note below). This is the same per-file override an `allow.read "~/.npmrc"` line produces: on macOS a literal re-allow emitted after the default deny, on Linux a read-only Landlock rule on the file. - A file that does not exist is skipped. - A file that is a symlink is granted at its target, like `allow.read`, as long as the target is a regular file of its own. It is refused, with a warning at launch, when it resolves into a credential directory (`~/.ssh`, `~/.aws`, ...) or onto another credential file, is a directory, or is a hardlink you own with more than one name. So `~/.npmrc -> ~/.ssh/id_ed25519` does not become a grant on the key. A target whose path holds a character that is unsafe in a macOS sandbox profile (such as `"`) is refused the same way, as is a file cplt cannot resolve for a reason other than absence (a symlink loop, say). The startup summary lists only the files actually granted. - A `--deny-path` or `deny.paths` entry covering one of the files keeps this key from granting it. On macOS and for `~/.npmrc` on Linux, that makes the file unreadable. For the Maven and Gradle files on Linux, see the note below. - `~/.m2/settings-security.xml`, `~/.cargo/credentials*` and `~/.nuget/NuGet.Config` are not included. Use `allow.read` for those. On Linux, `~/.m2/settings.xml` and `~/.gradle/gradle.properties` are already readable and writable inside the sandbox whether this key is on or off, because Landlock cannot deny a file inside the granted `~/.m2` and `~/.gradle` tool directories (see [Private registries](known-impacts.md#private-registries)). On Linux the key therefore changes only `~/.npmrc`. On macOS it changes all three, and the files stay unwritable. A deny on either of the two Linux files takes effect only when Bubblewrap is active and mount-masks it. Without Bubblewrap the deny does nothing for them: they stay readable and writable, and cplt names each one in a warning at launch. If you only need one of the files, a single `allow.read` line is narrower. `cplt init --global` still proposes exactly that: an `allow.read` entry for each file it finds credentials in. ## Refusing an invalid `.cplt.toml` (`sandbox.refuse_invalid_repo_config`) By default, a `.cplt.toml` that cannot be read, cannot be parsed or fails validation produces a warning, and the launch goes ahead. This applies to the launch repository and to every named repository. The warning names the error and says that the file's `[deny]` section is not applied. Everything the file would have granted is lost too. The session is less restricted than the file asks for, and a warning can scroll past unread. A `[deny]` key this version of cplt does not recognize, such as a typo, is warned about in the same way and is not applied. ```bash cplt config set sandbox.refuse_invalid_repo_config true ``` With the key on, cplt refuses to launch instead, for both cases: a file it cannot load, and a file with an unrecognized `[deny]` key. The error names the file and the problem. It also says which copy cplt read: cplt prefers the committed `HEAD:.cplt.toml` to the working tree, so a broken committed file needs a fixing commit, and editing the working tree changes nothing. An unrecognized `[propose]` key does not refuse. It grants nothing, and refusing it would break older cplt versions reading a file written for a newer one. `cplt exec` and `cplt check` read repo config the same way, so they refuse too. It is off by default, and it is set in your own config, never in `.cplt.toml`. Turning it on means that anyone who can write a repository's `.cplt.toml` can stop every launch there. That includes an earlier agent session on either platform, in the launch repository or a named one. A session can commit a broken `.cplt.toml` into HEAD, and that commit shows in `git log`. On the Landlock-only Linux path a session can also edit the uncommitted working-tree file. The refusal is loud and names the file, so this can stop your work but cannot weaken the sandbox. The key catches broken or mistyped files. It is not a defence against an adversarial session: a session that can commit a broken file can just as easily commit a valid one with the `[deny]` section removed, and cplt applies that without complaint. With the key off, cplt launches as before. ## OpenCode project config in untrusted repositories (`sandbox.skip_untrusted_opencode_config`) OpenCode reads configuration from the repository it runs in: `opencode.json` and `opencode.jsonc`, the `.opencode` directory (plugins, agents, commands) and the project's `AGENTS.md`. A plugin is code that runs inside the agent. A cloned repository can therefore change how the agent behaves, or switch off a plugin you rely on, without you doing anything ([#732](https://github.com/navikt/cplt/issues/732)). ```bash cplt config set sandbox.skip_untrusted_opencode_config true ``` With the key on, an OpenCode launch in a repository whose `.cplt.toml` is not approved sets `OPENCODE_DISABLE_PROJECT_CONFIG=1` and `OPENCODE_CONFIG_PROJECT_DISABLE=1`. OpenCode then skips the repository's config files, `.opencode` directories (plugins included) and root `AGENTS.md`, in 1.x and 2.x alike. OpenCode 1.x still attaches an `AGENTS.md`, `CLAUDE.md` or `CONTEXT.md` from a subdirectory when the agent reads a file there: those are instructions, not code, and 1.x has no switch for them. Your global OpenCode config, plugins and `AGENTS.md` still load. OpenCode documents the variable in its [CLI reference](https://opencode.ai/docs/cli/#environment-variables); 2.x reads `OPENCODE_CONFIG_PROJECT_DISABLE` first and falls back to it ([`server-process.ts`](https://github.com/anomalyco/opencode/blob/v2.2.0/packages/cli/src/server-process.ts)). OpenCode 1.x still reads `.claude/skills` and `.agents/skills` in the repository with project config off, so for 1.x cplt also sets `OPENCODE_DISABLE_EXTERNAL_SKILLS=1`, which drops your global skills in those two directories as well. 2.x skips the repository's skills with project config and keeps your global ones. cplt prints one line when a repository switches between skipped and loaded, not on every launch. A repository counts as trusted when the launch applies its `.cplt.toml` proposals: a current `cplt trust accept` approval bound to this repository, or `--accept-repo-config` for one run. An approval copied onto the same origin URL in a different clone does not count, and neither does an approval whose every approved key has changed value since, or one `cplt trust accept --all` retired. An approval needs a `[propose]` section, so a repository without one stays untrusted while the key is on; turn the key off to load its config. `cplt exec` does not apply the key. The key is off by default, and it is set in your own config, never in `.cplt.toml`. If `deny.env` names either variable, the launch stops, because cplt cannot apply the key without them. Other agents are not affected. ## Refusing deny paths this host cannot enforce (`sandbox.refuse_unenforced`) Linux only. With the key on, cplt refuses to launch in these cases: - `deny.paths` or `--deny-path` is set and Bubblewrap is not used. Landlock alone cannot deny a path inside a directory the sandbox allows. Bubblewrap can be missing, blocked (for example by AppArmor), or turned off with `sandbox.use_bubblewrap = false` or `--no-bubblewrap`. The error says which, and how to fix it. - Bubblewrap is used, but cannot mask a deny path: a path under `/proc`, `/dev`, `/sys` or `/tmp`, or a failure to create the mask file. The error names each path and the reason. - Landlock is below ABI v4 (kernel older than 6.7) and no proxy is configured, so outbound connections cannot be restricted. A deny path that does not exist denies nothing, so it only warns. Only a repository `.cplt.toml` can name such a path: entries in your own config and on the command line must exist when cplt loads them. `cplt check` and `--print-profile` refuse in the same cases as a launch. `cplt config init` writes `refuse_unenforced = true`. A config without the key keeps the old behaviour: cplt warns and launches. `cplt doctor` suggests the key when it would change something on this host. To keep launching with the warning: ```bash cplt config set sandbox.refuse_unenforced false ``` The key does not cover built-in protections that need Bubblewrap, or `sandbox.deny_nested_git` and `sandbox.deny_key_files_by_extension`. They still warn and launch. ## Symlinked cache-exec entries (`sandbox.refuse_cache_exec_links`) Linux only. An `allow_cache_exec` entry such as `ms-playwright` or `pnpm/dlx` can reach its directory through a symlink inside `~/.cache`, at the entry itself or at a directory above it. Landlock grants the directory the link points to, so that is where the agent gets write and execute. cplt grants that target by default, prints a warning naming it, and refuses a target it will never make writable and executable: exactly `/`, `/tmp`, `$HOME` or a parent of it, or a system root such as `/usr` (the root itself, not a path below it); anything inside a dot directory in `$HOME` other than `~/.cache` (`~/.config/systemd/user`, `~/.config/autostart`, `~/.local/bin`, `~/.claude`, …) or inside `~/bin`, where the host loads code from on its own; anything inside `/run`, `/var/tmp` or `/dev/shm`; a credential directory, cplt's own state, the project, or any other path the sandbox already grants, such as a tool directory or `~/.cache/pnpm` itself. A cache moved to a data disk or an ordinary directory in `$HOME` keeps working. cplt never creates a linked entry's target; a missing one gets no grant until you create it. ```bash cplt config set sandbox.refuse_cache_exec_links true ``` With the key on, any such link stops the launch. The sandbox can write `~/.cache`, so an earlier session could have planted the link or re-pointed it at a directory the checks above do not know about, such as a project checkout's build output or a tool prefix no rule names. The key is off by default because turning it on breaks a cache you moved on purpose with a symlink, which works without it. Either way, cplt opens the directory it checked without following any symlink when the launch applies the sandbox. A link swapped in after the check, at any level, stops the launch instead of moving the grant. A symlinked `~/.cache` itself (which only you can create, since the sandbox cannot write `$HOME`) is checked the same way and refused under the key; the scratch directory, on by default, refuses it regardless, so it only works with `--no-scratch-dir`. macOS is unaffected: Seatbelt checks the resolved path against rules on the literal cache path, so a link there grants nothing extra. ## Blocking new nested `.git` entries (`sandbox.deny_nested_git`) A session can make any subdirectory of the project into a repository whose config it wrote: a `.git` pointer file, a `.git` symlink, or a git directory renamed into place. Git run there later, outside the sandbox, runs whatever that config names ([#576](https://github.com/navikt/cplt/issues/576)). cplt checks for this at the end of every session and names each directory whose `.git` is new or changed, including a `.GIT`-style case variant, a git-directory layout with no `.git` at all (a `HEAD` plus `objects/` and `refs/`, or plus a `commondir` file), and a new `.git` at a project root that was not a repository at launch. It also names every new or re-aimed symlink to a directory, or dangling, whose lookup passes outside the project at any hop, whatever is at the other end (Nix `result` and Bazel `bazel-*` links show up here), and every directory it was not permitted to list. That check is always on and has no key. Its limits are listed in [SECURITY.md](../SECURITY.md). `sandbox.deny_nested_git` also blocks the plant on macOS. It refuses to create the `.git` name itself, in any letter case, below each writable root (file, directory, symlink, hard link, or rename onto the name). The root's own `.git` is not affected, so `git init` in the project directory still works. It also refuses a new `.git` anywhere inside a git directory the profile knows (such as `/.git/refs/.git`). It does not apply inside the managed worktree root of [`sandbox.allow_git_worktrees`](#worktrees-for-sub-agents-sandboxallow_git_worktrees): `git worktree add` has to create `//.git` there, and that root has its own rules, which refuse every other `.git` in it and check the worktree links at the end of the session. It does not block: - moving in a directory that already contains a `.git`, staged in a writable place outside the project such as `/private/tmp` or `/var/folders` - a symlink out of the project - the git-directory layout; for that, set `safe.bareRepository = explicit` in your global git config The end-of-session check reports all three: the moved-in directory and the layout as new repositories, the symlink as a link leading out of the project. ```bash cplt config set sandbox.deny_nested_git true ``` Off by default, because it breaks things that create a `.git` below the project: - `git worktree add` into a directory inside the project - test fixtures that `git init` or `mkdir .git` inside the project (on macOS, `git init` there already fails without this key, but a fixture that writes the `.git` itself succeeds) - any tool that writes a `gitdir:` pointer file inside the project - `allow.write` grants that cover a package manager's git checkouts, such as `~/.cargo/git/checkouts`: the rule applies below every writable root, so fetching a new git dependency there fails It has no effect on Linux, where the launch says so. Landlock cannot deny a path inside a tree it allows, and bubblewrap only protects paths that exist at launch, so there the end-of-session check is the only cover. ## Denying key files by extension (`sandbox.deny_key_files_by_extension`) The default deny for key files is a pattern on the whole file name. It blocks a file named exactly `.pem`, `.key`, `.p12`, `.pfx` or `.jks`, but not `server.pem`, `tls.key` or `keystore.jks`. Those stay readable and writable. SSH private keys named `id_rsa`, `id_dsa`, `id_ecdsa` or `id_ed25519` (not their `.pub` files) are denied by default as well, because no public certificate uses those names. `sandbox.deny_key_files_by_extension` also denies any file whose name ends in one of those extensions, or in `.p8` (App Store Connect and APNs keys), `.ppk` (PuTTY keys) or `.keystore` (Android signing keys), for read and write, on macOS. A name that only contains the extension (`x.key.bak`) is not affected. A directory whose name ends in one (`certs.pem/`) is denied itself, so it cannot be listed, but the files inside it stay readable. The extension denies apply only inside the trees you granted: the project, every `--repo-dir` root, and every `allow.write` and `allow.read` path. They are not global, unlike the exact-name patterns, because `*.pem` also matches the system CA bundles (`/etc/ssl/cert.pem`, Homebrew's `cert.pem`) that curl, git and pip need for HTTPS. Those stay readable unless you grant a tree that contains them. Such a grant (`--allow-read /`, or one covering `/private/etc/ssl`, `/opt/homebrew/etc`, `/usr/local/etc`, `/opt/homebrew/Cellar` or `/usr/local/Cellar`) is not refused, but the launch warns about it. The Cellar holds the real files behind Homebrew's symlinks, such as each Homebrew Python's `certifi/cacert.pem`. The warning does not cover python.org's installer: a grant over `/Library/Frameworks/Python.framework` denies its `certifi/cacert.pem` without a warning. ```bash cplt config set sandbox.deny_key_files_by_extension true ``` Off by default, because it blocks files that work today, anywhere inside a granted tree: - a local HTTPS dev server that reads its certificate or key from the project - tests that load a key or keystore fixture from the project - a Java or Android build that signs with a `.jks`, `.p12` or `.keystore` in the project, such as a committed Android `debug.keystore` - an iOS release script (fastlane) that reads an App Store Connect `.p8` key from the project - a CA bundle inside a granted tree. A Python virtualenv in the project ships `certifi/cacert.pem`, so `pip` and `requests` from that venv fail TLS verification. A wide grant such as `allow.read = ["~"]` puts every tool's bundled `cacert.pem` under the deny, and a grant that covers `/etc` or `/opt/homebrew` puts the system bundles there too - a package under `node_modules` or a vendored dependency that reads a `.pem` it ships To recover, set the key back to `false`. `--allow-env-files` also lifts it, but it lifts the `.env` deny as well. It follows `--allow-env-files`: with that flag, none of these patterns are emitted. Like the default patterns, it is lifted for read inside the extracted dependency stores (`~/go/pkg/mod`, `~/.cargo/registry`) when a granted tree covers them, since a key file there is a library's test fixture. A `--deny-path` or `deny.paths` entry inside or above a store still wins, the same way it does for `.env` (#597): the read deny is repeated after the carve-out, narrowed to the part of the store you denied, so `server.pem` under your deny stays unreadable and the rest of the store keeps the carve-out. It has no effect on Linux, where the launch says so. Landlock cannot deny a file by name pattern, so on Linux the default patterns do not apply either. ## Protecting pnpm's global config (`sandbox.protect_pnpm_config`) pnpm keeps its global config in `~/.config/pnpm` (or `$XDG_CONFIG_HOME/pnpm`). That is where pnpm looks on Linux, and on macOS when `XDG_CONFIG_HOME` is set; otherwise macOS pnpm uses `~/Library/Preferences/pnpm`, where cplt grants `config.yaml` read-only. The XDG directory holds `config.yaml` and, since pnpm 11, `auth.ini`, where `pnpm login` and `pnpm config set --global //registry/:_authToken=...` store registry tokens. pnpm 10 and older keep them in `rc`. cplt grants the directory read and write, so by default the agent can read those tokens and change settings that your own pnpm uses outside the sandbox. `sandbox.protect_pnpm_config` makes the directory read-only and denies `auth.ini` and `rc` for read and write. `config.yaml` stays readable, so `pnpm install` still sees your global settings. On macOS the two files get deny rules. On Linux, where Landlock cannot deny a file inside a granted directory, the directory is no longer granted at all and `config.yaml` is granted read-only on its own. Anything else in the directory is then unreadable on Linux. ```bash cplt config set sandbox.protect_pnpm_config true ``` Off by default, because it breaks things that work today: - a `pnpm install` from a private registry whose token is only in `auth.ini` or `rc`. It fails with 401, and pnpm 11 warns that it could not read `auth.ini` - `pnpm config set --global`, `pnpm login` and `pnpm logout` inside the sandbox To give pnpm one token file back, grant it by exact path. On macOS the grant re-allows it after the deny, as it does for `~/.npmrc`: ```bash cplt config set allow.read "~/.config/pnpm/auth.ini" ``` Or set the key back to `false`. A token in `config.yaml` (pnpm 11 accepts an `_auth` entry there) stays readable either way. See [Private registries](known-impacts.md#private-registries). ## Dropping the Keychain grant (`sandbox.keychain_substitute`) On by default for Copilot and Claude Code, off for the other agents. macOS only; on Linux it does nothing. `.cplt.toml` cannot set it. An explicit value applies to every agent: ```bash cplt config set sandbox.keychain_substitute false # keep the Keychain grant cplt config set sandbox.keychain_substitute true # also try it for Antigravity ``` For Claude Code, the grant goes when `CLAUDE_CODE_OAUTH_TOKEN` is exported. Create the token with `claude setup-token`; Claude Code then signs in with it alone and never reads the Keychain. Without the variable nothing changes, and `cplt --agent claude check` suggests the command. `ANTHROPIC_API_KEY` does not count, so a subscription login is never switched to API billing. If you already export `CLAUDE_CODE_OAUTH_TOKEN`, Claude Code loses the Keychain grant on your next launch. MCP servers that use OAuth then ask you to sign in once more, and Claude Code stores those tokens in `~/.claude/.credentials.json`. If Claude Code fails to sign in, do one of these: - unset `CLAUDE_CODE_OAUTH_TOKEN` to go back to the Keychain login - run `claude setup-token` again and export the new token, if the old one has expired - run `cplt config set sandbox.keychain_substitute false` to keep the Keychain grant When a substitute resolves, cplt removes the read+write grant on `~/Library/Keychains` and hands over that credential. For Copilot it first selects a nonblank exported `COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN` not denied by `deny.env`. Otherwise it retrieves gh's stored token with trusted `gh auth token --hostname github.com`, clearing those three variables from the subprocess, and passes the result into the first permitted name in the order `GH_TOKEN`, `GITHUB_TOKEN`, `COPILOT_GITHUB_TOKEN`. The retrieval has a 5-second exit timeout and 500 ms stdout-EOF grace. Failed or empty retrieval, no trusted gh, or no permitted name keeps the Keychain grant when no exported substitute resolves. On the stored-gh path, a detected mismatch between gh's github.com `user:` and a Copilot `copilot-cli` Keychain item's `acct` also keeps the grant. cplt reads attributes, not the secret. With several items it compares the one matching gh's login, else the first `security` returns. This cannot identify Copilot's actively selected account. Missing metadata, an error or timeout permits substitution; exported tokens skip the check. `gh_guard.inject_token` is a separate channel and does not perform that account comparison. The launch summary and doctor's `keychain:` and `auth:` lines report the resolved grant and credential source. A retrieved token is not proof of validity, Copilot entitlement, selected account or renewal. A retained grant is not proof of a stored native login. cplt does not extract Copilot's Keychain secret. The token is readable by every subprocess that inherits the agent's environment. A rejected token with Keychain denied can leave Copilot unable to authenticate; fallback with Keychain granted is version- and credential-specific. Recover the intended gh login on the host with `gh auth refresh --hostname github.com` or `gh auth login --hostname github.com`, or remove a stale exported token. To test native Copilot login, record the prior values and scopes, set **both** `sandbox.keychain_substitute = false` and `gh_guard.inject_token = false`, clear all three exported token variables in a subshell, and restart. Substitution off restores broad Keychain access, not access to one item; gh file/cache sources may still be reachable. Restore each key to its actual prior value and scope, using `--unset` only if it was originally unset there. See the [recovery sequence](known-impacts.md#the-same-vault-prompt-on-macos) and [security details](../SECURITY.md#keychain-access-is-all-or-nothing). ## No execute on `~/.copilot` (`sandbox.deny_copilot_dir_exec`) On Linux, the Copilot agent gets read, write and execute on `~/.copilot`. A directory the agent can both write to and run programs from is a place to drop a binary and run it, the same pair cplt refuses when `allow.exec` asks for it ([#324](https://github.com/navikt/cplt/issues/324)). In one trace, Copilot CLI 1.0.88 on linux-arm64, with no plugins, MCP servers, hooks or LSP servers installed, ran nothing from `~/.copilot` for a prompt, its search tool or a shell command. Its bundled programs (`rg`, `tgrep`) ran from `~/.cache/copilot/pkg`, and its native `.node` addons load with read access alone. Other versions and set-ups were not traced. ```bash cplt config set sandbox.deny_copilot_dir_exec true ``` With the key on, `~/.copilot` stays readable and writable and loses execute, under Landlock and under Bubblewrap. Off by default for now, because anything you have set up to run as a program stored under `~/.copilot` stops working: a plugin, MCP server, LSP server or hook whose command is a path in there. Commands started through an interpreter (`node ~/.copilot/...`, `bash ~/.copilot/...`) keep working, because the interpreter is what gets executed. Two set-ups keep some execute, and the launch warns about each instead of staying silent. Landlock adds up every rule that applies to a path, so another rule that grants execute gives it back: - **`~/.copilot` is a symlink into a tree that is granted execute** (the project, `~/.local/bin`, Copilot's cache). Everything in it stays executable through that tree's grant, so the key cannot take effect. The warning names the tree. Point `~/.copilot` at a directory outside every executable grant. - **Copilot is installed inside `~/.copilot`.** Copilot's install directory is always granted execute, or Copilot could not start. That directory and everything below it stay executable; the rest of `~/.copilot` does not. The warning names the directory. It has no effect on macOS, where the profile is unchanged and the launch warns that the key was ignored. The trace was done on Linux only. ## Copilot agents and saved approvals (`sandbox.allow_copilot_host_persistence`) Copilot loads `~/.copilot/agents/` and `~/.copilot/permissions-config.json` in every later session, including sessions outside cplt. A custom agent there can start a command when you select it, and a saved approval lets a tool run without asking ([#906](https://github.com/navikt/cplt/issues/906)). New configs deny the sandbox write access to both: ```bash cplt config set sandbox.allow_copilot_host_persistence false ``` With the key off, saving a tool approval inside cplt fails for that session, and you add custom agents from a shell outside cplt. cplt also warns after the session when Copilot trusted a new folder. A config without the key keeps both writable, and `cplt doctor` suggests the change. Set the key to `true` (`cplt config set --force sandbox.allow_copilot_host_persistence true`) to keep them writable and silence that. On Linux the deny needs bubblewrap and only covers paths that already exist. See [SECURITY.md](../SECURITY.md). ## Missing host-persistence directories on Linux (`sandbox.mask_missing_deny_paths`) On Linux, the agent's host-persistence denies (Copilot's `agents/` and `extensions/`, Pi's `extensions/`, `npm/`, `git/`, `skills/`, Claude's `plugins/`, the shell's fish `conf.d/`, `functions/` and `vendor_*.d/`) are read-only bubblewrap mounts, and bubblewrap cannot mount a path that does not exist. When the directory is missing at launch, the agent can create it, and a later host session loads what it put there. New configs close that: ```bash cplt config set sandbox.mask_missing_deny_paths true ``` With the key on, a missing directory gets an empty read-only mount, so creating it or writing inside it fails. The agent sees an empty directory where there was none, and bubblewrap leaves that empty directory on disk. It is masked only when its parent directory exists and lies inside a writable grant, after symlinks and `XDG_*` or `CLAUDE_CONFIG_DIR` roots are resolved, so cplt never creates a parent in your home directory. A directory that cannot be masked gets a launch warning naming it. Missing files (`settings.json`, `hooks.json`) are not masked. A config without the key keeps today's behaviour, and `cplt doctor` suggests the change; set the key to `false` to silence that. The key needs bubblewrap and has no effect on macOS, which denies these paths whether they exist or not. See [known impacts](known-impacts.md#linux-missing-host-persistence-directories-appear-empty). ## Other keys Keys not covered in a section above. `cplt config explain ` prints the same facts for the version you have installed. Keys marked ⚠️ weaken the sandbox when turned on, and `config set` asks for `--force` to do it. `git_guard.allow_push` is the exception: `config set` cannot write it at all, so you add it in the config file. | Key | Default | What it does | |---|---|---| | `sandbox.allow_env_files` | `false` | Lets the agent read, write and delete `.env*`, `.pem` and `.key` files in the project. See [known impacts](known-impacts.md). | | `sandbox.allow_docker` ⚠️ | `false` | Grants the Docker/Podman/Colima/OrbStack daemon sockets and `~/.docker` read-only. Container mounts bypass the sandbox. See [known impacts](known-impacts.md#docker-and-testcontainers). | | `sandbox.allow_rootless_docker` ⚠️ | `false` | Linux only. Grants just the rootless sockets under `$XDG_RUNTIME_DIR` (`docker.sock`, `podman/`); rootful sockets stay masked, no `~/.docker`. A rootless container still runs as you, outside the sandbox. `allow_docker` wins if both are set. No effect on macOS. | | `sandbox.allow_lifecycle_scripts` ⚠️ | `false` | Lets npm, yarn and pnpm run lifecycle scripts (`postinstall`, `prepare` and so on). | | `sandbox.allow_tmp_exec` ⚠️ | `false` | Allows executing binaries from `/tmp` and `/var/folders`. The per-session scratch directory usually covers the same need. | | `sandbox.allow_gpg_signing` ⚠️ | `false` | Allows GPG commit and tag signing: read-only access to the public keyring and the GPG agent socket. Private keys stay denied. See [GPG commit signing](known-impacts.md#gpg-commit-signing). | | `sandbox.allow_browser` ⚠️ | `false` | macOS Launch Services grant, for OAuth sign-in flows. With it on, the agent can start any application outside the sandbox, and the grant cannot be scoped. See [security](security.md#--allow-browser-is-a-sandbox-escape-and-cannot-be-scoped). | | `sandbox.deny_clipboard` | `true` | Denies the macOS clipboard, so `pbpaste` cannot read what you last copied. `--allow-clipboard` lifts it for one run. No effect on Linux. | | `allow.socket` | `[]` | Unix socket paths the agent may connect to. Whatever listens on the socket runs outside the sandbox. Below Landlock ABI v9 (kernel 7.1), these filesystem grants do not mediate pathname socket connections; they are not generally refused when ineffective. From ABI v9, broad writable-tree grants also permit connections within those trees. | | `proxy.allow_private_domains` | `[]` | Domains allowed to resolve to private or internal IP addresses, a waiver of the DNS-rebinding guard for intranet services. A name covers its subdomains. See [private-domain waiver](proxy.md#private-domain-waiver). | | `gh_guard.mode` | `block` | `block` refuses, `warn` prints a warning and runs the command, `audit` runs it and logs the decision. See [gh guard](gh-guard.md). | | `gh_guard.scope_check` | `true` | Refuses write commands aimed at another repository, for example with `-R`. | | `gh_guard.block_auth_token` | `true` | Intercepts `gh auth token` through the wrapper. Copilot can receive a cached token once when eligible; otherwise it refuses. With `false`, real gh answers from whatever credential source it can reach. Does not prevent reading environment tokens, guard caches or allowed credential-helper output. | | `gh_guard.inject_token` ⚠️ | `false` | **Deprecated, and no effect on macOS.** `gh` gets its token from the gh guard exec token, and Copilot signs in with `sandbox.keychain_substitute = true` (macOS only). On macOS the key does nothing: cplt accepts it, prints one line at launch while it is `true`, and never refuses a launch over it. Remove it. On Linux it remains a recommended credential handover when native Secret Service authentication fails; native access depends on the backend and agent version, and the keyring is not guaranteed closed. See [Linux keyring access](known-impacts.md#linux-keyring-access-depends-on-the-backend-and-agent-version). On Linux, for Copilot only, and only while `gh_guard.enabled` is on: puts your `gh` token in the sandbox environment at launch, where every subprocess can read it. The token goes into `GH_TOKEN`, or the next of `GITHUB_TOKEN` and `COPILOT_GITHUB_TOKEN` that `deny.env` leaves in place. Not needed for `gh` itself: the gh guard hands the real `gh` the exec token (see [gh guard](gh-guard.md#how-gh-itself-authenticates)). Skipped when the agent already has a token. | | `gh_guard.unknown_command` | `block` | What to do with a `gh` command the guard does not classify: `block` or `allow`. | | `gh_guard.allow_api_write` ⚠️ | `false` | Allows `gh api` writes (POST, PATCH, PUT and input flags), scope-checked to the current repository. | | `git_guard.enabled` | `true` | Intercepts `git push`, `request-pull` and `send-pack`. See [git guard](git-guard.md). | | `git_guard.prevent_push` | `true` | Treats those commands as violations; `git_guard.mode` decides what a violation costs. | | `git_guard.prevent_force_push` | `true` | Refuses force pushes on any branch, including feature branches under `protect_default_branch_only`. A `git_guard.allow_push` rule with `force = true` lets a matching force push through, and `git_guard.mode` `warn` or `audit` does not block. | | `git_guard.protect_default_branch_only` | `true` | Refuses only pushes to the default branch. `false` refuses every push, as `--preset strict` does. | | `git_guard.allow_push` ⚠️ | `[]` | Push exceptions, each a `[[git_guard.allow_push]]` block with `remote`, `branches` and `force`. `config set` cannot write an array of tables, so edit the config file. See [git guard](git-guard.md#configuration). | | `sandbox.strip_otlp_vendor_auth` | `false` | Also removes `x-honeycomb-team`, `api-key`, `x-api-key` and `dd-api-key` from `OTEL_EXPORTER_OTLP_HEADERS` and its `_TRACES`/`_METRICS`/`_LOGS` variants, as `Authorization` always is ([#878](https://github.com/navikt/cplt/issues/878)). Sanitized mode only: with `inherit_env` the value passes unchanged, and `--pass-env` keeps the raw value. `cplt config init` writes `true`. Without the key, the headers pass, and launch (once per project), `cplt doctor` and `cplt check` suggest setting it when one is present. | | `sandbox.gh_proxy` | `false` | Deprecated spelling of `gh_guard.enabled`. | | `sandbox.git_push_prevention` | `false` | Deprecated spelling of `git_guard.enabled`. | ## Configuration file The config file lives at `~/.config/cplt/config.toml`, or `$XDG_CONFIG_HOME/cplt/config.toml` when `config.toml` or `trust/` already exists there ([#851](https://github.com/navikt/cplt/issues/851)). ### Isolating cplt in tests and harnesses Pointing `XDG_CONFIG_HOME` at an empty scratch directory does **not** isolate cplt. cplt uses `$XDG_CONFIG_HOME/cplt` only when that directory already contains `config.toml` or `trust/`. An empty directory falls back to `~/.config/cplt`, so commands like `cplt config set` write to the real user config whenever `HOME` is real ([#943](https://github.com/navikt/cplt/issues/943)). To isolate cplt in tests, scripts, and harnesses: 1. Start from a clean environment (`env -i`), so agent-specific variables such as `CLAUDE_CONFIG_DIR` or `COPILOT_HOME` do not point the agent back at your real config. 2. Set `CPLT_CONFIG` to a file in a scratch directory, or to `/dev/null/nonexistent` to run with built-in defaults and no file. 3. Set `HOME` and every `XDG_*` directory (`XDG_CONFIG_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `XDG_CACHE_HOME`) to scratch paths. Keep the scratch directory outside the project and outside `/tmp`: a sandboxed agent can write both, and could change the config or its `trust/` and `local/` files before the next launch. Which paths follow `CPLT_CONFIG`: - **Follows `CPLT_CONFIG`:** `config.toml` and its directory, which also holds per-repo overrides in `local/`, approvals in `trust/` and the blocklist cache in `subscriptions/`. - **Does not follow `CPLT_CONFIG` ([#830](https://github.com/navikt/cplt/issues/830)):** runtime state such as launch fingerprints, update staging and one-off markers. These live in `$XDG_CONFIG_HOME/cplt` or `~/.config/cplt`, depending on the file and on which copy already exists. A scratch `HOME` and `XDG_CONFIG_HOME` cover both. `cplt config init` writes a commented starter template to `config.toml`. It covers `[proxy]`, `[proxy.subscriptions]`, `[allow]`, `[deny]`, `[sandbox]`, `[gh_guard]`, and `[git_guard]`, with every key documented inline. Two keys are set: `proxy.forced = true` ([#822](https://github.com/navikt/cplt/issues/822)), left commented out on Linux below Landlock ABI 4, where forced mode cannot launch, and `sandbox.strip_otlp_vendor_auth = true` ([#878](https://github.com/navikt/cplt/issues/878)). Every other key is commented out. An existing config is never changed: without the keys both stay off, and `cplt doctor` suggests them. Run it and read the result rather than copying a snippet from here, since the template is generated from `src/config/path.rs` and moves with the code: ```bash cplt config init cplt config explain # every key, its type, default, and what it does cplt config explain proxy.forced ``` For arrays of objects, multi-line values, and other complex configuration, edit the file directly and run `cplt config validate` afterwards. **Dotted keys:** a dotted key belongs to whatever section header precedes it. `allow.read = [...]` is valid on its own — above every header, or written as `read = [...]` under `[allow]` — but the same line below `[git_guard]` means `git_guard.allow.read`. cplt then sees an unknown key and ignores it, with a warning at launch and an error from `cplt config validate`, so the grant never takes effect. ## GitHub MCP in OpenCode (`sandbox.github_mcp_proxy`) GitHub's remote MCP server (`https://api.githubcopilot.com/mcp`) needs a GitHub login, and OpenCode cannot get one inside cplt: GitHub's OAuth has no dynamic client registration, and cplt strips `GH_TOKEN` for OpenCode. With OpenCode v2, cplt fixes this with no setup. cplt looks for an entry whose URL is `https://api.githubcopilot.com/mcp` or `/mcp/readonly`, under `mcp.servers` or the older `mcp.`. It only looks in config you control: `opencode.json(c)` and `config.json` in your OpenCode config dir (where `nav-pilot mcp enable --client opencode` writes), plus `OPENCODE_CONFIG_CONTENT` when it reaches the sandbox (`--pass-env` or `--inherit-env`). A project's `opencode.json` never turns it on, because the agent can write it. When cplt finds an entry, it: 1. gets your gh token on the host (`gh auth token`, as for the gh guard); 2. serves an endpoint at a random secret path on its proxy port. No new port is opened to the sandbox; 3. sets `OPENCODE_CONFIG_CONTENT` so that entry points at the endpoint with `"oauth": false`. An `OPENCODE_CONFIG_CONTENT` that reaches the sandbox is merged, not replaced. The endpoint sends every request to `https://api.githubcopilot.com/mcp/readonly`, whatever URL the entry names. It adds the token and forces `X-MCP-Readonly: true` and `X-MCP-Lockdown: true`. Any `Authorization` the entry sets is replaced, and `Cookie` and other `X-MCP-*` headers from the agent are dropped; `X-MCP-Toolsets` passes through. A header with a control character is refused. The token stays on the host. Streaming responses pass through as they arrive. Requests are logged like proxy requests (`[github-mcp]` lines, and `--proxy-log`). Without a gh login, or with the proxy off (`--no-proxy`), cplt leaves the entry alone and prints one line. OpenCode v1 entries are left alone. Turn it off with `sandbox.github_mcp_proxy = false`, or for one run with `--no-github-mcp-proxy`. `deny.env` naming `GH_TOKEN` or `OPENCODE_CONFIG_CONTENT` turns it off too. ## Brokered MCP servers (`sandbox.mcp_broker`) Some local MCP servers need a host login the sandbox does not grant. `nais alpha mcp serve`, for one, reads the macOS login keychain. For each such server, nav-pilot writes a broker spec to `~/.config/cplt/mcp/` (or `$XDG_CONFIG_HOME/cplt/mcp/`): the server's `server.json` plus a `_meta["io.github.navikt/broker"]` block. On macOS cplt then runs the server on the host, outside the sandbox, and points your client's entry at `cplt mcp-stdio `, which connects to it over a per-session socket. - The server gets a cleared environment. Its traffic goes through a proxy of its own that allows only the spec's `egressHosts` on port 443. The agent's allowlist does not change. - Only the tools in the spec's `tools.allow` are listed or callable. For `execute_graphql`, cplt refuses mutations, subscriptions and documents with more than one operation. - Copilot CLI gets `--additional-mcp-config` with an entry named exactly the server id (`io.github.nais/nais-mcp`), so a "Registry only" MCP policy still allows it, and `--disable-mcp-server` for your own entries that run the same command under another name. An entry of yours with exactly the id as its name is not disabled (that would turn off cplt's too); cplt relies on `--additional-mcp-config` taking precedence over it, which Copilot does not document. No automated test covers that precedence, since it needs a logged-in Copilot CLI. OpenCode v2 gets an overlay in `OPENCODE_CONFIG_CONTENT`. Your config files are not changed. - Broker DENIED and FAIL lines (refused calls and egress, login failures) reach the terminal at every `--proxy-log-level`, `none` included (D12). Other broker lines print only at `all`. The `--proxy-log` file gets them all. That file is append-only and never rotated. - A spec cplt refuses prints one line at launch, and the entry stays as configured. `cplt mcp status --json` lists the specs, their state and the reasons for any refusal, checked as a launch from the current project would. It also works outside a project (from `~`, say), checking against an empty project and reporting `"project": null`. A probe that times out reports `"credential_ok": null` with `"credential_note": "timed out"`. With no spec files nothing changes. Turn the broker off with `sandbox.mcp_broker = false`, or turn off one server for a run with `--no-mcp-broker `. On Linux the broker runs only under Bubblewrap with the D-Bus and systemd sockets masked, since without those masks the agent can read the server's credential from the Secret Service. Where Bubblewrap is not usable, cplt says so at launch and leaves the entries as configured. ## GitHub token injection (`proxy.inject_github_token`) On for new installs, off for existing ones. `cplt config init` writes `inject_github_token = true`; a config file without the key keeps the old behaviour, and `cplt doctor` and `cplt check` suggest turning it on while the proxy is on. Set the key to `true` or `false` to choose and to silence that note. `--inject-github-token` and `--no-inject-github-token` override it for one run. With it on, the agent gets a placeholder instead of your GitHub token, and cplt's proxy puts the real token back on the way out. A token read inside the sandbox is then useless anywhere else. - The placeholder has the same prefix and length as the real token (`gho_…`, `github_pat_…`). It replaces the token in `GH_TOKEN`, `GITHUB_TOKEN`, `COPILOT_GITHUB_TOKEN`, `OPENCODE_AUTH_CONTENT`, any other variable that contains a `gh?_`/`github_pat_` token, and the gh guard's token files. A token without one of those prefixes is passed unmasked. - For `api.github.com`, `github.com`, `uploads.github.com` and `*.githubcopilot.com` on port 443, the proxy terminates TLS with a CA created in memory for this session. It swaps the placeholder for the real token in the `Authorization` header (`Bearer`, `token`, or `Basic` where the password is the placeholder) and connects upstream itself. Every other host keeps the plain tunnel. - The token is never added on the GitHub MCP paths (`/mcp…` on the Copilot hosts), which `sandbox.github_mcp_proxy` handles, or on `TRACE`. - The CA certificate goes into bundles in the scratch dir. `SSL_CERT_FILE` and `GIT_SSL_CAINFO` point at the system roots plus any CA already named in `SSL_CERT_FILE`, `NODE_EXTRA_CA_CERTS` or `GIT_SSL_CAINFO`, plus cplt's CA. `NODE_EXTRA_CA_CERTS` gets your CA plus cplt's. A corporate CA keeps working. It needs the proxy and the scratch dir. If a step fails (`--no-proxy`, no scratch dir, a CA variable in `deny.env` or pointing into a tree the agent can write, an unreadable CA file, no system CA bundle), cplt refuses the launch and says what to fix. It never falls back to handing over the real token. Use `--no-inject-github-token` for one run, or set the key to `false`. What breaks with it on: every host other than the ones above gets the placeholder. A tool in the sandbox that sends the same token to GitHub Packages (`npm.pkg.github.com`, `maven.pkg.github.com`), `ghcr.io` or a GitHub Enterprise host gets `401`. A client that ignores the CA variables fails TLS to GitHub; Copilot reports that as "not authenticated". Copilot, `gh` and `git` honour them on macOS and Linux, including Copilot's Linux login through `gh_guard.inject_token`. Fix: set `proxy.inject_github_token = false`. Not covered yet: gh's `hosts.yml` stays readable, so a token stored there (Linux without a keyring, or insecure storage) is still reachable; and the agent can still use the token through the proxy against the GitHub hosts above. ## Agent sandbox brief (`sandbox.brief`, `sandbox.agents_md`) > `sandbox.agents_md` and its `--agents-md` / `--no-agents-md` flags are > **EXPERIMENTAL**: their output may change or be removed. Don't build tooling > on the AGENTS.md block's markers. An agent inside the sandbox has no way of knowing it is sandboxed: it hits `EPERM`, assumes a bug, and retries. cplt can hand it the answer up front, in two layers. Running `cplt check` from outside the sandbox reports the same policy either way. **`sandbox.brief`** — on in configs written by `cplt config init` (#967); a config without the key keeps it off, and `cplt doctor` suggests setting it either way. It writes two files into the per-session scratch directory (the one `$TMPDIR` points at inside the sandbox): | File | For | Named by | | --- | --- | --- | | `CPLT_BRIEF.md` | the agent to read | `$CPLT_BRIEF` | | `CPLT_BRIEF.json` | a tool to parse | `$CPLT_BRIEF_JSON` | Both are rendered from the resolved policy for *that* launch — the repositories in scope, network mode, `.env` handling, credential denies — and disappear with the scratch dir when the session ends. Neither touches your project. The two environment variables are set only when the files exist, so an agent can test for `$CPLT_BRIEF` rather than guessing a path, and a variable never names a file that is not there. That means neither is set with `sandbox.brief = false`, and neither is set with `--no-scratch-dir` (or `sandbox.scratch_dir = false`) either: the brief lives in the scratch dir and nowhere else, so cplt warns and writes nothing. The JSON carries a little more than the prose: the `allow.read` / `allow.write` / `allow.exec` path lists, the port numbers, and every repository in scope even for a single-repository session, where the prose omits the section because it would restate what the rest of the brief assumes. Both come from one captured set of facts, so they cannot describe different sessions. Turn it on for one run with `--brief`, or for good with `cplt config set sandbox.brief true`. A file the agent never opens does nothing, so cplt also loads the brief through the agent's own instruction channel. Each value is extended, never replaced: your own or nav-pilot's settings stay. | Agent | How the brief is loaded | | --- | --- | | Copilot | `COPILOT_CUSTOM_INSTRUCTIONS_DIRS` gets the scratch dir's `cplt-instructions/`, which holds a copy at `.github/instructions/cplt-sandbox.instructions.md` | | OpenCode (v1 and v2) | `OPENCODE_CONFIG_CONTENT` gets `CPLT_BRIEF.md` added to its `instructions` list | | Claude Code, Antigravity, Pi, Goose, dsh, shell | **Gap:** not loaded. cplt knows no channel it can extend without rewriting your arguments or files. The agent sees `$CPLT_BRIEF` only if something tells it to read it, for example a line in your own `CLAUDE.md` | If `deny.env` names the variable, or an existing `OPENCODE_CONFIG_CONTENT` is not a JSON object, cplt warns at launch and leaves the variable as it was. There is nothing about the brief that only works for some projects, so if you want your agents to have it, turn it on in your global config. Turning it on is also what makes the `## Repositories` section reach the agent: the list of what a multi-repository session actually spans exists only here. **`sandbox.agents_md` (default `false`)** — additionally injects a managed block into `AGENTS.md` at the repository root (the git toplevel, even when `--project-dir` names a subdirectory; a linked worktree or submodule is its own root), creating the file if it does not exist. When that file sits outside `--project-dir`, the sandbox grants read, and only read, on that one file so the agent can see the block; nothing else in the root becomes visible. The grant is dropped when the block could not be written, and a link or non-regular file at that path is never granted. `cplt exec` never writes the block and gets no grant. Under bubblewrap on Linux, a repository root under `/tmp` gets no grant either: the sandbox's `/tmp` is private, and the file is left out of it rather than bound in before cplt writes it. This writes into your repository, so it is a second opt-in on top of the first: - The block is delimited by `` / `` markers. Re-runs replace it in place; content outside the markers is never touched. - It contains no policy detail from your machine — the same generic text for every repo, safe to commit. - It is written only on an actual agent launch, after the confirmation prompt. `--print-profile`, `cplt check`, `cplt exec` and a declined prompt leave the repo untouched. - Skipped outside a git work tree, and skipped with a warning if the file somehow ends up with more than one marker pair. - It requires `sandbox.brief` as well. With the brief off, the AGENTS.md block is never written, whatever `agents_md` says. Turn both on for one run, or globally: ```bash cplt --brief --agents-md # this launch only cplt config set sandbox.brief true # every launch cplt config set sandbox.agents_md true ``` And back off again for a single run, without editing the config: ```bash cplt --no-brief # no CPLT_BRIEF.md, and no AGENTS.md block either cplt --no-agents-md # keep the brief, leave the repository alone ``` The flag always beats the config key, in both directions. `--no-brief` covers both layers because the AGENTS.md block is gated on the brief. Both are global-only keys — a repository cannot ask cplt to write into its own `AGENTS.md`. ## Per-repo configuration (`.cplt.toml`) ### Keys this cplt does not know A `.cplt.toml` may use keys a newer cplt understands. They are **ignored and reported**, not treated as errors, so a repository can adopt a key before every developer has upgraded: ``` [cplt] .cplt.toml uses a key this version of cplt does not understand (propose.allow_future_thing), and it is ignored. That is usually a newer cplt writing the file than the one reading it: upgrade, or check the spelling if you wrote them by hand. ``` The two sections fail in opposite directions, and the warning says which: - An unknown key under `[propose]` grants nothing, so ignoring it **fails closed**. This is also why a repository cannot smuggle in a key like `preset` that a future version might honour: an unknown key does nothing here. - An unknown key under `[deny]` is a restriction its author expected and this version will not apply. The warning calls that out explicitly, because nothing else in the session will mention it. Everything the version *does* understand still applies. Before this, one unknown key failed the whole parse — the launch repository lost its `[deny]` along with it, and a named repository stopped the launch. Malformed TOML is a different thing and still an error: it is not version skew, and there is nothing to salvage from it. An unknown key does not invalidate an existing trust approval, because it proposes nothing. When you upgrade and the key becomes real, it starts counting as a new proposed key and you are asked to review it — approval tracks what the file can actually do on your machine. Commit a `.cplt.toml` to your repository for project-specific sandbox settings, so every developer does not have to configure the same CLI flags or global config. Global config stays the default, so pass `--repo` explicitly for project settings: ```bash # Request sandbox permissions (requires approval via `cplt trust accept`) cplt config set --repo sandbox.allow_jvm_attach true cplt config set --repo sandbox.allow_localhost_any true cplt config set --repo allow.read "~/.gradle/gradle.properties" cplt config set --repo allow.ports 8080 # Deny section: tightens security, applied immediately without approval cplt config set --repo deny.paths "~/secrets" cplt config set --repo deny.env "VAULT_TOKEN" # Remove a permission request cplt config set --repo sandbox.allow_jvm_attach --unset ``` Settings map to sections automatically. Permission requests go under `[propose]`, restrictions under `[deny]`. Machine-specific keys such as `sandbox.quiet` and `proxy.port` are rejected with a clear explanation. ### Security model - `[deny]` applies automatically and needs no approval, because it can only tighten the sandbox. - `[propose]` holds requested permissions and requires explicit approval via `cplt trust accept`. Approvals are additive: they can enable a feature such as `allow_docker = true`, never disable something CLI or global config set. - cplt reads the file from `git HEAD`, the committed state, so the agent cannot tamper with its own config mid-session. - An **uncommitted** `.cplt.toml` — untracked, gitignored, or edited but not committed — grants nothing, on both platforms, and the launch says so. Its `[deny]` keys still apply, and so does a `[propose]` key that only tightens (`gh_guard = true`, `git_push_prevention = true`), because those are `[deny]`-shaped and need no approval anywhere. A restriction needs no audit trail, and dropping it would remove something its author believed was in force; a relaxation needs one, and the commit is it. `--accept-repo-config` does not change this: it skips the prompt, not the commit. - Writing `.cplt.toml` is kernel-denied inside the sandbox. - Trust approvals are content-pinned. If the requested values change, the approvals are invalidated. Paths in `[deny]` and `[propose.allow]` support `~/` expansion. A relative path resolves against the repository root, the directory the `.cplt.toml` came from, which under `--project-dir ` is still the git root rather than the subdir. So `paths = ["secrets"]` denies `/secrets` on every clone. `./secrets`, `secrets/.`, `secrets/`, and `a//b` all normalize to the same path, and symlinks resolve to their target. The sandbox matches resolved paths, so the un-normalized spellings would compile into the profile and silently match nothing. Resolution walks up to the deepest part of the path that exists, so a symlink still resolves when the leaf below it does not exist yet: `link/secret` with `link -> real` becomes `/real/secret`. `..` components are rejected, as are entries naming a whole tree root: `""`, `"."`, `"./"` (the repo root) and `"/"` (the filesystem root). As a deny entry, any of those would block read and write of the entire checkout, so a committed one would brick the repo for everyone who clones it. Globs are not expanded. Each entry is one path, so `**/*.pem` denies only a file or directory with that literal name, and no `.pem` file in the repo. cplt keeps such an entry, because `*`, `?`, `[` and `{` are legal in a file name, but it warns at launch, in `cplt config show`, in `cplt trust`, and after `cplt config set --repo`. An entry is not reported when a file or directory with exactly that literal name already exists, because then it is a working deny. Name each file or directory instead: `certs/server.pem`, or `certs` to deny the whole directory. Approved `[propose.allow]` paths must stay inside the repo. A trust approval pins a hash of the `.cplt.toml` bytes, so it only vouches for what those bytes name. A relative entry names a location in the repo. If a symlink resolves it somewhere outside, the entry is refused with a warning rather than granted. Otherwise a repo could get `read = ["data"]` approved while `data -> ./safe`, then repoint `data -> /` in a later commit with `.cplt.toml` unchanged, hash unchanged, and the approval still live. Absolute and `~/` entries are exempt, because they are written literally in the file and the pinned hash does cover them. `[deny]` has no such restriction, since it needs no approval and can only tighten. Unlike global config, a repo path that does not exist yet is kept rather than treated as an error. macOS starts enforcing the deny once the directory appears, and one committed typo cannot brick cplt for everyone who clones the repo. On Linux such a path cannot be masked, and cplt warns. ### Example `.cplt.toml` ```toml # Deny section: applied automatically, no approval needed [deny] paths = ["~/secrets", "~/.vault"] env = ["VAULT_TOKEN", "MY_SECRET"] # Requested permissions: requires approval via `cplt trust accept` [propose] allow_jvm_attach = true # For MockK/Mockito tests allow_docker = true # Container access allow_localhost_any = true # Dev servers on any port [propose.allow] read = ["~/.gradle/gradle.properties"] ports = [8080, 5432] localhost = [5432] [propose.proxy] allow_private_domains = ["intern.nav.no"] ``` ### Trust management ```bash cplt trust # Show permissions and approval status cplt trust show --json # The same, for scripts and tools cplt trust accept allow_jvm_attach # Approve specific keys cplt trust accept --all # Approve everything cplt trust revoke allow_docker # Revoke a specific key cplt trust revoke --all # Revoke all trust for this repo ``` Trust decisions live in `~/.config/cplt/trust/`, protected from the sandbox. An approval covers the repository it was granted in, so every `git worktree` of it is approved too. A worktree on a branch that changed `[propose]` is not: the approval is pinned to the values you reviewed, and different values need a fresh `cplt trust accept`. A separate clone is a separate repository and approves on its own, even when its `.cplt.toml` is identical. `cplt trust`, `cplt trust show --json` and `cplt config show` use the rule the launch uses: a key counts as approved only when the approval matches the current values and was granted in this repository. A proposal that keeps its keys but changes a value is shown as changed, not approved. #### Reading trust state from a script `cplt trust show --json` prints one JSON object for the repository you are in: ```json { "version": 1, "state": "changed", "project_dir": "/Users/you/src/app", "content_hash": "2deff3a5…", "drifted": false, "message": ".cplt.toml permissions changed since the last approval.", "proposed": [ { "key": "allow_localhost_any", "detail": "true", "effect": "reaches any local listener; on Linux also drops kernel TCP filtering for remote hosts. Ignored under proxy.forced", "approved": false } ], "command": "cplt trust accept" } ``` | Field | Meaning | |---|---| | `version` | Shape version. Refuse a version you do not know. New fields can appear without a version change, so ignore fields you do not know. | | `state` | `none` (no `.cplt.toml`, or nothing to approve), `approved`, `pending` (never approved, or only some keys), `changed` (values changed since the approval; only changed or new keys are pending, unchanged ones stay approved), `foreign` (approved in a different repository), `outlived` (an approval, but nothing left to approve), `uncommitted` (the file's proposals are ignored until committed), `invalid` (the file does not load) | | `content_hash` | Hash of the committed `[propose]` section. The trust entry also stores one hash per approved key, and a key stays approved only while its own value is unchanged. `null` when there is no committed `.cplt.toml` to approve: no file, an invalid one, or one that exists only in the working tree (the launch ignores its proposals). | | `drifted` | `true` when `.cplt.toml` is committed and the working-tree copy differs from `HEAD:.cplt.toml`, as `git diff HEAD` sees it (a line-ending filter alone is not a difference). The verdict describes the committed copy, so it does not cover the bytes on disk, and the launch warns that local edits are not in effect. `false` otherwise: the working tree matches HEAD, there is no working-tree copy, or there is no committed copy to differ from (no file, or one that is untracked or staged but not committed, which `state` reports as `uncommitted`). | | `message` | One line, worded as the launch warning. | | `proposed` | Every key that needs approval: its value (`detail`), what approving it costs (`effect`), and whether the launch grants it (`approved`). | | `command` | What to run to approve, or `null` when there is nothing to approve or `cplt trust accept` would refuse. | Print `effect` as given rather than writing your own cost text. The command exits 0 whenever it prints the object. Only the launch repository has trust state: a `--repo-dir` root contributes its `[deny]` and never proposes. In CI and scripts, where interactive approval is not possible: ```bash cplt --accept-repo-config -- -p "run tests" ``` ### Auto-generate with `cplt init` Instead of writing `.cplt.toml` by hand, let cplt detect your project's ecosystem: ```bash cplt init # Preview detected permissions cplt init --write # Write .cplt.toml to disk cplt init --write --force # Overwrite existing file cplt init --quiet # Output only TOML (pipe-friendly) ``` Supported ecosystems: | Ecosystem | Detected via | Suggests | |-----------|-------------|----------| | JVM (Gradle/Maven) | `build.gradle*`, `pom.xml` | `allow_jvm_attach`, read gradle properties; for Gradle also `allow_localhost_any` (the daemon listens on a random loopback port) | | Node.js | `package.json` | localhost ports, `allow_localhost_any` (for Next.js/Vite) | | Docker | `Dockerfile`, `compose.yml` | `allow_docker` (dangerous) and published compose ports as `allow.localhost`, for a compose file only. A bare `Dockerfile` gets a diagnostic instead: CI builds the image | | Python | `pyproject.toml`, `requirements.txt` | localhost ports (for Django/FastAPI) | | Rust | `Cargo.toml` | (works with defaults) | | Go | `go.mod` | `allow_localhost_any` when a `_test.go` file imports `net/http/httptest` (its test servers listen on a random loopback port); otherwise nothing | | Playwright | `@playwright/test` or `"playwright"` in package.json | `allow_cache_exec` (personal config hint) | | Environment secrets | `.env.example` | `deny.env` for sensitive variables | | Spring Boot | `application.yml` + Spring in Gradle | localhost 8080, and localhost 5432 when a datasource is configured | | Ktor | `application.conf` + Ktor in Gradle | localhost 8080 | | TestContainers | `testcontainers` in `build.gradle*`, `gradle/libs.versions.toml`, `pom.xml`, `package.json`, `go.mod`, `pyproject.toml` or `requirements.txt` | `allow_docker` (dangerous), `allow_localhost_any` | | Next.js | `next.config.ts/js/mjs` | localhost 3000, `allow_localhost_any` | | Vite | `vite.config.ts/js/mjs` | localhost 5173, `allow_localhost_any` | | Flyway | `src/main/resources/db/migration` or `.../migrations` | localhost 5432 | | mise | a `tools` table in `mise.toml`, `.mise.toml`, `mise.local.toml`, `.mise.local.toml`, `.config/mise.toml`, `.config/mise/config.toml`, `mise/config.toml` or `.mise/config.toml` | nothing; a diagnostic to run `mise install` outside cplt | | Cypress | `cypress.config.ts/js/mjs` + `cypress/` dir | `allow_localhost_any` (repository proposal); `allow_cache_exec` (personal config hint from `cplt init --global`) | Machine-specific suggestions such as `allow_cache_exec` or home-relative read paths come out as comments pointing you to add them to your personal `~/.config/cplt/config.toml`. The Playwright suggestion enables cache execution and, with it, turns off Chromium's nested sandbox, which cannot run inside cplt. Playwright MCP is handled through `PLAYWRIGHT_MCP_SANDBOX`; any other Chromium launcher needs `--no-sandbox` itself. See [Known impacts](known-impacts.md#cache-exec-playwright-pnpm-dlx-etc). Permissions marked dangerous above get risk warnings in the generated TOML. `allow_lifecycle_scripts` is never auto-suggested. If lifecycle scripts are detected it only shows up as a diagnostic, because it allows arbitrary code execution on `npm install`. #### Personal config with `cplt init --global` Scan your machine for installed tools and generate `~/.config/cplt/config.toml`: ```bash cplt init --global # Preview personal config suggestions cplt init --global --write # Write to ~/.config/cplt/config.toml ``` It detects: | Tool | Probes | Suggests | |------|--------|----------| | Playwright browsers | `~/Library/Caches/ms-playwright/` (macOS) or `~/.cache/ms-playwright/` (Linux) | `allow_cache_exec = ["ms-playwright"]` | | Cypress | `~/Library/Caches/Cypress/` (macOS) or `~/.cache/Cypress/` (Linux; `XDG_CACHE_HOME` is ignored) | `allow_cache_exec = ["Cypress"]` | | GPG signing | `~/.gnupg/`, plus `commit.gpgsign` from global git config for the reason text | `allow_gpg_signing = true` | | Gradle registry credentials | `~/.gradle/gradle.properties` mentioning `repository`, `nexus`, or `artifactory` | `allow.read` for that file | | npm registry credentials | `~/.npmrc` with a `registry` or `_authToken` line | `allow.read` for that file | | Maven repository settings | `~/.m2/settings.xml` with a ``, ``, or `` | `allow.read` for that file | | Default agent | `copilot`, `opencode`, `aider`, `antigravity`, `claude` in PATH | `agent = "..."`, but only when exactly one is found and it is not `copilot` |