Imported from yorch/ccpod (
AGENTS.md). Install upstream withnpx skills add yorch/ccpod. Copyright stays with the author.
Project Guidelines
This file provides guidance to AI agents when working with code in this repository.
CLAUDE.mdis a symlink toAGENTS.md— any changes here should be reflected inCLAUDE.mdand vice versa.
Package manager
This project uses bun exclusively. Never use npm, pnpm, or yarn. Always run bun install, bun run <script>, bun test, etc. The website (website/) also uses bun — same rule applies there.
Commands
bun run dev # run CLI without building
bun run build # compile to dist/ccpod binary
bun run typecheck # tsc --noEmit
bun run check # biome format + lint (writes fixes)
bun test # all tests
bun test tests/unit/config/merger.test.ts # single test file
Website commands
cd website
bun run dev # start Astro dev server
bun run build # build to website/dist/
bun run preview # preview built site
Architecture
ccpod is a CLI that runs Claude Code in Docker. Entry point: src/cli/index.ts (citty router).
Config pipeline (the core flow)
ccpod run executes this pipeline in order:
- Load —
src/config/loader.tsreads~/.ccpod/profiles/<name>/profile.yml(profile) and walks up fromcwdto find.ccpod.yml(project). Both validated via Zod schemas insrc/config/schema.ts. - Sync —
src/profile/git-sync.tspulls the profile's config repo ifsource: git. - Merge —
src/config/merger.tscombines profile + project usingmerge: deep|overridestrategy. CLAUDE.md files are merged separately viamergeClaudes()(append or override). - Auth —
src/auth/resolver.tsresolves API key or OAuth credentials into env vars. Same module'sresolveEnvForwardingcollapses profile/project/CLIenvlists, supporting bareKEY(forward host var),KEY=value(literal), andKEY=${HOST_VAR}/KEY=${HOST_VAR:-default}(interpolation, scoped to env values only). - Config write —
src/config/writer.tswrites merged config to a temp dir mounted as/ccpod/configin the container. - Container spec —
src/container/builder.tsbuilds theContainerSpec(binds, env, ports, labels, tmpfs). ExportscomputeProjectHash(dir). - Sidecars —
src/container/sidecars.tscreates shared Docker networkccpod-net-<hash>and starts declaredservices:containers before the main container. - Run —
src/container/runner.tscreates/reattaches/starts the container viadockerCLI (Bun.spawn). TTY mode = interactive; headless mode (--file) streams logs.
Key modules
| Path | Purpose |
|---|---|
src/types/index.ts |
Shared types: ProfileConfig, ProjectConfig, ResolvedConfig (ContainerSpec lives in src/container/builder.ts) |
src/runtime/detector.ts |
Auto-detects OrbStack / Docker / Colima / Podman socket |
src/runtime/docker.ts |
dockerExec (capture stdout/stderr) and dockerSpawn (inherit stdio) |
src/profile/manager.ts |
~/.ccpod/ directory layout, profile CRUD |
src/global/config.ts |
Read/write ~/.ccpod/config.yml (global settings like autoCheckUpdates) |
src/mcp/parser.ts |
Reads .mcp.json to auto-expose MCP HTTP ports |
src/image/manager.ts |
Pull or docker build the container image |
src/container/sidecars.ts |
Shared network creation and sidecar container lifecycle |
src/update/checker.ts |
Checks GitHub releases for newer version |
src/update/updater.ts |
Downloads and replaces the ccpod binary in-place |
src/profile/installer.ts |
detectSource + fetchProfileYaml — source detection and YAML fetching for profile install |
src/profile/exporter.ts |
exportProfile — reads profile.yml and returns base64-encoded string for sharing |
src/cli/validate.ts |
validateProfileArg — shared --profile name validation for all CLI commands |
src/cli/commands/prune.ts |
ccpod prune — remove stopped containers, orphaned networks, unreferenced plugin volumes, orphaned per-project state dirs |
src/auth/proxy.ts |
AuthProxy — HTTP proxy that translates sentinel API key into OAuth bearer token (proxy auth mode) |
src/auth/keychain.ts |
readHostOAuthCredentials / writeHostOAuthCredentials — read/write host OAuth credentials (macOS Keychain or ~/.claude/.credentials.json) |
Storage layout
~/.ccpod/
config.yml # global ccpod settings (autoCheckUpdates, etc.)
profiles/<name>/
profile.yml # profile config
config/ # Claude config dir (if source: git, cloned here)
credentials/<name>/ # auth tokens/keys
state/<name>/ # persistent state (when state: persistent, stateIsolation: per-profile)
state/<name>/<hash>/ # per-project state (when state: persistent, stateIsolation: per-project)
Docker volumes:
ccpod-plugins-<profile> # persistent plugin installs
Container mounts
/workspace— project dir (rw)/ccpod/config— merged Claude config dir (ro)/ccpod/credentials— auth credentials (rw)/ccpod/plugins— named volume for plugins/ccpod/state— host bind~/.ccpod/state/<profile>/(persistent) or tmpfs (ephemeral)
Security invariants
- Profile names are validated by Zod regex
/^[a-zA-Z0-9_-]{1,64}$/— enforced at parse time inschema.tsand at the CLI entry point viavalidateProfileArg()(src/cli/validate.ts). Every command that accepts--profilecallsvalidateProfileArgbefore passing the name toprofileExists,getProfileDir,getStateDir, etc., preventing path traversal (../etc) and shell metacharacter injection through the profile name. The shared setup helpersetupContaineralso validates, coveringrunandshell. --filearg inrun.tsis normalized and rejected if it starts with..or is absolute.- Config temp dirs live under a private per-uid parent
${tmpdir}/ccpod-u<uid>(created0o700, verified owned by us and not a symlink), so another user on a shared host cannot pre-seed or race the deterministic per-content path. Dirs are0o700, files0o600, and the merged config is assembled in amkdtempdir then atomicallyrenamed into place (a reader never sees a half-populated mount). On reuse, the writerlstatsoutDirand refuses it if it is a symlink, not a directory, or owned by a different uid. - Per-profile directories —
profilesDir(),credentialsBase(), andgetStateDir()allmkdirwithmode: 0o700, so another local user cannot read a profile's config, credentials, or Claude conversation state. The~/.ccpodbase dir itself is also created0o700byensureCcpodDirs()andsaveGlobalConfig(), withchmodSyncto tighten pre-existing dirs that may have been created with looser perms by prior versions. .mcp.jsonis parsed through a Zod schema (src/mcp/parser.ts): the server map is capped at 64 entries, auto-exposed HTTP/SSE ports must be in1–65535, and both JSON-parse and schema-validation failures are surfaced viaconsole.warnrather than silently swallowed. Symlinked.mcp.jsonfiles are rejected vialstatSync— an untrusted project could otherwise point.mcp.jsonat an arbitrary host file (e.g./etc/shadow) to probe readability. The same symlink rejection applies to.ccpod.ymlinfindProjectConfig(protecting all callers, includingconfig validate).- Resolved credential + forwarded env are passed to the container as bare
-e KEYflags with the values injected into thedockerCLI's own environment (ContainerSpec.secretEnv→dockerSpawnextraEnv), never as-e KEY=VALUEargv — so secrets don't appear inps//proc/<pid>/cmdline. ccpod's ownCCPOD_*control vars stay as plain flags. - Restricted network (
docker/entrypoint.sh) fails closed: ifiptablesis missing or any load-bearing rule (loopback, established, default-deny) cannot be installed, the container aborts rather than run with unrestricted egress. IPv6 is locked down viaip6tables(fail-closed when the kernel has IPv6 butip6tablesis absent), and DNS is scoped to/etc/resolv.confnameservers instead of an open:53. SSH_AUTH_SOCKis rejected if it contains:(would corrupt Docker bind spec).DOCKER_SOCKET_PATHenv var overrides the hardcoded/var/run/docker.sockpath (useful in tests and non-standard Docker setups).- Profile
config.repomust usehttps://,http://,ssh://,git://, or scp-style (user@host:path).config.refrejects values starting with-, containing.., or carrying shell metacharacters — closes git option-injection (--upload-pack=...) RCE. auth.keyFilemust point inside~/.ccpod/(typically~/.ccpod/credentials/<profile>/...). At read time the resolverrealpathSync-es the path and rejects it if the symlink target escapes~/.ccpod/— schema validation alone is a string-prefix check and would otherwise let a symlink under~/.ccpod/redirect to/etc/shadow. UsekeyEnvto load keys stored elsewhere.- Proxy auth mode (
auth.type: proxy) eliminates the OAuth refresh-token rotation race between concurrent ccpod containers. Instead of copying.credentials.jsoninto each container (which creates independent stores of the same rotating refresh token), ccpod starts a local HTTP proxy (src/auth/proxy.ts) before the container. The container runs claude in API-key mode with a format-valid sentinel key ([REDACTED openai-key]-...) andANTHROPIC_BASE_URLpointing at the proxy. The proxy validates the sentinel, strips thex-api-keyheader, addsAuthorization: Bearer <real-oauth-access-token>, and forwards toapi.anthropic.com. The proxy holds the only OAuth session, refreshes the access token with a single-flight lock (proactively before expiry and on-demand on 401), and writes refreshed tokens back to the host Keychain. No.credentials.jsonenters the container, no credential mount is created, and the entrypoint skips credential copy-in/copy-out. Multiple containers share one proxy, which serializes refreshes and serves all consumers from one cache. Limitation: if nativeclauderuns concurrently on the host and refreshes the same OAuth session independently, it can invalidate the proxy's refresh token. The proxy re-reads the host credential store oninvalid_grantto recover, but a brief window of 401s is possible. On macOS the proxy binds to127.0.0.1(Docker Desktop routeshost.docker.internalto host loopback); on Linux it binds to0.0.0.0(the sentinel key gates access) becausehost-gatewayresolves to the bridge IP, not loopback. profile installprompts for confirmation ongitandurlsources before fetching; pass--yesto bypass.detectSource(src/profile/installer.ts) classifies scp-styleuser@host:pathURLs asgit(matchinggitRepoSchemainschema.ts), so they are not misinterpreted as base64 data.- Project config walk (
findProjectConfiginsrc/config/loader.ts) stops at the user's home directory — a.ccpod.ymlfound above$HOME(e.g. at/on macOS) is not loaded, preventing a stray parent config from overriding profile settings for every child project. - Updater (
ccpod update) verifies the downloaded binary's SHA-256 againstSHASUMS256.txtfrom the release; releases without that asset are refused. Theinstall.shbootstrap performs the same verification (aborting on mismatch; warning and proceeding only when the release predates the checksum asset). - Project
.ccpod.ymltrust boundary — a repo's project config is untrusted by default:services: project-declared sidecar services are ignored unless the profile setsallowProjectServices: true. A cloned repo could otherwise start arbitrary container images on the same bridge network as the credential-bearing main container. When opted in,services[].volumesandservices[].portsare still sanitized (see below) unlessallowProjectHostMountsis also set.services[].volumes: host-path mounts rejected; only named volumes allowed unless the profile setsallowProjectHostMounts: true.services[].ports:0.0.0.0:and non-localhost binds rejected; two-parthost:containeris auto-localized to127.0.0.1:. Bracketed IPv6 is recognised —[::1]:host:containerloopback is accepted, every::-expanding wildcard ([::],[0::],[::0:0],[0:0:0:0:0:0:0:0], …) is rejected.- top-level
ports.list(main container) and auto-detected.mcp.jsonports from project are published on127.0.0.1only, not0.0.0.0—parsePortstags project-sourced mappings withhostIp: '127.0.0.1'(profile-sourced ports keep Docker's default bind). Prevents a cloned repo from exposing the credential-mounted container to the LAN. enventries from project may not use${VAR}interpolation (would exfiltrate host secrets), and may not set keys onPROJECT_ENV_DENYLIST(src/auth/resolver.ts) — the Anthropic credential/endpoint vars (ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN/ANTHROPIC_*_BASE_URL), proxy vars (HTTP(S)_PROXY/ALL_PROXY/NO_PROXY),NODE_OPTIONS(code injection), and TLS-trust vars (NODE_EXTRA_CA_CERTS/NODE_TLS_REJECT_UNAUTHORIZED/SSL_CERT_FILE/SSL_CERT_DIR/CURL_CA_BUNDLE/REQUESTS_CA_BUNDLE). These could redirect API traffic to exfiltrate the resolved credential, inject code into the credential-bearing process, or weaken TLS. Matched case-insensitively; ignored with a warning. Profile and--enventries are trusted and may set them. Project env entries withCCPOD_*orDOCKER_*prefixes are also blocked —CCPOD_*could override ccpod's own control vars (e.g.CCPOD_NETWORK_POLICY), andDOCKER_*(e.g.DOCKER_HOST) could redirect the docker CLI itself to an attacker-controlled daemon.builder.tsadditionally stripsCCPOD_*andDOCKER_*fromsecretEnvas defense-in-depth.network:(policyandallow) is profile-owned — projectnetworkkeys are ignored with a warning regardless ofmergestrategy, so a repo cannot downgrade arestrictedprofile tofullor widen the allow-list.init:commands are ignored unless the profile setsallowProjectInit: true.
- Project
.claude/settings.jsondeep-merges into profile settings (project wins on conflicts) — same trust level asclaudeArgspassthrough. Only run ccpod against repos you control. setupContainer(src/cli/commands/_setup.ts) throws on error instead of callingprocess.exit— the calling commands (run,shell) catch and exit. This makes the setup pipeline testable withawait expect(...).rejects.toThrow(...). The globalunhandledRejection/uncaughtExceptionhandler incli/index.tsremains as a last-resort backstop.ccpod pruneremoves stopped ccpod containers, orphanedccpod-net-*networks (no attached endpoints), unreferencedccpod-plugins-<profile>volumes (no container references and profile no longer exists on disk), and orphaned per-project state dirs (no remaining containers with matching project hash). Supports--dry-run,--profile, and--force. Volume and state dir removal prompts for confirmation unless--forceis given.stateIsolation(profile config, defaultper-profile) controls whether persistent state is shared across projects using the same profile (per-profile:~/.ccpod/state/<profile>/) or isolated per project (per-project:~/.ccpod/state/<profile>/<projectHash>/). Whenper-project, each project gets its own conversation history, todos, and statsig state — preventing cross-project state leakage when using the same profile for trusted and untrusted repos.ccpod state clearclears the current project's state by default;--allclears all state for the profile.ccpod prunecleans orphaned per-project state dirs.
Testing
Tests live in tests/unit/ and tests/integration/. Unit tests use bun:test; mock.module() is used for Docker subprocess isolation in container tests.
Workflow
- Always work in a dedicated worktree (e.g.
git worktree add -b <branch> /path/to/ccpod-<branch> origin/main) unless the user explicitly says otherwise. Never commit directly tomain. - Open a PR for every change — no matter how small. Push the branch and use
gh pr createwith a clear title and description. Do not push directly tomain. - Clean up worktrees and local branches after the PR is merged.
Commit Checklist
Before every commit:
- Quality gates —
bun run typecheck && bun test tests/unit/ && bun run checkmust all pass - Docs — update
CLAUDE.md,website/src/content/docs/reference/internals.md, or any affected docs to reflect the change - Code review — spawn a fresh subagent (the harness's built-in code reviewer, e.g.
code-reviewerorgeneral-purpose) to review the diff against the rest of the codebase; address any real bugs or meaningful risks before committing
Commit messages must follow Conventional Commits:
<type>(<scope>): <summary>
<body>
Types: feat, fix, refactor, test, docs, chore, perf, ci
Example: feat(state): replace Docker volume with host bind mount