Isolated, firewalled Docker environments where AI coding agents work with full permissions — locally on your Mac (or any Docker host). One container per project, declared by a manifest. The assembly is a config.
containers/<name>.yml ──./up.sh <name>──► dev-agent-<name>
│ ├── agents: claude, codex, pi,
.dev-agent/secrets.env │ gemini, cursor-agent, aider
(all secret values, gitignored; ├── egress firewall (zone allowlist)
move via DEV_AGENT_HOME) ├── /workspace (volume): repos/<name> + worktrees/
├── /agent-rules (ro): global rules + skills
rules/ (bundled default; ├── /artifacts → Mac-visible outbox
override via RULES_PATH) └── per-agent identity via shims
The repo is self-contained: a fresh clone runs with no external setup.
Runtime state (secrets.env, keys, artifacts) defaults to a gitignored
./.dev-agent/, and rules come from the bundled rules/. A gitignored
./.env overrides both — see Prerequisites.
containers/<name>.yml— one manifest = one container: repos, memory, tools, capability grants, per-agent identities. Secret-free, committable. Copycontainers/TEMPLATE.ymland edit.secrets.env— every secret value, one file, mode 600, never mounted (default./.dev-agent/secrets.env, gitignored). Copysecrets.env.exampleand fill in what your manifests reference.
Everything else is derived: ./up.sh <name> (idempotent) composes
credentials, applies the firewall, clones the repos, lays out worktrees, and
generates MCP configs. ./down.sh <name> stops (code survives);
--purge forgets the container entirely (artifacts still survive).
- Docker Desktop (macOS) or Docker Engine (Linux)
yq(brew install yq)python3(any 3.9+, stdlib only — present via Xcode CLT on macOS; on a minimal Linux box,apt install python3).up.shuses it for manifest validation and the wiring payload, preferring/usr/bin/python3over version-manager shims (PYTHON3=/pathoverrides).
That's it — the repo is self-contained. up.sh keeps its runtime state
(secrets, keys, artifacts) in a gitignored ./.dev-agent/ and uses the
bundled rules/. To point at your own locations instead, drop a gitignored
./.env at the repo root:
DEV_AGENT_HOME="$HOME/dev-agent" # move the runtime home (secrets/keys/artifacts)
RULES_PATH="$HOME/git/agent-conf/rules" # use your own rules repo instead of bundled rules/
CONTAINERS_PATH="$HOME/dev-agent/containers" # read manifests from your own (private) dir
BRAVE_APP="$HOME/Applications/Brave Browser.app" # browser plugin: app locations
CHROME_APP="/Applications/Chromium.app" # (defaults are /Applications/…)(When DEV_AGENT_HOME is set and $DEV_AGENT_HOME/rules exists, it's used as
the rules dir automatically — no need to set RULES_PATH too. The same applies
to manifests: if $DEV_AGENT_HOME/containers exists it's used automatically, so
you don't need to set CONTAINERS_PATH either.)
Keep your manifests out of this repo. Your real containers/*.yml carry
semi-private data (private repo URLs, LAN subnets, identity naming), so this
repo ships only containers/TEMPLATE.yml. Point manifests at a directory of
your own — e.g. ~/dev-agent/containers (auto-detected) — and make that its
own private git repo. The tool stays public; your configs stay private and
versioned, with no second copy of the project to maintain.
cp containers/TEMPLATE.yml containers/my-app.yml
./up.sh my-appTEMPLATE.yml runs unedited (omit/empty repos: → git-inits
/workspace/repos/scratch, no identities → needs no secrets), so a copy is a
working smoke test. Then edit it — repos, memory, capabilities, identities —
and rerun (idempotent). Add any secrets it references to secrets.env:
mkdir -p .dev-agent && cp secrets.env.example .dev-agent/secrets.env # up.sh also creates it emptyThen attach: VS Code / Cursor → "Dev Containers: Attach to Running
Container" → dev-agent-my-app (lands as coder in /workspace — open
dev.code-workspace for the multi-root view). Terminal:
docker exec -it -u coder dev-agent-my-app bash. Recommended start:
cd /workspace/repos && claude (sees every repo); starting inside one repo
also works.
First session per container (persists across rebuilds in per-container
auth volumes): claude login; codex / gemini if used. Agents already
carry the GitHub machine-user token from secrets.env.
up.sh / down.sh / service.sh resolve their own location, so they run from
any directory. Drop these in ~/.bashrc to invoke them from anywhere (each
lists its options when run with no argument):
export DA_REPO="$HOME/git/docker-dev" # adjust to your clone
alias daup="$DA_REPO/up.sh" # daup <name> (no arg → lists manifests)
alias dadown="$DA_REPO/down.sh" # dadown <name> [--purge]
alias dasvc="$DA_REPO/service.sh" # dasvc <name> [args] (no arg → lists host services)
alias daegress="$DA_REPO/bin/allow-egress.sh" # daegress <container> <domain>…
alias cdda="cd \$DA_REPO"Tab-completion for container names (daup/dadown) and host services (dasvc):
_da_ctr_dir() { # mirrors common.sh's CONTAINERS_PATH resolution
if [ -n "$CONTAINERS_PATH" ]; then echo "$CONTAINERS_PATH"
elif [ -d "${DEV_AGENT_HOME:-$DA_REPO/.dev-agent}/containers" ]; then echo "${DEV_AGENT_HOME:-$DA_REPO/.dev-agent}/containers"
else echo "$DA_REPO/containers"; fi
}
_da_names() {
local d f names=""; d="$(_da_ctr_dir)"
for f in "$d"/*.yml; do f=${f##*/}; [ "$f" = TEMPLATE.yml ] && continue; names="$names ${f%.yml}"; done
COMPREPLY=($(compgen -W "$names" -- "${COMP_WORDS[COMP_CWORD]}"))
}
complete -F _da_names daup dadown
_da_services() {
local p names=""
for p in "$DA_REPO"/plugins/*/run.sh; do [ -e "$p" ] && names="$names $(basename "$(dirname "$p")")"; done
COMPREPLY=($(compgen -W "$names" -- "${COMP_WORDS[COMP_CWORD]}"))
}
complete -F _da_services dasvcmacOS defaults to zsh. The aliases work in ~/.zshrc unchanged. For completion,
use the native zsh version below — (N) makes the globs no-match-safe. Needs
compinit to have run (frameworks like oh-my-zsh already do it):
_da_names_zsh() { # container short-names; mirrors common.sh's CONTAINERS_PATH resolution
local dir
if [ -n "$CONTAINERS_PATH" ]; then dir="$CONTAINERS_PATH"
elif [ -d "${DEV_AGENT_HOME:-$DA_REPO/.dev-agent}/containers" ]; then dir="${DEV_AGENT_HOME:-$DA_REPO/.dev-agent}/containers"
else dir="$DA_REPO/containers"; fi
local -a names=(${dir}/*.yml(N:t:r)); names=(${names:#TEMPLATE})
compadd -a names
}
compdef _da_names_zsh daup dadown
_da_services_zsh() { # plugins that ship a run.sh (:h dir, :t tail = plugin name)
local -a names=(${DA_REPO}/plugins/*/run.sh(N:h:t))
compadd -a names
}
compdef _da_services_zsh dasvc| Manifest key | Effect |
|---|---|
egress: [...] |
extra allowed zones (a zone covers its subdomains) |
egress_cidrs: [...] |
IP-range escape hatch (LAN subnets) |
A container without a grant cannot reach the zone — enforced by the in-container firewall (dnsmasq resolver-driven ipset; rotating CDN DNS can't outrun it).
The old
gateway/proxyman/browsercapability flags are plugins now (see below).capabilities: {gateway: true}still works for one release but prints a deprecation warning — preferplugins: [gateway].
A plugin is a directory — plugins/<name>/ — describing an MCP server (or
just a secret) a container can get. A manifest opts in by name; unlisted plugins
stay dormant in the shared image:
plugins: [serena, gateway, obsidian-annotated]Two shapes, decided by the entry (no type: field):
- Local — a stdio server baked into the image (
command:+install:), wired into every installed agent. - Remote — an HTTP server (
url:), on the Mac host (host_port:, started with./service.sh <name>) or a real internet host.
A plugin may also be env-only — a secrets: slot with no server. Slots are
env-scoped (one value shared by all agents) or agent-scoped (per-agent,
bound under the manifest's agent_secrets:). up.sh derives the wiring and
folds each plugin's egress/host_port into the firewall; de-listing one
removes its wiring on the next up.
→ plugins/README.md — the schema, how wiring works,
and how to add a plugin. Each plugins/<name>/README.md documents that
plugin.
Secret values live in one file — secrets.env (mode 600, gitignored, never
mounted). Manifests and the Python modules handle only secret names; values
are resolved host-side at up time. Plugins declare secret slots. Every
slot uses one hybrid resolution order:
common_secrets:provides an explicit default source for every enabled agent.agent_secrets:may replace that source for one agent.disabled: trueremoves the slot for one agent.
An unset common source warns and provides no value; an unset per-agent override
hard-fails at up.
Per-agent shims deliver them. Each agent CLI is fronted by a shim that, at
process start, loads only that agent's ~/.agent-keys/<agent>.env — its fully
resolved secret set — and overrides inherited env before exec'ing the real
binary. Two consequences:
cat <agent>.envis the full audit of exactly what that agent sees.- Delegation is safe: when claude spawns
cursor-agent -p, the child's shim loads its identity — the invoker's credentials never leak.
GitHub rides the same path: agents act as the machine user (GH_TOKEN); your
personal login never enters a container unless you gh auth login there, and
agent PRs/comments show as the bot (you review and merge as you).
Per-org git identity. When one machine user can't reach every repo (it isn't
a member of every org), give the container its own token — and route by repo
owner — from the manifest's git: block:
git:
name: "Fry Agent"
email: "agent+fry@example.com"
token: GH_TOKEN_fry # this container's default credential (secrets.env var NAME)
orgs: # optional per-owner overrides (multi-org containers only)
planetexpress:
token: GH_TOKEN_planetexpress # secrets.env var NAME
name: "Leela Bot" # optional — repo-local identity for planetexpress/* repos
email: "bot@planetexpress.example"token/orgs.*.token name vars in secrets.env (values never enter the
manifest). At up, a repo owned by <owner> authenticates with GH_TOKEN_<owner>
if set, else the container's git.token, else the global GH_TOKEN — resolved by
the git-credential-org helper on every github.com fetch/push. A git.orgs
owner with a name/email also gets that identity stamped repo-locally, so its
commits carry the right author. A token: naming a var that isn't in secrets.env
hard-fails the apply — never a silent fall-back to the wrong identity. This is
routing + attribution, not isolation: every org's token sits in each agent's
<agent>.env, so a repo whose token must be unreachable by other work belongs in
a separate container.
The rules dir mounts read-only at /agent-rules in every container:
AGENTS.md fans out as every agent's global rules file, skills/ as
Claude's skills. By default this is the repo's bundled rules/; set
RULES_PATH (in ./.env) to your own rules repo — e.g. ~/git/agent-conf/rules
— to override. Rule layers: global → /workspace/rules.local.md
(container-local, uncommitted) → the project repo's own CLAUDE.md.
Agents propose rule changes via PR; for an external rules repo, up.sh
git pulls it each run so merged changes land in every container.
| State | Lives in | Survives recreate | Survives --purge |
|---|---|---|---|
| Code | workspace volume (+ git) | ✓ | ✗ (git: forever) |
| Agent logins, MCP approvals | per-container auth volumes | ✓ | ✗ |
| Identity keys | secrets.env (composed at up) |
✓ | ✓ |
| Rules & skills | bundled rules/ (or your RULES_PATH repo) |
✓ | ✓ |
| Non-code outputs | $DEV_AGENT_HOME/artifacts/<name>/ (/artifacts) |
✓ | ✓ |
up.sh/down.sh/service.sh— the commands you run from the repo root:up.sh/down.share container lifecycle from manifests;service.sh <name>starts a plugin's Mac-side host service (seeplugins/)containers/— manifests (TEMPLATE.ymlto copy; your own are gitignored)plugins/— drop-in MCP tools, one directory each (<name>/plugin.yml+ optional host-only<name>/run.sh, started via./service.sh <name>). Seeplugins/README.mdfor the schema and how to add onerules/— bundled default agent rules & skills (override viaRULES_PATH)bin/— host commands you run occasionally (up.sh/down.sh/service.share the daily ones and stay at the root):allow-egress.sh— add egress domains to a running container (no restart)update-agent-keys.sh— temporary per-agent key override; durable changes go in secrets.env
src/— internal source, never run directly:common.sh— shared path resolution (sourced by the scripts)manifest.py— host-side manifest validation;wire_plugins.py— the agent-config writerup.shexecs after boot;keyfiles.sh— host-side key-file compositionup.shsourcesentrypoint.sh,init-firewall.sh,tmux*,mosh-server-wrapper.sh— baked into the imagefreshness.py+freshness-landing.bashrc— the no-network landing readout of container config age (lastup+ image build date);up.shstamps the two timestamps into/etc/environmentafter boot
compose/—docker-compose.local.yml(base) plus thessh.yml/mosh.ymloverlaysup.shapplies for a manifest'sssh:/remote.moshsettingsdocs/—script.md(every script, grouped by lifecycle),TIPS.md,workspace.CLAUDE.md(copied into each container as/workspace/CLAUDE.md)tests/— host-runnable checks.plugins.test.shis the entry point (yq + jq + python3); it runs the Python unit tests (test_manifest.py/test_wire_plugins.py— manifest validation + wiring logic) and the host-side bash unit tests (bash.test.sh—keyfiles.sh,common.sh,allow-egress.sh,update-agent-keys.sh,service.sh, theplugins/*/run.shtoken generation)Dockerfile— the shared image and its contractssecrets.env.example— template for yoursecrets.env.env(gitignored) — optionalDEV_AGENT_HOME/RULES_PATH/DEV_AGENT_SUBNEToverrides
Same system, same files, one addition. On any Linux box with Docker:
-
Install
yq(static binary) andpython3, and clone this repo. The bundledrules/and gitignored./.dev-agent/work as-is; setDEV_AGENT_HOME/RULES_PATHin./.envonly if you want them elsewhere. -
Put your secrets in
secrets.env(default./.dev-agent/secrets.env, 600) — includingSSH_AUTHORIZED_KEY(your public key). -
Add an
ssh:section to the container's manifest:ssh: port: 2222 # published on the host bind: 127.0.0.1 # keep loopback; reach it through your tunnel
-
./up.sh <name>— identical to the Mac. Connect with VS Code Remote-SSH to the host/port; everything else (firewall, secrets, rules, artifacts) behaves exactly the same.
Never expose sshd publicly: keep the bind on loopback (or a tunnel
interface) and front it with your WireGuard/VPN tunnel. The remote MCP
plugins (gateway/proxyman/browser) are Mac-desktop services — on
headless hosts leave them out of plugins: or run the host service on that
host.
The remote: manifest block (requires ssh:) turns an SSH-reachable
container into something you can drive from a phone — start a task, walk
away, get pinged when the agent needs you, answer from anywhere. Works for
every agent in the image; nothing is vendor-hosted or public-facing.
ssh: { port: 2222, bind: 127.0.0.1 }
remote: { tmux: true, mosh: true, notify: ntfy }- tmux — interactive SSH/mosh logins land attached to one durable
session (
agent). Phone and laptop share the same view; agents survive disconnects.docker execand editor terminals are exempt. - mosh — a per-manifest UDP range (
remote.mosh_ports, default 60000:60010; disjoint per container, likessh.port), published next to sshd with the same bind rules and pinned server-side. Survives phone sleep and WiFi↔cellular switches; use a mosh-capable client (e.g. Moshi or Blink on iOS). - notify: ntfy — an agent-blind monitor pushes to your ntfy topic when
the session goes idle at a prompt and nobody is attached. Set
NTFY_URL(+ optionalNTFY_TOPIC) insecrets.env; the host is auto-allowlisted.
Reach. All containers sit on one shared bridge (dev-agent-net,
172.30.0.0/24 by default, DEV_AGENT_SUBNET in ./.env to override;
created automatically by up.sh). Point your WireGuard/VPN layer at that
CIDR once and every container is reachable at its bridge IP from any
enrolled device — up.sh prints the IP in its summary. sshd and the mosh
range stay loopback/tunnel-only; nothing listens publicly.
MIT © Demetrio Urquidi. Use it, modify it, ship it — just keep the copyright and license notice on copies and derivatives.