This document describes the security architecture of cplt, the threat model it addresses, the defense layers it implements, and how they are validated through automated testing.
cplt sandboxes AI coding agents — currently GitHub Copilot CLI, OpenCode, Google Gemini CLI, and Pi. All share the same core sandbox infrastructure (deny-default Seatbelt/Landlock profile, env sanitization, scratch dir), with agent-specific adaptations:
| Property | Copilot | OpenCode | Gemini | Pi |
|---|---|---|---|---|
| Auth mechanism | GitHub token (Keychain, GH_TOKEN) |
/connect device flow → auth.json, or API keys |
Google OAuth (browser) or API key | API keys (Anthropic, OpenAI, Gemini, etc.) |
| Auth in sandbox | Token auto-passed via env allowlist | Copilot auth stored in data dir; third-party keys need --pass-env |
OAuth stored in ~/.gemini/; key needs --pass-env |
Keys need --pass-env |
| Config dir | ~/.copilot (read/write) |
~/.config/opencode (read-only) |
~/.gemini (read/write) |
~/.pi (read/write) |
| Data dir | ~/Library/Caches/copilot |
~/.local/share/opencode (write, no exec) |
N/A (in config dir) | ~/.pi/agent/bin (read + exec) |
| State data dir | N/A | ~/.local/state/opencode (write, no exec) |
N/A | N/A |
| Keychain access | Yes (required for token storage) | No | Yes (extension integrity) | No |
| SEA extraction | Yes (pre-sandbox) | No | No | No |
| Env isolation | GH_TOKEN, COPILOT_* passed |
GH_TOKEN, COPILOT_* suppressed |
GH_TOKEN, COPILOT_* suppressed |
GH_TOKEN, COPILOT_* suppressed |
| Auto-detected | Yes (priority 1) | Yes (priority 2) | Yes (priority 3) | No (explicit only — name collision risk) |
- Copilot provider support: OpenCode can authenticate with your GitHub Copilot subscription via
/connectdevice flow. The token is stored in~/.local/share/opencode/auth.json— no environment variables needed. This works out of the box in the sandbox. - Third-party API keys are opt-in:
ANTHROPIC_API_KEY,OPENAI_API_KEY, and other provider keys are never passed through by default. Users must explicitly use--pass-envfor each key. This prevents accidental exposure of credentials to a sandboxed process. - Data dir is write+no-exec:
~/.local/share/opencode/(sessions, auth, SQLite DB) is writable but has both(deny process-exec)and(deny file-map-executable)to prevent write+exec persistence attacks. - State data dir is write+no-exec:
~/.local/state/opencode/(locks, history, statistics) is writable but has both(deny process-exec)and(deny file-map-executable)to prevent write+exec persistence attacks. - Config dir is read-only:
~/.config/opencode/opencode.jsonand related config are readable but not writable, preventing config tampering across unsandboxed runs. - Copilot env vars isolated:
GH_TOKEN,GITHUB_TOKEN,COPILOT_GITHUB_TOKEN, and allCOPILOT_*env vars are suppressed for non-Copilot agents. OpenCode's Copilot provider uses its own auth file instead.
- OAuth browser flow: Gemini uses Google OAuth by default, requiring
--allow-browserfor first-time login. Auth tokens are stored in~/.gemini/. - API key alternative:
GEMINI_API_KEYorGOOGLE_CLOUD_PROJECTcan be used instead of OAuth — must be passed via--pass-env. - Keychain access enabled: Gemini uses macOS Keychain for extension integrity verification.
- Config dir is read/write:
~/.gemini/stores auth, settings, sessions, and agents.
- Not auto-detected: the
pibinary name is generic and may collide with other tools. Pi must be explicitly selected via--agent piorsandbox.agent = "pi"in config. - API keys are opt-in:
ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY,OPENROUTER_API_KEYare never passed through by default. Users must explicitly use--pass-envfor each key. - Config dir is read/write:
~/.pi/stores settings, auth, sessions, and themes. - Managed binaries have exec:
~/.pi/agent/bin/(bundledfd,rg) has process-exec permission with an explicit write deny (prevents write+exec persistence even though the parent~/.pi/is writable). - Copilot env vars isolated:
GH_TOKEN,GITHUB_TOKEN, andCOPILOT_*are suppressed for Pi.
cplt assumes the sandboxed agent is untrusted — executing arbitrary code suggestions on your machine. The threat model covers:
| Threat | Example | Defense layer |
|---|---|---|
| Credential theft | Read ~/.ssh/id_ed25519, ~/.aws/credentials |
Seatbelt deny rules (macOS) / Landlock deny (Linux) |
| Data exfiltration | POST secrets to https://evil.com/collect |
Filesystem isolation (credentials unreadable) |
| Secret file access | Read ~/.netrc, ~/.npmrc, ~/.vault-token |
Seatbelt deny rules (macOS) / Landlock deny (Linux) |
| DNS rebinding SSRF | Domain resolves to 127.0.0.1 after check |
Post-DNS-resolution IP validation; --allow-private-domain opt-in bypass for explicitly trusted internal domains |
| Sandbox profile injection | Path with \n(allow file-read* (subpath "/")) |
SBPL path character validation (macOS) |
| Temp file symlink attack | Symlink at predictable /tmp/cplt.sb |
Unique filename + O_CREAT|O_EXCL |
| Write-then-exec in /tmp | Drop binary in /tmp, execute it |
Seatbelt deny (macOS) / Landlock deny (Linux); --scratch-dir provides safe alternative |
| Cloud metadata access | Fetch 169.254.169.254 or CGNAT range |
Comprehensive private IP blocklist |
| Cross-project access | Read files outside project directory | Seatbelt subpath (macOS) / Landlock ruleset (Linux) |
| Process-group escape | Kill parent, children continue unsandboxed | Signal forwarding (SIGTERM, SIGHUP) |
| Env var credential theft | Read AWS_SECRET_ACCESS_KEY from env |
env_clear() + safe allowlist |
| Persistence via native modules | Replace keytar.node with malware |
Deny writes to ~/.copilot/pkg |
| Git hook injection | Write post-checkout hook that runs outside sandbox | Seatbelt deny rules (macOS); env hardening GIT_CONFIG_NOSYSTEM + proxy exfiltration blocking (Linux — project-internal .git/hooks is writable) |
| Git config hijacking | Set core.hooksPath=/tmp/evil or URL redirect |
Seatbelt deny rules (macOS); env hardening (Linux — .git/config writable within project, but proxy blocks redirected fetches) |
| Submodule supply chain | Modify .gitmodules to point to malicious repo |
Seatbelt deny rules (macOS); proxy domain filtering (Linux — .gitmodules writable within project) |
| Syscall abuse | ptrace, mount, kexec_load |
seccomp-BPF filter (Linux) |
| Protection | macOS (Seatbelt) | Linux (Landlock + seccomp) |
|---|---|---|
| Credential files (~/.ssh, ~/.aws) | ✅ Kernel deny | ✅ Not in ruleset (deny-by-default) |
| Project .env file read/write/delete | ✅ Kernel deny | |
| .git/hooks write in project | ✅ Kernel deny | |
| .git/config write in project | ✅ Kernel deny | |
| Network: outbound port filtering | ✅ Kernel (all versions) | ✅ Kernel (6.7+) / |
| Network: localhost isolation | ✅ Kernel deny | |
| Exec from /tmp | ✅ Kernel deny | ✅ Landlock deny |
| Dangerous syscalls | N/A (Seatbelt covers) | ✅ seccomp-BPF |
| --deny-path | ✅ Kernel deny | ❌ No effect (warned) |
Legend: ✅ = kernel-enforced,
- TLS interception — the proxy sees CONNECT targets (hostname:port) but not request bodies or responses
- Kernel exploits — we rely on Apple's Seatbelt (macOS) and Landlock/seccomp (Linux) enforcement being correct
- Keychain isolation (macOS) — Copilot requires Keychain access for auth; this is an accepted trade-off.
mach-lookupis blanket because Node.js needs it for DNS, Security framework, and system services. - sandbox-exec deprecation (macOS) — Apple marks it deprecated but has not removed it; Chromium and VS Code still use it
- Landlock subpath limitations (Linux) — Landlock cannot deny access to subpaths within allowed directories. If a parent directory is allowed, all children are allowed. This means certain fine-grained macOS rules (e.g., deny
.config/gh/extensionswhile allowing.config/gh/hosts.yml) cannot be replicated on Linux. - Code quality — the sandbox cannot judge whether code written by Copilot contains backdoors; that's a code review problem
~/.config/gh/hosts.ymltoken — contains the user's GitHub OAuth token. Copilot needs a GitHub token to function (via env var or this file). The token is readable inside the sandbox. If this is a concern, setGH_TOKENenv var (passes through allowlist) and add--deny-path ~/.config/ghto block the file.- Interpreter-based temp execution — the sandbox blocks direct exec from
/tmp(Mach-O/ELF binaries, dlopen), but cannot blockbash /tmp/evil.shornode /tmp/evil.jsbecause the exec target is the interpreter (/bin/bash,/usr/bin/node), not the script file. Sandboxing interpreters would break Copilot. .vscode/project configs — the agent can write.vscode/tasks.json,launch.json, andsettings.jsonwhich VS Code may auto-execute outside the sandbox. This is an IDE trust boundary issue, not a sandbox scope issue. Mitigation: review.vscode/changes ingit diffbefore committing; set"task.autoRunTasks": "off"in VS Code.- Write+exec in home cache dirs —
~/.gradle,~/.m2,~/Library/pnpm(macOS) /~/.local/share/pnpm(Linux) have both write and exec permissions. Build tools need write for dependency downloads and exec for build plugins. A rogue agent could write a malicious JAR to~/.m2or a Gradle plugin to~/.gradle, but the executed code would still be sandboxed.~/Library/Caches(macOS) is broadly allowed for dev tool caches (go-build, Homebrew, pip, etc.), but browser and app caches (Chrome, Firefox, Discord, etc.) are denied via regex prefix rules — no allowlist maintenance needed for new dev tools.--allow-cache-exec <SUBDIR>opts into the same write+exec trade-off for the named subdir (e.g.ms-playwright,pnpm/dlx): a rogue agent could write a binary there and execute it, but execution remains sandboxed. Prefer--allow-cache-exec <SUBDIR>over--allow-cache-exec-any, which opens the entire~/Library/Cachestree. - Playwright Chromium runtime (macOS,
allow_cache_exec = ["ms-playwright"]) — when browser testing is enabled, the sandbox grants elevated system permissions beyond the normalprocess-execandfile-map-executablerules:(allow syscall*)(all syscalls including Mach traps — Chromium uses undocumented traps that vary by macOS version and cannot be individually enumerated in a stable allowlist),(allow system-socket (socket-domain AF_UNIX))(Unix domain sockets for IPC between browser, renderer, and GPU processes),(allow iokit-open-user-client)(GPU capability probing — unscoped because IOKit class names are hardware-dependent), and(allow mach-register)scoped to^org\.chromium\..+$(Crashpad and inter-process IPC — anchored with$and requires at least one character after the prefix). Unix socket operations for Chrome'sSingletonSocketuse[^/]+/[^/]+for thevar/folderspath segments to prevent matching across directory boundaries. All filesystem, network, and credential denies remain enforced independently. These rules activate whenallow_cache_execcontains"ms-playwright"or any subpath like"ms-playwright/chromium-1217"(first path component match) —allow_cache_exec_anydoes not trigger them. Without these rules,chrome-headless-shellsegfaults (SEGV_ACCERR) during browser initialization. Linux parity: Landlock + seccomp-bpf may need corresponding relaxation if Playwright support is extended to Linux sandboxing (seccomp would need to allow the same Mach-trap-equivalent syscalls that Chromium requires). - Project build scripts — the agent can modify
Makefile,package.jsonscripts,build.gradle,.github/workflows/, etc. These are legitimate Copilot targets and cannot be blocked. The risk is mitigated by code review (git diff) before running builds or committing. - POSIX shared memory (macOS) —
ipc-posix-shm-*is allowed because Node.js needs it for DNS and system queries. An agent could theoretically use SHM as an IPC channel to processes outside the sandbox, but this requires a cooperating process already running on the machine. - DNS tunneling — DNS queries are unrestricted on both platforms. Bandwidth is ~15 KB/s max, requires attacker-controlled authoritative DNS, and is detectable with network monitoring.
This section documents the attack vectors and infrastructure observed in real supply chain attacks. cplt is designed to mitigate these specific threats.
Supply chain attacks through AI coding agents follow a consistent pattern:
1. INFECTION 2. RECONNAISSANCE 3. CREDENTIAL HARVEST 4. EXFILTRATION
postinstall hook → hostname, IP, user, → ~/.ssh/*, ~/.aws/*, → HTTP POST to C2
or patched file env vars, OS info .env, npm tokens or DNS tunnel
| Incident | Year | Vector | Impact |
|---|---|---|---|
| Shai-Hulud | 2025 | Compromised npm maintainer accounts | Self-replicating worm hit 700+ packages, stole npm tokens + AWS keys |
| CamoLeak | 2025 | Prompt injection in PR comments | Copilot Chat exfiltrated private code via GitHub image proxy (CVE-2025-59145, CVSS 9.6) |
| RoguePilot | 2026 | Prompt injection in GitHub issues | GITHUB_TOKEN leaked from Codespaces, enabling full repo takeover |
| YOLO Mode | 2025 | Agent writes to .vscode/settings.json | Auto-approved all commands → RCE (CVE-2025-53773) |
| MCP Poisoning | 2026 | Hidden instructions in npm metadata | AI agents extracted SSH keys from dev machines, invisible to user |
| axios RAT | 2026 | Trojanized npm package by STARDUST CHOLLIMA | Hidden RAT deployed to any system where AI agent ran npm install |
| Category | Domains/services | Why attackers use them |
|---|---|---|
| Discord webhooks | discord.com/api/webhooks/* |
Write-only, no authentication needed, blends with legitimate traffic |
| Webhook capture | webhook.site, pipedream.com, requestbin.com |
Disposable endpoints, no signup required |
| Tunneling | ngrok.io, localtunnel.me, serveo.net |
Reverse shells through NAT/firewall boundaries |
| Paste sites | pastebin.com, paste.ee, hastebin.com |
Credential dump staging for later retrieval |
| File sharing | transfer.sh, file.io, 0x0.st, catbox.moe |
Exfiltration of SSH keys and .env files |
| Telegram | api.telegram.org |
Bot API as write-only C2 channel |
| IP recon | ipinfo.io, ifconfig.me, checkip.amazonaws.com |
Victim network fingerprinting |
| Cloudflare Workers | *.workers.dev |
Free hosting for C2 relays, resistant to takedown |
| Ethereum dead-drop | Smart contract → Cloudflare-fronted domains | C2 URL rotation without code changes, impossible to take down |
A curated blocklist of these domains is included in blocked-domains.txt.
- npm/pip tokens — enables worm propagation (Shai-Hulud: 700+ packages from stolen tokens)
- CI/CD tokens — GITHUB_TOKEN, AWS keys from environment variables
- SSH keys —
~/.ssh/id_* - Cloud credentials —
~/.aws/credentials,~/.config/gcloud - Environment files —
.env,.env.local(API keys, database URLs) - Network topology — internal IPs, DNS servers, hostnames (recon for lateral movement)
| Kill chain step | Attack technique | Sandbox defense | Verdict |
|---|---|---|---|
| 1. Infection | postinstall hook runs code |
Blocked by default. Hardening injects npm_config_ignore_scripts=true and YARN_ENABLE_SCRIPTS=false |
✅ Stopped |
| 2. Recon | Read hostname, IP, env vars | Can read process env vars (needed for Copilot), hostname | |
| 3. Credential harvest | Read ~/.ssh, ~/.aws, .env | Kernel-blocked. macOS Seatbelt denies the read syscall. | ✅ Stopped |
| 4a. HTTP exfil | POST to discord/webhook/C2 | Partially mitigated. Only port 443 allowed (HTTPS); localhost blocked; SSH agent blocked. Credentials are unreadable, limiting blast radius. Proxy blocklist helps if enabled. | |
| 4b. DNS tunneling | Encode data in DNS queries | Not inspected — DNS bypasses the proxy | ❌ Not stopped |
| 4c. Reverse shell | Connect back via ngrok | Non-standard ports blocked; ngrok.io blocked when proxy enabled; localhost blocked |
|
| 5. Binary staging | Drop RAT into cache dir and execute | Kernel-blocked by default. ~/Library/Caches has no process-exec or file-map-executable; /tmp exec also denied. --allow-cache-exec <SUBDIR> grants exec to a specific subdir (e.g. ms-playwright) — write+exec risk applies to that subdir. |
✅ Stopped ( |
| Worm propagation | Republish infected packages | Can't read npm tokens (in ~/.npmrc, kernel-blocked) | ✅ Stopped |
Network is port-restricted, with optional domain filtering. SBPL (Seatbelt Profile Language) does not support domain-based filtering at the kernel level. Copilot CLI connects to CDN-backed endpoints (api.business.githubcopilot.com) with changing IPs that cannot be enumerated. We allow outbound TCP on port 443 only (use --allow-port for extras, e.g. --allow-port 80 for HTTP). SSH agent access and localhost outbound are blocked at the kernel level. This means:
- A compromised agent CAN make HTTPS requests to attacker-controlled servers on port 443
- A compromised agent CANNOT exfiltrate cloud credentials from env vars (env is sanitized; only safe allowlist passes through)
- A compromised agent CAN exfiltrate project source code and Copilot auth tokens
- A compromised agent CANNOT connect to local services (localhost is blocked on macOS; on Linux, use
--with-proxy— see Linux-specific limitations) - A compromised agent CANNOT use loaded SSH keys (unix socket is blocked)
- A compromised agent CANNOT connect on non-standard ports (e.g., 8080, 3000) unless
--allow-portis used - A compromised agent CANNOT exfiltrate SSH keys, cloud credentials, or npm tokens (kernel-blocked from reading them)
- A compromised agent CAN request GPG signatures (if
--allow-gpg-signingis enabled) but CANNOT exfiltrate private keys - The proxy logs and filters all outbound connections by default, including Copilot CLI traffic (via
NODE_USE_ENV_PROXY=1). The proxy also enforces port restrictions matching the sandbox policy. Use--no-proxyto disable.
--allow-localhost-any + --allow-jvm-attach weakens kernel-level network isolation. Due to a macOS SBPL limitation, "localhost" filters do not match Java NIO's IPv4-mapped addresses (::ffff:127.0.0.1). Since SBPL only accepts * or localhost as the host part (literal IPs are rejected), the only way to cover Java's addressing is (allow network-outbound (remote tcp "*:*")). This broadening is only applied when both --allow-localhost-any and --allow-jvm-attach are set (i.e., JVM developers who need Gradle/Kotlin daemon IPC). With just --allow-localhost-any alone (Next.js, Vite, etc.), the tighter "localhost:*" rule is used. The proxy remains as the compensating control — programs using HTTP_PROXY/HTTPS_PROXY (Node.js, curl, Go's net/http) are still domain-filtered. Raw TCP connections from programs that ignore proxy settings can bypass filtering in JVM mode. This trade-off is accepted because: (1) filesystem isolation still blocks credential access, (2) the proxy catches the primary exfiltration vector (HTTPS), (3) the user explicitly opted in with two flags, (4) the alternative (Gradle/Kotlin/Maven broken) makes the tool unusable for JVM developers.
Mitigation: Use --allowed-domains allowed-domains.txt to restrict traffic to known Copilot endpoints only. Use --blocked-domains blocked-domains.txt to block known exfiltration infrastructure. Use --proxy-log proxy.log for post-session audit. All traffic, including Copilot's own Node.js connections, routes through the proxy.
--allow-private-domain weakens DNS rebinding protection for named domains. When a domain is listed in proxy.allow_private_domains (or --allow-private-domain), the proxy skips the post-DNS private IP check for that domain. This is intentional for corporate intranet services (e.g. intern.nav.no) that legitimately resolve to RFC 1918 addresses. The accepted risk: if DNS for a listed domain is poisoned or hijacked, a compromised agent could reach arbitrary private hosts on your internal network — not just the intended service. All other proxy checks (port, allowlist, blocklist) still apply. Only list domains you control and whose DNS you trust.
~/.config/gh/hosts.yml is readable. Copilot spawns gh auth token inside the sandbox. This file contains a GitHub OAuth token. Only hosts.yml and config.yml are readable (not the entire .config/gh directory). With outbound port 443 allowed, a compromised agent could theoretically exfiltrate this token. However, the token grants access to GitHub — which Copilot is already connected to. Users who want to mitigate this can use --deny-path ~/.config/gh (Copilot will fall back to Keychain auth).
Possible mitigation: A repo-scoped MCP proxy or fine-grained PAT that limits token scope to the current repository only. See issue #4 for investigation.
DNS tunneling is the one channel we cannot inspect. However:
- Bandwidth is ~15 KB/s at best (encoding overhead in subdomain labels)
- Requires attacker-controlled authoritative DNS server
- The most valuable targets (credentials, tokens, keys) are kernel-blocked from being read
- Detectable with DNS monitoring (high-entropy subdomain queries to unusual domains)
Possible mitigation: Route DNS through a local resolver that logs and rate-limits queries, or block DNS entirely and use a pre-configured resolver for known domains. Practical impact is low given that credentials are already inaccessible.
Reconnaissance leaks basic host info. Hostname, IP address, OS version, and the sanitized subset of env vars are readable by any code running inside the sandbox. This is unavoidable — Copilot itself needs this information to function.
Possible mitigation: A future hardening category could mask hostname and inject synthetic env values, but this risks breaking tools that depend on accurate system info. Low priority given that recon without credential access has minimal value.
Project source code is readable and writable. The agent needs read/write access to the project directory — that's its job. A compromised agent could exfiltrate source code via HTTPS on port 443.
Possible mitigation: A read-only project mode (--read-only-project) for review-only workflows where the agent should not modify files. Outbound bandwidth tracking could detect bulk exfiltration (large POSTs relative to Copilot's normal API pattern), but would require deep packet inspection.
~/.copilot/ session history is broadly accessible. The sandbox grants read/write to all of ~/.copilot/, which includes the session store database (session-store.db) containing all past conversation history, and session-state/ with per-session artifacts. Copilot's runtime manages these files from inside the sandbox and requires access to function. A compromised agent could read all past conversations to extract business logic, architecture decisions, or referenced credentials.
Possible mitigation: Users concerned about session history exposure can use --deny-path ~/.copilot/session-state to block access to other sessions' artifacts (accepting loss of cross-session features). Scoping session store access to the current session only would require changes to Copilot's runtime (the session store database is a single SQLite file).
Since credentials are inaccessible inside the sandbox (both at filesystem and environment level), network-based exfiltration can only leak project source code and ~/.config/gh tokens — a much smaller blast radius than full credential theft.
By default, cplt clears the child process environment and re-adds only safe variables from an allowlist. This prevents credential leakage through inherited env vars.
How it works:
cmd.env_clear()removes all environment variables- Variables matching
ENV_ALLOWLIST(49 safe vars) are re-added from the parent process - Variables matching
ENV_PREFIX_ALLOWLIST(8 prefixes:LC_*,COPILOT_*,COREPACK_*,MISE_*,NVM_*,PYENV_*,SDKMAN_*,YARN_*) are re-added --pass-env VARadds explicit vars (repeatable)ENV_ALWAYS_DENYvars (NO_COLOR,FORCE_COLOR,SSH_AUTH_SOCK,SSH_AGENT_PID) are always stripped
Deliberately allowed: GH_TOKEN, GITHUB_TOKEN, COPILOT_GITHUB_TOKEN — Copilot needs a GitHub token to function. This is an accepted trade-off.
Deliberately blocked: AWS_*, AZURE_*, NPM_TOKEN, DATABASE_URL, VAULT_TOKEN, SSH_AUTH_SOCK, Docker vars, CI tokens.
Escape hatch: --inherit-env disables sanitization and inherits all env vars (still strips ENV_ALWAYS_DENY). This is dangerous and should only be used for debugging.
Beyond sanitization, cplt injects hardening environment variables that disable dangerous tool behaviors inside the sandbox. This is a declarative, category-based system designed for extensibility.
How it works:
HARDENING_ENV_VARSis a compile-time list of(name, value, category)tuples- Each variable belongs to a
HardeningCategory(e.g.,LifecycleScripts,GitHardening) - Variables are injected unless their category has been opted out via CLI flag
- If a user explicitly passes a variable via
--pass-env, their value is preserved
Currently injected variables:
| Variable | Value | Category | Purpose |
|---|---|---|---|
npm_config_ignore_scripts |
true |
LifecycleScripts | Block npm/pnpm postinstall hooks |
YARN_ENABLE_SCRIPTS |
false |
LifecycleScripts | Block Yarn Berry lifecycle scripts |
GIT_TERMINAL_PROMPT |
0 |
GitHardening | Prevent git credential prompts |
GIT_CONFIG_COUNT |
2 |
GitSigning | Number of git config overrides |
GIT_CONFIG_KEY_0 |
commit.gpgsign |
GitSigning | Override commit signing config |
GIT_CONFIG_VALUE_0 |
false |
GitSigning | Disable commit signing (private keys inaccessible) |
GIT_CONFIG_KEY_1 |
tag.gpgsign |
GitSigning | Override tag signing config |
GIT_CONFIG_VALUE_1 |
false |
GitSigning | Disable tag signing (private keys inaccessible) |
Why this matters: Supply chain attacks (e.g., axios March 2026) use postinstall hooks to execute malicious payloads. Blocking lifecycle scripts eliminates this attack class — npm install still downloads packages, but no arbitrary code runs. Explicit commands like npm run build still work normally. Git signing is disabled because ~/.ssh and ~/.gnupg are denied by the sandbox — attempting to sign would fail with EPERM. Disabling via env var gives a clean error-free experience.
Escape hatches:
--allow-lifecycle-scriptsdisables theLifecycleScriptscategory. Use whennpm installrequires postinstall hooks (e.g., native module compilation).--allow-gpg-signingdisables theGitSigningcategory and adds targeted SBPL rules for GPG access. See GPG signing risk analysis below.
The sandbox denies writes to ~/.copilot/pkg/ (where Copilot's native modules like keytar.node live). This prevents a persistence attack where a rogue agent replaces a native module with malware that executes unsandboxed next time Copilot runs outside cplt.
When Copilot CLI is installed via a non-standard Node version manager (e.g. n at ~/n/, Volta at ~/.volta/, custom npm prefix), its package directory falls outside the static TOOL_READ_DIRS. At startup, cplt resolves the copilot binary path, walks up at most 4 ancestors looking for a package.json with "name": "@github/copilot", and adds the directory to the sandbox read allowlist. Safety checks:
- Package identity: parsed via
serde_json— only the real Copilot package is accepted - Unsafe root rejection:
/,$HOME,/tmp, etc. are rejected - SBPL injection validation: path characters validated before profile interpolation
Git's core.hooksPath points to a directory of user-configured hooks that run on commit, push, etc. If not allowed, the sandbox causes git to fail with EPERM (instead of ENOENT for missing hooks). cplt auto-detects the hooks path and allows reading it. Safety checks:
- Write denied:
(deny file-write*)explicitly blocks writes to the hooks directory, preventing persistence attacks even if the path overlaps a writable sandbox directory - Under
$HOME: paths outside the home directory are rejected (prevents arbitrary filesystem reads) - Depth ≥ 3: the path must have at least 3 components under
$HOME(e.g.~/.config/git/hooksis OK,~/hooksis too broad) - Unsafe root rejection:
/,$HOME,/tmp, etc. are rejected
The primary defense is Apple's mandatory access control framework, enforced in the XNU kernel. All restrictions apply to the sandboxed process and all its children — there is no way to shed the sandbox after sandbox_init().
(deny default) ← Block everything by default
(import "bsd.sb") ← Allow basic system library access
(allow process-exec/fork) ← Allow running programs
(allow file-read/write project_dir) ← Project access
(allow file-read ~/.copilot) ← Auth token access + native modules
(allow file-read ~/.config/gh/hosts.yml)← GitHub CLI auth (2 files only)
(allow file-read ~/.config/git/config) ← Git config (read-only)
(allow file-read core.hooksPath dir) ← Global git hooks (auto-detected, if set)
(deny file-write core.hooksPath dir) ← Prevent persistence via hook modification
(allow file-read copilot_install_dir) ← Copilot CLI package dir (auto-detected)
(allow file-read/write /private/tmp) ← Temp file access
(deny process-exec /private/tmp) ← But no executing from tmp!
(allow unix-socket .java_pid*) ← JVM Attach API only (--allow-jvm-attach, regex-restricted)
(deny unix-socket /tmp/*) ← All other unix sockets blocked (SSH agent, etc.)
(deny file-* ~/.ssh, ~/.aws, ...) ← Sensitive dirs blocked
(deny network-outbound (remote tcp)) ← Block all outbound TCP by default
(allow network-outbound *:443) ← Then allow HTTPS port only (use --allow-port for extras)
(deny network-outbound localhost:*) ← Block localhost SSRF (default)
(allow network-outbound localhost:PORT) ← Carve-out for proxy (ephemeral port, assigned at runtime)
;; With --allow-localhost-any: replace deny with (allow ... localhost:*)
;; With --allow-localhost-any + --allow-jvm-attach: (allow ... "*:*") for Java IPv4-mapped
Network note: Outbound TCP is restricted to port 443 by default. SSH agent access (unix sockets) is blocked. JVM Attach API sockets (
/tmp/.java_pid*) are available via--allow-jvm-attach(opt-in, regex-restricted to.java_pid<PID>only) — all other unix sockets in/tmpremain blocked. Localhost outbound is blocked to prevent SSRF. Use--allow-portfor additional ports. SBPL does not support domain-based rules — filesystem isolation is the primary security control.
Key design decision: Deny rules are placed AFTER allow rules. In Seatbelt's evaluation model with (deny default), more-specific rules override broader ones, and later rules take precedence for equal specificity. This means our deny rules for ~/.ssh correctly override the broader temp/system allows.
Directories always denied (read + write):
~/.ssh,~/.gnupg— cryptographic keys~/.aws,~/.azure— cloud credentials~/.kube,~/.docker— infrastructure access~/.nais— Nav platform credentials~/.password-store— pass password manager~/.config/gcloud— Google Cloud credentials~/.config/op— 1Password CLI~/.terraform.d— Terraform credentials
Directories explicitly allowed (read-only):
~/.config/gh— GitHub CLI credentials (Copilot spawnsgh auth token; see Honest gaps)
Files always denied:
~/.netrc— HTTP credentials~/.npmrc— npm registry tokens~/.pypirc— PyPI credentials~/.gem/credentials— RubyGems credentials~/.vault-token— HashiCorp Vault
Home tool directories (~/.cargo, ~/.nvm, etc.) use a per-directory permission model (HomeToolDir) with granular process_exec, map_exec, and write flags:
| Directory | process-exec | file-map-executable | file-write | Rationale |
|---|---|---|---|---|
.local, .mise, .nvm, .pyenv, .cargo, .rustup, .sdkman, go/bin, Library/pnpm |
✅ | ✅ | varies | Contain executable binaries and shims |
.gradle, .m2, .konan, go/pkg |
❌ | ✅ | varies | JNI/cgo/Kotlin native libs loaded via dlopen, no direct executables |
.yarn |
❌ | ❌ | ✅ | Yarn Berry global cache — JavaScript packages only, no native binaries |
Library/Caches |
❌ | ❌* | ✅ | Broad allow for dev tool caches; browser/app caches denied via regex prefix rules (com.apple., com.google., org.mozilla., etc.) — Xcode dev tools (com.apple.dt.) re-allowed |
* Exception: ~/Library/Caches/copilot/pkg/ has file-map-executable and process-exec for Copilot's native modules and helper binaries (pty.node, spawn-helper, rg). A file-write* deny prevents write-then-exec attacks. These carve-outs are placed after the broader deny rules (SBPL last-match-wins).
Security principle: Every writable+executable directory is a potential binary-drop staging path. By denying both process-exec and file-map-executable on ~/Library/Caches, this vector is eliminated at the kernel level. Non-dev caches (browsers, system apps, communication tools) are denied via DENIED_CACHE_PREFIXES regex rules in the SBPL profile — new dev tools auto-work without code changes because their cache dirs don't use these prefixes.
When --scratch-dir is enabled, cplt creates a per-session directory at ~/Library/Caches/cplt/tmp/{session-id}/ with full read/write/exec/map-exec permissions. This is a controlled exception to the TMPDIR exec deny:
- Why it exists:
go test,miseinline tasks, andnode-gypcompile to$TMPDIRthen execute. The sandbox blocks this, breaking these tools. On macOS, JVM processes also need this becausejava.io.tmpdirdefaults to/var/folders/...(ignoringTMPDIRenv var); cplt injects-Djava.io.tmpdir,-Djansi.tmpdir, and-Djava.rmi.server.hostname=localhostviaJAVA_TOOL_OPTIONSto redirect JVM temp usage to the scratch dir and keep RMI communication on localhost. - Security model: The scratch dir has both write+exec — this is the accepted trade-off. Mitigations:
- Scoped path: Only the specific session subpath has exec, not all of
~/Library/Caches/cplt/ - 0700 permissions: Owner-only access, verified at creation
- Symlink rejection: Base path is validated as a real directory, not a symlink
- Owner check:
stat()verifies the directory owner matches the current uid - SBPL injection guard: Path validated against metacharacters before interpolation
- Ephemeral: Cleaned up on exit via RAII Drop; stale dirs GC'd after 24h on startup
- Scoped path: Only the specific session subpath has exec, not all of
- On by default: Enabled by default. Disable with
--no-scratch-dirorsandbox.scratch_dir = falsein config.
A localhost CONNECT proxy intercepts all outbound traffic by default. HTTP_PROXY/HTTPS_PROXY and NODE_USE_ENV_PROXY=1 are injected into the sandbox environment, routing traffic from Copilot CLI (Node.js), gh (Go), curl, and any other tool through the proxy. Use --no-proxy to disable.
The proxy handles CONNECT tunnels only (non-CONNECT returns 405). Each TCP connection processes exactly one request — no HTTP keep-alive or request pipelining. This eliminates HTTP request smuggling by design.
- Buffer: Fixed 8192 bytes, single read — no allocation amplification
- Connection limit: 64 concurrent connections max (excess dropped)
- Binding:
127.0.0.1only — not reachable from the network - Invalid UTF-8: Replaced with U+FFFD via
from_utf8_lossy, which won't match any domain — fail-safe - Relay timeout: 60-second read timeout on both directions prevents idle connection resource exhaustion
On Linux, kernel-level enforcement uses two complementary mechanisms:
Landlock is a stacking LSM that provides unprivileged, process-level access control. Rules are additive within a ruleset — access not explicitly granted is denied.
ABI version support:
| ABI | Kernel | Capabilities |
|---|---|---|
| v1 | 5.13+ | Filesystem access control |
| v2 | 5.19+ | + file refer (cross-directory rename) |
| v3 | 6.2+ | + file truncate |
| v4 | 6.7+ | + TCP port filtering (bind + connect) |
| v5 | 6.10+ | + ioctl on character devices |
cplt requires ABI v1 minimum. On ABI < v4, network security relies on the CONNECT proxy only (Landlock cannot filter TCP ports). On ABI v4+, Landlock denies all TCP connections except to explicitly allowed ports.
Key differences from Seatbelt:
| Property | Seatbelt (macOS) | Landlock (Linux) |
|---|---|---|
| Granularity | Path regex, file-level deny | Path-based, directory-level allow |
| Default | deny-by-default | deny-by-default |
| Subpath deny | ✅ Can deny subpaths within allowed dirs | ❌ Cannot deny within allowed paths |
| Network | Port-based (all ABIs) | Port-based (ABI v4+ only) |
| Audit logs | Full Seatbelt violation log | None (no audit mode) |
| Privilege | Requires sandbox-exec (deprecated) |
Unprivileged (any user) |
Pre-exec safety: The proxy thread makes the process multi-threaded before fork. Landlock rules are pre-computed in the parent process (PrecomputedSandbox), and the seccomp filter is installed via raw syscall. The Landlock crate performs small heap allocations in pre_exec which is technically not async-signal-safe, but works reliably in practice (the proxy thread is blocked in I/O syscalls during fork, minimizing allocator contention).
A BPF filter blocks dangerous syscalls that could be used to escape the sandbox or escalate privileges:
| Blocked syscall | Reason |
|---|---|
ptrace |
Prevents debugging/injecting into other processes |
process_vm_readv, process_vm_writev |
Prevents cross-process memory access |
mount, umount2 |
Prevents filesystem namespace manipulation |
pivot_root, chroot |
Prevents root filesystem escape |
unshare |
Prevents creating new namespaces |
setns |
Prevents entering other namespaces |
reboot |
Prevents system disruption |
kexec_load |
Prevents kernel replacement |
init_module, finit_module, delete_module |
Prevents kernel module manipulation |
swapon, swapoff |
Prevents swap manipulation |
personality |
Prevents ABI personality changes |
add_key, keyctl, request_key |
Prevents kernel keyring manipulation |
io_uring_setup, io_uring_enter, io_uring_register |
Prevents io_uring (bypass of seccomp/Landlock) |
userfaultfd |
Prevents userfaultfd exploitation |
perf_event_open |
Prevents perf-based side channels |
bpf |
Prevents BPF program loading |
iopl, ioperm |
Prevents I/O port access (x86_64 only) |
modify_ldt |
Prevents LDT modification (x86_64 only) |
The filter uses SECCOMP_RET_ERRNO (returns EPERM) rather than SECCOMP_RET_KILL to avoid crashing on legitimate probes.
The same credential directories are denied as on macOS:
~/.ssh,~/.gnupg,~/.aws,~/.azure,~/.kube,~/.docker,~/.nais~/.password-store,~/.config/gcloud,~/.config/op,~/.terraform.d~/.netrc,~/.npmrc,~/.pypirc,~/.gem/credentials,~/.vault-token
Linux-specific tool directories use XDG-style paths:
| Directory | Permissions | Rationale |
|---|---|---|
~/.cache |
read+write | XDG cache dir (pip, go-build, etc.) |
~/.local/share/pnpm |
read+write+exec | pnpm global store |
~/.local/bin |
read+exec | User-installed binaries |
~/.local/share/mise |
read+write+exec | mise tool installations |
- No
--show-denials: Landlock has no audit logging. Usestracefor debugging. - No subpath deny: Cannot deny
~/.config/gh/extensionswhile allowing~/.config/gh/hosts.yml— the entire directory must be allowed or denied. - No auth integration: Linux v1 supports env token +
gh authonly (no D-Bus/Secret Service). - Copilot extraction: The macOS SEA extraction path is unknown on Linux —
ensure_copilot_extracted()is skipped. - No localhost isolation at kernel level: Landlock network rules are port-based only — they cannot distinguish
localhost:443fromremote:443. On macOS, Seatbelt blocks localhost outbound separately. On Linux, use--with-proxyfor localhost SSRF protection (the proxy resolves DNS and blocks private IPs).
The proxy provides:
- Connection logging — every CONNECT target is logged with timestamp and status
- Domain blocklist — configurable file-based blocklist with subdomain matching
- Port enforcement — only port 443 (and
--allow-portvalues) are permitted, matching the sandbox policy - DNS rebinding protection — resolves DNS first, validates the resolved IP, then connects using the pinned address
- Comprehensive private IP blocking — covers all reserved ranges
A naïve proxy checks the hostname string (e.g., "api.github.com") against a blocklist before connecting. An attacker can register a domain that resolves to 127.0.0.1 — the hostname check passes but the connection reaches localhost.
Our defense (following OWASP SSRF Prevention guidance):
1. Check hostname against blocklist → block known-bad domains
2. Check hostname patterns (localhost, .local) → fast-path reject
3. DNS resolve hostname → IP address → get actual target
4. Check RESOLVED IP against private ranges → catch rebinding
5. Connect to the resolved IP (not hostname) → pin the address, prevent TOCTOU
Step 5 is critical: we connect to the SocketAddr from step 3, not re-resolving. This prevents time-of-check-to-time-of-use (TOCTOU) attacks where the DNS response changes between validation and connection.
| Range | RFC | Purpose |
|---|---|---|
127.0.0.0/8 |
RFC 1122 | Loopback |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
RFC 1918 | Private networks |
169.254.0.0/16 |
RFC 3927 | Link-local |
100.64.0.0/10 |
RFC 6598 | CGNAT (Tailscale, WireGuard) |
198.18.0.0/15 |
RFC 2544 | Benchmarking |
240.0.0.0/4 |
RFC 1112 | Reserved/future |
192.0.0.0/24 |
RFC 6890 | IETF protocol assignments |
0.0.0.0 |
— | Unspecified |
255.255.255.255 |
— | Broadcast |
::1 |
RFC 4291 | IPv6 loopback |
fc00::/7 |
RFC 4193 | IPv6 ULA (private) |
fe80::/10 |
RFC 4291 | IPv6 link-local |
::ffff:A.B.C.D (private v4) |
RFC 4291 | IPv4-mapped IPv6 |
All paths interpolated into sandbox profiles are validated against unsafe characters:
Blocked: " ) ( ; \ \n \r \0
The newline character is the most dangerous — a path containing \n(allow file-read* (subpath "/")) would inject a rule granting read access to the entire filesystem. We validate:
- Project directory path
- Home directory path
- All user-specified allow/deny paths (from CLI and config file)
Config file paths are additionally canonicalized (resolved to absolute paths) at load time.
The sandbox profile is written to a temp file with:
- Unique filename:
cplt-{PID}-{nanosecond_timestamp}.sb - Atomic creation:
OpenOptions::create_new(true)— fails if file exists (prevents symlink following) - Restricted permissions: mode
0o600(owner read/write only) - Cleanup on exit: file is removed after sandbox-exec completes
cplt refuses to sandbox overly broad directories that would grant the agent access to sensitive areas:
/— entire filesystem/Users— all user home directories$HOME— user's entire home directory/tmp,/private/tmp— shared temp directories/var,/private/var— system variable data/Applications— installed applications/System— macOS system files
- Allow paths (
--allow-read,--allow-write): canonicalized; unresolvable paths are warned and skipped - Deny paths (
--deny-path): canonicalized; unresolvable paths cause a hard error (silently dropping a deny rule is a security risk)
Repository maintainers can commit a .cplt.toml to configure sandbox settings for all contributors. This creates an attack surface: a compromised or malicious maintainer could weaken the sandbox for everyone who clones the repo. The trust model addresses this with defense-in-depth.
-
Deny-default for permissions. The
[propose]section requests sandbox relaxations, but they have no effect until the local user explicitly approves them withcplt trust accept. Unapproved permissions are silently ignored — the agent runs safely with the tighter default sandbox. -
Deny section is tighten-only. The
[deny]section can only add restrictions (block paths, block env vars). It is applied automatically without approval because it cannot weaken the sandbox. -
No interactive approval during launch. cplt deliberately does not prompt "approve these? [y/N]" when unapproved permissions exist. This prevents approval fatigue — users reflexively hitting
yto proceed. Instead, approval requires a separate deliberate command (cplt trust accept), matching the security model of Deno workspace trust and VS Code Restricted Mode. -
Tamper-proof source.
.cplt.tomlis read fromgit HEAD(committed state) viagit cat-file, not from the working tree. The sandboxed agent cannot modify its own config mid-session. Write access to.cplt.tomlis kernel-denied inside the sandbox. -
Content-pinned approvals. Trust entries store a SHA-256 hash of the approved
[propose]values. If the maintainer changes any proposed values (even reordering array elements is hash-stable due to pre-sort), previous approvals are automatically invalidated and the user must re-approve. -
Additive-only semantics. Repo config can enable features (
allow_docker = true) but cannot disable anything set by the user's CLI flags or global config. Precedence: CLI > global config > approved repo permissions > defaults. -
Path traversal rejection. Paths in
.cplt.tomlcontaining..components are rejected at parse time, preventing escape attempts like../../.ssh.
- Trust entries are stored in
~/.config/cplt/trust/— protected from the sandbox (the agent cannot self-approve). - Each entry is keyed by a SHA-256 fingerprint of the canonical repo path + normalized remote URL.
- Remote URLs are normalized (SSH/HTTPS variants, credentials stripped, ports removed) so the same repo accessed via different URLs shares one trust entry.
- Trust writes are atomic (temp file + rename) to prevent corruption from interrupted writes.
| Scenario | Mitigation |
|---|---|
Maintainer adds allow_docker = true |
No effect until each user explicitly approves |
Agent modifies .cplt.toml at runtime |
Read from git HEAD, not working tree; writes kernel-denied |
| Maintainer changes proposed values after approval | Content hash mismatch invalidates approval |
.cplt.toml blocks critical env vars via [deny].env |
[deny] can only tighten — removing env vars reduces attack surface |
| Path traversal in deny/allow paths | .. components rejected at parse time |
| Agent self-approves via trust store | Trust dir (~/.config/cplt/trust/) is outside the sandbox |
When --allow-gpg-signing is enabled, cplt grants targeted access to the GPG subsystem:
What is exposed:
- Read-only access to
~/.gnupg/pubring.kbx,pubring.gpg,trustdb.gpg,gpg.conf,common.conf(public data only) - Unix socket connect to
~/.gnupg/S.gpg-agent(IPC to the GPG agent daemon running outside the sandbox)
What stays denied:
~/.gnupg/private-keys-v1.d/— private key files remain kernel-blocked~/.gnupg/secring.gpg— legacy private keyring explicitly denied- All writes to
~/.gnupg/— no modifications possible ~/.ssh/andSSH_AUTH_SOCK— SSH signing is not enabled by this flag
Key exfiltration is impossible. The GPG agent uses the Assuan IPC protocol, which exposes PKSIGN (sign), PKDECRYPT (decrypt), READKEY (public key), and KEYINFO (metadata) — but has no command to export private key material. The agent is a privilege-separation boundary by design. Even if the on-disk key files weren't denied, they are encrypted with the user's passphrase.
The actual risk is signature impersonation AND decryption. A compromised process with agent socket access can:
- Request signatures via
PKSIGN— signing arbitrary data, including malicious commits - Request decryptions via
PKDECRYPT— if the user has an encryption subkey, the compromised process can decrypt arbitrary ciphertext
This is not key theft — the attacker cannot take the key with them. Operations can only be performed while the sandbox is running and the agent connection is active.
Risk context: Copilot already has git commit ability and can make commits as the user. GPG signing only adds the "Verified" badge. The incremental risk is specifically: a compromised agent can make commits that appear cryptographically verified by the user, and can decrypt data if an encryption subkey exists. Mitigating factors:
- Agent passphrase cache has a TTL (default: 10 min idle, 2 hr max)
- The network proxy (when enabled) can audit/block pushes to unexpected remotes
- Branch protection rules may still require PR review regardless of signature status
Deny-path override: If --deny-path ~/.gnupg is specified alongside --allow-gpg-signing, the deny wins — all GPG allows are suppressed. This is consistent with the project-wide principle that explicit denies always take precedence.
Known limitations:
GNUPGHOMEis not inENV_ALLOWLISTbut could be injected via--pass-envor--inherit-env, redirecting GPG to a different directory outside the SBPL policy. The SBPL rules only cover~/.gnupg/.- If
~/.gnupgis a symlink, SBPL path resolution may cause rules to not match as expected. Signing will fail closed (no access) rather than open.
Copilot CLI bundles Node.js v24.11.1, which supports NODE_USE_ENV_PROXY=1 (added in Node.js v24.5.0). When this env var is set, Node.js natively honors HTTP_PROXY/HTTPS_PROXY — routing all outbound connections through the specified proxy.
cplt injects NODE_USE_ENV_PROXY=1, HTTP_PROXY, and HTTPS_PROXY into the sandbox environment. All traffic — Copilot CLI, gh, curl, and any other tool — routes through the localhost CONNECT proxy.
Historical context: Earlier versions of Copilot CLI used a Node.js runtime that did not support proxy env vars, and injecting them broke the auth flow. This is no longer the case as of Copilot CLI 1.0.24+ with bundled Node.js v24.11.1.
Design decision: The proxy is enabled by default. It listens on an OS-assigned ephemeral port (port 0), so there are no fixed-port conflicts. Use --no-proxy to disable for a single run, or set proxy.enabled = false in config to disable permanently.
| Component | Language | Routes through proxy? |
|---|---|---|
| Copilot CLI | Node.js | ✅ Yes (via NODE_USE_ENV_PROXY=1) |
gh CLI |
Go | ✅ Yes (via net/http.ProxyFromEnvironment()) |
curl |
C | ✅ Yes |
SBPL has fundamental limitations for network filtering:
- No domain-based rules — SBPL operates at the syscall level, not the application level. It cannot match on hostnames.
- No wildcard port filtering — there is no syntax for "allow any host on port 443 only"
- IP-based rules require known IPs — Copilot's API endpoints use CDN-backed IPs that change regularly
- No loopback-only bind — SBPL only accepts
*orlocalhostas the host part of IP filters. Literal IPs like127.0.0.1cause"host must be * or localhost"errors. Thelocalhosthost matchesINADDR_ANY(0.0.0.0), meaning(allow network-bind (local ip "localhost:*"))also permits binding on all interfaces. This is a macOS Seatbelt limitation — processes inside the sandbox can start listeners accessible on the network. Mitigations: outbound is locked to port 443 (no exfiltration via inbound connections), dev machines are typically behind NAT/firewall, and the proxy intercepts all outbound traffic.
The only viable options are (allow network-outbound (remote tcp)) (allow all) or (deny network*) (deny all). We allow outbound TCP because Copilot cannot function without network access, and use port restrictions as a secondary control.
- Outbound TCP is allowed in the sandbox profile, restricted to port 443 (+
--allow-port) - Filesystem isolation is the primary security control — credentials are kernel-blocked regardless of network policy
- The proxy (when enabled) provides connection logging, domain blocking, port enforcement, and DNS rebinding protection for all traffic including Copilot
The update mechanism downloads releases from GitHub, verifies SHA256 checksums, and atomically replaces the binary.
Verified:
- SHA256 checksum is mandatory — update aborts on mismatch
--proto-redir =httpsprevents HTTP downgrade on redirects- Archive validation: must contain exactly one regular file named
cplt(no symlinks, no directories) - Extracted binary verified via
symlink_metadata(rejects symlinks) - Uses absolute paths for system tools on macOS (
/usr/bin/curl,/usr/bin/shasum,/usr/bin/tar) and Linux (/usr/bin/sha256sum, standard paths only — no bare PATH lookup) - Atomic replacement: stage to
.new, set permissions, rename
Not verified:
- No cryptographic signature (GPG or Sigstore).
SHA256SUMSand binary come from the same GitHub release — a compromised release controls both. This is consistent with most Go/Rust CLI tools but weaker than signed package managers. - Temp directory uses
/tmp/cplt-update-{PID}— predictable by local attackers, but extracted binary is checked for symlinks before installation.
The Homebrew install path (brew install navikt/tap/cplt) uses Homebrew's own verification and is preferred on macOS.
The install script downloads from GitHub Releases and verifies SHA256 checksums.
Caveat: If the SHA256SUMS file cannot be downloaded, or no hash utility is available, the script prints a warning and continues without verification. This is a deliberate trade-off for usability in minimal CI environments.
For high-security environments, verify the binary manually:
curl -fsSL -o cplt.tar.gz "https://github.com/navikt/cplt/releases/latest/..."
curl -fsSL -o SHA256SUMS "https://github.com/navikt/cplt/releases/latest/.../SHA256SUMS"
sha256sum -c SHA256SUMS --ignore-missingcplt --doctor probes the environment by running --version on all known agent binaries found in PATH (copilot, opencode, gemini, claude). These commands run outside the sandbox with full user privileges.
Trust model: cplt trusts that binaries in your PATH are legitimate. This is the same trust model as typing copilot --version yourself. If you don't trust a binary in your PATH, remove it before running --doctor.
~/.config/cplt/config.toml and ~/.config/cplt/trust/*.toml are trusted inputs read before sandboxing. They are not permission-checked — the trust model assumes $HOME is protected by OS-level permissions (0700 or 0755).
On shared systems, ensure ~/.config/cplt/ has restrictive permissions (0700). A user who can write to this directory can weaken the sandbox configuration.
Session IDs for per-session scratch directories are generated from /dev/urandom (16 random bytes, hex-encoded). Fallback to PID + nanosecond timestamp if /dev/urandom is unavailable (not expected on standard macOS/Linux). The fallback is predictable but the failure mode is denial-of-service (directory creation fails if it already exists), not compromise.
These test core logic without invoking sandbox-exec, using the real library functions (not duplicated copies):
| Category | Tests | What's verified |
|---|---|---|
| Unsafe root detection | 11 | Rejects /, /Users, /tmp, /var, /Applications, /System, $HOME; allows project subdirs |
| SBPL injection | 5 | Rejects \n, \0, ", (; allows normal paths |
| Domain blocking | 7 | Exact match, subdomain match, no partial match, comments, case-insensitive, empty blocklist |
| Private IP detection | 11 | Loopback, RFC 1918, link-local, unspecified, CGNAT, benchmarking, reserved, ULA, link-local v6 |
| Hostname detection | 3 | localhost, .localhost, .local patterns; allows normal hostnames |
| Profile generation | 35 | Uses real generate_profile(); verifies deny-default, project access, sensitive dir/file blocks, network rules, deny-after-allow ordering, exec-from-tmp denied, env file deny/allow, copilot caches carve-outs, tool dir permissions, scratch dir rules |
| Home tool dirs | 1 | All runtime entries present in HOME_TOOL_DIRS |
| Env allowlist | 3 | Essential vars included, dangerous vars excluded, runtime vars present |
| Env behavior | 17 | Sanitization, hardening injection, pass-env overrides, LANG prefix leak prevention, YARN hardening bypass prevention, scratch dir TMPDIR redirect, JAVA_TOOL_OPTIONS injection/append/override |
| Config parsing | 24 | TOML parsing, CLI/config merge precedence, tilde expansion, SBPL validation, scratch dir, allow-tmp-exec |
These invoke sandbox-exec with real Seatbelt profiles and verify kernel-level enforcement:
| Category | Tests | What's verified |
|---|---|---|
| File access | 5 | Project read/write, copilot config, temp write, process execution |
| Sensitive dir blocks | 4 | ~/.ssh, ~/.aws, ~/.docker, ~/.kube blocked |
| Network | 6 | Outbound blocked, JVM Attach socket allowed, SSH agent blocked, /tmp sockets blocked, localhost TCP bind on all interfaces (SBPL "localhost" doesn't match Java mapped addresses), --allow-localhost-any + --allow-jvm-attach opens all outbound TCP |
| Binary CLI | 4 | Version, help, root/home dir rejection |
| Tool dir permissions | 15 | Each HOME_TOOL_DIR has correct exec/map-exec/write at kernel level |
| GPG signing | 4 | Default blocks ~/.gnupg, flag allows pubring read, private keys stay denied, writes stay denied |
End-to-end tests using realistic project scaffolding (Node, Go, Python, Rust, Java/Maven, Kotlin) with fake copilot scripts:
| Category | Tests | What's verified |
|---|---|---|
| Per-language file ops | 7 | Read/write files in Node, Go, Python, Rust, Maven, Kotlin/Maven, multi-module Maven project structures |
| Git workflows | 2 | git init/commit/status/diff/log, multi-step edit cycles |
| Security matrix | 2 | Secret files blocked (.env, .pem, .key), home secrets (~/.ssh, ~/.aws) |
| Mode combinations | 7 | allow-env-files, scratch-dir exec, deny-path, config file, deny-path + scratch-dir, allow-lifecycle-scripts, JAVA_TOOL_OPTIONS injection |
| Git persistence | 1 | Cannot write .git/hooks or .git/config |
| Lifecycle scripts | 3 | npm/yarn/pnpm lifecycle script hardening |
Real Copilot CLI integration tests requiring authentication and network access:
| Test | What's verified |
|---|---|
smoke_copilot_version |
Copilot outputs version string inside sandbox |
smoke_copilot_list_models |
API call returns model list (JSON) |
smoke_copilot_simple_prompt |
Chat completion returns response containing UUID canary |
smoke_copilot_file_context |
Copilot reads project file and references its content |
smoke_copilot_write_file |
Copilot creates a new file on disk (side-effect assertion) |
smoke_env_vars_denied |
SUPER_SECRET_TOKEN not visible inside sandbox |
The GitHub Actions workflow runs in two stages:
- Linux (ubuntu-latest): formatting check (
cargo fmt), linting (cargo clippy -D warnings), unit tests - macOS (macos-latest): full test suite including integration tests, release binary build and verification
- Apple sandbox-exec(1) man page — Official documentation for the command-line sandbox tool
- Chromium Seatbelt V2 Design — How Chromium designs and maintains Seatbelt profiles for browser process sandboxing; influenced our deny-default + bsd.sb import approach
- HackTricks: macOS Sandbox — Comprehensive security research on Seatbelt internals, bypass techniques, and rule evaluation
- A New Era of macOS Sandbox Escapes (POC2024) — Recent CVE research on sandbox escape via XPC/Mach services; informed our understanding of Seatbelt's limitations
- michaelneale/agent-seatbelt-sandbox — Early proof-of-concept for sandboxing AI coding agents with Seatbelt; validated the basic approach
- OWASP SSRF Prevention Cheat Sheet — Authoritative guidance on validating resolved IPs (not hostnames) and pinning addresses to prevent TOCTOU attacks
- RFC 1918 — Private IPv4 address ranges (10/8, 172.16/12, 192.168/16)
- RFC 4193 — IPv6 Unique Local Addresses (fc00::/7)
- RFC 6598 — CGNAT shared address space (100.64.0.0/10); important for Tailscale/WireGuard environments
- RFC 4291 — IPv6 addressing architecture (loopback, link-local, IPv4-mapped addresses)
- CWE-377: Insecure Temporary File — Motivation for unique filenames and
O_CREAT|O_EXCL - CWE-59: Improper Link Resolution Before File Access — Symlink attacks on predictable temp paths
- GitHub Copilot Workspace sandbox settings — VS Code's built-in sandbox options for Copilot (terminal command restrictions)
- Copilot cloud agent firewall — GitHub's server-side network firewall for the cloud coding agent
- Copilot allowlist reference — Default allowed domains for Copilot cloud agent
- OpenAI Codex sandbox — OpenAI's approach to sandboxing code execution with network and filesystem restrictions
- Anthropic Claude Code permissions — Permission-based tool approval model for local agent execution
- Mend.io: Shai-Hulud npm worm analysis (2025) — Self-replicating worm that compromised 700+ npm packages
- Wiz: Shai-Hulud 2.0 — 25K+ repos exposed — Second wave and blast radius analysis
- Socket: 60 malicious npm packages — Network recon exfiltration to Discord webhooks
- Oligo: npm supply chain risks with AI agents — How AI coding agents amplify supply chain attacks
- ReversingLabs: npm reverse shell malware — Patched legitimate packages delivering reverse shells
- Rafter: AI Agent Security Incident Timeline (2025–2026) — Comprehensive timeline of agent security incidents
- CamoLeak: Copilot Chat exfiltration (CVE-2025-59145) — Invisible data exfiltration via GitHub image proxy
- LOTS Project — Living Off Trusted Sites — Catalog of legitimate domains abused for C2 and exfiltration
- Veracode: npm C2 via Ethereum smart contracts — Dead-drop C2 rotation technique
If you discover a vulnerability in cplt, please report it responsibly:
- Do not open a public GitHub issue
- Contact the team via Nav's internal security channels
- Include a description of the vulnerability, steps to reproduce, and potential impact
We aim to acknowledge reports within 48 hours and provide a fix within one week for critical issues.